power loss
This commit is contained in:
parent
0f6b618c0b
commit
d56084e4bc
1 changed files with 18667 additions and 3902 deletions
910
frontend/test-support/engine-regressions.test.ts
Normal file
910
frontend/test-support/engine-regressions.test.ts
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { PermanentSyncError } from "../sync-client/src/errors/errors";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
Database,
|
||||
emptyState,
|
||||
type StoredDatabase
|
||||
} from "../sync-client/src/persistence/database";
|
||||
import { Settings } from "../sync-client/src/persistence/settings";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import { Syncer } from "../sync-client/src/sync-operations/syncer";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import { Logger } from "../sync-client/src/tracing/logger";
|
||||
import { SyncHistory } from "../sync-client/src/tracing/sync-history";
|
||||
import { FixedSizeDocumentCache } from "../sync-client/src/utils/data-structures/fix-sized-cache";
|
||||
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
|
||||
import { allocatePortablePath } from "../sync-client/src/utils/portable-path";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
import type { SyncService } from "../sync-client/src/services/sync-service";
|
||||
import type { WebSocketManager } from "../sync-client/src/services/websocket-manager";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
import type { LocalChange } from "../sync-client/src/sync-operations/local-changes";
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
const head = (id: string, version: number, content: string) => ({
|
||||
documentId: id,
|
||||
vaultUpdateId: version,
|
||||
contentSize: bytes(content).length,
|
||||
updatedDate: "2026-09-19T00:00:00Z",
|
||||
userId: "user",
|
||||
deviceId: "device"
|
||||
});
|
||||
|
||||
async function fixture(
|
||||
contents: Record<string, string> = { "a.md": "local" },
|
||||
service: Partial<SyncService> = {}
|
||||
) {
|
||||
const disk = new MemoryDisk();
|
||||
const initial = emptyState("test");
|
||||
initial.initialized = true;
|
||||
for (const [path, content] of Object.entries(contents)) {
|
||||
await disk.userWrite(path, bytes(content));
|
||||
const id = path.split(".")[0];
|
||||
initial.local[id] = path;
|
||||
initial.documents[id] = {
|
||||
materialized: true,
|
||||
observedHash: (await toStoredSnapshot({ content: bytes(content) }))
|
||||
.hash
|
||||
};
|
||||
}
|
||||
initial.fileManifest = { fileManifestId: 1, entries: { ...initial.local } };
|
||||
const persistence = new MemoryPersistence({ database: initial });
|
||||
const logger = new Logger();
|
||||
const database = new Database(
|
||||
logger,
|
||||
initial,
|
||||
async (next) => persistence.save({ database: next }),
|
||||
"test",
|
||||
async () => (await persistence.load()).database as StoredDatabase
|
||||
);
|
||||
const config = {
|
||||
initialize: async () => {},
|
||||
getConfig: async () => ({ mergeableFileExtensions: ["md"] }),
|
||||
reset: () => {}
|
||||
} as unknown as ServerConfig;
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
{ syncIntervalMs: 0 },
|
||||
async () => {}
|
||||
);
|
||||
const changes: LocalChange[] = [];
|
||||
const files = new FileOperations(
|
||||
disk.session(),
|
||||
database,
|
||||
config,
|
||||
undefined,
|
||||
{ entries: () => changes, flush: async () => {} }
|
||||
);
|
||||
const websocket = {
|
||||
onWebSocketStatusChanged: new EventListeners(),
|
||||
onRemoteVaultUpdateReceived: new EventListeners()
|
||||
} as unknown as WebSocketManager;
|
||||
const syncer = new Syncer(
|
||||
"device",
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
service as SyncService,
|
||||
websocket,
|
||||
files,
|
||||
config,
|
||||
new SyncHistory(logger),
|
||||
new FixedSizeDocumentCache(1024),
|
||||
{ entries: changes, save: async () => {} }
|
||||
);
|
||||
const internals = syncer as unknown as {
|
||||
finishPending(): Promise<void>;
|
||||
preparePush(): Promise<boolean>;
|
||||
scan(): Promise<void>;
|
||||
incorporateEventBatch(
|
||||
batch: import("../sync-client/src/services/types/EventBatch").EventBatch
|
||||
): Promise<void>;
|
||||
stopped: boolean;
|
||||
incorporateFileManifest(remote: {
|
||||
fileManifestId: number;
|
||||
entries: Record<string, string>;
|
||||
}): Promise<void>;
|
||||
incorporateContent(remote: ReturnType<typeof head>): Promise<void>;
|
||||
};
|
||||
return {
|
||||
disk,
|
||||
database,
|
||||
files,
|
||||
settings,
|
||||
syncer,
|
||||
internals,
|
||||
persistence,
|
||||
changes
|
||||
};
|
||||
}
|
||||
|
||||
test("remote binary replacement retains displaced bytes outside the visible namespace", async () => {
|
||||
const f = await fixture({ "a.bin": "private edit" });
|
||||
const next = structuredClone(f.database.state);
|
||||
await f.files.apply(next, {
|
||||
a: {
|
||||
expected: await f.files.snapshot("a.bin"),
|
||||
replacement: await toStoredSnapshot({ content: bytes("remote") })
|
||||
}
|
||||
});
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.bin", bytes("remote")]]));
|
||||
const retained = (await f.disk.listFilesRecursively()).filter((path) =>
|
||||
path.startsWith(".vault-link-sync/recovery/")
|
||||
);
|
||||
assert(
|
||||
(
|
||||
await Promise.all(retained.map((path) => f.disk.readSnapshot(path)))
|
||||
).some(
|
||||
(value) =>
|
||||
value &&
|
||||
Buffer.from(value.content).toString() === "private edit"
|
||||
)
|
||||
);
|
||||
await f.files.recover();
|
||||
assert.deepEqual(
|
||||
await f.disk.listFilesRecursively(),
|
||||
["a.bin", ...retained].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test("remote deletion retains unsent bytes for recovery", async () => {
|
||||
const f = await fixture();
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
assert.equal(f.disk.userFiles().size, 0);
|
||||
const retained = await f.disk.listFilesRecursively();
|
||||
assert(
|
||||
(
|
||||
await Promise.all(retained.map((path) => f.disk.readSnapshot(path)))
|
||||
).some(
|
||||
(value) =>
|
||||
value && Buffer.from(value.content).toString() === "local"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("rejected content recovery remains idempotent across a rename and network failure", async () => {
|
||||
let offline = true;
|
||||
const f = await fixture(undefined, {
|
||||
getDocumentVersionContent: async () => {
|
||||
if (offline) throw new Error("offline");
|
||||
return bytes("base");
|
||||
}
|
||||
});
|
||||
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
|
||||
};
|
||||
f.database.state.pending = {
|
||||
type: "content",
|
||||
documentId: "a",
|
||||
snapshot,
|
||||
request: {
|
||||
requestId: "request",
|
||||
parentVersionId: 1,
|
||||
content: { type: "Snapshot", value: snapshot.contentBase64 }
|
||||
},
|
||||
response: {
|
||||
type: "StaleBase",
|
||||
...head("a", 2, "remote")
|
||||
}
|
||||
};
|
||||
await f.database.save();
|
||||
await f.disk.userRename("a.md", "renamed.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "renamed.md"
|
||||
});
|
||||
await assert.rejects(f.internals.finishPending(), /offline/);
|
||||
offline = false;
|
||||
await f.internals.finishPending();
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(f.database.state.local.a, "renamed.md");
|
||||
});
|
||||
|
||||
test("conflict names respect component limits without imposing a total path limit", () => {
|
||||
const wanted = "a".repeat(235) + ".md";
|
||||
const allocated = allocatePortablePath(
|
||||
wanted,
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
{ other: wanted }
|
||||
);
|
||||
assert(Buffer.byteLength(allocated) <= 255);
|
||||
assert(allocated.endsWith(".md"));
|
||||
assert.notEqual(allocated, wanted);
|
||||
const longPath = "folder/".repeat(100) + "note.md";
|
||||
assert.equal(allocatePortablePath(longPath, "id", {}), longPath);
|
||||
});
|
||||
|
||||
test("an unmaterialized file with a directory at its path is deconflicted", async () => {
|
||||
const f = await fixture(
|
||||
{},
|
||||
{ getDocumentVersionContent: async () => bytes("remote") }
|
||||
);
|
||||
await f.disk.userWrite("folder/note.md", bytes("local"));
|
||||
f.database.state.local.a = "folder";
|
||||
f.database.state.documents.a = { materialized: false };
|
||||
f.database.state.fileManifest.entries.a = "folder";
|
||||
await f.database.save();
|
||||
await f.internals.incorporateContent(head("a", 2, "remote"));
|
||||
assert(
|
||||
[...f.disk.userFiles().values()].some(
|
||||
(value) => Buffer.from(value).toString() === "remote"
|
||||
)
|
||||
);
|
||||
assert(
|
||||
[...f.disk.userFiles().values()].some(
|
||||
(value) => Buffer.from(value).toString() === "local"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("ignored materialized content survives remote deletion", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
assert.deepEqual(f.database.state.fileManifest.entries, {});
|
||||
});
|
||||
|
||||
test("unfinished bootstrap cannot be rebound to another vault", async () => {
|
||||
const database = emptyState(JSON.stringify(["http://example.test", "old"]));
|
||||
database.bootstrap = {
|
||||
headEventId: 1,
|
||||
fileManifest: { fileManifestId: 1, entries: {} },
|
||||
documents: []
|
||||
};
|
||||
const persistence = new MemoryPersistence({
|
||||
settings: { remoteUri: "http://example.test", vaultName: "old" },
|
||||
database
|
||||
});
|
||||
const client = await SyncClient.create({
|
||||
fs: new MemoryDisk(),
|
||||
persistence
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
client.setSettings({ vaultName: "new" }),
|
||||
/separate state store/
|
||||
);
|
||||
assert.equal(persistence.snapshot().settings!.vaultName, "old");
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("local swap arriving after a remote journal is saved preserves both identities and contents", async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
let fired = false;
|
||||
f.persistence.boundary = async (label) => {
|
||||
if (
|
||||
fired ||
|
||||
label !== "durable:save" ||
|
||||
!f.persistence.snapshot().database?.application
|
||||
)
|
||||
return;
|
||||
fired = true;
|
||||
for (const [oldPath, relativePath] of [
|
||||
["a.md", "tmp.md"],
|
||||
["b.md", "a.md"],
|
||||
["tmp.md", "b.md"]
|
||||
]) {
|
||||
await f.disk.userRename(oldPath, relativePath);
|
||||
await f.syncer.syncLocallyUpdatedFile({ oldPath, relativePath });
|
||||
}
|
||||
};
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { a: "remote-a.md", b: "remote-b.md" };
|
||||
await f.files.apply(next);
|
||||
assert(fired);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
f.disk.userFiles().get(f.database.state.local.a)!
|
||||
).toString(),
|
||||
"A"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
f.disk.userFiles().get(f.database.state.local.b)!
|
||||
).toString(),
|
||||
"B"
|
||||
);
|
||||
assert.deepEqual(f.database.state.local, { a: "b.md", b: "a.md" });
|
||||
});
|
||||
|
||||
test("a rejected upload is retained locally and does not block another file or a corrected snapshot", async () => {
|
||||
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")
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
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.documents.a.rejected!.message, /413/);
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(sent, ["a", "b"]);
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.disk.userWrite("a.md", bytes("small"));
|
||||
reject = false;
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(sent, ["a", "b", "a"]);
|
||||
assert.equal(f.database.state.documents.a.rejected, undefined);
|
||||
});
|
||||
|
||||
test("a transient upload failure retries the exact request and recovers its acknowledgement", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async (_id, request) => {
|
||||
requests.push(structuredClone(request));
|
||||
if (requests.length === 1) throw new Error("lost upload reply");
|
||||
return { type: "Accepted", ...head("a", 2, "local") };
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
await assert.rejects(f.internals.finishPending(), /lost upload reply/);
|
||||
assert(f.database.state.pending);
|
||||
await f.internals.finishPending();
|
||||
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.documents.a.rejected, undefined);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
});
|
||||
|
||||
test("a persisted upload rejection finishes locally after a recovery write fails", async () => {
|
||||
let uploads = 0;
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
uploads++;
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
const { pending } = f.database.state;
|
||||
assert(pending);
|
||||
const { requestId } = pending.request;
|
||||
const retained = `.vault-link-sync/requests/${requestId}.content`;
|
||||
const { boundary } = f.disk;
|
||||
f.disk.boundary = (label): void => {
|
||||
if (label === `before:write:${retained}`)
|
||||
throw new Error("recovery write failed");
|
||||
};
|
||||
await assert.rejects(f.internals.finishPending(), /recovery write failed/);
|
||||
assert.equal(
|
||||
f.persistence.snapshot().database?.pending?.rejection,
|
||||
"HTTP 413"
|
||||
);
|
||||
f.disk.boundary = boundary;
|
||||
await f.internals.finishPending();
|
||||
assert.equal(uploads, 1);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(f.database.state.documents.a.rejected?.message, "HTTP 413");
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot(retained))?.content,
|
||||
bytes("local")
|
||||
);
|
||||
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 () => {
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError(
|
||||
"HTTP 413 after proxy reconfiguration"
|
||||
);
|
||||
},
|
||||
getDocumentVersionContent: async () => bytes("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.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")
|
||||
}
|
||||
]
|
||||
});
|
||||
assert.equal(f.database.state.documents.a.base?.vaultUpdateId, 2);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
});
|
||||
|
||||
test("rejected manifests can be replaced after editing the local namespace", async () => {
|
||||
let reject = true;
|
||||
const f = await fixture(
|
||||
{},
|
||||
{
|
||||
pushFileManifest: async () => {
|
||||
if (reject) throw new PermanentSyncError("HTTP 413");
|
||||
return { type: "Accepted", fileManifestId: 3 };
|
||||
}
|
||||
}
|
||||
);
|
||||
f.database.state.fileManifest.entries = { a: "old.md" };
|
||||
f.database.state.pending = {
|
||||
type: "fileManifest",
|
||||
request: {
|
||||
requestId: "request",
|
||||
parentFileManifestId: 1,
|
||||
entries: { a: "new.md" }
|
||||
}
|
||||
};
|
||||
f.database.state.local = { a: "new.md" };
|
||||
f.database.state.documents.a = {
|
||||
materialized: false,
|
||||
base: { ...head("a", 1, "x"), hash: "hash" }
|
||||
};
|
||||
await f.database.save();
|
||||
await f.internals.finishPending();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
f.database.state.local = {};
|
||||
reject = false;
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(f.database.state.fileManifest.entries, {});
|
||||
});
|
||||
|
||||
test("offline delete/recreate identities survive restarting before any network connection", async () => {
|
||||
const key = JSON.stringify(["http://offline.test", "test"]);
|
||||
const initial = emptyState(key);
|
||||
initial.initialized = true;
|
||||
initial.local.a = "a.md";
|
||||
initial.documents.a = { materialized: true };
|
||||
initial.fileManifest = { fileManifestId: 1, entries: { a: "a.md" } };
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a.md", bytes("same"));
|
||||
const persistence = new MemoryPersistence({
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
const first = await SyncClient.create({ fs: disk, persistence });
|
||||
await disk.userDelete("a.md");
|
||||
await first.syncLocallyDeletedFile("a.md");
|
||||
await disk.userWrite("a.md", bytes("same"));
|
||||
await first.syncLocallyCreatedFile("a.md");
|
||||
await first.destroy();
|
||||
const second = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
const internals = second as unknown as {
|
||||
syncer: { scan(): Promise<void> };
|
||||
database: Database;
|
||||
};
|
||||
await internals.syncer.scan();
|
||||
assert.equal(internals.database.state.local.a, undefined);
|
||||
assert.equal(
|
||||
Object.values(internals.database.state.local).filter(
|
||||
(path) => path === "a.md"
|
||||
).length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
await second.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("offline notifications bind an otherwise empty client to its vault", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a.md", bytes("local"));
|
||||
const persistence = new MemoryPersistence({
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
await client.syncLocallyCreatedFile("a.md");
|
||||
await assert.rejects(
|
||||
client.setSettings({ vaultName: "other" }),
|
||||
/separate state store/
|
||||
);
|
||||
await client.destroy();
|
||||
const stored = persistence.snapshot();
|
||||
stored.settings!.vaultName = "other";
|
||||
await persistence.save(stored);
|
||||
await assert.rejects(
|
||||
SyncClient.create({ fs: disk, persistence }),
|
||||
/another vault/
|
||||
);
|
||||
});
|
||||
|
||||
test("unignoring a deleted file resumes the deferred remote deletion without resurrection", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert.equal(f.disk.userFiles().size, 0);
|
||||
assert.deepEqual(f.database.state.local, {});
|
||||
});
|
||||
|
||||
test("an ignored file does not move when a remote rename targets its path", async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "c.md", b: "a.md" }
|
||||
});
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["a.md", bytes("A")],
|
||||
["b.md", bytes("B")]
|
||||
])
|
||||
);
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["a.md", bytes("A")],
|
||||
["b.md", bytes("B")]
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
for (const kind of ["path", "content"] as const) {
|
||||
test(`a remote ${kind} change that was reverted before observation preserves the local edit`, async () => {
|
||||
const f = await fixture(
|
||||
{ "a.md": "local" },
|
||||
{
|
||||
getDocumentVersionContent: async ({ vaultUpdateId }) =>
|
||||
bytes(vaultUpdateId === 2 ? "temporary" : "base")
|
||||
}
|
||||
);
|
||||
f.database.state.lastSeenUpdateId = 1;
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "base"),
|
||||
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
|
||||
};
|
||||
if (kind === "path") {
|
||||
await f.disk.userRename("a.md", "c.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "c.md"
|
||||
});
|
||||
}
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 3,
|
||||
events: [2, 3].map((id) =>
|
||||
kind === "path"
|
||||
? {
|
||||
eventId: id,
|
||||
requestId: String(id),
|
||||
type: "fileManifest",
|
||||
fileManifest: {
|
||||
fileManifestId: id,
|
||||
entries: { a: id === 2 ? "b.md" : "a.md" }
|
||||
}
|
||||
}
|
||||
: {
|
||||
eventId: id,
|
||||
requestId: String(id),
|
||||
type: "content",
|
||||
document: head(
|
||||
"a",
|
||||
id,
|
||||
id === 2 ? "temporary" : "base"
|
||||
)
|
||||
}
|
||||
)
|
||||
});
|
||||
assert.equal(
|
||||
f.database.state.local.a,
|
||||
kind === "path" ? "c.md" : "a.md"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot(f.database.state.local.a))!.content
|
||||
).toString(),
|
||||
"local"
|
||||
);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test("concurrent settings updates cannot bind the engine to a different vault than its settings", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
const persistence = new MemoryPersistence();
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
await Promise.all([
|
||||
client.setSettings({ vaultName: "changed" }),
|
||||
client.setSettings({ maxFileSizeMB: 20 })
|
||||
]);
|
||||
const stored = persistence.snapshot();
|
||||
assert.equal(
|
||||
stored.database!.vaultKey,
|
||||
JSON.stringify([
|
||||
stored.settings!.remoteUri,
|
||||
stored.settings!.vaultName
|
||||
])
|
||||
);
|
||||
assert.equal(stored.settings!.maxFileSizeMB, 20);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy waits for an in-progress offline notification to become durable", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
const persistence = new MemoryPersistence();
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
await disk.userWrite("a.md", bytes("local"));
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
persistence.boundary = async (label) => {
|
||||
if (label === "before:save") {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
};
|
||||
const notification = client.syncLocallyCreatedFile("a.md");
|
||||
await reached.promise;
|
||||
let destroyed = false;
|
||||
const destroying = client.destroy().then(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(destroyed, false);
|
||||
release.resolve();
|
||||
await Promise.all([notification, destroying]);
|
||||
});
|
||||
|
||||
test("ignoring a locally renamed file defers its move without forgetting the local rename", async () => {
|
||||
const f = await fixture();
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "local"),
|
||||
hash: f.database.state.documents.a.observedHash!
|
||||
};
|
||||
await f.disk.userRename("a.md", "ignored.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "ignored.md"
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: ["ignored.md"] });
|
||||
await f.internals.scan();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "a.md" }
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert.equal(f.database.state.local.a, "ignored.md");
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot("ignored.md"))!.content
|
||||
).toString(),
|
||||
"local"
|
||||
);
|
||||
});
|
||||
|
||||
test("a notified deletion of an ignored file does not delete its server membership", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.syncer.syncLocallyDeletedFile("a.md");
|
||||
await f.internals.scan();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
assert.equal(f.database.state.local.a, undefined);
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert(await f.internals.preparePush());
|
||||
assert.equal(f.database.state.pending?.type, "fileManifest");
|
||||
});
|
||||
|
||||
test("an incoming ignored document cannot force an existing ignored file to move", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md", "*conflict*"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "b.md", other: "a.md" }
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
assert.equal(
|
||||
new Set(Object.values(f.database.state.local)).size,
|
||||
Object.keys(f.database.state.local).length
|
||||
);
|
||||
});
|
||||
|
||||
for (const boundary of ["visible", "durable"] as const) {
|
||||
for (const action of [
|
||||
"move-installed",
|
||||
"recreate-staged-source"
|
||||
] as const) {
|
||||
test(`${action} at a ${boundary} filesystem boundary keeps original identities and all bytes`, async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
let changed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (changed || !label.startsWith(`${boundary}:rename:`)) return;
|
||||
if (action === "move-installed" && label.endsWith("->b.md")) {
|
||||
changed = true;
|
||||
await f.disk.userRename("b.md", "later.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "b.md",
|
||||
relativePath: "later.md"
|
||||
});
|
||||
} else if (
|
||||
action === "recreate-staged-source" &&
|
||||
label.includes("rename:a.md->")
|
||||
) {
|
||||
changed = true;
|
||||
await f.disk.userWrite("a.md", bytes("new"));
|
||||
await f.syncer.syncLocallyCreatedFile("a.md");
|
||||
}
|
||||
};
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { a: "b.md", b: "a.md" };
|
||||
next.fileManifest = {
|
||||
fileManifestId: 2,
|
||||
entries: { ...next.local }
|
||||
};
|
||||
await f.files.apply(next);
|
||||
assert(changed);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot(f.database.state.local.a))!
|
||||
.content
|
||||
).toString(),
|
||||
"A"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot(f.database.state.local.b))!
|
||||
.content
|
||||
).toString(),
|
||||
"B"
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...f.disk.userFiles().values()]
|
||||
.map((value) => Buffer.from(value).toString())
|
||||
.sort(),
|
||||
action === "move-installed" ? ["A", "B"] : ["A", "B", "new"]
|
||||
);
|
||||
f.disk.crash(true);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot(f.database.state.local.a))!
|
||||
.content
|
||||
).toString(),
|
||||
"A"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.readSnapshot(f.database.state.local.b))!
|
||||
.content
|
||||
).toString(),
|
||||
"B"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("rejected content is reported to callers and an explicit retry clears the rejection", async () => {
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
|
||||
assert(f.syncer.isBusy);
|
||||
await f.syncer.retryRejectedRequests();
|
||||
assert(await f.internals.preparePush());
|
||||
});
|
||||
|
||||
test("notification saves cannot roll back an uncertain engine commit, even if the first reload fails", async () => {
|
||||
const key = JSON.stringify(["http://offline.test", "test"]);
|
||||
const initial = emptyState(key);
|
||||
initial.initialized = true;
|
||||
const disk = new MemoryDisk();
|
||||
const store = new MemoryPersistence({
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
let failLoad = false;
|
||||
const persistence = {
|
||||
load: async () => {
|
||||
if (failLoad) {
|
||||
failLoad = false;
|
||||
throw new Error("reload failed");
|
||||
}
|
||||
return store.load();
|
||||
},
|
||||
save: async (state: Parameters<typeof store.save>[0]) =>
|
||||
store.save(state)
|
||||
};
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
const internals = client as unknown as {
|
||||
database: Database;
|
||||
syncer: Syncer;
|
||||
};
|
||||
const next = structuredClone(initial);
|
||||
next.lastSeenUpdateId = 2;
|
||||
store.boundary = (label) => {
|
||||
if (label === "durable:save") {
|
||||
store.boundary = () => {};
|
||||
throw new Error("durable save failed");
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
internals.database.commit(next),
|
||||
/durable save failed/
|
||||
);
|
||||
await disk.userWrite("a.md", bytes("new"));
|
||||
failLoad = true;
|
||||
await assert.rejects(
|
||||
client.syncLocallyCreatedFile("a.md"),
|
||||
/reload failed/
|
||||
);
|
||||
await internals.syncer.flushLocalChanges();
|
||||
assert.equal(store.snapshot().database!.lastSeenUpdateId, 2);
|
||||
await internals.database.recoverPersistence();
|
||||
assert.equal(internals.database.state.lastSeenUpdateId, 2);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"a settings listener can await client shutdown without deadlocking the lifecycle lock",
|
||||
{ timeout: 1_000 },
|
||||
async () => {
|
||||
const client = await SyncClient.create({
|
||||
fs: new MemoryDisk(),
|
||||
persistence: new MemoryPersistence()
|
||||
});
|
||||
let notified = false;
|
||||
client.onSettingsChanged.add(async () => {
|
||||
await client.destroy();
|
||||
notified = true;
|
||||
});
|
||||
await client.setSettings({ maxFileSizeMB: 20 });
|
||||
assert(notified);
|
||||
}
|
||||
);
|
||||
Loading…
Reference in a new issue