@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/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/drivers/postgres.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* - `int8` and `numeric` arrive as STRINGS, because they can exceed
|
|
22
22
|
* what a double holds. The store's contract is JavaScript numbers —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* so they are converted. An `int8` outside the safe integer range
|
|
24
|
+
* refuses rather than returning a rounded key or count.
|
|
25
25
|
* - `json`/`jsonb` arrive PARSED, because the client's type parsers
|
|
26
26
|
* are the host's configuration. Every document read is already
|
|
27
27
|
* `::text` (the dialect's `jsonText`), but a graph load's built
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { chain, openConnection, baseCapabilities } from '../driver.js';
|
|
36
36
|
import { postgresDialect } from '../dialects/postgres.js';
|
|
37
|
-
import { DbCompileError } from '../errors.js';
|
|
37
|
+
import { DbCompileError, DbRuntimeError } from '../errors.js';
|
|
38
38
|
|
|
39
39
|
export { postgresDialect, IDENTIFIER_BYTES } from '../dialects/postgres.js';
|
|
40
40
|
|
|
@@ -45,7 +45,7 @@ export const POSTGRES_FLOOR = 160000;
|
|
|
45
45
|
/** Types the wire hands back as text because they can exceed a double,
|
|
46
46
|
* and the two it hands back as text for width alone. The store's
|
|
47
47
|
* contract is JavaScript numbers throughout. */
|
|
48
|
-
const NUMERIC_OIDS = new Set([
|
|
48
|
+
const NUMERIC_OIDS = new Set([21, 23, 26, 700, 701, 1700]);
|
|
49
49
|
/** `json` and `jsonb`: parsed by the client unless the host said
|
|
50
50
|
* otherwise, and the row decoder reads text. */
|
|
51
51
|
const JSON_OIDS = new Set([114, 3802]);
|
|
@@ -73,6 +73,17 @@ let statementSequence = 0;
|
|
|
73
73
|
* is already what the store reads
|
|
74
74
|
*/
|
|
75
75
|
function converterFor(dataTypeID) {
|
|
76
|
+
if (dataTypeID === 20) {
|
|
77
|
+
return (value) => {
|
|
78
|
+
if (value === null || value === undefined) return value;
|
|
79
|
+
const number = Number(value);
|
|
80
|
+
if (!Number.isSafeInteger(number)) {
|
|
81
|
+
throw new DbRuntimeError('JD2005',
|
|
82
|
+
`PostgreSQL int8 value '${String(value)}' is outside the safe JavaScript integer range`);
|
|
83
|
+
}
|
|
84
|
+
return number;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
76
87
|
if (NUMERIC_OIDS.has(dataTypeID)) {
|
|
77
88
|
return (value) => (value === null || value === undefined ? value : Number(value));
|
|
78
89
|
}
|
package/src/emit.js
CHANGED
|
@@ -393,6 +393,10 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
393
393
|
// per row.
|
|
394
394
|
const column = q(pred.column);
|
|
395
395
|
const list = pred.cells.map((cell) => param({ literal: cell })).join(', ');
|
|
396
|
+
// An empty membership search item raises in the query engine. The
|
|
397
|
+
// candidate fetch must retain it for the original row predicate.
|
|
398
|
+
if (pred.keepEmpty === true) return pred.cells.length === 0 ? `${column} IS NULL`
|
|
399
|
+
: `(${column} IS NULL OR ${column} IN (${list}))`;
|
|
396
400
|
return `(${column} IS NOT NULL AND ${column} IN (${list}))`;
|
|
397
401
|
}
|
|
398
402
|
case 'cellPrefix': {
|
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/plan.js
CHANGED
|
@@ -505,6 +505,8 @@ const SPATIAL_REASONS = {
|
|
|
505
505
|
distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
|
|
506
506
|
prefix: "a cell-range pre-filter over the derived column's precision is pushed; "
|
|
507
507
|
+ 'the longer prefix refines in the engine',
|
|
508
|
+
membership: 'the cell pre-filter retains unbounded members; the engine preserves '
|
|
509
|
+
+ 'the membership error when its search item is empty',
|
|
508
510
|
noIndex: 'no derived spatial index on this member covers the predicate '
|
|
509
511
|
+ '(declare indexes[].derive on it)',
|
|
510
512
|
notGeographic: 'spatial predicates translate only over a member the schema types as an '
|
|
@@ -856,10 +858,12 @@ function planCellPrefix(node, itSlot, shape) {
|
|
|
856
858
|
* the cells inline, where the planner can see them.
|
|
857
859
|
*
|
|
858
860
|
* Recognized: `{$exists: {$index-of: [{$geohash-neighbours: "cell"},
|
|
859
|
-
* {$geohash: [path, k]}]}}`.
|
|
861
|
+
* {$geohash: [path, k]}]}}`. Admitted only when the cell's length IS k —
|
|
860
862
|
* the membership test compares whole strings, so any other length makes
|
|
861
863
|
* the document's own predicate constantly false and the promotion would
|
|
862
|
-
* be answering a different question.
|
|
864
|
+
* be answering a different question. Unbounded rows remain candidates:
|
|
865
|
+
* the engine's membership raises on an empty search item, so the original
|
|
866
|
+
* predicate must still run even when the column covers whole cell strings.
|
|
863
867
|
* @param {any} node
|
|
864
868
|
* @param {number} itSlot
|
|
865
869
|
* @param {any} shape
|
|
@@ -878,9 +882,26 @@ function planCellNeighbourhood(node, itSlot, shape) {
|
|
|
878
882
|
const cells = cellNeighbourhood(cell.value);
|
|
879
883
|
if (cells.length === 0)
|
|
880
884
|
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
|
|
881
|
-
return promotion({ p: 'cellIn', column: derivation.column, cells },
|
|
885
|
+
return promotion({ p: 'cellIn', column: derivation.column, cells, keepEmpty: true },
|
|
882
886
|
{ construct: '$geohash-neighbours', via: 'columns', columns: [derivation.column],
|
|
883
|
-
exact:
|
|
887
|
+
exact: false }, [refusal('$geohash-neighbours', SPATIAL_REASONS.membership)]);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Error candidates cannot be discarded by a different pushed conjunct. */
|
|
891
|
+
function neighbourhoodErrorColumns(node, itSlot, shape, columns = new Set()) {
|
|
892
|
+
if (node === null || typeof node !== 'object') return columns;
|
|
893
|
+
if (node.kind === 'op' && node.name === '$index-of'
|
|
894
|
+
&& node.args[0]?.name === '$geohash-neighbours') {
|
|
895
|
+
const derivation = geohashDerivation(node.args[1], itSlot, shape, '$geohash-neighbours');
|
|
896
|
+
if (!('refusal' in derivation)) columns.add(derivation.column);
|
|
897
|
+
}
|
|
898
|
+
for (const value of Object.values(node)) {
|
|
899
|
+
if (Array.isArray(value)) {
|
|
900
|
+
for (const child of value) neighbourhoodErrorColumns(child, itSlot, shape, columns);
|
|
901
|
+
}
|
|
902
|
+
else if (value !== null && typeof value === 'object') neighbourhoodErrorColumns(value, itSlot, shape, columns);
|
|
903
|
+
}
|
|
904
|
+
return columns;
|
|
884
905
|
}
|
|
885
906
|
|
|
886
907
|
// ————— The k-nearest promotion: an ORDERING the column pre-filters —————
|
|
@@ -2365,6 +2386,21 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
2365
2386
|
}
|
|
2366
2387
|
}
|
|
2367
2388
|
|
|
2389
|
+
// A later conjunct must not hide an earlier membership error. Retain
|
|
2390
|
+
// every potentially empty search row through the combined SQL filter;
|
|
2391
|
+
// the original WHERE then decides whether short-circuiting reaches it.
|
|
2392
|
+
const errorColumns = neighbourhoodErrorColumns(node.where, itSlot, shape);
|
|
2393
|
+
if (errorColumns.size > 0 && plan.filter !== null) {
|
|
2394
|
+
if (!(plan.filter.p === 'cellIn' && plan.filter.keepEmpty === true
|
|
2395
|
+
&& errorColumns.size === 1 && errorColumns.has(plan.filter.column))) {
|
|
2396
|
+
plan.filter = { p: 'or', items: [plan.filter, ...[...errorColumns].map((column) =>
|
|
2397
|
+
({ p: 'cellIn', column, cells: [], keepEmpty: true }))] };
|
|
2398
|
+
}
|
|
2399
|
+
whereFullyPushed = false;
|
|
2400
|
+
if (!reasons.some((entry) => entry.reason === SPATIAL_REASONS.membership))
|
|
2401
|
+
reasons.push(refusal('$geohash-neighbours', SPATIAL_REASONS.membership));
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2368
2404
|
// ORDER BY: all terms or none — a partially pushed ordering is wrong.
|
|
2369
2405
|
// A k-nearest ordering is the third outcome: not pushed, but
|
|
2370
2406
|
// recognized for the column to pre-filter (the reason is the
|
package/src/store.js
CHANGED
|
@@ -450,12 +450,19 @@ function wrapWriteError(error, plan, collection, docPath, key) {
|
|
|
450
450
|
* open nothing else holds the connection.
|
|
451
451
|
* @param {any} connection
|
|
452
452
|
* @param {() => any} fn - value-or-promise
|
|
453
|
+
* @param {boolean} [retry] - one retry for a concurrent catalog creation
|
|
453
454
|
* @returns {any} value-or-promise
|
|
454
455
|
*/
|
|
455
|
-
function immediately(connection, fn) {
|
|
456
|
+
function immediately(connection, fn, retry = true) {
|
|
456
457
|
const dialect = connection.dialect;
|
|
457
458
|
const commit = (value) => chain(connection.exec(dialect.tx.commit), () => value);
|
|
458
|
-
const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => {
|
|
459
|
+
const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => {
|
|
460
|
+
// The winner's CREATE has committed before a catalog collision returns.
|
|
461
|
+
// Re-read and verify that shape in a fresh transaction, once per bracket;
|
|
462
|
+
// collection and entity creation can race independently during one open.
|
|
463
|
+
if (retry && dialect.isCreateRace?.(error) === true) return immediately(connection, fn, false);
|
|
464
|
+
throw error;
|
|
465
|
+
});
|
|
459
466
|
return chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
460
467
|
let out;
|
|
461
468
|
try {
|
|
@@ -673,6 +680,14 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
673
680
|
(error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)));
|
|
674
681
|
};
|
|
675
682
|
|
|
683
|
+
// RETURNING is decoded after the server has inserted the row. Keep
|
|
684
|
+
// decoding in the same transaction so an unrepresentable allocated
|
|
685
|
+
// key refuses without committing a document the caller cannot address.
|
|
686
|
+
const insertAllocated = (doc) => connection.transaction(() => chain(
|
|
687
|
+
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
688
|
+
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
689
|
+
(row) => row.key));
|
|
690
|
+
|
|
676
691
|
const core = {
|
|
677
692
|
stats: () => ({ ...stats, ...engine.stats() }),
|
|
678
693
|
model: collection,
|
|
@@ -694,12 +709,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
694
709
|
insert(doc) {
|
|
695
710
|
checkValid(doc);
|
|
696
711
|
const key = resolveWriteKey(doc, undefined);
|
|
697
|
-
if (key === null)
|
|
698
|
-
return chain(
|
|
699
|
-
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
700
|
-
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
701
|
-
(row) => row.key);
|
|
702
|
-
}
|
|
712
|
+
if (key === null) return insertAllocated(doc);
|
|
703
713
|
return chain(
|
|
704
714
|
runWrite('insert', dialect.dml.insert(shape),
|
|
705
715
|
[key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
|
|
@@ -708,12 +718,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
708
718
|
put(doc, explicitKey) {
|
|
709
719
|
checkValid(doc);
|
|
710
720
|
const key = resolveWriteKey(doc, explicitKey);
|
|
711
|
-
if (key === null)
|
|
712
|
-
return chain(
|
|
713
|
-
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
714
|
-
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
715
|
-
(row) => row.key);
|
|
716
|
-
}
|
|
721
|
+
if (key === null) return insertAllocated(doc);
|
|
717
722
|
return chain(
|
|
718
723
|
runWrite('upsert', dialect.dml.upsert(shape),
|
|
719
724
|
[key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
|
|
@@ -2439,6 +2444,7 @@ export function openStore(model, options) {
|
|
|
2439
2444
|
checkpointsFor: (job) => {
|
|
2440
2445
|
const inner = jobsEngine.checkpointsFor(job);
|
|
2441
2446
|
return Object.freeze({
|
|
2447
|
+
inspect: (runId, nodeId) => gated(() => inner.inspect(runId, nodeId), 'a root checkpoint identity read'),
|
|
2442
2448
|
load: (runId) => gated(() => inner.load(runId), 'a root checkpoint read'),
|
|
2443
2449
|
save: (runId, nodeId, value) =>
|
|
2444
2450
|
gated(() => inner.save(runId, nodeId, value), 'a root checkpoint save'),
|
|
@@ -2459,6 +2465,7 @@ export function openStore(model, options) {
|
|
|
2459
2465
|
gated(() => jobsEngine.cancel(id, cancelOptions), 'a root job cancellation'),
|
|
2460
2466
|
(outcome) => chain(jobsEngine.settledLocally(id), () => outcome))),
|
|
2461
2467
|
requeue: lift((...args) => gated(() => jobsEngine.requeue(...args), 'a root job requeue')),
|
|
2468
|
+
reset: lift((...args) => gated(() => jobsEngine.reset(...args), 'a root job reset')),
|
|
2462
2469
|
sweep: lift((...args) => gated(() => jobsEngine.sweep(...args), 'a root job sweep')),
|
|
2463
2470
|
}),
|
|
2464
2471
|
/**
|
|
@@ -2875,6 +2882,10 @@ export function openStore(model, options) {
|
|
|
2875
2882
|
checkpointsFor: (/** @type {any} */ job) => {
|
|
2876
2883
|
const inner = jobsEngine.checkpointsFor(job);
|
|
2877
2884
|
return Object.freeze({
|
|
2885
|
+
inspect: lift((/** @type {any} */ runId, /** @type {any} */ nodeId) => {
|
|
2886
|
+
requireScope(identity);
|
|
2887
|
+
return inner.inspect(runId, nodeId);
|
|
2888
|
+
}),
|
|
2878
2889
|
load: lift((/** @type {any} */ runId) => {
|
|
2879
2890
|
requireScope(identity);
|
|
2880
2891
|
return inner.load(runId);
|
package/types/index.d.ts
CHANGED
|
@@ -1274,6 +1274,23 @@ export declare function readSchema(connection: unknown, options?: {
|
|
|
1274
1274
|
tables?: readonly string[];
|
|
1275
1275
|
}): unknown;
|
|
1276
1276
|
|
|
1277
|
+
export interface AssertionBounds {
|
|
1278
|
+
maxRows?: number | null;
|
|
1279
|
+
maxBytes?: number | null;
|
|
1280
|
+
/** Opt into a distinct fold with at most this many unique items. */
|
|
1281
|
+
maxDistinct?: number;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
export interface AssertionPlan {
|
|
1285
|
+
migration: string;
|
|
1286
|
+
step: number;
|
|
1287
|
+
collection: string;
|
|
1288
|
+
strategy: 'provider' | 'perDocument' | 'fold' | 'materialize';
|
|
1289
|
+
shape: string | null;
|
|
1290
|
+
reason: string;
|
|
1291
|
+
bounds: AssertionBounds | null;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1277
1294
|
export interface MigrateOptions {
|
|
1278
1295
|
baseline: unknown;
|
|
1279
1296
|
model?: unknown;
|
|
@@ -1307,13 +1324,14 @@ export interface MigrateOptions {
|
|
|
1307
1324
|
/** An epoch-millisecond deadline on `runtime`'s clock (`JD2075`). */
|
|
1308
1325
|
deadline?: number;
|
|
1309
1326
|
/** What a MATERIALIZING assertion may hold. A per-document predicate
|
|
1310
|
-
* and a
|
|
1327
|
+
* and a supported ordered aggregate over independent rows are answered in
|
|
1311
1328
|
* batches and are never bounded by this; anything else must hold the
|
|
1312
1329
|
* collection at once and crosses these bounds before the excess is
|
|
1313
1330
|
* held (`JD2007` rows, `JD2076` bytes). Defaults to
|
|
1314
1331
|
* {@link ASSERTION_BOUNDS_DEFAULT}; `null` on either member removes
|
|
1315
1332
|
* that bound, deliberately. */
|
|
1316
|
-
assertionBounds?:
|
|
1333
|
+
assertionBounds?: AssertionBounds;
|
|
1334
|
+
onAssertionPlan?: (plan: AssertionPlan) => void;
|
|
1317
1335
|
}
|
|
1318
1336
|
|
|
1319
1337
|
/** The finite defaults a materializing assertion runs under when the
|
|
@@ -1324,9 +1342,8 @@ export declare const ASSERTION_BOUNDS_DEFAULT: {
|
|
|
1324
1342
|
};
|
|
1325
1343
|
|
|
1326
1344
|
/** How a host must run an assertion, and why. `perDocument` walks in
|
|
1327
|
-
* batches; `fold`
|
|
1328
|
-
|
|
1329
|
-
export declare function classifyAssertion(query: unknown): {
|
|
1345
|
+
* batches; `fold` accumulates query items in order; `materialize` needs every document at once and is bounded. */
|
|
1346
|
+
export declare function classifyAssertion(query: unknown, options?: { expect?: string; maxDistinct?: number }): {
|
|
1330
1347
|
strategy: 'perDocument' | 'fold' | 'materialize';
|
|
1331
1348
|
shape: string | null;
|
|
1332
1349
|
reason: string;
|
|
@@ -1402,7 +1419,8 @@ export interface DocumentMigrationOptions {
|
|
|
1402
1419
|
/** What a MATERIALIZING assertion may hold; the same bounds, and the
|
|
1403
1420
|
* same refusals, a Store applies. Defaults to
|
|
1404
1421
|
* {@link ASSERTION_BOUNDS_DEFAULT}. */
|
|
1405
|
-
assertionBounds?:
|
|
1422
|
+
assertionBounds?: AssertionBounds;
|
|
1423
|
+
onAssertionPlan?: (plan: AssertionPlan) => void;
|
|
1406
1424
|
/** Documents per assertion batch and per progress event (default 500). */
|
|
1407
1425
|
batchSize?: number;
|
|
1408
1426
|
onProgress?: (progress: MigrationProgress) => void;
|
|
@@ -1457,6 +1475,7 @@ export declare function compileDocumentStep(
|
|
|
1457
1475
|
compileJslt: (stylesheet: unknown) => (document: unknown) => unknown;
|
|
1458
1476
|
compileQuery: (query: unknown) => unknown;
|
|
1459
1477
|
keys?: readonly string[];
|
|
1478
|
+
assertionBounds?: { maxRows: number | null; maxBytes: number | null; maxDistinct?: number };
|
|
1460
1479
|
},
|
|
1461
1480
|
): unknown;
|
|
1462
1481
|
/** Structural validation of one migration document (`JD0023`/`JD0021`). */
|
|
@@ -1815,6 +1834,7 @@ export interface JobsApi {
|
|
|
1815
1834
|
* written up to it, and a settlement prunes no further, so a stale
|
|
1816
1835
|
* attempt cannot erase a live one's work. */
|
|
1817
1836
|
checkpointsFor(job: ClaimedJob): {
|
|
1837
|
+
inspect(runId: string, nodeId: string): unknown;
|
|
1818
1838
|
load(runId: string): unknown;
|
|
1819
1839
|
save(runId: string, nodeId: string, value: unknown): unknown;
|
|
1820
1840
|
complete(runId: string, result: unknown): unknown;
|
|
@@ -1840,6 +1860,11 @@ export interface JobPageOptions {
|
|
|
1840
1860
|
* schedule: WHEN to sweep or cancel is the host's call.
|
|
1841
1861
|
*/
|
|
1842
1862
|
export interface JobsAdminApi {
|
|
1863
|
+
/** Discard an inactive run's checkpoints and restart its attempts, atomically.
|
|
1864
|
+
* Requires its observed generation; refuses done jobs and live leases.
|
|
1865
|
+
* External effects are not undone. */
|
|
1866
|
+
reset(id: string, options: { expectedGeneration: number; signal?: AbortSignal; deadline?: number }):
|
|
1867
|
+
Promise<{ reset: true; discarded: number; generation: number }>;
|
|
1843
1868
|
/** A keyset cursor over the queue by id, admitted per pull under the
|
|
1844
1869
|
* store gate; each item is the record `get` answers. */
|
|
1845
1870
|
page(options?: JobPageOptions): QueryCursor<JobRecord>;
|
|
@@ -1876,7 +1901,7 @@ export interface JobsOptions {
|
|
|
1876
1901
|
export declare function createDagJobRunner(store: Store, options: {
|
|
1877
1902
|
compileDag: Function;
|
|
1878
1903
|
documents: Record<string, unknown>;
|
|
1879
|
-
tasks?: Record<string, Function>;
|
|
1904
|
+
tasks?: Record<string, Function | { run: Function; version?: string; taskVersions?: Record<string, string> }>;
|
|
1880
1905
|
concurrency?: number;
|
|
1881
1906
|
pollInterval?: number;
|
|
1882
1907
|
leaseMs?: number;
|
package/types/node.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export declare function readDocuments(
|
|
|
61
61
|
export interface DocumentTarget {
|
|
62
62
|
/** The sibling file being filled, or null for a sink with no file. */
|
|
63
63
|
readonly temporary: string | null;
|
|
64
|
-
write(document: unknown): Promise<void>;
|
|
64
|
+
write(document: unknown, collection?: string): Promise<void>;
|
|
65
65
|
/** Flush, rename over the target, and answer what was written. */
|
|
66
66
|
commit(): Promise<{ bytes: number; documents: number }>;
|
|
67
67
|
/** Remove the temporary; the target keeps the bytes it had. */
|
|
@@ -72,13 +72,19 @@ export interface DocumentTarget {
|
|
|
72
72
|
* on `commit`, and removed on `abort`, so a failed run leaves the
|
|
73
73
|
* original byte for byte. */
|
|
74
74
|
export declare function openAtomicTarget(
|
|
75
|
-
target: string, format: 'json' | 'jsonl',
|
|
75
|
+
target: string, format: 'json' | 'jsonl' | 'collections',
|
|
76
|
+
options?: { collections?: string[] },
|
|
76
77
|
): Promise<DocumentTarget>;
|
|
77
78
|
|
|
78
79
|
/** Write to an open stream; `abort` cannot take back what has left. */
|
|
79
80
|
export declare function openStreamTarget(
|
|
80
|
-
stream: DocumentByteSink, format: 'json' | 'jsonl',
|
|
81
|
+
stream: DocumentByteSink, format: 'json' | 'jsonl' | 'collections',
|
|
82
|
+
options?: { collections?: string[] },
|
|
81
83
|
): DocumentTarget;
|
|
82
84
|
|
|
83
85
|
/** Validate everything and write nothing. */
|
|
84
86
|
export declare function openNullTarget(): DocumentTarget;
|
|
87
|
+
|
|
88
|
+
/** Read an explicit collection bundle, materialized under the declared bounds. */
|
|
89
|
+
export declare function readCollectionBundle(source: DocumentByteSource,
|
|
90
|
+
bounds: { maxBytes: number | null; maxRows: number | null }): Promise<Record<string, unknown[]>>;
|