aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:29:46 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:29:46 +0900
commit864495bd4d7156ee433cbc12adda4bdebd43f6fe (patch)
treefe6f8a628d2812ce77ddf9bcaca4e8ad26544dfe
parent842c74fdc2bf06a020868f5b4e504fec0da8715d (diff)
downloadkioku-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.md2
-rw-r--r--src/client/sync/index.ts11
-rw-r--r--src/client/sync/pull.test.ts553
-rw-r--r--src/client/sync/pull.ts222
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);
+}