@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,348 @@
|
|
|
1
|
+
# The Jaren live format — change capture and live queries
|
|
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
|
+
Change capture and live queries are ONE story — patches out — and
|
|
7
|
+
share this document. This order writes §§1–6 (capture); live queries
|
|
8
|
+
continue at §7. Error codes join the package's single runtime table
|
|
9
|
+
(MODEL-FORMAT §7), never a per-document list.
|
|
10
|
+
|
|
11
|
+
## 1. Scope
|
|
12
|
+
|
|
13
|
+
Every committed write to a store opened with `capture` produces an
|
|
14
|
+
observable, ordered stream of **RFC 6902 patches** describing what
|
|
15
|
+
changed — derived from SQLite's own session changesets where the
|
|
16
|
+
binding has them, from a write-path journal where it does not. One
|
|
17
|
+
diff format then runs end to end: store → patch → live query → patch
|
|
18
|
+
→ O(k) render. The patches MUST be consumable by
|
|
19
|
+
`applyJSONPatch` from `@jarenjs/json/patch`, unmodified.
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const store = await openStore(model, {
|
|
23
|
+
driver: nodeDriver(),
|
|
24
|
+
capture: { mode: 'auto', log: { retention: 1000 } },
|
|
25
|
+
});
|
|
26
|
+
const stop = store.observe(({ seq, at, source, collections, patch }) => {
|
|
27
|
+
// one record per committed transaction, in commit order
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Capture is **opt-in per store** — it costs a transaction wrapper and
|
|
32
|
+
(in session mode) a session per commit; the measured overhead is
|
|
33
|
+
published with the benchmarks, not waved away.
|
|
34
|
+
|
|
35
|
+
## 2. The pointer contract
|
|
36
|
+
|
|
37
|
+
Every op's `path` is `/<table>/<key>/<path…>`, each token escaped per
|
|
38
|
+
RFC 6901 (`~` → `~0`, `/` → `~1`):
|
|
39
|
+
|
|
40
|
+
- `<table>` is the collection, entity or join-table name.
|
|
41
|
+
- `<key>`: a SINGLE key renders as its scalar text (integers in
|
|
42
|
+
decimal); a COMPOSITE key renders as the JSON text of its parts
|
|
43
|
+
array (`["ada","math"]`), then escaped as one token. The encoding is
|
|
44
|
+
deterministic: parts appear in declared key order.
|
|
45
|
+
- `<path…>` is the document-relative remainder, RFC 6901 as always.
|
|
46
|
+
|
|
47
|
+
The contract is stable — consumers depend on it. Join-table rows are
|
|
48
|
+
tiny documents under the join table's name (`/Label_User/["admin","u1"]`
|
|
49
|
+
→ `{ "Label_key": "admin", "User_key": "u1" }`, columns in the sorted
|
|
50
|
+
pair order): membership changes are part of the stream, not a blind
|
|
51
|
+
spot.
|
|
52
|
+
|
|
53
|
+
**Op order within a record is UNSPECIFIED.** A commit carries one net
|
|
54
|
+
op per row (SQLite sessions coalesce insert+update, drop
|
|
55
|
+
insert+delete, omit no-op updates — and the journal mirrors that
|
|
56
|
+
discipline), every op targets a distinct pointer, and the record
|
|
57
|
+
applies correctly in any order. The two capture modes MAY order the
|
|
58
|
+
same ops differently; they MUST agree as sets.
|
|
59
|
+
|
|
60
|
+
## 3. The op mapping
|
|
61
|
+
|
|
62
|
+
| Row change | Patch |
|
|
63
|
+
|---|---|
|
|
64
|
+
| INSERT | `add` at `/<table>/<key>` with the full document (mapped columns folded back: booleans as `true`/`false`, SQL `NULL` absent, derived epoch columns skipped — the document string is authoritative) |
|
|
65
|
+
| DELETE | `remove` at `/<table>/<key>` |
|
|
66
|
+
| UPDATE, document column changed | old JSONB diffed against new (`createJSONPatch`) → minimal nested ops under `/<table>/<key>` |
|
|
67
|
+
| UPDATE, mapped scalar/foreign-key column changed | property-level op at `/<table>/<key>/<property>`: `replace` (both present), `add` (was `NULL`), `remove` (now `NULL` — absent per MODEL-FORMAT §9.3) |
|
|
68
|
+
| join-table INSERT / DELETE | `add` / `remove` of the membership row document |
|
|
69
|
+
|
|
70
|
+
Changesets (not patchsets) are used because they carry OLD values —
|
|
71
|
+
that is what makes the minimal nested diff and the add/remove/replace
|
|
72
|
+
discrimination possible; the price is a larger in-memory blob per
|
|
73
|
+
transaction, stated here. Integer values beyond ±2⁵³ convert lossily
|
|
74
|
+
to JS numbers (a raw-SQL concern only; JS documents cannot produce
|
|
75
|
+
them).
|
|
76
|
+
|
|
77
|
+
## 4. Capture modes and their limitations
|
|
78
|
+
|
|
79
|
+
`capture.mode` is `'auto'` (default), `'session'` or `'journal'`;
|
|
80
|
+
`store.capabilities.capture` reports what actually runs
|
|
81
|
+
(`'session'`, `'journal'` or `'none'`).
|
|
82
|
+
|
|
83
|
+
- **`session`** — SQLite's session extension records row changes at
|
|
84
|
+
the storage layer: every write through this connection is seen,
|
|
85
|
+
including future raw-SQL surfaces. Requires the binding to expose
|
|
86
|
+
`createSession` (node:sqlite does; **bun:sqlite does not**; a wasm
|
|
87
|
+
build may not). Sessions are per COMMIT, never long-lived (an
|
|
88
|
+
unbounded session is a memory leak); a rolled-back transaction —
|
|
89
|
+
and a rolled-back savepoint inside a committed one — contributes
|
|
90
|
+
nothing (probed and pinned by test).
|
|
91
|
+
- **`journal`** — the write path itself emits records: the store
|
|
92
|
+
already knows what it wrote. **Less complete, stated plainly**: it
|
|
93
|
+
cannot see writes made through raw SQL, triggers, or another
|
|
94
|
+
connection; a journal-mode delete of a row the store never read
|
|
95
|
+
emits its `remove` without having seen the old document; and of the
|
|
96
|
+
database's own `ON DELETE` side effects it reconstructs exactly ONE
|
|
97
|
+
— join-table membership dying with its entity (read before the
|
|
98
|
+
delete) — while cascades into CHILD rows (`onDelete: 'cascade'` /
|
|
99
|
+
`'setNull'` on one-to-many relations) stay invisible. For writes
|
|
100
|
+
made through the store API within those bounds the two modes MUST
|
|
101
|
+
produce the same op sets — proven by a differential test.
|
|
102
|
+
- Requesting `mode: 'session'` on a driver without sessions is a
|
|
103
|
+
`TypeError` at open — an application that needs completeness can
|
|
104
|
+
refuse to start rather than silently miss changes.
|
|
105
|
+
|
|
106
|
+
## 5. The persisted log and retention
|
|
107
|
+
|
|
108
|
+
With `capture.log`, each record is appended to `_jaren_changes`
|
|
109
|
+
**inside the same transaction** as the writes it describes — an
|
|
110
|
+
observer crash cannot lose a committed record, and a late joiner
|
|
111
|
+
reads forward:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
const records = await store.changesSince(lastSeq); // JD2051 when no log
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`seq` is monotonic; with the log enabled it continues across reopens
|
|
118
|
+
(seeded from `MAX(seq)`), without it it is per-process. Retention is
|
|
119
|
+
a bounded count (`retention`, default 1000): older rows are pruned in
|
|
120
|
+
the same transaction. The log is an ordered, replayable stream —
|
|
121
|
+
which is what makes a late-joining consumer possible. **Replication
|
|
122
|
+
is not built here**, and this log alone does not make it safe: there
|
|
123
|
+
is no conflict resolution, no site identity, no causal ordering
|
|
124
|
+
across writers. That sentence is the whole claim.
|
|
125
|
+
|
|
126
|
+
## 6. Cross-connection behaviour and non-claims
|
|
127
|
+
|
|
128
|
+
Another process (or another connection) writing to the same file
|
|
129
|
+
produces NO local patches — sessions and journals are per-connection
|
|
130
|
+
facts. The honest mitigation is a coarse signal, not a pretend
|
|
131
|
+
fine-grained one: `store.dataVersion()` reads `PRAGMA data_version`,
|
|
132
|
+
which changes when ANOTHER connection commits; poll it and treat a
|
|
133
|
+
change as "re-read what you care about". Cross-tab delivery is §7's
|
|
134
|
+
story (the live-query layer).
|
|
135
|
+
|
|
136
|
+
Non-claims, in one place: no replication, no conflict resolution, no
|
|
137
|
+
capture of writes made by other connections, no capture on stores
|
|
138
|
+
opened without `capture`, and no statement-level ordering within a
|
|
139
|
+
commit (§2).
|
|
140
|
+
|
|
141
|
+
## 7. Live queries: the maintenance table
|
|
142
|
+
|
|
143
|
+
A live query is a registered query document whose result is
|
|
144
|
+
**maintained** as committed writes arrive, emitting RFC 6902 patches
|
|
145
|
+
against its own result document (§9). Registration:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const live = await store.collection('users').live(document, {
|
|
149
|
+
externals: {}, // fixed at registration (§8)
|
|
150
|
+
mode: 'auto', // 'auto' | 'incremental' | 'rerun'
|
|
151
|
+
});
|
|
152
|
+
live.result; // the maintained result document
|
|
153
|
+
live.mode; // { strategy, mode: 'incremental'|'rerun', reason }
|
|
154
|
+
const stop = live.subscribe(({ patch, seq, error }) => { /* … */ });
|
|
155
|
+
live.close();
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`store.live(document, options)` registers an entity-root document (the
|
|
159
|
+
multi-entity shape of MODEL-FORMAT §10) the same way. Live queries
|
|
160
|
+
REQUIRE change capture — the patch stream is the invalidation source —
|
|
161
|
+
and registering on a store opened without `capture` is `JD0050`.
|
|
162
|
+
|
|
163
|
+
**This table is normative.** Every row is implemented and tested;
|
|
164
|
+
nothing outside it is attempted. Classification reads the compiled
|
|
165
|
+
PLAN (never the raw document), so "extractable" below means exactly
|
|
166
|
+
what the pushdown planner already means by it.
|
|
167
|
+
|
|
168
|
+
| Construct (as planned) | Strategy | Maintained state |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `where` whose predicates translate (the plan's filter), no order, no aggregate | **incremental rows**: per-row re-evaluation; insert / remove / replace in the result | the result rows |
|
|
171
|
+
| the same with a per-row `select` projection (row-mode plan) | **incremental rows**: the affected row alone is recomputed; a source row may project to several items | result rows, grouped by source key |
|
|
172
|
+
| `orderBy` over extractable paths, optional `limit`, offset 0 | **maintained window**: a sorted structure; ties broken by the collection key, appended as the final sort term; an insert sorting beyond a full window is a no-op | the window rows and their sort keys |
|
|
173
|
+
| whole-query `count` / `sum` / `avg` / `min` / `max` (the plan's aggregate), optional `where` | **running accumulator** plus a per-row contribution map — a delete can only be answered from retained contributions (§3: a `remove` carries no old value). `min`/`max` removal of the last extremum holder FALLS BACK to a recompute over the retained contributions; the accumulator alone cannot answer, and this fallback is the documented cost | one contribution per matching row |
|
|
174
|
+
| single-level `groupBy` with aggregate returns, in the canonical form below | **per-group deltas**: the accumulator machinery, one instance per group; groups appear in first-appearance order, exactly the engine's order | per-group, per-row contributions |
|
|
175
|
+
| joins, multi-entity roots, graph loads, every entity query | **re-run on invalidation — declared, not attempted** in this version | the previous result, for diffing |
|
|
176
|
+
| anything else: non-translatable predicates, `limit` without `orderBy`, `offset` > 0, windowed aggregates, `@jarenjs/linq`'s nested two-level `groupBy` emission, non-canonical group returns | **re-run on invalidation**, the reason named | the previous result, for diffing |
|
|
177
|
+
|
|
178
|
+
Re-run is a first-class, documented outcome, not a failure. What is
|
|
179
|
+
forbidden is *silently* re-running while the reader believes the query
|
|
180
|
+
is incremental: `live.mode` reports `'incremental'` or `'rerun'`, the
|
|
181
|
+
strategy, and — for re-run — the reason. `mode: 'incremental'` in the
|
|
182
|
+
options DEMANDS incrementality: a query that classifies as re-run then
|
|
183
|
+
refuses at registration (`JD0051`), the same shape as capture's
|
|
184
|
+
demanded session — an application that needs the property can refuse
|
|
185
|
+
to start.
|
|
186
|
+
|
|
187
|
+
The **canonical group form** the classifier recognises (and the only
|
|
188
|
+
one — the linq chain's nested emission re-runs, stated plainly):
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{ "$for": { "it": "$[*]" },
|
|
192
|
+
"$where": { "…optional, translatable…": [] },
|
|
193
|
+
"$groupby": { "g": "$it.dept" },
|
|
194
|
+
"$return": { "key": { "$default": ["$g", null] },
|
|
195
|
+
"n": { "$count": "$it" }, "total": { "$sum": "$it.pay" } } }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
After `$groupby`, `$it` is the group's item sequence and `$g` its key;
|
|
199
|
+
return members are the group key or an aggregate over `$it` (a path
|
|
200
|
+
below it selects the aggregated member). Anything else in the return
|
|
201
|
+
is not canonical and re-runs.
|
|
202
|
+
|
|
203
|
+
## 8. Invalidation
|
|
204
|
+
|
|
205
|
+
Each live query derives its dependencies from the plan at
|
|
206
|
+
registration:
|
|
207
|
+
|
|
208
|
+
- the collection (or, for entity documents, every entity the plan
|
|
209
|
+
binds), matched first against a record's `collections` — a
|
|
210
|
+
non-matching record costs ONE array scan;
|
|
211
|
+
- for accumulator and group strategies, the top-level members the
|
|
212
|
+
plan actually reads (filter refs, the aggregated path, the group
|
|
213
|
+
key): an update record touching only other members is skipped;
|
|
214
|
+
- row and window strategies depend on their WHOLE collection — the
|
|
215
|
+
result carries the row documents, so any member change changes an
|
|
216
|
+
emitted row.
|
|
217
|
+
|
|
218
|
+
Matching is by table plus pointer prefix — cheap, sound, and
|
|
219
|
+
**over-approximate in exactly one direction**: an unnecessary
|
|
220
|
+
re-evaluation is a performance bug; a missed one would be a
|
|
221
|
+
correctness bug, so the approximation always leans toward
|
|
222
|
+
re-evaluating. Row inserts arrive with their full document in the
|
|
223
|
+
patch; row UPDATES are minimal (§3), so maintenance issues a
|
|
224
|
+
point-read of the touched row (one indexed lookup per touched key per
|
|
225
|
+
record) to re-evaluate; deletes are answered entirely from maintained
|
|
226
|
+
state. `externals` are fixed at registration — a query whose inputs
|
|
227
|
+
change is a new registration.
|
|
228
|
+
|
|
229
|
+
Maintenance runs synchronously inside patch delivery, in commit
|
|
230
|
+
order, on the store's own connection. Writes from ANOTHER connection
|
|
231
|
+
are invisible to capture (§6) and therefore to live queries; the
|
|
232
|
+
coarse `dataVersion()` signal and the §11 topology are the honest
|
|
233
|
+
answers, and re-registering re-reads.
|
|
234
|
+
|
|
235
|
+
## 9. The emitted patch contract
|
|
236
|
+
|
|
237
|
+
The result document is `{ "rows": [...] }` — always. Row and window
|
|
238
|
+
strategies fill `rows` with result items; group strategies with one
|
|
239
|
+
row per group; a whole-query aggregate is a ZERO-OR-ONE row result
|
|
240
|
+
(`rows: [42]`; an empty `min()` is `rows: []`, the engine's
|
|
241
|
+
undefined-as-absent mapping made visible). Consumers hold the result
|
|
242
|
+
document and apply patches to it; ops are `add`, `remove` and
|
|
243
|
+
`replace` only.
|
|
244
|
+
|
|
245
|
+
- **One record, one emission.** A committed transaction touching any
|
|
246
|
+
number of rows produces at most ONE `{ patch, seq }` event per live
|
|
247
|
+
query — the record's changes coalesce into one patch array, and a
|
|
248
|
+
record that ends up changing nothing emits nothing.
|
|
249
|
+
- **Structural sharing is the contract, not an optimisation.** After
|
|
250
|
+
an emission, every unaffected row in `live.result` is
|
|
251
|
+
REFERENCE-IDENTICAL to before; the `rows` array and the result
|
|
252
|
+
object are fresh per emission (a held previous result is never
|
|
253
|
+
mutated). Applying the emitted patch with `applyJSONPatch`'s
|
|
254
|
+
copy-on-write preserves the same sharing on the consumer's side —
|
|
255
|
+
which is what keeps the O(k) renderer's fast paths alive end to
|
|
256
|
+
end.
|
|
257
|
+
- **Order.** An ordered (window) query's row order is the engine's,
|
|
258
|
+
with ties broken by the collection key — the key is appended to the
|
|
259
|
+
declared terms at registration, so the order is total and stable by
|
|
260
|
+
construction. An UNORDERED query's initial order is the engine's;
|
|
261
|
+
maintenance then appends newly matching rows and splices removed
|
|
262
|
+
ones, which is deterministic given the write history but is NOT
|
|
263
|
+
re-derived rowid order — a consumer that needs a specific order
|
|
264
|
+
declares an `orderBy`. Group rows keep first-appearance order.
|
|
265
|
+
- `seq` is the capture record's `seq`; re-run emissions carry it too.
|
|
266
|
+
|
|
267
|
+
## 10. The app binding
|
|
268
|
+
|
|
269
|
+
A live query reaches an app as a SUBSCRIPTION (APP-FORMAT §5.3) whose
|
|
270
|
+
handler dispatches a patch-carrying action — generated documents, no
|
|
271
|
+
import in either direction (the `fsmToApp` precedent):
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
import { liveAppBinding, createLiveSubscription } from '@jarenjs/db/app';
|
|
275
|
+
|
|
276
|
+
const { subscription, actions } = liveAppBinding({
|
|
277
|
+
run: 'db/live', action: 'db/liveChanged', statePath: '/live/users' });
|
|
278
|
+
// subscription → { run: 'db/live', with: { …, statePath, action } }
|
|
279
|
+
// actions → { 'db/liveChanged': { patch: '$payload' } }
|
|
280
|
+
|
|
281
|
+
createApp({ …doc, subs: [subscription], actions: { …doc.actions, ...actions } },
|
|
282
|
+
{ subs: { 'db/live': createLiveSubscription(store) } });
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The handler registers the live query, dispatches ONE initializing
|
|
286
|
+
patch (`replace` of the whole `statePath` slot with the initial
|
|
287
|
+
result), then forwards each emission with every op's path prefixed by
|
|
288
|
+
`statePath` — the prefixing happens in the handler, so the action
|
|
289
|
+
document stays the two-line literal above and the app loop needs
|
|
290
|
+
nothing new: `@jarenjs/app` already applies patches copy-on-write and
|
|
291
|
+
derives changed paths, which is the payoff of one diff format end to
|
|
292
|
+
end. Closing is the subscription's cleanup; a handler props change
|
|
293
|
+
restarts it through the app's own key rule.
|
|
294
|
+
|
|
295
|
+
## 11. Cross-tab: the owner topology
|
|
296
|
+
|
|
297
|
+
Decided by a platform fact: OPFS synchronous access handles are
|
|
298
|
+
**exclusive** — a second tab cannot open the same database files at
|
|
299
|
+
all, so "one connection per tab" is not available and never will be.
|
|
300
|
+
Therefore:
|
|
301
|
+
|
|
302
|
+
- ONE owning context holds the sole connection — a `SharedWorker`
|
|
303
|
+
where available, else a leader tab elected via `navigator.locks` —
|
|
304
|
+
and every other tab is a client;
|
|
305
|
+
- queries, writes and the patch stream travel between clients and the
|
|
306
|
+
owner over `BroadcastChannel` / `MessagePort`; a client's live query
|
|
307
|
+
is a remote registration whose emissions arrive as messages;
|
|
308
|
+
- a second context attempting to OPEN the database is refused with
|
|
309
|
+
`JD2061` — a coded refusal, never a mysterious storage failure;
|
|
310
|
+
- conflict handling stays out of scope: there is one writer by
|
|
311
|
+
construction.
|
|
312
|
+
|
|
313
|
+
Node and Bun present the same API with no channel at all — the store
|
|
314
|
+
is its own owner, and application code is identical everywhere. The
|
|
315
|
+
in-browser proof of this topology (delivery across real tabs, the
|
|
316
|
+
refusal, reload survival) belongs to the browser-driver order and its
|
|
317
|
+
Playwright suite; this section is the decided contract it implements.
|
|
318
|
+
|
|
319
|
+
## 12. Lifecycle, bounds, and non-goals
|
|
320
|
+
|
|
321
|
+
A live query holds resources: dependency registrations, its
|
|
322
|
+
maintained state, possibly a sorted window. `close()` releases all of
|
|
323
|
+
them and is MANDATORY; closing the store closes every live query
|
|
324
|
+
first; after close, `subscribe` and re-registration refuse, `result`
|
|
325
|
+
stays readable (the last value), and a leak test asserts the live set
|
|
326
|
+
after a forced GC.
|
|
327
|
+
|
|
328
|
+
Bounds, both configurable at `openStore({ live: { … } })`, both
|
|
329
|
+
ERRORING rather than degrading (the D14 rule — the bound is printed):
|
|
330
|
+
|
|
331
|
+
- `maxQueries` (default 64): registrations beyond it are `JD0052`;
|
|
332
|
+
- `maxMaintained` (default 10 000): the per-query ceiling on
|
|
333
|
+
maintained ENTRIES — result rows, window rows, accumulator and
|
|
334
|
+
per-group contributions all count, because the state is the cost.
|
|
335
|
+
Crossing it mid-maintenance is `JD2060`: the live query delivers the
|
|
336
|
+
error to its subscribers and CLOSES — degraded silence is the one
|
|
337
|
+
outcome this format forbids. An unbounded live query over a growing
|
|
338
|
+
table is the classic memory leak of this category; the accumulator
|
|
339
|
+
strategies trade exactly one contribution entry per matching row for
|
|
340
|
+
delete-correctness, and a count over a table larger than the bound
|
|
341
|
+
is a conscious `maxMaintained` raise, not a silent one.
|
|
342
|
+
|
|
343
|
+
Non-claims, in one place: no incremental joins (re-run is the declared
|
|
344
|
+
strategy), no cross-connection invalidation (§6's `data_version` is
|
|
345
|
+
the signal), no maintenance over asynchronous connections in this
|
|
346
|
+
version (every current driver is synchronous; the browser driver's
|
|
347
|
+
order owns that story), no replication, and no ordering guarantee for
|
|
348
|
+
unordered queries beyond §9's determinism.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# The Jaren migration format (`jaren-migration`)
|
|
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-migration.schema.json`](../schemas/jaren-migration.schema.json)
|
|
7
|
+
(draft 2020-12), with the mechanically derived draft-07 twin beside it.
|
|
8
|
+
|
|
9
|
+
## 1. Scope
|
|
10
|
+
|
|
11
|
+
A store evolves without hand-written SQL: two model documents diff into
|
|
12
|
+
a **migration document** whose ordered steps are rendered DDL, JSLT
|
|
13
|
+
data transforms and query assertions. The migration replays on a
|
|
14
|
+
shadow database before the real store is touched; a history table
|
|
15
|
+
records what ran, in what order, with a checksum; rolling forward is
|
|
16
|
+
deterministic and inspectable, and the generated SQL is always shown
|
|
17
|
+
before it is executed.
|
|
18
|
+
|
|
19
|
+
This is the phase-A payoff for storing documents rather than rows: a
|
|
20
|
+
shape change is a **transformation of values**, not a table rebuild.
|
|
21
|
+
|
|
22
|
+
## 2. The migration document
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"$migration": "0.1",
|
|
27
|
+
"id": "0002-split-name",
|
|
28
|
+
"from": "1m60qna…", "to": "8kf22bd…",
|
|
29
|
+
"steps": [
|
|
30
|
+
{ "kind": "ddl", "sql": "DROP INDEX \"users_by_first\"" },
|
|
31
|
+
{ "kind": "jslt", "collection": "users", "stylesheet": [ { "match": "$", "body": { } } ] },
|
|
32
|
+
{ "kind": "query", "collection": "users",
|
|
33
|
+
"assert": { "$for": { "it": "$[*]" }, "$where": { "$empty": "$it.name" }, "$return": "$it.id" } }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `from`/`to` are `hashContent(canonicalizeJson(model))` — the
|
|
39
|
+
identity of a **shape**, not a version number a human must remember
|
|
40
|
+
to bump. A migration whose `from` does not match the database's
|
|
41
|
+
recorded shape MUST refuse to run (`JD0020`).
|
|
42
|
+
- `kind: "ddl"` executes one rendered statement. The planner produces
|
|
43
|
+
these through the dialect; they are ordinary text in the document so
|
|
44
|
+
a reviewer reads exactly what will run.
|
|
45
|
+
- `kind: "jslt"` rewrites every document of a collection through a
|
|
46
|
+
compiled JSLT stylesheet, in batches, inside the migration's
|
|
47
|
+
transaction. The empty stylesheet (`[]`) is the identity transform.
|
|
48
|
+
A step carrying `"draft": true` is a planner placeholder and MUST
|
|
49
|
+
refuse to run (`JD0021`) until the author fills it in.
|
|
50
|
+
- `kind: "query"` is an assertion: the query runs over the
|
|
51
|
+
collection's documents and MUST answer an empty sequence (`expect:
|
|
52
|
+
"empty"`, the default) or an EBV-true value (`expect: "ebv"`) for
|
|
53
|
+
the migration to proceed. This is how a migration states its own
|
|
54
|
+
precondition — "no user has a null email before the NOT NULL
|
|
55
|
+
index" — and it is checked on the shadow first.
|
|
56
|
+
- Steps are ordered, and the order is the contract.
|
|
57
|
+
|
|
58
|
+
## 3. Planning and the widening/narrowing rule
|
|
59
|
+
|
|
60
|
+
`planMigration(fromModel, toModel, { dialect, id })` produces
|
|
61
|
+
`{ migration, report }` by diffing the two models' PHYSICAL plans:
|
|
62
|
+
|
|
63
|
+
- An added collection becomes its full CREATE DDL; a removed
|
|
64
|
+
collection becomes a `DROP TABLE` step whose note says
|
|
65
|
+
**DESTRUCTIVE** plainly, and `report.destructive` is `true` — the
|
|
66
|
+
planner never guesses at data loss.
|
|
67
|
+
- A rename cannot be inferred from a diff. It is DECLARED with
|
|
68
|
+
`"x-rename": "oldName"` on the target collection; the planner emits
|
|
69
|
+
the rename first and rebuilds the indexes (a renamed SQLite table
|
|
70
|
+
keeps its old index names — probed). Without the hint, a rename is
|
|
71
|
+
a drop plus a create and the report says so.
|
|
72
|
+
- Added, removed and changed indexes become index DDL — reusing the
|
|
73
|
+
store's own DDL generator, never a second implementation. A changed
|
|
74
|
+
generated column (type or path) is a drop plus an add, with its
|
|
75
|
+
dependent indexes dropped first and recreated after.
|
|
76
|
+
- A changed schema gets a `jslt` step with the identity stylesheet and
|
|
77
|
+
`"draft": true`. The planner **cannot** infer a data transform and
|
|
78
|
+
MUST NOT pretend to — a silent identity transform is how data gets
|
|
79
|
+
quietly lost. The author fills in the stylesheet, or deletes the
|
|
80
|
+
step when the change is a pure widening.
|
|
81
|
+
- **The widening/narrowing rule runs against real data, not schema
|
|
82
|
+
comparison**: at the end of the migration run (inside its
|
|
83
|
+
transaction) every stored document is validated against the target
|
|
84
|
+
schema through the injected `compileSchema` hook. A document that no
|
|
85
|
+
longer validates is `JD0021` and the whole migration rolls back — a
|
|
86
|
+
narrowing without an adequate transform cannot land. A widening
|
|
87
|
+
needs no transform, and passes this check by fact.
|
|
88
|
+
- Changing a collection's key declaration is not planned (a rebuild);
|
|
89
|
+
the planner refuses with a `TypeError` naming the non-goal.
|
|
90
|
+
|
|
91
|
+
## 4. The shadow database
|
|
92
|
+
|
|
93
|
+
Before the real store is touched, the WHOLE chain — the baseline
|
|
94
|
+
shape, every applied migration, every pending migration — replays on a
|
|
95
|
+
shadow database (`:memory:` by default; free with SQLite, no server).
|
|
96
|
+
The shadow proves **structure**: every DDL statement runs, every
|
|
97
|
+
stylesheet and assertion compiles and executes, and the end shape is
|
|
98
|
+
verified against the target model. A failure there leaves the real
|
|
99
|
+
store untouched.
|
|
100
|
+
|
|
101
|
+
The shadow runs over an empty data set; the real-data facts (the
|
|
102
|
+
widening check, key consistency, the assertions over real rows) run on
|
|
103
|
+
the real store inside its transaction. The model format declares no
|
|
104
|
+
UDF-expression indexes, so there is no function set to re-register on
|
|
105
|
+
the shadow — stated here because a dialect that allowed such indexes
|
|
106
|
+
would make the shadow fail on a schema the real store accepts.
|
|
107
|
+
|
|
108
|
+
## 5. History and checksums
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
_jaren_migrations(id TEXT PRIMARY KEY, applied_at INTEGER,
|
|
112
|
+
from_hash TEXT, to_hash TEXT, checksum TEXT, steps INTEGER)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`checksum` is `hashContent(canonicalizeJson(migration))` —
|
|
116
|
+
signature-grade (D12), never the memo-grade `contentKey`. On every
|
|
117
|
+
run, the supplied migration list MUST contain every applied migration,
|
|
118
|
+
in order, with matching checksums; a migration whose recorded checksum
|
|
119
|
+
differs from the document on disk is `JD0022` — someone edited an
|
|
120
|
+
applied migration, which is always a bug and always worth failing on.
|
|
121
|
+
The database's current shape is the last applied `to_hash`, or the
|
|
122
|
+
hash of the `baseline` model when no migration has run.
|
|
123
|
+
|
|
124
|
+
## 6. Running and batching
|
|
125
|
+
|
|
126
|
+
`migrate({ driver, path }, migrations, options)`:
|
|
127
|
+
|
|
128
|
+
- `options.baseline` (REQUIRED) — the model the store was FIRST
|
|
129
|
+
created with: the chain's anchor and the shadow's starting shape.
|
|
130
|
+
- `options.model` (RECOMMENDED) — the target model. When present, the
|
|
131
|
+
last pending migration's `to` MUST equal its shape hash (`JD0020`
|
|
132
|
+
otherwise), the physical end shape is verified, and the real-data
|
|
133
|
+
validation of §3 runs.
|
|
134
|
+
- `dryRun: true` prints every statement and the affected document
|
|
135
|
+
counts, validates the chain on the shadow, and writes NOTHING. The
|
|
136
|
+
API default is to run; a CLI SHOULD default to the dry run.
|
|
137
|
+
- Each pending migration runs in ONE exclusive transaction
|
|
138
|
+
(`BEGIN IMMEDIATE` on SQLite — concurrent writers wait or time out
|
|
139
|
+
under the busy timeout) with a savepoint per step; any failure rolls
|
|
140
|
+
back the whole migration including its earlier steps. Where a driver
|
|
141
|
+
cannot open exclusively, the transaction still isolates; the busy
|
|
142
|
+
policy of MODEL-FORMAT §4 governs contention.
|
|
143
|
+
- JSLT steps walk the collection in bounded batches
|
|
144
|
+
(`options.batchSize`, default 500) ordered by row identity, report
|
|
145
|
+
progress through `options.onProgress`, and never hold the whole
|
|
146
|
+
collection in memory. Assertion steps read the whole collection into
|
|
147
|
+
one array — a documented cost; keep assertions early, before the
|
|
148
|
+
data grows.
|
|
149
|
+
- A transform MUST NOT change a caller-keyed document's key member —
|
|
150
|
+
the key column would go stale; the run refuses (`JD0023`).
|
|
151
|
+
|
|
152
|
+
## 7. Non-goals
|
|
153
|
+
|
|
154
|
+
- **Down migrations are not shipped in 0.1.** A JSLT transform is not
|
|
155
|
+
generally invertible, and a reverse step that silently loses data is
|
|
156
|
+
worse than a restore from backup. The recommended path: branch the
|
|
157
|
+
shape (a new collection or a new store), migrate forward, drop the
|
|
158
|
+
old collection once verified. `down` is not planned for this format
|
|
159
|
+
version; if it ever arrives it will be an explicit author-written
|
|
160
|
+
document, never an inferred inverse.
|
|
161
|
+
- **Inferred renames.** A diff cannot distinguish a rename from a drop
|
|
162
|
+
plus a create; guessing risks silent data loss. Renames are declared
|
|
163
|
+
with `x-rename`, or they are what they look like.
|
|
164
|
+
- **Key-declaration changes** (see §3) — a rebuild, not a migration
|
|
165
|
+
step.
|
|
166
|
+
|
|
167
|
+
## 8. Error codes
|
|
168
|
+
|
|
169
|
+
| code | raised when |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `JD0020` | the migration's from-shape does not match the database |
|
|
172
|
+
| `JD0021` | the migration is missing a required data transform |
|
|
173
|
+
| `JD0022` | an applied migration disagrees with the history record |
|
|
174
|
+
| `JD0023` | a migration step failed |
|
|
175
|
+
|
|
176
|
+
These live in the same runtime `DB_CODES` table as the storage codes
|
|
177
|
+
(MODEL-FORMAT §7); the union of both documents is proven in sync with
|
|
178
|
+
the runtime table by a test.
|
|
179
|
+
|
|
180
|
+
## 9. Relational changes (entities)
|
|
181
|
+
|
|
182
|
+
`planModelMigration(fromModel, toModel, { dialect })` extends the §3
|
|
183
|
+
planner to models with `entities`. The strategy table is the design;
|
|
184
|
+
every row has a shadow-verified test that migrates seeded data:
|
|
185
|
+
|
|
186
|
+
| Change | Strategy |
|
|
187
|
+
|---|---|
|
|
188
|
+
| add mapped column (property added, or moved out of the document) | `ALTER TABLE ADD COLUMN` — always nullable (absent reads back absent, MODEL-FORMAT §9.3) — plus a `sql` data step when the property's values already live in the document |
|
|
189
|
+
| drop mapped column (property removed, or moved into the document) | fold the column back into the document first (`sql` step) when the property survives; drop its index, then `DROP COLUMN` where SQLite's conditions hold, else rebuild |
|
|
190
|
+
| change type / enum CHECK / key / epoch flavor | **rebuild** (§10) |
|
|
191
|
+
| add or drop an index (`unique`/`index`/version) | plain DDL |
|
|
192
|
+
| add or drop a relation (foreign-key column, join table) | foreign keys **rebuild** the holder; join tables create/drop directly |
|
|
193
|
+
| entity added / dropped | create / `DROP TABLE` (destructive, named) |
|
|
194
|
+
| entity renamed | declared with `x-rename` on the target entity — never inferred; join tables renamed mechanically with their endpoints |
|
|
195
|
+
| scalar ⇄ JSONB move (`column: "json"` toggled, shape change) | rebuild + a data step |
|
|
196
|
+
|
|
197
|
+
Two rules keep the diff honest:
|
|
198
|
+
|
|
199
|
+
- **Document changes are compared with `x-entity` stripped.** A pure
|
|
200
|
+
mapping change (an index added, a column toggle) is NOT a document
|
|
201
|
+
schema change and demands no transform; a real document change
|
|
202
|
+
yields the §3 draft-`jslt` step over the entity's table.
|
|
203
|
+
- **Epoch columns populate in SQL** via
|
|
204
|
+
`(julianday(value) − 2440587.5) × 86 400 000`, rounded to the
|
|
205
|
+
millisecond — fractional seconds beyond that are the write
|
|
206
|
+
contract's business (MODEL-FORMAT §10.3), not the migration's.
|
|
207
|
+
|
|
208
|
+
### 9.4 The `sql` step
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{ "kind": "sql", "sql": "UPDATE …", "note": "why" }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
A data step spelled directly — for row-shuffling that is more honest
|
|
215
|
+
as SQL than as a stylesheet. Runs like `ddl` (inside the migration's
|
|
216
|
+
transaction, its own savepoint), but a dry run ALWAYS prints it with
|
|
217
|
+
its note, and reviewers read intent from the kind.
|
|
218
|
+
|
|
219
|
+
## 10. The rebuild procedure
|
|
220
|
+
|
|
221
|
+
SQLite's `ALTER TABLE` cannot drop a constraint, change a type or
|
|
222
|
+
reorder columns; the documented procedure for "making other kinds of
|
|
223
|
+
table schema changes" (sqlite.org/lang_altertable.html §7) is followed
|
|
224
|
+
literally, as one implementation used by every rebuilding strategy:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{ "kind": "rebuild", "table": "User",
|
|
228
|
+
"create": ["CREATE TABLE \"User__rebuild\" (…)"],
|
|
229
|
+
"copy": "INSERT INTO \"User__rebuild\" (…) SELECT … FROM \"User\"",
|
|
230
|
+
"indexes": ["CREATE INDEX …"], "note": "…" }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The step is SELF-CONTAINED rendered SQL — reviewable in the migration
|
|
234
|
+
document, mechanical to run: create the new shape under the temporary
|
|
235
|
+
name, copy (the planner renders the column mapping: surviving columns
|
|
236
|
+
verbatim, new ones from the document, dropped ones already folded),
|
|
237
|
+
`DROP` the old table, `RENAME` the new one into place, recreate every
|
|
238
|
+
index from the target model, then **`PRAGMA foreign_key_check` inside
|
|
239
|
+
the transaction** — a broken reference fails the migration rather
|
|
240
|
+
than shipping.
|
|
241
|
+
|
|
242
|
+
Two deviations from the cited twelve steps, recorded: (1) the
|
|
243
|
+
procedure brackets itself with `PRAGMA foreign_keys=OFF/ON`, which is
|
|
244
|
+
a no-op inside a transaction — the migration connection never enables
|
|
245
|
+
the pragma (SQLite's default is off; `openStore` enables AND verifies
|
|
246
|
+
it per connection), so enforcement during the rebuild is off exactly
|
|
247
|
+
as the procedure wants, and `foreign_key_check` provides the
|
|
248
|
+
guarantee; (2) triggers and views are not re-created because this
|
|
249
|
+
store creates none — a hand-added trigger is outside the model and
|
|
250
|
+
outside the diff, which drift (§12) will name.
|
|
251
|
+
|
|
252
|
+
**Shape equality is the acceptance criterion.** After a rebuild —
|
|
253
|
+
after ANY relational migration — the database's declared schema
|
|
254
|
+
(`schemaShapeOf`) must equal what a fresh `createModelShape(toModel)`
|
|
255
|
+
produces, indexes, foreign keys and constraints included. The shadow
|
|
256
|
+
asserts it before the real database is touched, and the real run
|
|
257
|
+
asserts it again after the last migration.
|
|
258
|
+
|
|
259
|
+
**UDF-expression indexes.** An index over a registered deterministic
|
|
260
|
+
function is invisible to any connection that has not registered the
|
|
261
|
+
function (probed): `migrate(…, { registerFunctions })` re-registers
|
|
262
|
+
every declared function on the real, shadow AND reference connections
|
|
263
|
+
before any DDL runs — without it, a rebuild would fail (or silently
|
|
264
|
+
drop the index) on a schema the store accepts.
|
|
265
|
+
|
|
266
|
+
## 11. The CLI
|
|
267
|
+
|
|
268
|
+
`jaren-db` drives the workflow (mirroring `jaren-emit`):
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
jaren-db plan --from <model> --to <model> [--store <db>] [--id x] [--out file]
|
|
272
|
+
jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>]
|
|
273
|
+
jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
|
|
274
|
+
jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>]
|
|
275
|
+
jaren-db shape --model <model>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- `plan` diffs two model FILES (a database stores shape hashes, not
|
|
279
|
+
models — the from-model is the previous model file); with `--store`
|
|
280
|
+
it first verifies the from-model's hash matches the database's
|
|
281
|
+
recorded shape.
|
|
282
|
+
- `check` is the CI command: exit 1 when migrations are pending OR the
|
|
283
|
+
database drifted; 0 in sync.
|
|
284
|
+
- `apply` prints every statement before running; destructive steps
|
|
285
|
+
(drop table/column, rebuild) require `--yes` or an interactive
|
|
286
|
+
confirmation that NAMES what is lost. Default is dry-run + ask.
|
|
287
|
+
- `status` lists applied/pending and reports drift (§12).
|
|
288
|
+
- `shape` prints the physical mapping a model produces.
|
|
289
|
+
|
|
290
|
+
## 12. Drift
|
|
291
|
+
|
|
292
|
+
Drift is the database not matching what its history says it should
|
|
293
|
+
be: someone changed it by hand. `status`/`check` detect it by
|
|
294
|
+
verifying the current model's physical shape against the actual
|
|
295
|
+
database (`schemaShapeOf` against `createModelShape`, when the chain
|
|
296
|
+
is fully applied) — a hand-added index, a dropped column or a foreign
|
|
297
|
+
key edited outside a migration is named early, which is the
|
|
298
|
+
difference between a puzzled afternoon and a five-minute fix.
|
|
299
|
+
|
|
300
|
+
Down migrations REMAIN a non-goal (§7's reasoning is unchanged): a
|
|
301
|
+
down migration is a data-loss generator wearing a seatbelt; recovery
|
|
302
|
+
is a backup restored plus the forward chain.
|