@jarenjs/db 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
@@ -0,0 +1,928 @@
1
+ # The Jaren model format (`jaren-model`)
2
+
3
+ This document is normative. The key words MUST, MUST NOT, SHOULD and
4
+ MAY are to be interpreted as described in RFC 2119.
5
+
6
+ Canonical schema: [`schemas/jaren-model.schema.json`](../schemas/jaren-model.schema.json)
7
+ (draft 2020-12), with the mechanically derived draft-07 twin beside it.
8
+
9
+ Section allocation is fixed so no two documents ever claim the same
10
+ section number: §§1–7 the storage subset, §8 the safe profile, §9
11
+ entities, §10 relational translation, §11 the unit of work.
12
+
13
+ ## 1. Scope
14
+
15
+ A **model document** declares the collections of a store: each
16
+ collection is a JSON Schema for its documents, a key declaration, and
17
+ a set of declared indexes. `openStore(model, { driver, ... })` opens
18
+ (or creates) a database through an injected **driver**, applies the
19
+ physical mapping through a **dialect**, and gives transactional,
20
+ schema-validated reads and writes.
21
+
22
+ This is version `0.1` — the storage subset. Every collection is an
23
+ entity with no relations and a single JSON document column. Later
24
+ versions add vocabulary to THIS format; they do not add a second
25
+ format. Querying, migrations, entities and change capture are outside
26
+ this document's scope and own their reserved sections below.
27
+
28
+ The store runs over SQLite — on Node (`@jarenjs/db/node`), on Bun
29
+ (`@jarenjs/db/bun`), and in a browser against an injected wasm handle
30
+ (`@jarenjs/db/wasm`) — and promises nothing else.
31
+
32
+ ## 2. The model document
33
+
34
+ ```json
35
+ {
36
+ "$model": "0.1",
37
+ "collections": {
38
+ "users": {
39
+ "schema": { "type": "object", "required": ["id", "email"],
40
+ "properties": { "id": {"type": "string"},
41
+ "email": {"type": "string", "format": "email"},
42
+ "age": {"type": "integer"} } },
43
+ "key": "/id",
44
+ "indexes": [
45
+ { "name": "by_email", "path": "$.email", "unique": true },
46
+ { "name": "by_age", "path": "$.age" }
47
+ ]
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ - `$model` MUST be `"0.1"`.
54
+ - `collections` MUST carry at least one collection; names MUST be
55
+ identifiers (`[A-Za-z_][A-Za-z0-9_]*`).
56
+ - `schema` MUST be an object schema — it is what stored documents
57
+ validate against (§5) and the type source for indexed paths (§3).
58
+ - `key` is an RFC 6901 pointer to the caller-supplied key member, and
59
+ MUST select at least one member — or `null` when the store
60
+ allocates keys (§6).
61
+ - `indexes[].path` is a JSONPath expression that MUST be **singular**:
62
+ it selects exactly one member per document. Wildcards, slices,
63
+ filters, descendants and function calls are not indexable; a
64
+ non-singular path is rejected at open with `JD0004` naming the
65
+ expression. A composite index takes a non-empty array of paths.
66
+ - Index names MUST be identifiers, unique within their collection.
67
+
68
+ An invalid model document is `JD0005` with a `docPath` pointing at the
69
+ offending member. Model checking happens before any database work.
70
+
71
+ ## 3. Physical mapping
72
+
73
+ Each collection maps to one table, rendered entirely by the dialect —
74
+ no SQL text exists outside a dialect. On SQLite:
75
+
76
+ - a `key` column (`PRIMARY KEY`, typed from the key declaration),
77
+ - a `doc` column holding the document as JSONB in a `BLOB` column of a
78
+ `STRICT` table,
79
+ - one **virtual generated column** per distinct indexed path, typed
80
+ from the collection's schema at that path (`string` → `TEXT`,
81
+ `integer` → `INTEGER`, `number` → `REAL`, `boolean` → `INTEGER`,
82
+ undeclared → `ANY`), and
83
+ - one index per `indexes` entry, named `<collection>_<index name>`,
84
+ over the generated columns of its paths.
85
+
86
+ Index paths are analyzed through the query engine's published AST: a
87
+ path is indexable exactly when the analysis reports it singular and
88
+ every segment is a plain member or index selection. The collection's
89
+ schema is the type source — the mapping needs no engine-side type
90
+ inference.
91
+
92
+ Two indexes over the same path share one generated column. A member
93
+ name the dialect's JSON path grammar cannot carry (an embedded `"` or
94
+ a control character, on SQLite) is `JD0004`.
95
+
96
+ **Opening an existing database verifies, never alters.** If a declared
97
+ collection's table already exists it MUST match what the model would
98
+ create; any disagreement is `JD0002` naming the first difference.
99
+ Reshaping a live database is the migration story — a later capability —
100
+ and `openStore` MUST NOT attempt it.
101
+
102
+ "Match" means every physical property that decides behaviour, not just
103
+ the names and types:
104
+
105
+ - structurally — column names, declared types and generated flags; index
106
+ names, uniqueness, and covered columns **in their declared order**
107
+ (`(a,b)` and `(b,a)` are different indexes: one serves an `a`-prefix
108
+ lookup and the other does not);
109
+ - by DECLARED TEXT — the stored `CREATE` statement is compared against
110
+ the planned one, which is where the properties no pragma reports live:
111
+ `PRIMARY KEY`, `NOT NULL`, `DEFAULT`, `CHECK`, `STRICT`, a generated
112
+ column's expression, an index's partial predicate, and each index
113
+ term's collation and direction. A table that lost its primary key, or
114
+ whose `gx_a` now reads `$.b`, keeps every name and type it had;
115
+ - for entities, the whole foreign-key TUPLE — source and target column,
116
+ `ON DELETE` and `ON UPDATE`. Comparing counts accepted
117
+ `ON DELETE SET NULL` becoming `ON DELETE CASCADE`, which deletes
118
+ different rows;
119
+ - an index the database has and the model does not declare is also
120
+ drift: it changes deletion semantics and the plans the optimizer picks.
121
+
122
+ Physical column ORDER is deliberately **not** drift. SQLite's
123
+ `ALTER TABLE … ADD COLUMN` can only append, so a migrated table and a
124
+ freshly built one legitimately disagree there, and this store never reads
125
+ a column positionally.
126
+
127
+ ## 4. The driver contract and the synchronous fast path
128
+
129
+ A driver is `{ name, dialect, open(path, options) }`; `open` returns a
130
+ `Connection` or a promise of one:
131
+
132
+ ```
133
+ Connection = {
134
+ synchronous, // boolean
135
+ capabilities, // read once at open — see below
136
+ exec(sql), prepare(sql), transaction(fn), close(),
137
+ registerFunction(name, options, fn) | null,
138
+ registerAggregate(name, spec) | null,
139
+ session(table) | null
140
+ }
141
+ Statement = { run(params), get(params), all(params), iterate(params) }
142
+ ```
143
+
144
+ Every method MAY return a value or a promise; the store never assumes
145
+ either. Parameters bind positionally as arrays.
146
+
147
+ **Capabilities** are read once at open — from the library's version
148
+ report, its compile options, and the binding's declaration — and are
149
+ the single source of truth for feature gating. The table covers at
150
+ least: `version`, `jsonb`, `generatedColumns`, `returning`, `upsert`,
151
+ `savepoints`, `rtree`, `fts`, `sessions`, `userFunctions`,
152
+ `deterministicIndexableFunctions`, `alterTableFull`, plus two slots
153
+ that are **empty (`false`) on every SQLite driver**:
154
+ `statementTimeout` and `rowEstimates`. SQLite exposes no interrupt, no
155
+ progress handler and no row-estimate API; a driver that cannot do a
156
+ thing MUST say so here rather than degrade silently. The slots exist
157
+ so a driver that has the facts can fill them without a contract
158
+ change.
159
+
160
+ On the Bun binding, `userFunctions`,
161
+ `deterministicIndexableFunctions` and `sessions` are `false` by
162
+ construction: `bun:sqlite` exposes no `function`, no `aggregate` and
163
+ no `createSession`.
164
+
165
+ A library below SQLite **3.45** fails at open with `JD0001` naming
166
+ the version found.
167
+
168
+ **The public store API is asynchronous.** Every store and collection
169
+ method returns a promise, because a browser store over OPFS is
170
+ asynchronous no matter what backend sits behind it. Where — and only
171
+ where — `connection.synchronous` is `true`, the store also carries
172
+ `store.sync`:
173
+
174
+ ```js
175
+ store.sync.collection('users').get(key) // the same read, no promise
176
+ store.sync.transaction(fn)
177
+ ```
178
+
179
+ `store.sync` is **absent** on an asynchronous driver — not a set of
180
+ throwing stubs — so feature-testing it is honest. The asynchronous
181
+ surface allocates exactly one promise per call (the internal
182
+ composition is sync-capable and adds none); the measured difference is
183
+ the price of portability, published with the benchmarks rather than
184
+ waved away.
185
+
186
+ **Concurrency defaults are decided here.** A file-backed store opens
187
+ with `PRAGMA busy_timeout` set to **5000 ms** and journal mode
188
+ **WAL**, both overridable through `openStore`'s `busyTimeout` and
189
+ `journalMode` options; `:memory:` stores set neither. The values in
190
+ effect are visible on `store.capabilities.busyTimeoutMs` and
191
+ `store.capabilities.journalMode` (`null` for in-memory stores).
192
+
193
+ The runtime builtin behind a binding is imported lazily inside
194
+ `open()` — never at module scope — so every driver subpath loads under
195
+ every runtime; on a runtime without the builtin, `open` fails with
196
+ `JD0003`. The root `@jarenjs/db` subpath never references a runtime
197
+ builtin at all.
198
+
199
+ ## 5. Writes and transactions
200
+
201
+ ```js
202
+ const store = await openStore(model, { driver, compileSchema });
203
+ const users = store.collection('users');
204
+
205
+ await users.insert(doc); // JD2001 when the key exists
206
+ await users.put(doc); // upsert
207
+ await users.patch(key, jsonPatch); // RFC 6902, applied in the database
208
+ await users.delete(key); // resolves false when nothing was stored
209
+ await users.get(key); // resolves undefined when absent
210
+ await store.transaction(fn); // savepoint-nested, returns fn's value
211
+ ```
212
+
213
+ **Writes validate through the injected hook.** `compileSchema` has the
214
+ `compileTypeTest` signature: it takes a collection's schema and
215
+ returns a validation function; the function returns `true`/`false` or
216
+ `{ valid, errors }`. A rejected write is `JD2003` carrying the hook's
217
+ `errors` when it produced any. Without a hook, writes are unvalidated
218
+ and `store.capabilities.validated === false` — a declared downgrade.
219
+ The cost of running without one: the database constraints only see the
220
+ key and the indexed members; everything else is stored as given.
221
+ `@jarenjs/db` never imports `@jarenjs/validate`.
222
+
223
+ **`patch` validates the result, then updates in place.** The patch is
224
+ applied to the stored document with the copy-on-write engine and the
225
+ RESULT is validated (`JD2003` rejects before any SQL). The operations
226
+ are then translated to the dialect's JSON-set primitives so a
227
+ one-field update does not rewrite a large document. Translatable in
228
+ 0.1: `replace`, `add` of an object member, `add` at an array's end,
229
+ and `remove`. Anything else — `test`, `move`, `copy`, a mid-array
230
+ insert — falls back to a whole-document write. The fallback is
231
+ **counted and exposed** at `collection.stats()`
232
+ (`{ patchTranslated, patchFallback }`), measured rather than assumed.
233
+ A malformed patch document raises the json family's own coded errors
234
+ unchanged; `patch` on an absent key is `JD2006`.
235
+
236
+ **`transaction(fn)` nests via savepoints.** `fn` receives the store
237
+ and may itself call `transaction`; each level is one savepoint. A
238
+ throw rolls back exactly its own level and rethrows — an outer
239
+ transaction that catches the error continues and its own work
240
+ commits. There is no implicit retry.
241
+
242
+ ### 5.1 Transaction ownership
243
+
244
+ A SQLite connection holds ONE savepoint stack, so two transactions that
245
+ overlap in time on one connection cannot both be correct: `RELEASE`
246
+ discards everything opened after its target, so whichever finished first
247
+ would take the other's savepoint with it, and the second would then fail
248
+ with *no such savepoint* over rows it had already committed. Unique
249
+ savepoint names do not help — the stack is a stack.
250
+
251
+ So a **top-level transaction owns its connection until it settles**, and
252
+ an overlapping one waits its turn. Two concurrent request handlers
253
+ sharing a store both commit, and both report success.
254
+
255
+ Nesting is asked for in one of two ways, and the difference is not
256
+ cosmetic:
257
+
258
+ - **Synchronously** — a `transaction` called while an owning callback is
259
+ still on the stack nests, because nothing can interleave there. This is
260
+ `store.sync.transaction` inside `store.sync.transaction`.
261
+ - **Through the scope** — an `async` callback has already awaited, so the
262
+ stack cannot say whether a request is its own nested work or an
263
+ unrelated caller. Nest through the store the callback RECEIVED:
264
+
265
+ ```js
266
+ await store.transaction(async (tx) => {
267
+ await store.collection('docs').put(doc, 'a'); // joins this transaction
268
+ await tx.transaction(async () => { … }); // nests inside it
269
+ });
270
+ ```
271
+
272
+ Reaching back through the outer `store.transaction` from inside a
273
+ callback queues behind the transaction the caller is part of, so it
274
+ waits for itself; after `queueTimeout` (default 5 s, the busy-timeout
275
+ default) that becomes `JD0012` naming the fix rather than hanging.
276
+
277
+ **One residual, stated plainly.** A bare statement issued while a
278
+ transaction is open JOINS that transaction and shares its fate, because
279
+ SQLite has no per-statement transaction scope and every operation inside
280
+ a callback reaches the connection the same way an unrelated caller does.
281
+ Work that must be in the transaction is therefore safe; an unrelated
282
+ writer on a SHARED store is not. Give each concurrent writer its own
283
+ store when independent writes must not share a rollback.
284
+
285
+ ## 6. Identity
286
+
287
+ Key allocation is declared, never guessed (three strategies, platform
288
+ primitives only):
289
+
290
+ | declaration | strategy | `insert` returns |
291
+ |---|---|---|
292
+ | `"key": "/id"` | caller-supplied: the key is read from the document at the pointer | the extracted key |
293
+ | `"key": null, "identity": "uuid"` | `crypto.randomUUID()`, stored in the key column only | the allocated UUID |
294
+ | `"key": null, "identity": "integer"` | database-allocated integer key | the allocated integer |
295
+
296
+ A caller-keyed document whose pointer resolves to nothing or to a
297
+ non-scalar is `JD2002`; so is an explicit key argument that is not a
298
+ string or a number. For allocated identities, `put(doc, key)` updates
299
+ a known document and `put(doc)` allocates.
300
+
301
+ ## 7. Error codes
302
+
303
+ `DbCompileError` (`JD0xxx`, problems opening a store) and
304
+ `DbRuntimeError` (`JD2xxx`, problems reading or writing one) build on
305
+ the suite's coded contract: a stable `code`, a bare `reason`, a
306
+ composed `message`, a `docPath` into the model document where one
307
+ exists — and, on runtime errors, the `collection` and (where known)
308
+ the `key` as own properties. Database errors are wrapped, never leaked
309
+ raw: the reason keeps the original text, `cause` keeps the original
310
+ error.
311
+
312
+ | code | raised when |
313
+ |---|---|
314
+ | `JD0001` | the SQLite library is below the supported floor |
315
+ | `JD0002` | the declared model disagrees with the existing database |
316
+ | `JD0003` | the driver binding is unavailable on this runtime |
317
+ | `JD0004` | an index path is not a singular member selection |
318
+ | `JD0005` | the model document is invalid |
319
+ | `JD0010` | strict mode refused a residual |
320
+ | `JD0011` | the profile refused the document |
321
+ | `JD0012` | work waited too long for the open transaction to settle |
322
+ | `JD0030` | an unknown x-entity member was declared |
323
+ | `JD0031` | relation declarations contradict each other |
324
+ | `JD0032` | the include specification is invalid |
325
+ | `JD0040` | the save spans a relation cycle |
326
+ | `JD0050` | live queries require change capture |
327
+ | `JD0051` | the demanded live mode is unavailable |
328
+ | `JD0052` | the live-query bound was reached |
329
+ | `JD2001` | insert found the key already present |
330
+ | `JD2002` | a usable key could not be resolved for the write |
331
+ | `JD2003` | the write failed schema validation |
332
+ | `JD2004` | an undeclared collection was requested |
333
+ | `JD2005` | a database operation failed |
334
+ | `JD2006` | patch found no document at the key |
335
+ | `JD2007` | the result exceeded the profile row bound |
336
+ | `JD2040` | the row changed under an optimistic update |
337
+ | `JD2050` | a changeset could not be decoded |
338
+ | `JD2051` | the change log is not enabled |
339
+ | `JD2060` | the maintained live state exceeded its bound |
340
+ | `JD2061` | another context owns the database |
341
+ | `JD2062` | the store closed with job handlers still in flight |
342
+
343
+ The table above is proven in sync with the runtime `DB_CODES` table by
344
+ a test.
345
+
346
+ ## 8. The safe execution profile
347
+
348
+ A query document that arrives from a tenant, a remote client or a
349
+ language model can reach a database. Parameter binding makes injection
350
+ structurally impossible; it does nothing about resource exhaustion or
351
+ cross-tenant reads. A **profile** composes four independent bounds:
352
+
353
+ ```js
354
+ const store = await openStore(model, { driver, profile: 'safe' });
355
+ // or per call:
356
+ collection.query(doc, { profile: { maxRows: 200, externals: ['min'] } });
357
+ ```
358
+
359
+ `'safe'` is the default table; a profile object overrides members over
360
+ it. The defaults: engine limits
361
+ `{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
362
+ `maxRows: 1000`, no externals, no host functions, no collations, all
363
+ of the store's collections, no mandatory predicates, no scan refusal.
364
+
365
+ 1. **Engine limits.** The four engine limits ride into every residual
366
+ compilation, so the JavaScript portion of a query is bounded by the
367
+ engine's own enforcement and fails with the engine's own codes.
368
+ 2. **The mandatory row bound.** Every non-aggregate fetch carries a
369
+ database-side `LIMIT` of `maxRows + 1`. A fetch that crosses
370
+ `maxRows` — a result set, a residual's candidate set, a diverted
371
+ full scan — is the coded `JD2007` and the result is refused WHOLE.
372
+ It is never silently truncated.
373
+ 3. **Reference containment.** The document may reference only the
374
+ externals, host functions and collations the profile declares, and
375
+ only collections the profile allows; an undeclared reference is the
376
+ compile error `JD0011`, never a runtime surprise. No UDF
377
+ registration happens under a profile. Optionally
378
+ (`refuseFullScan: true`), a plan whose `EXPLAIN QUERY PLAN`
379
+ narrative shows a full-table SCAN of the collection is refused with
380
+ `JD0011` — a structural gate, because SQLite exposes no row
381
+ estimates to bound by.
382
+ 4. **Mandatory predicates.** `predicates: { users: { $eq:
383
+ ['$it.tenant', 'acme'] } }` conjoins the predicate into EVERY plan
384
+ for that collection at the plan's root, after translation — the
385
+ native statement, the residual's candidate fetch and the diverted
386
+ full scan all wear it, so no document shape (`$or` at the top, a
387
+ negation, a quantifier, a residual, a window, an aggregate) can
388
+ produce a fetch without it. A predicate MUST translate natively; a
389
+ host-configured predicate that cannot is a `TypeError` at first
390
+ use, because there is no residual to hide it in.
391
+
392
+ **Read-only stores.** `openStore(model, { readOnly: true })` opens the
393
+ connection read-only at the DRIVER, so every write is refused by the
394
+ database itself (`JD2005` wrapping `SQLITE_READONLY`), not merely by
395
+ the API surface — a translation bug cannot become a write. A read-only
396
+ store verifies the declared shape and creates nothing (`JD0002` when a
397
+ table is missing), and leaves the file's journal mode untouched.
398
+
399
+ **The non-claims, stated plainly.** This profile does NOT claim:
400
+
401
+ - a statement timeout on the shipped drivers — `node:sqlite` and
402
+ `bun:sqlite` expose no interrupt and no progress handler, the
403
+ `statementTimeout` capability is `false`, and a long-running
404
+ database-internal computation (a native aggregate over a large
405
+ table) is bounded by nothing here. A driver whose capability is
406
+ filled gets a real timeout without a contract change.
407
+ - a row-estimate bound — SQLite's plan output is prose, so the
408
+ structural SCAN refusal is the honest substitute.
409
+ - safety for arbitrary untrusted SQL — none can be expressed.
410
+ - tenant isolation without the mandatory predicate — a shared database
411
+ is NOT safe for mutually hostile tenants unless the profile carries
412
+ one.
413
+
414
+ The containment story on SQLite is exactly this composition: engine
415
+ limits (bounding the residual portion), the mandatory `LIMIT`, the
416
+ optional SCAN refusal, the allow-lists, and the mandatory predicates.
417
+ Each bound is proven to fire by the hostile-input suite, and the store
418
+ is proven usable after every refusal.
419
+
420
+ ### 8.1 Registered operators run in the residual
421
+
422
+ A store MAY open with a registry (`createJsltRegistry()` from
423
+ `@jarenjs/json/jslt`, or raw `functions` / `extensions` maps), and its
424
+ operators — `$npv`, `$mean`, `$sqrt`, … — become engine vocabulary a
425
+ query or entity document may use:
426
+
427
+ ```js
428
+ import { createJsltRegistry, financePack, statsPack } from '@jarenjs/json/jslt';
429
+ const store = await openStore(model, {
430
+ driver,
431
+ operators: createJsltRegistry().use(financePack).use(statsPack),
432
+ });
433
+ store.capabilities.operators; // ['$npv', '$irr', …, '$mean', …]
434
+ ```
435
+
436
+ A registered operator is a vocabulary extension of the **engine**, and
437
+ the engine runs against a store in JavaScript over the fetched rows —
438
+ the **residual**. So in this ring every registered operator is
439
+ **correct everywhere and accelerated nowhere**: the planner recognises
440
+ its name (it is not the unknown-operator error `JQ0002`), keeps it in
441
+ the residual, compiles the residual with the same registered
442
+ `{ functions, extensions }`, and `explain()` names it as the reason the
443
+ query did not translate natively — never silently:
444
+
445
+ ```js
446
+ store.collection('deals').explain(doc).residual.reasons;
447
+ // [{ construct: '$npv', reason: "registered operator '$npv' runs in the
448
+ // residual (Ring 2 — correct, not pushed to SQL)" }, …]
449
+ ```
450
+
451
+ The result is identical to the same document run over the same rows by
452
+ the in-memory engine — differential-tested — and to the direct core
453
+ function. The pushable subset of these operators can be accelerated into
454
+ SQL where the driver allows — §8.2 — but that is an optimisation layered
455
+ over this residual, never a change to the answer.
456
+
457
+ **Profile interaction.** A first-class registered `op`/`agg` (a native
458
+ operator like `$npv`) is host-provided machinery — the store owner
459
+ registered it, not the foreign document — so it is **allowed by default**
460
+ under a profile, exactly as the internal `$apply` is. What the profile
461
+ still governs:
462
+
463
+ - the mandatory bounds apply to the residual as always: a registered-
464
+ operator query fetches at most `maxRows + 1` candidate rows and is
465
+ refused with `JD2007` past `maxRows`; the mandatory predicate still
466
+ conjoins into the candidate fetch; engine limits still bound the JS;
467
+ - the profile's `functions` allow-list still governs a registered `fn`
468
+ reached through `$call` — a `$call('clamp', …)` name must be declared
469
+ in `functions`, or it is the compile error `JD0011`, because a `fn` is
470
+ a host function like any other. Only the first-class `op`/`agg`
471
+ operators are exempt.
472
+
473
+ Without a registry the store is byte-identical to before: a document
474
+ using `$npv` fails `JQ0002`, and `capabilities.operators` is `[]`.
475
+
476
+ ### 8.2 SQL pushdown of the pushable-scalar subset (driver-gated)
477
+
478
+ A pack marks each entry `pushable: 'scalar'` (a per-row scalar function —
479
+ the math ops), `'aggregate'`, or `false`. Where the driver has user
480
+ functions, a `pushable:'scalar'` operator used in a **WHERE predicate**
481
+ is registered as a SQLite **deterministic UDF** and the plan emits the
482
+ call (`… WHERE jaren_p_<hash>(json_text(doc))`), so SQLite drives the row
483
+ iteration and the operator runs inside the callback — instead of every
484
+ candidate row crossing into the residual. It is the same compiled engine
485
+ fragment either way, so the answer is identical by construction; the
486
+ only question is where the loop runs.
487
+
488
+ The per-driver capability matrix — read once at open, on
489
+ `store.capabilities`:
490
+
491
+ | capability | node:sqlite | bun:sqlite | wasm |
492
+ |---|---|---|---|
493
+ | `userFunctions` (scalar UDF) | ✅ | ❌ | probed |
494
+ | `aggregateFunctions` (aggregate UDF) | ✅ | ❌ | probed |
495
+ | `pushableOperators` | the scalar subset | `[]` | scalar subset if `userFunctions` |
496
+
497
+ On **bun:sqlite** there is no UDF API, so `pushableOperators` is `[]` and
498
+ every registered operator is the residual (§8.1) — no failure, the same
499
+ result, reported by capability. A **profiled (untrusted) document never
500
+ triggers host-side registration** — the UDF hatch is gated on `profile
501
+ === null`, exactly as the engine-internal hatch is; a profiled `$sqrt`
502
+ predicate runs in the residual.
503
+
504
+ **When it wins — measured, published honestly.** The push narrows *before*
505
+ rows cross into JavaScript, so it wins exactly when something else
506
+ narrows too (20 000 rows, node:sqlite, median ms):
507
+
508
+ | shape | pushed | residual | verdict |
509
+ |---|---|---|---|
510
+ | solo `$sqrt` predicate, ~20 % match | 22 | 21 | ~even |
511
+ | solo `$sqrt` predicate, ~90 % match | 41 | 23 | residual **1.75×** |
512
+ | indexed `$eq` **and** `$sqrt` (5 % pass the index) | 5.2 | 21 | push **3.9×** |
513
+ | `$sqrt` predicate with `LIMIT 10` | 0.03 | 21 | push **615×** |
514
+
515
+ So a `$sqrt` predicate beside a selective native predicate or a `LIMIT`
516
+ is a large win; a `$sqrt` predicate that is the *sole* filter of a full
517
+ table scan is a wash to a modest loss (the UDF re-parses each row in the
518
+ callback). **The push is not gated behind a cost heuristic**, because
519
+ SQLite exposes no row estimates (`capabilities.rowEstimates` is `false`)
520
+ to build one on — a crude guess would be dishonest. It pushes
521
+ deterministically and this profile is published so the shape of the win
522
+ is known; add a narrowing predicate or a `LIMIT` and the push pays.
523
+
524
+ **The honest ceiling.** A `pushable:false` operator (a whole-series
525
+ `$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
526
+ lists no `udfs` for it. Aggregate-UDF pushdown (`db.aggregate` step/final
527
+ over `GROUP BY`) is **not emitted**: no shipped pack marks an entry
528
+ `pushable:'aggregate'` (the finance/stats aggregators fold a *per-document*
529
+ sequence — that is a per-row scalar to SQL, already covered by the scalar
530
+ path where marked — not a cross-row column), and cross-row aggregate
531
+ pushdown additionally waits on `$groupby` pushdown, itself a deliberate
532
+ residual today. The `aggregateFunctions` capability is probed and
533
+ reported regardless, so the day a pack marks `'aggregate'` the driver
534
+ gate is already in place.
535
+
536
+ ## 9. Entities, the `x-entity` vocabulary, relations
537
+
538
+ ### 9.1 Scope, and the phase-A relationship
539
+
540
+ An **entity** is a generalisation of a collection, not a replacement:
541
+ a collection is an entity whose every property is JSONB and which
542
+ declares no relations, and one physical engine sits underneath both.
543
+ A model document MAY declare `collections`, `entities`, or both, and
544
+ a phase-A store document opens unchanged under the entity engine
545
+ (test-asserted). Entities live under `entities`, keyed by identifier
546
+ names:
547
+
548
+ ```json
549
+ {
550
+ "$model": "0.1",
551
+ "entities": {
552
+ "User": {
553
+ "schema": {
554
+ "type": "object",
555
+ "required": ["id", "email"],
556
+ "properties": {
557
+ "id": { "type": "string", "x-entity": { "key": true, "default": "uuid" } },
558
+ "email": { "type": "string", "format": "email",
559
+ "x-entity": { "unique": true } },
560
+ "created": { "type": "string", "format": "date-time",
561
+ "x-entity": { "default": "now", "column": "integer", "index": true } },
562
+ "profile": { "type": "object" },
563
+ "posts": { "x-entity": { "relation": { "to": "Post", "many": true,
564
+ "via": "authorId", "onDelete": "cascade" } } }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+ ```
571
+
572
+ The schema stays a valid JSON Schema throughout: strip every
573
+ `x-entity` member and it accepts and rejects exactly the same values
574
+ (test-asserted over a corpus). The vocabulary is invisible to the
575
+ validator by the same argument as `x-form`.
576
+
577
+ ### 9.2 The `x-entity` vocabulary (a closed set)
578
+
579
+ | member | on | meaning |
580
+ |---|---|---|
581
+ | `key` | a property | this property is (part of) the primary key; several form a composite key |
582
+ | `unique` | a property | a unique index over the property's column |
583
+ | `index` | a property | a non-unique index over the property's column |
584
+ | `default` | a property | applied on write, in JavaScript (§9.6): `"now"` (insert stamp), `"updated"` (insert AND every update), `"uuid"`, `"auto"` (single INTEGER key, database-allocated), `{ "value": … }` (a literal), `{ "query": … }` (a query document over the document being written) |
585
+ | `column` | a property | storage override: `"integer"` on a `date-time`/`date` string stores epoch milliseconds in a real column (index-friendly range predicates); `"json"` keeps a scalar in the JSONB document (the opt-out that preserves present-`null`, §9.3) |
586
+ | `relation` | a property | `{ to, many?, via?, through?, onDelete? }` — §9.4 |
587
+ | `version` | a property | the optimistic-concurrency token (§11.5): a plain integer column, one per entity, never the key — engine-owned and bumped on every successful write |
588
+
589
+ **An unknown member of `x-entity` is `JD0030` with a `docPath`.** A
590
+ silently ignored mapping directive is a data-loss bug waiting to
591
+ happen, so this vocabulary is deliberately stricter than the
592
+ validator's ignore-unknown posture — the strictness is local to the
593
+ one namespace this package owns.
594
+
595
+ ### 9.3 The hybrid mapping
596
+
597
+ Stated once, mechanically applied, and returned as data by
598
+ `explainMapping(model)` so it can be golden-tested and printed:
599
+
600
+ | Schema shape | Storage |
601
+ |---|---|
602
+ | scalar (`string`/`number`/`integer`/`boolean`) at the top level | a real typed column |
603
+ | `format: date-time`/`date` with `column: "integer"` | an epoch-milliseconds `INTEGER` column; the document keeps the RFC 3339 string, the column carries the derived epoch |
604
+ | `enum` of scalars | a column plus a `CHECK (column IN (…))` |
605
+ | nested object / array, or `column: "json"` | the JSONB document column, queryable by path exactly as in phase A |
606
+ | relation | a foreign-key column, or a join table for many-to-many (§9.4) |
607
+
608
+ `STRICT` tables throughout. The physical row is the key column(s),
609
+ the mapped scalar columns, and one JSONB `doc` column holding
610
+ everything else; a read merges them back. **The absent-versus-null
611
+ rule, plainly**: for a column-mapped scalar, JSON `null` and absence
612
+ both store as SQL `NULL` and read back as ABSENT. A property that
613
+ needs present-`null` semantics declares `column: "json"` and stays in
614
+ the document.
615
+
616
+ ### 9.4 Relations and referential integrity
617
+
618
+ Declared on one side, inferred on the other; when both sides declare,
619
+ the inverses MUST agree (`JD0031` on any contradiction).
620
+
621
+ - **one-to-many** — `{ to, many: true, via, onDelete }`: `via` names
622
+ the foreign-key property on the TARGET entity (`authorId` on
623
+ `Post`). If the target declares that property it MUST be a
624
+ column-mapped scalar of the key's type; otherwise the column is
625
+ inferred.
626
+ - **one-to-one** — `{ to, via, onDelete }` (no `many`): `via` names
627
+ the foreign-key property on the DECLARING entity, and its column is
628
+ unique.
629
+ - **many-to-many** — `{ to, many: true, through? }` (no `via`): a
630
+ join table, named `through` when given, otherwise the deterministic
631
+ `<A>_<B>` with the entity names sorted — implicit names are exactly
632
+ the thing teams later regret, so the explicit name exists. Its two
633
+ foreign keys cascade on delete (join rows die with either side; not
634
+ configurable in this version).
635
+
636
+ `onDelete` is REQUIRED wherever a foreign-key column is created —
637
+ `"cascade"`, `"restrict"` or `"setNull"` — never defaulted silently.
638
+ Referential integrity is real SQLite foreign keys:
639
+ `PRAGMA foreign_keys = ON` is set AND VERIFIED per connection (it
640
+ defaults off), a violating write fails with the wrapped database
641
+ error, and the declared on-delete behaviour is observed by test.
642
+
643
+ ### 9.5 Identity
644
+
645
+ Per entity, by the key properties (D11 — platform primitives only):
646
+ caller-supplied (any scalar key, composite included);
647
+ `default: "uuid"` on a single string key (`crypto.randomUUID()`);
648
+ `default: "auto"` on a single integer key (the database allocates —
649
+ an index-locality choice, documented as NOT a sortable-id guarantee).
650
+ Composite keys are ordinary: mark several properties `key: true`;
651
+ reads and deletes take `{ prop: value, … }`.
652
+
653
+ ### 9.6 Defaults
654
+
655
+ Applied on write in JavaScript, never by SQL `DEFAULT`, so the value
656
+ the application sees and the value stored are the same — and the
657
+ behaviour is identical on every driver. `"now"` stamps an RFC 3339
658
+ UTC string on insert when the property is absent; `"updated"` stamps
659
+ on insert AND on every update, always; `{ "value": … }` fills a
660
+ literal when absent; `{ "query": … }` evaluates a query document over
661
+ the document being written. Defaults run BEFORE validation, so the
662
+ injected hook sees the completed document.
663
+
664
+ ### 9.7 Error-code additions
665
+
666
+ The entity engine adds two codes to the package's single table (§7):
667
+ `JD0030` — an unknown `x-entity` member; `JD0031` — relation
668
+ declarations whose inverses contradict. Everything else raises the
669
+ existing codes (`JD0005` for structural model defects, `JD2005` for
670
+ database-refused writes including foreign-key violations).
671
+
672
+ ## 10. Relational translation
673
+
674
+ Phase B's planner extension: entity query documents translate to
675
+ selections and joins over the hybrid tables, and graph loading is one
676
+ statement. The residual rule is unchanged — anything not proven
677
+ translatable runs the set residual over the fetched entity root,
678
+ `explain()` says so, and `strict: true` refuses it (`JD0010`).
679
+
680
+ ### 10.1 Entity query documents
681
+
682
+ `store.execute(document)` queries the **multi-entity root**: the
683
+ engine-side value is `{ <EntityName>: [documents…], … }` and bindings
684
+ range over `$.<Entity>[*]`. This is the shape the differential oracle
685
+ can actually prove — the in-memory engine sees exactly the documents
686
+ the entity sets return (`test/db/oracle/relations/`). Relation-NAME
687
+ navigation (`$.author.name`) is deliberately not query-document sugar:
688
+ the engine has no embedded `author` member to walk, so no oracle could
689
+ vouch for it. Name-based navigation lives on the `load` surface
690
+ (§10.4), where results and statement counts are the proof.
691
+
692
+ Per binding, predicates resolve through three reference flavors:
693
+
694
+ - **entity-column** — a mapped scalar column. Total forms, no
695
+ `json_type` guard: a column-mapped property has no present-`null`
696
+ (§9.3), so presence IS `IS NOT NULL`. Cross-type literals decide at
697
+ plan time (`false`, or presence for `$ne`).
698
+ - **entity-epoch** — a derived instant column (§10.3).
699
+ - **entity-doc** — any other path rides the JSONB document with the
700
+ phase-A guarded truth table, aliased per binding.
701
+
702
+ Externals bind against entity columns (with the phase-A `valueTypeOf`
703
+ guard); a boolean, `null` or missing external diverts to the residual
704
+ at bind time, exactly as phase A does. Externals against document
705
+ paths stay residual.
706
+
707
+ ### 10.2 Joins
708
+
709
+ Two bindings joined by one equality between their column references
710
+ become an INNER equijoin — exactly the engine's
711
+ cross-product-plus-filter semantics. Result order is deterministic:
712
+ any `$orderby` keys first, then BOTH bindings' row identities in
713
+ binding order, which is the engine's nested-loop order. `explain()`
714
+ reports the join (`{ left, right }`) and the `EXPLAIN QUERY PLAN`
715
+ narrative; the paired foreign key carries an index (every foreign key
716
+ does — unique for a strict one-to-one, plain otherwise), so the probe
717
+ side of the join is a `SEARCH`, never a second scan.
718
+
719
+ On the `load` surface the join KIND is derived from the schema
720
+ (§10.4): a `oneToOne` include reports `inner (fk required)` when the
721
+ `via` property is in `required`, `left (fk optional)` otherwise —
722
+ one of the quiet advantages of models being JSON Schema.
723
+
724
+ ### 10.3 Instants (the epoch column)
725
+
726
+ A `column: "integer"` date property stores the RFC 3339 string in the
727
+ document and a derived epoch-milliseconds column beside it (§9.3).
728
+ Two rules keep that column honest:
729
+
730
+ - **The write contract.** A present string value must parse in the
731
+ property's own family and be Z-normalized (`date` properties:
732
+ `YYYY-MM-DD`; `date-time` properties: any precision, `Z` suffix).
733
+ Anything else — an offset form, junk — is refused (`JD2003`): an
734
+ offset would let the epoch order sit hours away from the codepoint
735
+ order of the document string, which is the order the engine
736
+ compares.
737
+ - **The comparison form.** An ordering comparison against a literal of
738
+ the column's family compiles to a ±1 s epoch RANGE on the column —
739
+ Z-normalized strings sharing a second prefix sit within one second,
740
+ so the range is a superset — plus the exact document-string
741
+ comparison that decides. The index narrows
742
+ (`EXPLAIN QUERY PLAN … USING INDEX`), the text answers, and mixed
743
+ stored precisions cannot diverge from the engine. `$ne`, string
744
+ operators, presence tests and non-family literals simply ride the
745
+ guarded document forms. `$orderby` over an instant path sorts the
746
+ document string, never the integer column, for the same reason.
747
+
748
+ ### 10.4 One-statement graph loading
749
+
750
+ `store.entity(name).load(spec)` compiles an include tree to correlated
751
+ subqueries projected as JSON — `json_group_array(json_object(…))` for
752
+ to-many, a scalar `json_object` for to-one, a correlated `COUNT(*)`
753
+ for `count: true` — and executes **one statement regardless of depth
754
+ or parent count**, asserted by a counting driver
755
+ (`test/db/statement-count.test.js`); N+1 is a test, not a promise.
756
+
757
+ ```js
758
+ store.entity('User').load({
759
+ where: { $gt: ['$it.age', 10] }, // over the root entity
760
+ orderBy: '$it.name',
761
+ take: 20, after: cursor, // §10.5
762
+ include: {
763
+ posts: {
764
+ where: { $ge: ['$it.stars', 3] }, // INSIDE the subquery
765
+ orderBy: { $key: '$it.stars', $dir: 'desc' },
766
+ take: 2,
767
+ include: { comments: true }, // nesting
768
+ },
769
+ followers: { count: true }, // the count, not the rows
770
+ },
771
+ })
772
+ ```
773
+
774
+ Per-relation `where`/`orderBy`/`take` apply INSIDE the subquery — the
775
+ point where naive loaders fall back to N+1. Clauses compile against
776
+ the child's own reference flavors; an untranslatable clause is a
777
+ refusal (`JD0032`) naming the include path, never a silent residual.
778
+ Include depth is bounded (default 3, override with `maxDepth`);
779
+ exceeding it is `JD0032` with the bound printed. A cyclic include
780
+ specification is rejected. Unknown relation names are `JD0032` too.
781
+
782
+ ### 10.5 Pagination
783
+
784
+ `$orderby` + `$subsequence` translate to `ORDER BY` + `LIMIT/OFFSET`
785
+ on the query surface. On the `load` surface, `after` (a cursor) with a
786
+ single ascending or descending ordering over a UNIQUE column — the
787
+ key, or any `unique: true` column — compiles to **keyset pagination**
788
+ (`WHERE col > ?` / `< ?`) instead of a growing `OFFSET`; `skip`
789
+ compiles to offset. `explainLoad()` reports which strategy ran
790
+ (`keyset` / `offset` / `none`) — offset degrading quietly on large
791
+ tables is a well-known footgun, and naming it is cheap. A cursor over
792
+ a non-unique column, a document path, or a multi-key ordering is
793
+ refused (`JD0032`).
794
+
795
+ ### 10.6 What remains residual
796
+
797
+ Reported by `explain()` with reasons, refused under `strict`, and —
798
+ because joins make residuals more expensive — accompanied by the
799
+ `EXPLAIN QUERY PLAN` narrative (SQLite exposes no row estimates;
800
+ a number appears only where `capabilities.rowEstimates` is filled):
801
+
802
+ - three or more bindings;
803
+ - non-equality join predicates, and disjunctions spanning bindings;
804
+ - `$groupby` (the engine's post-group cardinality rebinding deserves
805
+ its own order; the count-of-related-rows case ORMs are bad at is
806
+ already native via `count: true` includes);
807
+ - projections (`$return` objects) — over one binding or across a join;
808
+ - externals against document paths; booleans and `null` at bind time;
809
+ - everything phase A already listed (§8 of `QUERY-FORMAT.md`
810
+ notwithstanding, the truth table is the contract).
811
+
812
+
813
+ ## 11. The unit of work
814
+
815
+ Read entities, produce changed plain JSON, call `saveChanges()`: the
816
+ minimal set of parameterised statements runs inside ONE transaction,
817
+ ordered so no foreign key is violated mid-flight, with optimistic
818
+ concurrency where a version property is declared. Change tracking is
819
+ copy-on-write diffing — **no proxies exist on any read path**, and the
820
+ assertion is a test, not a promise.
821
+
822
+ ### 11.1 Snapshot tracking (the default)
823
+
824
+ A materialised entity — from `create`, `get`, `update` or `load`
825
+ (root AND included children) — is plain, **deep-frozen** JSON,
826
+ registered under its identity. The frozen document itself is the
827
+ snapshot: the tracker retains exactly one reference per entity, no
828
+ copies. Mutation is replacement:
829
+
830
+ ```js
831
+ const ada = await users.get('u1'); // frozen, tracked
832
+ users.put({ ...ada, age: 37 }); // the next version
833
+ users.add({ id: 'u9', name: 'new' }); // pending insert
834
+ users.remove('u2'); // pending delete
835
+ const report = await store.saveChanges(); // one transaction
836
+ ```
837
+
838
+ - `put(next)` requires the key to be tracked (`JD2006` otherwise) and
839
+ validates through the injected hook. `add()` completes defaults and
840
+ validates immediately; an `auto` key stays absent until the save
841
+ allocates it. `remove()` of a pending add cancels it. Documents
842
+ handed to `add`/`put` are adopted and frozen.
843
+ - A re-read refreshes a CLEAN record's snapshot; a DIRTY record stays
844
+ authoritative — the read still returns the fresh row. `discard(key)`
845
+ drops tracking without scheduling anything; it is the recovery step
846
+ after a `JD2040` conflict (discard, re-read, reapply, save again).
847
+ - `asNoTracking()` returns a read-only surface (`get`, `load`) whose
848
+ results are plain UNfrozen data, registered nowhere — a 100k-row
849
+ report retains no snapshots (proven by a forced-GC live-set test).
850
+ - Query results (`store.execute`, linq) are plain data, never tracked:
851
+ a projection has no identity to track.
852
+
853
+ ### 11.2 Explicit updates (the other mode)
854
+
855
+ `set.update(key, changes)` and `set.delete(key)` skip tracking: one
856
+ immediate statement, last-write-wins by contract. An explicit update
857
+ still bumps a declared version property, so optimistic savers observe
858
+ the row changed. This is the path reactive layers and job runners use.
859
+
860
+ ### 11.3 The diff-to-statement table
861
+
862
+ `saveChanges()` diffs snapshot against current with the suite's own
863
+ diff engine (`createJSONPatch`) and maps each operation:
864
+
865
+ | Diff operation | Statement |
866
+ |---|---|
867
+ | a top-level mapped scalar (or foreign-key) member | one column assignment (`SET col = ?`; removal writes `NULL` — reads absent, §9.3) |
868
+ | a top-level instant member (`column: "integer"`) | the epoch column AND a `jsonb_set` of the document string, one statement |
869
+ | a path inside the JSONB document | a `jsonb_set` / `jsonb_remove` chain over the `doc` column (the §5 patch translation) |
870
+ | a many-to-many relation member | join-table `INSERT`/`DELETE` rows from the KEY-SET difference (element internals belong to the child entity) |
871
+ | the version member | dropped — engine-owned, always written as snapshot + 1 |
872
+ | a one-to-many / one-to-one relation member EDIT | refused (`JD2003`): projections are not stored state |
873
+ | anything else (`move`, a mid-array insert, …) | the whole-row fallback — full column set + full document, **counted** in the report |
874
+
875
+ All assignments for one entity coalesce into ONE `UPDATE`. Relation
876
+ members never enter the stored document (`split` strips them on every
877
+ write path).
878
+
879
+ ### 11.4 Ordering and batching
880
+
881
+ Statements run: inserts parent-first (topological over the foreign-key
882
+ edges among the inserted entities) → updates → join-table rows (both
883
+ endpoints exist by then) → deletes child-first. An update may
884
+ reference a parent inserted in the same save. A foreign-key cycle —
885
+ self-references included — among the entities being inserted or
886
+ deleted is **`JD0040`** naming the cycle; break the save in two.
887
+
888
+ Same-shape inserts of one entity coalesce into multi-row `VALUES`
889
+ statements, bounded by `min(100 rows, ⌊900 parameters / row width⌋)`
890
+ (`BATCH_ROW_BOUND`, `BATCH_PARAM_BUDGET`). Generated keys come back
891
+ through `RETURNING` in one round trip; ascending keys pair with
892
+ insertion order (asserted by test). Measured on this machine: 2000
893
+ inserts = 20 statements at ~22 ms versus 2000 single-row statements at
894
+ ~31 ms in one transaction — the wall-clock gap is modest in-process,
895
+ the 100× statement reduction is the point for anything remote.
896
+
897
+ ### 11.5 Optimistic concurrency
898
+
899
+ Declare a token with `version: true` (§9.2). Every `saveChanges()`
900
+ update and guarded delete carries `WHERE version = ?` (the SNAPSHOT
901
+ version) and writes snapshot + 1; a zero-row result is **`JD2040`**
902
+ carrying the entity and key, and the whole save rolls back. Without a
903
+ version property there is no concurrency check and the report says so:
904
+ `concurrency.unversioned` names every touched entity that has none —
905
+ never a silent last-write-wins the reader believes is protected. (A
906
+ row that vanished entirely still conflicts an update: zero rows is
907
+ zero rows.) An unguarded delete of a missing row is a no-op.
908
+
909
+ ### 11.6 Failure semantics and the return shape
910
+
911
+ `saveChanges()` is all-or-nothing inside one transaction. On ANY
912
+ failure the tracker is left exactly as it was before the call — the
913
+ same save can be retried once the cause is gone; a half-applied
914
+ tracker is worse than a rollback. Only a committed save advances
915
+ snapshots (bumped versions, generated keys) and clears pending work.
916
+
917
+ The return value is data, not a boolean:
918
+
919
+ ```js
920
+ {
921
+ inserted, updated, deleted, // row counts
922
+ joinInserted, joinDeleted, // membership rows
923
+ fallbacks, // whole-row writes, counted
924
+ statements: [{ sql, rows }, …], // what actually ran
925
+ concurrency: { checked, unversioned: [names] },
926
+ elapsedMs,
927
+ }
928
+ ```