@jarenjs/db 0.56.0 → 0.67.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 (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/migrate.js CHANGED
@@ -23,6 +23,8 @@
23
23
 
24
24
  import { canonicalizeJson } from '@jarenjs/json/canonical';
25
25
  import { hashContent } from '@jarenjs/core/string';
26
+ import { resolveRuntime } from '@jarenjs/core/runtime';
27
+ import { refuseCancelled } from './cancellation.js';
26
28
  import { setObjectMember } from '@jarenjs/core/object';
27
29
  import { compileJsonQuery } from '@jarenjs/json/query';
28
30
  import { compileJsltStylesheet } from '@jarenjs/json/jslt';
@@ -30,11 +32,19 @@ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
30
32
  import { DbCompileError } from './errors.js';
31
33
  import { chain, toPromise } from './driver.js';
32
34
  import { normalizeModel } from './store.js';
35
+ import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
36
+ import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
33
37
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
34
38
  import { normalizeEntities, explainMapping } from './model.js';
35
39
  import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
36
40
  import { mergeEntityRow } from './graph.js';
37
41
  import { entityCore } from './entity.js';
42
+ import {
43
+ MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
44
+ normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
45
+ } from './document-steps.js';
46
+
47
+ export { MIGRATION_VERSION, isPerDocumentAssertion, ASSERTION_BOUNDS_DEFAULT };
38
48
 
39
49
  /**
40
50
  * The physical mapping a connection's driver imposes on derived index
@@ -44,22 +54,30 @@ import { entityCore } from './entity.js';
44
54
  * @param {any} connection
45
55
  * @returns {{ derived: 'virtual' | 'stored', rtree: boolean }}
46
56
  */
47
- function mappingFor(connection) {
57
+ function mappingFor(connection, expressions = undefined) {
58
+ const registered = connection.capabilities?.deterministicIndexableFunctions === true;
48
59
  return {
49
- derived: connection.capabilities?.deterministicIndexableFunctions === true
50
- ? 'virtual' : 'stored',
60
+ derived: registered ? 'virtual' : 'stored',
51
61
  // the same reasoning for the R*Tree mapping: a build without the
52
62
  // module plans (and verifies) the B-tree shape
53
63
  rtree: connection.capabilities?.rtree === true,
64
+ // and the same for a declared index expression: this connection
65
+ // either computes it or calls the engine's own immutable function
66
+ expressions,
67
+ registered,
54
68
  };
55
69
  }
56
70
 
57
- /** The migration format version. */
58
- export const MIGRATION_VERSION = '0.1';
59
-
60
71
  /** The history table name (outside the model's identifier namespace
61
72
  * conventions on purpose — a collection cannot collide with it). */
62
73
  export const HISTORY_TABLE = '_jaren_migrations';
74
+ /** The tables the engine owns beside a model's: never a shape-drift finding. */
75
+ /** The tables this package owns. A model never declared one, so one
76
+ * found in a database is the engine's own bookkeeping rather than
77
+ * anybody's drift — the drift check skips them and the introspector
78
+ * does not derive them. */
79
+ export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
80
+ JOBS_TABLE, JOB_CHECKPOINTS_TABLE]);
63
81
 
64
82
  /**
65
83
  * The signature-grade identity of a model SHAPE.
@@ -162,7 +180,8 @@ function deriveStep(collection, plan, columnNames, note) {
162
180
  * @param {any} fromModel
163
181
  * @param {any} toModel
164
182
  * @param {{ id?: string, dialect?: any,
165
- * derived?: 'virtual' | 'stored', rtree?: boolean }} [options]
183
+ * derived?: 'virtual' | 'stored', rtree?: boolean,
184
+ * expressions?: Record<string, any> }} [options]
166
185
  * @returns {{ migration: any, report: {
167
186
  * renamed: { from: string, to: string }[],
168
187
  * added: string[], removed: string[],
@@ -173,9 +192,13 @@ export function planMigration(fromModel, toModel, options = undefined) {
173
192
  const dialect = options?.dialect ?? null;
174
193
  if (dialect === null || typeof dialect !== 'object')
175
194
  throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
176
- const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false };
177
- const fromCollections = normalizeModel(fromModel);
178
- const toCollections = normalizeModel(toModel);
195
+ const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
196
+ // a model that declares an index EXPRESSION resolves its functions
197
+ // here too: a plan is DDL, and DDL over a function this planner was
198
+ // not told about is DDL nobody can apply
199
+ expressions: options?.expressions, registered: options?.derived !== 'stored' };
200
+ const fromCollections = normalizeModel(fromModel, options?.expressions);
201
+ const toCollections = normalizeModel(toModel, options?.expressions);
179
202
 
180
203
  const steps = [];
181
204
  const report = {
@@ -829,15 +852,19 @@ function renderRebuild(name, fromName, fm, tm, fromMapping, toMapping, dialect,
829
852
  * equality compares against, and the tests.
830
853
  * @param {any} connection
831
854
  * @param {any} model
855
+ * @param {Record<string, any>} [expressions] - the host's declared
856
+ * index-expression functions, resolved into the DDL the same way the
857
+ * open path resolves them
832
858
  * @returns {any} value-or-promise
833
859
  */
834
- export function createModelShape(connection, model) {
860
+ export function createModelShape(connection, model, expressions = undefined) {
835
861
  const dialect = connection.dialect;
836
862
  /** @type {string[]} */
837
863
  const statements = [];
838
- for (const collection of normalizeModel(model).values()) {
864
+ for (const collection of normalizeModel(model, expressions).values()) {
839
865
  statements.push(
840
- ...planCollection(collection.name, collection, dialect, mappingFor(connection)).createSql);
866
+ ...planCollection(collection.name, collection, dialect,
867
+ mappingFor(connection, expressions)).createSql);
841
868
  }
842
869
  const entities = normalizeEntities(model);
843
870
  if (entities.size > 0) {
@@ -870,7 +897,9 @@ export function schemaShapeOf(connection) {
870
897
  const dialect = connection.dialect;
871
898
  return chain(connection.prepare(dialect.introspect.schemaDump()), (statement) =>
872
899
  chain(statement.all([]), (rows) => rows
873
- .filter((row) => row.name !== HISTORY_TABLE && row.owner !== HISTORY_TABLE)
900
+ // the engine's own tables history, the change log and its state
901
+ // row, the job queue — are never a model's drift
902
+ .filter((row) => !ENGINE_TABLES.has(String(row.name)) && !ENGINE_TABLES.has(String(row.owner)))
874
903
  .map((row) => ({
875
904
  type: String(row.type),
876
905
  name: String(row.name),
@@ -935,14 +964,22 @@ function normalizeSchemaSql(sql) {
935
964
  * @param {((connection: any) => any) | undefined} registerFunctions
936
965
  * @returns {any} value-or-promise of `string | null`
937
966
  */
938
- export function compareShapeToModel(driver, connection, model, registerFunctions) {
967
+ export function compareShapeToModel(driver, connection, model, registerFunctions, expressions) {
968
+ // The comparison IS a text comparison: it builds the model's shape in
969
+ // a reference database and compares the two engines' stored CREATE
970
+ // statements. An engine that keeps none has nothing to compare, and
971
+ // says so here rather than reading an undefined statement — the
972
+ // structural drift check (columns, indexes, foreign-key tuples) runs
973
+ // per collection and per entity either way, and `declaredSqlText` is
974
+ // what tells a reader which half they got.
975
+ if (connection.dialect.capabilities.declaredSqlText !== true) return null;
939
976
  return chain(driver.open(':memory:', {}), (reference) =>
940
977
  chain(chain(registerDeriveFunctions(reference),
941
978
  () => (registerFunctions !== undefined ? registerFunctions(reference) : null)), () => {
942
979
  const finish = (result) => chain(reference.close(), () => result);
943
980
  let outcome;
944
981
  try {
945
- outcome = chain(createModelShape(reference, model), () =>
982
+ outcome = chain(createModelShape(reference, model, expressions), () =>
946
983
  chain(schemaShapeOf(reference), (wanted) =>
947
984
  chain(schemaShapeOf(connection), (actual) => {
948
985
  const wantedText = JSON.stringify(wanted);
@@ -975,54 +1012,6 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
975
1012
  }));
976
1013
  }
977
1014
 
978
- const STEP_KINDS = new Set(['ddl', 'jslt', 'query', 'sql', 'rebuild', 'derive']);
979
-
980
- /**
981
- * Structural validation of one migration document, including the
982
- * draft refusal (`JD0021`).
983
- * @param {any} migration
984
- */
985
- function checkMigrationDocument(migration) {
986
- if (migration === null || typeof migration !== 'object'
987
- || migration.$migration !== MIGRATION_VERSION
988
- || typeof migration.id !== 'string' || migration.id === ''
989
- || typeof migration.from !== 'string' || typeof migration.to !== 'string'
990
- || !Array.isArray(migration.steps)) {
991
- throw refuse('JD0023',
992
- `migration '${migration?.id ?? '<unknown>'}' is not a valid ${MIGRATION_VERSION} migration document`);
993
- }
994
- for (let i = 0; i < migration.steps.length; i++) {
995
- const step = migration.steps[i];
996
- if (step === null || typeof step !== 'object' || !STEP_KINDS.has(step.kind)) {
997
- throw refuse('JD0023',
998
- `migration '${migration.id}' step ${i} has no recognised kind`);
999
- }
1000
- if (step.kind === 'rebuild'
1001
- && (typeof step.table !== 'string' || !Array.isArray(step.create)
1002
- || typeof step.copy !== 'string' || !Array.isArray(step.indexes))) {
1003
- throw refuse('JD0023',
1004
- `migration '${migration.id}' step ${i} is a rebuild without its rendered `
1005
- + 'table/create/copy/indexes');
1006
- }
1007
- if (step.kind === 'sql' && typeof step.sql !== 'string') {
1008
- throw refuse('JD0023',
1009
- `migration '${migration.id}' step ${i} is a sql step without sql text`);
1010
- }
1011
- if (step.kind === 'derive'
1012
- && (typeof step.collection !== 'string' || !Array.isArray(step.columns)
1013
- || step.columns.length === 0)) {
1014
- throw refuse('JD0023',
1015
- `migration '${migration.id}' step ${i} is a derive backfill without its columns`);
1016
- }
1017
- if (step.kind === 'jslt' && step.draft === true) {
1018
- throw refuse('JD0021',
1019
- `migration '${migration.id}' step ${i} is a DRAFT transform for collection `
1020
- + `'${step.collection}' — the planner cannot infer a data transform; fill in `
1021
- + 'the stylesheet (or delete the step for a pure widening) and remove "draft"');
1022
- }
1023
- }
1024
- }
1025
-
1026
1015
  /**
1027
1016
  * The batched row walk shared by transforms and post-validation:
1028
1017
  * `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
@@ -1032,9 +1021,14 @@ function checkMigrationDocument(migration) {
1032
1021
  * @param {number} batchSize
1033
1022
  * @param {(rows: { rid: any, doc: string, key: any }[]) => any} handle
1034
1023
  * value-or-promise per batch
1024
+ * @param {boolean} [keyed]
1025
+ * @param {any} [entityMapping]
1026
+ * @param {() => void} [check] - run before every batch: the
1027
+ * cancellation boundary a data step has between its batches
1035
1028
  * @returns {any}
1036
1029
  */
1037
- function walkRows(connection, table, batchSize, handle, keyed = true, entityMapping = null) {
1030
+ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapping = null,
1031
+ check = undefined) {
1038
1032
  // entity tables carry no 'key' column — the transform walk goes by
1039
1033
  // row identity alone; only the collection walks select the key. An
1040
1034
  // entity's mapped columns ride beside the document so the row can be
@@ -1046,18 +1040,23 @@ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapp
1046
1040
  const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
1047
1041
  const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
1048
1042
  .map((column) => `, ${q(column)}`).join('');
1049
- const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
1050
- + `${keySelect}${columnSelect} FROM ${q(table)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
1051
- + `ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
1052
- return chain(connection.prepare(sql), (statement) => {
1053
- const nextBatch = (after) =>
1054
- chain(statement.all([after]), (rows) => {
1043
+ const select = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}`
1044
+ + `${keySelect}${columnSelect} FROM ${q(table)}`;
1045
+ const ordered = ` ORDER BY ${rid} ${dialect.limitClause(batchSize, undefined)}`;
1046
+ // An INTEGER PRIMARY KEY can be negative. The first batch has no
1047
+ // lower bound; subsequent batches seek from a row actually read.
1048
+ return chain(connection.prepare(select + ordered), (first) => chain(connection.prepare(
1049
+ `${select} WHERE ${rid} > ${dialect.parameterRef(1, 'after')}${ordered}`), (statement) => {
1050
+ const nextBatch = (after) => {
1051
+ if (check !== undefined) check();
1052
+ return chain(after === undefined ? first.all([]) : statement.all([after]), (rows) => {
1055
1053
  if (rows.length === 0) return null;
1056
1054
  return chain(handle(rows), () =>
1057
1055
  nextBatch(rows[rows.length - 1].rid));
1058
1056
  });
1059
- return nextBatch(-1);
1060
- });
1057
+ };
1058
+ return nextBatch(undefined);
1059
+ }));
1061
1060
  }
1062
1061
 
1063
1062
  /** The physical columns an entity row carries beside its document. */
@@ -1085,21 +1084,6 @@ function entityStepMapping(options, table) {
1085
1084
  return { entity, mapping: explainMapping(options.model).entities[table] };
1086
1085
  }
1087
1086
 
1088
- /** All documents of a collection or entity (the assertion steps' working
1089
- * set — a documented whole-collection read), entities read WHOLE. */
1090
- function allDocs(connection, table, entityMapping = null) {
1091
- const dialect = connection.dialect;
1092
- const q = dialect.quoteIdentifier;
1093
- const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
1094
- .map((column) => `, ${q(column)}`).join('');
1095
- const sql = `SELECT ${dialect.jsonText(q('doc'))} AS ${q('doc')}${columnSelect} FROM ${q(table)} `
1096
- + `ORDER BY ${dialect.rowIdentity()}`;
1097
- return chain(connection.prepare(sql), (statement) =>
1098
- chain(statement.all([]), (rows) => rows.map((row) => (entityMapping === null
1099
- ? JSON.parse(row.doc)
1100
- : mergeEntityRow(entityMapping, row, 'doc')))));
1101
- }
1102
-
1103
1087
  /**
1104
1088
  * Run one migration's steps against a connection.
1105
1089
  * @param {any} connection
@@ -1112,6 +1096,9 @@ function runSteps(connection, migration, options) {
1112
1096
  const q = dialect.quoteIdentifier;
1113
1097
  const step = (i) => {
1114
1098
  if (i >= migration.steps.length) return null;
1099
+ // the cancellation boundary between steps: a refusal here rolls the
1100
+ // migration in flight back whole, as any step failure does
1101
+ if (options.check !== undefined) options.check();
1115
1102
  const current = migration.steps[i];
1116
1103
  const fail = (reason, cause) => {
1117
1104
  throw refuse('JD0023',
@@ -1146,6 +1133,7 @@ function runSteps(connection, migration, options) {
1146
1133
  ];
1147
1134
  const runNext = (j) => {
1148
1135
  if (j >= statements.length) {
1136
+ if (dialect.capabilities.foreignKeysAlwaysOn === true) return null;
1149
1137
  return chain(connection.prepare(dialect.pragma.foreignKeyCheck()),
1150
1138
  (checkStatement) => chain(checkStatement.all([]), (violations) => {
1151
1139
  if (violations.length > 0) {
@@ -1191,24 +1179,23 @@ function runSteps(connection, migration, options) {
1191
1179
  collection: current.collection,
1192
1180
  derived: derivedRows,
1193
1181
  });
1194
- }, false), () => derivedRows));
1182
+ }, false, null, options.check), () => derivedRows));
1195
1183
  }
1196
1184
  if (current.kind === 'jslt') {
1197
- let transform;
1198
- try {
1199
- transform = compileJsltStylesheet(current.stylesheet);
1200
- }
1201
- catch (cause) {
1202
- return fail(`the stylesheet does not compile: ${/** @type {Error} */ (cause).message}`,
1203
- /** @type {Error} */ (cause));
1204
- }
1205
1185
  const stepEntity = entityStepMapping(options, current.collection);
1186
+ const operation = compileDocumentStep(current, i, {
1187
+ migrationId: migration.id,
1188
+ compileJslt: compileJsltStylesheet,
1189
+ compileQuery: compileJsonQuery,
1190
+ keys: stepEntity === null ? [] : stepEntity.mapping.keys,
1191
+ });
1206
1192
  if (stepEntity !== null) {
1207
1193
  // an entity row is transformed WHOLE: the mapped columns fold in
1208
1194
  // before the stylesheet and split out after it, through the
1209
1195
  // entity's own split — a column-mapped member the stylesheet
1210
1196
  // wrote used to land in the document and be shadowed on read
1211
- const core = entityCore(connection, stepEntity.entity, stepEntity.mapping, null);
1197
+ const core = entityCore(connection, stepEntity.entity, stepEntity.mapping, null,
1198
+ options.runtime);
1212
1199
  const columns = entityColumnsOf(stepEntity.mapping);
1213
1200
  const assignments = [
1214
1201
  ...columns.map((column, i) => `${q(column)} = ${dialect.parameterRef(i + 1, 'v')}`),
@@ -1221,19 +1208,7 @@ function runSteps(connection, migration, options) {
1221
1208
  chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1222
1209
  for (const row of rows) {
1223
1210
  const whole = mergeEntityRow(stepEntity.mapping, row, 'doc');
1224
- const next = transform(whole);
1225
- if (next === null || typeof next !== 'object' || Array.isArray(next))
1226
- fail(`the transform produced a non-document for row ${row.rid}`);
1227
- // the key is the row's identity, not the document's to
1228
- // change: a body that leaves it out keeps it, a body
1229
- // that rewrites it is refused, as a collection's key is
1230
- for (const key of stepEntity.mapping.keys) {
1231
- if (next[key] === undefined) next[key] = whole[key];
1232
- else if (next[key] !== whole[key]) {
1233
- fail(`the transform changed the key member '${key}' of row ${row.rid} — `
1234
- + `key changes are not supported in ${MIGRATION_VERSION}`);
1235
- }
1236
- }
1211
+ const next = operation.apply(whole, row.rid);
1237
1212
  const { values, rest } = core.plan.split(next);
1238
1213
  const byName = new Map(values.map((value) => [value.name, value.value]));
1239
1214
  update.run([...columns.map((column) => byName.get(column) ?? null),
@@ -1245,7 +1220,7 @@ function runSteps(connection, migration, options) {
1245
1220
  collection: current.collection,
1246
1221
  transformed,
1247
1222
  });
1248
- }, false, stepEntity.mapping), () => transformed));
1223
+ }, false, stepEntity.mapping, options.check), () => transformed));
1249
1224
  }
1250
1225
  const updateSql = `UPDATE ${q(current.collection)} SET ${q('doc')} = `
1251
1226
  + `${dialect.jsonEncode(dialect.parameterRef(1, 'doc'))} `
@@ -1255,9 +1230,7 @@ function runSteps(connection, migration, options) {
1255
1230
  return chain(connection.prepare(updateSql), (update) =>
1256
1231
  chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1257
1232
  for (const row of rows) {
1258
- const next = transform(JSON.parse(row.doc));
1259
- if (next === null || typeof next !== 'object' || Array.isArray(next))
1260
- fail(`the transform produced a non-document for row ${row.rid}`);
1233
+ const next = operation.apply(JSON.parse(row.doc), row.rid);
1261
1234
  update.run([JSON.stringify(next), row.rid]);
1262
1235
  transformed++;
1263
1236
  }
@@ -1266,30 +1239,66 @@ function runSteps(connection, migration, options) {
1266
1239
  collection: current.collection,
1267
1240
  transformed,
1268
1241
  });
1269
- }, keyed), () => transformed));
1242
+ }, keyed, null, options.check), () => transformed));
1270
1243
  }
1271
1244
  // kind === 'query': the assertion step
1272
- let compiled;
1273
- try {
1274
- compiled = compileJsonQuery(current.assert);
1245
+ const assertionMapping = entityStepMapping(options, current.collection)?.mapping ?? null;
1246
+ const operation = compileDocumentStep(current, i, {
1247
+ migrationId: migration.id,
1248
+ compileJslt: compileJsltStylesheet,
1249
+ compileQuery: compileJsonQuery,
1250
+ });
1251
+ const assertOver = operation.assert;
1252
+ const readDoc = (row) => (assertionMapping === null
1253
+ ? JSON.parse(row.doc)
1254
+ : mergeEntityRow(assertionMapping, row, 'doc'));
1255
+
1256
+ if (operation.fold !== null) {
1257
+ // an associative aggregate: each batch is answered by the engine
1258
+ // and the partial answers combine, so the collection is never
1259
+ // held. The whole-collection read this replaces is the one
1260
+ // statement a cross-document assertion used to cost.
1261
+ let accumulated = operation.fold.start();
1262
+ let folded = 0;
1263
+ return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1264
+ accumulated = operation.fold.combine(accumulated, rows.map(readDoc));
1265
+ folded += rows.length;
1266
+ options.onProgress?.({
1267
+ migration: migration.id,
1268
+ collection: current.collection,
1269
+ asserted: folded,
1270
+ });
1271
+ }, false, assertionMapping, options.check), () => operation.fold.finish(accumulated));
1275
1272
  }
1276
- catch (cause) {
1277
- return fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
1278
- /** @type {Error} */ (cause));
1273
+
1274
+ if (!operation.perDocument) {
1275
+ // materializing: the answer needs every document at once. That is
1276
+ // a cost, so it is bounded and the bound is crossed BEFORE the
1277
+ // excess is held — the walk stops at the row that would break it
1278
+ const guard = createAssertionBoundGuard(options.assertionBounds, current.collection,
1279
+ `the assertion of migration '${migration.id}' step ${i}`);
1280
+ const gathered = [];
1281
+ return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
1282
+ for (const row of rows) {
1283
+ const doc = readDoc(row);
1284
+ guard.admit(doc, typeof row.doc === 'string' ? row.doc : undefined);
1285
+ gathered.push(doc);
1286
+ }
1287
+ }, false, assertionMapping, options.check), () => assertOver(gathered));
1279
1288
  }
1280
- return chain(allDocs(connection, current.collection,
1281
- entityStepMapping(options, current.collection)?.mapping ?? null), (docs) => {
1282
- if (current.expect === 'ebv') {
1283
- if (!compiled.ebv(docs)) fail('the EBV assertion answered false');
1284
- return null;
1285
- }
1286
- const result = compiled(docs);
1287
- if (result !== undefined) {
1288
- const count = Array.isArray(result) ? result.length : 1;
1289
- fail(`the assertion expected an empty sequence, got ${count} item(s)`);
1290
- }
1291
- return null;
1292
- });
1289
+ // per-document: walk in keyset batches like every other step,
1290
+ // failing fast at the first batch that violates
1291
+ let asserted = 0;
1292
+ return walkRows(connection, current.collection, options.batchSize, (rows) => {
1293
+ const docs = rows.map(readDoc);
1294
+ assertOver(docs);
1295
+ asserted += docs.length;
1296
+ options.onProgress?.({
1297
+ migration: migration.id,
1298
+ collection: current.collection,
1299
+ asserted,
1300
+ });
1301
+ }, false, assertionMapping, options.check);
1293
1302
  }), () => step(i + 1));
1294
1303
  };
1295
1304
  return step(0);
@@ -1307,10 +1316,9 @@ function runSteps(connection, migration, options) {
1307
1316
  * @returns {any} value-or-promise
1308
1317
  */
1309
1318
  function validateTargetState(connection, model, options) {
1310
- const collections = [...normalizeModel(model).values()];
1319
+ const collections = [...normalizeModel(model, options.expressions).values()];
1311
1320
  const entities = [...normalizeEntities(model).values()];
1312
1321
  const dialect = connection.dialect;
1313
- const q = dialect.quoteIdentifier;
1314
1322
  // entity tables carry no 'key' column; the batched walk goes by row
1315
1323
  // identity and validates every stored document against the target
1316
1324
  const verifyEntity = (i) => {
@@ -1324,33 +1332,23 @@ function validateTargetState(connection, model, options) {
1324
1332
  // schema judges; the rest-document alone failed every entity whose
1325
1333
  // required members are columns, so a pure widening could not land
1326
1334
  const entityMapping = explainMapping(model).entities[entity.name];
1327
- const rid = dialect.rowIdentity();
1328
- const columnSelect = entityColumnsOf(entityMapping).map((column) => `, ${q(column)}`).join('');
1329
- const sql = `SELECT ${rid} AS ${q('rid')}, ${dialect.jsonText(q('doc'))} AS ${q('doc')}${columnSelect} `
1330
- + `FROM ${q(entity.name)} WHERE ${rid} > ${dialect.parameterRef(1, 'after')} `
1331
- + `ORDER BY ${rid} ${dialect.limitClause(options.batchSize, undefined)}`;
1332
- return chain(connection.prepare(sql), (statement) => {
1333
- const nextBatch = (after) =>
1334
- chain(statement.all([after]), (rows) => {
1335
- if (rows.length === 0) return null;
1336
- for (const row of rows) {
1337
- const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
1338
- const valid = outcome === true || outcome?.valid === true;
1339
- if (!valid) {
1340
- throw refuse('JD0021',
1341
- `entity '${entity.name}': a stored document (row ${row.rid}) does not `
1342
- + 'validate against the target schema — a narrowing needs a data transform');
1343
- }
1344
- }
1345
- return nextBatch(rows[rows.length - 1].rid);
1346
- });
1347
- return nextBatch(-1);
1348
- });
1335
+ return chain(walkRows(connection, entity.name, options.batchSize, (rows) => {
1336
+ for (const row of rows) {
1337
+ const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
1338
+ const valid = outcome === true || outcome?.valid === true;
1339
+ if (!valid) {
1340
+ throw refuse('JD0021',
1341
+ `entity '${entity.name}': a stored document (row ${row.rid}) does not `
1342
+ + 'validate against the target schema — a narrowing needs a data transform');
1343
+ }
1344
+ }
1345
+ }, false, entityMapping), () => verifyEntity(i + 1));
1349
1346
  };
1350
1347
  const verifyNext = (i) => {
1351
1348
  if (i >= collections.length) return null;
1352
1349
  const collection = collections[i];
1353
- const plan = planCollection(collection.name, collection, dialect, mappingFor(connection));
1350
+ const plan = planCollection(collection.name, collection, dialect,
1351
+ mappingFor(connection, options.expressions));
1354
1352
  const validate = options.compileSchema !== undefined
1355
1353
  ? options.compileSchema(collection.schema)
1356
1354
  : null;
@@ -1408,8 +1406,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
1408
1406
  (options.registerFunctions !== undefined ? options.registerFunctions(shadow) : null));
1409
1407
  const apply = (i) => {
1410
1408
  if (i >= migrations.length) return null;
1409
+ // a rebuild moves rows between tables while their keys point at
1410
+ // the old one, so the switch comes off around it — on an engine
1411
+ // that HAS a switch. One that always enforces cannot rebuild that
1412
+ // way, and `alterTableFull` is why it never has to
1411
1413
  const bracket = migrations[i].steps.some(
1412
- (candidate) => candidate.kind === 'rebuild');
1414
+ (candidate) => candidate.kind === 'rebuild')
1415
+ && shadow.dialect.capabilities.foreignKeysAlwaysOn !== true;
1413
1416
  return chain(
1414
1417
  bracket ? shadow.exec(shadow.dialect.pragma.foreignKeys(false)) : null,
1415
1418
  () => chain(runSteps(shadow, migrations[i], options), () =>
@@ -1417,13 +1420,13 @@ function replayOnShadow(driver, shadowPath, baseline, migrations, model, options
1417
1420
  () => apply(i + 1))));
1418
1421
  };
1419
1422
  const run = () => chain(registered, () =>
1420
- chain(createModelShape(shadow, baseline), () => chain(apply(0), () => {
1423
+ chain(createModelShape(shadow, baseline, options.expressions), () => chain(apply(0), () => {
1421
1424
  if (model === undefined) return null;
1422
- const target = [...normalizeModel(model).values()];
1425
+ const target = [...normalizeModel(model, options.expressions).values()];
1423
1426
  const verifyNext = (i) => {
1424
1427
  if (i >= target.length) return null;
1425
1428
  const plan = planCollection(target[i].name, target[i], shadow.dialect,
1426
- mappingFor(shadow));
1429
+ mappingFor(shadow, options.expressions));
1427
1430
  return chain(
1428
1431
  verifyShape(shadow, plan, target[i].name, target[i].docPath),
1429
1432
  () => verifyNext(i + 1));
@@ -1485,18 +1488,33 @@ function historyStatements(dialect) {
1485
1488
  }
1486
1489
 
1487
1490
  /**
1488
- * Report a database's migration state without touching it: what is
1489
- * applied, what is pending, whether an applied migration was edited,
1490
- * and once the chain is fully applied whether the physical shape
1491
- * DRIFTED from the model (someone changed the database by hand, §12).
1491
+ * Report a database's migration state without touching it the
1492
+ * history table is probed, never created, so a fresh file stays byte
1493
+ * for byte what it was: what is applied, what is pending, whether an
1494
+ * applied migration was edited, and once the chain is fully applied —
1495
+ * whether the physical shape DRIFTED from the model (someone changed
1496
+ * the database by hand, §12).
1492
1497
  * @param {{ driver: any, path?: string }} target
1493
1498
  * @param {any[]} migrations - the full ordered list
1494
- * @param {{ baseline: any, model?: any,
1495
- * registerFunctions?: (connection: any) => any }} options
1499
+ * @param {{ model?: any, registerFunctions?: (connection: any) => any,
1500
+ * signal?: AbortSignal, deadline?: number,
1501
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
1502
+ * - `signal`/`deadline` refuse a call already cancelled (`JD2080`) or
1503
+ * past its deadline (`JD2075`) on the runtime record's clock
1496
1504
  * @returns {Promise<{ applied: string[], pending: string[],
1497
1505
  * drift: string | null, upToDate: boolean }>}
1498
1506
  */
1499
- export function migrationStatus(target, migrations, options) {
1507
+ export function migrationStatus(target, migrations, options = {}) {
1508
+ // a call already cancelled, or past its deadline on the caller's
1509
+ // clock, opens nothing
1510
+ try {
1511
+ refuseCancelled({ signal: options.signal, deadline: options.deadline },
1512
+ resolveRuntime(options.runtime).now,
1513
+ { abortCode: 'JD2080', aborted: 'it ran', passed: 'the status read ran', ran: 'no step ran' });
1514
+ }
1515
+ catch (error) {
1516
+ return Promise.reject(error);
1517
+ }
1500
1518
  return toPromise(chain(
1501
1519
  target.driver.open(target.path ?? ':memory:', {}),
1502
1520
  (connection) => {
@@ -1506,10 +1524,15 @@ export function migrationStatus(target, migrations, options) {
1506
1524
  const failClosed = (error) => chain(connection.close(), () => { throw error; });
1507
1525
  let work;
1508
1526
  try {
1527
+ // §6's "writes NOTHING" holds for a status read too: the history
1528
+ // table is probed, never created, and an absent one reads as an
1529
+ // empty history — the same promise the dry run makes
1509
1530
  work = chain(registerDeriveFunctions(connection), () =>
1510
- chain(connection.exec(statements.create), () =>
1511
- chain(connection.prepare(statements.select), (select) =>
1512
- chain(select.all([]), (rows) => {
1531
+ chain(chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
1532
+ chain(probe.get([HISTORY_TABLE]), (present) => (present === undefined
1533
+ ? []
1534
+ : chain(connection.prepare(statements.select), (select) => select.all([]))))),
1535
+ (rows) => chain(rows, () => {
1513
1536
  for (let i = 0; i < rows.length; i++) {
1514
1537
  const doc = migrations[i];
1515
1538
  if (doc === undefined || doc.id !== rows[i].id
@@ -1531,7 +1554,7 @@ export function migrationStatus(target, migrations, options) {
1531
1554
  (difference) => ({
1532
1555
  applied, pending, drift: difference, upToDate: difference === null,
1533
1556
  }));
1534
- }))));
1557
+ })));
1535
1558
  }
1536
1559
  catch (error) {
1537
1560
  return failClosed(error);
@@ -1556,7 +1579,17 @@ export function migrationStatus(target, migrations, options) {
1556
1579
  * @param {any[]} migrations
1557
1580
  * @param {{ baseline: any, model?: any, compileSchema?: Function,
1558
1581
  * dryRun?: boolean, batchSize?: number, onProgress?: Function,
1559
- * shadow?: boolean, shadowPath?: string }} options
1582
+ * shadow?: boolean, shadowPath?: string, shadowDriver?: any,
1583
+ * signal?: AbortSignal, deadline?: number,
1584
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime> }} options
1585
+ * `signal` and `deadline` cancel between migrations, steps and
1586
+ * batches (`JD2080` / `JD2075`, the deadline read against `runtime`'s
1587
+ * clock); a cancelled migration rolls back whole and the completed
1588
+ * ones stand.
1589
+ * `runtime` is the host's runtime record: the clock every applied
1590
+ * migration is stamped with, and the clock and identifiers an entity
1591
+ * step's `default: 'now'` / `default: 'uuid'` fill; the platform's own
1592
+ * when absent
1560
1593
  * @returns {Promise<any>}
1561
1594
  */
1562
1595
  export function migrate(target, migrations, options) {
@@ -1571,12 +1604,40 @@ export function migrate(target, migrations, options) {
1571
1604
  'migrate needs { baseline }: the model the store was first created with '
1572
1605
  + '(the chain anchor and the shadow starting shape)');
1573
1606
  const batchSize = options.batchSize ?? 500;
1607
+ const runtime = resolveRuntime(options.runtime);
1608
+ /**
1609
+ * The cancellation boundary: between migrations, between steps and
1610
+ * between the batches of a data step, on the runtime record's clock.
1611
+ * Nothing interrupts a statement that has started; a refusal inside a
1612
+ * migration rolls that migration back whole and the completed ones
1613
+ * stand, so a rerun resumes from the recorded position.
1614
+ */
1615
+ const check = () => refuseCancelled({ signal: options.signal, deadline: options.deadline }, runtime.now, {
1616
+ abortCode: 'JD2080', aborted: 'its next step', passed: 'its next step',
1617
+ ran: 'no further step ran; a migration in flight rolled back whole and a rerun resumes '
1618
+ + 'from the recorded position',
1619
+ });
1574
1620
  const runOptions = {
1575
1621
  batchSize,
1622
+ assertionBounds: normalizeAssertionBounds(options.assertionBounds),
1576
1623
  onProgress: options.onProgress,
1577
1624
  registerFunctions: options.registerFunctions,
1625
+ // the host's declared index-expression functions ride to every
1626
+ // planner and every connection this run opens — the shadow's
1627
+ // baseline, the reference database and the real store all resolve a
1628
+ // declared expression against the same declarations the open path
1629
+ // was given, or they would plan DDL nobody can apply
1630
+ expressions: options.expressions,
1578
1631
  model: options.model,
1632
+ runtime,
1633
+ check,
1579
1634
  };
1635
+ try {
1636
+ check();
1637
+ }
1638
+ catch (error) {
1639
+ return Promise.reject(error);
1640
+ }
1580
1641
 
1581
1642
  return toPromise(chain(
1582
1643
  target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }),
@@ -1650,7 +1711,8 @@ export function migrate(target, migrations, options) {
1650
1711
 
1651
1712
  const shadowRun = options.shadow === false
1652
1713
  ? null
1653
- : replayOnShadow(target.driver, options.shadowPath ?? ':memory:',
1714
+ : replayOnShadow(options.shadowDriver ?? target.driver,
1715
+ options.shadowPath ?? ':memory:',
1654
1716
  options.baseline, migrations, options.model, runOptions);
1655
1717
 
1656
1718
  return chain(shadowRun, () => {
@@ -1672,8 +1734,9 @@ export function migrate(target, migrations, options) {
1672
1734
  dialect.ddl.dropTable(migrationStep.table),
1673
1735
  dialect.ddl.renameTable(`${migrationStep.table}__rebuild`,
1674
1736
  migrationStep.table),
1675
- ...migrationStep.indexes,
1676
- dialect.pragma.foreignKeyCheck());
1737
+ ...migrationStep.indexes);
1738
+ if (dialect.capabilities.foreignKeysAlwaysOn !== true)
1739
+ rendered.push(dialect.pragma.foreignKeyCheck());
1677
1740
  }
1678
1741
  else if (migrationStep.kind === 'jslt')
1679
1742
  rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
@@ -1707,6 +1770,8 @@ export function migrate(target, migrations, options) {
1707
1770
  const applied = [];
1708
1771
  const applyNext = (i) => {
1709
1772
  if (i >= pending.length) return null;
1773
+ // the boundary between migrations
1774
+ check();
1710
1775
  const migration = pending[i];
1711
1776
  const last = i === pending.length - 1;
1712
1777
  // the §10 procedure's pragma bracket, literally: the
@@ -1716,7 +1781,8 @@ export function migrate(target, migrations, options) {
1716
1781
  const bracket = migration.steps.some(
1717
1782
  (candidate) => candidate.kind === 'rebuild');
1718
1783
  return chain(
1719
- bracket ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1784
+ bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
1785
+ ? connection.exec(dialect.pragma.foreignKeys(false)) : null,
1720
1786
  () => chain(connection.exec(dialect.tx.beginImmediate), () => {
1721
1787
  const body = () => chain(runSteps(connection, migration, runOptions), () =>
1722
1788
  chain(last && options.model !== undefined
@@ -1733,10 +1799,11 @@ export function migrate(target, migrations, options) {
1733
1799
  })))
1734
1800
  : null,
1735
1801
  () => chain(connection.prepare(statements.insert), (insert) =>
1736
- insert.run([migration.id, Date.now(), migration.from,
1802
+ insert.run([migration.id, runtime.now(), migration.from,
1737
1803
  migration.to, migrationChecksum(migration),
1738
1804
  migration.steps.length]))));
1739
1805
  const restore = () => (bracket
1806
+ && dialect.capabilities.foreignKeysAlwaysOn !== true
1740
1807
  ? connection.exec(dialect.pragma.foreignKeys(true)) : null);
1741
1808
  const commit = () => chain(connection.exec(dialect.tx.commit), () =>
1742
1809
  chain(restore(), () => {