From 26b6408c84bfcc46f3d470292688e4ffaf0265f2 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 16:20:40 +0900 Subject: feat(crdt): add server-side CRDT sync API handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add crdtChanges field to sync push/pull endpoints for CRDT document synchronization. The server now stores and retrieves CRDT binaries from the crdt_documents table, enabling conflict-free sync between clients. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/routes/sync.test.ts | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) (limited to 'src/server/routes/sync.test.ts') diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index f340af7..8ea2ce3 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -96,6 +96,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -133,6 +134,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], }); }); @@ -155,6 +157,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -212,6 +215,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -260,6 +264,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -311,6 +316,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: ["550e8400-e29b-41d4-a716-446655440003"], cards: [], @@ -474,6 +480,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -527,6 +534,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -590,6 +598,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -639,6 +648,7 @@ describe("POST /api/sync/push", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], conflicts: { decks: [], cards: [], @@ -667,6 +677,114 @@ describe("POST /api/sync/push", () => { const body = (await res.json()) as SyncPushResponse; expect(body.decks).toHaveLength(1); }); + + it("successfully pushes CRDT changes", async () => { + const crdtChange = { + documentId: "deck:550e8400-e29b-41d4-a716-446655440000", + entityType: "deck" as const, + entityId: "550e8400-e29b-41d4-a716-446655440000", + binary: "SGVsbG8gV29ybGQ=", // Base64 encoded test data + }; + + const mockResult: SyncPushResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [ + { + entityType: "deck", + entityId: "550e8400-e29b-41d4-a716-446655440000", + syncVersion: 1, + }, + ], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, + }; + vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [], + cards: [], + reviewLogs: [], + crdtChanges: [crdtChange], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { crdtChanges?: unknown[] }; + expect(body.crdtChanges).toHaveLength(1); + expect(mockSyncRepo.pushChanges).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + crdtChanges: [crdtChange], + }), + ); + }); + + it("validates CRDT changes have required fields", async () => { + const invalidCrdtChange = { + documentId: "deck:123", + entityType: "deck", + // missing entityId and binary + }; + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [], + cards: [], + reviewLogs: [], + crdtChanges: [invalidCrdtChange], + }), + }); + + expect(res.status).toBe(400); + }); + + it("validates CRDT entity type is valid", async () => { + const invalidCrdtChange = { + documentId: "invalid:550e8400-e29b-41d4-a716-446655440000", + entityType: "invalidType", + entityId: "550e8400-e29b-41d4-a716-446655440000", + binary: "SGVsbG8gV29ybGQ=", + }; + + const res = await app.request("/api/sync/push", { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decks: [], + cards: [], + reviewLogs: [], + crdtChanges: [invalidCrdtChange], + }), + }); + + expect(res.status).toBe(400); + }); }); interface SyncPullResponse { @@ -727,6 +845,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -761,6 +880,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 5, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -801,6 +921,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 2, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -850,6 +971,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 3, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -892,6 +1014,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -968,6 +1091,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 3, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -1012,6 +1136,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -1064,6 +1189,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -1104,6 +1230,7 @@ describe("GET /api/sync/pull", () => { noteFieldTypes: [], notes: [], noteFieldValues: [], + crdtChanges: [], currentSyncVersion: 2, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -1129,4 +1256,36 @@ describe("GET /api/sync/pull", () => { expect(res.status).toBe(400); }); + + it("returns CRDT changes in pull response", async () => { + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + crdtChanges: [ + { + documentId: "deck:550e8400-e29b-41d4-a716-446655440000", + entityType: "deck", + entityId: "550e8400-e29b-41d4-a716-446655440000", + binary: "SGVsbG8gV29ybGQ=", + }, + ], + currentSyncVersion: 1, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { crdtChanges?: unknown[] }; + expect(body.crdtChanges).toHaveLength(1); + }); }); -- cgit v1.2.3-70-g09d2