import pouch from './pouchdb-setup'; import { getCourseDB } from '.'; import { logger } from '../../util/logger'; import type { CourseConfig } from '@vue-skuilder/common'; // ============================================================================ // COURSE SYNC SERVICE // ============================================================================ // // Manages client-side PouchDB replicas of course databases. // // Courses opt in to local sync via CourseConfig.localSync.enabled. When // enabled, the service performs a one-shot replication from remote CouchDB // to a local PouchDB on first visit, then incremental sync on subsequent // visits. Pipeline scoring, tag hydration, and card lookup then run against // the local replica — eliminating network round trips from the study-session // hot path. // // Read/write split: // Local DB = read-only snapshot (pipeline, filters, card lookup) // Remote DB = all writes (ELO updates, tag mutations, admin ops) // // This avoids propagating per-interaction ELO write noise to every syncing // client. Each client's local snapshot refreshes on the next page load. // // Live replication is intentionally NOT supported. The remote course DB // receives high-frequency ELO updates from all concurrent users — live // sync would cause constant re-indexing of local PouchDB views. // // ============================================================================ /** * Sync state for a single course database. */ export type CourseSyncState = | 'not-started' | 'checking-config' | 'syncing' | 'warming-views' | 'ready' | 'disabled' | 'error'; /** * Detailed sync status for observability. */ export interface CourseSyncStatus { state: CourseSyncState; /** Number of documents replicated (set after sync completes) */ docsReplicated?: number; /** Total replication time in ms */ syncTimeMs?: number; /** View warming time in ms */ viewWarmTimeMs?: number; /** Error message if state is 'error' */ error?: string; } /** * Internal tracking entry per course. */ interface SyncEntry { localDB: PouchDB.Database | null; status: CourseSyncStatus; /** Promise that resolves when sync is complete (or rejects on failure) */ readyPromise: Promise | null; } /** * Service that manages local PouchDB replicas of course databases. * * Usage: * ```typescript * const syncService = CourseSyncService.getInstance(); * * // Trigger sync (typically on app load / pre-session) * await syncService.ensureSynced(courseId); * * // Get local DB for reads (returns null if sync not ready/enabled) * const localDB = syncService.getLocalDB(courseId); * ``` * * The service is a singleton — course sync state is shared across the app. */ /** * Tuning knobs for one-shot remote→local replication. * Defaults are chosen for one-shot snapshot replication of bounded course * corpora (text/JSON docs, no large attachments). PouchDB's built-in * defaults (100/10) target live continuous sync — too conservative for * cold-load UX on a course of a few thousand docs. * * Apps with smaller/larger corpora can override via * `initializeDataLayer({ options: { courseSync: { replication: {...} } } })`. */ export interface ReplicationOptions { /** Docs per `_bulk_get` HTTP request. Higher = fewer roundtrips. */ batchSize?: number; /** Concurrent batches in flight. */ batchesLimit?: number; } const DEFAULT_REPLICATION: Required = { batchSize: 1000, batchesLimit: 5, }; export class CourseSyncService { private static instance: CourseSyncService | null = null; private entries: Map = new Map(); private replicationOptions: Required = DEFAULT_REPLICATION; private constructor() {} static getInstance(): CourseSyncService { if (!CourseSyncService.instance) { CourseSyncService.instance = new CourseSyncService(); } return CourseSyncService.instance; } /** * Apply replication tuning. Typically called once from `initializeDataLayer`. * Partial overrides merge with defaults. */ configure(opts: { replication?: ReplicationOptions }): void { if (opts.replication) { this.replicationOptions = { ...DEFAULT_REPLICATION, ...opts.replication, }; logger.info( `[CourseSyncService] Replication configured: ` + `batch_size=${this.replicationOptions.batchSize}, ` + `batches_limit=${this.replicationOptions.batchesLimit}` ); } } /** * Reset the singleton (for testing). */ static resetInstance(): void { if (CourseSyncService.instance) { // Close all local DBs for (const [, entry] of CourseSyncService.instance.entries) { if (entry.localDB) { entry.localDB.close().catch(() => {}); } } CourseSyncService.instance.entries.clear(); } CourseSyncService.instance = null; } // -------------------------------------------------------------------------- // Public API // -------------------------------------------------------------------------- /** * Ensure a course's local replica is synced. * * On first call for a course: * 1. Fetches CourseConfig from remote to check localSync.enabled * 2. If enabled, performs one-shot replication remote → local * 3. Pre-warms PouchDB view indices (elo, getTags) * * On subsequent calls: returns immediately if already synced, or awaits * the in-flight sync if one is in progress. * * Safe to call multiple times — concurrent calls coalesce to one sync. * * @param courseId - The course to sync * @param forceEnabled - Skip the CourseConfig check and sync regardless. * Useful when the caller already knows local sync is desired (e.g., * LettersPractice hardcodes this). */ async ensureSynced(courseId: string, forceEnabled?: boolean): Promise { const existing = this.entries.get(courseId); // Already synced — but check if the remote DB has been recreated // (e.g., after a dev reseed). The seed script writes a `db-epoch` // doc; if the remote epoch differs from our local copy, the local // replica is stale and must be destroyed before re-syncing. if (existing?.status.state === 'ready' && existing.localDB) { const stale = await this.isLocalEpochStale(courseId, existing.localDB); if (!stale) { return; } logger.info( `[CourseSyncService] Remote DB epoch changed for course ${courseId} — destroying stale local replica` ); try { await existing.localDB.destroy(); } catch { // Ignore cleanup errors } existing.localDB = null; existing.readyPromise = null; // Fall through to start a fresh sync } // Already disabled if (existing?.status.state === 'disabled') { return; } // Sync in flight — coalesce if (existing?.readyPromise) { return existing.readyPromise; } // Start a new sync const entry: SyncEntry = { localDB: null, status: { state: 'not-started' }, readyPromise: null, }; this.entries.set(courseId, entry); entry.readyPromise = this.performSync(courseId, entry, forceEnabled); return entry.readyPromise; } /** * Get the local PouchDB for a course, or null if not available. * * Returns null when: * - Local sync is not enabled for this course * - Sync has not been triggered yet * - Sync is still in progress * - Sync failed */ getLocalDB(courseId: string): PouchDB.Database | null { const entry = this.entries.get(courseId); if (entry?.status.state === 'ready' && entry.localDB) { return entry.localDB; } return null; } /** * Check whether a course has a ready local replica. */ isReady(courseId: string): boolean { return this.entries.get(courseId)?.status.state === 'ready'; } /** * Get detailed sync status for a course. */ getStatus(courseId: string): CourseSyncStatus { return ( this.entries.get(courseId)?.status ?? { state: 'not-started' } ); } // -------------------------------------------------------------------------- // Internal // -------------------------------------------------------------------------- private async performSync( courseId: string, entry: SyncEntry, forceEnabled?: boolean ): Promise { try { // Step 1: Check if local sync is enabled for this course if (!forceEnabled) { entry.status = { state: 'checking-config' }; const enabled = await this.checkLocalSyncEnabled(courseId); if (!enabled) { entry.status = { state: 'disabled' }; entry.readyPromise = null; logger.debug( `[CourseSyncService] Local sync disabled for course ${courseId}` ); return; } } // Step 2: Create local PouchDB and replicate entry.status = { state: 'syncing' }; const localDBName = this.localDBName(courseId); let localDB = new pouch(localDBName); // Check for stale local replica before replicating. If the remote DB // was wiped and recreated (e.g., `yarn db:seed`), the local PouchDB // has documents at rev 1-oldHash while the remote has 1-newHash for // the same _ids. PouchDB replication treats this as a conflict rather // than an update, and the stale revision can win — causing partial or // incorrect data. Destroying the stale local DB avoids this entirely. const stale = await this.isLocalEpochStale(courseId, localDB); if (stale) { logger.info( `[CourseSyncService] Stale local DB detected for course ${courseId} — destroying before sync` ); await localDB.destroy(); localDB = new pouch(localDBName); } entry.localDB = localDB; const remoteDB = this.getRemoteDB(courseId); const syncStart = Date.now(); logger.info( `[CourseSyncService] Starting one-shot replication for course ${courseId} ` + `(batch_size=${this.replicationOptions.batchSize}, ` + `batches_limit=${this.replicationOptions.batchesLimit})` ); const result = await this.replicate(remoteDB, localDB); const syncTimeMs = Date.now() - syncStart; logger.info( `[CourseSyncService] Replication complete for course ${courseId}: ` + `${result.docs_written} docs in ${syncTimeMs}ms` ); // Step 3: Pre-warm view indices entry.status = { state: 'warming-views' }; const warmStart = Date.now(); await this.warmViewIndices(localDB); const viewWarmTimeMs = Date.now() - warmStart; logger.info( `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms` ); // Done entry.status = { state: 'ready', docsReplicated: result.docs_written, syncTimeMs, viewWarmTimeMs, }; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); logger.error( `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}` ); entry.status = { state: 'error', error: errorMsg }; entry.readyPromise = null; // Clean up the local DB on failure — don't leave a partial replica if (entry.localDB) { try { await entry.localDB.destroy(); } catch { // Ignore cleanup errors } entry.localDB = null; } } } /** * Check CourseConfig.localSync.enabled on the remote DB. */ private async checkLocalSyncEnabled(courseId: string): Promise { try { const remoteDB = this.getRemoteDB(courseId); const config = await remoteDB.get('CourseConfig'); return config.localSync?.enabled === true; } catch (e) { logger.warn( `[CourseSyncService] Could not read CourseConfig for ${courseId}, ` + `assuming local sync disabled: ${e}` ); return false; } } /** * One-shot replication from remote to local. */ private replicate( source: PouchDB.Database, target: PouchDB.Database ): Promise> { return new Promise((resolve, reject) => { void pouch.replicate(source, target, { // One-shot, not live. Local is a read-only snapshot. batch_size: this.replicationOptions.batchSize, batches_limit: this.replicationOptions.batchesLimit, }) .on('complete', (info) => { resolve(info); }) .on('error', (err) => { reject(err); }); }); } /** * Pre-warm PouchDB view indices by running a minimal query against each * design doc. This forces PouchDB to build the MapReduce index now * (during a loading phase) rather than on first pipeline query. */ private async warmViewIndices(localDB: PouchDB.Database): Promise { const viewsToWarm = ['elo', 'getTags']; for (const viewName of viewsToWarm) { try { await localDB.query(viewName, { limit: 1 }); logger.debug( `[CourseSyncService] Warmed view index: ${viewName}` ); } catch (e) { // View might not exist in this course DB — that's OK. // Not all courses have all design docs. logger.debug( `[CourseSyncService] Could not warm view ${viewName}: ${e}` ); } } } /** * Check whether the local replica's `db-epoch` doc matches the remote. * * The seed script (and optionally upload-cards) writes a `db-epoch` * document with a numeric timestamp. If the remote epoch differs from * the local copy, the remote DB was recreated (e.g., `yarn db:seed`) * and the local PouchDB is stale. * * Returns `true` if stale (epoch mismatch or remote has epoch but local * doesn't). Returns `false` (not stale) if epochs match, or if the * remote doesn't have an epoch doc at all (backwards compat). */ private async isLocalEpochStale( courseId: string, localDB: PouchDB.Database ): Promise { try { const remoteDB = this.getRemoteDB(courseId); const remoteEpoch = await remoteDB.get<{ epoch: number }>('db-epoch'); let localEpoch: { epoch: number } | null = null; try { localEpoch = await localDB.get<{ epoch: number }>('db-epoch'); } catch { // Local doesn't have the epoch doc — stale return true; } return remoteEpoch.epoch !== localEpoch.epoch; } catch { // Remote doesn't have db-epoch — no epoch tracking, not stale return false; } } /** * Get a remote PouchDB handle for a course. */ private getRemoteDB(courseId: string): PouchDB.Database { return getCourseDB(courseId); } /** * Local DB naming convention. */ private localDBName(courseId: string): string { return `coursedb-local-${courseId}`; } }