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,46 @@
1
+ import { Sha256 } from "./sha256.js";
2
+ /**
3
+ * The largest file hashed in ONE native `crypto.subtle.digest` call — read whole into memory.
4
+ *
5
+ * Native hashing ran 1,960 MiB/s on this Mac against 184 MiB/s for the incremental one, so a
6
+ * file is read whole whenever that is safe. 256 MiB is far under what a phone tab survives, and
7
+ * covers every photo, every song and most clips; past it the incremental hasher streams in
8
+ * {@link STREAM_SLICE_BYTES} slices at roughly the speed an external drive reads anyway.
9
+ */
10
+ export const NATIVE_HASH_MAX_BYTES = 256 * 1024 * 1024;
11
+ /**
12
+ * One read while streaming. Small enough that the incremental hasher returns to the event loop
13
+ * every ~10 ms (a 2 MiB slice at ~190 MiB/s), so a tab hashing an 8 GB disc stays responsive.
14
+ */
15
+ export const STREAM_SLICE_BYTES = 2 * 1024 * 1024;
16
+ function hex(digest) {
17
+ const view = new Uint8Array(digest);
18
+ let out = "";
19
+ for (let i = 0; i < view.length; i++)
20
+ out += view[i].toString(16).padStart(2, "0");
21
+ return out;
22
+ }
23
+ function throwIfAborted(signal) {
24
+ if (signal?.aborted)
25
+ throw signal.reason ?? new DOMException("The upload was canceled.", "AbortError");
26
+ }
27
+ /**
28
+ * The whole-file SHA-256 of a Blob (a browser `File`, a `Bun.file`) of any size, as lowercase hex
29
+ * — the same digest binary-server records in `file_index.checksum` for the same bytes.
30
+ */
31
+ export async function sha256OfBlob(blob, options = {}) {
32
+ const { onProgress, signal, nativeMaxBytes = NATIVE_HASH_MAX_BYTES } = options;
33
+ throwIfAborted(signal);
34
+ if (blob.size <= nativeMaxBytes) {
35
+ const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
36
+ onProgress?.(1);
37
+ return hex(digest);
38
+ }
39
+ const hasher = new Sha256();
40
+ for (let offset = 0; offset < blob.size; offset += STREAM_SLICE_BYTES) {
41
+ throwIfAborted(signal);
42
+ hasher.update(new Uint8Array(await blob.slice(offset, offset + STREAM_SLICE_BYTES).arrayBuffer()));
43
+ onProgress?.(Math.min(1, (offset + STREAM_SLICE_BYTES) / blob.size));
44
+ }
45
+ return hasher.hex();
46
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `cursedbelt-core/upload-precheck` — skip a duplicate upload by its hash BEFORE sending it
3
+ * (forge task 3579). Every app's dropzone, uploader and Mac ingest uses this one module, so the
4
+ * order of the work (sizes → memo → hash → ask) and the route's contract are the same everywhere.
5
+ *
6
+ * - `planUploads` — the client side: decide which files to send.
7
+ * - `parsePrecheckRequest`, `jsonList` — the server side: each app's route parses with the first
8
+ * and binds its lists with the second.
9
+ * - `sha256OfBlob` — the whole-file SHA-256 binary-server records, of a Blob of any size.
10
+ * - `indexedDbHashMemo`, `memoryHashMemo` — the memo that makes a re-import of the same folder
11
+ * read nothing.
12
+ *
13
+ * Pure of any framework; a browser bundle and a Bun script may both import it. Its sibling
14
+ * `cursedbelt-core/duplicates` answers the other question — two files that are LIKELY the same
15
+ * (a re-encode, a copy under another name) — which is never enough to skip an upload on.
16
+ */
17
+ export { isSha256Hex, jsonList, PRECHECK_MAX_HASHES, PRECHECK_MAX_SIZES, type ParsedPrecheck, type PrecheckHit, type PrecheckReason, type PrecheckRequest, type PrecheckResponse, parsePrecheckRequest, } from "./contract.js";
18
+ export { type HashOptions, NATIVE_HASH_MAX_BYTES, STREAM_SLICE_BYTES, sha256OfBlob } from "./hash.js";
19
+ export { BROWSER_MEMO_MAX_ENTRIES, type HashMemo, indexedDbHashMemo, memoKey, memoryHashMemo, statIdentity, } from "./memo.js";
20
+ export { type PlanOptions, type PlanResult, type PlanStats, type PrecheckAsk, planUploads, type Verdict, } from "./plan.js";
21
+ export { Sha256 } from "./sha256.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `cursedbelt-core/upload-precheck` — skip a duplicate upload by its hash BEFORE sending it
3
+ * (forge task 3579). Every app's dropzone, uploader and Mac ingest uses this one module, so the
4
+ * order of the work (sizes → memo → hash → ask) and the route's contract are the same everywhere.
5
+ *
6
+ * - `planUploads` — the client side: decide which files to send.
7
+ * - `parsePrecheckRequest`, `jsonList` — the server side: each app's route parses with the first
8
+ * and binds its lists with the second.
9
+ * - `sha256OfBlob` — the whole-file SHA-256 binary-server records, of a Blob of any size.
10
+ * - `indexedDbHashMemo`, `memoryHashMemo` — the memo that makes a re-import of the same folder
11
+ * read nothing.
12
+ *
13
+ * Pure of any framework; a browser bundle and a Bun script may both import it. Its sibling
14
+ * `cursedbelt-core/duplicates` answers the other question — two files that are LIKELY the same
15
+ * (a re-encode, a copy under another name) — which is never enough to skip an upload on.
16
+ */
17
+ export { isSha256Hex, jsonList, PRECHECK_MAX_HASHES, PRECHECK_MAX_SIZES, parsePrecheckRequest, } from "./contract.js";
18
+ export { NATIVE_HASH_MAX_BYTES, STREAM_SLICE_BYTES, sha256OfBlob } from "./hash.js";
19
+ export { BROWSER_MEMO_MAX_ENTRIES, indexedDbHashMemo, memoKey, memoryHashMemo, statIdentity, } from "./memo.js";
20
+ export { planUploads, } from "./plan.js";
21
+ export { Sha256 } from "./sha256.js";
@@ -0,0 +1,52 @@
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
+ /** The batch interface — one transaction per batch, not one per file. */
14
+ export interface HashMemo {
15
+ get(keys: readonly string[]): Promise<Map<string, string>>;
16
+ set(entries: readonly (readonly [key: string, sha256: string])[]): Promise<void>;
17
+ }
18
+ /**
19
+ * The key a file is remembered under. `path` is its path inside the drop when known, else its name.
20
+ *
21
+ * `identity` is what a Mac script can add for free from the `stat` it already took —
22
+ * {@link statIdentity}. It closes the one gap path+size+date leave: a file DELETED and replaced by a
23
+ * different one with the same name, the same size and the same modification time to the
24
+ * millisecond (`cp -p`, or a tool that copies dates). The replacement is a new inode, and any write
25
+ * moves the change time, which no tool can set back. A browser `File` exposes neither, so the
26
+ * browser's key is path+size+date alone — two different files agreeing on all three is the case
27
+ * left, and it takes a deliberately copied date.
28
+ */
29
+ export declare function memoKey(path: string, size: number, lastModified: number | undefined, identity?: string): string;
30
+ /**
31
+ * A file's identity beyond its path, size and date: its inode and its change time (`ctime`), both
32
+ * in the `stat` a script already has. Measured on the linked `tod` folder (APFS, 2026-10-22): files
33
+ * ingested that day still carried change times from months before — reading, hashing and filing a
34
+ * file never moves it — so this costs a re-hash only when something really touched the file.
35
+ */
36
+ export declare function statIdentity(stat: {
37
+ ino: number | bigint;
38
+ ctimeMs: number;
39
+ }): string;
40
+ /** A memo held in memory — the Mac scripts' and the specs'. */
41
+ export declare function memoryHashMemo(): HashMemo & {
42
+ readonly size: number;
43
+ };
44
+ /** How many files a browser memo remembers before it forgets the oldest quarter. ~150 bytes each. */
45
+ export declare const BROWSER_MEMO_MAX_ENTRIES = 200000;
46
+ /**
47
+ * A memo kept in this origin's IndexedDB, so it survives a reload and a re-import next week. Each
48
+ * app is its own origin, so each keeps its own. Where IndexedDB is missing or refuses (a private
49
+ * window, a test DOM), every call quietly answers "not remembered" — the upload still works, it
50
+ * just hashes.
51
+ */
52
+ export declare function indexedDbHashMemo(name?: string, maxEntries?: number): HashMemo;
@@ -0,0 +1,160 @@
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 key a file is remembered under. `path` is its path inside the drop when known, else its name.
15
+ *
16
+ * `identity` is what a Mac script can add for free from the `stat` it already took —
17
+ * {@link statIdentity}. It closes the one gap path+size+date leave: a file DELETED and replaced by a
18
+ * different one with the same name, the same size and the same modification time to the
19
+ * millisecond (`cp -p`, or a tool that copies dates). The replacement is a new inode, and any write
20
+ * moves the change time, which no tool can set back. A browser `File` exposes neither, so the
21
+ * browser's key is path+size+date alone — two different files agreeing on all three is the case
22
+ * left, and it takes a deliberately copied date.
23
+ */
24
+ export function memoKey(path, size, lastModified, identity) {
25
+ const key = `${path}\u0000${size}\u0000${lastModified ?? ""}`;
26
+ return identity ? `${key}\u0000${identity}` : key;
27
+ }
28
+ /**
29
+ * A file's identity beyond its path, size and date: its inode and its change time (`ctime`), both
30
+ * in the `stat` a script already has. Measured on the linked `tod` folder (APFS, 2026-10-22): files
31
+ * ingested that day still carried change times from months before — reading, hashing and filing a
32
+ * file never moves it — so this costs a re-hash only when something really touched the file.
33
+ */
34
+ export function statIdentity(stat) {
35
+ return `${stat.ino}:${Math.trunc(stat.ctimeMs)}`;
36
+ }
37
+ /** A memo held in memory — the Mac scripts' and the specs'. */
38
+ export function memoryHashMemo() {
39
+ const map = new Map();
40
+ return {
41
+ get size() {
42
+ return map.size;
43
+ },
44
+ async get(keys) {
45
+ const out = new Map();
46
+ for (const key of keys) {
47
+ const hit = map.get(key);
48
+ if (hit)
49
+ out.set(key, hit);
50
+ }
51
+ return out;
52
+ },
53
+ async set(entries) {
54
+ for (const [key, sha] of entries)
55
+ map.set(key, sha);
56
+ },
57
+ };
58
+ }
59
+ /** How many files a browser memo remembers before it forgets the oldest quarter. ~150 bytes each. */
60
+ export const BROWSER_MEMO_MAX_ENTRIES = 200_000;
61
+ const STORE = "hashes";
62
+ function request(req) {
63
+ return new Promise((resolve, reject) => {
64
+ req.onsuccess = () => resolve(req.result);
65
+ req.onerror = () => reject(req.error);
66
+ });
67
+ }
68
+ function done(tx) {
69
+ return new Promise((resolve, reject) => {
70
+ tx.oncomplete = () => resolve();
71
+ tx.onerror = () => reject(tx.error);
72
+ tx.onabort = () => reject(tx.error);
73
+ });
74
+ }
75
+ /**
76
+ * A memo kept in this origin's IndexedDB, so it survives a reload and a re-import next week. Each
77
+ * app is its own origin, so each keeps its own. Where IndexedDB is missing or refuses (a private
78
+ * window, a test DOM), every call quietly answers "not remembered" — the upload still works, it
79
+ * just hashes.
80
+ */
81
+ export function indexedDbHashMemo(name = "upload-precheck", maxEntries = BROWSER_MEMO_MAX_ENTRIES) {
82
+ let opened;
83
+ const open = () => {
84
+ opened ??= new Promise((resolve) => {
85
+ try {
86
+ if (typeof indexedDB === "undefined")
87
+ return resolve(null);
88
+ const req = indexedDB.open(name, 1);
89
+ req.onupgradeneeded = () => {
90
+ const store = req.result.createObjectStore(STORE, { keyPath: "k" });
91
+ store.createIndex("at", "at");
92
+ };
93
+ req.onsuccess = () => resolve(req.result);
94
+ req.onerror = () => resolve(null);
95
+ req.onblocked = () => resolve(null);
96
+ }
97
+ catch {
98
+ resolve(null);
99
+ }
100
+ });
101
+ return opened;
102
+ };
103
+ const prune = async (db) => {
104
+ const tx = db.transaction(STORE, "readwrite");
105
+ const store = tx.objectStore(STORE);
106
+ const count = await request(store.count());
107
+ if (count > maxEntries) {
108
+ let drop = Math.ceil(count / 4);
109
+ const cursor = store.index("at").openCursor();
110
+ await new Promise((resolve, reject) => {
111
+ cursor.onerror = () => reject(cursor.error);
112
+ cursor.onsuccess = () => {
113
+ const at = cursor.result;
114
+ if (!at || drop-- <= 0)
115
+ return resolve();
116
+ at.delete();
117
+ at.continue();
118
+ };
119
+ });
120
+ }
121
+ await done(tx);
122
+ };
123
+ return {
124
+ async get(keys) {
125
+ const out = new Map();
126
+ try {
127
+ const db = await open();
128
+ if (!db || keys.length === 0)
129
+ return out;
130
+ const tx = db.transaction(STORE, "readonly");
131
+ const store = tx.objectStore(STORE);
132
+ const rows = await Promise.all(keys.map((key) => request(store.get(key))));
133
+ for (const row of rows)
134
+ if (row)
135
+ out.set(row.k, row.sha);
136
+ }
137
+ catch {
138
+ // a refused read is a miss
139
+ }
140
+ return out;
141
+ },
142
+ async set(entries) {
143
+ try {
144
+ const db = await open();
145
+ if (!db || entries.length === 0)
146
+ return;
147
+ const tx = db.transaction(STORE, "readwrite");
148
+ const store = tx.objectStore(STORE);
149
+ const at = Date.now();
150
+ for (const [k, sha] of entries)
151
+ store.put({ k, sha, at });
152
+ await done(tx);
153
+ await prune(db);
154
+ }
155
+ catch {
156
+ // a refused write only costs the next re-import a hash
157
+ }
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Decide, for a drop of files, which ones to SEND — before sending any (forge task 3579).
3
+ *
4
+ * The order of the work is the whole design, cheapest question first:
5
+ *
6
+ * 1. **Sizes, one request.** A file whose byte size the library holds nothing of — and that no
7
+ * other file in this drop shares — cannot be a copy of anything. It is uploaded at once, never
8
+ * hashed here (binary-server hashes what it receives anyway). A folder of new videos is decided
9
+ * almost entirely by this step.
10
+ * 2. **The memo.** A file this browser (or script) hashed before, at the same path with the same
11
+ * size and date, takes its remembered hash — a second import of the same huge folder reads none
12
+ * of its bytes.
13
+ * 3. **Hash the rest**, a few at a time and within a memory budget, and ask the library about the
14
+ * hashes AS they finish: one request in flight, each carrying every hash completed since the
15
+ * last. Uploads of the first new files begin while later ones are still being hashed.
16
+ * 4. **A repeat inside the drop** (the same bytes under two names) is caught by its hash without
17
+ * asking anybody, and only the first is sent.
18
+ *
19
+ * A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
20
+ * it would have before the pre-check existed (each app's own after-the-fact check still stands),
21
+ * and is reported as `degraded`. It costs bandwidth, never a file.
22
+ */
23
+ import { type PrecheckHit, type PrecheckRequest, type PrecheckResponse } from "./contract.js";
24
+ import { type HashOptions } from "./hash.js";
25
+ import { type HashMemo } from "./memo.js";
26
+ /** Asks the app's pre-check route. Each app supplies its own fetch. */
27
+ export type PrecheckAsk = (request: PrecheckRequest) => Promise<PrecheckResponse>;
28
+ export type Verdict<T> =
29
+ /** Send it. `sha256` is set when it was hashed on the way (an app may pass it on). */
30
+ {
31
+ kind: "upload";
32
+ item: T;
33
+ sha256?: string;
34
+ }
35
+ /** The library already answers for these bytes. Do not send. */
36
+ | {
37
+ kind: "duplicate";
38
+ item: T;
39
+ sha256: string;
40
+ hit: PrecheckHit;
41
+ }
42
+ /** Another file in this same drop has these bytes; only that one is sent. */
43
+ | {
44
+ kind: "repeat";
45
+ item: T;
46
+ sha256: string;
47
+ of: T;
48
+ };
49
+ export interface PlanStats {
50
+ files: number;
51
+ bytes: number;
52
+ /** Files sent without being hashed, because their size matched nothing. */
53
+ sizeOnly: number;
54
+ /** Files whose hash came from the memo — no bytes read. */
55
+ memoHits: number;
56
+ /** Files hashed here, and how many bytes that read. */
57
+ hashedFiles: number;
58
+ hashedBytes: number;
59
+ /** Requests made to the pre-check route. */
60
+ asks: number;
61
+ /** Bytes the plan kept from being sent. */
62
+ savedBytes: number;
63
+ /** Files whose hash failed (an unreadable file) — sent unhashed rather than ending the plan. */
64
+ hashErrors: number;
65
+ /** Set when the pre-check could not be asked; the plan then sent what it could not rule out. */
66
+ degraded?: string;
67
+ }
68
+ export interface PlanResult<T> {
69
+ upload: Extract<Verdict<T>, {
70
+ kind: "upload";
71
+ }>[];
72
+ duplicates: Extract<Verdict<T>, {
73
+ kind: "duplicate";
74
+ }>[];
75
+ repeats: Extract<Verdict<T>, {
76
+ kind: "repeat";
77
+ }>[];
78
+ stats: PlanStats;
79
+ }
80
+ export interface PlanOptions<T> {
81
+ items: readonly T[];
82
+ /** The bytes of an item. Its `size` is the size asked about. */
83
+ blobOf: (item: T) => Blob;
84
+ /** The item's path inside the drop (or its name) — the memo's key. Without it there is no memo. */
85
+ pathOf?: (item: T) => string;
86
+ /** Modification time in ms (a `File`'s `lastModified`). Part of the memo's key. */
87
+ lastModifiedOf?: (item: T) => number | undefined;
88
+ /** A script's {@link statIdentity} of the item — part of the memo's key where it is known. */
89
+ identityOf?: (item: T) => string | undefined;
90
+ ask: PrecheckAsk;
91
+ memo?: HashMemo;
92
+ /** Defaults to {@link sha256OfBlob}. The specs count calls through it. */
93
+ hash?: (blob: Blob, options: HashOptions) => Promise<string>;
94
+ /** Files hashed at once. Default 3 — a disk is the bottleneck past that. */
95
+ concurrency?: number;
96
+ /** Bytes held in memory by hashes in flight. Default 320 MiB; one file is always allowed. */
97
+ maxInFlightBytes?: number;
98
+ /**
99
+ * How long a finished hash waits for company before it is asked about (default 250 ms), unless
100
+ * {@link batchSize} are already waiting or the hashing is over. Bounds a drop of 10,000 small
101
+ * files to about one request per batch rather than one per file, at a quarter-second's delay.
102
+ */
103
+ lingerMs?: number;
104
+ /** Hashes that are asked about at once without waiting out the linger. Default 200. */
105
+ batchSize?: number;
106
+ signal?: AbortSignal;
107
+ /** Each verdict as it is reached — start uploading on the first `upload`, not at the end. */
108
+ onVerdict?: (verdict: Verdict<T>) => void;
109
+ /** Hashing progress of one item, 0..1. */
110
+ onHashProgress?: (item: T, fraction: number) => void;
111
+ }
112
+ export declare function planUploads<T>(options: PlanOptions<T>): Promise<PlanResult<T>>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Decide, for a drop of files, which ones to SEND — before sending any (forge task 3579).
3
+ *
4
+ * The order of the work is the whole design, cheapest question first:
5
+ *
6
+ * 1. **Sizes, one request.** A file whose byte size the library holds nothing of — and that no
7
+ * other file in this drop shares — cannot be a copy of anything. It is uploaded at once, never
8
+ * hashed here (binary-server hashes what it receives anyway). A folder of new videos is decided
9
+ * almost entirely by this step.
10
+ * 2. **The memo.** A file this browser (or script) hashed before, at the same path with the same
11
+ * size and date, takes its remembered hash — a second import of the same huge folder reads none
12
+ * of its bytes.
13
+ * 3. **Hash the rest**, a few at a time and within a memory budget, and ask the library about the
14
+ * hashes AS they finish: one request in flight, each carrying every hash completed since the
15
+ * last. Uploads of the first new files begin while later ones are still being hashed.
16
+ * 4. **A repeat inside the drop** (the same bytes under two names) is caught by its hash without
17
+ * asking anybody, and only the first is sent.
18
+ *
19
+ * A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
20
+ * it would have before the pre-check existed (each app's own after-the-fact check still stands),
21
+ * and is reported as `degraded`. It costs bandwidth, never a file.
22
+ */
23
+ import { PRECHECK_MAX_HASHES, PRECHECK_MAX_SIZES } from "./contract.js";
24
+ import { NATIVE_HASH_MAX_BYTES, STREAM_SLICE_BYTES, sha256OfBlob } from "./hash.js";
25
+ import { memoKey } from "./memo.js";
26
+ function chunks(values, size) {
27
+ const out = [];
28
+ for (let i = 0; i < values.length; i += size)
29
+ out.push(values.slice(i, i + size));
30
+ return out;
31
+ }
32
+ const message = (error) => (error instanceof Error ? error.message : String(error));
33
+ export async function planUploads(options) {
34
+ const { items, blobOf, pathOf, lastModifiedOf, identityOf, ask, memo, hash = sha256OfBlob, concurrency = 3, maxInFlightBytes = 320 * 1024 * 1024, lingerMs = 250, batchSize = 200, signal, onVerdict, onHashProgress, } = options;
35
+ const result = {
36
+ upload: [],
37
+ duplicates: [],
38
+ repeats: [],
39
+ stats: {
40
+ files: items.length,
41
+ bytes: 0,
42
+ sizeOnly: 0,
43
+ memoHits: 0,
44
+ hashedFiles: 0,
45
+ hashedBytes: 0,
46
+ asks: 0,
47
+ savedBytes: 0,
48
+ hashErrors: 0,
49
+ },
50
+ };
51
+ const decide = (verdict) => {
52
+ if (verdict.kind === "upload")
53
+ result.upload.push(verdict);
54
+ else {
55
+ result.stats.savedBytes += blobOf(verdict.item).size;
56
+ if (verdict.kind === "duplicate")
57
+ result.duplicates.push(verdict);
58
+ else
59
+ result.repeats.push(verdict);
60
+ }
61
+ onVerdict?.(verdict);
62
+ };
63
+ const sizeOf = (item) => blobOf(item).size;
64
+ const inDrop = new Map();
65
+ for (const item of items) {
66
+ const size = sizeOf(item);
67
+ result.stats.bytes += size;
68
+ inDrop.set(size, (inDrop.get(size) ?? 0) + 1);
69
+ }
70
+ if (items.length === 0)
71
+ return result;
72
+ // 1 — sizes
73
+ const held = new Set();
74
+ try {
75
+ for (const batch of chunks([...inDrop.keys()], PRECHECK_MAX_SIZES)) {
76
+ result.stats.asks++;
77
+ for (const size of (await ask({ sizes: batch })).sizes)
78
+ held.add(size);
79
+ }
80
+ }
81
+ catch (error) {
82
+ if (signal?.aborted)
83
+ throw error;
84
+ result.stats.degraded = `pre-check unavailable (${message(error)}) — sending without it`;
85
+ for (const item of items)
86
+ decide({ kind: "upload", item });
87
+ return result;
88
+ }
89
+ const candidates = [];
90
+ for (const item of items) {
91
+ const size = sizeOf(item);
92
+ if (held.has(size) || (inDrop.get(size) ?? 0) > 1)
93
+ candidates.push(item);
94
+ else {
95
+ result.stats.sizeOnly++;
96
+ decide({ kind: "upload", item });
97
+ }
98
+ }
99
+ if (candidates.length === 0)
100
+ return result;
101
+ // 2 — the memo
102
+ const keyOf = pathOf
103
+ ? (item) => memoKey(pathOf(item), sizeOf(item), lastModifiedOf?.(item), identityOf?.(item))
104
+ : undefined;
105
+ const remembered = memo && keyOf ? await memo.get(candidates.map(keyOf)) : new Map();
106
+ // 3 + 4 — hash, and ask as hashes finish
107
+ const firstWith = new Map();
108
+ let pending = [];
109
+ let toRemember = [];
110
+ let asking;
111
+ let askFailed;
112
+ let hashingDone = false;
113
+ let linger;
114
+ const flush = async () => {
115
+ while (pending.length > 0) {
116
+ const batch = pending.splice(0, PRECHECK_MAX_HASHES);
117
+ const remember = toRemember;
118
+ toRemember = [];
119
+ if (askFailed) {
120
+ for (const { item, sha256 } of batch)
121
+ decide({ kind: "upload", item, sha256 });
122
+ continue;
123
+ }
124
+ let have = {};
125
+ try {
126
+ result.stats.asks++;
127
+ have = (await ask({ sha256: [...new Set(batch.map((b) => b.sha256))] })).have;
128
+ }
129
+ catch (error) {
130
+ if (signal?.aborted)
131
+ throw error;
132
+ askFailed = message(error);
133
+ result.stats.degraded = `pre-check failed mid-drop (${askFailed}) — sent the rest without it`;
134
+ }
135
+ for (const { item, sha256 } of batch) {
136
+ const hit = have[sha256];
137
+ decide(hit ? { kind: "duplicate", item, sha256, hit } : { kind: "upload", item, sha256 });
138
+ }
139
+ if (memo && remember.length > 0)
140
+ await memo.set(remember);
141
+ }
142
+ };
143
+ const kick = () => {
144
+ if (linger !== undefined) {
145
+ clearTimeout(linger);
146
+ linger = undefined;
147
+ }
148
+ if (asking)
149
+ return;
150
+ asking = flush().finally(() => {
151
+ asking = undefined;
152
+ if (pending.length > 0)
153
+ kick();
154
+ });
155
+ // Awaited at the end; this keeps an abort mid-hashing from surfacing as unhandled first.
156
+ asking.catch(() => { });
157
+ };
158
+ const settle = (item, sha256) => {
159
+ const first = firstWith.get(sha256);
160
+ if (first !== undefined) {
161
+ decide({ kind: "repeat", item, sha256, of: first });
162
+ return;
163
+ }
164
+ firstWith.set(sha256, item);
165
+ pending.push({ item, sha256 });
166
+ if (hashingDone || pending.length >= batchSize)
167
+ kick();
168
+ else
169
+ linger ??= setTimeout(kick, lingerMs);
170
+ };
171
+ const toHash = [];
172
+ for (const item of candidates) {
173
+ const known = keyOf ? remembered.get(keyOf(item)) : undefined;
174
+ if (known) {
175
+ result.stats.memoHits++;
176
+ settle(item, known);
177
+ }
178
+ else
179
+ toHash.push(item);
180
+ }
181
+ let next = 0;
182
+ let inFlightBytes = 0;
183
+ let running = 0;
184
+ const footprint = (size) => size <= NATIVE_HASH_MAX_BYTES ? size : STREAM_SLICE_BYTES;
185
+ await new Promise((resolve, reject) => {
186
+ let failed = false;
187
+ const pump = () => {
188
+ if (failed)
189
+ return;
190
+ if (next >= toHash.length && running === 0) {
191
+ resolve();
192
+ return;
193
+ }
194
+ while (next < toHash.length && running < concurrency) {
195
+ const item = toHash[next];
196
+ const blob = blobOf(item);
197
+ const cost = footprint(blob.size);
198
+ if (running > 0 && inFlightBytes + cost > maxInFlightBytes)
199
+ break;
200
+ next++;
201
+ running++;
202
+ inFlightBytes += cost;
203
+ hash(blob, { signal, onProgress: onHashProgress ? (f) => onHashProgress(item, f) : undefined })
204
+ .then((sha256) => {
205
+ result.stats.hashedFiles++;
206
+ result.stats.hashedBytes += blob.size;
207
+ if (keyOf)
208
+ toRemember.push([keyOf(item), sha256]);
209
+ settle(item, sha256);
210
+ })
211
+ .catch((error) => {
212
+ // An abort ends the plan. One file that cannot be READ does not: it is sent
213
+ // unhashed like any file the pre-check cannot rule out, and its own upload is
214
+ // where the person learns it is unreadable (roms, forge task 3579).
215
+ if (signal?.aborted) {
216
+ failed = true;
217
+ reject(error);
218
+ return;
219
+ }
220
+ result.stats.hashErrors++;
221
+ decide({ kind: "upload", item });
222
+ })
223
+ .finally(() => {
224
+ running--;
225
+ inFlightBytes -= cost;
226
+ pump();
227
+ });
228
+ }
229
+ };
230
+ pump();
231
+ }).catch((error) => {
232
+ if (linger !== undefined)
233
+ clearTimeout(linger);
234
+ throw error;
235
+ });
236
+ hashingDone = true;
237
+ if (pending.length > 0)
238
+ kick();
239
+ while (asking)
240
+ await asking;
241
+ return result;
242
+ }