@jarenjs/db 0.49.2 → 0.66.1

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.
Files changed (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
@@ -163,9 +163,9 @@ Three things follow, and none of them is optional:
163
163
  per-mapping cell.
164
164
 
165
165
  **What it costs, both halves** (`benchmark/spatial.js`, the store's own
166
- rows over 50 000 points): the same `$within` measures <!--bm:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/bm-->, and loading them
167
- costs <!--bm:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/bm-->. Isolated from the store on a raw
168
- connection, the same probe is <!--bm:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/bm-->. Read speed bought with write cost and a
166
+ rows over 50 000 points): the same `$within` measures <!--fact:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/fact-->, and loading them
167
+ costs <!--fact:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/fact-->. Isolated from the store on a raw
168
+ connection, the same probe is <!--fact:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/fact-->. Read speed bought with write cost and a
169
169
  second table: choose it deliberately, per index, which is why it is
170
170
  neither automatic nor a store-wide option.
171
171
 
@@ -198,11 +198,11 @@ Three things follow:
198
198
 
199
199
  **What it buys, and what it costs.** Measured against the same query
200
200
  over a collection with no such column — the whole embedding parsed out
201
- of the stored JSON per row — the column is worth <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->
202
- on the read, and costs <!--bm:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/bm-->
201
+ of the stored JSON per row — the column is worth <!--fact:vector.jsonDoc-->15.6× the plan at 10,000 × 768<!--/fact-->
202
+ on the read, and costs <!--fact:vector.write-->10.2 s against 4.9 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->
203
203
  on the way in, because every write pays a JSON round trip of the member
204
204
  plus the normalize and the pack. On
205
- disk it is <!--bm:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/bm-->
205
+ disk it is <!--fact:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/fact-->
206
206
  per vector — smaller than the member, and *added* to it, since the
207
207
  document still carries what the column is derived from. Choose it the
208
208
  way `physical: 'rtree'` is chosen: per index, with both halves in view
@@ -465,6 +465,19 @@ raise. The promotion therefore requires the schema to type the member
465
465
  as an array or an object and nothing else; a store that wants the
466
466
  engine's refusal instead keeps `compileSchema` injected.
467
467
 
468
+ **The same rule, for an interval.** `$overlaps` (§8.16) promotes over a
469
+ member the schema types as an object whose `start` and `end` are both
470
+ REQUIRED and both numeric — and only numeric, since an RFC 3339 bound
471
+ is a good instant to the engine and no epoch column compares against
472
+ it. Under that declaration a bound that is absent, textual or `null` is
473
+ a row the collection cannot hold, so the pushed comparison cannot swallow
474
+ a refusal the engine would have raised. One malformed case remains
475
+ declarable by no schema keyword — a span whose `end` is at or before its
476
+ `start` — so the statement keeps every one of those rows and the operator
477
+ raises over them, exactly as it would have. Both bounds must map to
478
+ declared columns; an unmapped pair pushes nothing and `strict: true`
479
+ names it (`JD0010`).
480
+
468
481
  Under `physical: 'rtree'` the same rule is enforced in SQL, by the
469
482
  `WHEN <stem>_w IS NOT NULL` guard on the sync triggers: **a document
470
483
  with no bounded position is ABSENT from the virtual table**, so the
@@ -553,6 +566,11 @@ the derived-column mapping branches on (§3.1), so a model that declares
553
566
  a spatial index is portable across all three drivers and the physical
554
567
  shape it produces is not.
555
568
 
569
+ Closing a Bun connection finalizes its live prepared statements before
570
+ closing the database, so releasing a file does not wait for garbage
571
+ collection. Statement tracking uses weak references and does not retain
572
+ past queries for the connection's lifetime.
573
+
556
574
  `rtree` is read from the library's compile options (`ENABLE_RTREE`) and
557
575
  is the second mapping branch: a `derive: 'bbox'` index that declares
558
576
  `physical: 'rtree'` (§2.1) opens on a build without the module as the
@@ -582,12 +600,96 @@ composition is sync-capable and adds none); the measured difference is
582
600
  the price of portability, published with the benchmarks rather than
583
601
  waved away.
584
602
 
585
- **Concurrency defaults are decided here.** A file-backed store opens
586
- with `PRAGMA busy_timeout` set to **5000 ms** and journal mode
587
- **WAL**, both overridable through `openStore`'s `busyTimeout` and
588
- `journalMode` options; `:memory:` stores set neither. The values in
589
- effect are visible on `store.capabilities.busyTimeoutMs` and
590
- `store.capabilities.journalMode` (`null` for in-memory stores).
603
+ **Connection configuration is a closed, validated set, read back
604
+ after it is applied.** `openStore` configures exactly eight
605
+ connection pragmas, each by its own option: `busyTimeout` (ms,
606
+ default **5000**), `journalMode` (`delete` | `truncate` | `persist` |
607
+ `memory` | `wal` | `off`, default **`wal`** on a writable file),
608
+ `synchronous` (`off` | `normal` | `full` | `extra`), `walAutocheckpoint`
609
+ (pages, `0` disables), `journalSizeLimit` (bytes, `-1` for none),
610
+ `cacheSize` (pages, or negative KiB), `mmapSize` (bytes) and
611
+ `tempStore` (`default` | `file` | `memory`). An option naming any
612
+ other pragma is refused `JD0006` — `foreign_keys` among them, which the
613
+ model requires ON and verifies per connection — and a value outside a
614
+ pragma's set is API misuse. A driver's capability table declares the
615
+ pragmas its binding applies (`configurablePragmas`); a request outside
616
+ that declaration is `JD0007`, as is an explicit `journalMode` on a
617
+ read-only store, whose journal-mode write the engine refuses. After the
618
+ open sequence every declared pragma is read back and the read values
619
+ are what `store.capabilities.pragmas` carries; a requested value the
620
+ engine did not take is `JD0008` and the store does not open. A
621
+ `:memory:` database keeps journal mode `memory` whatever is asked and
622
+ answers nothing for `mmap_size`, so those two are not written there and
623
+ the report says what the engine answers (`'memory'`, `null`).
624
+ `store.capabilities.busyTimeoutMs` and `store.capabilities.journalMode`
625
+ are the same two read-back values under their long-published names.
626
+
627
+ **Maintenance is a typed operation, never raw SQL.** Four store members
628
+ run what an operator runs on a production database, each under the
629
+ store gate (so none interleaves an in-flight write) and each answering
630
+ the engine's own row as typed data: `checkpoint({ mode })` runs
631
+ `PRAGMA wal_checkpoint` (`passive` | `full` | `restart` | `truncate`,
632
+ default `passive`) and answers `{ busy, logFrames, checkpointedFrames }`
633
+ — the engine's numbers, `-1` on a database that is not in WAL mode; a
634
+ second passive checkpoint reports the same counts as the first (the
635
+ frames stay in the log until a writer restarts it) and a second
636
+ `truncate` reports zeros. `integrityCheck({ limit })` answers
637
+ `{ ok, problems }`, the engine's rows verbatim — corruption is the
638
+ result, never a throw. `foreignKeyCheck()` answers
639
+ `{ ok, violations: [{ table, rowId, parent, fkid }] }`. `optimize()`
640
+ answers `{ ran: true }`, because `PRAGMA optimize` reports nothing and
641
+ this store invents no statistics. `store.capabilities.maintenance`
642
+ carries one boolean per operation: `false` where the driver's binding
643
+ does not declare it and, for `checkpoint` and `optimize`, on a
644
+ read-only store (the engine would answer a checkpoint there with a
645
+ silent no-op) — and a call is refused `JD2077` exactly where the report
646
+ says `false`. A driver failure inside an operation is `JD2078` with
647
+ the original as `cause`. None of them touches the change log's durable
648
+ watermark (LIVE-FORMAT §5).
649
+
650
+ **A backup is published whole or not at all.** `backupTo(targetPath,
651
+ { rate, onProgress, signal, checkpoint })` copies a live store through
652
+ the platform's online-backup API — writers proceed meanwhile — and
653
+ answers `{ path, pages, checkpoint }`. The copy is written to a
654
+ temporary sibling of the target (`<target>.jaren-tmp-<suffix>`, in the
655
+ same directory so the rename is one file system's) and renamed onto the
656
+ target only once the platform reported the copy complete; a
657
+ cancellation (`JD2079`, honoured between pages at the `rate` the
658
+ platform reports progress — the API takes no signal, so the check runs
659
+ in its progress callback), a copy failure or a rename failure removes
660
+ the temporary file and leaves the target untouched. A `checkpoint`
661
+ mode (default `passive`; `false` skips it, and a read-only store skips
662
+ it by default) fixes the snapshot boundary first, under the store gate.
663
+ Progress events are the platform's `{ totalPages, remainingPages }`
664
+ verbatim, and the last one may still carry a remainder: the platform
665
+ emits no zero event, completion is the resolved copy. The capability is
666
+ `store.capabilities.maintenance.backup` — the Node binding's; every
667
+ other binding reports `false` and refuses `JD2077`. What is NOT
668
+ decided here: where backups go, how they are named, encrypted, rotated
669
+ or retained — the host's policy.
670
+
671
+ **Cancellation is honoured where the driver can honour it, and the
672
+ report says where.** Every operation that runs more than one unit of
673
+ work takes `{ signal, deadline }` and checks them BETWEEN units, on the
674
+ clock the store's runtime record supplies — never inside a statement,
675
+ because no shipped SQLite binding exposes an interrupt.
676
+ `store.capabilities.cancellation` states the granularity per
677
+ lifecycle: `query: 'row'` (a call before it runs, a cursor or page at
678
+ every row boundary — `JD2072`), `queue: true` (a call still waiting for
679
+ the open transaction leaves the queue — `JD2064`), `migration: 'step'`
680
+ (between migrations, between steps and between the batches of a data
681
+ step — `JD2080`; the migration in flight rolls back whole), `maintenance:
682
+ 'statement'` (before the one statement each operation issues —
683
+ `JD2081`), `backup: 'page'` (between the pages the platform reports —
684
+ `JD2079`), and `midStatement: false` — a filled slot, not an absent
685
+ one; a driver that grows an interrupt flips exactly that member. A
686
+ passed deadline is `JD2075` in every lifecycle. A cursor's own report
687
+ is part of the same honesty: `capabilities.lazyIteration` is probed at
688
+ open, and on a binding whose statements carry no lazy iterator (the
689
+ driver composes `iterate` over `all()`) every cursor reports
690
+ `streaming: 'buffered'` with a `{ construct: 'driver' }` barrier,
691
+ `explain()` says the same, and `strictStreaming` refuses (`JD0037`) —
692
+ never a row stream the driver cannot deliver.
591
693
 
592
694
  The runtime builtin behind a binding is imported lazily inside
593
695
  `open()` — never at module scope — so every driver subpath loads under
@@ -617,6 +719,15 @@ returns a validation function; the function returns `true`/`false` or
617
719
  and `store.capabilities.validated === false` — a declared downgrade.
618
720
  The cost of running without one: the database constraints only see the
619
721
  key and the indexed members; everything else is stored as given.
722
+ Stated more sharply, because it changes ANSWERS and not only what is
723
+ stored: a typed generated column carries SQLite affinity, so a value
724
+ that violates the collection schema — the string `'020'` under an
725
+ `integer` path — reads as the integer `20` in the column while the
726
+ document still holds text, and a pushed `$eq: ['$it.n', '20']` finds
727
+ a row the engine, which compares the JSON value, does not. On an
728
+ unvalidated store the native and residual paths agree only over
729
+ documents that conform to the schema; with the hook injected no other
730
+ document is ever stored.
620
731
  `@jarenjs/db` never runs a validator of its own; what it imports from
621
732
  `@jarenjs/validate` is only the pure same-document `$ref`/`$anchor`
622
733
  resolution in `@jarenjs/validate/normalize`, for model compilation.
@@ -639,6 +750,18 @@ and may itself call `transaction`; each level is one savepoint. A
639
750
  throw rolls back exactly its own level and rethrows — an outer
640
751
  transaction that catches the error continues and its own work
641
752
  commits. There is no implicit retry.
753
+ If `COMMIT` or `RELEASE` itself fails, for example on a deferred foreign
754
+ key constraint, that level rolls back before another caller acquires
755
+ the connection. A rollback failure accompanies the original failure in
756
+ an `AggregateError`, retaining the primary failure's code, class and
757
+ driver cause when the store classifies it.
758
+
759
+ **On an asynchronous driver a refused write rejects.** Every write —
760
+ `insert`, `put`, `patch`, `delete`, the entity set's `create`/`update`/
761
+ `delete` and the queue's `enqueue` — answers its coded error (`JD2001`,
762
+ `JD2003`, `JD2005`, …) through the promise it returned: never a
763
+ synchronous throw, and never an unhandled rejection beside a result
764
+ that looks like success.
642
765
 
643
766
  ### 5.1 Transaction ownership
644
767
 
@@ -653,39 +776,209 @@ So a **top-level transaction owns its connection until it settles**, and
653
776
  an overlapping one waits its turn. Two concurrent request handlers
654
777
  sharing a store both commit, and both report success.
655
778
 
779
+ **The store the callback receives is the transaction.** `tx.collection`,
780
+ `tx.entity`, `tx.sync`, `tx.jobs` and `tx.saveChanges()` run as the
781
+ transaction's owner, and `tx.transaction()` nests through its savepoint:
782
+
783
+ ```js
784
+ await store.transaction(async (tx) => {
785
+ await tx.collection('docs').put(doc, 'a'); // inside this transaction
786
+ await tx.transaction(async (inner) => {
787
+ await inner.collection('docs').put(other, 'b'); // nested savepoint
788
+ });
789
+ });
790
+ ```
791
+
792
+ **A root transaction may take the write lock up front.**
793
+ `store.transaction(fn, { mode: 'immediate' })` begins with `BEGIN
794
+ IMMEDIATE` instead of a deferred savepoint. A body that reads before it
795
+ writes — a ledger claim: read the record, decide, insert — otherwise
796
+ meets the read→write upgrade `SQLITE_BUSY` when another connection
797
+ commits between its read and its write, the one busy the busy handler
798
+ cannot retry; with the lock taken first that wait is an ordinary busy
799
+ wait the `busyTimeout` covers, and two processes claiming one key see
800
+ one `new`. The default `'deferred'` is unchanged, `tx.transaction()`
801
+ inside either mode is a savepoint, `signal` and `unitOfWork` behave the
802
+ same, and the synchronous twin has no mode. The open path already
803
+ brackets every first-open object — collection, entity and join tables,
804
+ indexes, the change log and its state row, the job tables — the same
805
+ way (§2.4).
806
+
807
+ **A transaction handle lives exactly as long as its own scope.** Every
808
+ `tx` view is pinned to the exact scope that created it, and every
809
+ stateful member — connection work, unit-of-work bookkeeping like
810
+ `add()`, `tx.stats()`, a lazy `query()` cursor's `next()` — checks that
811
+ pin before reading tracker state or issuing a statement. A handle
812
+ retained past its callback, or an OUTER handle used while an async
813
+ inner savepoint is current, refuses **`JD2070`** naming the live
814
+ callback's handle as the fix; it never falls through to the root and
815
+ never follows a newer scope. Inside an inner transaction, use the inner
816
+ callback's own handle (synchronous nesting is unaffected — nothing can
817
+ interleave while a synchronous body is on the stack). `tx.stats()`
818
+ reports the work captured by its exact scope, and root `store.stats()`
819
+ always reports the root's, whichever scope happens to be current.
820
+
821
+ **A transaction view does not own the store lifetime.** The view
822
+ carries no `close` member, at runtime or in the declarations: closing
823
+ the connection under the view's own savepoint could only corrupt, so
824
+ the root store (or client) remains the only owner of connection
825
+ lifetime.
826
+
827
+ **Jobs have the same two spellings.** Root `store.jobs.*` finite calls
828
+ (and every worker's own claim/renewal/checkpoint/settlement I/O, no
829
+ matter where the worker was created) take the store gate, so an
830
+ unrelated job write can never join an open application transaction's
831
+ fate. `tx.jobs.*` runs as the exact scope — the transactional-outbox
832
+ spelling: an enqueue or settlement there co-commits with the domain
833
+ transaction and rolls back with it. A checkpoint store keeps the
834
+ root-or-scope ownership of the jobs view that created it.
835
+
836
+ **Capture is transparent to transaction options.** `{ capture: true }`
837
+ changes how committed records are translated, never queue cancellation
838
+ or tracker ownership: `store.transaction(fn, { signal, unitOfWork })`
839
+ behaves identically with and without capture — an aborted queued
840
+ callback still never runs (`JD2064`), and `unitOfWork: 'own'` still
841
+ gives the callback a tracker of its own without consuming the root's
842
+ pending state.
843
+
656
844
  Nesting is asked for in one of two ways, and the difference is not
657
845
  cosmetic:
658
846
 
659
847
  - **Synchronously** — a `transaction` called while an owning callback is
660
848
  still on the stack nests, because nothing can interleave there. This is
661
- `store.sync.transaction` inside `store.sync.transaction`.
849
+ `store.sync.transaction` inside `store.sync.transaction`, and it is why
850
+ a store-level synchronous call inside a synchronous callback runs as the
851
+ owner rather than waiting.
662
852
  - **Through the scope** — an `async` callback has already awaited, so the
663
853
  stack cannot say whether a request is its own nested work or an
664
- unrelated caller. Nest through the store the callback RECEIVED:
854
+ unrelated caller. Nest through the store the callback RECEIVED.
855
+
856
+ **A store-level handle is never inside the transaction.** `store.collection`,
857
+ `store.entity`, `store.saveChanges()`, `store.execute`, `store.dataVersion`
858
+ and the whole `store.sync` surface hold the connection for their own
859
+ extent, so their statements cannot fall inside a transaction they are not
860
+ part of and share a rollback they know nothing about. One store is
861
+ therefore safe for a handler per request: an unrelated writer waits for
862
+ the commit and keeps its own fate.
863
+
864
+ What that costs, stated plainly:
865
+
866
+ - A store-level call made while another caller's transaction is open
867
+ **waits** on the connection's gate, under `queueTimeout` (default 5 s,
868
+ the busy-timeout default). `openStore(model, { transactions: 'strict' })`
869
+ refuses at once instead, for a host that would rather see the contention
870
+ than pay for it.
871
+ - A store-level call **awaited from inside its own transaction** is a
872
+ self-wait: the store cannot tell it from an unrelated caller, so it
873
+ queues and, at `queueTimeout`, becomes `JD0012` whose message names
874
+ `tx.collection` / `tx.entity` / `tx.entities` as the fix. Bounded and
875
+ named, never a hang.
876
+ - `store.transaction(fn, { signal })` abandons a call that is still
877
+ **queued** — the callback never runs, and `JD2064` says so. A
878
+ transaction that has already taken the connection runs to its own end.
879
+ - A store-level **cursor** — `collection.query()`, `entity.cursor()`,
880
+ `entity.loadCursor()` — takes the gate **per pull**, not for its life:
881
+ constructing it touches no connection, each `next()` holds the
882
+ connection for exactly the source work one item needs (the first pull
883
+ prepares and opens the statement) and releases before it settles, and
884
+ `return()` is admitted the same way — a release the gate refuses (a
885
+ contended `'strict'` store, a queue timeout) still resets the statement
886
+ off-gate and answers `{ done: true }`, and an abort resets it at once,
887
+ because a statement left open until a stranger commits is the worse
888
+ outcome. So a consumer paused between pulls blocks no transaction, and
889
+ a pull made while one is open waits for its commit and observes
890
+ committed state only — never a row a stranger's transaction later rolls
891
+ back. Every cursor iterates a statement of its own, so two cursors over
892
+ one document never invalidate each other. A pull abandoned while queued
893
+ is `JD2064`; an aborted cursor is `JD2072` at its row boundary; a
894
+ passed deadline is `JD2075`; a settled cursor answers `{ done: true }`
895
+ whatever the clock or the gate say. A transaction view's cursors are
896
+ pinned to their exact scope instead (`JD2070`), as above.
897
+ - `store.live()` and `collection.live()` register **under the gate through
898
+ their initial query**: the registration is local but the first result is
899
+ a statement, so it waits for an open transaction like every other
900
+ store-level read and never publishes rows that transaction rolls back
901
+ (LIVE-FORMAT §7). A registration that is refused leaves no live query
902
+ behind. A live query registered from INSIDE a transaction (`tx.live`,
903
+ `tx.collection(name).live`) initializes from that transaction's rows and
904
+ shares its fate: committed, it stays and is maintained; rolled back, it
905
+ is closed with the rows that never existed.
906
+
907
+ **A unit of work's fate is its transaction's.** A tracked `saveChanges()`
908
+ inside a transaction writes its statements immediately — inside the
909
+ transaction the database does hold them, and every later read, plan and
910
+ optimistic guard in that unit of work agrees with that. What waits for
911
+ the commit is the right to *keep* the advance: if the enclosing
912
+ transaction rolls back, the tracker's snapshots are withdrawn to what
913
+ they were before the save, so a caller's retry plans the same statements
914
+ again instead of reporting a success it never had. A nested savepoint
915
+ that rolls back withdraws only what was registered inside it.
916
+
917
+ `@jarenjs/linq/db` projects all of this: `client.transaction(async (tx) =>
918
+ …)` hands the callback a typed client whose `tx.entities.X` and
919
+ `tx.collections.Y` are the transaction's, with a unit of work of its own
920
+ by default so two handlers never see each other's pending state
921
+ (`unitOfWork: 'shared'` opts back into the client's). The typed client
922
+ forwards `tx.savepoints` (§5.2) unchanged, and `tx.store` is the
923
+ underlying `TransactionStore`.
924
+
925
+ ### 5.2 Named savepoints: partial rollback without a sentinel exception
926
+
927
+ Structured nesting already gives partial rollback around a callback: an
928
+ inner `tx.transaction(fn)` is one savepoint, and an outer callback that
929
+ catches its error continues. What it cannot express is
930
+ checkpoint-and-continue from a LATER point without throwing for control
931
+ flow. A live transaction view carries that as `tx.savepoints`:
665
932
 
666
- ```js
667
- await store.transaction(async (tx) => {
668
- await store.collection('docs').put(doc, 'a'); // joins this transaction
669
- await tx.transaction(async () => { … }); // nests inside it
670
- });
671
- ```
672
-
673
- Reaching back through the outer `store.transaction` from inside a
674
- callback queues behind the transaction the caller is part of, so it
675
- waits for itself; after `queueTimeout` (default 5 s, the busy-timeout
676
- default) that becomes `JD0012` naming the fix rather than hanging.
677
-
678
- **One residual, stated plainly.** A bare statement issued while a
679
- transaction is open JOINS that transaction and shares its fate, because
680
- SQLite has no per-statement transaction scope and every operation inside
681
- a callback reaches the connection the same way an unrelated caller does.
682
- Work that must be in the transaction is therefore safe; an unrelated
683
- writer on a SHARED store is not. Give each concurrent writer its own
684
- store when independent writes must not share a rollback. Closing this
685
- — scope-bound `tx` handles, with store-level calls waiting on the gate
686
- while a foreign scope is open is an open ROADMAP item (`@jarenjs/db`,
687
- "Strong same-store transaction ownership"); until it lands, one store
688
- shared by independent request handlers is unsafe for bare writes.
933
+ ```js
934
+ await store.transaction(async (tx) => {
935
+ await tx.savepoints.create('before-optional-import');
936
+ await tx.collection('docs').put(optionalDoc, 'optional');
937
+
938
+ if (!accepted) {
939
+ await tx.savepoints.rollbackTo('before-optional-import');
940
+ // the checkpoint remains active and may be rolled back to again
941
+ }
942
+
943
+ await tx.savepoints.release('before-optional-import');
944
+ });
945
+ ```
946
+
947
+ The synchronous twin is `tx.sync.savepoints`, answering values. Root
948
+ `Store`, root `Client`, workers and checkpoint stores expose **none** of
949
+ it: only the transaction that owns the connection may move its stack,
950
+ and a stale or cross-scope view is `JD2070` before any label is even
951
+ looked at.
952
+
953
+ - **The label never reaches SQL.** It is a map key and diagnostic for
954
+ that exact transaction; the driver generates the same monotonic
955
+ `jaren_sp_*` identifier structured nesting uses, so both savepoint
956
+ kinds share one engine stack and cannot cross-release one another. A
957
+ structured inner transaction gets its own exact namespace — it cannot
958
+ target an outer label, and the `JD2070` rule keeps the outer view
959
+ from destroying an async inner savepoint.
960
+ - **A blank, duplicate or unknown label is `JD2071`**, raised before
961
+ any statement, so the database and the settlement/capture marks are
962
+ untouched.
963
+ - **`rollbackTo` follows the engine's semantics exactly.** The target
964
+ savepoint stays active (a second rollback to it is defined) while
965
+ every checkpoint created after it is invalidated; the rows after the
966
+ target are gone. In-memory effects follow the database: settlement
967
+ effects registered after the checkpoint run their rollback halves in
968
+ reverse — a `saveChanges()` advance after the checkpoint is
969
+ withdrawn, its entity intention pending again, so a corrected
970
+ `saveChanges()` retries it (and an enclosing rollback still withdraws
971
+ that later advance). Direct collection writes and `tx.jobs` writes
972
+ are undone by SQLite itself. Session capture observes the engine's
973
+ final changeset; journal capture truncates to the checkpoint's mark —
974
+ the two modes agree.
975
+ - **`release` keeps the rows.** It removes the target and every later
976
+ checkpoint without running rollback effects: those rows remain part
977
+ of the owning transaction, and their tracker withdrawals stay
978
+ registered until outer settlement. An outer commit or rollback
979
+ invalidates whatever names the callback left active.
980
+ - No observer delivery or persisted capture record occurs before the
981
+ owning transaction commits, exactly as everywhere else.
689
982
 
690
983
  ## 6. Identity
691
984
 
@@ -717,16 +1010,24 @@ error.
717
1010
  | code | raised when |
718
1011
  |---|---|
719
1012
  | `JD0001` | the SQLite library is below the supported floor |
720
- | `JD0002` | the declared model disagrees with the existing database |
1013
+ | `JD0002` | the existing database disagrees with the declared model, or the open failed in the driver |
721
1014
  | `JD0003` | the driver binding is unavailable on this runtime |
722
1015
  | `JD0004` | a declared index cannot be mapped to a column |
723
1016
  | `JD0005` | the model document is invalid |
1017
+ | `JD0006` | an open option named a pragma this store does not configure |
1018
+ | `JD0007` | the pragma cannot be applied on this driver or store |
1019
+ | `JD0008` | a pragma did not take: the read-back disagrees with the request |
724
1020
  | `JD0010` | strict mode refused a residual |
725
1021
  | `JD0011` | the profile refused the document |
726
1022
  | `JD0012` | work waited too long for the open transaction to settle |
727
1023
  | `JD0030` | an unknown x-entity member was declared |
728
1024
  | `JD0031` | relation declarations contradict each other |
729
1025
  | `JD0032` | the include specification is invalid |
1026
+ | `JD0033` | an entity query names no entity array |
1027
+ | `JD0034` | a tracked cursor needs a bare entity return |
1028
+ | `JD0035` | the continuation does not belong to this ordering |
1029
+ | `JD0036` | a snapshot page needs an immutable ordering |
1030
+ | `JD0037` | strictStreaming refused a plan that buffers |
730
1031
  | `JD0040` | the save spans a relation cycle |
731
1032
  | `JD0050` | live queries require change capture |
732
1033
  | `JD0051` | the demanded live mode is unavailable |
@@ -745,16 +1046,154 @@ error.
745
1046
  | `JD2060` | the maintained live state exceeded its bound |
746
1047
  | `JD2061` | another context owns the database |
747
1048
  | `JD2062` | the store closed with job handlers still in flight |
1049
+ | `JD2063` | the store is closed |
1050
+ | `JD2064` | the call was aborted while it waited for the open transaction |
1051
+ | `JD2065` | the job is not leased — it is unknown, or already settled |
1052
+ | `JD2066` | the lease was superseded by a newer claim or renewal |
1053
+ | `JD2067` | the lease expired before the call |
1054
+ | `JD2068` | a settling call needs the lease the claim returned |
1055
+ | `JD2069` | a resumed run does not match the workflow or input it was checkpointed under |
1056
+ | `JD2070` | the transaction handle does not belong to the live scope |
1057
+ | `JD2071` | the savepoint label is blank, duplicate or unknown |
1058
+ | `JD2072` | the call was aborted before its next row |
1059
+ | `JD2073` | an include exceeded its per-root bound |
1060
+ | `JD2074` | an item exceeds the page byte bound |
1061
+ | `JD2075` | the deadline passed before the next unit of work |
1062
+ | `JD2076` | an item exceeds the profile byte bound |
1063
+ | `JD2077` | the maintenance operation is unavailable on this store |
1064
+ | `JD2078` | the maintenance operation failed |
1065
+ | `JD2079` | the backup was cancelled |
1066
+ | `JD2080` | the migration was cancelled between steps |
1067
+ | `JD2081` | the maintenance operation was cancelled |
1068
+ | `JD2082` | the database or its disk is full |
1069
+ | `JD2083` | the database is read-only |
1070
+ | `JD2084` | a disk I/O error |
1071
+ | `JD2085` | the database file is corrupt or not a database |
1072
+ | `JD2086` | a seek anchor came back with a type the plan did not declare |
1073
+ | `JD2087` | the connection to the database was lost |
1074
+ | `JD2088` | the transaction was aborted by an earlier failure in it |
1075
+ | `JD2089` | the statement was cancelled by the server |
1076
+ | `JD2090` | worker generation lost; reopen, never automatically replay; retryable only outside a transaction |
1077
+ | `JD2091` | bounded worker/pool admission overflow; retryable, with queue depth |
1078
+ | `JD2092` | a worker row, compatibility result or remote identity count exceeds its declared bound |
1079
+ | `JD2093` | malformed worker protocol request |
1080
+ | `JD2094` | invalid or uncommitted durable snapshot; reopen the last committed version |
748
1081
 
749
1082
  The table above is proven in sync with the runtime `DB_CODES` table by
750
1083
  a test.
751
1084
 
1085
+ **One classification of driver failures.** Every path that meets a
1086
+ driver error — a collection or entity write, the job queue, the query
1087
+ path, a maintenance operation, the backup, the open sequence —
1088
+ consults one table (`classifyDriverError`), so the same failure arrives
1089
+ under the same code with the same `class` and `retryable` verdict
1090
+ whichever path met it: `busy` (SQLITE_BUSY/LOCKED → `JD2005`,
1091
+ retryable), `full` (`JD2082`), `readonly` (`JD2083`), `io` (`JD2084`),
1092
+ `corrupt` (`JD2085`), `cantopen` and `constraint` (`JD2005`), `duplicate`
1093
+ (a UNIQUE collision on the key column → `JD2001`), `overflow` (a pushed
1094
+ integer aggregate past int64 — never raised: the query path re-runs the
1095
+ document in the engine and answers the double, and `explain().fallback`
1096
+ records it), and the fallback `error` (`JD2005`). A classified error
1097
+ carries `class`, `retryable` and the driver's error as `cause`; a
1098
+ lifecycle that owns its failure code (`JD2078` for maintenance and
1099
+ backup, `JD0002` at open) keeps the code and still carries the class.
1100
+
1101
+ The ENGINE is discriminated by the evidence the error itself carries,
1102
+ never by a table threaded down from the caller: a SQLite binding
1103
+ attaches a numeric result code, a PostgreSQL one attaches a
1104
+ five-character SQLSTATE. Both land in the classes above, and three
1105
+ conditions a single-writer file database does not have get their own:
1106
+ `connection` (`JD2087`, retryable — SQLSTATE class 08 and the
1107
+ administrator's own terminations), `aborted` (`JD2088` — SQLSTATE
1108
+ `25P02`, a statement issued after an earlier failure inside the same
1109
+ transaction) and `cancelled` (`JD2089` — SQLSTATE `57014`). A
1110
+ serialization failure or a deadlock (`40001`, `40P01`) is `busy` and
1111
+ retryable, which is the same verdict, and the same caller branch, a
1112
+ locked SQLite file gets.
1113
+ An engine error thrown inside a pushed user function (`JQ…`) is not a
1114
+ driver error: it passes through untouched, relocated onto the caller's
1115
+ document path (`/$where/…`, never the hatch's `/$return/…`).
1116
+
1117
+ ## 7A. Model-declared index expressions
1118
+
1119
+ An index may name a computation instead of a member:
1120
+
1121
+ ```json
1122
+ {
1123
+ "name": "by_lower_email",
1124
+ "expression": { "call": "lower", "args": [{ "member": "$.email" }] },
1125
+ "unique": true
1126
+ }
1127
+ ```
1128
+
1129
+ `expression` is mutually exclusive with `path` and with `derive`
1130
+ (`JD0004`): an expression names the members it reads itself, and a
1131
+ derived spatial column IS an expression this format spells for you.
1132
+
1133
+ **The vocabulary is closed — three node kinds and no fourth**, and none
1134
+ of them is SQL text:
1135
+
1136
+ | node | meaning |
1137
+ |---|---|
1138
+ | `{ "member": "$.a.b" }` | a singular JSONPath expression into the stored document |
1139
+ | `{ "value": 1 }` | a JSON string, number or boolean. A `null` or a compound has no place in an index expression |
1140
+ | `{ "call": "lower", "args": [ … ] }` | a function the HOST declared, applied to its arguments in order |
1141
+
1142
+ Argument order is significant: `sub(a, b)` and `sub(b, a)` are
1143
+ different expressions and different columns. An expression nests at
1144
+ most eight deep.
1145
+
1146
+ **A function is DECLARED by the host, never created by the store.**
1147
+ `openStore(model, { expressions })` takes one declaration per name:
1148
+
1149
+ ```jsonc
1150
+ {
1151
+ "lower": {
1152
+ "arity": 1,
1153
+ "deterministic": true, // required, and never inferred
1154
+ "apply": (value) => …, // an engine that registers functions calls this
1155
+ "sql": "lower" // one that cannot calls this IMMUTABLE function
1156
+ }
1157
+ }
1158
+ ```
1159
+
1160
+ Why a declaration rather than an escape hatch: **an index over a
1161
+ function is a schema dependency.** A database whose column is computed
1162
+ by `lower(…)` cannot be written from a connection that has no `lower`,
1163
+ and a raw-SQL index would have had exactly that hazard with none of the
1164
+ checking. Here the model names the function, every store that opens the
1165
+ model is handed the same declaration, and a store that cannot honour
1166
+ one refuses at open — `JD0004`, before a single statement:
1167
+
1168
+ - a name this store was not given
1169
+ - an arity the expression does not match
1170
+ - a function not declared `deterministic`
1171
+ - no `apply` where the engine computes the value itself
1172
+ - no `sql` name where the engine calls its own — and that name is an
1173
+ identifier, never SQL text
1174
+
1175
+ **How each engine computes it.** SQLite registers `apply` as a
1176
+ deterministic function under a namespaced name (`jaren_x_lower`) — so a
1177
+ model's `lower` never shadows the engine's own — and the column is
1178
+ `GENERATED ALWAYS AS (jaren_x_lower(<member>)) VIRTUAL`. PostgreSQL
1179
+ registers nothing: the column is `GENERATED ALWAYS AS (lower(<member>))
1180
+ STORED` over the immutable function the host promised the server has.
1181
+ Both read the member as its own SCALAR, so `lower` of a string is the
1182
+ same answer on both.
1183
+
1184
+ One column serves every index that declares the same canonical
1185
+ expression, and the canonical form — order-preserving — is what the
1186
+ shape hash and the migration diff read. `store.introspect()` reads the
1187
+ expression back out of the SQL the dialect wrote (each dialect reads its
1188
+ own), so a declared expression index survives the round trip; without
1189
+ the declarations the column is REPORTED as unmapped rather than guessed.
1190
+
752
1191
  ## 8. The safe execution profile
753
1192
 
754
1193
  A query document that arrives from a tenant, a remote client or a
755
1194
  language model can reach a database. Parameter binding makes injection
756
1195
  structurally impossible; it does nothing about resource exhaustion or
757
- cross-tenant reads. A **profile** composes four independent bounds:
1196
+ cross-tenant reads. A **profile** composes five independent bounds:
758
1197
 
759
1198
  ```js
760
1199
  const store = await openStore(model, { driver, profile: 'safe' });
@@ -763,10 +1202,98 @@ collection.query(doc, { profile: { maxRows: 200, externals: ['min'] } });
763
1202
  ```
764
1203
 
765
1204
  `'safe'` is the default table; a profile object overrides members over
766
- it. The defaults: engine limits
1205
+ it. A per-call `profile` REPLACES the store's for that call — it is
1206
+ normalized over the `'safe'` defaults, not over the store's profile —
1207
+ so a store-level mandatory predicate or allow-list does not carry into
1208
+ `execute(doc, { profile: { maxRows: 50 } })`: spell the whole profile
1209
+ per call, or set it once on the store and pass none. The defaults: engine limits
767
1210
  `{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
768
1211
  `maxRows: 1000`, no externals, no host functions, no collations, all
769
- of the store's collections, no mandatory predicates, no scan refusal.
1212
+ of the store's collections, no mandatory predicates, no scan refusal,
1213
+ and no graph caps (`maxIncludedRows`, `maxDepth`, `maxBytes` all
1214
+ `null`), and no member allow-list (`members: null`).
1215
+
1216
+ **One profile, every engine.** A profile — the store's, or the call's
1217
+ through `ExecuteOptions.profile` on `execute`, `query`, `cursor`,
1218
+ `loadCursor`, `page` and `explain` alike — applies identically to
1219
+ collection execution, entity execution (`entity.execute`, the chain's
1220
+ cursor), graph loading (`load`, `loadCursor`, `page`, every include
1221
+ subquery) and store-root execution (`store.execute`). `collections` is
1222
+ the one allow-list and names collections AND entity roots; a document
1223
+ that reads a name outside it is `JD0011` on every engine, with the
1224
+ same reason. `predicates` is keyed by collection or entity name, and
1225
+ an entity's predicate is conjoined into every fetch of that entity —
1226
+ the native statement, each root the residual fetches, the load's root
1227
+ and every include subquery over that entity. `maxRows` bounds every
1228
+ fetch on every engine (`JD2007`), the residual's input rows included:
1229
+ each root an entity residual fetches carries `LIMIT maxRows + 1`. The
1230
+ graph caps are hard maxima an include's own declaration cannot exceed:
1231
+ `maxIncludedRows` refuses an include that declares more rows per root
1232
+ (or `Infinity`), `maxDepth` refuses a deeper load, and `maxBytes`
1233
+ refuses any one item — a document, an entity row, a loaded root graph —
1234
+ larger than that many serialised bytes (`JD2076`).
1235
+
1236
+ **The member allow-list.** `collections` says which roots a document
1237
+ may read; `members` says which MEMBERS of a root it may read. It is
1238
+ keyed by collection or entity name, and each value is a list in the
1239
+ model's own singular index-path spelling:
1240
+
1241
+ ```js
1242
+ collection.query(doc, { profile: {
1243
+ members: { docs: ['$.id', '$.n'], User: ['$.id', '$.age'] } } });
1244
+ ```
1245
+
1246
+ A root the list does not name is unrestricted; a root the MODEL does
1247
+ not declare is `JD0011` before any statement runs, because a policy
1248
+ that applies to nothing is a policy failing open. Allowing a member
1249
+ allows everything UNDER it (`$.address` allows `$.address.city`) and
1250
+ none of its siblings (`$.address.city` does not allow `$.address`,
1251
+ which would answer the rest of the address). One collector reads every
1252
+ member path a document references — in `$where`, in `$orderby`, in
1253
+ `$return`, in a join condition, in a registered operator's operands —
1254
+ so a denial is the same `JD0011` wherever the member was named, and it
1255
+ names the root, the member and the place in the caller's document.
1256
+
1257
+ Reading a root item WHOLE is refused, not narrowed: `$return: '$it'`,
1258
+ an alias of the binding through `$let`, and a wildcard with no singular
1259
+ prefix each answer members the list does not allow, and no list can
1260
+ cover them. For the same reason a graph `load()`, `page()` or
1261
+ `loadCursor()` over a policed entity is refused — it answers whole
1262
+ documents by definition — and the refusal names the members that ARE
1263
+ allowed, which the document query engine can project.
1264
+
1265
+ **Enforced, or refused — never approximated.** A budget the engine
1266
+ can COUNT is enforced during execution: rows returned, rows
1267
+ materialised, residual-input rows, included rows per root, depth,
1268
+ bytes, and the engine limits. A budget SQLite cannot measure is
1269
+ refused at preflight on plan shape instead: `refuseFullScan` declines
1270
+ a native plan whose own `EXPLAIN QUERY PLAN` shows a full-table scan,
1271
+ and declines an entity residual outright, because the residual reads
1272
+ every row of every referenced root before the engine decides — that
1273
+ is a full-table scan by shape, so it is refused before any fetch
1274
+ rather than estimated. Visited-row and elapsed-time budgets exist
1275
+ only where a driver supplies a progress or interrupt hook; the
1276
+ shipped SQLite drivers supply neither, and `explain().budget` reports
1277
+ `time: 'unavailable'` and `estimatedRows: 'unavailable'` rather than
1278
+ a number nothing measured.
1279
+
1280
+ **`signal` and `deadline`.** `ExecuteOptions.signal` cancels: a call
1281
+ already aborted issues no statement (`JD2072`), and a cursor or page
1282
+ releases its statement at the next row boundary. `deadline` is an
1283
+ epoch-millisecond instant checked before a statement runs and at
1284
+ every row boundary of a cursor or page (`JD2075`) — a row-boundary
1285
+ check, never a statement interrupt, for the reason above. The clock it
1286
+ is checked against is the store's runtime record's `now`
1287
+ (`@jarenjs/core/runtime`; the platform clock with no record), so a
1288
+ caller under an injected clock computes deadlines from that clock.
1289
+
1290
+ **Provenance.** Every `explain()` — collection, entity, graph —
1291
+ carries `budget`: the profile that applied and from where (`{ source:
1292
+ 'call' | 'store', name: 'safe' | 'custom' }`, or `null`), every bound
1293
+ it imposed (`rows`, `includedRows`, `depth`, `bytes`, `limits`), the
1294
+ scan verdict (`'refused-by-shape'` or `'unbounded'`), and the two
1295
+ driver slots by name (`time`, `estimatedRows`). A budget nobody can
1296
+ prove was applied is not a budget.
770
1297
 
771
1298
  1. **Engine limits.** The four engine limits ride into every residual
772
1299
  compilation, so the JavaScript portion of a query is bounded by the
@@ -934,6 +1461,38 @@ to build one on — a crude guess would be dishonest. It pushes
934
1461
  deterministically and this profile is published so the shape of the win
935
1462
  is known; add a narrowing predicate or a `LIMIT` and the push pays.
936
1463
 
1464
+ **`pushable: 'aggregate'` — the whole-sequence fold, in SQL.** A pack
1465
+ entry marked `'aggregate'` is lowered to a registered **SQL aggregate**
1466
+ where the driver has one (`capabilities.aggregateFunctions`): the plan
1467
+ emits `… SELECT jaren_a_<hash>(<the member's column>)`, SQLite drives
1468
+ the accumulation, and the SAME pure function the residual would call
1469
+ folds the values it collected. `$mean`, `$median`, `$variance` and
1470
+ `$stddev` of the statistics pack carry the token.
1471
+
1472
+ The token is a promise about the FOLD, and three rules enforce it:
1473
+
1474
+ - **order-insensitive.** A SQL aggregate visits rows in an order
1475
+ nothing specifies, so only a summary whose value depends on the
1476
+ multiset alone may be declared pushable.
1477
+ - **one sequence operand.** A SQL aggregate's final step sees only what
1478
+ the row steps accumulated, so a second operand does not reach a fold
1479
+ over zero rows. An entry declaring anything but a single
1480
+ `'seq<number>'` operand and a `'number'` result is a `TypeError` at
1481
+ `openStore`, naming the operator — loud, rather than a silent
1482
+ non-promotion. `$percentile`, whose second operand is the percentile,
1483
+ is therefore `pushable: false` and folds in the engine.
1484
+ - **a numeric member that cannot hold `null`.** SQL cannot tell a
1485
+ stored `null` from an absent member and the engine's sequence can, so
1486
+ the promotion needs the same schema-typed path the core `$sum` and
1487
+ `$avg` need. An absent member contributes nothing on either side.
1488
+
1489
+ A grouped fold is not promoted — a `$groupby` outside the fixed
1490
+ temporal bucket is engine work (§6) — and neither is an aggregate under
1491
+ a window. Both answer what the engine answers, and `explain()` names
1492
+ the reason. As with the scalar hatch, a profiled document triggers no
1493
+ registration: the same `$mean` under a profile folds in the residual,
1494
+ and `strict: true` refuses it by name (`JD0010`).
1495
+
937
1496
  **The same profile for a spatial predicate.** A `$within` against a
938
1497
  LITERAL region takes the hatch only on a collection that declares **no**
939
1498
  derived spatial index on the member: where one is declared the
@@ -945,15 +1504,15 @@ changing state. Measured on the same terms as the `$sqrt` rows
945
1504
  mean of 20 executions after a warm one; the residual comparator
946
1505
  pushes everything BUT the spatial conjunct):
947
1506
 
948
- <!--bm:spatial.udfTable-->
1507
+ <!--fact:spatial.udfTable-->
949
1508
  | shape | pushed (ms) | residual (ms) | verdict |
950
1509
  |---|---|---|---|
951
1510
  | solo `$within` over a full scan | 94 | 80 | ~even |
952
1511
  | indexed `$eq` **and** `$within` (~5 % pass the index) | 5.7 | 52 | push **9.1×** |
953
1512
  | `$within` with `LIMIT 10` | 1.9 | 77 | push **41.0×** |
954
- <!--/bm-->
1513
+ <!--/fact-->
955
1514
 
956
- So the spatial hatch <!--bm:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/bm-->,
1515
+ So the spatial hatch <!--fact:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/fact-->,
957
1516
  by the same rule as `$sqrt`: a sole `$within` over a full scan is a
958
1517
  loss (the UDF re-parses every row in the callback, and the exact
959
1518
  containment test is dearer than a square root), a `$within` beside
@@ -964,15 +1523,11 @@ it returns, which is faster than either column above.
964
1523
 
965
1524
  **The honest ceiling.** A `pushable:false` operator (a whole-series
966
1525
  `$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
967
- lists no `udfs` for it. Aggregate-UDF pushdown (`db.aggregate` step/final
968
- over `GROUP BY`) is **not emitted**: no shipped pack marks an entry
969
- `pushable:'aggregate'` (the finance/stats aggregators fold a *per-document*
970
- sequence that is a per-row scalar to SQL, already covered by the scalar
971
- path where marked not a cross-row column), and cross-row aggregate
972
- pushdown additionally waits on `$groupby` pushdown, itself a deliberate
973
- residual today. The `aggregateFunctions` capability is probed and
974
- reported regardless, so the day a pack marks `'aggregate'` the driver
975
- gate is already in place.
1526
+ lists no `udfs` for it, and neither does a `pushable:'aggregate'` entry
1527
+ whose shape the store refuses (above). A registered aggregate lowers
1528
+ only as the top-level fold of a whole selection: inside a `$groupby`'s
1529
+ `$return` the closed BUILT-IN set is what SQL groups by, and a
1530
+ registered summary there stays the engine's.
976
1531
 
977
1532
  ## 9. Entities, the `x-entity` vocabulary, relations
978
1533
 
@@ -1013,7 +1568,10 @@ names:
1013
1568
  The schema stays a valid JSON Schema throughout: strip every
1014
1569
  `x-entity` member and it accepts and rejects exactly the same values
1015
1570
  (test-asserted over a corpus). The vocabulary is invisible to the
1016
- validator by the same argument as `x-form`.
1571
+ validator by the same argument as `x-form`. A model document may also
1572
+ be written by code — `@jarenjs/linq/model`'s `defineModel()` emits
1573
+ exactly this document ([MODEL-PEN.md](../../linq/docs/MODEL-PEN.md)), and this section stays its
1574
+ one specification.
1017
1575
 
1018
1576
  ### 9.2 The `x-entity` vocabulary (a closed set)
1019
1577
 
@@ -1033,6 +1591,13 @@ happen, so this vocabulary is deliberately stricter than the
1033
1591
  validator's ignore-unknown posture — the strictness is local to the
1034
1592
  one namespace this package owns.
1035
1593
 
1594
+ The vocabulary is closed in POSITION as well as in name: `x-entity` is
1595
+ read on an entity's top-level properties (and through their `allOf`,
1596
+ `$defs` and `definitions` blocks) only. A block nested anywhere else —
1597
+ under a property's `properties`, `items`, `anyOf`, … — is never walked
1598
+ for mapping directives, so one found there is `JD0030` at its `docPath`
1599
+ rather than a key, an index or a relation that silently never existed.
1600
+
1036
1601
  ### 9.3 The hybrid mapping
1037
1602
 
1038
1603
  Stated once, mechanically applied, and returned as data by
@@ -1042,7 +1607,8 @@ Stated once, mechanically applied, and returned as data by
1042
1607
  |---|---|
1043
1608
  | scalar (`string`/`number`/`integer`/`boolean`) at the top level | a real typed column |
1044
1609
  | `format: date-time`/`date` with `column: "integer"` | an epoch-milliseconds `INTEGER` column; the document keeps the RFC 3339 string, the column carries the derived epoch |
1045
- | `enum` of scalars | a column plus a `CHECK (column IN (…))` |
1610
+ | `enum` of scalars | a column plus a `CHECK (column IN (…))` — a `null` member of the enum is left to the column's nullability, never written into the list |
1611
+ | a union of several scalar types (`['string', 'integer']`) | the JSONB document — a column has one affinity and a union has several; `['integer', 'null']` is that scalar, nullable |
1046
1612
  | nested object / array, or `column: "json"` | the JSONB document column, queryable by path exactly as in phase A |
1047
1613
  | relation | a foreign-key column, or a join table for many-to-many (§9.4) |
1048
1614
 
@@ -1052,13 +1618,39 @@ everything else; a read merges them back. **The absent-versus-null
1052
1618
  rule, plainly**: for a column-mapped scalar, JSON `null` and absence
1053
1619
  both store as SQL `NULL` and read back as ABSENT. A property that
1054
1620
  needs present-`null` semantics declares `column: "json"` and stays in
1055
- the document.
1621
+ the document. The rule reaches the WRITE's return value, not only the
1622
+ read: `create()`, `update()` and `add()` answer the document a read
1623
+ would answer, so a `null` handed in for a column-mapped scalar is
1624
+ absent from what comes back — a returned object never names a member
1625
+ no read will show. An epoch column (§9.3's `column: "integer"` row)
1626
+ keeps its string in the document, present-`null` included.
1056
1627
 
1057
1628
  ### 9.4 Relations and referential integrity
1058
1629
 
1059
1630
  Declared on one side, inferred on the other; when both sides declare,
1060
1631
  the inverses MUST agree (`JD0031` on any contradiction).
1061
1632
 
1633
+ What counts as "the same edge" is decided by `via`, not by the pair of
1634
+ entity names: two declarations pair up when they name the same
1635
+ foreign-key property, and a pair is one `many` side and one `one`
1636
+ side. Two `many` sides, or two `one` sides, on one `via` is `JD0031`,
1637
+ and a paired edge must agree on `onDelete`.
1638
+
1639
+ Between one pair of entities, a `many` declaration facing a `one`
1640
+ declaration with a DIFFERENT `via` is `JD0031` as well (the pair
1641
+ disagrees on its key) — the check is per entity pair, so **two
1642
+ one-to-many edges between the same two entities declare their inverse
1643
+ on ONE side only**. The author-and-editor model is written with both
1644
+ edges on `User` (`authored` by `authorId`, `edited` by `editorId`) and
1645
+ the foreign-key properties on `Post`, or with both edges on `Post` and
1646
+ none on `User`; declaring `Post.author` AND `User.edited` makes
1647
+ `User.authored` face `Post.editor` and is refused. Two declarations of
1648
+ the SAME kind with different `via`s are two independent edges — which
1649
+ is how a legitimate cycle is written (`Post.author` by `authorId` and
1650
+ `User.featured` by `featuredPostId`, one-to-one each way).
1651
+ Many-to-many pairs must agree on the join table, and a many-to-many
1652
+ facing a foreign-key relation is `JD0031`.
1653
+
1062
1654
  - **one-to-many** — `{ to, many: true, via, onDelete }`: `via` names
1063
1655
  the foreign-key property on the TARGET entity (`authorId` on
1064
1656
  `Post`). If the target declares that property it MUST be a
@@ -1096,19 +1688,37 @@ reads and deletes take `{ prop: value, … }`.
1096
1688
  Applied on write in JavaScript, never by SQL `DEFAULT`, so the value
1097
1689
  the application sees and the value stored are the same — and the
1098
1690
  behaviour is identical on every driver. `"now"` stamps an RFC 3339
1099
- UTC string on insert when the property is absent; `"updated"` stamps
1691
+ UTC string on insert when the property is absent the calendar date
1692
+ alone (`YYYY-MM-DD`) on a `format: date` property, so the stamp
1693
+ validates against its own format; `"updated"` stamps
1100
1694
  on insert AND on every update, always; `{ "value": … }` fills a
1101
1695
  literal when absent; `{ "query": … }` evaluates a query document over
1102
1696
  the document being written. Defaults run BEFORE validation, so the
1103
- injected hook sees the completed document.
1697
+ injected hook sees the completed document — with one member exempt: a
1698
+ store-allocated key (`default: "auto"`) is allocated by the database
1699
+ AFTER validation, so a write validates against the schema with that
1700
+ key dropped from `required` (the generated input type marks it
1701
+ optional for the same reason); the document the store answers carries
1702
+ it, and the read shape keeps it required. A `version` property is
1703
+ engine-owned and never defaulted by the caller: an insert without one
1704
+ writes `0` — not SQL `NULL`, which no `WHERE version = ?` guard could
1705
+ match — and every successful write bumps it (§11.5).
1104
1706
 
1105
1707
  ### 9.7 Error-code additions
1106
1708
 
1107
- The entity engine adds two codes to the package's single table (§7):
1108
- `JD0030` — an unknown `x-entity` member; `JD0031` — relation
1109
- declarations whose inverses contradict. Everything else raises the
1110
- existing codes (`JD0005` for structural model defects, `JD2005` for
1111
- database-refused writes including foreign-key violations).
1709
+ The entity engine adds four codes to the package's single table (§7):
1710
+ `JD0030` — an unknown or unread `x-entity` member; `JD0031` — relation
1711
+ declarations whose inverses contradict; `JD0033` an entity query
1712
+ document that binds no entity array (§10.1); `JD0034` — a cursor asked
1713
+ to track (`tracking: true`) over a document that yields no entity
1714
+ document to register: a projection, a count or a window (§10.1, the
1715
+ cursor). Everything else raises the
1716
+ existing codes: `JD0005` for structural model defects (a key, index or
1717
+ version property without a column of its own, a default on a relation,
1718
+ `default: "auto"` off the key, a self-referencing many-to-many, a
1719
+ foreign key onto a composite key), `JD2001` for a duplicate key on
1720
+ `create()` exactly as on a collection `insert`, and `JD2005` for
1721
+ database-refused writes including foreign-key violations.
1112
1722
 
1113
1723
  ## 10. Relational translation
1114
1724
 
@@ -1122,13 +1732,65 @@ translatable runs the set residual over the fetched entity root,
1122
1732
 
1123
1733
  `store.execute(document)` queries the **multi-entity root**: the
1124
1734
  engine-side value is `{ <EntityName>: [documents…], … }` and bindings
1125
- range over `$.<Entity>[*]`. This is the shape the differential oracle
1735
+ range over `$.<Entity>[*]`. A document that binds no entity array at
1736
+ all — a `$for` over a member the model does not name, or over a
1737
+ scalar — is `JD0033` at compile, never an empty answer. The
1738
+ array-constructor spelling `["$.<Entity>[*]"]`, which `@jarenjs/linq`
1739
+ emits so that an item that is itself an array stays one item
1740
+ (QUERY-PEN §3), names the same whole-entity source; the planner
1741
+ reads through it for collections (`["$[*]"]`) and entities alike.
1742
+
1743
+ An entity set is a provider (QUERY-PEN §8): `store.entity(name)` and
1744
+ `store.sync.entity(name)` carry `execute(document, options)` and
1745
+ `explain(document, options)` — the document is over the multi-entity
1746
+ root and goes to the entity translator whole, exactly as
1747
+ `store.execute` runs it — plus `root` (`"$.<Entity>[*]"`, the one
1748
+ spelling `collectEntityRoots` reads, so a chain binds its items through
1749
+ it) and `scope` (one identity per store, shared by every set, so two
1750
+ sets may be joined in one document). The store itself carries `roots`,
1751
+ the entity names, because it has no single root: a chain over it is
1752
+ refused by name (`JL0007`) rather than answering the mixed rows or
1753
+ counting the sets, and a document naming no entity array stays
1754
+ `JD0033`. A chain's element terminal hands over the one-item window
1755
+ `[<phrase>]` (QUERY-PEN §6); the planner reads through the window as
1756
+ it reads through the packed source — the phrase inside plans as if
1757
+ bare, and the store answers its rows as the ONE array item the engine
1758
+ would (`[]` for none, `[row]` for one, never singleton-unwrapped;
1759
+ `explain().wrapped` says so) — so `toArray()` over an entity set runs
1760
+ the translator, and a two-root equijoin returning a bare binding runs
1761
+ as one statement.
1762
+ This is the shape the differential oracle
1126
1763
  can actually prove — the in-memory engine sees exactly the documents
1127
1764
  the entity sets return (`test/db/oracle/relations/`). Relation-NAME
1128
1765
  navigation (`$.author.name`) is deliberately not query-document sugar:
1129
1766
  the engine has no embedded `author` member to walk, so no oracle could
1130
1767
  vouch for it. Name-based navigation lives on the `load` surface
1131
- (§10.4), where results and statement counts are the proof.
1768
+ (§10.4), where results and statement counts are the proof — and, by
1769
+ code, on a query producer that lowers a hop to what the engine CAN walk.
1770
+
1771
+ For that, every entity set carries **`relations`**, the relation table
1772
+ a producer may read: a frozen plain record, one entry per declared
1773
+ relation member of the entity, `{ to, kind, via?, fkEntity?, fkTargets?,
1774
+ joinTable?, targetKey }` — `to` the target entity, `kind` one of
1775
+ `oneToOne`/`oneToMany`/`manyToMany`; for a foreign-key relation `via`
1776
+ the key property, `fkEntity` the entity holding it, `fkTargets` the
1777
+ entity it references and `targetKey` the key property it references
1778
+ there (the column a hop's equality compares `via` with; `kind` says
1779
+ which side holds the key: `oneToOne` the declaring entity, `oneToMany`
1780
+ the target); for a many-to-many `joinTable` and the target's `targetKey`.
1781
+ The store (`store.relations`) and every set's `scope` (`scope.relations`)
1782
+ carry the same tables keyed by entity name, so a producer holding one
1783
+ set can follow a hop into another root. `relationTables(entities)` is
1784
+ the one function that derives them. The document format is unchanged:
1785
+ `@jarenjs/linq`'s chain reads the table and lowers `p.author.email` to
1786
+ `{ $for: { r1: '$.User[*]' }, $where: { $eq: ['$r1.id', '$it.authorId'] },
1787
+ $return: '$r1.email' }` (QUERY-PEN §4, relation navigation) — a
1788
+ document a hand could have written, which this translator and the
1789
+ engine both run and the oracle proves (`15-linq-hops.json`). Every such
1790
+ lowered shape is a residual here (§10.6 — a projection, a comparison or
1791
+ an existence test over a phrase rather than a member path, an ordering
1792
+ by a phrase), named by `explain()` and refused under `strict`; none
1793
+ pushes natively in this version.
1132
1794
 
1133
1795
  Per binding, predicates resolve through three reference flavors:
1134
1796
 
@@ -1212,26 +1874,137 @@ store.entity('User').load({
1212
1874
  })
1213
1875
  ```
1214
1876
 
1215
- Per-relation `where`/`orderBy`/`take` apply INSIDE the subquery — the
1216
- point where naive loaders fall back to N+1. Clauses compile against
1877
+ Per-relation `where`/`orderBy`/`take`/`skip` apply INSIDE the subquery
1878
+ (`LIMIT`/`OFFSET` per parent row) the point where naive loaders fall
1879
+ back to N+1; `after` (§10.5) paginates the ROOT alone, since a keyset
1880
+ cursor is one position in one ordered set, and on an include it is
1881
+ `JD0032`. Clauses compile against
1217
1882
  the child's own reference flavors; an untranslatable clause is a
1218
1883
  refusal (`JD0032`) naming the include path, never a silent residual.
1219
1884
  Include depth is bounded (default 3, override with `maxDepth`);
1220
1885
  exceeding it is `JD0032` with the bound printed. A cyclic include
1221
1886
  specification is rejected. Unknown relation names are `JD0032` too.
1222
1887
 
1888
+ **Every include is bounded per root.** One root graph is the unit a
1889
+ graph cursor yields and a page counts, and "one root" is no bound at
1890
+ all if one root may aggregate a million related rows. So a to-many
1891
+ include carries `maxRows` — related rows per parent — and every
1892
+ row-projecting include carries `maxBytes` — serialised bytes of the
1893
+ relation per parent, measured on the JSON text the database projects.
1894
+ A relation that crosses either bound is the coded refusal **`JD2073`**
1895
+ naming the root (entity and key), the member and the bound that was
1896
+ hit — never a truncated graph, which a caller could not tell from a
1897
+ whole one. The refusal names the two supported alternatives: read
1898
+ `{ count: true }` when the size is the question, or page that relation
1899
+ separately. A bound always exists: an include with none declared
1900
+ inherits the store defaults, `INCLUDE_ROWS_DEFAULT` (1000 rows) and
1901
+ `INCLUDE_BYTES_DEFAULT` (1 MiB); an include with a `take` has that
1902
+ window as its row bound. The unbounded case is spelled, never
1903
+ inherited — `maxRows: Infinity` (`null` in the JSON spec) — so loading
1904
+ a relation whole is a decision rather than an oversight. The to-many
1905
+ subquery carries `LIMIT maxRows + 1`, so the bound is detected at the
1906
+ bound instead of after aggregating the whole relation; a `count`
1907
+ include is a number and carries neither. `explainLoad().bounds` lists
1908
+ the bounds every include ran under.
1909
+
1910
+ **The graph cursor.** `store.entity(name).loadCursor(spec, options)` —
1911
+ `graph.cursor(options)` on the client — yields one root graph per
1912
+ pull, its includes attached and bounded as above, from the same one
1913
+ statement `load` runs: the include rows ride inside each root row as
1914
+ the JSON the database projected, so the window is the row itself and
1915
+ there is no second statement per level to hold or release. `return()`
1916
+ releases the statement exactly once; `signal` cancels at a row boundary
1917
+ (`JD2072`). The cursor registers nothing with the unit of work unless
1918
+ `tracking: true` is spelled per call — a snapshot per yielded root is a
1919
+ tracker that grows with the result.
1920
+
1223
1921
  ### 10.5 Pagination
1224
1922
 
1225
1923
  `$orderby` + `$subsequence` translate to `ORDER BY` + `LIMIT/OFFSET`
1226
- on the query surface. On the `load` surface, `after` (a cursor) with a
1227
- single ascending or descending ordering over a UNIQUE column the
1228
- key, or any `unique: true` column compiles to **keyset pagination**
1229
- (`WHERE col > ?` / `< ?`) instead of a growing `OFFSET`; `skip`
1230
- compiles to offset. `explainLoad()` reports which strategy ran
1231
- (`keyset` / `offset` / `none`) — offset degrading quietly on large
1232
- tables is a well-known footgun, and naming it is cheap. A cursor over
1233
- a non-unique column, a document path, or a multi-key ordering is
1234
- refused (`JD0032`).
1924
+ on the query surface. On the `load` surface `skip` compiles to offset,
1925
+ and `after` a cursor to **keyset pagination** (`WHERE …` over the
1926
+ last row's order keys) instead of a growing `OFFSET`; `explainLoad()`
1927
+ reports which strategy ran (`keyset` / `offset` / `none`) offset
1928
+ degrading quietly on large tables is a well-known footgun, and naming
1929
+ it is cheap.
1930
+
1931
+ **The single-column form.** `after: <value>` with a single ascending or
1932
+ descending ordering over a UNIQUE column — the key, or any `unique:
1933
+ true` column — compiles to `WHERE col > ?` / `< ?`. A scalar cursor
1934
+ over a non-unique column, a document path, or a multi-key ordering is
1935
+ refused (`JD0032`), because such a cursor either skips rows or repeats
1936
+ them wherever the value ties.
1937
+
1938
+ **The composite keyset.** The orderings a list actually wants —
1939
+ `(updatedAt, id)`, `(createdAt, id)`, `(priority desc, id)` — have a
1940
+ non-unique first column. `page()` and a structural `after` compile them
1941
+ as the lexicographic expansion over the declared terms
1942
+ `(k1 dir1, k2 dir2, …)` with **the primary key appended** as the
1943
+ tie-breaker whether or not the caller named it (it is the one column
1944
+ guaranteed unique, and a tie on every declared key would otherwise be a
1945
+ skipped row or a repeated one):
1946
+
1947
+ ```sql
1948
+ (k1 > v1) OR (k1 = v1 AND k2 > v2) OR (k1 = v1 AND k2 = v2 AND pk > vpk)
1949
+ ```
1950
+
1951
+ with `<` for a descending term, and the `ORDER BY` ending in the key
1952
+ column(s) rather than the row identity. **Null placement agrees with
1953
+ the plan**: every term's null order (`NULLS FIRST`/`LAST`, from `$dir`
1954
+ and `$empty`) is spelled in the expansion too — after a null value come
1955
+ the non-nulls when nulls sort first and nothing when they sort last;
1956
+ after a non-null value come the greater (or lesser) values and, when
1957
+ nulls sort last, the nulls — because SQL's `col > ?` is neither true
1958
+ nor false for `NULL`, and a comparison alone would visit a null-keyed
1959
+ row twice or never. Only mapped columns carry a keyset; a document path
1960
+ in the ordering is refused (`JD0032`).
1961
+
1962
+ **The continuation** a page emits is unsigned, structural and opaque:
1963
+
1964
+ ```jsonc
1965
+ { "order": [{ "column": "updatedAt", "desc": true, "nullsFirst": false },
1966
+ { "column": "id", "desc": false, "nullsFirst": false }],
1967
+ "keys": [v1], // the declared order-key values, as the document carries them
1968
+ "key": vpk } // the row's primary key (a record for a composite key)
1969
+ ```
1970
+
1971
+ `order` is the ordering's identity: a continuation replayed against a
1972
+ different ordering is the refusal `JD0035`, never a wrong page; a
1973
+ continuation whose `keys` do not match the declared key count, or
1974
+ whose `key` is not the entity's key shape, is `JD0035` too. Signing,
1975
+ tenant scoping, expiry and wire encoding are the **host's**: the store
1976
+ has no principal and no key, so any signature it invented would be
1977
+ security theatre — a host that ships a continuation to an untrusted
1978
+ client signs it first.
1979
+
1980
+ **The page.** `store.entity(name).page(spec, { limit, after, maxBytes,
1981
+ consistency, signal })` — `graph.page(options)` on the client — drains
1982
+ the graph cursor in keyset mode and answers `{ items, continuation,
1983
+ hasMore, snapshot }`: never more than `limit` roots (default
1984
+ `PAGE_LIMIT_DEFAULT`, 100) nor more than `maxBytes` serialised bytes
1985
+ (none unless given; every root is bounded by §10.4 regardless), the
1986
+ continuation of the last delivered root, and `hasMore` decided by one
1987
+ peek past the page. A `take` or `skip` in the spec beside `page()` is
1988
+ refused (`JD0032`): the page windows by its limit. **The
1989
+ `item_too_large` rule**: an item that alone exceeds `maxBytes` when
1990
+ nothing has been delivered yet is the coded refusal `JD2074`, raised
1991
+ without advancing the continuation — a caller that retries meets the
1992
+ same refusal, which is the honest answer, never a loop and never a
1993
+ silent breach; an item that does not fit beside earlier ones ends the
1994
+ page before it (`hasMore: true`). A page registers no snapshots unless
1995
+ `tracking: true` is spelled.
1996
+
1997
+ **Snapshot versus live.** A page reports `snapshot: true` only when
1998
+ every order key is immutable — and the primary key is the one column
1999
+ the engine itself guarantees never moves (`update()` refuses to rewrite
2000
+ it). Ordering a live table by a mutable column such as `updatedAt` is
2001
+ **live pagination**, and the page says so (`snapshot: false`): later
2002
+ inserts land where their keys sort, but an existing row whose order
2003
+ key changes between two pages can move across the cursor — it may be
2004
+ seen twice, or not at all — and no cutoff on later writes prevents
2005
+ that. `consistency: 'snapshot'` over such an ordering is refused
2006
+ (`JD0036`) rather than mislabelled; the default `'live'` reports the
2007
+ truth either way. A caller who needs a snapshot orders by the key.
1235
2008
 
1236
2009
  ### 10.6 What remains residual
1237
2010
 
@@ -1240,16 +2013,62 @@ because joins make residuals more expensive — accompanied by the
1240
2013
  `EXPLAIN QUERY PLAN` narrative (SQLite exposes no row estimates;
1241
2014
  a number appears only where `capabilities.rowEstimates` is filled):
1242
2015
 
1243
- - three or more bindings;
2016
+ - a binding nothing joins to — the cartesian product a nested-loop plan
2017
+ must never emit by accident; every binding past the first attaches by
2018
+ a column equality to one already joined, and a graph that does not
2019
+ close is the engine's;
1244
2020
  - non-equality join predicates, and disjunctions spanning bindings;
1245
- - `$groupby` (the engine's post-group cardinality rebinding deserves
1246
- its own order; the count-of-related-rows case ORMs are bad at is
1247
- already native via `count: true` includes);
1248
- - projections (`$return` objects) over one binding or across a join;
2021
+ - a `$groupby` whose key is untyped or admits `null`, whose `$return`
2022
+ reads the binding (after a grouping it holds the group's ROWS), or
2023
+ whose `$orderby` names anything but a group key; a window over the
2024
+ groups, or an aggregate of them;
2025
+ - projections (`$return` objects) ACROSS a join — over one binding a
2026
+ nested shape of member paths lowers (§ the projection tree);
1249
2027
  - externals against document paths; booleans and `null` at bind time;
1250
2028
  - everything phase A already listed (§8 of `QUERY-FORMAT.md`
1251
2029
  notwithstanding, the truth table is the contract).
1252
2030
 
2031
+ Two deviations between a pushed answer and the engine's are DECLARED
2032
+ rather than refused, because in both the database is right by its own
2033
+ arithmetic:
2034
+
2035
+ - `$sum`/`$avg` over `number` paths — SQLite sums with Kahan–Babuška
2036
+ compensation and the engine sums naively, so over `0.1, 0.2, 0.3`
2037
+ the store answers `0.6` and the residual `0.6000000000000001`: equal
2038
+ to within an ulp, never equal by `===` (the differential oracle
2039
+ draws dyadic fractions, which are exact on both sides).
2040
+ - a `$time-bucket` `$groupby` whose aggregated path admits `null`
2041
+ (`['number', 'null']`): the pushed `AVG` skips a `null` reading
2042
+ exactly as the series kernel — and therefore the `$resample`
2043
+ spelling — does, while the engine's `$avg` over a sequence holding
2044
+ `null` raises `JQ2001`; under this one spelling the store answers
2045
+ where the in-memory engine refuses. Everywhere else an ordering or
2046
+ an aggregate over a path that admits `null`, or over a boolean path,
2047
+ is a named residual, so the two paths keep answering alike.
2048
+
2049
+ ### 10.7 The join table as a query root
2050
+
2051
+ A declared many-to-many join table is a **read-only query root**:
2052
+ `$.<JoinTable>[*]` binds like any entity array and answers rows carrying
2053
+ exactly its two key columns — `<A>_key` and `<B>_key`, the names the DDL
2054
+ creates — and nothing else, because a join row has no document of its
2055
+ own. It joins to the entities it relates like any other binding, so
2056
+ `Person → Person_Tag → Tag` is one statement over three roots.
2057
+
2058
+ It is a ROOT and not an entity: `store.entity('<JoinTable>')` is
2059
+ `JD2004`, memberships are still written through `link`/`unlink` and the
2060
+ unit of work, and nothing about the table's lifecycle changes. The two
2061
+ namespaces are one, so a join table whose name is also a declared
2062
+ entity's is refused at open (`JD0005`) — `$.X[*]` may mean one thing.
2063
+
2064
+ Because the root exists, a many-to-many **hop** lowers: a chain's
2065
+ `u.labels` becomes two links — the join row that names the membership,
2066
+ then the target row it names — instead of the `JL0105` refusal it was.
2067
+ A relation entry that does not name its join row's columns
2068
+ (`{ joinTable, ownColumn, ownKey, targetColumn, targetKey }` — §10.1)
2069
+ still refuses with that code, because there is then nothing to lower
2070
+ through.
2071
+
1253
2072
 
1254
2073
  ## 11. The unit of work
1255
2074
 
@@ -1293,8 +2112,19 @@ const report = await store.saveChanges(); // one transaction
1293
2112
 
1294
2113
  ### 11.2 Explicit updates (the other mode)
1295
2114
 
1296
- `set.update(key, changes)` and `set.delete(key)` skip tracking: one
1297
- immediate statement, last-write-wins by contract. An explicit update
2115
+ `set.create(doc)`, `set.update(key, changes)` and `set.delete(key)`
2116
+ skip tracking: one immediate statement, last-write-wins by contract.
2117
+ `create` applies defaults, validates, inserts, and — for a
2118
+ many-to-many relation whose member the document carries as an array
2119
+ of target keys — writes the join rows in the same transaction, which
2120
+ rolls back whole when a membership names a row that does not exist; a
2121
+ duplicate key is `JD2001`. `update` takes the document's own members
2122
+ only: a relation member (`posts`) is `JD2003` exactly as on `create`,
2123
+ and a membership array (`labels`) — which `create` writes — is
2124
+ `JD2003` on `update` as well, because an explicit update never writes
2125
+ join rows or child rows and never echoes back a member it did not
2126
+ store; a change to a key member is `JD2003` too (the key column would
2127
+ go stale — delete and create). An explicit update
1298
2128
  still bumps a declared version property, so optimistic savers observe
1299
2129
  the row changed. This is the path reactive layers and job runners use.
1300
2130
 
@@ -1337,7 +2167,10 @@ the 100× statement reduction is the point for anything remote.
1337
2167
 
1338
2168
  ### 11.5 Optimistic concurrency
1339
2169
 
1340
- Declare a token with `version: true` (§9.2). Every `saveChanges()`
2170
+ Declare a token with `version: true` (§9.2). A row inserted without
2171
+ one starts at `0` — never SQL `NULL`, which no guard could match
2172
+ (§9.6) — so the first tracked save after `create()`/`add()` carries
2173
+ `WHERE version = 0`. Every `saveChanges()`
1341
2174
  update and guarded delete carries `WHERE version = ?` (the SNAPSHOT
1342
2175
  version) and writes snapshot + 1; a zero-row result is **`JD2040`**
1343
2176
  carrying the entity and key, and the whole save rolls back. Without a
@@ -1352,8 +2185,14 @@ zero rows.) An unguarded delete of a missing row is a no-op.
1352
2185
  `saveChanges()` is all-or-nothing inside one transaction. On ANY
1353
2186
  failure the tracker is left exactly as it was before the call — the
1354
2187
  same save can be retried once the cause is gone; a half-applied
1355
- tracker is worse than a rollback. Only a committed save advances
1356
- snapshots (bumped versions, generated keys) and clears pending work.
2188
+ tracker is worse than a rollback. A save whose statements all succeed
2189
+ advances snapshots (bumped versions, generated keys) and clears pending
2190
+ work **immediately**, even inside an enclosing transaction — inside it
2191
+ the database does hold those rows, and every later read, plan and
2192
+ optimistic guard must agree. The scope that owns the connection holds
2193
+ the exact undo delta: an enclosing rollback (or a named-savepoint
2194
+ rollback past the save, §5.2) withdraws the advance, a nested rollback
2195
+ withdraws only its own effects, and outer commit keeps it.
1357
2196
 
1358
2197
  The return value is data, not a boolean:
1359
2198
 
@@ -1367,3 +2206,46 @@ The return value is data, not a boolean:
1367
2206
  elapsedMs,
1368
2207
  }
1369
2208
  ```
2209
+
2210
+ ### 11.7 Membership: `link` and `unlink`
2211
+
2212
+ A many-to-many membership is attached or detached one row at a time
2213
+ through the unit of work, without carrying the whole membership array:
2214
+
2215
+ ```js
2216
+ users.link('u1', 'labels', 'admin'); // a key, or a document carrying the key
2217
+ users.unlink(ada, 'labels', { name: 'dev' }); // the own side is a key or a document too
2218
+ const report = await store.saveChanges(); // { joinInserted: 1, joinDeleted: 1, … }
2219
+ ```
2220
+
2221
+ - `link`/`unlink` are local, synchronous bookkeeping, like `add`/`put`/
2222
+ `remove`; nothing reaches the database until `saveChanges()`.
2223
+ - The member MUST be a many-to-many relation of the entity — `JD2003`
2224
+ otherwise, naming the relation's kind or the missing declaration. The
2225
+ target is read exactly as a membership array's element is: a key, or
2226
+ a document carrying the target's key (`JD2003` when it carries none).
2227
+ - The own side needs the entity's key. A pending insert whose `auto`
2228
+ key the save allocates has none to attach to and is refused (`JD2003`:
2229
+ "save the entity first, then attach"); a pending insert with a
2230
+ caller-supplied key may be linked in the same save, since join rows
2231
+ run after the inserts (§11.4).
2232
+ - The baseline is the join table **as read at save time**, not a
2233
+ snapshot: linking a member that already exists and unlinking one that
2234
+ does not are no-ops, so a save repeated with the same calls changes
2235
+ nothing (asserted). The last word on one target wins — `unlink` after
2236
+ `link` means unlink.
2237
+ - A `link`/`unlink` beside a `put` carrying the SAME member's membership
2238
+ array folds into that array's key-set difference (§11.3): one intent
2239
+ per entity, own key and member, never two statements racing for one
2240
+ row.
2241
+ - A tracked snapshot's loaded membership array is not rewritten by a
2242
+ saved `link`/`unlink` — a projection stays what it was read as;
2243
+ re-read (`load({ include })`) to see the membership. `discard(key)`
2244
+ drops the key's pending membership changes together with its
2245
+ tracking, and a failed save leaves them pending, as §11.6 promises.
2246
+ - The report counts the rows written under `joinInserted`/`joinDeleted`
2247
+ and `stats().tracker.pendingMemberships` counts the pending
2248
+ (entity, key, member) records.
2249
+
2250
+ Worker/pool options, synchronous cursors, wasm session probing and the browser
2251
+ persistence ladder are specified in [execution hosts](HOSTS.md).