@jarenjs/db 0.66.1 → 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.
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-replication-snapshot/draft-07",
4
+ "title": "Bounded replication snapshot and frontier handshake",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "$replicationSnapshot",
9
+ "model",
10
+ "frontier",
11
+ "rows",
12
+ "receipts"
13
+ ],
14
+ "properties": {
15
+ "$replicationSnapshot": {
16
+ "const": "0.1"
17
+ },
18
+ "model": {
19
+ "type": "string",
20
+ "minLength": 1
21
+ },
22
+ "frontier": {
23
+ "type": "object",
24
+ "propertyNames": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "maxLength": 128
28
+ },
29
+ "additionalProperties": {
30
+ "type": "integer",
31
+ "minimum": 0,
32
+ "maximum": 9007199254740991
33
+ }
34
+ },
35
+ "rows": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": [
41
+ "table",
42
+ "key",
43
+ "value",
44
+ "frontier"
45
+ ],
46
+ "properties": {
47
+ "table": {
48
+ "type": "string",
49
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
50
+ },
51
+ "key": {
52
+ "type": "string"
53
+ },
54
+ "value": {
55
+ "type": [
56
+ "object",
57
+ "null"
58
+ ]
59
+ },
60
+ "frontier": {
61
+ "type": "object",
62
+ "propertyNames": {
63
+ "type": "string",
64
+ "minLength": 1,
65
+ "maxLength": 128
66
+ },
67
+ "additionalProperties": {
68
+ "type": "integer",
69
+ "minimum": 0,
70
+ "maximum": 9007199254740991
71
+ }
72
+ }
73
+ }
74
+ }
75
+ },
76
+ "receipts": {
77
+ "type": "array",
78
+ "items": {
79
+ "$ref": "https://jarenjs.dev/schemas/jaren-replication/draft-07"
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-replication-snapshot",
4
+ "title": "Bounded replication snapshot and frontier handshake",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "$replicationSnapshot",
9
+ "model",
10
+ "frontier",
11
+ "rows",
12
+ "receipts"
13
+ ],
14
+ "properties": {
15
+ "$replicationSnapshot": {
16
+ "const": "0.1"
17
+ },
18
+ "model": {
19
+ "type": "string",
20
+ "minLength": 1
21
+ },
22
+ "frontier": {
23
+ "type": "object",
24
+ "propertyNames": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "maxLength": 128
28
+ },
29
+ "additionalProperties": {
30
+ "type": "integer",
31
+ "minimum": 0,
32
+ "maximum": 9007199254740991
33
+ }
34
+ },
35
+ "rows": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": [
41
+ "table",
42
+ "key",
43
+ "value",
44
+ "frontier"
45
+ ],
46
+ "properties": {
47
+ "table": {
48
+ "type": "string",
49
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
50
+ },
51
+ "key": {
52
+ "type": "string"
53
+ },
54
+ "value": {
55
+ "type": [
56
+ "object",
57
+ "null"
58
+ ]
59
+ },
60
+ "frontier": {
61
+ "type": "object",
62
+ "propertyNames": {
63
+ "type": "string",
64
+ "minLength": 1,
65
+ "maxLength": 128
66
+ },
67
+ "additionalProperties": {
68
+ "type": "integer",
69
+ "minimum": 0,
70
+ "maximum": 9007199254740991
71
+ }
72
+ }
73
+ }
74
+ }
75
+ },
76
+ "receipts": {
77
+ "type": "array",
78
+ "items": {
79
+ "$ref": "https://jarenjs.dev/schemas/jaren-replication"
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-replication/draft-07",
4
+ "title": "Portable logical replication transaction",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "$replication",
9
+ "replica",
10
+ "seq",
11
+ "frontier",
12
+ "model",
13
+ "operations"
14
+ ],
15
+ "properties": {
16
+ "$replication": {
17
+ "const": "0.1"
18
+ },
19
+ "replica": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "maxLength": 128
23
+ },
24
+ "seq": {
25
+ "type": "integer",
26
+ "minimum": 1,
27
+ "maximum": 9007199254740991
28
+ },
29
+ "frontier": {
30
+ "type": "object",
31
+ "propertyNames": {
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 128
35
+ },
36
+ "additionalProperties": {
37
+ "type": "integer",
38
+ "minimum": 0,
39
+ "maximum": 9007199254740991
40
+ }
41
+ },
42
+ "model": {
43
+ "type": "string",
44
+ "minLength": 1
45
+ },
46
+ "operations": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "items": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": [
53
+ "table",
54
+ "key",
55
+ "before",
56
+ "after"
57
+ ],
58
+ "properties": {
59
+ "table": {
60
+ "type": "string",
61
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
62
+ },
63
+ "key": {
64
+ "type": "string"
65
+ },
66
+ "before": {
67
+ "type": [
68
+ "object",
69
+ "null"
70
+ ]
71
+ },
72
+ "after": {
73
+ "type": [
74
+ "object",
75
+ "null"
76
+ ]
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-replication",
4
+ "title": "Portable logical replication transaction",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "$replication",
9
+ "replica",
10
+ "seq",
11
+ "frontier",
12
+ "model",
13
+ "operations"
14
+ ],
15
+ "properties": {
16
+ "$replication": {
17
+ "const": "0.1"
18
+ },
19
+ "replica": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "maxLength": 128
23
+ },
24
+ "seq": {
25
+ "type": "integer",
26
+ "minimum": 1,
27
+ "maximum": 9007199254740991
28
+ },
29
+ "frontier": {
30
+ "type": "object",
31
+ "propertyNames": {
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 128
35
+ },
36
+ "additionalProperties": {
37
+ "type": "integer",
38
+ "minimum": 0,
39
+ "maximum": 9007199254740991
40
+ }
41
+ },
42
+ "model": {
43
+ "type": "string",
44
+ "minLength": 1
45
+ },
46
+ "operations": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "items": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": [
53
+ "table",
54
+ "key",
55
+ "before",
56
+ "after"
57
+ ],
58
+ "properties": {
59
+ "table": {
60
+ "type": "string",
61
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
62
+ },
63
+ "key": {
64
+ "type": "string"
65
+ },
66
+ "before": {
67
+ "type": [
68
+ "object",
69
+ "null"
70
+ ]
71
+ },
72
+ "after": {
73
+ "type": [
74
+ "object",
75
+ "null"
76
+ ]
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
package/src/capture.js CHANGED
@@ -328,7 +328,8 @@ export function translateOperations(connection, shapes, operations) {
328
328
  * mode: 'session' | 'journal',
329
329
  * log: boolean, retention: number,
330
330
  * now?: () => number,
331
- * bracket?: (fn: () => any) => any }} options - `now` is the clock a
331
+ * bracket?: (fn: () => any) => any,
332
+ * beforeCommit?: (patch: any[], context: any) => any }} options - `now` is the clock a
332
333
  * delivery is stamped with (the store's runtime record); the platform's
333
334
  * when absent. `bracket` runs the first-open DDL (the store's immediate
334
335
  * transaction on a writable store); bare when absent
@@ -353,6 +354,7 @@ export function createCaptureEngine(options) {
353
354
  // from the durable row — never an answer to a watermark question
354
355
  let seq = 0;
355
356
  let depth = 0;
357
+ let context = null;
356
358
  /** @type {any} */
357
359
  let session = null;
358
360
  /** @type {any[]} */
@@ -572,6 +574,7 @@ export function createCaptureEngine(options) {
572
574
  depth = 1;
573
575
  const cleanupFailure = () => {
574
576
  depth = 0;
577
+ context = null;
575
578
  if (session !== null) {
576
579
  session.close();
577
580
  session = null;
@@ -584,7 +587,7 @@ export function createCaptureEngine(options) {
584
587
  else journal = [];
585
588
  outcome = connection.transaction((...scopeArgs) =>
586
589
  chain(fn(...scopeArgs), (result) =>
587
- chain(collect(), (patch) => {
590
+ chain(collect(), (patch) => chain(options.beforeCommit?.(patch, context), () => {
588
591
  if (patch.length === 0) return { result, delivery: null };
589
592
  const at = clock();
590
593
  return chain(persist(patch, at), () => ({
@@ -597,7 +600,7 @@ export function createCaptureEngine(options) {
597
600
  patch,
598
601
  },
599
602
  }));
600
- })));
603
+ }))));
601
604
  }
602
605
  catch (error) {
603
606
  cleanupFailure();
@@ -605,6 +608,7 @@ export function createCaptureEngine(options) {
605
608
  }
606
609
  const finish = (bundle) => {
607
610
  depth = 0;
611
+ context = null;
608
612
  if (bundle.delivery !== null) pendingDeliveries.push(bundle.delivery);
609
613
  deliver();
610
614
  return bundle.result;
@@ -669,6 +673,12 @@ export function createCaptureEngine(options) {
669
673
  mark,
670
674
  truncate,
671
675
  record,
676
+ // Metadata belongs to this capture transaction and is cleared on every
677
+ // settlement, including a failure while collecting or persisting changes.
678
+ setContext(value) {
679
+ if (depth === 0) throw new TypeError('capture context needs an active transaction');
680
+ context = value;
681
+ },
672
682
  observe(fn) {
673
683
  if (typeof fn !== 'function')
674
684
  throw new TypeError('observe needs a function');
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/cursor.js CHANGED
@@ -384,11 +384,7 @@ export function drainPage(cursor, options) {
384
384
  if (maxBytes !== null && bytes + size > maxBytes) {
385
385
  if (items.length === 0) {
386
386
  return chain(cursor.return(), () => {
387
- throw new DbRuntimeError('JD2074',
388
- `the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
389
- + `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
390
- + "item itself (an include's maxBytes, a narrower document)",
391
- { errors: [{ bytes: size, maxBytes, at: continuationOf(item) }] });
387
+ assertItemBytes(size, maxBytes, continuationOf(item));
392
388
  });
393
389
  }
394
390
  hasMore = true;
@@ -409,3 +405,13 @@ export function drainPage(cursor, options) {
409
405
  };
410
406
  return settling(step, () => cursor.return());
411
407
  }
408
+
409
+ /** The shared refusal for an indivisible item, including a replicated transaction.
410
+ * @param {number} size @param {number} maxBytes @param {any} [at] */
411
+ export function assertItemBytes(size, maxBytes, at) {
412
+ if (size > maxBytes) throw new DbRuntimeError('JD2074',
413
+ `the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
414
+ + `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
415
+ + "item itself (an include's maxBytes, a narrower document)",
416
+ { errors: [{ bytes: size, maxBytes, at }] });
417
+ }