@jarenjs/db 0.67.0 → 0.72.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 +6 -4
- package/README.md +41 -8
- package/docs/HOSTS.md +5 -0
- package/docs/JOBS-FORMAT.md +14 -1
- package/docs/MIGRATION-FORMAT.md +78 -26
- package/docs/MODEL-FORMAT.md +12 -3
- package/package.json +4 -4
- package/src/algebra.js +4 -1
- package/src/cli.js +89 -68
- package/src/dag-job.js +17 -12
- package/src/derive.js +2 -1
- package/src/dialect.js +3 -1
- package/src/dialects/postgres.js +5 -0
- package/src/dialects/sqlite.js +6 -2
- 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/drivers/postgres.js +15 -4
- package/src/emit.js +4 -0
- package/src/jobs.js +42 -0
- package/src/migrate.js +44 -5
- package/src/plan.js +40 -4
- package/src/store.js +25 -14
- package/types/index.d.ts +32 -7
- package/types/node.d.ts +9 -3
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.
|
|
134
|
-
const stored = loaded
|
|
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
|
-
|
|
156
|
-
.
|
|
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)
|
|
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
|
|
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/derive.js
CHANGED
|
@@ -257,7 +257,8 @@ export function memberAt(doc, segments) {
|
|
|
257
257
|
/**
|
|
258
258
|
* Parse the JSON text a derived column's expression hands the function.
|
|
259
259
|
* SQLite passes SQL NULL for a member the document does not have, and
|
|
260
|
-
*
|
|
260
|
+
* the JSON-valued extraction preserves the member's encoding otherwise,
|
|
261
|
+
* including quotes around strings and the spelling of booleans.
|
|
261
262
|
* @param {any} text
|
|
262
263
|
* @returns {any} the value, or `undefined` when there is none
|
|
263
264
|
*/
|
package/src/dialect.js
CHANGED
|
@@ -137,6 +137,7 @@ function normalizeCapabilities(declared) {
|
|
|
137
137
|
* limitClause: (limit: number, offset?: number) => string,
|
|
138
138
|
* jsonPathText: (segments: JsonPathSegment[]) => string | null,
|
|
139
139
|
* jsonExtract: (columnSql: string, pathText: string, kind?: string) => string,
|
|
140
|
+
* isCreateRace?: (error: any) => boolean,
|
|
140
141
|
* derivedExpression?: (memberSql: string, column: { derive: string,
|
|
141
142
|
* precision?: number, component?: string, dims?: number }) => string,
|
|
142
143
|
* jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
|
|
@@ -532,6 +533,7 @@ export function createDialect(spec) {
|
|
|
532
533
|
limitClause: spec.limitClause,
|
|
533
534
|
jsonPathText: spec.jsonPathText,
|
|
534
535
|
jsonExtract: spec.jsonExtract,
|
|
536
|
+
isCreateRace: spec.isCreateRace,
|
|
535
537
|
/**
|
|
536
538
|
* The expression a DERIVED column is generated from: the member at
|
|
537
539
|
* the index path, as JSON text, handed to the deterministic
|
|
@@ -542,7 +544,7 @@ export function createDialect(spec) {
|
|
|
542
544
|
* @returns {string}
|
|
543
545
|
*/
|
|
544
546
|
derivedColumn: (docColumnSql, pathText, column) => spec.derivedExpression(
|
|
545
|
-
spec.jsonText(spec.jsonExtract(docColumnSql, pathText)), column),
|
|
547
|
+
spec.jsonText(spec.jsonExtract(docColumnSql, pathText, 'json')), column),
|
|
546
548
|
jsonSet: spec.jsonSet,
|
|
547
549
|
jsonRemove: spec.jsonRemove,
|
|
548
550
|
jsonAppend: spec.jsonAppend,
|
package/src/dialects/postgres.js
CHANGED
|
@@ -442,6 +442,11 @@ export function postgresDialect(options = undefined) {
|
|
|
442
442
|
: `LIMIT ${limit === null ? 'ALL' : limit}`),
|
|
443
443
|
jsonPathText,
|
|
444
444
|
jsonExtract,
|
|
445
|
+
// Concurrent first opens can both observe an absent relation before
|
|
446
|
+
// either CREATE commits. Ordinary unique violations remain failures.
|
|
447
|
+
isCreateRace: (error) => error?.code === '42P07' || error?.code === '23505'
|
|
448
|
+
&& (error.constraint === 'pg_type_typname_nsp_index' && error.table === 'pg_type'
|
|
449
|
+
|| error.constraint === 'pg_class_relname_nsp_index' && error.table === 'pg_class'),
|
|
445
450
|
// a DERIVED column's expression names a function the HOST supplies;
|
|
446
451
|
// this dialect creates none and assumes none. Every driver this
|
|
447
452
|
// package ships for PostgreSQL declares
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -299,8 +299,12 @@ export const sqliteDialect = createDialect({
|
|
|
299
299
|
? `LIMIT ${limit === null ? -1 : limit} OFFSET ${offset}`
|
|
300
300
|
: `LIMIT ${limit === null ? -1 : limit}`),
|
|
301
301
|
jsonPathText,
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
// JSON-valued consumers need the encoded member, including quotes around
|
|
303
|
+
// strings and the original boolean spelling; jsonb_extract returns SQL
|
|
304
|
+
// scalars for these and json() cannot recover their JSON representation.
|
|
305
|
+
jsonExtract: (columnSql, pathText, kind) => kind === 'json'
|
|
306
|
+
? `(${columnSql} -> ${stringLiteral(pathText)})`
|
|
307
|
+
: `jsonb_extract(${columnSql}, ${stringLiteral(pathText)})`,
|
|
304
308
|
// a DERIVED column's expression: the member as JSON text handed to
|
|
305
309
|
// the deterministic function the store registers at open. The
|
|
306
310
|
// precision is a LITERAL, not a parameter — a generated column's
|
package/src/document-files.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
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
|
/**
|