cursedbelt-core 2.13.2 β†’ 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/core/analytics/range.d.ts +2 -2
  2. package/dist/core/analytics/range.js +2 -2
  3. package/dist/core/analytics/schedule.d.ts +1 -1
  4. package/dist/core/analytics/schedule.js +3 -3
  5. package/dist/core/duplicates/index.d.ts +2 -2
  6. package/dist/core/duplicates/index.js +1 -1
  7. package/dist/core/file-tree/fileTreeModel.d.ts +3 -3
  8. package/dist/core/file-tree/fileTreeModel.js +2 -2
  9. package/dist/core/folder-tree/actions.d.ts +1 -1
  10. package/dist/core/folder-tree/actions.js +1 -1
  11. package/dist/core/master-lock/kdf.js +1 -1
  12. package/dist/core/master-lock/presence.d.ts +1 -1
  13. package/dist/core/master-lock/presence.js +1 -1
  14. package/dist/core/master-lock/wire.d.ts +1 -1
  15. package/dist/core/media/mediaItem.js +1 -1
  16. package/dist/core/media/types.d.ts +4 -4
  17. package/dist/core/media/videoCuts.d.ts +1 -1
  18. package/dist/core/media/videoCuts.js +2 -2
  19. package/dist/core/navigation/index.js +1 -1
  20. package/dist/core/navigation/navigationModel.d.ts +1 -1
  21. package/dist/core/navigation/navigationModel.js +1 -1
  22. package/dist/core/navigation/navigationSelectors.d.ts +2 -2
  23. package/dist/core/navigation/navigationSelectors.js +2 -2
  24. package/dist/core/navigation/searchCatalogue.d.ts +2 -2
  25. package/dist/core/navigation/searchCatalogue.js +4 -4
  26. package/dist/core/palette/fuzzyRank.d.ts +1 -1
  27. package/dist/core/palette/fuzzyRank.js +3 -3
  28. package/dist/core/tokens/registry.d.ts +1 -1
  29. package/dist/core/tokens/registry.js +1 -1
  30. package/dist/core/upload-precheck/contract.d.ts +71 -0
  31. package/dist/core/upload-precheck/contract.js +76 -0
  32. package/dist/core/upload-precheck/hash.d.ts +26 -0
  33. package/dist/core/upload-precheck/hash.js +46 -0
  34. package/dist/core/upload-precheck/index.d.ts +21 -0
  35. package/dist/core/upload-precheck/index.js +21 -0
  36. package/dist/core/upload-precheck/memo.d.ts +52 -0
  37. package/dist/core/upload-precheck/memo.js +160 -0
  38. package/dist/core/upload-precheck/plan.d.ts +112 -0
  39. package/dist/core/upload-precheck/plan.js +242 -0
  40. package/dist/core/upload-precheck/sha256.d.ts +27 -0
  41. package/dist/core/upload-precheck/sha256.js +124 -0
  42. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +2 -2
  43. package/dist/core/wire/index.d.ts +1 -1
  44. package/dist/core/wire/index.js +1 -1
  45. package/dist/core/wire/ingress.d.ts +4 -4
  46. package/dist/core/wire/ingress.js +12 -12
  47. package/dist/core/wire/marshalWorker.d.ts +1 -1
  48. package/dist/core/wire/marshalWorker.js +15 -15
  49. package/dist/core/wire/marshalling.d.ts +16 -16
  50. package/dist/core/wire/marshalling.js +13 -13
  51. package/dist/ctgrClient/convertRequest.js +2 -2
  52. package/dist/testing/bytes.d.ts +1 -1
  53. package/dist/testing/bytes.js +1 -1
  54. package/package.json +7 -1
  55. package/src/core/analytics/range.spec.ts +1 -1
  56. package/src/core/analytics/range.ts +2 -2
  57. package/src/core/analytics/schedule.spec.ts +3 -3
  58. package/src/core/analytics/schedule.ts +3 -3
  59. package/src/core/duplicates/index.ts +2 -2
  60. package/src/core/file-tree/fileTreeModel.ts +4 -4
  61. package/src/core/folder-tree/actions.ts +1 -1
  62. package/src/core/master-lock/kdf.ts +1 -1
  63. package/src/core/master-lock/presence.ts +1 -1
  64. package/src/core/master-lock/wire.ts +1 -1
  65. package/src/core/media/mediaItem.ts +1 -1
  66. package/src/core/media/types.ts +4 -4
  67. package/src/core/media/videoCuts.ts +2 -2
  68. package/src/core/navigation/index.ts +1 -1
  69. package/src/core/navigation/navigation.spec.ts +3 -3
  70. package/src/core/navigation/navigationModel.ts +2 -2
  71. package/src/core/navigation/navigationSelectors.ts +3 -3
  72. package/src/core/navigation/searchCatalogue.ts +4 -4
  73. package/src/core/palette/fuzzyRank.ts +3 -3
  74. package/src/core/tokens/registry.ts +2 -2
  75. package/src/core/upload-precheck/contract.spec.ts +67 -0
  76. package/src/core/upload-precheck/contract.ts +107 -0
  77. package/src/core/upload-precheck/hash.ts +57 -0
  78. package/src/core/upload-precheck/index.ts +46 -0
  79. package/src/core/upload-precheck/memo.ts +170 -0
  80. package/src/core/upload-precheck/plan.spec.ts +299 -0
  81. package/src/core/upload-precheck/plan.ts +315 -0
  82. package/src/core/upload-precheck/sha256.spec.ts +47 -0
  83. package/src/core/upload-precheck/sha256.ts +127 -0
  84. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +2 -2
  85. package/src/core/wire/index.ts +1 -1
  86. package/src/core/wire/ingress.spec.ts +12 -12
  87. package/src/core/wire/ingress.ts +15 -15
  88. package/src/core/wire/marshalWorker.spec.ts +7 -7
  89. package/src/core/wire/marshalWorker.ts +15 -15
  90. package/src/core/wire/marshalling.spec.ts +3 -3
  91. package/src/core/wire/marshalling.ts +25 -25
  92. package/src/ctgrClient/convertRequest.ts +2 -2
  93. package/src/namedSubpathsResolve.spec.ts +1 -1
  94. package/src/publishShape.spec.ts +1 -1
  95. package/src/shippedFilesAreTracked.spec.ts +2 -2
  96. package/src/testing/bytes.ts +1 -1
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The memory that makes a RE-IMPORT cheap: a file's hash, remembered by where it was, how big and
3
+ * how old β€” so a second drop of the same huge folder hashes nothing it already hashed.
4
+ *
5
+ * Collections' Mac sweeper keeps the same key for its linked folders (`apps/collections/scripts/ingestLinked.ts`:
6
+ * path, size, mtime) and has re-walked `tod` every sweep on it since forge task 3508. A file whose
7
+ * path, size AND modification time all match a remembered one is the same file unless something
8
+ * rewrote it and set its clock back; anything else misses, and is hashed again.
9
+ *
10
+ * A memo only ever saves work: a miss, a broken store or a private window means "hash it", never a
11
+ * wrong answer, because the hash it returns is still checked against the library.
12
+ */
13
+
14
+ /** The batch interface β€” one transaction per batch, not one per file. */
15
+ export interface HashMemo {
16
+ get(keys: readonly string[]): Promise<Map<string, string>>;
17
+ set(entries: readonly (readonly [key: string, sha256: string])[]): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * The key a file is remembered under. `path` is its path inside the drop when known, else its name.
22
+ *
23
+ * `identity` is what a Mac script can add for free from the `stat` it already took β€”
24
+ * {@link statIdentity}. It closes the one gap path+size+date leave: a file DELETED and replaced by a
25
+ * different one with the same name, the same size and the same modification time to the
26
+ * millisecond (`cp -p`, or a tool that copies dates). The replacement is a new inode, and any write
27
+ * moves the change time, which no tool can set back. A browser `File` exposes neither, so the
28
+ * browser's key is path+size+date alone β€” two different files agreeing on all three is the case
29
+ * left, and it takes a deliberately copied date.
30
+ */
31
+ export function memoKey(path: string, size: number, lastModified: number | undefined, identity?: string): string {
32
+ const key = `${path}\u0000${size}\u0000${lastModified ?? ""}`;
33
+ return identity ? `${key}\u0000${identity}` : key;
34
+ }
35
+
36
+ /**
37
+ * A file's identity beyond its path, size and date: its inode and its change time (`ctime`), both
38
+ * in the `stat` a script already has. Measured on the linked `tod` folder (APFS, 2026-10-22): files
39
+ * ingested that day still carried change times from months before β€” reading, hashing and filing a
40
+ * file never moves it β€” so this costs a re-hash only when something really touched the file.
41
+ */
42
+ export function statIdentity(stat: { ino: number | bigint; ctimeMs: number }): string {
43
+ return `${stat.ino}:${Math.trunc(stat.ctimeMs)}`;
44
+ }
45
+
46
+ /** A memo held in memory β€” the Mac scripts' and the specs'. */
47
+ export function memoryHashMemo(): HashMemo & { readonly size: number } {
48
+ const map = new Map<string, string>();
49
+ return {
50
+ get size() {
51
+ return map.size;
52
+ },
53
+ async get(keys) {
54
+ const out = new Map<string, string>();
55
+ for (const key of keys) {
56
+ const hit = map.get(key);
57
+ if (hit) out.set(key, hit);
58
+ }
59
+ return out;
60
+ },
61
+ async set(entries) {
62
+ for (const [key, sha] of entries) map.set(key, sha);
63
+ },
64
+ };
65
+ }
66
+
67
+ /** How many files a browser memo remembers before it forgets the oldest quarter. ~150 bytes each. */
68
+ export const BROWSER_MEMO_MAX_ENTRIES = 200_000;
69
+
70
+ const STORE = "hashes";
71
+
72
+ interface Row {
73
+ k: string;
74
+ sha: string;
75
+ at: number;
76
+ }
77
+
78
+ function request<T>(req: IDBRequest<T>): Promise<T> {
79
+ return new Promise((resolve, reject) => {
80
+ req.onsuccess = () => resolve(req.result);
81
+ req.onerror = () => reject(req.error);
82
+ });
83
+ }
84
+
85
+ function done(tx: IDBTransaction): Promise<void> {
86
+ return new Promise((resolve, reject) => {
87
+ tx.oncomplete = () => resolve();
88
+ tx.onerror = () => reject(tx.error);
89
+ tx.onabort = () => reject(tx.error);
90
+ });
91
+ }
92
+
93
+ /**
94
+ * A memo kept in this origin's IndexedDB, so it survives a reload and a re-import next week. Each
95
+ * app is its own origin, so each keeps its own. Where IndexedDB is missing or refuses (a private
96
+ * window, a test DOM), every call quietly answers "not remembered" β€” the upload still works, it
97
+ * just hashes.
98
+ */
99
+ export function indexedDbHashMemo(name = "upload-precheck", maxEntries = BROWSER_MEMO_MAX_ENTRIES): HashMemo {
100
+ let opened: Promise<IDBDatabase | null> | undefined;
101
+ const open = (): Promise<IDBDatabase | null> => {
102
+ opened ??= new Promise<IDBDatabase | null>((resolve) => {
103
+ try {
104
+ if (typeof indexedDB === "undefined") return resolve(null);
105
+ const req = indexedDB.open(name, 1);
106
+ req.onupgradeneeded = () => {
107
+ const store = req.result.createObjectStore(STORE, { keyPath: "k" });
108
+ store.createIndex("at", "at");
109
+ };
110
+ req.onsuccess = () => resolve(req.result);
111
+ req.onerror = () => resolve(null);
112
+ req.onblocked = () => resolve(null);
113
+ } catch {
114
+ resolve(null);
115
+ }
116
+ });
117
+ return opened;
118
+ };
119
+
120
+ const prune = async (db: IDBDatabase): Promise<void> => {
121
+ const tx = db.transaction(STORE, "readwrite");
122
+ const store = tx.objectStore(STORE);
123
+ const count = await request(store.count());
124
+ if (count > maxEntries) {
125
+ let drop = Math.ceil(count / 4);
126
+ const cursor = store.index("at").openCursor();
127
+ await new Promise<void>((resolve, reject) => {
128
+ cursor.onerror = () => reject(cursor.error);
129
+ cursor.onsuccess = () => {
130
+ const at = cursor.result;
131
+ if (!at || drop-- <= 0) return resolve();
132
+ at.delete();
133
+ at.continue();
134
+ };
135
+ });
136
+ }
137
+ await done(tx);
138
+ };
139
+
140
+ return {
141
+ async get(keys) {
142
+ const out = new Map<string, string>();
143
+ try {
144
+ const db = await open();
145
+ if (!db || keys.length === 0) return out;
146
+ const tx = db.transaction(STORE, "readonly");
147
+ const store = tx.objectStore(STORE);
148
+ const rows = await Promise.all(keys.map((key) => request(store.get(key) as IDBRequest<Row | undefined>)));
149
+ for (const row of rows) if (row) out.set(row.k, row.sha);
150
+ } catch {
151
+ // a refused read is a miss
152
+ }
153
+ return out;
154
+ },
155
+ async set(entries) {
156
+ try {
157
+ const db = await open();
158
+ if (!db || entries.length === 0) return;
159
+ const tx = db.transaction(STORE, "readwrite");
160
+ const store = tx.objectStore(STORE);
161
+ const at = Date.now();
162
+ for (const [k, sha] of entries) store.put({ k, sha, at } satisfies Row);
163
+ await done(tx);
164
+ await prune(db);
165
+ } catch {
166
+ // a refused write only costs the next re-import a hash
167
+ }
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The owner's case, proved: a SECOND copy of a folder is decided without sending a byte, and β€”
3
+ * with the memo β€” without reading one (forge task 3579).
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { PrecheckHit, PrecheckRequest, PrecheckResponse } from "./contract.js";
7
+ import { sha256OfBlob } from "./hash.js";
8
+ import { memoryHashMemo, statIdentity } from "./memo.js";
9
+ import { planUploads, type Verdict } from "./plan.js";
10
+
11
+ interface Dropped {
12
+ path: string;
13
+ blob: Blob;
14
+ lastModified: number;
15
+ }
16
+
17
+ const file = (path: string, text: string, lastModified = 1): Dropped => ({
18
+ path,
19
+ blob: new Blob([text]),
20
+ lastModified,
21
+ });
22
+
23
+ /** A library that holds what it is given, and counts what it is asked. */
24
+ function library() {
25
+ const bySha = new Map<string, PrecheckHit>();
26
+ const sizes = new Set<number>();
27
+ const asked: PrecheckRequest[] = [];
28
+ return {
29
+ asked,
30
+ async store(dropped: Dropped) {
31
+ bySha.set(await sha256OfBlob(dropped.blob), { reason: "exists", name: dropped.path });
32
+ sizes.add(dropped.blob.size);
33
+ },
34
+ async ask(request: PrecheckRequest): Promise<PrecheckResponse> {
35
+ asked.push(request);
36
+ const have: Record<string, PrecheckHit> = {};
37
+ for (const sha of request.sha256 ?? []) {
38
+ const hit = bySha.get(sha);
39
+ if (hit) have[sha] = hit;
40
+ }
41
+ return { sizes: (request.sizes ?? []).filter((s) => sizes.has(s)), have };
42
+ },
43
+ };
44
+ }
45
+
46
+ function counter() {
47
+ const hashed: string[] = [];
48
+ return {
49
+ hashed,
50
+ hash: async (blob: Blob) => {
51
+ hashed.push(await blob.text());
52
+ return sha256OfBlob(blob);
53
+ },
54
+ };
55
+ }
56
+
57
+ const folder = (): Dropped[] => [
58
+ file("trip/a.jpg", "alpha alpha"),
59
+ file("trip/b.jpg", "bravo bravo bravo"),
60
+ file("trip/c.mov", "charlie charlie charlie charlie"),
61
+ file("trip/d.mov", "delta"),
62
+ ];
63
+
64
+ async function importInto(lib: ReturnType<typeof library>, drop: Dropped[], extra: Partial<Parameters<typeof planUploads<Dropped>>[0]> = {}) {
65
+ const plan = await planUploads({
66
+ items: drop,
67
+ blobOf: (d) => d.blob,
68
+ pathOf: (d) => d.path,
69
+ lastModifiedOf: (d) => d.lastModified,
70
+ ask: (r) => lib.ask(r),
71
+ ...extra,
72
+ });
73
+ for (const { item } of plan.upload) await lib.store(item);
74
+ return plan;
75
+ }
76
+
77
+ describe("planUploads", () => {
78
+ test("a first import of new files hashes NOTHING β€” no size matches, so nothing could be a copy", async () => {
79
+ const lib = library();
80
+ const { hashed, hash } = counter();
81
+ const plan = await importInto(lib, folder(), { hash });
82
+ expect(plan.upload).toHaveLength(4);
83
+ expect(plan.stats.sizeOnly).toBe(4);
84
+ expect(hashed).toEqual([]);
85
+ expect(lib.asked).toHaveLength(1); // the one sizes question
86
+ });
87
+
88
+ test("πŸ”΄ the second copy of the folder sends nothing, and asks twice in all β€” sizes once, hashes once", async () => {
89
+ const lib = library();
90
+ await importInto(lib, folder());
91
+ lib.asked.length = 0;
92
+ const { hashed, hash } = counter();
93
+ const again = await importInto(lib, folder(), { hash });
94
+ expect(again.upload).toEqual([]);
95
+ expect(again.duplicates.map((d) => d.item.path).sort()).toEqual(["trip/a.jpg", "trip/b.jpg", "trip/c.mov", "trip/d.mov"]);
96
+ expect(again.stats.savedBytes).toBe(again.stats.bytes);
97
+ expect(hashed).toHaveLength(4);
98
+ // one sizes ask, then every hash in ONE request β€” they finished inside the linger, so they
99
+ // waited for each other rather than going one request per file
100
+ expect(lib.asked).toHaveLength(2);
101
+ expect(lib.asked[0]).toEqual({ sizes: expect.any(Array) });
102
+ });
103
+
104
+ test("πŸ”΄ with the memo, a re-import of the same folder reads NO bytes at all", async () => {
105
+ const lib = library();
106
+ const memo = memoryHashMemo();
107
+ await importInto(lib, folder(), { memo });
108
+ // the first import hashed nothing (all new sizes), so prime the memo the way a second
109
+ // browser session would have: one import that had to hash
110
+ await importInto(lib, folder(), { memo });
111
+ expect(memo.size).toBe(4);
112
+ const { hashed, hash } = counter();
113
+ const third = await importInto(lib, folder(), { memo, hash });
114
+ expect(third.upload).toEqual([]);
115
+ expect(third.stats.memoHits).toBe(4);
116
+ expect(hashed).toEqual([]);
117
+ });
118
+
119
+ test("a missed file in a huge re-import is the only one sent, and the only one not hashed", async () => {
120
+ const lib = library();
121
+ const big = Array.from({ length: 300 }, (_, i) => file(`dump/${i}.mp4`, `clip ${i}`.padEnd(40 + i, "x")));
122
+ await importInto(lib, big);
123
+ const missed = file("dump/new.mp4", "the one that was missed".padEnd(1000, "y"));
124
+ const { hashed, hash } = counter();
125
+ lib.asked.length = 0;
126
+ const plan = await importInto(lib, [...big, missed], { hash });
127
+ // 1 sizes question + 300 hashes in batches of at most 200 β€” a handful of requests, not 301
128
+ expect(lib.asked.length).toBeLessThanOrEqual(4);
129
+ expect(Math.max(...lib.asked.map((a) => a.sha256?.length ?? 0))).toBeLessThanOrEqual(200);
130
+ expect(plan.upload.map((u) => u.item.path)).toEqual(["dump/new.mp4"]);
131
+ expect(plan.upload[0].sha256).toBeUndefined();
132
+ expect(hashed).toHaveLength(300);
133
+ expect(plan.duplicates).toHaveLength(300);
134
+ });
135
+
136
+ test("same size, different bytes β€” hashed, and sent", async () => {
137
+ const lib = library();
138
+ await importInto(lib, [file("a.txt", "aaaa")]);
139
+ const plan = await importInto(lib, [file("b.txt", "bbbb")]);
140
+ expect(plan.upload).toHaveLength(1);
141
+ expect(plan.upload[0].sha256).toBe(await sha256OfBlob(new Blob(["bbbb"])));
142
+ });
143
+
144
+ test("the same bytes twice in one drop are sent once β€” the second is a repeat of the first", async () => {
145
+ const lib = library();
146
+ const plan = await importInto(lib, [file("one/x.jpg", "same bytes"), file("two/x copy.jpg", "same bytes")]);
147
+ expect(plan.upload).toHaveLength(1);
148
+ expect(plan.repeats).toHaveLength(1);
149
+ expect(plan.repeats[0].of).toBe(plan.upload[0].item);
150
+ });
151
+
152
+ test("a changed modification time is a memo miss β€” the file is hashed again", async () => {
153
+ const lib = library();
154
+ const memo = memoryHashMemo();
155
+ await importInto(lib, [file("a.txt", "aaaa", 1)]);
156
+ await importInto(lib, [file("a.txt", "aaaa", 1)], { memo });
157
+ const { hashed, hash } = counter();
158
+ await importInto(lib, [file("a.txt", "aaaa", 2)], { memo, hash });
159
+ expect(hashed).toEqual(["aaaa"]);
160
+ });
161
+
162
+ test("πŸ”΄ a folder sent again with files ADDED sends exactly the new ones", async () => {
163
+ const lib = library();
164
+ const memo = memoryHashMemo();
165
+ await importInto(lib, folder(), { memo });
166
+ await importInto(lib, folder(), { memo }); // the memo now holds all four
167
+ const grown = [...folder(), file("trip/e.jpg", "echo, added later"), file("trip/f.jpg", "foxtrot!")];
168
+ const plan = await importInto(lib, grown, { memo });
169
+ expect(plan.upload.map((u) => u.item.path).sort()).toEqual(["trip/e.jpg", "trip/f.jpg"]);
170
+ expect(plan.duplicates).toHaveLength(4);
171
+ });
172
+
173
+ test("πŸ”΄ image.png deleted and replaced by a different image.png is sent β€” the date moved", async () => {
174
+ const lib = library();
175
+ const memo = memoryHashMemo();
176
+ await importInto(lib, [file("image.png", "the old picture", 1000)]);
177
+ await importInto(lib, [file("image.png", "the old picture", 1000)], { memo });
178
+ const plan = await importInto(lib, [file("image.png", "the NEW picture", 2000)], { memo });
179
+ expect(plan.upload).toHaveLength(1);
180
+ expect(plan.stats.memoHits).toBe(0);
181
+ });
182
+
183
+ test("πŸ”΄ …and with a script's stat identity, even with the same size AND the same date", async () => {
184
+ const lib = library();
185
+ const memo = memoryHashMemo();
186
+ const old = { ...file("image.png", "the old picture", 1000), ino: 11 };
187
+ const replaced = { ...file("image.png", "the NEW picture", 1000), ino: 12 }; // same size, same date
188
+ const run = (d: typeof old) =>
189
+ importInto(lib, [d], { memo, identityOf: (x) => statIdentity({ ino: (x as typeof old).ino, ctimeMs: 5 }) });
190
+ await importInto(lib, [old]);
191
+ await run(old);
192
+ const plan = await run(replaced);
193
+ expect(plan.upload).toHaveLength(1);
194
+ expect(plan.stats.memoHits).toBe(0);
195
+ });
196
+
197
+ test("a pre-check that cannot be asked sends everything, hashes nothing, and says so", async () => {
198
+ const { hashed, hash } = counter();
199
+ const plan = await planUploads({
200
+ items: folder(),
201
+ blobOf: (d) => d.blob,
202
+ ask: async () => {
203
+ throw new Error("404");
204
+ },
205
+ hash,
206
+ });
207
+ expect(plan.upload).toHaveLength(4);
208
+ expect(hashed).toEqual([]);
209
+ expect(plan.stats.degraded).toContain("404");
210
+ });
211
+
212
+ test("a hash question that fails mid-drop sends the rest rather than losing them", async () => {
213
+ const lib = library();
214
+ await importInto(lib, folder());
215
+ let calls = 0;
216
+ const plan = await planUploads({
217
+ items: folder(),
218
+ blobOf: (d) => d.blob,
219
+ ask: async (r) => {
220
+ if (++calls > 1) throw new Error("503");
221
+ return lib.ask(r);
222
+ },
223
+ });
224
+ expect(plan.upload).toHaveLength(4);
225
+ expect(plan.stats.degraded).toContain("503");
226
+ });
227
+
228
+ test("verdicts stream β€” an upload can start before the last file is hashed", async () => {
229
+ const lib = library();
230
+ await importInto(lib, [file("old.bin", "x".repeat(100))]);
231
+ const order: string[] = [];
232
+ const slowHash = async (blob: Blob) => {
233
+ await new Promise((r) => setTimeout(r, blob.size === 100 ? 30 : 0));
234
+ return sha256OfBlob(blob);
235
+ };
236
+ await planUploads({
237
+ items: [file("old copy.bin", "x".repeat(100)), file("new.bin", "fresh")],
238
+ blobOf: (d) => d.blob,
239
+ ask: (r) => lib.ask(r),
240
+ hash: slowHash,
241
+ onVerdict: (v: Verdict<Dropped>) => order.push(`${v.kind}:${v.item.path}`),
242
+ });
243
+ expect(order).toEqual(["upload:new.bin", "duplicate:old copy.bin"]);
244
+ });
245
+
246
+ test("hashes in flight stay inside the memory budget", async () => {
247
+ const drop = Array.from({ length: 6 }, (_, i) => file(`f${i}`, String(i).repeat(10)));
248
+ let live = 0;
249
+ let peak = 0;
250
+ await planUploads({
251
+ items: drop,
252
+ blobOf: (d) => d.blob,
253
+ ask: async (r) => ({ sizes: r.sizes ?? [], have: {} }),
254
+ concurrency: 6,
255
+ maxInFlightBytes: 25,
256
+ hash: async (blob) => {
257
+ peak = Math.max(peak, ++live);
258
+ await new Promise((r) => setTimeout(r, 5));
259
+ live--;
260
+ return sha256OfBlob(blob);
261
+ },
262
+ });
263
+ expect(peak).toBe(2); // 10 + 10 bytes fit in 25; a third would not
264
+ });
265
+
266
+ test("a file that cannot be read is sent unhashed β€” it does not end the plan for the rest", async () => {
267
+ const lib = library();
268
+ await importInto(lib, folder());
269
+ const plan = await planUploads({
270
+ items: folder(),
271
+ blobOf: (d) => d.blob,
272
+ ask: (r) => lib.ask(r),
273
+ hash: async (blob) => {
274
+ if ((await blob.text()) === "delta") throw new Error("NotReadableError");
275
+ return sha256OfBlob(blob);
276
+ },
277
+ });
278
+ expect(plan.upload.map((u) => u.item.path)).toEqual(["trip/d.mov"]);
279
+ expect(plan.duplicates).toHaveLength(3);
280
+ expect(plan.stats.hashErrors).toBe(1);
281
+ });
282
+
283
+ test("an abort stops the hashing and rejects", async () => {
284
+ const controller = new AbortController();
285
+ const plan = planUploads({
286
+ items: [file("a", "same"), file("b", "same"), file("c", "same")],
287
+ blobOf: (d) => d.blob,
288
+ ask: async (r) => ({ sizes: r.sizes ?? [], have: {} }),
289
+ concurrency: 1,
290
+ signal: controller.signal,
291
+ hash: async (blob, { signal }) => {
292
+ controller.abort(new Error("stopped"));
293
+ if (signal?.aborted) throw signal.reason;
294
+ return sha256OfBlob(blob);
295
+ },
296
+ });
297
+ await expect(plan).rejects.toThrow("stopped");
298
+ });
299
+ });