290 lines
9.5 KiB
TypeScript
290 lines
9.5 KiB
TypeScript
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<DefaultType extends object = any> {
|
|
/** The underlying raw PouchDB instance (escape hatch for advanced usage). */
|
|
readonly raw: PouchDB.Database;
|
|
|
|
private syncHandler: PouchDB.Replication.Sync<object> | null = null;
|
|
private listeners = new Set<() => void>();
|
|
private changesFeed: PouchDB.Core.Changes<object> | 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<T extends object = DefaultType>(data: T): Promise<PouchDB.Core.Response> {
|
|
return this.raw.put(data as PouchDB.Core.Document<T>);
|
|
}
|
|
|
|
/**
|
|
* Update an existing document by ID.
|
|
* Automatically fetches the latest `_rev` to prevent conflict errors.
|
|
*/
|
|
async update<T extends object = DefaultType>(id: string, data: Partial<T>): Promise<PouchDB.Core.Response> {
|
|
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<PouchDB.Core.Response> {
|
|
const doc = await this.raw.get(id);
|
|
return this.raw.remove(doc);
|
|
}
|
|
|
|
/**
|
|
* Retrieve a single document by ID.
|
|
*/
|
|
async getOne<T = DefaultType>(id: string): Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta> {
|
|
return this.raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
|
|
}
|
|
|
|
/**
|
|
* Retrieve all documents from the database.
|
|
* Returns a clean array of document objects (excludes PouchDB design docs).
|
|
*/
|
|
async getAll<T = DefaultType>(): Promise<T[]> {
|
|
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<T = DefaultType>(ids: string[]): Promise<T[]> {
|
|
const result = await this.raw.allDocs({ keys: ids, include_docs: true });
|
|
return result.rows
|
|
.filter((row): row is PouchDB.Core.AllDocsResponse<object>['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<T extends object = DefaultType>(options: PouchDB.Find.FindRequest<T>): Promise<T[]> {
|
|
const result = await this.raw.find(options as PouchDB.Find.FindRequest<object>);
|
|
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<void> {
|
|
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<void> {
|
|
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<string, PouchDatabaseWrapper<any>>();
|
|
|
|
/**
|
|
* 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<T extends object = any>(config: PouchConfig): PouchDatabaseWrapper<T> {
|
|
if (this.databases.has(config.localName)) {
|
|
return this.databases.get(config.localName) as PouchDatabaseWrapper<T>;
|
|
}
|
|
|
|
const wrapper = new PouchDatabaseWrapper<T>(config);
|
|
this.databases.set(config.localName, wrapper);
|
|
return wrapper;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a previously registered database wrapper by its local name.
|
|
*/
|
|
get<T extends object = any>(localName: string): PouchDatabaseWrapper<T> | undefined {
|
|
return this.databases.get(localName) as PouchDatabaseWrapper<T> | undefined;
|
|
}
|
|
|
|
/**
|
|
* Destroy a specific registered database and remove it from the manager.
|
|
*/
|
|
async destroy(localName: string): Promise<void> {
|
|
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<void> {
|
|
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());
|
|
}
|
|
}
|