@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/README.md CHANGED
@@ -48,7 +48,8 @@ const store = await openStore({
48
48
  const users = store.collection('users');
49
49
  await users.insert({ id: 'u1', email: 'ada@example.test', age: 36 });
50
50
 
51
- // a query document — here written by hand; linq writes the same thing
51
+ // a query document — here written by hand; linq writes the same one,
52
+ // its source wrapped as ['$[*]'] so an item that is an array stays one item
52
53
  const adults = await users.execute({
53
54
  $for: { it: '$[*]' },
54
55
  $where: { $ge: ['$it.age', 21] },
@@ -56,6 +57,78 @@ const adults = await users.execute({
56
57
  });
57
58
  ```
58
59
 
60
+ Or, by code — the same document, written by the model pen
61
+ ([MODEL-PEN.md](../linq/docs/MODEL-PEN.md)) and typed without a generate
62
+ step (`InferMeta<typeof model>` binds the typed store):
63
+
64
+ ```js
65
+ import * as m from '@jarenjs/linq/model';
66
+
67
+ const model = m.defineModel({
68
+ collections: {
69
+ users: m.collection(
70
+ m.object({ id: m.string(), email: m.string().email(), age: m.integer().optional() }).open(),
71
+ { key: (u) => u.id, indexes: [m.index((u) => u.age)] },
72
+ ),
73
+ },
74
+ });
75
+ const store = await openStore(model, { driver: nodeDriver(), path: 'app.db' });
76
+ ```
77
+
78
+ Over an entities model, an entity set is a provider too (MODEL-FORMAT
79
+ §10.1): a chain binds through the set's root and the store runs the
80
+ document whole — the translator for a bare-binding selection or
81
+ equijoin, the declared residual for a projection — while the store
82
+ itself, serving several roots, is refused by name (`JL0007`):
83
+
84
+ ```js
85
+ import { from, fromAsync } from '@jarenjs/linq';
86
+
87
+ const starred = from(store.sync.entity('Post')).where((p) => p.stars.ge(3));
88
+ starred.toDocument(); // { $for: { it: '$.Post[*]' }, $where: { $ge: ['$it.stars', 3] }, $return: '$it' }
89
+ starred.toArray(); // the entity translator, one statement
90
+ await fromAsync(store.entity('Post'))
91
+ .join(fromAsync(store.entity('User')), (p) => p.authorId, (u) => u.id, (p) => p)
92
+ .toArray(); // a two-root equijoin, one statement
93
+ ```
94
+
95
+ A declared relation navigates on the chain, and the document still
96
+ carries no relation name: the chain reads the set's relation table
97
+ (`store.entity('Post').relations`, MODEL-FORMAT §10.1) and lowers
98
+ `p.author.email` to the correlated phrase the engine and the store both
99
+ run. The store answers it as the residual it is — `explain()` says so,
100
+ `strict` refuses it — over the two fetched roots, never a statement per
101
+ row:
102
+
103
+ ```js
104
+ const byAuthor = from(store.sync.entity('Post'))
105
+ .where((p) => p.stars.ge(3))
106
+ .select((p) => ({ title: p.title, by: p.author.email }));
107
+
108
+ byAuthor.toDocument();
109
+ // { $for: { it: '$.Post[*]' },
110
+ // $where: { $ge: ['$it.stars', 3] },
111
+ // $return: { title: '$it.title',
112
+ // by: { $for: { r1: '$.User[*]' },
113
+ // $where: { $eq: ['$r1.id', '$it.authorId'] },
114
+ // $return: '$r1.email' } } }
115
+ byAuthor.explain().hops; // [{ member: 'author', kind: 'oneToOne', binding: 'r1' }]
116
+ store.sync.explain(byAuthor.toDocument());
117
+ // { mode: 'set', referenced: ['Post', 'User'], sql: null,
118
+ // reasons: [{ construct: '$return',
119
+ // reason: 'entity queries return one bare binding natively; projections run in the engine' }], … }
120
+ byAuthor.toArray(); // the rows, two fetches — one per referenced root
121
+
122
+ from(store.sync.entity('User')).where((u) => u.posts.all().count().ge(2));
123
+ // … $where: { $ge: [{ $count: { $for: { r1: '$.Post[*]' },
124
+ // $where: { $eq: ['$r1.authorId', '$it.id'] },
125
+ // $return: '$r1' } }, 2] } …
126
+ ```
127
+
128
+ A many-to-many member (`u.labels`) is refused at build time (`JL0105`)
129
+ naming the join table: it is not a queryable root in this version, so
130
+ `load({ include: { labels: true } })` is how the memberships are read.
131
+
59
132
  `execute` answers in the ENGINE's result shape (QUERY-FORMAT §1,
60
133
  "singleton ≡ item"): `undefined` for no rows, the document itself for
61
134
  exactly one, an array for more — typed `SequenceResult<R>`, with `R`
@@ -71,9 +144,12 @@ shape binds at `store.collection<User>('users')`.
71
144
  equivalent runs as a real compiled Jaren query (the residual), and
72
145
  `explain()` always says which is which — the SQL, the bound
73
146
  parameters, the indexes used (verified against the database's own
74
- plan output), and the residual's named reasons. A 418-run
75
- differential oracle keeps both paths agreeing. `strict: true` turns
76
- any residual into a compile error.
147
+ plan output), and the residual's named reasons. A differential
148
+ 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
151
+ compensated `SUM` and the engine's naive one differ in the last
152
+ bit). `strict: true` turns any residual into a compile error.
77
153
  - **Registered operators, correct in the residual, pushed where it
78
154
  pays.** Open with a registry (`operators:
79
155
  createJsltRegistry().use(mathPack).use(financePack)`) and a query may
@@ -113,12 +189,29 @@ shape binds at `store.collection<User>('users')`.
113
189
  decided or merely narrowed. The worked example, the geofence and the
114
190
  measured numbers are in [Spatial storage](#spatial-storage--the-model-the-plan-the-fence-the-numbers)
115
191
  below.
192
+ - **A time series is a composite index, not a storage kind.** Declare
193
+ `{ "path": ["$.series", "$.at"] }` over a numeric epoch member and the
194
+ planner recognizes three shapes over it: a half-open range under a
195
+ series equality, an as-of lookup (the index read backwards, one row),
196
+ and a fixed-width bucket ladder — a `$groupby` over `$time-bucket`, or
197
+ a `$resample` whose spec asks for nothing a `GROUP BY` cannot do — as
198
+ integer arithmetic in SQL. A fill policy, a calendar width, a rolling
199
+ window and an as-of JOIN are **named refinements**: the index bounds
200
+ the fetch and `@jarenjs/core/series` decides, with `explain().series`
201
+ carrying the reason code and the last run's actual candidate and
202
+ result counts. See [Time series](#time-series--the-index-the-ladder-the-refinement).
116
203
  - **Migrations are documents.** `planMigration` diffs two models into
117
204
  rendered-DDL + JSLT-transform + assertion steps; a shadow database
118
205
  replays the whole chain before the real store is touched; a
119
206
  checksummed history refuses edited or reordered migrations; a
120
207
  narrowing without an adequate transform is refused against the REAL
121
- data, inside the transaction.
208
+ data, inside the transaction. And by code: `@jarenjs/linq/migration`
209
+ ([MIGRATION-PEN.md](../linq/docs/MIGRATION-PEN.md))
210
+ writes the same document with the data transform typed old row → new
211
+ row, `jaren-db` loads model and migration MODULES beside JSON, plans
212
+ from the committed `model.snapshot.json`, refuses a module that is
213
+ not pure, and `jaren-db check` fails CI on a model that moved without
214
+ a plan (MIGRATION-FORMAT §11).
122
215
  - **The safe profile.** Untrusted query documents run under composed
123
216
  bounds: engine limits on the residual, a mandatory row bound that
124
217
  refuses rather than truncates, reference allow-lists, optional
@@ -228,16 +321,16 @@ every write (LIVE-FORMAT §7 states the cost). An ordering by
228
321
  `$distance` or a spatial aggregate re-runs on invalidation with the
229
322
  reason in `live.mode` — declared, never silent.
230
323
 
231
- **The numbers, the loss included.** `benchmark/spatial.js` stores <!--bm:spatial.corpus-->50,000 points<!--/bm-->
232
- over the Netherlands and probes one box at <!--bm:spatial.rows-->258 of 50,000 (0.5 %)<!--/bm--> selectivity,
324
+ **The numbers, the loss included.** `benchmark/spatial.js` stores <!--fact:spatial.corpus-->50,000 points<!--/fact-->
325
+ over the Netherlands and probes one box at <!--fact:spatial.rows-->258 of 50,000 (0.5 %)<!--/fact--> selectivity,
233
326
  asserting every plan case of the committed spatial corpus and every timed shape against the JavaScript
234
- engine before a single timing is printed. The `$within` a consumer writes went from <!--bm:spatial.scan-->80 ms<!--/bm-->
235
- as a full scan to <!--bm:spatial.within-->2 ms<!--/bm--> over the `bbox` index (<!--bm:spatial.scanVsIndexed-->40.0<!--/bm-->×);
236
- `$bbox-intersects` is <!--bm:spatial.bboxIntersects-->1.9 ms<!--/bm-->, a bounded `$distance` <!--bm:spatial.distance-->1.5 ms<!--/bm-->;
237
- one geohash cell answers in <!--bm:spatial.cellOne-->0.0068 ms for 0 row(s)<!--/bm--> and the honest nine-cell
238
- probe in <!--bm:spatial.cellNine-->0.026 ms for 2 row(s)<!--/bm-->. The row the store had to win is the same
239
- `$within` in the in-memory engine over the parsed array, no database at all: <!--bm:spatial.engine-->32 ms<!--/bm-->.
240
- The indexed store is now <!--bm:spatial.engineVsIndexed-->16.1× faster than<!--/bm--> it — but the un-indexed scan
327
+ engine before a single timing is printed. The `$within` a consumer writes went from <!--fact:spatial.scan-->80 ms<!--/fact-->
328
+ as a full scan to <!--fact:spatial.within-->2 ms<!--/fact--> over the `bbox` index (<!--fact:spatial.scanVsIndexed-->40.0<!--/fact-->×);
329
+ `$bbox-intersects` is <!--fact:spatial.bboxIntersects-->1.9 ms<!--/fact-->, a bounded `$distance` <!--fact:spatial.distance-->1.5 ms<!--/fact-->;
330
+ one geohash cell answers in <!--fact:spatial.cellOne-->0.0068 ms for 0 row(s)<!--/fact--> and the honest nine-cell
331
+ probe in <!--fact:spatial.cellNine-->0.026 ms for 2 row(s)<!--/fact-->. The row the store had to win is the same
332
+ `$within` in the in-memory engine over the parsed array, no database at all: <!--fact:spatial.engine-->32 ms<!--/fact-->.
333
+ The indexed store is now <!--fact:spatial.engineVsIndexed-->16.1× faster than<!--/fact--> it — but the un-indexed scan
241
334
  is not, and the comparison is not an even one either way: the engine starts from parsed objects where the
242
335
  store starts from bytes on a page and pays JSON materialisation for every row it returns. Both rows stay
243
336
  published. The deterministic-UDF hatch takes a literal `$within` on a collection with no derived index, and
@@ -252,10 +345,10 @@ columns (MODEL-FORMAT §2.1). The logical model is unchanged — the
252
345
  spatial corpus runs every entry under both mappings, in all three
253
346
  executors, with no special-cased entry — and the pushed conjunct becomes
254
347
  a `rowid` subquery over the virtual table. Through the store the same
255
- `$within` measures <!--bm:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/bm-->; loading the same rows
256
- costs <!--bm:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/bm-->, because the R\*Tree is a
348
+ `$within` measures <!--fact:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/fact-->; loading the same rows
349
+ costs <!--fact:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/fact-->, because the R\*Tree is a
257
350
  second table written inside every write transaction. Isolated from the
258
- store on a raw connection the probe is <!--bm:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/bm-->.
351
+ store on a raw connection the probe is <!--fact:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/fact-->.
259
352
  Both halves are published because both are the price. One honest
260
353
  difference comes with it: an R\*Tree stores 32-bit floats rounded
261
354
  outward, so its box is a superset and `$bbox-intersects` is refined
@@ -378,11 +471,11 @@ wrong width is refused at plan time and `explain()` says why; an
378
471
  that count, and a consumer who binds probes from a model should watch it.
379
472
 
380
473
  **The numbers, the losses included.** `benchmark/vector.js` measures one
381
- k-nearest query every physical way it can run — over <!--bm:vector.grid-->10,000 and 50,000 vectors at 384 and 768 dimensions, k = 10, the median of 10 probes<!--/bm--> —
474
+ 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--> —
382
475
  and asserts that every path returns the identical top-k, ids and order,
383
476
  on every probe before a single timing prints. The flagship row is the
384
- plan a consumer's own document runs, which measures <!--bm:vector.plan-->206 ms at 50,000 × 768<!--/bm-->:
385
- <!--bm:vector.table-->
477
+ plan a consumer's own document runs, which measures <!--fact:vector.plan-->206 ms at 50,000 × 768<!--/fact-->:
478
+ <!--fact:vector.table-->
386
479
  | path (ms) | 10,000 × 384 | 10,000 × 768 | 50,000 × 384 | 50,000 × 768 |
387
480
  |---|---:|---:|---:|---:|
388
481
  | engine resident sweep (no database) | 3.3 | 6.7 | 17 | 32 |
@@ -391,14 +484,14 @@ plan a consumer's own document runs, which measures <!--bm:vector.plan-->206 ms
391
484
  | `ORDER BY` over a registered function | 18 | 31 | 121 | 180 |
392
485
  | JSON-doc sweep (no vector column) | 249 | 501 | — | — |
393
486
  | sqlite-vec | 3.6 | 7.5 | 18 | 38 |
394
- <!--/bm-->
487
+ <!--/fact-->
395
488
 
396
489
  The row the column exists to beat is the last one that has no column: the
397
490
  same query document over a collection that stores the embedding only
398
- inside the document costs <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->,
491
+ inside the document costs <!--fact:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/fact-->,
399
492
  because every row's vector is parsed out of JSON before it can be
400
493
  compared. The row the store **cannot** beat is the one with no database
401
- in it: the same top-k over a resident `Float32Array` is <!--bm:vector.resident-->32 ms, which the plan is 6.4× slower than<!--/bm-->.
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-->.
402
495
  That comparison is not an even one and the direction is the point — the
403
496
  sweep starts from decoded floats in RAM and pays nothing for durability,
404
497
  for filters that compose with the ranking, or for a process that can
@@ -407,16 +500,16 @@ price should be able to say what the price is.
407
500
 
408
501
  **Both halves of the price.** The column costs on the way in as well as
409
502
  saving on the way out: writing the same documents with
410
- the index costs <!--bm:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/bm-->,
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-->,
411
504
  because every write pays a JSON round trip of the member plus a
412
- normalize and a pack. On disk one vector is <!--bm:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/bm--> —
505
+ 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--> —
413
506
  smaller, but *added*, since the document still carries the member the
414
507
  column is derived from.
415
508
 
416
509
  **Pushing the rank into SQL, re-measured.** A registered similarity
417
510
  function inside an `ORDER BY … LIMIT k` is the obvious alternative, and
418
511
  the suite measures it against the real column with the probe hoisted out
419
- of the per-row call: <!--bm: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<!--/bm-->.
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-->.
420
513
  The plan does not emit it, and after that measurement the reasons are not
421
514
  speed: `bun` has no user-function API, so a plan that needed one would
422
515
  exclude an executor outright; and an ordering decided in SQL cannot break
@@ -425,15 +518,15 @@ executors have to agree on.
425
518
 
426
519
  **The rival, and the ceiling.** `sqlite-vec` is the extension built for
427
520
  exactly this, and it is measured rather than described: it answers the
428
- same probes in <!--bm: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<!--/bm-->,
429
- over <!--bm:vector.agreement-->40 probes, no disagreements<!--/bm-->. It is a
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-->,
522
+ over <!--fact:vector.agreement-->40 probes, no disagreements<!--/fact-->. It is a
430
523
  loadable native extension, which is the one thing this store will not
431
524
  require — it would exclude the wasm tab and stock `bun`, half the
432
525
  execution story — so the comparison is published as what it is: a faster
433
526
  engine you may prefer, and a dependency this one does not take. What
434
527
  neither of them is, is an approximate index. Exact brute force is linear
435
528
  in `n · d`, and the suite states the envelope as arithmetic rather than
436
- opinion: <!--bm: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<!--/bm-->.
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-->.
437
530
  Past that this design is the wrong tool and no margin changes it; what
438
531
  lies beyond is an approximate index, and this store does not have one.
439
532
 
@@ -445,6 +538,92 @@ and unindexed — and every entry must answer identically, including a
445
538
  deliberate one-binary32-ulp near-tie and the windows that reach past the
446
539
  scored rows into the tail the column cannot rank.
447
540
 
541
+ ## Time series — the index, the ladder, the refinement
542
+
543
+ The physical declaration is one a model already has:
544
+
545
+ ```json
546
+ { "name": "by_series_at", "path": ["$.series", "$.at"] }
547
+ ```
548
+
549
+ No `derive` kind, no column type, no host function, no extension. What
550
+ the planner adds is the reading of that index — a B-tree seeks as far
551
+ as its leading columns are decided, so a query that pins `series` with
552
+ an equality and ranges over `at` is a SEARCH, and one that only bounds
553
+ `at` is a scan the plan says so about.
554
+
555
+ ```js
556
+ // native: SEARCH sample USING INDEX sample_by_series_at (gx_series=? AND gx_at>? AND gx_at<?)
557
+ await sample.execute({
558
+ $for: { s: '$[*]' },
559
+ $where: { $and: [
560
+ { $eq: ['$s.series', 'sensor-a'] },
561
+ { $ge: ['$s.at', from] },
562
+ { $lt: ['$s.at', to] },
563
+ ] },
564
+ $orderby: [{ $key: '$s.at' }],
565
+ $return: '$s',
566
+ });
567
+
568
+ // native: the same index, GROUP BY over integer bucket arithmetic
569
+ await sample.execute({ $resample: [
570
+ { $for: { s: '$[*]' }, $where: { $eq: ['$s.series', 'sensor-a'] }, $return: '$s' },
571
+ { every: 'PT1M', aggregate: 'mean' },
572
+ ] });
573
+
574
+ // hybrid: the index bounds the fetch, rollingSeries decides
575
+ await sample.execute({ $rolling: [
576
+ { $for: { s: '$[*]' }, $where: { $eq: ['$s.series', 'sensor-a'] }, $return: '$s' },
577
+ { width: 'PT1M', aggregate: 'mean', minPeriods: 30 },
578
+ ] });
579
+ ```
580
+
581
+ **The numbers, the loss included.** `benchmark/series.js` answers the
582
+ same range, the same buckets, the same rolling window and the same
583
+ as-of join over one seeded corpus by plain references, by the temporal
584
+ kernel, by a generic query document, by hand-written SQL and by the
585
+ store — every route checked against the others before a timing is
586
+ 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-->.
588
+ The range row is not the planner's price: the statement selects two
589
+ COLUMNS where the store renders and parses a whole JSON document per
590
+ row, which is what storing documents costs.
591
+
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-->
593
+
594
+ **A refinement is named, never quiet.** `explain().series` reports
595
+ `mode` — `native`, `hybrid` or `engine` — the declared index the fetch
596
+ seeks through, the instant bounds it used, which kernel finished the
597
+ answer, and a reason code for every thing the database could not do:
598
+ `fill-policy`, `calendar-width`, `named-zone`, `rolling-refinement`,
599
+ `asof-refinement`, `unsupported-aggregate`, `nonliteral-spec`,
600
+ `instant-not-integer`, `missing-series-prefix`, `row-selector`,
601
+ `value-not-numeric`, `nonnative-grouping`, `invalid-spec`. `strict: true` refuses
602
+ every one of them before a statement runs, and the counts `explain()`
603
+ prints are the LAST ACTUAL execution's — `null` until the document has
604
+ run, because an estimate wearing a count's name is worse than no
605
+ number.
606
+
607
+ **The as-of join is bounded, and the bound is the claim.** `$asof` with
608
+ the collection on the right reads the probes it was given, bounds the
609
+ 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
615
+ 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.
619
+
620
+ **One corpus, five executors, proven to agree.** The committed temporal
621
+ corpus (`test/json/fixtures/series-corpus.json`) runs through the plain
622
+ references, the query vocabulary, `node:sqlite`, a real wasm build and
623
+ both drivers again with pushdown forced off — indexed and unindexed —
624
+ and every case must answer identically, plan mode and reason codes
625
+ included.
626
+
448
627
  ## What SQLite-only means, frankly
449
628
 
450
629
  SQLite is the supported backend — 3.45 or newer, on `node:sqlite`,
@@ -479,6 +658,18 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
479
658
  `typedStore` (from `@jarenjs/db/typed`) types every read, checks
480
659
  every write, and widens `load` results by their include
481
660
  specification.
661
+ - **Membership** (§11.7): `link(own, member, target)` and `unlink` attach
662
+ and detach one many-to-many membership at a time through the unit of
663
+ work — written against the join table as it stands at save time, so a
664
+ repeated save changes nothing.
665
+ - **The front door**: `@jarenjs/linq/db` opens this store behind a
666
+ client typed from the model pen — `db.entities.Post.where((p) =>
667
+ p.stars.ge(3))` is the chain over the entity set, pushed down;
668
+ `db.entities.User.include((u) => u.posts, { where: (p) => p.stars.ge(3),
669
+ take: 2 }).toArray()` emits exactly the `load` spec above and runs in
670
+ the same one statement; `link`/`unlink` reach §11.7 and `live` the
671
+ registration below. It imports this package as an optional peer; this
672
+ package never imports it.
482
673
  - **Relational migrations and the `jaren-db` CLI** (MIGRATION-FORMAT
483
674
  §§9–12): the strategy-table diff, the documented twelve-step table
484
675
  rebuild with `foreign_key_check` inside the transaction, shape
@@ -493,7 +684,7 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
493
684
  journal where it does not (`bun:sqlite`, the wasm build). One diff
494
685
  format runs store → patch → live query → O(k) render. Capture is
495
686
  opt-in; the overhead is published, not waved away.
496
- - **Live queries** (LIVE-FORMAT §§7–12): `collection.live(document)`
687
+ - **Live queries** (LIVE-FORMAT §§7–13): `collection.live(document)`
497
688
  maintains a result as writes arrive and emits patches — incremental
498
689
  for `where`/`select`/`orderBy`+`limit`/aggregates/single-level
499
690
  `groupBy` and a spatial `where` over a derived index (the geofence;
@@ -501,6 +692,11 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
501
692
  **declared, never silent** (`live.mode` names the reason).
502
693
  Unaffected rows stay reference-identical; a seeded oracle holds the
503
694
  maintained result equal to a fresh re-query after every mutation.
695
+ - **Event time** (LIVE-FORMAT §13): a `$resample` or `$rolling` view
696
+ over a fixed width maintains exact event-time buckets and windows
697
+ against a watermark the HOST supplies — never a clock — and a reading
698
+ behind the declared lateness re-reads and emits a `lateData` record
699
+ rather than being folded in as though it had arrived on time.
504
700
  - **Durable runs and the job queue** (JOBS-FORMAT, FLOW-FORMAT §7.6):
505
701
  a `@jarenjs/flow` DAG run checkpoints declared nodes and RESUMES
506
702
  after a crash; `store.jobs` leases work in one guarded statement
@@ -510,7 +706,41 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
510
706
  queries, the same live updates run on the official SQLite wasm build
511
707
  over the header-free OPFS SAH-pool VFS — one tab owns the
512
708
  connection, others are clients. Proven in the `#/data` studio across
513
- Chromium, Firefox and WebKit.
709
+ Chromium, Firefox and WebKit. The subpath exports the two helpers
710
+ that studio is built on: `sqlite3Handle(sqlite3, { DbClass })` builds
711
+ the injected handle from a loaded wasm module and the database class
712
+ 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.
715
+
716
+ ### What an event-time view costs
717
+
718
+ `benchmark/live.js` maintains a 60 s bucket ladder and a 5 minute
719
+ rolling window over a seeded series and rewrites one reading per commit,
720
+ inside the lateness the view allows. The maintained rows are checked
721
+ against `resampleSeries` / `rollingSeries` over the **whole** collection
722
+ before a single timing is printed — a fast live view with the wrong
723
+ answer is not a fast live view — and the run exits non-zero if they
724
+ disagree.
725
+
726
+ <!--fact:live.eventTimeTable-->
727
+ | view | maintained | re-run | ratio |
728
+ |---|---:|---:|---:|
729
+ | bucket (60 s ladder, mean), 1000 rows | 119 µs | 1.09 ms | 9.2× |
730
+ | rolling (5 min window, mean), 1000 rows | 407 µs | 3.86 ms | 9.5× |
731
+ | bucket (60 s ladder, mean), 10000 rows | 136 µs | 10.9 ms | 80.2× |
732
+ | rolling (5 min window, mean), 10000 rows | 13.7 ms | 62.7 ms | 4.6× |
733
+ <!--/fact-->
734
+
735
+ The gain is <!--fact:live.eventTimeBand-->80.2× for the bucket and 4.6× for the rolling at 10,000 readings<!--/fact-->. A bucket
736
+ view is nearly flat in the series length, because a write folds one
737
+ bucket again and the rest of the ladder is untouched. A rolling view is
738
+ not, and the table says so: its answer is one row per reading, so the
739
+ emitted diff walks every one of them whatever changed. A bucket view
740
+ also holds one maintained entry per reading *plus* one per bucket, which
741
+ is over §12's default `maxMaintained` at ten thousand readings — the
742
+ bound errors rather than degrading, and raising it is a decision
743
+ somebody makes.
514
744
 
515
745
  ## Sync-readiness — what exists and what does not
516
746
 
@@ -540,13 +770,17 @@ replication on these primitives is a roadmap item, not a hint.
540
770
  priority classes, no cron, no workflow compensation.
541
771
  - **Live-query maintenance is limited to the declared table** (§7);
542
772
  joins, entity queries and non-canonical shapes re-run, reported.
773
+ - **`eventTime.retention` bounds repair work, not memory.** It is the
774
+ horizon a view claims and is checked against the window it maintains;
775
+ the maintained state is still bounded by `live.maxMaintained`, and no
776
+ version of this compacts a bucket's rows away.
543
777
  - **The wasm build journals** (its session extension is not yet
544
778
  adapted); OPFS needs a secure context, and where it is absent the
545
779
  store runs in memory with the durability difference stated.
546
- - **Named future work, not silent gaps**: `$groupby` pushdown,
547
- relation-name query sugar, a many-to-many membership API, incremental
548
- joins, other SQL dialects, replication, database introspection
549
- (MODEL-FORMAT §10.6, the roadmap).
780
+ - **Named future work, not silent gaps**: `$groupby` pushdown beyond
781
+ the `$time-bucket` ladder, a many-to-many hop on the chain, membership
782
+ on an auto-keyed pending insert, incremental joins, other SQL dialects,
783
+ replication, database introspection (MODEL-FORMAT §10.6, the roadmap).
550
784
 
551
785
  The normative formats are
552
786
  [docs/MODEL-FORMAT.md](docs/MODEL-FORMAT.md) (storage §§1–7, safe
@@ -554,7 +788,7 @@ profile §8, entities §9, relational translation §10, the unit of work
554
788
  §11), [docs/MIGRATION-FORMAT.md](docs/MIGRATION-FORMAT.md) (documents
555
789
  §§1–8, relational changes §§9–12),
556
790
  [docs/LIVE-FORMAT.md](docs/LIVE-FORMAT.md) (capture §§1–6, live queries
557
- §§7–12) and [docs/JOBS-FORMAT.md](docs/JOBS-FORMAT.md) (the durable
791
+ §§7–12, event time §13) and [docs/JOBS-FORMAT.md](docs/JOBS-FORMAT.md) (the durable
558
792
  queue §§1–9); the seams, the pushdown contract and every engine are in
559
793
  [ARCHITECTURE.md](ARCHITECTURE.md); the benchmark methodology is in
560
794
  [benchmark/README.md](../../benchmark/README.md).
@@ -49,7 +49,10 @@ restarting.
49
49
  `_jaren_job_checkpoints` holds `(run_id, node_id, value)` rows — the
50
50
  flow checkpoint store of §7, keyed by the JOB id (the run id IS the
51
51
  job id). Both tables are created on open when `jobs` is requested;
52
- neither appears in the model.
52
+ neither appears in the model. `enqueue`'s options are validated as
53
+ they are stored: `runAt` must be a finite epoch in milliseconds and
54
+ `maxAttempts` a positive integer (`TypeError`) — a `NaN` eligibility
55
+ was once stored, and that job was pending forever.
53
56
 
54
57
  ## 3. Leasing and exactly-once execution
55
58
 
@@ -113,22 +116,31 @@ reclaimed — size `leaseMs` to the slowest honest handler.
113
116
  ## 6. Workers and concurrency
114
117
 
115
118
  `createWorker({ handlers, concurrency, pollInterval, leaseMs, owner,
116
- maxAttempts, backoff, random })` returns `{ start(), stop(),
119
+ backoffBase, backoffCap, stopGraceMs })` returns `{ start(), stop(),
117
120
  stats() }`:
118
121
 
119
- - `concurrency` (default 1) independent claim-execute loops share one
120
- worker registration;
122
+ - `concurrency` (default 1, a positive integer `TypeError`
123
+ otherwise, because a worker with zero loops would `start()` and never
124
+ claim) independent claim-execute loops share one worker registration;
121
125
  - an idle loop sleeps `pollInterval` (default 500 ms — at most
122
126
  2 claims/s of idle cost per loop, stated). An `enqueue` on the SAME
123
127
  store wakes every idle local loop immediately, so same-process
124
128
  latency is not poll-bound; **cross-process wake-up is polling**,
125
129
  plainly (§8);
130
+ - retry backoff is `min(backoffCap, backoffBase × 2^(attempts − 1))`,
131
+ jittered to between half and all of itself (defaults 1 s and 60 s);
132
+ `maxAttempts` belongs to the JOB (`enqueue(kind, payload,
133
+ { maxAttempts })`, default 5), not to the worker;
126
134
  - `stop({ graceMs })` aborts in-flight handlers and resolves once they
127
- settle **or** the grace period expires (default 5 s), answering
135
+ settle **or** the grace period expires (default `stopGraceMs`, 5 s),
136
+ answering
128
137
  `{ drained, inFlight }`; a loop the grace period could not drain is
129
138
  **cancelled**, not left running — see §6.1;
130
- - `stats()` reports claims, completions, failures, wakes, polls and the
131
- in-flight handler count.
139
+ - `stats()` reports claims, completions, failures, wakes, polls, the
140
+ in-flight handler count and `claimErrors` — a claim statement the
141
+ database refused (a read-only file, a closed store), counted rather
142
+ than swallowed, so a worker that can never claim is visible instead
143
+ of silently idle.
132
144
 
133
145
  ### 6.1 A handler cannot break the loop, and cannot hold shutdown
134
146
 
@@ -224,6 +236,10 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
224
236
  This format adds NO codes. API misuse (a malformed handler map, a
225
237
  non-string kind, a worker started twice) is a `TypeError` at the
226
238
  call, matching the capture and live precedents; storage failures ride
227
- the existing `JD2005` wrap; a job's own failure is DATA — recorded in
239
+ the existing `JD2005` wrap and a coded error passes through it
240
+ unchanged (`JD2063` after `close()`); `store.jobs` on a read-only
241
+ store is `JD0002` at first use, because the queue tables cannot be
242
+ created — named there, not a raw `SQLITE_READONLY` at the first
243
+ enqueue; a job's own failure is DATA — recorded in
228
244
  `last_error` and the state machine of §4 — because a queue that
229
245
  throws away its failure story has failed twice.