aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/queue.ts
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/queue.ts
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/queue.ts')
-rw-r--r--src/client/sync/queue.ts260
1 files changed, 260 insertions, 0 deletions
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();