aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/crdt/migration.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/sync/crdt/migration.test.ts')
-rw-r--r--src/client/sync/crdt/migration.test.ts639
1 files changed, 639 insertions, 0 deletions
diff --git a/src/client/sync/crdt/migration.test.ts b/src/client/sync/crdt/migration.test.ts
new file mode 100644
index 0000000..22f311d
--- /dev/null
+++ b/src/client/sync/crdt/migration.test.ts
@@ -0,0 +1,639 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ LocalCard,
+ LocalDeck,
+ LocalNote,
+ LocalNoteFieldType,
+ LocalNoteFieldValue,
+ LocalNoteType,
+ LocalReviewLog,
+} from "../../db/index";
+import { CardState, Rating } from "../../db/index";
+import {
+ crdtCardRepository,
+ crdtDeckRepository,
+ crdtNoteFieldTypeRepository,
+ crdtNoteFieldValueRepository,
+ crdtNoteRepository,
+ crdtNoteTypeRepository,
+ crdtReviewLogRepository,
+} from "./repositories";
+import { crdtSyncDb, crdtSyncStateManager } from "./sync-state";
+import { CrdtEntityType } from "./types";
+
+// Mock Dexie databases
+vi.mock("../../db/index", async () => {
+ const actual =
+ await vi.importActual<typeof import("../../db/index")>("../../db/index");
+ return {
+ ...actual,
+ db: {
+ decks: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ noteTypes: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ noteFieldTypes: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ notes: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ noteFieldValues: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ cards: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ reviewLogs: {
+ toArray: vi.fn(),
+ count: vi.fn(),
+ offset: vi.fn(),
+ },
+ },
+ };
+});
+
+vi.mock("./sync-state", async () => {
+ const actual =
+ await vi.importActual<typeof import("./sync-state")>("./sync-state");
+ return {
+ ...actual,
+ crdtSyncDb: {
+ metadata: {
+ get: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ },
+ syncState: {
+ clear: vi.fn(),
+ },
+ },
+ crdtSyncStateManager: {
+ setDocumentBinary: vi.fn(),
+ batchSetDocuments: vi.fn(),
+ clearAll: vi.fn(),
+ },
+ };
+});
+
+// Get mocked db
+import { db } from "../../db/index";
+// Import the module under test after mocks are set up
+import {
+ clearAllCrdtState,
+ getMigrationStatus,
+ isMigrationCompleted,
+ MIGRATION_VERSION,
+ resetMigration,
+ runMigration,
+ runMigrationWithBatching,
+} from "./migration";
+
+describe("migration", () => {
+ const mockDeck: LocalDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockNoteType: LocalNoteType = {
+ id: "note-type-1",
+ userId: "user-1",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockNoteFieldType: LocalNoteFieldType = {
+ id: "field-type-1",
+ noteTypeId: "note-type-1",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockNote: LocalNote = {
+ id: "note-1",
+ deckId: "deck-1",
+ noteTypeId: "note-type-1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockNoteFieldValue: LocalNoteFieldValue = {
+ id: "field-value-1",
+ noteId: "note-1",
+ noteFieldTypeId: "field-type-1",
+ value: "Hello",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockCard: LocalCard = {
+ id: "card-1",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ front: "Front",
+ back: "Back",
+ state: CardState.New,
+ due: new Date(),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ const mockReviewLog: LocalReviewLog = {
+ id: "review-1",
+ cardId: "card-1",
+ userId: "user-1",
+ rating: Rating.Good,
+ state: CardState.Learning,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: new Date(),
+ durationMs: 1000,
+ syncVersion: 1,
+ _synced: true,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Default mock implementations for empty database
+ vi.mocked(db.decks.toArray).mockResolvedValue([]);
+ vi.mocked(db.noteTypes.toArray).mockResolvedValue([]);
+ vi.mocked(db.noteFieldTypes.toArray).mockResolvedValue([]);
+ vi.mocked(db.notes.toArray).mockResolvedValue([]);
+ vi.mocked(db.noteFieldValues.toArray).mockResolvedValue([]);
+ vi.mocked(db.cards.toArray).mockResolvedValue([]);
+ vi.mocked(db.reviewLogs.toArray).mockResolvedValue([]);
+
+ // Default: no existing migration
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue(undefined);
+ });
+
+ describe("isMigrationCompleted", () => {
+ it("should return false when no migration status exists", async () => {
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue(undefined);
+
+ const result = await isMigrationCompleted();
+ expect(result).toBe(false);
+ });
+
+ it("should return true when migration is completed", async () => {
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: Date.now(),
+ syncVersionWatermark: MIGRATION_VERSION,
+ actorId: JSON.stringify({
+ version: MIGRATION_VERSION,
+ completedAt: Date.now(),
+ counts: {
+ decks: 0,
+ noteTypes: 0,
+ noteFieldTypes: 0,
+ notes: 0,
+ noteFieldValues: 0,
+ cards: 0,
+ reviewLogs: 0,
+ },
+ }),
+ });
+
+ const result = await isMigrationCompleted();
+ expect(result).toBe(true);
+ });
+
+ it("should return false when migration version is outdated", async () => {
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: Date.now(),
+ syncVersionWatermark: 0,
+ actorId: JSON.stringify({
+ version: 0, // Older version
+ completedAt: Date.now(),
+ counts: {
+ decks: 0,
+ noteTypes: 0,
+ noteFieldTypes: 0,
+ notes: 0,
+ noteFieldValues: 0,
+ cards: 0,
+ reviewLogs: 0,
+ },
+ }),
+ });
+
+ const result = await isMigrationCompleted();
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("getMigrationStatus", () => {
+ it("should return null when no status exists", async () => {
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue(undefined);
+
+ const result = await getMigrationStatus();
+ expect(result).toBeNull();
+ });
+
+ it("should return parsed status when exists", async () => {
+ const expectedStatus = {
+ version: MIGRATION_VERSION,
+ completedAt: 1234567890,
+ counts: {
+ decks: 5,
+ noteTypes: 2,
+ noteFieldTypes: 4,
+ notes: 10,
+ noteFieldValues: 20,
+ cards: 15,
+ reviewLogs: 50,
+ },
+ };
+
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: expectedStatus.completedAt,
+ syncVersionWatermark: expectedStatus.version,
+ actorId: JSON.stringify(expectedStatus),
+ });
+
+ const result = await getMigrationStatus();
+ expect(result).toEqual(expectedStatus);
+ });
+
+ it("should return null for invalid JSON", async () => {
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: 0,
+ syncVersionWatermark: 0,
+ actorId: "invalid json",
+ });
+
+ const result = await getMigrationStatus();
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("runMigration", () => {
+ it("should skip migration if already completed", async () => {
+ const existingStatus = {
+ version: MIGRATION_VERSION,
+ completedAt: Date.now(),
+ counts: {
+ decks: 1,
+ noteTypes: 0,
+ noteFieldTypes: 0,
+ notes: 0,
+ noteFieldValues: 0,
+ cards: 0,
+ reviewLogs: 0,
+ },
+ };
+
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: existingStatus.completedAt,
+ syncVersionWatermark: existingStatus.version,
+ actorId: JSON.stringify(existingStatus),
+ });
+
+ const result = await runMigration();
+
+ expect(result.wasRun).toBe(false);
+ expect(result.status).toEqual(existingStatus);
+ expect(crdtSyncStateManager.setDocumentBinary).not.toHaveBeenCalled();
+ });
+
+ it("should migrate all entity types", async () => {
+ vi.mocked(db.decks.toArray).mockResolvedValue([mockDeck]);
+ vi.mocked(db.noteTypes.toArray).mockResolvedValue([mockNoteType]);
+ vi.mocked(db.noteFieldTypes.toArray).mockResolvedValue([
+ mockNoteFieldType,
+ ]);
+ vi.mocked(db.notes.toArray).mockResolvedValue([mockNote]);
+ vi.mocked(db.noteFieldValues.toArray).mockResolvedValue([
+ mockNoteFieldValue,
+ ]);
+ vi.mocked(db.cards.toArray).mockResolvedValue([mockCard]);
+ vi.mocked(db.reviewLogs.toArray).mockResolvedValue([mockReviewLog]);
+
+ const result = await runMigration();
+
+ expect(result.wasRun).toBe(true);
+ expect(result.status).toBeDefined();
+ expect(result.status?.counts).toEqual({
+ decks: 1,
+ noteTypes: 1,
+ noteFieldTypes: 1,
+ notes: 1,
+ noteFieldValues: 1,
+ cards: 1,
+ reviewLogs: 1,
+ });
+
+ // Verify all entity types were migrated
+ expect(crdtSyncStateManager.setDocumentBinary).toHaveBeenCalledTimes(7);
+ });
+
+ it("should call setDocumentBinary with correct parameters for deck", async () => {
+ vi.mocked(db.decks.toArray).mockResolvedValue([mockDeck]);
+
+ await runMigration();
+
+ expect(crdtSyncStateManager.setDocumentBinary).toHaveBeenCalledWith(
+ CrdtEntityType.Deck,
+ mockDeck.id,
+ expect.any(Uint8Array),
+ mockDeck.syncVersion,
+ );
+ });
+
+ it("should save migration status on success", async () => {
+ await runMigration();
+
+ expect(crdtSyncDb.metadata.put).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: "crdt-migration-status",
+ syncVersionWatermark: MIGRATION_VERSION,
+ }),
+ );
+ });
+
+ it("should handle errors gracefully", async () => {
+ const error = new Error("Database error");
+ vi.mocked(db.decks.toArray).mockRejectedValue(error);
+
+ const result = await runMigration();
+
+ expect(result.wasRun).toBe(true);
+ expect(result.status).toBeNull();
+ expect(result.error).toBe(error);
+ });
+ });
+
+ describe("runMigrationWithBatching", () => {
+ it("should skip migration if already completed", async () => {
+ const existingStatus = {
+ version: MIGRATION_VERSION,
+ completedAt: Date.now(),
+ counts: {
+ decks: 0,
+ noteTypes: 0,
+ noteFieldTypes: 0,
+ notes: 0,
+ noteFieldValues: 0,
+ cards: 0,
+ reviewLogs: 0,
+ },
+ };
+
+ vi.mocked(crdtSyncDb.metadata.get).mockResolvedValue({
+ key: "crdt-migration-status",
+ lastSyncAt: existingStatus.completedAt,
+ syncVersionWatermark: existingStatus.version,
+ actorId: JSON.stringify(existingStatus),
+ });
+
+ const result = await runMigrationWithBatching(10);
+
+ expect(result.wasRun).toBe(false);
+ expect(result.status).toEqual(existingStatus);
+ });
+
+ it("should process entities in batches", async () => {
+ // Create 3 decks to test batching
+ const decks = [
+ { ...mockDeck, id: "deck-1" },
+ { ...mockDeck, id: "deck-2" },
+ { ...mockDeck, id: "deck-3" },
+ ];
+
+ // Mock the offset/limit chain for batching
+ let callCount = 0;
+ const mockOffset = vi.fn().mockImplementation(() => ({
+ limit: vi.fn().mockImplementation((limit: number) => ({
+ toArray: vi.fn().mockImplementation(() => {
+ const start = callCount * limit;
+ callCount++;
+ return Promise.resolve(decks.slice(start, start + limit));
+ }),
+ })),
+ }));
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock
+ (db.decks as any).offset = mockOffset;
+ vi.mocked(db.decks.count).mockResolvedValue(3);
+
+ // Empty arrays for other entity types
+ const emptyOffset = vi.fn().mockReturnValue({
+ limit: vi.fn().mockReturnValue({
+ toArray: vi.fn().mockResolvedValue([]),
+ }),
+ });
+
+ for (const table of [
+ db.noteTypes,
+ db.noteFieldTypes,
+ db.notes,
+ db.noteFieldValues,
+ db.cards,
+ db.reviewLogs,
+ ]) {
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock
+ (table as any).offset = emptyOffset;
+ vi.mocked(table.count).mockResolvedValue(0);
+ }
+
+ const result = await runMigrationWithBatching(2); // Batch size of 2
+
+ expect(result.wasRun).toBe(true);
+ expect(result.status?.counts.decks).toBe(3);
+
+ // Should have called batchSetDocuments for deck batches
+ expect(crdtSyncStateManager.batchSetDocuments).toHaveBeenCalled();
+ });
+
+ it("should call progress callback", async () => {
+ const deck = { ...mockDeck, id: "deck-1" };
+
+ let callCount = 0;
+ const mockOffset = vi.fn().mockReturnValue({
+ limit: vi.fn().mockReturnValue({
+ toArray: vi.fn().mockImplementation(() => {
+ if (callCount === 0) {
+ callCount++;
+ return Promise.resolve([deck]);
+ }
+ return Promise.resolve([]);
+ }),
+ }),
+ });
+
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock
+ (db.decks as any).offset = mockOffset;
+ vi.mocked(db.decks.count).mockResolvedValue(1);
+
+ // Empty arrays for other entity types
+ const emptyOffset = vi.fn().mockReturnValue({
+ limit: vi.fn().mockReturnValue({
+ toArray: vi.fn().mockResolvedValue([]),
+ }),
+ });
+
+ for (const table of [
+ db.noteTypes,
+ db.noteFieldTypes,
+ db.notes,
+ db.noteFieldValues,
+ db.cards,
+ db.reviewLogs,
+ ]) {
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock
+ (table as any).offset = emptyOffset;
+ vi.mocked(table.count).mockResolvedValue(0);
+ }
+
+ const progressCallback = vi.fn();
+ await runMigrationWithBatching(10, progressCallback);
+
+ expect(progressCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entityType: "deck",
+ percentage: expect.any(Number),
+ }),
+ );
+ });
+ });
+
+ describe("resetMigration", () => {
+ it("should delete migration status", async () => {
+ await resetMigration();
+
+ expect(crdtSyncDb.metadata.delete).toHaveBeenCalledWith(
+ "crdt-migration-status",
+ );
+ });
+ });
+
+ describe("clearAllCrdtState", () => {
+ it("should clear all sync state", async () => {
+ await clearAllCrdtState();
+
+ expect(crdtSyncStateManager.clearAll).toHaveBeenCalled();
+ });
+ });
+
+ describe("CRDT document conversion", () => {
+ it("should convert deck to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtDeckRepository.toCrdtDocument(mockDeck);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`deck:${mockDeck.id}`);
+ });
+
+ it("should convert noteType to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtNoteTypeRepository.toCrdtDocument(mockNoteType);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`noteType:${mockNoteType.id}`);
+ });
+
+ it("should convert noteFieldType to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtNoteFieldTypeRepository.toCrdtDocument(mockNoteFieldType);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`noteFieldType:${mockNoteFieldType.id}`);
+ });
+
+ it("should convert note to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtNoteRepository.toCrdtDocument(mockNote);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`note:${mockNote.id}`);
+ });
+
+ it("should convert noteFieldValue to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtNoteFieldValueRepository.toCrdtDocument(mockNoteFieldValue);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`noteFieldValue:${mockNoteFieldValue.id}`);
+ });
+
+ it("should convert card to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtCardRepository.toCrdtDocument(mockCard);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`card:${mockCard.id}`);
+ });
+
+ it("should convert reviewLog to valid CRDT binary", () => {
+ const { binary, documentId } =
+ crdtReviewLogRepository.toCrdtDocument(mockReviewLog);
+
+ expect(binary).toBeInstanceOf(Uint8Array);
+ expect(binary.length).toBeGreaterThan(0);
+ expect(documentId).toBe(`reviewLog:${mockReviewLog.id}`);
+ });
+ });
+});