import PouchDB from 'pouchdb-browser'; import PouchDBFind from 'pouchdb-find'; // Register the find plugin globally PouchDB.plugin(PouchDBFind); // ─── Configuration Interface ──────────────────────────────────── /** * Configuration for creating a new PouchDB database instance. * Follows Inversion of Control — the consuming app decides names and remote URLs. */ export interface PouchConfig { /** Name of the local PouchDB database (stored in IndexedDB by the browser). */ localName: string; /** * Optional remote CouchDB URL for bi-directional live sync. * Should include credentials if authentication is required. * Example: `http://user:password@host:port/db_name` */ remoteUrl?: string; } // ─── PouchDatabaseWrapper ─────────────────────────────────────── /** * Object-Oriented wrapper around a single PouchDB instance. * Provides strictly-typed CRUD + query helpers so developers never interact * with the raw PouchDB API or pass `dbName` repeatedly. * * @example * ```typescript * const itemDB = dbManager.register({ localName: 'items' }); * await itemDB.create({ _id: 'item-001', name: 'Widget', price: 9.99 }); * const results = await itemDB.find({ selector: { price: { $gt: 5 } } }); * ``` */ export class PouchDatabaseWrapper { /** The underlying raw PouchDB instance (escape hatch for advanced usage). */ readonly raw: PouchDB.Database; private syncHandler: PouchDB.Replication.Sync | null = null; private listeners = new Set<() => void>(); private changesFeed: PouchDB.Core.Changes | null = null; constructor(config: PouchConfig) { this.raw = new PouchDB(config.localName); // Set up bi-directional live sync if a remote URL is provided if (config.remoteUrl) { this.syncHandler = this.raw.sync(config.remoteUrl, { live: true, retry: true, }); // Fault-tolerant error handling — prevents app crashes when CouchDB is unreachable this.syncHandler.on('error', (err: unknown) => { console.warn(`[PouchDB Sync] Error on "${config.localName}":`, err); }); this.syncHandler.on('paused', (info: unknown) => { if (info) { console.warn(`[PouchDB Sync] Paused on "${config.localName}":`, info); } }); this.syncHandler.on('denied', (err: unknown) => { console.warn(`[PouchDB Sync] Denied on "${config.localName}":`, err); }); } } // ─── CRUD Operations ──────────────────────────────────────── /** * Create a new document. If `_id` is not provided in data, PouchDB generates one. */ async create(data: T): Promise { return this.raw.put(data as PouchDB.Core.Document); } /** * Update an existing document by ID. * Automatically fetches the latest `_rev` to prevent conflict errors. */ async update(id: string, data: Partial): Promise { const existing = await this.raw.get(id); const merged = { ...existing, ...data }; return this.raw.put(merged); } /** * Delete a document by ID. * Automatically fetches the latest `_rev` before removal. */ async delete(id: string): Promise { const doc = await this.raw.get(id); return this.raw.remove(doc); } /** * Retrieve a single document by ID. */ async getOne(id: string): Promise { return this.raw.get(id) as Promise; } /** * Retrieve all documents from the database. * Returns a clean array of document objects (excludes PouchDB design docs). */ async getAll(): Promise { const result = await this.raw.allDocs({ include_docs: true }); return result.rows .filter((row) => !row.id.startsWith('_design/')) .map((row) => row.doc as unknown as T); } /** * Retrieve multiple documents by their IDs. * Returns a clean array of found documents (silently skips missing/errored entries). */ async getSome(ids: string[]): Promise { const result = await this.raw.allDocs({ keys: ids, include_docs: true }); return result.rows .filter((row): row is PouchDB.Core.AllDocsResponse['rows'][number] => !('error' in row) && !!(row as any).doc) .map((row) => (row as any).doc as T); } /** * Query documents using MongoDB-style selectors (powered by `pouchdb-find`). * * @example * ```typescript * const electronics = await itemDB.find({ selector: { category: 'electronics' } }); * const expensive = await itemDB.find({ selector: { price: { $gt: 100 } }, limit: 10 }); * ``` */ async find(options: PouchDB.Find.FindRequest): Promise { const result = await this.raw.find(options as PouchDB.Find.FindRequest); return result.docs as unknown as T[]; } /** * Remove all documents from the database while keeping the database itself intact. * Useful for "clear cache" or "reset local data" flows. */ async cleanAllData(): Promise { const result = await this.raw.allDocs(); const deletions = result.rows .filter((row) => !row.id.startsWith('_design/')) .map((row) => ({ _id: row.id, _rev: row.value.rev, _deleted: true as const, })); if (deletions.length > 0) { await this.raw.bulkDocs(deletions); } } /** * Cancel any active sync and completely destroy the local database. * After calling this, the wrapper instance should not be used again. */ async destroy(): Promise { if (this.syncHandler) { this.syncHandler.cancel(); } if (this.changesFeed) { this.changesFeed.cancel(); } this.listeners.clear(); await this.raw.destroy(); } /** * Cancel the live sync connection (if active) without destroying the database. */ cancelSync(): void { if (this.syncHandler) { this.syncHandler.cancel(); this.syncHandler = null; } } /** * Subscribe to real-time changes in the database. * Returns an unsubscribe function. */ onChange(callback: () => void): () => void { this.listeners.add(callback); if (!this.changesFeed) { this.changesFeed = this.raw.changes({ since: 'now', live: true, include_docs: true }).on('change', () => { this.listeners.forEach(cb => cb()); }).on('error', (err) => { console.warn(`[PouchDB Listener Error]`, err); }); } return () => { this.listeners.delete(callback); }; } } // ─── PouchDatabaseManager ─────────────────────────────────────── /** * IoC Factory for managing multiple PouchDB database instances with optional * bi-directional CouchDB synchronization. * * `register()` returns a `PouchDatabaseWrapper` with full CRUD + query helpers, * so developers never need to pass `dbName` into each operation. * * @example * ```typescript * const dbManager = new PouchDatabaseManager(); * * const itemDB = dbManager.register({ localName: 'items' }); * await itemDB.create({ _id: 'item-001', name: 'Widget', price: 9.99 }); * * const results = await itemDB.find({ selector: { price: { $gt: 5 } } }); * console.log(results); // [{ _id: 'item-001', name: 'Widget', price: 9.99, ... }] * * const salesDB = dbManager.register({ * localName: 'sales', * remoteUrl: 'http://admin:pass@localhost:5984/sales_db', * }); * ``` */ export class PouchDatabaseManager { private databases = new Map>(); /** * Register and initialize a new PouchDB database. * If a database with the same `localName` already exists, returns the existing wrapper. * * @param config - Configuration for the database instance. * @returns A `PouchDatabaseWrapper` with CRUD + query helpers. */ register(config: PouchConfig): PouchDatabaseWrapper { if (this.databases.has(config.localName)) { return this.databases.get(config.localName) as PouchDatabaseWrapper; } const wrapper = new PouchDatabaseWrapper(config); this.databases.set(config.localName, wrapper); return wrapper; } /** * Retrieve a previously registered database wrapper by its local name. */ get(localName: string): PouchDatabaseWrapper | undefined { return this.databases.get(localName) as PouchDatabaseWrapper | undefined; } /** * Destroy a specific registered database and remove it from the manager. */ async destroy(localName: string): Promise { const wrapper = this.databases.get(localName); if (!wrapper) return; await wrapper.destroy(); this.databases.delete(localName); } /** * Destroy all managed databases. Useful for logout/cleanup scenarios. */ async destroyAll(): Promise { const names = Array.from(this.databases.keys()); await Promise.all(names.map((name) => this.destroy(name))); } /** * List all currently registered database names. */ listDatabases(): string[] { return Array.from(this.databases.keys()); } }