.
This commit is contained in:
parent
25f64ec690
commit
ff1f474324
1 changed files with 3271 additions and 2003 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue