@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.
- package/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- 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
|
+
```
|