aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/sync/crdt/types.test.ts317
-rw-r--r--src/client/sync/crdt/types.ts253
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,
+ };
+}