From db60c5cc3e6dd2e51fce7dd946e477b3e125ba69 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 15:18:39 +0900 Subject: feat(crdt): add CRDT repository layer and sync state management Add Phase 2 of the CRDT implementation: - CRDT-aware repository wrappers for all entity types (Deck, Card, Note, etc.) - Sync state management with IndexedDB storage for CRDT document binaries - Base64 serialization utilities for network transport - Comprehensive test coverage (53 new tests) --- src/client/sync/crdt/repositories.ts | 485 +++++++++++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 src/client/sync/crdt/repositories.ts (limited to 'src/client/sync/crdt/repositories.ts') diff --git a/src/client/sync/crdt/repositories.ts b/src/client/sync/crdt/repositories.ts new file mode 100644 index 0000000..32d516f --- /dev/null +++ b/src/client/sync/crdt/repositories.ts @@ -0,0 +1,485 @@ +/** + * CRDT-Aware Repository Wrappers + * + * This module provides CRDT-aware repository wrappers that handle the conversion + * between local entities and Automerge CRDT documents. These repositories are used + * during sync operations to create, update, and merge CRDT documents. + * + * Design: + * - Each entity type has a corresponding CRDT repository + * - Repositories handle conversion to/from CRDT format + * - Binary serialization is handled for sync payload + * - Merge operations use Automerge's conflict-free merge + */ + +import type * as Automerge from "@automerge/automerge"; +import type { + LocalCard, + LocalDeck, + LocalNote, + LocalNoteFieldType, + LocalNoteFieldValue, + LocalNoteType, + LocalReviewLog, +} from "../../db/index"; +import { + cardToCrdtDocument, + crdtDocumentToCard, + crdtDocumentToDeck, + crdtDocumentToNote, + crdtDocumentToNoteFieldType, + crdtDocumentToNoteFieldValue, + crdtDocumentToNoteType, + crdtDocumentToReviewLog, + createDocument, + deckToCrdtDocument, + loadDocument, + type MergeResult, + mergeDocuments, + noteFieldTypeToCrdtDocument, + noteFieldValueToCrdtDocument, + noteToCrdtDocument, + noteTypeToCrdtDocument, + reviewLogToCrdtDocument, + saveDocument, +} from "./document-manager"; +import type { + CrdtCardDocument, + CrdtDeckDocument, + CrdtEntityTypeValue, + CrdtNoteDocument, + CrdtNoteFieldTypeDocument, + CrdtNoteFieldValueDocument, + CrdtNoteTypeDocument, + CrdtReviewLogDocument, +} from "./types"; +import { CrdtEntityType, createDocumentId, parseDocumentId } from "./types"; + +/** + * Result of creating or updating a CRDT document + */ +export interface CrdtDocumentResult { + /** The Automerge document */ + doc: Automerge.Doc; + /** Binary representation for sync */ + binary: Uint8Array; + /** Document ID (entityType:entityId format) */ + documentId: string; +} + +/** + * Result of merging CRDT documents + */ +export interface CrdtMergeResult { + /** The merged document */ + doc: Automerge.Doc; + /** Binary representation of merged document */ + binary: Uint8Array; + /** Whether the merge resulted in any changes */ + hasChanges: boolean; + /** Converted local entity from merged document */ + entity: T extends CrdtDeckDocument + ? Omit + : T extends CrdtNoteTypeDocument + ? Omit + : T extends CrdtNoteFieldTypeDocument + ? Omit + : T extends CrdtNoteDocument + ? Omit + : T extends CrdtNoteFieldValueDocument + ? Omit + : T extends CrdtCardDocument + ? Omit + : T extends CrdtReviewLogDocument + ? Omit + : never; +} + +/** + * Base interface for CRDT repositories + */ +export interface CrdtRepository { + /** Entity type identifier */ + readonly entityType: CrdtEntityTypeValue; + + /** Convert local entity to CRDT document and serialize */ + toCrdtDocument(entity: TLocal): CrdtDocumentResult; + + /** Load CRDT document from binary data */ + fromBinary(binary: Uint8Array): Automerge.Doc; + + /** Merge local and remote documents */ + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult; + + /** Convert CRDT document to local entity format */ + toLocalEntity(doc: Automerge.Doc): Omit; + + /** Create document ID for an entity */ + createDocumentId(entityId: string): string; +} + +/** + * CRDT Repository for Deck entities + */ +export const crdtDeckRepository: CrdtRepository = { + entityType: CrdtEntityType.Deck, + + toCrdtDocument(deck: LocalDeck): CrdtDocumentResult { + const crdtData = deckToCrdtDocument(deck); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, deck.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToDeck(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for NoteType entities + */ +export const crdtNoteTypeRepository: CrdtRepository< + LocalNoteType, + CrdtNoteTypeDocument +> = { + entityType: CrdtEntityType.NoteType, + + toCrdtDocument( + noteType: LocalNoteType, + ): CrdtDocumentResult { + const crdtData = noteTypeToCrdtDocument(noteType); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, noteType.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToNoteType(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for NoteFieldType entities + */ +export const crdtNoteFieldTypeRepository: CrdtRepository< + LocalNoteFieldType, + CrdtNoteFieldTypeDocument +> = { + entityType: CrdtEntityType.NoteFieldType, + + toCrdtDocument( + fieldType: LocalNoteFieldType, + ): CrdtDocumentResult { + const crdtData = noteFieldTypeToCrdtDocument(fieldType); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, fieldType.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToNoteFieldType(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for Note entities + */ +export const crdtNoteRepository: CrdtRepository = { + entityType: CrdtEntityType.Note, + + toCrdtDocument(note: LocalNote): CrdtDocumentResult { + const crdtData = noteToCrdtDocument(note); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, note.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToNote(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for NoteFieldValue entities + */ +export const crdtNoteFieldValueRepository: CrdtRepository< + LocalNoteFieldValue, + CrdtNoteFieldValueDocument +> = { + entityType: CrdtEntityType.NoteFieldValue, + + toCrdtDocument( + fieldValue: LocalNoteFieldValue, + ): CrdtDocumentResult { + const crdtData = noteFieldValueToCrdtDocument(fieldValue); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, fieldValue.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToNoteFieldValue(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for Card entities + */ +export const crdtCardRepository: CrdtRepository = { + entityType: CrdtEntityType.Card, + + toCrdtDocument(card: LocalCard): CrdtDocumentResult { + const crdtData = cardToCrdtDocument(card); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, card.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToCard(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * CRDT Repository for ReviewLog entities + */ +export const crdtReviewLogRepository: CrdtRepository< + LocalReviewLog, + CrdtReviewLogDocument +> = { + entityType: CrdtEntityType.ReviewLog, + + toCrdtDocument( + reviewLog: LocalReviewLog, + ): CrdtDocumentResult { + const crdtData = reviewLogToCrdtDocument(reviewLog); + const doc = createDocument(crdtData); + const binary = saveDocument(doc); + const documentId = createDocumentId(this.entityType, reviewLog.id); + + return { doc, binary, documentId }; + }, + + fromBinary(binary: Uint8Array): Automerge.Doc { + return loadDocument(binary); + }, + + merge( + local: Automerge.Doc, + remote: Automerge.Doc, + ): MergeResult { + return mergeDocuments(local, remote); + }, + + toLocalEntity( + doc: Automerge.Doc, + ): Omit { + return crdtDocumentToReviewLog(doc); + }, + + createDocumentId(entityId: string): string { + return createDocumentId(this.entityType, entityId); + }, +}; + +/** + * Map of entity types to their CRDT repositories + */ +export const crdtRepositories = { + [CrdtEntityType.Deck]: crdtDeckRepository, + [CrdtEntityType.NoteType]: crdtNoteTypeRepository, + [CrdtEntityType.NoteFieldType]: crdtNoteFieldTypeRepository, + [CrdtEntityType.Note]: crdtNoteRepository, + [CrdtEntityType.NoteFieldValue]: crdtNoteFieldValueRepository, + [CrdtEntityType.Card]: crdtCardRepository, + [CrdtEntityType.ReviewLog]: crdtReviewLogRepository, +} as const; + +/** + * Get the CRDT repository for an entity type + */ +export function getCrdtRepository( + entityType: T, +): (typeof crdtRepositories)[T] { + return crdtRepositories[entityType]; +} + +/** + * Helper to convert multiple entities to CRDT documents + */ +export function entitiesToCrdtDocuments( + entities: TLocal[], + repository: CrdtRepository, +): CrdtDocumentResult[] { + return entities.map((entity) => repository.toCrdtDocument(entity)); +} + +/** + * Helper to merge and convert a remote document with a local document + */ +export function mergeAndConvert( + localBinary: Uint8Array | null, + remoteBinary: Uint8Array, + repository: CrdtRepository, +): { + entity: Omit; + binary: Uint8Array; + hasChanges: boolean; +} { + const remoteDoc = repository.fromBinary(remoteBinary); + + if (localBinary === null) { + // No local document, use remote as-is + return { + entity: repository.toLocalEntity(remoteDoc), + binary: remoteBinary, + hasChanges: true, + }; + } + + const localDoc = repository.fromBinary(localBinary); + const mergeResult = repository.merge(localDoc, remoteDoc); + + return { + entity: repository.toLocalEntity(mergeResult.merged), + binary: mergeResult.binary, + hasChanges: mergeResult.hasChanges, + }; +} + +/** + * Parse a document ID and get the corresponding repository + */ +export function getRepositoryForDocumentId(documentId: string): { + repository: CrdtRepository; + entityId: string; +} | null { + const parsed = parseDocumentId(documentId); + if (!parsed) { + return null; + } + + const repository = getCrdtRepository(parsed.entityType); + return { + repository: repository as CrdtRepository, + entityId: parsed.entityId, + }; +} -- cgit v1.2.3-70-g09d2