@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
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# @jarenjs/db — architecture
|
|
2
|
+
|
|
3
|
+
The public documentation promises SQLite and nothing else. This file
|
|
4
|
+
documents the two seams that make that promise cheap to keep and cheap
|
|
5
|
+
to outgrow — an undocumented seam decays into an accident.
|
|
6
|
+
|
|
7
|
+
## The driver seam (`src/driver.js`, `src/drivers/*`)
|
|
8
|
+
|
|
9
|
+
A driver is `{ name, dialect, open(path, options) }` returning a
|
|
10
|
+
`Connection` (see `docs/MODEL-FORMAT.md` §4). Three bindings exist,
|
|
11
|
+
one per subpath — `./node` (`node:sqlite`), `./bun` (`bun:sqlite`),
|
|
12
|
+
`./wasm` (an injected handle) — and each imports its runtime builtin
|
|
13
|
+
**lazily inside `open()`**, never at module scope. The release gate
|
|
14
|
+
imports every export subpath under Node *and* Bun; Bun ships no
|
|
15
|
+
`node:sqlite` and Node cannot resolve `bun:` specifiers, so a
|
|
16
|
+
top-level builtin import would turn the gate red in both directions.
|
|
17
|
+
`open()` is where "this binding does not exist here" becomes the coded
|
|
18
|
+
`JD0003`.
|
|
19
|
+
|
|
20
|
+
Every connection and statement method may return a value or a promise.
|
|
21
|
+
The store composes through the sync-capable `chain` helper, which
|
|
22
|
+
applies a continuation without allocating a promise when the driver
|
|
23
|
+
answered with a value — the public asynchronous surface then lifts
|
|
24
|
+
exactly once per call. Where the connection is synchronous the same
|
|
25
|
+
operation cores are exposed promise-free under `store.sync`; where it
|
|
26
|
+
is not, `store.sync` is absent rather than stubbed.
|
|
27
|
+
|
|
28
|
+
Capabilities are probed once at open (version, compile options,
|
|
29
|
+
binding declaration) and frozen. Two slots — `statementTimeout` and
|
|
30
|
+
`rowEstimates` — are `false` on every SQLite driver because the
|
|
31
|
+
underlying facts do not exist there (no interrupt, no progress
|
|
32
|
+
handler, no estimate API). They are part of the contract so a driver
|
|
33
|
+
that has the facts can fill them without a contract change.
|
|
34
|
+
|
|
35
|
+
## The dialect seam (`src/dialect.js`, `src/dialects/sqlite.js`)
|
|
36
|
+
|
|
37
|
+
A dialect is data plus a small emitter, and it is the only place SQL
|
|
38
|
+
text is produced. `createDialect(spec)` composes a spelling spec —
|
|
39
|
+
identifier quoting, parameter references, string literals, JSON
|
|
40
|
+
extract/set/remove/append, transaction phrases, PRAGMA phrases,
|
|
41
|
+
introspection queries, the table suffix and document column type —
|
|
42
|
+
into the DDL and DML builders the store consumes. Nothing outside a
|
|
43
|
+
dialect concatenates SQL; a reviewer can point at every emitted
|
|
44
|
+
statement and find it behind the seam. The suite pins this with a
|
|
45
|
+
test-double dialect whose quoting, parameter style and type names all
|
|
46
|
+
differ: the same model produces correspondingly different SQL.
|
|
47
|
+
|
|
48
|
+
Deliberately *not* dialect concerns, because they are behavioural
|
|
49
|
+
rather than syntactic, and live in the capability table instead:
|
|
50
|
+
whether functions register per connection, whether change capture
|
|
51
|
+
exists and in what form, and whether tables can be restructured in
|
|
52
|
+
place.
|
|
53
|
+
|
|
54
|
+
The SQLite dialect stores documents as JSONB in a `BLOB` column of a
|
|
55
|
+
`STRICT` table, projects each indexed path into a virtual generated
|
|
56
|
+
column over `jsonb_extract`, and renders reads back to text through
|
|
57
|
+
`json()`. Parameters are positional because every shipped binding
|
|
58
|
+
binds arrays.
|
|
59
|
+
|
|
60
|
+
## What sits on top
|
|
61
|
+
|
|
62
|
+
- `src/ddl.js` — a normalized collection to its physical plan. Index
|
|
63
|
+
paths are analyzed through the query engine's **published AST**
|
|
64
|
+
(`analyzeQuery`): singularity decides indexability (`JD0004`
|
|
65
|
+
otherwise), and the collection's schema is the type source for
|
|
66
|
+
generated columns. The plan carries both the CREATE statements and
|
|
67
|
+
the structural facts an existing table must match (`JD0002` when it
|
|
68
|
+
does not — nothing is ever altered).
|
|
69
|
+
- `src/patch-sql.js` — RFC 6902 to JSON-set primitives, discriminated
|
|
70
|
+
against the live document (a pointer cannot say array-or-object on
|
|
71
|
+
its own). Untranslatable operations fall back to a whole-document
|
|
72
|
+
write; the store counts and exposes the fallback.
|
|
73
|
+
- `src/store.js` — model normalization (`JD0005`), shape
|
|
74
|
+
create-or-verify, the validated write path (injected
|
|
75
|
+
`compileSchema` hook; `capabilities.validated` says whether it is
|
|
76
|
+
in force), identity strategies, savepoint-nested transactions, and
|
|
77
|
+
the sync surface.
|
|
78
|
+
|
|
79
|
+
Dependency arrows: `core → json → db`, plus `validate → db` for
|
|
80
|
+
exactly ONE reason: the entity walk resolves `$ref` and merges `allOf`
|
|
81
|
+
through the schema resolvers `@jarenjs/validate/normalize` exports for
|
|
82
|
+
other packages to walk schemas with (emit already does). Validation
|
|
83
|
+
itself still arrives only through the injected `compileSchema` hook —
|
|
84
|
+
this package never calls the validator. No linq, no app, no view, no
|
|
85
|
+
flow. The linq package couples by contract (`execute(document,
|
|
86
|
+
options)`), never by import.
|
|
87
|
+
|
|
88
|
+
## The decisions that cost something
|
|
89
|
+
|
|
90
|
+
- **D3 — the normalized AST is a compatibility surface.** The planner
|
|
91
|
+
walks the engine's PUBLISHED AST, which promoted an internal shape
|
|
92
|
+
to a versioned contract the engine must now keep. Paid for: a
|
|
93
|
+
load-time exhaustiveness pact means a new language construct breaks
|
|
94
|
+
the build instead of silently becoming a residual.
|
|
95
|
+
- **D6 — the store API is asynchronous** even though every shipped
|
|
96
|
+
driver is synchronous. Measured cost: ~0.17 µs per point read
|
|
97
|
+
(~6 %) over the `store.sync` twin. Paid for: the browser's OPFS
|
|
98
|
+
story and any future non-embedded driver need no API change, and
|
|
99
|
+
the sync-capable `chain` keeps the internal composition
|
|
100
|
+
allocation-free so the surface pays exactly one promise per call.
|
|
101
|
+
- **D21 — the dialect indirection is paid for a second backend that
|
|
102
|
+
does not exist.** Every byte of SQL routes through a spelling spec;
|
|
103
|
+
the test double proves the seam by rendering the same plans and the
|
|
104
|
+
same DDL differently. The cost is one indirection and a larger
|
|
105
|
+
contract; the alternative was a rewrite on the day a second dialect
|
|
106
|
+
matters — and the capability slots (`statementTimeout`,
|
|
107
|
+
`rowEstimates`) already model what a server would fill in.
|
|
108
|
+
|
|
109
|
+
## The pushdown contract (`src/plan.js`, `src/emit.js`, `src/query.js`)
|
|
110
|
+
|
|
111
|
+
This is an implementation contract, not a format: it binds the planner,
|
|
112
|
+
the emitter and their tests, and `explain()` is its runtime witness.
|
|
113
|
+
|
|
114
|
+
**Three stages, and the middle one is dialect-neutral.** A query
|
|
115
|
+
document is analyzed to the engine's published AST, the AST becomes a
|
|
116
|
+
`Plan` — a relational algebra value carrying **no SQL text** (a test
|
|
117
|
+
scans plan output for dialect tokens) — and the plan renders to SQL
|
|
118
|
+
through a dialect. What cannot be proven equivalent runs as a
|
|
119
|
+
**residual**: a real compiled Jaren query, never a reimplementation.
|
|
120
|
+
`explain()` always says which is which.
|
|
121
|
+
|
|
122
|
+
**Dispatch is exhaustive.** The planner switches on the AST's node
|
|
123
|
+
kinds with no default branch that degrades silently: an unrecognised
|
|
124
|
+
kind is an internal error naming the kind and the `AST_VERSION`. A
|
|
125
|
+
construct that is *deliberately* not translated is a row in the table
|
|
126
|
+
below, and that table's reasons are what `explain()` reports.
|
|
127
|
+
|
|
128
|
+
### The translated set
|
|
129
|
+
|
|
130
|
+
A single-binding FLWOR over the collection (`$for: { it: '$[*]' }`)
|
|
131
|
+
with: comparison predicates (`$eq $ne $lt $le $gt $ge`) between a
|
|
132
|
+
singular member path and a literal or external; `$and`/`$or`/`$not`
|
|
133
|
+
composition; `$exists`/`$empty`; `$starts-with`/`$ends-with`/
|
|
134
|
+
`$contains` on schema-typed string paths with literal patterns;
|
|
135
|
+
`$orderby` over singular schema-typed paths (`$dir`, `$empty`, no
|
|
136
|
+
collation); a top-level `$subsequence` window with literal bounds; the
|
|
137
|
+
top-level aggregates `$count` (bare-binding return only) and
|
|
138
|
+
`$sum`/`$avg`/`$min`/`$max` over a singular schema-typed path; and the
|
|
139
|
+
whole-document projection `$return: '$it'`.
|
|
140
|
+
|
|
141
|
+
### The deliberate-residual table
|
|
142
|
+
|
|
143
|
+
| construct | reason |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `$let` bindings, `$fold`, `$groupby`, positional/window bindings | no equivalence proof exists yet; residual by default |
|
|
146
|
+
| a second `$for` binding, joins, non-singular path expansion | one relation per plan in this version |
|
|
147
|
+
| `$match` and other unlisted operators, `$call` | no native spelling proven equivalent |
|
|
148
|
+
| `$orderby` with a `$collation` | a collation the dialect cannot reproduce is refused, not approximated |
|
|
149
|
+
| projections other than `'$it'` | run per row (the row residual) — pushed, ordered and windowed rows, projected by the engine |
|
|
150
|
+
| string operators with an external pattern | the pattern's type is unknowable at plan time and the engine ERRORS on non-string patterns |
|
|
151
|
+
| comparisons where both sides are paths | join territory |
|
|
152
|
+
| array/object literals in comparisons | deep-equality has no guarded native form |
|
|
153
|
+
| spatial predicates | no spatial index vocabulary in the model format |
|
|
154
|
+
|
|
155
|
+
### The type truth table
|
|
156
|
+
|
|
157
|
+
Jaren compares by JSON type; SQL engines by storage class and
|
|
158
|
+
affinity. `jsonb_extract` returns SQL `NULL` for a missing member AND
|
|
159
|
+
for a stored JSON `null` — only `json_type` tells them apart, so every
|
|
160
|
+
translated predicate is guarded by it and is therefore **total
|
|
161
|
+
(two-valued)**: `NOT`, `AND` and `OR` compose classically and SQL's
|
|
162
|
+
three-valued `NULL` logic never reaches a result row. Probed engine
|
|
163
|
+
facts the table encodes: a comparison against a missing member is
|
|
164
|
+
`false` (even `$ne`); a cross-type `$eq` is `false` and a cross-type
|
|
165
|
+
`$ne` against a PRESENT value is `true`; booleans do not order;
|
|
166
|
+
string order is codepoint order (which is exactly SQLite's BINARY
|
|
167
|
+
order over UTF-8);
|
|
168
|
+
`$exists` is true for a stored `null`, `$empty` is true for a missing
|
|
169
|
+
member.
|
|
170
|
+
|
|
171
|
+
With `jt` = `json_type(doc, path)`, `v` = the compared value (the
|
|
172
|
+
generated column where one exists), `?` = the bound operand:
|
|
173
|
+
|
|
174
|
+
| Jaren predicate | emitted form |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `$eq` path, number | `jt IN ('integer','real') AND v = ?` |
|
|
177
|
+
| `$ne` path, number | `jt IS NOT NULL AND (jt NOT IN ('integer','real') OR v <> ?)` |
|
|
178
|
+
| `$lt/$le/$gt/$ge` path, number | `jt IN ('integer','real') AND v op ?` |
|
|
179
|
+
| `$eq` path, string | `jt = 'text' AND v = ?` |
|
|
180
|
+
| `$ne` path, string | `jt IS NOT NULL AND (jt <> 'text' OR v <> ?)` |
|
|
181
|
+
| `$lt/$le/$gt/$ge` path, string | `jt = 'text' AND v op ?` |
|
|
182
|
+
| `$eq` path, `true`/`false` | `jt = 'true'` / `jt = 'false'` (no value bind) |
|
|
183
|
+
| `$ne` path, `true`/`false` | `jt IS NOT NULL AND jt <> 'true'/'false'` |
|
|
184
|
+
| `$eq` path, `null` | `jt = 'null'` |
|
|
185
|
+
| `$ne` path, `null` | `jt IS NOT NULL AND jt <> 'null'` |
|
|
186
|
+
| ordering vs `true/false/null` literal | constant `FALSE` (booleans and nulls do not order) |
|
|
187
|
+
| `$exists` path | `jt IS NOT NULL` |
|
|
188
|
+
| `$empty` path | `jt IS NULL` |
|
|
189
|
+
| `$eq` path, external | `(jt = 'text' AND typeof(?) = 'text' AND v = ?) OR (jt IN ('integer','real') AND typeof(?) IN ('integer','real') AND v = ?)` |
|
|
190
|
+
| `$ne` path, external | `jt IS NOT NULL AND NOT (…the $eq form…)` |
|
|
191
|
+
| ordering vs external | the same two-branch form with `op` |
|
|
192
|
+
| `$starts-with` path, string | `jt = 'text' AND substr(v, 1, length(?)) = ?` |
|
|
193
|
+
| `$ends-with` path, string | `jt = 'text' AND (length(?) = 0 OR substr(v, -length(?)) = ?)` |
|
|
194
|
+
| `$contains` path, string | `jt = 'text' AND instr(v, ?) > 0` |
|
|
195
|
+
|
|
196
|
+
The guards make the forms sound for typed AND untyped paths alike —
|
|
197
|
+
the schema type's job is choosing the generated COLUMN (the index),
|
|
198
|
+
never weakening the guard. Two documented preconditions: string
|
|
199
|
+
operators, aggregates AND ordering are only promoted on schema-typed
|
|
200
|
+
paths because the engine ERRORS on non-conforming operands where SQL
|
|
201
|
+
would coerce or sort — a stored `null` under an ordered key, a
|
|
202
|
+
non-string under a string operator — so on an unvalidated store, rows
|
|
203
|
+
violating the collection schema can make the engine throw where the
|
|
204
|
+
database answers; keep `compileSchema` injected if that distinction
|
|
205
|
+
matters to you.
|
|
206
|
+
|
|
207
|
+
**Bind-time diversion.** SQLite cannot bind a boolean, and a
|
|
208
|
+
`null`-valued external needs Jaren's null semantics, not SQL's. At
|
|
209
|
+
execute time, if any referenced external is missing or not a string or
|
|
210
|
+
finite number, the call runs the always-compiled set residual instead
|
|
211
|
+
of the native statement — same answer, one branch, no wrong-typed SQL.
|
|
212
|
+
|
|
213
|
+
### The two residual modes
|
|
214
|
+
|
|
215
|
+
- **Row residual** — only the projection is untranslated: predicates,
|
|
216
|
+
ordering and the window are fully pushed; each fetched row runs
|
|
217
|
+
`{ $for: { it: '$[*]' }, $return: [ <the document's $return> ] }`
|
|
218
|
+
(the array wrapper keeps array-valued items unambiguous) and the
|
|
219
|
+
items concatenate in row order. Streams.
|
|
220
|
+
- **Set residual** — anything else: the pushed predicate conjuncts
|
|
221
|
+
narrow candidates (`$and` splits; a partially translatable `$or`
|
|
222
|
+
does not), and the WHOLE original compiled document runs over the
|
|
223
|
+
materialized candidate array. Re-applying pushed conjuncts is
|
|
224
|
+
idempotent, so pushdown is pure narrowing. Reported as a barrier.
|
|
225
|
+
|
|
226
|
+
### `explain()`
|
|
227
|
+
|
|
228
|
+
Extends `compileJsonQuery(...).explain()`'s shape — `{ externals,
|
|
229
|
+
operators, functions, collations, limits }` — with `{ sql, params,
|
|
230
|
+
indexes, residual, barriers, scanNarrative }`. `params` lists the
|
|
231
|
+
bound slots in order (external names and literal markers — values are
|
|
232
|
+
ALWAYS bound, never interpolated). `indexes` names the declared
|
|
233
|
+
indexes whose generated columns the pushed predicates and ordering
|
|
234
|
+
touch, and the `scanNarrative` is the database's own `EXPLAIN QUERY
|
|
235
|
+
PLAN` prose so the claim is checkable against the engine that will run
|
|
236
|
+
it. `estimatedRows` is ABSENT on SQLite drivers — the capability slot
|
|
237
|
+
is empty and no number is fabricated. `residual` is `null` or
|
|
238
|
+
`{ mode: 'row' | 'set', reasons: [{ construct, reason }] }` with
|
|
239
|
+
reasons drawn from the deliberate-residual table. With
|
|
240
|
+
`strict: true`, any residual is instead the compile error `JD0010`
|
|
241
|
+
naming the forcing construct.
|
|
242
|
+
|
|
243
|
+
### The UDF escape hatch (capability-gated)
|
|
244
|
+
|
|
245
|
+
Between native SQL and pulling rows sits registering a compiled
|
|
246
|
+
predicate conjunct as a deterministic function used in the `WHERE`
|
|
247
|
+
clause. Gated on `capabilities.userFunctions` (absent on Bun by
|
|
248
|
+
construction) and applied only to fragments with no externals, no
|
|
249
|
+
functions and no collations — deterministic and side-effect-free by
|
|
250
|
+
analysis, not by hope. Registration is keyed by `contentKey(fragment)`
|
|
251
|
+
so identical fragments share one registration, and the planner MUST
|
|
252
|
+
produce a correct plan with the capability disabled (tested that way).
|
|
253
|
+
Preference order: native SQL → deterministic function → residual, and
|
|
254
|
+
`explain()` names the choice. **Index-form UDFs are deliberately not
|
|
255
|
+
part of this**: an index over a registered function makes the database
|
|
256
|
+
unwritable from any connection that has not registered the identical
|
|
257
|
+
function — a WHERE-clause UDF carries no such schema dependency, and
|
|
258
|
+
that operational hazard is why the model format declares no
|
|
259
|
+
UDF-expression indexes.
|
|
260
|
+
|
|
261
|
+
### The statement cache
|
|
262
|
+
|
|
263
|
+
A caller of the core primitives, not an eighth implementation:
|
|
264
|
+
`createBoundedCache` keyed by `contentKey(document)` plus collection,
|
|
265
|
+
dialect and strictness. `contentKey` is the memo-grade key
|
|
266
|
+
(`hashContent(stableStringify(x) ?? '')` — drops `undefined` members,
|
|
267
|
+
no cycle guard; both properties acceptable for a cache key), never
|
|
268
|
+
`canonicalizeJson` (signature-grade, throws on `undefined`).
|
|
269
|
+
`store.stats()` exposes hits, misses and evictions, so the cache is
|
|
270
|
+
proven rather than assumed.
|
|
271
|
+
|
|
272
|
+
## The relational half (`src/model.js`, `src/plan.js` §entities, `src/query.js`)
|
|
273
|
+
|
|
274
|
+
The same planner, a second document kind: entity query documents
|
|
275
|
+
address the multi-entity root (`$.User[*]`), the ONLY shape a
|
|
276
|
+
differential oracle can prove (the engine has no embedded relation
|
|
277
|
+
members to walk — which is why relation-name sugar is deliberately
|
|
278
|
+
absent from the query surface and lives on `load`). Three reference
|
|
279
|
+
flavors decide emission: entity COLUMNS get total forms with no
|
|
280
|
+
`json_type` guard (a mapped property has no present-null), entity
|
|
281
|
+
EPOCH columns get a ±1 s index range plus the exact document-string
|
|
282
|
+
recheck (mixed stored precisions can never diverge from the engine's
|
|
283
|
+
codepoint order), and everything else rides the phase-A guarded truth
|
|
284
|
+
table aliased per binding. Two-binding equijoins emit INNER JOIN with
|
|
285
|
+
binding-order row-identity tiebreakers — exactly the engine's
|
|
286
|
+
nested-loop order. The graph loader compiles include trees to
|
|
287
|
+
correlated `json_group_array`/`json_object` subqueries: one statement
|
|
288
|
+
per load, proven by a counting driver, never promised.
|
|
289
|
+
|
|
290
|
+
## The unit of work (`src/tracker.js`)
|
|
291
|
+
|
|
292
|
+
Materialised entities are deep-frozen plain JSON and the frozen
|
|
293
|
+
document IS the snapshot — one retained reference, structural sharing
|
|
294
|
+
made safe by the freeze. `saveChanges()` diffs with the suite's own
|
|
295
|
+
diff engine and maps operations to minimal statements (column writes,
|
|
296
|
+
`jsonb_set` chains, join-table key-set sync, a counted whole-row
|
|
297
|
+
fallback); inserts batch parent-first, deletes run child-first,
|
|
298
|
+
foreign-key cycles among the changed set are `JD0040`, and a declared
|
|
299
|
+
`version` property turns every update into an optimistic
|
|
300
|
+
`WHERE version = ?` with `JD2040` on conflict. The tracker mutates
|
|
301
|
+
ONLY after commit: a failed save retries.
|
|
302
|
+
|
|
303
|
+
## The migration engine, relationally (`src/migrate.js`, `src/cli.js`)
|
|
304
|
+
|
|
305
|
+
The strategy-table diff renders SELF-CONTAINED steps — additive
|
|
306
|
+
columns, data steps, and one rebuild implementation following
|
|
307
|
+
SQLite's documented twelve-step procedure with `foreign_key_check`
|
|
308
|
+
inside the transaction. Shape EQUALITY (schemaShapeOf versus a fresh
|
|
309
|
+
createModelShape build) is the acceptance criterion, asserted on the
|
|
310
|
+
shadow before the real database is touched and again after. Probed
|
|
311
|
+
and designed around: node:sqlite enables foreign keys BY DEFAULT (the
|
|
312
|
+
pragma bracket is load-bearing), `jsonb()` PARSES its argument
|
|
313
|
+
(column folds pass plain SQL values), and a table rename does not
|
|
314
|
+
rename columns (join tables rename their endpoint keys explicitly).
|
|
315
|
+
|
|
316
|
+
## One cross-runtime seam worth remembering
|
|
317
|
+
|
|
318
|
+
`bun:sqlite` answers **null** for a missing row where `node:sqlite`
|
|
319
|
+
answers undefined; the bun adapter normalizes at the seam (and the
|
|
320
|
+
bun-shaped test double mimics the null so the packed run pins it).
|
|
321
|
+
Found by the ORM benchmark's first real-Bun file-store open — the
|
|
322
|
+
in-memory tests never reopen a database, so create-or-verify had
|
|
323
|
+
never seen bun's null.
|
|
324
|
+
|
|
325
|
+
## Change capture (`src/capture.js`)
|
|
326
|
+
|
|
327
|
+
Every committed write becomes an ordered stream of RFC 6902 patches.
|
|
328
|
+
Two sources behind one contract: SQLite's **session changeset** where
|
|
329
|
+
the binding exposes `createSession` (node:sqlite), parsed from its
|
|
330
|
+
binary format by a hand-written decoder; a **write-path journal**
|
|
331
|
+
where it does not (bun:sqlite, the wasm build), buffering before/after
|
|
332
|
+
documents as the store writes them. Both net ONE op per row — insert+
|
|
333
|
+
update coalesces, insert+delete vanishes, an update back to the
|
|
334
|
+
original emits nothing — so the two modes agree as SETS (a differential
|
|
335
|
+
test pins it). The journal's netting was added when the wasm parity
|
|
336
|
+
suite ran a same-row multi-op transaction the original differential
|
|
337
|
+
script never wrote. Op order within a record is UNSPECIFIED; every op targets
|
|
338
|
+
a distinct pointer. The persisted `_jaren_changes` log rides the same
|
|
339
|
+
transaction as the writes it describes; a caught inner-savepoint
|
|
340
|
+
rollback truncates the journal buffer to its checkpoint. Overhead is
|
|
341
|
+
measured and published: capture off ~6µs, journal ~17µs, session ~69µs
|
|
342
|
+
per single-op commit, amortizing across a transaction.
|
|
343
|
+
|
|
344
|
+
## Live queries (`src/live.js`, `src/window.js`)
|
|
345
|
+
|
|
346
|
+
A live query classifies its document against the normative maintenance
|
|
347
|
+
table by reading the COMPILED PLAN — translated filters, order terms
|
|
348
|
+
and aggregates are exactly the planner's, never re-derived. Five
|
|
349
|
+
strategies: incremental **rows** (per-key items, arrival order), the
|
|
350
|
+
maintained **window** (all matching rows sorted, ties by key token, a
|
|
351
|
+
delete inside the visible slice answered without re-query), running
|
|
352
|
+
**accumulators** (per-row contributions retained so a capture `remove`
|
|
353
|
+
— which carries no old value — is still answerable; min/max recompute
|
|
354
|
+
over contributions when the extremum's holder leaves), per-group
|
|
355
|
+
**deltas** (the accumulator machinery once per group), and **re-run**
|
|
356
|
+
for everything else — declared, reported through `live.mode`, never
|
|
357
|
+
silent. Invalidation matches a record by table plus pointer prefix,
|
|
358
|
+
over-approximating toward re-evaluation (a missed update would be a
|
|
359
|
+
correctness bug; an extra one is only slower). Emissions preserve
|
|
360
|
+
reference identity for untouched rows — the O(k) renderer's contract —
|
|
361
|
+
proven by a seeded oracle that holds the maintained result equal to a
|
|
362
|
+
fresh re-query after every mutation. Incremental beats re-run 13× at
|
|
363
|
+
1k rows, 51× at 10k.
|
|
364
|
+
|
|
365
|
+
## Durable runs and the job queue (`src/jobs.js`, `src/dag-job.js`)
|
|
366
|
+
|
|
367
|
+
The queue's whole correctness story is one guarded statement: the
|
|
368
|
+
claim UPDATE selects the earliest eligible row (pending, failed, or an
|
|
369
|
+
expired lease — so recovery IS the next claim, not a sweeper) whose
|
|
370
|
+
kind the worker registered, sets it leased with a deadline, and
|
|
371
|
+
RETURNs it. One statement is one transaction, so no two workers claim
|
|
372
|
+
the same job without any distributed lock; every later transition
|
|
373
|
+
wears `state='leased' AND lease_owner=?`, making execution
|
|
374
|
+
at-least-once and completion exactly-once (proven by four workers on
|
|
375
|
+
four connections over one WAL file). The `@jarenjs/flow` composition
|
|
376
|
+
injects `compileDag` (db never imports flow — an import-graph test
|
|
377
|
+
enforces it) and binds a per-job checkpoint store; a DAG run's
|
|
378
|
+
completion records the result, marks the job done and prunes the
|
|
379
|
+
checkpoint rows in ONE transaction, so a crash resumes from its
|
|
380
|
+
checkpointed nodes rather than restarting. Non-goals stated plainly: a
|
|
381
|
+
shared SQLite file over a network filesystem is not a safe
|
|
382
|
+
coordination substrate.
|
|
383
|
+
|
|
384
|
+
## The wasm driver (`src/drivers/wasm.js`)
|
|
385
|
+
|
|
386
|
+
An injected handle, never an import: the host loads the official
|
|
387
|
+
SQLite wasm build and this driver adapts its `oo1` object API — which
|
|
388
|
+
is SYNCHRONOUS in a dedicated worker over the SAH-pool OPFS VFS, which
|
|
389
|
+
is exactly what keeps journal capture, live queries and the job queue
|
|
390
|
+
working unchanged in a browser. The engine parity is proven in Node
|
|
391
|
+
against the real wasm bytes (the full pushdown oracle, entities, live
|
|
392
|
+
queries, jobs, a shadow-verified migration); the browser suite proves
|
|
393
|
+
the ENVIRONMENT — OPFS persistence across reloads, the owner topology
|
|
394
|
+
(one context holds the sole connection, tabs are clients over a
|
|
395
|
+
BroadcastChannel), and the second-writer refusal — across Chromium,
|
|
396
|
+
Firefox and WebKit, with the memory fallback stated where OPFS is
|
|
397
|
+
absent.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# @jarenjs/db
|
|
2
|
+
|
|
3
|
+
Documents AND entities in SQLite. A **model document** declares
|
|
4
|
+
collections (a JSON Schema, a key, indexes) and — since phase B —
|
|
5
|
+
**entities**: keys, typed columns, relations, defaults and an
|
|
6
|
+
optimistic-concurrency token, all inside the schema through the
|
|
7
|
+
`x-entity` vocabulary. `openStore` applies the physical mapping
|
|
8
|
+
through a dialect and gives transactional, schema-validated reads and
|
|
9
|
+
writes; queries arrive as plain Jaren query documents (usually
|
|
10
|
+
written through `@jarenjs/linq`) and are **pushed down to SQL** where
|
|
11
|
+
equivalence is proven, with everything else running honestly in the
|
|
12
|
+
engine. The same code runs on Node, on Bun, and in a browser against
|
|
13
|
+
an injected wasm handle, with zero dependencies outside `@jarenjs/*`.
|
|
14
|
+
|
|
15
|
+
**The honest framing, first**: Prisma, Drizzle and Kysely are mature,
|
|
16
|
+
support several databases, and are faster on some benchmark rows —
|
|
17
|
+
those losses are published on the suite page with their reasons. What
|
|
18
|
+
none of them has is a query that is one serializable JSON document,
|
|
19
|
+
executable by two independent engines proven to agree by a
|
|
20
|
+
differential oracle, running unchanged in Node, Bun and the browser,
|
|
21
|
+
with schema-validated writes from the fastest validator in the
|
|
22
|
+
ecosystem. The composition is the product; the individual numbers are
|
|
23
|
+
what they are.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { openStore } from '@jarenjs/db';
|
|
27
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
28
|
+
|
|
29
|
+
const store = await openStore({
|
|
30
|
+
$model: '0.1',
|
|
31
|
+
collections: {
|
|
32
|
+
users: {
|
|
33
|
+
schema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['id', 'email'],
|
|
36
|
+
properties: {
|
|
37
|
+
id: { type: 'string' },
|
|
38
|
+
email: { type: 'string', format: 'email' },
|
|
39
|
+
age: { type: 'integer' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
key: '/id',
|
|
43
|
+
indexes: [{ name: 'by_age', path: '$.age' }],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}, { driver: nodeDriver(), path: 'app.db' });
|
|
47
|
+
|
|
48
|
+
const users = store.collection('users');
|
|
49
|
+
await users.insert({ id: 'u1', email: 'ada@example.test', age: 36 });
|
|
50
|
+
|
|
51
|
+
// a query document — here written by hand; linq writes the same thing
|
|
52
|
+
const adults = await users.execute({
|
|
53
|
+
$for: { it: '$[*]' },
|
|
54
|
+
$where: { $ge: ['$it.age', 21] },
|
|
55
|
+
$return: '$it',
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **The pushdown planner with `explain()`.** A query compiles through
|
|
60
|
+
the engine's published AST into a dialect-neutral plan and renders
|
|
61
|
+
to guarded, parameter-bound SQL; whatever cannot be proven
|
|
62
|
+
equivalent runs as a real compiled Jaren query (the residual), and
|
|
63
|
+
`explain()` always says which is which — the SQL, the bound
|
|
64
|
+
parameters, the indexes used (verified against the database's own
|
|
65
|
+
plan output), and the residual's named reasons. A 418-run
|
|
66
|
+
differential oracle keeps both paths agreeing. `strict: true` turns
|
|
67
|
+
any residual into a compile error.
|
|
68
|
+
- **Registered operators, correct in the residual, pushed where it
|
|
69
|
+
pays.** Open with a registry (`operators:
|
|
70
|
+
createJsltRegistry().use(mathPack).use(financePack)`) and a query may
|
|
71
|
+
use `$npv`, `$mean`, `$sqrt`, … over stored documents. Each runs
|
|
72
|
+
correctly in the residual — identical to the in-memory engine,
|
|
73
|
+
`explain()` names it. The `pushable:'scalar'` subset (the math ops) is
|
|
74
|
+
additionally accelerated into SQLite as **deterministic UDFs** where
|
|
75
|
+
the driver allows (node:sqlite yes; bun:sqlite has no UDF API and stays
|
|
76
|
+
the residual — reported by `capabilities.pushableOperators`). Pushdown
|
|
77
|
+
is a large win beside a selective native predicate or a `LIMIT` (3.9×,
|
|
78
|
+
615× measured) and a wash on a solo full-table computed predicate —
|
|
79
|
+
published honestly in MODEL-FORMAT §8.2, not gated behind a cost model
|
|
80
|
+
SQLite gives no row estimates to build. A first-class operator is
|
|
81
|
+
allowed under a profile by default (which also blocks host-side UDF
|
|
82
|
+
registration for untrusted documents); the profile's `functions`
|
|
83
|
+
allow-list still governs a `$call`-reached `fn`; the row bound still
|
|
84
|
+
fires. Without a registry the store is unchanged — `$npv` is `JQ0002`.
|
|
85
|
+
- **Storage is declarative.** Indexed paths become generated columns
|
|
86
|
+
plus real indexes, typed from the collection's schema. Opening an
|
|
87
|
+
existing database verifies the declared shape and refuses to alter
|
|
88
|
+
it — reshaping is the migration story.
|
|
89
|
+
- **Migrations are documents.** `planMigration` diffs two models into
|
|
90
|
+
rendered-DDL + JSLT-transform + assertion steps; a shadow database
|
|
91
|
+
replays the whole chain before the real store is touched; a
|
|
92
|
+
checksummed history refuses edited or reordered migrations; a
|
|
93
|
+
narrowing without an adequate transform is refused against the REAL
|
|
94
|
+
data, inside the transaction.
|
|
95
|
+
- **The safe profile.** Untrusted query documents run under composed
|
|
96
|
+
bounds: engine limits on the residual, a mandatory row bound that
|
|
97
|
+
refuses rather than truncates, reference allow-lists, optional
|
|
98
|
+
full-scan refusal, and per-collection mandatory predicates no
|
|
99
|
+
document shape can shed. Read-only stores refuse writes at the
|
|
100
|
+
driver.
|
|
101
|
+
- **Writes validate** through an injected hook; without one,
|
|
102
|
+
`store.capabilities.validated` is `false` and the docs say what that
|
|
103
|
+
costs. The public API is asynchronous (the browser's OPFS story
|
|
104
|
+
forces it) with a promise-free `store.sync` twin where the driver is
|
|
105
|
+
synchronous.
|
|
106
|
+
|
|
107
|
+
## What SQLite-only means, frankly
|
|
108
|
+
|
|
109
|
+
SQLite is the supported backend — 3.45 or newer, on `node:sqlite`,
|
|
110
|
+
`bun:sqlite`, or your injected wasm build — and nothing else is
|
|
111
|
+
promised. The dialect seam exists and is tested against a double, but
|
|
112
|
+
no second dialect ships. Concretely: there is **no statement timeout**
|
|
113
|
+
(the drivers expose no interrupt; the capability slot is honestly
|
|
114
|
+
`false`), no server, no replication, and cross-process concurrency is
|
|
115
|
+
SQLite's own story (WAL plus a busy timeout, both set and visible on
|
|
116
|
+
`store.capabilities`).
|
|
117
|
+
|
|
118
|
+
## The relational half (phase B)
|
|
119
|
+
|
|
120
|
+
- **Entities and relations** (`x-entity`, MODEL-FORMAT §9): hybrid
|
|
121
|
+
rows — key and mapped scalar columns beside one JSONB document —
|
|
122
|
+
with real foreign keys (`PRAGMA foreign_keys` set AND verified),
|
|
123
|
+
all three relation kinds, and derived epoch columns for indexed
|
|
124
|
+
instant ranges.
|
|
125
|
+
- **One-statement graph loads** (§10): `entity('User').load({ include:
|
|
126
|
+
{ posts: { include: { comments: true } } } })` runs in exactly ONE
|
|
127
|
+
statement regardless of depth or parent count — asserted by a
|
|
128
|
+
counting driver in the tests, and published with statement counts
|
|
129
|
+
beside the timings on the benchmark page. Keyset pagination when
|
|
130
|
+
the ordering allows it, reported, never silent.
|
|
131
|
+
- **The unit of work** (§11): reads are plain deep-frozen JSON (no
|
|
132
|
+
proxies, asserted); mutation is replacement; `saveChanges()` diffs
|
|
133
|
+
snapshots into minimal parameterised statements in one transaction,
|
|
134
|
+
with insert batching, `JD0040` cycle refusal, `JD2040` optimistic
|
|
135
|
+
conflicts, and a report of every statement, fallback and count.
|
|
136
|
+
- **Generated types**: `entityEmitModel` + `@jarenjs/emit` render the
|
|
137
|
+
model into entity interfaces, input variants and an `EntityMetaMap`;
|
|
138
|
+
`typedStore` (from `@jarenjs/db/typed`) types every read, checks
|
|
139
|
+
every write, and widens `load` results by their include
|
|
140
|
+
specification.
|
|
141
|
+
- **Relational migrations and the `jaren-db` CLI** (MIGRATION-FORMAT
|
|
142
|
+
§§9–12): the strategy-table diff, the documented twelve-step table
|
|
143
|
+
rebuild with `foreign_key_check` inside the transaction, shape
|
|
144
|
+
EQUALITY against a fresh build as the acceptance criterion, drift
|
|
145
|
+
detection, and `jaren-db check` for CI.
|
|
146
|
+
|
|
147
|
+
## The reactive and durable half (phase C)
|
|
148
|
+
|
|
149
|
+
- **Change capture** (LIVE-FORMAT §§1–6): every committed write
|
|
150
|
+
becomes an observable stream of RFC 6902 patches — from SQLite's
|
|
151
|
+
own session changesets where the binding has them, from a write-path
|
|
152
|
+
journal where it does not (`bun:sqlite`, the wasm build). One diff
|
|
153
|
+
format runs store → patch → live query → O(k) render. Capture is
|
|
154
|
+
opt-in; the overhead is published, not waved away.
|
|
155
|
+
- **Live queries** (LIVE-FORMAT §§7–12): `collection.live(document)`
|
|
156
|
+
maintains a result as writes arrive and emits patches — incremental
|
|
157
|
+
for `where`/`select`/`orderBy`+`limit`/aggregates/single-level
|
|
158
|
+
`groupBy` (the normative maintenance table), re-run for everything
|
|
159
|
+
else, **declared, never silent** (`live.mode` names the reason).
|
|
160
|
+
Unaffected rows stay reference-identical; a seeded oracle holds the
|
|
161
|
+
maintained result equal to a fresh re-query after every mutation.
|
|
162
|
+
- **Durable runs and the job queue** (JOBS-FORMAT, FLOW-FORMAT §7.6):
|
|
163
|
+
a `@jarenjs/flow` DAG run checkpoints declared nodes and RESUMES
|
|
164
|
+
after a crash; `store.jobs` leases work in one guarded statement
|
|
165
|
+
(exactly-once completion, no distributed lock), retries with
|
|
166
|
+
backoff, dead-letters, and reclaims expired leases as recovery.
|
|
167
|
+
- **The browser** (`@jarenjs/db/wasm`): the same store, the same
|
|
168
|
+
queries, the same live updates run on the official SQLite wasm build
|
|
169
|
+
over the header-free OPFS SAH-pool VFS — one tab owns the
|
|
170
|
+
connection, others are clients. Proven in the `#/data` studio across
|
|
171
|
+
Chromium, Firefox and WebKit.
|
|
172
|
+
|
|
173
|
+
## Sync-readiness — what exists and what does not
|
|
174
|
+
|
|
175
|
+
The change stream is an ordered log of RFC 6902 patches with a
|
|
176
|
+
monotonic sequence, and SQLite's own changeset/conflict primitives are
|
|
177
|
+
available — which is what a replication protocol would be *built
|
|
178
|
+
from*. **No replication is shipped.** There is no conflict resolution,
|
|
179
|
+
no site identity, no causal ordering across writers, and no capture of
|
|
180
|
+
writes made by another connection (the coarse `dataVersion()` signal
|
|
181
|
+
is the honest mitigation, not a pretend fine-grained one). Building
|
|
182
|
+
replication on these primitives is a roadmap item, not a hint.
|
|
183
|
+
|
|
184
|
+
## What this is not — every non-claim in one place
|
|
185
|
+
|
|
186
|
+
- **SQLite only.** One backend (3.45+); the dialect seam is tested
|
|
187
|
+
against a double but no second dialect ships. No server.
|
|
188
|
+
- **No replication or sync engine** (see above). No cross-connection
|
|
189
|
+
change capture — another connection's writes are invisible locally.
|
|
190
|
+
- **No statement timeout** on SQLite (the drivers expose no interrupt;
|
|
191
|
+
the capability slot is honestly `false`), no row estimates.
|
|
192
|
+
- **Not safe for mutually hostile tenants** without the profile's
|
|
193
|
+
mandatory predicate — SECURITY states the claims and non-claims.
|
|
194
|
+
- **The job queue is one database, one machine.** A shared SQLite file
|
|
195
|
+
over a NETWORK FILESYSTEM (NFS, SMB, many container volume mounts) is
|
|
196
|
+
NOT a safe coordination substrate — SQLite's locking is unreliable
|
|
197
|
+
there. Same-host processes over WAL are the supported topology. No
|
|
198
|
+
priority classes, no cron, no workflow compensation.
|
|
199
|
+
- **Live-query maintenance is limited to the declared table** (§7);
|
|
200
|
+
joins, entity queries and non-canonical shapes re-run, reported.
|
|
201
|
+
- **The wasm build journals** (its session extension is not yet
|
|
202
|
+
adapted); OPFS needs a secure context, and where it is absent the
|
|
203
|
+
store runs in memory with the durability difference stated.
|
|
204
|
+
- **Named future work, not silent gaps**: `$groupby` pushdown,
|
|
205
|
+
relation-name query sugar, a many-to-many membership API, incremental
|
|
206
|
+
joins, other SQL dialects, replication, database introspection
|
|
207
|
+
(MODEL-FORMAT §10.6, the roadmap).
|
|
208
|
+
|
|
209
|
+
The normative formats are
|
|
210
|
+
[docs/MODEL-FORMAT.md](docs/MODEL-FORMAT.md) (storage §§1–7, safe
|
|
211
|
+
profile §8, entities §9, relational translation §10, the unit of work
|
|
212
|
+
§11), [docs/MIGRATION-FORMAT.md](docs/MIGRATION-FORMAT.md) (documents
|
|
213
|
+
§§1–8, relational changes §§9–12),
|
|
214
|
+
[docs/LIVE-FORMAT.md](docs/LIVE-FORMAT.md) (capture §§1–6, live queries
|
|
215
|
+
§§7–12) and [docs/JOBS-FORMAT.md](docs/JOBS-FORMAT.md) (the durable
|
|
216
|
+
queue §§1–9); the seams, the pushdown contract and every engine are in
|
|
217
|
+
[ARCHITECTURE.md](ARCHITECTURE.md); the benchmark methodology is in
|
|
218
|
+
[benchmark/README.md](../../benchmark/README.md).
|