@happyvertical/smrt-core 0.57.50 → 0.57.52

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.
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-core",
5
- "packageVersion": "0.57.50",
5
+ "packageVersion": "0.57.52",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-core:SmrtClass": {
8
8
  "name": "smrtclass",
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.57.50",
6
+ "packageVersion": "0.57.52",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "a52d4cb04893387c52cc5e5777027cef716ac638cdca98f76331fa8dca0a3c9a",
11
- "packageJson": "56e35993a9c26e94b11ee14182da5483f5e62d98a07d7ecd86a5fa48f844ce7f",
10
+ "manifest": "421e23b358eb4102afab6c71445192b47812f521a0da214ce853f0753176aaa8",
11
+ "packageJson": "fcdf5bcb282b0a2280bf56cef40e459c0b64e1cd307afdf68a2575f994dce915",
12
12
  "agents": "796e85343e7361fcb0874e904e709190189ffae4ca644d90d59b1bd2d1384b40",
13
13
  "moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
@@ -17,7 +17,7 @@
17
17
  "moduleDoc:agents/data-query.md": "726ef89560d4143d03bfc26943a993ecaa0b42b47e64e861d9de08ee17f1981b",
18
18
  "moduleDoc:agents/schema-paths.md": "e45d09450fe1ea680f0810d53aa27044650f3e74e473e813e8d6c0184795345b",
19
19
  "moduleDoc:agents/deployment-phases.md": "e1036073f31d54db95de95e498c74cddce5e47f367d56ccc00aad69f4e5a28e1",
20
- "moduleDoc:agents/change-feed.md": "bdffe7934683dba6c71108ea3c22a2528ef2ab06971119a60696d9cc2979875c",
20
+ "moduleDoc:agents/change-feed.md": "01b371ef4873a88d8d83725df801912a2516cc6efbf0e0cbafe473565abba280",
21
21
  "moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
22
22
  "moduleDoc:agents/generators.md": "e1de88e8486534e0d70ddd578be13a73d4296a5c337ab2db74cd46b565092667",
23
23
  "moduleDoc:agents/build-knowledge.md": "999cd57a28a521f2931d67dfb76eb35386aec6ad21a9642ccf6ba3f80e061961",
@@ -1188,7 +1188,7 @@
1188
1188
  {
1189
1189
  "path": "agents/change-feed.md",
1190
1190
  "module": "change-feed",
1191
- "content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry (a jittered pause of at most 40 ms between attempts, so two tight-loop writers cannot starve one another — #3062) — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- Tenant typing (#3297): on PostgreSQL `_smrt_changes.tenant_id`, `_smrt_changes_pending.tenant_id` and the dispatch tables' `tenant_id` are native `uuid` on fresh installs (TEXT elsewhere, and on existing installs until `smrt db:migrate-uuid` converts them in a maintenance window). The helpers (marker `smrt-change-feed-helpers:v5`) coerce each tenant id to the LIVE column type through `jsonb_populate_record(NULL::<table>, …)`, so one body serves TEXT, uuid and the mixed state mid-conversion, and `_smrt_drain_changes()` returns `drained_tenant_id` as text. v5 bodies also carry the v4 marker so an older process's probe keeps them during a rolling upgrade; `db:migrate-uuid` installs them in its own transaction before converting. Upgrade every writer before converting: an older process that reinstalls its v4 drain cannot return a uuid tenant id. Tenant ids written here must be UUIDs (or NULL); `_smrt_ai_usage.tenant_id` and `_smrt_run_once_claims.tenant_id` stay TEXT (caller-supplied scope strings).\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\nThe PostgreSQL drain also refuses them (#2982). Every drain passes the live\nsensitive set to `_smrt_drain_changes(limit, excluded_tables)`. Under the drain\nlock it deletes staged rows for those tables before allocating any sequence,\nand its batch skips any that commit afterwards. Entries an older replica staged,\nor that were staged before an upgrade, therefore never reach `_smrt_changes`.\nThe single-argument `_smrt_drain_changes(limit)` remains as a compatibility\nentry point for older replicas during a rollout. It delegates with no\nexclusions, which is the old behaviour; its output stays covered by the read,\nsignal and retention refusals above. The bootstrap and raw-handle probes require\nboth overloads, so an upgraded database installs the new one before current code\ncalls it. A newly created two-argument drain inherits the compat drain's\nreconciled EXECUTE grants, so a hardened ACL with no PUBLIC EXECUTE carries\nover. The compat drain has its own exception boundary. The purge is bounded per\npass, like the sequencing batch.\n\nIn a permissions-managed deployment the migration owner installs the helpers\nbefore current replicas boot. Permission plans must come from the current CLI:\nan older planner reports the two-argument overload as an unsupported routine.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nSTI sensitivity is table-wide. Manifest generation and registration refuse a\nhierarchy mixing classes declaring `sensitive: true` with classes omitting it or\ndeclaring false (`CONFIG_STI_MIXED_SENSITIVITY`, #3456). Mark every class sensitive\nor put the sensitive and public models on separate tables. This preserves the\nhistorical-read and live-signal protections without introducing a discriminator\ninto old feed rows. A uniformly sensitive hierarchy declares its shared table;\nthe authoritative write-path check still verifies the instance's actual table.\n\nScope note: the generated `_changes`/`_events` routes — both the SvelteKit\nroutes and the runtime REST generator's — authorize on an authenticated\nprincipal plus tenant scoping (`getTenantScopedChangesSince`, fail-closed) by\ndefault. A consumer-supplied table/row authorization seam beyond that —\n`change-feed-authz.ts` (#3020, below) — is optional and off until registered.\n\n## Consumer table/row authorization seam (#3020)\n\nTenant scoping keeps one tenant from seeing another's rows, but it cannot\nexpress \"table X is readable by some principals of the tenant but not others\"\nor \"principal P only sees their own rows of table X\" (e.g. a bay-tablet\nstation versus the back office). `?tables=` on `_changes` was always a\nclient-chosen filter, never enforcement — a table the client did not ask for\nwas withheld only because it did not ask, not because it could not have.\n\n`src/change-feed-authz.ts` closes that gap with two independent, optional\nhooks, dependency-inverted onto `globalThis` exactly like\n`resolveDispatchTenantScope` (`dispatch/tenant-resolver.ts`), so a consumer\nregisters them once with no change to the generated \"DO NOT EDIT\" route files.\nBoth hooks are evaluated identically by **every** transport — the generated\nSvelteKit `_changes`/`_events` routes AND the runtime REST generator's\n`_changes`/`_events` (`generators/changes-route.ts`, `generators/events-route.ts`)\n— so a consumer serving both cannot register a hook that protects one and\nleaks through the other:\n\n- `setChangeFeedAuthorizer(authorizeChangeFeed)` — table-level.\n `authorizeChangeFeed({ locals, request, tables }) => allowedTables` returns\n the tables the requester may read from the feed at all; the result is\n INTERSECTED with the client's `?tables=` filter (never unioned), so a table\n the hook does not name is never queried, let alone returned.\n- `setChangeFeedEntryVisibility(isChangeFeedEntryVisible)` — row-level, for\n scope narrower than a whole table. Applied per entry, after the table\n filter, before anything is serialized — including a live `_events` signal.\n\n`locals` is SvelteKit's `event.locals`; `request` is SvelteKit's\n`event.request` or, on REST, the `authMiddleware`-processed `Request`. REST\nhas no `locals` concept, so REST routes pass `locals: undefined` and a\nREST-hosting consumer identifies the principal from `request` instead\n(whatever the `authMiddleware` attached — headers, a decoded token, etc.).\nEvery generated route supplies `request`.\n\nBoth hooks are optional and independent; unregistered, every transport\nbehaves exactly as before (tenant-scoped only). A hook that throws or returns\nsomething other than the documented shape fails closed to \"authorizes\nnothing\" for that call — same posture as a throwing `resolveDispatchTenantScope`\nresolver, and the same 200-empty-page shape the sensitivity filter above\nalready gives a request naming only tables it may not see (never a 5xx, which\nwould tell a caller its own hook is broken — indistinguishable from \"denied\"\nfrom the caller's side).\n\nEvery filter narrows the *returned* `changes`; nothing ever touches\n`cursor`/`resyncRequired`/`resyncCursor` — mirrors the sensitivity filter's\n\"filters affect what is returned, never how the cursor advances\" and for the\nsame reason: a client must advance past a denied entry without re-polling it\nforever, and the entry's row id/timing must never be inferable from a stalled\ncursor. Table denial sets `getChangesSince`'s explicit `denyAllTables: true`\noption rather than passing an empty `tables` array, because `getChangesSince`\ntreats an omitted/empty `tables` as \"no filter\" and would otherwise widen the\nread. `denyAllTables` skips the `table_name IN (...)` clause (and the query)\nentirely — an earlier version instead passed a guaranteed-no-match sentinel\ntable name through that clause, which broke on PostgreSQL (it rejects a NUL\nbyte in a text parameter, turning a denied request into a 500 instead of the\nrequired 200 empty page, #3020 P1 follow-up). `denyAllTables` still runs every\nresync/pruned-cursor check unfiltered first, so the real horizon computation\nruns and the cursor still advances — it just matches zero rows.\n`change-feed-authz.ts`'s `isChangeFeedDenyAll()` is the one place that decides\n\"empty allow-list\" (deny all) vs. \"no hook registered\" (no filter); both\n`readAuthorized()` (pull side) and `buildChangeEventStream()`'s catch-up loop\n(push side) call it rather than re-deriving the distinction. With NO hook\nregistered, `resolveAuthorizedChangeFeedTables()` normalizes an explicit empty\nrequested `tables: []` to `undefined` before that decision runs: `?tables=`\nwith nothing in it is documented as equivalent to an omitted filter, and\nwithout a hook there is no authorized answer to narrow anything with — feeding\n`[]` through unchanged would let `isChangeFeedDenyAll()` read it as \"deny\nevery table\" and silently break the promised unchanged default (#3020\nfollow-up). Once a hook IS registered, its own answer is what decides\ndeny-all, exactly as above.\n\nA registered hook's returned table list is also validated for USABLE\nstrings, not just string-typed ones: any entry that is empty or contains a\nNUL byte fails the whole call closed to `[]` (deny all), the same posture as\na non-array or non-string result. This closes the same NUL-byte hazard the\n`denyAllTables` sentinel removal above fixed for a *synthesized* value — here\nthe NUL byte would come from the hook's OWN answer, which otherwise flows\nstraight into the ordinary `table_name IN (...)` clause and 500s on\nPostgreSQL (#3020 Copilot follow-up).\n\n`getAuthorizedChangesSince()` / `getAuthorizedTenantScopedChangesSince()` wrap\n`getChangesSince()` / `getTenantScopedChangesSince()` with both hooks applied;\nBOTH `_changes` routes (SvelteKit's `vite-plugin/changes-route.ts` and REST's\n`generators/changes-route.ts` `handleChangesRoute`) call the tenant-scoped\nvariant, and `buildChangeEventStream()` (`change-signals.md`) — shared by\nBOTH `_events` routes — applies the same hooks to catch-up replay and live\nsignal delivery, resolving the table allow-list ONCE at connection open\n(mirrors captured tenant scope).\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
1191
+ "content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing; they compare JSON values structurally and key-order-insensitively via `src/utils/json-equal.ts`, #4604); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry (a jittered pause of at most 40 ms between attempts, so two tight-loop writers cannot starve one another — #3062) — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- Tenant typing (#3297): on PostgreSQL `_smrt_changes.tenant_id`, `_smrt_changes_pending.tenant_id` and the dispatch tables' `tenant_id` are native `uuid` on fresh installs (TEXT elsewhere, and on existing installs until `smrt db:migrate-uuid` converts them in a maintenance window). The helpers (marker `smrt-change-feed-helpers:v5`) coerce each tenant id to the LIVE column type through `jsonb_populate_record(NULL::<table>, …)`, so one body serves TEXT, uuid and the mixed state mid-conversion, and `_smrt_drain_changes()` returns `drained_tenant_id` as text. v5 bodies also carry the v4 marker so an older process's probe keeps them during a rolling upgrade; `db:migrate-uuid` installs them in its own transaction before converting. Upgrade every writer before converting: an older process that reinstalls its v4 drain cannot return a uuid tenant id. Tenant ids written here must be UUIDs (or NULL); `_smrt_ai_usage.tenant_id` and `_smrt_run_once_claims.tenant_id` stay TEXT (caller-supplied scope strings).\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\nThe PostgreSQL drain also refuses them (#2982). Every drain passes the live\nsensitive set to `_smrt_drain_changes(limit, excluded_tables)`. Under the drain\nlock it deletes staged rows for those tables before allocating any sequence,\nand its batch skips any that commit afterwards. Entries an older replica staged,\nor that were staged before an upgrade, therefore never reach `_smrt_changes`.\nThe single-argument `_smrt_drain_changes(limit)` remains as a compatibility\nentry point for older replicas during a rollout. It delegates with no\nexclusions, which is the old behaviour; its output stays covered by the read,\nsignal and retention refusals above. The bootstrap and raw-handle probes require\nboth overloads, so an upgraded database installs the new one before current code\ncalls it. A newly created two-argument drain inherits the compat drain's\nreconciled EXECUTE grants, so a hardened ACL with no PUBLIC EXECUTE carries\nover. The compat drain has its own exception boundary. The purge is bounded per\npass, like the sequencing batch.\n\nIn a permissions-managed deployment the migration owner installs the helpers\nbefore current replicas boot. Permission plans must come from the current CLI:\nan older planner reports the two-argument overload as an unsupported routine.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nSTI sensitivity is table-wide. Manifest generation and registration refuse a\nhierarchy mixing classes declaring `sensitive: true` with classes omitting it or\ndeclaring false (`CONFIG_STI_MIXED_SENSITIVITY`, #3456). Mark every class sensitive\nor put the sensitive and public models on separate tables. This preserves the\nhistorical-read and live-signal protections without introducing a discriminator\ninto old feed rows. A uniformly sensitive hierarchy declares its shared table;\nthe authoritative write-path check still verifies the instance's actual table.\n\nScope note: the generated `_changes`/`_events` routes — both the SvelteKit\nroutes and the runtime REST generator's — authorize on an authenticated\nprincipal plus tenant scoping (`getTenantScopedChangesSince`, fail-closed) by\ndefault. A consumer-supplied table/row authorization seam beyond that —\n`change-feed-authz.ts` (#3020, below) — is optional and off until registered.\n\n## Consumer table/row authorization seam (#3020)\n\nTenant scoping keeps one tenant from seeing another's rows, but it cannot\nexpress \"table X is readable by some principals of the tenant but not others\"\nor \"principal P only sees their own rows of table X\" (e.g. a bay-tablet\nstation versus the back office). `?tables=` on `_changes` was always a\nclient-chosen filter, never enforcement — a table the client did not ask for\nwas withheld only because it did not ask, not because it could not have.\n\n`src/change-feed-authz.ts` closes that gap with two independent, optional\nhooks, dependency-inverted onto `globalThis` exactly like\n`resolveDispatchTenantScope` (`dispatch/tenant-resolver.ts`), so a consumer\nregisters them once with no change to the generated \"DO NOT EDIT\" route files.\nBoth hooks are evaluated identically by **every** transport — the generated\nSvelteKit `_changes`/`_events` routes AND the runtime REST generator's\n`_changes`/`_events` (`generators/changes-route.ts`, `generators/events-route.ts`)\n— so a consumer serving both cannot register a hook that protects one and\nleaks through the other:\n\n- `setChangeFeedAuthorizer(authorizeChangeFeed)` — table-level.\n `authorizeChangeFeed({ locals, request, tables }) => allowedTables` returns\n the tables the requester may read from the feed at all; the result is\n INTERSECTED with the client's `?tables=` filter (never unioned), so a table\n the hook does not name is never queried, let alone returned.\n- `setChangeFeedEntryVisibility(isChangeFeedEntryVisible)` — row-level, for\n scope narrower than a whole table. Applied per entry, after the table\n filter, before anything is serialized — including a live `_events` signal.\n\n`locals` is SvelteKit's `event.locals`; `request` is SvelteKit's\n`event.request` or, on REST, the `authMiddleware`-processed `Request`. REST\nhas no `locals` concept, so REST routes pass `locals: undefined` and a\nREST-hosting consumer identifies the principal from `request` instead\n(whatever the `authMiddleware` attached — headers, a decoded token, etc.).\nEvery generated route supplies `request`.\n\nBoth hooks are optional and independent; unregistered, every transport\nbehaves exactly as before (tenant-scoped only). A hook that throws or returns\nsomething other than the documented shape fails closed to \"authorizes\nnothing\" for that call — same posture as a throwing `resolveDispatchTenantScope`\nresolver, and the same 200-empty-page shape the sensitivity filter above\nalready gives a request naming only tables it may not see (never a 5xx, which\nwould tell a caller its own hook is broken — indistinguishable from \"denied\"\nfrom the caller's side).\n\nEvery filter narrows the *returned* `changes`; nothing ever touches\n`cursor`/`resyncRequired`/`resyncCursor` — mirrors the sensitivity filter's\n\"filters affect what is returned, never how the cursor advances\" and for the\nsame reason: a client must advance past a denied entry without re-polling it\nforever, and the entry's row id/timing must never be inferable from a stalled\ncursor. Table denial sets `getChangesSince`'s explicit `denyAllTables: true`\noption rather than passing an empty `tables` array, because `getChangesSince`\ntreats an omitted/empty `tables` as \"no filter\" and would otherwise widen the\nread. `denyAllTables` skips the `table_name IN (...)` clause (and the query)\nentirely — an earlier version instead passed a guaranteed-no-match sentinel\ntable name through that clause, which broke on PostgreSQL (it rejects a NUL\nbyte in a text parameter, turning a denied request into a 500 instead of the\nrequired 200 empty page, #3020 P1 follow-up). `denyAllTables` still runs every\nresync/pruned-cursor check unfiltered first, so the real horizon computation\nruns and the cursor still advances — it just matches zero rows.\n`change-feed-authz.ts`'s `isChangeFeedDenyAll()` is the one place that decides\n\"empty allow-list\" (deny all) vs. \"no hook registered\" (no filter); both\n`readAuthorized()` (pull side) and `buildChangeEventStream()`'s catch-up loop\n(push side) call it rather than re-deriving the distinction. With NO hook\nregistered, `resolveAuthorizedChangeFeedTables()` normalizes an explicit empty\nrequested `tables: []` to `undefined` before that decision runs: `?tables=`\nwith nothing in it is documented as equivalent to an omitted filter, and\nwithout a hook there is no authorized answer to narrow anything with — feeding\n`[]` through unchanged would let `isChangeFeedDenyAll()` read it as \"deny\nevery table\" and silently break the promised unchanged default (#3020\nfollow-up). Once a hook IS registered, its own answer is what decides\ndeny-all, exactly as above.\n\nA registered hook's returned table list is also validated for USABLE\nstrings, not just string-typed ones: any entry that is empty or contains a\nNUL byte fails the whole call closed to `[]` (deny all), the same posture as\na non-array or non-string result. This closes the same NUL-byte hazard the\n`denyAllTables` sentinel removal above fixed for a *synthesized* value — here\nthe NUL byte would come from the hook's OWN answer, which otherwise flows\nstraight into the ordinary `table_name IN (...)` clause and 500s on\nPostgreSQL (#3020 Copilot follow-up).\n\n`getAuthorizedChangesSince()` / `getAuthorizedTenantScopedChangesSince()` wrap\n`getChangesSince()` / `getTenantScopedChangesSince()` with both hooks applied;\nBOTH `_changes` routes (SvelteKit's `vite-plugin/changes-route.ts` and REST's\n`generators/changes-route.ts` `handleChangesRoute`) call the tenant-scoped\nvariant, and `buildChangeEventStream()` (`change-signals.md`) — shared by\nBOTH `_events` routes — applies the same hooks to catch-up replay and live\nsignal delivery, resolving the table allow-list ONCE at connection open\n(mirrors captured tenant scope).\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
1192
1192
  },
1193
1193
  {
1194
1194
  "path": "agents/change-signals.md",
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/sync/apply.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/sync/apply.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { isUniqueViolationError } from "../db-errors.js";
2
+ import { jsonValuesEqual } from "../utils/json-equal.js";
2
3
  //#region src/sync/apply.ts
3
4
  /**
4
5
  * Idempotent sync-apply batch processing (#1759).
@@ -177,10 +178,6 @@ function isStaleWrite(baseUpdatedAt, serverUpdatedAt) {
177
178
  if (server === null) return false;
178
179
  return base < server;
179
180
  }
180
- function normalizeForComparison(value) {
181
- if (value instanceof Date) return value.toISOString();
182
- return value;
183
- }
184
181
  function valuesEquivalent(rowValue, payloadValue) {
185
182
  if (rowValue instanceof Date) {
186
183
  const candidate = typeof payloadValue === "string" || typeof payloadValue === "number" ? new Date(payloadValue).getTime() : payloadValue instanceof Date ? payloadValue.getTime() : NaN;
@@ -188,11 +185,7 @@ function valuesEquivalent(rowValue, payloadValue) {
188
185
  }
189
186
  if (rowValue === null || rowValue === void 0) return payloadValue === null || payloadValue === void 0;
190
187
  if (typeof rowValue !== "object" && typeof payloadValue !== "object") return rowValue === payloadValue;
191
- try {
192
- return JSON.stringify(normalizeForComparison(rowValue)) === JSON.stringify(normalizeForComparison(payloadValue));
193
- } catch {
194
- return false;
195
- }
188
+ return jsonValuesEqual(rowValue, payloadValue);
196
189
  }
197
190
  /**
198
191
  * No-op detection: true when the row already reflects every (policy-stripped)
@@ -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 { 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 /** Host-owned transactional mutation seam (including opt-in audit recording). */\n mutate?: (\n row: SyncApplyRowLike,\n op: 'update' | 'delete',\n data: Record<string, unknown>,\n ) => Promise<void>;\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 if (target.mutate) await target.mutate(row, 'update', data);\n else {\n Object.assign(row, data);\n await row.save();\n }\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 if (target.mutate) await target.mutate(row, 'delete', data);\n else 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';\nimport { jsonValuesEqual } from '../utils/json-equal.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 /** Host-owned transactional mutation seam (including opt-in audit recording). */\n mutate?: (\n row: SyncApplyRowLike,\n op: 'update' | 'delete',\n data: Record<string, unknown>,\n ) => Promise<void>;\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 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 // JSON-form structural equality, key-order-insensitive (#4604).\n return jsonValuesEqual(rowValue, payloadValue);\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 if (target.mutate) await target.mutate(row, 'update', data);\n else {\n Object.assign(row, data);\n await row.save();\n }\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 if (target.mutate) await target.mutate(row, 'delete', data);\n else 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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"}
@@ -16,6 +16,7 @@
16
16
  * stored value or a value that cannot be compared is NOT reported unchanged.
17
17
  */
18
18
  import type { SchemaDefinition } from './schema/types.js';
19
+ import { jsonNumbersRoundTrip } from "./utils/json-equal.js";
19
20
  /**
20
21
  * Columns whose assignment DuckDB may treat as an index rewrite.
21
22
  *
@@ -36,16 +37,7 @@ export declare function collectIndexedColumns(schemas: ReadonlyArray<SchemaDefin
36
37
  * which this runtime always writes as UTC).
37
38
  */
38
39
  export declare function timestampMicros(value: unknown, naiveIsUtc?: boolean): bigint | undefined;
39
- /**
40
- * Whether every number literal in JSON text survives `JSON.parse` exactly.
41
- *
42
- * `JSON.parse` reads numbers as doubles, so `9007199254740993` and
43
- * `9007199254740992` parse to the same value. A literal is exact when the
44
- * double it parses to prints back as the same text; anything else (unsafe
45
- * integers, more digits than a double holds, `1.0`, `1e2`) makes the text
46
- * unprovable. Digits inside JSON strings are not numbers and are skipped.
47
- */
48
- export declare function jsonNumbersRoundTrip(text: string): boolean;
40
+ export { jsonNumbersRoundTrip };
49
41
  /**
50
42
  * Whether `next` (the value about to be written) provably equals `stored`
51
43
  * (the canonical value read back from the row). `false` means "assign it":
@@ -1 +1 @@
1
- {"version":3,"file":"unchanged-indexed.d.ts","sourceRoot":"","sources":["../src/unchanged-indexed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAM1D;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,aAAa,CAAC,gBAAgB,GAAG,SAAS,CAAC,EACpD,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,GAClC,GAAG,CAAC,MAAM,CAAC,CAuBb;AAWD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,OAAO,EACd,UAAU,UAAQ,GACjB,MAAM,GAAG,SAAS,CAwCpB;AAID;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAuB1D;AA8BD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,OAAO,EACf,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAwCT;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAAC,GACnE,MAAM,EAAE,CAUV;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAAC,EACpE,eAAe,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAM,GACrD,GAAG,CAAC,MAAM,CAAC,CAkBb;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAC7C;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAgCpC"}
1
+ {"version":3,"file":"unchanged-indexed.d.ts","sourceRoot":"","sources":["../src/unchanged-indexed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAoB,CAAC;AAK1D;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,aAAa,CAAC,gBAAgB,GAAG,SAAS,CAAC,EACpD,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,GAClC,GAAG,CAAC,MAAM,CAAC,CAuBb;AAWD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,OAAO,EACd,UAAU,UAAQ,GACjB,MAAM,GAAG,SAAS,CAwCpB;AAED,OAAO,EAAE,oBAAoB,EAAE,CAAC;AA8BhC;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,OAAO,EACf,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAwCT;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAAC,GACnE,MAAM,EAAE,CAUV;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACzC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAAC,EACpE,eAAe,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAM,GACrD,GAAG,CAAC,MAAM,CAAC,CAkBb;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAC7C;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAgCpC"}
@@ -1,5 +1,6 @@
1
1
  import { toSnakeCase } from "./utils/naming.js";
2
2
  import "./utils.js";
3
+ import { jsonNumbersRoundTrip } from "./utils/json-equal.js";
3
4
  //#region src/unchanged-indexed.ts
4
5
  /** Columns the save path always writes, whatever their stored value. */
5
6
  var ALWAYS_ASSIGNED = /* @__PURE__ */ new Set(["id", "updated_at"]);
@@ -68,40 +69,6 @@ function timestampMicros(value, naiveIsUtc = false) {
68
69
  const micros = BigInt(fraction.slice(0, 6).padEnd(6, "0"));
69
70
  return (BigInt(wall) - BigInt(offsetMinutes) * 60000n) * 1000n + micros;
70
71
  }
71
- var JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
72
- /**
73
- * Whether every number literal in JSON text survives `JSON.parse` exactly.
74
- *
75
- * `JSON.parse` reads numbers as doubles, so `9007199254740993` and
76
- * `9007199254740992` parse to the same value. A literal is exact when the
77
- * double it parses to prints back as the same text; anything else (unsafe
78
- * integers, more digits than a double holds, `1.0`, `1e2`) makes the text
79
- * unprovable. Digits inside JSON strings are not numbers and are skipped.
80
- */
81
- function jsonNumbersRoundTrip(text) {
82
- let inString = false;
83
- for (let index = 0; index < text.length; index += 1) {
84
- const char = text[index];
85
- if (inString) {
86
- if (char === "\\") index += 1;
87
- else if (char === "\"") inString = false;
88
- continue;
89
- }
90
- if (char === "\"") {
91
- inString = true;
92
- continue;
93
- }
94
- if (char === "-" || char >= "0" && char <= "9") {
95
- JSON_NUMBER.lastIndex = index;
96
- const match = JSON_NUMBER.exec(text);
97
- if (!match) return false;
98
- const literal = match[0];
99
- if (JSON.stringify(Number(literal)) !== literal) return false;
100
- index += literal.length - 1;
101
- }
102
- }
103
- return true;
104
- }
105
72
  function canonicalJson(value) {
106
73
  let parsed = value;
107
74
  if (typeof value === "string") {
@@ -1 +1 @@
1
- {"version":3,"file":"unchanged-indexed.js","names":[],"sources":["../src/unchanged-indexed.ts"],"sourcesContent":["/**\n * Unchanged indexed assignments on persisted rows (#3737).\n *\n * DuckDB rewrites a row (delete + insert) when an INDEXED column is assigned,\n * even with its existing value. A parent that a foreign key still references\n * therefore raises a referenced-parent violation for `SET slug = <same slug>`.\n * `SmrtObject.save()` serializes the whole model, so every existing-row write\n * used to assign `slug`, `context`, `tenant_id`, `created_at`, ... unchanged.\n *\n * This module holds the pure decisions: which columns are indexed, whether a\n * pending value provably equals the stored one, and how an assignment value\n * is bound by the generic UPDATE API. Nothing here touches a database, so the\n * save path keeps owning revision checks, tenant isolation and error typing.\n *\n * Everything errs toward \"assign as before\": an unknown column, a missing\n * stored value or a value that cannot be compared is NOT reported unchanged.\n */\n\nimport type { SchemaDefinition } from './schema/types.js';\nimport { toSnakeCase } from './utils';\n\n/** Columns the save path always writes, whatever their stored value. */\nconst ALWAYS_ASSIGNED = new Set(['id', 'updated_at']);\n\n/**\n * Columns whose assignment DuckDB may treat as an index rewrite.\n *\n * The union of what the registry schema declares (primary key, UNIQUE, every\n * declared index including partial and JSON-path ones, foreign keys) and the\n * natural-key / ownership columns the runtime itself keys writes by. Being\n * over-inclusive is harmless — a column is only skipped when it is also proven\n * unchanged — so this never has to be an exact model of the physical DDL.\n */\nexport function collectIndexedColumns(\n schemas: ReadonlyArray<SchemaDefinition | undefined>,\n runtimeKeyColumns: Iterable<string>,\n): Set<string> {\n const indexed = new Set<string>();\n for (const column of runtimeKeyColumns) {\n // Only camelCase field names need mapping; `toSnakeCase` would strip the\n // leading underscore of `_meta_type`.\n indexed.add(/[A-Z]/u.test(column) ? toSnakeCase(column) : column);\n }\n for (const schema of schemas) {\n if (!schema) continue;\n for (const [name, column] of Object.entries(schema.columns ?? {})) {\n if (column.primaryKey || column.unique || column.foreignKey) {\n indexed.add(name);\n }\n }\n for (const index of schema.indexes ?? []) {\n for (const name of index.columns ?? []) indexed.add(name);\n if (index.jsonPath?.column) indexed.add(index.jsonPath.column);\n }\n for (const foreignKey of schema.foreignKeys ?? []) {\n indexed.add(foreignKey.column);\n }\n }\n return indexed;\n}\n\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;\nconst TIMESTAMP_TEXT =\n /^(\\d{4})-(\\d{2})-(\\d{2})[T ](\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d+))?\\s*(Z|[+-]\\d{2}(?::?\\d{2})?)?$/iu;\n\nfunction isNullish(value: unknown): value is null | undefined {\n return value === null || value === undefined;\n}\n\n/**\n * An instant as whole microseconds since the epoch, or `undefined` when it\n * cannot be established exactly.\n *\n * A `Date` holds milliseconds. Text is read at its full written precision;\n * digits beyond microseconds must be zero. Text without a zone is a wall\n * clock in the process zone unless `naiveIsUtc` (DuckDB's `TIMESTAMP` text,\n * which this runtime always writes as UTC).\n */\nexport function timestampMicros(\n value: unknown,\n naiveIsUtc = false,\n): bigint | undefined {\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? undefined : BigInt(time) * 1000n;\n }\n if (typeof value !== 'string') return undefined;\n const match = TIMESTAMP_TEXT.exec(value.trim());\n if (!match) return undefined;\n const [, year, month, day, hour, minute, second, fraction = '', zone] = match;\n if (fraction.length > 6 && /[1-9]/u.test(fraction.slice(6))) return undefined;\n if (zone === undefined && !naiveIsUtc) return undefined;\n // `Date.UTC` maps years 0-99 onto 1900-1999; set the literal year instead.\n const date = new Date(0);\n date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));\n date.setUTCHours(Number(hour), Number(minute), Number(second), 0);\n const wall = date.getTime();\n if (Number.isNaN(wall)) return undefined;\n // Date rolls impossible fields over (month 13, minute 60, Feb 30); a field\n // that does not survive the round trip makes the text unprovable.\n if (\n date.getUTCFullYear() !== Number(year) ||\n date.getUTCMonth() !== Number(month) - 1 ||\n date.getUTCDate() !== Number(day) ||\n date.getUTCHours() !== Number(hour) ||\n date.getUTCMinutes() !== Number(minute) ||\n date.getUTCSeconds() !== Number(second)\n ) {\n return undefined;\n }\n let offsetMinutes = 0;\n if (zone && zone.toUpperCase() !== 'Z') {\n const digits = zone.slice(1).replace(':', '');\n const offsetHours = Number(digits.slice(0, 2));\n const offsetRest = Number(digits.slice(2) || '0');\n if (offsetHours > 23 || offsetRest > 59) return undefined;\n offsetMinutes =\n (zone.startsWith('-') ? -1 : 1) * (offsetHours * 60 + offsetRest);\n }\n const micros = BigInt(fraction.slice(0, 6).padEnd(6, '0'));\n return (BigInt(wall) - BigInt(offsetMinutes) * 60000n) * 1000n + micros;\n}\n\nconst JSON_NUMBER = /-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?/y;\n\n/**\n * Whether every number literal in JSON text survives `JSON.parse` exactly.\n *\n * `JSON.parse` reads numbers as doubles, so `9007199254740993` and\n * `9007199254740992` parse to the same value. A literal is exact when the\n * double it parses to prints back as the same text; anything else (unsafe\n * integers, more digits than a double holds, `1.0`, `1e2`) makes the text\n * unprovable. Digits inside JSON strings are not numbers and are skipped.\n */\nexport function jsonNumbersRoundTrip(text: string): boolean {\n let inString = false;\n for (let index = 0; index < text.length; index += 1) {\n const char = text[index];\n if (inString) {\n if (char === '\\\\') index += 1;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') {\n inString = true;\n continue;\n }\n if (char === '-' || (char >= '0' && char <= '9')) {\n JSON_NUMBER.lastIndex = index;\n const match = JSON_NUMBER.exec(text);\n if (!match) return false;\n const literal = match[0];\n if (JSON.stringify(Number(literal)) !== literal) return false;\n index += literal.length - 1;\n }\n }\n return true;\n}\n\nfunction canonicalJson(value: unknown): string | undefined {\n let parsed = value;\n if (typeof value === 'string') {\n if (!jsonNumbersRoundTrip(value)) return undefined;\n try {\n parsed = JSON.parse(value);\n } catch {\n return undefined;\n }\n }\n const normalize = (node: unknown): unknown => {\n if (Array.isArray(node)) return node.map(normalize);\n if (node !== null && typeof node === 'object') {\n return Object.fromEntries(\n Object.entries(node as Record<string, unknown>)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([key, child]) => [key, normalize(child)]),\n );\n }\n return node;\n };\n try {\n return JSON.stringify(normalize(parsed));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether `next` (the value about to be written) provably equals `stored`\n * (the canonical value read back from the row). `false` means \"assign it\":\n * either it differs or equality cannot be established.\n */\nexport function storedValueEquals(\n columnType: string | undefined,\n next: unknown,\n stored: unknown,\n exactStoredTimestamp?: string,\n): boolean {\n if (isNullish(next) || isNullish(stored)) {\n return isNullish(next) && isNullish(stored);\n }\n const type = (columnType ?? '').toUpperCase();\n if (type === 'TIMESTAMP') {\n // A hydrated `Date` has already lost anything below the millisecond, so\n // only the stored value as the database writes it can prove equality.\n if (exactStoredTimestamp === undefined) return false;\n const a = timestampMicros(next);\n const b = timestampMicros(exactStoredTimestamp, true);\n return a !== undefined && b !== undefined && a === b;\n }\n if (type === 'JSON') {\n const a = canonicalJson(next);\n const b = canonicalJson(stored);\n return a !== undefined && b !== undefined && a === b;\n }\n if (typeof next === 'string' && typeof stored === 'string') {\n if (\n type === 'UUID' &&\n UUID_PATTERN.test(next) &&\n UUID_PATTERN.test(stored)\n ) {\n return next.toLowerCase() === stored.toLowerCase();\n }\n return next === stored;\n }\n if (typeof next === 'boolean' && typeof stored === 'boolean') {\n return next === stored;\n }\n const numeric = (value: unknown): bigint | number | undefined =>\n typeof value === 'bigint' || typeof value === 'number' ? value : undefined;\n const a = numeric(next);\n const b = numeric(stored);\n if (a !== undefined && b !== undefined) {\n // biome-ignore lint/suspicious/noDoubleEquals: bigint/number cross-type value equality is the point.\n return a == b;\n }\n return false;\n}\n\n/**\n * `TIMESTAMP` columns of `data` whose equality needs the stored value at full\n * precision (see {@link storedValueEquals}); the caller reads them as text.\n */\nexport function timestampColumnsToProve(\n data: Readonly<Record<string, unknown>>,\n stored: Readonly<Record<string, unknown>>,\n indexedColumns: ReadonlySet<string>,\n columnTypes: Readonly<Record<string, { type?: string } | undefined>>,\n): string[] {\n return Object.keys(data).filter(\n (column) =>\n !ALWAYS_ASSIGNED.has(column) &&\n indexedColumns.has(column) &&\n Object.hasOwn(stored, column) &&\n !isNullish(data[column]) &&\n !isNullish(stored[column]) &&\n (columnTypes[column]?.type ?? '').toUpperCase() === 'TIMESTAMP',\n );\n}\n\n/**\n * Indexed columns of `data` whose value provably equals the stored row's, in\n * snake_case column names. `id` and `updated_at` are never reported: the\n * revision token always advances, and `id` is the write's key.\n */\nexport function unchangedIndexedColumns(\n data: Readonly<Record<string, unknown>>,\n stored: Readonly<Record<string, unknown>>,\n indexedColumns: ReadonlySet<string>,\n columnTypes: Readonly<Record<string, { type?: string } | undefined>>,\n exactTimestamps: Readonly<Record<string, string>> = {},\n): Set<string> {\n const unchanged = new Set<string>();\n for (const [column, value] of Object.entries(data)) {\n if (ALWAYS_ASSIGNED.has(column) || !indexedColumns.has(column)) continue;\n // A column the stored row does not carry cannot be compared.\n if (!Object.hasOwn(stored, column)) continue;\n if (\n storedValueEquals(\n columnTypes[column]?.type,\n value,\n stored[column],\n exactTimestamps[column],\n )\n ) {\n unchanged.add(column);\n }\n }\n return unchanged;\n}\n\n/**\n * An id-targeted `UPDATE` for native DuckDB, with every identifier quoted and\n * every value bound the way the DuckDB adapter's `upsert()` binds its\n * `DO UPDATE SET` values: `NULL` literals, `''` cast to TEXT, dates as ISO\n * strings, arrays and plain objects as JSON text.\n *\n * The adapter's generic `update()` is not used because it renders column names\n * unquoted (a column named `order` or `group` is a syntax error) and hands\n * dates and structures to the driver untouched; the `upsert()` it replaces\n * handled both.\n */\nexport function buildDuckDbIdUpdate(\n table: string,\n id: unknown,\n assignments: Readonly<Record<string, unknown>>,\n): { sql: string; values: unknown[] } {\n const quote = (identifier: string) => `\"${identifier.replaceAll('\"', '\"\"')}\"`;\n const values: unknown[] = [];\n const sets: string[] = [];\n for (const [column, value] of Object.entries(assignments)) {\n let expression: string;\n if (value === null || value === undefined) {\n expression = 'NULL';\n } else if (value === '') {\n values.push(value);\n expression = 'CAST(? AS TEXT)';\n } else if (value instanceof Date) {\n values.push(value.toISOString());\n expression = '?';\n } else if (\n Array.isArray(value) ||\n (typeof value === 'object' &&\n Object.getPrototypeOf(value) === Object.prototype)\n ) {\n values.push(JSON.stringify(value));\n expression = 'CAST(? AS JSON)';\n } else {\n values.push(value);\n expression = '?';\n }\n sets.push(`${quote(column)} = ${expression}`);\n }\n values.push(id);\n return {\n sql: `UPDATE ${quote(table)} SET ${sets.join(', ')} WHERE ${quote('id')} = ?`,\n values,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"unchanged-indexed.js","names":[],"sources":["../src/unchanged-indexed.ts"],"sourcesContent":["/**\n * Unchanged indexed assignments on persisted rows (#3737).\n *\n * DuckDB rewrites a row (delete + insert) when an INDEXED column is assigned,\n * even with its existing value. A parent that a foreign key still references\n * therefore raises a referenced-parent violation for `SET slug = <same slug>`.\n * `SmrtObject.save()` serializes the whole model, so every existing-row write\n * used to assign `slug`, `context`, `tenant_id`, `created_at`, ... unchanged.\n *\n * This module holds the pure decisions: which columns are indexed, whether a\n * pending value provably equals the stored one, and how an assignment value\n * is bound by the generic UPDATE API. Nothing here touches a database, so the\n * save path keeps owning revision checks, tenant isolation and error typing.\n *\n * Everything errs toward \"assign as before\": an unknown column, a missing\n * stored value or a value that cannot be compared is NOT reported unchanged.\n */\n\nimport type { SchemaDefinition } from './schema/types.js';\nimport { toSnakeCase } from './utils';\nimport { jsonNumbersRoundTrip } from './utils/json-equal';\n\n/** Columns the save path always writes, whatever their stored value. */\nconst ALWAYS_ASSIGNED = new Set(['id', 'updated_at']);\n\n/**\n * Columns whose assignment DuckDB may treat as an index rewrite.\n *\n * The union of what the registry schema declares (primary key, UNIQUE, every\n * declared index including partial and JSON-path ones, foreign keys) and the\n * natural-key / ownership columns the runtime itself keys writes by. Being\n * over-inclusive is harmless — a column is only skipped when it is also proven\n * unchanged — so this never has to be an exact model of the physical DDL.\n */\nexport function collectIndexedColumns(\n schemas: ReadonlyArray<SchemaDefinition | undefined>,\n runtimeKeyColumns: Iterable<string>,\n): Set<string> {\n const indexed = new Set<string>();\n for (const column of runtimeKeyColumns) {\n // Only camelCase field names need mapping; `toSnakeCase` would strip the\n // leading underscore of `_meta_type`.\n indexed.add(/[A-Z]/u.test(column) ? toSnakeCase(column) : column);\n }\n for (const schema of schemas) {\n if (!schema) continue;\n for (const [name, column] of Object.entries(schema.columns ?? {})) {\n if (column.primaryKey || column.unique || column.foreignKey) {\n indexed.add(name);\n }\n }\n for (const index of schema.indexes ?? []) {\n for (const name of index.columns ?? []) indexed.add(name);\n if (index.jsonPath?.column) indexed.add(index.jsonPath.column);\n }\n for (const foreignKey of schema.foreignKeys ?? []) {\n indexed.add(foreignKey.column);\n }\n }\n return indexed;\n}\n\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;\nconst TIMESTAMP_TEXT =\n /^(\\d{4})-(\\d{2})-(\\d{2})[T ](\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d+))?\\s*(Z|[+-]\\d{2}(?::?\\d{2})?)?$/iu;\n\nfunction isNullish(value: unknown): value is null | undefined {\n return value === null || value === undefined;\n}\n\n/**\n * An instant as whole microseconds since the epoch, or `undefined` when it\n * cannot be established exactly.\n *\n * A `Date` holds milliseconds. Text is read at its full written precision;\n * digits beyond microseconds must be zero. Text without a zone is a wall\n * clock in the process zone unless `naiveIsUtc` (DuckDB's `TIMESTAMP` text,\n * which this runtime always writes as UTC).\n */\nexport function timestampMicros(\n value: unknown,\n naiveIsUtc = false,\n): bigint | undefined {\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? undefined : BigInt(time) * 1000n;\n }\n if (typeof value !== 'string') return undefined;\n const match = TIMESTAMP_TEXT.exec(value.trim());\n if (!match) return undefined;\n const [, year, month, day, hour, minute, second, fraction = '', zone] = match;\n if (fraction.length > 6 && /[1-9]/u.test(fraction.slice(6))) return undefined;\n if (zone === undefined && !naiveIsUtc) return undefined;\n // `Date.UTC` maps years 0-99 onto 1900-1999; set the literal year instead.\n const date = new Date(0);\n date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));\n date.setUTCHours(Number(hour), Number(minute), Number(second), 0);\n const wall = date.getTime();\n if (Number.isNaN(wall)) return undefined;\n // Date rolls impossible fields over (month 13, minute 60, Feb 30); a field\n // that does not survive the round trip makes the text unprovable.\n if (\n date.getUTCFullYear() !== Number(year) ||\n date.getUTCMonth() !== Number(month) - 1 ||\n date.getUTCDate() !== Number(day) ||\n date.getUTCHours() !== Number(hour) ||\n date.getUTCMinutes() !== Number(minute) ||\n date.getUTCSeconds() !== Number(second)\n ) {\n return undefined;\n }\n let offsetMinutes = 0;\n if (zone && zone.toUpperCase() !== 'Z') {\n const digits = zone.slice(1).replace(':', '');\n const offsetHours = Number(digits.slice(0, 2));\n const offsetRest = Number(digits.slice(2) || '0');\n if (offsetHours > 23 || offsetRest > 59) return undefined;\n offsetMinutes =\n (zone.startsWith('-') ? -1 : 1) * (offsetHours * 60 + offsetRest);\n }\n const micros = BigInt(fraction.slice(0, 6).padEnd(6, '0'));\n return (BigInt(wall) - BigInt(offsetMinutes) * 60000n) * 1000n + micros;\n}\n\nexport { jsonNumbersRoundTrip };\n\nfunction canonicalJson(value: unknown): string | undefined {\n let parsed = value;\n if (typeof value === 'string') {\n if (!jsonNumbersRoundTrip(value)) return undefined;\n try {\n parsed = JSON.parse(value);\n } catch {\n return undefined;\n }\n }\n const normalize = (node: unknown): unknown => {\n if (Array.isArray(node)) return node.map(normalize);\n if (node !== null && typeof node === 'object') {\n return Object.fromEntries(\n Object.entries(node as Record<string, unknown>)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([key, child]) => [key, normalize(child)]),\n );\n }\n return node;\n };\n try {\n return JSON.stringify(normalize(parsed));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether `next` (the value about to be written) provably equals `stored`\n * (the canonical value read back from the row). `false` means \"assign it\":\n * either it differs or equality cannot be established.\n */\nexport function storedValueEquals(\n columnType: string | undefined,\n next: unknown,\n stored: unknown,\n exactStoredTimestamp?: string,\n): boolean {\n if (isNullish(next) || isNullish(stored)) {\n return isNullish(next) && isNullish(stored);\n }\n const type = (columnType ?? '').toUpperCase();\n if (type === 'TIMESTAMP') {\n // A hydrated `Date` has already lost anything below the millisecond, so\n // only the stored value as the database writes it can prove equality.\n if (exactStoredTimestamp === undefined) return false;\n const a = timestampMicros(next);\n const b = timestampMicros(exactStoredTimestamp, true);\n return a !== undefined && b !== undefined && a === b;\n }\n if (type === 'JSON') {\n const a = canonicalJson(next);\n const b = canonicalJson(stored);\n return a !== undefined && b !== undefined && a === b;\n }\n if (typeof next === 'string' && typeof stored === 'string') {\n if (\n type === 'UUID' &&\n UUID_PATTERN.test(next) &&\n UUID_PATTERN.test(stored)\n ) {\n return next.toLowerCase() === stored.toLowerCase();\n }\n return next === stored;\n }\n if (typeof next === 'boolean' && typeof stored === 'boolean') {\n return next === stored;\n }\n const numeric = (value: unknown): bigint | number | undefined =>\n typeof value === 'bigint' || typeof value === 'number' ? value : undefined;\n const a = numeric(next);\n const b = numeric(stored);\n if (a !== undefined && b !== undefined) {\n // biome-ignore lint/suspicious/noDoubleEquals: bigint/number cross-type value equality is the point.\n return a == b;\n }\n return false;\n}\n\n/**\n * `TIMESTAMP` columns of `data` whose equality needs the stored value at full\n * precision (see {@link storedValueEquals}); the caller reads them as text.\n */\nexport function timestampColumnsToProve(\n data: Readonly<Record<string, unknown>>,\n stored: Readonly<Record<string, unknown>>,\n indexedColumns: ReadonlySet<string>,\n columnTypes: Readonly<Record<string, { type?: string } | undefined>>,\n): string[] {\n return Object.keys(data).filter(\n (column) =>\n !ALWAYS_ASSIGNED.has(column) &&\n indexedColumns.has(column) &&\n Object.hasOwn(stored, column) &&\n !isNullish(data[column]) &&\n !isNullish(stored[column]) &&\n (columnTypes[column]?.type ?? '').toUpperCase() === 'TIMESTAMP',\n );\n}\n\n/**\n * Indexed columns of `data` whose value provably equals the stored row's, in\n * snake_case column names. `id` and `updated_at` are never reported: the\n * revision token always advances, and `id` is the write's key.\n */\nexport function unchangedIndexedColumns(\n data: Readonly<Record<string, unknown>>,\n stored: Readonly<Record<string, unknown>>,\n indexedColumns: ReadonlySet<string>,\n columnTypes: Readonly<Record<string, { type?: string } | undefined>>,\n exactTimestamps: Readonly<Record<string, string>> = {},\n): Set<string> {\n const unchanged = new Set<string>();\n for (const [column, value] of Object.entries(data)) {\n if (ALWAYS_ASSIGNED.has(column) || !indexedColumns.has(column)) continue;\n // A column the stored row does not carry cannot be compared.\n if (!Object.hasOwn(stored, column)) continue;\n if (\n storedValueEquals(\n columnTypes[column]?.type,\n value,\n stored[column],\n exactTimestamps[column],\n )\n ) {\n unchanged.add(column);\n }\n }\n return unchanged;\n}\n\n/**\n * An id-targeted `UPDATE` for native DuckDB, with every identifier quoted and\n * every value bound the way the DuckDB adapter's `upsert()` binds its\n * `DO UPDATE SET` values: `NULL` literals, `''` cast to TEXT, dates as ISO\n * strings, arrays and plain objects as JSON text.\n *\n * The adapter's generic `update()` is not used because it renders column names\n * unquoted (a column named `order` or `group` is a syntax error) and hands\n * dates and structures to the driver untouched; the `upsert()` it replaces\n * handled both.\n */\nexport function buildDuckDbIdUpdate(\n table: string,\n id: unknown,\n assignments: Readonly<Record<string, unknown>>,\n): { sql: string; values: unknown[] } {\n const quote = (identifier: string) => `\"${identifier.replaceAll('\"', '\"\"')}\"`;\n const values: unknown[] = [];\n const sets: string[] = [];\n for (const [column, value] of Object.entries(assignments)) {\n let expression: string;\n if (value === null || value === undefined) {\n expression = 'NULL';\n } else if (value === '') {\n values.push(value);\n expression = 'CAST(? AS TEXT)';\n } else if (value instanceof Date) {\n values.push(value.toISOString());\n expression = '?';\n } else if (\n Array.isArray(value) ||\n (typeof value === 'object' &&\n Object.getPrototypeOf(value) === Object.prototype)\n ) {\n values.push(JSON.stringify(value));\n expression = 'CAST(? AS JSON)';\n } else {\n values.push(value);\n expression = '?';\n }\n sets.push(`${quote(column)} = ${expression}`);\n }\n values.push(id);\n return {\n sql: `UPDATE ${quote(table)} SET ${sets.join(', ')} WHERE ${quote('id')} = ?`,\n values,\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Structural equality for JSON field values (#4604).
3
+ *
4
+ * Diff guards (`SmrtCollection.getOrUpsert()` / `getDiff()`, sync-apply
5
+ * no-op detection) must not report an unchanged JSON field as changed just
6
+ * because the incoming object or array is a different instance, or because
7
+ * its keys arrive in a different order than the stored row's. Doing so
8
+ * re-saves the row, moves `updated_at`, and appends a change-feed entry on
9
+ * every call.
10
+ *
11
+ * Equality is decided in the value's JSON (persisted) form:
12
+ * - plain objects compare key-order-insensitively; a key whose value is
13
+ * `undefined` is absent (JSON drops it), but `null` is a real value;
14
+ * - arrays compare element by element, in order;
15
+ * - a `Date` equals another `Date` at the same instant, or the ISO string
16
+ * `JSON.stringify` would write for it;
17
+ * - everything else compares with `===` (no numeric/string coercion).
18
+ *
19
+ * Errs toward "changed": anything it cannot prove equal is unequal.
20
+ */
21
+ /**
22
+ * Whether every number literal in JSON text survives `JSON.parse` exactly.
23
+ *
24
+ * `JSON.parse` reads numbers as doubles, so `9007199254740993` and
25
+ * `9007199254740992` parse to the same value. A literal is exact when the
26
+ * double it parses to prints back as the same text; anything else (unsafe
27
+ * integers, more digits than a double holds, `1.0`, `1e2`) makes the text
28
+ * unprovable. Digits inside JSON strings are not numbers and are skipped.
29
+ */
30
+ export declare function jsonNumbersRoundTrip(text: string): boolean;
31
+ /**
32
+ * Whether two values are equal in their JSON form.
33
+ *
34
+ * Bounded by `depth` so a cyclic value (which cannot be persisted as JSON)
35
+ * reports unequal instead of overflowing the stack.
36
+ */
37
+ export declare function jsonValuesEqual(a: unknown, b: unknown, depth?: number): boolean;
38
+ /**
39
+ * Parse `value` when it is JSON text for an object or array; otherwise
40
+ * return it unchanged. Used to compare a JSON field hydrated (or supplied)
41
+ * as text against the same value supplied as an object.
42
+ */
43
+ export declare function parseJsonContainerText(value: unknown): unknown;
44
+ //# sourceMappingURL=json-equal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-equal.d.ts","sourceRoot":"","sources":["../../src/utils/json-equal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAuB1D;AAiBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,OAAO,CA4C1E;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAY9D"}