@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/cli.js CHANGED
@@ -2,39 +2,77 @@
2
2
  //#region the jaren-db command
3
3
  // Migrations nobody drives by API stay undrifted by nobody: the CLI is
4
4
  // what puts `check` in CI and a reviewable migration document in the
5
- // repository. Five commands (MIGRATION-FORMAT §11): plan, status,
6
- // apply, check, shape.
5
+ // repository. Six commands (MIGRATION-FORMAT §11): plan, snapshot,
6
+ // status, apply, check, shape. A model or a migration is a JSON file or
7
+ // a MODULE — the model pen's document, the migration pen's builder —
8
+ // loaded twice, because a module that emits a different document on its
9
+ // second load is one whose migration can never match its own history.
7
10
 
8
11
  import * as fs from 'fs';
9
12
  import * as path from 'path';
10
13
  import * as readline from 'readline';
11
14
 
15
+ import { loadDocument as loadDocumentFile, isDocumentFile } from '@jarenjs/json/node';
16
+
12
17
  import {
13
- planModelMigration, migrate, migrationStatus, shapeHash,
18
+ planModelMigration, migrate, migrationStatus, shapeHash, compareShapeToModel,
14
19
  sqliteDialect, normalizeModel, normalizeEntities, explainMapping,
15
- planCollection, planEntity, planJoinTable, HISTORY_TABLE,
20
+ planCollection, planEntity, planJoinTable, HISTORY_TABLE, entityEmitModel,
21
+ migrateDocuments, streamDocuments, classifyAssertion,
16
22
  } from './index.js';
17
23
  import { nodeDriver } from './drivers/node.js';
24
+ import {
25
+ readDocuments, openAtomicTarget, openStreamTarget, openNullTarget,
26
+ formatOf, DOCUMENT_FORMATS,
27
+ } from './document-files.js';
18
28
 
19
29
  const USAGE = `jaren-db — model-driven SQLite migrations
20
30
 
21
31
  Usage:
22
- jaren-db plan --from <model> --to <model> [--store <db>] [--id <name>] [--out <file>]
23
- jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>]
24
- jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
25
- jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>]
26
- jaren-db shape --model <model>
27
-
28
- plan Diff two model FILES into a migration document (a database
29
- stores shape hashes, not models the from-model is the
30
- previous model file). With --store, first verify the
31
- from-model matches the database's recorded shape.
32
- status Applied, pending, and drift (a hand-modified database).
33
- apply Print every statement, then apply. Destructive steps (drop
34
- table/column, rebuild) require --yes or an interactive
35
- confirmation naming what is lost. --dry-run only prints.
36
- check The CI command: exit 1 on pending migrations or drift.
37
- shape Print the physical mapping a model produces.
32
+ jaren-db plan --from <model> --to <model> [--store <db>] [--id <name>] [--out <file>]
33
+ jaren-db plan --model <model> [--snapshot <file>] [--store <db>] [--id <name>] --out <file>
34
+ jaren-db snapshot --model <model> [--snapshot <file>] [--types <file>]
35
+ jaren-db status --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
36
+ jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
37
+ jaren-db check --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
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>]
41
+
42
+ A <model> or a migration is a .json file, or a MODULE (.js, .mjs, .cjs —
43
+ or .ts where Node strips types) whose default export, or its 'model' /
44
+ 'migration' export, is the document or a pen builder that emits one. A
45
+ module is loaded twice and refused when its two emissions differ: no
46
+ clock, no env, no randomness. --migrations reads .json files and
47
+ modules, sorted by file name.
48
+
49
+ plan Diff two model FILES into a migration document (a database
50
+ stores shape hashes, not models — the from-model is the
51
+ previous model file), or diff the committed SNAPSHOT (default
52
+ model.snapshot.json beside the model) against the model: with
53
+ --out the migration is written and the snapshot advanced; a
54
+ model matching its snapshot plans nothing. With --store, first
55
+ verify the from-model matches the database's recorded shape.
56
+ snapshot Write the model's snapshot; with --types, emit's TypeScript
57
+ declaration for it (needs @jarenjs/emit beside @jarenjs/db).
58
+ status Applied, pending, drift (a hand-modified database) and — with
59
+ a snapshot — an unplanned model change.
60
+ apply Print every statement, then apply. Destructive steps (drop
61
+ table/column, rebuild) require --yes or an interactive
62
+ confirmation naming what is lost. --dry-run only prints.
63
+ check The CI command: exit 1 on an unplanned model change, pending
64
+ migrations or drift.
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.
38
76
  `;
39
77
 
40
78
  function fail(message) {
@@ -42,10 +80,24 @@ function fail(message) {
42
80
  process.exit(1);
43
81
  }
44
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
+
45
94
  function parseArgs(argv) {
46
95
  const options = {
47
96
  command: argv[2], from: null, to: null, model: null, store: null,
48
97
  baseline: null, migrations: null, id: null, out: null,
98
+ snapshot: null, types: null,
99
+ in: null, format: null, outFormat: null, collection: null,
100
+ batchSize: null, inPlace: false, check: false,
49
101
  dryRun: false, yes: false, help: false,
50
102
  };
51
103
  for (let i = 3; i < argv.length; i++) {
@@ -58,15 +110,28 @@ function parseArgs(argv) {
58
110
  case '--migrations': options.migrations = argv[++i]; break;
59
111
  case '--id': options.id = argv[++i]; break;
60
112
  case '--out': options.out = argv[++i]; break;
113
+ case '--snapshot': options.snapshot = argv[++i]; break;
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;
61
122
  case '--dry-run': options.dryRun = true; break;
62
123
  case '--yes': options.yes = true; break;
63
124
  case '--help': case '-h': options.help = true; break;
64
- 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]}`);
65
129
  }
66
130
  }
67
131
  return options;
68
132
  }
69
133
 
134
+ /** A committed snapshot: JSON on disk, read as it is. */
70
135
  const readJson = (file, what) => {
71
136
  try {
72
137
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -76,13 +141,42 @@ const readJson = (file, what) => {
76
141
  }
77
142
  };
78
143
 
79
- /** The migrations directory, sorted — the full ordered chain. */
80
- const readMigrationsDir = (dir) => {
144
+ /**
145
+ * A model or migration document: a `.json` file, or a module loaded
146
+ * TWICE — an emission that changes between loads is not pure (a clock,
147
+ * the environment, randomness), and a migration that hashes differently
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.
151
+ */
152
+ async function loadDocument(file, what, exportName) {
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);
158
+ }
159
+ }
160
+
161
+ /** The migrations directory, sorted by file name — the full ordered chain. */
162
+ const loadMigrationsDir = async (dir) => {
81
163
  if (dir === null) return [];
82
- return fs.readdirSync(dir)
83
- .filter((file) => file.endsWith('.json'))
84
- .sort()
85
- .map((file) => readJson(path.join(dir, file), 'migration'));
164
+ const files = fs.readdirSync(dir)
165
+ .filter((file) => isDocumentFile(file))
166
+ .sort();
167
+ const migrations = [];
168
+ for (const file of files) migrations.push(await loadDocument(path.join(dir, file), 'migration', 'migration'));
169
+ return migrations;
170
+ };
171
+
172
+ /** The committed snapshot beside a model, unless one is named. */
173
+ const defaultSnapshotOf = (modelFile) => path.join(path.dirname(modelFile), 'model.snapshot.json');
174
+
175
+ /** Write a file only when its text changed — two runs on one input change nothing. */
176
+ const writeIfChanged = (file, text) => {
177
+ const same = fs.existsSync(file) && fs.readFileSync(file, 'utf8') === text;
178
+ if (!same) fs.writeFileSync(file, text);
179
+ return same;
86
180
  };
87
181
 
88
182
  /** What a migration will destroy, by note — the confirmation names it. */
@@ -105,17 +199,42 @@ const renderSteps = (migration) => {
105
199
  };
106
200
 
107
201
  async function commandPlan(options) {
108
- if (options.from === null || options.to === null)
109
- fail('plan needs --from and --to model files');
110
- const fromModel = readJson(options.from, 'from-model');
111
- const toModel = readJson(options.to, 'to-model');
202
+ let fromModel;
203
+ let toModel;
204
+ let snapshotFile = null;
205
+ if (options.model !== null) {
206
+ if (options.from !== null || options.to !== null)
207
+ fail('plan takes either --from and --to model files, or --model with its --snapshot — not both');
208
+ snapshotFile = options.snapshot ?? defaultSnapshotOf(options.model);
209
+ if (!fs.existsSync(snapshotFile)) {
210
+ fail(`no snapshot at '${snapshotFile}' — write one from the model the store was created with: `
211
+ + `jaren-db snapshot --model <baseline> --snapshot '${snapshotFile}'`);
212
+ }
213
+ fromModel = readJson(snapshotFile, 'snapshot');
214
+ toModel = await loadDocument(options.model, 'model', 'model');
215
+ if (shapeHash(fromModel) === shapeHash(toModel)) {
216
+ console.log(`no change — the model matches its snapshot (${snapshotFile}); nothing to plan`);
217
+ return;
218
+ }
219
+ }
220
+ else {
221
+ if (options.from === null || options.to === null)
222
+ fail('plan needs --from and --to model files, or --model with a committed --snapshot');
223
+ fromModel = await loadDocument(options.from, 'from-model', 'model');
224
+ toModel = await loadDocument(options.to, 'to-model', 'model');
225
+ }
112
226
  if (options.store !== null) {
113
- const status = await migrationStatus(
114
- { driver: nodeDriver(), path: options.store }, [], {
115
- baseline: fromModel, model: fromModel,
116
- }).catch((error) => fail(error.message));
117
- if (status.drift !== null) {
118
- fail(`the store does not match the from-model (${status.drift}) — `
227
+ // the from-model is compared with the database's SHAPE directly:
228
+ // asking the history with an empty chain refused every database
229
+ // that had applied a migration (JD0022), which is every database
230
+ // one plans a second migration for
231
+ const driver = nodeDriver();
232
+ const connection = await driver.open(options.store, {}).catch((error) => fail(error.message));
233
+ const drift = await Promise.resolve(compareShapeToModel(driver, connection, fromModel, undefined))
234
+ .catch((error) => fail(error.message));
235
+ await connection.close();
236
+ if (drift !== null) {
237
+ fail(`the store does not match the from-model (${drift}) — `
119
238
  + 'is this really the previous model?');
120
239
  }
121
240
  }
@@ -136,18 +255,45 @@ async function commandPlan(options) {
136
255
  + 'fill them in before applying');
137
256
  }
138
257
  if (report.destructive) console.error('NOTE: this migration is DESTRUCTIVE');
258
+ if (snapshotFile !== null) {
259
+ if (options.out === null) {
260
+ console.error(`NOTE: the snapshot was not advanced — plan with --out to write the migration and move ${snapshotFile}`);
261
+ }
262
+ else {
263
+ writeIfChanged(snapshotFile, JSON.stringify(toModel, null, 2) + '\n');
264
+ console.log(`advanced ${snapshotFile} (shape ${shapeHash(toModel)})`);
265
+ }
266
+ }
139
267
  }
140
268
 
141
269
  async function commandStatus(options, { asCheck }) {
142
- if (options.store === null || options.baseline === null)
143
- fail(`${asCheck ? 'check' : 'status'} needs --store and --baseline`);
144
- const baseline = readJson(options.baseline, 'baseline model');
145
- const model = options.model !== null ? readJson(options.model, 'model') : undefined;
146
- const migrations = readMigrationsDir(options.migrations);
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`);
275
+ // `check` without the model verified nothing and printed "in sync"
276
+ if (asCheck && options.model === null)
277
+ fail('check needs --model — drift is measured against the model the code carries');
278
+ const model = options.model !== null ? await loadDocument(options.model, 'model', 'model') : undefined;
279
+ const migrations = await loadMigrationsDir(options.migrations);
280
+ // the snapshot discipline, when it is in use: a model that moved
281
+ // without a plan is named as such, never as the database's drift
282
+ const snapshotFile = options.snapshot ?? (options.model !== null ? defaultSnapshotOf(options.model) : null);
283
+ let unplanned = null;
284
+ const snapshotInUse = snapshotFile !== null && (options.snapshot !== null || fs.existsSync(snapshotFile));
285
+ if (snapshotInUse) {
286
+ if (!fs.existsSync(snapshotFile)) fail(`no snapshot at '${snapshotFile}'`);
287
+ const recorded = shapeHash(readJson(snapshotFile, 'snapshot'));
288
+ const current = shapeHash(model);
289
+ if (recorded !== current) {
290
+ unplanned = `${snapshotFile} records shape ${recorded}, the model is ${current} — run jaren-db plan`;
291
+ }
292
+ }
147
293
  let status;
148
294
  try {
149
295
  status = await migrationStatus({ driver: nodeDriver(), path: options.store },
150
- migrations, { baseline, model });
296
+ migrations, { model });
151
297
  }
152
298
  catch (error) {
153
299
  return fail(error.message);
@@ -157,7 +303,12 @@ async function commandStatus(options, { asCheck }) {
157
303
  if (model !== undefined && status.pending.length === 0) {
158
304
  console.log(`drift: ${status.drift === null ? 'none — in sync' : status.drift}`);
159
305
  }
306
+ if (snapshotInUse) {
307
+ console.log(`model: ${unplanned === null ? 'planned — matches its snapshot' : `UNPLANNED change — ${unplanned}`}`);
308
+ }
160
309
  if (asCheck) {
310
+ if (unplanned !== null)
311
+ fail(`unplanned model change: ${unplanned}`);
161
312
  if (status.pending.length > 0)
162
313
  fail(`${status.pending.length} pending migration(s) — run jaren-db apply`);
163
314
  if (status.drift !== null)
@@ -177,12 +328,12 @@ const confirm = (question) => new Promise((resolve) => {
177
328
  async function commandApply(options) {
178
329
  if (options.store === null || options.baseline === null || options.migrations === null)
179
330
  fail('apply needs --store, --baseline and --migrations');
180
- const baseline = readJson(options.baseline, 'baseline model');
181
- const model = options.model !== null ? readJson(options.model, 'model') : undefined;
182
- const migrations = readMigrationsDir(options.migrations);
331
+ const baseline = await loadDocument(options.baseline, 'baseline model', 'model');
332
+ const model = options.model !== null ? await loadDocument(options.model, 'model', 'model') : undefined;
333
+ const migrations = await loadMigrationsDir(options.migrations);
183
334
  const target = { driver: nodeDriver(), path: options.store };
184
335
 
185
- const status = await migrationStatus(target, migrations, { baseline })
336
+ const status = await migrationStatus(target, migrations)
186
337
  .catch((error) => fail(error.message));
187
338
  if (status.pending.length === 0) {
188
339
  console.log('nothing to apply — up to date');
@@ -206,7 +357,10 @@ async function commandApply(options) {
206
357
  const answer = await confirm('Apply anyway? [y/N] ');
207
358
  if (!answer) fail('aborted — nothing was applied');
208
359
  }
209
- else if (!options.yes && process.stdin.isTTY) {
360
+ else if (!options.yes) {
361
+ // "default is dry-run + ask" (MIGRATION-FORMAT §11): where nobody
362
+ // can be asked, the statements above are the dry run and nothing runs
363
+ if (!process.stdin.isTTY) fail('apply needs --yes (no interactive terminal to ask) — nothing was applied');
210
364
  const answer = await confirm('Apply? [y/N] ');
211
365
  if (!answer) fail('aborted — nothing was applied');
212
366
  }
@@ -220,9 +374,37 @@ async function commandApply(options) {
220
374
  }
221
375
  }
222
376
 
223
- function commandShape(options) {
377
+ async function commandSnapshot(options) {
378
+ if (options.model === null) fail('snapshot needs --model');
379
+ const model = await loadDocument(options.model, 'model', 'model');
380
+ const snapshotFile = options.snapshot ?? defaultSnapshotOf(options.model);
381
+ const same = writeIfChanged(snapshotFile, JSON.stringify(model, null, 2) + '\n');
382
+ console.log(`${same ? 'unchanged' : 'wrote'} ${snapshotFile} (shape ${shapeHash(model)})`);
383
+ if (options.types === null) return;
384
+ // emit is loaded lazily, and only here: db does not depend on it, so a
385
+ // host without it is told exactly what --types needs
386
+ let emit;
387
+ let typescript;
388
+ try {
389
+ emit = await import('@jarenjs/emit');
390
+ typescript = await import('@jarenjs/emit/typescript');
391
+ }
392
+ catch (error) {
393
+ if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
394
+ return fail(`--types needs @jarenjs/emit beside @jarenjs/db, and it does not resolve `
395
+ + `(${error.message}) — install it: npm install @jarenjs/emit`);
396
+ }
397
+ throw error;
398
+ }
399
+ const declaration = typescript.renderTypeScript(
400
+ entityEmitModel(model, { compile: emit.compileEmitModel, source: options.model }), {});
401
+ const sameTypes = writeIfChanged(options.types, declaration);
402
+ console.log(`${sameTypes ? 'unchanged' : 'wrote'} ${options.types}`);
403
+ }
404
+
405
+ async function commandShape(options) {
224
406
  if (options.model === null) fail('shape needs --model');
225
- const model = readJson(options.model, 'model');
407
+ const model = await loadDocument(options.model, 'model', 'model');
226
408
  console.log(`shape hash: ${shapeHash(model)}`);
227
409
  for (const collection of normalizeModel(model).values()) {
228
410
  for (const sql of planCollection(collection.name, collection, sqliteDialect).createSql)
@@ -242,6 +424,109 @@ function commandShape(options) {
242
424
  console.log(`-- history rides in '${HISTORY_TABLE}'`);
243
425
  }
244
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
+
245
530
  async function main() {
246
531
  const options = parseArgs(process.argv);
247
532
  if (options.help || options.command === '--help' || options.command === '-h'
@@ -251,10 +536,12 @@ async function main() {
251
536
  }
252
537
  switch (options.command) {
253
538
  case 'plan': return commandPlan(options);
539
+ case 'snapshot': return commandSnapshot(options);
254
540
  case 'status': return commandStatus(options, { asCheck: false });
255
541
  case 'check': return commandStatus(options, { asCheck: true });
256
542
  case 'apply': return commandApply(options);
257
543
  case 'shape': return commandShape(options);
544
+ case 'documents': return commandDocuments(options);
258
545
  default: return fail(`unknown command '${options.command}' — try --help`);
259
546
  }
260
547
  }