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
- settle(item, known);
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
- settle(item, sha256);
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
- decide({ kind: "upload", item });
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.0",
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
- settle(item, known);
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
- settle(item, sha256);
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
- decide({ kind: "upload", item });
327
+ arrive(item, null);
298
328
  })
299
329
  .finally(() => {
300
330
  running--;