@jarenjs/db 0.34.2 → 0.43.3
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 +115 -12
- package/README.md +47 -0
- package/dist/types/algebra.d.ts +38 -2
- package/dist/types/ddl.d.ts +40 -6
- package/dist/types/derive.d.ts +161 -0
- package/dist/types/dialect.d.ts +11 -2
- package/dist/types/driver.d.ts +2 -20
- package/dist/types/emit.d.ts +6 -3
- package/dist/types/errors.d.ts +6 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/migrate.d.ts +7 -1
- package/dist/types/plan.d.ts +15 -1
- package/dist/types/residual.d.ts +17 -6
- package/dist/types/udf.d.ts +6 -1
- package/docs/MIGRATION-FORMAT.md +30 -1
- package/docs/MODEL-FORMAT.md +155 -4
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +71 -0
- package/schemas/jaren-migration.schema.json +71 -0
- package/schemas/jaren-model.draft-07.schema.json +14 -1
- package/schemas/jaren-model.schema.json +18 -5
- package/src/algebra.js +17 -2
- package/src/ddl.js +146 -17
- package/src/derive.js +284 -0
- package/src/dialect.js +89 -25
- package/src/dialects/sqlite.js +16 -1
- package/src/driver.js +6 -28
- package/src/emit.js +122 -22
- package/src/errors.js +6 -4
- package/src/index.js +5 -0
- package/src/migrate.js +132 -19
- package/src/plan.js +514 -32
- package/src/query.js +61 -9
- package/src/residual.js +18 -10
- package/src/store.js +122 -8
- package/src/udf.js +12 -3
package/src/migrate.js
CHANGED
|
@@ -31,6 +31,22 @@ import { chain, toPromise } from './driver.js';
|
|
|
31
31
|
import { normalizeModel } from './store.js';
|
|
32
32
|
import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
|
|
33
33
|
import { normalizeEntities, explainMapping } from './model.js';
|
|
34
|
+
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The physical mapping a connection's driver imposes on derived index
|
|
38
|
+
* columns. A driver that can index a registered deterministic function
|
|
39
|
+
* generates them; one that cannot has them written, which is why the
|
|
40
|
+
* planner emits a backfill for the second and not the first.
|
|
41
|
+
* @param {any} connection
|
|
42
|
+
* @returns {{ derived: 'virtual' | 'stored' }}
|
|
43
|
+
*/
|
|
44
|
+
function mappingFor(connection) {
|
|
45
|
+
return {
|
|
46
|
+
derived: connection.capabilities?.deterministicIndexableFunctions === true
|
|
47
|
+
? 'virtual' : 'stored',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
34
50
|
|
|
35
51
|
/** The migration format version. */
|
|
36
52
|
export const MIGRATION_VERSION = '0.1';
|
|
@@ -67,6 +83,30 @@ function refuse(code, reason, cause) {
|
|
|
67
83
|
return new DbCompileError(code, reason, undefined, cause);
|
|
68
84
|
}
|
|
69
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A backfill step: recompute named STORED derived columns from the
|
|
88
|
+
* documents already in a collection. Idempotent by construction — the
|
|
89
|
+
* value is a pure function of the document — so a second run writes
|
|
90
|
+
* what the first did.
|
|
91
|
+
* @param {string} collection
|
|
92
|
+
* @param {any} plan - the target collection's physical plan
|
|
93
|
+
* @param {string[]} columnNames
|
|
94
|
+
* @param {string} note
|
|
95
|
+
* @returns {any} the migration step
|
|
96
|
+
*/
|
|
97
|
+
function deriveStep(collection, plan, columnNames, note) {
|
|
98
|
+
const columns = plan.derived
|
|
99
|
+
.filter((column) => columnNames.includes(column.name))
|
|
100
|
+
.map((column) => {
|
|
101
|
+
/** @type {any} */
|
|
102
|
+
const entry = { name: column.name, derive: column.derive, segments: column.segments };
|
|
103
|
+
if (column.precision !== undefined) entry.precision = column.precision;
|
|
104
|
+
if (column.component !== undefined) entry.component = column.component;
|
|
105
|
+
return entry;
|
|
106
|
+
});
|
|
107
|
+
return { kind: 'derive', collection, columns, note };
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
/**
|
|
71
111
|
* Plan a migration between two model documents. The planner diffs the
|
|
72
112
|
* PHYSICAL plans (columns, indexes) and renders DDL through the
|
|
@@ -74,9 +114,14 @@ function refuse(code, reason, cause) {
|
|
|
74
114
|
* refuses to run until the author fills it in — the planner cannot
|
|
75
115
|
* infer a data transform and does not pretend to. Renames are declared
|
|
76
116
|
* (`x-rename` on the target collection), never guessed.
|
|
117
|
+
* The physical mapping of a DERIVED index column depends on the driver
|
|
118
|
+
* that will run the migration (`derived`), because the two mappings
|
|
119
|
+
* really are different columns; a migration document planned for one is
|
|
120
|
+
* not the document the other needs.
|
|
77
121
|
* @param {any} fromModel
|
|
78
122
|
* @param {any} toModel
|
|
79
|
-
* @param {{ id?: string, dialect?: any
|
|
123
|
+
* @param {{ id?: string, dialect?: any,
|
|
124
|
+
* derived?: 'virtual' | 'stored' }} [options]
|
|
80
125
|
* @returns {{ migration: any, report: {
|
|
81
126
|
* renamed: { from: string, to: string }[],
|
|
82
127
|
* added: string[], removed: string[],
|
|
@@ -87,6 +132,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
87
132
|
const dialect = options?.dialect ?? null;
|
|
88
133
|
if (dialect === null || typeof dialect !== 'object')
|
|
89
134
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
135
|
+
const mapping = { derived: options?.derived ?? 'virtual' };
|
|
90
136
|
const fromCollections = normalizeModel(fromModel);
|
|
91
137
|
const toCollections = normalizeModel(toModel);
|
|
92
138
|
|
|
@@ -133,15 +179,15 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
133
179
|
|
|
134
180
|
if (fromCollection === undefined) {
|
|
135
181
|
report.added.push(name);
|
|
136
|
-
for (const sql of planCollection(name, toCollection, dialect).createSql)
|
|
182
|
+
for (const sql of planCollection(name, toCollection, dialect, mapping).createSql)
|
|
137
183
|
steps.push({ kind: 'ddl', sql, note: `create collection '${name}'` });
|
|
138
184
|
continue;
|
|
139
185
|
}
|
|
140
186
|
|
|
141
187
|
// the from-side physical facts live under the RENAMED table: same
|
|
142
188
|
// columns, but index names still carry the old collection prefix
|
|
143
|
-
const fromPlan = planCollection(oldName, fromCollection, dialect);
|
|
144
|
-
const toPlan = planCollection(name, toCollection, dialect);
|
|
189
|
+
const fromPlan = planCollection(oldName, fromCollection, dialect, mapping);
|
|
190
|
+
const toPlan = planCollection(name, toCollection, dialect, mapping);
|
|
145
191
|
if (fromPlan.keyType !== toPlan.keyType
|
|
146
192
|
|| fromCollection.identity !== toCollection.identity
|
|
147
193
|
|| canonicalizeJson(fromCollection.key) !== canonicalizeJson(toCollection.key)) {
|
|
@@ -155,7 +201,12 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
155
201
|
const fromIndexes = new Map(fromPlan.expected.indexes.map((i) => [i.name, i]));
|
|
156
202
|
const toIndexes = new Map(toPlan.expected.indexes.map((i) => [i.name, i]));
|
|
157
203
|
|
|
158
|
-
|
|
204
|
+
// a derived column's EXPRESSION is part of its identity: the same
|
|
205
|
+
// path at a different precision, or under the other physical
|
|
206
|
+
// mapping, is a different column even where name and type agree
|
|
207
|
+
const columnChanged = (a, b) => a.type !== b.type || a.pathText !== b.pathText
|
|
208
|
+
|| (a.expression ?? null) !== (b.expression ?? null)
|
|
209
|
+
|| (a.stored === true) !== (b.stored === true);
|
|
159
210
|
const indexChanged = (a, b) => a.unique !== b.unique
|
|
160
211
|
|| a.columns.join(',') !== b.columns.join(',');
|
|
161
212
|
|
|
@@ -185,6 +236,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
185
236
|
steps.push({ kind: 'ddl', sql: dialect.ddl.dropColumn(name, columnName),
|
|
186
237
|
note: `drop generated column '${columnName}' on '${name}'` });
|
|
187
238
|
}
|
|
239
|
+
const backfilled = [];
|
|
188
240
|
for (const [columnName, toColumn] of toColumns) {
|
|
189
241
|
const source = fromColumns.get(columnName);
|
|
190
242
|
if (source === undefined || columnChanged(source, toColumn)) {
|
|
@@ -192,10 +244,21 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
192
244
|
kind: 'ddl',
|
|
193
245
|
sql: dialect.ddl.addGeneratedColumn(
|
|
194
246
|
{ table: name, docColumn: toPlan.docColumn, column: toColumn }),
|
|
195
|
-
note:
|
|
247
|
+
note: toColumn.stored === true
|
|
248
|
+
? `add stored derived column '${columnName}' on '${name}'`
|
|
249
|
+
: `add generated column '${columnName}' on '${name}'`,
|
|
196
250
|
});
|
|
251
|
+
if (toColumn.stored === true) backfilled.push(columnName);
|
|
197
252
|
}
|
|
198
253
|
}
|
|
254
|
+
// a GENERATED column arrives populated; a STORED one arrives NULL,
|
|
255
|
+
// and a pushdown over a NULL column silently returns fewer rows.
|
|
256
|
+
// The backfill is an explicit step rather than an assumption that
|
|
257
|
+
// the table is empty
|
|
258
|
+
if (backfilled.length > 0) {
|
|
259
|
+
steps.push(deriveStep(name, toPlan, backfilled,
|
|
260
|
+
`backfill derived column(s) ${backfilled.join(', ')} on '${name}'`));
|
|
261
|
+
}
|
|
199
262
|
for (const [indexName, toIndex] of toIndexes) {
|
|
200
263
|
const source = fromIndexes.get(indexName);
|
|
201
264
|
const rebuilt = source !== undefined && indexNeedsRebuild(indexName, source);
|
|
@@ -221,6 +284,17 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
221
284
|
+ 'transform. Fill in the stylesheet (or delete this step if every stored '
|
|
222
285
|
+ 'document already validates against the new schema) and remove "draft".',
|
|
223
286
|
});
|
|
287
|
+
// a transform rewrites the document, and a stored derived column
|
|
288
|
+
// is computed FROM the document: without this it keeps the value
|
|
289
|
+
// the old document had
|
|
290
|
+
const stale = toPlan.derived
|
|
291
|
+
.filter((column) => toColumns.get(column.name)?.stored === true)
|
|
292
|
+
.map((column) => column.name)
|
|
293
|
+
.filter((columnName) => !backfilled.includes(columnName));
|
|
294
|
+
if (stale.length > 0) {
|
|
295
|
+
steps.push(deriveStep(name, toPlan, stale,
|
|
296
|
+
`recompute derived column(s) ${stale.join(', ')} on '${name}' after the transform`));
|
|
297
|
+
}
|
|
224
298
|
}
|
|
225
299
|
}
|
|
226
300
|
|
|
@@ -599,8 +673,10 @@ export function createModelShape(connection, model) {
|
|
|
599
673
|
const dialect = connection.dialect;
|
|
600
674
|
/** @type {string[]} */
|
|
601
675
|
const statements = [];
|
|
602
|
-
for (const collection of normalizeModel(model).values())
|
|
603
|
-
statements.push(
|
|
676
|
+
for (const collection of normalizeModel(model).values()) {
|
|
677
|
+
statements.push(
|
|
678
|
+
...planCollection(collection.name, collection, dialect, mappingFor(connection)).createSql);
|
|
679
|
+
}
|
|
604
680
|
const entities = normalizeEntities(model);
|
|
605
681
|
if (entities.size > 0) {
|
|
606
682
|
const mapping = explainMapping(model);
|
|
@@ -699,7 +775,8 @@ function normalizeSchemaSql(sql) {
|
|
|
699
775
|
*/
|
|
700
776
|
export function compareShapeToModel(driver, connection, model, registerFunctions) {
|
|
701
777
|
return chain(driver.open(':memory:', {}), (reference) =>
|
|
702
|
-
chain(
|
|
778
|
+
chain(chain(registerDeriveFunctions(reference),
|
|
779
|
+
() => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
|
|
703
780
|
const finish = (result) => chain(reference.close(), () => result);
|
|
704
781
|
let outcome;
|
|
705
782
|
try {
|
|
@@ -736,7 +813,7 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
|
|
|
736
813
|
}));
|
|
737
814
|
}
|
|
738
815
|
|
|
739
|
-
const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild']);
|
|
816
|
+
const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
|
|
740
817
|
|
|
741
818
|
/**
|
|
742
819
|
* Structural validation of one migration document, including the
|
|
@@ -769,6 +846,12 @@ function checkMigrationDocument(migration) {
|
|
|
769
846
|
throw refuse('JD0023',
|
|
770
847
|
`migration '${migration.id}' step ${i} is a sql step without sql text`);
|
|
771
848
|
}
|
|
849
|
+
if (step.kind === 'derive'
|
|
850
|
+
&& (typeof step.collection !== 'string' || !Array.isArray(step.columns)
|
|
851
|
+
|| step.columns.length === 0)) {
|
|
852
|
+
throw refuse('JD0023',
|
|
853
|
+
`migration '${migration.id}' step ${i} is a derive backfill without its columns`);
|
|
854
|
+
}
|
|
772
855
|
if (step.kind === 'jslt' && step.draft === true) {
|
|
773
856
|
throw refuse('JD0021',
|
|
774
857
|
`migration '${migration.id}' step ${i} is a DRAFT transform for collection `
|
|
@@ -886,6 +969,34 @@ function runSteps(connection, migration, options) {
|
|
|
886
969
|
};
|
|
887
970
|
return runNext(0);
|
|
888
971
|
}
|
|
972
|
+
if (current.kind === 'derive') {
|
|
973
|
+
// recompute stored derived columns from the documents already
|
|
974
|
+
// present — the branch where a derived column is an ordinary
|
|
975
|
+
// one the store writes, so an ALTER that adds it leaves every
|
|
976
|
+
// existing row NULL until this runs
|
|
977
|
+
const columns = current.columns;
|
|
978
|
+
const assignments = columns.map((column, at) =>
|
|
979
|
+
`${q(column.name)} = ${dialect.parameterRef(at + 1, column.name)}`);
|
|
980
|
+
const updateSql = `UPDATE ${q(current.collection)} SET ${assignments.join(', ')} `
|
|
981
|
+
+ `WHERE ${dialect.rowIdentity()} = ${dialect.parameterRef(columns.length + 1, 'rid')}`;
|
|
982
|
+
let derivedRows = 0;
|
|
983
|
+
return chain(connection.prepare(updateSql), (update) =>
|
|
984
|
+
chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
985
|
+
for (const row of rows) {
|
|
986
|
+
const doc = JSON.parse(row.doc);
|
|
987
|
+
update.run([
|
|
988
|
+
...columns.map((column) => derivedValue(column, memberAt(doc, column.segments))),
|
|
989
|
+
row.rid,
|
|
990
|
+
]);
|
|
991
|
+
derivedRows++;
|
|
992
|
+
}
|
|
993
|
+
options.onProgress?.({
|
|
994
|
+
migration: migration.id,
|
|
995
|
+
collection: current.collection,
|
|
996
|
+
derived: derivedRows,
|
|
997
|
+
});
|
|
998
|
+
}, false), () => derivedRows));
|
|
999
|
+
}
|
|
889
1000
|
if (current.kind === 'jslt') {
|
|
890
1001
|
let transform;
|
|
891
1002
|
try {
|
|
@@ -992,7 +1103,7 @@ function validateTargetState(connection, model, options) {
|
|
|
992
1103
|
const verifyNext = (i) => {
|
|
993
1104
|
if (i >= collections.length) return null;
|
|
994
1105
|
const collection = collections[i];
|
|
995
|
-
const plan = planCollection(collection.name, collection, dialect);
|
|
1106
|
+
const plan = planCollection(collection.name, collection, dialect, mappingFor(connection));
|
|
996
1107
|
const validate = options.compileSchema !== undefined
|
|
997
1108
|
? options.compileSchema(collection.schema)
|
|
998
1109
|
: null;
|
|
@@ -1046,9 +1157,8 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
|
|
|
1046
1157
|
// a UDF-expression index is invisible to a connection that has not
|
|
1047
1158
|
// registered the function (probed, never assumed): the shadow
|
|
1048
1159
|
// re-registers every declared function BEFORE any DDL runs
|
|
1049
|
-
const registered =
|
|
1050
|
-
? options.registerFunctions(shadow)
|
|
1051
|
-
: null;
|
|
1160
|
+
const registered = chain(registerDeriveFunctions(shadow), () =>
|
|
1161
|
+
(options.registerFunctions !== undefined ? options.registerFunctions(shadow) : null));
|
|
1052
1162
|
const apply = (i) => {
|
|
1053
1163
|
if (i >= migrations.length) return null;
|
|
1054
1164
|
const bracket = migrations[i].steps.some(
|
|
@@ -1065,7 +1175,8 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
|
|
|
1065
1175
|
const target = [...normalizeModel(model).values()];
|
|
1066
1176
|
const verifyNext = (i) => {
|
|
1067
1177
|
if (i >= target.length) return null;
|
|
1068
|
-
const plan = planCollection(target[i].name, target[i], shadow.dialect
|
|
1178
|
+
const plan = planCollection(target[i].name, target[i], shadow.dialect,
|
|
1179
|
+
mappingFor(shadow));
|
|
1069
1180
|
return chain(
|
|
1070
1181
|
verifyShape(shadow, plan, target[i].name, target[i].docPath),
|
|
1071
1182
|
() => verifyNext(i + 1));
|
|
@@ -1148,7 +1259,8 @@ export function migrationStatus(target, migrations, options) {
|
|
|
1148
1259
|
const failClosed = (error) => chain(connection.close(), () => { throw error; });
|
|
1149
1260
|
let work;
|
|
1150
1261
|
try {
|
|
1151
|
-
work = chain(connection
|
|
1262
|
+
work = chain(registerDeriveFunctions(connection), () =>
|
|
1263
|
+
chain(connection.exec(statements.create), () =>
|
|
1152
1264
|
chain(connection.prepare(statements.select), (select) =>
|
|
1153
1265
|
chain(select.all([]), (rows) => {
|
|
1154
1266
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -1172,7 +1284,7 @@ export function migrationStatus(target, migrations, options) {
|
|
|
1172
1284
|
(difference) => ({
|
|
1173
1285
|
applied, pending, drift: difference, upToDate: difference === null,
|
|
1174
1286
|
}));
|
|
1175
|
-
})));
|
|
1287
|
+
}))));
|
|
1176
1288
|
}
|
|
1177
1289
|
catch (error) {
|
|
1178
1290
|
return failClosed(error);
|
|
@@ -1221,8 +1333,9 @@ export function migrate(target, migrations, options) {
|
|
|
1221
1333
|
return toPromise(chain(
|
|
1222
1334
|
target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
|
|
1223
1335
|
(connection) => chain(
|
|
1224
|
-
|
|
1225
|
-
|
|
1336
|
+
chain(registerDeriveFunctions(connection),
|
|
1337
|
+
() => (options.registerFunctions !== undefined
|
|
1338
|
+
? options.registerFunctions(connection) : null)),
|
|
1226
1339
|
() => {
|
|
1227
1340
|
const dialect = connection.dialect;
|
|
1228
1341
|
const statements = historyStatements(dialect);
|