@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/live.js
CHANGED
|
@@ -112,11 +112,11 @@ function documentsSource(binding, where) {
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Recognise the canonical single-level group form (§7): one binding
|
|
115
|
-
* over `$[*]`, optional `$where`,
|
|
115
|
+
* over `$[*]`, optional `$where`, one or more group keys, and a
|
|
116
116
|
* `$return` object whose members are the group key (`$g` plain or
|
|
117
117
|
* defaulted) or a single-member aggregate over the group sequence.
|
|
118
118
|
* @param {any} inner
|
|
119
|
-
* @returns {null | { binding: string,
|
|
119
|
+
* @returns {null | { binding: string, groups: string[], key: any,
|
|
120
120
|
* where: any, members: { name: string, kind: 'key' | 'aggregate',
|
|
121
121
|
* fn?: string, operand?: any, defaulted?: boolean }[] }}
|
|
122
122
|
*/
|
|
@@ -126,18 +126,30 @@ function recogniseGroupForm(inner) {
|
|
|
126
126
|
const allowed = new Set(['$for', '$where', '$groupby', '$return']);
|
|
127
127
|
if (!Object.keys(inner).every((key) => allowed.has(key))) return null;
|
|
128
128
|
const groupNames = Object.keys(inner.$groupby);
|
|
129
|
-
if (groupNames.length
|
|
130
|
-
|
|
129
|
+
if (groupNames.length === 0) return null;
|
|
130
|
+
// Subset evaluation must never read the global collection root.
|
|
131
|
+
const local = (node) => {
|
|
132
|
+
if (typeof node === 'string') return node !== '$' && !/^\$(?:\.|\[)/.test(node);
|
|
133
|
+
if (Array.isArray(node)) return node.every(local);
|
|
134
|
+
if (!isJsonObject(node) || Object.hasOwn(node, '$const')) return true;
|
|
135
|
+
return Object.values(node).every(local);
|
|
136
|
+
};
|
|
137
|
+
if (![inner.$where, inner.$groupby, inner.$return].every(local)) return null;
|
|
138
|
+
const scalar = groupNames.some((name) => inner.$return === `$${name}`)
|
|
139
|
+
|| (isJsonObject(inner.$return) && Object.keys(inner.$return).length === 1
|
|
140
|
+
&& AGGREGATE_MEMBERS.has(Object.keys(inner.$return)[0]));
|
|
141
|
+
if (scalar) return { binding, groups: groupNames,
|
|
142
|
+
key: groupNames.map((name) => [inner.$groupby[name]]), where: inner.$where, members: [] };
|
|
131
143
|
if (!isJsonObject(inner.$return)) return null;
|
|
132
144
|
const members = [];
|
|
133
145
|
for (const name of Object.keys(inner.$return)) {
|
|
134
146
|
const expr = inner.$return[name];
|
|
135
|
-
if (expr === `$${group}`) {
|
|
147
|
+
if (groupNames.some((group) => expr === `$${group}`)) {
|
|
136
148
|
members.push({ name, kind: 'key', defaulted: false });
|
|
137
149
|
continue;
|
|
138
150
|
}
|
|
139
151
|
if (isJsonObject(expr) && Array.isArray(expr.$default)
|
|
140
|
-
&& expr.$default.length === 2 && expr.$default[0] === `$${group}`
|
|
152
|
+
&& expr.$default.length === 2 && groupNames.some((group) => expr.$default[0] === `$${group}`)
|
|
141
153
|
&& expr.$default[1] === null && Object.keys(expr).length === 1) {
|
|
142
154
|
members.push({ name, kind: 'key', defaulted: true });
|
|
143
155
|
continue;
|
|
@@ -150,7 +162,8 @@ function recogniseGroupForm(inner) {
|
|
|
150
162
|
}
|
|
151
163
|
return null;
|
|
152
164
|
}
|
|
153
|
-
return { binding,
|
|
165
|
+
return { binding, groups: groupNames, key: groupNames.map((name) => [inner.$groupby[name]]),
|
|
166
|
+
where: inner.$where, members };
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
/**
|
|
@@ -276,10 +289,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
276
289
|
|
|
277
290
|
if (aggregate !== null) {
|
|
278
291
|
if (windowed) return rerun('a windowed aggregate maintains no accumulator');
|
|
292
|
+
if (recogniseGroupForm(inner) !== null) {
|
|
293
|
+
const grouped = classifyLiveQuery(inner, queryShape, keyed, eventTime);
|
|
294
|
+
if (grouped.strategy === 'group') return { ...grouped, aggregate: aggregate.name };
|
|
295
|
+
}
|
|
279
296
|
const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
|
|
280
297
|
if (planned.mode !== 'native' || planned.plan.aggregate === null) {
|
|
281
298
|
return rerun(refinedOnly(planned, false) ? SPATIAL_RERUN.aggregate : plannerReason(planned));
|
|
282
299
|
}
|
|
300
|
+
if (planned.plan.group !== null || planned.plan.bucket !== null)
|
|
301
|
+
return rerun('an aggregate over groups needs group-level maintenance');
|
|
283
302
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
284
303
|
return {
|
|
285
304
|
strategy: 'accumulator',
|
|
@@ -300,19 +319,9 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
300
319
|
: `the group filter did not translate: ${plannerReason(planned)}`);
|
|
301
320
|
}
|
|
302
321
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
303
|
-
const rowDocument = {
|
|
304
|
-
$for: { [group.binding]: '$[*]' },
|
|
305
|
-
...(group.where !== undefined ? { $where: group.where } : {}),
|
|
306
|
-
$return: {
|
|
307
|
-
k: [group.groupExpr],
|
|
308
|
-
...Object.fromEntries(group.members
|
|
309
|
-
.filter((member) => member.kind === 'aggregate')
|
|
310
|
-
.map((member) => [member.name, [member.operand]])),
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
322
|
return {
|
|
314
|
-
strategy: 'group', group,
|
|
315
|
-
deps: memberDeps(analyzeQuery(
|
|
323
|
+
strategy: 'group', inner, binding: group.binding, key: group.key, carrier,
|
|
324
|
+
deps: memberDeps(analyzeQuery(inner).root),
|
|
316
325
|
};
|
|
317
326
|
}
|
|
318
327
|
|
|
@@ -324,6 +333,16 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
324
333
|
return rerun('a k-nearest ranking re-runs (the vector column cuts the candidates and the engine orders them)');
|
|
325
334
|
|
|
326
335
|
const planned = planQuery(inner, queryShape, {});
|
|
336
|
+
const distinct = isJsonObject(inner) ? inner.$distinct : null;
|
|
337
|
+
if (!windowed && keyed && planned.mode === 'native' && isJsonObject(distinct)
|
|
338
|
+
&& distinct.$orderby === undefined && bindingNameOf(distinct) !== null) {
|
|
339
|
+
const binding = bindingNameOf(distinct);
|
|
340
|
+
const name = binding === '_distinct' ? '_distinctKey' : '_distinct';
|
|
341
|
+
const grouped = { ...distinct, $groupby: { [name]: distinct.$return }, $return: `$${name}` };
|
|
342
|
+
return { strategy: 'distinct', inner: grouped, binding, key: [distinct.$return],
|
|
343
|
+
carrier: documentsSource(binding, distinct.$where),
|
|
344
|
+
deps: memberDeps(analyzeQuery(grouped).root) };
|
|
345
|
+
}
|
|
327
346
|
if (planned.mode === 'set') {
|
|
328
347
|
// §7's spatial rows: the fetch is SQL-narrowed by the pushed box or
|
|
329
348
|
// cell range and the exact predicate is what per-row re-evaluation
|
|
@@ -346,6 +365,13 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
346
365
|
}
|
|
347
366
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
348
367
|
|
|
368
|
+
// SQL can group a complete selection without proving that evaluating
|
|
369
|
+
// the document once per changed row maintains its result. Canonical
|
|
370
|
+
// maintained groups were handled above; the remaining groups and
|
|
371
|
+
// distinct projections need set-level invalidation.
|
|
372
|
+
if (planned.plan.group !== null || planned.plan.bucket !== null)
|
|
373
|
+
return rerun('this grouping or distinct projection needs set-level maintenance');
|
|
374
|
+
|
|
349
375
|
if (planned.plan.order !== null) {
|
|
350
376
|
if (offset > 0 || (planned.plan.window?.offset ?? 0) > 0) {
|
|
351
377
|
return rerun('an offset window re-runs');
|
|
@@ -523,6 +549,7 @@ function rowsStrategy(description, context) {
|
|
|
523
549
|
return flatten();
|
|
524
550
|
}),
|
|
525
551
|
entries: () => flatten().length,
|
|
552
|
+
close: () => itemsByKey.clear(),
|
|
526
553
|
apply(record, previousRows) {
|
|
527
554
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
528
555
|
if (touched === null) return null;
|
|
@@ -602,6 +629,7 @@ function windowStrategy(description, context) {
|
|
|
602
629
|
return visibleRows();
|
|
603
630
|
}),
|
|
604
631
|
entries: () => sortedWindow.size(),
|
|
632
|
+
close: () => sortedWindow.clear(),
|
|
605
633
|
apply(record, previousRows) {
|
|
606
634
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
607
635
|
if (touched === null) return null;
|
|
@@ -681,6 +709,7 @@ function accumulatorStrategy(description, context) {
|
|
|
681
709
|
return rowsOf();
|
|
682
710
|
}),
|
|
683
711
|
entries: () => contributions.size,
|
|
712
|
+
close: () => contributions.clear(),
|
|
684
713
|
stats: () => ({ ...stats }),
|
|
685
714
|
apply(record, previousRows) {
|
|
686
715
|
const touched = touchedKeys(record, context.name, description.deps);
|
|
@@ -713,121 +742,6 @@ function accumulatorStrategy(description, context) {
|
|
|
713
742
|
};
|
|
714
743
|
}
|
|
715
744
|
|
|
716
|
-
/**
|
|
717
|
-
* Canonical single-level `groupBy` with aggregate returns: §7's
|
|
718
|
-
* per-group deltas — the accumulator machinery once per group, groups
|
|
719
|
-
* in first-appearance order.
|
|
720
|
-
* @param {any} description
|
|
721
|
-
* @param {any} context
|
|
722
|
-
*/
|
|
723
|
-
function groupStrategy(description, context) {
|
|
724
|
-
const { group, rowDocument, carrier } = description;
|
|
725
|
-
const evaluate = compileJsonQuery([rowDocument]);
|
|
726
|
-
|
|
727
|
-
/** @type {Map<string, { key: any[], rows: Map<string, any>, row: any }>}
|
|
728
|
-
* group token → per-row contributions, in first-appearance order */
|
|
729
|
-
const groups = new Map();
|
|
730
|
-
|
|
731
|
-
const contributionOf = (doc) => {
|
|
732
|
-
const evaluated = /** @type {any[]} */ (evaluate([doc], context.externals));
|
|
733
|
-
return evaluated.length === 0 ? undefined : evaluated[0];
|
|
734
|
-
};
|
|
735
|
-
const buildRow = (entry) => {
|
|
736
|
-
/** @type {any} */
|
|
737
|
-
const row = {};
|
|
738
|
-
for (const member of group.members) {
|
|
739
|
-
if (member.kind === 'key') {
|
|
740
|
-
if (entry.key.length > 0) row[member.name] = entry.key[0];
|
|
741
|
-
else if (member.defaulted) row[member.name] = null;
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
let sum = 0;
|
|
745
|
-
let count = 0;
|
|
746
|
-
let extreme;
|
|
747
|
-
let n = 0;
|
|
748
|
-
for (const contribution of entry.rows.values()) {
|
|
749
|
-
const items = /** @type {any[]} */ (contribution[member.name] ?? []);
|
|
750
|
-
n += items.length;
|
|
751
|
-
for (const value of items) {
|
|
752
|
-
sum += value;
|
|
753
|
-
count += 1;
|
|
754
|
-
if (extreme === undefined
|
|
755
|
-
|| (member.fn === 'min' ? value < extreme : value > extreme)) extreme = value;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
if (member.fn === 'count') row[member.name] = n;
|
|
759
|
-
else if (member.fn === 'sum') row[member.name] = sum;
|
|
760
|
-
else if (count > 0) row[member.name] = member.fn === 'avg' ? sum / count : extreme;
|
|
761
|
-
// an empty avg/min/max leaves the member absent, the engine's
|
|
762
|
-
// empty-sequence rule
|
|
763
|
-
}
|
|
764
|
-
return row;
|
|
765
|
-
};
|
|
766
|
-
const rowsOf = () => [...groups.values()].map((entry) => entry.row);
|
|
767
|
-
|
|
768
|
-
const placeRow = (token, doc, changedGroups) => {
|
|
769
|
-
const contribution = doc === undefined ? undefined : contributionOf(doc);
|
|
770
|
-
for (const [groupToken, entry] of groups) {
|
|
771
|
-
if (!entry.rows.has(token)) continue;
|
|
772
|
-
if (contribution !== undefined
|
|
773
|
-
&& stableStringify(entry.rows.get(token)) === stableStringify(contribution)) {
|
|
774
|
-
return; // unchanged in place
|
|
775
|
-
}
|
|
776
|
-
entry.rows.delete(token);
|
|
777
|
-
changedGroups.add(groupToken);
|
|
778
|
-
break;
|
|
779
|
-
}
|
|
780
|
-
if (contribution === undefined) return;
|
|
781
|
-
const groupToken = stableStringify(contribution.k) ?? '';
|
|
782
|
-
let entry = groups.get(groupToken);
|
|
783
|
-
if (entry === undefined) {
|
|
784
|
-
entry = { key: contribution.k, rows: new Map(), row: null };
|
|
785
|
-
groups.set(groupToken, entry);
|
|
786
|
-
}
|
|
787
|
-
entry.rows.set(token, contribution);
|
|
788
|
-
changedGroups.add(groupToken);
|
|
789
|
-
};
|
|
790
|
-
const settle = (changedGroups) => {
|
|
791
|
-
for (const groupToken of changedGroups) {
|
|
792
|
-
const entry = groups.get(groupToken);
|
|
793
|
-
if (entry === undefined) continue;
|
|
794
|
-
if (entry.rows.size === 0) groups.delete(groupToken);
|
|
795
|
-
else entry.row = sharedRow(entry.row, buildRow(entry));
|
|
796
|
-
}
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
return {
|
|
800
|
-
init: () => chain(context.execute([carrier], { externals: context.externals }),
|
|
801
|
-
(docs) => {
|
|
802
|
-
const changedGroups = new Set();
|
|
803
|
-
for (const doc of /** @type {any[]} */ (docs)) {
|
|
804
|
-
placeRow(context.keyOf(doc), doc, changedGroups);
|
|
805
|
-
}
|
|
806
|
-
settle(changedGroups);
|
|
807
|
-
return rowsOf();
|
|
808
|
-
}),
|
|
809
|
-
entries: () => {
|
|
810
|
-
let total = groups.size;
|
|
811
|
-
for (const entry of groups.values()) total += entry.rows.size;
|
|
812
|
-
return total;
|
|
813
|
-
},
|
|
814
|
-
apply(record, previousRows) {
|
|
815
|
-
const touched = touchedKeys(record, context.name, description.deps);
|
|
816
|
-
if (touched === null) return null;
|
|
817
|
-
const changedGroups = new Set();
|
|
818
|
-
for (const [token, change] of touched) {
|
|
819
|
-
const doc = change.kind === 'delete'
|
|
820
|
-
? undefined
|
|
821
|
-
: change.kind === 'insert' ? change.doc : context.readRow(token);
|
|
822
|
-
placeRow(token, doc, changedGroups);
|
|
823
|
-
}
|
|
824
|
-
if (changedGroups.size === 0) return null;
|
|
825
|
-
settle(changedGroups);
|
|
826
|
-
return diffAgainst(previousRows, rowsOf());
|
|
827
|
-
},
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
|
|
831
745
|
/**
|
|
832
746
|
* Everything outside the table: re-run the WHOLE query on
|
|
833
747
|
* invalidation and diff against the previous result with value-equal
|
|
@@ -903,7 +817,8 @@ export function createLiveRegistry(bounds) {
|
|
|
903
817
|
};
|
|
904
818
|
const STRATEGIES = {
|
|
905
819
|
rows: rowsStrategy, window: windowStrategy, accumulator: accumulatorStrategy,
|
|
906
|
-
group:
|
|
820
|
+
group: nestedGroupStrategy, distinct: nestedGroupStrategy,
|
|
821
|
+
bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
|
|
907
822
|
'nested-group': nestedGroupStrategy,
|
|
908
823
|
};
|
|
909
824
|
const strategy = (STRATEGIES[classification.strategy] ?? rerunStrategy)(
|
|
@@ -955,7 +870,7 @@ export function createLiveRegistry(bounds) {
|
|
|
955
870
|
outcome = ops.length === 0 && !late ? null
|
|
956
871
|
: { ops, rows, ...(late ? { late: outcome.late } : {}) };
|
|
957
872
|
}
|
|
958
|
-
|
|
873
|
+
checkBound(strategy.entries(outcome?.rows ?? state.result.rows));
|
|
959
874
|
}
|
|
960
875
|
catch (error) {
|
|
961
876
|
state.status = 'errored';
|
package/src/migrate.js
CHANGED
|
@@ -42,6 +42,9 @@ import { normalizeEntities, explainMapping } from './model.js';
|
|
|
42
42
|
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
43
43
|
import { mergeEntityRow } from './graph.js';
|
|
44
44
|
import { entityCore } from './entity.js';
|
|
45
|
+
import { sqlTokens } from './dialects/check-read.js';
|
|
46
|
+
import { readSchema } from './introspect.js';
|
|
47
|
+
import { verifyPhysical, physicalSelection } from './physical.js';
|
|
45
48
|
import {
|
|
46
49
|
MIGRATION_VERSION, isPerDocumentAssertion, compileDocumentStep, checkMigrationDocument,
|
|
47
50
|
normalizeAssertionBounds, ASSERTION_BOUNDS_DEFAULT, createAssertionBoundGuard,
|
|
@@ -195,6 +198,8 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
195
198
|
const dialect = options?.dialect ?? null;
|
|
196
199
|
if (dialect === null || typeof dialect !== 'object')
|
|
197
200
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
201
|
+
if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined)))
|
|
202
|
+
throw refuse('JD0021', 'column layouts require planPhysicalMigration with explicit preservation dispositions');
|
|
198
203
|
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
|
|
199
204
|
// a model that declares an index EXPRESSION resolves its functions
|
|
200
205
|
// here too: a plan is DDL, and DDL over a function this planner was
|
|
@@ -1054,6 +1059,18 @@ function walkRows(connection, table, batchSize, handle, keyed = true, entityMapp
|
|
|
1054
1059
|
// the rest-document alone could not see `id` or `name` at all
|
|
1055
1060
|
const dialect = connection.dialect;
|
|
1056
1061
|
const q = dialect.quoteIdentifier;
|
|
1062
|
+
if (entityMapping?.document === false) {
|
|
1063
|
+
const order = entityMapping.keys.map((k) => q(entityMapping.columns.find((c) => c.name === k).physical)).join(', ');
|
|
1064
|
+
const next = (offset) => {
|
|
1065
|
+
check?.();
|
|
1066
|
+
const sql = `SELECT ${physicalSelection(entityMapping, dialect)} FROM ${q(entityMapping.table)} ORDER BY ${order} ${dialect.limitClause(batchSize, offset)}`;
|
|
1067
|
+
return chain(connection.prepare(sql), (statement) => chain(statement.all([]), (rows) => {
|
|
1068
|
+
if (!rows.length) return null;
|
|
1069
|
+
return chain(handle(rows.map((row, i) => ({ ...row, rid: offset + i }))), () => next(offset + rows.length));
|
|
1070
|
+
}));
|
|
1071
|
+
};
|
|
1072
|
+
return next(0);
|
|
1073
|
+
}
|
|
1057
1074
|
const rid = dialect.rowIdentity();
|
|
1058
1075
|
const keySelect = keyed ? `, ${q('key')} AS ${q('k')}` : '';
|
|
1059
1076
|
const columnSelect = entityMapping === null ? '' : entityColumnsOf(entityMapping)
|
|
@@ -1118,6 +1135,8 @@ function runSteps(connection, migration, options) {
|
|
|
1118
1135
|
// migration in flight back whole, as any step failure does
|
|
1119
1136
|
if (options.check !== undefined) options.check();
|
|
1120
1137
|
const current = migration.steps[i];
|
|
1138
|
+
if (migration.physical && !['ddl', 'sql', 'rebuild'].includes(current.kind))
|
|
1139
|
+
throw refuse('JD0021', 'physical preservation plans use explicit SQL/rebuild steps and preservation assertions');
|
|
1121
1140
|
const fail = (reason, cause) => {
|
|
1122
1141
|
throw refuse('JD0023',
|
|
1123
1142
|
`migration '${migration.id}' step ${i} (${current.kind}) failed: ${reason}`,
|
|
@@ -1356,14 +1375,16 @@ function validateTargetState(connection, model, options) {
|
|
|
1356
1375
|
const validate = options.compileSchema !== undefined
|
|
1357
1376
|
? options.compileSchema(entity.schema)
|
|
1358
1377
|
: null;
|
|
1359
|
-
if (validate === null) return verifyEntity(i + 1);
|
|
1378
|
+
if (validate === null && entity.physical === null) return verifyEntity(i + 1);
|
|
1360
1379
|
// the WHOLE document — mapped columns folded in — is what the target
|
|
1361
1380
|
// schema judges; the rest-document alone failed every entity whose
|
|
1362
1381
|
// required members are columns, so a pure widening could not land
|
|
1363
1382
|
const entityMapping = explainMapping(model).entities[entity.name];
|
|
1364
|
-
return chain(
|
|
1383
|
+
return chain(entity.physical === null ? null : chain(readSchema(connection), (schema) =>
|
|
1384
|
+
verifyPhysical(connection, planEntity(entity.name, entityMapping, explainMapping(model), dialect).physical, schema)), () =>
|
|
1385
|
+
chain(walkRows(connection, entityMapping.table, options.batchSize, (rows) => {
|
|
1365
1386
|
for (const row of rows) {
|
|
1366
|
-
const outcome = validate(mergeEntityRow(entityMapping, row, 'doc'));
|
|
1387
|
+
const outcome = validate === null ? true : validate(mergeEntityRow(entityMapping, row, 'doc'));
|
|
1367
1388
|
const valid = outcome === true || outcome?.valid === true;
|
|
1368
1389
|
if (!valid) {
|
|
1369
1390
|
throw refuse('JD0021',
|
|
@@ -1371,7 +1392,7 @@ function validateTargetState(connection, model, options) {
|
|
|
1371
1392
|
+ 'validate against the target schema — a narrowing needs a data transform');
|
|
1372
1393
|
}
|
|
1373
1394
|
}
|
|
1374
|
-
}, false, entityMapping), () => verifyEntity(i + 1));
|
|
1395
|
+
}, false, entityMapping), () => verifyEntity(i + 1)));
|
|
1375
1396
|
};
|
|
1376
1397
|
const verifyNext = (i) => {
|
|
1377
1398
|
if (i >= collections.length) return null;
|
|
@@ -1689,13 +1710,12 @@ export function migrate(target, migrations, options) {
|
|
|
1689
1710
|
// table before reading it, a DRY RUN probes for it instead and
|
|
1690
1711
|
// reads an absent one as an empty history — the promise a dry
|
|
1691
1712
|
// run makes is the reason it is safe to point at production
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
chain(connection.prepare(statements.select), (select) => select.all([])));
|
|
1713
|
+
let historyExists = false;
|
|
1714
|
+
const history = chain(connection.prepare(dialect.introspect.tableExists()), (probe) =>
|
|
1715
|
+
chain(probe.get([HISTORY_TABLE]), (row) => {
|
|
1716
|
+
historyExists = row !== undefined;
|
|
1717
|
+
return !historyExists ? [] : chain(connection.prepare(statements.select), (select) => select.all([]));
|
|
1718
|
+
}));
|
|
1699
1719
|
work = chain(history, (appliedRows) => {
|
|
1700
1720
|
// the list must agree with the history: same ids, same
|
|
1701
1721
|
// order, same checksums — an edited applied migration is
|
|
@@ -1723,6 +1743,7 @@ export function migrate(target, migrations, options) {
|
|
|
1723
1743
|
let expectedFrom = currentShape;
|
|
1724
1744
|
for (const migration of pending) {
|
|
1725
1745
|
checkMigrationDocument(migration);
|
|
1746
|
+
checkPreservationPlan(migration);
|
|
1726
1747
|
if (migration.from !== expectedFrom) {
|
|
1727
1748
|
throw refuse('JD0020',
|
|
1728
1749
|
`migration '${migration.id}' expects shape '${migration.from}' but the `
|
|
@@ -1741,6 +1762,8 @@ export function migrate(target, migrations, options) {
|
|
|
1741
1762
|
return { applied: [], skipped: appliedRows.map((row) => row.id), upToDate: true };
|
|
1742
1763
|
}
|
|
1743
1764
|
|
|
1765
|
+
if (pending.some((m) => m.physical) && options.shadow !== false)
|
|
1766
|
+
throw refuse('JD0021', 'physical preservation plans require shadow:false; qualify against an explicit copy/fresh-target fixture');
|
|
1744
1767
|
const shadowRun = options.shadow === false
|
|
1745
1768
|
? null
|
|
1746
1769
|
: replayOnShadow(options.shadowDriver ?? target.driver,
|
|
@@ -1824,11 +1847,14 @@ export function migrate(target, migrations, options) {
|
|
|
1824
1847
|
bracket && dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1825
1848
|
? connection.exec(dialect.pragma.foreignKeys(false)) : null,
|
|
1826
1849
|
() => chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
1827
|
-
const body = () => chain(
|
|
1850
|
+
const body = () => chain(migration.physical ? verifyPreservation(connection, migration.physical, false) : null, () =>
|
|
1851
|
+
chain(historyExists ? null : connection.exec(statements.create), () =>
|
|
1852
|
+
chain(runSteps(connection, migration, runOptions), () =>
|
|
1853
|
+
chain(migration.physical ? verifyPreservation(connection, migration.physical, true) : null, () =>
|
|
1828
1854
|
chain(last && options.model !== undefined
|
|
1829
1855
|
? chain(validateTargetState(connection, options.model,
|
|
1830
1856
|
{ compileSchema: options.compileSchema, batchSize }),
|
|
1831
|
-
() => (normalizeEntities(options.model).size === 0 ? null
|
|
1857
|
+
() => (normalizeEntities(options.model).size === 0 || migration.physical ? null
|
|
1832
1858
|
: chain(compareShapeToModel(target.driver, connection,
|
|
1833
1859
|
options.model, options.registerFunctions), (difference) => {
|
|
1834
1860
|
if (difference !== null) {
|
|
@@ -1841,15 +1867,10 @@ export function migrate(target, migrations, options) {
|
|
|
1841
1867
|
() => chain(connection.prepare(statements.insert), (insert) =>
|
|
1842
1868
|
insert.run([migration.id, runtime.now(), migration.from,
|
|
1843
1869
|
migration.to, migrationChecksum(migration),
|
|
1844
|
-
migration.steps.length]))));
|
|
1870
|
+
migration.steps.length])))))));
|
|
1845
1871
|
const restore = () => (bracket
|
|
1846
1872
|
&& dialect.capabilities.foreignKeysAlwaysOn !== true
|
|
1847
1873
|
? connection.exec(dialect.pragma.foreignKeys(true)) : null);
|
|
1848
|
-
const commit = () => chain(connection.exec(dialect.tx.commit), () =>
|
|
1849
|
-
chain(restore(), () => {
|
|
1850
|
-
applied.push(migration.id);
|
|
1851
|
-
return applyNext(i + 1);
|
|
1852
|
-
}));
|
|
1853
1874
|
const rollback = (error) =>
|
|
1854
1875
|
chain(connection.exec(dialect.tx.rollback), () =>
|
|
1855
1876
|
chain(restore(), () => { throw error; }));
|
|
@@ -1864,9 +1885,16 @@ export function migrate(target, migrations, options) {
|
|
|
1864
1885
|
catch (error) {
|
|
1865
1886
|
return rollback(error);
|
|
1866
1887
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1888
|
+
const settle = () => {
|
|
1889
|
+
let result;
|
|
1890
|
+
try { result = connection.exec(dialect.tx.commit); }
|
|
1891
|
+
catch (error) { return rollback(error); }
|
|
1892
|
+
return result instanceof Promise ? result.then(published, rollback) : published();
|
|
1893
|
+
};
|
|
1894
|
+
const published = () => chain(restore(), () => {
|
|
1895
|
+
historyExists = true; applied.push(migration.id); return applyNext(i + 1);
|
|
1896
|
+
});
|
|
1897
|
+
return outcome instanceof Promise ? outcome.then(settle, rollback) : settle();
|
|
1870
1898
|
}));
|
|
1871
1899
|
};
|
|
1872
1900
|
return chain(applyNext(0), () => ({
|
|
@@ -1886,3 +1914,89 @@ export function migrate(target, migrations, options) {
|
|
|
1886
1914
|
: finish(work);
|
|
1887
1915
|
})));
|
|
1888
1916
|
}
|
|
1917
|
+
|
|
1918
|
+
/** Plan an existing file's explicit preservation migration. Every source object
|
|
1919
|
+
* needs a disposition; source/target assertions preserve application-owned facts.
|
|
1920
|
+
* @param {any} connection @param {any} fromModel @param {any} toModel
|
|
1921
|
+
* @param {{ id: string, steps: any[], dispositions: Record<string, 'preserve'|'replace'|'drop'>,
|
|
1922
|
+
* assertions?: { sql: string, params?: any[], expected: any[] }[] }} options @returns {any} */
|
|
1923
|
+
export function planPhysicalMigration(connection, fromModel, toModel, options) {
|
|
1924
|
+
normalizeEntities(fromModel); normalizeEntities(toModel);
|
|
1925
|
+
if (!options || typeof options.id !== 'string' || !options.id || !Array.isArray(options.steps))
|
|
1926
|
+
throw refuse('JD0021', 'a physical plan requires id and explicit steps');
|
|
1927
|
+
return chain(preservationSchemaOf(connection), (source) => {
|
|
1928
|
+
const dispositions = options.dispositions ?? {};
|
|
1929
|
+
const keys = source.map((o) => `${o.type}:${o.name}`);
|
|
1930
|
+
if (Object.keys(dispositions).some((key) => !keys.includes(key)) || keys.some((key) => !['preserve', 'replace', 'drop'].includes(dispositions[key])))
|
|
1931
|
+
throw refuse('JD0021', 'every physical source object must have an explicit preserve, replace or drop disposition');
|
|
1932
|
+
const assertions = options.assertions ?? [];
|
|
1933
|
+
for (const assertion of assertions) {
|
|
1934
|
+
if (!assertion || typeof assertion.sql !== 'string' || !/^SELECT\b/i.test(assertion.sql.trim()) || !Array.isArray(assertion.expected))
|
|
1935
|
+
throw refuse('JD0021', 'preservation assertions require a SELECT and expected rows');
|
|
1936
|
+
}
|
|
1937
|
+
const migration = { $migration: MIGRATION_VERSION, id: options.id, from: shapeHash(fromModel), to: shapeHash(toModel),
|
|
1938
|
+
steps: options.steps, physical: { source, dispositions, assertions } };
|
|
1939
|
+
checkMigrationDocument(migration);
|
|
1940
|
+
checkPreservationPlan(migration);
|
|
1941
|
+
return migration;
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** Validate saved plans again at execution, including SQL ownership boundaries. */
|
|
1946
|
+
function checkPreservationPlan(migration) {
|
|
1947
|
+
const physical = migration.physical;
|
|
1948
|
+
if (physical === undefined) return;
|
|
1949
|
+
const fail = () => { throw refuse('JD0021', 'invalid physical source, dispositions, assertions or steps'); };
|
|
1950
|
+
if (!physical || !Array.isArray(physical.source) || !physical.dispositions || !Array.isArray(physical.assertions)) fail();
|
|
1951
|
+
const keys = physical.source.map((object) => {
|
|
1952
|
+
if (!object || typeof object.name !== 'string' || !['table', 'view', 'index', 'trigger'].includes(object.type)) fail();
|
|
1953
|
+
return `${object.type}:${object.name}`;
|
|
1954
|
+
});
|
|
1955
|
+
if (new Set(keys).size !== keys.length || Object.keys(physical.dispositions).some((key) => !keys.includes(key))
|
|
1956
|
+
|| keys.some((key) => !['preserve', 'replace', 'drop'].includes(physical.dispositions[key]))) fail();
|
|
1957
|
+
for (const assertion of physical.assertions)
|
|
1958
|
+
if (!assertion || typeof assertion.sql !== 'string' || !/^SELECT\b/i.test(assertion.sql.trim())
|
|
1959
|
+
|| !Array.isArray(assertion.expected) || (assertion.params !== undefined && !Array.isArray(assertion.params))) fail();
|
|
1960
|
+
if (migration.steps.some((step) => !['ddl', 'sql', 'rebuild'].includes(step.kind))) fail();
|
|
1961
|
+
const fragments = migration.steps.flatMap((step) => step.kind === 'rebuild'
|
|
1962
|
+
? [...(step.create ?? []), step.copy, ...(step.indexes ?? [])] : [step.sql]);
|
|
1963
|
+
for (const sql of fragments) {
|
|
1964
|
+
const tokens = typeof sql === 'string' ? sqlTokens(sql) : [];
|
|
1965
|
+
const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
|
|
1966
|
+
if (!['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE'].includes(words[0])
|
|
1967
|
+
|| words.some((w) => /^(?:COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|VACUUM)$/.test(w)))
|
|
1968
|
+
throw refuse('JD0021', 'physical steps cannot change transaction or connection ownership');
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/** Preservation compares exact source programs, including whitespace in SQL literals. */
|
|
1973
|
+
function preservationSchemaOf(connection) {
|
|
1974
|
+
return chain(readSchema(connection), (schema) => schema.objects
|
|
1975
|
+
.filter((object) => !ENGINE_TABLES.has(object.name) && !ENGINE_TABLES.has(object.owner)));
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/** Verify source identity before destructive steps, and every preserved object
|
|
1979
|
+
* and fact before publication. The migration transaction owns all these reads. */
|
|
1980
|
+
function verifyPreservation(connection, physical, after) {
|
|
1981
|
+
return chain(preservationSchemaOf(connection), (actual) => {
|
|
1982
|
+
if (!after && canonicalizeJson(actual) !== canonicalizeJson(physical.source))
|
|
1983
|
+
throw refuse('JD0020', 'the physical source schema changed after the plan was prepared');
|
|
1984
|
+
if (after) {
|
|
1985
|
+
for (const object of physical.source) {
|
|
1986
|
+
const key = `${object.type}:${object.name}`;
|
|
1987
|
+
const current = actual.find((o) => o.type === object.type && o.name === object.name);
|
|
1988
|
+
if (physical.dispositions[key] === 'preserve' && canonicalizeJson(current ?? null) !== canonicalizeJson(object))
|
|
1989
|
+
throw refuse('JD0023', `preserved object '${key}' was changed or lost`);
|
|
1990
|
+
if (physical.dispositions[key] === 'drop' && current) throw refuse('JD0023', `declared drop '${key}' remains`);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const next = (i) => i >= physical.assertions.length ? null
|
|
1994
|
+
: chain(connection.prepare(physical.assertions[i].sql, { readOnly: true }), (s) =>
|
|
1995
|
+
chain(s.all(physical.assertions[i].params ?? []), (rows) => {
|
|
1996
|
+
if (canonicalizeJson(rows) !== canonicalizeJson(physical.assertions[i].expected))
|
|
1997
|
+
throw refuse('JD0023', `preservation assertion ${i} disagrees ${after ? 'after' : 'before'} migration`);
|
|
1998
|
+
return next(i + 1);
|
|
1999
|
+
}));
|
|
2000
|
+
return next(0);
|
|
2001
|
+
});
|
|
2002
|
+
}
|
package/src/model.js
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
28
28
|
|
|
29
29
|
import { DbCompileError } from './errors.js';
|
|
30
|
+
import { normalizePhysical } from './physical.js';
|
|
31
|
+
import { normalizeInvariants } from './invariants.js';
|
|
30
32
|
|
|
31
33
|
/** The closed `x-entity` vocabulary; anything else is `JD0030`. */
|
|
32
34
|
const ENTITY_MEMBERS = new Set(['key', 'unique', 'index', 'default', 'column', 'relation', 'version']);
|
|
@@ -343,9 +345,13 @@ export function normalizeEntities(model) {
|
|
|
343
345
|
keys,
|
|
344
346
|
relations,
|
|
345
347
|
version: versions.length === 1 ? versions[0].name : null,
|
|
348
|
+
physical: normalizePhysical(spec.physical, properties, keys, docPath),
|
|
349
|
+
invariants: normalizeInvariants(spec.invariants, `${docPath}/invariants`),
|
|
346
350
|
});
|
|
347
351
|
}
|
|
348
352
|
|
|
353
|
+
for (const entity of entities.values())
|
|
354
|
+
if (entity.physical !== null && entity.relations.length) throw modelError('physical join tables are declared as entities; relation navigation is not qualified for column layouts', entity.docPath);
|
|
349
355
|
resolveRelations(entities);
|
|
350
356
|
return entities;
|
|
351
357
|
}
|
|
@@ -556,6 +562,11 @@ export function explainMapping(model) {
|
|
|
556
562
|
const mapping = { entities: {}, joinTables: {} };
|
|
557
563
|
|
|
558
564
|
for (const entity of entities.values()) {
|
|
565
|
+
if (entity.physical !== null) {
|
|
566
|
+
mapping.entities[entity.name] = { ...entity.physical, document: false,
|
|
567
|
+
foreignKeys: [], indexes: [], version: entity.version, invariants: model.entities[entity.name].invariants ?? [] };
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
559
570
|
const columns = [];
|
|
560
571
|
const document = [];
|
|
561
572
|
const indexes = [];
|
|
@@ -626,6 +637,7 @@ export function explainMapping(model) {
|
|
|
626
637
|
indexes,
|
|
627
638
|
document,
|
|
628
639
|
version: entity.version ?? null,
|
|
640
|
+
...(entity.invariants.length ? { invariants: model.entities[entity.name].invariants } : {}),
|
|
629
641
|
};
|
|
630
642
|
}
|
|
631
643
|
|