cursedbelt-core 2.13.3 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/upload-precheck/contract.d.ts +71 -0
- package/dist/core/upload-precheck/contract.js +76 -0
- package/dist/core/upload-precheck/hash.d.ts +26 -0
- package/dist/core/upload-precheck/hash.js +46 -0
- package/dist/core/upload-precheck/index.d.ts +21 -0
- package/dist/core/upload-precheck/index.js +21 -0
- package/dist/core/upload-precheck/memo.d.ts +52 -0
- package/dist/core/upload-precheck/memo.js +160 -0
- package/dist/core/upload-precheck/plan.d.ts +116 -0
- package/dist/core/upload-precheck/plan.js +274 -0
- package/dist/core/upload-precheck/sha256.d.ts +27 -0
- package/dist/core/upload-precheck/sha256.js +124 -0
- package/package.json +7 -1
- package/src/core/upload-precheck/contract.spec.ts +67 -0
- package/src/core/upload-precheck/contract.ts +107 -0
- package/src/core/upload-precheck/hash.ts +57 -0
- package/src/core/upload-precheck/index.ts +46 -0
- package/src/core/upload-precheck/memo.ts +170 -0
- package/src/core/upload-precheck/plan.spec.ts +335 -0
- package/src/core/upload-precheck/plan.ts +345 -0
- package/src/core/upload-precheck/sha256.spec.ts +47 -0
- package/src/core/upload-precheck/sha256.ts +127 -0
|
@@ -0,0 +1,46 @@
|
|
|
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 {
|
|
18
|
+
isSha256Hex,
|
|
19
|
+
jsonList,
|
|
20
|
+
PRECHECK_MAX_HASHES,
|
|
21
|
+
PRECHECK_MAX_SIZES,
|
|
22
|
+
type ParsedPrecheck,
|
|
23
|
+
type PrecheckHit,
|
|
24
|
+
type PrecheckReason,
|
|
25
|
+
type PrecheckRequest,
|
|
26
|
+
type PrecheckResponse,
|
|
27
|
+
parsePrecheckRequest,
|
|
28
|
+
} from "./contract.js";
|
|
29
|
+
export { type HashOptions, NATIVE_HASH_MAX_BYTES, STREAM_SLICE_BYTES, sha256OfBlob } from "./hash.js";
|
|
30
|
+
export {
|
|
31
|
+
BROWSER_MEMO_MAX_ENTRIES,
|
|
32
|
+
type HashMemo,
|
|
33
|
+
indexedDbHashMemo,
|
|
34
|
+
memoKey,
|
|
35
|
+
memoryHashMemo,
|
|
36
|
+
statIdentity,
|
|
37
|
+
} from "./memo.js";
|
|
38
|
+
export {
|
|
39
|
+
type PlanOptions,
|
|
40
|
+
type PlanResult,
|
|
41
|
+
type PlanStats,
|
|
42
|
+
type PrecheckAsk,
|
|
43
|
+
planUploads,
|
|
44
|
+
type Verdict,
|
|
45
|
+
} from "./plan.js";
|
|
46
|
+
export { Sha256 } from "./sha256.js";
|
|
@@ -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,335 @@
|
|
|
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("🔴 the FIRST in the drop is the one sent, even when its hash finishes last (forge task 4407)", async () => {
|
|
153
|
+
const lib = library();
|
|
154
|
+
const drop = [file("A/song.wav", "same bytes"), file("B/song again.wav", "same bytes"), file("C/other.wav", "diff bytes")];
|
|
155
|
+
const plan = await planUploads({
|
|
156
|
+
items: drop,
|
|
157
|
+
blobOf: (d) => d.blob,
|
|
158
|
+
ask: (r) => lib.ask(r),
|
|
159
|
+
// The first file's hash is the slow one — a busy disk, or `crypto.subtle` finishing out of order.
|
|
160
|
+
hash: async (blob) => {
|
|
161
|
+
const sha = await sha256OfBlob(blob);
|
|
162
|
+
if (blob === drop[0]?.blob) await new Promise((r) => setTimeout(r, 30));
|
|
163
|
+
return sha;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
expect(plan.upload.map((u) => u.item.path).sort()).toEqual(["A/song.wav", "C/other.wav"]);
|
|
167
|
+
expect(plan.repeats.map((r) => [r.item.path, r.of.path])).toEqual([["B/song again.wav", "A/song.wav"]]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("…and a first that cannot be read still lets its line move — the copy is sent too", async () => {
|
|
171
|
+
const drop = [file("A/song.wav", "same bytes"), file("B/song again.wav", "same bytes")];
|
|
172
|
+
const plan = await planUploads({
|
|
173
|
+
items: drop,
|
|
174
|
+
blobOf: (d) => d.blob,
|
|
175
|
+
ask: async (r) => ({ sizes: r.sizes ?? [], have: {} }),
|
|
176
|
+
hash: async (blob) => {
|
|
177
|
+
if (blob === drop[0]?.blob) {
|
|
178
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
179
|
+
throw new Error("NotReadableError");
|
|
180
|
+
}
|
|
181
|
+
return sha256OfBlob(blob);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
expect(plan.upload.map((u) => u.item.path).sort()).toEqual(["A/song.wav", "B/song again.wav"]);
|
|
185
|
+
expect(plan.stats.hashErrors).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a changed modification time is a memo miss — the file is hashed again", async () => {
|
|
189
|
+
const lib = library();
|
|
190
|
+
const memo = memoryHashMemo();
|
|
191
|
+
await importInto(lib, [file("a.txt", "aaaa", 1)]);
|
|
192
|
+
await importInto(lib, [file("a.txt", "aaaa", 1)], { memo });
|
|
193
|
+
const { hashed, hash } = counter();
|
|
194
|
+
await importInto(lib, [file("a.txt", "aaaa", 2)], { memo, hash });
|
|
195
|
+
expect(hashed).toEqual(["aaaa"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("🔴 a folder sent again with files ADDED sends exactly the new ones", async () => {
|
|
199
|
+
const lib = library();
|
|
200
|
+
const memo = memoryHashMemo();
|
|
201
|
+
await importInto(lib, folder(), { memo });
|
|
202
|
+
await importInto(lib, folder(), { memo }); // the memo now holds all four
|
|
203
|
+
const grown = [...folder(), file("trip/e.jpg", "echo, added later"), file("trip/f.jpg", "foxtrot!")];
|
|
204
|
+
const plan = await importInto(lib, grown, { memo });
|
|
205
|
+
expect(plan.upload.map((u) => u.item.path).sort()).toEqual(["trip/e.jpg", "trip/f.jpg"]);
|
|
206
|
+
expect(plan.duplicates).toHaveLength(4);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("🔴 image.png deleted and replaced by a different image.png is sent — the date moved", async () => {
|
|
210
|
+
const lib = library();
|
|
211
|
+
const memo = memoryHashMemo();
|
|
212
|
+
await importInto(lib, [file("image.png", "the old picture", 1000)]);
|
|
213
|
+
await importInto(lib, [file("image.png", "the old picture", 1000)], { memo });
|
|
214
|
+
const plan = await importInto(lib, [file("image.png", "the NEW picture", 2000)], { memo });
|
|
215
|
+
expect(plan.upload).toHaveLength(1);
|
|
216
|
+
expect(plan.stats.memoHits).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("🔴 …and with a script's stat identity, even with the same size AND the same date", async () => {
|
|
220
|
+
const lib = library();
|
|
221
|
+
const memo = memoryHashMemo();
|
|
222
|
+
const old = { ...file("image.png", "the old picture", 1000), ino: 11 };
|
|
223
|
+
const replaced = { ...file("image.png", "the NEW picture", 1000), ino: 12 }; // same size, same date
|
|
224
|
+
const run = (d: typeof old) =>
|
|
225
|
+
importInto(lib, [d], { memo, identityOf: (x) => statIdentity({ ino: (x as typeof old).ino, ctimeMs: 5 }) });
|
|
226
|
+
await importInto(lib, [old]);
|
|
227
|
+
await run(old);
|
|
228
|
+
const plan = await run(replaced);
|
|
229
|
+
expect(plan.upload).toHaveLength(1);
|
|
230
|
+
expect(plan.stats.memoHits).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a pre-check that cannot be asked sends everything, hashes nothing, and says so", async () => {
|
|
234
|
+
const { hashed, hash } = counter();
|
|
235
|
+
const plan = await planUploads({
|
|
236
|
+
items: folder(),
|
|
237
|
+
blobOf: (d) => d.blob,
|
|
238
|
+
ask: async () => {
|
|
239
|
+
throw new Error("404");
|
|
240
|
+
},
|
|
241
|
+
hash,
|
|
242
|
+
});
|
|
243
|
+
expect(plan.upload).toHaveLength(4);
|
|
244
|
+
expect(hashed).toEqual([]);
|
|
245
|
+
expect(plan.stats.degraded).toContain("404");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a hash question that fails mid-drop sends the rest rather than losing them", async () => {
|
|
249
|
+
const lib = library();
|
|
250
|
+
await importInto(lib, folder());
|
|
251
|
+
let calls = 0;
|
|
252
|
+
const plan = await planUploads({
|
|
253
|
+
items: folder(),
|
|
254
|
+
blobOf: (d) => d.blob,
|
|
255
|
+
ask: async (r) => {
|
|
256
|
+
if (++calls > 1) throw new Error("503");
|
|
257
|
+
return lib.ask(r);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
expect(plan.upload).toHaveLength(4);
|
|
261
|
+
expect(plan.stats.degraded).toContain("503");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("verdicts stream — an upload can start before the last file is hashed", async () => {
|
|
265
|
+
const lib = library();
|
|
266
|
+
await importInto(lib, [file("old.bin", "x".repeat(100))]);
|
|
267
|
+
const order: string[] = [];
|
|
268
|
+
const slowHash = async (blob: Blob) => {
|
|
269
|
+
await new Promise((r) => setTimeout(r, blob.size === 100 ? 30 : 0));
|
|
270
|
+
return sha256OfBlob(blob);
|
|
271
|
+
};
|
|
272
|
+
await planUploads({
|
|
273
|
+
items: [file("old copy.bin", "x".repeat(100)), file("new.bin", "fresh")],
|
|
274
|
+
blobOf: (d) => d.blob,
|
|
275
|
+
ask: (r) => lib.ask(r),
|
|
276
|
+
hash: slowHash,
|
|
277
|
+
onVerdict: (v: Verdict<Dropped>) => order.push(`${v.kind}:${v.item.path}`),
|
|
278
|
+
});
|
|
279
|
+
expect(order).toEqual(["upload:new.bin", "duplicate:old copy.bin"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("hashes in flight stay inside the memory budget", async () => {
|
|
283
|
+
const drop = Array.from({ length: 6 }, (_, i) => file(`f${i}`, String(i).repeat(10)));
|
|
284
|
+
let live = 0;
|
|
285
|
+
let peak = 0;
|
|
286
|
+
await planUploads({
|
|
287
|
+
items: drop,
|
|
288
|
+
blobOf: (d) => d.blob,
|
|
289
|
+
ask: async (r) => ({ sizes: r.sizes ?? [], have: {} }),
|
|
290
|
+
concurrency: 6,
|
|
291
|
+
maxInFlightBytes: 25,
|
|
292
|
+
hash: async (blob) => {
|
|
293
|
+
peak = Math.max(peak, ++live);
|
|
294
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
295
|
+
live--;
|
|
296
|
+
return sha256OfBlob(blob);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
expect(peak).toBe(2); // 10 + 10 bytes fit in 25; a third would not
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a file that cannot be read is sent unhashed — it does not end the plan for the rest", async () => {
|
|
303
|
+
const lib = library();
|
|
304
|
+
await importInto(lib, folder());
|
|
305
|
+
const plan = await planUploads({
|
|
306
|
+
items: folder(),
|
|
307
|
+
blobOf: (d) => d.blob,
|
|
308
|
+
ask: (r) => lib.ask(r),
|
|
309
|
+
hash: async (blob) => {
|
|
310
|
+
if ((await blob.text()) === "delta") throw new Error("NotReadableError");
|
|
311
|
+
return sha256OfBlob(blob);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
expect(plan.upload.map((u) => u.item.path)).toEqual(["trip/d.mov"]);
|
|
315
|
+
expect(plan.duplicates).toHaveLength(3);
|
|
316
|
+
expect(plan.stats.hashErrors).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("an abort stops the hashing and rejects", async () => {
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const plan = planUploads({
|
|
322
|
+
items: [file("a", "same"), file("b", "same"), file("c", "same")],
|
|
323
|
+
blobOf: (d) => d.blob,
|
|
324
|
+
ask: async (r) => ({ sizes: r.sizes ?? [], have: {} }),
|
|
325
|
+
concurrency: 1,
|
|
326
|
+
signal: controller.signal,
|
|
327
|
+
hash: async (blob, { signal }) => {
|
|
328
|
+
controller.abort(new Error("stopped"));
|
|
329
|
+
if (signal?.aborted) throw signal.reason;
|
|
330
|
+
return sha256OfBlob(blob);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
await expect(plan).rejects.toThrow("stopped");
|
|
334
|
+
});
|
|
335
|
+
});
|