@jarenjs/db 0.67.0 → 0.72.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.
- package/README.md +29 -0
- package/docs/JOBS-FORMAT.md +14 -1
- package/docs/MIGRATION-FORMAT.md +45 -26
- package/package.json +4 -4
- package/src/cli.js +89 -68
- package/src/dag-job.js +17 -12
- package/src/document-files.js +76 -20
- package/src/document-steps.js +94 -120
- package/src/documents.js +24 -6
- package/src/drivers/node.js +1 -1
- package/src/jobs.js +42 -0
- package/src/migrate.js +44 -5
- package/src/store.js +6 -0
- package/types/index.d.ts +32 -7
- package/types/node.d.ts +9 -3
package/src/document-steps.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* differing in answer.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { analyzeQuery, createQueryAccumulator } from '@jarenjs/json/query';
|
|
19
20
|
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
20
21
|
import { utf8Length } from './cursor.js';
|
|
21
22
|
|
|
@@ -54,115 +55,73 @@ export function stepFailure(migrationId, index, kind, reason, cause) {
|
|
|
54
55
|
undefined, cause);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* exactly what evaluating it over the whole collection would. Anything
|
|
62
|
-
* that reads the root (`$count: '$[*]'`, a `$let`, a `$distinct`, a
|
|
63
|
-
* nested `$for`) is cross-document and keeps its whole-collection read.
|
|
64
|
-
* @param {any} query
|
|
65
|
-
* @returns {boolean}
|
|
66
|
-
*/
|
|
67
|
-
export function isPerDocumentAssertion(query) {
|
|
68
|
-
if (query === null || typeof query !== 'object' || Array.isArray(query)) return false;
|
|
69
|
-
const keys = Object.keys(query);
|
|
70
|
-
if (!keys.includes('$for') || !keys.includes('$return')
|
|
71
|
-
|| keys.some((key) => !['$for', '$where', '$return'].includes(key))) return false;
|
|
72
|
-
const bindings = query.$for;
|
|
73
|
-
if (bindings === null || typeof bindings !== 'object' || Array.isArray(bindings)) return false;
|
|
74
|
-
const names = Object.keys(bindings);
|
|
75
|
-
if (names.length !== 1 || bindings[names[0]] !== '$[*]') return false;
|
|
76
|
-
// a root reference anywhere in the body is a cross-document read
|
|
77
|
-
const body = JSON.stringify({ $where: query.$where ?? null, $return: query.$return });
|
|
78
|
-
return !/"\$(?:[[.]|")/.test(body.replace(/"\$[A-Za-z_][A-Za-z0-9_]*/g, '"'));
|
|
58
|
+
/** A path with no filter capable of reading the collection root. */
|
|
59
|
+
function localPath(path) {
|
|
60
|
+
return path.segments.every((segment) => !segment.descendant
|
|
61
|
+
&& segment.selectors.every((selector) => ['name', 'index', 'wildcard'].includes(selector.kind)));
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
start: undefined,
|
|
115
|
-
combine: (accumulated, partial) => (partial === undefined ? accumulated
|
|
116
|
-
: (accumulated === undefined || partial < accumulated ? partial : accumulated)),
|
|
117
|
-
},
|
|
118
|
-
};
|
|
64
|
+
/** Every item of this root scan belongs to exactly one input document. */
|
|
65
|
+
function rootScan(node) {
|
|
66
|
+
return node?.kind === 'path' && node.rootSlot === 0 && localPath(node)
|
|
67
|
+
&& node.segments[0]?.selectors.length === 1
|
|
68
|
+
&& node.segments[0].selectors[0].kind === 'wildcard';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Expressions local to one binding; global/root and positional reads refuse. */
|
|
72
|
+
function bindingLocal(node, slot) {
|
|
73
|
+
if (node === null || typeof node !== 'object') return true;
|
|
74
|
+
if (node.kind === 'literal' || node.kind === 'raw') return true;
|
|
75
|
+
if (node.kind === 'var') return node.slot === slot && !node.external;
|
|
76
|
+
if (node.kind === 'path') return node.rootSlot === slot && localPath(node);
|
|
77
|
+
if (node.kind === 'flwor' || node.kind === 'call') return false;
|
|
78
|
+
return Object.values(node).every((value) => Array.isArray(value)
|
|
79
|
+
? value.every((item) => bindingLocal(item, slot)) : bindingLocal(value, slot));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A single unwindowed root binding whose body cannot observe other rows. */
|
|
83
|
+
function independentPhrase(node) {
|
|
84
|
+
if (node?.kind !== 'flwor' || node.forBindings.length !== 1
|
|
85
|
+
|| node.letBindings.length > 0 || node.groupby || node.orderby || node.count
|
|
86
|
+
|| node.fold || node.limits || node.asChecks) return false;
|
|
87
|
+
const binding = node.forBindings[0];
|
|
88
|
+
return rootScan(binding.expr) && binding.atSlot < 0 && !binding.allowingEmpty
|
|
89
|
+
&& !binding.window && bindingLocal(node.where, binding.slot) && bindingLocal(node.ret, binding.slot);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Invalid/unknown documents conservatively retain their materializing classification. */
|
|
93
|
+
function assertionAst(query) {
|
|
94
|
+
try { return analyzeQuery(query).root; }
|
|
95
|
+
catch { return null; }
|
|
96
|
+
}
|
|
119
97
|
|
|
120
|
-
/**
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
const keys = Object.keys(query);
|
|
124
|
-
if (keys.length !== 1) return null;
|
|
125
|
-
const operator = keys[0];
|
|
126
|
-
return Object.hasOwn(ASSOCIATIVE_AGGREGATES, operator) ? operator : null;
|
|
98
|
+
/** Whether a query's result is a concatenation of independent per-row results. */
|
|
99
|
+
export function isPerDocumentAssertion(query) {
|
|
100
|
+
return independentPhrase(assertionAst(query));
|
|
127
101
|
}
|
|
128
102
|
|
|
129
103
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* whole, so it walks in batches and fails at the first that violates.
|
|
134
|
-
* - `fold` — it is one associative aggregate over the root, so each
|
|
135
|
-
* batch is answered by the engine and the partial answers combine.
|
|
136
|
-
* - `materialize` — nothing above holds: it needs every document at
|
|
137
|
-
* once, which is a cost the host must be told about and bound.
|
|
138
|
-
*
|
|
139
|
-
* The one classifier every host shares, so the Store and a file cannot
|
|
140
|
-
* disagree about what an assertion costs.
|
|
104
|
+
* Classify before execution. Folds consume operand ITEMS in their original
|
|
105
|
+
* order; addition is never regrouped into partition totals. Distinct folds
|
|
106
|
+
* require an explicit cardinality bound. EBV applies to the complete sequence.
|
|
141
107
|
* @param {any} query
|
|
142
|
-
* @
|
|
108
|
+
* @param {{ expect?: string, maxDistinct?: number }} [options]
|
|
109
|
+
* @returns {{ strategy: 'perDocument'|'fold'|'materialize', shape: string|null, reason: string }}
|
|
143
110
|
*/
|
|
144
|
-
export function classifyAssertion(query) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
111
|
+
export function classifyAssertion(query, options = {}) {
|
|
112
|
+
const ast = assertionAst(query);
|
|
113
|
+
if (independentPhrase(ast)) return options.expect === 'ebv'
|
|
114
|
+
? { strategy: 'fold', shape: '$ebv', reason: 'sequence EBV retains at most two items across all batches' }
|
|
115
|
+
: { strategy: 'perDocument', shape: null, reason: 'an independent predicate over each document' };
|
|
116
|
+
if (ast?.kind === 'op' && Object.keys(query).length === 1 && Object.hasOwn(query, ast.name) && ['$count', '$sum', '$avg', '$min', '$max', '$distinct'].includes(ast.name)
|
|
117
|
+
&& (rootScan(ast.args[0]) || independentPhrase(ast.args[0]))
|
|
118
|
+
&& (ast.name !== '$distinct' || Number.isSafeInteger(options.maxDistinct) && options.maxDistinct > 0)) {
|
|
119
|
+
return { strategy: 'fold', shape: ast.name,
|
|
120
|
+
reason: ast.name === '$distinct' ? 'distinct items are retained under an explicit cardinality bound'
|
|
121
|
+
: 'the operand is partition-independent and items accumulate in original order' };
|
|
151
122
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
strategy: 'fold',
|
|
156
|
-
shape: aggregate,
|
|
157
|
-
reason: `${aggregate} over the root is associative, so batch answers combine`,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
strategy: 'materialize',
|
|
162
|
-
shape: null,
|
|
163
|
-
reason: 'the assertion reads the whole root in a way that does not decompose '
|
|
164
|
-
+ 'into independent batches, so every document must be held at once',
|
|
165
|
-
};
|
|
123
|
+
return { strategy: 'materialize', shape: null,
|
|
124
|
+
reason: 'the assertion may observe the whole root or position, so every document must be held under declared bounds' };
|
|
166
125
|
}
|
|
167
126
|
|
|
168
127
|
/**
|
|
@@ -181,6 +140,7 @@ export function classifyAssertion(query) {
|
|
|
181
140
|
* compileJslt: (stylesheet: any) => (document: any) => any,
|
|
182
141
|
* compileQuery: (query: any) => any,
|
|
183
142
|
* keys?: string[],
|
|
143
|
+
* assertionBounds?: { maxRows: number | null, maxBytes: number | null, maxDistinct?: number },
|
|
184
144
|
* }} context - the host's compilers and the key members a document of
|
|
185
145
|
* this collection carries, which a transform may not move
|
|
186
146
|
* @returns {any} the compiled operation
|
|
@@ -244,17 +204,12 @@ export function compileDocumentStep(step, index, context) {
|
|
|
244
204
|
fail(`the assertion does not compile: ${/** @type {Error} */ (cause).message}`,
|
|
245
205
|
/** @type {Error} */ (cause));
|
|
246
206
|
}
|
|
247
|
-
const classification = classifyAssertion(step.assert
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
// answer over a collection that offered them nothing. It cannot be
|
|
254
|
-
// asked of `identity` — a query's input may not be `undefined` — so it
|
|
255
|
-
// is evaluated once, by the engine, over a wildcard that selects
|
|
256
|
-
// nothing. The fold never decides this itself.
|
|
257
|
-
const emptySequenceEbv = compileQuery('$[*]').ebv([]);
|
|
207
|
+
const classification = classifyAssertion(step.assert, { expect: step.expect,
|
|
208
|
+
maxDistinct: context.assertionBounds?.maxDistinct });
|
|
209
|
+
const operand = classification.shape === '$ebv' ? step.assert
|
|
210
|
+
: classification.shape === null ? null : step.assert[classification.shape];
|
|
211
|
+
const items = classification.strategy === 'fold' ? compileQuery(operand) : null;
|
|
212
|
+
const scalar = compileQuery('$');
|
|
258
213
|
|
|
259
214
|
/** The verdict on a computed result, whichever way it was computed. */
|
|
260
215
|
const check = (result, ebvOf) => {
|
|
@@ -272,6 +227,10 @@ export function compileDocumentStep(step, index, context) {
|
|
|
272
227
|
return {
|
|
273
228
|
kind: 'query',
|
|
274
229
|
collection: step.collection,
|
|
230
|
+
plan: { migration: migrationId, step: index, collection: step.collection, ...classification,
|
|
231
|
+
bounds: classification.strategy === 'materialize' || classification.shape === '$distinct'
|
|
232
|
+
? context.assertionBounds ?? ASSERTION_BOUNDS_DEFAULT : null },
|
|
233
|
+
accept: (value) => check(value, () => scalar.ebv(value)),
|
|
275
234
|
perDocument: classification.strategy === 'perDocument',
|
|
276
235
|
strategy: classification.strategy,
|
|
277
236
|
shape: classification.shape,
|
|
@@ -286,17 +245,28 @@ export function compileDocumentStep(step, index, context) {
|
|
|
286
245
|
assert: (documents) => check(compiled(documents), () => compiled.ebv(documents)),
|
|
287
246
|
/**
|
|
288
247
|
* The same answer, one batch at a time. `start` is the aggregate's
|
|
289
|
-
*
|
|
290
|
-
*
|
|
248
|
+
* initial state, `combine` adds the operand's items in row order,
|
|
249
|
+
* and `finish` applies the step's own
|
|
291
250
|
* verdict to the total — the same verdict `assert` applies.
|
|
292
251
|
* Null when this assertion does not fold.
|
|
293
252
|
*/
|
|
294
253
|
fold: classification.strategy !== 'fold' ? null : {
|
|
295
|
-
start: () =>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
254
|
+
start: () => {
|
|
255
|
+
const bounds = context.assertionBounds;
|
|
256
|
+
const guard = classification.shape === '$distinct' ? createAssertionBoundGuard({
|
|
257
|
+
maxRows: bounds.maxDistinct, maxBytes: bounds.maxBytes,
|
|
258
|
+
}, step.collection, `distinct items of migration '${migrationId}' step ${index}`) : null;
|
|
259
|
+
return createQueryAccumulator(classification.shape, {
|
|
260
|
+
docPath: `/${classification.shape}`,
|
|
261
|
+
...(guard === null ? {} : { admit: (item) => guard.admit(item) }),
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
combine: (state, documents) => {
|
|
265
|
+
for (const item of items.items(documents)) state.add(item);
|
|
266
|
+
return state;
|
|
267
|
+
},
|
|
268
|
+
value: (state) => state.value(),
|
|
269
|
+
finish: (state) => check(state.value(), state.ebv),
|
|
300
270
|
},
|
|
301
271
|
};
|
|
302
272
|
}
|
|
@@ -351,7 +321,7 @@ export function checkMigrationDocument(migration) {
|
|
|
351
321
|
|
|
352
322
|
/**
|
|
353
323
|
* What a MATERIALIZING assertion — one that is neither a per-document
|
|
354
|
-
* predicate nor a
|
|
324
|
+
* predicate nor a supported ordered fold — is allowed to hold.
|
|
355
325
|
*
|
|
356
326
|
* The defaults are deliberately generous and deliberately finite: a
|
|
357
327
|
* migration that used to read a large collection whole now refuses
|
|
@@ -377,7 +347,11 @@ export function normalizeAssertionBounds(declared) {
|
|
|
377
347
|
throw new TypeError(`assertionBounds.${name} must be a positive integer, or null for no bound`);
|
|
378
348
|
return value;
|
|
379
349
|
};
|
|
380
|
-
|
|
350
|
+
if (declared.maxDistinct !== undefined
|
|
351
|
+
&& (!Number.isSafeInteger(declared.maxDistinct) || declared.maxDistinct < 1))
|
|
352
|
+
throw new TypeError('assertionBounds.maxDistinct must be a positive integer');
|
|
353
|
+
return Object.freeze({ maxRows: read('maxRows'), maxBytes: read('maxBytes'),
|
|
354
|
+
...(declared.maxDistinct === undefined ? {} : { maxDistinct: declared.maxDistinct }) });
|
|
381
355
|
}
|
|
382
356
|
|
|
383
357
|
/**
|
package/src/documents.js
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
* document is read, anything this host cannot honour.
|
|
37
37
|
* @param {any[]} migrations
|
|
38
38
|
* @param {Set<string>} present - the collections the caller supplied
|
|
39
|
-
* @param {{ compileJslt: Function, compileQuery: Function, keys: Record<string, string[]
|
|
39
|
+
* @param {{ compileJslt: Function, compileQuery: Function, keys: Record<string, string[]>, assertionBounds: { maxRows: number | null, maxBytes: number | null, maxDistinct?: number } }} context
|
|
40
40
|
* @returns {{ id: string, to: string, from: string, operations: any[] }[]}
|
|
41
41
|
*/
|
|
42
42
|
function planStorelessRun(migrations, present, context) {
|
|
@@ -74,6 +74,7 @@ function planStorelessRun(migrations, present, context) {
|
|
|
74
74
|
migrationId: migration.id,
|
|
75
75
|
compileJslt: context.compileJslt,
|
|
76
76
|
compileQuery: context.compileQuery,
|
|
77
|
+
assertionBounds: context.assertionBounds,
|
|
77
78
|
keys: Object.hasOwn(context.keys, step.collection) ? (context.keys[step.collection] ?? []) : [],
|
|
78
79
|
})),
|
|
79
80
|
});
|
|
@@ -84,9 +85,12 @@ function planStorelessRun(migrations, present, context) {
|
|
|
84
85
|
/** The shared option surface both surfaces read. */
|
|
85
86
|
function runContext(options) {
|
|
86
87
|
const runtime = resolveRuntime(options.runtime);
|
|
88
|
+
if (!Number.isSafeInteger(options.batchSize ?? 500) || (options.batchSize ?? 500) < 1)
|
|
89
|
+
throw new TypeError('batchSize must be a positive safe integer');
|
|
87
90
|
return {
|
|
88
91
|
batchSize: options.batchSize ?? 500,
|
|
89
92
|
onProgress: options.onProgress,
|
|
93
|
+
onAssertionPlan: options.onAssertionPlan,
|
|
90
94
|
keys: options.keys ?? {},
|
|
91
95
|
compileJslt: options.compileJslt ?? compileJsltStylesheet,
|
|
92
96
|
compileQuery: options.compileQuery ?? compileJsonQuery,
|
|
@@ -100,6 +104,12 @@ function runContext(options) {
|
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
/** Compile all steps before a file host reads any document or opens its sink. */
|
|
108
|
+
export function prepareDocumentRun(names, migrations, options = {}) {
|
|
109
|
+
const context = runContext(options);
|
|
110
|
+
return planStorelessRun(migrations, new Set(names), context);
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
/** The report both surfaces answer with, before its counts are filled. */
|
|
104
114
|
const emptyReport = (plans) => ({
|
|
105
115
|
applied: plans.map((plan) => plan.id),
|
|
@@ -162,10 +172,12 @@ export async function migrateDocuments(collections, migrations, options = {}) {
|
|
|
162
172
|
for (const plan of plans) {
|
|
163
173
|
for (const operation of plan.operations) {
|
|
164
174
|
context.check();
|
|
175
|
+
if (operation.kind === 'query') context.onAssertionPlan?.({ ...operation.plan });
|
|
165
176
|
const documents = state[operation.collection];
|
|
166
177
|
const counters = countersFor(report, operation.collection);
|
|
167
178
|
if (operation.kind === 'jslt') {
|
|
168
179
|
for (let i = 0; i < documents.length; i++) {
|
|
180
|
+
context.check();
|
|
169
181
|
documents[i] = operation.apply(documents[i], i);
|
|
170
182
|
counters.transformed++;
|
|
171
183
|
if ((i + 1) % context.batchSize === 0) {
|
|
@@ -180,7 +192,7 @@ export async function migrateDocuments(collections, migrations, options = {}) {
|
|
|
180
192
|
continue;
|
|
181
193
|
}
|
|
182
194
|
if (operation.fold !== null) {
|
|
183
|
-
//
|
|
195
|
+
// Ordered aggregate state consumes the same batches item by item.
|
|
184
196
|
let accumulated = operation.fold.start();
|
|
185
197
|
for (let at = 0; at < documents.length; at += context.batchSize) {
|
|
186
198
|
context.check();
|
|
@@ -198,7 +210,8 @@ export async function migrateDocuments(collections, migrations, options = {}) {
|
|
|
198
210
|
// bound is what the caller was promised, so it is checked
|
|
199
211
|
const guard = createAssertionBoundGuard(context.assertionBounds, operation.collection,
|
|
200
212
|
`the assertion of migration '${plan.id}'`);
|
|
201
|
-
for (const document of documents) guard.admit(document);
|
|
213
|
+
for (const document of documents) { context.check(); guard.admit(document); }
|
|
214
|
+
counters.asserted += documents.length;
|
|
202
215
|
operation.assert(documents);
|
|
203
216
|
continue;
|
|
204
217
|
}
|
|
@@ -260,10 +273,11 @@ export async function streamDocuments(sources, migrations, options) {
|
|
|
260
273
|
const plans = planStorelessRun(migrations, new Set(Object.keys(sources)), context);
|
|
261
274
|
|
|
262
275
|
// the second refusal a single pass owes before it reads anything: a
|
|
263
|
-
// MATERIALIZING assertion. An
|
|
264
|
-
//
|
|
276
|
+
// MATERIALIZING assertion. An ordered fold only retains its state,
|
|
277
|
+
// so a single pass answers it exactly.
|
|
265
278
|
for (const plan of plans) {
|
|
266
279
|
for (const operation of plan.operations) {
|
|
280
|
+
if (operation.kind === 'query') context.onAssertionPlan?.({ ...operation.plan });
|
|
267
281
|
if (operation.kind === 'query' && operation.strategy === 'materialize') {
|
|
268
282
|
operation.fail('a cross-document assertion needs every document of '
|
|
269
283
|
+ `'${operation.collection}' at once, which a single pass does not hold — `
|
|
@@ -296,8 +310,10 @@ export async function streamDocuments(sources, migrations, options) {
|
|
|
296
310
|
if (batch.length === 0) return;
|
|
297
311
|
context.check();
|
|
298
312
|
for (const stage of stages) {
|
|
313
|
+
context.check();
|
|
299
314
|
if (stage.operation.kind === 'jslt') {
|
|
300
315
|
for (let i = 0; i < batch.length; i++) {
|
|
316
|
+
context.check();
|
|
301
317
|
batch[i] = stage.operation.apply(batch[i], counters.read - batch.length + i);
|
|
302
318
|
counters.transformed++;
|
|
303
319
|
}
|
|
@@ -315,10 +331,12 @@ export async function streamDocuments(sources, migrations, options) {
|
|
|
315
331
|
context.onProgress?.({ migration: stage.migration, collection: name,
|
|
316
332
|
asserted: counters.asserted });
|
|
317
333
|
}
|
|
318
|
-
for (const document of batch) await options.write(name, document);
|
|
334
|
+
for (const document of batch) { context.check(); await options.write(name, document); }
|
|
335
|
+
context.check();
|
|
319
336
|
batch = [];
|
|
320
337
|
};
|
|
321
338
|
for await (const document of sources[name]) {
|
|
339
|
+
context.check();
|
|
322
340
|
counters.read++;
|
|
323
341
|
batch.push(document);
|
|
324
342
|
if (batch.length >= context.batchSize) await flush();
|
package/src/drivers/node.js
CHANGED
|
@@ -121,7 +121,7 @@ export function nodeDriver() {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
export {
|
|
124
|
-
readDocuments, readJsonDocuments, readJsonlDocuments,
|
|
124
|
+
readDocuments, readJsonDocuments, readJsonlDocuments, readCollectionBundle,
|
|
125
125
|
openAtomicTarget, openStreamTarget, openNullTarget,
|
|
126
126
|
formatOf, DOCUMENT_FORMATS,
|
|
127
127
|
} from '../document-files.js';
|
package/src/jobs.js
CHANGED
|
@@ -557,6 +557,12 @@ export function createJobEngine(options) {
|
|
|
557
557
|
const lease = job?.lease;
|
|
558
558
|
const generation = isLease(lease) ? lease.generation : 0;
|
|
559
559
|
return {
|
|
560
|
+
inspect: (runId, nodeId) => chain(
|
|
561
|
+
prepared('cpIdentity', `SELECT value FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
562
|
+
WHERE run_id=? AND node_id=? AND generation <= ?`).get([runId, nodeId, generation]),
|
|
563
|
+
(row) => chain(prepared('cpHasValues', `SELECT 1 AS present FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
564
|
+
WHERE run_id=? AND node_id<>? AND generation <= ? LIMIT 1`).get([runId, nodeId, generation]),
|
|
565
|
+
(other) => ({ value: row === undefined ? undefined : JSON.parse(row.value), hasValues: other !== undefined }))),
|
|
560
566
|
load: (runId) => chain(
|
|
561
567
|
prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
562
568
|
WHERE run_id = ? AND generation <= ?`).all([runId, generation]),
|
|
@@ -772,6 +778,39 @@ export function createJobEngine(options) {
|
|
|
772
778
|
});
|
|
773
779
|
};
|
|
774
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Explicitly discard one inactive run's checkpoints and start a new attempt
|
|
783
|
+
* budget. The caller must name the observed generation; a concurrent claim
|
|
784
|
+
* or reset invalidates that authorization. External task effects are not undone.
|
|
785
|
+
* @param {string} id
|
|
786
|
+
* @param {{ expectedGeneration: number, signal?: AbortSignal, deadline?: number }} resetOptions
|
|
787
|
+
*/
|
|
788
|
+
const reset = (id, resetOptions) => {
|
|
789
|
+
if (typeof id !== 'string' || id === '') throw new TypeError('reset: id is a non-empty string');
|
|
790
|
+
const expected = resetOptions?.expectedGeneration;
|
|
791
|
+
if (!Number.isSafeInteger(expected) || expected < 0)
|
|
792
|
+
throw new TypeError('reset: expectedGeneration is the observed non-negative lease generation');
|
|
793
|
+
refuseCancelled(resetOptions, now, { abortCode: 'JD2081', aborted: 'reset() ran', passed: 'reset() ran' });
|
|
794
|
+
const at = now();
|
|
795
|
+
return connection.transaction(() => chain(
|
|
796
|
+
prepared('resetJob', `UPDATE "${JOBS_TABLE}" SET state='pending', attempts=0,
|
|
797
|
+
result=NULL, last_error=NULL, run_at=?, updated_at=?, lease_until=NULL,
|
|
798
|
+
lease_owner=NULL, lease_token=NULL, lease_generation=lease_generation+1
|
|
799
|
+
WHERE id=? AND lease_generation=? AND state<>'done'
|
|
800
|
+
AND (state<>'leased' OR lease_until<=?)`).run([at, at, id, expected, at]),
|
|
801
|
+
(out) => {
|
|
802
|
+
if (Number(out.changes ?? 0) === 0) return chain(get(id), (job) => {
|
|
803
|
+
const code = job?.state === 'leased' && job.leaseUntil > at ? 'JD2068'
|
|
804
|
+
: job !== undefined && job.leaseGeneration !== expected ? 'JD2066' : 'JD2065';
|
|
805
|
+
throw new DbRuntimeError(code,
|
|
806
|
+
`reset() refused: job '${id}' is unknown, completed, actively leased, or its generation changed; read it again before resetting`,
|
|
807
|
+
{ docPath: '/jobs', collection: JOBS_TABLE, key: id });
|
|
808
|
+
});
|
|
809
|
+
return chain(prepared('resetCheckpoints', `DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id=?`).run([id]),
|
|
810
|
+
(removed) => { wakeAll(); return { reset: true, discarded: Number(removed.changes ?? 0), generation: expected + 1 }; });
|
|
811
|
+
}));
|
|
812
|
+
};
|
|
813
|
+
|
|
775
814
|
/**
|
|
776
815
|
* Delete settled jobs (done, dead, cancelled) whose last change is
|
|
777
816
|
* older than `settledBefore`, oldest first, at most `limit` of them,
|
|
@@ -1014,6 +1053,8 @@ export function createJobEngine(options) {
|
|
|
1014
1053
|
// so a checkpoint can neither join an unrelated application
|
|
1015
1054
|
// transaction nor still be writing when stop() has resolved
|
|
1016
1055
|
attempt.checkpoints = {
|
|
1056
|
+
inspect: (runId, nodeId) => io(() =>
|
|
1057
|
+
checkpointsFor({ ...job, lease: attempt.lease }).inspect(runId, nodeId), 'a checkpoint identity read'),
|
|
1017
1058
|
load: (runId) => io(() =>
|
|
1018
1059
|
checkpointsFor({ ...job, lease: attempt.lease }).load(runId), 'a checkpoint read'),
|
|
1019
1060
|
save: (runId, nodeId, value) => io(() =>
|
|
@@ -1297,6 +1338,7 @@ export function createJobEngine(options) {
|
|
|
1297
1338
|
cancel,
|
|
1298
1339
|
settledLocally,
|
|
1299
1340
|
requeue,
|
|
1341
|
+
reset,
|
|
1300
1342
|
sweep,
|
|
1301
1343
|
/** Stop every worker, bounded. Resolves to the per-worker outcome so
|
|
1302
1344
|
* `close()` can report a handler it could not wait out rather than
|
package/src/migrate.js
CHANGED
|
@@ -32,6 +32,8 @@ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
|
32
32
|
import { DbCompileError } from './errors.js';
|
|
33
33
|
import { chain, toPromise } from './driver.js';
|
|
34
34
|
import { normalizeModel } from './store.js';
|
|
35
|
+
import { planQuery } from './plan.js';
|
|
36
|
+
import { createQueryEngine, createQueryState } from './query.js';
|
|
35
37
|
import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
|
|
36
38
|
import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
|
|
37
39
|
import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
|
|
@@ -1012,6 +1014,21 @@ export function compareShapeToModel(driver, connection, model, registerFunctions
|
|
|
1012
1014
|
}));
|
|
1013
1015
|
}
|
|
1014
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Count can use the existing provider planner without assuming an intermediate
|
|
1019
|
+
* schema. A native plan proves both row selection and item cardinality. Typed
|
|
1020
|
+
* aggregates keep ordered engine folds until their current shape is declared.
|
|
1021
|
+
*/
|
|
1022
|
+
function assertionProvider(query, collection, shape) {
|
|
1023
|
+
if (shape !== '$count') return null;
|
|
1024
|
+
const operand = query.$count;
|
|
1025
|
+
const document = { $count: typeof operand === 'string' && operand.startsWith('$[*]')
|
|
1026
|
+
? { $for: { row: '$[*]' }, $return: `$row${operand.slice(4)}` } : operand };
|
|
1027
|
+
const source = { collection, schema: { type: 'object' }, columnByCanonical: new Map(), indexes: [] };
|
|
1028
|
+
const planned = planQuery(document, source);
|
|
1029
|
+
return planned.mode === 'native' && planned.plan?.aggregate?.fn === 'count' ? document : null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1015
1032
|
/**
|
|
1016
1033
|
* The batched row walk shared by transforms and post-validation:
|
|
1017
1034
|
* `SELECT rowid, json(doc) ... WHERE rowid > ? ORDER BY rowid LIMIT ?`
|
|
@@ -1247,17 +1264,28 @@ function runSteps(connection, migration, options) {
|
|
|
1247
1264
|
migrationId: migration.id,
|
|
1248
1265
|
compileJslt: compileJsltStylesheet,
|
|
1249
1266
|
compileQuery: compileJsonQuery,
|
|
1267
|
+
assertionBounds: options.assertionBounds,
|
|
1250
1268
|
});
|
|
1251
1269
|
const assertOver = operation.assert;
|
|
1252
1270
|
const readDoc = (row) => (assertionMapping === null
|
|
1253
1271
|
? JSON.parse(row.doc)
|
|
1254
1272
|
: mergeEntityRow(assertionMapping, row, 'doc'));
|
|
1255
1273
|
|
|
1274
|
+
const provider = assertionMapping === null ? assertionProvider(current.assert, current.collection, operation.shape) : null;
|
|
1275
|
+
options.onAssertionPlan?.({ ...operation.plan, ...(provider === null ? {} : {
|
|
1276
|
+
strategy: 'provider', reason: 'the existing query planner proves a native count without assuming an intermediate schema',
|
|
1277
|
+
}) });
|
|
1278
|
+
if (provider !== null) {
|
|
1279
|
+
const engine = createQueryEngine({ connection, state: createQueryState(),
|
|
1280
|
+
collection: { name: current.collection, schema: { type: 'object' }, docPath: '' },
|
|
1281
|
+
physicalPlan: { table: current.collection, keyColumn: 'key', docColumn: 'doc', columnByCanonical: new Map() },
|
|
1282
|
+
});
|
|
1283
|
+
return chain(engine.execute(provider, { strict: true }), operation.accept);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1256
1286
|
if (operation.fold !== null) {
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
// held. The whole-collection read this replaces is the one
|
|
1260
|
-
// statement a cross-document assertion used to cost.
|
|
1287
|
+
// Consume operand items in row order through the query engine's
|
|
1288
|
+
// shared state: regrouping floating-point batch totals is unsound.
|
|
1261
1289
|
let accumulated = operation.fold.start();
|
|
1262
1290
|
let folded = 0;
|
|
1263
1291
|
return chain(walkRows(connection, current.collection, options.batchSize, (rows) => {
|
|
@@ -1604,6 +1632,8 @@ export function migrate(target, migrations, options) {
|
|
|
1604
1632
|
'migrate needs { baseline }: the model the store was first created with '
|
|
1605
1633
|
+ '(the chain anchor and the shadow starting shape)');
|
|
1606
1634
|
const batchSize = options.batchSize ?? 500;
|
|
1635
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1)
|
|
1636
|
+
throw new TypeError('batchSize must be a positive safe integer');
|
|
1607
1637
|
const runtime = resolveRuntime(options.runtime);
|
|
1608
1638
|
/**
|
|
1609
1639
|
* The cancellation boundary: between migrations, between steps and
|
|
@@ -1621,6 +1651,7 @@ export function migrate(target, migrations, options) {
|
|
|
1621
1651
|
batchSize,
|
|
1622
1652
|
assertionBounds: normalizeAssertionBounds(options.assertionBounds),
|
|
1623
1653
|
onProgress: options.onProgress,
|
|
1654
|
+
onAssertionPlan: options.onAssertionPlan,
|
|
1624
1655
|
registerFunctions: options.registerFunctions,
|
|
1625
1656
|
// the host's declared index-expression functions ride to every
|
|
1626
1657
|
// planner and every connection this run opens — the shadow's
|
|
@@ -1740,7 +1771,15 @@ export function migrate(target, migrations, options) {
|
|
|
1740
1771
|
}
|
|
1741
1772
|
else if (migrationStep.kind === 'jslt')
|
|
1742
1773
|
rendered.push(`-- jslt transform over '${migrationStep.collection}'`);
|
|
1743
|
-
else
|
|
1774
|
+
else {
|
|
1775
|
+
const operation = compileDocumentStep(migrationStep, migration.steps.indexOf(migrationStep), {
|
|
1776
|
+
migrationId: migration.id, compileJslt: compileJsltStylesheet, compileQuery: compileJsonQuery,
|
|
1777
|
+
assertionBounds: runOptions.assertionBounds,
|
|
1778
|
+
});
|
|
1779
|
+
const strategy = assertionProvider(migrationStep.assert, migrationStep.collection, operation.shape) === null
|
|
1780
|
+
? operation.strategy : 'provider';
|
|
1781
|
+
rendered.push(`-- assert over '${migrationStep.collection}' (${strategy}; ${operation.reason})`);
|
|
1782
|
+
}
|
|
1744
1783
|
}
|
|
1745
1784
|
const jsltCollections = [...new Set(migration.steps
|
|
1746
1785
|
.filter((s) => s.kind === 'jslt').map((s) => s.collection))];
|
package/src/store.js
CHANGED
|
@@ -2439,6 +2439,7 @@ export function openStore(model, options) {
|
|
|
2439
2439
|
checkpointsFor: (job) => {
|
|
2440
2440
|
const inner = jobsEngine.checkpointsFor(job);
|
|
2441
2441
|
return Object.freeze({
|
|
2442
|
+
inspect: (runId, nodeId) => gated(() => inner.inspect(runId, nodeId), 'a root checkpoint identity read'),
|
|
2442
2443
|
load: (runId) => gated(() => inner.load(runId), 'a root checkpoint read'),
|
|
2443
2444
|
save: (runId, nodeId, value) =>
|
|
2444
2445
|
gated(() => inner.save(runId, nodeId, value), 'a root checkpoint save'),
|
|
@@ -2459,6 +2460,7 @@ export function openStore(model, options) {
|
|
|
2459
2460
|
gated(() => jobsEngine.cancel(id, cancelOptions), 'a root job cancellation'),
|
|
2460
2461
|
(outcome) => chain(jobsEngine.settledLocally(id), () => outcome))),
|
|
2461
2462
|
requeue: lift((...args) => gated(() => jobsEngine.requeue(...args), 'a root job requeue')),
|
|
2463
|
+
reset: lift((...args) => gated(() => jobsEngine.reset(...args), 'a root job reset')),
|
|
2462
2464
|
sweep: lift((...args) => gated(() => jobsEngine.sweep(...args), 'a root job sweep')),
|
|
2463
2465
|
}),
|
|
2464
2466
|
/**
|
|
@@ -2875,6 +2877,10 @@ export function openStore(model, options) {
|
|
|
2875
2877
|
checkpointsFor: (/** @type {any} */ job) => {
|
|
2876
2878
|
const inner = jobsEngine.checkpointsFor(job);
|
|
2877
2879
|
return Object.freeze({
|
|
2880
|
+
inspect: lift((/** @type {any} */ runId, /** @type {any} */ nodeId) => {
|
|
2881
|
+
requireScope(identity);
|
|
2882
|
+
return inner.inspect(runId, nodeId);
|
|
2883
|
+
}),
|
|
2878
2884
|
load: lift((/** @type {any} */ runId) => {
|
|
2879
2885
|
requireScope(identity);
|
|
2880
2886
|
return inner.load(runId);
|