diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:29:46 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:29:46 +0900 |
| commit | 864495bd4d7156ee433cbc12adda4bdebd43f6fe (patch) | |
| tree | fe6f8a628d2812ce77ddf9bcaca4e8ad26544dfe | |
| parent | 842c74fdc2bf06a020868f5b4e504fec0da8715d (diff) | |
| download | kioku-864495bd4d7156ee433cbc12adda4bdebd43f6fe.tar.gz kioku-864495bd4d7156ee433cbc12adda4bdebd43f6fe.tar.zst kioku-864495bd4d7156ee433cbc12adda4bdebd43f6fe.zip | |
feat(client): add pull service for sync implementation
Implement PullService class to pull changes from server:
- Fetch changes since last sync version
- Convert server data format to local IndexedDB format
- Apply pulled decks, cards, and review logs to local database
- Update sync version after successful pull
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/sync/index.ts | 11 | ||||
| -rw-r--r-- | src/client/sync/pull.test.ts | 553 | ||||
| -rw-r--r-- | src/client/sync/pull.ts | 222 |
4 files changed, 787 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index d1c1874..276e6c5 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -157,7 +157,7 @@ Smaller features first to enable early MVP validation. - [x] GET /api/sync/pull endpoint - [x] Client: Sync queue management - [x] Client: Push implementation -- [ ] Client: Pull implementation +- [x] Client: Pull implementation - [ ] Conflict resolution (Last-Write-Wins) - [ ] Auto-sync on reconnect - [ ] Add tests diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts index 76f5081..80d0cc1 100644 --- a/src/client/sync/index.ts +++ b/src/client/sync/index.ts @@ -19,3 +19,14 @@ export { type SyncPushResult, type SyncReviewLogData, } from "./push"; + +export { + createPullService, + pullResultToLocalData, + PullService, + type PullServiceOptions, + type ServerCard, + type ServerDeck, + type ServerReviewLog, + type SyncPullResult, +} from "./pull"; diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts new file mode 100644 index 0000000..1aaac84 --- /dev/null +++ b/src/client/sync/pull.test.ts @@ -0,0 +1,553 @@ +/** + * @vitest-environment jsdom + */ +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 { pullResultToLocalData, PullService } from "./pull"; +import { SyncQueue } from "./queue"; + +describe("pullResultToLocalData", () => { + it("should convert server decks to local format", () => { + const serverDecks = [ + { + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 5, + }, + ]; + + const result = pullResultToLocalData({ + decks: serverDecks, + cards: [], + reviewLogs: [], + currentSyncVersion: 5, + }); + + expect(result.decks).toHaveLength(1); + expect(result.decks[0]).toEqual({ + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 5, + _synced: true, + }); + }); + + it("should convert deleted server decks with deletedAt timestamp", () => { + const serverDecks = [ + { + id: "deck-1", + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 10, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-03T12:00:00Z"), + deletedAt: new Date("2024-01-03T12:00:00Z"), + syncVersion: 3, + }, + ]; + + const result = pullResultToLocalData({ + decks: serverDecks, + cards: [], + reviewLogs: [], + currentSyncVersion: 3, + }); + + expect(result.decks[0]?.deletedAt).toEqual(new Date("2024-01-03T12:00:00Z")); + }); + + it("should convert server cards to local format", () => { + const serverCards = [ + { + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.Review, + due: new Date("2024-01-05T09:00:00Z"), + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: new Date("2024-01-02T10:00:00Z"), + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 2, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: serverCards, + reviewLogs: [], + currentSyncVersion: 2, + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]).toEqual({ + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.Review, + due: new Date("2024-01-05T09:00:00Z"), + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: new Date("2024-01-02T10:00:00Z"), + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 2, + _synced: true, + }); + }); + + it("should convert server cards with null lastReview", () => { + const serverCards = [ + { + id: "card-1", + deckId: "deck-1", + front: "New Card", + back: "Answer", + state: CardState.New, + due: new Date("2024-01-01T10:00:00Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-01T10:00:00Z"), + deletedAt: null, + syncVersion: 1, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: serverCards, + reviewLogs: [], + currentSyncVersion: 1, + }); + + expect(result.cards[0]?.lastReview).toBeNull(); + }); + + it("should convert server review logs to local format", () => { + const serverReviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: 5000, + syncVersion: 1, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: serverReviewLogs, + currentSyncVersion: 1, + }); + + expect(result.reviewLogs).toHaveLength(1); + expect(result.reviewLogs[0]).toEqual({ + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: 5000, + syncVersion: 1, + _synced: true, + }); + }); + + it("should convert review logs with null durationMs", () => { + const serverReviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Easy, + state: CardState.New, + scheduledDays: 3, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: null, + syncVersion: 1, + }, + ]; + + const result = pullResultToLocalData({ + decks: [], + cards: [], + reviewLogs: serverReviewLogs, + currentSyncVersion: 1, + }); + + expect(result.reviewLogs[0]?.durationMs).toBeNull(); + }); +}); + +describe("PullService", () => { + let syncQueue: SyncQueue; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + syncQueue = new SyncQueue(); + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + }); + + describe("pull", () => { + it("should return empty result when no server changes", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 0, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + const result = await pullService.pull(); + + expect(result).toEqual({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 0, + }); + expect(pullFromServer).toHaveBeenCalledWith(0); + }); + + it("should call pullFromServer with last sync version", async () => { + // Set a previous sync version + await syncQueue.completeSync(10); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 10, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + expect(pullFromServer).toHaveBeenCalledWith(10); + }); + + it("should apply pulled decks to local database", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [ + { + id: "server-deck-1", + userId: "user-1", + name: "Server Deck", + description: "From server", + newCardsPerDay: 15, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 5, + }, + ], + cards: [], + reviewLogs: [], + currentSyncVersion: 5, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const deck = await localDeckRepository.findById("server-deck-1"); + expect(deck).toBeDefined(); + expect(deck?.name).toBe("Server Deck"); + expect(deck?.description).toBe("From server"); + expect(deck?._synced).toBe(true); + expect(deck?.syncVersion).toBe(5); + }); + + it("should apply pulled cards to local database", async () => { + // First create a deck for the card + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [ + { + id: "server-card-1", + deckId: deck.id, + front: "Server Question", + back: "Server Answer", + state: CardState.New, + due: new Date("2024-01-01T10:00:00Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-01T10:00:00Z"), + deletedAt: null, + syncVersion: 3, + }, + ], + reviewLogs: [], + currentSyncVersion: 3, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const card = await localCardRepository.findById("server-card-1"); + expect(card).toBeDefined(); + expect(card?.front).toBe("Server Question"); + expect(card?.back).toBe("Server Answer"); + expect(card?._synced).toBe(true); + }); + + it("should update sync version after successful pull", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 15, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + expect(syncQueue.getLastSyncVersion()).toBe(15); + }); + + it("should not update sync version if unchanged", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 0, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + expect(syncQueue.getLastSyncVersion()).toBe(0); + }); + + it("should throw error if pull fails", async () => { + const pullFromServer = vi.fn().mockRejectedValue(new Error("Network error")); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await expect(pullService.pull()).rejects.toThrow("Network error"); + }); + + it("should update existing items when pulling", async () => { + // Create an existing deck + const existingDeck = await localDeckRepository.create({ + userId: "user-1", + name: "Old Name", + description: null, + newCardsPerDay: 10, + }); + + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [ + { + id: existingDeck.id, + userId: "user-1", + name: "Updated Name", + description: "Updated description", + newCardsPerDay: 25, + createdAt: existingDeck.createdAt, + updatedAt: new Date(), + deletedAt: null, + syncVersion: 10, + }, + ], + cards: [], + reviewLogs: [], + currentSyncVersion: 10, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + await pullService.pull(); + + const updatedDeck = await localDeckRepository.findById(existingDeck.id); + expect(updatedDeck?.name).toBe("Updated Name"); + expect(updatedDeck?.description).toBe("Updated description"); + expect(updatedDeck?.newCardsPerDay).toBe(25); + expect(updatedDeck?._synced).toBe(true); + }); + + it("should handle pulling all types of data together", async () => { + const pullFromServer = vi.fn().mockResolvedValue({ + decks: [ + { + id: "deck-1", + userId: "user-1", + name: "Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 1, + }, + ], + cards: [ + { + id: "card-1", + deckId: "deck-1", + front: "Q", + back: "A", + state: CardState.New, + due: new Date(), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 2, + }, + ], + reviewLogs: [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + syncVersion: 3, + }, + ], + currentSyncVersion: 3, + }); + + const pullService = new PullService({ + syncQueue, + pullFromServer, + }); + + const result = await pullService.pull(); + + expect(result.decks).toHaveLength(1); + expect(result.cards).toHaveLength(1); + expect(result.reviewLogs).toHaveLength(1); + expect(syncQueue.getLastSyncVersion()).toBe(3); + }); + }); + + describe("getLastSyncVersion", () => { + it("should return current sync version", async () => { + await syncQueue.completeSync(25); + + const pullService = new PullService({ + syncQueue, + pullFromServer: vi.fn(), + }); + + expect(pullService.getLastSyncVersion()).toBe(25); + }); + + it("should return 0 when never synced", () => { + const pullService = new PullService({ + syncQueue, + pullFromServer: vi.fn(), + }); + + expect(pullService.getLastSyncVersion()).toBe(0); + }); + }); +}); diff --git a/src/client/sync/pull.ts b/src/client/sync/pull.ts new file mode 100644 index 0000000..333782c --- /dev/null +++ b/src/client/sync/pull.ts @@ -0,0 +1,222 @@ +import type { + CardStateType, + LocalCard, + LocalDeck, + LocalReviewLog, + RatingType, +} from "../db/index"; +import type { SyncQueue } from "./queue"; + +/** + * Server deck data format from pull response + */ +export interface ServerDeck { + id: string; + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +/** + * Server card data format from pull response + */ +export interface ServerCard { + id: string; + deckId: string; + front: string; + back: string; + state: number; + due: Date; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: Date | null; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +/** + * Server review log data format from pull response + */ +export interface ServerReviewLog { + id: string; + cardId: string; + userId: string; + rating: number; + state: number; + scheduledDays: number; + elapsedDays: number; + reviewedAt: Date; + durationMs: number | null; + syncVersion: number; +} + +/** + * Response from pull endpoint + */ +export interface SyncPullResult { + decks: ServerDeck[]; + cards: ServerCard[]; + reviewLogs: ServerReviewLog[]; + currentSyncVersion: number; +} + +/** + * Options for creating a pull service + */ +export interface PullServiceOptions { + syncQueue: SyncQueue; + pullFromServer: (lastSyncVersion: number) => Promise<SyncPullResult>; +} + +/** + * Convert server deck to local deck format + */ +function serverDeckToLocal(deck: ServerDeck): LocalDeck { + return { + id: deck.id, + userId: deck.userId, + name: deck.name, + description: deck.description, + newCardsPerDay: deck.newCardsPerDay, + createdAt: new Date(deck.createdAt), + updatedAt: new Date(deck.updatedAt), + deletedAt: deck.deletedAt ? new Date(deck.deletedAt) : null, + syncVersion: deck.syncVersion, + _synced: true, + }; +} + +/** + * Convert server card to local card format + */ +function serverCardToLocal(card: ServerCard): LocalCard { + return { + id: card.id, + deckId: card.deckId, + front: card.front, + back: card.back, + state: card.state as CardStateType, + due: new Date(card.due), + stability: card.stability, + difficulty: card.difficulty, + elapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays, + reps: card.reps, + lapses: card.lapses, + lastReview: card.lastReview ? new Date(card.lastReview) : null, + createdAt: new Date(card.createdAt), + updatedAt: new Date(card.updatedAt), + deletedAt: card.deletedAt ? new Date(card.deletedAt) : null, + syncVersion: card.syncVersion, + _synced: true, + }; +} + +/** + * Convert server review log to local review log format + */ +function serverReviewLogToLocal(log: ServerReviewLog): LocalReviewLog { + return { + id: log.id, + cardId: log.cardId, + userId: log.userId, + rating: log.rating as RatingType, + state: log.state as CardStateType, + scheduledDays: log.scheduledDays, + elapsedDays: log.elapsedDays, + reviewedAt: new Date(log.reviewedAt), + durationMs: log.durationMs, + syncVersion: log.syncVersion, + _synced: true, + }; +} + +/** + * Convert server pull result to local format for storage + */ +export function pullResultToLocalData(result: SyncPullResult): { + decks: LocalDeck[]; + cards: LocalCard[]; + reviewLogs: LocalReviewLog[]; +} { + return { + decks: result.decks.map(serverDeckToLocal), + cards: result.cards.map(serverCardToLocal), + reviewLogs: result.reviewLogs.map(serverReviewLogToLocal), + }; +} + +/** + * Pull sync service + * + * Handles pulling changes from the server: + * 1. Get last sync version from sync queue + * 2. Request changes from server since that version + * 3. Convert server data to local format + * 4. Apply changes to local database + * 5. Update sync version + */ +export class PullService { + private syncQueue: SyncQueue; + private pullFromServer: (lastSyncVersion: number) => Promise<SyncPullResult>; + + constructor(options: PullServiceOptions) { + this.syncQueue = options.syncQueue; + this.pullFromServer = options.pullFromServer; + } + + /** + * Pull changes from the server + * + * @returns Result containing pulled items and new sync version + * @throws Error if pull fails + */ + async pull(): Promise<SyncPullResult> { + const lastSyncVersion = this.syncQueue.getLastSyncVersion(); + + // Pull changes from server + const result = await this.pullFromServer(lastSyncVersion); + + // If there are changes, apply them to local database + if ( + result.decks.length > 0 || + result.cards.length > 0 || + result.reviewLogs.length > 0 + ) { + const localData = pullResultToLocalData(result); + await this.syncQueue.applyPulledChanges(localData); + } + + // Update sync version even if no changes (to mark we synced up to this point) + if (result.currentSyncVersion > lastSyncVersion) { + await this.syncQueue.completeSync(result.currentSyncVersion); + } + + return result; + } + + /** + * Get the last sync version + */ + getLastSyncVersion(): number { + return this.syncQueue.getLastSyncVersion(); + } +} + +/** + * Create a pull service with the given options + */ +export function createPullService(options: PullServiceOptions): PullService { + return new PullService(options); +} |
