This commit is contained in:
Andras Schmelczer 2026-09-27 11:09:36 +01:00
commit ff1f474324
10 changed files with 3271 additions and 2003 deletions

View file

@ -85,14 +85,7 @@ test("highly fragmented local edits use a snapshot within the server operation b
i % 2 ? `changed${i}` : `word${i}`
).join(" ");
f.contentCache.put(1, bytes(original));
const syncer = f.syncer as unknown as {
pushContent(
path: string,
version: number,
snapshot: unknown
): Promise<{ type: string }>;
};
const request = await syncer.pushContent(
const request = await f.internals.content.createUploadPayload(
"a.md",
1,
await hashSnapshot({ content: bytes(edited) })

View file

@ -1,7 +1,8 @@
import { SyncPhase } from "../sync-client/src/sync-operations/sync-scheduler";
import assert from "node:assert/strict";
import { PermanentSyncError } from "../sync-client/src/errors/errors";
import { test } from "node:test";
import { Database, emptyState } from "../sync-client/src/persistence/database";
import { Database, createEmptyDatabase } 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 { hashSnapshot } from "../sync-client/src/snapshot";
@ -13,7 +14,7 @@ import { bytes, head, fixture } from "./sync-fixture";
test("offline content notifications need no saves and restart reads the current bytes", async () => {
const disk = new MemoryDisk();
await disk.userWrite("a.md", bytes("original"));
const initial = emptyState(JSON.stringify(["http://offline.test", "test"]));
const initial = createEmptyDatabase(JSON.stringify(["http://offline.test", "test"]));
initial.lastSeenUpdateId = 0;
initial.local = { a: "a.md" };
initial.documents.a = {
@ -44,14 +45,14 @@ test("offline content notifications need no saves and restart reads the current
const internals = client as unknown as {
database: Database;
syncer: {
stopped: boolean;
scan(): Promise<void>;
preparePush(): Promise<boolean>;
scheduler: { phase: SyncPhase };
refreshLocalFiles(): Promise<void>;
prepareNextUpload(): Promise<boolean>;
};
};
await internals.syncer.scan();
internals.syncer.stopped = false;
assert.equal(await internals.syncer.preparePush(), true);
await internals.syncer.refreshLocalFiles();
internals.syncer.scheduler.phase = SyncPhase.Idle;
assert.equal(await internals.syncer.prepareNextUpload(), true);
const pending = internals.database.state.pending;
assert(pending?.type === "content");
assert.equal(pending.documentId, "a");
@ -71,7 +72,7 @@ test("namespace notifications preserve the acknowledged prefix on restart", asyn
const disk = new MemoryDisk();
await disk.userWrite("d.md", bytes("A"));
await disk.userWrite("b.md", bytes("B"));
const initial = emptyState(JSON.stringify(["http://offline.test", "test"]));
const initial = createEmptyDatabase(JSON.stringify(["http://offline.test", "test"]));
initial.lastSeenUpdateId = 0;
initial.local = { a: "c.md", b: "b.md" };
initial.fileManifest.entries = { ...initial.local };
@ -111,9 +112,9 @@ test("namespace notifications preserve the acknowledged prefix on restart", asyn
try {
const internals = client as unknown as {
database: Database;
syncer: { scan(): Promise<void> };
syncer: { refreshLocalFiles(): Promise<void> };
};
await internals.syncer.scan();
await internals.syncer.refreshLocalFiles();
assert.deepEqual(internals.database.state.local, {
a: "d.md",
b: "b.md"
@ -129,12 +130,12 @@ test("namespace notifications preserve the acknowledged prefix on restart", asyn
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), {
await f.files.applyChanges(structuredClone(f.database.state), {
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")]]));
await f.internals.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: {}
});
@ -150,7 +151,7 @@ test("rejected content recovery remains idempotent across a rename and network f
return bytes("base");
}
});
const snapshot = (await f.files.snapshot("a.md"))!;
const snapshot = (await f.files.readSnapshot("a.md"))!;
f.database.state.documents.a.base = {
...head("a", 1, "base"),
hash: (await hashSnapshot({ content: bytes("base") })).hash
@ -171,9 +172,9 @@ test("rejected content recovery remains idempotent across a rename and network f
oldPath: "a.md",
relativePath: "renamed.md"
});
await assert.rejects(f.internals.finishPending(), /offline/);
await assert.rejects(f.internals.uploads.completePendingUpload(), /offline/);
offline = false;
await f.internals.finishPending();
await f.internals.uploads.completePendingUpload();
assert.equal(f.database.state.pending, undefined);
assert.equal(f.database.state.local.a, "renamed.md");
});
@ -203,7 +204,7 @@ test("an unmaterialized file with a directory at its path is deconflicted", asyn
f.database.state.fileManifest.entries.a = "folder";
await f.database.save();
f.database.state.documents.a.remote = head("a", 2, "remote");
await f.internals.reconcile(f.database.state.fileManifest);
await f.internals.applyRemoteChanges(f.database.state.fileManifest);
assert(
[...f.disk.userFiles().values()].some(
(value) => Buffer.from(value).toString() === "remote"
@ -219,7 +220,7 @@ test("an unmaterialized file with a directory at its path is deconflicted", asyn
test("ignored materialized content survives remote deletion", async () => {
const f = await fixture();
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
await f.internals.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: {}
});
@ -227,8 +228,8 @@ test("ignored materialized content survives remote deletion", async () => {
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"]));
test("unfinished initializeFromServerSnapshot cannot be rebound to another vault", async () => {
const database = createEmptyDatabase(JSON.stringify(["http://example.test", "old"]));
// A scan may commit local identities before the first remote application.
database.local = { a: "a.md" };
database.documents.a = { observedHash: "" };
@ -279,7 +280,7 @@ test("a rejected upload pauses all sync across restarts until an explicit retry"
const reads = eventReads;
await f.disk.userWrite("a.md", bytes("small"));
await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.md" });
f.syncer.wake();
f.syncer.requestSync();
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
assert.equal(eventReads, reads);
assert.equal(sent.length, 1);
@ -292,7 +293,7 @@ test("a rejected upload pauses all sync across restarts until an explicit retry"
assert.equal(sent.length, 1);
reject = false;
await f.syncer.retryRejectedRequests();
f.syncer.wake();
f.syncer.requestSync();
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");
@ -311,11 +312,11 @@ test("a transient upload failure retries the exact request and recovers its ackn
return { type: "Accepted", ...head("a", 2, "local") };
}
});
f.internals.stopped = false;
await f.internals.preparePush();
await assert.rejects(f.internals.finishPending(), /lost upload reply/);
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
await assert.rejects(f.internals.uploads.completePendingUpload(), /lost upload reply/);
assert(f.database.state.pending);
await f.internals.finishPending();
await f.internals.uploads.completePendingUpload();
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
assert.equal(f.database.state.documents.a.base!.vaultUpdateId, 2);
@ -330,13 +331,13 @@ test("a persisted upload rejection pauses without filesystem writes", async () =
throw new PermanentSyncError("HTTP 413");
}
});
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
f.disk.boundary = (label) => {
if (!label.startsWith("read:"))
throw new Error("Unexpected filesystem mutation");
};
await assert.rejects(f.internals.finishPending(), /413/);
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
assert.equal(uploads, 1);
assert.equal(f.database.state.pending?.rejection, "HTTP 413");
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
@ -351,16 +352,16 @@ test("an explicit retry recovers an earlier accepted upload after a permanent re
return { type: "Accepted", ...head("a", 2, "local") };
}
});
f.internals.stopped = false;
await f.internals.preparePush();
await assert.rejects(f.internals.finishPending(), /413/);
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
await f.internals.uploads.completePendingUpload();
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);
assert.equal(await f.internals.prepareNextUpload(), false);
await f.syncer.waitUntilFinished();
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
});
@ -390,22 +391,22 @@ test("rejected manifests are resolved before publishing later namespace changes"
base: { ...head("a", 1, "x"), hash: "hash" }
};
await f.database.save();
await assert.rejects(f.internals.finishPending(), /413/);
f.internals.stopped = false;
assert.equal(await f.internals.preparePush(), false);
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
f.internals.scheduler.phase = SyncPhase.Idle;
assert.equal(await f.internals.prepareNextUpload(), false);
f.database.state.local = {};
reject = false;
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
await f.internals.uploads.completePendingUpload();
assert.deepEqual(f.database.state.fileManifest.entries, { a: "new.md" });
assert(await f.internals.preparePush());
await f.internals.finishPending();
assert(await f.internals.prepareNextUpload());
await f.internals.uploads.completePendingUpload();
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);
const initial = createEmptyDatabase(key);
initial.lastSeenUpdateId = 0;
initial.local.a = "a.md";
initial.documents.a = { observedHash: "" };
@ -425,10 +426,10 @@ test("offline delete/recreate identities survive restarting before any network c
const second = await SyncClient.create({ fs: disk, persistence });
try {
const internals = second as unknown as {
syncer: { scan(): Promise<void> };
syncer: { refreshLocalFiles(): Promise<void> };
database: Database;
};
await internals.syncer.scan();
await internals.syncer.refreshLocalFiles();
assert.equal(internals.database.state.local.a, undefined);
assert.equal(
Object.values(internals.database.state.local).filter(
@ -466,12 +467,12 @@ test("offline notifications bind an otherwise empty client to its vault", async
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.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: {}
});
await f.settings.setSettings({ ignorePatterns: [] });
await f.internals.reconcile(f.database.state.fileManifest);
await f.internals.applyRemoteChanges(f.database.state.fileManifest);
assert.equal(f.disk.userFiles().size, 0);
assert.deepEqual(f.database.state.local, {});
});
@ -479,7 +480,7 @@ test("unignoring a deleted file resumes the deferred remote deletion without res
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.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: { a: "c.md", b: "a.md" }
});
@ -490,8 +491,8 @@ test("an ignored file does not move when a remote rename targets its path", asyn
["b.md", bytes("B")]
])
);
f.internals.stopped = false;
assert.equal(await f.internals.preparePush(), false);
f.internals.scheduler.phase = SyncPhase.Idle;
assert.equal(await f.internals.prepareNextUpload(), false);
assert.deepEqual(
f.disk.userFiles(),
new Map([
@ -522,7 +523,7 @@ for (const kind of ["path", "content"] as const) {
relativePath: "c.md"
});
}
await f.internals.incorporateEventBatch({
await f.internals.applyEventBatch({
headEventId: 3,
endEventId: 3,
events: [2, 3].map((id) =>
@ -622,15 +623,15 @@ test("ignoring a locally renamed file defers its move without forgetting the loc
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.reconcile({
await f.internals.refreshLocalFiles();
f.internals.scheduler.phase = SyncPhase.Idle;
assert.equal(await f.internals.prepareNextUpload(), false);
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: { a: "a.md" }
});
await f.settings.setSettings({ ignorePatterns: [] });
await f.internals.reconcile(f.database.state.fileManifest);
await f.internals.applyRemoteChanges(f.database.state.fileManifest);
assert.equal(f.database.state.local.a, "ignored.md");
assert.equal(
Buffer.from(
@ -645,25 +646,25 @@ test("a notified deletion of an ignored file does not delete its server membersh
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);
await f.internals.refreshLocalFiles();
f.internals.scheduler.phase = SyncPhase.Idle;
assert.equal(await f.internals.prepareNextUpload(), false);
assert.equal(f.database.state.local.a, undefined);
await f.settings.setSettings({ ignorePatterns: [] });
await f.internals.reconcile(f.database.state.fileManifest);
assert(await f.internals.preparePush());
await f.internals.applyRemoteChanges(f.database.state.fileManifest);
assert(await f.internals.prepareNextUpload());
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.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 2,
entries: { a: "b.md", other: "a.md" }
});
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
assert.equal(
new Set(Object.values(f.database.state.local)).size,
@ -677,9 +678,9 @@ test("rejected content is reported to callers and an explicit retry clears the r
throw new PermanentSyncError("HTTP 413");
}
});
f.internals.stopped = false;
await f.internals.preparePush();
await assert.rejects(f.internals.finishPending(), /413/);
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
assert(f.syncer.isBusy);
await f.syncer.retryRejectedRequests();
@ -689,7 +690,7 @@ test("rejected content is reported to callers and an explicit retry clears the r
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);
const initial = createEmptyDatabase(key);
initial.lastSeenUpdateId = 0;
const disk = new MemoryDisk();
const store = new MemoryPersistence({
@ -784,7 +785,7 @@ for (const edited of [false, true]) {
});
if (edited) await f.disk.userWrite("a.md", bytes("my new edit"));
f = await fixture({}, service, f);
await (f.syncer as unknown as { bootstrap(): Promise<void> }).bootstrap();
await (f.syncer as unknown as { initializeFromServerSnapshot(): Promise<void> }).initializeFromServerSnapshot();
assert.equal(f.database.state.lastSeenUpdateId, 3);
assert.deepEqual((await f.disk.read("a.md"))?.content, bytes("restored"));
const other = Object.keys(f.database.state.local).filter((id) => id !== "a");

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
Database,
emptyState,
createEmptyDatabase,
type StoredDatabase
} from "../sync-client/src/persistence/database";
import { Settings } from "../sync-client/src/persistence/settings";
@ -29,7 +29,7 @@ for (const [count, delivery] of [
{ timeout: 5_000 },
async () => {
const logger = new Logger();
const initial = emptyState("event-delivery");
const initial = createEmptyDatabase("event-delivery");
initial.lastSeenUpdateId = 0;
const persistence = new MemoryPersistence({ database: initial });
const database = new Database(

View file

@ -13,7 +13,7 @@ for (const equal of [false, true])
};
const next = structuredClone(f.database.state);
next.local = { a: "b.md", b: "a.md" };
await f.files.apply(next);
await f.files.applyChanges(next);
assert.deepEqual(f.database.state.local, next.local);
assert.deepEqual(
f.disk.userFiles(),
@ -40,7 +40,7 @@ for (const direction of ["file-to-directory", "directory-to-file"])
? { [ids[0]]: "folder/a.md", [ids[1]]: "b.md" }
: { [ids[0]]: "folder", [ids[1]]: "other.md" };
const expected = structuredClone(next.local);
await f.files.apply(next);
await f.files.applyChanges(next);
assert.deepEqual(f.database.state.local, expected);
assert.deepEqual(
new Set(f.disk.userFiles().keys()),
@ -59,7 +59,7 @@ test("an unexpected occupant is preserved under a visible conflict name", async
await f.disk.userWrite("remote.md", bytes("unexpected"));
const next = structuredClone(f.database.state);
next.local.a = "remote.md";
await f.files.apply(next);
await f.files.applyChanges(next);
assert.equal(f.database.state.local.a, "remote.md");
assert.equal(f.disk.userFiles().size, 2);
assert.deepEqual(
@ -73,9 +73,9 @@ test("an unexpected occupant is preserved under a visible conflict name", async
test("content edited after planning is merged with the incoming snapshot", async () => {
const f = await fixture({ "a.md": "base\n" });
const expected = (await f.files.snapshot("a.md"))!;
const expected = (await f.files.readSnapshot("a.md"))!;
await f.disk.userWrite("a.md", bytes("base\nlocal\n"));
await f.files.apply(structuredClone(f.database.state), {
await f.files.applyChanges(structuredClone(f.database.state), {
a: {
expected,
replacement: await hashSnapshot({
@ -94,7 +94,7 @@ test("deleting the source after planning does not resurrect it", async () => {
const next = structuredClone(f.database.state);
next.local.a = "remote.md";
await f.disk.userDelete("a.md");
await f.files.apply(next, {
await f.files.applyChanges(next, {
a: { replacement: await hashSnapshot({ content: bytes("remote") }) }
});
assert.deepEqual(f.disk.userFiles(), new Map());
@ -109,7 +109,7 @@ for (const protect of ["ignore", "size"] as const)
if (protect === "ignore")
await f.settings.setSetting("ignorePatterns", ["a.md"]);
else await f.settings.setSetting("maxFileSizeMB", 0);
await f.files.apply(next, {
await f.files.applyChanges(next, {
a: {
replacement: await hashSnapshot({
content: bytes("remote")
@ -149,12 +149,12 @@ for (const when of ["planning", "installed"])
next.local = { a: "b.md", b: "a.md" };
if (when === "planning")
await assert.rejects(
f.files.apply(next),
f.files.applyChanges(next),
LocalChangesDuringReconciliation
);
else await f.files.apply(next);
else await f.files.applyChanges(next);
assert(changed);
await f.internals.scan();
await f.internals.refreshLocalFiles();
assert.equal(f.database.state.local.a, "editor.md");
assert.deepEqual(
f.disk.userFiles().get(f.database.state.local.a),
@ -189,9 +189,9 @@ for (const action of ["move", "delete"] as const)
};
const next = structuredClone(f.database.state);
next.local = { a: "remote-a.md", b: "remote-b.md" };
await f.files.apply(next);
await f.files.applyChanges(next);
assert(changed);
await f.internals.scan();
await f.internals.refreshLocalFiles();
assert.deepEqual(f.disk.userFiles().get("b.md"), bytes("replacement"));
assert.equal(f.disk.userFiles().has("remote-b.md"), false);
assert.equal(
@ -207,7 +207,7 @@ test("a file and nested directory exchange their exact paths", async () => {
const [a, b] = Object.keys(f.database.state.local);
const next = structuredClone(f.database.state);
next.local = { [a]: "b", [b]: "a/deep/child" };
await f.files.apply(next);
await f.files.applyChanges(next);
assert.deepEqual(f.database.state.local, { [a]: "b", [b]: "a/deep/child" });
assert.deepEqual(
f.disk.userFiles(),
@ -223,7 +223,7 @@ for (const phase of ["before", "durable"])
const f = await fixture({ "a.md": "base\n" });
const original = structuredClone(f.database.state);
const write = {
expected: (await f.files.snapshot("a.md"))!,
expected: (await f.files.readSnapshot("a.md"))!,
replacement: await hashSnapshot({
content: bytes("remote\nbase\n")
})
@ -239,25 +239,25 @@ for (const phase of ["before", "durable"])
}
};
await assert.rejects(
f.files.apply(structuredClone(original), { a: write }),
f.files.applyChanges(structuredClone(original), { a: write }),
/save failed/
);
f.persistence.boundary = () => {};
await f.database.recoverPersistence();
await f.files.apply(structuredClone(original), { a: write });
await f.files.applyChanges(structuredClone(original), { a: write });
assert.deepEqual(
f.disk.userFiles().get("a.md"),
bytes("remote\nbase\nlocal\n")
);
assert.equal(
f.database.state.documents.a.observedHash,
(await f.files.snapshot("a.md"))!.hash
(await f.files.readSnapshot("a.md"))!.hash
);
});
test("atomic updater includes an edit made after the last snapshot without unlinking the file", async () => {
const f = await fixture({ "a.md": "base\n" });
const expected = (await f.files.snapshot("a.md"))!;
const expected = (await f.files.readSnapshot("a.md"))!;
let edited = false;
f.disk.boundary = async (label) => {
assert(
@ -269,7 +269,7 @@ test("atomic updater includes an edit made after the last snapshot without unlin
await f.disk.userWrite("a.md", bytes("base\nlatest edit\n"));
}
};
await f.files.apply(structuredClone(f.database.state), {
await f.files.applyChanges(structuredClone(f.database.state), {
a: {
expected,
replacement: await hashSnapshot({
@ -284,7 +284,7 @@ test("atomic updater includes an edit made after the last snapshot without unlin
);
assert.equal(
f.database.state.documents.a.observedHash,
(await f.files.snapshot("a.md"))!.hash
(await f.files.readSnapshot("a.md"))!.hash
);
});
@ -295,9 +295,9 @@ test("a failed atomic update leaves database metadata unchanged", async () => {
if (label === "before:write:a.md") throw new Error("update failed");
};
await assert.rejects(
f.files.apply(structuredClone(original), {
f.files.applyChanges(structuredClone(original), {
a: {
expected: (await f.files.snapshot("a.md"))!,
expected: (await f.files.readSnapshot("a.md"))!,
replacement: await hashSnapshot({
content: bytes("remote")
})

View file

@ -21,7 +21,7 @@ import { fixture } from "./sync-fixture";
f.syncer.start();
await f.syncer.waitUntilFinished();
const before = delivered;
f.syncer.wake();
f.syncer.requestSync();
await f.syncer.waitUntilFinished();
assert(delivered > before, "Background sync must remain usable");
} finally {

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
Database,
emptyState,
createEmptyDatabase,
type StoredDatabase
} from "../sync-client/src/persistence/database";
import { Settings } from "../sync-client/src/persistence/settings";
@ -87,7 +87,7 @@ async function fixture(syncIntervalMs: number | undefined) {
};
}
};
const initial = emptyState("polling-test");
const initial = createEmptyDatabase("polling-test");
initial.lastSeenUpdateId = 2;
initial.local = { [documentId]: "note.bin" };
initial.fileManifest = { fileManifestId: 2, entries: { ...initial.local } };

View file

@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
import { Database, emptyState } from "../sync-client/src/persistence/database";
import { Database, createEmptyDatabase } from "../sync-client/src/persistence/database";
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
import type { ServerConfig } from "../sync-client/src/services/server-config";
import { hashSnapshot } from "../sync-client/src/snapshot";
@ -97,7 +97,7 @@ test(
"original bytes"
);
}
const initial = emptyState("real");
const initial = createEmptyDatabase("real");
initial.local = { a: "a.md", b: "b.md" };
for (const [id, relative] of Object.entries(initial.local))
initial.documents[id] = {
@ -119,7 +119,7 @@ test(
} as ServerConfig);
const next = structuredClone(initial);
next.local = { a: "b.md", b: "a.md" };
await operations.apply(next);
await operations.applyChanges(next);
assert.equal(
Buffer.from(
(await disk.read("a.md"))!.content

View file

@ -3,7 +3,7 @@ import { test } from "node:test";
import { MemoryDisk, MemoryPersistence } from "./storage";
import {
Database,
emptyState,
createEmptyDatabase,
type StoredDatabase
} from "../sync-client/src/persistence/database";
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
@ -28,7 +28,7 @@ async function fixture(
}
) {
const disk = new MemoryDisk();
const initial = emptyState("recovery-test");
const initial = createEmptyDatabase("recovery-test");
for (const [id, { path, text }] of Object.entries(contents)) {
await disk.userWrite(path, bytes(text));
initial.local[id] = path;
@ -211,7 +211,7 @@ for (const [name, create] of [
trace(label);
};
baseline.persistence.boundary = trace;
await baseline.restart().files.apply(baseline.next, baseline.writes);
await baseline.restart().files.applyChanges(baseline.next, baseline.writes);
assert.deepEqual(baseline.disk.userFiles(), baseline.expected);
assert.deepEqual(
baseline.persistence.snapshot().database!.local,
@ -242,7 +242,7 @@ for (const [name, create] of [
await assert.rejects(
current
.restart()
.files.apply(current.next, current.writes),
.files.applyChanges(current.next, current.writes),
/interrupted/
);
assert(fired);
@ -279,7 +279,7 @@ for (const [name, create] of [
assert.equal(
runtime.database.state.documents[id]
.observedHash,
(await runtime.files.snapshot(path))!.hash
(await runtime.files.readSnapshot(path))!.hash
);
}
assert(

View file

@ -1,9 +1,10 @@
import { SyncPhase } from "../sync-client/src/sync-operations/sync-scheduler";
import assert from "node:assert/strict";
import { SUPPORTED_API_VERSION } from "../sync-client/src/consts";
import { PermanentSyncError } from "../sync-client/src/errors/errors";
import { test } from "node:test";
import type { Database } from "../sync-client/src/persistence/database";
import { emptyState } from "../sync-client/src/persistence/database";
import { createEmptyDatabase } from "../sync-client/src/persistence/database";
import { SyncClient } from "../sync-client/src/sync-client";
import { hashSnapshot } from "../sync-client/src/snapshot";
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
@ -32,9 +33,9 @@ test("ignored rename must not produce a duplicate submitted path", async () => {
relativePath: "a.md"
});
await f.settings.setSettings({ ignorePatterns: ["ignored.md"] });
await f.internals.scan();
f.internals.stopped = false;
await f.internals.preparePush();
await f.internals.refreshLocalFiles();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
const p = f.database.state.pending;
assert(p?.type === "fileManifest");
assert.equal(
@ -51,12 +52,12 @@ test("ignored untracked ancestor must remain in place", async () => {
await f.disk.userWrite("private", bytes("LOCAL PRIVATE"));
await f.settings.setSettings({ ignorePatterns: ["private"] });
(f.database.state.documents.a ??= {}).remote = head("a", 2, "remote");
await f.internals.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 3,
entries: { a: "private/note.md" }
});
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
assert.deepEqual(await f.disk.read("private"), {
content: bytes("LOCAL PRIVATE")
});
@ -70,7 +71,7 @@ test("oversized untracked file must remain in place", async () => {
await f.disk.userWrite("a.md", bytes("LOCAL OVERSIZED"));
await f.settings.setSettings({ maxFileSizeMB: 5 / (1024 * 1024) });
(f.database.state.documents.a ??= {}).remote = head("a", 2, "R");
await f.internals.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 3,
entries: { a: "a.md" }
});
@ -105,15 +106,15 @@ test("rejected unknown success must use submitted content as merge base", async
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("base A"));
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
const req = f.database.state.pending!.request.requestId;
await assert.rejects(f.internals.finishPending(), /413/);
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
await f.disk.userWrite("a.md", bytes("base"));
reject = false;
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
await f.internals.incorporateEventBatch({
await f.internals.uploads.completePendingUpload();
await f.internals.applyEventBatch({
headEventId: 3,
endEventId: 3,
events: [
@ -150,7 +151,7 @@ test("bootstrap adopts same-path identity even after multiple notifications", as
fileManifest: { fileManifestId: 2, entries: { remoteA: "a.md" } },
documents: [head("remoteA", 1, "remote a")]
};
await f.internals.scan(initial);
await f.internals.refreshLocalFiles(initial);
assert.equal(f.database.state.local.remoteA, "a.md");
});
@ -176,9 +177,9 @@ test("oversized remote head must not cause identical stale uploads forever", asy
};
(f.database.state.documents.a ??= {}).remote = head("a", 2, remoteText);
await f.settings.setSettings({ maxFileSizeMB: 10 / (1024 * 1024) });
f.internals.stopped = false;
f.internals.scheduler.phase = SyncPhase.Idle;
// Even a small local edit must wait until its required remote base is readable.
assert.equal(await f.internals.preparePush(), false);
assert.equal(await f.internals.prepareNextUpload(), false);
assert.equal(attempts, 0);
assert.equal(f.database.state.pending, undefined);
});
@ -205,12 +206,12 @@ test("local undo of accepted-but-rejected manifest must survive replay", async (
oldPath: "a.md",
relativePath: "b.md"
});
await f.internals.scan();
f.internals.stopped = false;
await f.internals.preparePush();
await f.internals.refreshLocalFiles();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
const req = f.database.state.pending!.request.requestId;
f.internals.stopped = true;
await assert.rejects(f.internals.finishPending(), /413/);
f.internals.scheduler.phase = SyncPhase.Stopped;
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
await f.disk.userRename("b.md", "a.md");
await f.syncer.syncLocallyUpdatedFile({
oldPath: "b.md",
@ -218,8 +219,8 @@ test("local undo of accepted-but-rejected manifest must survive replay", async (
});
reject = false;
await f.syncer.retryRejectedRequests();
await f.internals.finishPending();
await f.internals.incorporateEventBatch({
await f.internals.uploads.completePendingUpload();
await f.internals.applyEventBatch({
headEventId: 2,
endEventId: 2,
events: [
@ -397,7 +398,7 @@ test("oversized files should be filtered before reading complete snapshots", asy
f.disk.boundary = (label) => {
if (label === "read:snapshot:huge.bin") reads++;
};
await f.internals.scan();
await f.internals.refreshLocalFiles();
assert.equal(reads, 0);
});
@ -412,7 +413,7 @@ test("permanent destination failure replans the affected file and finishes the b
const next = structuredClone(f.database.state);
next.local = { a: "unsupported/a.md", b: "good.md" };
next.fileManifest = { fileManifestId: 2, entries: { ...next.local } };
await f.files.apply(next);
await f.files.applyChanges(next);
await f.settings.setSettings({ ignorePatterns: ["unsupported/**"] });
assert.deepEqual(
[...f.disk.userFiles().values()]
@ -508,7 +509,7 @@ for (const interrupted of [false, true]) {
await f.database.save();
const saved = f.persistence.snapshot();
const catchUp = async () =>
f.internals.incorporateEventBatch(
f.internals.applyEventBatch(
await service.getEvents!(f.database.state.lastSeenUpdateId!)
);
if (interrupted) {
@ -539,7 +540,7 @@ test("an ignored occupant arriving after the scan keeps its path and privacy", a
);
await f.settings.setSettings({ ignorePatterns: ["private"] });
(f.database.state.documents.a ??= {}).remote = head("a", 2, "remote");
await f.internals.reconcile({
await f.internals.applyRemoteChanges({
fileManifestId: 3,
entries: { a: "private/note.md" }
});
@ -547,8 +548,8 @@ test("an ignored occupant arriving after the scan keeps its path and privacy", a
(await f.disk.read("private"))?.content,
bytes("SECRET")
);
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
const { pending } = f.database.state;
assert(
pending?.type !== "content" ||
@ -571,7 +572,7 @@ test("content notifications during every snapshot do not starve a complete names
});
}
};
await f.internals.scan();
await f.internals.refreshLocalFiles();
assert.equal(notifications, 2);
assert.deepEqual(f.database.state.local, { a: "a.md", b: "b.md" });
});
@ -604,10 +605,10 @@ for (const boundary of ["before:save", "durable:save"]) {
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("base A"));
f.internals.stopped = false;
await f.internals.preparePush();
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
const { requestId } = f.database.state.pending!.request;
await assert.rejects(f.internals.finishPending(), /413/);
await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
await f.disk.userWrite("a.md", bytes("base"));
const first = {
headEventId: 3,
@ -639,15 +640,15 @@ for (const boundary of ["before:save", "durable:save"]) {
if (label === boundary) throw new Error("injected crash");
};
await assert.rejects(
f.internals.finishPending(),
f.internals.uploads.completePendingUpload(),
/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);
await f.internals.uploads.completePendingUpload();
await f.internals.applyEventBatch(first);
assert.equal(f.database.state.lastSeenUpdateId, 3);
assert.deepEqual(
(await f.disk.read("a.md"))?.content,
@ -753,21 +754,21 @@ for (const retry of [false, true]) {
};
f.database.state.lastSeenUpdateId = 1;
await f.disk.userWrite("a.md", bytes("edited"));
f.internals.stopped = false;
await f.internals.preparePush();
if (retry) await assert.rejects(f.internals.finishPending(), /413/);
f.internals.scheduler.phase = SyncPhase.Idle;
await f.internals.prepareNextUpload();
if (retry) await assert.rejects(f.internals.uploads.completePendingUpload(), /413/);
// Recovery folds queued deletions into the durable namespace before
// an outstanding request is retried.
f.internals.stopped = true;
f.internals.scheduler.phase = SyncPhase.Stopped;
await f.disk.userDelete("a.md");
await f.syncer.syncLocallyDeletedFile("a.md");
await f.internals.scan();
await f.internals.refreshLocalFiles();
assert.deepEqual(f.database.state.local, {});
if (retry) {
reject = false;
await f.syncer.retryRejectedRequests();
}
await f.internals.finishPending();
await f.internals.uploads.completePendingUpload();
assert.equal(f.database.state.pending, undefined);
assert.deepEqual(f.database.state.local, {});
assert.equal(await f.disk.read("a.md"), undefined);

View file

@ -1,6 +1,13 @@
import type { UploadQueue } from "../sync-client/src/sync-operations/upload-queue";
import type { ContentSync } from "../sync-client/src/sync-operations/content-sync";
import { fetchRemoteChanges, type RemoteApplicationOptions } from "../sync-client/src/sync-operations/remote-changes";
import type { EventBatch } from "../sync-client/src/services/types/EventBatch";
import type { FileManifest } from "../sync-client/src/services/types/FileManifest";
import type { VaultSnapshot } from "../sync-client/src/services/types/VaultSnapshot";
import { SyncPhase } from "../sync-client/src/sync-operations/sync-scheduler";
import {
Database,
emptyState,
createEmptyDatabase,
type StoredDatabase
} from "../sync-client/src/persistence/database";
import { Settings } from "../sync-client/src/persistence/settings";
@ -35,7 +42,7 @@ export async function fixture(
const disk = recovered?.disk ?? new MemoryDisk();
const initial = recovered
? (recovered.persistence.snapshot().database as StoredDatabase)
: emptyState("test");
: createEmptyDatabase("test");
if (!recovered) {
initial.lastSeenUpdateId = 0;
for (const [path, content] of Object.entries(contents)) {
@ -99,19 +106,14 @@ export async function fixture(
{ entries: changes, save: async () => {} }
);
const internals = syncer as unknown as {
finishPending: () => Promise<void>;
preparePush: () => Promise<boolean>;
scan: (
content: ContentSync;
uploads: UploadQueue;
prepareNextUpload: () => Promise<boolean>;
refreshLocalFiles: (
initial?: import("../sync-client/src/services/types/VaultSnapshot").VaultSnapshot
) => Promise<void>;
incorporateEventBatch: (
batch: import("../sync-client/src/services/types/EventBatch").EventBatch
) => Promise<void>;
stopped: boolean;
reconcile: (remote: {
fileManifestId: number;
entries: Record<string, string>;
}) => Promise<void>;
scheduler: { phase: SyncPhase };
applyRemoteChanges: (remote: FileManifest, options?: RemoteApplicationOptions) => Promise<void>;
};
return {
disk,
@ -119,7 +121,18 @@ export async function fixture(
files,
settings,
syncer,
internals,
internals: {
content: internals.content,
uploads: internals.uploads,
scheduler: internals.scheduler,
prepareNextUpload: () => internals.prepareNextUpload(),
refreshLocalFiles: (initial?: VaultSnapshot) => internals.refreshLocalFiles(initial),
applyRemoteChanges: (manifest: FileManifest) => internals.applyRemoteChanges(manifest),
applyEventBatch: async (batch: EventBatch) => {
const remote = await fetchRemoteChanges(service as SyncService, database.state, batch);
await internals.applyRemoteChanges(remote.manifest, remote);
}
},
persistence,
changes,
contentCache