Initial Commit
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { VAULT_SYNC_CODES } from "../errors/codes.js";
|
||||
import { VaultSyncError } from "../errors/vault-sync-error.js";
|
||||
import type {
|
||||
EncryptedSyncPayload,
|
||||
SyncTransport,
|
||||
VaultItem,
|
||||
VaultSnapshot,
|
||||
} from "../types/types.js";
|
||||
import { SyncEngine } from "./engine.js";
|
||||
|
||||
class CountingRng implements Rng {
|
||||
private state = 1;
|
||||
public nextUint32(): number {
|
||||
this.state = (this.state * 1664525 + 1013904223) >>> 0;
|
||||
return this.state;
|
||||
}
|
||||
public nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_00_00_00_00;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryTransport implements SyncTransport {
|
||||
public stored: VaultSnapshot | undefined;
|
||||
public failOn: "fetch" | "push" | undefined;
|
||||
public async fetchSnapshot(): Promise<EncryptedSyncPayload> {
|
||||
if (this.failOn === "fetch") throw new Error("offline");
|
||||
if (!this.stored) {
|
||||
return {
|
||||
version: "v1" as const,
|
||||
nonce: new Uint8Array(24),
|
||||
ciphertext: new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
// We pass the snapshot through as the envelope; the engine's
|
||||
// injected decrypt callback turns it back into a snapshot.
|
||||
return this.stored as unknown as EncryptedSyncPayload;
|
||||
}
|
||||
public async pushSnapshot(envelope: EncryptedSyncPayload): Promise<void> {
|
||||
if (this.failOn === "push") throw new Error("offline");
|
||||
this.stored = envelope as unknown as VaultSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(items: VaultItem[]): VaultSnapshot {
|
||||
const clock: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
for (const [k, v] of Object.entries(item.clock)) {
|
||||
clock[k] = Math.max(clock[k] ?? 0, v);
|
||||
}
|
||||
}
|
||||
return { items, clock, snapshotAt: 1 };
|
||||
}
|
||||
|
||||
function item(over: Partial<VaultItem> = {}): VaultItem {
|
||||
return {
|
||||
id: "i1",
|
||||
title: "Email",
|
||||
password: "pw",
|
||||
folderId: null,
|
||||
history: [],
|
||||
nodeId: "a",
|
||||
updatedAt: 1,
|
||||
clock: { a: 1 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEngine(transport: MemoryTransport, init?: VaultSnapshot) {
|
||||
return new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport,
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
...(init !== undefined ? { initialSnapshot: init } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SyncEngine — pull", () => {
|
||||
it("pull applies remote changes", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t);
|
||||
t.stored = snapshot([
|
||||
item({ id: "i1", title: "x" }),
|
||||
item({ id: "i2", title: "y" }),
|
||||
]);
|
||||
const result = await engine.pull();
|
||||
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
|
||||
expect(engine.currentSnapshot.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("empty pull returns empty result", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryTransport();
|
||||
t.stored = snapshot([]);
|
||||
const engine = makeEngine(t);
|
||||
const result = await engine.pull();
|
||||
expect(result.merged).toEqual([]);
|
||||
expect(result.conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates deletions as tombstones", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t);
|
||||
t.stored = snapshot([item({ id: "i1", deleted: true })]);
|
||||
const result = await engine.pull();
|
||||
expect(result.deleted).toEqual(["i1"]);
|
||||
});
|
||||
|
||||
it("merged-deleted items land in deleted[]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(
|
||||
t,
|
||||
snapshot([item({ id: "i1", deleted: true })])
|
||||
);
|
||||
t.stored = snapshot([item({ id: "i1", nodeId: "b", clock: { b: 1 } })]);
|
||||
const result = await engine.pull();
|
||||
// The local copy carries deleted=true; the merge propagates that.
|
||||
expect(result.deleted).toContain("i1");
|
||||
});
|
||||
|
||||
it("retains local-only deleted items in deleted[]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(
|
||||
t,
|
||||
snapshot([item({ id: "i1", deleted: true })])
|
||||
);
|
||||
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
|
||||
const result = await engine.pull();
|
||||
// i1 is local-only and deleted; i2 is new from remote.
|
||||
expect(result.deleted).toContain("i1");
|
||||
expect(result.merged).toContain("i2");
|
||||
});
|
||||
|
||||
it("retains local-only non-deleted items in the snapshot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t, snapshot([item({ id: "i1" })]));
|
||||
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
|
||||
const result = await engine.pull();
|
||||
// i1 is local-only and NOT deleted; result should not include it.
|
||||
expect(result.deleted).not.toContain("i1");
|
||||
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
|
||||
expect(ids).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("wraps transport errors with SYNC_TRANSPORT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
t.failOn = "fetch";
|
||||
const engine = makeEngine(t);
|
||||
try {
|
||||
await engine.pull();
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
if (!(err instanceof VaultSyncError)) throw err;
|
||||
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncEngine — push", () => {
|
||||
it("push returns only local changes since last sync", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t);
|
||||
engine.applyLocalMutation(item({ id: "i1" }));
|
||||
engine.applyLocalMutation(item({ id: "i2" }));
|
||||
const result = await engine.push();
|
||||
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("empty push returns empty result", { timeout: 5000 }, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t);
|
||||
const result = await engine.push();
|
||||
expect(result.merged).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates locally-deleted items in deleted[]", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const engine = makeEngine(t);
|
||||
engine.applyLocalMutation(item({ id: "i1", deleted: true }));
|
||||
const result = await engine.push();
|
||||
expect(result.deleted).toEqual(["i1"]);
|
||||
expect(result.merged).toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps push transport errors with SYNC_TRANSPORT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
t.failOn = "push";
|
||||
const engine = makeEngine(t);
|
||||
engine.applyLocalMutation(item({ id: "i1" }));
|
||||
try {
|
||||
await engine.push();
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
if (!(err instanceof VaultSyncError)) throw err;
|
||||
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncEngine — sync (bidirectional)", () => {
|
||||
it("bidirectional sync reconciles divergent states", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A merging transport: pushSnapshot merges into stored state, fetch
|
||||
// returns the merged remote state.
|
||||
const merger = new MemoryTransport();
|
||||
const original = merger.pushSnapshot.bind(merger);
|
||||
merger.pushSnapshot = async (env): Promise<void> => {
|
||||
const incoming = env as unknown as VaultSnapshot;
|
||||
const existing =
|
||||
(merger.stored as VaultSnapshot | undefined) ?? snapshot([]);
|
||||
const byId = new Map<string, VaultItem>();
|
||||
for (const i of existing.items) byId.set(i.id, i);
|
||||
for (const i of incoming.items) byId.set(i.id, i);
|
||||
await original({
|
||||
...env,
|
||||
} as never);
|
||||
merger.stored = {
|
||||
items: Array.from(byId.values()),
|
||||
clock: { ...existing.clock, ...incoming.clock },
|
||||
snapshotAt: Math.max(existing.snapshotAt, incoming.snapshotAt),
|
||||
};
|
||||
};
|
||||
merger.stored = snapshot([
|
||||
item({ id: "i2", title: "remote", nodeId: "b", clock: { b: 1 } }),
|
||||
]);
|
||||
const engine = makeEngine(merger);
|
||||
engine.applyLocalMutation(item({ id: "i1", title: "local" }));
|
||||
const result = await engine.sync();
|
||||
expect(result.merged.length).toBeGreaterThanOrEqual(1);
|
||||
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
|
||||
expect(ids).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("detects concurrent edits as conflicts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const local = item({
|
||||
id: "i1",
|
||||
folderId: "loc",
|
||||
clock: { a: 1 },
|
||||
});
|
||||
// Remote: same item, concurrent edit to folderId.
|
||||
t.stored = snapshot([
|
||||
item({
|
||||
id: "i1",
|
||||
folderId: "rem",
|
||||
nodeId: "b",
|
||||
clock: { b: 1 },
|
||||
}),
|
||||
]);
|
||||
const engine = new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport: t,
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
defaultStrategy: "atomic",
|
||||
initialSnapshot: snapshot([local]),
|
||||
});
|
||||
const result = await engine.pull();
|
||||
expect(result.conflicts).toContain("i1");
|
||||
});
|
||||
|
||||
it("resolves conflicts with configured strategy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const local = item({ id: "i1", title: "loc", updatedAt: 1 });
|
||||
t.stored = snapshot([
|
||||
item({
|
||||
id: "i1",
|
||||
title: "rem",
|
||||
nodeId: "b",
|
||||
clock: { b: 1 },
|
||||
updatedAt: 5,
|
||||
}),
|
||||
]);
|
||||
const engine = new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport: t,
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
initialSnapshot: snapshot([local]),
|
||||
});
|
||||
const result = await engine.pull();
|
||||
expect(result.merged).toContain("i1");
|
||||
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
|
||||
expect(merged?.title).toBe("rem");
|
||||
});
|
||||
|
||||
it("uses history strategy when items have history", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const local = item({
|
||||
id: "i1",
|
||||
history: [{ historyId: "h1", timestamp: 1, note: "a" }],
|
||||
clock: { a: 1 },
|
||||
});
|
||||
t.stored = snapshot([
|
||||
item({
|
||||
id: "i1",
|
||||
history: [{ historyId: "h2", timestamp: 2, note: "b" }],
|
||||
nodeId: "b",
|
||||
clock: { b: 1 },
|
||||
}),
|
||||
]);
|
||||
const engine = new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport: t,
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
initialSnapshot: snapshot([local]),
|
||||
});
|
||||
await engine.pull();
|
||||
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
|
||||
expect(merged?.history).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncEngine — hydrate / mutations", () => {
|
||||
it("hydrate validates snapshot shape", { timeout: 5000 }, () => {
|
||||
const engine = makeEngine(new MemoryTransport());
|
||||
try {
|
||||
engine.hydrate({
|
||||
items: [{ bogus: 1 } as unknown as VaultItem],
|
||||
clock: {},
|
||||
snapshotAt: 0,
|
||||
});
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
if (!(err instanceof VaultSyncError)) throw err;
|
||||
expect(err.code).toBe(
|
||||
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("hydrate accepts a valid snapshot", { timeout: 5000 }, () => {
|
||||
const engine = makeEngine(new MemoryTransport());
|
||||
engine.hydrate(snapshot([item({ id: "i1" })]));
|
||||
expect(engine.currentSnapshot.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applyLocalMutation overwrites existing item by id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = makeEngine(new MemoryTransport());
|
||||
engine.applyLocalMutation(item({ id: "i1", title: "a" }));
|
||||
engine.applyLocalMutation(
|
||||
item({ id: "i1", title: "b", clock: { a: 2 } })
|
||||
);
|
||||
expect(engine.currentSnapshot.items).toHaveLength(1);
|
||||
expect(engine.currentSnapshot.items[0]?.title).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncEngine — accepts injectable clock and rng", () => {
|
||||
it("accepts injectable clock and rng", { timeout: 5000 }, () => {
|
||||
const engine = new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport: new MemoryTransport(),
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
clock: () => 99,
|
||||
rng: new CountingRng(),
|
||||
});
|
||||
expect(engine.owner).toBe("a");
|
||||
expect(engine.randomSource).toBeDefined();
|
||||
});
|
||||
|
||||
it("randomSource is undefined when no rng provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = makeEngine(new MemoryTransport());
|
||||
expect(engine.randomSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncEngine — preferLocal on conflict", () => {
|
||||
it("retains local copy with merged clock when atomic conflict", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MemoryTransport();
|
||||
const local = item({ id: "i1", folderId: "loc", clock: { a: 1 } });
|
||||
t.stored = snapshot([
|
||||
item({
|
||||
id: "i1",
|
||||
folderId: "rem",
|
||||
nodeId: "b",
|
||||
clock: { b: 1 },
|
||||
}),
|
||||
]);
|
||||
const engine = new SyncEngine({
|
||||
nodeId: "a",
|
||||
transport: t,
|
||||
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
|
||||
decrypt: async (env) => env as VaultSnapshot,
|
||||
defaultStrategy: "atomic",
|
||||
initialSnapshot: snapshot([local]),
|
||||
});
|
||||
const result = await engine.pull();
|
||||
expect(result.conflicts).toContain("i1");
|
||||
const stored = engine.currentSnapshot.items.find((i) => i.id === "i1");
|
||||
expect(stored?.folderId).toBe("loc");
|
||||
expect(stored?.clock).toEqual({ a: 1, b: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user