@jarenjs/db 0.56.0 → 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 (68) hide show
  1. package/ARCHITECTURE.md +393 -56
  2. package/README.md +585 -53
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +122 -14
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +744 -64
  8. package/package.json +21 -7
  9. package/schemas/jaren-model.draft-07.schema.json +224 -162
  10. package/schemas/jaren-model.schema.json +224 -162
  11. package/src/algebra.js +227 -9
  12. package/src/backup.js +161 -0
  13. package/src/cancellation.js +48 -0
  14. package/src/capture.js +218 -45
  15. package/src/cli.js +165 -59
  16. package/src/cursor.js +411 -0
  17. package/src/dag-job.js +154 -21
  18. package/src/ddl.js +102 -8
  19. package/src/dialect.js +267 -112
  20. package/src/dialects/expression-read.js +158 -0
  21. package/src/dialects/postgres.js +618 -0
  22. package/src/dialects/rtree-ddl.js +129 -0
  23. package/src/dialects/sqlite.js +243 -11
  24. package/src/document-files.js +311 -0
  25. package/src/document-steps.js +422 -0
  26. package/src/documents.js +335 -0
  27. package/src/driver.js +448 -61
  28. package/src/drivers/bun.js +37 -1
  29. package/src/drivers/indexeddb-snapshot.js +149 -0
  30. package/src/drivers/node-pool.js +11 -0
  31. package/src/drivers/node-worker-endpoint.js +105 -0
  32. package/src/drivers/node-worker.js +204 -0
  33. package/src/drivers/node.js +41 -7
  34. package/src/drivers/postgres.js +331 -0
  35. package/src/drivers/wasm-oo1.js +97 -0
  36. package/src/drivers/wasm-session.js +67 -0
  37. package/src/drivers/wasm.js +17 -83
  38. package/src/drivers/worker-pool.js +183 -0
  39. package/src/drivers/worker-protocol.js +79 -0
  40. package/src/drivers/worker-queue.js +60 -0
  41. package/src/emit.js +339 -48
  42. package/src/entity.js +20 -22
  43. package/src/errors.js +422 -19
  44. package/src/expression.js +284 -0
  45. package/src/graph.js +64 -8
  46. package/src/index.js +46 -17
  47. package/src/introspect.js +583 -0
  48. package/src/jobs.js +843 -107
  49. package/src/json-bytes.js +58 -0
  50. package/src/maintenance.js +175 -0
  51. package/src/migrate.js +248 -181
  52. package/src/model.js +68 -0
  53. package/src/plan.js +1119 -138
  54. package/src/pragmas.js +314 -0
  55. package/src/profile.js +151 -3
  56. package/src/query.js +1634 -323
  57. package/src/residual.js +17 -0
  58. package/src/series.js +12 -4
  59. package/src/store.js +1505 -264
  60. package/src/tracker.js +203 -29
  61. package/src/udf.js +88 -7
  62. package/types/index.d.ts +1097 -25
  63. package/types/node-pool.d.ts +28 -0
  64. package/types/node-worker.d.ts +54 -0
  65. package/types/node.d.ts +69 -2
  66. package/types/postgres.d.ts +46 -0
  67. package/types/typed.d.ts +25 -3
  68. package/types/wasm.d.ts +14 -0
package/README.md CHANGED
@@ -136,7 +136,44 @@ stated per call (`users.execute<User>(…)`) because only the caller
136
136
  knows what its `$return` produces. `query()` answers the same document
137
137
  as an item cursor (`for await`), one item per pull and never unwrapped —
138
138
  the read to use when an item may itself be an array. The handle's own
139
- shape binds at `store.collection<User>('users')`.
139
+ shape binds at `store.collection<User>('users')`. An entity set answers
140
+ the same cursor as `cursor(document, options)`: one row per pull from
141
+ an open statement, the statement released exactly once when the loop
142
+ breaks, throws, finishes or its `signal` aborts (`JD2072` on the next
143
+ pull). Every cursor says what it will do — `streaming: 'row'`, or
144
+ `'buffered'` with the `barrier` that forces it (a set residual's
145
+ construct, an external the database cannot bind, a chain's window) —
146
+ and a linq chain's `for await` over a set IS this cursor, so a
147
+ `break` after three rows of twenty thousand costs three rows. A cursor
148
+ registers no snapshots unless asked (`tracking: true`, one per yielded
149
+ row — unbounded in the result size, by the caller's choice).
150
+
151
+ On synchronous connections, `store.sync.entity(name)` also offers `cursor`,
152
+ `loadCursor` and `page`. The cursors use `for...of` and support `return()`
153
+ and `Symbol.dispose`; pages return values immediately. Both surfaces use
154
+ the same plans, bounds and continuation identities. Closing the store
155
+ releases every active iterator; later entry refuses with `JD2063`.
156
+
157
+ A graph
158
+ streams the same way: `loadCursor(spec)` yields one root with its
159
+ includes attached, and every include is bounded per root (`maxRows`,
160
+ `maxBytes`, defaults 1000 rows and 1 MiB; `Infinity` spelled for the
161
+ unbounded case) — crossing a bound is `JD2073` naming the root, the
162
+ member and the bound, never a silently truncated graph. A list pages
163
+ over a composite keyset — `entity.page(spec, { limit, after, maxBytes })`
164
+ with `orderBy` over `(updatedAt, id)`-shaped orderings, the primary
165
+ key appended as the tie-breaker and null placement matching the plan —
166
+ and answers `{ items, continuation, hasMore, snapshot }`: the
167
+ continuation is unsigned and structural (the host signs it), an item
168
+ larger than `maxBytes` is `JD2074` without advancing it, and
169
+ `snapshot` is true only over an immutable ordering; over a mutable one
170
+ the page is live and says so (MODEL-FORMAT §10.5). `explainLoad()`
171
+ reports both facts separately: `order` is the deterministic order the
172
+ statement executes under in EVERY load mode — the declared terms and the
173
+ tie-breaker the clause appends, the primary key in keyset mode and the
174
+ row identity otherwise — and `identity` is the ordering identity a
175
+ continuation carries and is checked against, `null` for a load that has
176
+ no continuation to emit.
140
177
 
141
178
  - **The pushdown planner with `explain()`.** A query compiles through
142
179
  the engine's published AST into a dialect-neutral plan and renders
@@ -144,10 +181,50 @@ shape binds at `store.collection<User>('users')`.
144
181
  equivalent runs as a real compiled Jaren query (the residual), and
145
182
  `explain()` always says which is which — the SQL, the bound
146
183
  parameters, the indexes used (verified against the database's own
147
- plan output), and the residual's named reasons. A differential
184
+ plan output), and the residual's named reasons. It answers for the
185
+ RUN it describes: given the externals `execute` is given, a value the
186
+ database cannot bind (a boolean, a null, a missing name) is reported
187
+ as the set residual the call becomes — mode, statement and reason —
188
+ and counted in `stats().bind.diverted`, so a production diversion is
189
+ visible where nobody calls `explain()`. Every explanation carries
190
+ `streaming` (`'row'` or `'buffered'`) and `barrier` (the construct
191
+ that forces a buffer, or `null`), the same classification the cursor
192
+ itself carries; `strictStreaming: true` on a cursor declines a
193
+ buffering plan by name (`JD0037`) before any statement runs. Every
194
+ explanation also carries `order`: the deterministic order the
195
+ statement executes under, in one closed vocabulary — a mapped
196
+ `column`, a `document` path, a bucketed plan's `group` key, or the
197
+ row `identity` the plan appends so a sequence answers in insertion
198
+ order — read from the plan rather than parsed back out of SQL, and
199
+ `null` only for a statement that orders nothing at all (an aggregate
200
+ answers one row; a k-nearest fetch is unordered because the engine
201
+ ranks it). Each refusal reason is drawn from a closed vocabulary too,
202
+ so a reason a caller matched on stays the sentence it was. A
203
+ `$return` that is ONE member path over the binding projects that
204
+ path into the statement — its value beside its JSON type, so a
205
+ present `null`, an absent member and a boolean read back exactly as
206
+ the engine answers them — and `$count` over it counts the rows where
207
+ the member is present with a `COUNT(*)`. A `$return` that is a nested
208
+ SHAPE — objects, arrays, literals and member paths, to any depth —
209
+ projects the same way: the statement fetches one value/type pair per
210
+ DISTINCT leaf (a path named twice is fetched once) and the decoder
211
+ rebuilds the shape, so an absent member is omitted from its object
212
+ and skipped in its array exactly as the engine does it, and a literal
213
+ `null` stays present where a path that finds nothing does not.
214
+ `explain().projection` names the path or the leaf `paths`, and
215
+ reading a shape no longer reads every document. A shape the tree
216
+ cannot rebuild — an operator over a member, a reference to the
217
+ binding itself, a projection with no path at all — refuses WHOLE and
218
+ runs per row, with `explain().residualProjection` naming what stayed
219
+ behind: promoting the half that composes would answer a shape nobody
220
+ asked for. Every `explain()` also carries `budget`: the profile
221
+ that applied, every bound it imposed, and the two driver slots
222
+ (`time`, `estimatedRows`) reported `unavailable` on SQLite rather
223
+ than estimated (MODEL-FORMAT §8). A differential
148
224
  oracle — a committed corpus and a seeded generator, every case run
149
- in both modes keeps both paths agreeing, with the one arithmetic
150
- deviation declared rather than hidden (MODEL-FORMAT §10.6: SQLite's
225
+ resident, native, native again over a store with every declared index
226
+ removed, and forced-residual keeps every path agreeing, with the one
227
+ arithmetic deviation declared rather than hidden (MODEL-FORMAT §10.6: SQLite's
151
228
  compensated `SUM` and the engine's naive one differ in the last
152
229
  bit). `strict: true` turns any residual into a compile error.
153
230
  - **Registered operators, correct in the residual, pushed where it
@@ -167,6 +244,44 @@ shape binds at `store.collection<User>('users')`.
167
244
  registration for untrusted documents); the profile's `functions`
168
245
  allow-list still governs a `$call`-reached `fn`; the row bound still
169
246
  fires. Without a registry the store is unchanged — `$npv` is `JQ0002`.
247
+ A pack may also mark a whole-sequence summary `pushable: 'aggregate'`
248
+ (the statistics pack's `$mean`, `$median`, `$variance`, `$stddev`):
249
+ where the driver has an aggregate API it becomes a registered **SQL
250
+ aggregate** over the member's column, folded by the same pure
251
+ function, so the accumulation runs in SQLite rather than over fetched
252
+ rows. The token is a promise the store checks — one `seq<number>`
253
+ operand and a scalar result, or the declaration is refused at
254
+ `openStore` by name — and the promotion still needs a numeric member
255
+ the schema forbids `null` on, because SQL cannot tell a stored `null`
256
+ from an absent one and the engine can.
257
+ - **Grouping and joins lower whole, or not at all.** A `$groupby` over
258
+ schema-typed member keys becomes a real `GROUP BY`: the keys come back
259
+ with their JSON types beside them, so a group whose key is ABSENT
260
+ leaves that member out exactly as the object constructor does, the
261
+ closed aggregate set (`$count`, `$sum`, `$avg`, `$min`, `$max`) folds
262
+ in SQL under the ENGINE's empty rules (`0` for a count or a sum, no
263
+ member at all for the other three), and the groups come out in the
264
+ engine's own order of first appearance unless an `$orderby` over the
265
+ keys says otherwise. Entity queries join any number of bindings: every
266
+ binding past the first must be attached by a column equality to one
267
+ already joined, which is what makes the plan a nested loop the engine
268
+ can be compared against — a binding nothing attaches would be a
269
+ cartesian product, so it is the residual, named, and `strict: true`
270
+ refuses it. `explain()` lists the join order with the equalities that
271
+ attached each binding, and the group's keys, aggregates and order.
272
+ A join predicate that is not an equality — a range between two mapped
273
+ columns of one family — refines a match it never makes: the anchor is
274
+ still an equality, so a range alone stays the residual. A projected
275
+ join lowers too, through the same leaf decoder a single-binding
276
+ projection uses, each leaf read from its own binding's alias.
277
+ - **A declared join table is a read-only query root.** `$.<JoinTable>[*]`
278
+ answers rows carrying exactly its two key columns and joins to the
279
+ entities it relates, so `Person → Person_Tag → Tag` is one statement
280
+ over three roots — while `store.entity('<JoinTable>')` is still
281
+ `JD2004` and memberships are still written through `link`/`unlink`.
282
+ Because the root exists, a many-to-many hop on the chain lowers
283
+ through it (two links: the membership, then the row it names) instead
284
+ of refusing.
170
285
  - **Storage is declarative.** Indexed paths become generated columns
171
286
  plus real indexes, typed from the collection's schema. Opening an
172
287
  existing database verifies the declared shape and refuses to alter
@@ -212,11 +327,42 @@ shape binds at `store.collection<User>('users')`.
212
327
  from the committed `model.snapshot.json`, refuses a module that is
213
328
  not pure, and `jaren-db check` fails CI on a model that moved without
214
329
  a plan (MIGRATION-FORMAT §11).
330
+ - **A document migration does not need the database.** Its `jslt` and
331
+ `query` steps act on documents, so `migrateDocuments` runs them over
332
+ arrays and `streamDocuments` over a source it may walk only once,
333
+ writing each document out as it finishes and holding one batch. Both
334
+ share ONE implementation of what a step means with the store runner,
335
+ so the array answer equals the store answer — on the same step, in the
336
+ same words, down to the refusal. A step that needs tables (`ddl`,
337
+ `sql`, `rebuild`, `derive`) is refused by name before the first
338
+ document is read, never skipped (MIGRATION-FORMAT §6.1). `jaren-db
339
+ documents` is the same thing at the command line, over a JSON array,
340
+ JSONL or stdio:
341
+
342
+ ```sh
343
+ # rewrite a file in place — a sibling temporary is renamed over it
344
+ # only once every document has survived every step
345
+ jaren-db documents --migrations ./migrations --in users.jsonl --in-place --yes
346
+
347
+ # the CI shape: transform and validate everything, write nothing
348
+ jaren-db documents --migrations ./migrations --in users.json --check
349
+ # 0 the chain applies, 1 the run failed, 2 the command line was wrong
350
+
351
+ # also the converter: the input and output encodings are independent
352
+ jaren-db documents --migrations ./migrations --in users.json --out users.jsonl
353
+ ```
354
+
355
+ A JSON array is scanned structurally rather than parsed whole, so both
356
+ encodings hold one batch and not the file: the live set is flat in the
357
+ size of the input, and a file larger than memory still migrates.
215
358
  - **The safe profile.** Untrusted query documents run under composed
216
359
  bounds: engine limits on the residual, a mandatory row bound that
217
360
  refuses rather than truncates, reference allow-lists, optional
218
- full-scan refusal, and per-collection mandatory predicates no
219
- document shape can shed. Read-only stores refuse writes at the
361
+ full-scan refusal, per-collection mandatory predicates no document
362
+ shape can shed, and a per-root MEMBER allow-list the members a
363
+ document may read, checked wherever it names one, where allowing a
364
+ member allows what is under it and reading the item whole is refused
365
+ rather than quietly narrowed. Read-only stores refuse writes at the
220
366
  driver.
221
367
  - **Writes validate** through an injected hook; without one,
222
368
  `store.capabilities.validated` is `false` and the docs say what that
@@ -370,8 +516,8 @@ fixture on disk and the number of entries run against the corpus. Each
370
516
  runner names the executor, the entry and the query when it disagrees.
371
517
  That is the whole claim — not faster than anyone, not PostGIS — and the
372
518
  browser leg's limit is stated with it: **it proves execution, not
373
- durability.** Where OPFS is unavailable the tab's store is in-memory,
374
- which is a property of the host, not of the suite.
519
+ durability.** The separate [host matrix](docs/HOSTS.md) proves OPFS and
520
+ IndexedDB persistence and the visibly non-durable memory fallback.
375
521
 
376
522
  **No head-to-head rival, and saying so.** Nothing else in JavaScript
377
523
  stores GeoJSON in SQLite from a JSON query document, so the suite
@@ -470,28 +616,40 @@ wrong width is refused at plan time and `explain()` says why; an
470
616
  `knn` and the fallback is counted instead — `stats().knn.diverted` is
471
617
  that count, and a consumer who binds probes from a model should watch it.
472
618
 
619
+ **Several declared widths, one compiled query.** A member may carry more
620
+ than one `derive: 'vector'` index — a 384-wide embedding beside a
621
+ 768-wide one — and an external probe then binds the column its OWN width
622
+ names. The plan carries one alternative per declared width and emits one
623
+ statement per alternative, prepared once and kept with the plan; the
624
+ bind picks exactly one. No `CASE` across the columns (that would read
625
+ every one of them per row) and no statement per call (that would give up
626
+ the prepared cache). `explain().rank` names the `alternatives` and, when
627
+ the call was given its externals, the `selected` width — the probe is
628
+ named, never printed. A width the model does not declare is the same
629
+ counted diversion any unbindable probe is.
630
+
473
631
  **The numbers, the losses included.** `benchmark/vector.js` measures one
474
632
  k-nearest query every physical way it can run — over <!--fact:vector.grid-->10,000 and 50,000 vectors at 384 and 768 dimensions, k = 10, the median of 10 probes<!--/fact--> —
475
633
  and asserts that every path returns the identical top-k, ids and order,
476
634
  on every probe before a single timing prints. The flagship row is the
477
- plan a consumer's own document runs, which measures <!--fact:vector.plan-->206 ms at 50,000 × 768<!--/fact-->:
635
+ plan a consumer's own document runs, which measures <!--fact:vector.plan-->191 ms at 50,000 × 768<!--/fact-->:
478
636
  <!--fact:vector.table-->
479
637
  | path (ms) | 10,000 × 384 | 10,000 × 768 | 50,000 × 384 | 50,000 × 768 |
480
638
  |---|---:|---:|---:|---:|
481
- | engine resident sweep (no database) | 3.3 | 6.7 | 17 | 32 |
482
- | **the k-nearest plan (the store's own)** | 22 | 33 | 139 | 206 |
483
- | raw fetch + engine sweep (the plan's statement) | 20 | 31 | 130 | 202 |
484
- | `ORDER BY` over a registered function | 18 | 31 | 121 | 180 |
485
- | JSON-doc sweep (no vector column) | 249 | 501 | — | — |
486
- | sqlite-vec | 3.6 | 7.5 | 18 | 38 |
639
+ | engine resident sweep (no database) | 3 | 5.8 | 15 | 28 |
640
+ | **the k-nearest plan (the store's own)** | 21 | 30 | 127 | 191 |
641
+ | raw fetch + engine sweep (the plan's statement) | 19 | 28 | 118 | 182 |
642
+ | `ORDER BY` over a registered function | 17 | 26 | 113 | 158 |
643
+ | JSON-doc sweep (no vector column) | 232 | 468 | — | — |
644
+ | sqlite-vec | 3.3 | 6.9 | 17 | 35 |
487
645
  <!--/fact-->
488
646
 
489
647
  The row the column exists to beat is the last one that has no column: the
490
648
  same query document over a collection that stores the embedding only
491
- inside the document costs <!--fact:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/fact-->,
649
+ inside the document costs <!--fact:vector.jsonDoc-->15.6× the plan at 10,000 × 768<!--/fact-->,
492
650
  because every row's vector is parsed out of JSON before it can be
493
651
  compared. The row the store **cannot** beat is the one with no database
494
- in it: the same top-k over a resident `Float32Array` is <!--fact:vector.resident-->32 ms, which the plan is 6.4× slower than<!--/fact-->.
652
+ in it: the same top-k over a resident `Float32Array` is <!--fact:vector.resident-->28 ms, which the plan is 6.8× slower than<!--/fact-->.
495
653
  That comparison is not an even one and the direction is the point — the
496
654
  sweep starts from decoded floats in RAM and pays nothing for durability,
497
655
  for filters that compose with the ranking, or for a process that can
@@ -500,7 +658,7 @@ price should be able to say what the price is.
500
658
 
501
659
  **Both halves of the price.** The column costs on the way in as well as
502
660
  saving on the way out: writing the same documents with
503
- the index costs <!--fact:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->,
661
+ the index costs <!--fact:vector.write-->10.2 s against 4.9 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->,
504
662
  because every write pays a JSON round trip of the member plus a
505
663
  normalize and a pack. On disk one vector is <!--fact:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/fact--> —
506
664
  smaller, but *added*, since the document still carries the member the
@@ -509,7 +667,7 @@ column is derived from.
509
667
  **Pushing the rank into SQL, re-measured.** A registered similarity
510
668
  function inside an `ORDER BY … LIMIT k` is the obvious alternative, and
511
669
  the suite measures it against the real column with the probe hoisted out
512
- of the per-row call: <!--fact:vector.udf-->180 ms against 202 ms at 50,000 × 768, and 0.87–1.00× the fetch-and-rank across the grid — rough parity on speed<!--/fact-->.
670
+ of the per-row call: <!--fact:vector.udf-->158 ms against 182 ms at 50,000 × 768, and 0.87–0.95× the fetch-and-rank across the grid — rough parity on speed<!--/fact-->.
513
671
  The plan does not emit it, and after that measurement the reasons are not
514
672
  speed: `bun` has no user-function API, so a plan that needed one would
515
673
  exclude an executor outright; and an ordering decided in SQL cannot break
@@ -518,7 +676,7 @@ executors have to agree on.
518
676
 
519
677
  **The rival, and the ceiling.** `sqlite-vec` is the extension built for
520
678
  exactly this, and it is measured rather than described: it answers the
521
- same probes in <!--fact:vector.rival-->38 ms against 206 ms at 50,000 × 768 — 5.4× in sqlite-vec's favour, out of a database 6.6× smaller that holds no documents<!--/fact-->,
679
+ same probes in <!--fact:vector.rival-->35 ms against 191 ms at 50,000 × 768 — 5.5× in sqlite-vec's favour, out of a database 6.6× smaller that holds no documents<!--/fact-->,
522
680
  over <!--fact:vector.agreement-->40 probes, no disagreements<!--/fact-->. It is a
523
681
  loadable native extension, which is the one thing this store will not
524
682
  require — it would exclude the wasm tab and stock `bun`, half the
@@ -526,7 +684,7 @@ execution story — so the comparison is published as what it is: a faster
526
684
  engine you may prefer, and a dependency this one does not take. What
527
685
  neither of them is, is an approximate index. Exact brute force is linear
528
686
  in `n · d`, and the suite states the envelope as arithmetic rather than
529
- opinion: <!--fact:vector.ceiling-->5.546 ns per vector component — one query reaches 100 ms at about 22,000 vectors of 768 dimensions and one second at about 234,000<!--/fact-->.
687
+ opinion: <!--fact:vector.ceiling-->5.142 ns per vector component — one query reaches 100 ms at about 24,000 vectors of 768 dimensions and one second at about 252,000<!--/fact-->.
530
688
  Past that this design is the wrong tool and no margin changes it; what
531
689
  lies beyond is an approximate index, and this store does not have one.
532
690
 
@@ -584,12 +742,12 @@ as-of join over one seeded corpus by plain references, by the temporal
584
742
  kernel, by a generic query document, by hand-written SQL and by the
585
743
  store — every route checked against the others before a timing is
586
744
  taken. At <!--fact:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/fact-->,
587
- the store is measured three ways at once — <!--fact:series.storeShapes-->the planned range costs 4.2× the hand-written statement and 1535.5× the resident cut, and the pushed bucket ladder 2.5× the hand-written GROUP BY, 1.6× FASTER than the generic query route, and 155.1× the one-pass loop<!--/fact-->.
745
+ the store is measured three ways at once — <!--fact:series.storeShapes-->the planned range costs 4.3× the hand-written statement and 1435.1× the resident cut, and the pushed bucket ladder 2.1× the hand-written GROUP BY, 1.8× FASTER than the generic query route, and 146.6× the one-pass loop<!--/fact-->.
588
746
  The range row is not the planner's price: the statement selects two
589
747
  COLUMNS where the store renders and parses a whole JSON document per
590
748
  row, which is what storing documents costs.
591
749
 
592
- And what a refinement costs, with the loss in it: <!--fact:series.storeRefinement-->A window measured in time is not pushed: the store answers it at 18.5× the kernel over an array already in memory, over 100,000 candidates the index bounded. The batched as-of join reads 99,129 rows in 1 statement and costs 1424.3× fifty-one separate index reads — a bound is what it buys, not a speed-up, and without a tolerance a backward join can only be bounded above.<!--/fact-->
750
+ And what a refinement costs, with the loss in it: <!--fact:series.storeRefinement-->A window measured in time is not pushed: the store answers it at 17.7× the kernel over an array already in memory, over 100,000 candidates the index bounded. The batched as-of join reads 91,682 rows in 2 statements and costs 1477.0× fifty-one separate index reads — a bound is what it buys, not a speed-up. Without a tolerance the open side has no bound the probes imply, so the plan reads the data's own: the last instant each series carries at or before the earliest probe, folded to the least of them. That anchor is the extra statement, and what it saves depends on where the probes sit evenly spread ones, as here, leave the least below them to skip.<!--/fact-->
593
751
 
594
752
  **A refinement is named, never quiet.** `explain().series` reports
595
753
  `mode` — `native`, `hybrid` or `engine` — the declared index the fetch
@@ -602,20 +760,49 @@ answer, and a reason code for every thing the database could not do:
602
760
  every one of them before a statement runs, and the counts `explain()`
603
761
  prints are the LAST ACTUAL execution's — `null` until the document has
604
762
  run, because an estimate wearing a count's name is worse than no
605
- number.
763
+ number. Every path counts: a set residual, a row projection, a native
764
+ aggregate (whose `candidates` stays `null`, since no row reached the
765
+ engine and SQLite reports no visited-row count) and a cursor, which
766
+ counts as it is drained and is final when it settles — read
767
+ mid-iteration, `counts.partial` is `true`. `series.mode` follows the
768
+ plan mode: a selection the engine finishes is `hybrid` when the index
769
+ narrowed the fetch and `engine` when nothing did, never `native` on the
770
+ strength of an index alone.
606
771
 
607
772
  **The as-of join is bounded, and the bound is the claim.** `$asof` with
608
773
  the collection on the right reads the probes it was given, bounds the
609
774
  fetch by their own span and by a membership test over their `by` keys,
610
- and issues exactly ONE statement whatever the probes number — the
611
- failure mode a batch exists to refuse is one seek per left row, and
612
- `test/db/statement-count.test.js` pins it at 1, 10 and 200 probes.
613
- Without a `tolerance` a backward join can only be bounded ABOVE, so
614
- that one statement can read most of a long history: the benchmark
775
+ and issues a FIXED number of statements whatever the probes number —
776
+ the failure mode a batch exists to refuse is one seek per left row, and
777
+ `test/db/statement-count.test.js` pins the count at 1, 10 and 200
778
+ probes. Without a `tolerance` the open side has no bound the probes
779
+ imply the row that answers the earliest probe may lie arbitrarily far
780
+ before it — so the plan asks the database for the data's own: per
781
+ series, the last instant at or before that probe, folded to the least
782
+ of them. That anchor is one aggregate read through the same declared
783
+ index, bound through a typed slot, and it is the second statement.
784
+
785
+ What it saves depends on where the probes sit, so the benchmark
615
786
  publishes the candidate count beside the timing rather than netting it
616
- out, and at fifty-one probes over a hundred thousand rows the batch
617
- LOSES to fifty-one separate index reads. Few questions of a large
618
- series belong to a batch; a join of two series does.
787
+ out, over probes spread evenly across the whole span which is the
788
+ anchor at its worst, since the earliest of them has almost nothing
789
+ below it to skip. At fifty-one such probes over a hundred thousand rows
790
+ the batch still LOSES to fifty-one separate index reads. Few questions
791
+ of a large series belong to a batch; a join of two series does.
792
+
793
+ **`$overlaps` narrows through a declared interval.** Type a member as an
794
+ object whose `start` and `end` are both required and both numeric, map
795
+ each bound to a column, and the half-open test pushes: two comparisons
796
+ over the declared columns, with the engine's own operator deciding over
797
+ what comes back. It stays a pre-filter rather than an exact translation
798
+ because §8.16 RAISES on a span whose end is at or before its start, and
799
+ no schema keyword forbids storing one — so the statement keeps every
800
+ inverted row and the operator raises over it exactly as it would have.
801
+ That disjunct compares two columns, which no index bounds, so the fetch
802
+ scans; what it buys is that the pruned rows never reach the engine at
803
+ all. An unmapped pair, a schema that admits a textual bound, or a probe
804
+ that is not itself a half-open span pushes nothing and names why —
805
+ `strict: true` answers `JD0010`.
619
806
 
620
807
  **One corpus, five executors, proven to agree.** The committed temporal
621
808
  corpus (`test/json/fixtures/series-corpus.json`) runs through the plain
@@ -624,16 +811,93 @@ both drivers again with pushdown forced off — indexed and unindexed —
624
811
  and every case must answer identically, plan mode and reason codes
625
812
  included.
626
813
 
627
- ## What SQLite-only means, frankly
814
+ ## Two backends, frankly
815
+
816
+ SQLite is the primary backend — 3.45 or newer, on `node:sqlite`,
817
+ `bun:sqlite`, or your injected wasm build — and **PostgreSQL 16+** is
818
+ the second, through `@jarenjs/db/postgres`:
628
819
 
629
- SQLite is the supported backend — 3.45 or newer, on `node:sqlite`,
630
- `bun:sqlite`, or your injected wasm build — and nothing else is
631
- promised. The dialect seam exists and is tested against a double, but
632
- no second dialect ships. Concretely: there is **no statement timeout**
633
- (the drivers expose no interrupt; the capability slot is honestly
634
- `false`), no server, no replication, and cross-process concurrency is
635
- SQLite's own story (WAL plus a busy timeout, both set and visible on
636
- `store.capabilities`).
820
+ ```js
821
+ import { openStore } from '@jarenjs/db';
822
+ import { postgresDriver } from '@jarenjs/db/postgres';
823
+ import { Pool } from 'pg'; // yours, not ours
824
+
825
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
826
+ const store = await openStore(model, {
827
+ driver: postgresDriver(pool, { schema: 'app' }),
828
+ });
829
+ ```
830
+
831
+ The client is INJECTED. `@jarenjs/db` depends on nothing outside
832
+ `@jarenjs/*` and imports no PostgreSQL package; anything with
833
+ `connect()` answering `{ query(text, values), release?() }` will do, and
834
+ a `pg.Pool` is one as it stands. One client is acquired at open, held
835
+ for the store's life (a connection owns one savepoint stack) and
836
+ released exactly once at `close()`.
837
+
838
+ The same model, the same query documents and the same differential
839
+ oracle run on both. What differs is declared rather than discovered:
840
+
841
+ | | SQLite | PostgreSQL |
842
+ |---|---|---|
843
+ | documents | JSONB in a `BLOB` of a `STRICT` table | `jsonb` |
844
+ | an indexed path | a `VIRTUAL` generated column | a `STORED` one, guarded by `jsonb_typeof` |
845
+ | text ordering | the default `BINARY` collation | `COLLATE "C"`, which is the same bytes |
846
+ | a collection's order | the implicit `rowid` | a declared `rid bigserial` |
847
+ | `integer` and `number` | `INTEGER` and `REAL` | both `numeric` |
848
+ | a `boolean` member | `INTEGER` 1/0 | `smallint` 1/0 |
849
+ | an untyped indexed path | `ANY` — compares with anything | `jsonb` — the predicate reads the document instead |
850
+ | configuration | the closed pragma set, read back at open | the operator's; `store.capabilities.pragmas` is all `null` |
851
+ | drift detection | the stored `CREATE` text, exactly | the structural check: columns, indexes, foreign keys |
852
+ | the job queue, the change ledger and live queries | yes | no — `capabilities.jobs` and `capabilities.changeCapture` are `false`, and asking for one is a coded refusal at open |
853
+ | maintenance (checkpoint, integrity, optimize) | yes | no — every one of them IS a pragma |
854
+ | a spatial `physical: 'rtree'` | an R\*Tree virtual table | the B-tree over the four edge columns, and `explain().prefilters[].via` says so |
855
+ | a transaction a statement failed in | continues | is aborted until it ends (`JD2088`); catch-and-continue needs a nested `transaction()` |
856
+ | `ALTER TABLE` | additive only | full, so a rebuild is never needed |
857
+
858
+ Neither backend has a **statement timeout** (`capabilities.statementTimeout`
859
+ is `false` on both) or **row estimates**. There is no replication.
860
+ Cross-process concurrency on SQLite is its own story — WAL plus a busy
861
+ timeout, both set and visible on `store.capabilities`; on PostgreSQL it
862
+ is the server's, and a serialization failure or a deadlock arrives as
863
+ `class: 'busy'`, `retryable: true` — the same verdict, and the same
864
+ caller branch, a locked SQLite file gets.
865
+
866
+ ### What PostgreSQL costs
867
+
868
+ `npm run benchmark:postgres` runs the same model, the same documents and
869
+ the same query documents through the same store on both engines, checks
870
+ that they answered identically, and prints the difference. It is **not**
871
+ a rival comparison and reading it as one would be reading it wrong: an
872
+ in-process database against a server over a socket loses every row that
873
+ pays a round trip, and the shape of the loss is the point.
874
+
875
+ Measured here at 500 documents — PostgreSQL 17.5 in a container on the
876
+ same host, SQLite in memory, Node 24.19.0 — every row a loss except the
877
+ first, and every one expected:
878
+
879
+ | operation | ratio, PostgreSQL over SQLite |
880
+ |---|---|
881
+ | open the store (create or verify the shape) | 0.9× |
882
+ | insert one document | 14.5× |
883
+ | get one document by key | 12.8× |
884
+ | an indexed equality over 500 documents | 1.6× |
885
+ | an indexed range over 500 documents | 1.6× |
886
+ | an unindexed equality over 500 documents | 1.5× |
887
+ | one transaction with one write | 4.7× |
888
+ | apply one migration (one new index) | 4.3× |
889
+
890
+ The reading: a per-ROW operation pays about thirteen to fifteen times,
891
+ because each one is a round trip that SQLite makes as a function call. A
892
+ whole-collection query pays about one and a half, because the round trip
893
+ is amortized over five hundred rows and the rest is the server doing the
894
+ same work SQLite did. Two consequences worth stating: a loop of
895
+ `insert()` is the wrong shape on PostgreSQL in a way it is not on SQLite,
896
+ and pushdown matters MORE there — an unindexed scan that comes back as
897
+ five hundred rows for the engine to filter would pay the per-row price,
898
+ not the per-query one.
899
+
900
+ Your numbers will differ; the command is the point, not the table.
637
901
 
638
902
  ## The relational half (phase B)
639
903
 
@@ -652,7 +916,37 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
652
916
  proxies, asserted); mutation is replacement; `saveChanges()` diffs
653
917
  snapshots into minimal parameterised statements in one transaction,
654
918
  with insert batching, `JD0040` cycle refusal, `JD2040` optimistic
655
- conflicts, and a report of every statement, fallback and count.
919
+ conflicts, and a report of every statement, fallback and count. Its
920
+ fate is its transaction's: an enclosing rollback WITHDRAWS the advance,
921
+ so a caller's retry plans the same statements again instead of
922
+ reporting a success it never had.
923
+ - **Transaction ownership** (MODEL-FORMAT §5.1): the store a transaction
924
+ callback receives IS the transaction — `tx.collection`, `tx.entity`,
925
+ `tx.sync`, `tx.jobs`, `tx.saveChanges()`, and `tx.transaction()`
926
+ nesting through its savepoint. A store-level handle is by construction
927
+ somebody else: it holds the connection for its own extent, so an
928
+ unrelated writer on a shared store keeps its own fate rather than
929
+ sharing a rollback it knows nothing about. Contention waits under
930
+ `queueTimeout` and then names itself `JD0012`, or refuses at once under
931
+ `openStore(model, { transactions: 'strict' })`. One store is safe for a
932
+ handler per request. Every `tx` view is pinned to its EXACT scope: a
933
+ handle retained past its callback, or an outer handle used while an
934
+ async inner savepoint is open, refuses `JD2070` instead of joining a
935
+ transaction it does not own — and a transaction view carries no
936
+ `close`, because it never owns the connection's lifetime. A root
937
+ cursor (`collection.query()`, `entity.cursor()`, `entity.loadCursor()`)
938
+ is admitted **per pull**: construction holds nothing, each `next()` and
939
+ `return()` borrows the gate for one item's work and releases before it
940
+ settles, so a paused consumer blocks no transaction and no pull ever
941
+ reads another transaction's uncommitted row; `store.live()` registers
942
+ under the same gate through its initial query.
943
+ - **Named savepoints** (MODEL-FORMAT §5.2): `tx.savepoints.create /
944
+ rollbackTo / release` give a live transaction checkpoint-and-continue
945
+ without a sentinel exception — the target stays active after a
946
+ rollback, `release` keeps the rows, labels never reach SQL, and the
947
+ tracker, capture stream and `tx.jobs` outbox all agree with the
948
+ database after every partial rollback. The synchronous twin is
949
+ `tx.sync.savepoints`; a blank, duplicate or unknown label is `JD2071`.
656
950
  - **Generated types**: `entityEmitModel` + `@jarenjs/emit` render the
657
951
  model into entity interfaces, input variants and an `EntityMetaMap`;
658
952
  `typedStore` (from `@jarenjs/db/typed`) types every read, checks
@@ -676,12 +970,24 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
676
970
  EQUALITY against a fresh build as the acceptance criterion, drift
677
971
  detection, and `jaren-db check` for CI.
678
972
 
973
+ ## Execution hosts
974
+
975
+ `@jarenjs/db/node-worker` exports `nodeWorkerDriver()` for SQLite off the caller
976
+ thread; `@jarenjs/db/node-pool` exports `nodeWorkerPoolDriver()` for one writer and
977
+ bounded read-only WAL workers. Both use the ordinary Store contract, with credited
978
+ row frames, generation fencing, queue refusals and inspected lifecycle metrics.
979
+ Async connections declare `store.sync` and live queries unavailable. See
980
+ [execution hosts](docs/HOSTS.md) for options, cleanup guarantees, native-call
981
+ shutdown limits, the browser persistence matrix and measured latency/memory losses.
982
+
679
983
  ## The reactive and durable half (phase C)
680
984
 
681
985
  - **Change capture** (LIVE-FORMAT §§1–6): every committed write
682
986
  becomes an observable stream of RFC 6902 patches — from SQLite's
683
987
  own session changesets where the binding has them, from a write-path
684
- journal where it does not (`bun:sqlite`, the wasm build). One diff
988
+ journal where it does not (`bun:sqlite`, Node worker connections). The
989
+ wasm adapter probes the live session API and names a failed probe in
990
+ `sessionReason`. One diff
685
991
  format runs store → patch → live query → O(k) render. Capture is
686
992
  opt-in; the overhead is published, not waved away.
687
993
  - **Live queries** (LIVE-FORMAT §§7–13): `collection.live(document)`
@@ -699,19 +1005,53 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
699
1005
  rather than being folded in as though it had arrived on time.
700
1006
  - **Durable runs and the job queue** (JOBS-FORMAT, FLOW-FORMAT §7.6):
701
1007
  a `@jarenjs/flow` DAG run checkpoints declared nodes and RESUMES
702
- after a crash; `store.jobs` leases work in one guarded statement
703
- (exactly-once completion, no distributed lock), retries with
704
- backoff, dead-letters, and reclaims expired leases as recovery.
1008
+ after a crash; `store.jobs` leases work in one guarded statement (no
1009
+ distributed lock), retries with backoff, dead-letters, and reclaims
1010
+ expired leases as recovery. Every settling call carries a FENCE — the
1011
+ opaque token one claim mints, plus a lease that is still valid — so an
1012
+ expired or superseded attempt cannot mark a job done over the live
1013
+ one's result, and is told which of three things happened rather than
1014
+ answered `false`. `jobs.renew()` replaces a lease while a handler runs;
1015
+ the worker does it automatically, retries a renewal that failed for a
1016
+ mere storage reason, and aborts only an attempt whose lease is proven
1017
+ lost by a fence code. Settlement is exactly-once **against the store**
1018
+ — an external effect is still the handler's to make idempotent.
1019
+ Ownership follows the handle: root `store.jobs.*` and all worker I/O
1020
+ take the store gate and never join an open application transaction,
1021
+ while `tx.jobs` is the transactional outbox that co-commits with it;
1022
+ and `worker.stop()` quiesces every claim, renewal, checkpoint and
1023
+ settlement before it resolves, so closing the store releases the
1024
+ database file deterministically.
705
1025
  - **The browser** (`@jarenjs/db/wasm`): the same store, the same
706
1026
  queries, the same live updates run on the official SQLite wasm build
707
- over the header-free OPFS SAH-pool VFS one tab owns the
1027
+ over a probed persistence ladder: isolated SharedArrayBuffer OPFS,
1028
+ header-free SAH-pool OPFS, atomic IndexedDB snapshots, then visibly
1029
+ non-durable memory. One tab owns the
708
1030
  connection, others are clients. Proven in the `#/data` studio across
709
- Chromium, Firefox and WebKit. The subpath exports the two helpers
1031
+ Chromium, Firefox and WebKit, whose boot is a closed five-stage protocol
1032
+ (LIVE-FORMAT §11): every attempt ends ready or in a named, retryable
1033
+ `DATA_BOOT` failure, never a status line stuck at boot. The subpath exports the two helpers
710
1034
  that studio is built on: `sqlite3Handle(sqlite3, { DbClass })` builds
711
1035
  the injected handle from a loaded wasm module and the database class
712
1036
  the host picks (`sqlite3.oo1.DB` in memory, the SAH-pool
713
- `OpfsSAHPoolDb` for OPFS), and `adaptOo1Database(sqlite3, db)` wraps
714
- an oo1 database the host already opened.
1037
+ `OpfsSAHPoolDb` or `OpfsDb` for OPFS), and `adaptOo1Database(sqlite3, db)`
1038
+ wraps an oo1 database the host already opened. `indexedDbSnapshotHandle`
1039
+ adds bounded atomic persistence with asynchronous commit acknowledgements;
1040
+ its connection declares synchronous/live methods unavailable.
1041
+ - **The runtime record** (`@jarenjs/core/runtime`): `openStore(model,
1042
+ { runtime })` and `migrate(target, migrations, { runtime })` take one
1043
+ frozen record — `{ now, uuid, random, zoneProvider }`, defaulting
1044
+ member for member to the platform's own — for the clock the capture
1045
+ log and the job queue stamp, the identifier a `uuid` identity and a
1046
+ `default: 'uuid'` allocate, the backoff jitter, and the zone provider
1047
+ a temporal spec naming a zone compiles through. The store hands it to
1048
+ the job engine it constructs, so a consumer configures it once, and a
1049
+ subsystem's own explicit option (`zoneProvider`, `jobs.now`,
1050
+ `jobs.random`) wins over the record's member. A query `deadline` is an
1051
+ absolute instant the caller computed and is compared against the
1052
+ record's `now` — before a statement runs and at every row boundary of
1053
+ a cursor or page — so a deterministic run computes its deadlines from
1054
+ the same clock the store reads.
715
1055
 
716
1056
  ### What an event-time view costs
717
1057
 
@@ -742,6 +1082,86 @@ is over §12's default `maxMaintained` at ten thousand readings — the
742
1082
  bound errors rather than degrading, and raising it is a decision
743
1083
  somebody makes.
744
1084
 
1085
+ ## Indexing a computed value
1086
+
1087
+ An index may name a computation instead of a member:
1088
+
1089
+ ```js
1090
+ const model = {
1091
+ $model: '0.1',
1092
+ collections: {
1093
+ users: {
1094
+ schema: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } },
1095
+ key: '/id',
1096
+ indexes: [{
1097
+ name: 'by_lower_email',
1098
+ expression: { call: 'lower', args: [{ member: '$.email' }] },
1099
+ unique: true,
1100
+ }],
1101
+ },
1102
+ },
1103
+ };
1104
+
1105
+ const store = await openStore(model, {
1106
+ driver: nodeDriver(),
1107
+ expressions: {
1108
+ lower: {
1109
+ arity: 1,
1110
+ deterministic: true,
1111
+ apply: (value) => String(value).toLowerCase(), // SQLite registers this
1112
+ sql: 'lower', // PostgreSQL calls its own
1113
+ },
1114
+ },
1115
+ });
1116
+
1117
+ await store.collection('users').insert({ id: 'a', email: 'ANN@Example.COM' });
1118
+ await store.collection('users').insert({ id: 'b', email: 'ann@example.com' });
1119
+ // JD2001 — the unique index is over the LOWERED value
1120
+ ```
1121
+
1122
+ The expression is a closed AST — a member, a JSON scalar, or a call —
1123
+ and **never SQL text**. The function is DECLARED by you and resolved at
1124
+ open: an unknown name, a wrong arity, one not declared `deterministic`,
1125
+ or a declaration missing the half this engine needs is `JD0004` before a
1126
+ single statement runs. That is not ceremony: an index over a function is
1127
+ a schema dependency, and a database whose column is computed by
1128
+ `lower(…)` cannot be written from a connection that has no `lower`.
1129
+
1130
+ `@jarenjs/linq/model`'s `expressionIndex()` writes the same document
1131
+ from a lambda: `expressionIndex({ call: 'lower', args: [(d) => d.email] },
1132
+ { unique: true })`.
1133
+
1134
+ ## Reading a database back into a model
1135
+
1136
+ `store.introspect()` answers the `jaren-model` document a live
1137
+ database's shape says it is, and a report of everything the shape
1138
+ cannot carry:
1139
+
1140
+ ```js
1141
+ const { model, report } = await store.introspect({ keys: { users: '/id' } });
1142
+ // model — a valid jaren-model: collections, keys, indexes with their paths,
1143
+ // entities with their keys, unique/indexed columns and foreign keys
1144
+ // report — [{ code, object, detail }], sorted, one row per gap
1145
+ ```
1146
+
1147
+ It issues no DDL and no DML — every statement it runs begins `SELECT` —
1148
+ so reading a production database is a read. What comes back is exact
1149
+ where the shape carries the fact and REPORTED where it does not: a
1150
+ document's unindexed members are not in the physical shape
1151
+ (`document-members`), a text key column cannot say which member filled
1152
+ it (`key-source` — `options.keys` supplies the pointer), a view cannot
1153
+ be declared (`unmapped-view`), a foreign key cannot say which side
1154
+ declared the edge (`ambiguous-relation`). `strict: true` refuses rather
1155
+ than answering a partial model.
1156
+
1157
+ The derived model is usable as a migration's `from`: read a database,
1158
+ plan against your declared model, and an unchanged shape plans nothing.
1159
+ SQLite and PostgreSQL derive the same logical model from equivalent
1160
+ databases — same collections, same keys, same index names and paths,
1161
+ same report rows — with the one difference the PostgreSQL mapping
1162
+ states: `numeric` carries both JSON number types, so an `integer`
1163
+ member reads back as `number`.
1164
+
745
1165
  ## Sync-readiness — what exists and what does not
746
1166
 
747
1167
  The change stream is an ordered log of RFC 6902 patches with a
@@ -753,6 +1173,93 @@ writes made by another connection (the coarse `dataVersion()` signal
753
1173
  is the honest mitigation, not a pretend fine-grained one). Building
754
1174
  replication on these primitives is a roadmap item, not a hint.
755
1175
 
1176
+ ## Operating a store — configuration, maintenance, backup, cancellation, the queue
1177
+
1178
+ Everything an operator does to a production SQLite database is a typed
1179
+ operation with a capability, a cancellation and an error class; none of
1180
+ it needs raw SQL or the raw handle. The rules, in one place:
1181
+
1182
+ - **Configuration is a closed, validated set, read back.** Eight
1183
+ connection pragmas are `openStore` options — `busyTimeout`,
1184
+ `journalMode`, `synchronous`, `walAutocheckpoint`, `journalSizeLimit`,
1185
+ `cacheSize`, `mmapSize`, `tempStore` — validated before anything
1186
+ reaches SQL. An option naming any other pragma is refused (`JD0006`),
1187
+ one the driver or the store kind cannot apply is refused (`JD0007`),
1188
+ and after the open every declared pragma is read back:
1189
+ `store.capabilities.pragmas` carries the values the connection
1190
+ actually has, and an explicitly requested value the engine did not
1191
+ take refuses the open (`JD0008`) rather than leaving a store that
1192
+ believes a configuration it does not have.
1193
+ - **Maintenance is four typed operations under the store gate** —
1194
+ `checkpoint({ mode })`, `integrityCheck({ limit })`, `foreignKeyCheck()`,
1195
+ `optimize()` — each answering SQLite's own row as typed data, refused
1196
+ by code (`JD2077`) exactly where `capabilities.maintenance` says
1197
+ `false` (a read-only store for the two that write; a binding that
1198
+ declares one absent). Corruption an integrity check finds is its
1199
+ RESULT, never a throw.
1200
+ - **A backup is published whole or not at all.** `backupTo(path)` copies
1201
+ a live store while writers proceed, into a temporary sibling in the
1202
+ same directory, and renames it onto the target only once the platform
1203
+ reported the copy complete; a cancelled (`JD2079`) or failed copy
1204
+ leaves neither the target nor the temporary file. Where it goes, how
1205
+ it is named, rotated or encrypted is the host's.
1206
+ - **Cancellation is honoured where the driver can honour it, and the
1207
+ report says where.** `capabilities.cancellation` states the
1208
+ granularity per lifecycle — `query: 'row'`, `queue: true`, `migration:
1209
+ 'step'`, `maintenance: 'statement'`, `backup: 'page'`, `midStatement:
1210
+ false` — and every operation with more than one unit of work takes
1211
+ `{ signal, deadline }` on the store's injected clock. A cursor over a
1212
+ binding with no lazy iterator says `streaming: 'buffered'` with a
1213
+ driver barrier instead of a row stream it cannot deliver.
1214
+ - **One classification of driver failures.** A locked, full, read-only,
1215
+ corrupt or unopenable database arrives under one code with a stable
1216
+ `class` and a `retryable` verdict from every path alike (`JD2005`
1217
+ busy, `JD2082`–`JD2085`; `JD0002` at open), the driver's error as
1218
+ `cause`; a pushed integer aggregate past int64 is answered by the
1219
+ engine as the coded residual it always was elsewhere.
1220
+ - **The queue is administrable, never scheduled.** `store.jobs.page`,
1221
+ `cancel` (through the lease fence), `requeue` and `sweep` (with a
1222
+ required horizon) are mechanisms; WHEN to sweep or cancel is the
1223
+ host's call, and priority classes stay a documented non-goal.
1224
+
1225
+ ```js
1226
+ import { openStore } from '@jarenjs/db';
1227
+ import { nodeDriver } from '@jarenjs/db/node';
1228
+
1229
+ const store = await openStore(model, {
1230
+ driver: nodeDriver(), path: 'app.db', jobs: true,
1231
+ synchronous: 'normal', cacheSize: -8000, walAutocheckpoint: 250,
1232
+ });
1233
+ // the values the CONNECTION has, read back — not the request
1234
+ const { journalMode, synchronous } = store.capabilities.pragmas;
1235
+
1236
+ const checkpoint = await store.checkpoint({ mode: 'truncate' }); // { busy, logFrames, checkpointedFrames }
1237
+ const health = await store.integrityCheck(); // { ok, problems }
1238
+ const copy = await store.backupTo('backups/app.db', {
1239
+ rate: 64,
1240
+ onProgress: ({ totalPages, remainingPages }) => report(totalPages - remainingPages, totalPages),
1241
+ signal: controller.signal, // JD2079 between pages, nothing left behind
1242
+ });
1243
+
1244
+ for await (const job of store.jobs.page({ state: 'failed', kind: 'mail' })) await store.jobs.requeue(job.id);
1245
+ await store.jobs.sweep({ settledBefore: Date.now() - 7 * 24 * 3600 * 1000 });
1246
+
1247
+ try {
1248
+ await store.collection('users').insert(user);
1249
+ }
1250
+ catch (error) {
1251
+ if (error.class === 'busy' && error.retryable) scheduleRetry(); // one class, whichever path met it
1252
+ else throw error;
1253
+ }
1254
+ ```
1255
+
1256
+ The normative contract is [MODEL-FORMAT](docs/MODEL-FORMAT.md) §4 (the
1257
+ pragma set, the maintenance and backup rules, the cancellation report)
1258
+ and §7 (the codes and the classifier); the queue's administration is
1259
+ [JOBS-FORMAT](docs/JOBS-FORMAT.md) §10; a migration's cancellation and
1260
+ its side-effect-free status read are in
1261
+ [MIGRATION-FORMAT](docs/MIGRATION-FORMAT.md) §6.
1262
+
756
1263
  ## What this is not — every non-claim in one place
757
1264
 
758
1265
  - **SQLite only.** One backend (3.45+); the dialect seam is tested
@@ -774,9 +1281,10 @@ replication on these primitives is a roadmap item, not a hint.
774
1281
  horizon a view claims and is checked against the window it maintains;
775
1282
  the maintained state is still bounded by `live.maxMaintained`, and no
776
1283
  version of this compacts a bucket's rows away.
777
- - **The wasm build journals** (its session extension is not yet
778
- adapted); OPFS needs a secure context, and where it is absent the
779
- store runs in memory with the durability difference stated.
1284
+ - **Browser storage is probed.** The canonical wasm build supports session
1285
+ capture after a disposable live probe. Failed session probes select journal
1286
+ capture. OPFS, IndexedDB snapshots and memory have explicit capability and
1287
+ durability differences; see [execution hosts](docs/HOSTS.md).
780
1288
  - **Named future work, not silent gaps**: `$groupby` pushdown beyond
781
1289
  the `$time-bucket` ladder, a many-to-many hop on the chain, membership
782
1290
  on an auto-keyed pending insert, incremental joins, other SQL dialects,
@@ -792,3 +1300,27 @@ profile §8, entities §9, relational translation §10, the unit of work
792
1300
  queue §§1–9); the seams, the pushdown contract and every engine are in
793
1301
  [ARCHITECTURE.md](ARCHITECTURE.md); the benchmark methodology is in
794
1302
  [benchmark/README.md](../../benchmark/README.md).
1303
+
1304
+ ## Exports
1305
+
1306
+ Every subpath a consumer can import, derived from the manifest by
1307
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
1308
+
1309
+ <!--fact:exports.db-->
1310
+ | Import | Kind | Declarations |
1311
+ |---|---|---|
1312
+ | `@jarenjs/db` | JavaScript | declared |
1313
+ | `@jarenjs/db/node` | JavaScript | declared |
1314
+ | `@jarenjs/db/postgres` | JavaScript | declared |
1315
+ | `@jarenjs/db/bun` | JavaScript | declared |
1316
+ | `@jarenjs/db/wasm` | JavaScript | declared |
1317
+ | `@jarenjs/db/typed` | JavaScript | declared |
1318
+ | `@jarenjs/db/app` | JavaScript | declared |
1319
+ | `@jarenjs/db/schemas/jaren-migration.draft-07.schema.json` | schema | — |
1320
+ | `@jarenjs/db/schemas/jaren-migration.schema.json` | schema | — |
1321
+ | `@jarenjs/db/schemas/jaren-model.draft-07.schema.json` | schema | — |
1322
+ | `@jarenjs/db/schemas/jaren-model.schema.json` | schema | — |
1323
+ | `@jarenjs/db/package.json` | metadata | — |
1324
+ | `@jarenjs/db/node-worker` | JavaScript | declared |
1325
+ | `@jarenjs/db/node-pool` | JavaScript | declared |
1326
+ <!--/fact-->