@elaraai/e3-cloud-files 1.0.94 → 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.
Files changed (49) hide show
  1. package/README.md +129 -23
  2. package/dist/src/authoring.d.ts +61 -24
  3. package/dist/src/authoring.d.ts.map +1 -1
  4. package/dist/src/authoring.js +92 -48
  5. package/dist/src/authoring.js.map +1 -1
  6. package/dist/src/check/index.d.ts +3 -4
  7. package/dist/src/check/index.d.ts.map +1 -1
  8. package/dist/src/check/index.js +3 -4
  9. package/dist/src/check/index.js.map +1 -1
  10. package/dist/src/check.d.ts +44 -9
  11. package/dist/src/check.d.ts.map +1 -1
  12. package/dist/src/check.js +130 -53
  13. package/dist/src/check.js.map +1 -1
  14. package/dist/src/compat.d.ts +384 -0
  15. package/dist/src/compat.d.ts.map +1 -0
  16. package/dist/src/compat.js +258 -0
  17. package/dist/src/compat.js.map +1 -0
  18. package/dist/src/index.d.ts +12 -9
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +12 -9
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/s3/index.d.ts.map +1 -1
  23. package/dist/src/s3/index.js +34 -12
  24. package/dist/src/s3/index.js.map +1 -1
  25. package/dist/src/storage/index.d.ts +72 -3
  26. package/dist/src/storage/index.d.ts.map +1 -1
  27. package/dist/src/storage/index.js +258 -13
  28. package/dist/src/storage/index.js.map +1 -1
  29. package/dist/src/sync/engine.d.ts +282 -49
  30. package/dist/src/sync/engine.d.ts.map +1 -1
  31. package/dist/src/sync/engine.js +599 -110
  32. package/dist/src/sync/engine.js.map +1 -1
  33. package/dist/src/sync/index.d.ts +3 -2
  34. package/dist/src/sync/index.d.ts.map +1 -1
  35. package/dist/src/sync/index.js +3 -2
  36. package/dist/src/sync/index.js.map +1 -1
  37. package/dist/src/sync/memory.d.ts +67 -12
  38. package/dist/src/sync/memory.d.ts.map +1 -1
  39. package/dist/src/sync/memory.js +248 -18
  40. package/dist/src/sync/memory.js.map +1 -1
  41. package/dist/src/sync/push.d.ts +56 -0
  42. package/dist/src/sync/push.d.ts.map +1 -0
  43. package/dist/src/sync/push.js +205 -0
  44. package/dist/src/sync/push.js.map +1 -0
  45. package/dist/test/typed.examples.d.ts +49 -0
  46. package/dist/test/typed.examples.d.ts.map +1 -0
  47. package/dist/test/typed.examples.js +58 -0
  48. package/dist/test/typed.examples.js.map +1 -0
  49. package/package.json +10 -6
@@ -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 what remembers the
10
- * files each input took are given to it ({@link FilesReader},
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 input
14
- * took last (its ETag and size), read what is new or changed, write the input,
15
- * and remember what it took and the value it wrote. A file the same as before
16
- * is never read again, so a sync with nothing new reads nothing; an input
17
- * whose files are as they were is not written. A file over its shape's limit
18
- * is refused, named in the report, and never truncated; so is a file its
19
- * reader cannot read (archived, changed since it was listed, or closed to the
20
- * reader's access: {@link FilesUnreadableError}), the sync going on with the
21
- * rest, and the next sync trying it again. An input is remembered only once
22
- * it is written, so a sync that stops part way takes the rest next time.
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 inputs and between a folder's batches
28
- * when asked ({@link syncFilesRound}, a cloud round handing over), and the
29
- * next takes the rest.
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 an input took is trusted only while the input still holds the value
32
- * the sync wrote, and its mapping and its source's binding are those it was
33
- * taken under ({@link LedgerState}'s `under`): a deploy that resets the
34
- * workspace's inputs, anyone setting one by hand, a changed declaration or a
35
- * source bound elsewhere, and the next sync takes all of its files again, even
36
- * when a sync under the old declarations wrote its ledger after the change.
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
- * A file a reader cannot read, which a sync refuses and names in its report,
48
- * going on with the rest; the next sync tries it again. A reader throws it
49
- * for what is the file's own, never for its source as a whole: a file
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 an input took before its first sync: nothing. */
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 an input took, as a store keeps it (e3-cloud-types' `FilesLedgerType`).
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, { etag: e.etag, size: BigInt(e.size) }])),
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 an input took, from what a store keeps.
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, { etag: e.etag, size: Number(e.size) }])),
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 an input's files are taken under: its mapping's shape and where its
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
- return printUnder({ url: binding.url, shape: mapping.shape });
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 { input: mapping.input, ...tally };
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 an input took last, trusted only while it holds the value the sync
160
- * wrote and was taken under the mapping and binding as they are.
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 [state, current] = await Promise.all([sync.options.ledger.read(mapping.input), sync.options.writer.current(mapping.input)]);
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 (state.value !== null && state.value === current && state.under === under)
166
- return { entries: state.entries, trusted: true, stale: false, under };
167
- return { entries: new Map(), trusted: false, stale: current !== null || state.value !== null, under };
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
- /** Syncs a `file` or `latest` mapping to the one file it selects, or none. */
225
- async function syncOne(sync, mapping, chosen, missing) {
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({ key: chosen.key, reason: `is ${chosen.size} bytes, over the ${FILES_FILE_MAX_BYTES} a file input takes` });
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({ key: chosen.key, reason: err.reason });
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(mapping.input, bytes);
265
- await options.ledger.write(mapping.input, { entries: new Map([[chosen.key, entryOf(chosen)]]), value, under: prior.under });
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({ key: object.key, reason: `is ${object.size} bytes, over the ${FILES_FOLDER_ENTRY_MAX_BYTES} a folder's file may be` });
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
- set.set(relative(prefix, object.key), await reader.read(object));
339
- read.push(object);
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({ key: object.key, reason: err.reason });
810
+ report.refused.push(refusal(object.key, variant('unreadable', err.reason)));
345
811
  }
346
812
  }
347
- const remove = first ? new Set(gone.map((key) => relative(prefix, key))) : new Set();
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(mapping.input, { set, remove, fresh: makes });
817
+ const value = await options.writer.updateFolder(input, { set, remove, fresh: makes });
352
818
  if (first) {
353
- for (const key of gone)
354
- entries.delete(key);
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(mapping.input, { entries: new Map(entries), value, under: prior.under });
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(mapping.input, rows);
398
- await options.ledger.write(mapping.input, { entries: after, value, under: prior.under });
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 input up to date with its source's files, stopping
404
- * when asked between inputs or between a folder's batches: one round of a
405
- * sync that hands over, as the platform's do. What it stopped before, the
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 input it reached, and whether it stopped before the end
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 an input named is not one a declaration fills
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 declared = new Set(options.manifest.mappings.map((m) => m.input));
418
- const unknown = (options.inputs ?? []).filter((input) => !declared.has(input));
419
- if (unknown.length > 0) {
420
- throw new Error(`No declaration fills ${unknown.map((i) => `'${i}'`).join(', ')}: the inputs files fill are ${[...declared].join(', ') || 'none'}`);
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
- const shape = mapping.shape;
445
- if (shape.type === 'file') {
446
- const missing = { key: shape.value.key, reason: 'no such file' };
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 input up to date with its source's files, to the end.
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 an input named is not one a declaration fills
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;