@happyvertical/smrt-core 0.40.68 → 0.40.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -4
- package/README.md +20 -1
- package/agents/query-bounds.md +45 -0
- package/agents/schema-paths.md +328 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +28 -4
- package/dist/class.js.map +1 -1
- package/dist/collection.d.ts +101 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +224 -43
- package/dist/collection.js.map +1 -1
- package/dist/database.d.ts +28 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -8
- package/dist/database.js.map +1 -1
- package/dist/db-errors.d.ts +105 -0
- package/dist/db-errors.d.ts.map +1 -0
- package/dist/db-errors.js +382 -0
- package/dist/db-errors.js.map +1 -0
- package/dist/decorators/index.d.ts +17 -1
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +65 -7
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +4 -3
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/errors.d.ts +30 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +34 -2
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +6 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/mcp-runtime-template.d.ts +8 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +38 -4
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +16 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +41 -3
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +22 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +34 -3
- package/dist/generators/rest.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.d.ts +202 -8
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +604 -46
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +31 -4
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +49 -5
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +5 -3
- package/dist/migrations/orchestrate.js +35 -6
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +142 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
- package/dist/migrations/sqlite-rebuild.js +514 -0
- package/dist/migrations/sqlite-rebuild.js.map +1 -0
- package/dist/migrations/tracker.d.ts +114 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +331 -16
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +19 -4
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/migrations.js +5 -3
- package/dist/object.d.ts +45 -6
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +60 -13
- package/dist/object.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +240 -0
- package/dist/postgres-timeouts.d.ts.map +1 -0
- package/dist/postgres-timeouts.js +204 -0
- package/dist/postgres-timeouts.js.map +1 -0
- package/dist/query-bounds.d.ts +97 -0
- package/dist/query-bounds.d.ts.map +1 -0
- package/dist/query-bounds.js +173 -0
- package/dist/query-bounds.js.map +1 -0
- package/dist/registry/types.d.ts +32 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/scanner/types.d.ts +5 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +70 -5
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +124 -6
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/live-parity.d.ts +90 -0
- package/dist/schema/live-parity.d.ts.map +1 -0
- package/dist/schema/live-parity.js +600 -0
- package/dist/schema/live-parity.js.map +1 -0
- package/dist/schema/manifest-schema.d.ts +121 -0
- package/dist/schema/manifest-schema.d.ts.map +1 -0
- package/dist/schema/manifest-schema.js +267 -0
- package/dist/schema/manifest-schema.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +24 -10
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +35 -90
- package/dist/schema/schema-aggregator.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +52 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -0
- package/dist/schema/system-table-shapes.js +174 -0
- package/dist/schema/system-table-shapes.js.map +1 -0
- package/dist/schema/types.d.ts +103 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +1 -0
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +3 -1
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +4 -1
- package/dist/smrt-knowledge.json +18 -6
- package/dist/sync/apply.d.ts.map +1 -1
- package/dist/sync/apply.js +9 -16
- package/dist/sync/apply.js.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +1 -0
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +97 -13
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +11 -11
package/dist/sync/apply.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apply.js","names":[],"sources":["../../src/sync/apply.ts"],"sourcesContent":["/**\n * Idempotent sync-apply batch processing (#1759).\n *\n * The single shared write contract for the web offline outbox (#1762) and the\n * KMP mobile write queue (#1739): an ordered batch of client-authored\n * mutations carrying client-generated row UUIDs, applied FIFO through the\n * host's full stack (authentication, tenant isolation, interceptors, writable\n * policy), returning per-item results (`applied` / `conflict` / `rejected`).\n *\n * This module is transport-agnostic: the runtime REST generator\n * (`generators/rest.ts`) and the generated SvelteKit route\n * (`vite-plugin/sync-apply-route.ts`) both delegate here, supplying a\n * {@link SyncApplyHost} that adapts their own collection resolution,\n * authorization, and writable-policy machinery. Keeping the logic here keeps\n * the two generators byte-minimal and the contract single-sourced.\n *\n * Contract documentation: docs/content/architecture/sync-apply-contract.md\n *\n * ## Why replay is idempotent\n *\n * - Row identity is the client-generated UUID (validated server-side), and\n * ONLY that UUID: creates persist via a strict INSERT\n * (`_insertOnly`, never natural-key dedup-upsert), so a create can never\n * adopt or overwrite a different row, and a re-delivered create finds its\n * own row via the id pre-check instead of duplicating it.\n * - Before writing, each create/update is compared against the current row;\n * when the row already reflects the item's payload the write is skipped and\n * reported `applied` (a no-op re-apply). This is what makes N replays of an\n * identical batch yield byte-identical database state (no `updated_at`\n * churn) AND identical per-item results, without a server-side idempotency\n * ledger.\n * - Deletes of rows that are already gone report `applied` consistently.\n *\n * ## Conflict policy v1 (server-authoritative LWW, updated-at guard)\n *\n * The stale guard applies to mutations that would actually change server\n * state: an update/delete whose `baseUpdatedAt` is older than the server\n * row's `updated_at` AND whose effect is not already present is the LOSING\n * write — it is **skipped** (the newer server state wins) and the item is\n * reported `conflict` with the server's current `updatedAt` so the client can\n * rebase. Two deliberate precedences soften \"stale ⇒ conflict\":\n * no-op detection runs first (a stale update whose payload already matches\n * the row is an idempotent replay of an applied write → `applied`), and a\n * delete of a row that is already gone is a no-op → `applied`. Skipping\n * (rather than clobbering) was chosen because it never destroys newer data,\n * and it is replay-stable: a replayed conflicting item reports `conflict`\n * again against unchanged state. Field-level merge is explicitly out of\n * scope for v1.\n */\n\nimport { ValidationError } from '../errors.js';\n\n/**\n * URL segments of the generated batch apply endpoint, relative to the API\n * base path: `POST {basePath}/sync/apply`. The `sync` segment is reserved —\n * a collection named `sync` cannot expose an item route named `apply`.\n */\nexport const SYNC_APPLY_ROUTE_SEGMENTS = ['sync', 'apply'] as const;\n\n/** Maximum items accepted in one batch; larger batches are rejected (400). */\nexport const MAX_SYNC_APPLY_BATCH_SIZE = 1000;\n\n/**\n * Client-generated row ids must be well-formed UUIDs. Same shape the ORM\n * accepts as an id filter in `SmrtCollection.get()`.\n */\nexport const SYNC_APPLY_UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Mutation kinds accepted by the batch apply endpoint. */\nexport type SyncApplyOp = 'create' | 'update' | 'delete';\n\nconst SYNC_APPLY_OPS: readonly SyncApplyOp[] = ['create', 'update', 'delete'];\n\n/** Per-item outcome status. */\nexport type SyncApplyStatus = 'applied' | 'conflict' | 'rejected';\n\n/** Machine-readable reasons for `rejected` items. */\nexport type SyncApplyRejectionReason =\n | 'invalid_item'\n | 'invalid_id'\n | 'invalid_payload'\n | 'unknown_object'\n | 'op_not_allowed'\n | 'auth_required'\n | 'forbidden'\n | 'not_found'\n | 'id_conflict'\n | 'write_failed';\n\n/** Machine-readable reasons for `conflict` items. */\nexport type SyncApplyConflictReason = 'stale_write' | 'create_conflict';\n\nexport type SyncApplyReason =\n | SyncApplyRejectionReason\n | SyncApplyConflictReason;\n\n/**\n * One client-authored mutation in a batch. See the contract doc for field\n * semantics; briefly:\n *\n * - `itemId` — the client's idempotency/correlation handle (the durable queue\n * row id). Echoed back verbatim; results are also positional.\n * - `object` — the collection route segment the model's CRUD routes use\n * (e.g. `products`).\n * - `id` — the client-generated row UUID. This is the ONLY channel for a\n * client-supplied id; payload `id` fields are stripped by the writable\n * policy exactly as on plain CRUD routes (#1540).\n * - `baseUpdatedAt` — the server `updated_at` the client last saw for this\n * row; drives the conflict guard on update/delete. Omit to opt out.\n */\nexport interface SyncApplyItem {\n itemId: string;\n object: string;\n op: SyncApplyOp;\n id: string;\n payload?: Record<string, unknown>;\n baseUpdatedAt?: string;\n}\n\n/** Request body of `POST …/sync/apply`. */\nexport interface SyncApplyBatchRequest {\n items: SyncApplyItem[];\n}\n\n/**\n * Per-item result. `results[i]` always corresponds to `items[i]` (positional);\n * `itemId` is echoed for convenience (`null` when the incoming item was too\n * malformed to carry one). `updatedAt` is the server row's `updated_at` after\n * processing — present on applied creates/updates and on conflicts (so clients\n * can rebase), absent on deletes and rejections.\n */\nexport interface SyncApplyItemResult {\n itemId: string | null;\n id: string | null;\n status: SyncApplyStatus;\n reason?: SyncApplyReason;\n updatedAt?: string;\n}\n\n/** Response body of `POST …/sync/apply` (HTTP 200). */\nexport interface SyncApplyBatchResponse {\n results: SyncApplyItemResult[];\n}\n\n/** Batch-level error body (HTTP 400). */\nexport interface SyncApplyBatchError {\n error: {\n code: 'invalid_batch' | 'batch_too_large';\n message: string;\n };\n}\n\n/** Outcome of {@link processSyncApplyBatch}: HTTP status + JSON body. */\nexport type SyncApplyOutcome =\n | { status: 200; body: SyncApplyBatchResponse }\n | { status: 400; body: SyncApplyBatchError };\n\n/**\n * Minimal structural view of a persisted row as the processor needs it.\n * Matches `SmrtObject` structurally; kept structural so hosts and tests can\n * exercise the processor without importing ORM classes.\n */\nexport interface SyncApplyRowLike {\n id?: string | null;\n updated_at?: Date | string | null;\n save(): Promise<unknown>;\n delete(): Promise<unknown>;\n}\n\n/**\n * Minimal structural view of a collection (matches `SmrtCollection`).\n * `create()` must honor the `_insertOnly` option (strict INSERT — see\n * `SmrtCreateInput`), which the processor sets on every sync create.\n */\nexport interface SyncApplyCollectionLike {\n get(id: string): Promise<SyncApplyRowLike | null>;\n create(options: Record<string, unknown>): Promise<SyncApplyRowLike>;\n}\n\n/** Per-item authorization decision from the host. */\nexport type SyncApplyAuthzDecision = 'ok' | 'auth_required' | 'forbidden';\n\n/**\n * A resolved mutation target: everything the processor needs to apply one\n * item through the host's full stack. Hosts build these from their existing\n * machinery so sync items follow the exact same authorization, action-gating,\n * and mass-assignment rules as the host's plain CRUD routes.\n */\nexport interface SyncApplyTarget {\n /** Registry/class name, for diagnostics. */\n objectName: string;\n /** Collection whose get/create/save/delete run the full interceptor stack. */\n collection: SyncApplyCollectionLike;\n /** Whether the object's API config exposes this op (mirrors CRUD gating). */\n isOpAllowed(op: SyncApplyOp): boolean;\n /** Per-item authorization (mutating semantics, fail-closed). */\n authorize(\n op: SyncApplyOp,\n ): Promise<SyncApplyAuthzDecision> | SyncApplyAuthzDecision;\n /**\n * Mass-assignment guard (#1540): strip server-managed/readonly/disallowed\n * fields from the item payload. The row UUID is injected from the validated\n * envelope `id` AFTER this runs — never from the body.\n */\n prepare(payload: Record<string, unknown>): Record<string, unknown>;\n}\n\n/** Host adapter handed to {@link processSyncApplyBatch}. */\nexport interface SyncApplyHost {\n /**\n * Resolve an item's `object` segment to a target, or `null` when unknown.\n * Called once per item so per-item auth context stays accurate.\n */\n resolveTarget(\n objectSegment: string,\n ): Promise<SyncApplyTarget | null> | SyncApplyTarget | null;\n}\n\n/**\n * Writable-policy helper shared with the generated SvelteKit sync route: the\n * same stripping rules as the generated CRUD handlers (#1540) — server-managed\n * fields, `_`-prefixed keys, `@field({ readonly: true })` fields, and (when\n * configured) intersection with the `api.writable` allowlist.\n */\nexport function applySyncWritablePolicy(\n data: unknown,\n policy: {\n readonlyFields?: readonly string[];\n writableAllowlist?: readonly string[] | null;\n } = {},\n): Record<string, unknown> {\n if (!data || typeof data !== 'object') {\n return {};\n }\n\n const serverManaged = new Set([\n 'id',\n 'tenantId',\n 'tenant_id',\n 'createdAt',\n 'created_at',\n 'updatedAt',\n 'updated_at',\n ]);\n const readonly = new Set(policy.readonlyFields ?? []);\n const writable = policy.writableAllowlist ?? null;\n\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key.startsWith('_')) continue;\n if (serverManaged.has(key)) continue;\n if (readonly.has(key)) continue;\n if (writable && !writable.includes(key)) continue;\n result[key] = value;\n }\n return result;\n}\n\n/**\n * Batch-level validation. Returns the raw items array or a 400-shaped error.\n */\nexport function parseSyncApplyBatch(\n body: unknown,\n): { items: unknown[] } | SyncApplyBatchError {\n if (\n !body ||\n typeof body !== 'object' ||\n Array.isArray(body) ||\n !Array.isArray((body as { items?: unknown }).items)\n ) {\n return {\n error: {\n code: 'invalid_batch',\n message: 'Request body must be an object with an \"items\" array',\n },\n };\n }\n\n const items = (body as { items: unknown[] }).items;\n if (items.length > MAX_SYNC_APPLY_BATCH_SIZE) {\n return {\n error: {\n code: 'batch_too_large',\n message: `Batch exceeds the maximum of ${MAX_SYNC_APPLY_BATCH_SIZE} items`,\n },\n };\n }\n\n return { items };\n}\n\ninterface ValidatedItem {\n ok: true;\n item: SyncApplyItem;\n}\n\ninterface InvalidItem {\n ok: false;\n result: SyncApplyItemResult;\n}\n\n/**\n * Per-item shape validation. Never throws; malformed items become `rejected`\n * results so one bad item cannot fail the batch.\n */\nexport function validateSyncApplyItem(\n raw: unknown,\n): ValidatedItem | InvalidItem {\n const record =\n raw && typeof raw === 'object' && !Array.isArray(raw)\n ? (raw as Record<string, unknown>)\n : null;\n\n const itemId =\n record && typeof record.itemId === 'string' && record.itemId.length > 0\n ? record.itemId\n : null;\n const id = record && typeof record.id === 'string' ? record.id : null;\n\n const reject = (reason: SyncApplyRejectionReason): InvalidItem => ({\n ok: false,\n result: { itemId, id, status: 'rejected', reason },\n });\n\n if (!record || !itemId) {\n return reject('invalid_item');\n }\n\n const op = record.op;\n if (typeof op !== 'string' || !SYNC_APPLY_OPS.includes(op as SyncApplyOp)) {\n return reject('invalid_item');\n }\n\n const object = record.object;\n if (typeof object !== 'string' || object.length === 0) {\n return reject('invalid_item');\n }\n\n const baseUpdatedAt = record.baseUpdatedAt;\n if (baseUpdatedAt !== undefined) {\n if (\n typeof baseUpdatedAt !== 'string' ||\n Number.isNaN(Date.parse(baseUpdatedAt))\n ) {\n return reject('invalid_item');\n }\n }\n\n // Client UUID validation: the row id must be a well-formed UUID.\n if (!id || !SYNC_APPLY_UUID_PATTERN.test(id)) {\n return reject('invalid_id');\n }\n\n const payload = record.payload;\n if (op === 'create' || op === 'update') {\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n return reject('invalid_payload');\n }\n }\n\n return {\n ok: true,\n item: {\n itemId,\n object,\n op: op as SyncApplyOp,\n id,\n payload: payload as Record<string, unknown> | undefined,\n baseUpdatedAt: baseUpdatedAt as string | undefined,\n },\n };\n}\n\nfunction toEpochMillis(value: Date | string | null | undefined): number | null {\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? null : time;\n }\n if (typeof value === 'string') {\n const time = Date.parse(value);\n return Number.isNaN(time) ? null : time;\n }\n return null;\n}\n\n/**\n * Updated-at conflict guard: true when the client's base is strictly older\n * than the server row's `updated_at`. No base (client opted out) or no\n * comparable server timestamp → not stale.\n */\nexport function isStaleWrite(\n baseUpdatedAt: string | undefined,\n serverUpdatedAt: Date | string | null | undefined,\n): boolean {\n const base = toEpochMillis(baseUpdatedAt ?? null);\n if (base === null) return false;\n const server = toEpochMillis(serverUpdatedAt);\n if (server === null) return false;\n return base < server;\n}\n\nfunction normalizeForComparison(value: unknown): unknown {\n if (value instanceof Date) return value.toISOString();\n return value;\n}\n\nfunction valuesEquivalent(rowValue: unknown, payloadValue: unknown): boolean {\n // Datetime fields hydrate as Date; clients send ISO strings (or epoch ms).\n if (rowValue instanceof Date) {\n const candidate =\n typeof payloadValue === 'string' || typeof payloadValue === 'number'\n ? new Date(payloadValue).getTime()\n : payloadValue instanceof Date\n ? payloadValue.getTime()\n : Number.NaN;\n return !Number.isNaN(candidate) && candidate === rowValue.getTime();\n }\n\n if (rowValue === null || rowValue === undefined) {\n return payloadValue === null || payloadValue === undefined;\n }\n\n if (typeof rowValue !== 'object' && typeof payloadValue !== 'object') {\n return rowValue === payloadValue;\n }\n\n try {\n return (\n JSON.stringify(normalizeForComparison(rowValue)) ===\n JSON.stringify(normalizeForComparison(payloadValue))\n );\n } catch {\n return false;\n }\n}\n\n/**\n * No-op detection: true when the row already reflects every (policy-stripped)\n * payload field. Payload keys that are not properties of the hydrated row are\n * ignored — they would never persist, so they must not defeat idempotent\n * replay. This check is what keeps replayed batches from churning\n * `updated_at` (and keeps their per-item results identical).\n */\nexport function payloadMatchesRow(\n payload: Record<string, unknown>,\n row: SyncApplyRowLike,\n): boolean {\n const record = row as unknown as Record<string, unknown>;\n for (const [key, value] of Object.entries(payload)) {\n if (!(key in record)) continue;\n if (!valuesEquivalent(record[key], value)) return false;\n }\n return true;\n}\n\nfunction rowUpdatedAtIso(row: SyncApplyRowLike): string | undefined {\n const value = row.updated_at;\n if (value instanceof Date) return value.toISOString();\n if (typeof value === 'string') {\n const epoch = Date.parse(value);\n return Number.isNaN(epoch) ? value : new Date(epoch).toISOString();\n }\n return undefined;\n}\n\n/**\n * Detect a unique/primary-key constraint failure anywhere in an error's cause\n * chain. `save()` classifies constraint errors into typed `ValidationError`s\n * when the driver message reaches it directly, but adapter layers may wrap\n * the driver error (e.g. \"Failed to upsert record into table …\" with the\n * SQLite/PostgreSQL/DuckDB constraint text nested in `cause`), surfacing as a\n * generic `DatabaseError` instead — so match both, walking the chain. The\n * message patterns mirror `SmrtObject.classifyConstraintError`.\n */\nfunction isUniqueConstraintError(error: unknown): boolean {\n const seen = new Set<unknown>();\n let current: unknown = error;\n while (current instanceof Error && !seen.has(current)) {\n seen.add(current);\n if (\n current instanceof ValidationError &&\n current.code === 'VALIDATION_UNIQUE_CONSTRAINT'\n ) {\n return true;\n }\n if (\n /UNIQUE constraint failed/i.test(current.message) ||\n /violates unique constraint/i.test(current.message) ||\n /violates primary key constraint/i.test(current.message)\n ) {\n return true;\n }\n current = (current as { cause?: unknown }).cause;\n }\n return false;\n}\n\nasync function applyValidatedItem(\n item: SyncApplyItem,\n target: SyncApplyTarget,\n): Promise<SyncApplyItemResult> {\n const base = { itemId: item.itemId, id: item.id } as const;\n\n // Authorization first (mirrors CRUD: the auth middleware runs before the\n // per-action 405 gate), then action gating.\n const decision = await target.authorize(item.op);\n if (decision !== 'ok') {\n return { ...base, status: 'rejected', reason: decision };\n }\n\n if (!target.isOpAllowed(item.op)) {\n return { ...base, status: 'rejected', reason: 'op_not_allowed' };\n }\n\n // Payload passes through the host's writable policy; the row UUID comes\n // from the validated envelope `id`, never from the body — the #1540\n // mass-assignment guard stays intact on this path too.\n const data = item.op === 'delete' ? {} : target.prepare(item.payload ?? {});\n\n // Tenant isolation: collection.get runs the interceptor stack, so rows\n // outside the caller's tenant are simply not visible here.\n const row = await target.collection.get(item.id);\n\n switch (item.op) {\n case 'create': {\n if (row) {\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied create.\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n // The id exists with different content — the server state wins (LWW\n // skip) and the client is told to rebase.\n return {\n ...base,\n status: 'conflict',\n reason: 'create_conflict',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n\n try {\n // Strict insert (`_insertOnly`): row identity is the envelope UUID\n // alone. Without it, `create()` saves new objects with natural-key\n // conflict resolution (slug/context or configured `conflictColumns`,\n // #1472's ingestion dedup), so a fresh-UUID create whose payload\n // derives an existing row's natural key would silently adopt and\n // rewrite that row — violating this contract.\n const created = await target.collection.create({\n ...data,\n id: item.id,\n _insertOnly: true,\n });\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(created),\n };\n } catch (error) {\n // A unique-constraint failure here means the id (or natural key)\n // belongs to a row this caller cannot see (e.g. another tenant's) or\n // a concurrent writer won the race. Never clobber it.\n if (isUniqueConstraintError(error)) {\n return { ...base, status: 'rejected', reason: 'id_conflict' };\n }\n throw error;\n }\n }\n\n case 'update': {\n if (!row) {\n return { ...base, status: 'rejected', reason: 'not_found' };\n }\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied update (checked before the\n // stale guard: our own earlier apply advanced `updated_at`).\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n Object.assign(row, data);\n await row.save();\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n\n case 'delete': {\n if (!row) {\n // Idempotent delete: already gone (or never visible) — reported\n // consistently on every replay.\n return { ...base, status: 'applied' };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n await row.delete();\n return { ...base, status: 'applied' };\n }\n }\n}\n\nasync function processOneItem(\n raw: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyItemResult> {\n const validated = validateSyncApplyItem(raw);\n if (!validated.ok) {\n return validated.result;\n }\n\n const item = validated.item;\n try {\n const target = await host.resolveTarget(item.object);\n if (!target) {\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'unknown_object',\n };\n }\n return await applyValidatedItem(item, target);\n } catch {\n // One item's failure must never fail the batch.\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'write_failed',\n };\n }\n}\n\n/**\n * Process a sync-apply batch: validate the envelope, then apply items FIFO\n * (strictly in order, awaiting each — items may depend on earlier items in\n * the same batch). Always resolves; item-level failures surface as `rejected`\n * results and only envelope-level problems produce a 400.\n */\nexport async function processSyncApplyBatch(\n body: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyOutcome> {\n const parsed = parseSyncApplyBatch(body);\n if ('error' in parsed) {\n return { status: 400, body: parsed };\n }\n\n const results: SyncApplyItemResult[] = [];\n for (const raw of parsed.items) {\n // Sequential on purpose: FIFO within the batch is part of the contract.\n results.push(await processOneItem(raw, host));\n }\n\n return { status: 200, body: { results } };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"apply.js","names":[],"sources":["../../src/sync/apply.ts"],"sourcesContent":["/**\n * Idempotent sync-apply batch processing (#1759).\n *\n * The single shared write contract for the web offline outbox (#1762) and the\n * KMP mobile write queue (#1739): an ordered batch of client-authored\n * mutations carrying client-generated row UUIDs, applied FIFO through the\n * host's full stack (authentication, tenant isolation, interceptors, writable\n * policy), returning per-item results (`applied` / `conflict` / `rejected`).\n *\n * This module is transport-agnostic: the runtime REST generator\n * (`generators/rest.ts`) and the generated SvelteKit route\n * (`vite-plugin/sync-apply-route.ts`) both delegate here, supplying a\n * {@link SyncApplyHost} that adapts their own collection resolution,\n * authorization, and writable-policy machinery. Keeping the logic here keeps\n * the two generators byte-minimal and the contract single-sourced.\n *\n * Contract documentation: docs/content/architecture/sync-apply-contract.md\n *\n * ## Why replay is idempotent\n *\n * - Row identity is the client-generated UUID (validated server-side), and\n * ONLY that UUID: creates persist via a strict INSERT\n * (`_insertOnly`, never natural-key dedup-upsert), so a create can never\n * adopt or overwrite a different row, and a re-delivered create finds its\n * own row via the id pre-check instead of duplicating it.\n * - Before writing, each create/update is compared against the current row;\n * when the row already reflects the item's payload the write is skipped and\n * reported `applied` (a no-op re-apply). This is what makes N replays of an\n * identical batch yield byte-identical database state (no `updated_at`\n * churn) AND identical per-item results, without a server-side idempotency\n * ledger.\n * - Deletes of rows that are already gone report `applied` consistently.\n *\n * ## Conflict policy v1 (server-authoritative LWW, updated-at guard)\n *\n * The stale guard applies to mutations that would actually change server\n * state: an update/delete whose `baseUpdatedAt` is older than the server\n * row's `updated_at` AND whose effect is not already present is the LOSING\n * write — it is **skipped** (the newer server state wins) and the item is\n * reported `conflict` with the server's current `updatedAt` so the client can\n * rebase. Two deliberate precedences soften \"stale ⇒ conflict\":\n * no-op detection runs first (a stale update whose payload already matches\n * the row is an idempotent replay of an applied write → `applied`), and a\n * delete of a row that is already gone is a no-op → `applied`. Skipping\n * (rather than clobbering) was chosen because it never destroys newer data,\n * and it is replay-stable: a replayed conflicting item reports `conflict`\n * again against unchanged state. Field-level merge is explicitly out of\n * scope for v1.\n */\n\nimport { isUniqueViolationError } from '../db-errors.js';\n\n/**\n * URL segments of the generated batch apply endpoint, relative to the API\n * base path: `POST {basePath}/sync/apply`. The `sync` segment is reserved —\n * a collection named `sync` cannot expose an item route named `apply`.\n */\nexport const SYNC_APPLY_ROUTE_SEGMENTS = ['sync', 'apply'] as const;\n\n/** Maximum items accepted in one batch; larger batches are rejected (400). */\nexport const MAX_SYNC_APPLY_BATCH_SIZE = 1000;\n\n/**\n * Client-generated row ids must be well-formed UUIDs. Same shape the ORM\n * accepts as an id filter in `SmrtCollection.get()`.\n */\nexport const SYNC_APPLY_UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Mutation kinds accepted by the batch apply endpoint. */\nexport type SyncApplyOp = 'create' | 'update' | 'delete';\n\nconst SYNC_APPLY_OPS: readonly SyncApplyOp[] = ['create', 'update', 'delete'];\n\n/** Per-item outcome status. */\nexport type SyncApplyStatus = 'applied' | 'conflict' | 'rejected';\n\n/** Machine-readable reasons for `rejected` items. */\nexport type SyncApplyRejectionReason =\n | 'invalid_item'\n | 'invalid_id'\n | 'invalid_payload'\n | 'unknown_object'\n | 'op_not_allowed'\n | 'auth_required'\n | 'forbidden'\n | 'not_found'\n | 'id_conflict'\n | 'write_failed';\n\n/** Machine-readable reasons for `conflict` items. */\nexport type SyncApplyConflictReason = 'stale_write' | 'create_conflict';\n\nexport type SyncApplyReason =\n | SyncApplyRejectionReason\n | SyncApplyConflictReason;\n\n/**\n * One client-authored mutation in a batch. See the contract doc for field\n * semantics; briefly:\n *\n * - `itemId` — the client's idempotency/correlation handle (the durable queue\n * row id). Echoed back verbatim; results are also positional.\n * - `object` — the collection route segment the model's CRUD routes use\n * (e.g. `products`).\n * - `id` — the client-generated row UUID. This is the ONLY channel for a\n * client-supplied id; payload `id` fields are stripped by the writable\n * policy exactly as on plain CRUD routes (#1540).\n * - `baseUpdatedAt` — the server `updated_at` the client last saw for this\n * row; drives the conflict guard on update/delete. Omit to opt out.\n */\nexport interface SyncApplyItem {\n itemId: string;\n object: string;\n op: SyncApplyOp;\n id: string;\n payload?: Record<string, unknown>;\n baseUpdatedAt?: string;\n}\n\n/** Request body of `POST …/sync/apply`. */\nexport interface SyncApplyBatchRequest {\n items: SyncApplyItem[];\n}\n\n/**\n * Per-item result. `results[i]` always corresponds to `items[i]` (positional);\n * `itemId` is echoed for convenience (`null` when the incoming item was too\n * malformed to carry one). `updatedAt` is the server row's `updated_at` after\n * processing — present on applied creates/updates and on conflicts (so clients\n * can rebase), absent on deletes and rejections.\n */\nexport interface SyncApplyItemResult {\n itemId: string | null;\n id: string | null;\n status: SyncApplyStatus;\n reason?: SyncApplyReason;\n updatedAt?: string;\n}\n\n/** Response body of `POST …/sync/apply` (HTTP 200). */\nexport interface SyncApplyBatchResponse {\n results: SyncApplyItemResult[];\n}\n\n/** Batch-level error body (HTTP 400). */\nexport interface SyncApplyBatchError {\n error: {\n code: 'invalid_batch' | 'batch_too_large';\n message: string;\n };\n}\n\n/** Outcome of {@link processSyncApplyBatch}: HTTP status + JSON body. */\nexport type SyncApplyOutcome =\n | { status: 200; body: SyncApplyBatchResponse }\n | { status: 400; body: SyncApplyBatchError };\n\n/**\n * Minimal structural view of a persisted row as the processor needs it.\n * Matches `SmrtObject` structurally; kept structural so hosts and tests can\n * exercise the processor without importing ORM classes.\n */\nexport interface SyncApplyRowLike {\n id?: string | null;\n updated_at?: Date | string | null;\n save(): Promise<unknown>;\n delete(): Promise<unknown>;\n}\n\n/**\n * Minimal structural view of a collection (matches `SmrtCollection`).\n * `create()` must honor the `_insertOnly` option (strict INSERT — see\n * `SmrtCreateInput`), which the processor sets on every sync create.\n */\nexport interface SyncApplyCollectionLike {\n get(id: string): Promise<SyncApplyRowLike | null>;\n create(options: Record<string, unknown>): Promise<SyncApplyRowLike>;\n}\n\n/** Per-item authorization decision from the host. */\nexport type SyncApplyAuthzDecision = 'ok' | 'auth_required' | 'forbidden';\n\n/**\n * A resolved mutation target: everything the processor needs to apply one\n * item through the host's full stack. Hosts build these from their existing\n * machinery so sync items follow the exact same authorization, action-gating,\n * and mass-assignment rules as the host's plain CRUD routes.\n */\nexport interface SyncApplyTarget {\n /** Registry/class name, for diagnostics. */\n objectName: string;\n /** Collection whose get/create/save/delete run the full interceptor stack. */\n collection: SyncApplyCollectionLike;\n /** Whether the object's API config exposes this op (mirrors CRUD gating). */\n isOpAllowed(op: SyncApplyOp): boolean;\n /** Per-item authorization (mutating semantics, fail-closed). */\n authorize(\n op: SyncApplyOp,\n ): Promise<SyncApplyAuthzDecision> | SyncApplyAuthzDecision;\n /**\n * Mass-assignment guard (#1540): strip server-managed/readonly/disallowed\n * fields from the item payload. The row UUID is injected from the validated\n * envelope `id` AFTER this runs — never from the body.\n */\n prepare(payload: Record<string, unknown>): Record<string, unknown>;\n}\n\n/** Host adapter handed to {@link processSyncApplyBatch}. */\nexport interface SyncApplyHost {\n /**\n * Resolve an item's `object` segment to a target, or `null` when unknown.\n * Called once per item so per-item auth context stays accurate.\n */\n resolveTarget(\n objectSegment: string,\n ): Promise<SyncApplyTarget | null> | SyncApplyTarget | null;\n}\n\n/**\n * Writable-policy helper shared with the generated SvelteKit sync route: the\n * same stripping rules as the generated CRUD handlers (#1540) — server-managed\n * fields, `_`-prefixed keys, `@field({ readonly: true })` fields, and (when\n * configured) intersection with the `api.writable` allowlist.\n */\nexport function applySyncWritablePolicy(\n data: unknown,\n policy: {\n readonlyFields?: readonly string[];\n writableAllowlist?: readonly string[] | null;\n } = {},\n): Record<string, unknown> {\n if (!data || typeof data !== 'object') {\n return {};\n }\n\n const serverManaged = new Set([\n 'id',\n 'tenantId',\n 'tenant_id',\n 'createdAt',\n 'created_at',\n 'updatedAt',\n 'updated_at',\n ]);\n const readonly = new Set(policy.readonlyFields ?? []);\n const writable = policy.writableAllowlist ?? null;\n\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key.startsWith('_')) continue;\n if (serverManaged.has(key)) continue;\n if (readonly.has(key)) continue;\n if (writable && !writable.includes(key)) continue;\n result[key] = value;\n }\n return result;\n}\n\n/**\n * Batch-level validation. Returns the raw items array or a 400-shaped error.\n */\nexport function parseSyncApplyBatch(\n body: unknown,\n): { items: unknown[] } | SyncApplyBatchError {\n if (\n !body ||\n typeof body !== 'object' ||\n Array.isArray(body) ||\n !Array.isArray((body as { items?: unknown }).items)\n ) {\n return {\n error: {\n code: 'invalid_batch',\n message: 'Request body must be an object with an \"items\" array',\n },\n };\n }\n\n const items = (body as { items: unknown[] }).items;\n if (items.length > MAX_SYNC_APPLY_BATCH_SIZE) {\n return {\n error: {\n code: 'batch_too_large',\n message: `Batch exceeds the maximum of ${MAX_SYNC_APPLY_BATCH_SIZE} items`,\n },\n };\n }\n\n return { items };\n}\n\ninterface ValidatedItem {\n ok: true;\n item: SyncApplyItem;\n}\n\ninterface InvalidItem {\n ok: false;\n result: SyncApplyItemResult;\n}\n\n/**\n * Per-item shape validation. Never throws; malformed items become `rejected`\n * results so one bad item cannot fail the batch.\n */\nexport function validateSyncApplyItem(\n raw: unknown,\n): ValidatedItem | InvalidItem {\n const record =\n raw && typeof raw === 'object' && !Array.isArray(raw)\n ? (raw as Record<string, unknown>)\n : null;\n\n const itemId =\n record && typeof record.itemId === 'string' && record.itemId.length > 0\n ? record.itemId\n : null;\n const id = record && typeof record.id === 'string' ? record.id : null;\n\n const reject = (reason: SyncApplyRejectionReason): InvalidItem => ({\n ok: false,\n result: { itemId, id, status: 'rejected', reason },\n });\n\n if (!record || !itemId) {\n return reject('invalid_item');\n }\n\n const op = record.op;\n if (typeof op !== 'string' || !SYNC_APPLY_OPS.includes(op as SyncApplyOp)) {\n return reject('invalid_item');\n }\n\n const object = record.object;\n if (typeof object !== 'string' || object.length === 0) {\n return reject('invalid_item');\n }\n\n const baseUpdatedAt = record.baseUpdatedAt;\n if (baseUpdatedAt !== undefined) {\n if (\n typeof baseUpdatedAt !== 'string' ||\n Number.isNaN(Date.parse(baseUpdatedAt))\n ) {\n return reject('invalid_item');\n }\n }\n\n // Client UUID validation: the row id must be a well-formed UUID.\n if (!id || !SYNC_APPLY_UUID_PATTERN.test(id)) {\n return reject('invalid_id');\n }\n\n const payload = record.payload;\n if (op === 'create' || op === 'update') {\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n return reject('invalid_payload');\n }\n }\n\n return {\n ok: true,\n item: {\n itemId,\n object,\n op: op as SyncApplyOp,\n id,\n payload: payload as Record<string, unknown> | undefined,\n baseUpdatedAt: baseUpdatedAt as string | undefined,\n },\n };\n}\n\nfunction toEpochMillis(value: Date | string | null | undefined): number | null {\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? null : time;\n }\n if (typeof value === 'string') {\n const time = Date.parse(value);\n return Number.isNaN(time) ? null : time;\n }\n return null;\n}\n\n/**\n * Updated-at conflict guard: true when the client's base is strictly older\n * than the server row's `updated_at`. No base (client opted out) or no\n * comparable server timestamp → not stale.\n */\nexport function isStaleWrite(\n baseUpdatedAt: string | undefined,\n serverUpdatedAt: Date | string | null | undefined,\n): boolean {\n const base = toEpochMillis(baseUpdatedAt ?? null);\n if (base === null) return false;\n const server = toEpochMillis(serverUpdatedAt);\n if (server === null) return false;\n return base < server;\n}\n\nfunction normalizeForComparison(value: unknown): unknown {\n if (value instanceof Date) return value.toISOString();\n return value;\n}\n\nfunction valuesEquivalent(rowValue: unknown, payloadValue: unknown): boolean {\n // Datetime fields hydrate as Date; clients send ISO strings (or epoch ms).\n if (rowValue instanceof Date) {\n const candidate =\n typeof payloadValue === 'string' || typeof payloadValue === 'number'\n ? new Date(payloadValue).getTime()\n : payloadValue instanceof Date\n ? payloadValue.getTime()\n : Number.NaN;\n return !Number.isNaN(candidate) && candidate === rowValue.getTime();\n }\n\n if (rowValue === null || rowValue === undefined) {\n return payloadValue === null || payloadValue === undefined;\n }\n\n if (typeof rowValue !== 'object' && typeof payloadValue !== 'object') {\n return rowValue === payloadValue;\n }\n\n try {\n return (\n JSON.stringify(normalizeForComparison(rowValue)) ===\n JSON.stringify(normalizeForComparison(payloadValue))\n );\n } catch {\n return false;\n }\n}\n\n/**\n * No-op detection: true when the row already reflects every (policy-stripped)\n * payload field. Payload keys that are not properties of the hydrated row are\n * ignored — they would never persist, so they must not defeat idempotent\n * replay. This check is what keeps replayed batches from churning\n * `updated_at` (and keeps their per-item results identical).\n */\nexport function payloadMatchesRow(\n payload: Record<string, unknown>,\n row: SyncApplyRowLike,\n): boolean {\n const record = row as unknown as Record<string, unknown>;\n for (const [key, value] of Object.entries(payload)) {\n if (!(key in record)) continue;\n if (!valuesEquivalent(record[key], value)) return false;\n }\n return true;\n}\n\nfunction rowUpdatedAtIso(row: SyncApplyRowLike): string | undefined {\n const value = row.updated_at;\n if (value instanceof Date) return value.toISOString();\n if (typeof value === 'string') {\n const epoch = Date.parse(value);\n return Number.isNaN(epoch) ? value : new Date(epoch).toISOString();\n }\n return undefined;\n}\n\n/**\n * Detect a unique/primary-key constraint failure anywhere in an error's cause\n * chain.\n *\n * Reduced to the framework contract in #2366: `save()` now classifies through\n * the driver-error cause chain, so this reports `true` for both the typed\n * `ValidationError` it raises and any still-wrapped adapter error that reaches\n * here by another path. The private chain walk and duplicated dialect regexes\n * this replaced are gone — `classifyDatabaseError()` is the single matcher.\n */\nfunction isUniqueConstraintError(error: unknown): boolean {\n return isUniqueViolationError(error);\n}\n\nasync function applyValidatedItem(\n item: SyncApplyItem,\n target: SyncApplyTarget,\n): Promise<SyncApplyItemResult> {\n const base = { itemId: item.itemId, id: item.id } as const;\n\n // Authorization first (mirrors CRUD: the auth middleware runs before the\n // per-action 405 gate), then action gating.\n const decision = await target.authorize(item.op);\n if (decision !== 'ok') {\n return { ...base, status: 'rejected', reason: decision };\n }\n\n if (!target.isOpAllowed(item.op)) {\n return { ...base, status: 'rejected', reason: 'op_not_allowed' };\n }\n\n // Payload passes through the host's writable policy; the row UUID comes\n // from the validated envelope `id`, never from the body — the #1540\n // mass-assignment guard stays intact on this path too.\n const data = item.op === 'delete' ? {} : target.prepare(item.payload ?? {});\n\n // Tenant isolation: collection.get runs the interceptor stack, so rows\n // outside the caller's tenant are simply not visible here.\n const row = await target.collection.get(item.id);\n\n switch (item.op) {\n case 'create': {\n if (row) {\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied create.\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n // The id exists with different content — the server state wins (LWW\n // skip) and the client is told to rebase.\n return {\n ...base,\n status: 'conflict',\n reason: 'create_conflict',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n\n try {\n // Strict insert (`_insertOnly`): row identity is the envelope UUID\n // alone. Without it, `create()` saves new objects with natural-key\n // conflict resolution (slug/context or configured `conflictColumns`,\n // #1472's ingestion dedup), so a fresh-UUID create whose payload\n // derives an existing row's natural key would silently adopt and\n // rewrite that row — violating this contract.\n const created = await target.collection.create({\n ...data,\n id: item.id,\n _insertOnly: true,\n });\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(created),\n };\n } catch (error) {\n // A unique-constraint failure here means the id (or natural key)\n // belongs to a row this caller cannot see (e.g. another tenant's) or\n // a concurrent writer won the race. Never clobber it.\n if (isUniqueConstraintError(error)) {\n return { ...base, status: 'rejected', reason: 'id_conflict' };\n }\n throw error;\n }\n }\n\n case 'update': {\n if (!row) {\n return { ...base, status: 'rejected', reason: 'not_found' };\n }\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied update (checked before the\n // stale guard: our own earlier apply advanced `updated_at`).\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n Object.assign(row, data);\n await row.save();\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n\n case 'delete': {\n if (!row) {\n // Idempotent delete: already gone (or never visible) — reported\n // consistently on every replay.\n return { ...base, status: 'applied' };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n await row.delete();\n return { ...base, status: 'applied' };\n }\n }\n}\n\nasync function processOneItem(\n raw: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyItemResult> {\n const validated = validateSyncApplyItem(raw);\n if (!validated.ok) {\n return validated.result;\n }\n\n const item = validated.item;\n try {\n const target = await host.resolveTarget(item.object);\n if (!target) {\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'unknown_object',\n };\n }\n return await applyValidatedItem(item, target);\n } catch {\n // One item's failure must never fail the batch.\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'write_failed',\n };\n }\n}\n\n/**\n * Process a sync-apply batch: validate the envelope, then apply items FIFO\n * (strictly in order, awaiting each — items may depend on earlier items in\n * the same batch). Always resolves; item-level failures surface as `rejected`\n * results and only envelope-level problems produce a 400.\n */\nexport async function processSyncApplyBatch(\n body: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyOutcome> {\n const parsed = parseSyncApplyBatch(body);\n if ('error' in parsed) {\n return { status: 400, body: parsed };\n }\n\n const results: SyncApplyItemResult[] = [];\n for (const raw of parsed.items) {\n // Sequential on purpose: FIFO within the batch is part of the contract.\n results.push(await processOneItem(raw, host));\n }\n\n return { status: 200, body: { results } };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/testing/database.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAkJ5D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;IAEpC;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAEnB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAuCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,iBAAiB,CAAC,
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/testing/database.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAkJ5D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;IAEpC;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;OAGG;IACH,EAAE,CAAC,EAAE,iBAAiB,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAEnB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAuCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,iBAAiB,CAAC,CA4H5B"}
|
package/dist/testing/database.js
CHANGED
|
@@ -138,6 +138,7 @@ async function getTestDatabase(options = {}) {
|
|
|
138
138
|
const runtimeSchemaConfig = {
|
|
139
139
|
conflictColumns: ObjectRegistry.getConflictColumns(className),
|
|
140
140
|
idType: registered?.config.idType,
|
|
141
|
+
indexes: registered?.config.indexes,
|
|
141
142
|
registry: ObjectRegistry
|
|
142
143
|
};
|
|
143
144
|
const schema = strategy === "sti" ? await schemaGenerator.generateSTISchemaFromRegistry(className, tableName, fields, runtimeSchemaConfig) : schemaGenerator.generateSchemaFromRegistry(className, tableName, fields, runtimeSchemaConfig);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.js","names":[],"sources":["../../src/testing/database.ts"],"sourcesContent":["/**\n * Test database utilities for SMRT framework\n *\n * Provides `getTestDatabase()` which creates an in-memory database with all\n * registered SMRT object schemas. Uses the same schema generation logic as\n * the migration system to ensure consistency between test and production.\n *\n * @example\n * ```typescript\n * import { getTestDatabase } from '@happyvertical/smrt-core/testing';\n *\n * beforeEach(async () => {\n * const db = await getTestDatabase();\n * collection = await MyCollection.create({ db });\n * });\n * ```\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { ensurePostgresChangeFeedAppendFunction } from '../change-feed.js';\nimport {\n type CollectionRegistrationLookup,\n isCollectionRegistration,\n resolveCollectionItemClassName,\n resolveRelatedRegistration,\n} from '../registry/collection-resolution.js';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport { SchemaGenerator } from '../schema/generator.js';\nimport { ensureLegacySystemTableCompatibility } from '../system/compatibility.js';\nimport { getSystemTableDDL } from '../system/schema.js';\n\ntype TestDatabaseConnectionOptions = Parameters<typeof getDatabase>[0] & {\n __smrtSkipVitestSchemaPreparation?: boolean;\n};\n\nfunction resolveSTIBaseRegistration(className: string, stiBaseName: string) {\n const registered = ObjectRegistry.getClass(className);\n\n if (stiBaseName.includes(':')) {\n return ObjectRegistry.getClass(stiBaseName);\n }\n\n if (registered?.packageName) {\n const samePackageBase = ObjectRegistry.getClassInPackage(\n registered.packageName,\n stiBaseName,\n );\n if (samePackageBase) {\n return samePackageBase;\n }\n }\n\n return ObjectRegistry.getClass(stiBaseName);\n}\n\nfunction resolveSTIBaseLookupName(\n className: string,\n stiBaseName: string,\n): string {\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n return stiBase?.qualifiedName || stiBase?.name || stiBaseName;\n}\n\nfunction isSTIChild(className: string): boolean {\n const stiBaseName = ObjectRegistry.getSTIBase(className);\n if (!stiBaseName) {\n return false;\n }\n\n const registered = ObjectRegistry.getClass(className);\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n\n if (registered && stiBase) {\n return registered !== stiBase;\n }\n\n return stiBaseName !== className;\n}\n\ntype RegisteredSchemaClass = NonNullable<\n ReturnType<typeof ObjectRegistry.getClass>\n>;\n\nconst collectionRegistrationLookup: CollectionRegistrationLookup = {\n findClass: (className) => ObjectRegistry.getClass(className),\n findClassInPackage: (packageName, className) =>\n ObjectRegistry.getClassInPackage(packageName, className),\n getInheritanceChain: (className) =>\n ObjectRegistry.getInheritanceChain(className),\n};\n\nfunction resolveCollectionSchemaClassName(\n className: string,\n registered: RegisteredSchemaClass,\n): string {\n const itemClassName = resolveCollectionItemClassName(\n className,\n registered,\n collectionRegistrationLookup,\n );\n if (itemClassName) {\n const itemRegistration = resolveRelatedRegistration(\n itemClassName,\n registered,\n collectionRegistrationLookup,\n );\n const itemLookupName =\n itemRegistration?.qualifiedName ||\n itemRegistration?.name ||\n itemClassName;\n const stiBase = ObjectRegistry.getSTIBase(itemLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(itemLookupName, stiBase)\n : itemLookupName;\n }\n\n const tableName =\n registered.schema?.tableName ||\n registered.config.tableName ||\n ObjectRegistry.getTableName(className);\n if (!tableName) {\n return className;\n }\n\n const collectionPackage = registered.packageName;\n\n for (const candidate of ObjectRegistry.getAllClasses().values()) {\n if (\n candidate === registered ||\n isCollectionRegistration(\n candidate.qualifiedName || candidate.name,\n candidate,\n collectionRegistrationLookup,\n )\n ) {\n continue;\n }\n\n const candidateTableName =\n candidate.schema?.tableName || candidate.config.tableName;\n if (candidateTableName !== tableName) {\n continue;\n }\n\n if (\n collectionPackage &&\n candidate.packageName &&\n candidate.packageName !== collectionPackage\n ) {\n continue;\n }\n\n const candidateLookupName = candidate.qualifiedName || candidate.name;\n const stiBase = ObjectRegistry.getSTIBase(candidateLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(candidateLookupName, stiBase)\n : candidateLookupName;\n }\n\n return className;\n}\n\n/**\n * Options for creating a test database\n */\nexport interface TestDatabaseOptions {\n /**\n * Database type (default: 'sqlite')\n * - 'sqlite': SQLite database\n * - 'json': JSON adapter (stores data as JSON files with DuckDB for querying)\n * - 'duckdb': Native DuckDB database\n */\n type?: 'sqlite' | 'json' | 'duckdb';\n\n /**\n * Database URL (default: ':memory:')\n * Use ':memory:' for in-memory databases (fastest for tests)\n * Or provide a file path for persistent test databases\n */\n url?: string;\n\n /**\n * Pre-existing database to initialize schemas in.\n * If provided, `type` and `url` are ignored.\n */\n db?: DatabaseInterface;\n\n /**\n * Specific classes to setup schemas for.\n * If not provided, sets up schemas for all registered classes.\n */\n classes?: string[];\n\n /**\n * Whether to include system tables (default: true)\n * System tables include _smrt_contexts, _smrt_migrations, etc.\n */\n includeSystemTables?: boolean;\n}\n\nfunction resolveRequestedSchemaClassName(className: string): string {\n const registered = ObjectRegistry.getClass(className);\n if (!registered) {\n return className;\n }\n\n if (\n !isCollectionRegistration(\n className,\n registered,\n collectionRegistrationLookup,\n )\n ) {\n const stiBase = ObjectRegistry.getSTIBase(className);\n return stiBase ? resolveSTIBaseLookupName(className, stiBase) : className;\n }\n\n return resolveCollectionSchemaClassName(className, registered);\n}\n\nfunction resolveRequestedSchemaClassNames(classNames: string[]): string[] {\n const resolved: string[] = [];\n const seen = new Set<string>();\n\n for (const className of classNames) {\n const schemaClassName = resolveRequestedSchemaClassName(className);\n if (seen.has(schemaClassName)) {\n continue;\n }\n\n seen.add(schemaClassName);\n resolved.push(schemaClassName);\n }\n\n return resolved;\n}\n\n/**\n * Creates an in-memory test database with schemas pre-created\n *\n * This utility is designed for testing. It creates an in-memory database\n * and initializes all registered SMRT object schemas using the **same\n * schema generation logic** as the production migration system.\n *\n * **Key features:**\n * - Uses `SchemaGenerator.generateSQL()` - the single source of truth for DDL\n * - Handles STI (Single Table Inheritance) correctly\n * - Creates system tables for framework functionality\n * - Safe for parallel test execution (each call creates isolated instance)\n *\n * @param options - Configuration options\n * @returns Promise resolving to configured DatabaseInterface\n *\n * @example\n * ```typescript\n * // Basic usage - all registered schemas\n * const db = await getTestDatabase();\n *\n * // Specific classes only\n * const db = await getTestDatabase({ classes: ['Council', 'Meeting'] });\n *\n * // JSON adapter instead of SQLite\n * const db = await getTestDatabase({ type: 'json' });\n *\n * // Native DuckDB adapter\n * const db = await getTestDatabase({ type: 'duckdb', url: ':memory:' });\n *\n * // File-based database for persistent tests\n * const db = await getTestDatabase({ type: 'sqlite', url: '/tmp/test.db' });\n *\n * // Initialize schemas in an existing database\n * const myDb = await getDatabase({ type: 'sqlite', url: ':memory:' });\n * await getTestDatabase({ db: myDb });\n *\n * // Skip system tables (rare use case)\n * const db = await getTestDatabase({ includeSystemTables: false });\n * ```\n */\nexport async function getTestDatabase(\n options: TestDatabaseOptions = {},\n): Promise<DatabaseInterface> {\n const {\n type = 'sqlite',\n url = ':memory:',\n db: existingDb,\n classes,\n includeSystemTables = true,\n } = options;\n\n // Use existing database or create new one\n const db =\n existingDb ??\n (await getDatabase({\n type,\n url,\n __smrtSkipVitestSchemaPreparation: true,\n } as TestDatabaseConnectionOptions));\n\n // Initialize system tables (same as production)\n if (includeSystemTables) {\n await initializeSystemTables(db);\n }\n\n // Get class names to setup\n const classNames = resolveRequestedSchemaClassNames(\n classes ?? ObjectRegistry.getQualifiedClassNames(),\n );\n\n // Skip if no classes registered\n if (classNames.length === 0) {\n return db;\n }\n\n // Use the same schema generation as production\n const schemaGenerator = new SchemaGenerator();\n const ddlEngine =\n type === 'json' ||\n typeof (db as { exportTable?: unknown }).exportTable === 'function'\n ? 'json'\n : type === 'duckdb'\n ? 'duckdb'\n : 'sqlite';\n\n // Track created tables to avoid duplicates (STI base classes)\n const createdTables = new Set<string>();\n\n for (const className of classNames) {\n // R11: the registration carries idType (native uuid vs text); read it\n // below when building runtimeSchemaConfig.\n const registered = ObjectRegistry.getClass(className);\n // Skip STI children - their schema is part of the base class table.\n // main (#1324): isSTIChild() compares RegisteredClass *identity* rather\n // than raw strings, so it stays correct under R5-canon (getSTIBase returns\n // qualified names) while also handling collection/override registrations.\n if (isSTIChild(className)) {\n continue;\n }\n\n const tableName = ObjectRegistry.getTableName(className);\n if (!tableName || createdTables.has(tableName)) {\n continue;\n }\n\n const fields = await ObjectRegistry.getAllFields(className);\n const strategy = ObjectRegistry.getTableStrategy(className);\n const runtimeSchemaConfig = {\n conflictColumns: ObjectRegistry.getConflictColumns(className),\n idType: registered?.config.idType,\n registry: ObjectRegistry,\n };\n\n // Generate schema using SchemaGenerator (same as migrations)\n const schema =\n strategy === 'sti'\n ? await schemaGenerator.generateSTISchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n )\n : schemaGenerator.generateSchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n );\n\n // Generate DDL using generateSQL() - the single source of truth\n const ddl = schemaGenerator.generateSQL(schema, ddlEngine);\n\n try {\n await db.query(ddl);\n createdTables.add(tableName);\n\n // Create indexes (use DDL strategy so jsonPath / where / etc. render)\n const ddlStrategy = (\n await import('../schema/ddl/index.js')\n ).getDDLStrategy(ddlEngine);\n const indexStatements = ddlStrategy.generateIndexes(schema);\n for (const indexSQL of indexStatements) {\n try {\n await db.query(indexSQL);\n } catch (indexError) {\n // Log but don't fail on index creation errors\n // Some indexes may fail if columns don't exist (STI meta fields)\n console.warn(\n `[getTestDatabase] Warning: Failed to create index: ${indexError instanceof Error ? indexError.message : String(indexError)} (SQL: ${indexSQL})`,\n );\n }\n }\n } catch (error) {\n // Provide helpful error message for table creation failures\n throw new Error(\n `[getTestDatabase] Failed to create table '${tableName}' for class '${className}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n }\n\n return db;\n}\n\n/**\n * Initialize SMRT system tables in a database\n *\n * System tables use _smrt_ prefix and store framework metadata.\n * All statements use `IF NOT EXISTS` for idempotency.\n *\n * @param db - Database interface to initialize\n */\nasync function initializeSystemTables(db: DatabaseInterface): Promise<void> {\n await ensureLegacySystemTableCompatibility(db);\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n };\n const engine = detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n\n // Split multi-statement SQL into individual statements\n const allStatements: string[] = [];\n for (const multiStatementSQL of getSystemTableDDL(engine)) {\n const statements = multiStatementSQL\n .split(';')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n allStatements.push(...statements);\n }\n\n // Use db.query() — system tables use CREATE TABLE/INDEX IF NOT EXISTS\n // which databases handle natively without per-column existence checks.\n for (const statement of allStatements) {\n await db.query(statement);\n }\n\n // PostgreSQL keeps the best-effort append boundary in a function that must\n // be executed whole rather than included in semicolon-split portable DDL.\n await ensurePostgresChangeFeedAppendFunction(db);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"database.js","names":[],"sources":["../../src/testing/database.ts"],"sourcesContent":["/**\n * Test database utilities for SMRT framework\n *\n * Provides `getTestDatabase()` which creates an in-memory database with all\n * registered SMRT object schemas. Uses the same schema generation logic as\n * the migration system to ensure consistency between test and production.\n *\n * @example\n * ```typescript\n * import { getTestDatabase } from '@happyvertical/smrt-core/testing';\n *\n * beforeEach(async () => {\n * const db = await getTestDatabase();\n * collection = await MyCollection.create({ db });\n * });\n * ```\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { ensurePostgresChangeFeedAppendFunction } from '../change-feed.js';\nimport {\n type CollectionRegistrationLookup,\n isCollectionRegistration,\n resolveCollectionItemClassName,\n resolveRelatedRegistration,\n} from '../registry/collection-resolution.js';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport { SchemaGenerator } from '../schema/generator.js';\nimport { ensureLegacySystemTableCompatibility } from '../system/compatibility.js';\nimport { getSystemTableDDL } from '../system/schema.js';\n\ntype TestDatabaseConnectionOptions = Parameters<typeof getDatabase>[0] & {\n __smrtSkipVitestSchemaPreparation?: boolean;\n};\n\nfunction resolveSTIBaseRegistration(className: string, stiBaseName: string) {\n const registered = ObjectRegistry.getClass(className);\n\n if (stiBaseName.includes(':')) {\n return ObjectRegistry.getClass(stiBaseName);\n }\n\n if (registered?.packageName) {\n const samePackageBase = ObjectRegistry.getClassInPackage(\n registered.packageName,\n stiBaseName,\n );\n if (samePackageBase) {\n return samePackageBase;\n }\n }\n\n return ObjectRegistry.getClass(stiBaseName);\n}\n\nfunction resolveSTIBaseLookupName(\n className: string,\n stiBaseName: string,\n): string {\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n return stiBase?.qualifiedName || stiBase?.name || stiBaseName;\n}\n\nfunction isSTIChild(className: string): boolean {\n const stiBaseName = ObjectRegistry.getSTIBase(className);\n if (!stiBaseName) {\n return false;\n }\n\n const registered = ObjectRegistry.getClass(className);\n const stiBase = resolveSTIBaseRegistration(className, stiBaseName);\n\n if (registered && stiBase) {\n return registered !== stiBase;\n }\n\n return stiBaseName !== className;\n}\n\ntype RegisteredSchemaClass = NonNullable<\n ReturnType<typeof ObjectRegistry.getClass>\n>;\n\nconst collectionRegistrationLookup: CollectionRegistrationLookup = {\n findClass: (className) => ObjectRegistry.getClass(className),\n findClassInPackage: (packageName, className) =>\n ObjectRegistry.getClassInPackage(packageName, className),\n getInheritanceChain: (className) =>\n ObjectRegistry.getInheritanceChain(className),\n};\n\nfunction resolveCollectionSchemaClassName(\n className: string,\n registered: RegisteredSchemaClass,\n): string {\n const itemClassName = resolveCollectionItemClassName(\n className,\n registered,\n collectionRegistrationLookup,\n );\n if (itemClassName) {\n const itemRegistration = resolveRelatedRegistration(\n itemClassName,\n registered,\n collectionRegistrationLookup,\n );\n const itemLookupName =\n itemRegistration?.qualifiedName ||\n itemRegistration?.name ||\n itemClassName;\n const stiBase = ObjectRegistry.getSTIBase(itemLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(itemLookupName, stiBase)\n : itemLookupName;\n }\n\n const tableName =\n registered.schema?.tableName ||\n registered.config.tableName ||\n ObjectRegistry.getTableName(className);\n if (!tableName) {\n return className;\n }\n\n const collectionPackage = registered.packageName;\n\n for (const candidate of ObjectRegistry.getAllClasses().values()) {\n if (\n candidate === registered ||\n isCollectionRegistration(\n candidate.qualifiedName || candidate.name,\n candidate,\n collectionRegistrationLookup,\n )\n ) {\n continue;\n }\n\n const candidateTableName =\n candidate.schema?.tableName || candidate.config.tableName;\n if (candidateTableName !== tableName) {\n continue;\n }\n\n if (\n collectionPackage &&\n candidate.packageName &&\n candidate.packageName !== collectionPackage\n ) {\n continue;\n }\n\n const candidateLookupName = candidate.qualifiedName || candidate.name;\n const stiBase = ObjectRegistry.getSTIBase(candidateLookupName);\n return stiBase\n ? resolveSTIBaseLookupName(candidateLookupName, stiBase)\n : candidateLookupName;\n }\n\n return className;\n}\n\n/**\n * Options for creating a test database\n */\nexport interface TestDatabaseOptions {\n /**\n * Database type (default: 'sqlite')\n * - 'sqlite': SQLite database\n * - 'json': JSON adapter (stores data as JSON files with DuckDB for querying)\n * - 'duckdb': Native DuckDB database\n */\n type?: 'sqlite' | 'json' | 'duckdb';\n\n /**\n * Database URL (default: ':memory:')\n * Use ':memory:' for in-memory databases (fastest for tests)\n * Or provide a file path for persistent test databases\n */\n url?: string;\n\n /**\n * Pre-existing database to initialize schemas in.\n * If provided, `type` and `url` are ignored.\n */\n db?: DatabaseInterface;\n\n /**\n * Specific classes to setup schemas for.\n * If not provided, sets up schemas for all registered classes.\n */\n classes?: string[];\n\n /**\n * Whether to include system tables (default: true)\n * System tables include _smrt_contexts, _smrt_migrations, etc.\n */\n includeSystemTables?: boolean;\n}\n\nfunction resolveRequestedSchemaClassName(className: string): string {\n const registered = ObjectRegistry.getClass(className);\n if (!registered) {\n return className;\n }\n\n if (\n !isCollectionRegistration(\n className,\n registered,\n collectionRegistrationLookup,\n )\n ) {\n const stiBase = ObjectRegistry.getSTIBase(className);\n return stiBase ? resolveSTIBaseLookupName(className, stiBase) : className;\n }\n\n return resolveCollectionSchemaClassName(className, registered);\n}\n\nfunction resolveRequestedSchemaClassNames(classNames: string[]): string[] {\n const resolved: string[] = [];\n const seen = new Set<string>();\n\n for (const className of classNames) {\n const schemaClassName = resolveRequestedSchemaClassName(className);\n if (seen.has(schemaClassName)) {\n continue;\n }\n\n seen.add(schemaClassName);\n resolved.push(schemaClassName);\n }\n\n return resolved;\n}\n\n/**\n * Creates an in-memory test database with schemas pre-created\n *\n * This utility is designed for testing. It creates an in-memory database\n * and initializes all registered SMRT object schemas using the **same\n * schema generation logic** as the production migration system.\n *\n * **Key features:**\n * - Uses `SchemaGenerator.generateSQL()` - the single source of truth for DDL\n * - Handles STI (Single Table Inheritance) correctly\n * - Creates system tables for framework functionality\n * - Safe for parallel test execution (each call creates isolated instance)\n *\n * @param options - Configuration options\n * @returns Promise resolving to configured DatabaseInterface\n *\n * @example\n * ```typescript\n * // Basic usage - all registered schemas\n * const db = await getTestDatabase();\n *\n * // Specific classes only\n * const db = await getTestDatabase({ classes: ['Council', 'Meeting'] });\n *\n * // JSON adapter instead of SQLite\n * const db = await getTestDatabase({ type: 'json' });\n *\n * // Native DuckDB adapter\n * const db = await getTestDatabase({ type: 'duckdb', url: ':memory:' });\n *\n * // File-based database for persistent tests\n * const db = await getTestDatabase({ type: 'sqlite', url: '/tmp/test.db' });\n *\n * // Initialize schemas in an existing database\n * const myDb = await getDatabase({ type: 'sqlite', url: ':memory:' });\n * await getTestDatabase({ db: myDb });\n *\n * // Skip system tables (rare use case)\n * const db = await getTestDatabase({ includeSystemTables: false });\n * ```\n */\nexport async function getTestDatabase(\n options: TestDatabaseOptions = {},\n): Promise<DatabaseInterface> {\n const {\n type = 'sqlite',\n url = ':memory:',\n db: existingDb,\n classes,\n includeSystemTables = true,\n } = options;\n\n // Use existing database or create new one\n const db =\n existingDb ??\n (await getDatabase({\n type,\n url,\n __smrtSkipVitestSchemaPreparation: true,\n } as TestDatabaseConnectionOptions));\n\n // Initialize system tables (same as production)\n if (includeSystemTables) {\n await initializeSystemTables(db);\n }\n\n // Get class names to setup\n const classNames = resolveRequestedSchemaClassNames(\n classes ?? ObjectRegistry.getQualifiedClassNames(),\n );\n\n // Skip if no classes registered\n if (classNames.length === 0) {\n return db;\n }\n\n // Use the same schema generation as production\n const schemaGenerator = new SchemaGenerator();\n const ddlEngine =\n type === 'json' ||\n typeof (db as { exportTable?: unknown }).exportTable === 'function'\n ? 'json'\n : type === 'duckdb'\n ? 'duckdb'\n : 'sqlite';\n\n // Track created tables to avoid duplicates (STI base classes)\n const createdTables = new Set<string>();\n\n for (const className of classNames) {\n // R11: the registration carries idType (native uuid vs text); read it\n // below when building runtimeSchemaConfig.\n const registered = ObjectRegistry.getClass(className);\n // Skip STI children - their schema is part of the base class table.\n // main (#1324): isSTIChild() compares RegisteredClass *identity* rather\n // than raw strings, so it stays correct under R5-canon (getSTIBase returns\n // qualified names) while also handling collection/override registrations.\n if (isSTIChild(className)) {\n continue;\n }\n\n const tableName = ObjectRegistry.getTableName(className);\n if (!tableName || createdTables.has(tableName)) {\n continue;\n }\n\n const fields = await ObjectRegistry.getAllFields(className);\n const strategy = ObjectRegistry.getTableStrategy(className);\n // Mirrors `schema/utils.ts`: this bag is rebuilt by hand, so every `@smrt()`\n // option the generator reads has to be listed or tests silently diverge\n // from the production schema. `indexes` (#2357) was missing from both.\n const runtimeSchemaConfig = {\n conflictColumns: ObjectRegistry.getConflictColumns(className),\n idType: registered?.config.idType,\n indexes: registered?.config.indexes,\n registry: ObjectRegistry,\n };\n\n // Generate schema using SchemaGenerator (same as migrations)\n const schema =\n strategy === 'sti'\n ? await schemaGenerator.generateSTISchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n )\n : schemaGenerator.generateSchemaFromRegistry(\n className,\n tableName,\n fields,\n runtimeSchemaConfig,\n );\n\n // Generate DDL using generateSQL() - the single source of truth\n const ddl = schemaGenerator.generateSQL(schema, ddlEngine);\n\n try {\n await db.query(ddl);\n createdTables.add(tableName);\n\n // Create indexes (use DDL strategy so jsonPath / where / etc. render)\n const ddlStrategy = (\n await import('../schema/ddl/index.js')\n ).getDDLStrategy(ddlEngine);\n const indexStatements = ddlStrategy.generateIndexes(schema);\n for (const indexSQL of indexStatements) {\n try {\n await db.query(indexSQL);\n } catch (indexError) {\n // Log but don't fail on index creation errors\n // Some indexes may fail if columns don't exist (STI meta fields)\n console.warn(\n `[getTestDatabase] Warning: Failed to create index: ${indexError instanceof Error ? indexError.message : String(indexError)} (SQL: ${indexSQL})`,\n );\n }\n }\n } catch (error) {\n // Provide helpful error message for table creation failures\n throw new Error(\n `[getTestDatabase] Failed to create table '${tableName}' for class '${className}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n }\n\n return db;\n}\n\n/**\n * Initialize SMRT system tables in a database\n *\n * System tables use _smrt_ prefix and store framework metadata.\n * All statements use `IF NOT EXISTS` for idempotency.\n *\n * @param db - Database interface to initialize\n */\nasync function initializeSystemTables(db: DatabaseInterface): Promise<void> {\n await ensureLegacySystemTableCompatibility(db);\n\n const configuredDb = db as DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n };\n const engine = detectEngine(\n db.url || configuredDb.config?.url || '',\n configuredDb.type || configuredDb.config?.type,\n );\n\n // Split multi-statement SQL into individual statements\n const allStatements: string[] = [];\n for (const multiStatementSQL of getSystemTableDDL(engine)) {\n const statements = multiStatementSQL\n .split(';')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n allStatements.push(...statements);\n }\n\n // Use db.query() — system tables use CREATE TABLE/INDEX IF NOT EXISTS\n // which databases handle natively without per-column existence checks.\n for (const statement of allStatements) {\n await db.query(statement);\n }\n\n // PostgreSQL keeps the best-effort append boundary in a function that must\n // be executed whole rather than included in semicolon-split portable DDL.\n await ensurePostgresChangeFeedAppendFunction(db);\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAQvE,OAAO,KAAK,EAGV,aAAa,EAEd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAEV,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAY1B,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACrC;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,EAAE;QACZ,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAC1B,gBAAgB,CAAC,EAAE,OAAO,CAAC;KAC5B,CAAC;CACH;AAYD,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC;IAC7B,MAAM,EAAE,aAAa,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAqxBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD;AA+BD,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,EACjC,SAAS,EAAE,OAAO,EAClB,YAAY,GAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAA;CAAO,EAC5C,YAAY,GAAE,MAAM,GAAG,YAEb,GACT,4BAA4B,CAwB9B;AAwSD;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,IAAI,CAAC,CAuFf;AAy0BD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,qBAAqB,EAChC,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,GAAG,CAAC,MAAM,CAAC,CA6Cb;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,wBAAwB,EAAE,CA+B5B;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,mBAAmB,GAC5B,IAAI,CAkBN"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_LIST_LIMIT, buildDefaultListOrderBy } from "../query-bounds.js";
|
|
1
2
|
import { resolveCustomActionMetadata } from "../generators/custom-action.js";
|
|
2
3
|
import { generateConditionalGetRouteHelper } from "../generators/conditional-get.js";
|
|
3
4
|
import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
|
|
@@ -6,7 +7,7 @@ import { generateEventsRoute } from "./events-route.js";
|
|
|
6
7
|
import { generateSyncApplyRoute } from "./sync-apply-route.js";
|
|
7
8
|
import { computeWebManifestHash, isCollectionManifestClass, resolveCollectionItemObject, resolveCollectionItemTypeName } from "./web-collections.js";
|
|
8
9
|
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join, relative } from "node:path";
|
|
10
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
10
11
|
//#region src/vite-plugin/sveltekit-generator.ts
|
|
11
12
|
/**
|
|
12
13
|
* SvelteKit route auto-generation from SMRT objects
|
|
@@ -410,6 +411,72 @@ function needsRouteTenantContext(objectDef) {
|
|
|
410
411
|
return isTenantScoped(objectDef) || usesPrincipalContext(objectDef);
|
|
411
412
|
}
|
|
412
413
|
/**
|
|
414
|
+
* Emit the page-bounds guard and the deterministic ordering constant for a
|
|
415
|
+
* generated list route (#2367).
|
|
416
|
+
*
|
|
417
|
+
* The route used to parse its own bounds with
|
|
418
|
+
* `Number(url.searchParams.get('limit')) || 50`, which has three faults: it
|
|
419
|
+
* accepts `NaN` from any non-numeric input and binds it (PostgreSQL answers
|
|
420
|
+
* `invalid input syntax for type bigint` — a 500 for a caller typo), it folds a
|
|
421
|
+
* deliberate `limit=0` into 50, and it has no ceiling, so `?limit=100000000` is
|
|
422
|
+
* a full table scan plus a full hydration pass on a public endpoint.
|
|
423
|
+
*
|
|
424
|
+
* The helper is emitted into the route rather than imported from
|
|
425
|
+
* `@happyvertical/smrt-core` so the generated file stays readable on its own and
|
|
426
|
+
* the bound is visible at the call site; the constants come from core so there
|
|
427
|
+
* is still one source of truth.
|
|
428
|
+
*/
|
|
429
|
+
function generateListBoundsHelper(objectDef) {
|
|
430
|
+
const orderBy = resolveDefaultListOrderBy(objectDef);
|
|
431
|
+
return `
|
|
432
|
+
/** Deterministic list ordering (#2367); matching index is #2363. */
|
|
433
|
+
const LIST_ORDER_BY = ${JSON.stringify(orderBy)};
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Parse \`?limit\`/\`?offset\` (#2367): default when absent, 400 when malformed,
|
|
437
|
+
* clamped to ${MAX_LIST_LIMIT} when oversized.
|
|
438
|
+
*/
|
|
439
|
+
function listBounds(url: URL): { limit: number; offset: number } {
|
|
440
|
+
const parse = (name: 'limit' | 'offset', fallback: number, max?: number): number => {
|
|
441
|
+
const raw = url.searchParams.get(name);
|
|
442
|
+
if (raw === null || raw === '') {
|
|
443
|
+
return max === undefined ? fallback : Math.min(fallback, max);
|
|
444
|
+
}
|
|
445
|
+
if (!/^\\d+$/.test(raw.trim())) {
|
|
446
|
+
throw error(400, \`Invalid \${name}: '\${raw}' is not a non-negative integer.\`);
|
|
447
|
+
}
|
|
448
|
+
const value = Number(raw.trim());
|
|
449
|
+
if (!Number.isSafeInteger(value)) {
|
|
450
|
+
throw error(400, \`Invalid \${name}: '\${raw}' is not a non-negative integer.\`);
|
|
451
|
+
}
|
|
452
|
+
return max === undefined ? value : Math.min(value, max);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
limit: parse('limit', 50, ${MAX_LIST_LIMIT}),
|
|
457
|
+
offset: parse('offset', 0),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
`;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Deterministic default ordering for a generated list route (#2367).
|
|
464
|
+
*
|
|
465
|
+
* The list route pages with `LIMIT`/`OFFSET` and previously emitted no
|
|
466
|
+
* `ORDER BY` at all. That is not pagination: without an ordering PostgreSQL may
|
|
467
|
+
* return rows in any order and is free to pick a different one per query, so
|
|
468
|
+
* page 2 can repeat rows from page 1 and skip others entirely — silently, and
|
|
469
|
+
* only under concurrency or a plan change, which is why it survived review.
|
|
470
|
+
*
|
|
471
|
+
* `created_at DESC` alone is not enough (rows created in the same tick tie), so
|
|
472
|
+
* the primary key follows as a total-order tiebreak. The matching index is
|
|
473
|
+
* #2363.
|
|
474
|
+
*/
|
|
475
|
+
function resolveDefaultListOrderBy(objectDef) {
|
|
476
|
+
const customPrimaryKey = Object.entries(objectDef.fields ?? {}).find(([, fieldDef]) => fieldDef?._meta?.primaryKey === true || fieldDef?.primaryKey === true)?.[0];
|
|
477
|
+
return buildDefaultListOrderBy(customPrimaryKey);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
413
480
|
* Emit the tenant-context establishment + fail-closed read-scope helpers for
|
|
414
481
|
* `@TenantScoped` objects (#1540 facet 2; #1782). Generated handlers call
|
|
415
482
|
* `establishTenantContext(locals)` after the auth guard so every query runs
|
|
@@ -801,6 +868,26 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
801
868
|
const consumerRegistrationPath = relative(configDir, join(projectRoot, ".smrt", "register.js")).replace(/\\/g, "/");
|
|
802
869
|
consumerRegistrationImport = `import '${consumerRegistrationPath.startsWith(".") ? consumerRegistrationPath : `./${consumerRegistrationPath}`}';`;
|
|
803
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* Strip machine specifics from an object definition before it is embedded in
|
|
873
|
+
* generated source.
|
|
874
|
+
*
|
|
875
|
+
* `smrt-register.ts` is a tracked file in consumer repositories, and the
|
|
876
|
+
* scanner records `filePath` as an absolute path. Embedding it verbatim wrote
|
|
877
|
+
* the generating machine's home directory into the repository, so the file
|
|
878
|
+
* could never be committed without breaking every other checkout and CI, and
|
|
879
|
+
* every build left a dirty working tree (#2341). The field is only produced
|
|
880
|
+
* at scan time — nothing reads it back through `_manifest` at runtime — so a
|
|
881
|
+
* project-relative path keeps the shape without the machine.
|
|
882
|
+
*/
|
|
883
|
+
function portableObjectManifest(root, objectDef) {
|
|
884
|
+
const { filePath } = objectDef;
|
|
885
|
+
if (typeof filePath !== "string" || !isAbsolute(filePath)) return objectDef;
|
|
886
|
+
return {
|
|
887
|
+
...objectDef,
|
|
888
|
+
filePath: relative(root, filePath).split(sep).join("/")
|
|
889
|
+
};
|
|
890
|
+
}
|
|
804
891
|
const registrationManifests = Object.fromEntries(Object.entries(manifest.objects).flatMap(([className, objectDef]) => {
|
|
805
892
|
if (isCollectionManifestClass(manifest, objectDef)) return [];
|
|
806
893
|
const packageName = getRegistrationPackageName(manifest, objectDef, isLocalObject(projectRoot, objectDef));
|
|
@@ -808,7 +895,7 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
808
895
|
return [[className, {
|
|
809
896
|
...manifest,
|
|
810
897
|
packageName,
|
|
811
|
-
objects: { [className]: objectDef }
|
|
898
|
+
objects: { [className]: portableObjectManifest(projectRoot, objectDef) }
|
|
812
899
|
}]];
|
|
813
900
|
}));
|
|
814
901
|
const registrationManifestLiteral = JSON.stringify(JSON.stringify(registrationManifests));
|
|
@@ -915,12 +1002,10 @@ import './smrt-register.js';
|
|
|
915
1002
|
import { ObjectRegistry } from '@happyvertical/smrt-core';
|
|
916
1003
|
import type { SmrtClassOptions } from '@happyvertical/smrt-core';
|
|
917
1004
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
| undefined;
|
|
923
|
-
}
|
|
1005
|
+
// \`__smrtGetRequestScopedDatabase\` is declared canonically by
|
|
1006
|
+
// @happyvertical/smrt-core; importing its types above is enough to see it.
|
|
1007
|
+
// Do not redeclare it here — duplicate \`declare global\` entries must be
|
|
1008
|
+
// type-identical, and a second declaration broke consumer checks (#2342).
|
|
924
1009
|
|
|
925
1010
|
/**
|
|
926
1011
|
* Per-object configuration overrides
|
|
@@ -1297,7 +1382,7 @@ ${serializerImports ? `${serializerImports}\n` : ""}import { getCollection } fro
|
|
|
1297
1382
|
${hasPost ? "import { normalizeTypedHttpError } from '@happyvertical/smrt-core';\n" : ""}
|
|
1298
1383
|
${modelType.importStatement ? `${modelType.importStatement}\n` : ""}import type { RequestHandler } from './$types';
|
|
1299
1384
|
// Note: ${className} is auto-registered by the Vite plugin scanner
|
|
1300
|
-
${generateAuthGuardHelper(objectDef, manifest)}${needsRouteTenantContext(objectDef) ? generateTenantContextHelper(usesPrincipalContext(objectDef), isTenantScoped(objectDef)) : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}${hasPost ? generateTypedRouteErrorHelper() : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
|
|
1385
|
+
${generateAuthGuardHelper(objectDef, manifest)}${needsRouteTenantContext(objectDef) ? generateTenantContextHelper(usesPrincipalContext(objectDef), isTenantScoped(objectDef)) : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}${hasPost ? generateTypedRouteErrorHelper() : ""}${hasGet ? generateListBoundsHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
|
|
1301
1386
|
tenantScoped: isTenantScoped(objectDef),
|
|
1302
1387
|
permissionScoped: listUsesPermissionScopedBody,
|
|
1303
1388
|
modelName: className,
|
|
@@ -1305,16 +1390,15 @@ ${generateAuthGuardHelper(objectDef, manifest)}${needsRouteTenantContext(objectD
|
|
|
1305
1390
|
manifestHash: webManifestHash
|
|
1306
1391
|
}) : ""}`;
|
|
1307
1392
|
const listAndCount = isTenantScoped(objectDef) ? ` const readScope = tenantReadScope();
|
|
1308
|
-
const items = await collection.list({ limit, offset, where: readScope });
|
|
1309
|
-
const count = await collection.count({ where: readScope });` : ` const items = await collection.list({ limit, offset });
|
|
1393
|
+
const items = await collection.list({ limit, offset, orderBy: LIST_ORDER_BY, where: readScope });
|
|
1394
|
+
const count = await collection.count({ where: readScope });` : ` const items = await collection.list({ limit, offset, orderBy: LIST_ORDER_BY });
|
|
1310
1395
|
const count = await collection.count();`;
|
|
1311
1396
|
const getHandler = hasGet ? `
|
|
1312
1397
|
// List all ${className.toLowerCase()}s
|
|
1313
1398
|
export const GET: RequestHandler = async ({ locals, url, request }) => {
|
|
1314
1399
|
${routeGuardPreamble(objectDef, false)}
|
|
1315
1400
|
const publicJsonOptions = getPublicJsonOptions(locals);
|
|
1316
|
-
const limit =
|
|
1317
|
-
const offset = Number(url.searchParams.get('offset')) || 0;
|
|
1401
|
+
const { limit, offset } = listBounds(url);
|
|
1318
1402
|
|
|
1319
1403
|
${generateCollectionLoad(className, { typeName: modelType.typeName })}
|
|
1320
1404
|
${listUsesSerializer ? `${listAndCount}
|