diff --git a/src/sync/execute.test.ts b/src/sync/execute.test.ts index 2062b8d..13e28a7 100644 --- a/src/sync/execute.test.ts +++ b/src/sync/execute.test.ts @@ -1,9 +1,16 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { hashBytes } from "../vault/vault.ts"; import { executeSyncPlan } from "./execute.ts"; -import { fakeLocalWriter, fakeReader, fakeStorage } from "./fake.ts"; +import { empty, fakeLocalWriter, fakeReader, fakeStorage, file, snapshot } from "./fake.ts"; import { conflictCopyPath, type SyncAction } from "./plan.ts"; +// hashOf returns the real content hash of text, for building local snapshots whose entries +// executeSyncPlan's drift check can verify against a fake reader's live bytes. +async function hashOf(text: string): Promise { + return hashBytes(new TextEncoder().encode(text)); +} + test("executeSyncPlan: push reads the local file and puts it remotely", async () => { const reader = fakeReader({ "a.md": "hello" }); const { writer, files } = fakeLocalWriter(); @@ -11,6 +18,7 @@ test("executeSyncPlan: push reads the local file and puts it remotely", async () const failures = await executeSyncPlan( [{ kind: "push", path: "a.md" }], + empty, reader, writer, storage, @@ -27,7 +35,7 @@ test("executeSyncPlan: pushDelete removes the remote object", async () => { const { writer } = fakeLocalWriter(); const { storage, objects } = fakeStorage({ "a.md": "hello" }); - await executeSyncPlan([{ kind: "pushDelete", path: "a.md" }], reader, writer, storage, 1); + await executeSyncPlan([{ kind: "pushDelete", path: "a.md" }], empty, reader, writer, storage, 1); assert.equal(objects.has("a.md"), false); }); @@ -39,6 +47,7 @@ test("executeSyncPlan: pull fetches the remote object and writes it locally", as const failures = await executeSyncPlan( [{ kind: "pull", path: "a.md" }], + empty, reader, writer, storage, @@ -49,17 +58,138 @@ test("executeSyncPlan: pull fetches the remote object and writes it locally", as assert.equal(files.get("a.md"), "hello"); }); -test("executeSyncPlan: pullDelete removes the local file", async () => { - const reader = fakeReader({}); +test("executeSyncPlan: pull overwrites a local file that still matches the snapshot", async () => { + const reader = fakeReader({ "a.md": "unchanged" }); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "unchanged"); + const local = snapshot(file("a.md", await hashOf("unchanged"))); + const { storage } = fakeStorage({ "a.md": "remote edit" }); + + const failures = await executeSyncPlan( + [{ kind: "pull", path: "a.md" }], + local, + reader, + writer, + storage, + 1, + ); + + assert.deepEqual(failures, []); + assert.equal(files.get("a.md"), "remote edit"); +}); + +test("executeSyncPlan: pull onto a file edited after the snapshot is refused and the edit survives", async () => { + // Reproduces #86. The pull was planned from a snapshot in which a.md was unchanged, but the + // user edited it before the plan reached this action. Overwriting it now would silently discard + // that edit, so the action must fail instead (the next sync replans it as a conflict) and the + // rest of the plan must still run. + const reader = fakeReader({ "a.md": "edited after snapshot" }); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "edited after snapshot"); + const local = snapshot(file("a.md", await hashOf("as snapshotted"))); + const { storage } = fakeStorage({ "a.md": "remote edit", "b.md": "remote b" }); + + const actions: SyncAction[] = [ + { kind: "pull", path: "a.md" }, + { kind: "pull", path: "b.md" }, + ]; + const failures = await executeSyncPlan(actions, local, reader, writer, storage, 1); + + assert.deepEqual(failures, [ + { path: "a.md", message: "changed locally mid sync; sync again to reconcile" }, + ]); + assert.equal(files.get("a.md"), "edited after snapshot"); + // The following action still ran. + assert.equal(files.get("b.md"), "remote b"); +}); + +test("executeSyncPlan: pull onto a file created after the snapshot is refused", async () => { + // The snapshot saw nothing at this path (the pull was planned for a remote-only file), but the + // user created a file there before the plan reached this action. Writing the remote version + // over it would discard a file the plan never knew existed. + const reader = fakeReader({ "a.md": "created after snapshot" }); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "created after snapshot"); + const { storage } = fakeStorage({ "a.md": "remote edit" }); + + const failures = await executeSyncPlan( + [{ kind: "pull", path: "a.md" }], + empty, + reader, + writer, + storage, + 1, + ); + + assert.deepEqual(failures, [ + { path: "a.md", message: "changed locally mid sync; sync again to reconcile" }, + ]); + assert.equal(files.get("a.md"), "created after snapshot"); +}); + +test("executeSyncPlan: pullDelete removes a local file that still matches the snapshot", async () => { + const reader = fakeReader({ "a.md": "hello" }); const { writer, files } = fakeLocalWriter(); files.set("a.md", "hello"); + const local = snapshot(file("a.md", await hashOf("hello"))); const { storage } = fakeStorage(); - await executeSyncPlan([{ kind: "pullDelete", path: "a.md" }], reader, writer, storage, 1); + await executeSyncPlan([{ kind: "pullDelete", path: "a.md" }], local, reader, writer, storage, 1); assert.equal(files.has("a.md"), false); }); +test("executeSyncPlan: pullDelete of a file edited after the snapshot is refused and the edit survives", async () => { + // Reproduces #86 for the delete side: the remote deletion was planned against a snapshot in + // which a.md was unchanged, but the user edited it in the window since. Deleting it now would + // silently discard the edit; the next sync replans this as a conflict instead. + const reader = fakeReader({ "a.md": "edited after snapshot" }); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "edited after snapshot"); + const local = snapshot(file("a.md", await hashOf("as snapshotted"))); + const { storage } = fakeStorage(); + + const failures = await executeSyncPlan( + [{ kind: "pullDelete", path: "a.md" }], + local, + reader, + writer, + storage, + 1, + ); + + assert.deepEqual(failures, [ + { path: "a.md", message: "changed locally mid sync; sync again to reconcile" }, + ]); + assert.equal(files.get("a.md"), "edited after snapshot"); +}); + +test("executeSyncPlan: pullDelete of a file that exists but cannot be read is refused, never treated as absent", async () => { + // A read failing on a file that is still present (a permission error, say) must not read as + // "nothing to discard": the delete could succeed against content the drift check never + // verified. The action must fail with the read's own error and leave the file alone. + const reader = fakeReader({ "a.md": "hello" }); + reader.readFile = async () => { + throw new Error("EACCES: permission denied"); + }; + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "hello"); + const local = snapshot(file("a.md", await hashOf("hello"))); + const { storage } = fakeStorage(); + + const failures = await executeSyncPlan( + [{ kind: "pullDelete", path: "a.md" }], + local, + reader, + writer, + storage, + 1, + ); + + assert.deepEqual(failures, [{ path: "a.md", message: "EACCES: permission denied" }]); + assert.equal(files.get("a.md"), "hello"); +}); + test("executeSyncPlan: a conflict renames the local copy, pushes it to storage, and pulls the remote version clean", async () => { const reader = fakeReader({ "a.md": "local edit" }); const { writer, files } = fakeLocalWriter(); @@ -69,6 +199,7 @@ test("executeSyncPlan: a conflict renames the local copy, pushes it to storage, const failures = await executeSyncPlan( [{ kind: "conflict", path: "a.md", deletedSide: "none" }], + empty, reader, writer, storage, @@ -92,6 +223,7 @@ test("executeSyncPlan: a conflict with nothing local to preserve just pulls the const failures = await executeSyncPlan( [{ kind: "conflict", path: "a.md", deletedSide: "local" }], + empty, reader, writer, storage, @@ -103,6 +235,31 @@ test("executeSyncPlan: a conflict with nothing local to preserve just pulls the assert.equal(files.has(conflictCopyPath("a.md", now)), false); }); +test("executeSyncPlan: a conflict restore onto a path recreated after the snapshot is refused", async () => { + // The snapshot saw this path as locally deleted, so the plan decided the remote edit could be + // restored with nothing to preserve. The user then recreated the file before the plan reached + // this action; overwriting it now would discard content the plan never saw (#86). + const reader = fakeReader({ "a.md": "recreated after snapshot" }); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "recreated after snapshot"); + const { storage } = fakeStorage({ "a.md": "remote edit" }); + const now = Date.parse("2026-07-14T10:00:00.000Z"); + + const failures = await executeSyncPlan( + [{ kind: "conflict", path: "a.md", deletedSide: "local" }], + empty, + reader, + writer, + storage, + now, + ); + + assert.deepEqual(failures, [ + { path: "a.md", message: "changed locally mid sync; sync again to reconcile" }, + ]); + assert.equal(files.get("a.md"), "recreated after snapshot"); +}); + test("executeSyncPlan: a conflict with nothing remote to pull preserves the local edit as a copy and reports no failure", async () => { const reader = fakeReader({ "a.md": "local edit" }); const { writer, files } = fakeLocalWriter(); @@ -112,6 +269,7 @@ test("executeSyncPlan: a conflict with nothing remote to pull preserves the loca const failures = await executeSyncPlan( [{ kind: "conflict", path: "a.md", deletedSide: "remote" }], + empty, reader, writer, storage, @@ -136,7 +294,7 @@ test("executeSyncPlan: a push whose local file vanished is reported and doesn't { kind: "push", path: "a.md" }, { kind: "push", path: "b.md" }, ]; - const failures = await executeSyncPlan(actions, reader, writer, storage, 1); + const failures = await executeSyncPlan(actions, empty, reader, writer, storage, 1); assert.deepEqual(failures, [{ path: "a.md", message: "no such file: a.md" }]); assert.equal(objects.get("b.md"), "world"); @@ -156,7 +314,7 @@ test("executeSyncPlan: a conflict whose local file vanished is reported, nothing { kind: "conflict", path: "a.md", deletedSide: "none" }, { kind: "push", path: "b.md" }, ]; - const failures = await executeSyncPlan(actions, reader, writer, storage, now); + const failures = await executeSyncPlan(actions, empty, reader, writer, storage, now); assert.deepEqual(failures, [{ path: "a.md", message: "no such file: a.md" }]); // No conflict copy was created locally or remotely from a file that wasn't there to preserve. @@ -182,7 +340,7 @@ test("executeSyncPlan: a failed push is reported and doesn't stop the rest of th { kind: "push", path: "a.md" }, { kind: "push", path: "b.md" }, ]; - const failures = await executeSyncPlan(actions, reader, writer, storage, 1); + const failures = await executeSyncPlan(actions, empty, reader, writer, storage, 1); assert.deepEqual(failures, [{ path: "a.md", message: "Storage rejected the write (500)" }]); assert.equal(objects.get("b.md"), "world"); @@ -206,7 +364,7 @@ test("executeSyncPlan: a pull whose local write throws is reported and doesn't s { kind: "pull", path: "a.md" }, { kind: "pull", path: "b.md" }, ]; - const failures = await executeSyncPlan(actions, reader, writer, storage, 1); + const failures = await executeSyncPlan(actions, empty, reader, writer, storage, 1); assert.deepEqual(failures, [{ path: "a.md", message: "EACCES: permission denied" }]); assert.equal(files.get("b.md"), "pulled"); @@ -227,6 +385,7 @@ test("executeSyncPlan: a conflict whose rename throws is reported and the local const failures = await executeSyncPlan( [{ kind: "conflict", path: "a.md", deletedSide: "none" }], + empty, reader, writer, storage, diff --git a/src/sync/execute.ts b/src/sync/execute.ts index b7aaed8..d9385ba 100644 --- a/src/sync/execute.ts +++ b/src/sync/execute.ts @@ -1,7 +1,11 @@ import type { StorageClient } from "../storage/storage.ts"; -import type { Reader } from "../vault/vault.ts"; +import { byPath, type FileState, hashBytes, type Reader, type Snapshot } from "../vault/vault.ts"; import { conflictCopyPath, type SyncAction } from "./plan.ts"; +// DRIFT_MESSAGE is the failure reported when a local file changed after the snapshot an action +// was planned from; the next sync re-snapshots and replans the path as a conflict. +const DRIFT_MESSAGE = "changed locally mid sync; sync again to reconcile"; + // LocalWriter applies changes decided by a sync to the local vault. The real implementation // writes through the vault adapter (see vault/obsidian.ts); tests use an in-memory fake. export type LocalWriter = { @@ -17,16 +21,20 @@ export type SyncFailure = { }; // executeSyncPlan carries out every action against reader/localWriter (the local vault) and -// storage (the remote bucket), and reports whatever couldn't be completed. now is passed in -// rather than read internally so a conflict's copy name is deterministic under test. +// storage (the remote bucket), and reports whatever couldn't be completed. local is the snapshot +// the plan was made from, so each destructive local write can first check the file hasn't changed +// since (#86). now is passed in rather than read internally so a conflict's copy name is +// deterministic under test. export async function executeSyncPlan( actions: SyncAction[], + local: Snapshot, reader: Reader, localWriter: LocalWriter, storage: StorageClient, now: number, ): Promise { const failures: SyncFailure[] = []; + const localByPath = byPath(local.files); for (const action of actions) { if (action.kind === "push") { @@ -53,6 +61,11 @@ export async function executeSyncPlan( } if (action.kind === "pull") { + const drift = await checkLocalDrift(reader, action.path, localByPath.get(action.path)); + if (drift !== null) { + failures.push(drift); + continue; + } const result = await storage.getObject(action.path); if (!result.ok || result.body === null) { failures.push({ path: action.path, message: result.message }); @@ -69,6 +82,11 @@ export async function executeSyncPlan( } if (action.kind === "pullDelete") { + const drift = await checkLocalDrift(reader, action.path, localByPath.get(action.path)); + if (drift !== null) { + failures.push(drift); + continue; + } const failure = await applyLocalWrite(action.path, () => localWriter.deleteFile(action.path)); if (failure !== null) { failures.push(failure); @@ -77,8 +95,14 @@ export async function executeSyncPlan( } // conflict, deletedSide "local": the user deleted their copy, so there is no local edit to - // preserve; the remote edit simply wins and is restored onto the local path. + // preserve; the remote edit simply wins and is restored onto the local path. The snapshot has + // no entry here, so any file found now was recreated after it and must not be overwritten. if (action.deletedSide === "local") { + const drift = await checkLocalDrift(reader, action.path, localByPath.get(action.path)); + if (drift !== null) { + failures.push(drift); + continue; + } const result = await storage.getObject(action.path); if (!result.ok || result.body === null) { failures.push({ path: action.path, message: result.message }); @@ -156,6 +180,42 @@ async function applyLocalWrite(path: string, op: () => Promise): Promise { + const exists = await reader.fileExists(path); + if (!exists) { + return null; + } + let bytes: Uint8Array; + try { + bytes = await reader.readFile(path); + } catch (err) { + return { path, message: localFailureMessage(err) }; + } + if (expected === undefined) { + return { path, message: DRIFT_MESSAGE }; + } + if ((await hashBytes(bytes)) !== expected.hash) { + return { path, message: DRIFT_MESSAGE }; + } + + return null; +} + // localFailureMessage turns whatever a local vault operation threw into a SyncFailure message. // readFile throws when a file vanishes between the snapshot and now (a user deleting it mid sync), // and writeFile/deleteFile/renameFile can throw on a disk full or permission error; routing all of diff --git a/src/sync/fake.ts b/src/sync/fake.ts index 66b3801..24dc436 100644 --- a/src/sync/fake.ts +++ b/src/sync/fake.ts @@ -36,6 +36,9 @@ export function fakeLocalWriter(): { writer: LocalWriter; files: Map): Reader { return { + fileExists: async (path) => { + return files[path] !== undefined; + }, listFiles: async () => { const list = []; for (const [path, content] of Object.entries(files)) { diff --git a/src/sync/sync.test.ts b/src/sync/sync.test.ts index 164e50f..c7be488 100644 --- a/src/sync/sync.test.ts +++ b/src/sync/sync.test.ts @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { Snapshot } from "../vault/vault.ts"; +import { hashBytes, type Snapshot } from "../vault/vault.ts"; import { empty, fakeLocalWriter, fakeReader, fakeStorage, file, snapshot } from "./fake.ts"; -import { MANIFEST_KEY } from "./plan.ts"; +import { conflictCopyPath, MANIFEST_KEY } from "./plan.ts"; import { adoptLiveStats, readRemoteManifest, syncOnce } from "./sync.ts"; +// hashOf returns the real content hash of text, for snapshots whose entries executeSyncPlan's +// drift check will verify against live bytes. +async function hashOf(text: string): Promise { + return hashBytes(new TextEncoder().encode(text)); +} + test("readRemoteManifest: a 404 is treated as an empty snapshot", async () => { const { storage } = fakeStorage(); @@ -87,8 +93,9 @@ test("syncOnce: a present but empty manifest still trusts the ancestor and pulls // really did produce an empty remote. A file the ancestor knew about, unchanged locally, was // deleted remotely, and pullDelete is the correct result that must NOT be suppressed. The reader // reports the file at the same size and mtime as the ancestor so takeSnapshot reuses its hash and - // sees no local change. - const previous = snapshot(file("a.md", "h1")); + // sees no local change; the hash is the real content hash so the pullDelete's drift check also + // sees the file as unchanged. + const previous = snapshot({ path: "a.md", size: 2, mtime: 1, hash: await hashOf("xy") }); const reader = fakeReader({ "a.md": "xy" }); const { writer, files } = fakeLocalWriter(); files.set("a.md", "xy"); @@ -213,6 +220,60 @@ test("syncOnce: a file changed mid sync is not recorded in the manifest and is p assert.equal(objects.get("c.md"), "created mid sync"); }); +test("syncOnce: a file edited mid sync is never overwritten by a pull, and the retry preserves it as a conflict copy", async () => { + // Reproduces #86. Both files are in sync locally and edited remotely, so the pass plans a pull + // for each. While a.md's pull is fetching, the user edits b.md; before the fix the pull planned + // for b.md then overwrote that edit with the remote version, silently discarding it. The pass + // must refuse that pull and fail instead, and because state.json never advances, the retry sees + // b.md changed on both sides and preserves the edit as a conflict copy. + const ancestor = snapshot( + { path: "a.md", size: 4, mtime: 1, hash: await hashOf("a v1") }, + { path: "b.md", size: 4, mtime: 1, hash: await hashOf("b v1") }, + ); + const remoteManifest = JSON.stringify( + snapshot(file("a.md", await hashOf("a v2")), file("b.md", await hashOf("b v2"))), + ); + const { storage, objects } = fakeStorage({ + [MANIFEST_KEY]: remoteManifest, + "a.md": "a v2", + "b.md": "b v2", + }); + const readerFiles: Record = { "a.md": "a v1", "b.md": "b v1" }; + const reader = fakeReader(readerFiles); + const { writer, files } = fakeLocalWriter(); + files.set("a.md", "a v1"); + files.set("b.md", "b v1"); + const inner = storage.getObject; + let edited = false; + storage.getObject = async (key) => { + if (key === "a.md" && !edited) { + edited = true; + readerFiles["b.md"] = "edited mid sync"; + files.set("b.md", "edited mid sync"); + } + return inner(key); + }; + const now = Date.parse("2026-07-14T10:00:00.000Z"); + + const outcome = await syncOnce(ancestor, reader, writer, storage, now); + + assert.equal(outcome.ok, false); + // The edit survived, a.md's pull still landed, and no manifest was uploaded. + assert.equal(files.get("b.md"), "edited mid sync"); + assert.equal(files.get("a.md"), "a v2"); + assert.equal(objects.get(MANIFEST_KEY), remoteManifest); + + // The retry diffs against the same ancestor: b.md changed locally and remotely, a genuine + // conflict, so the edit is renamed to a conflict copy, pushed, and the remote version pulled. + const retry = await syncOnce(ancestor, reader, writer, storage, now); + + assert.equal(retry.ok, true); + const copyPath = conflictCopyPath("b.md", now); + assert.equal(files.get(copyPath), "edited mid sync"); + assert.equal(objects.get(copyPath), "edited mid sync"); + assert.equal(files.get("b.md"), "b v2"); +}); + test("syncOnce: two first syncs racing for an empty bucket, the loser fails instead of clobbering", async () => { // Both devices see no manifest and plan a first sync. The other device's manifest lands while // this one is mid pass; the "ifAbsent" conditional upload must lose rather than overwrite it. diff --git a/src/sync/sync.ts b/src/sync/sync.ts index 155c515..4139ee8 100644 --- a/src/sync/sync.ts +++ b/src/sync/sync.ts @@ -121,7 +121,7 @@ export async function syncOnce( const local = await takeSnapshot(reader, ancestor); const actions = planSync(ancestor, local, remote.snapshot); - const failures = await executeSyncPlan(actions, reader, localWriter, storage, now); + const failures = await executeSyncPlan(actions, local, reader, localWriter, storage, now); if (failures.length > 0) { return { ok: false, message: `${failures.length} file(s) failed to sync`, failures }; } diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index fc80c9a..43df0be 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -31,6 +31,9 @@ export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter { // lives inside .obsidian/plugins/geode/) never shows up as a vault file to snapshot. export function createObsidianReader(vault: Vault): Reader { return { + fileExists: async (path) => { + return vault.getFileByPath(path) !== null; + }, listFiles: async () => { const files: FileInfo[] = []; for (const file of vault.getFiles()) { diff --git a/src/vault/vault.test.ts b/src/vault/vault.test.ts index b6366b5..5455527 100644 --- a/src/vault/vault.test.ts +++ b/src/vault/vault.test.ts @@ -17,6 +17,9 @@ function fakeReader(files: Record): } { let reads = 0; const reader: Reader = { + fileExists: async (path) => { + return files[path] !== undefined; + }, listFiles: async () => { const list: FileInfo[] = []; for (const [path, file] of Object.entries(files)) { @@ -116,6 +119,9 @@ test("takeSnapshot: concurrency is bounded by the limit", async () => { let inflight = 0; let peakInflight = 0; const reader: Reader = { + fileExists: async (path) => { + return files[path] !== undefined; + }, listFiles: async () => { const list: FileInfo[] = []; for (const [path, file] of Object.entries(files)) { diff --git a/src/vault/vault.ts b/src/vault/vault.ts index 965c2ef..0404c9e 100644 --- a/src/vault/vault.ts +++ b/src/vault/vault.ts @@ -19,9 +19,11 @@ export type FileState = { hash: string; }; -// Reader lists files present in the vault right now and reads their bytes. The real -// implementation wraps Obsidian's Vault API (see obsidian.ts); tests use an in-memory fake. +// Reader lists files present in the vault right now, reads their bytes, and answers whether a +// path currently exists, so a failed read on a present file is never mistaken for absence. The +// real implementation wraps Obsidian's Vault API (see obsidian.ts); tests use an in-memory fake. export type Reader = { + fileExists: (path: string) => Promise; listFiles: () => Promise; readFile: (path: string) => Promise; }; @@ -75,6 +77,18 @@ export function diffSnapshots(previous: Snapshot, current: Snapshot): Change[] { return changes; } +// hashBytes returns the lowercase hex SHA-256 digest of data. +export async function hashBytes(data: Uint8Array): Promise { + // Same TS/DOM lib generic mismatch as storage.ts's BodyInit cast: Uint8Array + // vs BufferSource's stricter ArrayBuffer expectation. Not a real runtime issue. + const digest = await crypto.subtle.digest("SHA-256", data as BufferSource); + let hex = ""; + for (const byte of new Uint8Array(digest)) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +} + // isSnapshot reports whether a value parsed from untrusted JSON (a remote manifest, a local // state.json) is shaped like a snapshot: a non-null object with a files array. Callers use this // instead of a blind `as Snapshot` cast, so a body that parses but is the wrong shape becomes @@ -115,18 +129,6 @@ export async function takeSnapshot( return { files }; } -// hashBytes returns the lowercase hex SHA-256 digest of data. -async function hashBytes(data: Uint8Array): Promise { - // Same TS/DOM lib generic mismatch as storage.ts's BodyInit cast: Uint8Array - // vs BufferSource's stricter ArrayBuffer expectation. Not a real runtime issue. - const digest = await crypto.subtle.digest("SHA-256", data as BufferSource); - let hex = ""; - for (const byte of new Uint8Array(digest)) { - hex += byte.toString(16).padStart(2, "0"); - } - return hex; -} - // mapWithConcurrency runs fn over each item with at most limit concurrent invocations, preserving // input order in the returned results. async function mapWithConcurrency(