@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/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';
@@ -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
- * the same ceiling SQLite's INTEGER has so they are converted, and
24
- * a value past 2^53 loses precision here exactly as it would there.
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([20, 21, 23, 26, 700, 701, 1700]);
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
- // an associative aggregate: each batch is answered by the engine
1258
- // and the partial answers combine, so the collection is never
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 rendered.push(`-- assert over '${migrationStep.collection}'`);
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]}]}}`. Exact only when the cell's length IS 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: true });
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), () => { throw error; });
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 single associative aggregate over the root are answered in
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?: { maxRows?: number | null; maxBytes?: number | null };
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` is one associative aggregate whose batch answers
1328
- * combine; `materialize` needs every document at once and is bounded. */
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?: { maxRows?: number | null; maxBytes?: number | null };
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[]>>;