890 lines
31 KiB
TypeScript
890 lines
31 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { SpaceSync, SyncSnapshot } from "./sync.ts";
|
|
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
|
|
import { MemoryKvPrimitives } from "../data/memory_kv_primitives.ts";
|
|
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
|
|
|
|
const encode = (s: string) => new TextEncoder().encode(s);
|
|
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
|
|
|
|
function createSyncSetup(opts?: {
|
|
isSyncCandidate?: (path: string) => boolean;
|
|
}) {
|
|
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const snapshot = new SyncSnapshot();
|
|
const sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: opts?.isSyncCandidate ?? (() => true),
|
|
});
|
|
return { primary, secondary, snapshot, sync };
|
|
}
|
|
|
|
async function doSync(
|
|
sync: SpaceSync,
|
|
snapshot: SyncSnapshot,
|
|
): Promise<number> {
|
|
await sleep(10);
|
|
return sync.syncFiles(snapshot);
|
|
}
|
|
|
|
describe("Sync with no filtering", () => {
|
|
test("bidirectional create, update, delete, conflict", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
console.log("Write one page to primary");
|
|
await primary.writeFile("index.md", encode("Hello"));
|
|
expect((await secondary.fetchFileList()).length).toEqual(0);
|
|
console.log("Initial sync ops", await doSync(sync, snapshot));
|
|
|
|
expect((await secondary.fetchFileList()).length).toEqual(1);
|
|
expect((await secondary.readFile("index.md")).data).toEqual(
|
|
encode("Hello"),
|
|
);
|
|
|
|
// Should be a no-op
|
|
let ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
|
|
// Now let's make a change on the secondary
|
|
await secondary.writeFile("index.md", encode("Hello!!"));
|
|
await secondary.writeFile("test.md", encode("Test page"));
|
|
|
|
// And sync it
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(2);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
|
|
expect((await primary.fetchFileList()).length).toEqual(2);
|
|
expect((await secondary.fetchFileList()).length).toEqual(2);
|
|
|
|
expect((await primary.readFile("index.md")).data).toEqual(
|
|
encode("Hello!!"),
|
|
);
|
|
|
|
// Let's make some random edits on both ends
|
|
await primary.writeFile("index.md", encode("1"));
|
|
await primary.writeFile("index2.md", encode("2"));
|
|
await secondary.writeFile("index3.md", encode("3"));
|
|
await secondary.writeFile("index4.md", encode("4"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await primary.fetchFileList()).length).toEqual(5);
|
|
expect((await secondary.fetchFileList()).length).toEqual(5);
|
|
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0);
|
|
|
|
console.log("Deleting pages");
|
|
// Delete some pages
|
|
await primary.deleteFile("index.md");
|
|
await primary.deleteFile("index3.md");
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await primary.fetchFileList()).length).toEqual(3);
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
|
|
// No-op
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0);
|
|
|
|
await secondary.deleteFile("index4.md");
|
|
await primary.deleteFile("index2.md");
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
// Just "test" left
|
|
expect((await primary.fetchFileList()).length).toEqual(1);
|
|
expect((await secondary.fetchFileList()).length).toEqual(1);
|
|
|
|
// No-op
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0);
|
|
|
|
await secondary.writeFile("index.md", encode("I'm back"));
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await primary.readFile("index.md")).data).toEqual(
|
|
encode("I'm back"),
|
|
);
|
|
|
|
// Cause a conflict
|
|
console.log("Introducing a conflict now");
|
|
await primary.writeFile("index.md", encode("Hello 1"));
|
|
await secondary.writeFile("index.md", encode("Hello 2"));
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
// Sync conflicting copy back
|
|
await doSync(sync, snapshot);
|
|
|
|
// Verify that primary won
|
|
expect((await primary.readFile("index.md")).data).toEqual(
|
|
encode("Hello 1"),
|
|
);
|
|
expect((await secondary.readFile("index.md")).data).toEqual(
|
|
encode("Hello 1"),
|
|
);
|
|
|
|
// test + index + index.conflicting copy
|
|
expect((await primary.fetchFileList()).length).toEqual(3);
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
|
|
// Introducing a fake conflict (same content, so not really conflicting)
|
|
await primary.writeFile("index.md", encode("Hello 1"));
|
|
await secondary.writeFile("index.md", encode("Hello 1"));
|
|
|
|
await doSync(sync, snapshot);
|
|
await doSync(sync, snapshot);
|
|
|
|
// test + index + index.md + previous index.conflicting copy but nothing more
|
|
expect((await primary.fetchFileList()).length).toEqual(3);
|
|
});
|
|
});
|
|
|
|
describe("Sync with filtering", () => {
|
|
test("filter mode switching", async () => {
|
|
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
|
|
const snapshot = new SyncSnapshot();
|
|
let sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
|
|
});
|
|
|
|
console.log(
|
|
"Write one non-sync file on the primary, which SHOULD sync to the secondary",
|
|
);
|
|
await primary.writeFile("index.txt", encode("Hello"));
|
|
expect((await secondary.fetchFileList()).length).toEqual(0);
|
|
let ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(1);
|
|
// Note: this number should be 0 because the file DOES have a local representation so it's not technically non-synced
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
|
|
expect((await secondary.fetchFileList()).length).toEqual(1);
|
|
expect((await secondary.readFile("index.txt")).data).toEqual(
|
|
encode("Hello"),
|
|
);
|
|
|
|
console.log("Updating on secondary");
|
|
await secondary.writeFile("index.txt", encode("Hello Updated"));
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(1);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(1);
|
|
try {
|
|
await primary.getFileMeta("index.md");
|
|
expect(
|
|
false,
|
|
"Local file should have been deleted locally since it's out of date",
|
|
).toBeTruthy();
|
|
} catch {
|
|
// Expected
|
|
}
|
|
|
|
console.log("Deleting remote files");
|
|
await secondary.deleteFile("index.txt");
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(1);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
|
|
console.log("Creating a remote non-synced file");
|
|
await secondary.writeFile("index2.txt", encode("Hello 2"));
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0); // No-op, metadata only
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(1);
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(0); // No-op, metadata only
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(1);
|
|
|
|
await primary.writeFile("index2.txt", encode("Hello local"));
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(1);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
|
|
console.log("Getting into a state with some synced and non-synced files");
|
|
await secondary.writeFile("index.md", encode("This will sync"));
|
|
await secondary.writeFile("index.txt", encode("This will not sync"));
|
|
await secondary.writeFile("index2.txt", encode("This will not sync"));
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(2);
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(2);
|
|
|
|
// Check file listings on both ends
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
expect((await primary.fetchFileList()).length).toEqual(1);
|
|
|
|
////////////
|
|
// Now let's start another sync session, but now wanting to sync everything
|
|
console.log("Going to switch to syncing everything now");
|
|
sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: () => true,
|
|
});
|
|
|
|
ops = await doSync(sync, snapshot);
|
|
// This should pull 2 files from remote to local
|
|
expect(ops).toEqual(2);
|
|
expect((await primary.fetchFileList()).length).toEqual(3);
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
|
|
console.log("And now to syncing nothing");
|
|
sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: () => false,
|
|
});
|
|
|
|
ops = await doSync(sync, snapshot);
|
|
// This should delete 3 files from the primary
|
|
expect(ops).toEqual(3);
|
|
// Leaving nothing on primary
|
|
expect((await primary.fetchFileList()).length).toEqual(0);
|
|
// And everything unchanged on secondary
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
|
|
// Ok, now we're going to sync everything again
|
|
sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: () => true,
|
|
});
|
|
ops = await doSync(sync, snapshot);
|
|
expect(ops).toEqual(3);
|
|
expect((await primary.fetchFileList()).length).toEqual(3);
|
|
expect((await secondary.fetchFileList()).length).toEqual(3);
|
|
});
|
|
});
|
|
|
|
describe("syncSingleFile", () => {
|
|
test("basic push sync", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
const operations = await sync.syncFiles(snapshot);
|
|
expect(operations).toEqual(0);
|
|
|
|
await primary.writeFile("index.md", encode("Hello"));
|
|
expect(1).toEqual(await sync.syncSingleFile("index.md", snapshot));
|
|
|
|
expect((await secondary.readFile("index.md")).data).toEqual(
|
|
encode("Hello"),
|
|
);
|
|
|
|
console.log(
|
|
"Let's write a new file on primary that is not a sync candidate",
|
|
);
|
|
await primary.writeFile("test.txt", encode("Hello"));
|
|
expect(1).toEqual(await sync.syncSingleFile("test.txt", snapshot));
|
|
expect(snapshot.nonSyncedFiles.size).toEqual(0);
|
|
});
|
|
|
|
test("sync new file from primary to secondary", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("new.md", encode("New file"));
|
|
await sleep(10);
|
|
const ops = await sync.syncSingleFile("new.md", snapshot);
|
|
expect(ops).toBe(1);
|
|
expect(decode((await secondary.readFile("new.md")).data)).toBe("New file");
|
|
});
|
|
|
|
test("sync changes from primary to secondary", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("page.md", encode("v1"));
|
|
await doSync(sync, snapshot);
|
|
|
|
await primary.writeFile("page.md", encode("v2"));
|
|
await sleep(10);
|
|
const ops = await sync.syncSingleFile("page.md", snapshot);
|
|
expect(ops).toBe(1);
|
|
expect(decode((await secondary.readFile("page.md")).data)).toBe("v2");
|
|
});
|
|
|
|
test("skip non-synced files", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
await secondary.writeFile("img.png", encode("image"));
|
|
await doSync(sync, snapshot);
|
|
expect(snapshot.nonSyncedFiles.has("img.png")).toBe(true);
|
|
|
|
const ops = await sync.syncSingleFile("img.png", snapshot);
|
|
expect(ops).toBe(0); // Skipped
|
|
});
|
|
|
|
test("propagate deletion when primary file is deleted", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("test.md", encode("Hello"));
|
|
await doSync(sync, snapshot);
|
|
expect(snapshot.files.has("test.md")).toBe(true);
|
|
expect(decode((await secondary.readFile("test.md")).data)).toBe("Hello");
|
|
|
|
await primary.deleteFile("test.md");
|
|
|
|
const ops = await sync.syncSingleFile("test.md", snapshot);
|
|
expect(ops).toBeGreaterThanOrEqual(0);
|
|
|
|
// File should be deleted from secondary
|
|
try {
|
|
await secondary.getFileMeta("test.md");
|
|
expect.fail("Expected file to be deleted from secondary");
|
|
} catch {
|
|
// Expected: file not found
|
|
}
|
|
|
|
expect(snapshot.files.has("test.md")).toBe(false);
|
|
});
|
|
|
|
test("handle file not existing on either side", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("gone.md", encode("Hello"));
|
|
await doSync(sync, snapshot);
|
|
|
|
await primary.deleteFile("gone.md");
|
|
await secondary.deleteFile("gone.md");
|
|
|
|
const ops = await sync.syncSingleFile("gone.md", snapshot);
|
|
expect(ops).toBeGreaterThanOrEqual(0);
|
|
expect(snapshot.files.has("gone.md")).toBe(false);
|
|
});
|
|
|
|
test("sync file only on secondary (not in snapshot)", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
// File exists only on secondary, no snapshot entry
|
|
await secondary.writeFile("remote-only.md", encode("Remote content"));
|
|
await sleep(10);
|
|
|
|
const ops = await sync.syncSingleFile("remote-only.md", snapshot);
|
|
// syncSingleFile calls syncFile with syncBack=true, so it should copy to primary
|
|
expect(ops).toBe(1);
|
|
expect(decode((await primary.readFile("remote-only.md")).data)).toBe(
|
|
"Remote content",
|
|
);
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// nonSyncedFiles snapshot persistence
|
|
// =================================================================
|
|
|
|
describe("nonSyncedFiles snapshot persistence", () => {
|
|
test("snapshotUpdated fires even when only nonSyncedFiles changed", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
let snapshotSaveCount = 0;
|
|
sync.on({
|
|
snapshotUpdated: () => {
|
|
snapshotSaveCount++;
|
|
},
|
|
syncProgress: () => {},
|
|
});
|
|
|
|
await secondary.writeFile("image.png", encode("binary data"));
|
|
await sleep(10);
|
|
|
|
snapshotSaveCount = 0;
|
|
const ops = await sync.syncFiles(snapshot);
|
|
|
|
expect(ops).toBe(0);
|
|
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
|
|
expect(snapshotSaveCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("nonSyncedFiles persist across sync cycles", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
await secondary.writeFile("img1.png", encode("img1"));
|
|
await secondary.writeFile("img2.png", encode("img2"));
|
|
await sleep(10);
|
|
|
|
await sync.syncFiles(snapshot);
|
|
expect(snapshot.nonSyncedFiles.size).toBe(2);
|
|
|
|
const ops = await doSync(sync, snapshot);
|
|
expect(ops).toBe(0);
|
|
expect(snapshot.nonSyncedFiles.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Mutex/concurrency
|
|
// =================================================================
|
|
|
|
describe("Sync mutex behavior", () => {
|
|
test("concurrent syncFiles calls should be mutexed", async () => {
|
|
const { primary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("test.md", encode("Hello"));
|
|
await sleep(10);
|
|
|
|
const [result1, result2] = await Promise.all([
|
|
sync.syncFiles(snapshot),
|
|
sync.syncFiles(snapshot),
|
|
]);
|
|
|
|
const results = [result1, result2].sort((a, b) => a - b);
|
|
expect(results[0]).toBe(-1);
|
|
expect(results[1]).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test("syncSingleFile during syncFiles should return -1", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await secondary.writeFile(`file${i}.md`, encode(`content ${i}`));
|
|
}
|
|
await sleep(10);
|
|
|
|
const fullSyncPromise = sync.syncFiles(snapshot);
|
|
|
|
const singleResult = await sync.syncSingleFile("file0.md", snapshot);
|
|
expect(singleResult).toBe(-1);
|
|
|
|
await fullSyncPromise;
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Conflict resolution
|
|
// =================================================================
|
|
|
|
describe("Conflict resolution", () => {
|
|
test("fake conflict (same content) should NOT create conflict copy", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("test.md", encode("Same content"));
|
|
await doSync(sync, snapshot);
|
|
|
|
await primary.writeFile("test.md", encode("Updated same"));
|
|
await secondary.writeFile("test.md", encode("Updated same"));
|
|
await doSync(sync, snapshot);
|
|
|
|
const files = await primary.fetchFileList();
|
|
const conflictFiles = files.filter((f) => f.name.includes(".conflicted:"));
|
|
expect(conflictFiles.length).toBe(0);
|
|
});
|
|
|
|
test("real conflict creates conflict copy, primary wins", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("test.md", encode("Original"));
|
|
await doSync(sync, snapshot);
|
|
|
|
await primary.writeFile("test.md", encode("Primary version"));
|
|
await secondary.writeFile("test.md", encode("Secondary version"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(decode((await primary.readFile("test.md")).data)).toBe(
|
|
"Primary version",
|
|
);
|
|
expect(decode((await secondary.readFile("test.md")).data)).toBe(
|
|
"Primary version",
|
|
);
|
|
|
|
const primaryFiles = await primary.fetchFileList();
|
|
const conflictFile = primaryFiles.find((f) =>
|
|
f.name.includes(".conflicted:"),
|
|
);
|
|
expect(conflictFile).toBeDefined();
|
|
|
|
const conflictData = await primary.readFile(conflictFile!.name);
|
|
expect(decode(conflictData.data)).toBe("Secondary version");
|
|
|
|
await doSync(sync, snapshot);
|
|
const secondaryFiles = await secondary.fetchFileList();
|
|
const secondaryConflict = secondaryFiles.find((f) =>
|
|
f.name.includes(".conflicted:"),
|
|
);
|
|
expect(secondaryConflict).toBeDefined();
|
|
});
|
|
|
|
test("conflict on file without extension", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("LICENSE", encode("License v1"));
|
|
await doSync(sync, snapshot);
|
|
|
|
await primary.writeFile("LICENSE", encode("License v2 primary"));
|
|
await secondary.writeFile("LICENSE", encode("License v2 secondary"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(decode((await primary.readFile("LICENSE")).data)).toBe(
|
|
"License v2 primary",
|
|
);
|
|
|
|
const files = await primary.fetchFileList();
|
|
const conflictFile = files.find((f) =>
|
|
f.name.startsWith("LICENSE.conflicted:"),
|
|
);
|
|
expect(conflictFile).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Deletion scenarios
|
|
// =================================================================
|
|
|
|
describe("Deletion scenarios", () => {
|
|
test("primary deletion propagates to secondary", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("delete-me.md", encode("Will be deleted"));
|
|
await doSync(sync, snapshot);
|
|
expect(decode((await secondary.readFile("delete-me.md")).data)).toBe(
|
|
"Will be deleted",
|
|
);
|
|
|
|
await primary.deleteFile("delete-me.md");
|
|
await doSync(sync, snapshot);
|
|
|
|
try {
|
|
await secondary.getFileMeta("delete-me.md");
|
|
expect.fail("Expected file to be deleted from secondary");
|
|
} catch {
|
|
// Expected
|
|
}
|
|
});
|
|
|
|
test("secondary deletion propagates to primary", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await secondary.writeFile("delete-me.md", encode("Will be deleted"));
|
|
await doSync(sync, snapshot);
|
|
expect(decode((await primary.readFile("delete-me.md")).data)).toBe(
|
|
"Will be deleted",
|
|
);
|
|
|
|
await secondary.deleteFile("delete-me.md");
|
|
await doSync(sync, snapshot);
|
|
|
|
try {
|
|
await primary.getFileMeta("delete-me.md");
|
|
expect.fail("Expected file to be deleted from primary");
|
|
} catch {
|
|
// Expected
|
|
}
|
|
});
|
|
|
|
test("both sides delete cleans up snapshot", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("both-delete.md", encode("Content"));
|
|
await doSync(sync, snapshot);
|
|
expect(snapshot.files.has("both-delete.md")).toBe(true);
|
|
|
|
await primary.deleteFile("both-delete.md");
|
|
await secondary.deleteFile("both-delete.md");
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(snapshot.files.has("both-delete.md")).toBe(false);
|
|
});
|
|
|
|
test("file deleted on secondary, was previously non-synced (sync.ts:321-329)", async () => {
|
|
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const snapshot = new SyncSnapshot();
|
|
|
|
// Start with filtered sync so file is tracked as nonSynced
|
|
let sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
// Create a non-sync-candidate file on both sides via primary push
|
|
await primary.writeFile("data.json", encode('{"key":"value"}'));
|
|
await doSync(sync, snapshot);
|
|
|
|
// Secondary should have the file, and it's tracked
|
|
expect(decode((await secondary.readFile("data.json")).data)).toBe(
|
|
'{"key":"value"}',
|
|
);
|
|
|
|
// Now update on secondary so it becomes nonSynced
|
|
await secondary.writeFile("data.json", encode('{"key":"updated"}'));
|
|
await doSync(sync, snapshot);
|
|
expect(snapshot.nonSyncedFiles.has("data.json")).toBe(true);
|
|
|
|
// Now switch to unfiltered sync (syncBack=true), but delete on secondary
|
|
sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: () => true,
|
|
});
|
|
|
|
await secondary.deleteFile("data.json");
|
|
await doSync(sync, snapshot);
|
|
|
|
// Snapshot should be cleaned up, primary should NOT be deleted
|
|
// (because it was previously non-synced, the code skips the primary delete)
|
|
expect(snapshot.files.has("data.json")).toBe(false);
|
|
expect(snapshot.nonSyncedFiles.has("data.json")).toBe(false);
|
|
});
|
|
|
|
test("file deleted on secondary with !syncBack (sync.ts:339-356)", async () => {
|
|
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const snapshot = new SyncSnapshot();
|
|
|
|
const sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
// Create file on primary (which always pushes regardless of filter)
|
|
await primary.writeFile("config.txt", encode("config"));
|
|
await doSync(sync, snapshot);
|
|
|
|
// File should be on secondary
|
|
expect(decode((await secondary.readFile("config.txt")).data)).toBe(
|
|
"config",
|
|
);
|
|
|
|
// Now delete on secondary
|
|
await secondary.deleteFile("config.txt");
|
|
await doSync(sync, snapshot);
|
|
|
|
// Snapshot should be cleaned
|
|
expect(snapshot.files.has("config.txt")).toBe(false);
|
|
expect(snapshot.nonSyncedFiles.has("config.txt")).toBe(false);
|
|
|
|
// Primary copy should also be deleted (the !syncBack path attempts deletion)
|
|
try {
|
|
await primary.getFileMeta("config.txt");
|
|
expect.fail("Expected file to be deleted from primary");
|
|
} catch {
|
|
// Expected: file was deleted
|
|
}
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Sync candidate filtering
|
|
// =================================================================
|
|
|
|
describe("Sync candidate filtering", () => {
|
|
test("non-sync candidate on primary still syncs to secondary", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
await primary.writeFile("script.js", encode("console.log('hi')"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(decode((await secondary.readFile("script.js")).data)).toBe(
|
|
"console.log('hi')",
|
|
);
|
|
});
|
|
|
|
test("non-sync candidate on secondary tracked in nonSyncedFiles", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup({
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
await secondary.writeFile("image.png", encode("binary"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
|
|
});
|
|
|
|
test("switching from filtered to unfiltered pulls missing files", async () => {
|
|
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
|
|
const snapshot = new SyncSnapshot();
|
|
|
|
let sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: (path) => path.endsWith(".md"),
|
|
});
|
|
|
|
await secondary.writeFile("page.md", encode("markdown"));
|
|
await secondary.writeFile("image.png", encode("image"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await primary.fetchFileList()).length).toBe(1);
|
|
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
|
|
|
|
sync = new SpaceSync(primary, secondary, {
|
|
conflictResolver: SpaceSync.primaryConflictResolver,
|
|
isSyncCandidate: () => true,
|
|
});
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await primary.fetchFileList()).length).toBe(2);
|
|
expect(snapshot.nonSyncedFiles.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Resync scenario (both sides have file, no snapshot entry)
|
|
// =================================================================
|
|
|
|
describe("Resync scenario", () => {
|
|
test("both sides have file but no snapshot entry, same content", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
// Write same content to both sides without syncing
|
|
await primary.writeFile("resync.md", encode("Same content"));
|
|
await secondary.writeFile("resync.md", encode("Same content"));
|
|
await sleep(10);
|
|
|
|
// No snapshot entry exists — this is a resync scenario (sync.ts:454-458)
|
|
const ops = await doSync(sync, snapshot);
|
|
|
|
// Should go through conflict resolver, detect same content, no conflict copy
|
|
const files = await primary.fetchFileList();
|
|
const conflictFiles = files.filter((f) => f.name.includes(".conflicted:"));
|
|
expect(conflictFiles.length).toBe(0);
|
|
// Snapshot should now have the file
|
|
expect(snapshot.files.has("resync.md")).toBe(true);
|
|
expect(ops).toBe(0); // Same content = 0 ops from conflict resolver
|
|
});
|
|
|
|
test("both sides have file but no snapshot entry, different content", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
await primary.writeFile("resync.md", encode("Primary version"));
|
|
await secondary.writeFile("resync.md", encode("Secondary version"));
|
|
await sleep(10);
|
|
|
|
await doSync(sync, snapshot);
|
|
|
|
// Primary should win
|
|
expect(decode((await primary.readFile("resync.md")).data)).toBe(
|
|
"Primary version",
|
|
);
|
|
|
|
// Conflict copy should exist
|
|
const files = await primary.fetchFileList();
|
|
const conflictFile = files.find((f) => f.name.includes(".conflicted:"));
|
|
expect(conflictFile).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Size mismatch conflict
|
|
// =================================================================
|
|
|
|
describe("Size mismatch conflict", () => {
|
|
test("matching timestamps but different sizes triggers conflict resolution", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
// Initial sync
|
|
await primary.writeFile("data.bin", encode("original"));
|
|
await doSync(sync, snapshot);
|
|
|
|
// Manually manipulate the snapshot to simulate matching timestamps
|
|
// but the underlying files have different sizes
|
|
|
|
// Write different-sized content directly (bypass normal sync)
|
|
await primary.writeFile("data.bin", encode("short"));
|
|
await secondary.writeFile("data.bin", encode("much longer content here"));
|
|
|
|
// Set snapshot timestamps to match current files' timestamps
|
|
const primaryMeta = await primary.getFileMeta("data.bin");
|
|
const secondaryMeta = await secondary.getFileMeta("data.bin");
|
|
snapshot.files.set("data.bin", [
|
|
primaryMeta.lastModified,
|
|
secondaryMeta.lastModified,
|
|
]);
|
|
|
|
// Now sync — sizes differ despite timestamps matching in snapshot
|
|
// This should trigger the size mismatch conflict path
|
|
await doSync(sync, snapshot);
|
|
|
|
// Primary should win (primaryConflictResolver)
|
|
expect(decode((await primary.readFile("data.bin")).data)).toBe("short");
|
|
expect(decode((await secondary.readFile("data.bin")).data)).toBe("short");
|
|
|
|
// Conflict copy should exist
|
|
const files = await primary.fetchFileList();
|
|
const conflictFile = files.find((f) => f.name.includes(".conflicted:"));
|
|
expect(conflictFile).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// Empty file sync
|
|
// =================================================================
|
|
|
|
describe("Empty file sync", () => {
|
|
test("zero-byte files sync correctly in both directions", async () => {
|
|
const { primary, secondary, snapshot, sync } = createSyncSetup();
|
|
|
|
// Empty file from primary to secondary
|
|
await primary.writeFile("empty.md", encode(""));
|
|
await doSync(sync, snapshot);
|
|
|
|
const secondaryData = await secondary.readFile("empty.md");
|
|
expect(secondaryData.data.byteLength).toBe(0);
|
|
|
|
// Empty file from secondary to primary
|
|
await secondary.writeFile("empty2.md", encode(""));
|
|
await doSync(sync, snapshot);
|
|
|
|
const primaryData = await primary.readFile("empty2.md");
|
|
expect(primaryData.data.byteLength).toBe(0);
|
|
|
|
// Update empty file to have content
|
|
await primary.writeFile("empty.md", encode("now has content"));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect(decode((await secondary.readFile("empty.md")).data)).toBe(
|
|
"now has content",
|
|
);
|
|
|
|
// Update back to empty
|
|
await primary.writeFile("empty.md", encode(""));
|
|
await doSync(sync, snapshot);
|
|
|
|
expect((await secondary.readFile("empty.md")).data.byteLength).toBe(0);
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// syncProgress event
|
|
// =================================================================
|
|
|
|
describe("syncProgress event", () => {
|
|
test("fires during syncFiles with correct counts", async () => {
|
|
const { secondary, snapshot, sync } = createSyncSetup();
|
|
const progressEvents: { filesProcessed: number; totalFiles: number }[] = [];
|
|
|
|
sync.on({
|
|
snapshotUpdated: () => {},
|
|
syncProgress: (status) => {
|
|
progressEvents.push({ ...status });
|
|
},
|
|
});
|
|
|
|
await secondary.writeFile("a.md", encode("a"));
|
|
await secondary.writeFile("b.md", encode("b"));
|
|
await secondary.writeFile("c.md", encode("c"));
|
|
await sleep(10);
|
|
|
|
await sync.syncFiles(snapshot);
|
|
|
|
// syncProgress only fires when fileOperations > 0
|
|
expect(progressEvents.length).toBeGreaterThan(0);
|
|
|
|
// Each event should have valid counts
|
|
for (const event of progressEvents) {
|
|
expect(event.filesProcessed).toBeGreaterThan(0);
|
|
expect(event.totalFiles).toBe(3);
|
|
expect(event.filesProcessed).toBeLessThanOrEqual(event.totalFiles);
|
|
}
|
|
});
|
|
});
|