cursedbelt-core 2.13.3 → 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.
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * An INCREMENTAL SHA-256 — fed chunk by chunk, for a Blob too big to hold in one buffer.
3
+ *
4
+ * 🔴 Why not `crypto.subtle.digest`: it takes the whole message in one buffer, and a PS2 disc is
5
+ * up to 8.5 GB, a camera video several. Born in roms (`src/ps2/sha256.ts`, the disc download's
6
+ * verifier) and moved here so every app's upload pre-check hashes with the same code
7
+ * (forge task 3579). `sha256.spec.ts` pins it against WebCrypto across every chunk boundary that
8
+ * matters (55/56/64 bytes, and a 1 MB split at an odd offset).
9
+ *
10
+ * Measured on this Mac under Bun, 256 MiB in 8 MiB slices: 184 MiB/s here, 195 MiB/s for
11
+ * `@noble/hashes`, 1,960 MiB/s for `crypto.subtle` — which is why {@link sha256OfBlob} only falls
12
+ * back to this past the size it is willing to read into memory at once.
13
+ */
14
+ export declare class Sha256 {
15
+ private readonly h;
16
+ /** Back to the empty message — a download that has to start over. */
17
+ reset(): this;
18
+ private readonly w;
19
+ private readonly tail;
20
+ private tailLength;
21
+ /** Total bytes fed, as a double — exact to 2^53, far past any disc. */
22
+ private length;
23
+ update(data: Uint8Array): this;
24
+ /** The digest as lowercase hex. The hasher is spent afterwards. */
25
+ hex(): string;
26
+ private block;
27
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * An INCREMENTAL SHA-256 — fed chunk by chunk, for a Blob too big to hold in one buffer.
3
+ *
4
+ * 🔴 Why not `crypto.subtle.digest`: it takes the whole message in one buffer, and a PS2 disc is
5
+ * up to 8.5 GB, a camera video several. Born in roms (`src/ps2/sha256.ts`, the disc download's
6
+ * verifier) and moved here so every app's upload pre-check hashes with the same code
7
+ * (forge task 3579). `sha256.spec.ts` pins it against WebCrypto across every chunk boundary that
8
+ * matters (55/56/64 bytes, and a 1 MB split at an odd offset).
9
+ *
10
+ * Measured on this Mac under Bun, 256 MiB in 8 MiB slices: 184 MiB/s here, 195 MiB/s for
11
+ * `@noble/hashes`, 1,960 MiB/s for `crypto.subtle` — which is why {@link sha256OfBlob} only falls
12
+ * back to this past the size it is willing to read into memory at once.
13
+ */
14
+ const K = new Uint32Array([
15
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
16
+ 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
17
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
18
+ 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
19
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
20
+ 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
21
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
22
+ 0xc67178f2,
23
+ ]);
24
+ const INITIAL = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
25
+ export class Sha256 {
26
+ h = new Uint32Array(INITIAL);
27
+ /** Back to the empty message — a download that has to start over. */
28
+ reset() {
29
+ this.h.set(INITIAL);
30
+ this.tailLength = 0;
31
+ this.length = 0;
32
+ return this;
33
+ }
34
+ w = new Uint32Array(64);
35
+ tail = new Uint8Array(64);
36
+ tailLength = 0;
37
+ /** Total bytes fed, as a double — exact to 2^53, far past any disc. */
38
+ length = 0;
39
+ update(data) {
40
+ this.length += data.length;
41
+ let offset = 0;
42
+ if (this.tailLength > 0) {
43
+ const take = Math.min(64 - this.tailLength, data.length);
44
+ this.tail.set(data.subarray(0, take), this.tailLength);
45
+ this.tailLength += take;
46
+ offset = take;
47
+ if (this.tailLength < 64)
48
+ return this;
49
+ this.block(this.tail, 0);
50
+ this.tailLength = 0;
51
+ }
52
+ const whole = data.length - ((data.length - offset) % 64);
53
+ for (; offset < whole; offset += 64)
54
+ this.block(data, offset);
55
+ if (offset < data.length) {
56
+ this.tail.set(data.subarray(offset), 0);
57
+ this.tailLength = data.length - offset;
58
+ }
59
+ return this;
60
+ }
61
+ /** The digest as lowercase hex. The hasher is spent afterwards. */
62
+ hex() {
63
+ const bits = this.length * 8;
64
+ const pad = new Uint8Array(this.tailLength < 56 ? 64 : 128);
65
+ pad.set(this.tail.subarray(0, this.tailLength));
66
+ pad[this.tailLength] = 0x80;
67
+ const view = new DataView(pad.buffer);
68
+ view.setUint32(pad.length - 8, Math.floor(bits / 2 ** 32));
69
+ view.setUint32(pad.length - 4, bits >>> 0);
70
+ for (let offset = 0; offset < pad.length; offset += 64)
71
+ this.block(pad, offset);
72
+ let out = "";
73
+ for (const word of this.h)
74
+ out += word.toString(16).padStart(8, "0");
75
+ return out;
76
+ }
77
+ block(data, offset) {
78
+ const w = this.w;
79
+ for (let i = 0; i < 16; i++) {
80
+ const j = offset + i * 4;
81
+ w[i] = (data[j] << 24) | (data[j + 1] << 16) | (data[j + 2] << 8) | data[j + 3];
82
+ }
83
+ for (let i = 16; i < 64; i++) {
84
+ const a = w[i - 15];
85
+ const b = w[i - 2];
86
+ const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
87
+ const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
88
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
89
+ }
90
+ const h = this.h;
91
+ let a = h[0];
92
+ let b = h[1];
93
+ let c = h[2];
94
+ let d = h[3];
95
+ let e = h[4];
96
+ let f = h[5];
97
+ let g = h[6];
98
+ let hh = h[7];
99
+ for (let i = 0; i < 64; i++) {
100
+ const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
101
+ const ch = (e & f) ^ (~e & g);
102
+ const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
103
+ const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
104
+ const maj = (a & b) ^ (a & c) ^ (b & c);
105
+ const t2 = (S0 + maj) | 0;
106
+ hh = g;
107
+ g = f;
108
+ f = e;
109
+ e = (d + t1) | 0;
110
+ d = c;
111
+ c = b;
112
+ b = a;
113
+ a = (t1 + t2) | 0;
114
+ }
115
+ h[0] = (h[0] + a) | 0;
116
+ h[1] = (h[1] + b) | 0;
117
+ h[2] = (h[2] + c) | 0;
118
+ h[3] = (h[3] + d) | 0;
119
+ h[4] = (h[4] + e) | 0;
120
+ h[5] = (h[5] + f) | 0;
121
+ h[6] = (h[6] + g) | 0;
122
+ h[7] = (h[7] + hh) | 0;
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.13.3",
3
+ "version": "2.14.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -133,6 +133,12 @@
133
133
  "source": "./src/core/duplicates/index.ts",
134
134
  "import": "./dist/core/duplicates/index.js"
135
135
  },
136
+ "./upload-precheck": {
137
+ "types": "./dist/core/upload-precheck/index.d.ts",
138
+ "bun": "./src/core/upload-precheck/index.ts",
139
+ "source": "./src/core/upload-precheck/index.ts",
140
+ "import": "./dist/core/upload-precheck/index.js"
141
+ },
136
142
  "./errors": {
137
143
  "types": "./dist/shared/errors.d.ts",
138
144
  "bun": "./src/shared/errors.ts",
@@ -0,0 +1,67 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+ import { jsonList, PRECHECK_MAX_HASHES, PRECHECK_MAX_SIZES, parsePrecheckRequest } from "./contract.js";
4
+ import { NATIVE_HASH_MAX_BYTES, sha256OfBlob } from "./hash.js";
5
+
6
+ const sha = (n: number) => n.toString(16).padStart(64, "0");
7
+
8
+ describe("parsePrecheckRequest", () => {
9
+ test("both lists, deduplicated, hashes lower-cased", () => {
10
+ const parsed = parsePrecheckRequest({ sizes: [3, 3, 0], sha256: [sha(1).toUpperCase(), sha(1)] });
11
+ expect(parsed).toEqual({ ok: true, sizes: [3, 0], sha256: [sha(1)] });
12
+ });
13
+
14
+ test("either list may be absent", () => {
15
+ expect(parsePrecheckRequest({})).toEqual({ ok: true, sizes: [], sha256: [] });
16
+ });
17
+
18
+ test.each([
19
+ [null],
20
+ [[1, 2]],
21
+ [{ sizes: "3" }],
22
+ [{ sizes: [-1] }],
23
+ [{ sizes: [1.5] }],
24
+ [{ sha256: ["abc"] }],
25
+ [{ sha256: [42] }],
26
+ [{ sizes: Array.from({ length: PRECHECK_MAX_SIZES + 1 }, (_, i) => i) }],
27
+ [{ sha256: Array.from({ length: PRECHECK_MAX_HASHES + 1 }, (_, i) => sha(i)) }],
28
+ ])("refuses %j", (body) => {
29
+ expect(parsePrecheckRequest(body).ok).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("jsonList — one bound parameter for a whole batch", () => {
34
+ test("🔴 a full batch of hashes in ONE statement, answered from the index", () => {
35
+ // D1 refuses more than 100 bound parameters; this is the shape that never needs more than one.
36
+ const db = new Database(":memory:");
37
+ db.run("CREATE TABLE items (id INTEGER PRIMARY KEY, owner TEXT, checksum TEXT, size INTEGER)");
38
+ db.run("CREATE INDEX items_owner_checksum ON items(owner, checksum)");
39
+ const insert = db.prepare("INSERT INTO items (owner, checksum, size) VALUES (?, ?, ?)");
40
+ for (let i = 0; i < 5000; i++) insert.run(i % 2 ? "me" : "you", sha(i), i);
41
+ const asked = Array.from({ length: PRECHECK_MAX_HASHES }, (_, i) => sha(i * 3));
42
+ const sql = "SELECT checksum FROM items WHERE owner = ? AND checksum IN (SELECT value FROM json_each(?))";
43
+ const rows = db.prepare(sql).all("me", jsonList(asked)) as { checksum: string }[];
44
+ expect(rows.length).toBe(asked.filter((_, i) => (i * 3) % 2 === 1).length);
45
+ const plan = (db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all("me", jsonList(asked)) as { detail: string }[])
46
+ .map((r) => r.detail)
47
+ .join(" | ");
48
+ expect(plan).toContain("USING COVERING INDEX items_owner_checksum");
49
+ });
50
+ });
51
+
52
+ describe("sha256OfBlob", () => {
53
+ test("the native road and the streaming road give the same digest", async () => {
54
+ const bytes = new Uint8Array(5 * 1024 * 1024 + 13).map((_, i) => (i * 31) & 0xff);
55
+ const blob = new Blob([bytes]);
56
+ const native = await sha256OfBlob(blob);
57
+ let last = 0;
58
+ const streamed = await sha256OfBlob(blob, { nativeMaxBytes: 1024, onProgress: (f) => (last = f) });
59
+ expect(streamed).toBe(native);
60
+ expect(last).toBe(1);
61
+ expect(NATIVE_HASH_MAX_BYTES).toBeGreaterThan(64 * 1024 * 1024);
62
+ });
63
+
64
+ test("the empty file", async () => {
65
+ expect(await sha256OfBlob(new Blob([]))).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
66
+ });
67
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The wire contract of an upload PRE-CHECK — "which of these do you already have?", asked before
3
+ * a single byte of the files is sent (forge task 3579).
4
+ *
5
+ * The owner, 2026-10-22: *"They should find and skip dups efficiently without wasting bandwidth or
6
+ * processing power … They need to know dups based on the hashes rather than a long check such as
7
+ * copying the file first … I will sometimes reimport from huge directories just in case they have a
8
+ * new file that was missed on previous uploads and I want it to be fast."*
9
+ *
10
+ * ── Two questions, one route ───────────────────────────────────────────────────────────────
11
+ * 1. **`sizes`** — which of these byte sizes does the library hold at all? A file whose size
12
+ * matches nothing stored CANNOT be a byte-identical copy, so it is uploaded without being hashed
13
+ * first (binary-server hashes what it receives, as it always has). On a folder of mostly new
14
+ * videos that is nearly every file, and nearly all of the hashing.
15
+ * 2. **`sha256`** — which of these whole-file SHA-256s does the library hold? Asked only for the
16
+ * files whose size DID match, so the proof (the hash) is paid exactly where it can decide
17
+ * something. A hit is never sent.
18
+ *
19
+ * A route answers both in one body, so a client may ask either or both. Every app's library is
20
+ * scoped by the app's own rules (an account, an owner) — the route answers for the CALLER only,
21
+ * which is also what keeps the answer from saying anything about anyone else's files.
22
+ *
23
+ * ── SQL: one bound parameter, any length ───────────────────────────────────────────────────
24
+ * D1 refuses a statement with more than 100 bound parameters, so `IN (?, ?, …)` over a batch of
25
+ * 1,000 hashes cannot be written. Bind the list ONCE as JSON instead —
26
+ * `WHERE checksum IN (SELECT value FROM json_each(?))` with {@link jsonList} — which D1 and
27
+ * `bun:sqlite` both run against the column's index.
28
+ */
29
+
30
+ /** The most sizes one pre-check may ask about. A drop of 5,000 files asks once. */
31
+ export const PRECHECK_MAX_SIZES = 5000;
32
+
33
+ /** The most hashes one pre-check may ask about — a client sends them in batches this size or less. */
34
+ export const PRECHECK_MAX_HASHES = 1000;
35
+
36
+ /** What the client sends. Either list may be absent or empty. */
37
+ export interface PrecheckRequest {
38
+ sizes?: number[];
39
+ sha256?: string[];
40
+ }
41
+
42
+ /** Why a hash is refused: the library holds it, or once held it and its owner deleted it. */
43
+ export type PrecheckReason = "exists" | "deleted";
44
+
45
+ /** One hash the library already answers for. */
46
+ export interface PrecheckHit {
47
+ reason: PrecheckReason;
48
+ /** What the library calls it — shown to the person beside the skipped file. */
49
+ name?: string;
50
+ /** The app's own id for the item, when it has one worth linking to. */
51
+ id?: string;
52
+ }
53
+
54
+ /** What the route answers. */
55
+ export interface PrecheckResponse {
56
+ /** The asked sizes that the library holds at least one file of. */
57
+ sizes: number[];
58
+ /** The asked hashes the library already answers for; any hash absent here is new. */
59
+ have: Record<string, PrecheckHit>;
60
+ }
61
+
62
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
63
+
64
+ /** Is this a lowercase hex SHA-256 — the only spelling every store in the fleet records? */
65
+ export function isSha256Hex(value: unknown): value is string {
66
+ return typeof value === "string" && SHA256_HEX.test(value);
67
+ }
68
+
69
+ export type ParsedPrecheck = { ok: true; sizes: number[]; sha256: string[] } | { ok: false; error: string };
70
+
71
+ /**
72
+ * Validate a pre-check body — the ONE parser every app's route calls, so the limits and the
73
+ * spelling are the same everywhere. Duplicates collapse; order is not kept.
74
+ */
75
+ export function parsePrecheckRequest(body: unknown): ParsedPrecheck {
76
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
77
+ return { ok: false, error: "expected a JSON object with `sizes` and/or `sha256`" };
78
+ }
79
+ const { sizes: rawSizes, sha256: rawHashes } = body as Record<string, unknown>;
80
+ if (rawSizes !== undefined && !Array.isArray(rawSizes)) return { ok: false, error: "`sizes` must be an array" };
81
+ if (rawHashes !== undefined && !Array.isArray(rawHashes)) return { ok: false, error: "`sha256` must be an array" };
82
+ const sizes = new Set<number>();
83
+ for (const size of rawSizes ?? []) {
84
+ if (!Number.isSafeInteger(size) || (size as number) < 0) {
85
+ return { ok: false, error: "every size must be a whole number of bytes" };
86
+ }
87
+ sizes.add(size as number);
88
+ }
89
+ const hashes = new Set<string>();
90
+ for (const hash of rawHashes ?? []) {
91
+ const spelled = typeof hash === "string" ? hash.toLowerCase() : hash;
92
+ if (!isSha256Hex(spelled)) return { ok: false, error: "every sha256 must be 64 hex characters" };
93
+ hashes.add(spelled);
94
+ }
95
+ if (sizes.size > PRECHECK_MAX_SIZES) {
96
+ return { ok: false, error: `at most ${PRECHECK_MAX_SIZES} sizes per pre-check` };
97
+ }
98
+ if (hashes.size > PRECHECK_MAX_HASHES) {
99
+ return { ok: false, error: `at most ${PRECHECK_MAX_HASHES} hashes per pre-check` };
100
+ }
101
+ return { ok: true, sizes: [...sizes], sha256: [...hashes] };
102
+ }
103
+
104
+ /** A list bound as ONE SQL parameter, for `IN (SELECT value FROM json_each(?))`. */
105
+ export function jsonList(values: readonly (string | number)[]): string {
106
+ return JSON.stringify(values);
107
+ }
@@ -0,0 +1,57 @@
1
+ import { Sha256 } from "./sha256.js";
2
+
3
+ /**
4
+ * The largest file hashed in ONE native `crypto.subtle.digest` call — read whole into memory.
5
+ *
6
+ * Native hashing ran 1,960 MiB/s on this Mac against 184 MiB/s for the incremental one, so a
7
+ * file is read whole whenever that is safe. 256 MiB is far under what a phone tab survives, and
8
+ * covers every photo, every song and most clips; past it the incremental hasher streams in
9
+ * {@link STREAM_SLICE_BYTES} slices at roughly the speed an external drive reads anyway.
10
+ */
11
+ export const NATIVE_HASH_MAX_BYTES = 256 * 1024 * 1024;
12
+
13
+ /**
14
+ * One read while streaming. Small enough that the incremental hasher returns to the event loop
15
+ * every ~10 ms (a 2 MiB slice at ~190 MiB/s), so a tab hashing an 8 GB disc stays responsive.
16
+ */
17
+ export const STREAM_SLICE_BYTES = 2 * 1024 * 1024;
18
+
19
+ export interface HashOptions {
20
+ /** 0..1 as the bytes are read. */
21
+ onProgress?: (fraction: number) => void;
22
+ signal?: AbortSignal;
23
+ /** Override {@link NATIVE_HASH_MAX_BYTES} — the specs force the streaming road with it. */
24
+ nativeMaxBytes?: number;
25
+ }
26
+
27
+ function hex(digest: ArrayBuffer): string {
28
+ const view = new Uint8Array(digest);
29
+ let out = "";
30
+ for (let i = 0; i < view.length; i++) out += view[i].toString(16).padStart(2, "0");
31
+ return out;
32
+ }
33
+
34
+ function throwIfAborted(signal: AbortSignal | undefined): void {
35
+ if (signal?.aborted) throw signal.reason ?? new DOMException("The upload was canceled.", "AbortError");
36
+ }
37
+
38
+ /**
39
+ * The whole-file SHA-256 of a Blob (a browser `File`, a `Bun.file`) of any size, as lowercase hex
40
+ * — the same digest binary-server records in `file_index.checksum` for the same bytes.
41
+ */
42
+ export async function sha256OfBlob(blob: Blob, options: HashOptions = {}): Promise<string> {
43
+ const { onProgress, signal, nativeMaxBytes = NATIVE_HASH_MAX_BYTES } = options;
44
+ throwIfAborted(signal);
45
+ if (blob.size <= nativeMaxBytes) {
46
+ const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
47
+ onProgress?.(1);
48
+ return hex(digest);
49
+ }
50
+ const hasher = new Sha256();
51
+ for (let offset = 0; offset < blob.size; offset += STREAM_SLICE_BYTES) {
52
+ throwIfAborted(signal);
53
+ hasher.update(new Uint8Array(await blob.slice(offset, offset + STREAM_SLICE_BYTES).arrayBuffer()));
54
+ onProgress?.(Math.min(1, (offset + STREAM_SLICE_BYTES) / blob.size));
55
+ }
56
+ return hasher.hex();
57
+ }