@jarenjs/db 0.49.2 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/ARCHITECTURE.md CHANGED
@@ -1,21 +1,21 @@
1
1
  # @jarenjs/db — architecture
2
2
 
3
- The public documentation promises SQLite and nothing else. This file
4
- documents the two seams that make that promise cheap to keep and cheap
5
- to outgrow — an undocumented seam decays into an accident.
3
+ The package separates Store behavior from driver ownership and dialect spelling.
6
4
 
7
5
  ## The driver seam (`src/driver.js`, `src/drivers/*`)
8
6
 
9
- A driver is `{ name, dialect, open(path, options) }` returning a
10
- `Connection` (see `docs/MODEL-FORMAT.md` §4). Three bindings exist,
11
- one per subpath `./node` (`node:sqlite`), `./bun` (`bun:sqlite`),
12
- `./wasm` (an injected handle) and each imports its runtime builtin
13
- **lazily inside `open()`**, never at module scope. The release gate
14
- imports every export subpath under Node *and* Bun; Bun ships no
15
- `node:sqlite` and Node cannot resolve `bun:` specifiers, so a
16
- top-level builtin import would turn the gate red in both directions.
17
- `open()` is where "this binding does not exist here" becomes the coded
18
- `JD0003`.
7
+ A driver is `{ name, dialect, open(path, options) }` returning a Connection
8
+ (`docs/MODEL-FORMAT.md` §4). Runtime bindings live behind public Node, Bun, wasm,
9
+ PostgreSQL, Node worker and Node pool subpaths. Node and Bun builtins load lazily
10
+ inside `open()`, so importing a subpath remains portable and an unavailable
11
+ binding becomes `JD0003`. The worker endpoint's static Node imports are reached
12
+ only when the Node worker factory launches that endpoint.
13
+
14
+ Worker protocol, scheduling and snapshot storage sit below `finishConnection`;
15
+ query planning, capture and transaction scopes remain in the Store. Compiled
16
+ operation metadata classifies reads for the pool. The wasm oo1 adapter shares one
17
+ session mapping across OPFS and atomic IndexedDB snapshots. [Execution hosts](docs/HOSTS.md)
18
+ documents bounds, native-call shutdown limits, capability probes and measurements.
19
19
 
20
20
  Every connection and statement method may return a value or a promise.
21
21
  The store composes through the sync-capable `chain` helper, which
@@ -46,10 +46,54 @@ test-double dialect whose quoting, parameter style and type names all
46
46
  differ: the same model produces correspondingly different SQL.
47
47
 
48
48
  Deliberately *not* dialect concerns, because they are behavioural
49
- rather than syntactic, and live in the capability table instead:
50
- whether functions register per connection, whether change capture
51
- exists and in what form, and whether tables can be restructured in
52
- place.
49
+ rather than syntactic, and live in the CONNECTION capability table
50
+ instead: whether functions register per connection, whether change
51
+ capture exists and in what form, and whether tables can be
52
+ restructured in place.
53
+
54
+ The dialect has a capability table of its own, and it is a closed set
55
+ (`DIALECT_CAPABILITIES`): `createDialect` refuses a name outside it, so
56
+ a misspelled capability cannot read as a quiet `false` on the engine
57
+ that has the feature. Every entry answers a SYNTACTIC question — does
58
+ this engine have a binary JSON type, indexable generated columns, a
59
+ column with no scalar type, `RETURNING`, upsert, savepoints, an
60
+ up-front write lock, a result alias in `GROUP BY`, in-place `ALTER`,
61
+ virtual tables, triggers, a configuration vocabulary, a stored CREATE
62
+ text, always-on referential integrity, an insertion-ordered row
63
+ identity. What the store does where the answer is `false` is a
64
+ FALLBACK or a coded refusal on the common path, never a skip:
65
+
66
+ | Capability off | What the common path does instead |
67
+ |---|---|
68
+ | `pragmas` | no configuration statement is issued; an explicitly requested pragma is `JD0007`, a defaulted one is dropped, and the effective record reads `null` throughout |
69
+ | `declaredSqlText` | the drift check is the structural one — columns and their types, indexes and their covered columns in order, foreign-key tuples — and an object the model never declared is no longer detectable |
70
+ | `foreignKeysAlwaysOn` (on) | the open path neither sets nor reads back the referential-integrity switch |
71
+ | `virtualTables` / `triggers` | a `physical: 'rtree'` column set maps back onto the B-tree over the four edge columns, and `explain().prefilters[].via` says so |
72
+ | `untypedColumns` | a comparison against a member the schema does not type reads the member out of the document instead of the column: same answer, unindexed |
73
+ | `indexableGeneratedColumns` | the derived-column mapping is the STORED one — the store writes the values and the columns are ordinary |
74
+ | `rowIdentity` (with a declared identity column) | the dialect adds that column to every table it creates, and it IS `rowIdentity()` |
75
+ | `immediateTransactions` | `tx.beginImmediate` is `tx.begin`, and a read-then-write body takes its lock when it writes |
76
+
77
+ Two subsystems are SQLite's own in 0.1 and say so on the connection
78
+ rather than in the dialect: the durable job queue (`capabilities.jobs`)
79
+ and the change ledger (`capabilities.changeCapture`) write their own
80
+ statements. Opening a store that asks for either on a connection that
81
+ declares it absent is a coded refusal at open — `JD0003` and `JD0051` —
82
+ not a failure at the first statement.
83
+
84
+ `test/db/dialect-conformance.js` is the kit every dialect passes: it
85
+ asserts behaviour rather than text, because a second dialect that
86
+ passed a golden-SQL comparison would have to spell PostgreSQL the way
87
+ SQLite spells it. A quoted identifier cannot close its own quoting; a
88
+ write statement binds through the dialect's own parameter form; a
89
+ generated column carries the storage word its engine has; an optional
90
+ feature is absent in BOTH halves or present in both.
91
+ `test/db/dialect-census.test.js` is the other side of the same claim: a
92
+ character scanner reads every string and template literal of every
93
+ module, keeps the ones that begin as SQL, and fails on a SQLite
94
+ spelling outside `src/dialects/` and `src/drivers/` unless the module
95
+ is named — with the capability that makes it honest — in a short
96
+ approved list.
53
97
 
54
98
  The SQLite dialect stores documents as JSONB in a `BLOB` column of a
55
99
  `STRICT` table, projects each indexed path into a virtual generated
@@ -57,6 +101,175 @@ column over `jsonb_extract`, and renders reads back to text through
57
101
  `json()`. Parameters are positional because every shipped binding
58
102
  binds arrays.
59
103
 
104
+ ### The PostgreSQL dialect (`src/dialects/postgres.js`)
105
+
106
+ `postgresDialect()` is the second spelling, and the reason the contract
107
+ above exists. It imports no client and no runtime builtin, so
108
+ `@jarenjs/db/postgres` resolves in a browser bundle and type-checks with
109
+ nothing installed; transport is the injected driver's business.
110
+
111
+ Documents are `jsonb`. An indexed path becomes a **STORED** generated
112
+ column — PostgreSQL has no indexable virtual one — typed from the
113
+ schema, and its expression guards the member with `jsonb_typeof` inside
114
+ a `CASE`, because a generated column's expression must be IMMUTABLE and
115
+ a cast that can raise is not one: a document whose member is the wrong
116
+ JSON type stores `NULL` rather than failing its INSERT. Text columns and
117
+ text comparisons carry `COLLATE "C"`, which is byte order, which is what
118
+ SQLite's default `BINARY` collation is — without it the same half-open
119
+ prefix range would hold different rows on a server initialised in
120
+ another locale. Every table the dialect creates carries `rid bigserial`,
121
+ because a collection is a SEQUENCE and PostgreSQL has no per-row
122
+ identity that survives an UPDATE (`ctid` moves).
123
+
124
+ Three mapping decisions cost something, and each is stated rather than
125
+ worked around:
126
+
127
+ | Decision | Why | What it costs |
128
+ |---|---|---|
129
+ | `integer` and `number` are both `numeric` | `bigint` would reject a document the model accepts — `3.5` in a member the schema types `integer` — inside a generated column's cast, and a storage decision that can refuse a valid document is not one | introspection cannot tell the two apart on the way back |
130
+ | a `boolean` member is `smallint` holding 1 or 0 | the shared forms compare a boolean against those integers (a type test, a projected pair's type name); a `boolean` column would make each an operator-resolution error rather than a row | introspection reads the column back as a number |
131
+ | a path the schema does not type is `jsonb` | there is no honest scalar type for it, and `capabilities.untypedColumns: false` says so | a comparison against such a member reads the document rather than the column: the same answer, unindexed |
132
+
133
+ Two engine differences the emitter had to be taught, rather than the
134
+ dialect papering over:
135
+
136
+ - **The type discriminator's vocabulary.** `jsonb_typeof` answers six
137
+ names; the row decoder reads SQLite's in JavaScript. The dialect maps
138
+ the two that differ (`string` → `text`, `boolean` → `true`/`false`)
139
+ and declares `numericTypeNames: ['number']`, which is what the
140
+ emitter's numeric guard reads instead of `('integer', 'real')`.
141
+ - **An external operand's parameter.** A PostgreSQL parameter's type is
142
+ resolved once for the whole statement, so one placeholder cannot be a
143
+ text member's operand in one branch and a numeric member's in
144
+ another — and the guard that keeps a row out of the wrong branch does
145
+ not stop the COERCION, because `AND` does not short-circuit. The
146
+ dialect declares `externalEncoding: 'json'`: the store binds the
147
+ value's JSON text, and the two branches compare in a space that holds
148
+ every scalar (text against text under `C`, number against number in
149
+ `jsonb`'s own numeric order).
150
+
151
+ An identifier longer than 63 bytes is REFUSED rather than emitted:
152
+ PostgreSQL truncates one silently, and two generated column names that
153
+ share a prefix would become one, which is an index quietly serving
154
+ another path.
155
+
156
+ ### Database → model (`src/introspect.js`)
157
+
158
+ `store.introspect(options)` — and `introspectModel(connection, options)`
159
+ under it — reads a live database's catalog and derives the
160
+ `jaren-model` document that would produce it, beside a report of
161
+ everything it could not.
162
+
163
+ It is **read-only**, and a statement trace is what says so: every
164
+ statement it issues begins `SELECT` (or `PRAGMA`, on SQLite). A derived
165
+ model is an ANSWER; applying it is the migration planner's job and the
166
+ operator's decision, and the two are deliberately not one act.
167
+
168
+ It **never claims a byte-perfect round trip**. A physical shape carries
169
+ less than the model that made it, and each gap is a report row with a
170
+ stable code, an object and a detail, sorted, once:
171
+
172
+ | code | what the shape does not carry |
173
+ |---|---|
174
+ | `document-members` | a document's unindexed members leave no trace, so the derived schema holds only what an index or the key names |
175
+ | `key-source` | a text key column cannot say which document member filled it; `identity: 'uuid'` is derived and `options.keys` supplies the pointer |
176
+ | `unmapped-view` | a view is not a shape a model can declare |
177
+ | `unmapped-table` | a table with neither a document column nor a key |
178
+ | `unmapped-column` | a generated column whose expression is not a member path this dialect wrote |
179
+ | `unmapped-index` | an index over an expression, a predicate, or a column no member path explains |
180
+ | `unmapped-type` | a column type no schema type maps back from — the member is derived untyped |
181
+ | `ambiguous-relation` | a foreign key says which entity it points at; which side declared the edge, and whether the other holds many, is not in the shape |
182
+
183
+ `strict: true` refuses instead of returning a partial model, because a
184
+ caller about to diff the result against a declared model needs to know
185
+ the difference is real.
186
+
187
+ What IS exact: every index, by name and by covered member paths in
188
+ order; the type of every member an index covers, placed at the DEPTH
189
+ the path names it at (so `$.nested.deep` rebuilds
190
+ `nested.properties.deep`, and the column type round-trips); a
191
+ database-allocated integer key; an entity's primary key, its unique and
192
+ indexed columns, and its foreign keys with their on-delete behaviour.
193
+
194
+ Two things make the two engines answer the same question. The dialect's
195
+ catalog statements hand back tables, columns, indexes and foreign keys
196
+ in ONE row shape — so the IR in the middle is engine-neutral — and only
197
+ the dialect reads its own generated-column expression back into a member
198
+ path (`memberPathOf`), because only the dialect wrote it. Indexes come
199
+ back sorted by name on both, because a physical shape carries no record
200
+ of the order a model declared them in and a derivation that inherited
201
+ one engine's listing order would not be a derivation.
202
+
203
+ ### The portability matrix
204
+
205
+ Everything a dialect may decline, and what the store does instead. A
206
+ `—` is a feature the engine does not have; the store's behaviour is the
207
+ common fallback, never a skip.
208
+
209
+ | Capability | SQLite | PostgreSQL |
210
+ |---|---|---|
211
+ | `jsonb` | yes (`BLOB` + the JSONB family) | yes (`jsonb`) |
212
+ | `generatedColumns` / `indexableGeneratedColumns` | yes, `VIRTUAL` | yes, `STORED` |
213
+ | `untypedColumns` | yes (`ANY`) | — (the member is read from the document) |
214
+ | `returning`, `upsert`, `savepoints` | yes | yes |
215
+ | `immediateTransactions` | yes (`BEGIN IMMEDIATE`) | — (`tx.beginImmediate` is `BEGIN`) |
216
+ | `groupByAlias` | yes | yes |
217
+ | `alterTableFull` | — | yes |
218
+ | `virtualTables` / `triggers` | yes (the R\*Tree mapping) | — (`physical: 'rtree'` maps back onto the four edge columns) |
219
+ | `pragmas` | yes (the closed set) | — (a server is configured by its operator) |
220
+ | `declaredSqlText` | yes | — (the drift check is the structural one) |
221
+ | `foreignKeysAlwaysOn` | — (set and verified per connection) | yes |
222
+ | `rowIdentity` | yes (`rowid`) | yes (the declared `rid` column) |
223
+ | `savepointStartsTransaction` | yes | — (a top-level transaction is `BEGIN`/`COMMIT`) |
224
+
225
+ And the two subsystems that are SQLite's own, declared on the
226
+ CONNECTION rather than the dialect because they are a library's
227
+ capability and not a syntax: the durable job queue (`jobs`) and the
228
+ change ledger (`changeCapture`). A store asked for either on a
229
+ connection that declares it absent is refused at open by name.
230
+
231
+ One behavioural difference is neither a capability nor a fallback,
232
+ because nothing can paper over it: **PostgreSQL aborts a transaction a
233
+ statement failed in**, and every later statement in it is `25P02` until
234
+ it ends. SQLite carries on. A body that means to catch a write's failure
235
+ and continue has to say so — `scope.transaction()` opens a savepoint the
236
+ failure rolls back to — and where it does not, the store reports the
237
+ engine's own condition under `JD2088` (`class: 'aborted'`, not
238
+ retryable) rather than a confusing failure of the next statement.
239
+
240
+ ### The injected driver (`src/drivers/postgres.js`)
241
+
242
+ The driver imports nothing either. It takes a connection SOURCE —
243
+ `{ connect() }` answering `{ query(text, values), release?() }`, which a
244
+ `pg.Pool` is verbatim — acquires ONE client at open, holds it for the
245
+ store's life and releases it exactly once at `close()`. Holding one is
246
+ not a simplification: a connection owns one savepoint stack, and a store
247
+ that took a different pooled client per statement would have `BEGIN` on
248
+ one and `COMMIT` on another.
249
+
250
+ Statements are prepared by name, from a counter that is monotonic across
251
+ the PROCESS rather than across one adapter — a prepared statement
252
+ belongs to the session, and a pooled client's session outlives the store
253
+ that borrowed it, so a per-adapter counter would re-use a name for
254
+ different SQL and the server refuses that. The names are deallocated at
255
+ `close()`, so the session goes back to the pool clean. A cached plan
256
+ whose result type changed under it (`0A000`, after a migration) is
257
+ re-run unnamed rather than raised.
258
+
259
+ Three normalizations, each forced by the wire rather than chosen:
260
+ `int8` and `numeric` arrive as strings (they can exceed a double) and
261
+ become JavaScript numbers, which is the same ceiling SQLite's INTEGER
262
+ has; `json`/`jsonb` arrive parsed, because the client's type parsers are
263
+ the host's configuration, and the row decoder reads text; and a
264
+ JavaScript boolean is bound as 1 or 0, because a boolean member is 1 or
265
+ 0 in this mapping.
266
+
267
+ `schema` is where the store lives. It is set on the acquired connection
268
+ AND given to the dialect, so the DDL and the catalog reads agree — one
269
+ value, two consumers. A migration's SHADOW replay is a second database,
270
+ which on a file engine is `:memory:` and on a server has to be another
271
+ schema: `migrate(..., { shadowDriver })` is where the host names one.
272
+
60
273
  ## What sits on top
61
274
 
62
275
  - `src/ddl.js` — a normalized collection to its physical plan. Index
@@ -141,9 +354,13 @@ composition; `$exists`/`$empty`; `$starts-with`/`$ends-with`/
141
354
  `$contains` on schema-typed string paths with literal patterns;
142
355
  `$orderby` over singular schema-typed paths (`$dir`, `$empty`, no
143
356
  collation); a top-level `$subsequence` window with literal bounds; the
144
- top-level aggregates `$count` (bare-binding return only) and
145
- `$sum`/`$avg`/`$min`/`$max` over a singular schema-typed path; and the
146
- whole-document projection that returns the bare binding.
357
+ top-level aggregates `$count` and `$sum`/`$avg`/`$min`/`$max` over a
358
+ singular schema-typed path; the whole-document projection that returns
359
+ the bare binding; ONE member path, projected as its value beside its
360
+ JSON type; and a nested SHAPE of objects, arrays, literals and member
361
+ paths, projected as one value/type pair per distinct leaf and rebuilt
362
+ by the decoder — never by parsing a JSON text the database assembled,
363
+ which could not tell an absent member from a present `null`.
147
364
 
148
365
  Plus the spatial predicates a **derived** index makes decidable:
149
366
  `$bbox-intersects` against a literal or external region, a geohash
@@ -176,11 +393,14 @@ mode, `knn`, beside native, row and set.
176
393
 
177
394
  | construct | reason |
178
395
  |---|---|
179
- | `$let` bindings, `$fold`, `$groupby`, positional/window bindings | no equivalence proof exists yet; residual by default |
180
- | a second `$for` binding, joins, non-singular path expansion | one relation per plan in this version |
396
+ | `$let` bindings, `$fold`, positional/window bindings | no equivalence proof exists yet; residual by default |
397
+ | a `$groupby` whose key is untyped or nullable, whose `$return` reads the binding, or whose `$orderby` names anything but a key | SQL's grouping and the engine's need not agree on an untyped key; after a grouping the binding holds the group's ROWS, which an object member cannot take |
398
+ | a window over the GROUPS, or an aggregate of them | the plan groups whole; a `LIMIT` over the groups would cut a different set |
399
+ | a `$for` binding nothing joins to — a cartesian product | the engine builds the product; a plan that emitted one by accident is the thing an equi-join graph exists to prevent |
400
+ | non-singular path expansion | one relation per binding in this version |
181
401
  | `$match` and other unlisted operators, `$call` | no native spelling proven equivalent |
182
402
  | `$orderby` with a `$collation` | a collation the dialect cannot reproduce is refused, not approximated |
183
- | projections other than `'$it'` | run per row (the row residual) — pushed, ordered and windowed rows, projected by the engine |
403
+ | a projection the tree cannot rebuild: an operator over a member, a reference to the binding itself, a non-singular path, a projection with no member path at all | the WHOLE projection runs per row (the row residual) — pushed, ordered and windowed rows, projected by the engine; promoting the part that composes would answer a shape nobody asked for |
184
404
  | string operators with an external pattern | the pattern's type is unknowable at plan time and the engine ERRORS on non-string patterns |
185
405
  | comparisons where both sides are paths | join territory |
186
406
  | array/object literals in comparisons | deep-equality has no guarded native form |
@@ -247,14 +467,21 @@ index-usable spelling and read every row of the collection.
247
467
 
248
468
  The guards make the forms sound for typed AND untyped paths alike —
249
469
  the schema type's job is choosing the generated COLUMN (the index),
250
- never weakening the guard. Two documented preconditions: string
251
- operators, aggregates AND ordering are only promoted on schema-typed
252
- paths because the engine ERRORS on non-conforming operands where SQL
253
- would coerce or sort a stored `null` under an ordered key, a
254
- non-string under a string operator — so on an unvalidated store, rows
255
- violating the collection schema can make the engine throw where the
256
- database answers; keep `compileSchema` injected if that distinction
257
- matters to you.
470
+ never weakening the guard. Two documented preconditions. String
471
+ operators, aggregates and ordering are only promoted on schema-typed
472
+ paths that cannot hold `null` and are not boolean, because the engine
473
+ ERRORS on non-conforming operands where SQL would coerce or sort a
474
+ `null` under an ordered key, a non-string under a string operator —
475
+ so a path whose schema admits `null` is a named residual for those
476
+ forms and the two engines keep answering alike over conforming data.
477
+ And the guards protect the JSON TYPE, not the VALUE: a typed
478
+ generated column carries SQLite affinity, so on an unvalidated store a
479
+ row that violates the schema — the string `'020'` under an `integer`
480
+ path — reads as the integer `20` in the column while `json_type` still
481
+ says text, and a pushed comparison answers rows the engine (comparing
482
+ the JSON value) does not. Keep `compileSchema` injected: with the hook
483
+ no such row is ever stored, and without it the pushdown is exact only
484
+ over documents that happen to conform.
258
485
 
259
486
  **Bind-time diversion.** SQLite cannot bind a boolean, and a
260
487
  `null`-valued external needs Jaren's null semantics, not SQL's. At
@@ -400,15 +627,19 @@ wants the engine's refusal for every row keeps `compileSchema` injected
400
627
  and nothing else, which is what makes the two agree on every row it
401
628
  does fetch.
402
629
 
403
- **The probe.** A literal vector must be the column's width at plan time
630
+ **The probe.** A literal vector must be a DECLARED width at plan time
404
631
  (another width is not recognized, and the reason says both widths). An
405
- external probe is checked at call time by the binder's own rule: a
406
- bound value that is not a vector of the column's width another width,
407
- a non-finite component, not an array at allDIVERTS the call to the
408
- full-collection residual, where the engine answers what it answers
409
- everywhere (empty keys, so the secondary keys order every row; or its
410
- own `JQ2001` for a non-array). The plan never raises on the engine's
411
- behalf.
632
+ external probe's width is only knowable when it is bound, so the plan
633
+ carries one ALTERNATIVE per declared width over the memberone emitted
634
+ statement each, prepared once and kept with the plan and the bind
635
+ picks the alternative whose width the probe has. A `CASE` across the
636
+ columns would read every one of them per row, and a statement per call
637
+ would give up the prepared cache; neither is emitted. A bound value no
638
+ declared width takes — another width, a non-finite component, not an
639
+ array at all — DIVERTS the call to the full-collection residual, where
640
+ the engine answers what it answers everywhere (empty keys, so the
641
+ secondary keys order every row; or its own `JQ2001` for a non-array).
642
+ The plan never raises on the engine's behalf.
412
643
 
413
644
  That diversion is correct and it is expensive: the residual reads every
414
645
  document, and `explain()` still reports `knn`, because the plan is the
@@ -467,17 +698,66 @@ is the ENGINE running the caller's own document, decides:
467
698
 
468
699
  | operator | what bounds the fetch |
469
700
  |---|---|
701
+ | `$overlaps` over a declared interval | the half-open conjunction over the two bound columns, plus every row the operator RAISES on (below) |
470
702
  | `$resample` (fill, calendar, `first`/`last`) | the spec's own `start`/`end`, plus the operand's `$where` |
471
703
  | `$rolling` | the operand's `$where` alone — a window measured in time answers once per input instant |
472
- | `$asof` with the collection on the RIGHT | the probes' own span (`at <= max` backward, `at >= min` forward, both sides under a `tolerance`) and a membership test over the probes' `by` keys |
704
+ | `$asof` with the collection on the RIGHT | the probes' own span (`at <= max` backward, `at >= min` forward, both sides under a `tolerance`) and a membership test over the probes' `by` keys, plus the ANCHOR below |
473
705
  | `$asof` with the collection on the LEFT | nothing — a join answers once per LEFT row, so every left row is needed |
474
706
 
475
- The as-of bound is one STATEMENT, whatever the probes number, which is
476
- what `test/db/statement-count.test.js` pins: the failure mode a batch
477
- exists to refuse is one seek per left row. Without a `tolerance` a
478
- backward join can only be bounded ABOVE, so that one statement can read
479
- most of a long history `benchmark/series.js` publishes the candidate
480
- count beside the timing rather than netting it out.
707
+ The as-of bound is a FIXED number of statements, whatever the probes
708
+ number, which is what `test/db/statement-count.test.js` pins: the
709
+ failure mode a batch exists to refuse is one seek per left row.
710
+
711
+ **The anchor.** Without a `tolerance` the open side has no bound the
712
+ probes imply: the row that answers the earliest probe is the last one at
713
+ or before it, however far back that lies. The tight bound is the data's
714
+ own, so the plan ASKS for it — `plan.seeks` carries one aggregate read
715
+ through the same declared index, and the statement binds its answer
716
+ through the typed slot (`emit.js`'s fourth `ParamSlot` kind), which
717
+ needs no bind-time type branch because the column's type is declared.
718
+
719
+ Per group the anchor is `MAX(at) WHERE at <= min(probes)`; the scalar is
720
+ the LEAST of those over the groups. A single global `MAX` would be
721
+ unsound — it can come from one group and drop another group's only
722
+ candidate — and matches only move forward as the probe does, so no row
723
+ below the fold can answer any probe. The comparison stays inclusive, so
724
+ rows sharing the anchor instant reach the kernel and its duplicate rule
725
+ decides among them. A seek that finds nothing binds its own probe: it
726
+ proved there is no row on that side. `forward` anchors the other end,
727
+ `nearest` both, and a `tolerance` asks for none — arithmetic already
728
+ closed both sides. What the anchor saves depends on where the probes
729
+ sit; `benchmark/series.js` publishes the candidate count beside the
730
+ timing rather than netting it out, over probes spread evenly across the
731
+ whole span, which is the anchor at its worst.
732
+
733
+ **`$overlaps` and the row that raises.** Two half-open spans share an
734
+ instant when each starts before the other ends, which over declared
735
+ bound columns is two comparisons the emitter already spells. What makes
736
+ it a pre-filter rather than an exact translation is the kernel's other
737
+ half: a span that is empty or reversed is not `false`, it RAISES
738
+ (`JQ2001`). A conjunction alone would drop `[500, 100)` for a probe of
739
+ `[100, 200)` — answering where the engine errors, which no pushdown may
740
+ do — so the statement keeps every row whose own span is inverted
741
+ (`start >= end`) and the operator decides over the candidates. That
742
+ disjunct compares two COLUMNS, which no index bounds, so the fetch
743
+ scans; what it still buys is that the pruned rows never reach the
744
+ engine at all. The other malformed cases are SCHEMA ones, exactly as the
745
+ spatial promotions' precondition is: the member must be typed as an
746
+ object whose `start` and `end` are both REQUIRED and both numeric, and a
747
+ write is validated against that schema, so a bound that is absent,
748
+ textual or null is a row the collection cannot hold. An unmapped pair,
749
+ an untyped one, or a probe that is not itself a half-open span pushes
750
+ nothing and names why. ROADMAP carries the declared-interval `CHECK`
751
+ that would make the conjunction exact and the fetch a seek.
752
+
753
+ **Reading a declared column without its guard.** A temporal refinement's
754
+ own bounds (`p: 'colCmp'`) carry no `json_type` beside them: the model
755
+ declared the column, the comparison only NARROWS, and the kernel decides
756
+ over what comes back. Without that, the statement parsed every scanned
757
+ row's document to discriminate a member the column already carried — at
758
+ the benchmark's largest shape that guard was most of the fetch's cost.
759
+ Narrowing is what makes it safe: SQL's own ordering keeps a row the
760
+ guard would have dropped, never the other way about.
481
761
 
482
762
  **A group with no instant.** A row whose instant member is missing or
483
763
  is not a number groups under SQL `NULL`; the kernel REFUSES such a row
@@ -571,8 +851,9 @@ collection binding, so the planner passes that binding's **name** in:
571
851
  wrapped under any other name every reference would read as an external,
572
852
  the determinism rule would reject the fragment, and the hatch would
573
853
  silently not engage — no error and no reason in `explain()`.
574
- Registration is keyed by `contentKey(fragment)`
575
- so identical fragments share one registration, and the planner MUST
854
+ Registration is keyed by `semanticKey(fragment)` (the
855
+ order-insensitive identity from `@jarenjs/core/object`) so identical
856
+ fragments share one registration, and the planner MUST
576
857
  produce a correct plan with the capability disabled (tested that way).
577
858
  Preference order: native SQL → deterministic function → residual, and
578
859
  `explain()` names the choice. **Index-form UDFs are deliberately not
@@ -585,11 +866,15 @@ UDF-expression indexes.
585
866
  ### The statement cache
586
867
 
587
868
  A caller of the core primitives, not an eighth implementation:
588
- `createBoundedCache` keyed by `contentKey(document)` plus collection,
589
- dialect and strictness. `contentKey` is the memo-grade key
590
- (`hashContent(stableStringify(x) ?? '')` drops `undefined` members,
591
- no cycle guard; both properties acceptable for a cache key), never
592
- `canonicalizeJson` (signature-grade, throws on `undefined`).
869
+ `createSemanticCache` keyed by the whole discriminating tuple — the
870
+ document plus collection, dialect, strictness, the pushdown switch and
871
+ the profilewhere the identity is the tuple's COMPLETE
872
+ serialization, never a fingerprint of it: a 32-bit content hash
873
+ collides after tens of thousands of documents, and a collision here
874
+ answers one query with another query's plan and rows (the
875
+ cache-identity test exists to keep that key abolished).
876
+ `store.stats()` exposes hits, misses and evictions so the cache is
877
+ proven rather than assumed.
593
878
  `store.stats()` exposes hits, misses and evictions, so the cache is
594
879
  proven rather than assumed.
595
880
 
@@ -621,8 +906,12 @@ diff engine and maps operations to minimal statements (column writes,
621
906
  fallback); inserts batch parent-first, deletes run child-first,
622
907
  foreign-key cycles among the changed set are `JD0040`, and a declared
623
908
  `version` property turns every update into an optimistic
624
- `WHERE version = ?` with `JD2040` on conflict. The tracker mutates
625
- ONLY after commit: a failed save retries.
909
+ `WHERE version = ?` with `JD2040` on conflict. A save that FAILS leaves
910
+ the tracker exactly as it was, so it retries; a save whose statements
911
+ all succeed advances at once — inside an enclosing transaction too,
912
+ where the database already holds the rows — and registers the exact
913
+ withdrawal of that advance on the owning scope, whose rollback takes it
914
+ back so the retry plans the same statements again.
626
915
 
627
916
  ## The migration engine, relationally (`src/migrate.js`, `src/cli.js`)
628
917
 
@@ -711,22 +1000,40 @@ re-query to keep a promise rather than a number.
711
1000
 
712
1001
  ## Durable runs and the job queue (`src/jobs.js`, `src/dag-job.js`)
713
1002
 
714
- The queue's whole correctness story is one guarded statement: the
715
- claim UPDATE selects the earliest eligible row (pending, failed, or an
716
- expired lease — so recovery IS the next claim, not a sweeper) whose
717
- kind the worker registered, sets it leased with a deadline, and
718
- RETURNs it. One statement is one transaction, so no two workers claim
719
- the same job without any distributed lock; every later transition
720
- wears `state='leased' AND lease_owner=?`, making execution
721
- at-least-once and completion exactly-once (proven by four workers on
722
- four connections over one WAL file). The `@jarenjs/flow` composition
723
- injects `compileDag` (db never imports flow an import-graph test
724
- enforces it) and binds a per-job checkpoint store; a DAG run's
725
- completion records the result, marks the job done and prunes the
726
- checkpoint rows in ONE transaction, so a crash resumes from its
727
- checkpointed nodes rather than restarting. Non-goals stated plainly: a
728
- shared SQLite file over a network filesystem is not a safe
729
- coordination substrate.
1003
+ The queue's correctness story has two halves. The claim UPDATE selects
1004
+ the earliest eligible row (pending, failed, or an expired lease — so
1005
+ recovery IS the next claim, not a sweeper) whose kind the worker
1006
+ registered, MINTS the fence a fresh opaque `lease_token` and the next
1007
+ `lease_generation` sets the row leased with a deadline, and RETURNs
1008
+ it. One statement is one transaction, so no two workers claim the same
1009
+ job without any distributed lock. Every later settling transition —
1010
+ renew, checkpoint save, complete, fail then wears the fence:
1011
+ `state='leased' AND lease_token=? AND lease_until > ?`, the token plus
1012
+ CURRENT validity, never the owner (one worker reuses one owner for
1013
+ every attempt it ever makes, so an owner cannot say which attempt is
1014
+ speaking). A lease is immutable `renew` answers a replacement and the
1015
+ superseded token settles nothing and a guard that matches nothing is
1016
+ a coded refusal naming which of the three reasons applied (`JD2065`
1017
+ settled/unknown, `JD2066` superseded, `JD2067` expired), never a silent
1018
+ `false`. A renewal that fails for any OTHER reason is a storage problem
1019
+ the next renewal may survive; only the three fence codes prove loss.
1020
+ Checkpoint rows are stamped with the attempt's generation: `load` reads
1021
+ up to it, and a settlement prunes no further, so a stale attempt cannot
1022
+ erase a live one's work. Execution stays at-least-once; settlement is
1023
+ exactly-once AGAINST THE STORE (proven by four workers on four
1024
+ connections over one WAL file) — an external effect a handler already
1025
+ made is still the handler's to make idempotent. Ownership follows the
1026
+ handle: root `store.jobs.*` and all worker control I/O take the store
1027
+ gate, while `tx.jobs` runs as its exact transaction scope — the
1028
+ transactional outbox. The `@jarenjs/flow` composition injects
1029
+ `compileDag` (db never imports flow — an import-graph test enforces it)
1030
+ and binds a per-ATTEMPT checkpoint store that follows the current
1031
+ lease; a DAG run's completion records the result, marks the job done
1032
+ and prunes the checkpoint rows in ONE transaction, so a crash resumes
1033
+ from its checkpointed nodes rather than restarting, and a resume must
1034
+ match the persisted workflow revision and input hash (`JD2069`).
1035
+ Non-goals stated plainly: a shared SQLite file over a network
1036
+ filesystem is not a safe coordination substrate.
730
1037
 
731
1038
  ## The wasm driver (`src/drivers/wasm.js`)
732
1039
 
@@ -746,3 +1053,45 @@ studio's throwaway-store operation, holds the wasm build to the
746
1053
  JavaScript engine's recorded answers in every one of those engines,
747
1054
  OPFS or not, because an entry seeds its own store and needs execution,
748
1055
  not persistence.
1056
+
1057
+ ## Operating the store (`src/pragmas.js`, `src/maintenance.js`, `src/backup.js`, `src/cancellation.js`, `src/errors.js`)
1058
+
1059
+ The operability surface is five small modules and one rule each,
1060
+ consumed by `src/store.js` and never reached by a query:
1061
+
1062
+ - `src/pragmas.js` — the closed table of configurable connection
1063
+ pragmas (option name, SQL spelling, validator, read-back mapping,
1064
+ default, the two store-kind flags). The open sequence applies the
1065
+ requests in table order and reads every declared pragma back; an
1066
+ explicit request the engine did not take refuses the open, a default
1067
+ it could not take is reported as read. The dialect spells exactly one
1068
+ set statement and one read statement for it.
1069
+ - `src/maintenance.js` — `checkpoint`, `integrityCheck`,
1070
+ `foreignKeyCheck`, `optimize` over a connection, answering the engine's
1071
+ own row as typed data; availability per operation is the binding's
1072
+ declaration and, for the two that write, the store's read-only flag —
1073
+ refused by code exactly where the report says `false`.
1074
+ - `src/backup.js` — the online backup over the driver's primitive triple
1075
+ (`copy`, `rename`, `remove`; the Node binding's), written to a
1076
+ temporary sibling and renamed only at verified completion, cleaned up
1077
+ on every other branch. The root closure imports no builtin: the
1078
+ primitives are the driver's.
1079
+ - `src/cancellation.js` — the one check every lifecycle runs between its
1080
+ units of work (a statement, a row, a step, a batch, a page), on the
1081
+ clock the caller was given; each lifecycle owns its abort code, the
1082
+ deadline code is one. `capabilities.cancellation` states the
1083
+ granularity per lifecycle and that nothing interrupts mid-statement.
1084
+ - `src/errors.js` — `classifyDriverError`, the one table every path
1085
+ consults (writes, the query engines through their boundary and the
1086
+ cursor's `wrap`, maintenance and backup under their own code, the open
1087
+ sequence): class, retryability, code. The query path's only special
1088
+ case is the int64 overflow of a pushed aggregate, answered by the
1089
+ engine as a coded residual.
1090
+
1091
+ The open path itself creates or verifies the shape inside an IMMEDIATE
1092
+ transaction with idempotent DDL (one dialect spelling, applied on the
1093
+ open path only) and retries once on a classed busy failure, so two
1094
+ processes creating one fresh file never meet the deferred-upgrade
1095
+ `SQLITE_BUSY` the busy handler cannot retry. The job queue's
1096
+ administration (`page`, `cancel`, `requeue`, `sweep`) lives in
1097
+ `src/jobs.js` beside the fence it authorises through (JOBS-FORMAT §10).