@jarenjs/db 0.34.0 → 0.43.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/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 }} [options]
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
- const columnChanged = (a, b) => a.type !== b.type || a.pathText !== b.pathText;
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: `add generated column '${columnName}' on '${name}'`,
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(...planCollection(collection.name, collection, dialect).createSql);
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(registerFunctions !== undefined ? registerFunctions(reference) : null, () => {
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 = options.registerFunctions !== undefined
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.exec(statements.create), () =>
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
- options.registerFunctions !== undefined
1225
- ? options.registerFunctions(connection) : null,
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);