@jarenjs/db 0.66.1 → 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/src/dag-job.js CHANGED
@@ -48,7 +48,7 @@ const fingerprint = (value) => hashContent(canonicalizeJson(value ?? null));
48
48
  * @param {any} store - an open store with `{ jobs: true }`
49
49
  * @param {{ compileDag: Function,
50
50
  * documents: Record<string, any>,
51
- * tasks?: Record<string, Function>,
51
+ * tasks?: Record<string, Function | { run: Function, version?: string, taskVersions?: Record<string, string> }>,
52
52
  * concurrency?: number, pollInterval?: number, leaseMs?: number,
53
53
  * owner?: string, renew?: boolean, onOutcome?: (event: any) => void,
54
54
  * backoffBase?: number, backoffCap?: number,
@@ -130,14 +130,20 @@ export function createDagJobRunner(store, options) {
130
130
  * just as surely as an edited document does.
131
131
  */
132
132
  const requireSameRun = async (context, jobId, revision, inputHash, taskVersions) => {
133
- const loaded = await context.checkpoints.load(jobId);
134
- const stored = loaded?.values?.[RUN_IDENTITY_NODE];
133
+ const loaded = await context.checkpoints.inspect(jobId, RUN_IDENTITY_NODE);
134
+ const stored = loaded.value;
135
135
  const taskVersionsHash = fingerprint(taskVersions);
136
136
  const identity = { revision, inputHash, taskVersionsHash, taskVersions };
137
137
  if (stored === undefined) {
138
+ if (loaded.hasValues) throw new DbRuntimeError('JD2069',
139
+ `run '${jobId}' cannot resume: checkpoint values have no recorded workflow, input or task identity`,
140
+ { docPath: '/jobs', collection: jobId });
138
141
  await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
139
142
  return;
140
143
  }
144
+ if (stored === null || typeof stored !== 'object' || Array.isArray(stored))
145
+ throw new DbRuntimeError('JD2069', `run '${jobId}' has invalid checkpoint identity metadata`,
146
+ { docPath: '/jobs', collection: jobId });
141
147
  const differs = [];
142
148
  if (stored.revision !== revision) {
143
149
  differs.push(`the workflow (checkpointed under revision ${stored.revision}, `
@@ -152,13 +158,8 @@ export function createDagJobRunner(store, options) {
152
158
  // equal: the upgrade is allowed only where nothing can be replayed
153
159
  // wrongly — when no node value has been recorded yet, so the run has
154
160
  // nothing to inherit from an implementation nobody can name.
155
- const recorded = Object.keys(loaded?.values ?? {})
156
- .filter((nodeId) => nodeId !== RUN_IDENTITY_NODE);
157
- if (recorded.length === 0) {
158
- await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
159
- }
160
- else {
161
- differs.push(`the task versions (this run recorded ${recorded.length} node value(s) `
161
+ if (loaded.hasValues) {
162
+ differs.push(`the task versions (this run recorded node value(s) `
162
163
  + 'before task identity was persisted, so the implementation that produced them '
163
164
  + 'cannot be confirmed)');
164
165
  }
@@ -169,10 +170,14 @@ export function createDagJobRunner(store, options) {
169
170
  : `checkpointed under ${stored.taskVersionsHash}, this runner compiles `
170
171
  + `${taskVersionsHash}`})`);
171
172
  }
172
- if (differs.length === 0) return;
173
+ if (differs.length === 0) {
174
+ if (stored.taskVersionsHash === undefined)
175
+ await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
176
+ return;
177
+ }
173
178
  throw new DbRuntimeError('JD2069',
174
179
  `run '${jobId}' cannot resume: ${differs.join(' and ')} changed since its `
175
- + 'checkpoints were written. Enqueue it under a new id, or drop the run — '
180
+ + 'checkpoints were written. Enqueue it under a new id, or explicitly reset this inactive run with its observed generation — '
176
181
  + 'reusing them would answer for a computation nobody asked for',
177
182
  { docPath: '/jobs', collection: jobId });
178
183
  };
package/src/dialect.js CHANGED
@@ -171,7 +171,7 @@ function normalizeCapabilities(declared) {
171
171
  * usesIndex: (line: string, index: string) => boolean,
172
172
  * excludedRef: (columnSql: string) => string,
173
173
  * tx: { begin: string, beginImmediate: string, commit: string,
174
- * rollback: string,
174
+ * rollback: string, deferForeignKeys?: string,
175
175
  * savepoint: (n: string) => string, release: (n: string) => string,
176
176
  * rollbackTo: (n: string) => string },
177
177
  * pragma?: { set: (name: string, value: number | string) => string,
@@ -401,6 +401,7 @@ export const sqliteDialect = createDialect({
401
401
  tx: {
402
402
  begin: 'BEGIN',
403
403
  beginImmediate: 'BEGIN IMMEDIATE',
404
+ deferForeignKeys: 'PRAGMA defer_foreign_keys = ON',
404
405
  commit: 'COMMIT',
405
406
  rollback: 'ROLLBACK',
406
407
  savepoint: (n) => `SAVEPOINT ${quoteIdentifier(n)}`,
@@ -208,6 +208,65 @@ export function readDocuments(source, format) {
208
208
  throw refuse(`unknown document format '${format}' — one of ${DOCUMENT_FORMATS.join(', ')}`);
209
209
  }
210
210
 
211
+ /**
212
+ * Read an explicitly materialized collection-map bundle under a byte ceiling.
213
+ * JSON/JSONL collection readers remain streaming; a bundle holds named arrays.
214
+ * @param {string | AsyncIterable<any>} source
215
+ * @param {{ maxBytes: number | null, maxRows: number | null }} bounds
216
+ */
217
+ export async function readCollectionBundle(source, bounds) {
218
+ let text = '', bytes = 0;
219
+ for await (const chunk of textChunks(source)) {
220
+ bytes += Buffer.byteLength(chunk);
221
+ if (bounds.maxBytes !== null && bytes > bounds.maxBytes)
222
+ throw refuse(`the collection bundle exceeds its maxBytes bound of ${bounds.maxBytes}`);
223
+ text += chunk;
224
+ }
225
+ const value = parseDocument(text, 'collection bundle');
226
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
227
+ || Object.values(value).some((documents) => !Array.isArray(documents)))
228
+ throw refuse('a collection bundle is an object mapping collection names to document arrays');
229
+ for (const [name, documents] of Object.entries(value))
230
+ if (bounds.maxRows !== null && documents.length > bounds.maxRows)
231
+ throw refuse(`collection '${name}' exceeds its maxRows bound of ${bounds.maxRows}`);
232
+ return value;
233
+ }
234
+
235
+ /** One encoder for file and stream output, including atomic collection bundles. */
236
+ function documentEncoder(format, collections = []) {
237
+ if (!['json', 'jsonl', 'collections'].includes(format)) throw refuse(`unknown document format '${format}'`);
238
+ if (format === 'collections' && (new Set(collections).size !== collections.length
239
+ || collections.some((name) => typeof name !== 'string')))
240
+ throw refuse('collection bundle names must be unique strings');
241
+ let documents = 0, index = -1, members = 0;
242
+ const advance = (next) => {
243
+ let text = '';
244
+ while (index < next) {
245
+ text += index < 0 ? '{\n' : '\n],\n';
246
+ index++;
247
+ text += `${JSON.stringify(collections[index])}: [`;
248
+ members = 0;
249
+ }
250
+ return text;
251
+ };
252
+ return {
253
+ write: (document, collection) => {
254
+ const value = JSON.stringify(document);
255
+ if (value === undefined) throw refuse('a document must be JSON serializable');
256
+ if (format === 'jsonl') { documents++; return `${value}\n`; }
257
+ if (format === 'json') return documents++ === 0 ? `[\n${value}` : `,\n${value}`;
258
+ const next = collections.indexOf(collection);
259
+ if (next < index || next < 0) throw refuse('collection bundle writes must follow the declared collection order');
260
+ const prefix = advance(next);
261
+ documents++;
262
+ return prefix + (members++ === 0 ? '\n' : ',\n') + value;
263
+ },
264
+ finish: () => format === 'jsonl' ? '' : format === 'json' ? documents === 0 ? '[]\n' : '\n]\n'
265
+ : collections.length === 0 ? '{}\n' : advance(collections.length - 1) + '\n]\n}\n',
266
+ count: () => documents,
267
+ };
268
+ }
269
+
211
270
  /**
212
271
  * A sink that publishes whole or not at all.
213
272
  *
@@ -217,17 +276,18 @@ export function readDocuments(source, format) {
217
276
  * a target that never changed.
218
277
  *
219
278
  * @param {string} target - the file to replace
220
- * @param {'json' | 'jsonl'} format
221
- * @returns {Promise<{ write: (document: any) => Promise<void>,
279
+ * @param {'json' | 'jsonl' | 'collections'} format
280
+ * @param {{ collections?: string[] }} [options]
281
+ * @returns {Promise<{ write: (document: any, collection?: string) => Promise<void>,
222
282
  * commit: () => Promise<{ bytes: number, documents: number }>,
223
283
  * abort: () => Promise<void>, temporary: string }>}
224
284
  */
225
- export async function openAtomicTarget(target, format) {
285
+ export async function openAtomicTarget(target, format, options = {}) {
286
+ const encoder = documentEncoder(format, options.collections);
226
287
  const directory = path.dirname(path.resolve(target));
227
288
  const temporary = path.join(directory,
228
289
  `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
229
290
  const handle = await fsp.open(temporary, 'wx');
230
- let documents = 0;
231
291
  let bytes = 0;
232
292
  let settled = false;
233
293
 
@@ -243,21 +303,20 @@ export async function openAtomicTarget(target, format) {
243
303
 
244
304
  return {
245
305
  temporary,
246
- write: async (document) => {
247
- const text = JSON.stringify(document);
248
- if (format === 'jsonl') { documents++; return put(`${text}\n`); }
249
- return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
306
+ write: async (document, collection) => {
307
+ if (settled) throw refuse('this target was already settled');
308
+ await put(encoder.write(document, collection));
250
309
  },
251
310
  commit: async () => {
252
311
  if (settled) throw refuse('this target was already settled');
253
312
  settled = true;
254
313
  try {
255
- if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
314
+ await put(encoder.finish());
256
315
  // Publish only after the temporary has been durably flushed.
257
316
  await handle.sync();
258
317
  await handle.close();
259
318
  await fsp.rename(temporary, target);
260
- return { bytes, documents };
319
+ return { bytes, documents: encoder.count() };
261
320
  }
262
321
  catch (error) {
263
322
  try { await discard(); }
@@ -277,23 +336,20 @@ export async function openAtomicTarget(target, format) {
277
336
  * A sink that writes to an open stream (standard output) and can never
278
337
  * be taken back — `abort` is honest that what left has left.
279
338
  * @param {{ write: (chunk: string, callback: (error?: any) => void) => any }} stream
280
- * @param {'json' | 'jsonl'} format
339
+ * @param {'json' | 'jsonl' | 'collections'} format
340
+ * @param {{ collections?: string[] }} [options]
281
341
  */
282
- export function openStreamTarget(stream, format) {
283
- let documents = 0;
342
+ export function openStreamTarget(stream, format, options = {}) {
343
+ const encoder = documentEncoder(format, options.collections);
284
344
  const put = (text) => new Promise((resolve, reject) => {
285
345
  stream.write(text, (error) => (error ? reject(error) : resolve(undefined)));
286
346
  });
287
347
  return {
288
348
  temporary: null,
289
- write: async (document) => {
290
- const text = JSON.stringify(document);
291
- if (format === 'jsonl') { documents++; return put(`${text}\n`); }
292
- return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
293
- },
349
+ write: async (document, collection) => put(encoder.write(document, collection)),
294
350
  commit: async () => {
295
- if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
296
- return { bytes: 0, documents };
351
+ await put(encoder.finish());
352
+ return { bytes: 0, documents: encoder.count() };
297
353
  },
298
354
  abort: async () => undefined,
299
355
  };
@@ -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
- * Whether an assertion is a PER-DOCUMENT predicate — a FLWOR over the
59
- * collection's documents whose `$where` and `$return` read only the
60
- * binding so evaluating it over each batch of documents answers
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
- * The aggregate shapes whose answer over a whole collection is the
83
- * COMBINATION of its answers over any partition of that collection —
84
- * which is what lets a host compute them one batch at a time and never
85
- * hold the collection.
86
- *
87
- * Each entry says only how two partial answers combine. What the
88
- * operator MEANS over a batch its null handling, its empty-sequence
89
- * answer, its type coercions — is the engine's, because every batch is
90
- * answered by the engine's own compiled query. Nothing here reimplements
91
- * an operator; a fold that disagreed with the engine on any input would
92
- * be caught by the parity suite, which runs every shape both ways.
93
- *
94
- * `$distinct` is here only under an explicit cardinality bound: its
95
- * partial answer grows with the data, so without one it is not a fold
96
- * at all.
97
- * @type {Record<string, { start: any, combine: (accumulated: any, partial: any) => any,
98
- * growing?: boolean }>}
99
- */
100
- const ASSOCIATIVE_AGGREGATES = {
101
- // fn:count of a partition sums; the empty collection answers 0
102
- $count: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
103
- // fn:sum of the empty sequence is 0, so the identity is 0
104
- $sum: { start: 0, combine: (accumulated, partial) => accumulated + (partial ?? 0) },
105
- // fn:min/fn:max of the empty sequence is the empty sequence, so the
106
- // identity is `undefined` and a partial that is empty contributes
107
- // nothing
108
- $max: {
109
- start: undefined,
110
- combine: (accumulated, partial) => (partial === undefined ? accumulated
111
- : (accumulated === undefined || partial > accumulated ? partial : accumulated)),
112
- },
113
- $min: {
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
- /** An assertion that is exactly one aggregate over the root. */
121
- function aggregateShapeOf(query) {
122
- if (query === null || typeof query !== 'object' || Array.isArray(query)) return null;
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
- * How a host must run an assertion, and why.
131
- *
132
- * - `perDocument` its answer over each batch is its answer over the
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
- * @returns {{ strategy: 'perDocument' | 'fold' | 'materialize', shape: string | null, reason: string }}
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
- if (isPerDocumentAssertion(query)) {
146
- return {
147
- strategy: 'perDocument',
148
- shape: null,
149
- reason: 'a predicate over each document, whose answer per batch is its answer over the whole',
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
- const aggregate = aggregateShapeOf(query);
153
- if (aggregate !== null) {
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
- // the engine's own effective-boolean-value, reached through a query
249
- // that answers its input unchanged: a fold checks the value it
250
- // computed with exactly the rule the whole-collection path uses
251
- const identity = compileQuery('$');
252
- // and the engine's verdict on the EMPTY SEQUENCE, which `$max`/`$min`
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
- * identity, `combine` folds the engine's answer for a batch into
290
- * what earlier batches answered, and `finish` applies the step's own
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: () => ASSOCIATIVE_AGGREGATES[/** @type {string} */ (classification.shape)].start,
296
- combine: (accumulated, documents) => ASSOCIATIVE_AGGREGATES[
297
- /** @type {string} */ (classification.shape)].combine(accumulated, compiled(documents)),
298
- finish: (accumulated) => check(accumulated,
299
- () => (accumulated === undefined ? emptySequenceEbv : identity.ebv(accumulated))),
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 single associative aggregate — is allowed to hold.
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
- return Object.freeze({ maxRows: read('maxRows'), maxBytes: read('maxBytes') });
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[]> }} context
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
- // an associative aggregate: the same batches, combined
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 associative aggregate is not one — its
264
- // batches combine, so a single pass answers it exactly.
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();
@@ -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';