@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,335 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Migrations without a database: the same `jslt` transforms and
4
+ * `query` assertions a Store applies to its tables, applied to
5
+ * documents a caller already holds or can stream past once.
6
+ *
7
+ * Two surfaces, because they cost different things and a caller should
8
+ * have to say which it is buying. {@link migrateDocuments} takes arrays
9
+ * and answers arrays: the source is REWINDABLE, so it runs the steps
10
+ * exactly as the Store does — every step over every document, in step
11
+ * order — and therefore refuses on exactly the step the Store would
12
+ * refuse on. {@link streamDocuments} takes anything iterable once and
13
+ * writes each document out as it finishes: bounded, and honest that a
14
+ * single pass cannot look ahead (see its own note).
15
+ *
16
+ * A physical step — rendered DDL, a data statement spelled as SQL, the
17
+ * table-rebuild procedure, the stored-derived-column backfill — has no
18
+ * meaning without tables. Neither surface skips one: both refuse, by
19
+ * name, before they ask the source for its first document.
20
+ */
21
+
22
+ import { resolveRuntime } from '@jarenjs/core/runtime';
23
+ import { setObjectMember } from '@jarenjs/core/object';
24
+ import { compileJsonQuery } from '@jarenjs/json/query';
25
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
26
+
27
+ import { DbCompileError } from './errors.js';
28
+ import { refuseCancelled } from './cancellation.js';
29
+ import {
30
+ compileDocumentStep, checkMigrationDocument, PHYSICAL_STEP_KINDS, DOCUMENT_STEP_KINDS,
31
+ normalizeAssertionBounds, createAssertionBoundGuard,
32
+ } from './document-steps.js';
33
+
34
+ /**
35
+ * Compile every migration's document steps and refuse, before any
36
+ * document is read, anything this host cannot honour.
37
+ * @param {any[]} migrations
38
+ * @param {Set<string>} present - the collections the caller supplied
39
+ * @param {{ compileJslt: Function, compileQuery: Function, keys: Record<string, string[]> }} context
40
+ * @returns {{ id: string, to: string, from: string, operations: any[] }[]}
41
+ */
42
+ function planStorelessRun(migrations, present, context) {
43
+ if (!Array.isArray(migrations))
44
+ throw new TypeError('a document migration needs the ordered migration list');
45
+ const plans = [];
46
+ for (const migration of migrations) {
47
+ checkMigrationDocument(migration);
48
+ // the whole refusal happens here, before the first document: a
49
+ // half-applied migration is the one outcome a runner without a
50
+ // transaction can never take back
51
+ for (let i = 0; i < migration.steps.length; i++) {
52
+ const step = migration.steps[i];
53
+ if (PHYSICAL_STEP_KINDS.has(step.kind)) {
54
+ throw new DbCompileError('JD0023',
55
+ `migration '${migration.id}' step ${i} (${step.kind}) needs a database: a `
56
+ + 'document runner has no tables to change. Run this migration against a '
57
+ + 'store, or split the physical steps out of it');
58
+ }
59
+ if (!DOCUMENT_STEP_KINDS.has(step.kind)) {
60
+ throw new DbCompileError('JD0023',
61
+ `migration '${migration.id}' step ${i} has no recognised kind`);
62
+ }
63
+ if (!present.has(step.collection)) {
64
+ throw new DbCompileError('JD0023',
65
+ `migration '${migration.id}' step ${i} (${step.kind}) names collection `
66
+ + `'${step.collection}', which was not supplied`);
67
+ }
68
+ }
69
+ plans.push({
70
+ id: migration.id,
71
+ from: migration.from,
72
+ to: migration.to,
73
+ operations: migration.steps.map((step, i) => compileDocumentStep(step, i, {
74
+ migrationId: migration.id,
75
+ compileJslt: context.compileJslt,
76
+ compileQuery: context.compileQuery,
77
+ keys: Object.hasOwn(context.keys, step.collection) ? (context.keys[step.collection] ?? []) : [],
78
+ })),
79
+ });
80
+ }
81
+ return plans;
82
+ }
83
+
84
+ /** The shared option surface both surfaces read. */
85
+ function runContext(options) {
86
+ const runtime = resolveRuntime(options.runtime);
87
+ return {
88
+ batchSize: options.batchSize ?? 500,
89
+ onProgress: options.onProgress,
90
+ keys: options.keys ?? {},
91
+ compileJslt: options.compileJslt ?? compileJsltStylesheet,
92
+ compileQuery: options.compileQuery ?? compileJsonQuery,
93
+ assertionBounds: normalizeAssertionBounds(options.assertionBounds),
94
+ check: () => refuseCancelled({ signal: options.signal, deadline: options.deadline },
95
+ runtime.now, {
96
+ abortCode: 'JD2080', aborted: 'its next step', passed: 'its next step',
97
+ ran: 'no further step ran; a document migration leaves the documents it has '
98
+ + 'already written where they are',
99
+ }),
100
+ };
101
+ }
102
+
103
+ /** The report both surfaces answer with, before its counts are filled. */
104
+ const emptyReport = (plans) => ({
105
+ applied: plans.map((plan) => plan.id),
106
+ skipped: [],
107
+ shape: plans.length > 0 ? plans[plans.length - 1].to : null,
108
+ counts: /** @type {Record<string, any>} */ ({}),
109
+ strategy: /** @type {Record<string, string>} */ ({}),
110
+ });
111
+
112
+ /** The per-collection counters a report carries. */
113
+ const countersFor = (report, collection) => {
114
+ if (!Object.hasOwn(report.counts, collection))
115
+ setObjectMember(report.counts, collection, { read: 0, transformed: 0, asserted: 0 });
116
+ return report.counts[collection];
117
+ };
118
+
119
+ /**
120
+ * Migrate documents held in memory: `{ users: [...] }` in, the migrated
121
+ * `{ users: [...] }` out, alongside the report.
122
+ *
123
+ * The source is rewindable, so the steps run exactly as a Store runs
124
+ * them — every step over the whole collection, in step order — which is
125
+ * what makes this mode's answer, and its refusal, identical to the
126
+ * Store's for the same migration and the same documents.
127
+ *
128
+ * @param {Record<string, any[]>} collections - the documents, per collection
129
+ * @param {any[]} migrations - the ordered migration list
130
+ * @param {{
131
+ * batchSize?: number,
132
+ * onProgress?: Function,
133
+ * keys?: Record<string, string[]>,
134
+ * compileJslt?: Function,
135
+ * compileQuery?: Function,
136
+ * runtime?: any,
137
+ * signal?: AbortSignal,
138
+ * deadline?: number,
139
+ * }} [options]
140
+ * @returns {Promise<{ documents: Record<string, any[]>, report: any }>}
141
+ */
142
+ export async function migrateDocuments(collections, migrations, options = {}) {
143
+ if (collections === null || typeof collections !== 'object' || Array.isArray(collections))
144
+ throw new TypeError('migrateDocuments needs { [collection]: document[] }');
145
+ for (const [name, documents] of Object.entries(collections)) {
146
+ if (!Array.isArray(documents))
147
+ throw new TypeError(`migrateDocuments needs an array for collection '${name}'`);
148
+ }
149
+ const context = runContext(options);
150
+ const plans = planStorelessRun(migrations, new Set(Object.keys(collections)), context);
151
+ context.check();
152
+
153
+ /** @type {Record<string, any[]>} */
154
+ const state = {};
155
+ for (const [name, documents] of Object.entries(collections)) setObjectMember(state, name, [...documents]);
156
+ const report = emptyReport(plans);
157
+ for (const name of Object.keys(state)) {
158
+ countersFor(report, name).read = state[name].length;
159
+ setObjectMember(report.strategy, name, 'materialized');
160
+ }
161
+
162
+ for (const plan of plans) {
163
+ for (const operation of plan.operations) {
164
+ context.check();
165
+ const documents = state[operation.collection];
166
+ const counters = countersFor(report, operation.collection);
167
+ if (operation.kind === 'jslt') {
168
+ for (let i = 0; i < documents.length; i++) {
169
+ documents[i] = operation.apply(documents[i], i);
170
+ counters.transformed++;
171
+ if ((i + 1) % context.batchSize === 0) {
172
+ context.onProgress?.({ migration: plan.id, collection: operation.collection,
173
+ transformed: counters.transformed });
174
+ }
175
+ }
176
+ if (documents.length % context.batchSize !== 0) {
177
+ context.onProgress?.({ migration: plan.id, collection: operation.collection,
178
+ transformed: counters.transformed });
179
+ }
180
+ continue;
181
+ }
182
+ if (operation.fold !== null) {
183
+ // an associative aggregate: the same batches, combined
184
+ let accumulated = operation.fold.start();
185
+ for (let at = 0; at < documents.length; at += context.batchSize) {
186
+ context.check();
187
+ accumulated = operation.fold.combine(accumulated,
188
+ documents.slice(at, at + context.batchSize));
189
+ counters.asserted += Math.min(context.batchSize, documents.length - at);
190
+ context.onProgress?.({ migration: plan.id, collection: operation.collection,
191
+ asserted: counters.asserted });
192
+ }
193
+ operation.fold.finish(accumulated);
194
+ continue;
195
+ }
196
+ if (!operation.perDocument) {
197
+ // materializing: this mode already holds the collection, but the
198
+ // bound is what the caller was promised, so it is checked
199
+ const guard = createAssertionBoundGuard(context.assertionBounds, operation.collection,
200
+ `the assertion of migration '${plan.id}'`);
201
+ for (const document of documents) guard.admit(document);
202
+ operation.assert(documents);
203
+ continue;
204
+ }
205
+ // per-document: the same keyset-sized batches the Store asserts
206
+ // over, so a refusal counts the same violations it counts there
207
+ for (let at = 0; at < documents.length; at += context.batchSize) {
208
+ context.check();
209
+ const batch = documents.slice(at, at + context.batchSize);
210
+ operation.assert(batch);
211
+ counters.asserted += batch.length;
212
+ context.onProgress?.({ migration: plan.id, collection: operation.collection,
213
+ asserted: counters.asserted });
214
+ }
215
+ }
216
+ }
217
+ return { documents: state, report };
218
+ }
219
+
220
+ /**
221
+ * Migrate documents the caller can only walk once, writing each out as
222
+ * it finishes. The source is consumed exactly once and nothing beyond
223
+ * one batch is held, so a collection larger than memory still migrates.
224
+ *
225
+ * **What a single pass cannot do.** The Store, and {@link migrateDocuments},
226
+ * run each step over the whole collection before the next step begins,
227
+ * so when two different steps would each refuse, the EARLIER step
228
+ * refuses first. One pass carries each batch through every step, so the
229
+ * later step can refuse first. Both refuse, with the same code and the
230
+ * same words; only which step is named can differ. A caller who needs
231
+ * that identity has a rewindable source and should use
232
+ * {@link migrateDocuments}.
233
+ *
234
+ * A cross-document assertion (`$count`, `$let`, `$distinct`, a nested
235
+ * `$for`) needs every document at once and so cannot run in one pass;
236
+ * it is refused here by name rather than silently buffering the
237
+ * collection.
238
+ *
239
+ * @param {Record<string, Iterable<any> | AsyncIterable<any>>} sources
240
+ * @param {any[]} migrations - the ordered migration list
241
+ * @param {{
242
+ * write: (collection: string, document: any) => any,
243
+ * batchSize?: number,
244
+ * onProgress?: Function,
245
+ * keys?: Record<string, string[]>,
246
+ * compileJslt?: Function,
247
+ * compileQuery?: Function,
248
+ * runtime?: any,
249
+ * signal?: AbortSignal,
250
+ * deadline?: number,
251
+ * }} options
252
+ * @returns {Promise<any>} the report
253
+ */
254
+ export async function streamDocuments(sources, migrations, options) {
255
+ if (sources === null || typeof sources !== 'object' || Array.isArray(sources))
256
+ throw new TypeError('streamDocuments needs { [collection]: iterable }');
257
+ if (options === null || typeof options !== 'object' || typeof options.write !== 'function')
258
+ throw new TypeError('streamDocuments needs { write(collection, document) }');
259
+ const context = runContext(options);
260
+ const plans = planStorelessRun(migrations, new Set(Object.keys(sources)), context);
261
+
262
+ // the second refusal a single pass owes before it reads anything: a
263
+ // MATERIALIZING assertion. An associative aggregate is not one — its
264
+ // batches combine, so a single pass answers it exactly.
265
+ for (const plan of plans) {
266
+ for (const operation of plan.operations) {
267
+ if (operation.kind === 'query' && operation.strategy === 'materialize') {
268
+ operation.fail('a cross-document assertion needs every document of '
269
+ + `'${operation.collection}' at once, which a single pass does not hold — `
270
+ + 'migrate this collection from a rewindable source');
271
+ }
272
+ }
273
+ }
274
+ context.check();
275
+
276
+ const report = emptyReport(plans);
277
+ // one collection's whole ordered pipeline: every migration's steps
278
+ // over it, in order, applied to each document as it passes
279
+ /** @type {Record<string, any[]>} */
280
+ const pipeline = Object.create(null);
281
+ for (const plan of plans) {
282
+ for (const operation of plan.operations) {
283
+ (pipeline[operation.collection] ??= []).push({
284
+ migration: plan.id, operation, accumulated: undefined, folded: false,
285
+ });
286
+ }
287
+ }
288
+
289
+ for (const name of Object.keys(sources)) {
290
+ const counters = countersFor(report, name);
291
+ setObjectMember(report.strategy, name, 'streamed');
292
+ const stages = pipeline[name] ?? [];
293
+ /** @type {any[]} */
294
+ let batch = [];
295
+ const flush = async () => {
296
+ if (batch.length === 0) return;
297
+ context.check();
298
+ for (const stage of stages) {
299
+ if (stage.operation.kind === 'jslt') {
300
+ for (let i = 0; i < batch.length; i++) {
301
+ batch[i] = stage.operation.apply(batch[i], counters.read - batch.length + i);
302
+ counters.transformed++;
303
+ }
304
+ context.onProgress?.({ migration: stage.migration, collection: name,
305
+ transformed: counters.transformed });
306
+ continue;
307
+ }
308
+ if (stage.operation.fold !== null) {
309
+ stage.accumulated = stage.operation.fold.combine(
310
+ stage.accumulated ?? stage.operation.fold.start(), batch);
311
+ stage.folded = true;
312
+ }
313
+ else stage.operation.assert(batch);
314
+ counters.asserted += batch.length;
315
+ context.onProgress?.({ migration: stage.migration, collection: name,
316
+ asserted: counters.asserted });
317
+ }
318
+ for (const document of batch) await options.write(name, document);
319
+ batch = [];
320
+ };
321
+ for await (const document of sources[name]) {
322
+ counters.read++;
323
+ batch.push(document);
324
+ if (batch.length >= context.batchSize) await flush();
325
+ }
326
+ await flush();
327
+ // a fold's verdict is on the TOTAL, so it is reached once the source
328
+ // is spent — an empty source still folds, to its aggregate's identity
329
+ for (const stage of stages) {
330
+ if (stage.operation.fold === null) continue;
331
+ stage.operation.fold.finish(stage.folded ? stage.accumulated : stage.operation.fold.start());
332
+ }
333
+ }
334
+ return report;
335
+ }