aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/crdt/sync-state.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/sync/crdt/sync-state.test.ts')
-rw-r--r--src/client/sync/crdt/sync-state.test.ts449
1 files changed, 449 insertions, 0 deletions
diff --git a/src/client/sync/crdt/sync-state.test.ts b/src/client/sync/crdt/sync-state.test.ts
new file mode 100644
index 0000000..4fb9066
--- /dev/null
+++ b/src/client/sync/crdt/sync-state.test.ts
@@ -0,0 +1,449 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import "fake-indexeddb/auto";
+import {
+ base64ToBinary,
+ binaryToBase64,
+ type CrdtSyncPayload,
+ type CrdtSyncStateEntry,
+ CrdtSyncStateManager,
+ crdtSyncDb,
+ entriesToSyncPayload,
+ syncPayloadToEntries,
+} from "./sync-state";
+import { CrdtEntityType } from "./types";
+
+describe("binaryToBase64 and base64ToBinary", () => {
+ it("should roundtrip binary data correctly", () => {
+ const original = new Uint8Array([0, 1, 2, 255, 128, 64]);
+ const base64 = binaryToBase64(original);
+ const restored = base64ToBinary(base64);
+
+ expect(restored).toEqual(original);
+ });
+
+ it("should handle empty array", () => {
+ const original = new Uint8Array([]);
+ const base64 = binaryToBase64(original);
+ const restored = base64ToBinary(base64);
+
+ expect(restored).toEqual(original);
+ });
+
+ it("should handle large data", () => {
+ const original = new Uint8Array(1000);
+ for (let i = 0; i < 1000; i++) {
+ original[i] = i % 256;
+ }
+
+ const base64 = binaryToBase64(original);
+ const restored = base64ToBinary(base64);
+
+ expect(restored).toEqual(original);
+ });
+
+ it("should produce valid base64 string", () => {
+ const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
+ const base64 = binaryToBase64(data);
+
+ expect(base64).toBe("SGVsbG8=");
+ });
+});
+
+describe("entriesToSyncPayload", () => {
+ it("should convert entries to sync payload format", () => {
+ const entries: CrdtSyncStateEntry[] = [
+ {
+ documentId: "deck:deck-1",
+ entityType: CrdtEntityType.Deck,
+ entityId: "deck-1",
+ binary: new Uint8Array([1, 2, 3]),
+ lastSyncedAt: Date.now(),
+ syncVersion: 1,
+ },
+ {
+ documentId: "card:card-1",
+ entityType: CrdtEntityType.Card,
+ entityId: "card-1",
+ binary: new Uint8Array([4, 5, 6]),
+ lastSyncedAt: Date.now(),
+ syncVersion: 2,
+ },
+ ];
+
+ const payloads = entriesToSyncPayload(entries);
+
+ expect(payloads).toHaveLength(2);
+ expect(payloads[0]?.documentId).toBe("deck:deck-1");
+ expect(payloads[0]?.entityType).toBe(CrdtEntityType.Deck);
+ expect(payloads[0]?.entityId).toBe("deck-1");
+ expect(typeof payloads[0]?.binary).toBe("string"); // Base64 encoded
+ });
+});
+
+describe("syncPayloadToEntries", () => {
+ it("should convert sync payload to entries format", () => {
+ const payloads: CrdtSyncPayload[] = [
+ {
+ documentId: "deck:deck-1",
+ entityType: CrdtEntityType.Deck,
+ entityId: "deck-1",
+ binary: binaryToBase64(new Uint8Array([1, 2, 3])),
+ },
+ ];
+
+ const entries = syncPayloadToEntries(payloads, 5);
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.entityType).toBe(CrdtEntityType.Deck);
+ expect(entries[0]?.entityId).toBe("deck-1");
+ expect(entries[0]?.binary).toEqual(new Uint8Array([1, 2, 3]));
+ expect(entries[0]?.syncVersion).toBe(5);
+ });
+});
+
+describe("CrdtSyncStateManager", () => {
+ let manager: CrdtSyncStateManager;
+
+ beforeEach(async () => {
+ manager = new CrdtSyncStateManager();
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ });
+
+ afterEach(async () => {
+ await crdtSyncDb.syncState.clear();
+ await crdtSyncDb.metadata.clear();
+ });
+
+ describe("document operations", () => {
+ it("should store and retrieve document binary", async () => {
+ const binary = new Uint8Array([1, 2, 3, 4, 5]);
+ await manager.setDocumentBinary(CrdtEntityType.Deck, "deck-1", binary, 1);
+
+ const retrieved = await manager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ );
+ expect(retrieved).toEqual(binary);
+ });
+
+ it("should return null for non-existent document", async () => {
+ const result = await manager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ "non-existent",
+ );
+ expect(result).toBeNull();
+ });
+
+ it("should update existing document", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([2]),
+ 2,
+ );
+
+ const retrieved = await manager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ );
+ expect(retrieved).toEqual(new Uint8Array([2]));
+ });
+
+ it("should delete document", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.deleteDocument(CrdtEntityType.Deck, "deck-1");
+
+ const result = await manager.getDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ );
+ expect(result).toBeNull();
+ });
+
+ it("should check if document exists", async () => {
+ expect(await manager.hasDocument(CrdtEntityType.Deck, "deck-1")).toBe(
+ false,
+ );
+
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+
+ expect(await manager.hasDocument(CrdtEntityType.Deck, "deck-1")).toBe(
+ true,
+ );
+ });
+ });
+
+ describe("document by type operations", () => {
+ it("should get all documents by type", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-2",
+ new Uint8Array([2]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Card,
+ "card-1",
+ new Uint8Array([3]),
+ 1,
+ );
+
+ const decks = await manager.getDocumentsByType(CrdtEntityType.Deck);
+ expect(decks).toHaveLength(2);
+
+ const cards = await manager.getDocumentsByType(CrdtEntityType.Card);
+ expect(cards).toHaveLength(1);
+ });
+
+ it("should delete documents by type", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-2",
+ new Uint8Array([2]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Card,
+ "card-1",
+ new Uint8Array([3]),
+ 1,
+ );
+
+ await manager.deleteDocumentsByType(CrdtEntityType.Deck);
+
+ const decks = await manager.getDocumentsByType(CrdtEntityType.Deck);
+ expect(decks).toHaveLength(0);
+
+ const cards = await manager.getDocumentsByType(CrdtEntityType.Card);
+ expect(cards).toHaveLength(1);
+ });
+
+ it("should count documents by type", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-2",
+ new Uint8Array([2]),
+ 1,
+ );
+
+ const count = await manager.getDocumentCountByType(CrdtEntityType.Deck);
+ expect(count).toBe(2);
+ });
+ });
+
+ describe("metadata operations", () => {
+ it("should get and set metadata", async () => {
+ await manager.setMetadata({
+ lastSyncAt: 1234567890,
+ syncVersionWatermark: 10,
+ actorId: "actor-123",
+ });
+
+ const metadata = await manager.getMetadata();
+ expect(metadata?.lastSyncAt).toBe(1234567890);
+ expect(metadata?.syncVersionWatermark).toBe(10);
+ expect(metadata?.actorId).toBe("actor-123");
+ });
+
+ it("should return null for non-existent metadata", async () => {
+ const metadata = await manager.getMetadata();
+ expect(metadata).toBeNull();
+ });
+
+ it("should update partial metadata", async () => {
+ await manager.setMetadata({
+ lastSyncAt: 1000,
+ syncVersionWatermark: 1,
+ actorId: "actor-1",
+ });
+
+ await manager.setMetadata({ lastSyncAt: 2000 });
+
+ const metadata = await manager.getMetadata();
+ expect(metadata?.lastSyncAt).toBe(2000);
+ expect(metadata?.syncVersionWatermark).toBe(1); // Preserved
+ expect(metadata?.actorId).toBe("actor-1"); // Preserved
+ });
+
+ it("should get and set last sync timestamp", async () => {
+ expect(await manager.getLastSyncAt()).toBe(0);
+
+ await manager.setLastSyncAt(1234567890);
+ expect(await manager.getLastSyncAt()).toBe(1234567890);
+ });
+
+ it("should get and set sync version watermark", async () => {
+ expect(await manager.getSyncVersionWatermark()).toBe(0);
+
+ await manager.setSyncVersionWatermark(42);
+ expect(await manager.getSyncVersionWatermark()).toBe(42);
+ });
+ });
+
+ describe("batch operations", () => {
+ it("should batch set multiple documents", async () => {
+ const deckType = CrdtEntityType.Deck;
+ const cardType = CrdtEntityType.Card;
+ const entries = [
+ {
+ entityType: deckType,
+ entityId: "deck-1",
+ binary: new Uint8Array([1]),
+ syncVersion: 1,
+ },
+ {
+ entityType: cardType,
+ entityId: "card-1",
+ binary: new Uint8Array([2]),
+ syncVersion: 1,
+ },
+ {
+ entityType: cardType,
+ entityId: "card-2",
+ binary: new Uint8Array([3]),
+ syncVersion: 1,
+ },
+ ];
+
+ await manager.batchSetDocuments(entries);
+
+ expect(await manager.getTotalDocumentCount()).toBe(3);
+ expect(
+ await manager.getDocumentBinary(CrdtEntityType.Deck, "deck-1"),
+ ).toEqual(new Uint8Array([1]));
+ expect(
+ await manager.getDocumentBinary(CrdtEntityType.Card, "card-1"),
+ ).toEqual(new Uint8Array([2]));
+ });
+
+ it("should batch delete multiple documents", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Card,
+ "card-1",
+ new Uint8Array([2]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Card,
+ "card-2",
+ new Uint8Array([3]),
+ 1,
+ );
+
+ await manager.batchDeleteDocuments([
+ { entityType: CrdtEntityType.Deck, entityId: "deck-1" },
+ { entityType: CrdtEntityType.Card, entityId: "card-1" },
+ ]);
+
+ expect(await manager.getTotalDocumentCount()).toBe(1);
+ expect(
+ await manager.getDocumentBinary(CrdtEntityType.Card, "card-2"),
+ ).toEqual(new Uint8Array([3]));
+ });
+ });
+
+ describe("sync time queries", () => {
+ it("should get documents synced since timestamp", async () => {
+ // Set documents with different sync times
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+
+ // Wait a bit to ensure different timestamps
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ const afterFirst = Date.now();
+
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-2",
+ new Uint8Array([2]),
+ 1,
+ );
+
+ const recentDocs = await manager.getDocumentsSyncedSince(afterFirst - 5);
+ expect(recentDocs.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe("clear operations", () => {
+ it("should clear all data", async () => {
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setMetadata({ lastSyncAt: 1000 });
+
+ await manager.clearAll();
+
+ expect(await manager.getTotalDocumentCount()).toBe(0);
+ expect(await manager.getMetadata()).toBeNull();
+ });
+ });
+
+ describe("total document count", () => {
+ it("should return total count of all documents", async () => {
+ expect(await manager.getTotalDocumentCount()).toBe(0);
+
+ await manager.setDocumentBinary(
+ CrdtEntityType.Deck,
+ "deck-1",
+ new Uint8Array([1]),
+ 1,
+ );
+ await manager.setDocumentBinary(
+ CrdtEntityType.Card,
+ "card-1",
+ new Uint8Array([2]),
+ 1,
+ );
+
+ expect(await manager.getTotalDocumentCount()).toBe(2);
+ });
+ });
+});