This commit is contained in:
Andras Schmelczer 2026-09-27 08:29:17 +01:00
commit 3dab48f58b

View file

@ -4,7 +4,7 @@ import { test } from "node:test";
import { Database, emptyState } from "../sync-client/src/persistence/database";
import { Syncer } from "../sync-client/src/sync-operations/syncer";
import { SyncClient, type StoredClient } from "../sync-client/src/sync-client";
import { toStoredSnapshot } from "../sync-client/src/snapshot";
import { hashSnapshot } from "../sync-client/src/snapshot";
import { allocatePortablePath } from "../sync-client/src/utils/portable-path";
import { MemoryDisk, MemoryPersistence } from "./storage";
@ -18,7 +18,7 @@ test("offline content notifications need no saves and restart reads the current
initial.local = { a: "a.md" };
initial.documents.a = {
observedHash: (await toStoredSnapshot({ content: bytes("original") }))
observedHash: (await hashSnapshot({ content: bytes("original") }))
.hash
};
const store = new MemoryPersistence({
@ -67,7 +67,7 @@ test("offline content notifications need no saves and restart reads the current
}
});
test("legacy content notifications preserve the acknowledged namespace prefix on restart", async () => {
test("namespace notifications preserve the acknowledged prefix on restart", async () => {
const disk = new MemoryDisk();
await disk.userWrite("d.md", bytes("A"));
await disk.userWrite("b.md", bytes("B"));
@ -81,10 +81,10 @@ test("legacy content notifications preserve the acknowledged namespace prefix on
])
initial.documents[id] = {
observedHash: (await toStoredSnapshot({ content: bytes(content) }))
observedHash: (await hashSnapshot({ content: bytes(content) }))
.hash
};
initial.lastAppliedLocalChangeId = "consumed-update";
initial.lastAppliedLocalChangeId = "consumed-move";
const saved: StoredClient = {
database: initial,
settings: { remoteUri: "http://offline.test", vaultName: "test" },
@ -97,8 +97,6 @@ test("legacy content notifications preserve the acknowledged namespace prefix on
identities: { a: "a.md" },
changeId: "consumed-move"
},
{ type: "update", path: "c.md", changeId: "consumed-update" },
{ type: "update", path: "b.md", changeId: "new-update" },
{
type: "move",
oldPath: "c.md",
@ -132,7 +130,7 @@ test("legacy content notifications preserve the acknowledged namespace prefix on
test("remote binary replacement and deletion do not create backup artifacts", async () => {
const f = await fixture({ "a.bin": "private edit" });
await f.files.apply(structuredClone(f.database.state), {
a: { replacement: await toStoredSnapshot({ content: bytes("remote") }) }
a: { replacement: await hashSnapshot({ content: bytes("remote") }) }
});
assert.deepEqual(await f.disk.listFilesRecursively(), ["a.bin"]);
assert.deepEqual(f.disk.userFiles(), new Map([["a.bin", bytes("remote")]]));
@ -155,7 +153,7 @@ test("rejected content recovery remains idempotent across a rename and network f
const snapshot = (await f.files.snapshot("a.md"))!;
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
hash: (await hashSnapshot({ content: bytes("base") })).hash
};
f.database.state.pending = {
type: "content",
@ -164,7 +162,7 @@ test("rejected content recovery remains idempotent across a rename and network f
request: {
requestId: "request",
parentVersionId: 1,
content: { type: "Snapshot", value: snapshot.contentBase64 }
content: { type: "Snapshot", value: Buffer.from(snapshot.content).toString("base64") }
}
};
await f.database.save();
@ -253,38 +251,55 @@ test("unfinished bootstrap cannot be rebound to another vault", async () => {
}
});
test("a rejected upload is retained locally and does not block another file or a corrected snapshot", async () => {
test("a rejected upload pauses all sync across restarts until an explicit retry", async (context) => {
let reject = true;
const sent: string[] = [];
const f = await fixture(
{ "a.md": "too large", "b.md": "other" },
{
putFileContent: async (id) => {
sent.push(id);
if (id === "a" && reject)
throw new PermanentSyncError("HTTP 413");
return {
type: "Accepted",
...head(id, sent.length + 2, "small")
};
}
let eventReads = 0;
const sent: { id: string; request: unknown }[] = [];
const service = {
getEvents: async () => {
eventReads++;
return { headEventId: 0, endEventId: 0, events: [] };
},
putFileContent: async (id: string, request: unknown) => {
sent.push({ id, request: structuredClone(request) });
if (reject) throw new PermanentSyncError("HTTP 413");
return { type: "Accepted" as const, ...head(id, sent.length + 2, "small") };
}
);
f.internals.stopped = false;
assert(await f.internals.preparePush());
await f.internals.finishPending();
assert.equal(f.database.state.pending, undefined);
assert.match(f.database.state.unconfirmed![0].rejection!, /413/);
assert(await f.internals.preparePush());
await f.internals.finishPending();
assert.deepEqual(sent, ["a", "b"]);
assert.equal(await f.internals.preparePush(), false);
};
let f = await fixture({ "a.md": "too large", "b.md": "other" }, service);
context.after(async () => f.syncer.stop());
for (const doc of Object.values(f.database.state.documents))
doc.base = { vaultUpdateId: 1, hash: "previous" };
await f.database.save();
f.syncer.start();
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
const pending = structuredClone(f.database.state.pending!);
assert.equal(pending.rejection, "HTTP 413");
assert.deepEqual(sent.map(({ id }) => id), ["a"]);
const reads = eventReads;
await f.disk.userWrite("a.md", bytes("small"));
await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.md" });
f.syncer.wake();
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
assert.equal(eventReads, reads);
assert.equal(sent.length, 1);
await f.syncer.stop();
f = await fixture({}, service, f);
f.syncer.start();
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
assert.equal(eventReads, reads);
assert.equal(sent.length, 1);
reject = false;
assert(await f.internals.preparePush());
await f.internals.finishPending();
assert.deepEqual(sent, ["a", "b", "a"]);
assert.equal(f.database.state.unconfirmed?.find((request) => request.rejection), undefined);
await f.syncer.retryRejectedRequests();
f.syncer.wake();
await f.syncer.waitUntilFinished();
assert.deepEqual(sent.map(({ id }) => id), ["a", "a", "a", "b"]);
assert.deepEqual(sent[1], sent[0], "resolve the old request before uploading corrected bytes");
assert.notDeepEqual(sent[2].request, sent[0].request);
assert.equal(f.database.state.pending, undefined);
assert.deepEqual((await f.disk.read("a.md"))?.content, bytes("small"));
await f.syncer.stop();
});
test("a transient upload failure retries the exact request and recovers its acknowledgement", async () => {
@ -304,11 +319,10 @@ test("a transient upload failure retries the exact request and recovers its ackn
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
assert.equal(f.database.state.documents.a.base!.vaultUpdateId, 2);
assert.equal(f.database.state.unconfirmed?.find((request) => request.rejection), undefined);
assert.equal(f.database.state.pending, undefined);
});
test("a persisted upload rejection finishes without filesystem writes", async () => {
test("a persisted upload rejection pauses without filesystem writes", async () => {
let uploads = 0;
const f = await fixture(undefined, {
putFileContent: async () => {
@ -322,41 +336,28 @@ test("a persisted upload rejection finishes without filesystem writes", async ()
if (!label.startsWith("read:"))
throw new Error("Unexpected filesystem mutation");
};
await f.internals.finishPending();
await assert.rejects(f.internals.finishPending(), /413/);
assert.equal(uploads, 1);
assert.equal(f.database.state.pending, undefined);
assert.equal(f.database.state.unconfirmed![0].rejection, "HTTP 413");
assert.equal(f.database.state.pending?.rejection, "HTTP 413");
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
});
test("event replay incorporates an earlier accepted upload after a permanent retry rejection", async () => {
test("an explicit retry recovers an earlier accepted upload after a permanent rejection", async () => {
const requests: unknown[] = [];
const f = await fixture(undefined, {
putFileContent: async () => {
throw new PermanentSyncError(
"HTTP 413 after proxy reconfiguration"
);
},
getDocumentVersionContent: async () => bytes("local")
putFileContent: async (_id, request) => {
requests.push(structuredClone(request));
if (requests.length === 1) throw new PermanentSyncError("HTTP 413 after proxy reconfiguration");
return { type: "Accepted", ...head("a", 2, "local") };
}
});
f.internals.stopped = false;
await f.internals.preparePush();
const { pending } = f.database.state;
assert(pending);
const { requestId } = pending.request;
await f.internals.finishPending();
await assert.rejects(f.internals.finishPending(), /413/);
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
f.database.state.lastSeenUpdateId = 1;
await f.internals.incorporateEventBatch({
headEventId: 2,
events: [
{
eventId: 2,
requestId,
type: "content",
document: head("a", 2, "local")
}
]
});
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
assert.deepEqual(requests[1], requests[0]);
assert.equal(f.database.state.documents.a.base?.vaultUpdateId, 2);
assert.equal(f.database.state.pending, undefined);
assert.equal(await f.internals.preparePush(), false);
@ -364,7 +365,7 @@ test("event replay incorporates an earlier accepted upload after a permanent ret
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
});
test("rejected manifests can be replaced after editing the local namespace", async () => {
test("rejected manifests are resolved before publishing later namespace changes", async () => {
let reject = true;
const f = await fixture(
{},
@ -389,11 +390,14 @@ test("rejected manifests can be replaced after editing the local namespace", asy
base: { ...head("a", 1, "x"), hash: "hash" }
};
await f.database.save();
await f.internals.finishPending();
await assert.rejects(f.internals.finishPending(), /413/);
f.internals.stopped = false;
assert.equal(await f.internals.preparePush(), false);
f.database.state.local = {};
reject = false;
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
assert.deepEqual(f.database.state.fileManifest.entries, { a: "new.md" });
assert(await f.internals.preparePush());
await f.internals.finishPending();
assert.deepEqual(f.database.state.fileManifest.entries, {});
@ -509,7 +513,7 @@ for (const kind of ["path", "content"] as const) {
f.database.state.lastSeenUpdateId = 1;
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
hash: (await hashSnapshot({ content: bytes("base") })).hash
};
if (kind === "path") {
await f.disk.userRename("a.md", "c.md");
@ -520,6 +524,7 @@ for (const kind of ["path", "content"] as const) {
}
await f.internals.incorporateEventBatch({
headEventId: 3,
endEventId: 3,
events: [2, 3].map((id) =>
kind === "path"
? {
@ -674,11 +679,12 @@ test("rejected content is reported to callers and an explicit retry clears the r
});
f.internals.stopped = false;
await f.internals.preparePush();
await f.internals.finishPending();
await assert.rejects(f.internals.finishPending(), /413/);
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
assert(f.syncer.isBusy);
await f.syncer.retryRejectedRequests();
assert(await f.internals.preparePush());
assert.equal(f.database.state.pending?.rejection, undefined);
assert(f.database.state.pending, "explicit retry must retain the exact request");
});
test("notification saves cannot roll back an uncertain engine commit, even if the first reload fails", async () => {