feat: implement PouchDB storage layer with CRUD operations and add showcase UI component
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
export type { IStorageService } from './storage.interface';
|
||||
export type { StorageOptions } from './local-storage.service';
|
||||
export type { IndexedDBConfig } from './indexed-db.service';
|
||||
export type { PouchConfig } from './pouch';
|
||||
|
||||
// ─── Service Classes ────────────────────────────────────────────
|
||||
export { LocalStorageService, createLocalStorage } from './local-storage.service';
|
||||
export { IndexedDBService, createIndexedDB } from './indexed-db.service';
|
||||
export { PouchDatabaseManager, PouchDatabaseWrapper } from './pouch';
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ describe('LocalStorageService', () => {
|
||||
});
|
||||
|
||||
it('returns null for non-existent keys', async () => {
|
||||
const result = await storage.getItem<string>('nonexistent' as TestStorageKeyValue);
|
||||
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user