feat: implement PouchDB storage layer with CRUD operations and add showcase UI component

This commit is contained in:
Firman Ramdhani
2026-05-29 15:35:14 +07:00
parent 86c02e7111
commit 6712558eaf
15 changed files with 1820 additions and 309 deletions
+2
View File
@@ -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';
+289
View File
@@ -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());
}
}
+1 -1
View File
@@ -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();
});