diff --git a/.gitignore b/.gitignore index 64770bd..3cdbddc 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,7 @@ typings/ .next # application -dist/ \ No newline at end of file +dist/ + +# test database +spec/test/db/test.db \ No newline at end of file diff --git a/docs/BatchRequests.md b/docs/BatchRequests.md new file mode 100644 index 0000000..46991b3 --- /dev/null +++ b/docs/BatchRequests.md @@ -0,0 +1,2060 @@ +# OData v4 Batch Requests with Content-ID Referencing + +## Table of Contents + +1. [Overview](#overview) +2. [Basic Batch Requests](#basic-batch-requests) +3. [Content-ID Referencing](#content-id-referencing) +4. [URL References with `$`](#url-references-with-) +5. [Body Property References with `$$`](#body-property-references-with-) +6. [Atomicity Groups (Changesets)](#atomicity-groups-changesets) +7. [Advanced Examples](#advanced-examples) +8. [Error Handling](#error-handling) +9. [Best Practices](#best-practices) +10. [API Reference](#api-reference) + +--- + +## Overview + +The `@themost/express` framework supports OData v4 batch requests, allowing multiple API operations to be executed in a single HTTP request. This feature includes: + +- ✅ **Standard OData v4 batch processing** +- ✅ **Content-ID based URL referencing** (`$`) +- ✅ **Extended body property referencing** (`$$.`) +- ✅ **Atomicity Groups (Changesets)** for transactional operations +- ✅ **Sequential execution** with dependency support +- ✅ **Error isolation** - one request failure doesn't stop others + +### Key Benefits + +- **Reduced network overhead** - Multiple operations in one HTTP call +- **Request dependencies** - Use results from previous requests +- **Transactional integrity** - Create related entities in sequence +- **Atomic operations** - All-or-nothing execution with atomicity groups +- **Better performance** - Reduced latency for complex operations + +--- + +## Basic Batch Requests + +### Endpoint + +``` +POST /api/$batch +Content-Type: application/json +``` + +### Request Structure + +```json +{ + "requests": [ + { + "id": "1", + "method": "GET|POST|PUT|PATCH|DELETE", + "url": "/api/EntitySet", + "headers": { + "Content-Type": "application/json" + }, + "body": { /* request body for POST/PUT/PATCH */ }, + "atomicityGroup": "group1" // Optional: for transactional operations + } + ] +} +``` + +### Response Structure + +```json +{ + "responses": [ + { + "id": "1", + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { /* response data */ } + } + ] +} +``` + +### Simple Example + +**Request:** + +```json +{ + "requests": [ + { + "id": "1", + "method": "GET", + "url": "/api/Customers?$top=5" + }, + { + "id": "2", + "method": "GET", + "url": "/api/Products?$filter=price gt 100" + } + ] +} +``` + +**Response:** + +```json +{ + "responses": [ + { + "id": "1", + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "@odata.context": "/$metadata#Customers", + "value": [ + {"id": 1, "name": "Customer A"}, + {"id": 2, "name": "Customer B"} + ] + } + }, + { + "id": "2", + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "@odata.context": "/$metadata#Products", + "value": [ + {"id": 101, "name": "Premium Widget", "price": 150} + ] + } + } + ] +} +``` + +--- + +## Content-ID Referencing + +Content-ID allows subsequent requests to reference results from previous requests within the same batch. + +### Three Key Features + +| Feature | Purpose | OData Standard | Example | +|---------|---------|----------------|---------| +| `$` | URL reference | ✅ Yes | `$1/Orders` | +| `$$.` | Body property reference | ❌ Extension | `$$1.id` | +| `atomicityGroup` | Transactional grouping | ✅ Yes | `"atomicityGroup": "g1"` | + +--- + +## URL References with `$` + +**Standard OData v4.0 feature** - References the Location header or @odata.id from a previous request. + +### How It Works + +1. A POST request creates an entity with `Content-ID: "1"` +2. The response includes `Location: /api/Customers(42)` +3. Subsequent requests use `$1` which resolves to `/api/Customers(42)` + +### Example: Create Customer and Add Address + +**Request:** + +```json +{ + "requests": [ + { + "id": "create-customer", + "method": "POST", + "url": "/api/Customers", + "body": { + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "create-address", + "method": "POST", + "url": "$create-customer/Addresses", + "body": { + "street": "123 Main St", + "city": "New York", + "zipCode": "10001" + } + } + ] +} +``` + +**What Happens:** + +1. Request `create-customer` creates a customer → Returns `Location: /api/Customers(42)` +2. Request `create-address` URL becomes: `/api/Customers(42)/Addresses` +3. Address is created for the new customer + +**Response:** + +```json +{ + "responses": [ + { + "id": "create-customer", + "status": 201, + "headers": { + "Location": "/api/Customers(42)" + }, + "body": { + "@odata.id": "/api/Customers(42)", + "id": 42, + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "create-address", + "status": 201, + "headers": { + "Location": "/api/Customers(42)/Addresses(99)" + }, + "body": { + "@odata.id": "/api/Customers(42)/Addresses(99)", + "id": 99, + "customerId": 42, + "street": "123 Main St", + "city": "New York" + } + } + ] +} +``` + +### Resolution Priority + +When resolving `$`: + +1. ✅ `Location` header (HTTP standard) +2. ✅ `location` header (case-insensitive fallback) +3. ✅ `@odata.id` in response body +4. ✅ `value[0].@odata.id` (for collection responses) + +--- + +## Body Property References with `$$` + +**Custom extension** - Allows referencing specific properties from previous response bodies. + +### Syntax + +``` +$$. +``` + +### Supported Path Formats + +```javascript +$$1.id // Simple property +$$1.customer.name // Nested property +$$1.value[0].id // Array index +$$1.metadata.createdAt // Deep nesting +``` + +### Example: Create Order with Customer Reference + +**Request:** + +```json +{ + "requests": [ + { + "id": "1", + "method": "POST", + "url": "/api/Customers", + "body": { + "name": "Jane Smith", + "email": "jane@example.com", + "phone": "+1234567890" + } + }, + { + "id": "2", + "method": "POST", + "url": "/api/Orders", + "body": { + "customerId": "$$1.id", + "customerEmail": "$$1.email", + "items": [ + { + "productId": 101, + "quantity": 2 + } + ], + "totalAmount": 299.98 + } + }, + { + "id": "3", + "method": "POST", + "url": "/api/Notifications", + "body": { + "recipient": "$$1.email", + "subject": "Order Confirmation", + "message": "Your order #$$2.id has been placed successfully!", + "metadata": { + "orderId": "$$2.id", + "customerId": "$$1.id" + } + } + } + ] +} +``` + +**Resolution Process:** + +1. Request `1` creates customer with `id: 42` +2. Request `2`: + - `$$1.id` → `42` + - `$$1.email` → `"jane@example.com"` + - Creates order with `orderId: 500` +3. Request `3`: + - `$$1.email` → `"jane@example.com"` + - `$$2.id` → `500` + - Sends notification + +--- + +## Atomicity Groups (Changesets) + +### What is an Atomicity Group? + +An **atomicity group** (also called a **changeset** in OData terminology) is a collection of requests that must **all succeed or all fail together**. This provides **transactional integrity** for related operations. + +### Key Characteristics + +| Feature | Behavior | +|---------|----------| +| **All or Nothing** | If any request fails, all requests in the group are rolled back | +| **Isolation** | Changes are not visible until the entire group succeeds | +| **Ordering** | Requests within a group execute in order | +| **Dependencies** | Can reference other requests in the same group | + +### When to Use Atomicity Groups + +✅ **Use atomicity groups when:** +- Creating related entities that must exist together +- Financial transactions (payment + order + inventory update) +- Data consistency is critical +- You need rollback capability + +❌ **Don't use atomicity groups when:** +- Requests are independent +- You want partial success +- Read-only operations (GET requests) + +### Syntax + +Add the `atomicityGroup` property to requests: + +```json +{ + "id": "request-id", + "method": "POST", + "url": "/api/EntitySet", + "atomicityGroup": "group-name", + "body": { ... } +} +``` + +### Important Rules + +1. **GET requests should NOT be in atomicity groups** (read-only operations) +2. **All requests in a group must use the same group name** +3. **Groups are processed sequentially** +4. **Requests without `atomicityGroup` are processed independently** + +--- + +## Atomicity Group Examples + +### Example 1: Basic Transaction + +Create customer and order atomically: + +**Request:** + +```json +{ + "requests": [ + { + "id": "customer", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "transaction1", + "body": { + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "order", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "transaction1", + "body": { + "customerId": "$$customer.id", + "amount": 500.00 + } + } + ] +} +``` + +**Success Scenario:** + +Both requests succeed: + +```json +{ + "responses": [ + { + "id": "customer", + "status": 201, + "body": {"id": 42, "name": "John Doe"} + }, + { + "id": "order", + "status": 201, + "body": {"id": 100, "customerId": 42, "amount": 500.00} + } + ] +} +``` + +**Failure Scenario:** + +If order creation fails (e.g., validation error), **both operations are rolled back**: + +```json +{ + "responses": [ + { + "id": "customer", + "status": 424, + "body": { + "message": "Failed Dependency - Transaction rolled back" + } + }, + { + "id": "order", + "status": 400, + "body": { + "message": "Invalid amount" + } + } + ] +} +``` + +❌ Customer is **NOT created** in the database +❌ Order is **NOT created** in the database + +### Example 2: Financial Transaction + +Transfer money between accounts: + +**Request:** + +```json +{ + "requests": [ + { + "id": "debit", + "method": "POST", + "url": "/api/Transactions", + "atomicityGroup": "transfer-001", + "body": { + "accountId": 123, + "amount": -100.00, + "type": "debit", + "description": "Transfer to account 456" + } + }, + { + "id": "credit", + "method": "POST", + "url": "/api/Transactions", + "atomicityGroup": "transfer-001", + "body": { + "accountId": 456, + "amount": 100.00, + "type": "credit", + "description": "Transfer from account 123" + } + }, + { + "id": "update-balance-1", + "method": "PATCH", + "url": "/api/Accounts(123)", + "atomicityGroup": "transfer-001", + "body": { + "balance": "$$debit.newBalance" + } + }, + { + "id": "update-balance-2", + "method": "PATCH", + "url": "/api/Accounts(456)", + "atomicityGroup": "transfer-001", + "body": { + "balance": "$$credit.newBalance" + } + } + ] +} +``` + +**Guarantee:** Either all 4 operations succeed, or none do. No partial transfers! + +### Example 3: E-commerce Order Processing + +Complete order with inventory update: + +**Request:** + +```json +{ + "requests": [ + { + "id": "create-order", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "order-12345", + "body": { + "customerId": 42, + "items": [ + {"productId": 101, "quantity": 2}, + {"productId": 102, "quantity": 1} + ] + } + }, + { + "id": "reserve-inventory-1", + "method": "POST", + "url": "/api/Inventory/reserve", + "atomicityGroup": "order-12345", + "body": { + "orderId": "$$create-order.id", + "productId": 101, + "quantity": 2 + } + }, + { + "id": "reserve-inventory-2", + "method": "POST", + "url": "/api/Inventory/reserve", + "atomicityGroup": "order-12345", + "body": { + "orderId": "$$create-order.id", + "productId": 102, + "quantity": 1 + } + }, + { + "id": "create-payment", + "method": "POST", + "url": "/api/Payments", + "atomicityGroup": "order-12345", + "body": { + "orderId": "$$create-order.id", + "amount": "$$create-order.totalAmount", + "status": "pending" + } + } + ] +} +``` + +**If ANY operation fails:** +- ❌ Order is NOT created +- ❌ Inventory is NOT reserved +- ❌ Payment is NOT created +- ✅ Database remains consistent + +### Example 4: Mixed Groups + +Different atomicity groups in one batch: + +**Request:** + +```json +{ + "requests": [ + { + "id": "query-products", + "method": "GET", + "url": "/api/Products?$top=10" + // No atomicityGroup - independent operation + }, + { + "id": "customer-1", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "group-A", + "body": {"name": "Customer A"} + }, + { + "id": "order-1", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "group-A", + "body": { + "customerId": "$$customer-1.id", + "amount": 100 + } + }, + { + "id": "customer-2", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "group-B", + "body": {"name": "Customer B"} + }, + { + "id": "order-2", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "group-B", + "body": { + "customerId": "$$customer-2.id", + "amount": 200 + } + } + ] +} +``` + +**Processing:** +1. `query-products` executes independently +2. `group-A` executes as transaction (customer-1 + order-1) +3. `group-B` executes as transaction (customer-2 + order-2) + +**Scenario: group-A fails, group-B succeeds:** + +```json +{ + "responses": [ + { + "id": "query-products", + "status": 200, + "body": {"value": [...]} // ✅ Success + }, + { + "id": "customer-1", + "status": 424, // Failed Dependency + "body": {"message": "Transaction rolled back"} + }, + { + "id": "order-1", + "status": 400, // Original failure + "body": {"message": "Invalid amount"} + }, + { + "id": "customer-2", + "status": 201, // ✅ Success + "body": {"id": 99, "name": "Customer B"} + }, + { + "id": "order-2", + "status": 201, // ✅ Success + "body": {"id": 200, "customerId": 99, "amount": 200} + } + ] +} +``` + +**Result:** +- ✅ Products query succeeded +- ❌ Customer A and Order 1 rolled back (group-A failed) +- ✅ Customer B and Order 2 created (group-B succeeded) + +--- + +## Atomicity Group Implementation + +### Database Transaction Support + +For atomicity groups to work properly, your implementation must support **database transactions**: + +```javascript +// Example implementation with transaction support +async function executeAtomicityGroup(requests, groupName) { + const transaction = await db.beginTransaction(); + + try { + const results = []; + + for (const request of requests) { + if (request.atomicityGroup === groupName) { + const result = await executeRequest(request, transaction); + results.push(result); + + // If any request fails, throw to rollback + if (result.status >= 400) { + throw new Error(`Request ${request.id} failed`); + } + } + } + + // All succeeded, commit transaction + await transaction.commit(); + return results; + + } catch (error) { + // Any failure rolls back entire group + await transaction.rollback(); + + // Return 424 (Failed Dependency) for all requests in group + return requests + .filter(r => r.atomicityGroup === groupName) + .map(r => ({ + id: r.id, + status: 424, + body: { + message: 'Failed Dependency - Transaction rolled back', + error: error.message + } + })); + } +} +``` + +### Status Codes + +| Status | Meaning | When Used | +|--------|---------|-----------| +| **200-299** | Success | Request completed successfully | +| **400** | Bad Request | Original request failure (validation, etc.) | +| **424** | Failed Dependency | Request rolled back due to group failure | +| **500** | Server Error | Unexpected error during processing | + +### Configuration + +Enable transaction support in your batch middleware: + +```javascript +import { batch } from '@themost/express'; + +app.use('/api/', batch(app, { + min: 2, + max: 25, + + // Enable atomicity group support + atomicityGroups: true, + + // Transaction timeout (milliseconds) + transactionTimeout: 30000, + + // Isolation level + isolationLevel: 'READ_COMMITTED' +})); +``` + +--- + +## Comparing Approaches + +### Independent Requests (No Atomicity Group) + +```json +{ + "requests": [ + {"id": "1", "method": "POST", "url": "/api/Customers", "body": {...}}, + {"id": "2", "method": "POST", "url": "/api/Orders", "body": {...}} + ] +} +``` + +**Behavior:** +- ✅ Request 1 creates customer → **committed immediately** +- ❌ Request 2 fails → customer **remains in database** +- ⚠️ Inconsistent state: customer exists without order + +### With Atomicity Group + +```json +{ + "requests": [ + { + "id": "1", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "tx1", + "body": {...} + }, + { + "id": "2", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "tx1", + "body": {...} + } + ] +} +``` + +**Behavior:** +- ✅ Request 1 creates customer → **pending in transaction** +- ❌ Request 2 fails → **entire transaction rolls back** +- ✅ Consistent state: neither customer nor order exists + +--- + +## Atomicity Group Best Practices + +### 1. Keep Groups Small + +✅ **Good:** 2-5 related operations +```json +{ + "atomicityGroup": "order-create", + // Customer + Order + Payment +} +``` + +❌ **Avoid:** Large, complex transactions +```json +{ + "atomicityGroup": "huge-transaction", + // 20+ operations - high chance of failure +} +``` + +### 2. Don't Mix Read and Write Operations + +✅ **Correct:** +```json +[ + {"id": "1", "method": "GET", "url": "/api/Products"}, // No group + {"id": "2", "method": "POST", "atomicityGroup": "g1", ...}, + {"id": "3", "method": "POST", "atomicityGroup": "g1", ...} +] +``` + +❌ **Incorrect:** +```json +[ + {"id": "1", "method": "GET", "atomicityGroup": "g1", ...}, // Don't include GETs + {"id": "2", "method": "POST", "atomicityGroup": "g1", ...} +] +``` + +### 3. Use Descriptive Group Names + +✅ **Good:** +```json +"atomicityGroup": "order-12345-payment" +"atomicityGroup": "customer-registration" +"atomicityGroup": "inventory-transfer-abc" +``` + +❌ **Avoid:** +```json +"atomicityGroup": "g1" +"atomicityGroup": "group" +"atomicityGroup": "tx" +``` + +### 4. Handle 424 Status (Failed Dependency) + +```javascript +const responses = batchResponse.responses; + +responses.forEach(response => { + if (response.status === 424) { + console.log(`Request ${response.id} was rolled back due to group failure`); + // Don't retry - the entire group failed + } else if (response.status >= 400) { + console.log(`Request ${response.id} failed: ${response.body.message}`); + // This might be the original failure that caused rollback + } +}); +``` + +### 5. Consider Timeout Implications + +Long-running transactions can: +- Hold database locks +- Block other operations +- Increase failure risk + +**Recommendation:** Keep transaction time under 5 seconds. + +--- + +## Advanced Examples + +### Example 1: E-commerce Order Flow with Atomicity + +Complete workflow with multiple atomicity groups: + +```json +{ + "requests": [ + { + "id": "check-inventory", + "method": "GET", + "url": "/api/Inventory?productId=101" + // Independent query - no atomicity group + }, + { + "id": "customer", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "order-flow", + "body": { + "firstName": "Alice", + "lastName": "Johnson", + "email": "alice@example.com" + } + }, + { + "id": "shipping-address", + "method": "POST", + "url": "$customer/Addresses", + "atomicityGroup": "order-flow", + "body": { + "type": "shipping", + "street": "456 Oak Ave", + "city": "Boston" + } + }, + { + "id": "order", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "order-flow", + "body": { + "customerId": "$$customer.id", + "shippingAddressId": "$$shipping-address.id", + "items": [ + {"productId": 101, "quantity": 2} + ] + } + }, + { + "id": "reserve-inventory", + "method": "POST", + "url": "/api/Inventory/reserve", + "atomicityGroup": "order-flow", + "body": { + "orderId": "$$order.id", + "productId": 101, + "quantity": 2 + } + }, + { + "id": "payment", + "method": "POST", + "url": "/api/Payments", + "atomicityGroup": "order-flow", + "body": { + "orderId": "$$order.id", + "amount": "$$order.totalAmount", + "method": "credit_card" + } + }, + { + "id": "send-confirmation", + "method": "POST", + "url": "/api/Emails/send", + "body": { + "to": "$$customer.email", + "template": "order_confirmation", + "data": { + "orderId": "$$order.id" + } + } + // Email sending is separate - not in atomicity group + } + ] +} +``` + +**Processing:** +1. ✅ Inventory check (independent) +2. ⚡ Atomicity group executes: + - Customer creation + - Address creation + - Order creation + - Inventory reservation + - Payment creation +3. ✅ Email sending (independent, even if email fails, order is already committed) + +### Example 2: Bulk Import with Validation + +```json +{ + "requests": [ + { + "id": "validate-data", + "method": "POST", + "url": "/api/Validation/bulk", + "body": { + "data": [/* bulk data */] + } + // Validation step - no atomicity group + }, + { + "id": "import-1", + "method": "POST", + "url": "/api/Products", + "atomicityGroup": "import-batch-1", + "body": {"name": "Product 1", "price": 99} + }, + { + "id": "import-2", + "method": "POST", + "url": "/api/Products", + "atomicityGroup": "import-batch-1", + "body": {"name": "Product 2", "price": 149} + }, + { + "id": "import-3", + "method": "POST", + "url": "/api/Products", + "atomicityGroup": "import-batch-2", + "body": {"name": "Product 3", "price": 199} + }, + { + "id": "import-4", + "method": "POST", + "url": "/api/Products", + "atomicityGroup": "import-batch-2", + "body": {"name": "Product 4", "price": 249} + } + ] +} +``` + +**Benefit:** If products 1-2 succeed but 3-4 fail, you have partial success instead of all-or-nothing. + +### Example 3: Multi-Tenant Data Migration + +```json +{ + "requests": [ + { + "id": "tenant-1-user", + "method": "POST", + "url": "/api/Users", + "atomicityGroup": "tenant-1-migration", + "body": {"tenantId": 1, "name": "User A"} + }, + { + "id": "tenant-1-settings", + "method": "POST", + "url": "/api/Settings", + "atomicityGroup": "tenant-1-migration", + "body": {"userId": "$$tenant-1-user.id", "preferences": {...}} + }, + { + "id": "tenant-2-user", + "method": "POST", + "url": "/api/Users", + "atomicityGroup": "tenant-2-migration", + "body": {"tenantId": 2, "name": "User B"} + }, + { + "id": "tenant-2-settings", + "method": "POST", + "url": "/api/Settings", + "atomicityGroup": "tenant-2-migration", + "body": {"userId": "$$tenant-2-user.id", "preferences": {...}} + } + ] +} +``` + +**Each tenant's migration is atomic**, but tenants are independent. + +--- + +## Error Handling + +### Failed Request Behavior + +When a request in a batch fails: + +**Without Atomicity Group:** +- ✅ The failed request returns its error status and details +- ✅ Subsequent requests **continue to execute** +- ⚠️ References to failed requests remain unresolved + +**With Atomicity Group:** +- ❌ The failed request returns its error (400, 500, etc.) +- ❌ All other requests in the same group return **424 Failed Dependency** +- ✅ Requests in different groups or without groups **continue to execute** +- ⚡ **Database transaction is rolled back** + +### Example: Atomicity Group Failure + +**Request:** + +```json +{ + "requests": [ + { + "id": "independent", + "method": "POST", + "url": "/api/Logs", + "body": {"message": "Starting process"} + }, + { + "id": "group-req-1", + "method": "POST", + "url": "/api/Customers", + "atomicityGroup": "tx1", + "body": {"name": "Test"} + }, + { + "id": "group-req-2", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "tx1", + "body": {"invalidData": true} // This will fail + }, + { + "id": "group-req-3", + "method": "POST", + "url": "/api/Payments", + "atomicityGroup": "tx1", + "body": {"amount": 100} + } + ] +} +``` + +**Response:** + +```json +{ + "responses": [ + { + "id": "independent", + "status": 201, + "body": {"id": 1, "message": "Starting process"} + // ✅ Succeeded - not in group + }, + { + "id": "group-req-1", + "status": 424, + "body": { + "message": "Failed Dependency - Transaction rolled back", + "atomicityGroup": "tx1" + } + // ❌ Rolled back - part of failed group + }, + { + "id": "group-req-2", + "status": 400, + "body": { + "message": "Bad Request - Invalid data", + "errors": [...] + } + // ❌ Original failure + }, + { + "id": "group-req-3", + "status": 424, + "body": { + "message": "Failed Dependency - Transaction rolled back", + "atomicityGroup": "tx1" + } + // ❌ Rolled back - part of failed group + } + ] +} +``` + +**Database State:** +- ✅ Log entry exists (independent request) +- ❌ Customer does NOT exist (rolled back) +- ❌ Order does NOT exist (original failure) +- ❌ Payment does NOT exist (rolled back) + +### Unresolved References + +If a referenced request fails or doesn't exist: + +```json +{ + "requests": [ + { + "id": "1", + "method": "POST", + "url": "/api/InvalidEndpoint", + "atomicityGroup": "tx1", + "body": {"test": "data"} + }, + { + "id": "2", + "method": "POST", + "url": "/api/Orders", + "atomicityGroup": "tx1", + "body": { + "customerId": "$$1.id" // ← Will not resolve due to group failure + } + } + ] +} +``` + +**Best Practice:** Check response statuses and handle unresolved references in your application logic. + +--- + +## Best Practices + +### 1. Use Descriptive Content-IDs + +❌ **Avoid:** +```json +{"id": "1"}, {"id": "2"}, {"id": "3"} +``` + +✅ **Prefer:** +```json +{"id": "create-customer"}, +{"id": "create-order"}, +{"id": "send-notification"} +``` + +### 2. Order Requests by Dependency + +Ensure requests appear **after** their dependencies: + +✅ **Correct Order:** +```json +[ + {"id": "customer", "method": "POST", "url": "/api/Customers"}, + {"id": "order", "body": {"customerId": "$$customer.id"}} +] +``` + +❌ **Wrong Order:** +```json +[ + {"id": "order", "body": {"customerId": "$$customer.id"}}, + {"id": "customer", "method": "POST", "url": "/api/Customers"} +] +``` + +### 3. Choose Atomicity Groups Wisely + +✅ **Use atomicity groups for:** +- Financial transactions +- Related entity creation +- Data consistency requirements +- Operations that must succeed together + +❌ **Don't use atomicity groups for:** +- Independent operations +- Read operations (GET) +- When partial success is acceptable +- Long-running operations + +### 4. Limit Batch Size + +The default configuration limits batches to **2-25 requests**: + +```javascript +batch(app, { + min: 2, // Minimum requests per batch + max: 25 // Maximum requests per batch +}); +``` + +**Recommendation:** Keep batches focused and under 20 requests for optimal performance. + +### 5. Use Both Reference Types Appropriately + +| Scenario | Use | Example | +|----------|-----|---------| +| Navigation to child entity | `$id` | `$customer/Orders` | +| Foreign key reference | `$$id.property` | `$$customer.id` | +| Complex data passing | `$$id.property` | `$$order.totalAmount` | +| Transactional operations | `atomicityGroup` | `"atomicityGroup": "tx1"` | + +### 6. Handle Errors Gracefully + +Always check response statuses: + +```javascript +const responses = batchResponse.responses; + +// Check if all succeeded +const allSucceeded = responses.every(r => r.status >= 200 && r.status < 300); + +// Find failures +const failures = responses.filter(r => r.status >= 400); + +// Find rollbacks +const rollbacks = responses.filter(r => r.status === 424); + +// Get specific result +const customerResponse = responses.find(r => r.id === 'create-customer'); +if (customerResponse.status === 201) { + const customerId = customerResponse.body.id; + // Use customerId... +} else if (customerResponse.status === 424) { + console.log('Customer creation rolled back due to transaction failure'); +} +``` + +### 7. Monitor Transaction Duration + +```javascript +// Log transaction durations +responses.forEach(response => { + if (response.body.atomicityGroup) { + console.log(`Group ${response.body.atomicityGroup}: ${response.duration}ms`); + } +}); +``` + +### 8. Optimize Network Usage + +Batch related operations together: + +✅ **Good:** +```json +// One batch: Create customer + add address + create order (atomic) +{ + "requests": [ + {"id": "customer", "atomicityGroup": "order-tx", ...}, + {"id": "address", "atomicityGroup": "order-tx", ...}, + {"id": "order", "atomicityGroup": "order-tx", ...} + ] +} +``` + +❌ **Bad:** +```json +// Three separate HTTP requests +POST /api/Customers +POST /api/Addresses +POST /api/Orders +``` + +--- + +## API Reference + +### Configuration Options + +```javascript +import { batch } from '@themost/express'; + +app.use('/api/', batch(app, { + // Minimum number of requests per batch + min: 2, + + // Maximum number of requests per batch + max: 25, + + // Enable atomicity group support + atomicityGroups: true, + + // Transaction timeout in milliseconds + transactionTimeout: 30000, + + // Database isolation level + isolationLevel: 'READ_COMMITTED', + + // Headers to inherit from parent request + headers: [ + 'authorization', + 'content-type', + 'accept', + 'accept-language', + 'accept-encoding', + 'user-agent' + ] +})); +``` + +### Request Object Schema + +```typescript +interface BatchRequest { + id: string; // Unique identifier (Content-ID) + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + url: string; // Can contain $id references + headers?: Record; // Optional request headers + body?: any; // Request body (can contain $$id.property) + atomicityGroup?: string; // Optional: transaction group name +} +``` + +### Response Object Schema + +```typescript +interface BatchResponse { + id: string; // Matches request Content-ID + status: number; // HTTP status code + headers: Record; // Response headers + body: any; // Response body + atomicityGroup?: string; // If part of a group + duration?: number; // Processing time in ms +} +``` + +### Batch Request Schema + +```typescript +interface BatchRequestPayload { + requests: BatchRequest[]; +} +``` + +### Batch Response Schema + +```typescript +interface BatchResponsePayload { + responses: BatchResponse[]; +} +``` + +### HTTP Status Codes + +| Status | Name | Usage | +|--------|------|-------| +| **200** | OK | Successful GET, PATCH, DELETE | +| **201** | Created | Successful POST | +| **204** | No Content | Successful operation with no response body | +| **400** | Bad Request | Validation error, malformed request | +| **401** | Unauthorized | Missing or invalid authentication | +| **403** | Forbidden | Insufficient permissions | +| **404** | Not Found | Resource does not exist | +| **424** | Failed Dependency | Rolled back due to atomicity group failure | +| **500** | Internal Server Error | Unexpected server error | + +--- + +## Reference Resolution Algorithm + +### URL Reference (`$id`) + +``` +1. Check if URL contains $ pattern +2. Look up result by Content-ID +3. Try resolution in order: + a. response.headers.Location + b. response.headers.location (case-insensitive) + c. response.body['@odata.id'] + d. response.body.value[0]['@odata.id'] (for collections) +4. Replace $ with resolved URL +5. If not found, leave as-is (will likely result in 404) +``` + +### Body Property Reference (`$$id.property`) + +``` +1. Scan request body for $$. patterns +2. For each match: + a. Look up result by Content-ID + b. Check if request succeeded (status 2xx) + c. If in atomicity group, check group didn't fail + d. Parse property path (support dot notation and array indexes) + e. Extract value from response body + f. Replace $$. with extracted value +3. If resolution fails, leave as-is (literal string) +``` + +### Atomicity Group Processing + +``` +1. Group requests by atomicityGroup property +2. For each group: + a. Begin database transaction + b. Execute requests in order + c. Resolve references within group + d. If all succeed: + - Commit transaction + - Return success responses + e. If any fails: + - Rollback transaction + - Return 424 for all requests in group + - Include original error for failed request +3. Process requests without groups independently +``` + +--- + +## Testing Examples + +### Example Test: Atomicity Group Success + +```javascript +import request from 'supertest'; + +describe('Batch Atomicity Groups', () => { + it('should commit transaction when all requests succeed', async () => { + const response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .send({ + requests: [ + { + id: 'customer', + method: 'POST', + url: '/api/Customers', + atomicityGroup: 'tx1', + body: { + name: 'Test Customer', + email: 'test@example.com' + } + }, + { + id: 'order', + method: 'POST', + url: '/api/Orders', + atomicityGroup: 'tx1', + body: { + customerId: '$$customer.id', + amount: 100 + } + } + ] + }); + + expect(response.status).toBe(200); + expect(response.body.responses).toHaveLength(2); + + const customerResponse = response.body.responses[0]; + expect(customerResponse.status).toBe(201); + expect(customerResponse.body.id).toBeDefined(); + + const orderResponse = response.body.responses[1]; + expect(orderResponse.status).toBe(201); + expect(orderResponse.body.customerId).toBe(customerResponse.body.id); + + // Verify data exists in database + const customer = await db.customers.findById(customerResponse.body.id); + expect(customer).toBeDefined(); + + const order = await db.orders.findById(orderResponse.body.id); + expect(order).toBeDefined(); + }); + + it('should rollback transaction when any request fails', async () => { + const response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .send({ + requests: [ + { + id: 'customer', + method: 'POST', + url: '/api/Customers', + atomicityGroup: 'tx1', + body: { + name: 'Test Customer' + } + }, + { + id: 'order', + method: 'POST', + url: '/api/Orders', + atomicityGroup: 'tx1', + body: { + customerId: '$$customer.id', + amount: -100 // Invalid amount - will fail validation + } + } + ] + }); + + expect(response.status).toBe(200); + + const customerResponse = response.body.responses[0]; + expect(customerResponse.status).toBe(424); // Failed Dependency + + const orderResponse = response.body.responses[1]; + expect(orderResponse.status).toBe(400); // Original failure + + // Verify nothing was created in database + const customerCount = await db.customers.count(); + expect(customerCount).toBe(0); + + const orderCount = await db.orders.count(); + expect(orderCount).toBe(0); + }); + + it('should isolate atomicity groups from each other', async () => { + const response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .send({ + requests: [ + { + id: 'customer-1', + method: 'POST', + url: '/api/Customers', + atomicityGroup: 'group-A', + body: {name: 'Customer A'} + }, + { + id: 'order-1', + method: 'POST', + url: '/api/Orders', + atomicityGroup: 'group-A', + body: { + customerId: '$$customer-1.id', + amount: -100 // Will fail + } + }, + { + id: 'customer-2', + method: 'POST', + url: '/api/Customers', + atomicityGroup: 'group-B', + body: {name: 'Customer B'} + }, + { + id: 'order-2', + method: 'POST', + url: '/api/Orders', + atomicityGroup: 'group-B', + body: { + customerId: '$$customer-2.id', + amount: 100 // Valid + } + } + ] + }); + + expect(response.status).toBe(200); + + // Group A failed + expect(response.body.responses[0].status).toBe(424); // customer-1 rolled back + expect(response.body.responses[1].status).toBe(400); // order-1 failed + + // Group B succeeded + expect(response.body.responses[2].status).toBe(201); // customer-2 created + expect(response.body.responses[3].status).toBe(201); // order-2 created + + // Verify only group B data exists + const customers = await db.customers.findAll(); + expect(customers).toHaveLength(1); + expect(customers[0].name).toBe('Customer B'); + + const orders = await db.orders.findAll(); + expect(orders).toHaveLength(1); + }); +}); +``` + +--- + +## Migration Guide + +### From Individual Requests to Batch with Atomicity + +**Before:** + +```javascript +// Multiple requests with manual rollback +let customer, order; + +try { + customer = await fetch('/api/Customers', { + method: 'POST', + body: JSON.stringify({name: 'John'}) + }).then(r => r.json()); + + order = await fetch('/api/Orders', { + method: 'POST', + body: JSON.stringify({ + customerId: customer.id, + amount: 100 + }) + }).then(r => r.json()); + +} catch (error) { + // Manual cleanup - delete customer if order failed + if (customer && !order) { + await fetch(`/api/Customers(${customer.id})`, { + method: 'DELETE' + }); + } + throw error; +} +``` + +**After:** + +```javascript +// Single batch request with automatic rollback +const batchResponse = await fetch('/api/$batch', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + requests: [ + { + id: 'customer', + method: 'POST', + url: '/api/Customers', + atomicityGroup: 'create-order', + body: {name: 'John'} + }, + { + id: 'order', + method: 'POST', + url: '/api/Orders', + atomicityGroup: 'create-order', + body: { + customerId: '$$customer.id', + amount: 100 + } + } + ] + }) +}).then(r => r.json()); + +// Check if transaction succeeded +const allSucceeded = batchResponse.responses.every(r => + r.status >= 200 && r.status < 300 +); + +if (allSucceeded) { + const [customerRes, orderRes] = batchResponse.responses; + // Both created successfully +} else { + // Automatic rollback - nothing to clean up + console.error('Transaction failed and rolled back'); +} +``` + +**Benefits:** +- 🚀 **2x fewer network round trips** +- ⚡ **Automatic rollback** - no manual cleanup +- 🔒 **Guaranteed consistency** +- 📦 **Simpler error handling** + +--- + +## Troubleshooting + +### Issue: Atomicity Group Not Rolling Back + +**Symptom:** Partial data remains in database after failure + +**Causes:** +1. Database doesn't support transactions +2. Transaction not properly configured +3. Requests not in same atomicity group + +**Solution:** +```javascript +// Verify transaction support +const config = { + atomicityGroups: true, // Must be enabled + transactionTimeout: 30000 +}; + +// Check all requests have same group name +requests.forEach(r => { + console.log(`${r.id}: ${r.atomicityGroup}`); +}); +``` + +### Issue: 424 Status on All Requests + +**Symptom:** All requests in batch return 424 + +**Causes:** +1. First request in group failed +2. Database transaction error +3. Timeout exceeded + +**Solution:** +```javascript +// Find the original failure +const originalFailure = responses.find(r => + r.status >= 400 && r.status !== 424 +); +console.error('Original failure:', originalFailure); +``` + +### Issue: References Not Resolving + +**Symptom:** `$$1.id` appears literally in created entities + +**Causes:** +1. Referenced request failed (check status) +2. Request rolled back due to group failure +3. Property path is incorrect +4. Request order is wrong + +**Solution:** +```javascript +// Check responses +responses.forEach(r => { + console.log(`Request ${r.id}: Status ${r.status}`); + if (r.status === 424) { + console.log(` Rolled back in group: ${r.body.atomicityGroup}`); + } + if (r.status >= 400) { + console.error(` Failed: ${r.body.message}`); + } +}); +``` + +### Issue: Transaction Timeout + +**Symptom:** 500 error - "Transaction timeout" + +**Causes:** +1. Too many operations in one group +2. Slow database operations +3. Lock contention + +**Solution:** +```javascript +// Split into smaller groups +{ + "requests": [ + // Group 1: 2-3 operations + {"atomicityGroup": "group-1", ...}, + {"atomicityGroup": "group-1", ...}, + + // Group 2: 2-3 operations + {"atomicityGroup": "group-2", ...}, + {"atomicityGroup": "group-2", ...} + ] +} + +// Or increase timeout +batch(app, { + transactionTimeout: 60000 // 60 seconds +}); +``` + +--- + +## Security Considerations + +### 1. Authentication + +Batch requests inherit authentication from the parent request: + +```javascript +POST /api/$batch +Authorization: Bearer +``` + +All sub-requests automatically receive this authorization. + +### 2. Authorization + +Each sub-request is authorized **individually**, even within atomicity groups: + +```json +{ + "requests": [ + {"id": "1", "url": "/api/PublicData", "atomicityGroup": "tx1"}, + {"id": "2", "url": "/api/AdminOnly", "atomicityGroup": "tx1"} + ] +} +``` + +Response: +```json +{ + "responses": [ + {"id": "1", "status": 424}, // Rolled back + {"id": "2", "status": 403} // Forbidden - caused rollback + ] +} +``` + +**Security benefit:** Authorization failures trigger rollback, preventing partial operations. + +### 3. Transaction Isolation + +Configure appropriate isolation level: + +```javascript +batch(app, { + isolationLevel: 'READ_COMMITTED' // Prevent dirty reads +}); +``` + +### 4. Rate Limiting + +Consider limiting atomicity groups: + +```javascript +// Limit transaction complexity per user +const MAX_ATOMICITY_GROUP_SIZE = 10; +const MAX_CONCURRENT_TRANSACTIONS = 5; +``` + +--- + +## Performance Tips + +### 1. Transaction Duration + +**Target:** Keep transactions under 5 seconds + +```javascript +// ✅ Good: Small, focused transaction +"atomicityGroup": "order-tx" +// 3 operations: customer + order + payment + +// ❌ Bad: Large, complex transaction +"atomicityGroup": "huge-tx" +// 20+ operations: high failure risk, long locks +``` + +### 2. Database Optimization + +Ensure proper indexing: + +```sql +-- Index foreign keys used in transactions +CREATE INDEX idx_orders_customer_id ON orders(customer_id); +CREATE INDEX idx_payments_order_id ON payments(order_id); +``` + +### 3. Lock Contention + +Minimize lock contention: + +- **Avoid long-running read operations in atomicity groups** +- **Order operations to minimize lock time** +- **Use appropriate isolation level** + +### 4. Connection Pooling + +Configure adequate connection pool: + +```javascript +{ + database: { + pool: { + min: 5, + max: 20, + acquireTimeoutMillis: 30000 + } + } +} +``` + +--- + +## FAQ + +**Q: Can I reference a request that comes later in the batch?** + +A: No. Requests execute sequentially. You can only reference requests that have already completed. + +**Q: What happens if I reference a failed request?** + +A: The reference remains unresolved (as a literal string). The dependent request may fail validation or create incomplete data. + +**Q: Can I use both `$id` and `$$id.property` in the same request?** + +A: Yes! For example: +```json +{ + "url": "$customer/Orders", + "body": {"amount": "$$product.price"} +} +``` + +**Q: Are atomicity groups transactional across the entire batch?** + +A: No. Each atomicity group is its own transaction. Different groups are independent. + +**Q: Can requests in different atomicity groups reference each other?** + +A: Yes, but carefully: +```json +[ + {"id": "1", "atomicityGroup": "groupA", ...}, + {"id": "2", "atomicityGroup": "groupB", "body": {"refId": "$$1.id"}} +] +``` +If groupA rolls back, the reference in groupB won't resolve. + +**Q: What's the difference between atomicityGroup and no group?** + +A: +- **With group:** All-or-nothing, automatic rollback on failure +- **Without group:** Each request is independent, no rollback + +**Q: Can I nest atomicity groups?** + +A: No. Atomicity groups cannot be nested. Each request belongs to zero or one group. + +**Q: What happens if my database doesn't support transactions?** + +A: Atomicity groups won't work. Operations will execute independently. Enable transaction support in your database configuration. + +**Q: Can GET requests be in atomicity groups?** + +A: Technically yes, but it's not recommended. GET requests don't modify data, so they don't need transactional protection. + +**Q: What's the maximum size for an atomicity group?** + +A: No hard limit, but **keep groups under 10 requests** for optimal performance and reliability. + +--- + +## Additional Resources + +- [OData v4.0 Specification](http://docs.oasis-open.org/odata/odata/v4.0/odata-v4.0-part1-protocol.html) +- [OData Batch Processing](http://docs.oasis-open.org/odata/odata/v4.0/os/part1-protocol/odata-v4.0-os-part1-protocol.html#_Toc372793748) +- [OData Batch Request Format](http://docs.oasis-open.org/odata/odata-json-format/v4.0/os/odata-json-format-v4.0-os.html#_Toc372793091) +- [@themost/express Documentation](https://github.com/themost-framework/express) +- [Database Transaction Best Practices](https://www.postgresql.org/docs/current/transaction-iso.html) + +--- + +## Changelog + +### Version 2.x +- ✅ Added batch request support +- ✅ Implemented Content-ID URL referencing (`$id`) +- ✅ Added custom body property referencing (`$$id.property`) +- ✅ Implemented atomicity groups (changesets) with transaction support +- ✅ Support for sequential execution +- ✅ Configurable batch size limits +- ✅ 424 Failed Dependency status for rolled back requests + +--- + +## License + +MIT License - See LICENSE file for details + +--- + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +--- + +**Need Help?** Open an issue on [GitHub](https://github.com/themost-framework/express/issues) \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js index b1ebdec..d4cdfbd 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,4 +2,4 @@ const {TraceUtils} = require('@themost/common'); const {JsonLogger} = require('@themost/json-logger'); // noinspection JSCheckFunctionSignatures TraceUtils.useLogger(new JsonLogger()); -jest.setTimeout(15000); \ No newline at end of file +jest.setTimeout(45000); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8568619..b3401b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "2.6.2", "license": "BSD-3-Clause", "dependencies": { + "@themost/promise-sequence": "^1.0.2", + "ajv": "^8.18.0", "express": "^4.21.2", "lodash": "^4.17.23", "multer": "2.0.2", @@ -30,11 +32,11 @@ "@eslint/js": "^9.24.0", "@rollup/plugin-babel": "^5.3.1", "@themost/common": "^2.12.0", - "@themost/data": "^2.21.3", + "@themost/data": "^2.23.2", "@themost/events": "^1.5.0", "@themost/json-logger": "^1.3.0", "@themost/query": "^2.14.11", - "@themost/sqlite": "^3.1.0", + "@themost/sqlite": "^2.11.1", "@themost/xml": "^2.5.2", "@types/express": "^4.17.2", "@types/jest": "^30.0.0", @@ -1607,13 +1609,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1622,19 +1624,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1645,10 +1650,11 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1656,7 +1662,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -1667,6 +1673,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1698,6 +1721,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1711,9 +1741,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1724,9 +1754,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1734,19 +1764,27 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2329,6 +2367,62 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -2428,9 +2522,9 @@ "dev": true }, "node_modules/@themost/data": { - "version": "2.21.3", - "resolved": "https://registry.npmjs.org/@themost/data/-/data-2.21.3.tgz", - "integrity": "sha512-TTn7QnsqodD4U9w4EUEbS0+zVit00yliB9jeEW1yOa3vfdNXO70LLBgpqq6I4VvkTxQxKjgl6lHseTBpbJ8uiw==", + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/@themost/data/-/data-2.23.2.tgz", + "integrity": "sha512-kkAdxQTndWVA2MYAOkraf6mUoDhUQEeASBcQcPTEz1lgWPxK8FhFhbGgd+tIHQEMXrJ3FMCBhj/Z6Ufm3suLwQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2444,6 +2538,7 @@ "node-cache": "^1.1.0", "pluralize": "^7.0.0", "q": "^1.4.1", + "rxjs": "^7.8.2", "sprintf-js": "^1.1.2", "symbol": "^0.3.1", "uuid": "^10.0.0" @@ -2489,7 +2584,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@themost/promise-sequence/-/promise-sequence-1.0.2.tgz", "integrity": "sha512-flTqKuFh1k9JXccwoUskHSjSG81tu0Di/kraNap6dcoQMZyDEgcT9AICu6tyPidWbMilf0bXg5gW1gRjQl5Yiw==", - "dev": true + "license": "BSD-3-Clause" }, "node_modules/@themost/query": { "version": "2.14.11", @@ -2521,18 +2616,18 @@ "dev": true }, "node_modules/@themost/sqlite": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@themost/sqlite/-/sqlite-3.1.0.tgz", - "integrity": "sha512-Fo7gb2DRgN1we4lXPN8bEXC1qWSyn2IRNEC6LZezeK1d9G5EqsGkioMoHRLv/UYyti/hOZD0qr8p/Sb6Ix+bWA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@themost/sqlite/-/sqlite-2.11.1.tgz", + "integrity": "sha512-zq/rqIntcdg+ybcHk9Aowh9deZEa1sdEdoHWG7yT5wPEyX+J6zApOR7zdDBefs+IYe6Lc18DY8ajNLlN1qf2FQ==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.5.0", "async": "^2.6.4", - "better-sqlite3": "^12.6.2", - "lodash": "^4.17.23", + "lodash": "^4.17.21", "sprintf-js": "^1.1.2", + "sqlite3": "^5.1.7", "unzipper": "^0.12.3" }, "engines": { @@ -2555,6 +2650,17 @@ "integrity": "sha512-J3qXDJ/Rey5Tri8Swb4ghF7TRaoQ4NXjCsXiyf1GKNAhN4nn4XtfMwIhsrN9LI5z9TUZOut6LOK9PMWDVWRrpA==", "dev": true }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3164,6 +3270,14 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3208,16 +3322,59 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3313,6 +3470,46 @@ "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=", "dev": true }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3498,21 +3695,6 @@ ], "license": "MIT" }, - "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3773,6 +3955,51 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3889,11 +4116,14 @@ } }, "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/ci-info": { "version": "3.9.0", @@ -3916,6 +4146,17 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3946,6 +4187,17 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "dev": true }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4007,6 +4259,14 @@ "node": ">= 6" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4264,6 +4524,14 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4380,6 +4648,31 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4390,6 +4683,25 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4472,25 +4784,24 @@ "license": "MIT" }, "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -4589,6 +4900,23 @@ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4630,9 +4958,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "dependencies": { @@ -4944,8 +5279,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -4987,6 +5321,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5204,6 +5554,19 @@ "node": ">=14.14" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5235,6 +5598,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5307,15 +5692,17 @@ "license": "MIT" }, "node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, @@ -5412,6 +5799,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/hashmap": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/hashmap/-/hashmap-2.4.0.tgz", @@ -5446,6 +5841,14 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5463,6 +5866,37 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5472,6 +5906,17 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5614,6 +6059,25 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5646,6 +6110,17 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5706,6 +6181,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -6572,10 +7055,10 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -6727,6 +7210,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/magic-string": { "version": "0.26.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", @@ -6752,6 +7249,35 @@ "node": ">=6" } }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -6902,6 +7428,108 @@ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -6969,6 +7597,17 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -6983,9 +7622,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6995,6 +7634,13 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-cache": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-1.1.0.tgz", @@ -7007,6 +7653,46 @@ "node": ">= 0.4.6" } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7021,6 +7707,23 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -7054,6 +7757,24 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7191,6 +7912,23 @@ "node": ">=6" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -7476,6 +8214,29 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7514,10 +8275,11 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -7549,9 +8311,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7779,6 +8541,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7838,6 +8609,17 @@ "node": ">=10" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8089,6 +8871,14 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -8279,6 +9069,50 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8343,6 +9177,45 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8552,6 +9425,25 @@ "integrity": "sha1-tvmpANSWpX8CQI8iGYwQndoGMEE=", "dev": true }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -8565,6 +9457,13 @@ "tar-stream": "^2.1.4" } }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -8597,6 +9496,29 @@ "node": ">= 6" } }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8770,6 +9692,28 @@ "node": ">=4" } }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -8836,10 +9780,11 @@ } }, "node_modules/uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -8929,6 +9874,17 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8992,6 +9948,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 9aece7a..5773c10 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "express": "^4.21.2" }, "dependencies": { + "@themost/promise-sequence": "^1.0.2", + "ajv": "^8.18.0", "express": "^4.21.2", "lodash": "^4.17.23", "multer": "2.0.2", @@ -63,11 +65,11 @@ "@eslint/js": "^9.24.0", "@rollup/plugin-babel": "^5.3.1", "@themost/common": "^2.12.0", - "@themost/data": "^2.21.3", + "@themost/data": "^2.23.2", "@themost/events": "^1.5.0", "@themost/json-logger": "^1.3.0", "@themost/query": "^2.14.11", - "@themost/sqlite": "^3.1.0", + "@themost/sqlite": "^2.11.1", "@themost/xml": "^2.5.2", "@types/express": "^4.17.2", "@types/jest": "^30.0.0", diff --git a/spec/batch.spec.js b/spec/batch.spec.js new file mode 100644 index 0000000..a5b65b3 --- /dev/null +++ b/spec/batch.spec.js @@ -0,0 +1,431 @@ +import express from 'express'; +import {ExpressDataApplication, batch} from '@themost/express'; +import path from 'path'; +import fs from 'fs'; +import {dateReviver} from '@themost/express'; +import passport from 'passport'; +import {serviceRouter} from '@themost/express'; +import {TestPassportStrategy} from './passport'; +import request from 'supertest'; +import {finalizeDataApplication, jsonErrorHandler} from './utils'; +import {DataConfigurationStrategy} from '@themost/data'; + +describe('Batch', () => { + let app; + let passportStrategy = new TestPassportStrategy(); + beforeAll(() => { + app = express(); + // create a new instance of data application + const dataApplication= new ExpressDataApplication(path.resolve(__dirname, 'test/config')); + const dataConfiguration = dataApplication.configuration.getStrategy(DataConfigurationStrategy); + const adapter = dataConfiguration.adapters.find((adapter) => adapter.default); + if (adapter) { + // copy test database to a temporary location to avoid conflicts between tests + fs.copyFileSync(path.resolve(process.cwd(), adapter.options.database), path.resolve(process.cwd(), 'spec/test/db/test.db')); + // update adapter configuration to use the temporary database + adapter.options.database = path.resolve(process.cwd(), 'spec/test/db/test.db'); + } + app.use(express.json({ + reviver: dateReviver + })); + // hold data application + app.set('ExpressDataApplication', dataApplication); + // use data middleware (register req.context) + app.use(dataApplication.middleware(app)); + // use test passport strategy + // noinspection JSCheckFunctionSignatures + passport.use(passportStrategy); + // noinspection JSCheckFunctionSignatures + app.use('/api/', passport.authenticate('bearer', { session: false }), batch(app), serviceRouter); + app.use(jsonErrorHandler()) + }); + + afterAll(async () => { + const dataApplication = app.get('ExpressDataApplication'); + await finalizeDataApplication(dataApplication); + }); + + it('should execute a batch request', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + url: '/api/users/?$filter=groups/name eq \'Administrators\'' + } + ] + }); + expect(response.status).toEqual(200); + const { responses } = response.body; + expect(responses).toHaveLength(2); + const userResponse = responses.find(r => r.id === '1'); + expect(userResponse).toBeDefined(); + expect(userResponse.status).toEqual(200); + expect(userResponse.body).toHaveProperty('name', 'alexis.rees@example.com'); + const usersResponse = responses.find(r => r.id === '2'); + expect(usersResponse).toBeDefined(); + expect(usersResponse.status).toEqual(200); + expect(usersResponse.body).toHaveProperty('value'); + expect(usersResponse.body.value).toBeInstanceOf(Array); + expect(usersResponse.body.value.length).toBeGreaterThan(0); + const user = usersResponse.body.value.find(u => u.name === 'alexis.rees@example.com'); + expect(user).toBeDefined(); + }); + + it('should execute a batch request with error', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + url: '/api/users/me/status' + } + ] + }); + expect(response.status).toEqual(200); + const { responses } = response.body; + expect(responses).toBeDefined(); + expect(responses).toHaveLength(2); + const userResponse = responses.find(r => r.id === '1'); + expect(userResponse).toBeDefined(); + expect(userResponse.status).toEqual(200); + const errorResponse = responses.find(r => r.id === '2'); + expect(errorResponse).toBeDefined(); + expect(errorResponse.status).toEqual(500); + expect(errorResponse.body.message).toEqual('This is a status error'); + expect(errorResponse.body.name).toEqual('Error'); + + }); + + + it('should execute a batch request with a non-existing endpoint', async () => { + const testRequest = request(app).post('/api/$batch'); + let response = await testRequest + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + url: '/api/NonExistingEndpoint' + }, + { + id: '2', + method: 'GET', + url: '/api/NonExistingEndpoint' + } + ] + }); + expect(response.status).toEqual(200); + const { responses } = response.body; + expect(responses).toHaveLength(2); + const userResponse = responses.find(r => r.id === '1'); + expect(userResponse).toBeDefined(); + expect(userResponse.status).toEqual(404); + }); + + it('should validate atomicity group', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + const testRequest = request(app).post('/api/$batch'); + let response = await testRequest + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/groups' + } + ] + }); + expect(response.status).toEqual(400); + expect(response.body.name).toEqual('HttpBadRequestError'); + }); + + it('should more than one atomicity groups', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + const testRequest = request(app).post('/api/$batch'); + let response = await testRequest + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/groups' + }, + { + id: '3', + method: 'GET', + atomicityGroup: 'group2', + url: '/api/orders' + } + ] + }); + expect(response.status).toEqual(200); + /** + * @type {{responses: {status: number, body: *}[]}} + */ + const { responses } = response.body; + expect(responses).toHaveLength(3); + for (const response of responses) { + expect(response.status).toEqual(200); + } + }); + + it('should execute requests with atomicity group', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app) + .post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/groups?$select=name,alternateName' + } + ] + }); + expect(response.status).toEqual(200); + }); + + it('should execute requests and validate absolute urls', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + const testRequest = request(app).post('/api/$batch'); + let response = await testRequest + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/users/me' + }, + { + id: '2', + method: 'GET', + atomicityGroup: 'group1', + url: '/api/groups?$select=name,alternateName' + } + ] + }); + expect(response.status).toEqual(200); + for(const r of response.body.responses) { + expect(r.body).toBeDefined(); + expect(r.status).toEqual(200); + } + }); + + it('should rollback transaction for atomicity groups', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app).post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'POST', + atomicityGroup: 'create-user', + url: '/api/users', + body: { + name: 'Test User', + alternateName: 'test100@example.com' + } + }, + { + id: '2', + method: 'GET', + atomicityGroup: 'create-user', + url: '/api/NonExistingEndpoint' + }, + { + id: '3', + method: 'GET', + atomicityGroup: 'get-user', + url: '/api/users?$filter=alternateName eq \'test100@example.com\'', + }, + ] + }); + expect(response.status).toEqual(200); + const lastResponse = response.body.responses.find(r => r.id === '3'); + expect(lastResponse).toBeDefined(); + expect(lastResponse.status).toEqual(200); + expect(lastResponse.body.value).toBeInstanceOf(Array); + expect(lastResponse.body.value.length).toEqual(0); + }); + + it('should commit transaction for atomicity groups', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app).post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'POST', + atomicityGroup: 'create-customer', + url: '/api/people', + body: { + name: 'Test Customer', + givenName: 'Test', + familyName: 'Customer', + } + }, + { + id: '2', + method: 'POST', + atomicityGroup: 'create-order', + url: '/api/orders', + body: { + orderedItem: { + name: 'Apple MacBook Air (13.3-inch, 2013 Version)', + }, + customer: { + givenName: 'Test', + familyName: 'Customer', + } + } + } + ] + }); + expect(response.status).toEqual(200); + for(const r of response.body.responses) { + expect(r.body).toBeDefined(); + expect(r.status).toEqual(200); + } + }); + + it('should use params from a previous request', async () => { + const mock = jest.spyOn(passportStrategy, 'getUser'); + mock.mockImplementation(() => { + return { + name: 'alexis.rees@example.com' + }; + }); + let response = await request(app).post('/api/$batch') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + requests: [ + { + id: '1', + method: 'POST', + atomicityGroup: 'create-customer', + url: '/api/people', + body: { + name: 'Test Customer', + givenName: 'Test', + familyName: 'Customer', + } + }, + { + id: '2', + method: 'POST', + atomicityGroup: 'create-order', + url: '/api/orders', + body: { + orderedItem: { + name: 'Apple MacBook Air (13.3-inch, 2013 Version)', + }, + customer: '$$1.id' + } + } + ] + }); + expect(response.status).toEqual(200); + for(const r of response.body.responses) { + expect(r.body).toBeDefined(); + expect(r.status).toEqual(200); + } + }); + +}); diff --git a/spec/test/db/local.db b/spec/test/db/local.db index 06c9600..693669b 100644 Binary files a/spec/test/db/local.db and b/spec/test/db/local.db differ diff --git a/spec/test/models/UserModel.js b/spec/test/models/UserModel.js index 50a8f24..c17fe09 100644 --- a/spec/test/models/UserModel.js +++ b/spec/test/models/UserModel.js @@ -29,6 +29,11 @@ class User extends DataObject { return context.model('User').where('name').equal(context.user.name).getItem(); } + @EdmMapping.func('status', EdmType.EdmBoolean) + getStatus() { + throw new Error('This is a status error'); + } + @EdmMapping.param('name', EdmType.EdmString, false) @EdmMapping.func('active', 'User') static getActiveUser(context, name) { diff --git a/spec/utils.js b/spec/utils.js index f7eac58..6f1f734 100644 --- a/spec/utils.js +++ b/spec/utils.js @@ -13,6 +13,32 @@ async function finalizeDataApplication(dataApplication) { } } +function jsonErrorHandler() { + return (err, req, res, next) => { + if (res.headersSent) { + return next(err) + } + const isDevOrTest = req.app.get('env') === 'development' || req.app.get('env') === 'test'; + if (req.get('accept') === 'application/json') { + // get error object + const error = Object.getOwnPropertyNames(err).filter((key) => { + return key !== 'stack' || (key === 'stack' && isDevOrTest); + }).reduce((acc, key) => { + acc[key] = err[key]; + return acc; + }, {}); + const proto = Object.getPrototypeOf(err); + if (proto && proto.constructor && proto.constructor.name) { + error.name = proto.constructor.name; + } + // return error as json + return res.status(err.status || err.statusCode || 500).json(error); + } + return next(err); + } +} + export { - finalizeDataApplication + finalizeDataApplication, + jsonErrorHandler } \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index 42614d9..e4754a9 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -2,12 +2,13 @@ // tslint:disable-next-line:ordered-imports import {ConfigurationBase, IApplicationService, ApplicationBase} from '@themost/common'; import {ApplicationServiceConstructor} from '@themost/common/app'; -import {DataAdapterConstructor, DefaultDataContext, ODataModelBuilder} from '@themost/data'; +import {DataAdapterConstructor, DataModel, DefaultDataContext, ODataModelBuilder} from '@themost/data'; import {Application, RequestHandler, Router} from 'express'; import {BehaviorSubject} from 'rxjs'; export declare interface ApplicationConfiguration { [key: string]: unknown; + services?: { serviceType: string; strategyType?: string }[]; settings?: { [key: string]: unknown, @@ -20,13 +21,13 @@ export declare interface ApplicationConfiguration { unattendedExecutionAccount?: string; }, schema?: { - loaders?: { loaderType: string;}[]; + loaders?: { loaderType: string; }[]; }, i18n?: { defaultLocale: string; locales: string[]; } - } + }; adapterTypes?: { name: string; invariantName: string; @@ -128,6 +129,10 @@ export interface InteractiveUser { export declare class ExpressDataContext extends DefaultDataContext { + model(name: any): DataModel; + + finalize(callback?: ((err?: Error | undefined) => void) | undefined): void; + public application: ExpressDataApplication; public getConfiguration(): ConfigurationBase; diff --git a/src/app.js b/src/app.js index 2f650cf..11e61a6 100644 --- a/src/app.js +++ b/src/app.js @@ -1,9 +1,10 @@ import Symbol from 'symbol'; -import {Args, ConfigurationBase, ApplicationService, IApplication} from '@themost/common'; +import {Args, ConfigurationBase, ApplicationService, IApplication, TraceUtils} from '@themost/common'; import {DefaultDataContext, DataConfigurationStrategy, ODataConventionModelBuilder, ODataModelBuilder} from '@themost/data'; import {ServicesConfiguration} from './configuration'; import {serviceRouter} from './service'; import {BehaviorSubject} from 'rxjs'; +import {IncomingMessage} from 'http'; const configurationProperty = Symbol('configuration'); const applicationProperty = Symbol('application'); @@ -225,7 +226,17 @@ class ExpressDataApplication extends IApplication { } // broadcast container this.container.next(app); + /** + * Express middleware which initializes a data context for each request and defines req.context property to access the context in the request handlers. The context is finalized on response finish or close events by disposing the underlying data adapter if exists and then calling the finalize method of the context. + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + * @returns {import('express').RequestHandler} + */ return function dataContextMiddleware(req, res, next) { + if (req.context && req.parentReq instanceof IncomingMessage) { + return next(); + } const context = new ExpressDataContext(thisApp.getConfiguration()); // define application property context[applicationProperty] = thisApp; @@ -256,19 +267,28 @@ class ExpressDataApplication extends IApplication { return context; } }); - res.on('close', () => { - if (req.context) { - // if db is a disposable adapter - if (req.context.db && typeof req.context.db.dispose === 'function') { - // dispose db - req.context.db.dispose(); - } - // and finalize data context - return req.context.finalize( () => { - // - }); - } - }); + /** + * Finalizes the current context by disposing the underlying data adapter if exists and then calling the finalize method of the context + */ + const closeListener = function() { + if (req.context) { + // if db is a disposable adapter + if (req.context.db && typeof req.context.db.dispose === 'function') { + // dispose db + req.context.db.dispose(); + } + // and finalize data context + return req.context.finalize( (err) => { + if (err) { + TraceUtils.warn('An error occurred while finalizing data context using server response close listener'); + TraceUtils.warn(err); + } + }); + } + } + // finalize context on response finish or close + res.once('finish', closeListener); + res.once('close', closeListener); return next(); }; } diff --git a/src/batch.d.ts b/src/batch.d.ts new file mode 100644 index 0000000..3310707 --- /dev/null +++ b/src/batch.d.ts @@ -0,0 +1,28 @@ +import {Router } from 'express'; + +declare global { + namespace http { + interface IncomingMessage { + batchReq?: BatchRequestMessage + parentReq?: IncomingMessage; + } + } + namespace Express { + interface Request { + batchReq?: BatchRequestMessage + parentReq?: Request; + } + } +} + +export declare function batch(routerOrApplication: Router, options?: { headers: string[], min?: number, max?: number }): Router; + +export interface BatchRequestMessage { + id: string; + method: string; + url: string; + headers: Record; + body?: any; + atomicityGroup?: string; + dependsOn?: string[]; +} \ No newline at end of file diff --git a/src/batch.js b/src/batch.js new file mode 100644 index 0000000..cff934e --- /dev/null +++ b/src/batch.js @@ -0,0 +1,413 @@ +import {HttpNotAcceptableError, HttpNotFoundError, TraceUtils, HttpBadRequestError, Guid} from '@themost/common'; +import {URL} from 'url'; +import { IncomingMessage, ServerResponse } from 'http'; +import {Router} from 'express'; +import {schema as BatchRequestMessageSchema} from './batch.schema'; +import Ajv from 'ajv'; +import '@themost/promise-sequence'; +import at from 'lodash/at'; + +/** + * Represents a single request inside a batch payload. + * + * @interface BatchRequestMessage + * @property {string} id - Unique identifier for the batched request. + * @property {string} method - HTTP method (e.g. `GET`, `POST`, `PUT`, `DELETE`). + * @property {string} url - Request URL or path (relative to the batch endpoint). + * @property {Record} headers - Key/value map of request headers. + * @property {*} [body] - Optional request body / payload. + * @property {string} [atomicityGroup] - Optional atomicity group identifier; requests in the same group should be executed atomically. + * @property {string[]} [dependsOn] - Optional list of other request `id`s this request depends on. + */ + +/** + * Custom IncomingMessage class to represent individual requests within the batch payload. + * This allows us to create child request objects that can be processed by the Express router as if they were real HTTP requests. + */ +class BatchIncomingMessage extends IncomingMessage { + /** + * @param {import('express').Request} req + */ + constructor(req) { + super(); + const { method, url, body, headers } = req; + this.method = method; + const uri = new URL(url, 'http://localhost'); + this.url = uri.pathname; + this.body = body; + // use _body to disable body parsing in the child request since the body is already parsed in the parent request + if (this.method === 'POST' || this.method === 'PUT' || this.method === 'PATCH') { + this._body = this.body || {}; + } + this.query = Object.fromEntries(uri.searchParams.entries()); + this.headers = headers || {}; + if (this.body) { + this.headers['content-length'] = Buffer.byteLength(JSON.stringify(this.body)).toString(); + } else { + delete this.headers['content-length']; + } + } +} + +class BatchServerResponse extends ServerResponse { + /** + * @param {IncomingMessage} req + */ + constructor(req) { + super(req); + this.statusCode = 200; + this.headers = {}; + } + status(code) { + this.statusCode = code; + return this; + } + + write(chunk, encoding, callback) { + super.write(chunk, encoding, callback); + } + + end(callback) { + super.end(callback); + } + + send(body) { + this.emit('data', { + status: this.statusCode, + headers: this.headers, + body + }); + } + json(body) { + this.setHeader('Content-Type', 'application/json'); + this.send(body); + } + set(field, value) { + this.headers[field] = value; + return this; + } + setHeader(field, value) { + this.set(field, value); + } + get(field) { + return this.headers[field]; + } + getHeader(field) { + return this.headers[field]; + } +} + +/** + * @param {import('express').Router} routerOrApplication - The Express routerOrApplication to use for handling batch requests. This is necessary to execute the batch requests using the same routerOrApplication as the main application. + * @param {{headers:Array=,min:number=,max:number=}=} options - Optional configuration options for the batch middleware. + * @returns {import('express').Handler} + */ +function batch(routerOrApplication, options) { + + const batchRouter = Router(); + + const opts = options || { + min: 2, + max: 25, + headers: [ + 'authorization', + 'content-type', + 'accept', + 'accept-language', + 'accept-encoding', + 'user-agent' + ] + }; + if (typeof opts.min !== 'number') { + opts.min = 2; + } + if (typeof opts.max !== 'number') { + opts.max = 25; + } + + batchRouter.use(function batchInit(req, res, next) { + // noinspection JSUnresolvedReference + if (req.batchReq) { + // override res.send and res.json to capture the response from the batch request + res.json = function (body) { + res.body = body; + res.emit('batch.data', res); + }; + res.on('error', function (err) { + res.emit('batch.error', err); + }); + res.on('finish', function () { + TraceUtils.debug( + `Batch request [${req.batchReq.id}] ${req.batchReq.method} ${req.batchReq.url} ${res.statusCode}` + ) + }); + } + return next(); + }); + + batchRouter.post('/\\$batch', function(req, res, next) { + try { + const contentType = req.get('content-type'); + if (contentType !== 'application/json') { + return next(new HttpNotAcceptableError()); + } + const { min, max } = opts; + // check if the request is a batch request + const {requests: batchRequests} = req.body; + if (Array.isArray(batchRequests)) { + if (batchRequests.length < min || batchRequests.length > max) { + return next(new HttpNotAcceptableError(`Batch request must contain between ${min} and ${max} requests`)); + } + // stage #1 - assign id and headers to batch requests + batchRequests.forEach((batchRequest, index) => { + // assign id to batch request if not provided + batchRequest.id = batchRequest.id || (index + 1).toString(); + // convert relative urls to absolute urls by prefixing them with the original request url + if (batchRequest.url.startsWith('/')) { + batchRequest.url = new URL(batchRequest.url, req.protocol + '://' + req.get('host')).toString(); + } + // validate that batch request has method and url properties + if (typeof batchRequest.method !== 'string' || typeof batchRequest.url !== 'string') { + throw new HttpBadRequestError(`Batch request at index ${index} is missing required properties 'method' and 'url'`); + } + // assign headers from the original request to the batch request + // note: only include headers that are specified in the options to prevent leaking sensitive information to the batch requests + batchRequest.headers = { + ...Object.keys(req.headers) + .filter(header => opts.headers.includes(header)).reduce((acc, header) => { + acc[header] = req.headers[header]; + return acc; + }, {}) + }; + }); + // stage #2 - assign atomicity group to batch requests and execute them sequentially + const shouldAssignAtomicityGroup = batchRequests.some(batchRequest => batchRequest.atomicityGroup != null); + if (shouldAssignAtomicityGroup) { + batchRequests.forEach((batchRequest, index) => { + if (batchRequest.atomicityGroup == null) { + throw new HttpBadRequestError(`Batch request at index ${index} is missing required property 'atomicityGroup' which is required when at least one batch request contains an 'atomicityGroup' property`); + } + }); + } + // stage #3 - validate that all batch requests with the same atomicity group have the same method and url properties + /** + * @type {{[k:string]:Array}} + */ + const atomicityGroups = {}; + batchRequests.forEach((batchRequest) => { + // validate batch request against the schema + const validate = new Ajv({ + strict: false + }).compile(BatchRequestMessageSchema); + if (validate(batchRequest) === false) { + const error = new HttpBadRequestError(`Batch request with id ${batchRequest.id} is invalid`); + TraceUtils.error(`Batch request with url "${batchRequest.url}" is invalid`); + validate.errors.forEach(validationError => { + TraceUtils.error(`Validation error: ${validationError.instancePath} ${validationError.message}.`); + }) + throw error; + } + if (batchRequest.atomicityGroup != null) { + if (Object.hasOwnProperty.call(atomicityGroups, batchRequest.atomicityGroup) === false) { + atomicityGroups[batchRequest.atomicityGroup] = []; + } + // push batch request to the corresponding atomicity group + atomicityGroups[batchRequest.atomicityGroup].push(batchRequest); + } + }); + function executeBatchRequestAsync(batchRequest) { + return new Promise((resolve, reject) => { + try { + // create child request + const childReq = new BatchIncomingMessage(batchRequest); + // inherit context from the original request + Object.defineProperty(childReq, 'context', { + get() { + return req.context; + }, + configurable: true + }); + Object.defineProperty(childReq, 'parentReq', { + get() { + return req; + }, + configurable: true + }); + Object.defineProperty(childReq, 'batchReq', { + get() { + return batchRequest; + }, + configurable: true + }); + // create a new response object for the batch request + const childRes = new BatchServerResponse(childReq); + // add events to capture the response from the batch request + childRes.on( + 'batch.data', + /** + * @this {ServerResponse} + * @param response + */ + function (response) { + this.end(); + this.emit('finish'); + resolve({ + id: batchRequest.id, + status: response.statusCode, + headers: response.headers, + body: response.body + }); + }); + childRes.on( + 'batch.error', + /** + * @this {ServerResponse} + * @param {*} error + */ + function (error) { + const errorResult = { + id: batchRequest.id, + status: error.status || error.statusCode || 500, + body: Object.getOwnPropertyNames(error).reduce((acc, key) => { + acc[key] = error[key]; + return acc; + }, {}) + }; + // if the error has a constructor name, include it in the response body + // noinspection JSUnresolvedReference + if (error.constructor && error.constructor.name) { + errorResult.body.name = error.constructor && error.constructor.name; + } + this.end(); + this.emit('finish'); + // noinspection JSUnresolvedReference + if (this.req.batchReq && this.req.batchReq.atomicityGroup) { + // if the batch request is part of an atomicity group, throw an error to trigger a transaction rollback for the entire group + reject(errorResult); + } + resolve(errorResult); + }); + // noinspection JSUnresolvedReference + const router = routerOrApplication._router || routerOrApplication; + router.handle(childReq, childRes, function (err) { + // if the batch request was not handled, return a 404 error + if (err == null) { + return childRes.emit('batch.error', new HttpNotFoundError()); + } + Object.assign(err, { + message: err.message + }); + childRes.emit('batch.error', err); + }); + } catch(err) { + return reject(err); + } + + }); + } + // check atomicity groups for consistency + if (Object.keys(atomicityGroups).length > 0) { + const results = batchRequests.map(({id}) => { + return { + id, + } + }); + // create a map of atomicity groups to functions that execute the batch requests in the group sequentially within a transaction + const sources = Object.keys(atomicityGroups).map((atomicityGroup) => { + // get batch requests for the atomicity group + const requests = atomicityGroups[atomicityGroup]; + // return a function that executes the batch requests in the atomicity group sequentially within a transaction + return () => { + // execute batch requests in the atomicity group sequentially within a transaction + Object.assign(req.context.db, { + identifier: Guid.newGuid().toString() + }); + return req.context.db.executeInTransactionAsync(async () => { + await Promise.sequence(requests.map((request) => { + return () => { + // parse body for assigning params in the batch request + if (request.body && typeof request.body === 'object') { + const body = JSON.parse(JSON.stringify(request.body), (key, value) => { + if (typeof value === 'string' && value.startsWith('$$')) { + // split property path by dot notation to extract dataset and property name for value assignment + // e.g. "$$dataset.property" -> dataset: "dataset", property: "property" + const property = value.substring(2).split('.'); + // get dataset name from the property path + const dataset = property.shift(); + // get the result of the batch request that corresponds to the dataset name + const result = results.find(r => r.id === dataset); + if (result) { + const [val] = at(result.body, property); + return val; + } else { + throw new HttpBadRequestError(`Batch request with id "${dataset}" cannot be found for property reference "${value}"`); + } + } + return value; + }); + request.body = request._body = body; + } + return executeBatchRequestAsync(request).then((intermediateResult) => { + const result = results.find(r => r.id === request.id); + if (result) { + Object.assign(result, intermediateResult); + } + }); + } + })); + }).catch((atomicityGroupError) => { + // if any request in the atomicity group fails, capture the error for all requests in the group + requests.forEach((request) => { + const result = results.find(r => r.id === request.id); + if (result) { + if (result.id === atomicityGroupError.id) { + Object.assign(result, atomicityGroupError, { + atomicityGroup + }); + } else { + // for requests that belongs to the same atomicity group but did not cause the error, set status to 0 to indicate that they were not executed due to the failure of another request in the same atomicity group + Object.assign(result, { + status: 0, + atomicityGroup + }); + } + } + }); + const result = results.find(r => r.id === atomicityGroupError.id); + if (result) { + Object.assign(result, atomicityGroupError); + } + }); + } + }); + Promise.sequence(sources).then(() => { + res.json({ responses: results }); + }).catch((err) => { + next(err); + }); + } else { + // no atomicity groups, execute batch requests sequentially + void Promise.sequence(batchRequests.map((request) => { + return () => { + return executeBatchRequestAsync(request); + } + })).then((results) => { + res.json({ responses: results }); + }).catch((err) => { + next(err); + }); + } + } else { + // not a batch request, continue to the next middleware + return next(); + } + } catch (err) { + return next(err); + } + }); + return batchRouter; +} + +export { + batch +} \ No newline at end of file diff --git a/src/batch.schema.js b/src/batch.schema.js new file mode 100644 index 0000000..0767b43 --- /dev/null +++ b/src/batch.schema.js @@ -0,0 +1,53 @@ +/* eslint-disable quotes */ +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BatchRequestMessage", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string" + }, + "url": { + "type": "string", + "$ref": "#/definitions/relativeUri" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": {}, + "atomicityGroup": { + "type": "string" + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "id", + "method", + "url" + ], + "additionalProperties": false, + "definitions": { + "relativeUri": { + "type": "string", + "pattern": "^((?:https?:)?\/\/)", + "title": "Relative URI", + "description": "A relative URI that does not include the scheme and host. It may include query parameters and fragments." + } + } +} + +export { + schema +} \ No newline at end of file diff --git a/src/context.js b/src/context.js index 2d1b864..5f5b0a3 100644 --- a/src/context.js +++ b/src/context.js @@ -1,4 +1,5 @@ import {TraceUtils} from '@themost/common'; +import {IncomingMessage} from 'http'; /** * Finalize request context @@ -6,6 +7,9 @@ import {TraceUtils} from '@themost/common'; */ function finalizeContext() { return function(req, res, next) { + if (req && req.parentReq instanceof IncomingMessage) { + return next(); + } if (req.context) { // if db is a disposable adapter if (req.context.db && typeof req.context.db.dispose === 'function') { diff --git a/src/index.d.ts b/src/index.d.ts index 72f6271..6fd5a68 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -6,3 +6,4 @@ export * from './helpers'; export * from './service'; export * from './formatter'; export * from './context'; +export * from './batch'; diff --git a/src/index.js b/src/index.js index a5d67df..9db8582 100644 --- a/src/index.js +++ b/src/index.js @@ -5,4 +5,5 @@ export * from './middleware'; export * from './helpers'; export * from './service'; export * from './formatter'; -export * from './context'; \ No newline at end of file +export * from './context'; +export * from './batch'; \ No newline at end of file diff --git a/src/middleware.js b/src/middleware.js index 2fd752c..f6ea3a8 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -7,6 +7,7 @@ import {LangUtils, HttpNotFoundError, HttpBadRequestError, HttpMethodNotAllowedE import { ResponseFormatter, StreamFormatter } from './formatter'; import {multerInstance} from './multer'; import fs from 'fs'; +import {IncomingMessage} from 'http'; const parseBoolean = LangUtils.parseBoolean; const DefaultTopOption = 25; @@ -106,6 +107,9 @@ const DefaultTopOption = 25; */ function finalizeContext(req, next) { + if (req && req.parentReq instanceof IncomingMessage) { + return next(); + } if (req && req.context && typeof req.context.finalize === 'function') { return req.context.finalize(next); }