diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 02:15:17 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 02:15:17 +0900 |
| commit | ed93dd099f43dd6746276a72953485de91b49c8c (patch) | |
| tree | db737032e32508b7de24d94696a13e4bfebe8978 | |
| parent | 78609e0b390e9a485c8935c17db6e0093660ebef (diff) | |
| download | kioku-ed93dd099f43dd6746276a72953485de91b49c8c.tar.gz kioku-ed93dd099f43dd6746276a72953485de91b49c8c.tar.zst kioku-ed93dd099f43dd6746276a72953485de91b49c8c.zip | |
feat(sync): add sync support for note-related entities
Extend the sync system to handle NoteType, NoteFieldType, Note, and
NoteFieldValue entities. This includes:
- Server sync repository and routes for push/pull of new entities
- Client sync queue, push, pull, and conflict resolution for notes
- Update Card sync to include noteId and isReversed fields
- Add comprehensive tests for all sync functionality
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | src/client/stores/sync.test.tsx | 26 | ||||
| -rw-r--r-- | src/client/stores/sync.tsx | 54 | ||||
| -rw-r--r-- | src/client/sync/conflict.test.ts | 99 | ||||
| -rw-r--r-- | src/client/sync/conflict.ts | 337 | ||||
| -rw-r--r-- | src/client/sync/manager.test.ts | 56 | ||||
| -rw-r--r-- | src/client/sync/pull.test.ts | 48 | ||||
| -rw-r--r-- | src/client/sync/pull.ts | 153 | ||||
| -rw-r--r-- | src/client/sync/push.test.ts | 80 | ||||
| -rw-r--r-- | src/client/sync/push.ts | 157 | ||||
| -rw-r--r-- | src/client/sync/queue.test.ts | 32 | ||||
| -rw-r--r-- | src/client/sync/queue.ts | 106 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 535 | ||||
| -rw-r--r-- | src/server/routes/sync.test.ts | 370 | ||||
| -rw-r--r-- | src/server/routes/sync.ts | 46 |
14 files changed, 2027 insertions, 72 deletions
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx index 9c4e5c2..fee79d7 100644 --- a/src/client/stores/sync.test.tsx +++ b/src/client/stores/sync.test.tsx @@ -38,7 +38,18 @@ describe("useSync", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, currentSyncVersion: 0, }), }); @@ -138,7 +149,18 @@ describe("useSync", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, currentSyncVersion: 1, }), }); diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx index 29c6c4f..aea5c16 100644 --- a/src/client/stores/sync.tsx +++ b/src/client/stores/sync.tsx @@ -22,6 +22,10 @@ import { import type { ServerCard, ServerDeck, + ServerNote, + ServerNoteFieldType, + ServerNoteFieldValue, + ServerNoteType, ServerReviewLog, SyncPullResult, } from "../sync/pull"; @@ -73,6 +77,33 @@ interface PullResponse { reviewedAt: string; } >; + noteTypes: Array< + Omit<ServerNoteType, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + noteFieldTypes: Array< + Omit<ServerNoteFieldType, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + notes: Array< + Omit<ServerNote, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + noteFieldValues: Array< + Omit<ServerNoteFieldValue, "createdAt" | "updatedAt"> & { + createdAt: string; + updatedAt: string; + } + >; currentSyncVersion: number; } @@ -141,6 +172,29 @@ async function pullFromServer( ...r, reviewedAt: new Date(r.reviewedAt), })), + noteTypes: data.noteTypes.map((n) => ({ + ...n, + createdAt: new Date(n.createdAt), + updatedAt: new Date(n.updatedAt), + deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, + })), + noteFieldTypes: data.noteFieldTypes.map((f) => ({ + ...f, + createdAt: new Date(f.createdAt), + updatedAt: new Date(f.updatedAt), + deletedAt: f.deletedAt ? new Date(f.deletedAt) : null, + })), + notes: data.notes.map((n) => ({ + ...n, + createdAt: new Date(n.createdAt), + updatedAt: new Date(n.updatedAt), + deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, + })), + noteFieldValues: data.noteFieldValues.map((v) => ({ + ...v, + createdAt: new Date(v.createdAt), + updatedAt: new Date(v.updatedAt), + })), currentSyncVersion: data.currentSyncVersion, }; } diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts index 211f410..6a10c4f 100644 --- a/src/client/sync/conflict.test.ts +++ b/src/client/sync/conflict.test.ts @@ -9,6 +9,29 @@ import { ConflictResolver } from "./conflict"; import type { SyncPullResult } from "./pull"; import type { SyncPushResult } from "./push"; +function createEmptyConflicts() { + return { + decks: [] as string[], + cards: [] as string[], + noteTypes: [] as string[], + noteFieldTypes: [] as string[], + notes: [] as string[], + noteFieldValues: [] as string[], + }; +} + +function createEmptyPullResult( + currentSyncVersion = 0, +): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion, + }; +} + describe("ConflictResolver", () => { beforeEach(async () => { await db.decks.clear(); @@ -31,7 +54,11 @@ describe("ConflictResolver", () => { decks: [{ id: "deck-1", syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: createEmptyConflicts(), }; expect(resolver.hasConflicts(pushResult)).toBe(false); @@ -43,7 +70,11 @@ describe("ConflictResolver", () => { decks: [{ id: "deck-1", syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: ["deck-1"], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), decks: ["deck-1"] }, }; expect(resolver.hasConflicts(pushResult)).toBe(true); @@ -55,7 +86,11 @@ describe("ConflictResolver", () => { decks: [], cards: [{ id: "card-1", syncVersion: 1 }], reviewLogs: [], - conflicts: { decks: [], cards: ["card-1"] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), cards: ["card-1"] }, }; expect(resolver.hasConflicts(pushResult)).toBe(true); @@ -69,7 +104,11 @@ describe("ConflictResolver", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: ["deck-1", "deck-2"], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), decks: ["deck-1", "deck-2"] }, }; expect(resolver.getConflictingDeckIds(pushResult)).toEqual([ @@ -86,7 +125,11 @@ describe("ConflictResolver", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: ["card-1", "card-2"] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), cards: ["card-1", "card-2"] }, }; expect(resolver.getConflictingCardIds(pushResult)).toEqual([ @@ -329,7 +372,14 @@ describe("ConflictResolver", () => { ], cards: [], reviewLogs: [], - conflicts: { decks: [deck1.id, deck2.id], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + ...createEmptyConflicts(), + decks: [deck1.id, deck2.id], + }, }; const pullResult: SyncPullResult = { @@ -359,7 +409,7 @@ describe("ConflictResolver", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 6, + ...createEmptyPullResult(6), }; const resolver = new ConflictResolver({ strategy: "server_wins" }); @@ -393,7 +443,11 @@ describe("ConflictResolver", () => { decks: [], cards: [{ id: card.id, syncVersion: 1 }], reviewLogs: [], - conflicts: { decks: [], cards: [card.id] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), cards: [card.id] }, }; const pullResult: SyncPullResult = { @@ -420,7 +474,7 @@ describe("ConflictResolver", () => { }, ], reviewLogs: [], - currentSyncVersion: 3, + ...createEmptyPullResult(3), }; const resolver = new ConflictResolver({ strategy: "server_wins" }); @@ -438,7 +492,14 @@ describe("ConflictResolver", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: ["non-existent-deck"], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + ...createEmptyConflicts(), + decks: ["non-existent-deck"], + }, }; const pullResult: SyncPullResult = { @@ -457,7 +518,7 @@ describe("ConflictResolver", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 1, + ...createEmptyPullResult(1), }; const resolver = new ConflictResolver({ strategy: "server_wins" }); @@ -483,14 +544,18 @@ describe("ConflictResolver", () => { decks: [{ id: deck.id, syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [deck.id], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), decks: [deck.id] }, }; const pullResult: SyncPullResult = { decks: [], // Server doesn't have this deck cards: [], reviewLogs: [], - currentSyncVersion: 0, + ...createEmptyPullResult(0), }; const resolver = new ConflictResolver({ strategy: "server_wins" }); @@ -516,7 +581,11 @@ describe("ConflictResolver", () => { decks: [{ id: deck.id, syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [deck.id], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { ...createEmptyConflicts(), decks: [deck.id] }, }; const pullResult: SyncPullResult = { @@ -535,7 +604,7 @@ describe("ConflictResolver", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 5, + ...createEmptyPullResult(5), }; // Create resolver without explicit strategy diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts index e4f1cbf..d9c3f55 100644 --- a/src/client/sync/conflict.ts +++ b/src/client/sync/conflict.ts @@ -1,6 +1,28 @@ -import type { LocalCard, LocalDeck } from "../db/index"; -import { localCardRepository, localDeckRepository } from "../db/repositories"; -import type { ServerCard, ServerDeck, SyncPullResult } from "./pull"; +import type { + LocalCard, + LocalDeck, + LocalNote, + LocalNoteFieldType, + LocalNoteFieldValue, + LocalNoteType, +} from "../db/index"; +import { + localCardRepository, + localDeckRepository, + localNoteFieldTypeRepository, + localNoteFieldValueRepository, + localNoteRepository, + localNoteTypeRepository, +} from "../db/repositories"; +import type { + ServerCard, + ServerDeck, + ServerNote, + ServerNoteFieldType, + ServerNoteFieldValue, + ServerNoteType, + SyncPullResult, +} from "./pull"; import type { SyncPushResult } from "./push"; /** @@ -17,6 +39,10 @@ export interface ConflictResolutionItem { export interface ConflictResolutionResult { decks: ConflictResolutionItem[]; cards: ConflictResolutionItem[]; + noteTypes: ConflictResolutionItem[]; + noteFieldTypes: ConflictResolutionItem[]; + notes: ConflictResolutionItem[]; + noteFieldValues: ConflictResolutionItem[]; } /** @@ -87,6 +113,79 @@ function serverCardToLocal(card: ServerCard): LocalCard { } /** + * Convert server note type to local format for storage + */ +function serverNoteTypeToLocal(noteType: ServerNoteType): LocalNoteType { + return { + id: noteType.id, + userId: noteType.userId, + name: noteType.name, + frontTemplate: noteType.frontTemplate, + backTemplate: noteType.backTemplate, + isReversible: noteType.isReversible, + createdAt: new Date(noteType.createdAt), + updatedAt: new Date(noteType.updatedAt), + deletedAt: noteType.deletedAt ? new Date(noteType.deletedAt) : null, + syncVersion: noteType.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note field type to local format for storage + */ +function serverNoteFieldTypeToLocal( + fieldType: ServerNoteFieldType, +): LocalNoteFieldType { + return { + id: fieldType.id, + noteTypeId: fieldType.noteTypeId, + name: fieldType.name, + order: fieldType.order, + fieldType: fieldType.fieldType as LocalNoteFieldType["fieldType"], + createdAt: new Date(fieldType.createdAt), + updatedAt: new Date(fieldType.updatedAt), + deletedAt: fieldType.deletedAt ? new Date(fieldType.deletedAt) : null, + syncVersion: fieldType.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note to local format for storage + */ +function serverNoteToLocal(note: ServerNote): LocalNote { + return { + id: note.id, + deckId: note.deckId, + noteTypeId: note.noteTypeId, + createdAt: new Date(note.createdAt), + updatedAt: new Date(note.updatedAt), + deletedAt: note.deletedAt ? new Date(note.deletedAt) : null, + syncVersion: note.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note field value to local format for storage + */ +function serverNoteFieldValueToLocal( + fieldValue: ServerNoteFieldValue, +): LocalNoteFieldValue { + return { + id: fieldValue.id, + noteId: fieldValue.noteId, + noteFieldTypeId: fieldValue.noteFieldTypeId, + value: fieldValue.value, + createdAt: new Date(fieldValue.createdAt), + updatedAt: new Date(fieldValue.updatedAt), + syncVersion: fieldValue.syncVersion, + _synced: true, + }; +} + +/** * Conflict Resolver * * Handles conflicts reported by the server during push operations. @@ -109,7 +208,11 @@ export class ConflictResolver { hasConflicts(pushResult: SyncPushResult): boolean { return ( pushResult.conflicts.decks.length > 0 || - pushResult.conflicts.cards.length > 0 + pushResult.conflicts.cards.length > 0 || + pushResult.conflicts.noteTypes.length > 0 || + pushResult.conflicts.noteFieldTypes.length > 0 || + pushResult.conflicts.notes.length > 0 || + pushResult.conflicts.noteFieldValues.length > 0 ); } @@ -200,6 +303,142 @@ export class ConflictResolver { } /** + * Resolve note type conflict using configured strategy + */ + async resolveNoteTypeConflict( + localNoteType: LocalNoteType, + serverNoteType: ServerNoteType, + ): Promise<ConflictResolutionItem> { + let resolution: "server_wins" | "local_wins"; + + switch (this.strategy) { + case "server_wins": + resolution = "server_wins"; + break; + case "local_wins": + resolution = "local_wins"; + break; + case "newer_wins": + resolution = isServerNewer( + new Date(serverNoteType.updatedAt), + localNoteType.updatedAt, + ) + ? "server_wins" + : "local_wins"; + break; + } + + if (resolution === "server_wins") { + const localData = serverNoteTypeToLocal(serverNoteType); + await localNoteTypeRepository.upsertFromServer(localData); + } + + return { id: localNoteType.id, resolution }; + } + + /** + * Resolve note field type conflict using configured strategy + */ + async resolveNoteFieldTypeConflict( + localFieldType: LocalNoteFieldType, + serverFieldType: ServerNoteFieldType, + ): Promise<ConflictResolutionItem> { + let resolution: "server_wins" | "local_wins"; + + switch (this.strategy) { + case "server_wins": + resolution = "server_wins"; + break; + case "local_wins": + resolution = "local_wins"; + break; + case "newer_wins": + resolution = isServerNewer( + new Date(serverFieldType.updatedAt), + localFieldType.updatedAt, + ) + ? "server_wins" + : "local_wins"; + break; + } + + if (resolution === "server_wins") { + const localData = serverNoteFieldTypeToLocal(serverFieldType); + await localNoteFieldTypeRepository.upsertFromServer(localData); + } + + return { id: localFieldType.id, resolution }; + } + + /** + * Resolve note conflict using configured strategy + */ + async resolveNoteConflict( + localNote: LocalNote, + serverNote: ServerNote, + ): Promise<ConflictResolutionItem> { + let resolution: "server_wins" | "local_wins"; + + switch (this.strategy) { + case "server_wins": + resolution = "server_wins"; + break; + case "local_wins": + resolution = "local_wins"; + break; + case "newer_wins": + resolution = isServerNewer( + new Date(serverNote.updatedAt), + localNote.updatedAt, + ) + ? "server_wins" + : "local_wins"; + break; + } + + if (resolution === "server_wins") { + const localData = serverNoteToLocal(serverNote); + await localNoteRepository.upsertFromServer(localData); + } + + return { id: localNote.id, resolution }; + } + + /** + * Resolve note field value conflict using configured strategy + */ + async resolveNoteFieldValueConflict( + localFieldValue: LocalNoteFieldValue, + serverFieldValue: ServerNoteFieldValue, + ): Promise<ConflictResolutionItem> { + let resolution: "server_wins" | "local_wins"; + + switch (this.strategy) { + case "server_wins": + resolution = "server_wins"; + break; + case "local_wins": + resolution = "local_wins"; + break; + case "newer_wins": + resolution = isServerNewer( + new Date(serverFieldValue.updatedAt), + localFieldValue.updatedAt, + ) + ? "server_wins" + : "local_wins"; + break; + } + + if (resolution === "server_wins") { + const localData = serverNoteFieldValueToLocal(serverFieldValue); + await localNoteFieldValueRepository.upsertFromServer(localData); + } + + return { id: localFieldValue.id, resolution }; + } + + /** * Resolve all conflicts from a push result * Uses pull result to get server data for conflicting items */ @@ -210,6 +449,10 @@ export class ConflictResolver { const result: ConflictResolutionResult = { decks: [], cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }; // Resolve deck conflicts @@ -252,6 +495,92 @@ export class ConflictResolver { // If server doesn't have it but local does, keep local (will push again) } + // Resolve note type conflicts + for (const noteTypeId of pushResult.conflicts.noteTypes) { + const localNoteType = await localNoteTypeRepository.findById(noteTypeId); + const serverNoteType = pullResult.noteTypes.find( + (nt) => nt.id === noteTypeId, + ); + + if (localNoteType && serverNoteType) { + const resolution = await this.resolveNoteTypeConflict( + localNoteType, + serverNoteType, + ); + result.noteTypes.push(resolution); + } else if (serverNoteType) { + const localData = serverNoteTypeToLocal(serverNoteType); + await localNoteTypeRepository.upsertFromServer(localData); + result.noteTypes.push({ id: noteTypeId, resolution: "server_wins" }); + } + } + + // Resolve note field type conflicts + for (const fieldTypeId of pushResult.conflicts.noteFieldTypes) { + const localFieldType = + await localNoteFieldTypeRepository.findById(fieldTypeId); + const serverFieldType = pullResult.noteFieldTypes.find( + (ft) => ft.id === fieldTypeId, + ); + + if (localFieldType && serverFieldType) { + const resolution = await this.resolveNoteFieldTypeConflict( + localFieldType, + serverFieldType, + ); + result.noteFieldTypes.push(resolution); + } else if (serverFieldType) { + const localData = serverNoteFieldTypeToLocal(serverFieldType); + await localNoteFieldTypeRepository.upsertFromServer(localData); + result.noteFieldTypes.push({ + id: fieldTypeId, + resolution: "server_wins", + }); + } + } + + // Resolve note conflicts + for (const noteId of pushResult.conflicts.notes) { + const localNote = await localNoteRepository.findById(noteId); + const serverNote = pullResult.notes.find((n) => n.id === noteId); + + if (localNote && serverNote) { + const resolution = await this.resolveNoteConflict( + localNote, + serverNote, + ); + result.notes.push(resolution); + } else if (serverNote) { + const localData = serverNoteToLocal(serverNote); + await localNoteRepository.upsertFromServer(localData); + result.notes.push({ id: noteId, resolution: "server_wins" }); + } + } + + // Resolve note field value conflicts + for (const fieldValueId of pushResult.conflicts.noteFieldValues) { + const localFieldValue = + await localNoteFieldValueRepository.findById(fieldValueId); + const serverFieldValue = pullResult.noteFieldValues.find( + (fv) => fv.id === fieldValueId, + ); + + if (localFieldValue && serverFieldValue) { + const resolution = await this.resolveNoteFieldValueConflict( + localFieldValue, + serverFieldValue, + ); + result.noteFieldValues.push(resolution); + } else if (serverFieldValue) { + const localData = serverNoteFieldValueToLocal(serverFieldValue); + await localNoteFieldValueRepository.upsertFromServer(localData); + result.noteFieldValues.push({ + id: fieldValueId, + resolution: "server_wins", + }); + } + } + return result; } } diff --git a/src/client/sync/manager.test.ts b/src/client/sync/manager.test.ts index 96fb97d..a3799c0 100644 --- a/src/client/sync/manager.test.ts +++ b/src/client/sync/manager.test.ts @@ -19,6 +19,41 @@ import { PullService, type SyncPullResult } from "./pull"; import { PushService, type SyncPushResult } from "./push"; import { SyncQueue } from "./queue"; +function createEmptyConflicts() { + return { + decks: [] as string[], + cards: [] as string[], + noteTypes: [] as string[], + noteFieldTypes: [] as string[], + notes: [] as string[], + noteFieldValues: [] as string[], + }; +} + +function createEmptyPullResult( + currentSyncVersion = 0, +): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion, + }; +} + +function createEmptyPushResult(): Omit< + SyncPushResult, + "decks" | "cards" | "reviewLogs" | "conflicts" +> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }; +} + describe("SyncManager", () => { let syncQueue: SyncQueue; let conflictResolver: ConflictResolver; @@ -63,14 +98,15 @@ describe("SyncManager", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), } satisfies SyncPushResult); pullFromServer = vi.fn().mockResolvedValue({ decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 0, + ...createEmptyPullResult(0), } satisfies SyncPullResult); conflictResolver = new ConflictResolver({ strategy: "server_wins" }); @@ -249,7 +285,8 @@ describe("SyncManager", () => { decks: [{ id: "deck-1", syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }; pushToServer.mockResolvedValue(expectedPushResult); @@ -257,7 +294,7 @@ describe("SyncManager", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 5, + ...createEmptyPullResult(5), }; pullFromServer.mockResolvedValue(expectedPullResult); @@ -323,7 +360,8 @@ describe("SyncManager", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }), 100, ), @@ -357,7 +395,8 @@ describe("SyncManager", () => { decks: [{ id: deck.id, syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [deck.id], cards: [] }, + ...createEmptyPushResult(), + conflicts: { ...createEmptyConflicts(), decks: [deck.id] }, }; pushToServer.mockResolvedValue(pushResult); @@ -377,7 +416,7 @@ describe("SyncManager", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 5, + ...createEmptyPullResult(5), }; pullFromServer.mockResolvedValue(pullResult); @@ -573,7 +612,8 @@ describe("SyncManager", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }), 100, ), diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts index 84c22bd..baf4bca 100644 --- a/src/client/sync/pull.test.ts +++ b/src/client/sync/pull.test.ts @@ -5,9 +5,21 @@ import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CardState, db, Rating } from "../db/index"; import { localCardRepository, localDeckRepository } from "../db/repositories"; -import { PullService, pullResultToLocalData } from "./pull"; +import { PullService, pullResultToLocalData, type SyncPullResult } from "./pull"; import { SyncQueue } from "./queue"; +function createEmptyPullResult( + currentSyncVersion = 0, +): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + currentSyncVersion, + }; +} + describe("pullResultToLocalData", () => { it("should convert server decks to local format", () => { const serverDecks = [ @@ -28,7 +40,7 @@ describe("pullResultToLocalData", () => { decks: serverDecks, cards: [], reviewLogs: [], - currentSyncVersion: 5, + ...createEmptyPullResult(5), }); expect(result.decks).toHaveLength(1); @@ -65,7 +77,7 @@ describe("pullResultToLocalData", () => { decks: serverDecks, cards: [], reviewLogs: [], - currentSyncVersion: 3, + ...createEmptyPullResult(3), }); expect(result.decks[0]?.deletedAt).toEqual( @@ -100,7 +112,7 @@ describe("pullResultToLocalData", () => { decks: [], cards: serverCards, reviewLogs: [], - currentSyncVersion: 2, + ...createEmptyPullResult(2), }); expect(result.cards).toHaveLength(1); @@ -155,7 +167,7 @@ describe("pullResultToLocalData", () => { decks: [], cards: serverCards, reviewLogs: [], - currentSyncVersion: 1, + ...createEmptyPullResult(1), }); expect(result.cards[0]?.lastReview).toBeNull(); @@ -181,7 +193,7 @@ describe("pullResultToLocalData", () => { decks: [], cards: [], reviewLogs: serverReviewLogs, - currentSyncVersion: 1, + ...createEmptyPullResult(1), }); expect(result.reviewLogs).toHaveLength(1); @@ -220,7 +232,7 @@ describe("pullResultToLocalData", () => { decks: [], cards: [], reviewLogs: serverReviewLogs, - currentSyncVersion: 1, + ...createEmptyPullResult(1), }); expect(result.reviewLogs[0]?.durationMs).toBeNull(); @@ -251,7 +263,7 @@ describe("PullService", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 0, + ...createEmptyPullResult(0), }); const pullService = new PullService({ @@ -265,7 +277,7 @@ describe("PullService", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 0, + ...createEmptyPullResult(0), }); expect(pullFromServer).toHaveBeenCalledWith(0); }); @@ -278,7 +290,7 @@ describe("PullService", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 10, + ...createEmptyPullResult(10), }); const pullService = new PullService({ @@ -308,7 +320,7 @@ describe("PullService", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 5, + ...createEmptyPullResult(5), }); const pullService = new PullService({ @@ -342,6 +354,8 @@ describe("PullService", () => { { id: "server-card-1", deckId: deck.id, + noteId: null, + isReversed: null, front: "Server Question", back: "Server Answer", state: CardState.New, @@ -360,7 +374,7 @@ describe("PullService", () => { }, ], reviewLogs: [], - currentSyncVersion: 3, + ...createEmptyPullResult(3), }); const pullService = new PullService({ @@ -382,7 +396,7 @@ describe("PullService", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 15, + ...createEmptyPullResult(15), }); const pullService = new PullService({ @@ -400,7 +414,7 @@ describe("PullService", () => { decks: [], cards: [], reviewLogs: [], - currentSyncVersion: 0, + ...createEmptyPullResult(0), }); const pullService = new PullService({ @@ -451,7 +465,7 @@ describe("PullService", () => { ], cards: [], reviewLogs: [], - currentSyncVersion: 10, + ...createEmptyPullResult(10), }); const pullService = new PullService({ @@ -487,6 +501,8 @@ describe("PullService", () => { { id: "card-1", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Q", back: "A", state: CardState.New, @@ -518,7 +534,7 @@ describe("PullService", () => { syncVersion: 3, }, ], - currentSyncVersion: 3, + ...createEmptyPullResult(3), }); const pullService = new PullService({ diff --git a/src/client/sync/pull.ts b/src/client/sync/pull.ts index fa0899b..55c859c 100644 --- a/src/client/sync/pull.ts +++ b/src/client/sync/pull.ts @@ -1,7 +1,12 @@ import type { CardStateType, + FieldTypeType, LocalCard, LocalDeck, + LocalNote, + LocalNoteFieldType, + LocalNoteFieldValue, + LocalNoteType, LocalReviewLog, RatingType, } from "../db/index"; @@ -64,12 +69,73 @@ export interface ServerReviewLog { } /** + * Server note type data format from pull response + */ +export interface ServerNoteType { + id: string; + userId: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +/** + * Server note field type data format from pull response + */ +export interface ServerNoteFieldType { + id: string; + noteTypeId: string; + name: string; + order: number; + fieldType: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +/** + * Server note data format from pull response + */ +export interface ServerNote { + id: string; + deckId: string; + noteTypeId: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +/** + * Server note field value data format from pull response + */ +export interface ServerNoteFieldValue { + id: string; + noteId: string; + noteFieldTypeId: string; + value: string; + createdAt: Date; + updatedAt: Date; + syncVersion: number; +} + +/** * Response from pull endpoint */ export interface SyncPullResult { decks: ServerDeck[]; cards: ServerCard[]; reviewLogs: ServerReviewLog[]; + noteTypes: ServerNoteType[]; + noteFieldTypes: ServerNoteFieldType[]; + notes: ServerNote[]; + noteFieldValues: ServerNoteFieldValue[]; currentSyncVersion: number; } @@ -147,17 +213,98 @@ function serverReviewLogToLocal(log: ServerReviewLog): LocalReviewLog { } /** + * Convert server note type to local note type format + */ +function serverNoteTypeToLocal(noteType: ServerNoteType): LocalNoteType { + return { + id: noteType.id, + userId: noteType.userId, + name: noteType.name, + frontTemplate: noteType.frontTemplate, + backTemplate: noteType.backTemplate, + isReversible: noteType.isReversible, + createdAt: new Date(noteType.createdAt), + updatedAt: new Date(noteType.updatedAt), + deletedAt: noteType.deletedAt ? new Date(noteType.deletedAt) : null, + syncVersion: noteType.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note field type to local note field type format + */ +function serverNoteFieldTypeToLocal( + fieldType: ServerNoteFieldType, +): LocalNoteFieldType { + return { + id: fieldType.id, + noteTypeId: fieldType.noteTypeId, + name: fieldType.name, + order: fieldType.order, + fieldType: fieldType.fieldType as FieldTypeType, + createdAt: new Date(fieldType.createdAt), + updatedAt: new Date(fieldType.updatedAt), + deletedAt: fieldType.deletedAt ? new Date(fieldType.deletedAt) : null, + syncVersion: fieldType.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note to local note format + */ +function serverNoteToLocal(note: ServerNote): LocalNote { + return { + id: note.id, + deckId: note.deckId, + noteTypeId: note.noteTypeId, + createdAt: new Date(note.createdAt), + updatedAt: new Date(note.updatedAt), + deletedAt: note.deletedAt ? new Date(note.deletedAt) : null, + syncVersion: note.syncVersion, + _synced: true, + }; +} + +/** + * Convert server note field value to local note field value format + */ +function serverNoteFieldValueToLocal( + fieldValue: ServerNoteFieldValue, +): LocalNoteFieldValue { + return { + id: fieldValue.id, + noteId: fieldValue.noteId, + noteFieldTypeId: fieldValue.noteFieldTypeId, + value: fieldValue.value, + createdAt: new Date(fieldValue.createdAt), + updatedAt: new Date(fieldValue.updatedAt), + syncVersion: fieldValue.syncVersion, + _synced: true, + }; +} + +/** * Convert server pull result to local format for storage */ export function pullResultToLocalData(result: SyncPullResult): { decks: LocalDeck[]; cards: LocalCard[]; reviewLogs: LocalReviewLog[]; + noteTypes: LocalNoteType[]; + noteFieldTypes: LocalNoteFieldType[]; + notes: LocalNote[]; + noteFieldValues: LocalNoteFieldValue[]; } { return { decks: result.decks.map(serverDeckToLocal), cards: result.cards.map(serverCardToLocal), reviewLogs: result.reviewLogs.map(serverReviewLogToLocal), + noteTypes: result.noteTypes.map(serverNoteTypeToLocal), + noteFieldTypes: result.noteFieldTypes.map(serverNoteFieldTypeToLocal), + notes: result.notes.map(serverNoteToLocal), + noteFieldValues: result.noteFieldValues.map(serverNoteFieldValueToLocal), }; } @@ -196,7 +343,11 @@ export class PullService { if ( result.decks.length > 0 || result.cards.length > 0 || - result.reviewLogs.length > 0 + result.reviewLogs.length > 0 || + result.noteTypes.length > 0 || + result.noteFieldTypes.length > 0 || + result.notes.length > 0 || + result.noteFieldValues.length > 0 ) { const localData = pullResultToLocalData(result); await this.syncQueue.applyPulledChanges(localData); diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts index a3ff154..ccd2c7d 100644 --- a/src/client/sync/push.test.ts +++ b/src/client/sync/push.test.ts @@ -9,9 +9,57 @@ import { localDeckRepository, localReviewLogRepository, } from "../db/repositories"; +import type { PendingChanges } from "./queue"; import { PushService, pendingChangesToPushData } from "./push"; import { SyncQueue } from "./queue"; +function createEmptyPending(): Omit< + PendingChanges, + "decks" | "cards" | "reviewLogs" +> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }; +} + +function createEmptyConflicts() { + return { + decks: [] as string[], + cards: [] as string[], + noteTypes: [] as string[], + noteFieldTypes: [] as string[], + notes: [] as string[], + noteFieldValues: [] as string[], + }; +} + +function createEmptyPushResult(): Omit< + import("./push").SyncPushResult, + "decks" | "cards" | "reviewLogs" | "conflicts" +> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }; +} + +function createEmptyPushData(): Omit< + import("./push").SyncPushData, + "decks" | "cards" | "reviewLogs" +> { + return { + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }; +} + describe("pendingChangesToPushData", () => { it("should convert decks to sync format", () => { const decks = [ @@ -33,6 +81,7 @@ describe("pendingChangesToPushData", () => { decks, cards: [], reviewLogs: [], + ...createEmptyPending(), }); expect(result.decks).toHaveLength(1); @@ -67,6 +116,7 @@ describe("pendingChangesToPushData", () => { decks, cards: [], reviewLogs: [], + ...createEmptyPending(), }); expect(result.decks[0]?.deletedAt).toBe("2024-01-03T12:00:00.000Z"); @@ -102,12 +152,15 @@ describe("pendingChangesToPushData", () => { decks: [], cards, reviewLogs: [], + ...createEmptyPending(), }); expect(result.cards).toHaveLength(1); expect(result.cards[0]).toEqual({ id: "card-1", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Question", back: "Answer", state: CardState.Review, @@ -155,6 +208,7 @@ describe("pendingChangesToPushData", () => { decks: [], cards, reviewLogs: [], + ...createEmptyPending(), }); expect(result.cards[0]?.lastReview).toBeNull(); @@ -181,6 +235,7 @@ describe("pendingChangesToPushData", () => { decks: [], cards: [], reviewLogs, + ...createEmptyPending(), }); expect(result.reviewLogs).toHaveLength(1); @@ -217,6 +272,7 @@ describe("pendingChangesToPushData", () => { decks: [], cards: [], reviewLogs, + ...createEmptyPending(), }); expect(result.reviewLogs[0]?.durationMs).toBeNull(); @@ -255,7 +311,8 @@ describe("PushService", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); expect(pushToServer).not.toHaveBeenCalled(); }); @@ -272,7 +329,8 @@ describe("PushService", () => { decks: [{ id: deck.id, syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); const pushService = new PushService({ @@ -292,6 +350,7 @@ describe("PushService", () => { ], cards: [], reviewLogs: [], + ...createEmptyPushData(), }); expect(result.decks).toHaveLength(1); expect(result.decks[0]?.id).toBe(deck.id); @@ -316,7 +375,8 @@ describe("PushService", () => { decks: [], cards: [{ id: card.id, syncVersion: 1 }], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); const pushService = new PushService({ @@ -336,6 +396,7 @@ describe("PushService", () => { }), ], reviewLogs: [], + ...createEmptyPushData(), }); expect(result.cards).toHaveLength(1); }); @@ -371,7 +432,8 @@ describe("PushService", () => { decks: [], cards: [], reviewLogs: [{ id: log.id, syncVersion: 1 }], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); const pushService = new PushService({ @@ -390,6 +452,7 @@ describe("PushService", () => { rating: Rating.Good, }), ], + ...createEmptyPushData(), }); expect(result.reviewLogs).toHaveLength(1); }); @@ -406,7 +469,8 @@ describe("PushService", () => { decks: [{ id: deck.id, syncVersion: 5 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); const pushService = new PushService({ @@ -433,7 +497,8 @@ describe("PushService", () => { decks: [{ id: deck.id, syncVersion: 3 }], cards: [], reviewLogs: [], - conflicts: { decks: [deck.id], cards: [] }, + ...createEmptyPushResult(), + conflicts: { ...createEmptyConflicts(), decks: [deck.id] }, }); const pushService = new PushService({ @@ -495,7 +560,8 @@ describe("PushService", () => { decks: [{ id: deck.id, syncVersion: 1 }], cards: [{ id: card.id, syncVersion: 1 }], reviewLogs: [{ id: log.id, syncVersion: 1 }], - conflicts: { decks: [], cards: [] }, + ...createEmptyPushResult(), + conflicts: createEmptyConflicts(), }); const pushService = new PushService({ diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts index 2493e4e..f5c9275 100644 --- a/src/client/sync/push.ts +++ b/src/client/sync/push.ts @@ -1,4 +1,12 @@ -import type { LocalCard, LocalDeck, LocalReviewLog } from "../db/index"; +import type { + LocalCard, + LocalDeck, + LocalNote, + LocalNoteFieldType, + LocalNoteFieldValue, + LocalNoteType, + LocalReviewLog, +} from "../db/index"; import type { PendingChanges, SyncQueue } from "./queue"; /** @@ -8,6 +16,10 @@ export interface SyncPushData { decks: SyncDeckData[]; cards: SyncCardData[]; reviewLogs: SyncReviewLogData[]; + noteTypes: SyncNoteTypeData[]; + noteFieldTypes: SyncNoteFieldTypeData[]; + notes: SyncNoteData[]; + noteFieldValues: SyncNoteFieldValueData[]; } export interface SyncDeckData { @@ -23,6 +35,8 @@ export interface SyncDeckData { export interface SyncCardData { id: string; deckId: string; + noteId: string | null; + isReversed: boolean | null; front: string; back: string; state: number; @@ -50,6 +64,46 @@ export interface SyncReviewLogData { durationMs: number | null; } +export interface SyncNoteTypeData { + id: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteFieldTypeData { + id: string; + noteTypeId: string; + name: string; + order: number; + fieldType: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteData { + id: string; + deckId: string; + noteTypeId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteFieldValueData { + id: string; + noteId: string; + noteFieldTypeId: string; + value: string; + createdAt: string; + updatedAt: string; +} + /** * Response from push endpoint */ @@ -57,9 +111,17 @@ export interface SyncPushResult { decks: { id: string; syncVersion: number }[]; cards: { id: string; syncVersion: number }[]; reviewLogs: { id: string; syncVersion: number }[]; + noteTypes: { id: string; syncVersion: number }[]; + noteFieldTypes: { id: string; syncVersion: number }[]; + notes: { id: string; syncVersion: number }[]; + noteFieldValues: { id: string; syncVersion: number }[]; conflicts: { decks: string[]; cards: string[]; + noteTypes: string[]; + noteFieldTypes: string[]; + notes: string[]; + noteFieldValues: string[]; }; } @@ -93,6 +155,8 @@ function cardToSyncData(card: LocalCard): SyncCardData { return { id: card.id, deckId: card.deckId, + noteId: card.noteId, + isReversed: card.isReversed, front: card.front, back: card.back, state: card.state, @@ -127,6 +191,70 @@ function reviewLogToSyncData(log: LocalReviewLog): SyncReviewLogData { } /** + * Convert local note type to sync format + */ +function noteTypeToSyncData(noteType: LocalNoteType): SyncNoteTypeData { + return { + id: noteType.id, + name: noteType.name, + frontTemplate: noteType.frontTemplate, + backTemplate: noteType.backTemplate, + isReversible: noteType.isReversible, + createdAt: noteType.createdAt.toISOString(), + updatedAt: noteType.updatedAt.toISOString(), + deletedAt: noteType.deletedAt?.toISOString() ?? null, + }; +} + +/** + * Convert local note field type to sync format + */ +function noteFieldTypeToSyncData( + fieldType: LocalNoteFieldType, +): SyncNoteFieldTypeData { + return { + id: fieldType.id, + noteTypeId: fieldType.noteTypeId, + name: fieldType.name, + order: fieldType.order, + fieldType: fieldType.fieldType, + createdAt: fieldType.createdAt.toISOString(), + updatedAt: fieldType.updatedAt.toISOString(), + deletedAt: fieldType.deletedAt?.toISOString() ?? null, + }; +} + +/** + * Convert local note to sync format + */ +function noteToSyncData(note: LocalNote): SyncNoteData { + return { + id: note.id, + deckId: note.deckId, + noteTypeId: note.noteTypeId, + createdAt: note.createdAt.toISOString(), + updatedAt: note.updatedAt.toISOString(), + deletedAt: note.deletedAt?.toISOString() ?? null, + }; +} + +/** + * Convert local note field value to sync format + */ +function noteFieldValueToSyncData( + fieldValue: LocalNoteFieldValue, +): SyncNoteFieldValueData { + return { + id: fieldValue.id, + noteId: fieldValue.noteId, + noteFieldTypeId: fieldValue.noteFieldTypeId, + value: fieldValue.value, + createdAt: fieldValue.createdAt.toISOString(), + updatedAt: fieldValue.updatedAt.toISOString(), + }; +} + +/** * Convert pending changes to sync push data format */ export function pendingChangesToPushData( @@ -136,6 +264,10 @@ export function pendingChangesToPushData( decks: changes.decks.map(deckToSyncData), cards: changes.cards.map(cardToSyncData), reviewLogs: changes.reviewLogs.map(reviewLogToSyncData), + noteTypes: changes.noteTypes.map(noteTypeToSyncData), + noteFieldTypes: changes.noteFieldTypes.map(noteFieldTypeToSyncData), + notes: changes.notes.map(noteToSyncData), + noteFieldValues: changes.noteFieldValues.map(noteFieldValueToSyncData), }; } @@ -171,13 +303,28 @@ export class PushService { if ( pendingChanges.decks.length === 0 && pendingChanges.cards.length === 0 && - pendingChanges.reviewLogs.length === 0 + pendingChanges.reviewLogs.length === 0 && + pendingChanges.noteTypes.length === 0 && + pendingChanges.noteFieldTypes.length === 0 && + pendingChanges.notes.length === 0 && + pendingChanges.noteFieldValues.length === 0 ) { return { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; } @@ -192,6 +339,10 @@ export class PushService { decks: result.decks, cards: result.cards, reviewLogs: result.reviewLogs, + noteTypes: result.noteTypes, + noteFieldTypes: result.noteFieldTypes, + notes: result.notes, + noteFieldValues: result.noteFieldValues, }); return result; diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts index b62ece2..2038e0d 100644 --- a/src/client/sync/queue.test.ts +++ b/src/client/sync/queue.test.ts @@ -303,6 +303,10 @@ describe("SyncQueue", () => { decks: [{ id: deck.id, syncVersion: 5 }], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localDeckRepository.findById(deck.id); @@ -327,6 +331,10 @@ describe("SyncQueue", () => { decks: [], cards: [{ id: card.id, syncVersion: 3 }], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localCardRepository.findById(card.id); @@ -361,6 +369,10 @@ describe("SyncQueue", () => { decks: [], cards: [], reviewLogs: [{ id: reviewLog.id, syncVersion: 2 }], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localReviewLogRepository.findById(reviewLog.id); @@ -376,6 +388,10 @@ describe("SyncQueue", () => { decks: [], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); expect(listener).toHaveBeenCalled(); @@ -401,6 +417,10 @@ describe("SyncQueue", () => { decks: [serverDeck], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localDeckRepository.findById("server-deck-1"); @@ -444,6 +464,10 @@ describe("SyncQueue", () => { decks: [], cards: [serverCard], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localCardRepository.findById("server-card-1"); @@ -482,6 +506,10 @@ describe("SyncQueue", () => { decks: [], cards: [], reviewLogs: [serverLog], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); const found = await localReviewLogRepository.findById("server-log-1"); @@ -497,6 +525,10 @@ describe("SyncQueue", () => { decks: [], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); expect(listener).toHaveBeenCalled(); diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts index 01c62cc..984edc3 100644 --- a/src/client/sync/queue.ts +++ b/src/client/sync/queue.ts @@ -2,11 +2,19 @@ import { db, type LocalCard, type LocalDeck, + type LocalNote, + type LocalNoteFieldType, + type LocalNoteFieldValue, + type LocalNoteType, type LocalReviewLog, } from "../db/index"; import { localCardRepository, localDeckRepository, + localNoteFieldTypeRepository, + localNoteFieldValueRepository, + localNoteRepository, + localNoteTypeRepository, localReviewLogRepository, } from "../db/repositories"; @@ -28,6 +36,10 @@ export interface PendingChanges { decks: LocalDeck[]; cards: LocalCard[]; reviewLogs: LocalReviewLog[]; + noteTypes: LocalNoteType[]; + noteFieldTypes: LocalNoteFieldType[]; + notes: LocalNote[]; + noteFieldValues: LocalNoteFieldValue[]; } /** @@ -131,13 +143,33 @@ export class SyncQueue { * Get all pending (unsynced) changes */ async getPendingChanges(): Promise<PendingChanges> { - const [decks, cards, reviewLogs] = await Promise.all([ + const [ + decks, + cards, + reviewLogs, + noteTypes, + noteFieldTypes, + notes, + noteFieldValues, + ] = await Promise.all([ localDeckRepository.findUnsynced(), localCardRepository.findUnsynced(), localReviewLogRepository.findUnsynced(), + localNoteTypeRepository.findUnsynced(), + localNoteFieldTypeRepository.findUnsynced(), + localNoteRepository.findUnsynced(), + localNoteFieldValueRepository.findUnsynced(), ]); - return { decks, cards, reviewLogs }; + return { + decks, + cards, + reviewLogs, + noteTypes, + noteFieldTypes, + notes, + noteFieldValues, + }; } /** @@ -146,7 +178,13 @@ export class SyncQueue { async getPendingCount(): Promise<number> { const changes = await this.getPendingChanges(); return ( - changes.decks.length + changes.cards.length + changes.reviewLogs.length + changes.decks.length + + changes.cards.length + + changes.reviewLogs.length + + changes.noteTypes.length + + changes.noteFieldTypes.length + + changes.notes.length + + changes.noteFieldValues.length ); } @@ -214,10 +252,22 @@ export class SyncQueue { decks: { id: string; syncVersion: number }[]; cards: { id: string; syncVersion: number }[]; reviewLogs: { id: string; syncVersion: number }[]; + noteTypes: { id: string; syncVersion: number }[]; + noteFieldTypes: { id: string; syncVersion: number }[]; + notes: { id: string; syncVersion: number }[]; + noteFieldValues: { id: string; syncVersion: number }[]; }): Promise<void> { await db.transaction( "rw", - [db.decks, db.cards, db.reviewLogs], + [ + db.decks, + db.cards, + db.reviewLogs, + db.noteTypes, + db.noteFieldTypes, + db.notes, + db.noteFieldValues, + ], async () => { for (const deck of results.decks) { await localDeckRepository.markSynced(deck.id, deck.syncVersion); @@ -231,6 +281,27 @@ export class SyncQueue { reviewLog.syncVersion, ); } + for (const noteType of results.noteTypes) { + await localNoteTypeRepository.markSynced( + noteType.id, + noteType.syncVersion, + ); + } + for (const fieldType of results.noteFieldTypes) { + await localNoteFieldTypeRepository.markSynced( + fieldType.id, + fieldType.syncVersion, + ); + } + for (const note of results.notes) { + await localNoteRepository.markSynced(note.id, note.syncVersion); + } + for (const fieldValue of results.noteFieldValues) { + await localNoteFieldValueRepository.markSynced( + fieldValue.id, + fieldValue.syncVersion, + ); + } }, ); await this.notifyListeners(); @@ -243,14 +314,39 @@ export class SyncQueue { decks: LocalDeck[]; cards: LocalCard[]; reviewLogs: LocalReviewLog[]; + noteTypes: LocalNoteType[]; + noteFieldTypes: LocalNoteFieldType[]; + notes: LocalNote[]; + noteFieldValues: LocalNoteFieldValue[]; }): Promise<void> { await db.transaction( "rw", - [db.decks, db.cards, db.reviewLogs], + [ + db.decks, + db.cards, + db.reviewLogs, + db.noteTypes, + db.noteFieldTypes, + db.notes, + db.noteFieldValues, + ], async () => { + // Apply in dependency order: NoteTypes first, then dependent entities + for (const noteType of data.noteTypes) { + await localNoteTypeRepository.upsertFromServer(noteType); + } + for (const fieldType of data.noteFieldTypes) { + await localNoteFieldTypeRepository.upsertFromServer(fieldType); + } for (const deck of data.decks) { await localDeckRepository.upsertFromServer(deck); } + for (const note of data.notes) { + await localNoteRepository.upsertFromServer(note); + } + for (const fieldValue of data.noteFieldValues) { + await localNoteFieldValueRepository.upsertFromServer(fieldValue); + } for (const card of data.cards) { await localCardRepository.upsertFromServer(card); } diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 3e9f8ed..ac9b336 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -1,7 +1,23 @@ import { and, eq, gt, sql } from "drizzle-orm"; import { db } from "../db/index.js"; -import { cards, decks, reviewLogs } from "../db/schema.js"; -import type { Card, Deck, ReviewLog } from "./types.js"; +import { + cards, + decks, + noteFieldTypes, + noteFieldValues, + noteTypes, + notes, + reviewLogs, +} from "../db/schema.js"; +import type { + Card, + Deck, + Note, + NoteFieldType, + NoteFieldValue, + NoteType, + ReviewLog, +} from "./types.js"; /** * Sync data types for push/pull operations @@ -10,6 +26,10 @@ export interface SyncPushData { decks: SyncDeckData[]; cards: SyncCardData[]; reviewLogs: SyncReviewLogData[]; + noteTypes: SyncNoteTypeData[]; + noteFieldTypes: SyncNoteFieldTypeData[]; + notes: SyncNoteData[]; + noteFieldValues: SyncNoteFieldValueData[]; } export interface SyncDeckData { @@ -25,6 +45,8 @@ export interface SyncDeckData { export interface SyncCardData { id: string; deckId: string; + noteId: string | null; + isReversed: boolean | null; front: string; back: string; state: number; @@ -52,13 +74,61 @@ export interface SyncReviewLogData { durationMs: number | null; } +export interface SyncNoteTypeData { + id: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteFieldTypeData { + id: string; + noteTypeId: string; + name: string; + order: number; + fieldType: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteData { + id: string; + deckId: string; + noteTypeId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SyncNoteFieldValueData { + id: string; + noteId: string; + noteFieldTypeId: string; + value: string; + createdAt: string; + updatedAt: string; +} + export interface SyncPushResult { decks: { id: string; syncVersion: number }[]; cards: { id: string; syncVersion: number }[]; reviewLogs: { id: string; syncVersion: number }[]; + noteTypes: { id: string; syncVersion: number }[]; + noteFieldTypes: { id: string; syncVersion: number }[]; + notes: { id: string; syncVersion: number }[]; + noteFieldValues: { id: string; syncVersion: number }[]; conflicts: { decks: string[]; cards: string[]; + noteTypes: string[]; + noteFieldTypes: string[]; + notes: string[]; + noteFieldValues: string[]; }; } @@ -70,6 +140,10 @@ export interface SyncPullResult { decks: Deck[]; cards: Card[]; reviewLogs: ReviewLog[]; + noteTypes: NoteType[]; + noteFieldTypes: NoteFieldType[]; + notes: Note[]; + noteFieldValues: NoteFieldValue[]; currentSyncVersion: number; } @@ -87,9 +161,17 @@ export const syncRepository: SyncRepository = { decks: [], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], conflicts: { decks: [], cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }, }; @@ -200,6 +282,8 @@ export const syncRepository: SyncRepository = { .values({ id: cardData.id, deckId: cardData.deckId, + noteId: cardData.noteId, + isReversed: cardData.isReversed, front: cardData.front, back: cardData.back, state: cardData.state, @@ -235,6 +319,8 @@ export const syncRepository: SyncRepository = { .update(cards) .set({ deckId: cardData.deckId, + noteId: cardData.noteId, + isReversed: cardData.isReversed, front: cardData.front, back: cardData.back, state: cardData.state, @@ -333,6 +419,357 @@ export const syncRepository: SyncRepository = { } } + // Process note types with Last-Write-Wins conflict resolution + for (const noteTypeData of data.noteTypes) { + const clientUpdatedAt = new Date(noteTypeData.updatedAt); + + // Check if note type exists and belongs to user + const existing = await db + .select({ + id: noteTypes.id, + updatedAt: noteTypes.updatedAt, + syncVersion: noteTypes.syncVersion, + }) + .from(noteTypes) + .where( + and(eq(noteTypes.id, noteTypeData.id), eq(noteTypes.userId, userId)), + ); + + if (existing.length === 0) { + // New note type - insert + const [inserted] = await db + .insert(noteTypes) + .values({ + id: noteTypeData.id, + userId, + name: noteTypeData.name, + frontTemplate: noteTypeData.frontTemplate, + backTemplate: noteTypeData.backTemplate, + isReversible: noteTypeData.isReversible, + createdAt: new Date(noteTypeData.createdAt), + updatedAt: clientUpdatedAt, + deletedAt: noteTypeData.deletedAt + ? new Date(noteTypeData.deletedAt) + : null, + syncVersion: 1, + }) + .returning({ id: noteTypes.id, syncVersion: noteTypes.syncVersion }); + + if (inserted) { + result.noteTypes.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); + } + } else { + const serverNoteType = existing[0]; + if (serverNoteType && clientUpdatedAt > serverNoteType.updatedAt) { + // Client wins - update + const [updated] = await db + .update(noteTypes) + .set({ + name: noteTypeData.name, + frontTemplate: noteTypeData.frontTemplate, + backTemplate: noteTypeData.backTemplate, + isReversible: noteTypeData.isReversible, + updatedAt: clientUpdatedAt, + deletedAt: noteTypeData.deletedAt + ? new Date(noteTypeData.deletedAt) + : null, + syncVersion: sql`${noteTypes.syncVersion} + 1`, + }) + .where(eq(noteTypes.id, noteTypeData.id)) + .returning({ + id: noteTypes.id, + syncVersion: noteTypes.syncVersion, + }); + + if (updated) { + result.noteTypes.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); + } + } else if (serverNoteType) { + // Server wins - mark as conflict + result.conflicts.noteTypes.push(noteTypeData.id); + result.noteTypes.push({ + id: serverNoteType.id, + syncVersion: serverNoteType.syncVersion, + }); + } + } + } + + // Process note field types with Last-Write-Wins conflict resolution + for (const fieldTypeData of data.noteFieldTypes) { + const clientUpdatedAt = new Date(fieldTypeData.updatedAt); + + // Verify parent note type belongs to user + const noteTypeCheck = await db + .select({ id: noteTypes.id }) + .from(noteTypes) + .where( + and( + eq(noteTypes.id, fieldTypeData.noteTypeId), + eq(noteTypes.userId, userId), + ), + ); + + if (noteTypeCheck.length === 0) { + // Parent note type doesn't belong to user, skip + continue; + } + + // Check if note field type exists + const existing = await db + .select({ + id: noteFieldTypes.id, + updatedAt: noteFieldTypes.updatedAt, + syncVersion: noteFieldTypes.syncVersion, + }) + .from(noteFieldTypes) + .where(eq(noteFieldTypes.id, fieldTypeData.id)); + + if (existing.length === 0) { + // New note field type - insert + const [inserted] = await db + .insert(noteFieldTypes) + .values({ + id: fieldTypeData.id, + noteTypeId: fieldTypeData.noteTypeId, + name: fieldTypeData.name, + order: fieldTypeData.order, + fieldType: fieldTypeData.fieldType, + createdAt: new Date(fieldTypeData.createdAt), + updatedAt: clientUpdatedAt, + deletedAt: fieldTypeData.deletedAt + ? new Date(fieldTypeData.deletedAt) + : null, + syncVersion: 1, + }) + .returning({ + id: noteFieldTypes.id, + syncVersion: noteFieldTypes.syncVersion, + }); + + if (inserted) { + result.noteFieldTypes.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); + } + } else { + const serverFieldType = existing[0]; + if (serverFieldType && clientUpdatedAt > serverFieldType.updatedAt) { + // Client wins - update + const [updated] = await db + .update(noteFieldTypes) + .set({ + noteTypeId: fieldTypeData.noteTypeId, + name: fieldTypeData.name, + order: fieldTypeData.order, + fieldType: fieldTypeData.fieldType, + updatedAt: clientUpdatedAt, + deletedAt: fieldTypeData.deletedAt + ? new Date(fieldTypeData.deletedAt) + : null, + syncVersion: sql`${noteFieldTypes.syncVersion} + 1`, + }) + .where(eq(noteFieldTypes.id, fieldTypeData.id)) + .returning({ + id: noteFieldTypes.id, + syncVersion: noteFieldTypes.syncVersion, + }); + + if (updated) { + result.noteFieldTypes.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); + } + } else if (serverFieldType) { + // Server wins - mark as conflict + result.conflicts.noteFieldTypes.push(fieldTypeData.id); + result.noteFieldTypes.push({ + id: serverFieldType.id, + syncVersion: serverFieldType.syncVersion, + }); + } + } + } + + // Process notes with Last-Write-Wins conflict resolution + for (const noteData of data.notes) { + const clientUpdatedAt = new Date(noteData.updatedAt); + + // Verify parent deck belongs to user + const deckCheck = await db + .select({ id: decks.id }) + .from(decks) + .where(and(eq(decks.id, noteData.deckId), eq(decks.userId, userId))); + + if (deckCheck.length === 0) { + // Parent deck doesn't belong to user, skip + continue; + } + + // Check if note exists + const existing = await db + .select({ + id: notes.id, + updatedAt: notes.updatedAt, + syncVersion: notes.syncVersion, + }) + .from(notes) + .where(eq(notes.id, noteData.id)); + + if (existing.length === 0) { + // New note - insert + const [inserted] = await db + .insert(notes) + .values({ + id: noteData.id, + deckId: noteData.deckId, + noteTypeId: noteData.noteTypeId, + createdAt: new Date(noteData.createdAt), + updatedAt: clientUpdatedAt, + deletedAt: noteData.deletedAt + ? new Date(noteData.deletedAt) + : null, + syncVersion: 1, + }) + .returning({ id: notes.id, syncVersion: notes.syncVersion }); + + if (inserted) { + result.notes.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); + } + } else { + const serverNote = existing[0]; + if (serverNote && clientUpdatedAt > serverNote.updatedAt) { + // Client wins - update + const [updated] = await db + .update(notes) + .set({ + deckId: noteData.deckId, + noteTypeId: noteData.noteTypeId, + updatedAt: clientUpdatedAt, + deletedAt: noteData.deletedAt + ? new Date(noteData.deletedAt) + : null, + syncVersion: sql`${notes.syncVersion} + 1`, + }) + .where(eq(notes.id, noteData.id)) + .returning({ id: notes.id, syncVersion: notes.syncVersion }); + + if (updated) { + result.notes.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); + } + } else if (serverNote) { + // Server wins - mark as conflict + result.conflicts.notes.push(noteData.id); + result.notes.push({ + id: serverNote.id, + syncVersion: serverNote.syncVersion, + }); + } + } + } + + // Process note field values with Last-Write-Wins conflict resolution + for (const fieldValueData of data.noteFieldValues) { + const clientUpdatedAt = new Date(fieldValueData.updatedAt); + + // Verify parent note belongs to user (via deck ownership) + const noteCheck = await db + .select({ id: notes.id }) + .from(notes) + .innerJoin(decks, eq(notes.deckId, decks.id)) + .where( + and(eq(notes.id, fieldValueData.noteId), eq(decks.userId, userId)), + ); + + if (noteCheck.length === 0) { + // Parent note doesn't belong to user, skip + continue; + } + + // Check if note field value exists + const existing = await db + .select({ + id: noteFieldValues.id, + updatedAt: noteFieldValues.updatedAt, + syncVersion: noteFieldValues.syncVersion, + }) + .from(noteFieldValues) + .where(eq(noteFieldValues.id, fieldValueData.id)); + + if (existing.length === 0) { + // New note field value - insert + const [inserted] = await db + .insert(noteFieldValues) + .values({ + id: fieldValueData.id, + noteId: fieldValueData.noteId, + noteFieldTypeId: fieldValueData.noteFieldTypeId, + value: fieldValueData.value, + createdAt: new Date(fieldValueData.createdAt), + updatedAt: clientUpdatedAt, + syncVersion: 1, + }) + .returning({ + id: noteFieldValues.id, + syncVersion: noteFieldValues.syncVersion, + }); + + if (inserted) { + result.noteFieldValues.push({ + id: inserted.id, + syncVersion: inserted.syncVersion, + }); + } + } else { + const serverFieldValue = existing[0]; + if (serverFieldValue && clientUpdatedAt > serverFieldValue.updatedAt) { + // Client wins - update + const [updated] = await db + .update(noteFieldValues) + .set({ + noteId: fieldValueData.noteId, + noteFieldTypeId: fieldValueData.noteFieldTypeId, + value: fieldValueData.value, + updatedAt: clientUpdatedAt, + syncVersion: sql`${noteFieldValues.syncVersion} + 1`, + }) + .where(eq(noteFieldValues.id, fieldValueData.id)) + .returning({ + id: noteFieldValues.id, + syncVersion: noteFieldValues.syncVersion, + }); + + if (updated) { + result.noteFieldValues.push({ + id: updated.id, + syncVersion: updated.syncVersion, + }); + } + } else if (serverFieldValue) { + // Server wins - mark as conflict + result.conflicts.noteFieldValues.push(fieldValueData.id); + result.noteFieldValues.push({ + id: serverFieldValue.id, + syncVersion: serverFieldValue.syncVersion, + }); + } + } + } + return result; }, @@ -380,6 +817,76 @@ export const syncRepository: SyncRepository = { ), ); + // Get all note types for user with syncVersion > lastSyncVersion + const pulledNoteTypes = await db + .select() + .from(noteTypes) + .where( + and( + eq(noteTypes.userId, userId), + gt(noteTypes.syncVersion, lastSyncVersion), + ), + ); + + // Get user's note type IDs for filtering note field types + const userNoteTypeIds = await db + .select({ id: noteTypes.id }) + .from(noteTypes) + .where(eq(noteTypes.userId, userId)); + + const noteTypeIdList = userNoteTypeIds.map((nt) => nt.id); + + // Get all note field types for user's note types with syncVersion > lastSyncVersion + let pulledNoteFieldTypes: NoteFieldType[] = []; + if (noteTypeIdList.length > 0) { + const fieldTypeResults = await db + .select() + .from(noteFieldTypes) + .where(gt(noteFieldTypes.syncVersion, lastSyncVersion)); + + pulledNoteFieldTypes = fieldTypeResults.filter((ft) => + noteTypeIdList.includes(ft.noteTypeId), + ); + } + + // Get all notes for user's decks with syncVersion > lastSyncVersion + let pulledNotes: Note[] = []; + if (deckIdList.length > 0) { + const noteResults = await db + .select() + .from(notes) + .where(gt(notes.syncVersion, lastSyncVersion)); + + pulledNotes = noteResults.filter((n) => deckIdList.includes(n.deckId)); + } + + // Get note IDs for filtering note field values + const noteIdList = pulledNotes.map((n) => n.id); + + // Also get all user's note IDs (not just recently synced ones) for field value filtering + let allUserNoteIds: string[] = []; + if (deckIdList.length > 0) { + const allNotes = await db + .select({ id: notes.id }) + .from(notes) + .innerJoin(decks, eq(notes.deckId, decks.id)) + .where(eq(decks.userId, userId)); + allUserNoteIds = allNotes.map((n) => n.id); + } + + // Get all note field values for user's notes with syncVersion > lastSyncVersion + let pulledNoteFieldValues: NoteFieldValue[] = []; + if (allUserNoteIds.length > 0) { + const fieldValueResults = await db + .select() + .from(noteFieldValues) + .where(gt(noteFieldValues.syncVersion, lastSyncVersion)); + + pulledNoteFieldValues = fieldValueResults.filter((fv) => + allUserNoteIds.includes(fv.noteId), + ); + } + // Calculate current max sync version across all entities let currentSyncVersion = lastSyncVersion; @@ -398,11 +905,35 @@ export const syncRepository: SyncRepository = { currentSyncVersion = log.syncVersion; } } + for (const noteType of pulledNoteTypes) { + if (noteType.syncVersion > currentSyncVersion) { + currentSyncVersion = noteType.syncVersion; + } + } + for (const fieldType of pulledNoteFieldTypes) { + if (fieldType.syncVersion > currentSyncVersion) { + currentSyncVersion = fieldType.syncVersion; + } + } + for (const note of pulledNotes) { + if (note.syncVersion > currentSyncVersion) { + currentSyncVersion = note.syncVersion; + } + } + for (const fieldValue of pulledNoteFieldValues) { + if (fieldValue.syncVersion > currentSyncVersion) { + currentSyncVersion = fieldValue.syncVersion; + } + } return { decks: pulledDecks, cards: pulledCards, reviewLogs: pulledReviewLogs, + noteTypes: pulledNoteTypes, + noteFieldTypes: pulledNoteFieldTypes, + notes: pulledNotes, + noteFieldValues: pulledNoteFieldValues, currentSyncVersion, }; }, diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index 7492b49..1107acd 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -7,7 +7,15 @@ import type { SyncPushResult, SyncRepository, } from "../repositories/sync.js"; -import type { Card, Deck, ReviewLog } from "../repositories/types.js"; +import type { + Card, + Deck, + Note, + NoteFieldType, + NoteFieldValue, + NoteType, + ReviewLog, +} from "../repositories/types.js"; import { createSyncRouter } from "./sync.js"; function createMockSyncRepo(): SyncRepository { @@ -35,9 +43,17 @@ interface SyncPushResponse { decks?: { id: string; syncVersion: number }[]; cards?: { id: string; syncVersion: number }[]; reviewLogs?: { id: string; syncVersion: number }[]; + noteTypes?: { id: string; syncVersion: number }[]; + noteFieldTypes?: { id: string; syncVersion: number }[]; + notes?: { id: string; syncVersion: number }[]; + noteFieldValues?: { id: string; syncVersion: number }[]; conflicts?: { decks: string[]; cards: string[]; + noteTypes: string[]; + noteFieldTypes: string[]; + notes: string[]; + noteFieldValues: string[]; }; error?: { code: string; @@ -76,7 +92,18 @@ describe("POST /api/sync/push", () => { decks: [], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -94,11 +121,18 @@ describe("POST /api/sync/push", () => { expect(body.decks).toEqual([]); expect(body.cards).toEqual([]); expect(body.reviewLogs).toEqual([]); - expect(body.conflicts).toEqual({ decks: [], cards: [] }); + expect(body.noteTypes).toEqual([]); + expect(body.noteFieldTypes).toEqual([]); + expect(body.notes).toEqual([]); + expect(body.noteFieldValues).toEqual([]); expect(mockSyncRepo.pushChanges).toHaveBeenCalledWith(userId, { decks: [], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], }); }); @@ -117,7 +151,18 @@ describe("POST /api/sync/push", () => { decks: [{ id: "deck-uuid-123", syncVersion: 1 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -141,6 +186,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440001", deckId: "550e8400-e29b-41d4-a716-446655440000", + noteId: null, + isReversed: null, front: "Question", back: "Answer", state: 0, @@ -161,7 +208,18 @@ describe("POST /api/sync/push", () => { decks: [], cards: [{ id: "550e8400-e29b-41d4-a716-446655440001", syncVersion: 1 }], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -198,7 +256,18 @@ describe("POST /api/sync/push", () => { reviewLogs: [ { id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }, ], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -238,7 +307,18 @@ describe("POST /api/sync/push", () => { decks: [{ id: "550e8400-e29b-41d4-a716-446655440003", syncVersion: 5 }], cards: [], reviewLogs: [], - conflicts: { decks: ["550e8400-e29b-41d4-a716-446655440003"], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: ["550e8400-e29b-41d4-a716-446655440003"], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -355,6 +435,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440005", deckId: "550e8400-e29b-41d4-a716-446655440004", + noteId: null, + isReversed: null, front: "Q", back: "A", state: 0, @@ -388,7 +470,18 @@ describe("POST /api/sync/push", () => { reviewLogs: [ { id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 }, ], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -412,6 +505,121 @@ describe("POST /api/sync/push", () => { expect(body.reviewLogs).toHaveLength(1); }); + it("successfully pushes note types", async () => { + const noteTypeData = { + id: "550e8400-e29b-41d4-a716-446655440010", + name: "Basic Note", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const mockResult: SyncPushResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [ + { id: "550e8400-e29b-41d4-a716-446655440010", syncVersion: 1 }, + ], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + 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: [], + noteTypes: [noteTypeData], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.noteTypes).toHaveLength(1); + expect(body.noteTypes?.[0]?.id).toBe( + "550e8400-e29b-41d4-a716-446655440010", + ); + }); + + it("successfully pushes cards with noteId and isReversed", async () => { + const cardData = { + id: "550e8400-e29b-41d4-a716-446655440011", + deckId: "550e8400-e29b-41d4-a716-446655440000", + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: true, + front: "Question", + back: "Answer", + state: 0, + due: "2024-01-01T00:00:00.000Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const mockResult: SyncPushResult = { + decks: [], + cards: [{ id: "550e8400-e29b-41d4-a716-446655440011", syncVersion: 1 }], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + 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: [cardData], + reviewLogs: [], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPushResponse; + expect(body.cards).toHaveLength(1); + expect(body.cards?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440011"); + }); + it("handles soft-deleted entities", async () => { const deletedDeck = { id: "550e8400-e29b-41d4-a716-446655440007", @@ -427,7 +635,18 @@ describe("POST /api/sync/push", () => { decks: [{ id: "550e8400-e29b-41d4-a716-446655440007", syncVersion: 2 }], cards: [], reviewLogs: [], - conflicts: { decks: [], cards: [] }, + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + conflicts: { + decks: [], + cards: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + }, }; vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult); @@ -454,6 +673,10 @@ interface SyncPullResponse { decks?: Deck[]; cards?: Card[]; reviewLogs?: ReviewLog[]; + noteTypes?: NoteType[]; + noteFieldTypes?: NoteFieldType[]; + notes?: Note[]; + noteFieldValues?: NoteFieldValue[]; currentSyncVersion?: number; error?: { code: string; @@ -500,6 +723,10 @@ describe("GET /api/sync/pull", () => { decks: [mockDeck], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -515,6 +742,10 @@ describe("GET /api/sync/pull", () => { expect(body.decks).toHaveLength(1); expect(body.cards).toHaveLength(0); expect(body.reviewLogs).toHaveLength(0); + expect(body.noteTypes).toHaveLength(0); + expect(body.noteFieldTypes).toHaveLength(0); + expect(body.notes).toHaveLength(0); + expect(body.noteFieldValues).toHaveLength(0); expect(body.currentSyncVersion).toBe(1); expect(mockSyncRepo.pullChanges).toHaveBeenCalledWith(userId, { lastSyncVersion: 0, @@ -526,6 +757,10 @@ describe("GET /api/sync/pull", () => { decks: [], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 5, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -562,6 +797,10 @@ describe("GET /api/sync/pull", () => { decks: [mockDeck], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 2, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -607,6 +846,10 @@ describe("GET /api/sync/pull", () => { decks: [], cards: [mockCard], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 3, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -645,6 +888,10 @@ describe("GET /api/sync/pull", () => { decks: [], cards: [], reviewLogs: [mockReviewLog], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 1, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -717,6 +964,10 @@ describe("GET /api/sync/pull", () => { decks: [mockDeck], cards: [mockCard], reviewLogs: [mockReviewLog], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 3, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); @@ -732,9 +983,106 @@ describe("GET /api/sync/pull", () => { expect(body.decks).toHaveLength(1); expect(body.cards).toHaveLength(1); expect(body.reviewLogs).toHaveLength(1); + expect(body.noteTypes).toHaveLength(0); + expect(body.noteFieldTypes).toHaveLength(0); + expect(body.notes).toHaveLength(0); + expect(body.noteFieldValues).toHaveLength(0); expect(body.currentSyncVersion).toBe(3); }); + it("returns note types", async () => { + const mockNoteType: NoteType = { + id: "550e8400-e29b-41d4-a716-446655440010", + userId, + name: "Basic Note", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: null, + syncVersion: 1, + }; + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + noteTypes: [mockNoteType], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + 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 SyncPullResponse; + expect(body.noteTypes).toHaveLength(1); + expect(body.noteTypes?.[0]?.id).toBe( + "550e8400-e29b-41d4-a716-446655440010", + ); + expect(body.noteTypes?.[0]?.name).toBe("Basic Note"); + expect(body.noteTypes?.[0]?.frontTemplate).toBe("{{Front}}"); + expect(body.noteTypes?.[0]?.isReversible).toBe(false); + }); + + it("returns cards with noteId and isReversed fields", async () => { + const mockCard: Card = { + id: "550e8400-e29b-41d4-a716-446655440001", + deckId: "550e8400-e29b-41d4-a716-446655440000", + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: true, + front: "Question", + back: "Answer", + state: 0, + due: new Date("2024-01-01T00:00:00.000Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: null, + syncVersion: 1, + }; + + const mockResult: SyncPullResult = { + decks: [], + cards: [mockCard], + reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], + 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 SyncPullResponse; + expect(body.cards).toHaveLength(1); + expect(body.cards?.[0]?.noteId).toBe( + "550e8400-e29b-41d4-a716-446655440020", + ); + expect(body.cards?.[0]?.isReversed).toBe(true); + }); + it("returns soft-deleted entities", async () => { const deletedDeck: Deck = { id: "550e8400-e29b-41d4-a716-446655440000", @@ -752,6 +1100,10 @@ describe("GET /api/sync/pull", () => { decks: [deletedDeck], cards: [], reviewLogs: [], + noteTypes: [], + noteFieldTypes: [], + notes: [], + noteFieldValues: [], currentSyncVersion: 2, }; vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts index ff95bf4..f05a7ba 100644 --- a/src/server/routes/sync.ts +++ b/src/server/routes/sync.ts @@ -26,6 +26,8 @@ const syncDeckSchema = z.object({ const syncCardSchema = z.object({ id: z.uuid(), deckId: z.uuid(), + noteId: z.uuid().nullable(), + isReversed: z.boolean().nullable(), front: z.string().min(1), back: z.string().min(1), state: z.number().int().min(0).max(3), @@ -53,10 +55,54 @@ const syncReviewLogSchema = z.object({ durationMs: z.number().int().min(0).nullable(), }); +const syncNoteTypeSchema = z.object({ + id: z.uuid(), + name: z.string().min(1).max(255), + frontTemplate: z.string(), + backTemplate: z.string(), + isReversible: z.boolean(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + deletedAt: z.string().datetime().nullable(), +}); + +const syncNoteFieldTypeSchema = z.object({ + id: z.uuid(), + noteTypeId: z.uuid(), + name: z.string().min(1).max(255), + order: z.number().int().min(0), + fieldType: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + deletedAt: z.string().datetime().nullable(), +}); + +const syncNoteSchema = z.object({ + id: z.uuid(), + deckId: z.uuid(), + noteTypeId: z.uuid(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + deletedAt: z.string().datetime().nullable(), +}); + +const syncNoteFieldValueSchema = z.object({ + id: z.uuid(), + noteId: z.uuid(), + noteFieldTypeId: z.uuid(), + value: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + const syncPushSchema = z.object({ decks: z.array(syncDeckSchema).default([]), cards: z.array(syncCardSchema).default([]), reviewLogs: z.array(syncReviewLogSchema).default([]), + noteTypes: z.array(syncNoteTypeSchema).default([]), + noteFieldTypes: z.array(syncNoteFieldTypeSchema).default([]), + notes: z.array(syncNoteSchema).default([]), + noteFieldValues: z.array(syncNoteFieldValueSchema).default([]), }); const syncPullQuerySchema = z.object({ |
