From 83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 19:24:18 +0900 Subject: feat(client): add sync queue management for offline sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement SyncQueue class to manage pending changes for offline sync: - Track unsynced decks, cards, and review logs from IndexedDB - Manage sync status (idle, syncing, error) with listener support - Persist last sync version and timestamp to localStorage - Provide methods to mark items as synced after push - Apply pulled changes from server to local database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/dev/roadmap.md | 2 +- src/client/sync/index.ts | 9 + src/client/sync/queue.test.ts | 583 ++++++++++++++++++++++++++++++++++++++++++ src/client/sync/queue.ts | 260 +++++++++++++++++++ 4 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 src/client/sync/index.ts create mode 100644 src/client/sync/queue.test.ts create mode 100644 src/client/sync/queue.ts diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 97715cc..8c81872 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -155,7 +155,7 @@ Smaller features first to enable early MVP validation. ### Sync Engine - [x] POST /api/sync/push endpoint - [x] GET /api/sync/pull endpoint -- [ ] Client: Sync queue management +- [x] Client: Sync queue management - [ ] Client: Push implementation - [ ] Client: Pull implementation - [ ] Conflict resolution (Last-Write-Wins) diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts new file mode 100644 index 0000000..6f75a29 --- /dev/null +++ b/src/client/sync/index.ts @@ -0,0 +1,9 @@ +export { + SyncQueue, + SyncStatus, + syncQueue, + type PendingChanges, + type SyncQueueListener, + type SyncQueueState, + type SyncStatusType, +} from "./queue"; diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts new file mode 100644 index 0000000..d35ae32 --- /dev/null +++ b/src/client/sync/queue.test.ts @@ -0,0 +1,583 @@ +/** + * @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, + localReviewLogRepository, +} from "../db/repositories"; +import { SyncQueue, SyncStatus } from "./queue"; + +describe("SyncQueue", () => { + 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("initial state", () => { + it("should have idle status by default", async () => { + const state = await syncQueue.getState(); + expect(state.status).toBe(SyncStatus.Idle); + }); + + it("should have zero pending count initially", async () => { + const state = await syncQueue.getState(); + expect(state.pendingCount).toBe(0); + }); + + it("should have zero last sync version initially", () => { + expect(syncQueue.getLastSyncVersion()).toBe(0); + }); + + it("should have no last sync date initially", async () => { + const state = await syncQueue.getState(); + expect(state.lastSyncAt).toBeNull(); + }); + + it("should have no error initially", async () => { + const state = await syncQueue.getState(); + expect(state.lastError).toBeNull(); + }); + }); + + describe("getPendingChanges", () => { + it("should return empty arrays when no pending changes", async () => { + const changes = await syncQueue.getPendingChanges(); + expect(changes.decks).toHaveLength(0); + expect(changes.cards).toHaveLength(0); + expect(changes.reviewLogs).toHaveLength(0); + }); + + it("should return unsynced decks", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const changes = await syncQueue.getPendingChanges(); + expect(changes.decks).toHaveLength(1); + expect(changes.decks[0]?.name).toBe("Test Deck"); + }); + + it("should return unsynced cards", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localCardRepository.create({ + deckId: deck.id, + front: "Question", + back: "Answer", + }); + + const changes = await syncQueue.getPendingChanges(); + expect(changes.cards).toHaveLength(1); + expect(changes.cards[0]?.front).toBe("Question"); + }); + + it("should return unsynced review logs", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Question", + back: "Answer", + }); + await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + const changes = await syncQueue.getPendingChanges(); + expect(changes.reviewLogs).toHaveLength(1); + }); + + it("should not return synced items", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const changes = await syncQueue.getPendingChanges(); + expect(changes.decks).toHaveLength(0); + }); + }); + + describe("getPendingCount", () => { + it("should return total count of pending items", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localCardRepository.create({ + deckId: deck.id, + front: "Q1", + back: "A1", + }); + await localCardRepository.create({ + deckId: deck.id, + front: "Q2", + back: "A2", + }); + + const count = await syncQueue.getPendingCount(); + // 1 deck + 2 cards = 3 + expect(count).toBe(3); + }); + }); + + describe("hasPendingChanges", () => { + it("should return false when no pending changes", async () => { + const hasPending = await syncQueue.hasPendingChanges(); + expect(hasPending).toBe(false); + }); + + it("should return true when there are pending changes", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const hasPending = await syncQueue.hasPendingChanges(); + expect(hasPending).toBe(true); + }); + }); + + describe("startSync", () => { + it("should set status to syncing", async () => { + await syncQueue.startSync(); + + const state = await syncQueue.getState(); + expect(state.status).toBe(SyncStatus.Syncing); + }); + + it("should clear previous error", async () => { + await syncQueue.failSync("Previous error"); + await syncQueue.startSync(); + + const state = await syncQueue.getState(); + expect(state.lastError).toBeNull(); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.startSync(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + status: SyncStatus.Syncing, + }), + ); + }); + }); + + describe("completeSync", () => { + it("should set status to idle", async () => { + await syncQueue.startSync(); + await syncQueue.completeSync(10); + + const state = await syncQueue.getState(); + expect(state.status).toBe(SyncStatus.Idle); + }); + + it("should update last sync version", async () => { + await syncQueue.completeSync(10); + + expect(syncQueue.getLastSyncVersion()).toBe(10); + }); + + it("should update last sync date", async () => { + const before = new Date(); + await syncQueue.completeSync(10); + + const state = await syncQueue.getState(); + expect(state.lastSyncAt).not.toBeNull(); + expect(state.lastSyncAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + }); + + it("should persist state to localStorage", async () => { + await syncQueue.completeSync(10); + + const stored = JSON.parse(localStorage.getItem("kioku_sync_state") ?? "{}"); + expect(stored.lastSyncVersion).toBe(10); + expect(stored.lastSyncAt).toBeDefined(); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.completeSync(10); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + status: SyncStatus.Idle, + lastSyncVersion: 10, + }), + ); + }); + }); + + describe("failSync", () => { + it("should set status to error", async () => { + await syncQueue.failSync("Network error"); + + const state = await syncQueue.getState(); + expect(state.status).toBe(SyncStatus.Error); + }); + + it("should set error message", async () => { + await syncQueue.failSync("Network error"); + + const state = await syncQueue.getState(); + expect(state.lastError).toBe("Network error"); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.failSync("Network error"); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + status: SyncStatus.Error, + lastError: "Network error", + }), + ); + }); + }); + + describe("markSynced", () => { + it("should mark decks as synced", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + await syncQueue.markSynced({ + decks: [{ id: deck.id, syncVersion: 5 }], + cards: [], + reviewLogs: [], + }); + + const found = await localDeckRepository.findById(deck.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(5); + }); + + it("should mark cards as synced", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + + await syncQueue.markSynced({ + decks: [], + cards: [{ id: card.id, syncVersion: 3 }], + reviewLogs: [], + }); + + const found = await localCardRepository.findById(card.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(3); + }); + + it("should mark review logs as synced", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + const reviewLog = await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + await syncQueue.markSynced({ + decks: [], + cards: [], + reviewLogs: [{ id: reviewLog.id, syncVersion: 2 }], + }); + + const found = await localReviewLogRepository.findById(reviewLog.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(2); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.markSynced({ + decks: [], + cards: [], + reviewLogs: [], + }); + + expect(listener).toHaveBeenCalled(); + }); + }); + + describe("applyPulledChanges", () => { + it("should upsert decks from server", async () => { + const serverDeck = { + id: "server-deck-1", + userId: "user-1", + name: "Server Deck", + description: null, + newCardsPerDay: 15, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 5, + _synced: false, + }; + + await syncQueue.applyPulledChanges({ + decks: [serverDeck], + cards: [], + reviewLogs: [], + }); + + const found = await localDeckRepository.findById("server-deck-1"); + expect(found?.name).toBe("Server Deck"); + expect(found?._synced).toBe(true); + }); + + it("should upsert cards from server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const serverCard = { + id: "server-card-1", + deckId: deck.id, + front: "Server Question", + back: "Server Answer", + 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: 3, + _synced: false, + } as const; + + await syncQueue.applyPulledChanges({ + decks: [], + cards: [serverCard], + reviewLogs: [], + }); + + const found = await localCardRepository.findById("server-card-1"); + expect(found?.front).toBe("Server Question"); + expect(found?._synced).toBe(true); + }); + + it("should upsert review logs from server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + + const serverLog = { + id: "server-log-1", + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + syncVersion: 2, + _synced: false, + } as const; + + await syncQueue.applyPulledChanges({ + decks: [], + cards: [], + reviewLogs: [serverLog], + }); + + const found = await localReviewLogRepository.findById("server-log-1"); + expect(found?.rating).toBe(Rating.Good); + expect(found?._synced).toBe(true); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.applyPulledChanges({ + decks: [], + cards: [], + reviewLogs: [], + }); + + expect(listener).toHaveBeenCalled(); + }); + }); + + describe("reset", () => { + it("should reset all state", async () => { + await syncQueue.completeSync(10); + await syncQueue.reset(); + + const state = await syncQueue.getState(); + expect(state.status).toBe(SyncStatus.Idle); + expect(state.lastSyncVersion).toBe(0); + expect(state.lastSyncAt).toBeNull(); + expect(state.lastError).toBeNull(); + }); + + it("should clear localStorage", async () => { + await syncQueue.completeSync(10); + await syncQueue.reset(); + + expect(localStorage.getItem("kioku_sync_state")).toBeNull(); + }); + + it("should notify listeners", async () => { + const listener = vi.fn(); + syncQueue.subscribe(listener); + + await syncQueue.reset(); + + expect(listener).toHaveBeenCalled(); + }); + }); + + describe("subscribe", () => { + it("should return unsubscribe function", async () => { + const listener = vi.fn(); + const unsubscribe = syncQueue.subscribe(listener); + + await syncQueue.startSync(); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + + await syncQueue.completeSync(10); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should support multiple listeners", async () => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + + syncQueue.subscribe(listener1); + syncQueue.subscribe(listener2); + + await syncQueue.startSync(); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + }); + }); + + describe("state persistence", () => { + it("should restore state from localStorage on construction", async () => { + // Simulate previous sync state + localStorage.setItem( + "kioku_sync_state", + JSON.stringify({ + lastSyncVersion: 15, + lastSyncAt: "2024-01-15T10:00:00.000Z", + }), + ); + + const newQueue = new SyncQueue(); + + expect(newQueue.getLastSyncVersion()).toBe(15); + const state = await newQueue.getState(); + expect(state.lastSyncAt).toEqual(new Date("2024-01-15T10:00:00.000Z")); + }); + + it("should handle invalid localStorage data", async () => { + localStorage.setItem("kioku_sync_state", "invalid json"); + + const newQueue = new SyncQueue(); + + expect(newQueue.getLastSyncVersion()).toBe(0); + }); + }); +}); diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts new file mode 100644 index 0000000..f0b112a --- /dev/null +++ b/src/client/sync/queue.ts @@ -0,0 +1,260 @@ +import { db, type LocalCard, type LocalDeck, type LocalReviewLog } from "../db/index"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "../db/repositories"; + +/** + * Sync status enum for tracking queue state + */ +export const SyncStatus = { + Idle: "idle", + Syncing: "syncing", + Error: "error", +} as const; + +export type SyncStatusType = (typeof SyncStatus)[keyof typeof SyncStatus]; + +/** + * Pending changes to be pushed to the server + */ +export interface PendingChanges { + decks: LocalDeck[]; + cards: LocalCard[]; + reviewLogs: LocalReviewLog[]; +} + +/** + * Sync queue state + */ +export interface SyncQueueState { + status: SyncStatusType; + pendingCount: number; + lastSyncVersion: number; + lastSyncAt: Date | null; + lastError: string | null; +} + +const SYNC_STATE_KEY = "kioku_sync_state"; + +/** + * Load sync state from localStorage + */ +function loadSyncState(): Pick { + const stored = localStorage.getItem(SYNC_STATE_KEY); + if (!stored) { + return { lastSyncVersion: 0, lastSyncAt: null }; + } + try { + const parsed = JSON.parse(stored) as { + lastSyncVersion?: number; + lastSyncAt?: string; + }; + return { + lastSyncVersion: parsed.lastSyncVersion ?? 0, + lastSyncAt: parsed.lastSyncAt ? new Date(parsed.lastSyncAt) : null, + }; + } catch { + return { lastSyncVersion: 0, lastSyncAt: null }; + } +} + +/** + * Save sync state to localStorage + */ +function saveSyncState(lastSyncVersion: number, lastSyncAt: Date): void { + localStorage.setItem( + SYNC_STATE_KEY, + JSON.stringify({ + lastSyncVersion, + lastSyncAt: lastSyncAt.toISOString(), + }), + ); +} + +/** + * Listener type for sync queue state changes + */ +export type SyncQueueListener = (state: SyncQueueState) => void; + +/** + * Sync Queue Manager + * + * Manages the queue of pending changes to be synchronized with the server. + * Provides methods to: + * - Get pending changes count + * - Get pending changes to push + * - Mark items as synced after successful push + * - Handle sync state persistence + */ +export class SyncQueue { + private status: SyncStatusType = SyncStatus.Idle; + private lastError: string | null = null; + private lastSyncVersion: number; + private lastSyncAt: Date | null; + private listeners: Set = new Set(); + + constructor() { + const saved = loadSyncState(); + this.lastSyncVersion = saved.lastSyncVersion; + this.lastSyncAt = saved.lastSyncAt; + } + + /** + * Subscribe to sync queue state changes + */ + subscribe(listener: SyncQueueListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Notify all listeners of state change + */ + private async notifyListeners(): Promise { + const state = await this.getState(); + for (const listener of this.listeners) { + listener(state); + } + } + + /** + * Get all pending (unsynced) changes + */ + async getPendingChanges(): Promise { + const [decks, cards, reviewLogs] = await Promise.all([ + localDeckRepository.findUnsynced(), + localCardRepository.findUnsynced(), + localReviewLogRepository.findUnsynced(), + ]); + + return { decks, cards, reviewLogs }; + } + + /** + * Get count of pending changes + */ + async getPendingCount(): Promise { + const changes = await this.getPendingChanges(); + return changes.decks.length + changes.cards.length + changes.reviewLogs.length; + } + + /** + * Check if there are any pending changes + */ + async hasPendingChanges(): Promise { + return (await this.getPendingCount()) > 0; + } + + /** + * Get current sync queue state + */ + async getState(): Promise { + return { + status: this.status, + pendingCount: await this.getPendingCount(), + lastSyncVersion: this.lastSyncVersion, + lastSyncAt: this.lastSyncAt, + lastError: this.lastError, + }; + } + + /** + * Get the last sync version for pull requests + */ + getLastSyncVersion(): number { + return this.lastSyncVersion; + } + + /** + * Set sync status to syncing + */ + async startSync(): Promise { + this.status = SyncStatus.Syncing; + this.lastError = null; + await this.notifyListeners(); + } + + /** + * Mark sync as completed successfully + */ + async completeSync(newSyncVersion: number): Promise { + this.status = SyncStatus.Idle; + this.lastSyncVersion = newSyncVersion; + this.lastSyncAt = new Date(); + this.lastError = null; + saveSyncState(this.lastSyncVersion, this.lastSyncAt); + await this.notifyListeners(); + } + + /** + * Mark sync as failed + */ + async failSync(error: string): Promise { + this.status = SyncStatus.Error; + this.lastError = error; + await this.notifyListeners(); + } + + /** + * Mark items as synced after successful push + */ + async markSynced(results: { + decks: { id: string; syncVersion: number }[]; + cards: { id: string; syncVersion: number }[]; + reviewLogs: { id: string; syncVersion: number }[]; + }): Promise { + await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => { + for (const deck of results.decks) { + await localDeckRepository.markSynced(deck.id, deck.syncVersion); + } + for (const card of results.cards) { + await localCardRepository.markSynced(card.id, card.syncVersion); + } + for (const reviewLog of results.reviewLogs) { + await localReviewLogRepository.markSynced(reviewLog.id, reviewLog.syncVersion); + } + }); + await this.notifyListeners(); + } + + /** + * Apply changes pulled from server + */ + async applyPulledChanges(data: { + decks: LocalDeck[]; + cards: LocalCard[]; + reviewLogs: LocalReviewLog[]; + }): Promise { + await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => { + for (const deck of data.decks) { + await localDeckRepository.upsertFromServer(deck); + } + for (const card of data.cards) { + await localCardRepository.upsertFromServer(card); + } + for (const reviewLog of data.reviewLogs) { + await localReviewLogRepository.upsertFromServer(reviewLog); + } + }); + await this.notifyListeners(); + } + + /** + * Reset sync state (for logout or debugging) + */ + async reset(): Promise { + this.status = SyncStatus.Idle; + this.lastSyncVersion = 0; + this.lastSyncAt = null; + this.lastError = null; + localStorage.removeItem(SYNC_STATE_KEY); + await this.notifyListeners(); + } +} + +/** + * Singleton instance of the sync queue + */ +export const syncQueue = new SyncQueue(); -- cgit v1.2.3-70-g09d2