@jarenjs/db 0.67.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -688,6 +688,15 @@ opinion: <!--fact:vector.ceiling-->5.142 ns per vector component — one query r
688
688
  Past that this design is the wrong tool and no margin changes it; what
689
689
  lies beyond is an approximate index, and this store does not have one.
690
690
 
691
+ The [labelled retrieval comparison](../../benchmark/README.md#labelled-recall-and-repeated-refinement)
692
+ also scores an optional storage adapter with approximate candidates against
693
+ exact cosine on cached live embeddings. The AI ledger reports the adapter's
694
+ algorithm, exhaustive flag and returned candidate count, validates candidate
695
+ identity/vector shape, and re-scores candidates itself. The DB ledger recipe
696
+ continues to normalize as an exact adapter. No ANN runtime dependency is added.
697
+
698
+ Index decision: <!--fact:recall.annDecision-->0/6 contender rows cleared all bars; retain exact. Required exact-top-10 recall ≥ 0.95, p95 speedup ≥ 2×, and a measured exact p95 ≥ 100 ms. The largest reference corpus contains 5183 documents; scale beyond it remains unmeasured.<!--/fact-->
699
+
691
700
  **One document, three executors, proven to agree.** As with the spatial
692
701
  family, the k-nearest shapes of a committed corpus
693
702
  (`test/json/fixtures/vector-corpus.json`) run through the JavaScript
@@ -1028,6 +1037,10 @@ shutdown limits, the browser persistence matrix and measured latency/memory loss
1028
1037
  and `worker.stop()` quiesces every claim, renewal, checkpoint and
1029
1038
  settlement before it resolves, so closing the store releases the
1030
1039
  database file deterministically.
1040
+ DAG jobs inspect persisted workflow/input/task identity before reading
1041
+ checkpoint values. `jobs.reset(id, { expectedGeneration })` explicitly
1042
+ discards an inactive run's checkpoints and restarts it under a new fence;
1043
+ live leases and stale observations refuse (JOBS-FORMAT §10).
1031
1044
  - **The browser** (`@jarenjs/db/wasm`): the same store, the same
1032
1045
  queries, the same live updates run on the official SQLite wasm build
1033
1046
  over a probed persistence ladder: isolated SharedArrayBuffer OPFS,
@@ -1335,3 +1348,19 @@ Every subpath a consumer can import, derived from the manifest by
1335
1348
  | `@jarenjs/db/node-worker` | JavaScript | declared |
1336
1349
  | `@jarenjs/db/node-pool` | JavaScript | declared |
1337
1350
  <!--/fact-->
1351
+
1352
+
1353
+ The [durable AI ledger recipe](../ai/README.md#a-durable-ledger-over-jarenjsdb)
1354
+ exposes optional atomic namespace mutation as well as its base storage and rank
1355
+ capabilities. It reads and publishes through one immediate transaction, using the
1356
+ transaction's synchronous collection facade so another SQLite connection on the
1357
+ same event loop cannot block an awaiting writer. A rejected callback changes
1358
+ neither records nor counters; ledger id minting and guarded multi-record updates
1359
+ use that boundary.
1360
+
1361
+ Document-only migrations also accept named files:
1362
+ `jaren-db documents --migrations migrations --in users=users.json --in events=events.jsonl --out migrated.json`.
1363
+ The output is one atomically published collection bundle; read it with
1364
+ `--format collections`. Assertion plans expose provider, ordered-fold and
1365
+ bounded materialization strategies through `onAssertionPlan`.
1366
+ See [MIGRATION-FORMAT §6 and §11](docs/MIGRATION-FORMAT.md).
@@ -390,7 +390,10 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
390
390
 
391
391
  **The legacy rule is deterministic, and never reads unknown as equal.**
392
392
  A run checkpointed by a release that did not record task identity has
393
- no `taskVersionsHash`. If it recorded no node value yet, there is
393
+ no `taskVersionsHash`. Identity inspection reads only the reserved metadata
394
+ value and probes whether other rows exist; it does not load or parse node
395
+ values. Missing metadata with saved values refuses `JD2069`. Refused
396
+ legacy upgrades leave the original identity untouched. If it recorded no node value yet, there is
394
397
  nothing that could be replayed wrongly, so the identity is upgraded in
395
398
  place and the run proceeds. If it DID record values, the implementation
396
399
  that produced them cannot be confirmed and the resume is refused,
@@ -471,6 +474,16 @@ a cancellation policy, a retry policy are the host's.
471
474
  and counts it under `stats().cancellations`; an attempt held by
472
475
  another process meets the fence at its next settling call and records
473
476
  a loss, as any superseded attempt does.
477
+ - **`reset(id, { expectedGeneration })`** — explicitly discard one inactive
478
+ run's checkpoints and restart its attempts at zero. Read the job's
479
+ `leaseGeneration` first and pass that observation. One transaction
480
+ conditionally updates the job, increments its fence generation and deletes
481
+ its checkpoint rows. A concurrent generation change refuses `JD2066`, a
482
+ live lease `JD2068`, an unknown or completed job `JD2065`; no refusal
483
+ discards values. The next claim recomputes the workflow under current
484
+ identity. Reset does not undo external task effects; their idempotency
485
+ keys remain the host's responsibility. It is root-only administration,
486
+ with the same signal/deadline options as requeue.
474
487
  - **`requeue(id)`** — returns a failed, dead, cancelled or lease-expired
475
488
  job to `pending` at the clock's instant (the queue's own order). The
476
489
  attempt history is KEPT: `attempts` counts every claim the job ever
@@ -235,25 +235,34 @@ hash of the `baseline` model when no migration has run.
235
235
 
236
236
  | strategy | which assertions | what it costs |
237
237
  |---|---|---|
238
- | per-document | a FLWOR over `$[*]` whose `$where`/`$return` read only the binding | one keyset batch at a time; fails fast at the first batch that violates |
239
- | fold | exactly one of `$count`, `$sum`, `$min`, `$max` over the root | one batch at a time; each batch is answered by the ENGINE and the partial answers combine |
240
- | materialize | everything else (`$let`, `$distinct`, a nested `$for`, two aggregates) | every document at once, under `assertionBounds` |
241
-
242
- A fold is sound because the operator is associative: the answer over a
243
- collection is the combination of the answers over any partition of it.
244
- Nothing reimplements an operator each batch is evaluated by the same
245
- compiled query the whole-collection path would use, and only the
246
- COMBINE step is written here, so null handling, empty-sequence answers
247
- and type coercions are the engine's. A suite runs every fold shape both
248
- ways, over ten corpora and six partitions, and requires the value and
249
- the verdict to be indistinguishable; a shape that cannot pass it is not
250
- in the set.
238
+ | provider | a collection `$count` that the existing query planner proves native without a typed intermediate schema | one aggregate result, with no assertion document fetch |
239
+ | per-document | an independent, unwindowed FLWOR over `$[*]`, with the default empty-sequence expectation | one keyset batch; fails at the first violation |
240
+ | fold | `$count`, `$sum`, `$avg`, `$min`, `$max` over a partition-independent operand; sequence EBV of an independent FLWOR | one batch plus fixed accumulator state |
241
+ | fold with bounds | `$distinct` over such an operand with an explicit positive `maxDistinct` | one batch plus unique items under cardinality and byte bounds |
242
+ | materialize | global or positional operands, nested scans, unsupported shapes, or unbounded distinct | the collection under `assertionBounds` |
243
+
244
+ Folds feed the operand's sequence items, in original order, into the
245
+ query engine's shared accumulator. Floating-point addition is **not
246
+ associative**: combining batch totals changes answers. The ordered state
247
+ preserves the whole-query result, including singleton arrays, negative
248
+ zero, NaN, mixed-type refusals and Unicode code-point ordering. Sequence
249
+ EBV is checked once across all batches, including an empty source.
250
+ `$[0]`, root-dependent filters and positional bindings never qualify as
251
+ independent merely because they occur inside an aggregate.
252
+
253
+ `classifyAssertion(query, { expect, maxDistinct })` exposes the portable
254
+ strategy. `onAssertionPlan(plan)` reports migration, step, collection,
255
+ strategy, shape, reason and bounds before the assertion reads documents;
256
+ Store dry-run query statements also expose strategy and reason. Provider
257
+ promotion reuses the query planner with no assumed typed columns. Typed
258
+ SQL aggregates still need a trustworthy intermediate schema and proof
259
+ that their numeric/order semantics match; otherwise the ordered fold runs.
251
260
 
252
261
  **A materializing assertion is bounded.** `options.assertionBounds`
253
262
  defaults to `{ maxRows: 100000, maxBytes: 67108864 }` and is crossed
254
263
  BEFORE the excess is held — the walk stops at the row that would break
255
264
  it, refusing `JD2007` (rows) or `JD2076` (bytes) and naming the two
256
- assertion shapes that are answered in batches instead. `null` on either
265
+ assertion strategies that are answered in batches instead. `null` on either
257
266
  member removes that bound, which a caller must ask for: an unbounded
258
267
  read nobody declared is exactly what this classification removes. This
259
268
  is a deliberate behavior change — a migration that used to read a very
@@ -265,14 +274,11 @@ hash of the `baseline` model when no migration has run.
265
274
  transformed | derived | asserted }`, one event per batch), and never
266
275
  hold the whole collection in memory. A PER-DOCUMENT assertion — a
267
276
  FLWOR over `$[*]` whose `$where` and `$return` read only the binding
268
- — walks the same batches and fails fast at the first batch that
269
- violates, because its answer over each batch is its answer over the
270
- whole. A cross-document assertion (one that reads the root: `$count:
271
- '$[*]'`, a `$let`, a `$distinct`, a nested `$for`) reads the whole
272
- collection into one array a stated cost; keep such assertions
273
- early, before the data grows. A cross-document assertion that is one
274
- associative aggregate no longer costs that read at all — see the
275
- classification table above.
277
+ with the default empty expectation walks the same batches and fails
278
+ fast. Other assertions follow the classification table above.
279
+ `batchSize` must be a positive safe integer. `maxDistinct` opts into a
280
+ distinct fold and bounds retained unique items; `maxBytes` applies to
281
+ those items. Duplicates do not consume additional cardinality.
276
282
  - A transform MUST NOT change a caller-keyed document's key member —
277
283
  the key column would go stale; the run refuses (`JD0023`).
278
284
 
@@ -510,10 +516,23 @@ jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place
510
516
  line-delimited, everything else one JSON array) unless `--format` /
511
517
  `--out-format` says otherwise, and stdio defaults to JSONL. Input and
512
518
  output encodings are independent, so this is also the converter.
513
- - **A file holds ONE collection.** The migrations name it; a chain
514
- whose document steps touch more than one cannot be applied to a
515
- file, and is refused rather than partly run. `--collection` asserts
516
- which collection the file holds and refuses a mismatch.
519
+ - **Named sources share one collection bundle.** Repeat
520
+ `--in users=users.json --in events=events.jsonl --out migrated.json`.
521
+ Output defaults to `--out-format collections`: a standard JSON object
522
+ mapping collection names to arrays, including empty collections.
523
+ Publication is one atomic rename after every collection succeeds.
524
+ Multiple source files cannot use `--in-place`, because separate
525
+ renames cannot give this guarantee. Single-source `--collection`
526
+ still asserts the collection name and refuses a mismatch.
527
+ - **A bundle can be read explicitly** with `--format collections`, then
528
+ replaced using `--in-place --yes`. Bundle input is materialized under
529
+ a whole-file `maxBytes` ceiling before parsing and per-collection
530
+ `maxRows` validation after parsing. JSON and JSONL single-collection
531
+ sources remain streaming. `--max-rows`, `--max-bytes`, and
532
+ `--max-distinct` configure assertion admission too; the first two
533
+ accept `none` to remove their limit deliberately. A materializing
534
+ file run admits rows before retaining them. Physical-step and query
535
+ compilation refusals precede every input read.
517
536
  - **`--out` writes elsewhere; `--in-place` replaces the input and
518
537
  needs `--yes`.** Either way the documents land in a sibling
519
538
  temporary that is renamed over the target only once every document
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.67.0",
4
+ "version": "0.72.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -84,9 +84,9 @@
84
84
  "prepack": "npm run build:types"
85
85
  },
86
86
  "dependencies": {
87
- "@jarenjs/core": "^0.67.0",
88
- "@jarenjs/json": "^0.67.0",
89
- "@jarenjs/validate": "^0.67.0"
87
+ "@jarenjs/core": "^0.72.0",
88
+ "@jarenjs/json": "^0.72.0",
89
+ "@jarenjs/validate": "^0.72.0"
90
90
  },
91
91
  "bin": {
92
92
  "jaren-db": "./src/cli.js"
package/src/cli.js CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  migrateDocuments, streamDocuments, classifyAssertion,
22
22
  } from './index.js';
23
23
  import { nodeDriver } from './drivers/node.js';
24
+ import { prepareDocumentRun } from './documents.js';
25
+ import { normalizeAssertionBounds, createAssertionBoundGuard } from './document-steps.js';
24
26
  import {
25
- readDocuments, openAtomicTarget, openStreamTarget, openNullTarget,
27
+ readDocuments, readCollectionBundle, openAtomicTarget, openStreamTarget, openNullTarget,
26
28
  formatOf, DOCUMENT_FORMATS,
27
29
  } from './document-files.js';
28
30
 
@@ -37,7 +39,9 @@ Usage:
37
39
  jaren-db check --model <model> --store <db> [--migrations <dir>] [--snapshot <file>]
38
40
  jaren-db shape --model <model>
39
41
  jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place --yes | --check)
40
- [--format json|jsonl] [--out-format json|jsonl] [--collection <name>] [--batch-size <n>]
42
+ [--format json|jsonl|collections] [--out-format json|jsonl|collections]
43
+ [--in collection=file ...] [--collection <name>] [--batch-size <n>]
44
+ [--max-rows <n|none>] [--max-bytes <n|none>] [--max-distinct <n>]
41
45
 
42
46
  A <model> or a migration is a .json file, or a MODULE (.js, .mjs, .cjs —
43
47
  or .ts where Node strips types) whose default export, or its 'model' /
@@ -96,6 +100,7 @@ function parseArgs(argv) {
96
100
  command: argv[2], from: null, to: null, model: null, store: null,
97
101
  baseline: null, migrations: null, id: null, out: null,
98
102
  snapshot: null, types: null,
103
+ inputs: [], maxRows: undefined, maxBytes: undefined, maxDistinct: undefined,
99
104
  in: null, format: null, outFormat: null, collection: null,
100
105
  batchSize: null, inPlace: false, check: false,
101
106
  dryRun: false, yes: false, help: false,
@@ -112,7 +117,10 @@ function parseArgs(argv) {
112
117
  case '--out': options.out = argv[++i]; break;
113
118
  case '--snapshot': options.snapshot = argv[++i]; break;
114
119
  case '--types': options.types = argv[++i]; break;
115
- case '--in': options.in = argv[++i]; break;
120
+ case '--in': options.in = argv[++i]; options.inputs.push(options.in); break;
121
+ case '--max-rows': options.maxRows = argv[++i]; break;
122
+ case '--max-bytes': options.maxBytes = argv[++i]; break;
123
+ case '--max-distinct': options.maxDistinct = argv[++i]; break;
116
124
  case '--out-format': options.outFormat = argv[++i]; break;
117
125
  case '--format': options.format = argv[++i]; break;
118
126
  case '--collection': options.collection = argv[++i]; break;
@@ -435,94 +443,107 @@ async function commandShape(options) {
435
443
  */
436
444
  async function commandDocuments(options) {
437
445
  if (options.migrations === null) misuse('documents needs --migrations <dir>');
438
- if (options.in === null) misuse('documents needs --in <file> (or - for standard input)');
446
+ if (options.inputs.length === 0) misuse('documents needs --in <file> (or - for standard input)');
447
+ if (options.inputs.some((input) => typeof input !== 'string' || input.startsWith('--')))
448
+ misuse('--in requires a file or collection=file');
449
+ const multiple = options.inputs.length > 1 || /^[A-Za-z_][A-Za-z0-9_]*=/.test(options.in);
450
+ if (multiple && options.inPlace)
451
+ misuse('multiple sources require one --out collection bundle; several in-place renames are not atomic');
439
452
  const sinks = [options.out !== null, options.inPlace, options.check].filter(Boolean).length;
440
- if (sinks === 0)
441
- misuse('documents needs one of --out <file>, --in-place or --check');
442
- if (sinks > 1)
443
- misuse('documents takes exactly one of --out, --in-place and --check');
444
- if (options.inPlace && options.in === '-')
445
- misuse('--in-place needs a file to replace, not standard input');
446
- if (options.inPlace && !options.yes)
447
- misuse('--in-place rewrites the input file — pass --yes to confirm, or --out to write elsewhere');
453
+ if (sinks !== 1) misuse('documents takes exactly one of --out <file>, --in-place and --check');
454
+ if (options.inPlace && options.in === '-') misuse('--in-place needs a file to replace, not standard input');
455
+ if (options.inPlace && !options.yes) misuse('--in-place rewrites the input file — pass --yes to confirm, or --out to write elsewhere');
448
456
  const batchSize = options.batchSize === null ? 500 : Number(options.batchSize);
449
- if (!Number.isInteger(batchSize) || batchSize < 1)
450
- misuse(`--batch-size must be a positive integer, not '${options.batchSize}'`);
451
-
452
- const fromStdin = options.in === '-';
453
- const inFormat = options.format ?? (fromStdin ? 'jsonl' : formatOf(options.in));
454
- if (!DOCUMENT_FORMATS.includes(inFormat))
455
- misuse(`--format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${inFormat}'`);
457
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1) misuse('--batch-size must be a positive integer');
458
+ const declared = {};
459
+ for (const name of ['maxRows', 'maxBytes', 'maxDistinct']) {
460
+ if (options[name] === undefined) continue;
461
+ declared[name] = options[name] === 'none' ? null : Number(options[name]);
462
+ }
463
+ let assertionBounds;
464
+ try { assertionBounds = normalizeAssertionBounds(declared); }
465
+ catch (error) { misuse(error.message); }
466
+ const inFormat = options.format;
467
+ const bundleInput = inFormat === 'collections';
468
+ if (inFormat !== null && ![...DOCUMENT_FORMATS, 'collections'].includes(inFormat))
469
+ misuse('--format must be one of json, jsonl or collections');
470
+ if (bundleInput && multiple) misuse('--format collections takes one bundle file');
456
471
  const target = options.inPlace ? options.in : options.out;
457
472
  const toStdout = target === '-';
458
- const outFormat = options.outFormat
459
- ?? (options.check || toStdout ? inFormat : formatOf(/** @type {string} */ (target)));
460
- if (!DOCUMENT_FORMATS.includes(outFormat))
461
- misuse(`--out-format must be one of ${DOCUMENT_FORMATS.join(', ')}, not '${outFormat}'`);
462
- if (!fromStdin && !fs.existsSync(options.in)) fail(`no such file: '${options.in}'`);
463
-
464
473
  const migrations = await loadMigrationsDir(options.migrations);
465
474
  if (migrations.length === 0) fail(`no migration documents in '${options.migrations}'`);
466
-
467
- // A document file holds ONE collection. The migrations say which:
468
- // every document step must name it, or this chain cannot be applied to
469
- // a file at all — running only the steps that match would leave the
470
- // rest silently unapplied, which is the one outcome a migration runner
471
- // may never produce.
472
475
  const documentSteps = migrations.flatMap((migration) => migration.steps)
473
476
  .filter((step) => step.kind === 'jslt' || step.kind === 'query');
474
477
  const named = [...new Set(documentSteps.map((step) => step.collection))];
475
- if (named.length > 1) {
476
- fail(`a document file holds one collection, and these migrations touch ${named.length} `
477
- + `(${named.join(', ')}) run them against a store, or split the chain so each `
478
- + 'migration touches the collection its file holds');
479
- }
480
- if (options.collection !== null && named.length === 1 && options.collection !== named[0]) {
481
- misuse(`--collection names '${options.collection}', but these migrations touch `
482
- + `'${named[0]}' is this the right file for them?`);
483
- }
484
- // with no document step at all there is no collection to infer; the
485
- // run still proceeds, because a physical step must be REFUSED by name
486
- // rather than reported as a missing collection
487
- const collection = named[0] ?? options.collection ?? 'documents';
488
-
489
- // only a MATERIALIZING assertion needs the collection at once; a
490
- // per-document predicate and an associative aggregate are both
491
- // answered one batch at a time, so they stream
492
- const materializes = documentSteps.some((step) => step.collection === collection
493
- && step.kind === 'query' && classifyAssertion(step.assert).strategy === 'materialize');
494
-
495
- const source = () => (fromStdin ? process.stdin : options.in);
478
+ const files = {};
479
+ if (multiple) {
480
+ if (options.collection !== null) misuse('named --in sources already declare their collections');
481
+ for (const input of options.inputs) {
482
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(input);
483
+ if (match === null) misuse('each named --in must be collection=file');
484
+ if (Object.hasOwn(files, match[1])) misuse(`duplicate input collection '${match[1]}'`);
485
+ Object.defineProperty(files, match[1], { value: match[2], enumerable: true });
486
+ }
487
+ if (Object.values(files).filter((file) => file === '-').length > 1) misuse('standard input can supply only one collection');
488
+ }
489
+ else if (!bundleInput) {
490
+ if (named.length > 1) fail(`a document file holds one collection, but these migrations touch ${named.join(', ')}; supply --in collection=file for every collection, or --format collections`);
491
+ const name = named[0] ?? options.collection ?? 'documents';
492
+ if (options.collection !== null && options.collection !== name) misuse(`--collection names '${options.collection}', but these migrations touch '${name}'`);
493
+ Object.defineProperty(files, name, { value: options.in, enumerable: true });
494
+ }
495
+ const runOptions = { batchSize, assertionBounds };
496
+ // Physical and compilation refusals precede every document read, including
497
+ // the materializing route. A bundle's actual collection inventory is checked
498
+ // again after its bounded parse.
499
+ prepareDocumentRun(bundleInput ? named : Object.keys(files), migrations, runOptions);
500
+ let state;
501
+ if (bundleInput) state = await readCollectionBundle(options.in === '-' ? process.stdin : options.in, assertionBounds);
502
+ const names = bundleInput ? Object.keys(state) : Object.keys(files);
503
+ prepareDocumentRun(names, migrations, runOptions);
504
+ const outFormat = options.outFormat ?? (multiple || bundleInput ? 'collections'
505
+ : options.check || toStdout ? inFormat ?? (options.in === '-' ? 'jsonl' : formatOf(options.in)) : formatOf(target));
506
+ if (!['json', 'jsonl', 'collections'].includes(outFormat)) misuse('--out-format must be json, jsonl or collections');
507
+ if (names.length > 1 && outFormat !== 'collections') misuse('multiple collections require --out-format collections');
508
+ const materializes = bundleInput || documentSteps.some((step) => step.kind === 'query'
509
+ && classifyAssertion(step.assert, { expect: step.expect, maxDistinct: assertionBounds.maxDistinct }).strategy === 'materialize');
510
+ const sources = Object.fromEntries(Object.entries(files).map(([name, file]) => [name,
511
+ readDocuments(file === '-' ? process.stdin : file, inFormat ?? (file === '-' ? 'jsonl' : formatOf(file)))]));
496
512
  let sink;
497
513
  if (options.check) sink = openNullTarget();
498
- else if (toStdout) sink = openStreamTarget(process.stdout, outFormat);
499
- else sink = await openAtomicTarget(/** @type {string} */ (target), outFormat);
500
-
514
+ else if (toStdout) sink = openStreamTarget(process.stdout, outFormat, { collections: names });
515
+ else sink = await openAtomicTarget(target, outFormat, { collections: names });
501
516
  try {
502
517
  let report;
503
518
  if (materializes) {
504
- const documents = [];
505
- for await (const document of readDocuments(source(), inFormat)) documents.push(document);
506
- const out = await migrateDocuments({ [collection]: documents }, migrations, { batchSize });
519
+ if (!bundleInput) {
520
+ state = {};
521
+ for (const name of names) {
522
+ const documents = [], guard = createAssertionBoundGuard(assertionBounds, name, 'the file runner');
523
+ for await (const document of sources[name]) { guard.admit(document); documents.push(document); }
524
+ Object.defineProperty(state, name, { value: documents, enumerable: true });
525
+ }
526
+ }
527
+ const out = await migrateDocuments(state, migrations, runOptions);
507
528
  report = out.report;
508
- for (const document of out.documents[collection]) await sink.write(document);
509
- }
510
- else {
511
- report = await streamDocuments({ [collection]: readDocuments(source(), inFormat) },
512
- migrations, { batchSize, write: (name, document) => sink.write(document) });
529
+ for (const name of names) for (const document of out.documents[name]) await sink.write(document, name);
513
530
  }
531
+ else report = await streamDocuments(sources, migrations,
532
+ { ...runOptions, write: (name, document) => sink.write(document, name) });
514
533
  const written = await sink.commit();
515
- const counts = report.counts[collection] ?? { read: 0, transformed: 0, asserted: 0 };
516
- console.log(`${options.check ? 'checked' : 'migrated'} '${collection}': `
517
- + `${counts.read} read, ${counts.transformed} transformed, ${counts.asserted} asserted `
518
- + `(${report.strategy[collection]})`);
534
+ for (const name of names) {
535
+ const counts = report.counts[name] ?? { read: 0, transformed: 0, asserted: 0 };
536
+ console.log(`${options.check ? 'checked' : 'migrated'} '${name}': ${counts.read} read, `
537
+ + `${counts.transformed} transformed, ${counts.asserted} asserted (${report.strategy[name]})`);
538
+ }
519
539
  console.log(`applied: ${report.applied.join(', ')}`);
520
540
  if (options.check) console.log('checked only — nothing was written');
521
541
  else if (toStdout) console.log(`wrote ${written.documents} document(s) to standard output`);
522
542
  else console.log(`wrote ${written.documents} document(s) to ${target} (${written.bytes} bytes)`);
523
543
  }
524
544
  catch (error) {
525
- await sink.abort();
545
+ try { await sink.abort(); }
546
+ catch (cleanupError) { error.cleanupError = cleanupError; }
526
547
  return fail(error.message);
527
548
  }
528
549
  }
package/src/dag-job.js CHANGED
@@ -48,7 +48,7 @@ const fingerprint = (value) => hashContent(canonicalizeJson(value ?? null));
48
48
  * @param {any} store - an open store with `{ jobs: true }`
49
49
  * @param {{ compileDag: Function,
50
50
  * documents: Record<string, any>,
51
- * tasks?: Record<string, Function>,
51
+ * tasks?: Record<string, Function | { run: Function, version?: string, taskVersions?: Record<string, string> }>,
52
52
  * concurrency?: number, pollInterval?: number, leaseMs?: number,
53
53
  * owner?: string, renew?: boolean, onOutcome?: (event: any) => void,
54
54
  * backoffBase?: number, backoffCap?: number,
@@ -130,14 +130,20 @@ export function createDagJobRunner(store, options) {
130
130
  * just as surely as an edited document does.
131
131
  */
132
132
  const requireSameRun = async (context, jobId, revision, inputHash, taskVersions) => {
133
- const loaded = await context.checkpoints.load(jobId);
134
- const stored = loaded?.values?.[RUN_IDENTITY_NODE];
133
+ const loaded = await context.checkpoints.inspect(jobId, RUN_IDENTITY_NODE);
134
+ const stored = loaded.value;
135
135
  const taskVersionsHash = fingerprint(taskVersions);
136
136
  const identity = { revision, inputHash, taskVersionsHash, taskVersions };
137
137
  if (stored === undefined) {
138
+ if (loaded.hasValues) throw new DbRuntimeError('JD2069',
139
+ `run '${jobId}' cannot resume: checkpoint values have no recorded workflow, input or task identity`,
140
+ { docPath: '/jobs', collection: jobId });
138
141
  await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
139
142
  return;
140
143
  }
144
+ if (stored === null || typeof stored !== 'object' || Array.isArray(stored))
145
+ throw new DbRuntimeError('JD2069', `run '${jobId}' has invalid checkpoint identity metadata`,
146
+ { docPath: '/jobs', collection: jobId });
141
147
  const differs = [];
142
148
  if (stored.revision !== revision) {
143
149
  differs.push(`the workflow (checkpointed under revision ${stored.revision}, `
@@ -152,13 +158,8 @@ export function createDagJobRunner(store, options) {
152
158
  // equal: the upgrade is allowed only where nothing can be replayed
153
159
  // wrongly — when no node value has been recorded yet, so the run has
154
160
  // nothing to inherit from an implementation nobody can name.
155
- const recorded = Object.keys(loaded?.values ?? {})
156
- .filter((nodeId) => nodeId !== RUN_IDENTITY_NODE);
157
- if (recorded.length === 0) {
158
- await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
159
- }
160
- else {
161
- differs.push(`the task versions (this run recorded ${recorded.length} node value(s) `
161
+ if (loaded.hasValues) {
162
+ differs.push(`the task versions (this run recorded node value(s) `
162
163
  + 'before task identity was persisted, so the implementation that produced them '
163
164
  + 'cannot be confirmed)');
164
165
  }
@@ -169,10 +170,14 @@ export function createDagJobRunner(store, options) {
169
170
  : `checkpointed under ${stored.taskVersionsHash}, this runner compiles `
170
171
  + `${taskVersionsHash}`})`);
171
172
  }
172
- if (differs.length === 0) return;
173
+ if (differs.length === 0) {
174
+ if (stored.taskVersionsHash === undefined)
175
+ await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
176
+ return;
177
+ }
173
178
  throw new DbRuntimeError('JD2069',
174
179
  `run '${jobId}' cannot resume: ${differs.join(' and ')} changed since its `
175
- + 'checkpoints were written. Enqueue it under a new id, or drop the run — '
180
+ + 'checkpoints were written. Enqueue it under a new id, or explicitly reset this inactive run with its observed generation — '
176
181
  + 'reusing them would answer for a computation nobody asked for',
177
182
  { docPath: '/jobs', collection: jobId });
178
183
  };
@@ -208,6 +208,65 @@ export function readDocuments(source, format) {
208
208
  throw refuse(`unknown document format '${format}' — one of ${DOCUMENT_FORMATS.join(', ')}`);
209
209
  }
210
210
 
211
+ /**
212
+ * Read an explicitly materialized collection-map bundle under a byte ceiling.
213
+ * JSON/JSONL collection readers remain streaming; a bundle holds named arrays.
214
+ * @param {string | AsyncIterable<any>} source
215
+ * @param {{ maxBytes: number | null, maxRows: number | null }} bounds
216
+ */
217
+ export async function readCollectionBundle(source, bounds) {
218
+ let text = '', bytes = 0;
219
+ for await (const chunk of textChunks(source)) {
220
+ bytes += Buffer.byteLength(chunk);
221
+ if (bounds.maxBytes !== null && bytes > bounds.maxBytes)
222
+ throw refuse(`the collection bundle exceeds its maxBytes bound of ${bounds.maxBytes}`);
223
+ text += chunk;
224
+ }
225
+ const value = parseDocument(text, 'collection bundle');
226
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
227
+ || Object.values(value).some((documents) => !Array.isArray(documents)))
228
+ throw refuse('a collection bundle is an object mapping collection names to document arrays');
229
+ for (const [name, documents] of Object.entries(value))
230
+ if (bounds.maxRows !== null && documents.length > bounds.maxRows)
231
+ throw refuse(`collection '${name}' exceeds its maxRows bound of ${bounds.maxRows}`);
232
+ return value;
233
+ }
234
+
235
+ /** One encoder for file and stream output, including atomic collection bundles. */
236
+ function documentEncoder(format, collections = []) {
237
+ if (!['json', 'jsonl', 'collections'].includes(format)) throw refuse(`unknown document format '${format}'`);
238
+ if (format === 'collections' && (new Set(collections).size !== collections.length
239
+ || collections.some((name) => typeof name !== 'string')))
240
+ throw refuse('collection bundle names must be unique strings');
241
+ let documents = 0, index = -1, members = 0;
242
+ const advance = (next) => {
243
+ let text = '';
244
+ while (index < next) {
245
+ text += index < 0 ? '{\n' : '\n],\n';
246
+ index++;
247
+ text += `${JSON.stringify(collections[index])}: [`;
248
+ members = 0;
249
+ }
250
+ return text;
251
+ };
252
+ return {
253
+ write: (document, collection) => {
254
+ const value = JSON.stringify(document);
255
+ if (value === undefined) throw refuse('a document must be JSON serializable');
256
+ if (format === 'jsonl') { documents++; return `${value}\n`; }
257
+ if (format === 'json') return documents++ === 0 ? `[\n${value}` : `,\n${value}`;
258
+ const next = collections.indexOf(collection);
259
+ if (next < index || next < 0) throw refuse('collection bundle writes must follow the declared collection order');
260
+ const prefix = advance(next);
261
+ documents++;
262
+ return prefix + (members++ === 0 ? '\n' : ',\n') + value;
263
+ },
264
+ finish: () => format === 'jsonl' ? '' : format === 'json' ? documents === 0 ? '[]\n' : '\n]\n'
265
+ : collections.length === 0 ? '{}\n' : advance(collections.length - 1) + '\n]\n}\n',
266
+ count: () => documents,
267
+ };
268
+ }
269
+
211
270
  /**
212
271
  * A sink that publishes whole or not at all.
213
272
  *
@@ -217,17 +276,18 @@ export function readDocuments(source, format) {
217
276
  * a target that never changed.
218
277
  *
219
278
  * @param {string} target - the file to replace
220
- * @param {'json' | 'jsonl'} format
221
- * @returns {Promise<{ write: (document: any) => Promise<void>,
279
+ * @param {'json' | 'jsonl' | 'collections'} format
280
+ * @param {{ collections?: string[] }} [options]
281
+ * @returns {Promise<{ write: (document: any, collection?: string) => Promise<void>,
222
282
  * commit: () => Promise<{ bytes: number, documents: number }>,
223
283
  * abort: () => Promise<void>, temporary: string }>}
224
284
  */
225
- export async function openAtomicTarget(target, format) {
285
+ export async function openAtomicTarget(target, format, options = {}) {
286
+ const encoder = documentEncoder(format, options.collections);
226
287
  const directory = path.dirname(path.resolve(target));
227
288
  const temporary = path.join(directory,
228
289
  `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
229
290
  const handle = await fsp.open(temporary, 'wx');
230
- let documents = 0;
231
291
  let bytes = 0;
232
292
  let settled = false;
233
293
 
@@ -243,21 +303,20 @@ export async function openAtomicTarget(target, format) {
243
303
 
244
304
  return {
245
305
  temporary,
246
- write: async (document) => {
247
- const text = JSON.stringify(document);
248
- if (format === 'jsonl') { documents++; return put(`${text}\n`); }
249
- return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
306
+ write: async (document, collection) => {
307
+ if (settled) throw refuse('this target was already settled');
308
+ await put(encoder.write(document, collection));
250
309
  },
251
310
  commit: async () => {
252
311
  if (settled) throw refuse('this target was already settled');
253
312
  settled = true;
254
313
  try {
255
- if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
314
+ await put(encoder.finish());
256
315
  // Publish only after the temporary has been durably flushed.
257
316
  await handle.sync();
258
317
  await handle.close();
259
318
  await fsp.rename(temporary, target);
260
- return { bytes, documents };
319
+ return { bytes, documents: encoder.count() };
261
320
  }
262
321
  catch (error) {
263
322
  try { await discard(); }
@@ -277,23 +336,20 @@ export async function openAtomicTarget(target, format) {
277
336
  * A sink that writes to an open stream (standard output) and can never
278
337
  * be taken back — `abort` is honest that what left has left.
279
338
  * @param {{ write: (chunk: string, callback: (error?: any) => void) => any }} stream
280
- * @param {'json' | 'jsonl'} format
339
+ * @param {'json' | 'jsonl' | 'collections'} format
340
+ * @param {{ collections?: string[] }} [options]
281
341
  */
282
- export function openStreamTarget(stream, format) {
283
- let documents = 0;
342
+ export function openStreamTarget(stream, format, options = {}) {
343
+ const encoder = documentEncoder(format, options.collections);
284
344
  const put = (text) => new Promise((resolve, reject) => {
285
345
  stream.write(text, (error) => (error ? reject(error) : resolve(undefined)));
286
346
  });
287
347
  return {
288
348
  temporary: null,
289
- write: async (document) => {
290
- const text = JSON.stringify(document);
291
- if (format === 'jsonl') { documents++; return put(`${text}\n`); }
292
- return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
293
- },
349
+ write: async (document, collection) => put(encoder.write(document, collection)),
294
350
  commit: async () => {
295
- if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
296
- return { bytes: 0, documents };
351
+ await put(encoder.finish());
352
+ return { bytes: 0, documents: encoder.count() };
297
353
  },
298
354
  abort: async () => undefined,
299
355
  };