@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.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -149
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -167
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live.d.ts +0 -62
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- 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
|
-
*
|
|
16
|
-
*
|
|
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 (${
|
|
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.
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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:
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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.
|
|
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
|
|
23
|
-
jaren-db
|
|
24
|
-
jaren-db
|
|
25
|
-
jaren-db
|
|
26
|
-
jaren-db
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
80
|
-
const
|
|
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
|
-
|
|
83
|
-
.filter((file) => file.endsWith('.json'))
|
|
84
|
-
.sort()
|
|
85
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
181
|
-
const model = options.model !== null ?
|
|
182
|
-
const 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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
74
|
-
|
|
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
|