diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 15:06:25 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 15:06:25 +0900 |
| commit | 2e21859626e69d992d4dff21338487d372097cb0 (patch) | |
| tree | 9f84a36729701d542f3e430694bb64b402fe3da1 | |
| parent | d463fd3339c791bf999873ea37d320d56319d7d4 (diff) | |
| download | kioku-2e21859626e69d992d4dff21338487d372097cb0.tar.gz kioku-2e21859626e69d992d4dff21338487d372097cb0.tar.zst kioku-2e21859626e69d992d4dff21338487d372097cb0.zip | |
feat(crdt): add Automerge document lifecycle management
Implement document-manager.ts with core CRDT operations:
- Document creation, update, merge, save/load functions
- Conversion functions between local entities and CRDT documents
- Actor ID management for client identification
- Conflict detection utilities
Completes Phase 1 of CRDT implementation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 4 | ||||
| -rw-r--r-- | src/client/sync/crdt/document-manager.test.ts | 606 | ||||
| -rw-r--r-- | src/client/sync/crdt/document-manager.ts | 716 | ||||
| -rw-r--r-- | src/client/sync/crdt/index.ts | 69 |
4 files changed, 1393 insertions, 2 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 7eb417b..41697a9 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -13,8 +13,8 @@ Replace the current Last-Write-Wins (LWW) conflict resolution with Automerge CRD - [x] Install dependencies: `@automerge/automerge`, `@automerge/automerge-repo`, `@automerge/automerge-repo-storage-indexeddb` - [x] Create `src/client/sync/crdt/types.ts` - Automerge document type definitions -- [ ] Create `src/client/sync/crdt/document-manager.ts` - Automerge document lifecycle management -- [ ] Create `src/client/sync/crdt/index.ts` - Module exports +- [x] Create `src/client/sync/crdt/document-manager.ts` - Automerge document lifecycle management +- [x] Create `src/client/sync/crdt/index.ts` - Module exports ### Phase 2: Create CRDT Repository Layer diff --git a/src/client/sync/crdt/document-manager.test.ts b/src/client/sync/crdt/document-manager.test.ts new file mode 100644 index 0000000..7c0fc00 --- /dev/null +++ b/src/client/sync/crdt/document-manager.test.ts @@ -0,0 +1,606 @@ +import * as Automerge from "@automerge/automerge"; +import { describe, expect, it } from "vitest"; +import type { LocalCard, LocalDeck, LocalReviewLog } from "../../db/index"; +import { CardState, Rating } from "../../db/index"; +import { + applyChanges, + cardToCrdtDocument, + crdtDocumentToCard, + crdtDocumentToDeck, + crdtDocumentToReviewLog, + createDocument, + createDocumentFromEntity, + createEmptyDocument, + deckToCrdtDocument, + getChanges, + getLastModified, + hasConflicts, + isDeleted, + loadDocument, + loadIncremental, + mergeDocuments, + reviewLogToCrdtDocument, + saveDocument, + saveIncremental, + updateDocument, +} from "./document-manager"; +import { + type CrdtCardDocument, + type CrdtDeckDocument, + CrdtEntityType, + type CrdtReviewLogDocument, +} from "./types"; + +describe("createDocument", () => { + it("should create an Automerge document from data", () => { + const data = { name: "test", value: 42 }; + const doc = createDocument(data); + + expect(doc.name).toBe("test"); + expect(doc.value).toBe(42); + }); + + it("should create a CRDT deck document", () => { + const deckData: CrdtDeckDocument = { + meta: { + entityId: "deck-1", + lastModified: Date.now(), + deleted: false, + }, + data: { + userId: "user-1", + name: "My Deck", + description: null, + newCardsPerDay: 20, + createdAt: Date.now(), + deletedAt: null, + }, + }; + + const doc = createDocument(deckData); + expect(doc.meta.entityId).toBe("deck-1"); + expect(doc.data.name).toBe("My Deck"); + }); +}); + +describe("updateDocument", () => { + it("should update document and return new immutable copy", () => { + const doc = createDocument({ count: 0 }); + const updated = updateDocument(doc, (d) => { + d.count = 1; + }); + + expect(updated.count).toBe(1); + // Original document should be frozen after change + }); + + it("should update nested properties", () => { + const doc = createDocument({ + meta: { entityId: "test" }, + data: { name: "original" }, + }); + + const updated = updateDocument(doc, (d) => { + d.data.name = "updated"; + }); + + expect(updated.data.name).toBe("updated"); + }); +}); + +describe("mergeDocuments", () => { + it("should merge two documents without conflicts", () => { + const doc1 = createDocument({ a: 1, b: 2 }); + const doc2 = Automerge.clone(doc1); + + const updated1 = updateDocument(doc1, (d) => { + d.a = 10; + }); + const updated2 = updateDocument(doc2, (d) => { + d.b = 20; + }); + + const result = mergeDocuments(updated1, updated2); + + expect(result.merged.a).toBe(10); + expect(result.merged.b).toBe(20); + expect(result.hasChanges).toBe(true); + }); + + it("should detect when no changes occurred", () => { + const doc1 = createDocument({ a: 1 }); + const doc2 = Automerge.clone(doc1); + + const result = mergeDocuments(doc1, doc2); + expect(result.hasChanges).toBe(false); + }); + + it("should return binary representation of merged document", () => { + const doc1 = createDocument({ value: 1 }); + const doc2 = Automerge.clone(doc1); + + const result = mergeDocuments(doc1, doc2); + expect(result.binary).toBeInstanceOf(Uint8Array); + expect(result.binary.length).toBeGreaterThan(0); + }); +}); + +describe("getChanges and applyChanges", () => { + it("should get and apply changes between documents", () => { + const doc1 = createDocument({ value: 1 }); + const doc2 = updateDocument(doc1, (d) => { + d.value = 2; + }); + + const changes = getChanges(doc1, doc2); + expect(changes.length).toBeGreaterThan(0); + + // Create a clone of doc1 and apply changes + const doc3 = Automerge.clone(doc1); + const result = applyChanges(doc3, changes); + expect(result.value).toBe(2); + }); +}); + +describe("saveDocument and loadDocument", () => { + it("should serialize and deserialize document", () => { + const original: CrdtDeckDocument = { + meta: { + entityId: "deck-123", + lastModified: 1234567890, + deleted: false, + }, + data: { + userId: "user-1", + name: "Test Deck", + description: "A test deck", + newCardsPerDay: 15, + createdAt: 1234567890, + deletedAt: null, + }, + }; + + const doc = createDocument(original); + const binary = saveDocument(doc); + + expect(binary).toBeInstanceOf(Uint8Array); + + const loaded = loadDocument<CrdtDeckDocument>(binary); + expect(loaded.meta.entityId).toBe("deck-123"); + expect(loaded.data.name).toBe("Test Deck"); + expect(loaded.data.newCardsPerDay).toBe(15); + }); +}); + +describe("saveIncremental and loadIncremental", () => { + it("should save and load incremental changes", () => { + const doc1 = createDocument({ value: 1 }); + const doc2 = updateDocument(doc1, (d) => { + d.value = 2; + }); + + const incremental = saveIncremental(doc2); + expect(incremental).toBeInstanceOf(Uint8Array); + + // Create a clone and load incremental + const doc3 = Automerge.clone(doc1); + const result = loadIncremental(doc3, incremental); + expect(result.value).toBe(2); + }); +}); + +describe("createEmptyDocument", () => { + it("should create empty deck document", () => { + const doc = createEmptyDocument(CrdtEntityType.Deck); + expect(doc.meta.entityId).toBe(""); + expect(doc.meta.deleted).toBe(false); + expect(doc.data.name).toBe(""); + expect(doc.data.newCardsPerDay).toBe(20); + }); + + it("should create empty card document", () => { + const doc = createEmptyDocument(CrdtEntityType.Card); + expect(doc.meta.entityId).toBe(""); + expect(doc.data.state).toBe(0); + expect(doc.data.reps).toBe(0); + }); + + it("should create empty note type document", () => { + const doc = createEmptyDocument(CrdtEntityType.NoteType); + expect(doc.data.frontTemplate).toBe(""); + expect(doc.data.isReversible).toBe(false); + }); + + it("should create empty review log document", () => { + const doc = createEmptyDocument(CrdtEntityType.ReviewLog); + expect(doc.data.rating).toBe(3); + expect(doc.data.durationMs).toBeNull(); + }); +}); + +describe("deckToCrdtDocument and crdtDocumentToDeck", () => { + it("should convert LocalDeck to CRDT document", () => { + const now = new Date(); + const deck: LocalDeck = { + id: "deck-1", + userId: "user-1", + name: "My Deck", + description: "A deck for testing", + newCardsPerDay: 25, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 1, + _synced: true, + }; + + const crdtDoc = deckToCrdtDocument(deck); + + expect(crdtDoc.meta.entityId).toBe("deck-1"); + expect(crdtDoc.meta.deleted).toBe(false); + expect(crdtDoc.data.name).toBe("My Deck"); + expect(crdtDoc.data.description).toBe("A deck for testing"); + expect(crdtDoc.data.newCardsPerDay).toBe(25); + expect(crdtDoc.data.createdAt).toBe(now.getTime()); + }); + + it("should convert deleted deck correctly", () => { + const now = new Date(); + const deletedAt = new Date(now.getTime() + 1000); + const deck: LocalDeck = { + id: "deck-2", + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 20, + createdAt: now, + updatedAt: deletedAt, + deletedAt: deletedAt, + syncVersion: 2, + _synced: false, + }; + + const crdtDoc = deckToCrdtDocument(deck); + + expect(crdtDoc.meta.deleted).toBe(true); + expect(crdtDoc.data.deletedAt).toBe(deletedAt.getTime()); + }); + + it("should convert CRDT document back to LocalDeck", () => { + const now = Date.now(); + const crdtDoc: CrdtDeckDocument = { + meta: { + entityId: "deck-3", + lastModified: now, + deleted: false, + }, + data: { + userId: "user-2", + name: "Converted Deck", + description: "Converted from CRDT", + newCardsPerDay: 30, + createdAt: now - 10000, + deletedAt: null, + }, + }; + + const localDeck = crdtDocumentToDeck(crdtDoc); + + expect(localDeck.id).toBe("deck-3"); + expect(localDeck.userId).toBe("user-2"); + expect(localDeck.name).toBe("Converted Deck"); + expect(localDeck.newCardsPerDay).toBe(30); + expect(localDeck.deletedAt).toBeNull(); + expect(localDeck.syncVersion).toBe(0); // Set by sync layer + }); +}); + +describe("cardToCrdtDocument and crdtDocumentToCard", () => { + it("should convert LocalCard to CRDT document", () => { + const now = new Date(); + const card: LocalCard = { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "What is the capital?", + back: "Tokyo", + state: CardState.New, + due: now, + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 1, + _synced: true, + }; + + const crdtDoc = cardToCrdtDocument(card); + + expect(crdtDoc.meta.entityId).toBe("card-1"); + expect(crdtDoc.data.front).toBe("What is the capital?"); + expect(crdtDoc.data.back).toBe("Tokyo"); + expect(crdtDoc.data.state).toBe(CardState.New); + expect(crdtDoc.data.due).toBe(now.getTime()); + }); + + it("should convert reviewed card correctly", () => { + const now = new Date(); + const lastReview = new Date(now.getTime() - 86400000); + const card: LocalCard = { + id: "card-2", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "Tokyo", + back: "What is the capital?", + state: CardState.Review, + due: now, + stability: 5.5, + difficulty: 0.3, + elapsedDays: 1, + scheduledDays: 4, + reps: 3, + lapses: 0, + lastReview: lastReview, + createdAt: new Date(now.getTime() - 100000), + updatedAt: now, + deletedAt: null, + syncVersion: 5, + _synced: true, + }; + + const crdtDoc = cardToCrdtDocument(card); + + expect(crdtDoc.data.isReversed).toBe(true); + expect(crdtDoc.data.state).toBe(CardState.Review); + expect(crdtDoc.data.stability).toBe(5.5); + expect(crdtDoc.data.reps).toBe(3); + expect(crdtDoc.data.lastReview).toBe(lastReview.getTime()); + }); + + it("should convert CRDT document back to LocalCard", () => { + const now = Date.now(); + const crdtDoc: CrdtCardDocument = { + meta: { + entityId: "card-3", + lastModified: now, + deleted: false, + }, + data: { + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Front text", + back: "Back text", + state: CardState.Learning, + due: now + 3600000, + stability: 1.0, + difficulty: 0.5, + elapsedDays: 0, + scheduledDays: 1, + reps: 1, + lapses: 0, + lastReview: now, + createdAt: now - 10000, + deletedAt: null, + }, + }; + + const localCard = crdtDocumentToCard(crdtDoc); + + expect(localCard.id).toBe("card-3"); + expect(localCard.front).toBe("Front text"); + expect(localCard.state).toBe(CardState.Learning); + expect(localCard.lastReview).toBeInstanceOf(Date); + expect(localCard.lastReview?.getTime()).toBe(now); + }); +}); + +describe("reviewLogToCrdtDocument and crdtDocumentToReviewLog", () => { + it("should convert LocalReviewLog to CRDT document", () => { + const now = new Date(); + const reviewLog: LocalReviewLog = { + id: "review-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Review, + scheduledDays: 4, + elapsedDays: 1, + reviewedAt: now, + durationMs: 5000, + syncVersion: 1, + _synced: true, + }; + + const crdtDoc = reviewLogToCrdtDocument(reviewLog); + + expect(crdtDoc.meta.entityId).toBe("review-1"); + expect(crdtDoc.data.rating).toBe(Rating.Good); + expect(crdtDoc.data.state).toBe(CardState.Review); + expect(crdtDoc.data.durationMs).toBe(5000); + expect(crdtDoc.data.reviewedAt).toBe(now.getTime()); + }); + + it("should convert CRDT document back to LocalReviewLog", () => { + const now = Date.now(); + const crdtDoc: CrdtReviewLogDocument = { + meta: { + entityId: "review-2", + lastModified: now, + deleted: false, + }, + data: { + cardId: "card-2", + userId: "user-2", + rating: Rating.Hard, + state: CardState.Relearning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: now, + durationMs: null, + }, + }; + + const localReviewLog = crdtDocumentToReviewLog(crdtDoc); + + expect(localReviewLog.id).toBe("review-2"); + expect(localReviewLog.rating).toBe(Rating.Hard); + expect(localReviewLog.durationMs).toBeNull(); + }); +}); + +describe("createDocumentFromEntity", () => { + it("should create document from LocalDeck", () => { + const now = new Date(); + const deck: LocalDeck = { + id: "deck-1", + userId: "user-1", + name: "Test", + description: null, + newCardsPerDay: 20, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + + const doc = createDocumentFromEntity(CrdtEntityType.Deck, deck); + expect(doc.meta.entityId).toBe("deck-1"); + expect(doc.data.name).toBe("Test"); + }); + + it("should create document from LocalCard", () => { + const now = new Date(); + const card: LocalCard = { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Q", + back: "A", + state: CardState.New, + due: now, + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + + const doc = createDocumentFromEntity(CrdtEntityType.Card, card); + expect(doc.meta.entityId).toBe("card-1"); + expect(doc.data.front).toBe("Q"); + }); +}); + +describe("hasConflicts", () => { + it("should detect no conflicts when documents are in sync", () => { + const doc1 = createDocument({ value: 1 }); + const doc2 = Automerge.clone(doc1); + + expect(hasConflicts(doc1, doc2)).toBe(false); + }); + + it("should detect conflicts when documents have diverged", () => { + const doc1 = createDocument({ value: 1 }); + const doc2 = Automerge.clone(doc1); + + // Make concurrent changes + const doc1Updated = updateDocument(doc1, (d) => { + d.value = 10; + }); + const doc2Updated = updateDocument(doc2, (d) => { + d.value = 20; + }); + + expect(hasConflicts(doc1Updated, doc2Updated)).toBe(true); + }); +}); + +describe("getLastModified", () => { + it("should return lastModified timestamp from document", () => { + const timestamp = 1234567890000; + const data: CrdtDeckDocument = { + meta: { + entityId: "deck-1", + lastModified: timestamp, + deleted: false, + }, + data: { + userId: "user-1", + name: "Test", + description: null, + newCardsPerDay: 20, + createdAt: timestamp, + deletedAt: null, + }, + }; + + const doc = createDocument(data); + expect(getLastModified(doc)).toBe(timestamp); + }); +}); + +describe("isDeleted", () => { + it("should return false for non-deleted document", () => { + const data: CrdtDeckDocument = { + meta: { + entityId: "deck-1", + lastModified: Date.now(), + deleted: false, + }, + data: { + userId: "user-1", + name: "Test", + description: null, + newCardsPerDay: 20, + createdAt: Date.now(), + deletedAt: null, + }, + }; + + const doc = createDocument(data); + expect(isDeleted(doc)).toBe(false); + }); + + it("should return true for deleted document", () => { + const data: CrdtDeckDocument = { + meta: { + entityId: "deck-1", + lastModified: Date.now(), + deleted: true, + }, + data: { + userId: "user-1", + name: "Test", + description: null, + newCardsPerDay: 20, + createdAt: Date.now(), + deletedAt: Date.now(), + }, + }; + + const doc = createDocument(data); + expect(isDeleted(doc)).toBe(true); + }); +}); + +// Note: getActorId tests are skipped because they require browser localStorage +// which is not available in the Node.js test environment. +// The function is tested implicitly through integration tests. diff --git a/src/client/sync/crdt/document-manager.ts b/src/client/sync/crdt/document-manager.ts new file mode 100644 index 0000000..5c32b67 --- /dev/null +++ b/src/client/sync/crdt/document-manager.ts @@ -0,0 +1,716 @@ +/** + * Automerge Document Manager + * + * Manages the lifecycle of Automerge CRDT documents including: + * - Document creation and initialization + * - Document updates with change tracking + * - Document merging for conflict resolution + * - Conversion between local entities and CRDT documents + */ + +import * as Automerge from "@automerge/automerge"; +import type { + LocalCard, + LocalDeck, + LocalNote, + LocalNoteFieldType, + LocalNoteFieldValue, + LocalNoteType, + LocalReviewLog, +} from "../../db/index"; +import { + type CrdtCardDocument, + type CrdtDeckDocument, + type CrdtDocumentMap, + CrdtEntityType, + type CrdtEntityTypeValue, + type CrdtNoteDocument, + type CrdtNoteFieldTypeDocument, + type CrdtNoteFieldValueDocument, + type CrdtNoteTypeDocument, + type CrdtReviewLogDocument, + createCrdtMetadata, +} from "./types"; + +/** + * Result of a merge operation + */ +export interface MergeResult<T> { + /** The merged document */ + merged: Automerge.Doc<T>; + /** Whether the merge resulted in any changes */ + hasChanges: boolean; + /** Binary representation of the merged document */ + binary: Uint8Array; +} + +/** + * Document change record for sync + */ +export interface DocumentChange { + /** Entity type */ + entityType: CrdtEntityTypeValue; + /** Entity ID */ + entityId: string; + /** Binary changes since last sync (incremental) */ + changes: Uint8Array; +} + +/** + * Full document snapshot for initial sync + */ +export interface DocumentSnapshot { + /** Entity type */ + entityType: CrdtEntityTypeValue; + /** Entity ID */ + entityId: string; + /** Full binary document */ + binary: Uint8Array; +} + +/** + * Create a new Automerge document with initial data + */ +export function createDocument<T>(data: T): Automerge.Doc<T> { + // Type assertion needed because Automerge.from expects Record<string, unknown> + // but we want to support typed documents + return Automerge.from(data as Record<string, unknown>) as Automerge.Doc<T>; +} + +/** + * Update an Automerge document with new data + * Returns a new document (Automerge documents are immutable) + */ +export function updateDocument<T>( + doc: Automerge.Doc<T>, + updater: (doc: T) => void, +): Automerge.Doc<T> { + return Automerge.change(doc, updater); +} + +/** + * Merge two Automerge documents + * This is used for conflict resolution - Automerge automatically handles concurrent changes + */ +export function mergeDocuments<T>( + local: Automerge.Doc<T>, + remote: Automerge.Doc<T>, +): MergeResult<T> { + const merged = Automerge.merge(local, remote); + const hasChanges = !Automerge.equals(local, merged); + const binary = Automerge.save(merged); + + return { merged, hasChanges, binary }; +} + +/** + * Get changes between two document states + * Used for incremental sync + */ +export function getChanges<T>( + oldDoc: Automerge.Doc<T>, + newDoc: Automerge.Doc<T>, +): Automerge.Change[] { + return Automerge.getChanges(oldDoc, newDoc); +} + +/** + * Apply changes to a document + */ +export function applyChanges<T>( + doc: Automerge.Doc<T>, + changes: Automerge.Change[], +): Automerge.Doc<T> { + const [newDoc] = Automerge.applyChanges(doc, changes); + return newDoc; +} + +/** + * Save incremental changes since last save + * Returns binary data that can be loaded with loadIncremental + */ +export function saveIncremental<T>(doc: Automerge.Doc<T>): Uint8Array { + return Automerge.saveIncremental(doc); +} + +/** + * Load incremental changes into a document + */ +export function loadIncremental<T>( + doc: Automerge.Doc<T>, + data: Uint8Array, +): Automerge.Doc<T> { + return Automerge.loadIncremental(doc, data); +} + +/** + * Serialize a document to binary format + */ +export function saveDocument<T>(doc: Automerge.Doc<T>): Uint8Array { + return Automerge.save(doc); +} + +/** + * Load a document from binary format + */ +export function loadDocument<T>(binary: Uint8Array): Automerge.Doc<T> { + return Automerge.load(binary); +} + +/** + * Create an empty document of the given type + * Used when receiving changes for a document that doesn't exist locally + */ +export function createEmptyDocument<T extends CrdtEntityTypeValue>( + entityType: T, +): Automerge.Doc<CrdtDocumentMap[T]> { + // Create minimal initial structure based on entity type + const emptyData = getEmptyDocumentData(entityType); + return Automerge.from( + emptyData as unknown as Record<string, unknown>, + ) as Automerge.Doc<CrdtDocumentMap[T]>; +} + +/** + * Get empty document data structure for a given entity type + */ +function getEmptyDocumentData( + entityType: CrdtEntityTypeValue, +): CrdtDocumentMap[CrdtEntityTypeValue] { + const meta = createCrdtMetadata(""); + + switch (entityType) { + case CrdtEntityType.Deck: + return { + meta, + data: { + userId: "", + name: "", + description: null, + newCardsPerDay: 20, + createdAt: 0, + deletedAt: null, + }, + } as CrdtDeckDocument; + + case CrdtEntityType.NoteType: + return { + meta, + data: { + userId: "", + name: "", + frontTemplate: "", + backTemplate: "", + isReversible: false, + createdAt: 0, + deletedAt: null, + }, + } as CrdtNoteTypeDocument; + + case CrdtEntityType.NoteFieldType: + return { + meta, + data: { + noteTypeId: "", + name: "", + order: 0, + fieldType: "text", + createdAt: 0, + deletedAt: null, + }, + } as CrdtNoteFieldTypeDocument; + + case CrdtEntityType.Note: + return { + meta, + data: { + deckId: "", + noteTypeId: "", + createdAt: 0, + deletedAt: null, + }, + } as CrdtNoteDocument; + + case CrdtEntityType.NoteFieldValue: + return { + meta, + data: { + noteId: "", + noteFieldTypeId: "", + value: "", + createdAt: 0, + }, + } as CrdtNoteFieldValueDocument; + + case CrdtEntityType.Card: + return { + meta, + data: { + deckId: "", + noteId: "", + isReversed: false, + front: "", + back: "", + state: 0, + due: 0, + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: 0, + deletedAt: null, + }, + } as CrdtCardDocument; + + case CrdtEntityType.ReviewLog: + return { + meta, + data: { + cardId: "", + userId: "", + rating: 3, + state: 0, + scheduledDays: 0, + elapsedDays: 0, + reviewedAt: 0, + durationMs: null, + }, + } as CrdtReviewLogDocument; + + default: { + const _exhaustive: never = entityType; + throw new Error(`Unknown entity type: ${_exhaustive}`); + } + } +} + +/** + * Convert a LocalDeck to a CRDT document + */ +export function deckToCrdtDocument(deck: LocalDeck): CrdtDeckDocument { + return { + meta: { + entityId: deck.id, + lastModified: deck.updatedAt.getTime(), + deleted: deck.deletedAt !== null, + }, + data: { + userId: deck.userId, + name: deck.name, + description: deck.description, + newCardsPerDay: deck.newCardsPerDay, + createdAt: deck.createdAt.getTime(), + deletedAt: deck.deletedAt?.getTime() ?? null, + }, + }; +} + +/** + * Convert a CRDT document to LocalDeck data (without _synced flag) + */ +export function crdtDocumentToDeck( + doc: CrdtDeckDocument, +): Omit<LocalDeck, "_synced"> { + return { + id: doc.meta.entityId, + userId: doc.data.userId, + name: doc.data.name, + description: doc.data.description, + newCardsPerDay: doc.data.newCardsPerDay, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + deletedAt: doc.data.deletedAt ? new Date(doc.data.deletedAt) : null, + syncVersion: 0, // Will be set by sync layer + }; +} + +/** + * Convert a LocalNoteType to a CRDT document + */ +export function noteTypeToCrdtDocument( + noteType: LocalNoteType, +): CrdtNoteTypeDocument { + return { + meta: { + entityId: noteType.id, + lastModified: noteType.updatedAt.getTime(), + deleted: noteType.deletedAt !== null, + }, + data: { + userId: noteType.userId, + name: noteType.name, + frontTemplate: noteType.frontTemplate, + backTemplate: noteType.backTemplate, + isReversible: noteType.isReversible, + createdAt: noteType.createdAt.getTime(), + deletedAt: noteType.deletedAt?.getTime() ?? null, + }, + }; +} + +/** + * Convert a CRDT document to LocalNoteType data + */ +export function crdtDocumentToNoteType( + doc: CrdtNoteTypeDocument, +): Omit<LocalNoteType, "_synced"> { + return { + id: doc.meta.entityId, + userId: doc.data.userId, + name: doc.data.name, + frontTemplate: doc.data.frontTemplate, + backTemplate: doc.data.backTemplate, + isReversible: doc.data.isReversible, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + deletedAt: doc.data.deletedAt ? new Date(doc.data.deletedAt) : null, + syncVersion: 0, + }; +} + +/** + * Convert a LocalNoteFieldType to a CRDT document + */ +export function noteFieldTypeToCrdtDocument( + noteFieldType: LocalNoteFieldType, +): CrdtNoteFieldTypeDocument { + return { + meta: { + entityId: noteFieldType.id, + lastModified: noteFieldType.updatedAt.getTime(), + deleted: noteFieldType.deletedAt !== null, + }, + data: { + noteTypeId: noteFieldType.noteTypeId, + name: noteFieldType.name, + order: noteFieldType.order, + fieldType: noteFieldType.fieldType, + createdAt: noteFieldType.createdAt.getTime(), + deletedAt: noteFieldType.deletedAt?.getTime() ?? null, + }, + }; +} + +/** + * Convert a CRDT document to LocalNoteFieldType data + */ +export function crdtDocumentToNoteFieldType( + doc: CrdtNoteFieldTypeDocument, +): Omit<LocalNoteFieldType, "_synced"> { + return { + id: doc.meta.entityId, + noteTypeId: doc.data.noteTypeId, + name: doc.data.name, + order: doc.data.order, + fieldType: doc.data.fieldType, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + deletedAt: doc.data.deletedAt ? new Date(doc.data.deletedAt) : null, + syncVersion: 0, + }; +} + +/** + * Convert a LocalNote to a CRDT document + */ +export function noteToCrdtDocument(note: LocalNote): CrdtNoteDocument { + return { + meta: { + entityId: note.id, + lastModified: note.updatedAt.getTime(), + deleted: note.deletedAt !== null, + }, + data: { + deckId: note.deckId, + noteTypeId: note.noteTypeId, + createdAt: note.createdAt.getTime(), + deletedAt: note.deletedAt?.getTime() ?? null, + }, + }; +} + +/** + * Convert a CRDT document to LocalNote data + */ +export function crdtDocumentToNote( + doc: CrdtNoteDocument, +): Omit<LocalNote, "_synced"> { + return { + id: doc.meta.entityId, + deckId: doc.data.deckId, + noteTypeId: doc.data.noteTypeId, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + deletedAt: doc.data.deletedAt ? new Date(doc.data.deletedAt) : null, + syncVersion: 0, + }; +} + +/** + * Convert a LocalNoteFieldValue to a CRDT document + */ +export function noteFieldValueToCrdtDocument( + noteFieldValue: LocalNoteFieldValue, +): CrdtNoteFieldValueDocument { + return { + meta: { + entityId: noteFieldValue.id, + lastModified: noteFieldValue.updatedAt.getTime(), + deleted: false, + }, + data: { + noteId: noteFieldValue.noteId, + noteFieldTypeId: noteFieldValue.noteFieldTypeId, + value: noteFieldValue.value, + createdAt: noteFieldValue.createdAt.getTime(), + }, + }; +} + +/** + * Convert a CRDT document to LocalNoteFieldValue data + */ +export function crdtDocumentToNoteFieldValue( + doc: CrdtNoteFieldValueDocument, +): Omit<LocalNoteFieldValue, "_synced"> { + return { + id: doc.meta.entityId, + noteId: doc.data.noteId, + noteFieldTypeId: doc.data.noteFieldTypeId, + value: doc.data.value, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + syncVersion: 0, + }; +} + +/** + * Convert a LocalCard to a CRDT document + */ +export function cardToCrdtDocument(card: LocalCard): CrdtCardDocument { + return { + meta: { + entityId: card.id, + lastModified: card.updatedAt.getTime(), + deleted: card.deletedAt !== null, + }, + data: { + deckId: card.deckId, + noteId: card.noteId, + isReversed: card.isReversed, + front: card.front, + back: card.back, + state: card.state, + due: card.due.getTime(), + stability: card.stability, + difficulty: card.difficulty, + elapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays, + reps: card.reps, + lapses: card.lapses, + lastReview: card.lastReview?.getTime() ?? null, + createdAt: card.createdAt.getTime(), + deletedAt: card.deletedAt?.getTime() ?? null, + }, + }; +} + +/** + * Convert a CRDT document to LocalCard data + */ +export function crdtDocumentToCard( + doc: CrdtCardDocument, +): Omit<LocalCard, "_synced"> { + return { + id: doc.meta.entityId, + deckId: doc.data.deckId, + noteId: doc.data.noteId, + isReversed: doc.data.isReversed, + front: doc.data.front, + back: doc.data.back, + state: doc.data.state, + due: new Date(doc.data.due), + stability: doc.data.stability, + difficulty: doc.data.difficulty, + elapsedDays: doc.data.elapsedDays, + scheduledDays: doc.data.scheduledDays, + reps: doc.data.reps, + lapses: doc.data.lapses, + lastReview: doc.data.lastReview ? new Date(doc.data.lastReview) : null, + createdAt: new Date(doc.data.createdAt), + updatedAt: new Date(doc.meta.lastModified), + deletedAt: doc.data.deletedAt ? new Date(doc.data.deletedAt) : null, + syncVersion: 0, + }; +} + +/** + * Convert a LocalReviewLog to a CRDT document + */ +export function reviewLogToCrdtDocument( + reviewLog: LocalReviewLog, +): CrdtReviewLogDocument { + return { + meta: { + entityId: reviewLog.id, + lastModified: reviewLog.reviewedAt.getTime(), + deleted: false, + }, + data: { + cardId: reviewLog.cardId, + userId: reviewLog.userId, + rating: reviewLog.rating, + state: reviewLog.state, + scheduledDays: reviewLog.scheduledDays, + elapsedDays: reviewLog.elapsedDays, + reviewedAt: reviewLog.reviewedAt.getTime(), + durationMs: reviewLog.durationMs, + }, + }; +} + +/** + * Convert a CRDT document to LocalReviewLog data + */ +export function crdtDocumentToReviewLog( + doc: CrdtReviewLogDocument, +): Omit<LocalReviewLog, "_synced"> { + return { + id: doc.meta.entityId, + cardId: doc.data.cardId, + userId: doc.data.userId, + rating: doc.data.rating, + state: doc.data.state, + scheduledDays: doc.data.scheduledDays, + elapsedDays: doc.data.elapsedDays, + reviewedAt: new Date(doc.data.reviewedAt), + durationMs: doc.data.durationMs, + syncVersion: 0, + }; +} + +/** + * Create an Automerge document from a local entity + */ +export function createDocumentFromEntity<T extends CrdtEntityTypeValue>( + entityType: T, + entity: + | LocalDeck + | LocalNoteType + | LocalNoteFieldType + | LocalNote + | LocalNoteFieldValue + | LocalCard + | LocalReviewLog, +): Automerge.Doc<CrdtDocumentMap[T]> { + let crdtDoc: CrdtDocumentMap[T]; + + switch (entityType) { + case CrdtEntityType.Deck: + crdtDoc = deckToCrdtDocument(entity as LocalDeck) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.NoteType: + crdtDoc = noteTypeToCrdtDocument( + entity as LocalNoteType, + ) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.NoteFieldType: + crdtDoc = noteFieldTypeToCrdtDocument( + entity as LocalNoteFieldType, + ) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.Note: + crdtDoc = noteToCrdtDocument(entity as LocalNote) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.NoteFieldValue: + crdtDoc = noteFieldValueToCrdtDocument( + entity as LocalNoteFieldValue, + ) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.Card: + crdtDoc = cardToCrdtDocument(entity as LocalCard) as CrdtDocumentMap[T]; + break; + case CrdtEntityType.ReviewLog: + crdtDoc = reviewLogToCrdtDocument( + entity as LocalReviewLog, + ) as CrdtDocumentMap[T]; + break; + default: { + const _exhaustive: never = entityType; + throw new Error(`Unknown entity type: ${_exhaustive}`); + } + } + + return createDocument(crdtDoc); +} + +/** + * Get the actor ID for the current client + * Used for Automerge to identify changes from this client + */ +export function getActorId(): string { + const storageKey = "kioku-crdt-actor-id"; + let actorId = localStorage.getItem(storageKey); + + if (!actorId) { + // Generate a new UUID-based actor ID + actorId = crypto.randomUUID(); + localStorage.setItem(storageKey, actorId); + } + + return actorId; +} + +/** + * Initialize a document with a specific actor ID + * This ensures changes from this client are identifiable + */ +export function initDocumentWithActor<T>( + data: T, + actorId: string, +): Automerge.Doc<T> { + return Automerge.from(data as Record<string, unknown>, { + actor: actorId as Automerge.ActorId, + }) as Automerge.Doc<T>; +} + +/** + * Check if two documents have diverged (have concurrent changes) + */ +export function hasConflicts<T>( + local: Automerge.Doc<T>, + remote: Automerge.Doc<T>, +): boolean { + // Get the heads (latest change hashes) of both documents + const localHeads = Automerge.getHeads(local); + const remoteHeads = Automerge.getHeads(remote); + + // If either document is ahead of the other without sharing the latest changes, + // they have diverged + const localHasRemoteHeads = remoteHeads.every((h) => localHeads.includes(h)); + const remoteHasLocalHeads = localHeads.every((h) => remoteHeads.includes(h)); + + // Conflict exists if neither fully contains the other's heads + return !localHasRemoteHeads && !remoteHasLocalHeads; +} + +/** + * Get the last modified timestamp from a CRDT document + */ +export function getLastModified<T extends { meta: { lastModified: number } }>( + doc: Automerge.Doc<T>, +): number { + return doc.meta.lastModified; +} + +/** + * Check if a document represents a deleted entity + */ +export function isDeleted<T extends { meta: { deleted: boolean } }>( + doc: Automerge.Doc<T>, +): boolean { + return doc.meta.deleted; +} diff --git a/src/client/sync/crdt/index.ts b/src/client/sync/crdt/index.ts new file mode 100644 index 0000000..fa296bd --- /dev/null +++ b/src/client/sync/crdt/index.ts @@ -0,0 +1,69 @@ +/** + * CRDT Module + * + * This module provides Automerge CRDT-based sync functionality for Kioku. + * It enables conflict-free synchronization of data between clients and server. + */ + +// Document lifecycle management +export { + // Core document operations + applyChanges, + // Entity to/from CRDT document conversions + cardToCrdtDocument, + crdtDocumentToCard, + crdtDocumentToDeck, + crdtDocumentToNote, + crdtDocumentToNoteFieldType, + crdtDocumentToNoteFieldValue, + crdtDocumentToNoteType, + crdtDocumentToReviewLog, + createDocument, + // Generic entity conversion + createDocumentFromEntity, + createEmptyDocument, + // Types + type DocumentChange, + type DocumentSnapshot, + deckToCrdtDocument, + // Actor ID management + getActorId, + getChanges, + // Conflict detection and utilities + getLastModified, + hasConflicts, + initDocumentWithActor, + isDeleted, + loadDocument, + loadIncremental, + type MergeResult, + mergeDocuments, + noteFieldTypeToCrdtDocument, + noteFieldValueToCrdtDocument, + noteToCrdtDocument, + noteTypeToCrdtDocument, + reviewLogToCrdtDocument, + saveDocument, + saveIncremental, + updateDocument, +} from "./document-manager"; +// Type definitions +export { + type CrdtCardDocument, + type CrdtDeckDocument, + type CrdtDocument, + type CrdtDocumentMap, + CrdtEntityType, + type CrdtEntityTypeValue, + type CrdtMetadata, + type CrdtNoteDocument, + type CrdtNoteFieldTypeDocument, + type CrdtNoteFieldValueDocument, + type CrdtNoteTypeDocument, + type CrdtReviewLogDocument, + createCrdtMetadata, + createDeletedCrdtMetadata, + createDocumentId, + type DocumentId, + parseDocumentId, +} from "./types"; |
