cursedbelt-core 2.14.0 → 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.
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* hashes AS they finish: one request in flight, each carrying every hash completed since the
|
|
15
15
|
* last. Uploads of the first new files begin while later ones are still being hashed.
|
|
16
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
|
|
17
|
+
* asking anybody, and only the first IN THE DROP is sent — never whichever hash happened to
|
|
18
|
+
* finish first. Hashes finish out of order (a busy disk, `crypto.subtle` on its own threads), so
|
|
19
|
+
* a file's hash is held until every earlier file of its SIZE has one: only those can be its
|
|
20
|
+
* repeat, and they started hashing before it did. music's gate went red on exactly that race
|
|
21
|
+
* (forge task 4407): "song again.wav" sent and "song.wav" called the copy.
|
|
18
22
|
*
|
|
19
23
|
* A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
|
|
20
24
|
* it would have before the pre-check existed (each app's own after-the-fact check still stands),
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* hashes AS they finish: one request in flight, each carrying every hash completed since the
|
|
15
15
|
* last. Uploads of the first new files begin while later ones are still being hashed.
|
|
16
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
|
|
17
|
+
* asking anybody, and only the first IN THE DROP is sent — never whichever hash happened to
|
|
18
|
+
* finish first. Hashes finish out of order (a busy disk, `crypto.subtle` on its own threads), so
|
|
19
|
+
* a file's hash is held until every earlier file of its SIZE has one: only those can be its
|
|
20
|
+
* repeat, and they started hashing before it did. music's gate went red on exactly that race
|
|
21
|
+
* (forge task 4407): "song again.wav" sent and "song.wav" called the copy.
|
|
18
22
|
*
|
|
19
23
|
* A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
|
|
20
24
|
* it would have before the pre-check existed (each app's own after-the-fact check still stands),
|
|
@@ -168,12 +172,40 @@ export async function planUploads(options) {
|
|
|
168
172
|
else
|
|
169
173
|
linger ??= setTimeout(kick, lingerMs);
|
|
170
174
|
};
|
|
175
|
+
// Step 4's drop order: each size shared inside the drop is a line, in drop order, and a hash
|
|
176
|
+
// (or a failure to read, `null`) is settled only once everything ahead of it in its line has one.
|
|
177
|
+
const lines = new Map();
|
|
178
|
+
for (const item of candidates) {
|
|
179
|
+
const size = sizeOf(item);
|
|
180
|
+
if ((inDrop.get(size) ?? 0) > 1)
|
|
181
|
+
lines.set(size, [...(lines.get(size) ?? []), item]);
|
|
182
|
+
}
|
|
183
|
+
const arrived = new Map();
|
|
184
|
+
const apply = (item, sha256) => {
|
|
185
|
+
if (sha256 !== null)
|
|
186
|
+
settle(item, sha256);
|
|
187
|
+
else
|
|
188
|
+
decide({ kind: "upload", item });
|
|
189
|
+
};
|
|
190
|
+
const arrive = (item, sha256) => {
|
|
191
|
+
const line = lines.get(sizeOf(item));
|
|
192
|
+
if (!line) {
|
|
193
|
+
apply(item, sha256);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
arrived.set(item, sha256);
|
|
197
|
+
while (line.length > 0 && arrived.has(line[0])) {
|
|
198
|
+
const head = line.shift();
|
|
199
|
+
apply(head, arrived.get(head) ?? null);
|
|
200
|
+
arrived.delete(head);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
171
203
|
const toHash = [];
|
|
172
204
|
for (const item of candidates) {
|
|
173
205
|
const known = keyOf ? remembered.get(keyOf(item)) : undefined;
|
|
174
206
|
if (known) {
|
|
175
207
|
result.stats.memoHits++;
|
|
176
|
-
|
|
208
|
+
arrive(item, known);
|
|
177
209
|
}
|
|
178
210
|
else
|
|
179
211
|
toHash.push(item);
|
|
@@ -206,7 +238,7 @@ export async function planUploads(options) {
|
|
|
206
238
|
result.stats.hashedBytes += blob.size;
|
|
207
239
|
if (keyOf)
|
|
208
240
|
toRemember.push([keyOf(item), sha256]);
|
|
209
|
-
|
|
241
|
+
arrive(item, sha256);
|
|
210
242
|
})
|
|
211
243
|
.catch((error) => {
|
|
212
244
|
// An abort ends the plan. One file that cannot be READ does not: it is sent
|
|
@@ -218,7 +250,7 @@ export async function planUploads(options) {
|
|
|
218
250
|
return;
|
|
219
251
|
}
|
|
220
252
|
result.stats.hashErrors++;
|
|
221
|
-
|
|
253
|
+
arrive(item, null);
|
|
222
254
|
})
|
|
223
255
|
.finally(() => {
|
|
224
256
|
running--;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.1",
|
|
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.",
|
|
@@ -149,6 +149,42 @@ describe("planUploads", () => {
|
|
|
149
149
|
expect(plan.repeats[0].of).toBe(plan.upload[0].item);
|
|
150
150
|
});
|
|
151
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
|
+
|
|
152
188
|
test("a changed modification time is a memo miss — the file is hashed again", async () => {
|
|
153
189
|
const lib = library();
|
|
154
190
|
const memo = memoryHashMemo();
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* hashes AS they finish: one request in flight, each carrying every hash completed since the
|
|
15
15
|
* last. Uploads of the first new files begin while later ones are still being hashed.
|
|
16
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
|
|
17
|
+
* asking anybody, and only the first IN THE DROP is sent — never whichever hash happened to
|
|
18
|
+
* finish first. Hashes finish out of order (a busy disk, `crypto.subtle` on its own threads), so
|
|
19
|
+
* a file's hash is held until every earlier file of its SIZE has one: only those can be its
|
|
20
|
+
* repeat, and they started hashing before it did. music's gate went red on exactly that race
|
|
21
|
+
* (forge task 4407): "song again.wav" sent and "song.wav" called the copy.
|
|
18
22
|
*
|
|
19
23
|
* A pre-check that FAILS never blocks an upload: the sizes question failing sends everything as
|
|
20
24
|
* it would have before the pre-check existed (each app's own after-the-fact check still stands),
|
|
@@ -247,12 +251,38 @@ export async function planUploads<T>(options: PlanOptions<T>): Promise<PlanResul
|
|
|
247
251
|
else linger ??= setTimeout(kick, lingerMs);
|
|
248
252
|
};
|
|
249
253
|
|
|
254
|
+
// Step 4's drop order: each size shared inside the drop is a line, in drop order, and a hash
|
|
255
|
+
// (or a failure to read, `null`) is settled only once everything ahead of it in its line has one.
|
|
256
|
+
const lines = new Map<number, T[]>();
|
|
257
|
+
for (const item of candidates) {
|
|
258
|
+
const size = sizeOf(item);
|
|
259
|
+
if ((inDrop.get(size) ?? 0) > 1) lines.set(size, [...(lines.get(size) ?? []), item]);
|
|
260
|
+
}
|
|
261
|
+
const arrived = new Map<T, string | null>();
|
|
262
|
+
const apply = (item: T, sha256: string | null): void => {
|
|
263
|
+
if (sha256 !== null) settle(item, sha256);
|
|
264
|
+
else decide({ kind: "upload", item });
|
|
265
|
+
};
|
|
266
|
+
const arrive = (item: T, sha256: string | null): void => {
|
|
267
|
+
const line = lines.get(sizeOf(item));
|
|
268
|
+
if (!line) {
|
|
269
|
+
apply(item, sha256);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
arrived.set(item, sha256);
|
|
273
|
+
while (line.length > 0 && arrived.has(line[0] as T)) {
|
|
274
|
+
const head = line.shift() as T;
|
|
275
|
+
apply(head, arrived.get(head) ?? null);
|
|
276
|
+
arrived.delete(head);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
250
280
|
const toHash: T[] = [];
|
|
251
281
|
for (const item of candidates) {
|
|
252
282
|
const known = keyOf ? remembered.get(keyOf(item)) : undefined;
|
|
253
283
|
if (known) {
|
|
254
284
|
result.stats.memoHits++;
|
|
255
|
-
|
|
285
|
+
arrive(item, known);
|
|
256
286
|
} else toHash.push(item);
|
|
257
287
|
}
|
|
258
288
|
|
|
@@ -282,7 +312,7 @@ export async function planUploads<T>(options: PlanOptions<T>): Promise<PlanResul
|
|
|
282
312
|
result.stats.hashedFiles++;
|
|
283
313
|
result.stats.hashedBytes += blob.size;
|
|
284
314
|
if (keyOf) toRemember.push([keyOf(item), sha256]);
|
|
285
|
-
|
|
315
|
+
arrive(item, sha256);
|
|
286
316
|
})
|
|
287
317
|
.catch((error) => {
|
|
288
318
|
// An abort ends the plan. One file that cannot be READ does not: it is sent
|
|
@@ -294,7 +324,7 @@ export async function planUploads<T>(options: PlanOptions<T>): Promise<PlanResul
|
|
|
294
324
|
return;
|
|
295
325
|
}
|
|
296
326
|
result.stats.hashErrors++;
|
|
297
|
-
|
|
327
|
+
arrive(item, null);
|
|
298
328
|
})
|
|
299
329
|
.finally(() => {
|
|
300
330
|
running--;
|