@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.
- package/ARCHITECTURE.md +19 -0
- package/README.md +44 -4
- package/docs/JOBS-FORMAT.md +14 -1
- package/docs/LIVE-FORMAT.md +47 -6
- package/docs/MIGRATION-FORMAT.md +45 -26
- package/docs/MODEL-FORMAT.md +8 -0
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +4 -4
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/capture.js +13 -3
- package/src/cli.js +89 -68
- package/src/cursor.js +11 -5
- package/src/dag-job.js +17 -12
- package/src/dialect.js +1 -1
- package/src/dialects/sqlite.js +1 -0
- 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/errors.js +8 -0
- package/src/index.js +2 -0
- package/src/jobs.js +42 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/migrate.js +44 -5
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/store.js +73 -14
- package/types/index.d.ts +94 -10
- package/types/node.d.ts +9 -3
- package/types/typed.d.ts +3 -2
|
@@ -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
|
|
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]
|
|
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/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
|
-
|
|
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
|
+
}
|