@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.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -149
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -167
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live.d.ts +0 -62
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- 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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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 <!--
|
|
232
|
-
over the Netherlands and probes one box at <!--
|
|
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 <!--
|
|
235
|
-
as a full scan to <!--
|
|
236
|
-
`$bbox-intersects` is <!--
|
|
237
|
-
one geohash cell answers in <!--
|
|
238
|
-
probe in <!--
|
|
239
|
-
`$within` in the in-memory engine over the parsed array, no database at all: <!--
|
|
240
|
-
The indexed store is now <!--
|
|
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 <!--
|
|
256
|
-
costs <!--
|
|
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 <!--
|
|
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 <!--
|
|
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 <!--
|
|
385
|
-
<!--
|
|
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
|
-
<!--/
|
|
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 <!--
|
|
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 <!--
|
|
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 <!--
|
|
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 <!--
|
|
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: <!--
|
|
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 <!--
|
|
429
|
-
over <!--
|
|
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: <!--
|
|
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–
|
|
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
|
-
|
|
548
|
-
joins, other SQL dialects,
|
|
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).
|
package/docs/JOBS-FORMAT.md
CHANGED
|
@@ -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
|
-
|
|
119
|
+
backoffBase, backoffCap, stopGraceMs })` returns `{ start(), stop(),
|
|
117
120
|
stats() }`:
|
|
118
121
|
|
|
119
|
-
- `concurrency` (default 1
|
|
120
|
-
worker
|
|
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),
|
|
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
|
|
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
|
|
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.
|