@elaraai/e3-cloud-files 1.0.93 → 1.0.97
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 +129 -23
- package/dist/src/authoring.d.ts +61 -24
- package/dist/src/authoring.d.ts.map +1 -1
- package/dist/src/authoring.js +92 -48
- package/dist/src/authoring.js.map +1 -1
- package/dist/src/check/index.d.ts +3 -4
- package/dist/src/check/index.d.ts.map +1 -1
- package/dist/src/check/index.js +3 -4
- package/dist/src/check/index.js.map +1 -1
- package/dist/src/check.d.ts +44 -9
- package/dist/src/check.d.ts.map +1 -1
- package/dist/src/check.js +130 -53
- package/dist/src/check.js.map +1 -1
- package/dist/src/compat.d.ts +384 -0
- package/dist/src/compat.d.ts.map +1 -0
- package/dist/src/compat.js +258 -0
- package/dist/src/compat.js.map +1 -0
- package/dist/src/index.d.ts +12 -9
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +12 -9
- package/dist/src/index.js.map +1 -1
- package/dist/src/s3/index.d.ts.map +1 -1
- package/dist/src/s3/index.js +34 -12
- package/dist/src/s3/index.js.map +1 -1
- package/dist/src/storage/index.d.ts +72 -3
- package/dist/src/storage/index.d.ts.map +1 -1
- package/dist/src/storage/index.js +258 -13
- package/dist/src/storage/index.js.map +1 -1
- package/dist/src/sync/engine.d.ts +282 -49
- package/dist/src/sync/engine.d.ts.map +1 -1
- package/dist/src/sync/engine.js +599 -110
- package/dist/src/sync/engine.js.map +1 -1
- package/dist/src/sync/index.d.ts +3 -2
- package/dist/src/sync/index.d.ts.map +1 -1
- package/dist/src/sync/index.js +3 -2
- package/dist/src/sync/index.js.map +1 -1
- package/dist/src/sync/memory.d.ts +67 -12
- package/dist/src/sync/memory.d.ts.map +1 -1
- package/dist/src/sync/memory.js +248 -18
- package/dist/src/sync/memory.js.map +1 -1
- package/dist/src/sync/push.d.ts +56 -0
- package/dist/src/sync/push.d.ts.map +1 -0
- package/dist/src/sync/push.js +205 -0
- package/dist/src/sync/push.js.map +1 -0
- package/dist/test/typed.examples.d.ts +49 -0
- package/dist/test/typed.examples.d.ts.map +1 -0
- package/dist/test/typed.examples.js +58 -0
- package/dist/test/typed.examples.js.map +1 -0
- package/package.json +10 -6
package/dist/src/sync/engine.js
CHANGED
|
@@ -6,109 +6,180 @@
|
|
|
6
6
|
* The sync: what a source's files fill, brought up to date.
|
|
7
7
|
*
|
|
8
8
|
* One algorithm for a local repository and a deployed workspace alike: what
|
|
9
|
-
* reads a bucket, what writes a workspace's inputs and
|
|
10
|
-
* files each
|
|
9
|
+
* reads a bucket, what writes a workspace's inputs and records, and what
|
|
10
|
+
* remembers the files each target took are given to it ({@link FilesReader},
|
|
11
11
|
* {@link FilesWriter}, {@link FilesLedger}), so both behave the same.
|
|
12
12
|
*
|
|
13
|
-
* For each mapping: list the source, compare each file with what the
|
|
14
|
-
* took last (its ETag and size), read what is new or changed, write the
|
|
15
|
-
* and remember what it took and the value it wrote
|
|
16
|
-
* is never read again, so a sync with
|
|
17
|
-
* whose files are as they were is not
|
|
18
|
-
* is refused, named in the report, and never
|
|
19
|
-
* reader cannot read (archived, changed since it
|
|
20
|
-
* reader's access: {@link FilesUnreadableError}),
|
|
21
|
-
* rest, and the next sync trying it again.
|
|
22
|
-
* it is written, so a sync that stops part
|
|
13
|
+
* For each mapping: list the source, compare each file with what the target
|
|
14
|
+
* took last (its ETag and size), read what is new or changed, write the
|
|
15
|
+
* target, and remember what it took and the value it wrote or the commit it
|
|
16
|
+
* made. A file the same as before is never read again, so a sync with
|
|
17
|
+
* nothing new reads nothing; a target whose files are as they were is not
|
|
18
|
+
* written. A file over its limit is refused, named in the report, and never
|
|
19
|
+
* truncated; so is a file its reader cannot read (archived, changed since it
|
|
20
|
+
* was listed, or closed to the reader's access: {@link FilesUnreadableError}),
|
|
21
|
+
* the sync going on with the rest, and the next sync trying it again. A
|
|
22
|
+
* target is remembered only once it is written, so a sync that stops part
|
|
23
|
+
* way takes the rest next time.
|
|
24
|
+
*
|
|
25
|
+
* A `file` or `latest` mapping with a type takes its file in as the beast2
|
|
26
|
+
* value it holds: the file's header is read first, through a ranged read,
|
|
27
|
+
* and a file of another type, or no beast2, is refused before any other
|
|
28
|
+
* byte of it is read; then the writer takes the file in from a stream of its
|
|
29
|
+
* bytes ({@link FilesWriter.takeTyped}: never held whole, a collection by
|
|
30
|
+
* pieces), which may outlast a round and resume in the next, the file it was
|
|
31
|
+
* taking remembered by its key and ETag so no other file is resumed as it.
|
|
32
|
+
* A record's mapping seeds the record ({@link FilesWriter.seed}: its file is
|
|
33
|
+
* the record's first state, or its state again while nothing has mutated it
|
|
34
|
+
* since, or when the sync is asked to reset it); a mutation's feeds it
|
|
35
|
+
* ({@link FilesWriter.feed}): every new file under its prefix, in key order,
|
|
36
|
+
* each applied once, by the file's hash, and never again under another name.
|
|
23
37
|
*
|
|
24
38
|
* A folder's new and changed files are taken in batches of at most 256 MiB
|
|
25
39
|
* ({@link FOLDER_BATCH_BYTES}): each batch is read, written and remembered
|
|
26
40
|
* before the next is read, so a sync holds one batch of files, however large
|
|
27
|
-
* the folder. A sync can stop between
|
|
28
|
-
*
|
|
29
|
-
*
|
|
41
|
+
* the folder. A sync can stop between targets, between a folder's batches,
|
|
42
|
+
* between a feed's files and within a typed intake when asked
|
|
43
|
+
* ({@link syncFilesRound}, a cloud round handing over), and the next takes
|
|
44
|
+
* the rest.
|
|
30
45
|
*
|
|
31
|
-
* What
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* workspace's inputs, anyone setting one by hand, a
|
|
35
|
-
* source bound elsewhere, and the next sync takes
|
|
36
|
-
* when a sync under the old declarations wrote
|
|
46
|
+
* What a target took is trusted only while its mapping and its source's
|
|
47
|
+
* binding are those it was taken under ({@link LedgerState}'s `under`) and,
|
|
48
|
+
* for an input, while the input still holds the value the sync wrote: a
|
|
49
|
+
* deploy that resets the workspace's inputs, anyone setting one by hand, a
|
|
50
|
+
* changed declaration or a source bound elsewhere, and the next sync takes
|
|
51
|
+
* all of its files again, even when a sync under the old declarations wrote
|
|
52
|
+
* its ledger after the change. A record's or mutation's ledger stays
|
|
53
|
+
* trusted as the record moves on: the record itself says what a seed may do.
|
|
37
54
|
*
|
|
38
55
|
* Mappings that share a source and a prefix list it once a round; a glob that
|
|
39
56
|
* matches only the prefix's own folder lists that folder alone.
|
|
40
57
|
*
|
|
41
58
|
* @packageDocumentation
|
|
42
59
|
*/
|
|
43
|
-
import { StringType, StructType, none, printFor, some, variant } from '@elaraai/east';
|
|
44
|
-
import { FILES_FILE_MAX_BYTES, FILES_FOLDER_ENTRY_MAX_BYTES, FilesShapeType, filesProblemText, } from '@elaraai/e3-cloud-types';
|
|
60
|
+
import { StringType, StructType, isTypeValueEqual, none, printFor, readBeast2HeaderType, some, variant } from '@elaraai/east';
|
|
61
|
+
import { FILES_FILE_MAX_BYTES, FILES_FOLDER_ENTRY_MAX_BYTES, FILES_TYPED_VALUE_MAX_BYTES, FilesShapeType, filesProblemText, filesTargetText, sameFilesTarget, } from '@elaraai/e3-cloud-types';
|
|
62
|
+
import { FilesShapeTypeV1 } from '../compat.js';
|
|
45
63
|
import { globMatcher, matchesTopFolderOnly } from '../paths.js';
|
|
46
64
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* archived, changed or gone since it was listed, or closed to the reader's
|
|
51
|
-
* access. Any other error ends the round, which is tried again.
|
|
65
|
+
* Thrown by a reader for a file it cannot read, which the sync refuses and
|
|
66
|
+
* goes on past: archived, changed since it was listed, gone, or closed to
|
|
67
|
+
* the reader's access. Anything else a reader throws stops the sync.
|
|
52
68
|
*/
|
|
53
69
|
export class FilesUnreadableError extends Error {
|
|
70
|
+
/** The file's key */
|
|
54
71
|
key;
|
|
72
|
+
/** Why, as the refusal says it */
|
|
55
73
|
reason;
|
|
56
|
-
/**
|
|
57
|
-
* @param key - The file's key, relative to the binding
|
|
58
|
-
* @param reason - Why, completing "'<key>' …"
|
|
59
|
-
*/
|
|
60
74
|
constructor(key, reason) {
|
|
61
75
|
super(`${key} ${reason}`);
|
|
76
|
+
this.name = 'FilesUnreadableError';
|
|
62
77
|
this.key = key;
|
|
63
78
|
this.reason = reason;
|
|
64
|
-
this.name = 'FilesUnreadableError';
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
|
-
/** What
|
|
68
|
-
export const NO_LEDGER = { entries: new Map(), value: null, under: null };
|
|
81
|
+
/** What a target took before its first sync: nothing. */
|
|
82
|
+
export const NO_LEDGER = { entries: new Map(), value: null, under: null, commit: null, tried: null, taking: null };
|
|
69
83
|
/**
|
|
70
|
-
* What
|
|
84
|
+
* What a target took, as a store keeps it (e3-cloud-types' `FilesLedgerType`).
|
|
71
85
|
*
|
|
72
86
|
* @param state - What it took
|
|
73
87
|
* @returns Its East value
|
|
74
88
|
*/
|
|
75
89
|
export function ledgerValueOf(state) {
|
|
76
90
|
return {
|
|
77
|
-
entries: new Map([...state.entries].map(([key, e]) => [key, {
|
|
91
|
+
entries: new Map([...state.entries].map(([key, e]) => [key, {
|
|
92
|
+
etag: e.etag,
|
|
93
|
+
size: BigInt(e.size),
|
|
94
|
+
sha256: e.sha256 === undefined ? none : some(e.sha256),
|
|
95
|
+
modified: e.modified === undefined ? none : some(e.modified),
|
|
96
|
+
}])),
|
|
78
97
|
value: state.value === null ? none : some(state.value),
|
|
79
98
|
under: state.under === null ? none : some(state.under),
|
|
99
|
+
commit: state.commit === null ? none : some(state.commit),
|
|
100
|
+
tried: state.tried === null ? none : some(state.tried),
|
|
101
|
+
taking: state.taking === null ? none : some({
|
|
102
|
+
key: state.taking.key,
|
|
103
|
+
etag: state.taking.etag,
|
|
104
|
+
sha256: state.taking.sha256 === null ? none : some(state.taking.sha256),
|
|
105
|
+
staging: state.taking.staging === null ? none : some(state.taking.staging),
|
|
106
|
+
}),
|
|
80
107
|
};
|
|
81
108
|
}
|
|
82
109
|
/**
|
|
83
|
-
* What
|
|
110
|
+
* What a target took, from what a store keeps.
|
|
84
111
|
*
|
|
85
112
|
* @param value - Its East value
|
|
86
113
|
* @returns What it took
|
|
87
114
|
*/
|
|
88
115
|
export function ledgerStateOf(value) {
|
|
89
116
|
return {
|
|
90
|
-
entries: new Map([...value.entries].map(([key, e]) => [key, {
|
|
117
|
+
entries: new Map([...value.entries].map(([key, e]) => [key, {
|
|
118
|
+
etag: e.etag,
|
|
119
|
+
size: Number(e.size),
|
|
120
|
+
...(e.sha256.type === 'some' && { sha256: e.sha256.value }),
|
|
121
|
+
...(e.modified.type === 'some' && { modified: e.modified.value }),
|
|
122
|
+
}])),
|
|
91
123
|
value: value.value.type === 'some' ? value.value.value : null,
|
|
92
124
|
under: value.under.type === 'some' ? value.under.value : null,
|
|
125
|
+
commit: value.commit.type === 'some' ? value.commit.value : null,
|
|
126
|
+
tried: value.tried.type === 'some' ? value.tried.value : null,
|
|
127
|
+
taking: value.taking.type === 'some' ? {
|
|
128
|
+
key: value.taking.value.key,
|
|
129
|
+
etag: value.taking.value.etag,
|
|
130
|
+
sha256: value.taking.value.sha256.type === 'some' ? value.taking.value.sha256.value : null,
|
|
131
|
+
staging: value.taking.value.staging.type === 'some' ? value.taking.value.staging.value : null,
|
|
132
|
+
} : null,
|
|
93
133
|
};
|
|
94
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* The key a target's ledger is kept under: an input's name, as the first
|
|
137
|
+
* version kept it; a record's under `records/`; a mutation's under
|
|
138
|
+
* `mutations/{record}/{mutation}` (two segments: a name may hold a dot).
|
|
139
|
+
*
|
|
140
|
+
* @param target - The target
|
|
141
|
+
* @returns Its ledger's key
|
|
142
|
+
*/
|
|
143
|
+
export function ledgerKeyOf(target) {
|
|
144
|
+
switch (target.type) {
|
|
145
|
+
case 'input': return target.value;
|
|
146
|
+
case 'record': return `records/${target.value}`;
|
|
147
|
+
case 'mutation': return `mutations/${target.value.record}/${target.value.mutation}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
95
150
|
const printUnder = printFor(StructType({ url: StringType, shape: FilesShapeType }));
|
|
151
|
+
const printUnderV1 = printFor(StructType({ url: StringType, shape: FilesShapeTypeV1 }));
|
|
152
|
+
/** A shape as the first version printed it, when it names no type; null when it does. */
|
|
153
|
+
function untypedShape(shape) {
|
|
154
|
+
switch (shape.type) {
|
|
155
|
+
case 'file': return shape.value.type.type === 'none' ? variant('file', { key: shape.value.key }) : null;
|
|
156
|
+
case 'latest': return shape.value.type.type === 'none' ? variant('latest', { prefix: shape.value.prefix, include: shape.value.include }) : null;
|
|
157
|
+
case 'folder': return variant('folder', shape.value);
|
|
158
|
+
case 'listing': return variant('listing', shape.value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
96
161
|
/**
|
|
97
|
-
* What
|
|
162
|
+
* What a target's files are taken under: its mapping's shape and where its
|
|
98
163
|
* source is bound, as one text. A ledger taken under another is not trusted:
|
|
99
|
-
* its keys may name other files, and its paths another folder's.
|
|
164
|
+
* its keys may name other files, and its paths another folder's. A shape
|
|
165
|
+
* that names no type prints as the first version printed it, so a ledger it
|
|
166
|
+
* wrote stays trusted.
|
|
100
167
|
*
|
|
101
168
|
* @param mapping - The mapping
|
|
102
169
|
* @param binding - Its source's binding
|
|
103
170
|
* @returns The text
|
|
104
171
|
*/
|
|
105
172
|
export function takenUnder(mapping, binding) {
|
|
106
|
-
|
|
173
|
+
const untyped = untypedShape(mapping.shape);
|
|
174
|
+
return untyped === null ? printUnder({ url: binding.url, shape: mapping.shape }) : printUnderV1({ url: binding.url, shape: untyped });
|
|
107
175
|
}
|
|
108
176
|
/** The most bytes of a folder's files a sync holds before it writes them, when not told: 256 MiB. */
|
|
109
177
|
export const FOLDER_BATCH_BYTES = 256 * 1024 * 1024;
|
|
110
178
|
/** The most files of a folder a sync writes at once. */
|
|
111
179
|
const FOLDER_BATCH_FILES = 4096;
|
|
180
|
+
/** The head of a typed file read first for its type: 64 KiB, then wider, to at most 16 MiB. */
|
|
181
|
+
const HEAD_PROBE_BYTES = 64 * 1024;
|
|
182
|
+
const HEAD_MAX_BYTES = 16 * 1024 * 1024;
|
|
112
183
|
/** Thrown when a sync cannot start, naming every problem. */
|
|
113
184
|
export class FilesSyncError extends Error {
|
|
114
185
|
problems;
|
|
@@ -126,11 +197,24 @@ function stopping(sync) {
|
|
|
126
197
|
}
|
|
127
198
|
/** A tally of nothing. */
|
|
128
199
|
function emptyTally() {
|
|
129
|
-
return { added: 0n, changed: 0n, removed: 0n, unchanged: 0n, refused: [], bytes: 0n, updated: false };
|
|
200
|
+
return { added: 0n, changed: 0n, removed: 0n, unchanged: 0n, refused: [], bytes: 0n, updated: false, taken: null, commit: null, kept: null, waiting: null };
|
|
130
201
|
}
|
|
131
202
|
/** A mapping's report, from its tally. */
|
|
132
203
|
function reportOf(mapping, tally) {
|
|
133
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
target: mapping.target,
|
|
206
|
+
added: tally.added,
|
|
207
|
+
changed: tally.changed,
|
|
208
|
+
removed: tally.removed,
|
|
209
|
+
unchanged: tally.unchanged,
|
|
210
|
+
refused: tally.refused,
|
|
211
|
+
bytes: tally.bytes,
|
|
212
|
+
updated: tally.updated,
|
|
213
|
+
taken: tally.taken === null ? none : some(tally.taken),
|
|
214
|
+
commit: tally.commit === null ? none : some(tally.commit),
|
|
215
|
+
kept: tally.kept === null ? none : some(tally.kept),
|
|
216
|
+
waiting: tally.waiting === null ? none : some(tally.waiting),
|
|
217
|
+
};
|
|
134
218
|
}
|
|
135
219
|
/** A key's path relative to a prefix. */
|
|
136
220
|
function relative(prefix, key) {
|
|
@@ -141,8 +225,8 @@ function same(a, b) {
|
|
|
141
225
|
return a !== undefined && b !== undefined && a.etag === b.etag && a.size === b.size;
|
|
142
226
|
}
|
|
143
227
|
/** A file's ledger entry. */
|
|
144
|
-
function entryOf(object) {
|
|
145
|
-
return { etag: object.etag, size: object.size };
|
|
228
|
+
function entryOf(object, sha256) {
|
|
229
|
+
return { etag: object.etag, size: object.size, modified: object.modified, ...(sha256 !== undefined && { sha256 }) };
|
|
146
230
|
}
|
|
147
231
|
/** A mapping's binding; checked to exist before a sync starts. */
|
|
148
232
|
function bindingOf(mapping, options) {
|
|
@@ -156,15 +240,23 @@ function readerOf(sync, mapping) {
|
|
|
156
240
|
return reader;
|
|
157
241
|
}
|
|
158
242
|
/**
|
|
159
|
-
* What
|
|
160
|
-
*
|
|
243
|
+
* What a target took last, trusted only while it was taken under the mapping
|
|
244
|
+
* and binding as they are and, for an input, while the input holds the value
|
|
245
|
+
* the sync wrote.
|
|
161
246
|
*/
|
|
162
247
|
async function before(sync, mapping) {
|
|
163
|
-
const
|
|
248
|
+
const { target } = mapping;
|
|
249
|
+
const state = await sync.options.ledger.read(ledgerKeyOf(target));
|
|
164
250
|
const under = takenUnder(mapping, bindingOf(mapping, sync.options));
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
251
|
+
if (target.type === 'input') {
|
|
252
|
+
const current = await sync.options.writer.current(target.value);
|
|
253
|
+
if (state.value !== null && state.value === current && state.under === under)
|
|
254
|
+
return { state, entries: state.entries, trusted: true, stale: false, current, under };
|
|
255
|
+
return { state, entries: new Map(), trusted: false, stale: current !== null || state.value !== null, current, under };
|
|
256
|
+
}
|
|
257
|
+
if (state.under === under)
|
|
258
|
+
return { state, entries: state.entries, trusted: true, stale: false, current: null, under };
|
|
259
|
+
return { state, entries: new Map(), trusted: false, stale: false, current: null, under };
|
|
168
260
|
}
|
|
169
261
|
/** A listing's key among a round's: its source, its prefix and its depth. */
|
|
170
262
|
function listingKey(source, prefix, topFolderOnly) {
|
|
@@ -221,8 +313,116 @@ async function selection(sync, mapping, prefix, include) {
|
|
|
221
313
|
}
|
|
222
314
|
return files;
|
|
223
315
|
}
|
|
224
|
-
/**
|
|
225
|
-
|
|
316
|
+
/** A refusal of one file. */
|
|
317
|
+
function refusal(key, reason) {
|
|
318
|
+
return { key, reason };
|
|
319
|
+
}
|
|
320
|
+
/** The SHA-256 of bytes the sync holds, hex: a push finds a file it put unchanged by it. */
|
|
321
|
+
async function sha256Of(bytes) {
|
|
322
|
+
// A view over the bytes' own buffer, as the platform's digest takes one
|
|
323
|
+
const view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
324
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', view);
|
|
325
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
326
|
+
}
|
|
327
|
+
/** Asked for more of a file's head than was read. */
|
|
328
|
+
class NeedMore extends Error {
|
|
329
|
+
needed;
|
|
330
|
+
constructor(needed) {
|
|
331
|
+
super(`needs ${needed} bytes of the head`);
|
|
332
|
+
this.needed = needed;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The root type a beast2 file's header declares, read through its head:
|
|
337
|
+
* 64 KiB, then wider only for a wide type section, never the file. Refused
|
|
338
|
+
* as no beast2 when the header is malformed, or wider than any type section
|
|
339
|
+
* is. What the sync and a push's reading of a file on disk share.
|
|
340
|
+
*
|
|
341
|
+
* @param size - The file's size
|
|
342
|
+
* @param readHead - Reads the file's first bytes, as many as asked
|
|
343
|
+
* @returns The declared root type, or why the file is no beast2
|
|
344
|
+
*/
|
|
345
|
+
export async function beast2HeaderTypeOf(size, readHead) {
|
|
346
|
+
if (size === 0)
|
|
347
|
+
return { reason: variant('not_beast2', 'it is empty') };
|
|
348
|
+
let head = new Uint8Array(0);
|
|
349
|
+
let want = Math.min(size, HEAD_PROBE_BYTES);
|
|
350
|
+
for (;;) {
|
|
351
|
+
if (want > head.length)
|
|
352
|
+
head = await readHead(want);
|
|
353
|
+
try {
|
|
354
|
+
const type = readBeast2HeaderType({
|
|
355
|
+
size,
|
|
356
|
+
read(offset, length) {
|
|
357
|
+
if (offset + length > head.length)
|
|
358
|
+
throw new NeedMore(offset + length);
|
|
359
|
+
return head.subarray(offset, offset + length);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
return { type };
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
const more = err instanceof NeedMore ? err : (err instanceof Error && err.cause instanceof NeedMore ? err.cause : null);
|
|
366
|
+
if (more === null)
|
|
367
|
+
return { reason: variant('not_beast2', err instanceof Error ? err.message : String(err)) };
|
|
368
|
+
if (more.needed > size || more.needed > HEAD_MAX_BYTES)
|
|
369
|
+
return { reason: variant('not_beast2', 'its header is wider than any beast2 header') };
|
|
370
|
+
want = Math.min(size, Math.max(more.needed, head.length * 4));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** The root type a typed file's beast2 header declares, through a range of its head from the reader; a refusal names the file. */
|
|
375
|
+
async function headerType(reader, file) {
|
|
376
|
+
try {
|
|
377
|
+
return await beast2HeaderTypeOf(file.size, (bytes) => reader.readRange(file, 0, bytes));
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
if (err instanceof FilesUnreadableError)
|
|
381
|
+
return { reason: variant('unreadable', err.reason) };
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Whether a type is a collection's: an Array, Set or Dict root, which beast2
|
|
387
|
+
* stores in segments and e3 takes in by pieces, of any size.
|
|
388
|
+
*
|
|
389
|
+
* @param type - The type
|
|
390
|
+
* @returns Whether it is a collection
|
|
391
|
+
*/
|
|
392
|
+
export function isCollectionType(type) {
|
|
393
|
+
return type.type === 'Array' || type.type === 'Set' || type.type === 'Dict';
|
|
394
|
+
}
|
|
395
|
+
/** Whether two type values are the same type, as East compares types. */
|
|
396
|
+
function sameType(a, b) {
|
|
397
|
+
return isTypeValueEqual(a, b);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A typed file's header held to the mapping's type, and its size to the
|
|
401
|
+
* limit of a value that is no collection: the refusal, or null when it fits.
|
|
402
|
+
*/
|
|
403
|
+
async function typedRefusal(reader, file, type) {
|
|
404
|
+
if (!isCollectionType(type) && file.size > FILES_TYPED_VALUE_MAX_BYTES) {
|
|
405
|
+
return refusal(file.key, variant('too_large', { size: BigInt(file.size), limit: BigInt(FILES_TYPED_VALUE_MAX_BYTES) }));
|
|
406
|
+
}
|
|
407
|
+
const header = await headerType(reader, file);
|
|
408
|
+
if ('reason' in header)
|
|
409
|
+
return refusal(file.key, header.reason);
|
|
410
|
+
if (!sameType(header.type, type))
|
|
411
|
+
return refusal(file.key, variant('type_mismatch', { expected: type, actual: header.type }));
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
/** The chosen file of a `file` or `latest` mapping, or why there is none. */
|
|
415
|
+
async function chooseOne(sync, mapping, shape) {
|
|
416
|
+
if (shape.type === 'file') {
|
|
417
|
+
return { chosen: await readerOf(sync, mapping).stat(shape.value.key), missing: refusal(shape.value.key, variant('missing', 'no such file')) };
|
|
418
|
+
}
|
|
419
|
+
const { prefix } = shape.value;
|
|
420
|
+
const files = await selection(sync, mapping, prefix, shape.value.include.type === 'some' ? shape.value.include.value : null);
|
|
421
|
+
const greatest = [...files.keys()].sort().at(-1);
|
|
422
|
+
return { chosen: greatest === undefined ? null : files.get(greatest), missing: refusal(prefix, variant('missing', 'no file under it matches')) };
|
|
423
|
+
}
|
|
424
|
+
/** Syncs a `file` or `latest` input mapping with no type to the one file it selects, as a `Blob`, or none. */
|
|
425
|
+
async function syncOne(sync, mapping, input, chosen, missing) {
|
|
226
426
|
const { options } = sync;
|
|
227
427
|
const report = emptyTally();
|
|
228
428
|
if (chosen === null) {
|
|
@@ -235,7 +435,7 @@ async function syncOne(sync, mapping, chosen, missing) {
|
|
|
235
435
|
return reportOf(mapping, report);
|
|
236
436
|
}
|
|
237
437
|
if (chosen.size > FILES_FILE_MAX_BYTES) {
|
|
238
|
-
report.refused.push(
|
|
438
|
+
report.refused.push(refusal(chosen.key, variant('too_large', { size: BigInt(chosen.size), limit: BigInt(FILES_FILE_MAX_BYTES) })));
|
|
239
439
|
return reportOf(mapping, report);
|
|
240
440
|
}
|
|
241
441
|
const taken = () => {
|
|
@@ -258,15 +458,279 @@ async function syncOne(sync, mapping, chosen, missing) {
|
|
|
258
458
|
if (!(err instanceof FilesUnreadableError))
|
|
259
459
|
throw err;
|
|
260
460
|
// The input keeps what it holds, and the next sync tries the file again
|
|
261
|
-
report.refused.push(
|
|
461
|
+
report.refused.push(refusal(chosen.key, variant('unreadable', err.reason)));
|
|
262
462
|
return reportOf(mapping, report);
|
|
263
463
|
}
|
|
264
|
-
const value = await options.writer.setFile(
|
|
265
|
-
await options.ledger.write(mapping.
|
|
464
|
+
const value = await options.writer.setFile(input, bytes);
|
|
465
|
+
await options.ledger.write(ledgerKeyOf(mapping.target), { ...NO_LEDGER, entries: new Map([[chosen.key, entryOf(chosen, await sha256Of(bytes))]]), value, under: prior.under });
|
|
266
466
|
taken();
|
|
267
467
|
sync.progressed = true;
|
|
268
468
|
return reportOf(mapping, report);
|
|
269
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Syncs a typed `file` or `latest` input mapping: the file's header read
|
|
472
|
+
* first and held to the mapping's type, then the file taken in by the writer
|
|
473
|
+
* from a stream of its bytes, which may stop at the round's end and resume
|
|
474
|
+
* in the next (the same file alone: by its key and ETag). The same bytes
|
|
475
|
+
* under a new ETag are taken through the store's memo: the input's value is
|
|
476
|
+
* as it was, and nothing downstream moves.
|
|
477
|
+
*/
|
|
478
|
+
async function syncTyped(sync, mapping, input, type, chosen, missing) {
|
|
479
|
+
const { options } = sync;
|
|
480
|
+
const report = emptyTally();
|
|
481
|
+
const prior = await before(sync, mapping);
|
|
482
|
+
const key = ledgerKeyOf(mapping.target);
|
|
483
|
+
// An intake left under way is resumed only for the same file: its key and
|
|
484
|
+
// ETag; one left for a file since replaced or gone is forgotten
|
|
485
|
+
const taking = prior.state.taking;
|
|
486
|
+
const resuming = taking !== null && chosen !== null && taking.key === chosen.key && taking.etag === chosen.etag ? taking : null;
|
|
487
|
+
const forget = async () => {
|
|
488
|
+
if (taking !== null && resuming === null)
|
|
489
|
+
await options.ledger.write(key, { ...prior.state, taking: null });
|
|
490
|
+
};
|
|
491
|
+
if (chosen === null) {
|
|
492
|
+
report.refused.push(missing);
|
|
493
|
+
await forget();
|
|
494
|
+
return reportOf(mapping, report);
|
|
495
|
+
}
|
|
496
|
+
const previous = prior.entries.get(chosen.key);
|
|
497
|
+
if (same(previous, entryOf(chosen)) && resuming === null) {
|
|
498
|
+
report.unchanged = 1n;
|
|
499
|
+
await forget();
|
|
500
|
+
return reportOf(mapping, report);
|
|
501
|
+
}
|
|
502
|
+
const counted = () => {
|
|
503
|
+
if (!prior.trusted && !prior.stale)
|
|
504
|
+
report.added = 1n;
|
|
505
|
+
else
|
|
506
|
+
report.changed = 1n;
|
|
507
|
+
report.bytes = BigInt(chosen.size);
|
|
508
|
+
};
|
|
509
|
+
if (options.dryRun === true) {
|
|
510
|
+
counted();
|
|
511
|
+
report.updated = true;
|
|
512
|
+
return reportOf(mapping, report);
|
|
513
|
+
}
|
|
514
|
+
const reader = readerOf(sync, mapping);
|
|
515
|
+
// The header first: a file of another type, or no beast2, costs no other byte
|
|
516
|
+
const refused = await typedRefusal(reader, chosen, type);
|
|
517
|
+
if (refused !== null) {
|
|
518
|
+
report.refused.push(refused);
|
|
519
|
+
if (taking !== null)
|
|
520
|
+
await options.ledger.write(key, { ...prior.state, taking: null });
|
|
521
|
+
return reportOf(mapping, report);
|
|
522
|
+
}
|
|
523
|
+
const result = await options.writer.takeTyped(input, chosen, type, reader, {
|
|
524
|
+
...(resuming !== null && { resume: resuming }),
|
|
525
|
+
...(options.signal !== undefined && { signal: options.signal }),
|
|
526
|
+
});
|
|
527
|
+
switch (result.status) {
|
|
528
|
+
case 'refused':
|
|
529
|
+
report.refused.push(refusal(chosen.key, result.reason));
|
|
530
|
+
// A resumed intake refused: nothing to resume any more
|
|
531
|
+
if (taking !== null)
|
|
532
|
+
await options.ledger.write(key, { ...prior.state, taking: null });
|
|
533
|
+
return reportOf(mapping, report);
|
|
534
|
+
case 'yielded':
|
|
535
|
+
// Remembered where it stopped, under the ledger as it was, so the next
|
|
536
|
+
// round resumes it; stopped before anything was kept, the next starts again
|
|
537
|
+
if (result.taking !== null)
|
|
538
|
+
await options.ledger.write(key, { ...prior.state, under: prior.state.under ?? prior.under, taking: result.taking });
|
|
539
|
+
else if (taking !== null)
|
|
540
|
+
await options.ledger.write(key, { ...prior.state, taking: null });
|
|
541
|
+
sync.progressed = true;
|
|
542
|
+
sync.yielded = true;
|
|
543
|
+
return reportOf(mapping, report);
|
|
544
|
+
case 'taken': {
|
|
545
|
+
counted();
|
|
546
|
+
report.updated = prior.current !== result.hash;
|
|
547
|
+
report.taken = result.taken;
|
|
548
|
+
await options.ledger.write(key, { ...NO_LEDGER, entries: new Map([[chosen.key, entryOf(chosen, result.sha256)]]), value: result.hash, under: prior.under });
|
|
549
|
+
sync.progressed = true;
|
|
550
|
+
return reportOf(mapping, report);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** How many mutations a record has taken since its seed, in words. */
|
|
555
|
+
function mutatedWords(sinceSeed) {
|
|
556
|
+
if (sinceSeed === null)
|
|
557
|
+
return 'the record has a state Files did not seed';
|
|
558
|
+
return `${sinceSeed} ${sinceSeed === 1 ? 'mutation' : 'mutations'} since the seed`;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Syncs a record's mapping: its file is the record's first state while the
|
|
562
|
+
* record is pending; its state again while nothing has mutated it since its
|
|
563
|
+
* seed, or when the sync is asked to reset it; kept otherwise.
|
|
564
|
+
*/
|
|
565
|
+
async function syncSeed(sync, mapping, record, type, chosen, missing) {
|
|
566
|
+
const { options } = sync;
|
|
567
|
+
const report = emptyTally();
|
|
568
|
+
const { writer } = options;
|
|
569
|
+
if (writer.recordState === undefined || writer.seed === undefined) {
|
|
570
|
+
report.refused.push(refusal(chosen?.key ?? '', variant('other', 'records cannot be seeded here')));
|
|
571
|
+
return reportOf(mapping, report);
|
|
572
|
+
}
|
|
573
|
+
if (chosen === null) {
|
|
574
|
+
report.refused.push(missing);
|
|
575
|
+
return reportOf(mapping, report);
|
|
576
|
+
}
|
|
577
|
+
const prior = await before(sync, mapping);
|
|
578
|
+
const previous = prior.entries.get(chosen.key);
|
|
579
|
+
const state = await writer.recordState(record);
|
|
580
|
+
const unchanged = same(previous, entryOf(chosen));
|
|
581
|
+
// A record holding the seed the ledger made: its file as it was is nothing new
|
|
582
|
+
if (!state.pending && unchanged && prior.state.commit === state.head) {
|
|
583
|
+
report.unchanged = 1n;
|
|
584
|
+
return reportOf(mapping, report);
|
|
585
|
+
}
|
|
586
|
+
const reset = (options.reset ?? []).includes(record);
|
|
587
|
+
if (!state.pending && !reset && (state.sinceSeed === null || state.sinceSeed > 0)) {
|
|
588
|
+
// Seeded, then mutated: the file waits for a reset
|
|
589
|
+
report.unchanged = unchanged ? 1n : 0n;
|
|
590
|
+
report.kept = mutatedWords(state.sinceSeed);
|
|
591
|
+
return reportOf(mapping, report);
|
|
592
|
+
}
|
|
593
|
+
if (!state.pending && unchanged && !reset) {
|
|
594
|
+
// The seed's file as it was, and the record at its seed: nothing to do
|
|
595
|
+
report.unchanged = 1n;
|
|
596
|
+
return reportOf(mapping, report);
|
|
597
|
+
}
|
|
598
|
+
const counted = () => {
|
|
599
|
+
if (previous === undefined)
|
|
600
|
+
report.added = 1n;
|
|
601
|
+
else
|
|
602
|
+
report.changed = 1n;
|
|
603
|
+
report.bytes = BigInt(chosen.size);
|
|
604
|
+
};
|
|
605
|
+
if (options.dryRun === true) {
|
|
606
|
+
counted();
|
|
607
|
+
report.updated = true;
|
|
608
|
+
report.commit = state.pending ? '$init' : '$seed';
|
|
609
|
+
return reportOf(mapping, report);
|
|
610
|
+
}
|
|
611
|
+
const reader = readerOf(sync, mapping);
|
|
612
|
+
const refused = await typedRefusal(reader, chosen, type);
|
|
613
|
+
if (refused !== null) {
|
|
614
|
+
report.refused.push(refused);
|
|
615
|
+
return reportOf(mapping, report);
|
|
616
|
+
}
|
|
617
|
+
const result = await writer.seed(record, chosen, type, reader, { genesis: state.pending, ...(options.signal !== undefined && { signal: options.signal }) });
|
|
618
|
+
if (result.status === 'refused') {
|
|
619
|
+
report.refused.push(refusal(chosen.key, result.reason));
|
|
620
|
+
return reportOf(mapping, report);
|
|
621
|
+
}
|
|
622
|
+
counted();
|
|
623
|
+
report.updated = true;
|
|
624
|
+
report.commit = result.commit;
|
|
625
|
+
report.taken = { key: chosen.key, type, rows: result.rows === null ? none : some(result.rows), how: variant('carried', null) };
|
|
626
|
+
await options.ledger.write(ledgerKeyOf(mapping.target), {
|
|
627
|
+
...NO_LEDGER, entries: new Map([[chosen.key, entryOf(chosen, result.sha256)]]), under: prior.under, commit: result.commit,
|
|
628
|
+
});
|
|
629
|
+
sync.progressed = true;
|
|
630
|
+
return reportOf(mapping, report);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Syncs a mutation's mapping: every new file it selects (one key, or every
|
|
634
|
+
* file under its prefix its glob matches, in key order) is applied once
|
|
635
|
+
* through the mutation, by the file's hash, and never again under another
|
|
636
|
+
* name; a file the mutation refused is not tried again until it changes,
|
|
637
|
+
* and the files after it wait; a record with no state yet makes the feed
|
|
638
|
+
* wait, before any file is read. The round may stop between files.
|
|
639
|
+
*/
|
|
640
|
+
async function syncFeed(sync, mapping, record, mutation, type, files, missing) {
|
|
641
|
+
const { options } = sync;
|
|
642
|
+
const report = emptyTally();
|
|
643
|
+
const { writer } = options;
|
|
644
|
+
if (writer.feed === undefined) {
|
|
645
|
+
report.refused.push(refusal(files[0]?.key ?? '', variant('other', 'mutations cannot be fed here')));
|
|
646
|
+
return reportOf(mapping, report);
|
|
647
|
+
}
|
|
648
|
+
if (missing !== null) {
|
|
649
|
+
report.refused.push(missing);
|
|
650
|
+
return reportOf(mapping, report);
|
|
651
|
+
}
|
|
652
|
+
const prior = await before(sync, mapping);
|
|
653
|
+
const entries = new Map(prior.entries);
|
|
654
|
+
const applied = new Set([...prior.entries.values()].flatMap((e) => (e.sha256 === undefined ? [] : [e.sha256])));
|
|
655
|
+
let commit = prior.state.commit;
|
|
656
|
+
const key = ledgerKeyOf(mapping.target);
|
|
657
|
+
const fresh = files.filter((file) => !same(prior.entries.get(file.key), entryOf(file)));
|
|
658
|
+
report.unchanged = BigInt(files.length - fresh.length);
|
|
659
|
+
if (fresh.length === 0) {
|
|
660
|
+
const last = files.at(-1);
|
|
661
|
+
if (last !== undefined && prior.state.tried !== null && prior.state.tried === prior.entries.get(last.key)?.sha256)
|
|
662
|
+
report.kept = 'the mutation refused it earlier: not tried again until the file changes';
|
|
663
|
+
return reportOf(mapping, report);
|
|
664
|
+
}
|
|
665
|
+
for (const file of fresh) {
|
|
666
|
+
if (file.size > FILES_TYPED_VALUE_MAX_BYTES) {
|
|
667
|
+
report.refused.push(refusal(file.key, variant('too_large', { size: BigInt(file.size), limit: BigInt(FILES_TYPED_VALUE_MAX_BYTES) })));
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (report.refused.length > 0)
|
|
672
|
+
return reportOf(mapping, report);
|
|
673
|
+
if (options.dryRun === true) {
|
|
674
|
+
for (const file of fresh) {
|
|
675
|
+
if (prior.entries.has(file.key))
|
|
676
|
+
report.changed += 1n;
|
|
677
|
+
else
|
|
678
|
+
report.added += 1n;
|
|
679
|
+
report.bytes += BigInt(file.size);
|
|
680
|
+
}
|
|
681
|
+
report.updated = true;
|
|
682
|
+
return reportOf(mapping, report);
|
|
683
|
+
}
|
|
684
|
+
// A record with no state yet: nothing is read for it
|
|
685
|
+
if (writer.recordState !== undefined && (await writer.recordState(record)).pending) {
|
|
686
|
+
report.waiting = 'the record has no state yet';
|
|
687
|
+
return reportOf(mapping, report);
|
|
688
|
+
}
|
|
689
|
+
const reader = readerOf(sync, mapping);
|
|
690
|
+
let first = true;
|
|
691
|
+
for (const file of fresh) {
|
|
692
|
+
if (!first && stopping(sync))
|
|
693
|
+
break;
|
|
694
|
+
first = false;
|
|
695
|
+
const refused = await typedRefusal(reader, file, type);
|
|
696
|
+
if (refused !== null) {
|
|
697
|
+
report.refused.push(refused);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
const result = await writer.feed(record, mutation, file, type, reader, { applied, ...(options.signal !== undefined && { signal: options.signal }) });
|
|
701
|
+
if (result.status === 'waiting') {
|
|
702
|
+
report.waiting = result.reason;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
if (result.status === 'refused') {
|
|
706
|
+
// Remembered as tried: the next sync tries it again only once the file changes
|
|
707
|
+
report.refused.push(refusal(file.key, result.reason));
|
|
708
|
+
entries.set(file.key, entryOf(file, result.sha256));
|
|
709
|
+
await options.ledger.write(key, { ...prior.state, entries: new Map(entries), under: prior.under, commit, tried: result.sha256 });
|
|
710
|
+
sync.progressed = true;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
entries.set(file.key, entryOf(file, result.sha256));
|
|
714
|
+
applied.add(result.sha256);
|
|
715
|
+
if (result.status === 'committed') {
|
|
716
|
+
commit = result.commit;
|
|
717
|
+
report.commit = result.commit;
|
|
718
|
+
report.updated = true;
|
|
719
|
+
if (prior.entries.has(file.key))
|
|
720
|
+
report.changed += 1n;
|
|
721
|
+
else
|
|
722
|
+
report.added += 1n;
|
|
723
|
+
report.bytes += BigInt(file.size);
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
// Its bytes applied before, under another name: remembered, nothing more
|
|
727
|
+
report.unchanged += 1n;
|
|
728
|
+
}
|
|
729
|
+
await options.ledger.write(key, { ...NO_LEDGER, entries: new Map(entries), under: prior.under, commit });
|
|
730
|
+
sync.progressed = true;
|
|
731
|
+
}
|
|
732
|
+
return reportOf(mapping, report);
|
|
733
|
+
}
|
|
270
734
|
/**
|
|
271
735
|
* Syncs a `folder` mapping: what is new or changed read and written, what is
|
|
272
736
|
* gone taken out. The files to read are taken in batches, each written and
|
|
@@ -276,7 +740,7 @@ async function syncOne(sync, mapping, chosen, missing) {
|
|
|
276
740
|
* cannot read is refused: what the input held of it stays, and so does what
|
|
277
741
|
* the ledger remembers of it, so the next sync tries it again.
|
|
278
742
|
*/
|
|
279
|
-
async function syncFolder(sync, mapping, prefix, files) {
|
|
743
|
+
async function syncFolder(sync, mapping, input, prefix, files) {
|
|
280
744
|
const { options } = sync;
|
|
281
745
|
const report = emptyTally();
|
|
282
746
|
const prior = await before(sync, mapping);
|
|
@@ -298,7 +762,7 @@ async function syncFolder(sync, mapping, prefix, files) {
|
|
|
298
762
|
continue;
|
|
299
763
|
}
|
|
300
764
|
if (object.size > FILES_FOLDER_ENTRY_MAX_BYTES) {
|
|
301
|
-
report.refused.push(
|
|
765
|
+
report.refused.push(refusal(object.key, variant('too_large', { size: BigInt(object.size), limit: BigInt(FILES_FOLDER_ENTRY_MAX_BYTES) })));
|
|
302
766
|
// What the input held of it, if anything, stays
|
|
303
767
|
if (previous !== undefined)
|
|
304
768
|
staying.add(object.key);
|
|
@@ -327,6 +791,7 @@ async function syncFolder(sync, mapping, prefix, files) {
|
|
|
327
791
|
const reader = readerOf(sync, mapping);
|
|
328
792
|
const entries = new Map(prior.entries);
|
|
329
793
|
const batches = batched(toRead, options.batchBytes ?? FOLDER_BATCH_BYTES);
|
|
794
|
+
const key = ledgerKeyOf(mapping.target);
|
|
330
795
|
let first = true;
|
|
331
796
|
for (const batch of batches.length === 0 ? [[]] : batches) {
|
|
332
797
|
if (!first && stopping(sync))
|
|
@@ -335,35 +800,36 @@ async function syncFolder(sync, mapping, prefix, files) {
|
|
|
335
800
|
const read = [];
|
|
336
801
|
for (const object of batch) {
|
|
337
802
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
803
|
+
const bytes = await reader.read(object);
|
|
804
|
+
set.set(relative(prefix, object.key), bytes);
|
|
805
|
+
read.push({ object, sha256: await sha256Of(bytes) });
|
|
340
806
|
}
|
|
341
807
|
catch (err) {
|
|
342
808
|
if (!(err instanceof FilesUnreadableError))
|
|
343
809
|
throw err;
|
|
344
|
-
report.refused.push(
|
|
810
|
+
report.refused.push(refusal(object.key, variant('unreadable', err.reason)));
|
|
345
811
|
}
|
|
346
812
|
}
|
|
347
|
-
const remove = first ? new Set(gone.map((
|
|
813
|
+
const remove = first ? new Set(gone.map((k) => relative(prefix, k))) : new Set();
|
|
348
814
|
const makes = first && fresh;
|
|
349
815
|
// A batch whose files were all refused changes nothing: no write
|
|
350
816
|
if (set.size > 0 || remove.size > 0 || makes) {
|
|
351
|
-
const value = await options.writer.updateFolder(
|
|
817
|
+
const value = await options.writer.updateFolder(input, { set, remove, fresh: makes });
|
|
352
818
|
if (first) {
|
|
353
|
-
for (const
|
|
354
|
-
entries.delete(
|
|
819
|
+
for (const k of gone)
|
|
820
|
+
entries.delete(k);
|
|
355
821
|
report.removed = BigInt(gone.length);
|
|
356
822
|
}
|
|
357
|
-
for (const object of read) {
|
|
823
|
+
for (const { object, sha256 } of read) {
|
|
358
824
|
if (prior.entries.has(object.key))
|
|
359
825
|
report.changed += 1n;
|
|
360
826
|
else
|
|
361
827
|
report.added += 1n;
|
|
362
828
|
report.bytes += BigInt(object.size);
|
|
363
|
-
entries.set(object.key, entryOf(object));
|
|
829
|
+
entries.set(object.key, entryOf(object, sha256));
|
|
364
830
|
}
|
|
365
831
|
report.updated = true;
|
|
366
|
-
await options.ledger.write(
|
|
832
|
+
await options.ledger.write(key, { ...NO_LEDGER, entries: new Map(entries), value, under: prior.under });
|
|
367
833
|
sync.progressed = true;
|
|
368
834
|
}
|
|
369
835
|
first = false;
|
|
@@ -371,7 +837,7 @@ async function syncFolder(sync, mapping, prefix, files) {
|
|
|
371
837
|
return reportOf(mapping, report);
|
|
372
838
|
}
|
|
373
839
|
/** Syncs a `listing` mapping: written whole whenever any file is new, changed or gone. */
|
|
374
|
-
async function syncListing(sync, mapping, files) {
|
|
840
|
+
async function syncListing(sync, mapping, input, files) {
|
|
375
841
|
const { options } = sync;
|
|
376
842
|
const report = emptyTally();
|
|
377
843
|
const prior = await before(sync, mapping);
|
|
@@ -394,33 +860,74 @@ async function syncListing(sync, mapping, files) {
|
|
|
394
860
|
report.updated = report.added > 0n || report.changed > 0n || report.removed > 0n || !prior.trusted;
|
|
395
861
|
if (options.dryRun === true || !report.updated)
|
|
396
862
|
return reportOf(mapping, report);
|
|
397
|
-
const value = await options.writer.setListing(
|
|
398
|
-
await options.ledger.write(mapping.
|
|
863
|
+
const value = await options.writer.setListing(input, rows);
|
|
864
|
+
await options.ledger.write(ledgerKeyOf(mapping.target), { ...NO_LEDGER, entries: after, value, under: prior.under });
|
|
399
865
|
sync.progressed = true;
|
|
400
866
|
return reportOf(mapping, report);
|
|
401
867
|
}
|
|
868
|
+
/** The mappings a sync takes: every one, or those of the targets named. */
|
|
869
|
+
function wantedMappings(options) {
|
|
870
|
+
const { targets } = options;
|
|
871
|
+
if (targets === undefined)
|
|
872
|
+
return [...options.manifest.mappings];
|
|
873
|
+
const declared = options.manifest.mappings;
|
|
874
|
+
const unknown = targets.filter((t) => !declared.some((m) => sameFilesTarget(m.target, t)));
|
|
875
|
+
if (unknown.length > 0) {
|
|
876
|
+
throw new Error(`No declaration fills ${unknown.map((t) => `'${filesTargetText(t)}'`).join(', ')}: the declarations fill ${declared.map((m) => filesTargetText(m.target)).join(', ') || 'nothing'}`);
|
|
877
|
+
}
|
|
878
|
+
return declared.filter((m) => targets.some((t) => sameFilesTarget(m.target, t)));
|
|
879
|
+
}
|
|
880
|
+
/** Syncs one mapping, by its target and shape. */
|
|
881
|
+
async function syncMapping(sync, mapping) {
|
|
882
|
+
const { target, shape } = mapping;
|
|
883
|
+
if (shape.type === 'folder' || shape.type === 'listing') {
|
|
884
|
+
// Held to inputs by the checks; a record's or mutation's never reaches here
|
|
885
|
+
const input = target.type === 'input' ? target.value : filesTargetText(target);
|
|
886
|
+
const { prefix } = shape.value;
|
|
887
|
+
const files = await selection(sync, mapping, prefix, shape.value.include.type === 'some' ? shape.value.include.value : null);
|
|
888
|
+
return shape.type === 'folder' ? syncFolder(sync, mapping, input, prefix, files) : syncListing(sync, mapping, input, files);
|
|
889
|
+
}
|
|
890
|
+
const type = shape.value.type.type === 'some' ? shape.value.type.value : null;
|
|
891
|
+
if (target.type === 'mutation') {
|
|
892
|
+
// Every file the shape selects, in key order: each new one is applied
|
|
893
|
+
if (shape.type === 'file') {
|
|
894
|
+
const chosen = await readerOf(sync, mapping).stat(shape.value.key);
|
|
895
|
+
return syncFeed(sync, mapping, target.value.record, target.value.mutation, type, chosen === null ? [] : [chosen], chosen === null ? refusal(shape.value.key, variant('missing', 'no such file')) : null);
|
|
896
|
+
}
|
|
897
|
+
const { prefix } = shape.value;
|
|
898
|
+
const files = await selection(sync, mapping, prefix, shape.value.include.type === 'some' ? shape.value.include.value : null);
|
|
899
|
+
const ordered = [...files.keys()].sort().map((path) => files.get(path));
|
|
900
|
+
return syncFeed(sync, mapping, target.value.record, target.value.mutation, type, ordered, ordered.length === 0 ? refusal(prefix, variant('missing', 'no file under it matches')) : null);
|
|
901
|
+
}
|
|
902
|
+
const { chosen, missing } = await chooseOne(sync, mapping, shape);
|
|
903
|
+
switch (target.type) {
|
|
904
|
+
case 'input':
|
|
905
|
+
return type === null ? syncOne(sync, mapping, target.value, chosen, missing) : syncTyped(sync, mapping, target.value, type, chosen, missing);
|
|
906
|
+
case 'record':
|
|
907
|
+
return syncSeed(sync, mapping, target.value, type, chosen, missing);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
402
910
|
/**
|
|
403
|
-
* Brings each mapping's
|
|
404
|
-
* when asked between
|
|
405
|
-
*
|
|
406
|
-
* next round takes.
|
|
911
|
+
* Brings each mapping's target up to date with its source's files, stopping
|
|
912
|
+
* when asked between targets, between a folder's batches, between a feed's
|
|
913
|
+
* files or within a typed intake: one round of a sync that hands over, as
|
|
914
|
+
* the platform's do. What it stopped before, the next round takes. Records
|
|
915
|
+
* are seeded before mutations are fed, so a record seeded and fed in one
|
|
916
|
+
* sync takes both, in that order.
|
|
407
917
|
*
|
|
408
918
|
* @param options - The declarations, the bindings, what reads, writes and remembers, and when to stop
|
|
409
|
-
* @returns What it did, per
|
|
919
|
+
* @returns What it did, per target it reached, and whether it stopped before the end
|
|
410
920
|
* @throws {FilesSyncError} When a mapping's source is not bound, before
|
|
411
921
|
* anything is read
|
|
412
|
-
* @throws {Error} When
|
|
922
|
+
* @throws {Error} When a target named is not one a declaration fills
|
|
413
923
|
*/
|
|
414
924
|
export async function syncFilesRound(options) {
|
|
415
925
|
const now = options.now ?? (() => new Date());
|
|
416
926
|
const started = now();
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
const wanted = options.inputs === undefined ? null : new Set(options.inputs);
|
|
423
|
-
const mappings = options.manifest.mappings.filter((m) => wanted === null || wanted.has(m.input));
|
|
927
|
+
const wanted = wantedMappings(options);
|
|
928
|
+
// Seeds before feeds: a feed on a record seeded in the same sync finds its state
|
|
929
|
+
const order = (m) => (m.target.type === 'mutation' ? 1 : 0);
|
|
930
|
+
const mappings = wanted.map((m, i) => ({ m, i })).sort((a, b) => order(a.m) - order(b.m) || a.i - b.i).map(({ m }) => m);
|
|
424
931
|
const bound = new Set(options.bindings.map((b) => b.source));
|
|
425
932
|
const problems = [...new Set(mappings.map((m) => m.source))]
|
|
426
933
|
.filter((source) => !bound.has(source))
|
|
@@ -441,39 +948,21 @@ export async function syncFilesRound(options) {
|
|
|
441
948
|
for (const mapping of mappings) {
|
|
442
949
|
if (stopping(sync))
|
|
443
950
|
break;
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
reports.push(await syncOne(sync, mapping, await readerOf(sync, mapping).stat(shape.value.key), missing));
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
const { prefix } = shape.value;
|
|
451
|
-
const files = await selection(sync, mapping, prefix, shape.value.include.type === 'some' ? shape.value.include.value : null);
|
|
452
|
-
switch (shape.type) {
|
|
453
|
-
case 'latest': {
|
|
454
|
-
const greatest = [...files.keys()].sort().at(-1);
|
|
455
|
-
const missing = { key: prefix, reason: 'no file under it matches' };
|
|
456
|
-
reports.push(await syncOne(sync, mapping, greatest === undefined ? null : files.get(greatest), missing));
|
|
457
|
-
break;
|
|
458
|
-
}
|
|
459
|
-
case 'folder':
|
|
460
|
-
reports.push(await syncFolder(sync, mapping, prefix, files));
|
|
461
|
-
break;
|
|
462
|
-
case 'listing':
|
|
463
|
-
reports.push(await syncListing(sync, mapping, files));
|
|
464
|
-
break;
|
|
465
|
-
}
|
|
951
|
+
reports.push(await syncMapping(sync, mapping));
|
|
952
|
+
if (sync.yielded)
|
|
953
|
+
break;
|
|
466
954
|
}
|
|
467
955
|
return { report: { started, finished: now(), dryRun: options.dryRun === true, mappings: reports }, yielded: sync.yielded };
|
|
468
956
|
}
|
|
469
957
|
/**
|
|
470
|
-
* Brings each mapping's
|
|
958
|
+
* Brings each mapping's target up to date with its source's files, to the
|
|
959
|
+
* end.
|
|
471
960
|
*
|
|
472
961
|
* @param options - The declarations, the bindings, and what reads, writes and remembers
|
|
473
962
|
* @returns What it did, per mapping
|
|
474
963
|
* @throws {FilesSyncError} When a mapping's source is not bound, before
|
|
475
964
|
* anything is read
|
|
476
|
-
* @throws {Error} When
|
|
965
|
+
* @throws {Error} When a target named is not one a declaration fills
|
|
477
966
|
*/
|
|
478
967
|
export async function syncFiles(options) {
|
|
479
968
|
return (await syncFilesRound(options)).report;
|