@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.
- package/ARCHITECTURE.md +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- 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 -154
- 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 -170
- 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-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- 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 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- 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/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.
|
|
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
|
|
23
|
-
jaren-db
|
|
24
|
-
jaren-db
|
|
25
|
-
jaren-db
|
|
26
|
-
jaren-db
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
.filter((file) => file
|
|
84
|
-
.sort()
|
|
85
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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, {
|
|
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 =
|
|
181
|
-
const model = options.model !== null ?
|
|
182
|
-
const 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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
}
|