diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 14:54:56 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 14:56:37 +0900 |
| commit | d463fd3339c791bf999873ea37d320d56319d7d4 (patch) | |
| tree | 3954f599fb780445dbd35d3e050a3e734057bf9a | |
| parent | 128db64ed1a08b80a23e3c397b07a91ba1ac2e7c (diff) | |
| download | kioku-d463fd3339c791bf999873ea37d320d56319d7d4.tar.gz kioku-d463fd3339c791bf999873ea37d320d56319d7d4.tar.zst kioku-d463fd3339c791bf999873ea37d320d56319d7d4.zip | |
feat(crdt): add Automerge document type definitions
Add CRDT document types for all entities (Deck, NoteType, NoteFieldType,
Note, NoteFieldValue, Card, ReviewLog) with LWW Register pattern.
Includes utility functions for document ID creation/parsing and metadata
management. Part of Phase 1 for CRDT-based sync 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 | 2 | ||||
| -rw-r--r-- | src/client/sync/crdt/types.test.ts | 317 | ||||
| -rw-r--r-- | src/client/sync/crdt/types.ts | 253 |
3 files changed, 571 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 5665927..7eb417b 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -12,7 +12,7 @@ Replace the current Last-Write-Wins (LWW) conflict resolution with Automerge CRD ### Phase 1: Add Automerge and Core Types - [x] Install dependencies: `@automerge/automerge`, `@automerge/automerge-repo`, `@automerge/automerge-repo-storage-indexeddb` -- [ ] Create `src/client/sync/crdt/types.ts` - Automerge document type definitions +- [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 diff --git a/src/client/sync/crdt/types.test.ts b/src/client/sync/crdt/types.test.ts new file mode 100644 index 0000000..15fd79b --- /dev/null +++ b/src/client/sync/crdt/types.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from "vitest"; +import { CardState, FieldType, Rating } from "../../db/index"; +import { + type CrdtCardDocument, + type CrdtDeckDocument, + CrdtEntityType, + type CrdtNoteDocument, + type CrdtNoteFieldTypeDocument, + type CrdtNoteFieldValueDocument, + type CrdtNoteTypeDocument, + type CrdtReviewLogDocument, + createCrdtMetadata, + createDeletedCrdtMetadata, + createDocumentId, + parseDocumentId, +} from "./types"; + +describe("CrdtEntityType", () => { + it("should have all required entity types", () => { + expect(CrdtEntityType.Deck).toBe("deck"); + expect(CrdtEntityType.NoteType).toBe("noteType"); + expect(CrdtEntityType.NoteFieldType).toBe("noteFieldType"); + expect(CrdtEntityType.Note).toBe("note"); + expect(CrdtEntityType.NoteFieldValue).toBe("noteFieldValue"); + expect(CrdtEntityType.Card).toBe("card"); + expect(CrdtEntityType.ReviewLog).toBe("reviewLog"); + }); +}); + +describe("createDocumentId", () => { + it("should create a document ID from entity type and ID", () => { + const docId = createDocumentId(CrdtEntityType.Deck, "deck-123"); + expect(docId).toBe("deck:deck-123"); + }); + + it("should work with all entity types", () => { + expect(createDocumentId(CrdtEntityType.Card, "card-1")).toBe("card:card-1"); + expect(createDocumentId(CrdtEntityType.Note, "note-1")).toBe("note:note-1"); + expect(createDocumentId(CrdtEntityType.NoteType, "notetype-1")).toBe( + "noteType:notetype-1", + ); + }); +}); + +describe("parseDocumentId", () => { + it("should parse a valid document ID", () => { + const result = parseDocumentId("deck:deck-123"); + expect(result).toEqual({ + entityType: "deck", + entityId: "deck-123", + }); + }); + + it("should parse document IDs for all entity types", () => { + expect(parseDocumentId("card:card-456")).toEqual({ + entityType: "card", + entityId: "card-456", + }); + expect(parseDocumentId("noteType:nt-789")).toEqual({ + entityType: "noteType", + entityId: "nt-789", + }); + expect(parseDocumentId("noteFieldType:nft-111")).toEqual({ + entityType: "noteFieldType", + entityId: "nft-111", + }); + expect(parseDocumentId("note:n-222")).toEqual({ + entityType: "note", + entityId: "n-222", + }); + expect(parseDocumentId("noteFieldValue:nfv-333")).toEqual({ + entityType: "noteFieldValue", + entityId: "nfv-333", + }); + expect(parseDocumentId("reviewLog:rl-444")).toEqual({ + entityType: "reviewLog", + entityId: "rl-444", + }); + }); + + it("should return null for invalid document ID format", () => { + expect(parseDocumentId("invalid")).toBeNull(); + expect(parseDocumentId("")).toBeNull(); + expect(parseDocumentId(":missing-type")).toBeNull(); + expect(parseDocumentId("missing-id:")).toBeNull(); + }); + + it("should return null for invalid entity type", () => { + expect(parseDocumentId("invalid:entity-123")).toBeNull(); + expect(parseDocumentId("unknown:entity-456")).toBeNull(); + }); + + it("should handle entity IDs with colons", () => { + // Note: Our simple split implementation only handles one colon + // If entity IDs contain colons, this would need adjustment + const result = parseDocumentId("deck:uuid-with-colon"); + expect(result).toEqual({ + entityType: "deck", + entityId: "uuid-with-colon", + }); + }); +}); + +describe("createCrdtMetadata", () => { + it("should create metadata with correct entity ID", () => { + const meta = createCrdtMetadata("entity-123"); + expect(meta.entityId).toBe("entity-123"); + expect(meta.deleted).toBe(false); + }); + + it("should set lastModified to current time", () => { + const before = Date.now(); + const meta = createCrdtMetadata("entity-123"); + const after = Date.now(); + + expect(meta.lastModified).toBeGreaterThanOrEqual(before); + expect(meta.lastModified).toBeLessThanOrEqual(after); + }); +}); + +describe("createDeletedCrdtMetadata", () => { + it("should create metadata with deleted flag", () => { + const meta = createDeletedCrdtMetadata("entity-123"); + expect(meta.entityId).toBe("entity-123"); + expect(meta.deleted).toBe(true); + }); + + it("should set lastModified to current time", () => { + const before = Date.now(); + const meta = createDeletedCrdtMetadata("entity-123"); + const after = Date.now(); + + expect(meta.lastModified).toBeGreaterThanOrEqual(before); + expect(meta.lastModified).toBeLessThanOrEqual(after); + }); +}); + +describe("CRDT Document type structures", () => { + const now = Date.now(); + + it("should allow creating a valid CrdtDeckDocument", () => { + const doc: CrdtDeckDocument = { + meta: { + entityId: "deck-1", + lastModified: now, + deleted: false, + }, + data: { + userId: "user-1", + name: "My Deck", + description: "A test deck", + newCardsPerDay: 20, + createdAt: now, + deletedAt: null, + }, + }; + + expect(doc.meta.entityId).toBe("deck-1"); + expect(doc.data.name).toBe("My Deck"); + expect(doc.data.newCardsPerDay).toBe(20); + }); + + it("should allow creating a valid CrdtNoteTypeDocument", () => { + const doc: CrdtNoteTypeDocument = { + meta: { + entityId: "notetype-1", + lastModified: now, + deleted: false, + }, + data: { + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: now, + deletedAt: null, + }, + }; + + expect(doc.data.frontTemplate).toBe("{{Front}}"); + expect(doc.data.isReversible).toBe(false); + }); + + it("should allow creating a valid CrdtNoteFieldTypeDocument", () => { + const doc: CrdtNoteFieldTypeDocument = { + meta: { + entityId: "fieldtype-1", + lastModified: now, + deleted: false, + }, + data: { + noteTypeId: "notetype-1", + name: "Front", + order: 0, + fieldType: FieldType.Text, + createdAt: now, + deletedAt: null, + }, + }; + + expect(doc.data.name).toBe("Front"); + expect(doc.data.fieldType).toBe("text"); + }); + + it("should allow creating a valid CrdtNoteDocument", () => { + const doc: CrdtNoteDocument = { + meta: { + entityId: "note-1", + lastModified: now, + deleted: false, + }, + data: { + deckId: "deck-1", + noteTypeId: "notetype-1", + createdAt: now, + deletedAt: null, + }, + }; + + expect(doc.data.deckId).toBe("deck-1"); + expect(doc.data.noteTypeId).toBe("notetype-1"); + }); + + it("should allow creating a valid CrdtNoteFieldValueDocument", () => { + const doc: CrdtNoteFieldValueDocument = { + meta: { + entityId: "fieldvalue-1", + lastModified: now, + deleted: false, + }, + data: { + noteId: "note-1", + noteFieldTypeId: "fieldtype-1", + value: "What is the capital of Japan?", + createdAt: now, + }, + }; + + expect(doc.data.value).toBe("What is the capital of Japan?"); + }); + + it("should allow creating a valid CrdtCardDocument", () => { + const doc: CrdtCardDocument = { + meta: { + entityId: "card-1", + lastModified: now, + deleted: false, + }, + data: { + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "What is the capital of Japan?", + back: "Tokyo", + state: CardState.New, + due: now, + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: now, + deletedAt: null, + }, + }; + + expect(doc.data.front).toBe("What is the capital of Japan?"); + expect(doc.data.state).toBe(CardState.New); + }); + + it("should allow creating a valid CrdtReviewLogDocument", () => { + const doc: CrdtReviewLogDocument = { + meta: { + entityId: "review-1", + lastModified: now, + deleted: false, + }, + data: { + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Review, + scheduledDays: 4, + elapsedDays: 1, + reviewedAt: now, + durationMs: 5000, + }, + }; + + expect(doc.data.rating).toBe(Rating.Good); + expect(doc.data.durationMs).toBe(5000); + }); + + it("should handle deleted entities", () => { + const doc: CrdtDeckDocument = { + meta: { + entityId: "deck-deleted", + lastModified: now, + deleted: true, + }, + data: { + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 20, + createdAt: now - 86400000, + deletedAt: now, + }, + }; + + expect(doc.meta.deleted).toBe(true); + expect(doc.data.deletedAt).toBe(now); + }); +}); diff --git a/src/client/sync/crdt/types.ts b/src/client/sync/crdt/types.ts new file mode 100644 index 0000000..1dfae1a --- /dev/null +++ b/src/client/sync/crdt/types.ts @@ -0,0 +1,253 @@ +/** + * Automerge CRDT Document Type Definitions + * + * This module defines Automerge document types for CRDT-based sync. + * Each entity type has a corresponding Automerge document type that wraps + * the entity data in an LWW (Last-Write-Wins) Register pattern. + * + * Design decisions: + * - LWW Register for text fields: Simple and predictable conflict resolution + * - Each entity is stored as a separate Automerge document + * - Documents are keyed by entity ID + */ + +import type { CardStateType, FieldTypeType, RatingType } from "../../db/index"; + +/** + * Base CRDT metadata for all documents + * Used for tracking document state and sync information + */ +export interface CrdtMetadata { + /** Entity ID (same as the entity's primary key) */ + entityId: string; + /** Timestamp of last local modification */ + lastModified: number; + /** Whether the entity has been soft-deleted */ + deleted: boolean; +} + +/** + * CRDT document type for Deck entities + * Wraps deck data in an Automerge-compatible structure + */ +export interface CrdtDeckDocument { + meta: CrdtMetadata; + data: { + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: number; // Unix timestamp in ms + deletedAt: number | null; + }; +} + +/** + * CRDT document type for NoteType entities + * Wraps note type data in an Automerge-compatible structure + */ +export interface CrdtNoteTypeDocument { + meta: CrdtMetadata; + data: { + userId: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: number; + deletedAt: number | null; + }; +} + +/** + * CRDT document type for NoteFieldType entities + * Wraps note field type data in an Automerge-compatible structure + */ +export interface CrdtNoteFieldTypeDocument { + meta: CrdtMetadata; + data: { + noteTypeId: string; + name: string; + order: number; + fieldType: FieldTypeType; + createdAt: number; + deletedAt: number | null; + }; +} + +/** + * CRDT document type for Note entities + * Wraps note data in an Automerge-compatible structure + */ +export interface CrdtNoteDocument { + meta: CrdtMetadata; + data: { + deckId: string; + noteTypeId: string; + createdAt: number; + deletedAt: number | null; + }; +} + +/** + * CRDT document type for NoteFieldValue entities + * Wraps note field value data in an Automerge-compatible structure + */ +export interface CrdtNoteFieldValueDocument { + meta: CrdtMetadata; + data: { + noteId: string; + noteFieldTypeId: string; + value: string; + createdAt: number; + }; +} + +/** + * CRDT document type for Card entities + * Wraps card data including FSRS scheduling state + */ +export interface CrdtCardDocument { + meta: CrdtMetadata; + data: { + deckId: string; + noteId: string; + isReversed: boolean; + front: string; + back: string; + + // FSRS fields + state: CardStateType; + due: number; // Unix timestamp in ms + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: number | null; + + createdAt: number; + deletedAt: number | null; + }; +} + +/** + * CRDT document type for ReviewLog entities + * ReviewLogs are append-only (no conflicts), but we use CRDT for consistency + */ +export interface CrdtReviewLogDocument { + meta: CrdtMetadata; + data: { + cardId: string; + userId: string; + rating: RatingType; + state: CardStateType; + scheduledDays: number; + elapsedDays: number; + reviewedAt: number; + durationMs: number | null; + }; +} + +/** + * Union type of all CRDT document types + */ +export type CrdtDocument = + | CrdtDeckDocument + | CrdtNoteTypeDocument + | CrdtNoteFieldTypeDocument + | CrdtNoteDocument + | CrdtNoteFieldValueDocument + | CrdtCardDocument + | CrdtReviewLogDocument; + +/** + * Entity type identifiers for CRDT documents + */ +export const CrdtEntityType = { + Deck: "deck", + NoteType: "noteType", + NoteFieldType: "noteFieldType", + Note: "note", + NoteFieldValue: "noteFieldValue", + Card: "card", + ReviewLog: "reviewLog", +} as const; + +export type CrdtEntityTypeValue = + (typeof CrdtEntityType)[keyof typeof CrdtEntityType]; + +/** + * Map entity types to their CRDT document types + */ +export interface CrdtDocumentMap { + deck: CrdtDeckDocument; + noteType: CrdtNoteTypeDocument; + noteFieldType: CrdtNoteFieldTypeDocument; + note: CrdtNoteDocument; + noteFieldValue: CrdtNoteFieldValueDocument; + card: CrdtCardDocument; + reviewLog: CrdtReviewLogDocument; +} + +/** + * Document ID format for Automerge documents + * Format: `{entityType}:{entityId}` + */ +export interface DocumentId { + entityType: CrdtEntityTypeValue; + entityId: string; +} + +/** + * Create a document ID string from entity type and ID + */ +export function createDocumentId( + entityType: CrdtEntityTypeValue, + entityId: string, +): string { + return `${entityType}:${entityId}`; +} + +/** + * Parse a document ID string into entity type and ID + */ +export function parseDocumentId(documentId: string): DocumentId | null { + const [entityType, entityId] = documentId.split(":"); + if (!entityType || !entityId) { + return null; + } + + const validTypes = Object.values(CrdtEntityType); + if (!validTypes.includes(entityType as CrdtEntityTypeValue)) { + return null; + } + + return { + entityType: entityType as CrdtEntityTypeValue, + entityId, + }; +} + +/** + * Create CRDT metadata for a new document + */ +export function createCrdtMetadata(entityId: string): CrdtMetadata { + return { + entityId, + lastModified: Date.now(), + deleted: false, + }; +} + +/** + * Create CRDT metadata for a deleted document + */ +export function createDeletedCrdtMetadata(entityId: string): CrdtMetadata { + return { + entityId, + lastModified: Date.now(), + deleted: true, + }; +} |
