,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
1 changed files with 1141 additions and 1376 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue