@jarenjs/db 0.56.0 → 0.67.0

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 (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
@@ -198,8 +198,8 @@ 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 <!--fact:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/fact-->
202
- on the read, and costs <!--fact:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->
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
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-->
@@ -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
@@ -648,6 +750,11 @@ and may itself call `transaction`; each level is one savepoint. A
648
750
  throw rolls back exactly its own level and rethrows — an outer
649
751
  transaction that catches the error continues and its own work
650
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.
651
758
 
652
759
  **On an asynchronous driver a refused write rejects.** Every write —
653
760
  `insert`, `put`, `patch`, `delete`, the entity set's `create`/`update`/
@@ -669,39 +776,209 @@ So a **top-level transaction owns its connection until it settles**, and
669
776
  an overlapping one waits its turn. Two concurrent request handlers
670
777
  sharing a store both commit, and both report success.
671
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
+
672
844
  Nesting is asked for in one of two ways, and the difference is not
673
845
  cosmetic:
674
846
 
675
847
  - **Synchronously** — a `transaction` called while an owning callback is
676
848
  still on the stack nests, because nothing can interleave there. This is
677
- `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.
678
852
  - **Through the scope** — an `async` callback has already awaited, so the
679
853
  stack cannot say whether a request is its own nested work or an
680
- 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`:
681
932
 
682
- ```js
683
- await store.transaction(async (tx) => {
684
- await store.collection('docs').put(doc, 'a'); // joins this transaction
685
- await tx.transaction(async () => { … }); // nests inside it
686
- });
687
- ```
688
-
689
- Reaching back through the outer `store.transaction` from inside a
690
- callback queues behind the transaction the caller is part of, so it
691
- waits for itself; after `queueTimeout` (default 5 s, the busy-timeout
692
- default) that becomes `JD0012` naming the fix rather than hanging.
693
-
694
- **One residual, stated plainly.** A bare statement issued while a
695
- transaction is open JOINS that transaction and shares its fate, because
696
- SQLite has no per-statement transaction scope and every operation inside
697
- a callback reaches the connection the same way an unrelated caller does.
698
- Work that must be in the transaction is therefore safe; an unrelated
699
- writer on a SHARED store is not. Give each concurrent writer its own
700
- store when independent writes must not share a rollback. Closing this
701
- — scope-bound `tx` handles, with store-level calls waiting on the gate
702
- while a foreign scope is open is an open ROADMAP item (`@jarenjs/db`,
703
- "Strong same-store transaction ownership"); until it lands, one store
704
- 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.
705
982
 
706
983
  ## 6. Identity
707
984
 
@@ -733,10 +1010,13 @@ error.
733
1010
  | code | raised when |
734
1011
  |---|---|
735
1012
  | `JD0001` | the SQLite library is below the supported floor |
736
- | `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 |
737
1014
  | `JD0003` | the driver binding is unavailable on this runtime |
738
1015
  | `JD0004` | a declared index cannot be mapped to a column |
739
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 |
740
1020
  | `JD0010` | strict mode refused a residual |
741
1021
  | `JD0011` | the profile refused the document |
742
1022
  | `JD0012` | work waited too long for the open transaction to settle |
@@ -744,6 +1024,10 @@ error.
744
1024
  | `JD0031` | relation declarations contradict each other |
745
1025
  | `JD0032` | the include specification is invalid |
746
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 |
747
1031
  | `JD0040` | the save spans a relation cycle |
748
1032
  | `JD0050` | live queries require change capture |
749
1033
  | `JD0051` | the demanded live mode is unavailable |
@@ -763,16 +1047,161 @@ error.
763
1047
  | `JD2061` | another context owns the database |
764
1048
  | `JD2062` | the store closed with job handlers still in flight |
765
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 |
1081
+ | `JD0060` | a replication envelope or snapshot is invalid |
1082
+ | `JD2100` | a replica sequence or causal dependency has a gap |
1083
+ | `JD2101` | an envelope identity names different content or an unknown local origin |
1084
+ | `JD2102` | the replica identity or model revision disagrees |
1085
+ | `JD2103` | a resolver fails its synchronous decision contract |
1086
+ | `JD2104` | a logical row or snapshot disagrees with its causal history |
1087
+ | `JD2105` | an explicit snapshot reset is required, or reset would discard acknowledged history |
1088
+ | `JD2106` | a replication operation or snapshot exceeds its configured bound |
766
1089
 
767
1090
  The table above is proven in sync with the runtime `DB_CODES` table by
768
1091
  a test.
769
1092
 
1093
+ **One classification of driver failures.** Every path that meets a
1094
+ driver error — a collection or entity write, the job queue, the query
1095
+ path, a maintenance operation, the backup, the open sequence —
1096
+ consults one table (`classifyDriverError`), so the same failure arrives
1097
+ under the same code with the same `class` and `retryable` verdict
1098
+ whichever path met it: `busy` (SQLITE_BUSY/LOCKED → `JD2005`,
1099
+ retryable), `full` (`JD2082`), `readonly` (`JD2083`), `io` (`JD2084`),
1100
+ `corrupt` (`JD2085`), `cantopen` and `constraint` (`JD2005`), `duplicate`
1101
+ (a UNIQUE collision on the key column → `JD2001`), `overflow` (a pushed
1102
+ integer aggregate past int64 — never raised: the query path re-runs the
1103
+ document in the engine and answers the double, and `explain().fallback`
1104
+ records it), and the fallback `error` (`JD2005`). A classified error
1105
+ carries `class`, `retryable` and the driver's error as `cause`; a
1106
+ lifecycle that owns its failure code (`JD2078` for maintenance and
1107
+ backup, `JD0002` at open) keeps the code and still carries the class.
1108
+
1109
+ The ENGINE is discriminated by the evidence the error itself carries,
1110
+ never by a table threaded down from the caller: a SQLite binding
1111
+ attaches a numeric result code, a PostgreSQL one attaches a
1112
+ five-character SQLSTATE. Both land in the classes above, and three
1113
+ conditions a single-writer file database does not have get their own:
1114
+ `connection` (`JD2087`, retryable — SQLSTATE class 08 and the
1115
+ administrator's own terminations), `aborted` (`JD2088` — SQLSTATE
1116
+ `25P02`, a statement issued after an earlier failure inside the same
1117
+ transaction) and `cancelled` (`JD2089` — SQLSTATE `57014`). A
1118
+ serialization failure or a deadlock (`40001`, `40P01`) is `busy` and
1119
+ retryable, which is the same verdict, and the same caller branch, a
1120
+ locked SQLite file gets.
1121
+ An engine error thrown inside a pushed user function (`JQ…`) is not a
1122
+ driver error: it passes through untouched, relocated onto the caller's
1123
+ document path (`/$where/…`, never the hatch's `/$return/…`).
1124
+
1125
+ ## 7A. Model-declared index expressions
1126
+
1127
+ An index may name a computation instead of a member:
1128
+
1129
+ ```json
1130
+ {
1131
+ "name": "by_lower_email",
1132
+ "expression": { "call": "lower", "args": [{ "member": "$.email" }] },
1133
+ "unique": true
1134
+ }
1135
+ ```
1136
+
1137
+ `expression` is mutually exclusive with `path` and with `derive`
1138
+ (`JD0004`): an expression names the members it reads itself, and a
1139
+ derived spatial column IS an expression this format spells for you.
1140
+
1141
+ **The vocabulary is closed — three node kinds and no fourth**, and none
1142
+ of them is SQL text:
1143
+
1144
+ | node | meaning |
1145
+ |---|---|
1146
+ | `{ "member": "$.a.b" }` | a singular JSONPath expression into the stored document |
1147
+ | `{ "value": 1 }` | a JSON string, number or boolean. A `null` or a compound has no place in an index expression |
1148
+ | `{ "call": "lower", "args": [ … ] }` | a function the HOST declared, applied to its arguments in order |
1149
+
1150
+ Argument order is significant: `sub(a, b)` and `sub(b, a)` are
1151
+ different expressions and different columns. An expression nests at
1152
+ most eight deep.
1153
+
1154
+ **A function is DECLARED by the host, never created by the store.**
1155
+ `openStore(model, { expressions })` takes one declaration per name:
1156
+
1157
+ ```jsonc
1158
+ {
1159
+ "lower": {
1160
+ "arity": 1,
1161
+ "deterministic": true, // required, and never inferred
1162
+ "apply": (value) => …, // an engine that registers functions calls this
1163
+ "sql": "lower" // one that cannot calls this IMMUTABLE function
1164
+ }
1165
+ }
1166
+ ```
1167
+
1168
+ Why a declaration rather than an escape hatch: **an index over a
1169
+ function is a schema dependency.** A database whose column is computed
1170
+ by `lower(…)` cannot be written from a connection that has no `lower`,
1171
+ and a raw-SQL index would have had exactly that hazard with none of the
1172
+ checking. Here the model names the function, every store that opens the
1173
+ model is handed the same declaration, and a store that cannot honour
1174
+ one refuses at open — `JD0004`, before a single statement:
1175
+
1176
+ - a name this store was not given
1177
+ - an arity the expression does not match
1178
+ - a function not declared `deterministic`
1179
+ - no `apply` where the engine computes the value itself
1180
+ - no `sql` name where the engine calls its own — and that name is an
1181
+ identifier, never SQL text
1182
+
1183
+ **How each engine computes it.** SQLite registers `apply` as a
1184
+ deterministic function under a namespaced name (`jaren_x_lower`) — so a
1185
+ model's `lower` never shadows the engine's own — and the column is
1186
+ `GENERATED ALWAYS AS (jaren_x_lower(<member>)) VIRTUAL`. PostgreSQL
1187
+ registers nothing: the column is `GENERATED ALWAYS AS (lower(<member>))
1188
+ STORED` over the immutable function the host promised the server has.
1189
+ Both read the member as its own SCALAR, so `lower` of a string is the
1190
+ same answer on both.
1191
+
1192
+ One column serves every index that declares the same canonical
1193
+ expression, and the canonical form — order-preserving — is what the
1194
+ shape hash and the migration diff read. `store.introspect()` reads the
1195
+ expression back out of the SQL the dialect wrote (each dialect reads its
1196
+ own), so a declared expression index survives the round trip; without
1197
+ the declarations the column is REPORTED as unmapped rather than guessed.
1198
+
770
1199
  ## 8. The safe execution profile
771
1200
 
772
1201
  A query document that arrives from a tenant, a remote client or a
773
1202
  language model can reach a database. Parameter binding makes injection
774
1203
  structurally impossible; it does nothing about resource exhaustion or
775
- cross-tenant reads. A **profile** composes four independent bounds:
1204
+ cross-tenant reads. A **profile** composes five independent bounds:
776
1205
 
777
1206
  ```js
778
1207
  const store = await openStore(model, { driver, profile: 'safe' });
@@ -788,7 +1217,91 @@ so a store-level mandatory predicate or allow-list does not carry into
788
1217
  per call, or set it once on the store and pass none. The defaults: engine limits
789
1218
  `{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
790
1219
  `maxRows: 1000`, no externals, no host functions, no collations, all
791
- of the store's collections, no mandatory predicates, no scan refusal.
1220
+ of the store's collections, no mandatory predicates, no scan refusal,
1221
+ and no graph caps (`maxIncludedRows`, `maxDepth`, `maxBytes` all
1222
+ `null`), and no member allow-list (`members: null`).
1223
+
1224
+ **One profile, every engine.** A profile — the store's, or the call's
1225
+ through `ExecuteOptions.profile` on `execute`, `query`, `cursor`,
1226
+ `loadCursor`, `page` and `explain` alike — applies identically to
1227
+ collection execution, entity execution (`entity.execute`, the chain's
1228
+ cursor), graph loading (`load`, `loadCursor`, `page`, every include
1229
+ subquery) and store-root execution (`store.execute`). `collections` is
1230
+ the one allow-list and names collections AND entity roots; a document
1231
+ that reads a name outside it is `JD0011` on every engine, with the
1232
+ same reason. `predicates` is keyed by collection or entity name, and
1233
+ an entity's predicate is conjoined into every fetch of that entity —
1234
+ the native statement, each root the residual fetches, the load's root
1235
+ and every include subquery over that entity. `maxRows` bounds every
1236
+ fetch on every engine (`JD2007`), the residual's input rows included:
1237
+ each root an entity residual fetches carries `LIMIT maxRows + 1`. The
1238
+ graph caps are hard maxima an include's own declaration cannot exceed:
1239
+ `maxIncludedRows` refuses an include that declares more rows per root
1240
+ (or `Infinity`), `maxDepth` refuses a deeper load, and `maxBytes`
1241
+ refuses any one item — a document, an entity row, a loaded root graph —
1242
+ larger than that many serialised bytes (`JD2076`).
1243
+
1244
+ **The member allow-list.** `collections` says which roots a document
1245
+ may read; `members` says which MEMBERS of a root it may read. It is
1246
+ keyed by collection or entity name, and each value is a list in the
1247
+ model's own singular index-path spelling:
1248
+
1249
+ ```js
1250
+ collection.query(doc, { profile: {
1251
+ members: { docs: ['$.id', '$.n'], User: ['$.id', '$.age'] } } });
1252
+ ```
1253
+
1254
+ A root the list does not name is unrestricted; a root the MODEL does
1255
+ not declare is `JD0011` before any statement runs, because a policy
1256
+ that applies to nothing is a policy failing open. Allowing a member
1257
+ allows everything UNDER it (`$.address` allows `$.address.city`) and
1258
+ none of its siblings (`$.address.city` does not allow `$.address`,
1259
+ which would answer the rest of the address). One collector reads every
1260
+ member path a document references — in `$where`, in `$orderby`, in
1261
+ `$return`, in a join condition, in a registered operator's operands —
1262
+ so a denial is the same `JD0011` wherever the member was named, and it
1263
+ names the root, the member and the place in the caller's document.
1264
+
1265
+ Reading a root item WHOLE is refused, not narrowed: `$return: '$it'`,
1266
+ an alias of the binding through `$let`, and a wildcard with no singular
1267
+ prefix each answer members the list does not allow, and no list can
1268
+ cover them. For the same reason a graph `load()`, `page()` or
1269
+ `loadCursor()` over a policed entity is refused — it answers whole
1270
+ documents by definition — and the refusal names the members that ARE
1271
+ allowed, which the document query engine can project.
1272
+
1273
+ **Enforced, or refused — never approximated.** A budget the engine
1274
+ can COUNT is enforced during execution: rows returned, rows
1275
+ materialised, residual-input rows, included rows per root, depth,
1276
+ bytes, and the engine limits. A budget SQLite cannot measure is
1277
+ refused at preflight on plan shape instead: `refuseFullScan` declines
1278
+ a native plan whose own `EXPLAIN QUERY PLAN` shows a full-table scan,
1279
+ and declines an entity residual outright, because the residual reads
1280
+ every row of every referenced root before the engine decides — that
1281
+ is a full-table scan by shape, so it is refused before any fetch
1282
+ rather than estimated. Visited-row and elapsed-time budgets exist
1283
+ only where a driver supplies a progress or interrupt hook; the
1284
+ shipped SQLite drivers supply neither, and `explain().budget` reports
1285
+ `time: 'unavailable'` and `estimatedRows: 'unavailable'` rather than
1286
+ a number nothing measured.
1287
+
1288
+ **`signal` and `deadline`.** `ExecuteOptions.signal` cancels: a call
1289
+ already aborted issues no statement (`JD2072`), and a cursor or page
1290
+ releases its statement at the next row boundary. `deadline` is an
1291
+ epoch-millisecond instant checked before a statement runs and at
1292
+ every row boundary of a cursor or page (`JD2075`) — a row-boundary
1293
+ check, never a statement interrupt, for the reason above. The clock it
1294
+ is checked against is the store's runtime record's `now`
1295
+ (`@jarenjs/core/runtime`; the platform clock with no record), so a
1296
+ caller under an injected clock computes deadlines from that clock.
1297
+
1298
+ **Provenance.** Every `explain()` — collection, entity, graph —
1299
+ carries `budget`: the profile that applied and from where (`{ source:
1300
+ 'call' | 'store', name: 'safe' | 'custom' }`, or `null`), every bound
1301
+ it imposed (`rows`, `includedRows`, `depth`, `bytes`, `limits`), the
1302
+ scan verdict (`'refused-by-shape'` or `'unbounded'`), and the two
1303
+ driver slots by name (`time`, `estimatedRows`). A budget nobody can
1304
+ prove was applied is not a budget.
792
1305
 
793
1306
  1. **Engine limits.** The four engine limits ride into every residual
794
1307
  compilation, so the JavaScript portion of a query is bounded by the
@@ -956,6 +1469,38 @@ to build one on — a crude guess would be dishonest. It pushes
956
1469
  deterministically and this profile is published so the shape of the win
957
1470
  is known; add a narrowing predicate or a `LIMIT` and the push pays.
958
1471
 
1472
+ **`pushable: 'aggregate'` — the whole-sequence fold, in SQL.** A pack
1473
+ entry marked `'aggregate'` is lowered to a registered **SQL aggregate**
1474
+ where the driver has one (`capabilities.aggregateFunctions`): the plan
1475
+ emits `… SELECT jaren_a_<hash>(<the member's column>)`, SQLite drives
1476
+ the accumulation, and the SAME pure function the residual would call
1477
+ folds the values it collected. `$mean`, `$median`, `$variance` and
1478
+ `$stddev` of the statistics pack carry the token.
1479
+
1480
+ The token is a promise about the FOLD, and three rules enforce it:
1481
+
1482
+ - **order-insensitive.** A SQL aggregate visits rows in an order
1483
+ nothing specifies, so only a summary whose value depends on the
1484
+ multiset alone may be declared pushable.
1485
+ - **one sequence operand.** A SQL aggregate's final step sees only what
1486
+ the row steps accumulated, so a second operand does not reach a fold
1487
+ over zero rows. An entry declaring anything but a single
1488
+ `'seq<number>'` operand and a `'number'` result is a `TypeError` at
1489
+ `openStore`, naming the operator — loud, rather than a silent
1490
+ non-promotion. `$percentile`, whose second operand is the percentile,
1491
+ is therefore `pushable: false` and folds in the engine.
1492
+ - **a numeric member that cannot hold `null`.** SQL cannot tell a
1493
+ stored `null` from an absent member and the engine's sequence can, so
1494
+ the promotion needs the same schema-typed path the core `$sum` and
1495
+ `$avg` need. An absent member contributes nothing on either side.
1496
+
1497
+ A grouped fold is not promoted — a `$groupby` outside the fixed
1498
+ temporal bucket is engine work (§6) — and neither is an aggregate under
1499
+ a window. Both answer what the engine answers, and `explain()` names
1500
+ the reason. As with the scalar hatch, a profiled document triggers no
1501
+ registration: the same `$mean` under a profile folds in the residual,
1502
+ and `strict: true` refuses it by name (`JD0010`).
1503
+
959
1504
  **The same profile for a spatial predicate.** A `$within` against a
960
1505
  LITERAL region takes the hatch only on a collection that declares **no**
961
1506
  derived spatial index on the member: where one is declared the
@@ -986,15 +1531,11 @@ it returns, which is faster than either column above.
986
1531
 
987
1532
  **The honest ceiling.** A `pushable:false` operator (a whole-series
988
1533
  `$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
989
- lists no `udfs` for it. Aggregate-UDF pushdown (`db.aggregate` step/final
990
- over `GROUP BY`) is **not emitted**: no shipped pack marks an entry
991
- `pushable:'aggregate'` (the finance/stats aggregators fold a *per-document*
992
- sequence that is a per-row scalar to SQL, already covered by the scalar
993
- path where marked not a cross-row column), and cross-row aggregate
994
- pushdown additionally waits on `$groupby` pushdown, itself a deliberate
995
- residual today. The `aggregateFunctions` capability is probed and
996
- reported regardless, so the day a pack marks `'aggregate'` the driver
997
- gate is already in place.
1534
+ lists no `udfs` for it, and neither does a `pushable:'aggregate'` entry
1535
+ whose shape the store refuses (above). A registered aggregate lowers
1536
+ only as the top-level fold of a whole selection: inside a `$groupby`'s
1537
+ `$return` the closed BUILT-IN set is what SQL groups by, and a
1538
+ registered summary there stays the engine's.
998
1539
 
999
1540
  ## 9. Entities, the `x-entity` vocabulary, relations
1000
1541
 
@@ -1173,10 +1714,13 @@ match — and every successful write bumps it (§11.5).
1173
1714
 
1174
1715
  ### 9.7 Error-code additions
1175
1716
 
1176
- The entity engine adds three codes to the package's single table (§7):
1717
+ The entity engine adds four codes to the package's single table (§7):
1177
1718
  `JD0030` — an unknown or unread `x-entity` member; `JD0031` — relation
1178
1719
  declarations whose inverses contradict; `JD0033` — an entity query
1179
- document that binds no entity array (§10.1). Everything else raises the
1720
+ document that binds no entity array (§10.1); `JD0034` a cursor asked
1721
+ to track (`tracking: true`) over a document that yields no entity
1722
+ document to register: a projection, a count or a window (§10.1, the
1723
+ cursor). Everything else raises the
1180
1724
  existing codes: `JD0005` for structural model defects (a key, index or
1181
1725
  version property without a column of its own, a default on a relation,
1182
1726
  `default: "auto"` off the key, a self-referencing many-to-many, a
@@ -1349,18 +1893,126 @@ Include depth is bounded (default 3, override with `maxDepth`);
1349
1893
  exceeding it is `JD0032` with the bound printed. A cyclic include
1350
1894
  specification is rejected. Unknown relation names are `JD0032` too.
1351
1895
 
1896
+ **Every include is bounded per root.** One root graph is the unit a
1897
+ graph cursor yields and a page counts, and "one root" is no bound at
1898
+ all if one root may aggregate a million related rows. So a to-many
1899
+ include carries `maxRows` — related rows per parent — and every
1900
+ row-projecting include carries `maxBytes` — serialised bytes of the
1901
+ relation per parent, measured on the JSON text the database projects.
1902
+ A relation that crosses either bound is the coded refusal **`JD2073`**
1903
+ naming the root (entity and key), the member and the bound that was
1904
+ hit — never a truncated graph, which a caller could not tell from a
1905
+ whole one. The refusal names the two supported alternatives: read
1906
+ `{ count: true }` when the size is the question, or page that relation
1907
+ separately. A bound always exists: an include with none declared
1908
+ inherits the store defaults, `INCLUDE_ROWS_DEFAULT` (1000 rows) and
1909
+ `INCLUDE_BYTES_DEFAULT` (1 MiB); an include with a `take` has that
1910
+ window as its row bound. The unbounded case is spelled, never
1911
+ inherited — `maxRows: Infinity` (`null` in the JSON spec) — so loading
1912
+ a relation whole is a decision rather than an oversight. The to-many
1913
+ subquery carries `LIMIT maxRows + 1`, so the bound is detected at the
1914
+ bound instead of after aggregating the whole relation; a `count`
1915
+ include is a number and carries neither. `explainLoad().bounds` lists
1916
+ the bounds every include ran under.
1917
+
1918
+ **The graph cursor.** `store.entity(name).loadCursor(spec, options)` —
1919
+ `graph.cursor(options)` on the client — yields one root graph per
1920
+ pull, its includes attached and bounded as above, from the same one
1921
+ statement `load` runs: the include rows ride inside each root row as
1922
+ the JSON the database projected, so the window is the row itself and
1923
+ there is no second statement per level to hold or release. `return()`
1924
+ releases the statement exactly once; `signal` cancels at a row boundary
1925
+ (`JD2072`). The cursor registers nothing with the unit of work unless
1926
+ `tracking: true` is spelled per call — a snapshot per yielded root is a
1927
+ tracker that grows with the result.
1928
+
1352
1929
  ### 10.5 Pagination
1353
1930
 
1354
1931
  `$orderby` + `$subsequence` translate to `ORDER BY` + `LIMIT/OFFSET`
1355
- on the query surface. On the `load` surface, `after` (a cursor) with a
1356
- single ascending or descending ordering over a UNIQUE column the
1357
- key, or any `unique: true` column compiles to **keyset pagination**
1358
- (`WHERE col > ?` / `< ?`) instead of a growing `OFFSET`; `skip`
1359
- compiles to offset. `explainLoad()` reports which strategy ran
1360
- (`keyset` / `offset` / `none`) — offset degrading quietly on large
1361
- tables is a well-known footgun, and naming it is cheap. A cursor over
1362
- a non-unique column, a document path, or a multi-key ordering is
1363
- refused (`JD0032`).
1932
+ on the query surface. On the `load` surface `skip` compiles to offset,
1933
+ and `after` a cursor to **keyset pagination** (`WHERE …` over the
1934
+ last row's order keys) instead of a growing `OFFSET`; `explainLoad()`
1935
+ reports which strategy ran (`keyset` / `offset` / `none`) offset
1936
+ degrading quietly on large tables is a well-known footgun, and naming
1937
+ it is cheap.
1938
+
1939
+ **The single-column form.** `after: <value>` with a single ascending or
1940
+ descending ordering over a UNIQUE column — the key, or any `unique:
1941
+ true` column — compiles to `WHERE col > ?` / `< ?`. A scalar cursor
1942
+ over a non-unique column, a document path, or a multi-key ordering is
1943
+ refused (`JD0032`), because such a cursor either skips rows or repeats
1944
+ them wherever the value ties.
1945
+
1946
+ **The composite keyset.** The orderings a list actually wants —
1947
+ `(updatedAt, id)`, `(createdAt, id)`, `(priority desc, id)` — have a
1948
+ non-unique first column. `page()` and a structural `after` compile them
1949
+ as the lexicographic expansion over the declared terms
1950
+ `(k1 dir1, k2 dir2, …)` with **the primary key appended** as the
1951
+ tie-breaker whether or not the caller named it (it is the one column
1952
+ guaranteed unique, and a tie on every declared key would otherwise be a
1953
+ skipped row or a repeated one):
1954
+
1955
+ ```sql
1956
+ (k1 > v1) OR (k1 = v1 AND k2 > v2) OR (k1 = v1 AND k2 = v2 AND pk > vpk)
1957
+ ```
1958
+
1959
+ with `<` for a descending term, and the `ORDER BY` ending in the key
1960
+ column(s) rather than the row identity. **Null placement agrees with
1961
+ the plan**: every term's null order (`NULLS FIRST`/`LAST`, from `$dir`
1962
+ and `$empty`) is spelled in the expansion too — after a null value come
1963
+ the non-nulls when nulls sort first and nothing when they sort last;
1964
+ after a non-null value come the greater (or lesser) values and, when
1965
+ nulls sort last, the nulls — because SQL's `col > ?` is neither true
1966
+ nor false for `NULL`, and a comparison alone would visit a null-keyed
1967
+ row twice or never. Only mapped columns carry a keyset; a document path
1968
+ in the ordering is refused (`JD0032`).
1969
+
1970
+ **The continuation** a page emits is unsigned, structural and opaque:
1971
+
1972
+ ```jsonc
1973
+ { "order": [{ "column": "updatedAt", "desc": true, "nullsFirst": false },
1974
+ { "column": "id", "desc": false, "nullsFirst": false }],
1975
+ "keys": [v1], // the declared order-key values, as the document carries them
1976
+ "key": vpk } // the row's primary key (a record for a composite key)
1977
+ ```
1978
+
1979
+ `order` is the ordering's identity: a continuation replayed against a
1980
+ different ordering is the refusal `JD0035`, never a wrong page; a
1981
+ continuation whose `keys` do not match the declared key count, or
1982
+ whose `key` is not the entity's key shape, is `JD0035` too. Signing,
1983
+ tenant scoping, expiry and wire encoding are the **host's**: the store
1984
+ has no principal and no key, so any signature it invented would be
1985
+ security theatre — a host that ships a continuation to an untrusted
1986
+ client signs it first.
1987
+
1988
+ **The page.** `store.entity(name).page(spec, { limit, after, maxBytes,
1989
+ consistency, signal })` — `graph.page(options)` on the client — drains
1990
+ the graph cursor in keyset mode and answers `{ items, continuation,
1991
+ hasMore, snapshot }`: never more than `limit` roots (default
1992
+ `PAGE_LIMIT_DEFAULT`, 100) nor more than `maxBytes` serialised bytes
1993
+ (none unless given; every root is bounded by §10.4 regardless), the
1994
+ continuation of the last delivered root, and `hasMore` decided by one
1995
+ peek past the page. A `take` or `skip` in the spec beside `page()` is
1996
+ refused (`JD0032`): the page windows by its limit. **The
1997
+ `item_too_large` rule**: an item that alone exceeds `maxBytes` when
1998
+ nothing has been delivered yet is the coded refusal `JD2074`, raised
1999
+ without advancing the continuation — a caller that retries meets the
2000
+ same refusal, which is the honest answer, never a loop and never a
2001
+ silent breach; an item that does not fit beside earlier ones ends the
2002
+ page before it (`hasMore: true`). A page registers no snapshots unless
2003
+ `tracking: true` is spelled.
2004
+
2005
+ **Snapshot versus live.** A page reports `snapshot: true` only when
2006
+ every order key is immutable — and the primary key is the one column
2007
+ the engine itself guarantees never moves (`update()` refuses to rewrite
2008
+ it). Ordering a live table by a mutable column such as `updatedAt` is
2009
+ **live pagination**, and the page says so (`snapshot: false`): later
2010
+ inserts land where their keys sort, but an existing row whose order
2011
+ key changes between two pages can move across the cursor — it may be
2012
+ seen twice, or not at all — and no cutoff on later writes prevents
2013
+ that. `consistency: 'snapshot'` over such an ordering is refused
2014
+ (`JD0036`) rather than mislabelled; the default `'live'` reports the
2015
+ truth either way. A caller who needs a snapshot orders by the key.
1364
2016
 
1365
2017
  ### 10.6 What remains residual
1366
2018
 
@@ -1369,13 +2021,17 @@ because joins make residuals more expensive — accompanied by the
1369
2021
  `EXPLAIN QUERY PLAN` narrative (SQLite exposes no row estimates;
1370
2022
  a number appears only where `capabilities.rowEstimates` is filled):
1371
2023
 
1372
- - three or more bindings;
2024
+ - a binding nothing joins to — the cartesian product a nested-loop plan
2025
+ must never emit by accident; every binding past the first attaches by
2026
+ a column equality to one already joined, and a graph that does not
2027
+ close is the engine's;
1373
2028
  - non-equality join predicates, and disjunctions spanning bindings;
1374
- - `$groupby`, except the `$time-bucket` ladder the series plan pushes
1375
- (README, *Time series*) the engine's post-group cardinality
1376
- rebinding deserves its own order; the count-of-related-rows case
1377
- ORMs are bad at is already native via `count: true` includes;
1378
- - projections (`$return` objects) — over one binding or across a join;
2029
+ - a `$groupby` whose key is untyped or admits `null`, whose `$return`
2030
+ reads the binding (after a grouping it holds the group's ROWS), or
2031
+ whose `$orderby` names anything but a group key; a window over the
2032
+ groups, or an aggregate of them;
2033
+ - projections (`$return` objects) ACROSS a join — over one binding a
2034
+ nested shape of member paths lowers (§ the projection tree);
1379
2035
  - externals against document paths; booleans and `null` at bind time;
1380
2036
  - everything phase A already listed (§8 of `QUERY-FORMAT.md`
1381
2037
  notwithstanding, the truth table is the contract).
@@ -1398,6 +2054,29 @@ arithmetic:
1398
2054
  an aggregate over a path that admits `null`, or over a boolean path,
1399
2055
  is a named residual, so the two paths keep answering alike.
1400
2056
 
2057
+ ### 10.7 The join table as a query root
2058
+
2059
+ A declared many-to-many join table is a **read-only query root**:
2060
+ `$.<JoinTable>[*]` binds like any entity array and answers rows carrying
2061
+ exactly its two key columns — `<A>_key` and `<B>_key`, the names the DDL
2062
+ creates — and nothing else, because a join row has no document of its
2063
+ own. It joins to the entities it relates like any other binding, so
2064
+ `Person → Person_Tag → Tag` is one statement over three roots.
2065
+
2066
+ It is a ROOT and not an entity: `store.entity('<JoinTable>')` is
2067
+ `JD2004`, memberships are still written through `link`/`unlink` and the
2068
+ unit of work, and nothing about the table's lifecycle changes. The two
2069
+ namespaces are one, so a join table whose name is also a declared
2070
+ entity's is refused at open (`JD0005`) — `$.X[*]` may mean one thing.
2071
+
2072
+ Because the root exists, a many-to-many **hop** lowers: a chain's
2073
+ `u.labels` becomes two links — the join row that names the membership,
2074
+ then the target row it names — instead of the `JL0105` refusal it was.
2075
+ A relation entry that does not name its join row's columns
2076
+ (`{ joinTable, ownColumn, ownKey, targetColumn, targetKey }` — §10.1)
2077
+ still refuses with that code, because there is then nothing to lower
2078
+ through.
2079
+
1401
2080
 
1402
2081
  ## 11. The unit of work
1403
2082
 
@@ -1514,8 +2193,14 @@ zero rows.) An unguarded delete of a missing row is a no-op.
1514
2193
  `saveChanges()` is all-or-nothing inside one transaction. On ANY
1515
2194
  failure the tracker is left exactly as it was before the call — the
1516
2195
  same save can be retried once the cause is gone; a half-applied
1517
- tracker is worse than a rollback. Only a committed save advances
1518
- snapshots (bumped versions, generated keys) and clears pending work.
2196
+ tracker is worse than a rollback. A save whose statements all succeed
2197
+ advances snapshots (bumped versions, generated keys) and clears pending
2198
+ work **immediately**, even inside an enclosing transaction — inside it
2199
+ the database does hold those rows, and every later read, plan and
2200
+ optimistic guard must agree. The scope that owns the connection holds
2201
+ the exact undo delta: an enclosing rollback (or a named-savepoint
2202
+ rollback past the save, §5.2) withdraws the advance, a nested rollback
2203
+ withdraws only its own effects, and outer commit keeps it.
1519
2204
 
1520
2205
  The return value is data, not a boolean:
1521
2206
 
@@ -1569,3 +2254,6 @@ const report = await store.saveChanges(); // { joinInserted: 1, joinDeleted
1569
2254
  - The report counts the rows written under `joinInserted`/`joinDeleted`
1570
2255
  and `stats().tracker.pendingMemberships` counts the pending
1571
2256
  (entity, key, member) records.
2257
+
2258
+ Worker/pool options, synchronous cursors, wasm session probing and the browser
2259
+ persistence ladder are specified in [execution hosts](HOSTS.md).