@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 +29 -0
- package/docs/JOBS-FORMAT.md +14 -1
- package/docs/MIGRATION-FORMAT.md +45 -26
- package/package.json +4 -4
- package/src/cli.js +89 -68
- package/src/dag-job.js +17 -12
- 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/jobs.js +42 -0
- package/src/migrate.js +44 -5
- package/src/store.js +6 -0
- package/types/index.d.ts +32 -7
- package/types/node.d.ts +9 -3
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).
|
package/docs/JOBS-FORMAT.md
CHANGED
|
@@ -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`.
|
|
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
|
package/docs/MIGRATION-FORMAT.md
CHANGED
|
@@ -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
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
- **
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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.
|
|
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.
|
|
88
|
-
"@jarenjs/json": "^0.
|
|
89
|
-
"@jarenjs/validate": "^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]
|
|
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.
|
|
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
|
|
441
|
-
|
|
442
|
-
if (
|
|
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.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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[
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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.
|
|
134
|
-
const stored = loaded
|
|
133
|
+
const loaded = await context.checkpoints.inspect(jobId, RUN_IDENTITY_NODE);
|
|
134
|
+
const stored = loaded.value;
|
|
135
135
|
const taskVersionsHash = fingerprint(taskVersions);
|
|
136
136
|
const identity = { revision, inputHash, taskVersionsHash, taskVersions };
|
|
137
137
|
if (stored === undefined) {
|
|
138
|
+
if (loaded.hasValues) throw new DbRuntimeError('JD2069',
|
|
139
|
+
`run '${jobId}' cannot resume: checkpoint values have no recorded workflow, input or task identity`,
|
|
140
|
+
{ docPath: '/jobs', collection: jobId });
|
|
138
141
|
await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
144
|
+
if (stored === null || typeof stored !== 'object' || Array.isArray(stored))
|
|
145
|
+
throw new DbRuntimeError('JD2069', `run '${jobId}' has invalid checkpoint identity metadata`,
|
|
146
|
+
{ docPath: '/jobs', collection: jobId });
|
|
141
147
|
const differs = [];
|
|
142
148
|
if (stored.revision !== revision) {
|
|
143
149
|
differs.push(`the workflow (checkpointed under revision ${stored.revision}, `
|
|
@@ -152,13 +158,8 @@ export function createDagJobRunner(store, options) {
|
|
|
152
158
|
// equal: the upgrade is allowed only where nothing can be replayed
|
|
153
159
|
// wrongly — when no node value has been recorded yet, so the run has
|
|
154
160
|
// nothing to inherit from an implementation nobody can name.
|
|
155
|
-
|
|
156
|
-
.
|
|
157
|
-
if (recorded.length === 0) {
|
|
158
|
-
await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
differs.push(`the task versions (this run recorded ${recorded.length} node value(s) `
|
|
161
|
+
if (loaded.hasValues) {
|
|
162
|
+
differs.push(`the task versions (this run recorded node value(s) `
|
|
162
163
|
+ 'before task identity was persisted, so the implementation that produced them '
|
|
163
164
|
+ 'cannot be confirmed)');
|
|
164
165
|
}
|
|
@@ -169,10 +170,14 @@ export function createDagJobRunner(store, options) {
|
|
|
169
170
|
: `checkpointed under ${stored.taskVersionsHash}, this runner compiles `
|
|
170
171
|
+ `${taskVersionsHash}`})`);
|
|
171
172
|
}
|
|
172
|
-
if (differs.length === 0)
|
|
173
|
+
if (differs.length === 0) {
|
|
174
|
+
if (stored.taskVersionsHash === undefined)
|
|
175
|
+
await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
173
178
|
throw new DbRuntimeError('JD2069',
|
|
174
179
|
`run '${jobId}' cannot resume: ${differs.join(' and ')} changed since its `
|
|
175
|
-
+ 'checkpoints were written. Enqueue it under a new id, or
|
|
180
|
+
+ 'checkpoints were written. Enqueue it under a new id, or explicitly reset this inactive run with its observed generation — '
|
|
176
181
|
+ 'reusing them would answer for a computation nobody asked for',
|
|
177
182
|
{ docPath: '/jobs', collection: jobId });
|
|
178
183
|
};
|
package/src/document-files.js
CHANGED
|
@@ -208,6 +208,65 @@ export function readDocuments(source, format) {
|
|
|
208
208
|
throw refuse(`unknown document format '${format}' — one of ${DOCUMENT_FORMATS.join(', ')}`);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Read an explicitly materialized collection-map bundle under a byte ceiling.
|
|
213
|
+
* JSON/JSONL collection readers remain streaming; a bundle holds named arrays.
|
|
214
|
+
* @param {string | AsyncIterable<any>} source
|
|
215
|
+
* @param {{ maxBytes: number | null, maxRows: number | null }} bounds
|
|
216
|
+
*/
|
|
217
|
+
export async function readCollectionBundle(source, bounds) {
|
|
218
|
+
let text = '', bytes = 0;
|
|
219
|
+
for await (const chunk of textChunks(source)) {
|
|
220
|
+
bytes += Buffer.byteLength(chunk);
|
|
221
|
+
if (bounds.maxBytes !== null && bytes > bounds.maxBytes)
|
|
222
|
+
throw refuse(`the collection bundle exceeds its maxBytes bound of ${bounds.maxBytes}`);
|
|
223
|
+
text += chunk;
|
|
224
|
+
}
|
|
225
|
+
const value = parseDocument(text, 'collection bundle');
|
|
226
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
227
|
+
|| Object.values(value).some((documents) => !Array.isArray(documents)))
|
|
228
|
+
throw refuse('a collection bundle is an object mapping collection names to document arrays');
|
|
229
|
+
for (const [name, documents] of Object.entries(value))
|
|
230
|
+
if (bounds.maxRows !== null && documents.length > bounds.maxRows)
|
|
231
|
+
throw refuse(`collection '${name}' exceeds its maxRows bound of ${bounds.maxRows}`);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One encoder for file and stream output, including atomic collection bundles. */
|
|
236
|
+
function documentEncoder(format, collections = []) {
|
|
237
|
+
if (!['json', 'jsonl', 'collections'].includes(format)) throw refuse(`unknown document format '${format}'`);
|
|
238
|
+
if (format === 'collections' && (new Set(collections).size !== collections.length
|
|
239
|
+
|| collections.some((name) => typeof name !== 'string')))
|
|
240
|
+
throw refuse('collection bundle names must be unique strings');
|
|
241
|
+
let documents = 0, index = -1, members = 0;
|
|
242
|
+
const advance = (next) => {
|
|
243
|
+
let text = '';
|
|
244
|
+
while (index < next) {
|
|
245
|
+
text += index < 0 ? '{\n' : '\n],\n';
|
|
246
|
+
index++;
|
|
247
|
+
text += `${JSON.stringify(collections[index])}: [`;
|
|
248
|
+
members = 0;
|
|
249
|
+
}
|
|
250
|
+
return text;
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
write: (document, collection) => {
|
|
254
|
+
const value = JSON.stringify(document);
|
|
255
|
+
if (value === undefined) throw refuse('a document must be JSON serializable');
|
|
256
|
+
if (format === 'jsonl') { documents++; return `${value}\n`; }
|
|
257
|
+
if (format === 'json') return documents++ === 0 ? `[\n${value}` : `,\n${value}`;
|
|
258
|
+
const next = collections.indexOf(collection);
|
|
259
|
+
if (next < index || next < 0) throw refuse('collection bundle writes must follow the declared collection order');
|
|
260
|
+
const prefix = advance(next);
|
|
261
|
+
documents++;
|
|
262
|
+
return prefix + (members++ === 0 ? '\n' : ',\n') + value;
|
|
263
|
+
},
|
|
264
|
+
finish: () => format === 'jsonl' ? '' : format === 'json' ? documents === 0 ? '[]\n' : '\n]\n'
|
|
265
|
+
: collections.length === 0 ? '{}\n' : advance(collections.length - 1) + '\n]\n}\n',
|
|
266
|
+
count: () => documents,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
211
270
|
/**
|
|
212
271
|
* A sink that publishes whole or not at all.
|
|
213
272
|
*
|
|
@@ -217,17 +276,18 @@ export function readDocuments(source, format) {
|
|
|
217
276
|
* a target that never changed.
|
|
218
277
|
*
|
|
219
278
|
* @param {string} target - the file to replace
|
|
220
|
-
* @param {'json' | 'jsonl'} format
|
|
221
|
-
* @
|
|
279
|
+
* @param {'json' | 'jsonl' | 'collections'} format
|
|
280
|
+
* @param {{ collections?: string[] }} [options]
|
|
281
|
+
* @returns {Promise<{ write: (document: any, collection?: string) => Promise<void>,
|
|
222
282
|
* commit: () => Promise<{ bytes: number, documents: number }>,
|
|
223
283
|
* abort: () => Promise<void>, temporary: string }>}
|
|
224
284
|
*/
|
|
225
|
-
export async function openAtomicTarget(target, format) {
|
|
285
|
+
export async function openAtomicTarget(target, format, options = {}) {
|
|
286
|
+
const encoder = documentEncoder(format, options.collections);
|
|
226
287
|
const directory = path.dirname(path.resolve(target));
|
|
227
288
|
const temporary = path.join(directory,
|
|
228
289
|
`.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
229
290
|
const handle = await fsp.open(temporary, 'wx');
|
|
230
|
-
let documents = 0;
|
|
231
291
|
let bytes = 0;
|
|
232
292
|
let settled = false;
|
|
233
293
|
|
|
@@ -243,21 +303,20 @@ export async function openAtomicTarget(target, format) {
|
|
|
243
303
|
|
|
244
304
|
return {
|
|
245
305
|
temporary,
|
|
246
|
-
write: async (document) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
|
|
306
|
+
write: async (document, collection) => {
|
|
307
|
+
if (settled) throw refuse('this target was already settled');
|
|
308
|
+
await put(encoder.write(document, collection));
|
|
250
309
|
},
|
|
251
310
|
commit: async () => {
|
|
252
311
|
if (settled) throw refuse('this target was already settled');
|
|
253
312
|
settled = true;
|
|
254
313
|
try {
|
|
255
|
-
|
|
314
|
+
await put(encoder.finish());
|
|
256
315
|
// Publish only after the temporary has been durably flushed.
|
|
257
316
|
await handle.sync();
|
|
258
317
|
await handle.close();
|
|
259
318
|
await fsp.rename(temporary, target);
|
|
260
|
-
return { bytes, documents };
|
|
319
|
+
return { bytes, documents: encoder.count() };
|
|
261
320
|
}
|
|
262
321
|
catch (error) {
|
|
263
322
|
try { await discard(); }
|
|
@@ -277,23 +336,20 @@ export async function openAtomicTarget(target, format) {
|
|
|
277
336
|
* A sink that writes to an open stream (standard output) and can never
|
|
278
337
|
* be taken back — `abort` is honest that what left has left.
|
|
279
338
|
* @param {{ write: (chunk: string, callback: (error?: any) => void) => any }} stream
|
|
280
|
-
* @param {'json' | 'jsonl'} format
|
|
339
|
+
* @param {'json' | 'jsonl' | 'collections'} format
|
|
340
|
+
* @param {{ collections?: string[] }} [options]
|
|
281
341
|
*/
|
|
282
|
-
export function openStreamTarget(stream, format) {
|
|
283
|
-
|
|
342
|
+
export function openStreamTarget(stream, format, options = {}) {
|
|
343
|
+
const encoder = documentEncoder(format, options.collections);
|
|
284
344
|
const put = (text) => new Promise((resolve, reject) => {
|
|
285
345
|
stream.write(text, (error) => (error ? reject(error) : resolve(undefined)));
|
|
286
346
|
});
|
|
287
347
|
return {
|
|
288
348
|
temporary: null,
|
|
289
|
-
write: async (document) =>
|
|
290
|
-
const text = JSON.stringify(document);
|
|
291
|
-
if (format === 'jsonl') { documents++; return put(`${text}\n`); }
|
|
292
|
-
return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
|
|
293
|
-
},
|
|
349
|
+
write: async (document, collection) => put(encoder.write(document, collection)),
|
|
294
350
|
commit: async () => {
|
|
295
|
-
|
|
296
|
-
return { bytes: 0, documents };
|
|
351
|
+
await put(encoder.finish());
|
|
352
|
+
return { bytes: 0, documents: encoder.count() };
|
|
297
353
|
},
|
|
298
354
|
abort: async () => undefined,
|
|
299
355
|
};
|