@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/src/algebra.js CHANGED
@@ -11,9 +11,10 @@
11
11
  * One plan shape covers this version: a guarded selection over ONE
12
12
  * collection with optional ordering, window, aggregate and a
13
13
  * whole-document projection — or, instead of an ordering and a window,
14
- * a k-nearest RANK the engine finishes over the rows the plan fetches.
15
- * Constructs beyond it are residuals by design (see ARCHITECTURE.md's
16
- * deliberate-residual table).
14
+ * a k-nearest RANK the engine finishes over the rows the plan fetches,
15
+ * or, instead of a projection, a fixed-width temporal BUCKET the plan
16
+ * groups and aggregates itself. Constructs beyond it are residuals by
17
+ * design (see ARCHITECTURE.md's deliberate-residual table).
17
18
  */
18
19
 
19
20
  /** The plan format version, carried on every plan. */
@@ -57,6 +58,21 @@ export const PLAN_VERSION = 2;
57
58
  *
58
59
  * @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
59
60
  *
61
+ * @typedef {{ ref: PlanRef, every: number, origin: number, as: string,
62
+ * order: 'asc' | 'desc' | 'first-seen',
63
+ * aggregates: { fn: 'rows' | 'sum' | 'avg' | 'min' | 'max',
64
+ * ref: PlanRef | null, as: string,
65
+ * empty: 'null' | 'zero' | 'omit' }[] }} PlanBucket
66
+ * The fixed-width temporal GROUP BY: the instant column, the ladder's
67
+ * width and anchor in epoch milliseconds, the name the bucket's start
68
+ * is answered under, how the groups are ordered, and one aggregate
69
+ * per answered member. `rows` is `COUNT(*)` — the D5 count of SOURCE
70
+ * rows, duplicates and measured gaps included — and the four value
71
+ * aggregates skip a `NULL` reading exactly as the kernel skips a
72
+ * `null` one. `first-seen` order is the group's earliest row identity,
73
+ * which is the engine's own "order of first appearance" (§6.5).
74
+ * A plan carrying a bucket carries no `aggregate` and no `rank`.
75
+ *
60
76
  * @typedef {{ column: string, dims: number,
61
77
  * probe: { lit: number[] } | { ext: string },
62
78
  * offset: number, limit: number, margin: number }} PlanRank
@@ -79,6 +95,7 @@ export const PLAN_VERSION = 2;
79
95
  * order: PlanOrderTerm[] | null,
80
96
  * window: { offset: number, limit: number | null } | null,
81
97
  * rank: PlanRank | null,
98
+ * bucket: PlanBucket | null,
82
99
  * aggregate: { fn: 'count' | 'sum' | 'avg' | 'min' | 'max',
83
100
  * ref: PlanRef | null } | null,
84
101
  * project: 'document',
@@ -99,6 +116,7 @@ export function selectPlan(collection) {
99
116
  order: null,
100
117
  window: null,
101
118
  rank: null,
119
+ bucket: null,
102
120
  aggregate: null,
103
121
  project: 'document',
104
122
  };
@@ -123,6 +141,7 @@ export function conjoin(filter, predicate) {
123
141
  const SQL_TOKENS = [
124
142
  'SELECT', 'WHERE', 'ORDER BY', 'LIMIT ', 'INSERT', 'FROM ',
125
143
  'jsonb_extract', 'json_type', 'substr(', 'instr(', '"doc"', '@p1', ' AS ',
144
+ 'GROUP BY', 'COUNT(',
126
145
  ];
127
146
 
128
147
  /**
package/src/capture.js CHANGED
@@ -32,8 +32,8 @@
32
32
  import { createJSONPatch } from '@jarenjs/json/patch';
33
33
  import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
34
34
 
35
- import { DbRuntimeError } from './errors.js';
36
- import { chain } from './driver.js';
35
+ import { DbCompileError, DbRuntimeError } from './errors.js';
36
+ import { chain, attempt } from './driver.js';
37
37
 
38
38
  /** The persisted change log (LIVE-FORMAT §5). */
39
39
  export const CHANGES_TABLE = '_jaren_changes';
@@ -323,6 +323,11 @@ export function createCaptureEngine(options) {
323
323
  const { connection, shapes, mode } = options;
324
324
  const dialect = connection.dialect;
325
325
  const q = dialect.quoteIdentifier;
326
+ if (options.log && !(Number.isInteger(options.retention) && options.retention >= 1)) {
327
+ // a retention of 0 pruned every record the moment it was written,
328
+ // with the log reported as enabled
329
+ throw new TypeError('capture.log.retention must be a positive integer (records kept)');
330
+ }
326
331
 
327
332
  /** @type {Set<Function>} */
328
333
  const observers = new Set();
@@ -345,9 +350,15 @@ export function createCaptureEngine(options) {
345
350
  { name: 'patch', type: dialect.typeFor('string', 'key') },
346
351
  ],
347
352
  }),
353
+ // the sequence is allocated by the STATEMENT, inside the write's
354
+ // own transaction: a counter seeded once at open collided with
355
+ // another store's writes to the same file and rolled the user's
356
+ // write back with a raw UNIQUE failure
348
357
  insert: `INSERT INTO ${q(CHANGES_TABLE)} `
349
358
  + `(${['seq', 'at', 'source', 'patch'].map(q).join(', ')}) `
350
- + `VALUES (${[1, 2, 3, 4].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
359
+ + `VALUES ((SELECT COALESCE(MAX(${q('seq')}), 0) + 1 FROM ${q(CHANGES_TABLE)}), `
360
+ + `${[1, 2, 3].map((i) => dialect.parameterRef(i, 'v')).join(', ')}) `
361
+ + `RETURNING ${q('seq')} AS ${q('seq')}`,
351
362
  prune: `DELETE FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} <= ${dialect.parameterRef(1, 'v')}`,
352
363
  highest: `SELECT MAX(${q('seq')}) AS ${q('n')} FROM ${q(CHANGES_TABLE)}`,
353
364
  read: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
@@ -357,7 +368,10 @@ export function createCaptureEngine(options) {
357
368
 
358
369
  const ready = logStatements === null
359
370
  ? null
360
- : chain(connection.exec(logStatements.create), () =>
371
+ : chain(attempt(() => connection.exec(logStatements.create), (error) => new DbCompileError('JD0002',
372
+ `the change log table could not be created (${error?.message ?? String(error)}) — `
373
+ + 'a read-only store creates nothing; open it read-write once, or without capture.log',
374
+ '/capture', error)), () =>
361
375
  chain(connection.prepare(logStatements.highest), (statement) =>
362
376
  chain(statement.get([]), (row) => {
363
377
  seq = Number(row?.n ?? 0) || 0;
@@ -443,28 +457,45 @@ export function createCaptureEngine(options) {
443
457
 
444
458
  const persist = (patch, at) => {
445
459
  if (logStatements === null || patch.length === 0) return null;
446
- seq += 1;
447
- const mySeq = seq;
448
460
  return chain(connection.prepare(logStatements.insert), (insert) =>
449
- chain(insert.run([mySeq, at, mode, JSON.stringify(patch)]), () =>
450
- chain(connection.prepare(logStatements.prune), (prune) =>
451
- chain(prune.run([mySeq - options.retention]), () => null))));
461
+ chain(insert.get([at, mode, JSON.stringify(patch)]), (row) => {
462
+ seq = Number(row.seq);
463
+ return chain(connection.prepare(logStatements.prune), (prune) =>
464
+ chain(prune.run([seq - options.retention]), () => null));
465
+ }));
452
466
  };
453
467
 
468
+ /** The collections a patch touches, in first-seen order. */
469
+ const collectionsOf = (patch) => [...new Set(patch.map(
470
+ (op) => decodeJSONPointerSegment(op.path.split('/')[1])))];
471
+
472
+ let delivering = false;
454
473
  const deliver = () => {
455
- while (pendingDeliveries.length > 0) {
456
- const delivery = pendingDeliveries.shift();
457
- for (const observer of [...observers]) {
458
- // error isolation: a throwing observer must never affect the
459
- // write (the app.observe discipline)
460
- try {
461
- observer(delivery);
462
- }
463
- catch {
464
- // deliberately swallowed; the write already committed
474
+ // never re-entered: an observer that WRITES commits a further record
475
+ // from inside this loop, and delivering that record here handed it to
476
+ // every sibling before the older one — commit order inverted for
477
+ // them, and a maintained view kept a stale row for good. The nested
478
+ // call queues its record; this loop drains it after the current one.
479
+ if (delivering) return;
480
+ delivering = true;
481
+ try {
482
+ while (pendingDeliveries.length > 0) {
483
+ const delivery = pendingDeliveries.shift();
484
+ for (const observer of [...observers]) {
485
+ // error isolation: a throwing observer must never affect the
486
+ // write (the app.observe discipline)
487
+ try {
488
+ observer(delivery);
489
+ }
490
+ catch {
491
+ // deliberately swallowed; the write already committed
492
+ }
465
493
  }
466
494
  }
467
495
  }
496
+ finally {
497
+ delivering = false;
498
+ }
468
499
  };
469
500
 
470
501
  /**
@@ -498,9 +529,7 @@ export function createCaptureEngine(options) {
498
529
  seq: logStatements === null ? (seq += 1) : seq,
499
530
  at,
500
531
  source: mode,
501
- collections: [...new Set(patch.map(
502
- (op) => decodeJSONPointerSegment(op.path.split('/')[1]))),
503
- ],
532
+ collections: collectionsOf(patch),
504
533
  patch,
505
534
  },
506
535
  }));
@@ -570,13 +599,22 @@ export function createCaptureEngine(options) {
570
599
  throw new DbRuntimeError('JD2051',
571
600
  'the change log is not enabled — open the store with capture.log');
572
601
  }
602
+ if (typeof after !== 'number' || !Number.isFinite(after)) {
603
+ throw new TypeError(`changesSince(after) takes the last seq seen as a number, got ${
604
+ after === undefined ? 'undefined' : JSON.stringify(after)}`);
605
+ }
573
606
  return chain(connection.prepare(logStatements.read), (statement) =>
574
- chain(statement.all([after]), (rows) => rows.map((row) => ({
575
- seq: Number(row.seq),
576
- at: Number(row.at),
577
- source: String(row.source),
578
- patch: JSON.parse(row.patch),
579
- }))));
607
+ chain(statement.all([after]), (rows) => rows.map((row) => {
608
+ const patch = JSON.parse(row.patch);
609
+ // the same record shape observers receive: `collections` too
610
+ return {
611
+ seq: Number(row.seq),
612
+ at: Number(row.at),
613
+ source: String(row.source),
614
+ collections: collectionsOf(patch),
615
+ patch,
616
+ };
617
+ })));
580
618
  },
581
619
  };
582
620
  }
package/src/cli.js CHANGED
@@ -2,39 +2,61 @@
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';
14
+ import { pathToFileURL } from 'url';
15
+
16
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
11
17
 
12
18
  import {
13
- planModelMigration, migrate, migrationStatus, shapeHash,
19
+ planModelMigration, migrate, migrationStatus, shapeHash, compareShapeToModel,
14
20
  sqliteDialect, normalizeModel, normalizeEntities, explainMapping,
15
- planCollection, planEntity, planJoinTable, HISTORY_TABLE,
21
+ planCollection, planEntity, planJoinTable, HISTORY_TABLE, entityEmitModel,
16
22
  } from './index.js';
17
23
  import { nodeDriver } from './drivers/node.js';
18
24
 
19
25
  const USAGE = `jaren-db — model-driven SQLite migrations
20
26
 
21
27
  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>
28
+ jaren-db plan --from <model> --to <model> [--store <db>] [--id <name>] [--out <file>]
29
+ jaren-db plan --model <model> [--snapshot <file>] [--store <db>] [--id <name>] --out <file>
30
+ jaren-db snapshot --model <model> [--snapshot <file>] [--types <file>]
31
+ jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>] [--snapshot <file>]
32
+ 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>]
34
+ jaren-db shape --model <model>
35
+
36
+ A <model> or a migration is a .json file, or a MODULE (.js, .mjs, .cjs —
37
+ or .ts where Node strips types) whose default export, or its 'model' /
38
+ 'migration' export, is the document or a pen builder that emits one. A
39
+ module is loaded twice and refused when its two emissions differ: no
40
+ clock, no env, no randomness. --migrations reads .json files and
41
+ modules, sorted by file name.
27
42
 
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.
43
+ plan Diff two model FILES into a migration document (a database
44
+ stores shape hashes, not models — the from-model is the
45
+ previous model file), or diff the committed SNAPSHOT (default
46
+ model.snapshot.json beside the model) against the model: with
47
+ --out the migration is written and the snapshot advanced; a
48
+ model matching its snapshot plans nothing. With --store, first
49
+ verify the from-model matches the database's recorded shape.
50
+ snapshot Write the model's snapshot; with --types, emit's TypeScript
51
+ declaration for it (needs @jarenjs/emit beside @jarenjs/db).
52
+ status Applied, pending, drift (a hand-modified database) and — with
53
+ a snapshot — an unplanned model change.
54
+ apply Print every statement, then apply. Destructive steps (drop
55
+ table/column, rebuild) require --yes or an interactive
56
+ confirmation naming what is lost. --dry-run only prints.
57
+ check The CI command: exit 1 on an unplanned model change, pending
58
+ migrations or drift.
59
+ shape Print the physical mapping a model produces.
38
60
  `;
39
61
 
40
62
  function fail(message) {
@@ -46,6 +68,7 @@ function parseArgs(argv) {
46
68
  const options = {
47
69
  command: argv[2], from: null, to: null, model: null, store: null,
48
70
  baseline: null, migrations: null, id: null, out: null,
71
+ snapshot: null, types: null,
49
72
  dryRun: false, yes: false, help: false,
50
73
  };
51
74
  for (let i = 3; i < argv.length; i++) {
@@ -58,6 +81,8 @@ function parseArgs(argv) {
58
81
  case '--migrations': options.migrations = argv[++i]; break;
59
82
  case '--id': options.id = argv[++i]; break;
60
83
  case '--out': options.out = argv[++i]; break;
84
+ case '--snapshot': options.snapshot = argv[++i]; break;
85
+ case '--types': options.types = argv[++i]; break;
61
86
  case '--dry-run': options.dryRun = true; break;
62
87
  case '--yes': options.yes = true; break;
63
88
  case '--help': case '-h': options.help = true; break;
@@ -76,13 +101,82 @@ const readJson = (file, what) => {
76
101
  }
77
102
  };
78
103
 
79
- /** The migrations directory, sorted — the full ordered chain. */
80
- const readMigrationsDir = (dir) => {
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
+ /**
129
+ * A model or migration document: a `.json` file, or a module loaded
130
+ * TWICE — an emission that changes between loads is not pure (a clock,
131
+ * the environment, randomness), and a migration that hashes differently
132
+ * per load can never match its own history.
133
+ */
134
+ 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');
157
+ }
158
+ return first;
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) => (file.endsWith('.json') || MODULE_EXT.test(file)) && !file.endsWith('.d.ts'))
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,14 +255,39 @@ 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
270
  if (options.store === null || options.baseline === null)
143
271
  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);
272
+ // `check` without the model verified nothing and printed "in sync"
273
+ if (asCheck && options.model === null)
274
+ fail('check needs --model — drift is measured against the model the code carries');
275
+ const baseline = await loadDocument(options.baseline, 'baseline model', 'model');
276
+ const model = options.model !== null ? await loadDocument(options.model, 'model', 'model') : undefined;
277
+ const migrations = await loadMigrationsDir(options.migrations);
278
+ // the snapshot discipline, when it is in use: a model that moved
279
+ // without a plan is named as such, never as the database's drift
280
+ const snapshotFile = options.snapshot ?? (options.model !== null ? defaultSnapshotOf(options.model) : null);
281
+ let unplanned = null;
282
+ const snapshotInUse = snapshotFile !== null && (options.snapshot !== null || fs.existsSync(snapshotFile));
283
+ if (snapshotInUse) {
284
+ if (!fs.existsSync(snapshotFile)) fail(`no snapshot at '${snapshotFile}'`);
285
+ const recorded = shapeHash(readJson(snapshotFile, 'snapshot'));
286
+ const current = shapeHash(model);
287
+ if (recorded !== current) {
288
+ unplanned = `${snapshotFile} records shape ${recorded}, the model is ${current} — run jaren-db plan`;
289
+ }
290
+ }
147
291
  let status;
148
292
  try {
149
293
  status = await migrationStatus({ driver: nodeDriver(), path: options.store },
@@ -157,7 +301,12 @@ async function commandStatus(options, { asCheck }) {
157
301
  if (model !== undefined && status.pending.length === 0) {
158
302
  console.log(`drift: ${status.drift === null ? 'none — in sync' : status.drift}`);
159
303
  }
304
+ if (snapshotInUse) {
305
+ console.log(`model: ${unplanned === null ? 'planned — matches its snapshot' : `UNPLANNED change — ${unplanned}`}`);
306
+ }
160
307
  if (asCheck) {
308
+ if (unplanned !== null)
309
+ fail(`unplanned model change: ${unplanned}`);
161
310
  if (status.pending.length > 0)
162
311
  fail(`${status.pending.length} pending migration(s) — run jaren-db apply`);
163
312
  if (status.drift !== null)
@@ -177,9 +326,9 @@ const confirm = (question) => new Promise((resolve) => {
177
326
  async function commandApply(options) {
178
327
  if (options.store === null || options.baseline === null || options.migrations === null)
179
328
  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);
329
+ const baseline = await loadDocument(options.baseline, 'baseline model', 'model');
330
+ const model = options.model !== null ? await loadDocument(options.model, 'model', 'model') : undefined;
331
+ const migrations = await loadMigrationsDir(options.migrations);
183
332
  const target = { driver: nodeDriver(), path: options.store };
184
333
 
185
334
  const status = await migrationStatus(target, migrations, { baseline })
@@ -206,7 +355,10 @@ async function commandApply(options) {
206
355
  const answer = await confirm('Apply anyway? [y/N] ');
207
356
  if (!answer) fail('aborted — nothing was applied');
208
357
  }
209
- else if (!options.yes && process.stdin.isTTY) {
358
+ else if (!options.yes) {
359
+ // "default is dry-run + ask" (MIGRATION-FORMAT §11): where nobody
360
+ // can be asked, the statements above are the dry run and nothing runs
361
+ if (!process.stdin.isTTY) fail('apply needs --yes (no interactive terminal to ask) — nothing was applied');
210
362
  const answer = await confirm('Apply? [y/N] ');
211
363
  if (!answer) fail('aborted — nothing was applied');
212
364
  }
@@ -220,9 +372,37 @@ async function commandApply(options) {
220
372
  }
221
373
  }
222
374
 
223
- function commandShape(options) {
375
+ async function commandSnapshot(options) {
376
+ if (options.model === null) fail('snapshot needs --model');
377
+ const model = await loadDocument(options.model, 'model', 'model');
378
+ const snapshotFile = options.snapshot ?? defaultSnapshotOf(options.model);
379
+ const same = writeIfChanged(snapshotFile, JSON.stringify(model, null, 2) + '\n');
380
+ console.log(`${same ? 'unchanged' : 'wrote'} ${snapshotFile} (shape ${shapeHash(model)})`);
381
+ if (options.types === null) return;
382
+ // emit is loaded lazily, and only here: db does not depend on it, so a
383
+ // host without it is told exactly what --types needs
384
+ let emit;
385
+ let typescript;
386
+ try {
387
+ emit = await import('@jarenjs/emit');
388
+ typescript = await import('@jarenjs/emit/typescript');
389
+ }
390
+ catch (error) {
391
+ if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
392
+ return fail(`--types needs @jarenjs/emit beside @jarenjs/db, and it does not resolve `
393
+ + `(${error.message}) — install it: npm install @jarenjs/emit`);
394
+ }
395
+ throw error;
396
+ }
397
+ const declaration = typescript.renderTypeScript(
398
+ entityEmitModel(model, { compile: emit.compileEmitModel, source: options.model }), {});
399
+ const sameTypes = writeIfChanged(options.types, declaration);
400
+ console.log(`${sameTypes ? 'unchanged' : 'wrote'} ${options.types}`);
401
+ }
402
+
403
+ async function commandShape(options) {
224
404
  if (options.model === null) fail('shape needs --model');
225
- const model = readJson(options.model, 'model');
405
+ const model = await loadDocument(options.model, 'model', 'model');
226
406
  console.log(`shape hash: ${shapeHash(model)}`);
227
407
  for (const collection of normalizeModel(model).values()) {
228
408
  for (const sql of planCollection(collection.name, collection, sqliteDialect).createSql)
@@ -251,6 +431,7 @@ async function main() {
251
431
  }
252
432
  switch (options.command) {
253
433
  case 'plan': return commandPlan(options);
434
+ case 'snapshot': return commandSnapshot(options);
254
435
  case 'status': return commandStatus(options, { asCheck: false });
255
436
  case 'check': return commandStatus(options, { asCheck: true });
256
437
  case 'apply': return commandApply(options);
package/src/ddl.js CHANGED
@@ -70,12 +70,32 @@ export function compileIndexPath(expression, docPath) {
70
70
  `the index path '${expression}' selects the whole document — index a member`,
71
71
  docPath);
72
72
  }
73
- const canonical = segments
74
- .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`))
73
+ return { segments, canonical: canonicalOf(segments) };
74
+ }
75
+
76
+ /**
77
+ * The canonical spelling of a member path — the key every generated
78
+ * column and every promoted reference is matched by. INJECTIVE: a
79
+ * member literally named `a.b` and the nested path `a` → `b` used to
80
+ * spell the same `.a.b`, so an index over one silently served the
81
+ * other and a filter on the flat member answered from the nested
82
+ * value. Names are JSON-quoted, so no two paths share a spelling; the
83
+ * generated column STEM strips the quotes and keeps its old form.
84
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
85
+ * @returns {string}
86
+ */
87
+ export function canonicalOf(segments) {
88
+ return segments
89
+ .map((s) => ('name' in s
90
+ ? (IDENTIFIER.test(s.name) ? `.${s.name}` : `.${JSON.stringify(s.name)}`)
91
+ : `[${s.index}]`))
75
92
  .join('');
76
- return { segments, canonical };
77
93
  }
78
94
 
95
+ /** A member name that spells itself: anything else is JSON-quoted in
96
+ * the canonical, so `.a.b` (nested) and `."a.b"` (one member) differ. */
97
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
98
+
79
99
  /**
80
100
  * The schema subschema at a segment path, walked structurally through
81
101
  * `properties` / `items` / `prefixItems`. The collection's schema is
package/src/dialect.js CHANGED
@@ -58,6 +58,9 @@
58
58
  * strEndsWith: (valueSql: string, patternA: string, patternB: string, patternC: string) => string,
59
59
  * strContains: (valueSql: string, patternSql: string) => string,
60
60
  * orderNulls: (nullsFirst: boolean) => string,
61
+ * timeBucket: (instantSql: string, originSql: string, everyA: string,
62
+ * everyB: string, everyC: string) => string,
63
+ * groupAggregate: (fn: string, valueSql: string | null) => string,
61
64
  * rowIdentity: () => string,
62
65
  * identityIn: (identitySql: string, paramSqls: string[]) => string,
63
66
  * rtree?: { module: string, columns: readonly string[] },
@@ -473,6 +476,16 @@ export function createDialect(spec) {
473
476
  strEndsWith: spec.strEndsWith,
474
477
  strContains: spec.strContains,
475
478
  orderNulls: spec.orderNulls,
479
+ /**
480
+ * The instant a fixed-width bucket ladder labels one row with:
481
+ * `origin + floor((at - origin) / every) * every`, which reduces to
482
+ * `at` less the non-negative remainder. The parameters appear in
483
+ * TEXT order — the origin once, the width three times — because a
484
+ * positional dialect numbers them by where they are written.
485
+ */
486
+ timeBucket: spec.timeBucket,
487
+ /** One grouped aggregate; `null` counts ROWS rather than values. */
488
+ groupAggregate: spec.groupAggregate,
476
489
  rowIdentity: spec.rowIdentity,
477
490
  /**
478
491
  * The R\*Tree spelling: the module name and the virtual table's own