@jarenjs/db 0.56.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- 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.
|
|
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
|
-
|
|
150
|
-
|
|
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,
|
|
219
|
-
|
|
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.**
|
|
374
|
-
|
|
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-->
|
|
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
|
|
482
|
-
| **the k-nearest plan (the store's own)** |
|
|
483
|
-
| raw fetch + engine sweep (the plan's statement) |
|
|
484
|
-
| `ORDER BY` over a registered function |
|
|
485
|
-
| JSON-doc sweep (no vector column) |
|
|
486
|
-
| sqlite-vec | 3.
|
|
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.
|
|
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-->
|
|
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.
|
|
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-->
|
|
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-->
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
611
|
-
failure mode a batch exists to refuse is one seek per left row, and
|
|
612
|
-
`test/db/statement-count.test.js` pins
|
|
613
|
-
Without a `tolerance`
|
|
614
|
-
|
|
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,
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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,19 +970,37 @@ 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`,
|
|
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.
|
|
993
|
+
- **Portable replication** ([REPLICATION-FORMAT](docs/REPLICATION-FORMAT.md)):
|
|
994
|
+
opt-in replica identities, causal frontiers, bounded logical envelopes,
|
|
995
|
+
durable replay receipts, explicit conflict evidence and snapshot resets.
|
|
996
|
+
Data and acknowledgements commit together. Hosts supply transport and any
|
|
997
|
+
conflict resolver; the default preserves both contenders and rejects the write.
|
|
687
998
|
- **Live queries** (LIVE-FORMAT §§7–13): `collection.live(document)`
|
|
688
999
|
maintains a result as writes arrive and emits patches — incremental
|
|
689
1000
|
for `where`/`select`/`orderBy`+`limit`/aggregates/single-level
|
|
690
1001
|
`groupBy` and a spatial `where` over a derived index (the geofence;
|
|
691
|
-
the normative maintenance table),
|
|
1002
|
+
the normative maintenance table), indexed inner/left entity joins, bounded
|
|
1003
|
+
graph projections and explicit two-level groups; re-run for other shapes,
|
|
692
1004
|
**declared, never silent** (`live.mode` names the reason).
|
|
693
1005
|
Unaffected rows stay reference-identical; a seeded oracle holds the
|
|
694
1006
|
maintained result equal to a fresh re-query after every mutation.
|
|
@@ -699,19 +1011,53 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
|
|
|
699
1011
|
rather than being folded in as though it had arrived on time.
|
|
700
1012
|
- **Durable runs and the job queue** (JOBS-FORMAT, FLOW-FORMAT §7.6):
|
|
701
1013
|
a `@jarenjs/flow` DAG run checkpoints declared nodes and RESUMES
|
|
702
|
-
after a crash; `store.jobs` leases work in one guarded statement
|
|
703
|
-
|
|
704
|
-
|
|
1014
|
+
after a crash; `store.jobs` leases work in one guarded statement (no
|
|
1015
|
+
distributed lock), retries with backoff, dead-letters, and reclaims
|
|
1016
|
+
expired leases as recovery. Every settling call carries a FENCE — the
|
|
1017
|
+
opaque token one claim mints, plus a lease that is still valid — so an
|
|
1018
|
+
expired or superseded attempt cannot mark a job done over the live
|
|
1019
|
+
one's result, and is told which of three things happened rather than
|
|
1020
|
+
answered `false`. `jobs.renew()` replaces a lease while a handler runs;
|
|
1021
|
+
the worker does it automatically, retries a renewal that failed for a
|
|
1022
|
+
mere storage reason, and aborts only an attempt whose lease is proven
|
|
1023
|
+
lost by a fence code. Settlement is exactly-once **against the store**
|
|
1024
|
+
— an external effect is still the handler's to make idempotent.
|
|
1025
|
+
Ownership follows the handle: root `store.jobs.*` and all worker I/O
|
|
1026
|
+
take the store gate and never join an open application transaction,
|
|
1027
|
+
while `tx.jobs` is the transactional outbox that co-commits with it;
|
|
1028
|
+
and `worker.stop()` quiesces every claim, renewal, checkpoint and
|
|
1029
|
+
settlement before it resolves, so closing the store releases the
|
|
1030
|
+
database file deterministically.
|
|
705
1031
|
- **The browser** (`@jarenjs/db/wasm`): the same store, the same
|
|
706
1032
|
queries, the same live updates run on the official SQLite wasm build
|
|
707
|
-
over
|
|
1033
|
+
over a probed persistence ladder: isolated SharedArrayBuffer OPFS,
|
|
1034
|
+
header-free SAH-pool OPFS, atomic IndexedDB snapshots, then visibly
|
|
1035
|
+
non-durable memory. One tab owns the
|
|
708
1036
|
connection, others are clients. Proven in the `#/data` studio across
|
|
709
|
-
Chromium, Firefox and WebKit
|
|
1037
|
+
Chromium, Firefox and WebKit, whose boot is a closed five-stage protocol
|
|
1038
|
+
(LIVE-FORMAT §11): every attempt ends ready or in a named, retryable
|
|
1039
|
+
`DATA_BOOT` failure, never a status line stuck at boot. The subpath exports the two helpers
|
|
710
1040
|
that studio is built on: `sqlite3Handle(sqlite3, { DbClass })` builds
|
|
711
1041
|
the injected handle from a loaded wasm module and the database class
|
|
712
1042
|
the host picks (`sqlite3.oo1.DB` in memory, the SAH-pool
|
|
713
|
-
`OpfsSAHPoolDb` for OPFS), and `adaptOo1Database(sqlite3, db)`
|
|
714
|
-
an oo1 database the host already opened.
|
|
1043
|
+
`OpfsSAHPoolDb` or `OpfsDb` for OPFS), and `adaptOo1Database(sqlite3, db)`
|
|
1044
|
+
wraps an oo1 database the host already opened. `indexedDbSnapshotHandle`
|
|
1045
|
+
adds bounded atomic persistence with asynchronous commit acknowledgements;
|
|
1046
|
+
its connection declares synchronous/live methods unavailable.
|
|
1047
|
+
- **The runtime record** (`@jarenjs/core/runtime`): `openStore(model,
|
|
1048
|
+
{ runtime })` and `migrate(target, migrations, { runtime })` take one
|
|
1049
|
+
frozen record — `{ now, uuid, random, zoneProvider }`, defaulting
|
|
1050
|
+
member for member to the platform's own — for the clock the capture
|
|
1051
|
+
log and the job queue stamp, the identifier a `uuid` identity and a
|
|
1052
|
+
`default: 'uuid'` allocate, the backoff jitter, and the zone provider
|
|
1053
|
+
a temporal spec naming a zone compiles through. The store hands it to
|
|
1054
|
+
the job engine it constructs, so a consumer configures it once, and a
|
|
1055
|
+
subsystem's own explicit option (`zoneProvider`, `jobs.now`,
|
|
1056
|
+
`jobs.random`) wins over the record's member. A query `deadline` is an
|
|
1057
|
+
absolute instant the caller computed and is compared against the
|
|
1058
|
+
record's `now` — before a statement runs and at every row boundary of
|
|
1059
|
+
a cursor or page — so a deterministic run computes its deadlines from
|
|
1060
|
+
the same clock the store reads.
|
|
715
1061
|
|
|
716
1062
|
### What an event-time view costs
|
|
717
1063
|
|
|
@@ -742,6 +1088,86 @@ is over §12's default `maxMaintained` at ten thousand readings — the
|
|
|
742
1088
|
bound errors rather than degrading, and raising it is a decision
|
|
743
1089
|
somebody makes.
|
|
744
1090
|
|
|
1091
|
+
## Indexing a computed value
|
|
1092
|
+
|
|
1093
|
+
An index may name a computation instead of a member:
|
|
1094
|
+
|
|
1095
|
+
```js
|
|
1096
|
+
const model = {
|
|
1097
|
+
$model: '0.1',
|
|
1098
|
+
collections: {
|
|
1099
|
+
users: {
|
|
1100
|
+
schema: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } },
|
|
1101
|
+
key: '/id',
|
|
1102
|
+
indexes: [{
|
|
1103
|
+
name: 'by_lower_email',
|
|
1104
|
+
expression: { call: 'lower', args: [{ member: '$.email' }] },
|
|
1105
|
+
unique: true,
|
|
1106
|
+
}],
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const store = await openStore(model, {
|
|
1112
|
+
driver: nodeDriver(),
|
|
1113
|
+
expressions: {
|
|
1114
|
+
lower: {
|
|
1115
|
+
arity: 1,
|
|
1116
|
+
deterministic: true,
|
|
1117
|
+
apply: (value) => String(value).toLowerCase(), // SQLite registers this
|
|
1118
|
+
sql: 'lower', // PostgreSQL calls its own
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
await store.collection('users').insert({ id: 'a', email: 'ANN@Example.COM' });
|
|
1124
|
+
await store.collection('users').insert({ id: 'b', email: 'ann@example.com' });
|
|
1125
|
+
// JD2001 — the unique index is over the LOWERED value
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
The expression is a closed AST — a member, a JSON scalar, or a call —
|
|
1129
|
+
and **never SQL text**. The function is DECLARED by you and resolved at
|
|
1130
|
+
open: an unknown name, a wrong arity, one not declared `deterministic`,
|
|
1131
|
+
or a declaration missing the half this engine needs is `JD0004` before a
|
|
1132
|
+
single statement runs. That is not ceremony: an index over a function is
|
|
1133
|
+
a schema dependency, and a database whose column is computed by
|
|
1134
|
+
`lower(…)` cannot be written from a connection that has no `lower`.
|
|
1135
|
+
|
|
1136
|
+
`@jarenjs/linq/model`'s `expressionIndex()` writes the same document
|
|
1137
|
+
from a lambda: `expressionIndex({ call: 'lower', args: [(d) => d.email] },
|
|
1138
|
+
{ unique: true })`.
|
|
1139
|
+
|
|
1140
|
+
## Reading a database back into a model
|
|
1141
|
+
|
|
1142
|
+
`store.introspect()` answers the `jaren-model` document a live
|
|
1143
|
+
database's shape says it is, and a report of everything the shape
|
|
1144
|
+
cannot carry:
|
|
1145
|
+
|
|
1146
|
+
```js
|
|
1147
|
+
const { model, report } = await store.introspect({ keys: { users: '/id' } });
|
|
1148
|
+
// model — a valid jaren-model: collections, keys, indexes with their paths,
|
|
1149
|
+
// entities with their keys, unique/indexed columns and foreign keys
|
|
1150
|
+
// report — [{ code, object, detail }], sorted, one row per gap
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
It issues no DDL and no DML — every statement it runs begins `SELECT` —
|
|
1154
|
+
so reading a production database is a read. What comes back is exact
|
|
1155
|
+
where the shape carries the fact and REPORTED where it does not: a
|
|
1156
|
+
document's unindexed members are not in the physical shape
|
|
1157
|
+
(`document-members`), a text key column cannot say which member filled
|
|
1158
|
+
it (`key-source` — `options.keys` supplies the pointer), a view cannot
|
|
1159
|
+
be declared (`unmapped-view`), a foreign key cannot say which side
|
|
1160
|
+
declared the edge (`ambiguous-relation`). `strict: true` refuses rather
|
|
1161
|
+
than answering a partial model.
|
|
1162
|
+
|
|
1163
|
+
The derived model is usable as a migration's `from`: read a database,
|
|
1164
|
+
plan against your declared model, and an unchanged shape plans nothing.
|
|
1165
|
+
SQLite and PostgreSQL derive the same logical model from equivalent
|
|
1166
|
+
databases — same collections, same keys, same index names and paths,
|
|
1167
|
+
same report rows — with the one difference the PostgreSQL mapping
|
|
1168
|
+
states: `numeric` carries both JSON number types, so an `integer`
|
|
1169
|
+
member reads back as `number`.
|
|
1170
|
+
|
|
745
1171
|
## Sync-readiness — what exists and what does not
|
|
746
1172
|
|
|
747
1173
|
The change stream is an ordered log of RFC 6902 patches with a
|
|
@@ -753,6 +1179,93 @@ writes made by another connection (the coarse `dataVersion()` signal
|
|
|
753
1179
|
is the honest mitigation, not a pretend fine-grained one). Building
|
|
754
1180
|
replication on these primitives is a roadmap item, not a hint.
|
|
755
1181
|
|
|
1182
|
+
## Operating a store — configuration, maintenance, backup, cancellation, the queue
|
|
1183
|
+
|
|
1184
|
+
Everything an operator does to a production SQLite database is a typed
|
|
1185
|
+
operation with a capability, a cancellation and an error class; none of
|
|
1186
|
+
it needs raw SQL or the raw handle. The rules, in one place:
|
|
1187
|
+
|
|
1188
|
+
- **Configuration is a closed, validated set, read back.** Eight
|
|
1189
|
+
connection pragmas are `openStore` options — `busyTimeout`,
|
|
1190
|
+
`journalMode`, `synchronous`, `walAutocheckpoint`, `journalSizeLimit`,
|
|
1191
|
+
`cacheSize`, `mmapSize`, `tempStore` — validated before anything
|
|
1192
|
+
reaches SQL. An option naming any other pragma is refused (`JD0006`),
|
|
1193
|
+
one the driver or the store kind cannot apply is refused (`JD0007`),
|
|
1194
|
+
and after the open every declared pragma is read back:
|
|
1195
|
+
`store.capabilities.pragmas` carries the values the connection
|
|
1196
|
+
actually has, and an explicitly requested value the engine did not
|
|
1197
|
+
take refuses the open (`JD0008`) rather than leaving a store that
|
|
1198
|
+
believes a configuration it does not have.
|
|
1199
|
+
- **Maintenance is four typed operations under the store gate** —
|
|
1200
|
+
`checkpoint({ mode })`, `integrityCheck({ limit })`, `foreignKeyCheck()`,
|
|
1201
|
+
`optimize()` — each answering SQLite's own row as typed data, refused
|
|
1202
|
+
by code (`JD2077`) exactly where `capabilities.maintenance` says
|
|
1203
|
+
`false` (a read-only store for the two that write; a binding that
|
|
1204
|
+
declares one absent). Corruption an integrity check finds is its
|
|
1205
|
+
RESULT, never a throw.
|
|
1206
|
+
- **A backup is published whole or not at all.** `backupTo(path)` copies
|
|
1207
|
+
a live store while writers proceed, into a temporary sibling in the
|
|
1208
|
+
same directory, and renames it onto the target only once the platform
|
|
1209
|
+
reported the copy complete; a cancelled (`JD2079`) or failed copy
|
|
1210
|
+
leaves neither the target nor the temporary file. Where it goes, how
|
|
1211
|
+
it is named, rotated or encrypted is the host's.
|
|
1212
|
+
- **Cancellation is honoured where the driver can honour it, and the
|
|
1213
|
+
report says where.** `capabilities.cancellation` states the
|
|
1214
|
+
granularity per lifecycle — `query: 'row'`, `queue: true`, `migration:
|
|
1215
|
+
'step'`, `maintenance: 'statement'`, `backup: 'page'`, `midStatement:
|
|
1216
|
+
false` — and every operation with more than one unit of work takes
|
|
1217
|
+
`{ signal, deadline }` on the store's injected clock. A cursor over a
|
|
1218
|
+
binding with no lazy iterator says `streaming: 'buffered'` with a
|
|
1219
|
+
driver barrier instead of a row stream it cannot deliver.
|
|
1220
|
+
- **One classification of driver failures.** A locked, full, read-only,
|
|
1221
|
+
corrupt or unopenable database arrives under one code with a stable
|
|
1222
|
+
`class` and a `retryable` verdict from every path alike (`JD2005`
|
|
1223
|
+
busy, `JD2082`–`JD2085`; `JD0002` at open), the driver's error as
|
|
1224
|
+
`cause`; a pushed integer aggregate past int64 is answered by the
|
|
1225
|
+
engine as the coded residual it always was elsewhere.
|
|
1226
|
+
- **The queue is administrable, never scheduled.** `store.jobs.page`,
|
|
1227
|
+
`cancel` (through the lease fence), `requeue` and `sweep` (with a
|
|
1228
|
+
required horizon) are mechanisms; WHEN to sweep or cancel is the
|
|
1229
|
+
host's call, and priority classes stay a documented non-goal.
|
|
1230
|
+
|
|
1231
|
+
```js
|
|
1232
|
+
import { openStore } from '@jarenjs/db';
|
|
1233
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
1234
|
+
|
|
1235
|
+
const store = await openStore(model, {
|
|
1236
|
+
driver: nodeDriver(), path: 'app.db', jobs: true,
|
|
1237
|
+
synchronous: 'normal', cacheSize: -8000, walAutocheckpoint: 250,
|
|
1238
|
+
});
|
|
1239
|
+
// the values the CONNECTION has, read back — not the request
|
|
1240
|
+
const { journalMode, synchronous } = store.capabilities.pragmas;
|
|
1241
|
+
|
|
1242
|
+
const checkpoint = await store.checkpoint({ mode: 'truncate' }); // { busy, logFrames, checkpointedFrames }
|
|
1243
|
+
const health = await store.integrityCheck(); // { ok, problems }
|
|
1244
|
+
const copy = await store.backupTo('backups/app.db', {
|
|
1245
|
+
rate: 64,
|
|
1246
|
+
onProgress: ({ totalPages, remainingPages }) => report(totalPages - remainingPages, totalPages),
|
|
1247
|
+
signal: controller.signal, // JD2079 between pages, nothing left behind
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
for await (const job of store.jobs.page({ state: 'failed', kind: 'mail' })) await store.jobs.requeue(job.id);
|
|
1251
|
+
await store.jobs.sweep({ settledBefore: Date.now() - 7 * 24 * 3600 * 1000 });
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
await store.collection('users').insert(user);
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
if (error.class === 'busy' && error.retryable) scheduleRetry(); // one class, whichever path met it
|
|
1258
|
+
else throw error;
|
|
1259
|
+
}
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
The normative contract is [MODEL-FORMAT](docs/MODEL-FORMAT.md) §4 (the
|
|
1263
|
+
pragma set, the maintenance and backup rules, the cancellation report)
|
|
1264
|
+
and §7 (the codes and the classifier); the queue's administration is
|
|
1265
|
+
[JOBS-FORMAT](docs/JOBS-FORMAT.md) §10; a migration's cancellation and
|
|
1266
|
+
its side-effect-free status read are in
|
|
1267
|
+
[MIGRATION-FORMAT](docs/MIGRATION-FORMAT.md) §6.
|
|
1268
|
+
|
|
756
1269
|
## What this is not — every non-claim in one place
|
|
757
1270
|
|
|
758
1271
|
- **SQLite only.** One backend (3.45+); the dialect seam is tested
|
|
@@ -769,18 +1282,20 @@ replication on these primitives is a roadmap item, not a hint.
|
|
|
769
1282
|
there. Same-host processes over WAL are the supported topology. No
|
|
770
1283
|
priority classes, no cron, no workflow compensation.
|
|
771
1284
|
- **Live-query maintenance is limited to the declared table** (§7);
|
|
772
|
-
joins
|
|
1285
|
+
indexed joins and graph projections require bounded dependencies. Offset
|
|
1286
|
+
windows, unindexed joins, load-spec graphs and non-canonical shapes re-run, reported.
|
|
773
1287
|
- **`eventTime.retention` bounds repair work, not memory.** It is the
|
|
774
1288
|
horizon a view claims and is checked against the window it maintains;
|
|
775
1289
|
the maintained state is still bounded by `live.maxMaintained`, and no
|
|
776
1290
|
version of this compacts a bucket's rows away.
|
|
777
|
-
- **
|
|
778
|
-
|
|
779
|
-
|
|
1291
|
+
- **Browser storage is probed.** The canonical wasm build supports session
|
|
1292
|
+
capture after a disposable live probe. Failed session probes select journal
|
|
1293
|
+
capture. OPFS, IndexedDB snapshots and memory have explicit capability and
|
|
1294
|
+
durability differences; see [execution hosts](docs/HOSTS.md).
|
|
780
1295
|
- **Named future work, not silent gaps**: `$groupby` pushdown beyond
|
|
781
1296
|
the `$time-bucket` ladder, a many-to-many hop on the chain, membership
|
|
782
|
-
on an auto-keyed pending insert,
|
|
783
|
-
|
|
1297
|
+
on an auto-keyed pending insert, additional join/group shapes, other SQL dialects,
|
|
1298
|
+
transport policy, database introspection (MODEL-FORMAT §10.6, the roadmap).
|
|
784
1299
|
|
|
785
1300
|
The normative formats are
|
|
786
1301
|
[docs/MODEL-FORMAT.md](docs/MODEL-FORMAT.md) (storage §§1–7, safe
|
|
@@ -792,3 +1307,31 @@ profile §8, entities §9, relational translation §10, the unit of work
|
|
|
792
1307
|
queue §§1–9); the seams, the pushdown contract and every engine are in
|
|
793
1308
|
[ARCHITECTURE.md](ARCHITECTURE.md); the benchmark methodology is in
|
|
794
1309
|
[benchmark/README.md](../../benchmark/README.md).
|
|
1310
|
+
|
|
1311
|
+
## Exports
|
|
1312
|
+
|
|
1313
|
+
Every subpath a consumer can import, derived from the manifest by
|
|
1314
|
+
`npm run docs:derive` (`npm run docs:check` fails when the two drift):
|
|
1315
|
+
|
|
1316
|
+
<!--fact:exports.db-->
|
|
1317
|
+
| Import | Kind | Declarations |
|
|
1318
|
+
|---|---|---|
|
|
1319
|
+
| `@jarenjs/db` | JavaScript | declared |
|
|
1320
|
+
| `@jarenjs/db/node` | JavaScript | declared |
|
|
1321
|
+
| `@jarenjs/db/postgres` | JavaScript | declared |
|
|
1322
|
+
| `@jarenjs/db/bun` | JavaScript | declared |
|
|
1323
|
+
| `@jarenjs/db/wasm` | JavaScript | declared |
|
|
1324
|
+
| `@jarenjs/db/typed` | JavaScript | declared |
|
|
1325
|
+
| `@jarenjs/db/app` | JavaScript | declared |
|
|
1326
|
+
| `@jarenjs/db/schemas/jaren-migration.draft-07.schema.json` | schema | — |
|
|
1327
|
+
| `@jarenjs/db/schemas/jaren-migration.schema.json` | schema | — |
|
|
1328
|
+
| `@jarenjs/db/schemas/jaren-model.draft-07.schema.json` | schema | — |
|
|
1329
|
+
| `@jarenjs/db/schemas/jaren-model.schema.json` | schema | — |
|
|
1330
|
+
| `@jarenjs/db/schemas/jaren-replication-snapshot.draft-07.schema.json` | schema | — |
|
|
1331
|
+
| `@jarenjs/db/schemas/jaren-replication-snapshot.schema.json` | schema | — |
|
|
1332
|
+
| `@jarenjs/db/schemas/jaren-replication.draft-07.schema.json` | schema | — |
|
|
1333
|
+
| `@jarenjs/db/schemas/jaren-replication.schema.json` | schema | — |
|
|
1334
|
+
| `@jarenjs/db/package.json` | metadata | — |
|
|
1335
|
+
| `@jarenjs/db/node-worker` | JavaScript | declared |
|
|
1336
|
+
| `@jarenjs/db/node-pool` | JavaScript | declared |
|
|
1337
|
+
<!--/fact-->
|