,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
1 changed files with 1141 additions and 1376 deletions
|
|
@ -5,7 +5,7 @@ import { test } from "node:test";
|
|||
import type { Database } from "../sync-client/src/persistence/database";
|
||||
import { emptyState } from "../sync-client/src/persistence/database";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { toStoredSnapshot } from "../sync-client/src/snapshot";
|
||||
import { hashSnapshot } from "../sync-client/src/snapshot";
|
||||
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
|
||||
import type { SyncService } from "../sync-client/src/services/sync-service";
|
||||
import type { WebSocketManager } from "../sync-client/src/services/websocket-manager";
|
||||
|
|
@ -81,11 +81,13 @@ test("oversized untracked file must remain in place", async () => {
|
|||
});
|
||||
|
||||
test("rejected unknown success must use submitted content as merge base", async () => {
|
||||
let reject = true;
|
||||
const f = await fixture(
|
||||
{ "a.md": "base" },
|
||||
{
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
if (reject) throw new PermanentSyncError("HTTP 413");
|
||||
return { type: "Accepted", ...head("a", 2, "base A") };
|
||||
},
|
||||
getDocumentVersionContent: async ({ vaultUpdateId }) =>
|
||||
bytes(
|
||||
|
|
@ -106,10 +108,14 @@ test("rejected unknown success must use submitted content as merge base", async
|
|||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
const req = f.database.state.pending!.request.requestId;
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.internals.finishPending(), /413/);
|
||||
await f.disk.userWrite("a.md", bytes("base"));
|
||||
reject = false;
|
||||
await f.syncer.retryRejectedRequests();
|
||||
await f.internals.finishPending();
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 3,
|
||||
endEventId: 3,
|
||||
events: [
|
||||
{
|
||||
eventId: 2,
|
||||
|
|
@ -166,7 +172,7 @@ test("oversized remote head must not cause identical stale uploads forever", asy
|
|||
);
|
||||
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.documents.a ??= {}).remote = head("a", 2, remoteText);
|
||||
await f.settings.setSettings({ maxFileSizeMB: 10 / (1024 * 1024) });
|
||||
|
|
@ -178,11 +184,13 @@ test("oversized remote head must not cause identical stale uploads forever", asy
|
|||
});
|
||||
|
||||
test("local undo of accepted-but-rejected manifest must survive replay", async () => {
|
||||
let reject = true;
|
||||
const f = await fixture(
|
||||
{ "a.md": "A" },
|
||||
{
|
||||
pushFileManifest: async () => {
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
if (reject) throw new PermanentSyncError("HTTP 413");
|
||||
return { type: "Accepted", fileManifestId: 2 };
|
||||
},
|
||||
getDocumentVersionContent: async () => bytes("A")
|
||||
}
|
||||
|
|
@ -202,14 +210,18 @@ test("local undo of accepted-but-rejected manifest must survive replay", async (
|
|||
await f.internals.preparePush();
|
||||
const req = f.database.state.pending!.request.requestId;
|
||||
f.internals.stopped = true;
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.internals.finishPending(), /413/);
|
||||
await f.disk.userRename("b.md", "a.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "b.md",
|
||||
relativePath: "a.md"
|
||||
});
|
||||
reject = false;
|
||||
await f.syncer.retryRejectedRequests();
|
||||
await f.internals.finishPending();
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 2,
|
||||
endEventId: 2,
|
||||
events: [
|
||||
{
|
||||
eventId: 2,
|
||||
|
|
@ -244,9 +256,6 @@ test("a dirty cursor update must invalidate old precise positions", async () =>
|
|||
const { CursorTracker } = await import(
|
||||
"../sync-client/src/sync-operations/cursor-tracker"
|
||||
);
|
||||
const { FileChangeNotifier } = await import(
|
||||
"../sync-client/src/sync-operations/file-change-notifier"
|
||||
);
|
||||
const f = await fixture({ "a.md": "base" });
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "base"),
|
||||
|
|
@ -261,7 +270,7 @@ test("a dirty cursor update must invalidate old precise positions", async () =>
|
|||
f.database,
|
||||
ws as unknown as WebSocketManager,
|
||||
f.files,
|
||||
new FileChangeNotifier()
|
||||
new EventListeners()
|
||||
);
|
||||
let latest: import("../sync-client/src/types/maybe-outdated-client-cursors").MaybeOutdatedClientCursors[] =
|
||||
[];
|
||||
|
|
@ -335,21 +344,35 @@ test("silent open WebSocket must eventually trigger catchup or reconnect", async
|
|||
fetch: async (input) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/ping"))
|
||||
return Response.json({
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.endsWith("/file-manifest"))
|
||||
return Response.json(
|
||||
{ type: "Accepted", fileManifestId: 0 },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.endsWith("/vault-snapshot"))
|
||||
return Response.json({
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.includes("/events-since")) {
|
||||
eventReads++;
|
||||
return Response.json({ headEventId: 0, events: [] });
|
||||
return Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected request " + path);
|
||||
}
|
||||
|
|
@ -478,7 +501,7 @@ for (const interrupted of [false, true]) {
|
|||
f = await fixture({ "a.md": "base LOCAL" }, service);
|
||||
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.documents.a ??= {}).remote = head("a", 1, "base");
|
||||
f.database.state.lastSeenUpdateId = 1;
|
||||
|
|
@ -554,14 +577,16 @@ test("content notifications during every snapshot do not starve a complete names
|
|||
});
|
||||
|
||||
for (const boundary of ["before:save", "durable:save"]) {
|
||||
test(`receipt incorporation after paged replay survives ${boundary} and process replacement`, async () => {
|
||||
test(`retried acknowledgement before paged replay survives ${boundary} and process replacement`, async () => {
|
||||
let reject = true;
|
||||
const service: Partial<SyncService> = {
|
||||
getEvents: async (after) => {
|
||||
assert.equal(after, 2);
|
||||
return page;
|
||||
},
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
if (reject) throw new PermanentSyncError("HTTP 413");
|
||||
return { type: "Accepted", ...head("a", 2, "base A") };
|
||||
},
|
||||
getDocumentVersionContent: async ({ vaultUpdateId }) =>
|
||||
bytes(
|
||||
|
|
@ -582,7 +607,7 @@ for (const boundary of ["before:save", "durable:save"]) {
|
|||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
const { requestId } = f.database.state.pending!.request;
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.internals.finishPending(), /413/);
|
||||
await f.disk.userWrite("a.md", bytes("base"));
|
||||
const first = {
|
||||
headEventId: 3,
|
||||
|
|
@ -608,17 +633,20 @@ for (const boundary of ["before:save", "durable:save"]) {
|
|||
}
|
||||
]
|
||||
};
|
||||
reject = false;
|
||||
await f.syncer.retryRejectedRequests();
|
||||
f.persistence.boundary = (label) => {
|
||||
if (label === boundary) throw new Error("injected crash");
|
||||
};
|
||||
await assert.rejects(
|
||||
f.internals.incorporateEventBatch(first),
|
||||
f.internals.finishPending(),
|
||||
/injected crash/
|
||||
);
|
||||
f.persistence.boundary = () => { };
|
||||
f.disk.crash(true);
|
||||
f = await fixture({}, service, f);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 1);
|
||||
await f.internals.finishPending();
|
||||
await f.internals.incorporateEventBatch(first);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 3);
|
||||
assert.deepEqual(
|
||||
|
|
@ -653,19 +681,28 @@ test("initial vault settings bind the engine and survive restart", async () => {
|
|||
webSocket: Socket as unknown as typeof WebSocket,
|
||||
fetch: async (input: RequestInfo | URL) => {
|
||||
if (String(input).endsWith("/ping"))
|
||||
return Response.json({
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (String(input).endsWith("/vault-snapshot"))
|
||||
return Response.json({
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
});
|
||||
return Response.json({ headEventId: 0, events: [] });
|
||||
return Response.json(
|
||||
{
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
return Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
let client = await SyncClient.create(options);
|
||||
|
|
@ -695,13 +732,14 @@ test("disabling sync persists across reset without resuming the transport", asyn
|
|||
}
|
||||
});
|
||||
|
||||
for (const receipt of [false, true]) {
|
||||
test(`${receipt ? "replayed" : "direct"} upload acknowledgement survives a local deletion`, async () => {
|
||||
for (const retry of [false, true]) {
|
||||
test(`${retry ? "retried" : "direct"} upload acknowledgement survives a local deletion`, async () => {
|
||||
let reject = retry;
|
||||
const f = await fixture(
|
||||
{ "a.md": "base" },
|
||||
{
|
||||
putFileContent: async () => {
|
||||
if (receipt)
|
||||
if (reject)
|
||||
throw new PermanentSyncError(
|
||||
"HTTP 413 after lost success"
|
||||
);
|
||||
|
|
@ -717,30 +755,19 @@ for (const receipt of [false, true]) {
|
|||
await f.disk.userWrite("a.md", bytes("edited"));
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
const { requestId } = f.database.state.pending!.request;
|
||||
if (receipt) await f.internals.finishPending();
|
||||
if (retry) await assert.rejects(f.internals.finishPending(), /413/);
|
||||
// Recovery folds queued deletions into the durable namespace before
|
||||
// an outstanding request or its delayed receipt is handled.
|
||||
// an outstanding request is retried.
|
||||
f.internals.stopped = true;
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.syncer.syncLocallyDeletedFile("a.md");
|
||||
await f.internals.scan();
|
||||
assert.deepEqual(f.database.state.local, {});
|
||||
if (receipt) {
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 2,
|
||||
events: [
|
||||
{
|
||||
eventId: 2,
|
||||
requestId,
|
||||
type: "content",
|
||||
document: head("a", 2, "edited")
|
||||
}
|
||||
]
|
||||
});
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 2);
|
||||
assert.equal(f.database.state.unconfirmed?.length ?? 0, 0);
|
||||
} else await f.internals.finishPending();
|
||||
if (retry) {
|
||||
reject = false;
|
||||
await f.syncer.retryRejectedRequests();
|
||||
}
|
||||
await f.internals.finishPending();
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.deepEqual(f.database.state.local, {});
|
||||
assert.equal(await f.disk.read("a.md"), undefined);
|
||||
|
|
@ -781,21 +808,35 @@ async function settingsLifecycleFixture() {
|
|||
fetch: async (input) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/ping"))
|
||||
return Response.json({
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
supportedApiVersion: SUPPORTED_API_VERSION,
|
||||
isAuthenticated: true,
|
||||
mergeableFileExtensions: ["md"],
|
||||
serverVersion: "test"
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.endsWith("/file-manifest"))
|
||||
return Response.json(
|
||||
{ type: "Accepted", fileManifestId: 0 },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.endsWith("/vault-snapshot"))
|
||||
return Response.json({
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
headEventId: 0,
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
documents: []
|
||||
},
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
if (path.includes("/events-since")) {
|
||||
eventReads++;
|
||||
return Response.json({ headEventId: 0, events: [] });
|
||||
return Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected request " + path);
|
||||
}
|
||||
|
|
@ -818,12 +859,11 @@ for (const boundary of ["before:save", "durable:save"]) {
|
|||
const { database } = f.client as unknown as { database: Database };
|
||||
await database.commit({
|
||||
...database.state,
|
||||
unconfirmed: [{
|
||||
pending: {
|
||||
type: "fileManifest",
|
||||
requestId: "rejected",
|
||||
entries: { retired: "old.md" },
|
||||
rejection: "retired rejection"
|
||||
}]
|
||||
request: { requestId: "rejected", parentFileManifestId: 0, entries: {} },
|
||||
rejection: "upload rejected"
|
||||
}
|
||||
});
|
||||
let saves = 0,
|
||||
failed = false;
|
||||
|
|
@ -840,6 +880,10 @@ for (const boundary of ["before:save", "durable:save"]) {
|
|||
);
|
||||
assert(failed);
|
||||
assert.equal(f.client.getSettings().maxFileSizeMB, 20);
|
||||
if (boundary === "before:save") {
|
||||
await assert.rejects(f.client.waitUntilFinished(), /upload rejected/);
|
||||
await f.client.reset();
|
||||
}
|
||||
const before = f.reads();
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: "absent.md"
|
||||
|
|
|
|||
Loading…
Reference in a new issue