diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 16:26:49 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 16:26:49 +0900 |
| commit | 29aed156d0ec252c3ce49c5c68183aaa6d45a531 (patch) | |
| tree | d1337cf67a0ac7f4d654a7d3eb780f999eddacf9 | |
| parent | 26b6408c84bfcc46f3d470292688e4ffaf0265f2 (diff) | |
| download | kioku-29aed156d0ec252c3ce49c5c68183aaa6d45a531.tar.gz kioku-29aed156d0ec252c3ce49c5c68183aaa6d45a531.tar.zst kioku-29aed156d0ec252c3ce49c5c68183aaa6d45a531.zip | |
test(crdt): add integration tests for concurrent edit scenarios
Add comprehensive test suite covering multi-device concurrent editing:
- Two devices editing different fields (no conflicts)
- Two devices editing same field (deterministic resolution)
- Concurrent edit and delete scenarios
- Card FSRS field concurrent updates
- Incremental sync simulation
- Three-way merge scenarios
- Serialization roundtrip with concurrent edits
- Edge cases (null handling, rapid edits)
- Offline queue simulation
🤖 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/concurrent-edits.test.ts | 633 |
2 files changed, 635 insertions, 2 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index df4e695..1531b63 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -43,6 +43,6 @@ Replace the current Last-Write-Wins (LWW) conflict resolution with Automerge CRD ### Phase 6: Testing and Cleanup -- [ ] Add unit tests for CRDT operations -- [ ] Add integration tests for concurrent edit scenarios +- [x] Add unit tests for CRDT operations +- [x] Add integration tests for concurrent edit scenarios - [ ] Remove legacy LWW code after validation diff --git a/src/client/sync/crdt/concurrent-edits.test.ts b/src/client/sync/crdt/concurrent-edits.test.ts new file mode 100644 index 0000000..2b6f182 --- /dev/null +++ b/src/client/sync/crdt/concurrent-edits.test.ts @@ -0,0 +1,633 @@ +/** + * Integration tests for CRDT concurrent edit scenarios + * + * These tests simulate real-world concurrent editing scenarios where + * multiple devices/clients edit the same data while offline and then sync. + */ +import * as Automerge from "@automerge/automerge"; +import { describe, expect, it } from "vitest"; +import type { LocalCard, LocalDeck } from "../../db/index"; +import { CardState } from "../../db/index"; +import { + applyChanges, + cardToCrdtDocument, + crdtDocumentToCard, + crdtDocumentToDeck, + createDocument, + deckToCrdtDocument, + getChanges, + hasConflicts, + loadDocument, + mergeDocuments, + saveDocument, + updateDocument, +} from "./document-manager"; +import type { CrdtDeckDocument } from "./types"; + +/** + * Helper to create a test deck + */ +function createTestDeck(overrides: Partial<LocalDeck> = {}): LocalDeck { + const now = new Date(); + return { + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 1, + _synced: true, + ...overrides, + }; +} + +/** + * Helper to create a test card + */ +function createTestCard(overrides: Partial<LocalCard> = {}): LocalCard { + const now = new Date(); + return { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Question", + back: "Answer", + 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, + ...overrides, + }; +} + +describe("Concurrent edit scenarios", () => { + describe("Two devices editing different fields of the same deck", () => { + it("should merge changes from both devices without conflicts", () => { + // Setup: Create initial deck document (represents server state) + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A: Offline edit - change deck name + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Updated by Device A"; + d.meta.lastModified = Date.now(); + }); + + // Device B: Offline edit - change newCardsPerDay + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 30; + d.meta.lastModified = Date.now(); + }); + + // Sync: Merge both changes + const mergeResult = mergeDocuments(deviceAEdited, deviceBEdited); + + // Both changes should be present + expect(mergeResult.merged.data.name).toBe("Updated by Device A"); + expect(mergeResult.merged.data.newCardsPerDay).toBe(30); + expect(mergeResult.hasChanges).toBe(true); + }); + + it("should correctly convert merged deck back to LocalDeck", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A and B make different edits + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "New Name"; + d.data.description = "Added by Device A"; + d.meta.lastModified = Date.now(); + }); + + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 50; + d.meta.lastModified = Date.now(); + }); + + const mergeResult = mergeDocuments(deviceAEdited, deviceBEdited); + const mergedDeck = crdtDocumentToDeck(mergeResult.merged); + + expect(mergedDeck.name).toBe("New Name"); + expect(mergedDeck.description).toBe("Added by Device A"); + expect(mergedDeck.newCardsPerDay).toBe(50); + }); + }); + + describe("Two devices editing the same field", () => { + it("should detect conflicts when same field is edited concurrently", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Both devices edit the same field + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Name from Device A"; + }); + + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.name = "Name from Device B"; + }); + + // hasConflicts should detect the divergence + expect(hasConflicts(deviceAEdited, deviceBEdited)).toBe(true); + }); + + it("should resolve conflicting edits deterministically using Automerge", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Both devices edit the same field + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Name from Device A"; + }); + + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.name = "Name from Device B"; + }); + + // Merge from both directions should produce the same result + const mergeAB = mergeDocuments(deviceAEdited, deviceBEdited); + const mergeBA = mergeDocuments(deviceBEdited, deviceAEdited); + + // Results should be deterministic (same regardless of merge order) + expect(mergeAB.merged.data.name).toBe(mergeBA.merged.data.name); + }); + }); + + describe("Concurrent edit and delete", () => { + it("should handle concurrent edit and soft-delete", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A: Edit the deck + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Edited name"; + d.meta.lastModified = Date.now(); + }); + + // Device B: Delete the deck + const now = Date.now(); + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBDeleted = updateDocument(deviceBDoc, (d) => { + d.meta.deleted = true; + d.data.deletedAt = now; + d.meta.lastModified = now; + }); + + // Merge: Both changes should be preserved + const mergeResult = mergeDocuments(deviceAEdited, deviceBDeleted); + + // The edit should be present, and the delete flag should be set + expect(mergeResult.merged.data.name).toBe("Edited name"); + expect(mergeResult.merged.meta.deleted).toBe(true); + expect(mergeResult.merged.data.deletedAt).toBe(now); + }); + + it("should preserve deletedAt timestamp from the deleting device", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + const deleteTime = Date.now(); + const deviceADoc = Automerge.clone(serverDoc); + const deviceADeleted = updateDocument(deviceADoc, (d) => { + d.meta.deleted = true; + d.data.deletedAt = deleteTime; + d.meta.lastModified = deleteTime; + }); + + // Another device just syncs without knowing about the delete + const deviceBDoc = Automerge.clone(serverDoc); + + // Merge + const mergeResult = mergeDocuments(deviceADeleted, deviceBDoc); + + expect(mergeResult.merged.meta.deleted).toBe(true); + expect(mergeResult.merged.data.deletedAt).toBe(deleteTime); + }); + }); + + describe("Card FSRS field concurrent edits", () => { + it("should merge concurrent card reviews from different devices", () => { + const card = createTestCard(); + const crdtData = cardToCrdtDocument(card); + const serverDoc = createDocument(crdtData); + + // Device A: Review card (update FSRS fields) + const deviceADoc = Automerge.clone(serverDoc); + const now = Date.now(); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.state = CardState.Learning; + d.data.reps = 1; + d.data.stability = 1.5; + d.data.lastReview = now; + d.meta.lastModified = now; + }); + + // Device B: Different field edit (update front text) + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.front = "Updated question"; + d.meta.lastModified = now + 100; + }); + + // Merge + const mergeResult = mergeDocuments(deviceAEdited, deviceBEdited); + + // Both sets of changes should be present + expect(mergeResult.merged.data.state).toBe(CardState.Learning); + expect(mergeResult.merged.data.reps).toBe(1); + expect(mergeResult.merged.data.stability).toBe(1.5); + expect(mergeResult.merged.data.front).toBe("Updated question"); + }); + + it("should handle concurrent FSRS updates deterministically", () => { + const card = createTestCard(); + const crdtData = cardToCrdtDocument(card); + const serverDoc = createDocument(crdtData); + const now = Date.now(); + + // Device A: Review with "Good" + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.state = CardState.Review; + d.data.reps = 2; + d.data.stability = 4.0; + d.data.difficulty = 0.3; + d.data.scheduledDays = 4; + d.data.lastReview = now; + d.meta.lastModified = now; + }); + + // Device B: Review with "Hard" (different scheduling) + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.state = CardState.Review; + d.data.reps = 1; + d.data.stability = 2.0; + d.data.difficulty = 0.5; + d.data.scheduledDays = 2; + d.data.lastReview = now + 100; + d.meta.lastModified = now + 100; + }); + + // Merge from both directions + const mergeAB = mergeDocuments(deviceAEdited, deviceBEdited); + const mergeBA = mergeDocuments(deviceBEdited, deviceAEdited); + + // Results should be deterministic + expect(mergeAB.merged.data.reps).toBe(mergeBA.merged.data.reps); + expect(mergeAB.merged.data.stability).toBe(mergeBA.merged.data.stability); + expect(mergeAB.merged.data.difficulty).toBe( + mergeBA.merged.data.difficulty, + ); + }); + }); + + describe("Incremental sync simulation", () => { + it("should sync changes incrementally between devices", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + + // Create server document + const serverDoc = createDocument(crdtData); + const serverBinary = saveDocument(serverDoc); + + // Device A downloads initial state + const deviceADoc = loadDocument<CrdtDeckDocument>(serverBinary); + + // Device A makes an edit + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Edit 1 from A"; + d.meta.lastModified = Date.now(); + }); + + // Get changes from Device A to send to server + const changesFromA = getChanges(deviceADoc, deviceAEdited); + expect(changesFromA.length).toBeGreaterThan(0); + + // Server applies changes from A + const serverUpdated = applyChanges(serverDoc, changesFromA); + expect(serverUpdated.data.name).toBe("Edit 1 from A"); + + // Device B downloads initial state + const deviceBDoc = loadDocument<CrdtDeckDocument>(serverBinary); + + // Device B makes a different edit + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 100; + d.meta.lastModified = Date.now(); + }); + + // Get changes from Device B + const changesFromB = getChanges(deviceBDoc, deviceBEdited); + + // Server merges Device B's changes with its current state + const serverWithB = applyChanges(serverUpdated, changesFromB); + + // Both changes should be present + expect(serverWithB.data.name).toBe("Edit 1 from A"); + expect(serverWithB.data.newCardsPerDay).toBe(100); + }); + + it("should handle three-way merge correctly", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Clone for 3 devices + const deviceADoc = Automerge.clone(serverDoc); + const deviceBDoc = Automerge.clone(serverDoc); + const deviceCDoc = Automerge.clone(serverDoc); + + // Each device makes a different edit + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Name from A"; + }); + + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.description = "Description from B"; + }); + + const deviceCEdited = updateDocument(deviceCDoc, (d) => { + d.data.newCardsPerDay = 75; + }); + + // Sequential merge: A + B + const mergeAB = mergeDocuments(deviceAEdited, deviceBEdited); + + // Then: (A+B) + C + const mergeABC = mergeDocuments(mergeAB.merged, deviceCEdited); + + // All three changes should be present + expect(mergeABC.merged.data.name).toBe("Name from A"); + expect(mergeABC.merged.data.description).toBe("Description from B"); + expect(mergeABC.merged.data.newCardsPerDay).toBe(75); + }); + }); + + describe("Serialization roundtrip with concurrent edits", () => { + it("should preserve merged changes after serialization", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Two concurrent edits + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.name = "Serialization test name"; + }); + + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.description = "Serialization test description"; + }); + + // Merge + const mergeResult = mergeDocuments(deviceAEdited, deviceBEdited); + + // Serialize and deserialize + const binary = saveDocument(mergeResult.merged); + const restored = loadDocument<CrdtDeckDocument>(binary); + + // Verify changes are preserved + expect(restored.data.name).toBe("Serialization test name"); + expect(restored.data.description).toBe("Serialization test description"); + }); + + it("should maintain history after serialization roundtrip", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + let doc = createDocument(crdtData); + + // Make multiple edits + doc = updateDocument(doc, (d) => { + d.data.name = "First edit"; + }); + doc = updateDocument(doc, (d) => { + d.data.name = "Second edit"; + }); + doc = updateDocument(doc, (d) => { + d.data.name = "Third edit"; + }); + + // Serialize and deserialize + const binary = saveDocument(doc); + const restored = loadDocument<CrdtDeckDocument>(binary); + + // Final state should be preserved + expect(restored.data.name).toBe("Third edit"); + + // Automerge history should be preserved + const history = Automerge.getHistory(restored); + expect(history.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("Edge cases", () => { + it("should handle empty description being set to a value", () => { + const deck = createTestDeck({ description: null }); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.description = "Now has a description"; + }); + + const localDeck = crdtDocumentToDeck(deviceAEdited); + expect(localDeck.description).toBe("Now has a description"); + }); + + it("should handle description being set to null", () => { + const deck = createTestDeck({ description: "Has description" }); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.description = null; + }); + + const localDeck = crdtDocumentToDeck(deviceAEdited); + expect(localDeck.description).toBeNull(); + }); + + it("should handle concurrent null to value and value to null", () => { + const deck = createTestDeck({ description: "Initial" }); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A: Set description to null + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.description = null; + }); + + // Device B: Update description to new value + const deviceBDoc = Automerge.clone(serverDoc); + const deviceBEdited = updateDocument(deviceBDoc, (d) => { + d.data.description = "Updated value"; + }); + + // Merge - result is deterministic (one of the values) + const mergeAB = mergeDocuments(deviceAEdited, deviceBEdited); + const mergeBA = mergeDocuments(deviceBEdited, deviceAEdited); + + // Both merges should produce the same result + expect(mergeAB.merged.data.description).toBe( + mergeBA.merged.data.description, + ); + }); + + it("should handle card with null lastReview being reviewed", () => { + const card = createTestCard({ lastReview: null }); + const crdtData = cardToCrdtDocument(card); + const serverDoc = createDocument(crdtData); + const now = Date.now(); + + const deviceADoc = Automerge.clone(serverDoc); + const deviceAEdited = updateDocument(deviceADoc, (d) => { + d.data.lastReview = now; + d.data.reps = 1; + d.data.state = CardState.Learning; + }); + + const localCard = crdtDocumentToCard(deviceAEdited); + expect(localCard.lastReview).toBeInstanceOf(Date); + expect(localCard.lastReview?.getTime()).toBe(now); + expect(localCard.reps).toBe(1); + }); + + it("should handle multiple rapid edits to the same document", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + let doc = createDocument(crdtData); + + // Simulate rapid typing in name field + for (let i = 1; i <= 10; i++) { + doc = updateDocument(doc, (d) => { + d.data.name = `Name after ${i} edits`; + d.meta.lastModified = Date.now(); + }); + } + + expect(doc.data.name).toBe("Name after 10 edits"); + + // Should still serialize/deserialize correctly + const binary = saveDocument(doc); + const restored = loadDocument<CrdtDeckDocument>(binary); + expect(restored.data.name).toBe("Name after 10 edits"); + }); + }); + + describe("Multi-device simulation with offline queuing", () => { + it("should simulate offline edit queue being synced", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A goes offline and makes multiple edits + let deviceADoc = Automerge.clone(serverDoc); + const offlineEdits: Automerge.Change[][] = []; + + // Edit 1 + const beforeEdit1 = Automerge.clone(deviceADoc); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.name = "Offline edit 1"; + }); + offlineEdits.push(getChanges(beforeEdit1, deviceADoc)); + + // Edit 2 + const beforeEdit2 = Automerge.clone(deviceADoc); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.description = "Offline edit 2"; + }); + offlineEdits.push(getChanges(beforeEdit2, deviceADoc)); + + // Edit 3 + const beforeEdit3 = Automerge.clone(deviceADoc); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.newCardsPerDay = 42; + }); + offlineEdits.push(getChanges(beforeEdit3, deviceADoc)); + + // Device comes online and syncs all changes + let currentServer = serverDoc; + for (const changes of offlineEdits) { + currentServer = applyChanges(currentServer, changes); + } + + // Verify all offline edits are applied + expect(currentServer.data.name).toBe("Offline edit 1"); + expect(currentServer.data.description).toBe("Offline edit 2"); + expect(currentServer.data.newCardsPerDay).toBe(42); + }); + + it("should handle two devices syncing after extended offline periods", () => { + const deck = createTestDeck(); + const crdtData = deckToCrdtDocument(deck); + const serverDoc = createDocument(crdtData); + + // Device A: Multiple offline edits + let deviceADoc = Automerge.clone(serverDoc); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.name = "A: First edit"; + }); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.name = "A: Second edit"; + }); + deviceADoc = updateDocument(deviceADoc, (d) => { + d.data.name = "A: Final name"; + d.data.description = "A: Added description"; + }); + + // Device B: Different offline edits + let deviceBDoc = Automerge.clone(serverDoc); + deviceBDoc = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 50; + }); + deviceBDoc = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 60; + }); + deviceBDoc = updateDocument(deviceBDoc, (d) => { + d.data.newCardsPerDay = 100; + }); + + // Both devices come online and sync + const mergeResult = mergeDocuments(deviceADoc, deviceBDoc); + + // Device A's content edits and Device B's card setting + expect(mergeResult.merged.data.name).toBe("A: Final name"); + expect(mergeResult.merged.data.description).toBe("A: Added description"); + expect(mergeResult.merged.data.newCardsPerDay).toBe(100); + }); + }); +}); |
