aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:24:18 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:24:18 +0900
commit83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6 (patch)
treecb880dd513112827f4f6132843b8949121c9167a /src/client/sync
parent9632d70ea0d326ac0df4e9bffb7fb669013f0755 (diff)
downloadkioku-83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6.tar.gz
kioku-83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6.tar.zst
kioku-83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6.zip
feat(client): add sync queue management for offline sync
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/sync')
-rw-r--r--src/client/sync/index.ts9
-rw-r--r--src/client/sync/queue.test.ts583
-rw-r--r--src/client/sync/queue.ts260
3 files changed, 852 insertions, 0 deletions
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<SyncQueueState, "lastSyncVersion" | "lastSyncAt"> {
+ 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<SyncQueueListener> = 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<void> {
+ const state = await this.getState();
+ for (const listener of this.listeners) {
+ listener(state);
+ }
+ }
+
+ /**
+ * Get all pending (unsynced) changes
+ */
+ async getPendingChanges(): Promise<PendingChanges> {
+ 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<number> {
+ 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<boolean> {
+ return (await this.getPendingCount()) > 0;
+ }
+
+ /**
+ * Get current sync queue state
+ */
+ async getState(): Promise<SyncQueueState> {
+ 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<void> {
+ this.status = SyncStatus.Syncing;
+ this.lastError = null;
+ await this.notifyListeners();
+ }
+
+ /**
+ * Mark sync as completed successfully
+ */
+ async completeSync(newSyncVersion: number): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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();