@jarenjs/db 0.34.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 (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/cli.js ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ //#region the jaren-db command
3
+ // Migrations nobody drives by API stay undrifted by nobody: the CLI is
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.
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as readline from 'readline';
11
+
12
+ import {
13
+ planModelMigration, migrate, migrationStatus, shapeHash,
14
+ sqliteDialect, normalizeModel, normalizeEntities, explainMapping,
15
+ planCollection, planEntity, planJoinTable, HISTORY_TABLE,
16
+ } from './index.js';
17
+ import { nodeDriver } from './drivers/node.js';
18
+
19
+ const USAGE = `jaren-db — model-driven SQLite migrations
20
+
21
+ Usage:
22
+ jaren-db plan --from <model> --to <model> [--store <db>] [--id <name>] [--out <file>]
23
+ jaren-db status --model <model> --store <db> --baseline <model> [--migrations <dir>]
24
+ jaren-db apply --store <db> --baseline <model> --migrations <dir> [--model <m>] [--dry-run] [--yes]
25
+ jaren-db check --model <model> --store <db> --baseline <model> [--migrations <dir>]
26
+ jaren-db shape --model <model>
27
+
28
+ plan Diff two model FILES into a migration document (a database
29
+ stores shape hashes, not models — the from-model is the
30
+ previous model file). With --store, first verify the
31
+ from-model matches the database's recorded shape.
32
+ status Applied, pending, and drift (a hand-modified database).
33
+ apply Print every statement, then apply. Destructive steps (drop
34
+ table/column, rebuild) require --yes or an interactive
35
+ confirmation naming what is lost. --dry-run only prints.
36
+ check The CI command: exit 1 on pending migrations or drift.
37
+ shape Print the physical mapping a model produces.
38
+ `;
39
+
40
+ function fail(message) {
41
+ console.error(`jaren-db: ${message}`);
42
+ process.exit(1);
43
+ }
44
+
45
+ function parseArgs(argv) {
46
+ const options = {
47
+ command: argv[2], from: null, to: null, model: null, store: null,
48
+ baseline: null, migrations: null, id: null, out: null,
49
+ dryRun: false, yes: false, help: false,
50
+ };
51
+ for (let i = 3; i < argv.length; i++) {
52
+ switch (argv[i]) {
53
+ case '--from': options.from = argv[++i]; break;
54
+ case '--to': options.to = argv[++i]; break;
55
+ case '--model': options.model = argv[++i]; break;
56
+ case '--store': options.store = argv[++i]; break;
57
+ case '--baseline': options.baseline = argv[++i]; break;
58
+ case '--migrations': options.migrations = argv[++i]; break;
59
+ case '--id': options.id = argv[++i]; break;
60
+ case '--out': options.out = argv[++i]; break;
61
+ case '--dry-run': options.dryRun = true; break;
62
+ case '--yes': options.yes = true; break;
63
+ case '--help': case '-h': options.help = true; break;
64
+ default: fail(`unknown option: ${argv[i]}`);
65
+ }
66
+ }
67
+ return options;
68
+ }
69
+
70
+ const readJson = (file, what) => {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ }
74
+ catch (error) {
75
+ return fail(`cannot read ${what} '${file}': ${error.message}`);
76
+ }
77
+ };
78
+
79
+ /** The migrations directory, sorted — the full ordered chain. */
80
+ const readMigrationsDir = (dir) => {
81
+ 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'));
86
+ };
87
+
88
+ /** What a migration will destroy, by note — the confirmation names it. */
89
+ const lossesOf = (migration) => migration.steps
90
+ .filter((step) => /DESTRUCTIVE/.test(step.note ?? '') || step.kind === 'rebuild')
91
+ .map((step) => step.note ?? `${step.kind} '${step.table ?? ''}'`);
92
+
93
+ const renderSteps = (migration) => {
94
+ for (const step of migration.steps) {
95
+ if (step.kind === 'ddl' || step.kind === 'sql') console.log(` ${step.sql}`);
96
+ else if (step.kind === 'rebuild') {
97
+ for (const sql of step.create) console.log(` ${sql}`);
98
+ console.log(` ${step.copy}`);
99
+ console.log(` -- drop '${step.table}', rename '${step.table}__rebuild', `
100
+ + 'recreate indexes, PRAGMA foreign_key_check (§10)');
101
+ for (const sql of step.indexes) console.log(` ${sql}`);
102
+ }
103
+ else console.log(` -- ${step.kind}${step.draft ? ' (DRAFT)' : ''}: ${step.note ?? ''}`);
104
+ }
105
+ };
106
+
107
+ 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');
112
+ 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}) — `
119
+ + 'is this really the previous model?');
120
+ }
121
+ }
122
+ const { migration, report } = planModelMigration(fromModel, toModel, {
123
+ dialect: sqliteDialect,
124
+ id: options.id ?? undefined,
125
+ });
126
+ const rendered = JSON.stringify(migration, null, 2);
127
+ if (options.out !== null) {
128
+ fs.writeFileSync(options.out, rendered + '\n');
129
+ console.log(`wrote ${options.out} (${migration.steps.length} step(s))`);
130
+ }
131
+ else {
132
+ console.log(rendered);
133
+ }
134
+ if (report.drafts.length > 0) {
135
+ console.error(`NOTE: draft data transform(s) for ${report.drafts.join(', ')} — `
136
+ + 'fill them in before applying');
137
+ }
138
+ if (report.destructive) console.error('NOTE: this migration is DESTRUCTIVE');
139
+ }
140
+
141
+ async function commandStatus(options, { asCheck }) {
142
+ if (options.store === null || options.baseline === null)
143
+ fail(`${asCheck ? 'check' : 'status'} needs --store and --baseline`);
144
+ const baseline = readJson(options.baseline, 'baseline model');
145
+ const model = options.model !== null ? readJson(options.model, 'model') : undefined;
146
+ const migrations = readMigrationsDir(options.migrations);
147
+ let status;
148
+ try {
149
+ status = await migrationStatus({ driver: nodeDriver(), path: options.store },
150
+ migrations, { baseline, model });
151
+ }
152
+ catch (error) {
153
+ return fail(error.message);
154
+ }
155
+ console.log(`applied: ${status.applied.length === 0 ? '(none)' : status.applied.join(', ')}`);
156
+ console.log(`pending: ${status.pending.length === 0 ? '(none)' : status.pending.join(', ')}`);
157
+ if (model !== undefined && status.pending.length === 0) {
158
+ console.log(`drift: ${status.drift === null ? 'none — in sync' : status.drift}`);
159
+ }
160
+ if (asCheck) {
161
+ if (status.pending.length > 0)
162
+ fail(`${status.pending.length} pending migration(s) — run jaren-db apply`);
163
+ if (status.drift !== null)
164
+ fail(`the database drifted from the model: ${status.drift}`);
165
+ console.log('in sync');
166
+ }
167
+ }
168
+
169
+ const confirm = (question) => new Promise((resolve) => {
170
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
171
+ rl.question(question, (answer) => {
172
+ rl.close();
173
+ resolve(/^y(es)?$/i.test(answer.trim()));
174
+ });
175
+ });
176
+
177
+ async function commandApply(options) {
178
+ if (options.store === null || options.baseline === null || options.migrations === null)
179
+ 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);
183
+ const target = { driver: nodeDriver(), path: options.store };
184
+
185
+ const status = await migrationStatus(target, migrations, { baseline })
186
+ .catch((error) => fail(error.message));
187
+ if (status.pending.length === 0) {
188
+ console.log('nothing to apply — up to date');
189
+ return;
190
+ }
191
+ console.log(`pending: ${status.pending.join(', ')}`);
192
+ const pendingDocs = migrations.slice(status.applied.length);
193
+ for (const migration of pendingDocs) {
194
+ console.log(`-- ${migration.id} (${migration.from.slice(0, 8)} → ${migration.to.slice(0, 8)})`);
195
+ renderSteps(migration);
196
+ }
197
+ if (options.dryRun) return;
198
+
199
+ const losses = pendingDocs.flatMap(lossesOf);
200
+ if (losses.length > 0 && !options.yes) {
201
+ console.log('\nThis migration is destructive:');
202
+ for (const loss of losses) console.log(` - ${loss}`);
203
+ if (!process.stdin.isTTY) {
204
+ fail('destructive steps need --yes (no interactive terminal to ask)');
205
+ }
206
+ const answer = await confirm('Apply anyway? [y/N] ');
207
+ if (!answer) fail('aborted — nothing was applied');
208
+ }
209
+ else if (!options.yes && process.stdin.isTTY) {
210
+ const answer = await confirm('Apply? [y/N] ');
211
+ if (!answer) fail('aborted — nothing was applied');
212
+ }
213
+
214
+ try {
215
+ const outcome = await migrate(target, migrations, { baseline, model });
216
+ console.log(`applied: ${outcome.applied.join(', ')}`);
217
+ }
218
+ catch (error) {
219
+ fail(error.message);
220
+ }
221
+ }
222
+
223
+ function commandShape(options) {
224
+ if (options.model === null) fail('shape needs --model');
225
+ const model = readJson(options.model, 'model');
226
+ console.log(`shape hash: ${shapeHash(model)}`);
227
+ for (const collection of normalizeModel(model).values()) {
228
+ for (const sql of planCollection(collection.name, collection, sqliteDialect).createSql)
229
+ console.log(sql);
230
+ }
231
+ if (normalizeEntities(model).size > 0) {
232
+ const mapping = explainMapping(model);
233
+ for (const name of Object.keys(mapping.entities)) {
234
+ for (const sql of planEntity(name, mapping.entities[name], mapping, sqliteDialect).createSql)
235
+ console.log(sql);
236
+ }
237
+ for (const name of Object.keys(mapping.joinTables)) {
238
+ for (const sql of planJoinTable(name, mapping.joinTables[name], mapping, sqliteDialect).createSql)
239
+ console.log(sql);
240
+ }
241
+ }
242
+ console.log(`-- history rides in '${HISTORY_TABLE}'`);
243
+ }
244
+
245
+ async function main() {
246
+ const options = parseArgs(process.argv);
247
+ if (options.help || options.command === '--help' || options.command === '-h'
248
+ || options.command === undefined) {
249
+ console.log(USAGE);
250
+ process.exit(options.command === undefined && !options.help ? 1 : 0);
251
+ }
252
+ switch (options.command) {
253
+ case 'plan': return commandPlan(options);
254
+ case 'status': return commandStatus(options, { asCheck: false });
255
+ case 'check': return commandStatus(options, { asCheck: true });
256
+ case 'apply': return commandApply(options);
257
+ case 'shape': return commandShape(options);
258
+ default: return fail(`unknown command '${options.command}' — try --help`);
259
+ }
260
+ }
261
+
262
+ main().catch((error) => fail(error.message));
263
+
264
+ //#endregion
package/src/dag-job.js ADDED
@@ -0,0 +1,86 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The composition (JOBS-FORMAT §7): a persisted `@jarenjs/flow`
4
+ * DAG run wired to a queue job. THE FLOW ENGINE IS INJECTED, NEVER
5
+ * IMPORTED — the shared invariant forbids `@jarenjs/db` importing
6
+ * `@jarenjs/flow`, so `compileDag` arrives as a capability (the D10
7
+ * shape applied to flow) and a test asserts the manifest and import
8
+ * graph name flow nowhere.
9
+ *
10
+ * Each kind's document compiles ONCE against a delegating checkpoint
11
+ * store; per claimed job, the delegate binds the engine's guarded
12
+ * per-job store (`checkpointsFor`) — `save` refuses once the lease is
13
+ * lost and `complete` records the DAG result, marks the job done and
14
+ * prunes the checkpoint rows in ONE transaction, so a failure leaves
15
+ * neither and a crash resumes instead of restarting.
16
+ */
17
+
18
+ /**
19
+ * Build a worker whose handlers run checkpointed DAG documents.
20
+ * @param {any} store - an open store with `{ jobs: true }`
21
+ * @param {{ compileDag: Function,
22
+ * documents: Record<string, any>,
23
+ * tasks?: Record<string, Function>,
24
+ * concurrency?: number, pollInterval?: number, leaseMs?: number,
25
+ * owner?: string, backoffBase?: number, backoffCap?: number }} options
26
+ * @returns {{ start: () => any, stop: () => Promise<void>, stats: () => any }}
27
+ */
28
+ export function createDagJobRunner(store, options) {
29
+ if (store?.jobs === undefined) {
30
+ throw new TypeError(
31
+ 'createDagJobRunner: the store was opened without { jobs } — nothing to compose');
32
+ }
33
+ const { compileDag, documents } = options ?? {};
34
+ if (typeof compileDag !== 'function') {
35
+ throw new TypeError(
36
+ 'createDagJobRunner: "compileDag" must be injected from @jarenjs/flow — '
37
+ + 'this package deliberately does not import it');
38
+ }
39
+ if (documents === null || typeof documents !== 'object'
40
+ || Object.keys(documents).length === 0) {
41
+ throw new TypeError(
42
+ 'createDagJobRunner: "documents" must map job kinds to dag documents');
43
+ }
44
+
45
+ /** The active claim contexts, keyed by run id (= job id): the
46
+ * delegate resolves the CURRENT lease binding per store call. */
47
+ const active = new Map();
48
+ const boundStore = (runId) => {
49
+ const context = active.get(runId);
50
+ if (context === undefined) {
51
+ throw new Error(`no active job holds run '${runId}'`);
52
+ }
53
+ return context.checkpointsFor(context.job);
54
+ };
55
+ const checkpoint = {
56
+ load: (runId) => boundStore(runId).load(runId),
57
+ save: (runId, nodeId, value) => boundStore(runId).save(runId, nodeId, value),
58
+ complete: (runId, result) => boundStore(runId).complete(runId, result),
59
+ };
60
+
61
+ /** @type {Record<string, Function>} */
62
+ const handlers = {};
63
+ for (const kind of Object.keys(documents)) {
64
+ const compiled = compileDag(documents[kind],
65
+ { tasks: options.tasks ?? {}, checkpoint });
66
+ handlers[kind] = async (payload, context) => {
67
+ active.set(context.job.id, context);
68
+ try {
69
+ return await compiled.run(payload?.input ?? null, { runId: context.job.id });
70
+ }
71
+ finally {
72
+ active.delete(context.job.id);
73
+ }
74
+ };
75
+ }
76
+
77
+ return store.jobs.createWorker({
78
+ handlers,
79
+ concurrency: options.concurrency,
80
+ pollInterval: options.pollInterval,
81
+ leaseMs: options.leaseMs,
82
+ owner: options.owner,
83
+ backoffBase: options.backoffBase,
84
+ backoffCap: options.backoffCap,
85
+ });
86
+ }