@jarenjs/db 0.56.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
@@ -0,0 +1,422 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The document-step kernel: what a migration's `jslt` transform
4
+ * and `query` assertion steps MEAN, written once for every host that
5
+ * owns documents — the Store's tables and an in-memory or streamed
6
+ * collection alike.
7
+ *
8
+ * A host supplies the compilers and the key members a row carries; the
9
+ * kernel owns the semantics: what a transform may produce, which key
10
+ * members it may not move, whether an assertion answers per document
11
+ * or needs the collection whole, and the exact refusal each violation
12
+ * raises. Iteration stays the host's — a Store walks its table in
13
+ * keyset batches under a savepoint per step, a storeless run makes one
14
+ * pass over a source it can read only once. Sharing the meaning and
15
+ * not the loop is what lets the two hosts differ in cost without ever
16
+ * differing in answer.
17
+ */
18
+
19
+ import { DbCompileError, DbRuntimeError } from './errors.js';
20
+ import { utf8Length } from './cursor.js';
21
+
22
+ /** The migration format version. */
23
+ export const MIGRATION_VERSION = '0.1';
24
+
25
+ /**
26
+ * The step kinds that act on DOCUMENTS, and so run on any host.
27
+ * @type {ReadonlySet<string>}
28
+ */
29
+ export const DOCUMENT_STEP_KINDS = new Set(['jslt', 'query']);
30
+
31
+ /**
32
+ * The step kinds that act on a PHYSICAL database — rendered DDL, a
33
+ * data statement spelled as SQL, the table-rebuild procedure, and the
34
+ * backfill that recomputes stored derived COLUMNS. A host without
35
+ * tables cannot honour any of them, and silently skipping one would
36
+ * leave a migration half-applied, so it refuses instead.
37
+ * @type {ReadonlySet<string>}
38
+ */
39
+ export const PHYSICAL_STEP_KINDS = new Set(['ddl', 'sql', 'rebuild', 'derive']);
40
+
41
+ /**
42
+ * The refusal a failing step raises, spelled the one way — a reader
43
+ * who has seen it from a Store recognises it from a file.
44
+ * @param {string} migrationId
45
+ * @param {number} index
46
+ * @param {string} kind
47
+ * @param {string} reason
48
+ * @param {Error} [cause]
49
+ * @returns {DbCompileError}
50
+ */
51
+ export function stepFailure(migrationId, index, kind, reason, cause) {
52
+ return new DbCompileError('JD0023',
53
+ `migration '${migrationId}' step ${index} (${kind}) failed: ${reason}`,
54
+ undefined, cause);
55
+ }
56
+
57
+ /**
58
+ * Whether an assertion is a PER-DOCUMENT predicate — a FLWOR over the
59
+ * collection's documents whose `$where` and `$return` read only the
60
+ * binding — so evaluating it over each batch of documents answers
61
+ * exactly what evaluating it over the whole collection would. Anything
62
+ * that reads the root (`$count: '$[*]'`, a `$let`, a `$distinct`, a
63
+ * nested `$for`) is cross-document and keeps its whole-collection read.
64
+ * @param {any} query
65
+ * @returns {boolean}
66
+ */
67
+ export function isPerDocumentAssertion(query) {
68
+ if (query === null || typeof query !== 'object' || Array.isArray(query)) return false;
69
+ const keys = Object.keys(query);
70
+ if (!keys.includes('$for') || !keys.includes('$return')
71
+ || keys.some((key) => !['$for', '$where', '$return'].includes(key))) return false;
72
+ const bindings = query.$for;
73
+ if (bindings === null || typeof bindings !== 'object' || Array.isArray(bindings)) return false;
74
+ const names = Object.keys(bindings);
75
+ if (names.length !== 1 || bindings[names[0]] !== '$[*]') return false;
76
+ // a root reference anywhere in the body is a cross-document read
77
+ const body = JSON.stringify({ $where: query.$where ?? null, $return: query.$return });
78
+ return !/"\$(?:[[.]|")/.test(body.replace(/"\$[A-Za-z_][A-Za-z0-9_]*/g, '"'));
79
+ }
80
+
81
+ /**
82
+ * The aggregate shapes whose answer over a whole collection is the
83
+ * COMBINATION of its answers over any partition of that collection —
84
+ * which is what lets a host compute them one batch at a time and never
85
+ * hold the collection.
86
+ *
87
+ * Each entry says only how two partial answers combine. What the
88
+ * operator MEANS over a batch — its null handling, its empty-sequence
89
+ * answer, its type coercions — is the engine's, because every batch is
90
+ * answered by the engine's own compiled query. Nothing here reimplements
91
+ * an operator; a fold that disagreed with the engine on any input would
92
+ * be caught by the parity suite, which runs every shape both ways.
93
+ *
94
+ * `$distinct` is here only under an explicit cardinality bound: its
95
+ * partial answer grows with the data, so without one it is not a fold
96
+ * at all.
97
+ * @type {Record<string, { start: any, combine: (accumulated: any, partial: any) => any,
98
+ * growing?: boolean }>}
99
+ */
100
+ const ASSOCIATIVE_AGGREGATES = {
101
+ // fn:count of a partition sums; the empty collection answers 0
102
+ $count: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
103
+ // fn:sum of the empty sequence is 0, so the identity is 0
104
+ $sum: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
105
+ // fn:min/fn:max of the empty sequence is the empty sequence, so the
106
+ // identity is `undefined` and a partial that is empty contributes
107
+ // nothing
108
+ $max: {
109
+ start: undefined,
110
+ combine: (accumulated, partial) => (partial === undefined ? accumulated
111
+ : (accumulated === undefined || partial > accumulated ? partial : accumulated)),
112
+ },
113
+ $min: {
114
+ start: undefined,
115
+ combine: (accumulated, partial) => (partial === undefined ? accumulated
116
+ : (accumulated === undefined || partial < accumulated ? partial : accumulated)),
117
+ },
118
+ };
119
+
120
+ /** An assertion that is exactly one aggregate over the root. */
121
+ function aggregateShapeOf(query) {
122
+ if (query === null || typeof query !== 'object' || Array.isArray(query)) return null;
123
+ const keys = Object.keys(query);
124
+ if (keys.length !== 1) return null;
125
+ const operator = keys[0];
126
+ return Object.hasOwn(ASSOCIATIVE_AGGREGATES, operator) ? operator : null;
127
+ }
128
+
129
+ /**
130
+ * How a host must run an assertion, and why.
131
+ *
132
+ * - `perDocument` — its answer over each batch is its answer over the
133
+ * whole, so it walks in batches and fails at the first that violates.
134
+ * - `fold` — it is one associative aggregate over the root, so each
135
+ * batch is answered by the engine and the partial answers combine.
136
+ * - `materialize` — nothing above holds: it needs every document at
137
+ * once, which is a cost the host must be told about and bound.
138
+ *
139
+ * The one classifier every host shares, so the Store and a file cannot
140
+ * disagree about what an assertion costs.
141
+ * @param {any} query
142
+ * @returns {{ strategy: 'perDocument' | 'fold' | 'materialize', shape: string | null, reason: string }}
143
+ */
144
+ export function classifyAssertion(query) {
145
+ if (isPerDocumentAssertion(query)) {
146
+ return {
147
+ strategy: 'perDocument',
148
+ shape: null,
149
+ reason: 'a predicate over each document, whose answer per batch is its answer over the whole',
150
+ };
151
+ }
152
+ const aggregate = aggregateShapeOf(query);
153
+ if (aggregate !== null) {
154
+ return {
155
+ strategy: 'fold',
156
+ shape: aggregate,
157
+ reason: `${aggregate} over the root is associative, so batch answers combine`,
158
+ };
159
+ }
160
+ return {
161
+ strategy: 'materialize',
162
+ shape: null,
163
+ reason: 'the assertion reads the whole root in a way that does not decompose '
164
+ + 'into independent batches, so every document must be held at once',
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Compile one document step into the operation both hosts run.
170
+ *
171
+ * A `jslt` step answers `{ apply }`: one document in, its replacement
172
+ * out, refusing a non-document and any move of a declared key member.
173
+ * A `query` step answers `{ assert, perDocument }`: `assert` takes the
174
+ * documents the host has in hand — every document of the collection
175
+ * when `perDocument` is false, any batch of them when it is true.
176
+ *
177
+ * @param {any} step - the migration step
178
+ * @param {number} index - its position, which its refusals name
179
+ * @param {{
180
+ * migrationId: string,
181
+ * compileJslt: (stylesheet: any) => (document: any) => any,
182
+ * compileQuery: (query: any) => any,
183
+ * keys?: string[],
184
+ * }} context - the host's compilers and the key members a document of
185
+ * this collection carries, which a transform may not move
186
+ * @returns {any} the compiled operation
187
+ */
188
+ export function compileDocumentStep(step, index, context) {
189
+ const { migrationId, compileJslt, compileQuery } = context;
190
+ const keys = context.keys ?? [];
191
+ /** @type {(reason: string, cause?: Error) => never} */
192
+ const fail = (reason, cause) => {
193
+ throw stepFailure(migrationId, index, step.kind, reason, cause);
194
+ };
195
+
196
+ if (step.kind === 'jslt') {
197
+ let transform;
198
+ try {
199
+ transform = compileJslt(step.stylesheet);
200
+ }
201
+ catch (cause) {
202
+ fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
203
+ /** @type {Error} */ (cause));
204
+ }
205
+ return {
206
+ kind: 'jslt',
207
+ collection: step.collection,
208
+ // a transform has no assertion strategy and nothing to fold; both
209
+ // are spelled so every host can read an operation the same way
210
+ strategy: null,
211
+ shape: null,
212
+ fold: null,
213
+ fail,
214
+ /**
215
+ * @param {any} document - read whole, mapped columns folded in
216
+ * @param {any} identity - the host's name for this row, which a
217
+ * refusal quotes so the operator can find it
218
+ * @returns {any} the replacement document
219
+ */
220
+ apply: (document, identity) => {
221
+ const next = transform(document);
222
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
223
+ fail(`the transform produced a non-document for row ${identity}`);
224
+ // the key is the row's identity, not the document's to change:
225
+ // a body that leaves it out keeps it, a body that rewrites it
226
+ // is refused, as a collection's key is
227
+ for (const key of keys) {
228
+ if (next[key] === undefined) next[key] = document[key];
229
+ else if (next[key] !== document[key]) {
230
+ fail(`the transform changed the key member '${key}' of row ${identity} — `
231
+ + `key changes are not supported in ${MIGRATION_VERSION}`);
232
+ }
233
+ }
234
+ return next;
235
+ },
236
+ };
237
+ }
238
+
239
+ let compiled;
240
+ try {
241
+ compiled = compileQuery(step.assert);
242
+ }
243
+ catch (cause) {
244
+ fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
245
+ /** @type {Error} */ (cause));
246
+ }
247
+ const classification = classifyAssertion(step.assert);
248
+ // the engine's own effective-boolean-value, reached through a query
249
+ // that answers its input unchanged: a fold checks the value it
250
+ // computed with exactly the rule the whole-collection path uses
251
+ const identity = compileQuery('$');
252
+ // and the engine's verdict on the EMPTY SEQUENCE, which `$max`/`$min`
253
+ // answer over a collection that offered them nothing. It cannot be
254
+ // asked of `identity` — a query's input may not be `undefined` — so it
255
+ // is evaluated once, by the engine, over a wildcard that selects
256
+ // nothing. The fold never decides this itself.
257
+ const emptySequenceEbv = compileQuery('$[*]').ebv([]);
258
+
259
+ /** The verdict on a computed result, whichever way it was computed. */
260
+ const check = (result, ebvOf) => {
261
+ if (step.expect === 'ebv') {
262
+ if (!ebvOf()) fail('the EBV assertion answered false');
263
+ return null;
264
+ }
265
+ if (result !== undefined) {
266
+ const count = Array.isArray(result) ? result.length : 1;
267
+ fail(`the assertion expected an empty sequence, got ${count} item(s)`);
268
+ }
269
+ return null;
270
+ };
271
+
272
+ return {
273
+ kind: 'query',
274
+ collection: step.collection,
275
+ perDocument: classification.strategy === 'perDocument',
276
+ strategy: classification.strategy,
277
+ shape: classification.shape,
278
+ reason: classification.reason,
279
+ fail,
280
+ /**
281
+ * The whole-collection answer: every document at once, which is what
282
+ * `perDocument` batches and `materialize` holds.
283
+ * @param {any[]} documents
284
+ * @returns {null}
285
+ */
286
+ assert: (documents) => check(compiled(documents), () => compiled.ebv(documents)),
287
+ /**
288
+ * The same answer, one batch at a time. `start` is the aggregate's
289
+ * identity, `combine` folds the engine's answer for a batch into
290
+ * what earlier batches answered, and `finish` applies the step's own
291
+ * verdict to the total — the same verdict `assert` applies.
292
+ * Null when this assertion does not fold.
293
+ */
294
+ fold: classification.strategy !== 'fold' ? null : {
295
+ start: () => ASSOCIATIVE_AGGREGATES[/** @type {string} */ (classification.shape)].start,
296
+ combine: (accumulated, documents) => ASSOCIATIVE_AGGREGATES[
297
+ /** @type {string} */ (classification.shape)].combine(accumulated, compiled(documents)),
298
+ finish: (accumulated) => check(accumulated,
299
+ () => (accumulated === undefined ? emptySequenceEbv : identity.ebv(accumulated))),
300
+ },
301
+ };
302
+ }
303
+
304
+ const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
305
+
306
+ /**
307
+ * Structural validation of one migration document, including the
308
+ * draft refusal (`JD0021`).
309
+ * @param {any} migration
310
+ */
311
+ export function checkMigrationDocument(migration) {
312
+ if (migration === null || typeof migration !== 'object'
313
+ || migration.$migration !== MIGRATION_VERSION
314
+ || typeof migration.id !== 'string' || migration.id === ''
315
+ || typeof migration.from !== 'string' || typeof migration.to !== 'string'
316
+ || !Array.isArray(migration.steps)) {
317
+ throw new DbCompileError('JD0023',
318
+ `migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
319
+ }
320
+ for (let i = 0; i < migration.steps.length; i++) {
321
+ const step = migration.steps[i];
322
+ if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
323
+ throw new DbCompileError('JD0023',
324
+ `migration '${migration.id}' step ${i} has no recognised kind`);
325
+ }
326
+ if (step.kind === 'rebuild'
327
+ && (typeof step.table !== 'string' || !Array.isArray(step.create)
328
+ || typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
329
+ throw new DbCompileError('JD0023',
330
+ `migration '${migration.id}' step ${i} is a rebuild without its rendered `
331
+ + 'table/create/copy/indexes');
332
+ }
333
+ if (step.kind === 'sql' && typeof step.sql !== 'string') {
334
+ throw new DbCompileError('JD0023',
335
+ `migration '${migration.id}' step ${i} is a sql step without sql text`);
336
+ }
337
+ if (step.kind === 'derive'
338
+ && (typeof step.collection !== 'string' || !Array.isArray(step.columns)
339
+ || step.columns.length === 0)) {
340
+ throw new DbCompileError('JD0023',
341
+ `migration '${migration.id}' step ${i} is a derive backfill without its columns`);
342
+ }
343
+ if (step.kind === 'jslt' && step.draft === true) {
344
+ throw new DbCompileError('JD0021',
345
+ `migration '${migration.id}' step ${i} is a DRAFT transform for collection `
346
+ + `'${step.collection}' — the planner cannot infer a data transform; fill in `
347
+ + 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * What a MATERIALIZING assertion — one that is neither a per-document
354
+ * predicate nor a single associative aggregate — is allowed to hold.
355
+ *
356
+ * The defaults are deliberately generous and deliberately finite: a
357
+ * migration that used to read a large collection whole now refuses
358
+ * instead, naming the bound and the two assertion shapes that are
359
+ * answered in batches. An unbounded read that nobody declared is the
360
+ * thing this campaign exists to remove, so `null` must be asked for.
361
+ */
362
+ export const ASSERTION_BOUNDS_DEFAULT = Object.freeze({
363
+ maxRows: 100_000,
364
+ maxBytes: 64 * 1024 * 1024,
365
+ });
366
+
367
+ /** The declared bounds, or the defaults; `null` means no bound. */
368
+ export function normalizeAssertionBounds(declared) {
369
+ if (declared === undefined) return ASSERTION_BOUNDS_DEFAULT;
370
+ if (declared === null || typeof declared !== 'object')
371
+ throw new TypeError('assertionBounds must be an object of { maxRows, maxBytes }');
372
+ const read = (name) => {
373
+ const value = declared[name];
374
+ if (value === undefined) return ASSERTION_BOUNDS_DEFAULT[name];
375
+ if (value === null || value === Infinity) return null;
376
+ if (!(Number.isSafeInteger(value) && value >= 1))
377
+ throw new TypeError(`assertionBounds.${name} must be a positive integer, or null for no bound`);
378
+ return value;
379
+ };
380
+ return Object.freeze({ maxRows: read('maxRows'), maxBytes: read('maxBytes') });
381
+ }
382
+
383
+ /**
384
+ * The guard a MATERIALIZING assertion admits its documents through.
385
+ *
386
+ * One rule, one wording, for every host: a Store admits row by row as it
387
+ * walks, an array host admits the array it was handed. Either way the
388
+ * bound is crossed at the document that would break it, and never after
389
+ * the excess is already held.
390
+ *
391
+ * @param {{ maxRows: number | null, maxBytes: number | null }} bounds
392
+ * @param {string} collection - named by the refusal
393
+ * @param {string} where - what is asserting, as the refusal should say it
394
+ * @returns {{ admit: (document: any, serialized?: string) => void }}
395
+ */
396
+ export function createAssertionBoundGuard(bounds, collection, where) {
397
+ let rows = 0;
398
+ let bytes = 0;
399
+ const advice = 'Raise the bound, or write the assertion as a per-document predicate or a '
400
+ + 'single aggregate over the root, which are answered one batch at a time';
401
+ return {
402
+ admit: (document, serialized) => {
403
+ if (bounds.maxRows !== null && rows + 1 > bounds.maxRows) {
404
+ throw new DbRuntimeError('JD2007',
405
+ `${where} must hold every document of '${collection}' at once, and the collection `
406
+ + `crossed its maxRows bound of ${bounds.maxRows}. ${advice}`,
407
+ { collection });
408
+ }
409
+ rows++;
410
+ if (bounds.maxBytes === null) return;
411
+ const size = utf8Length(serialized ?? JSON.stringify(document));
412
+ if (bytes + size > bounds.maxBytes) {
413
+ throw new DbRuntimeError('JD2076',
414
+ `${where} must hold every document of '${collection}' at once; the document that `
415
+ + `would make ${bytes + size} serialised bytes crosses its maxBytes bound of `
416
+ + `${bounds.maxBytes}. ${advice}`,
417
+ { collection });
418
+ }
419
+ bytes += size;
420
+ },
421
+ };
422
+ }