/** * Sync service for keeping local SQLite cache in sync with Convex. * Provides offline-first functionality with background sync. */ import { ConvexReactClient } from "convex/react"; import { api } from "../../convex/_generated/api"; import { initDatabase, getLastSyncTimestamp, setLastSyncTimestamp, getCachedHashes, getCachedCardCount, upsertCards, clearCache, } from "./localDatabase"; import type { CardHashEntry } from "../recognition"; export interface SyncStatus { isInitialized: boolean; isSyncing: boolean; lastSync: number; localCardCount: number; error?: string; } export interface SyncService { initialize: () => Promise; sync: () => Promise; getHashes: () => Promise; getStatus: () => SyncStatus; clearLocalCache: () => Promise; onStatusChange: (callback: (status: SyncStatus) => void) => () => void; } /** * Create a sync service instance. */ export function createSyncService(convexClient: ConvexReactClient): SyncService { let status: SyncStatus = { isInitialized: false, isSyncing: false, lastSync: 0, localCardCount: 0, }; const listeners = new Set<(status: SyncStatus) => void>(); function notifyListeners() { listeners.forEach((cb) => cb(status)); } function updateStatus(partial: Partial) { status = { ...status, ...partial }; notifyListeners(); } return { /** * Initialize the local database and load cached data. */ async initialize() { try { await initDatabase(); const [lastSync, cardCount] = await Promise.all([ getLastSyncTimestamp(), getCachedCardCount(), ]); updateStatus({ isInitialized: true, lastSync, localCardCount: cardCount, error: undefined, }); console.log(`[SyncService] Initialized with ${cardCount} cached cards`); } catch (error) { console.error("[SyncService] Initialization failed:", error); updateStatus({ error: error instanceof Error ? error.message : "Initialization failed", }); } }, /** * Sync with Convex - fetch new/updated cards. */ async sync() { if (status.isSyncing) { console.log("[SyncService] Sync already in progress"); return; } updateStatus({ isSyncing: true, error: undefined }); try { // Get cards updated since last sync const lastSync = status.lastSync; // Query Convex for updated cards // Note: This uses the HTTP client for one-off queries const cards = lastSync > 0 ? await convexClient.query(api.cards.listUpdatedAfter, { since: lastSync }) : await convexClient.query(api.cards.list, {}); if (cards && cards.length > 0) { // Convert to local format and save const localCards = cards.map((card) => ({ scryfallId: card.scryfallId, oracleId: card.oracleId, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, rarity: card.rarity, artist: card.artist, imageUri: card.imageUri, hash: new Uint8Array(card.hash), hashVersion: card.hashVersion, updatedAt: card.updatedAt, })); await upsertCards(localCards); console.log(`[SyncService] Synced ${localCards.length} cards`); } // Update sync timestamp const now = Date.now(); await setLastSyncTimestamp(now); // Update status const cardCount = await getCachedCardCount(); updateStatus({ isSyncing: false, lastSync: now, localCardCount: cardCount, }); console.log(`[SyncService] Sync complete. ${cardCount} cards cached.`); } catch (error) { console.error("[SyncService] Sync failed:", error); updateStatus({ isSyncing: false, error: error instanceof Error ? error.message : "Sync failed", }); } }, /** * Get cached hashes for recognition. */ async getHashes() { return getCachedHashes(); }, /** * Get current sync status. */ getStatus() { return status; }, /** * Clear local cache and re-sync. */ async clearLocalCache() { await clearCache(); updateStatus({ lastSync: 0, localCardCount: 0, }); }, /** * Subscribe to status changes. */ onStatusChange(callback: (status: SyncStatus) => void) { listeners.add(callback); return () => listeners.delete(callback); }, }; }