@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
package/src/cli.js CHANGED
@@ -11,16 +11,20 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import * as readline from 'readline';
14
- import { pathToFileURL } from 'url';
15
14
 
16
- import { canonicalizeJson } from '@jarenjs/json/canonical';
15
+ import { loadDocument as loadDocumentFile, isDocumentFile } from '@jarenjs/json/node';
17
16
 
18
17
  import {
19
18
  planModelMigration, migrate, migrationStatus, shapeHash, compareShapeToModel,
20
19
  sqliteDialect, normalizeModel, normalizeEntities, explainMapping,
21
20
  planCollection, planEntity, planJoinTable, HISTORY_TABLE, entityEmitModel,
21
+ migrateDocuments, streamDocuments, classifyAssertion,
22
22
  } from './index.js';
23
23
  import { nodeDriver } from './drivers/node.js';
24
+ import {
25
+ readDocuments, openAtomicTarget, openStreamTarget, openNullTarget,
26
+ formatOf, DOCUMENT_FORMATS,
27
+ } from './document-files.js';
24
28
 
25
29
  const USAGE = `jaren-db — model-driven SQLite migrations
26
30
 
@@ -28,10 +32,12 @@ Usage:
28
32
  jaren-db plan --from <model> --to <model> [--store <db>] [--id <name>] [--out <file>]
29
33
  jaren-db plan --model <model> [--snapshot <file>] [--store <db>] [--id <name>] --out <file>
30
34
  jaren-db snapshot --model <model> [--snapshot <file>] [--types <file>]
31
- jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
35
+ jaren-db status --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
32
36
  jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
33
- jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
37
+ jaren-db check --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
34
38
  jaren-db shape --model <model>
39
+ jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place --yes | --check)
40
+ [--format json|jsonl] [--out-format json|jsonl] [--collection <name>] [--batch-size <n>]
35
41
 
36
42
  A <model> or a migration is a .json file, or a MODULE (.js, .mjs, .cjs —
37
43
  or .ts where Node strips types) whose default export, or its 'model' /
@@ -57,6 +63,16 @@ apply Print every statement, then apply. Destructive steps (drop
57
63
  check The CI command: exit 1 on an unplanned model change, pending
58
64
  migrations or drift.
59
65
  shape Print the physical mapping a model produces.
66
+ documents Run a migration's DOCUMENT steps (jslt, query) over a file of
67
+ documents instead of a database — a JSON array or JSONL, a
68
+ path or stdio. A step that needs tables (ddl, sql, rebuild,
69
+ derive) is refused by name before the first document is read.
70
+ --out writes a new file, --in-place replaces the input (a
71
+ sibling temporary is renamed over it only once every document
72
+ has survived every step; any failure leaves the original byte
73
+ for byte), and --check transforms and validates everything
74
+ while writing nothing. Exit: 0 applicable and valid, 1 a
75
+ migration or source failure, 2 a misuse of this command line.
60
76
  `;
61
77
 
62
78
  function fail(message) {
@@ -64,11 +80,24 @@ function fail(message) {
64
80
  process.exit(1);
65
81
  }
66
82
 
83
+ /**
84
+ * A command line that cannot be obeyed — a missing flag, two flags that
85
+ * contradict, a value that names nothing. Distinct from `fail`, because
86
+ * a caller scripting this command has to tell "you asked for the wrong
87
+ * thing" (2) from "what you asked for did not hold" (1).
88
+ */
89
+ function misuse(message) {
90
+ console.error(`jaren-db: ${message}`);
91
+ process.exit(2);
92
+ }
93
+
67
94
  function parseArgs(argv) {
68
95
  const options = {
69
96
  command: argv[2], from: null, to: null, model: null, store: null,
70
97
  baseline: null, migrations: null, id: null, out: null,
71
98
  snapshot: null, types: null,
99
+ in: null, format: null, outFormat: null, collection: null,
100
+ batchSize: null, inPlace: false, check: false,
72
101
  dryRun: false, yes: false, help: false,
73
102
  };
74
103
  for (let i = 3; i < argv.length; i++) {
@@ -83,15 +112,26 @@ function parseArgs(argv) {
83
112
  case '--out': options.out = argv[++i]; break;
84
113
  case '--snapshot': options.snapshot = argv[++i]; break;
85
114
  case '--types': options.types = argv[++i]; break;
115
+ case '--in': options.in = argv[++i]; break;
116
+ case '--out-format': options.outFormat = argv[++i]; break;
117
+ case '--format': options.format = argv[++i]; break;
118
+ case '--collection': options.collection = argv[++i]; break;
119
+ case '--batch-size': options.batchSize = argv[++i]; break;
120
+ case '--in-place': options.inPlace = true; break;
121
+ case '--check': options.check = true; break;
86
122
  case '--dry-run': options.dryRun = true; break;
87
123
  case '--yes': options.yes = true; break;
88
124
  case '--help': case '-h': options.help = true; break;
89
- default: fail(`unknown option: ${argv[i]}`);
125
+ // an unrecognised flag is a misuse of the command line, which is
126
+ // what `documents` promises exit 2 for; the five older commands
127
+ // keep the exit 1 their published contract has always used
128
+ default: (options.command === 'documents' ? misuse : fail)(`unknown option: ${argv[i]}`);
90
129
  }
91
130
  }
92
131
  return options;
93
132
  }
94
133
 
134
+ /** A committed snapshot: JSON on disk, read as it is. */
95
135
  const readJson = (file, what) => {
96
136
  try {
97
137
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -101,68 +141,28 @@ const readJson = (file, what) => {
101
141
  }
102
142
  };
103
143
 
104
- const MODULE_EXT = /\.(?:m?js|cjs|m?ts|cts)$/;
105
- const TS_EXT = /\.[mc]?ts$/;
106
- let loads = 0;
107
-
108
- /** The document a module exports — `default`, or the named export — as
109
- * its JSON emission (a pen builder's `toJSON()`), or a named failure. */
110
- const emissionOf = (mod, exportName, file) => {
111
- const value = mod.default !== undefined ? mod.default : mod[exportName];
112
- if (value === null || typeof value !== 'object') {
113
- return fail(`module '${file}' exports neither a default nor a '${exportName}' document`);
114
- }
115
- let doc;
116
- try {
117
- doc = JSON.parse(JSON.stringify(value));
118
- }
119
- catch (error) {
120
- return fail(`module '${file}': the ${exportName} emission is not JSON (${error.message})`);
121
- }
122
- if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
123
- return fail(`module '${file}': the ${exportName} emission is not a document`);
124
- }
125
- return doc;
126
- };
127
-
128
144
  /**
129
145
  * A model or migration document: a `.json` file, or a module loaded
130
146
  * TWICE — an emission that changes between loads is not pure (a clock,
131
147
  * the environment, randomness), and a migration that hashes differently
132
- * per load can never match its own history.
148
+ * per load can never match its own history. The loader is the suite's
149
+ * one (`@jarenjs/json/node`, shared with `jaren-contract`); every
150
+ * refusal it names exits here under this CLI's prefix.
133
151
  */
134
152
  async function loadDocument(file, what, exportName) {
135
- if (file.endsWith('.json')) return readJson(file, what);
136
- if (!MODULE_EXT.test(file)) {
137
- return fail(`cannot read ${what} '${file}': neither a .json file nor a module `
138
- + '(.js, .mjs, .cjs — or .ts where Node strips types)');
139
- }
140
- const url = pathToFileURL(path.resolve(file)).href;
141
- const load = async () => {
142
- try {
143
- return await import(`${url}?jaren-db-load=${++loads}`);
144
- }
145
- catch (error) {
146
- return fail(`cannot load ${what} module '${file}': ${error.message}`
147
- + (TS_EXT.test(file)
148
- ? ' — a .ts module loads only where Node strips types (Node >= 24 does by default; --no-strip-types turns it off)'
149
- : ''));
150
- }
151
- };
152
- const first = emissionOf(await load(), exportName, file);
153
- const second = emissionOf(await load(), exportName, file);
154
- if (canonicalizeJson(first) !== canonicalizeJson(second)) {
155
- return fail(`the ${what} module '${file}' is not pure — two loads emitted different documents; `
156
- + 'no clock, no env, no randomness in a model or migration module');
153
+ try {
154
+ return await loadDocumentFile(file, { what, exportName, impure: 'no clock, no env, no randomness in a model or migration module' });
155
+ }
156
+ catch (error) {
157
+ return fail(error.message);
157
158
  }
158
- return first;
159
159
  }
160
160
 
161
161
  /** The migrations directory, sorted by file name — the full ordered chain. */
162
162
  const loadMigrationsDir = async (dir) => {
163
163
  if (dir === null) return [];
164
164
  const files = fs.readdirSync(dir)
165
- .filter((file) => (file.endsWith('.json') || MODULE_EXT.test(file)) && !file.endsWith('.d.ts'))
165
+ .filter((file) => isDocumentFile(file))
166
166
  .sort();
167
167
  const migrations = [];
168
168
  for (const file of files) migrations.push(await loadDocument(path.join(dir, file), 'migration', 'migration'));
@@ -267,12 +267,14 @@ async function commandPlan(options) {
267
267
  }
268
268
 
269
269
  async function commandStatus(options, { asCheck }) {
270
- if (options.store === null || options.baseline === null)
271
- fail(`${asCheck ? 'check' : 'status'} needs --store and --baseline`);
270
+ // the history and the model are what a status read needs; the
271
+ // baseline anchors an APPLY (the chain's first shape), not a read —
272
+ // `--baseline` is still accepted so an existing invocation keeps working
273
+ if (options.store === null)
274
+ fail(`${asCheck ? 'check' : 'status'} needs --store`);
272
275
  // `check` without the model verified nothing and printed "in sync"
273
276
  if (asCheck && options.model === null)
274
277
  fail('check needs --model — drift is measured against the model the code carries');
275
- const baseline = await loadDocument(options.baseline, 'baseline model', 'model');
276
278
  const model = options.model !== null ? await loadDocument(options.model, 'model', 'model') : undefined;
277
279
  const migrations = await loadMigrationsDir(options.migrations);
278
280
  // the snapshot discipline, when it is in use: a model that moved
@@ -291,7 +293,7 @@ async function commandStatus(options, { asCheck }) {
291
293
  let status;
292
294
  try {
293
295
  status = await migrationStatus({ driver: nodeDriver(), path: options.store },
294
- migrations, { baseline, model });
296
+ migrations, { model });
295
297
  }
296
298
  catch (error) {
297
299
  return fail(error.message);
@@ -331,7 +333,7 @@ async function commandApply(options) {
331
333
  const migrations = await loadMigrationsDir(options.migrations);
332
334
  const target = { driver: nodeDriver(), path: options.store };
333
335
 
334
- const status = await migrationStatus(target, migrations, { baseline })
336
+ const status = await migrationStatus(target, migrations)
335
337
  .catch((error) => fail(error.message));
336
338
  if (status.pending.length === 0) {
337
339
  console.log('nothing to apply — up to date');
@@ -422,6 +424,109 @@ async function commandShape(options) {
422
424
  console.log(`-- history rides in '${HISTORY_TABLE}'`);
423
425
  }
424
426
 
427
+ /**
428
+ * Run a migration's document steps over a file of documents.
429
+ *
430
+ * The runner is chosen by what the migration asks for, and named in the
431
+ * report: a cross-document assertion needs every document at once, so
432
+ * its collection is read into memory; anything else streams, holding one
433
+ * batch. Reading a rewindable FILE for the first is not a compromise —
434
+ * it is the same source, read twice.
435
+ */
436
+ async function commandDocuments(options) {
437
+ if (options.migrations === null) misuse('documents needs --migrations <dir>');
438
+ if (options.in === null) misuse('documents needs --in <file> (or - for standard input)');
439
+ const sinks = [options.out !== null, options.inPlace, options.check].filter(Boolean).length;
440
+ if (sinks === 0)
441
+ misuse('documents needs one of --out <file>, --in-place or --check');
442
+ if (sinks > 1)
443
+ misuse('documents takes exactly one of --out, --in-place and --check');
444
+ if (options.inPlace && options.in === '-')
445
+ misuse('--in-place needs a file to replace, not standard input');
446
+ if (options.inPlace && !options.yes)
447
+ misuse('--in-place rewrites the input file — pass --yes to confirm, or --out to write elsewhere');
448
+ const batchSize = options.batchSize === null ? 500 : Number(options.batchSize);
449
+ if (!Number.isInteger(batchSize) || batchSize < 1)
450
+ misuse(`--batch-size must be a positive integer, not '${options.batchSize}'`);
451
+
452
+ const fromStdin = options.in === '-';
453
+ const inFormat = options.format ?? (fromStdin ? 'jsonl' : formatOf(options.in));
454
+ if (!DOCUMENT_FORMATS.includes(inFormat))
455
+ misuse(`--format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${inFormat}'`);
456
+ const target = options.inPlace ? options.in : options.out;
457
+ const toStdout = target === '-';
458
+ const outFormat = options.outFormat
459
+ ?? (options.check || toStdout ? inFormat : formatOf(/** @type {string} */ (target)));
460
+ if (!DOCUMENT_FORMATS.includes(outFormat))
461
+ misuse(`--out-format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${outFormat}'`);
462
+ if (!fromStdin && !fs.existsSync(options.in)) fail(`no such file: '${options.in}'`);
463
+
464
+ const migrations = await loadMigrationsDir(options.migrations);
465
+ if (migrations.length === 0) fail(`no migration documents in '${options.migrations}'`);
466
+
467
+ // A document file holds ONE collection. The migrations say which:
468
+ // every document step must name it, or this chain cannot be applied to
469
+ // a file at all — running only the steps that match would leave the
470
+ // rest silently unapplied, which is the one outcome a migration runner
471
+ // may never produce.
472
+ const documentSteps = migrations.flatMap((migration) => migration.steps)
473
+ .filter((step) => step.kind === 'jslt' || step.kind === 'query');
474
+ const named = [...new Set(documentSteps.map((step) => step.collection))];
475
+ if (named.length > 1) {
476
+ fail(`a document file holds one collection, and these migrations touch ${named.length} `
477
+ + `(${named.join(', ')}) — run them against a store, or split the chain so each `
478
+ + 'migration touches the collection its file holds');
479
+ }
480
+ if (options.collection !== null && named.length === 1 && options.collection !== named[0]) {
481
+ misuse(`--collection names '${options.collection}', but these migrations touch `
482
+ + `'${named[0]}' — is this the right file for them?`);
483
+ }
484
+ // with no document step at all there is no collection to infer; the
485
+ // run still proceeds, because a physical step must be REFUSED by name
486
+ // rather than reported as a missing collection
487
+ const collection = named[0] ?? options.collection ?? 'documents';
488
+
489
+ // only a MATERIALIZING assertion needs the collection at once; a
490
+ // per-document predicate and an associative aggregate are both
491
+ // answered one batch at a time, so they stream
492
+ const materializes = documentSteps.some((step) => step.collection === collection
493
+ && step.kind === 'query' && classifyAssertion(step.assert).strategy === 'materialize');
494
+
495
+ const source = () => (fromStdin ? process.stdin : options.in);
496
+ let sink;
497
+ if (options.check) sink = openNullTarget();
498
+ else if (toStdout) sink = openStreamTarget(process.stdout, outFormat);
499
+ else sink = await openAtomicTarget(/** @type {string} */ (target), outFormat);
500
+
501
+ try {
502
+ let report;
503
+ if (materializes) {
504
+ const documents = [];
505
+ for await (const document of readDocuments(source(), inFormat)) documents.push(document);
506
+ const out = await migrateDocuments({ [collection]: documents }, migrations, { batchSize });
507
+ report = out.report;
508
+ for (const document of out.documents[collection]) await sink.write(document);
509
+ }
510
+ else {
511
+ report = await streamDocuments({ [collection]: readDocuments(source(), inFormat) },
512
+ migrations, { batchSize, write: (name, document) => sink.write(document) });
513
+ }
514
+ const written = await sink.commit();
515
+ const counts = report.counts[collection] ?? { read: 0, transformed: 0, asserted: 0 };
516
+ console.log(`${options.check ? 'checked' : 'migrated'} '${collection}': `
517
+ + `${counts.read} read, ${counts.transformed} transformed, ${counts.asserted} asserted `
518
+ + `(${report.strategy[collection]})`);
519
+ console.log(`applied: ${report.applied.join(', ')}`);
520
+ if (options.check) console.log('checked only — nothing was written');
521
+ else if (toStdout) console.log(`wrote ${written.documents} document(s) to standard output`);
522
+ else console.log(`wrote ${written.documents} document(s) to ${target} (${written.bytes} bytes)`);
523
+ }
524
+ catch (error) {
525
+ await sink.abort();
526
+ return fail(error.message);
527
+ }
528
+ }
529
+
425
530
  async function main() {
426
531
  const options = parseArgs(process.argv);
427
532
  if (options.help || options.command === '--help' || options.command === '-h'
@@ -436,6 +541,7 @@ async function main() {
436
541
  case 'check': return commandStatus(options, { asCheck: true });
437
542
  case 'apply': return commandApply(options);
438
543
  case 'shape': return commandShape(options);
544
+ case 'documents': return commandDocuments(options);
439
545
  default: return fail(`unknown command '${options.command}' — try --help`);
440
546
  }
441
547
  }