feat(core-storage): add PouchEnvelope tests and LocalStorageService implementation

- Introduced tests for PouchEnvelope to validate envelope-aware CRUD operations using an in-memory PouchDB.
- Implemented LocalStorageService with encryption support for sensitive keys, including tests for setItem, getItem, removeItem, and clear methods.
- Defined a generic storage interface (IStorageService) to enforce type-safe serialization/deserialization across storage implementations.
This commit is contained in:
Firman Ramdhani
2026-07-06 11:38:37 +07:00
parent 7130eb3fe3
commit d1ce292e3c
17 changed files with 1568 additions and 215 deletions
@@ -0,0 +1,11 @@
// ─── PouchDB Database Manager ────────────────────────────────────────────────
export { PouchDBManager } from './managers/pouch.manager';
export { PouchEnvelopeDBManager } from './managers/pouch-envelope.manager';
// ─── PouchDB Service ─────────────────────────────────────────────────────
export { PouchService } from './services/pouch.service';
export { PouchEnvelopeService } from './services/pouch-envelope.service';
// ─── PouchDB Types ───────────────────────────────────────────────────────
export type { PouchConfig, SyncStatus } from './services/pouch.service';
export type { CouchEnvelope, FlatDoc } from './services/pouch-envelope.service';
@@ -0,0 +1,38 @@
import { PouchDBManager } from './pouch.manager';
import { PouchConfig } from '../services/pouch.service';
import { PouchEnvelopeService } from '../services/pouch-envelope.service';
/**
* IoC Factory for managing multiple Envelope-based PouchDB database instances.
* Inherits from `PouchDBManager`.
*/
export class PouchEnvelopeDBManager extends PouchDBManager {
/**
* Register and initialize a new PouchDB database using the Envelope pattern.
* If a database with the same `localName` already exists, returns the existing wrapper.
*
* @param config - Configuration for the database instance.
* @param entity - The domain entity name used as a prefix (e.g., 'item', 'booking').
* @returns A `PouchEnvelopeService` with envelope-aware CRUD + query helpers.
*/
override register<T extends object = any>(config: PouchConfig, entity?: string): PouchEnvelopeService<T> {
if (!entity) {
throw new Error(`[PouchEnvelopeManager] 'entity' parameter is required for database: ${config.localName}`);
}
if (this.databases.has(config.localName)) {
return this.databases.get(config.localName) as PouchEnvelopeService<T>;
}
const wrapper = new PouchEnvelopeService<T>(config, entity);
this.databases.set(config.localName, wrapper);
return wrapper;
}
/**
* Retrieve a previously registered envelope database wrapper by its local name.
*/
override get<T extends object = any>(localName: string): PouchEnvelopeService<T> | undefined {
return super.get(localName) as PouchEnvelopeService<T> | undefined;
}
}
@@ -0,0 +1,77 @@
import { PouchConfig, PouchService } from '../services/pouch.service';
/**
* IoC Factory for managing multiple PouchDB database instances with optional
* bi-directional CouchDB synchronization.
*
* `register()` returns a `PouchService` with full CRUD + query helpers,
* so developers never need to pass `dbName` into each operation.
*
* @example
* ```typescript
* const dbManager = new PouchDBManager();
*
* 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 PouchDBManager {
protected databases = new Map<string, PouchService<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 `PouchService` with CRUD + query helpers.
*/
register<T extends object = any>(config: PouchConfig): PouchService<T> {
if (this.databases.has(config.localName)) {
return this.databases.get(config.localName) as PouchService<T>;
}
const wrapper = new PouchService<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): PouchService<T> | undefined {
return this.databases.get(localName) as PouchService<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());
}
}
@@ -0,0 +1,329 @@
import { PouchService, PouchConfig } from './pouch.service';
// ─── Types ────────────────────────────────────────────────────────
/**
* Shape of a document in CouchDB/PouchDB after the backend's CouchService.buildDoc().
* Every document across all databases uses this envelope format.
*/
export interface CouchEnvelope<T = Record<string, any>> {
_id: string; // format: "{entity}:{originalId}" e.g. "item:abc-123"
_rev?: string;
entity: string; // e.g. "item", "booking", "pos_activity"
data: T; // actual business payload
}
/**
* Shape received by the frontend after the envelope is stripped.
* `_id` and `_rev` are injected into the data so the consumer can
* directly use `doc._id` for delete/update without knowing the prefix.
*/
export type FlatDoc<T> = T & { _id: string; _rev: string };
// ─── PouchEnvelopeService ──────────────────────────────────────────────
/**
* Domain-specific subclass that transparently wraps/unwraps documents
* in the `CouchEnvelope` format used by the backend.
*
* Overrides all base CRUD methods — the consumer API is identical to
* `PouchService`, but documents are internally stored as envelopes:
* `{ _id: "entity:id", entity: "...", data: { ... } }`
*
* The `entity` is set once at construction and applied to all operations.
*
* @example
* ```typescript
* const itemDB = new PouchEnvelopeService<Item>({ localName: 'items' }, 'item');
* await itemDB.create({ _id: 'abc', name: 'Widget', price: 9.99 });
* // Stored as: { _id: 'item:abc', entity: 'item', data: { _id: 'abc', name: 'Widget', price: 9.99 } }
*
* const item = await itemDB.getOne('abc');
* // Returns: { _id: 'item:abc', _rev: '1-...', name: 'Widget', price: 9.99 }
* ```
*/
export class PouchEnvelopeService<DefaultEntity extends object = any> extends PouchService<DefaultEntity> {
protected entity: string;
constructor(config: PouchConfig, entity: string) {
super(config);
this.entity = entity;
}
// ─── Private Helpers ────────────────────────────────────────
/**
* Build a case-insensitive regex pattern for keyword search
* without relying on `$options` (which PouchDB doesn't support).
*/
private toContainsRegex(keyword: string): string {
return keyword
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape special regex chars
.replace(/[a-zA-Z]/g, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`); // case-insensitive
}
private buildEnvelope(id: string, payload: Record<string, any>): Omit<CouchEnvelope, '_rev'> {
return {
_id: `${this.entity}:${id}`,
entity: this.entity,
data: payload,
};
}
private flatten<T>(doc: CouchEnvelope<T>): FlatDoc<T> {
return {
...doc.data,
_id: doc._id,
_rev: doc._rev!,
} as FlatDoc<T>;
}
/**
* Extract the business ID from data.
* Supports `_id` (PouchDB convention) and `id` (common DTO pattern).
*/
private extractId(data: Record<string, any>): string {
const id = data._id ?? data.id;
if (!id) {
throw new Error(`[PouchEnvelopeService] Cannot create document without an "_id" or "id" field.`);
}
return String(id);
}
// ─── Overridden CRUD Operations ─────────────────────────────
/**
* Create a new document wrapped in an envelope.
* The document must have an `_id` (or `id`) field — used as the business key.
* Internally stored as `{ _id: "entity:businessId", entity, data }`.
*/
override async create<T extends object = DefaultEntity>(data: T): Promise<PouchDB.Core.Response> {
const record = data as Record<string, any>;
const id = this.extractId(record);
const doc = this.buildEnvelope(id, record);
return this.raw.put(doc);
}
/**
* Create multiple documents in batches, each wrapped in an envelope.
* Every item must have an `_id` (or `id`) field.
*/
override async createBulk<T extends object = DefaultEntity>(
dataList: T[],
batchSize = 100,
): Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]> {
const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = [];
for (let i = 0; i < dataList.length; i += batchSize) {
const batch = dataList.slice(i, i + batchSize).map((d) => {
const record = d as Record<string, any>;
const id = this.extractId(record);
return this.buildEnvelope(id, record);
});
const response = await this.raw.bulkDocs(batch);
results.push(...response);
}
return results;
}
/**
* Update an existing envelope document by business ID.
* Automatically fetches the latest `_rev` and rebuilds the envelope with merged data.
*/
override async update<T extends object = DefaultEntity>(
id: string,
data: Partial<T>,
): Promise<PouchDB.Core.Response> {
const docId = `${this.entity}:${id}`;
const existing = (await this.raw.get(docId)) as CouchEnvelope;
const mergedPayload = { ...existing.data, ...data };
const doc = this.buildEnvelope(id, mergedPayload);
return this.raw.put({ ...doc, _rev: existing._rev } as any);
}
/**
* Create or update a document by business ID.
* If the document exists, merges new data into the envelope payload.
* If not, creates a new envelope document.
*/
override async upsert<T extends object = DefaultEntity>(id: string, data: T): Promise<PouchDB.Core.Response> {
const docId = `${this.entity}:${id}`;
const existing = (await this.raw.get(docId).catch(() => null)) as CouchEnvelope | null;
const payload = existing ? { ...existing.data, ...(data as Record<string, any>) } : (data as Record<string, any>);
const doc = this.buildEnvelope(id, payload);
return this.raw.put(existing ? { ...doc, _rev: (existing as any)._rev } : (doc as any));
}
/**
* Delete a document by business ID.
* Internally resolves to `entity:id` before removal.
*/
override async delete(id: string): Promise<PouchDB.Core.Response> {
const docId = `${this.entity}:${id}`;
const existing = await this.raw.get(docId);
return this.raw.remove(existing);
}
/**
* Retrieve a single document by business ID.
* Returns the flattened document (envelope stripped, `_id` and `_rev` injected).
*/
override async getOne<T = DefaultEntity>(id: string): Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta> {
const docId = `${this.entity}:${id}`;
const doc = (await this.raw.get(docId)) as CouchEnvelope<T>;
return this.flatten(doc) as T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta;
}
/**
* Retrieve all documents belonging to this entity.
* Returns flattened documents (envelope stripped).
*/
override async getAll<T = DefaultEntity>(): Promise<T[]> {
const result = await this.raw.find({
selector: { entity: this.entity },
limit: 10000,
});
return (result.docs as CouchEnvelope<T>[]).map((doc) => this.flatten(doc)) as T[];
}
/**
* Retrieve multiple documents by their business IDs.
* Internally prepends `entity:` to each ID.
* Returns flattened documents (silently skips missing/errored entries).
*/
override async getSome<T = DefaultEntity>(ids: string[]): Promise<T[]> {
const prefixedIds = ids.map((id) => `${this.entity}:${id}`);
const result = await this.raw.allDocs({
keys: prefixedIds,
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) => this.flatten((row as any).doc as CouchEnvelope<T>)) as T[];
}
/**
* Query documents using MongoDB-style selectors, auto-scoped to this entity.
* The `entity` constraint is automatically added to the selector.
* Results are flattened (envelope stripped).
*
* @example
* ```typescript
* const settled = await db.find({
* selector: { 'data.status': 'settled' },
* limit: 100,
* });
* ```
*/
override async find<T extends object = DefaultEntity>(options: PouchDB.Find.FindRequest<T>): Promise<T[]> {
const result = await this.raw.find({
...options,
selector: {
entity: this.entity,
...(options.selector as Record<string, any>),
},
} as PouchDB.Find.FindRequest<object>);
return (result.docs as CouchEnvelope<T>[]).map((doc) => this.flatten(doc)) as T[];
}
/**
* Remove all documents belonging to this entity.
* Other entities in the same database are left untouched.
*/
override async cleanAllData(): Promise<void> {
const result = await this.raw.find({
selector: { entity: this.entity },
fields: ['_id', '_rev'],
limit: 100000,
});
const deletions = result.docs.map((doc: any) => ({
_id: doc._id,
_rev: doc._rev,
_deleted: true as const,
}));
if (deletions.length > 0) {
await this.raw.bulkDocs(deletions);
}
}
// ─── New Methods (no base equivalent) ───────────────────────
/**
* Search by keyword across multiple `data.*` fields.
* Runs one indexed query per field, then merges and deduplicates results in memory.
* Uses case-insensitive regex matching.
*
* @param keyword - The search term.
* @param fields - Data fields to search (default: `['data.name']`).
* @param limit - Maximum results per field query (default: 10000).
*
* @example
* ```typescript
* const results = await itemDB.search('widget', ['data.name', 'data.sku']);
* ```
*/
async search<T = DefaultEntity>(
keyword: string,
fields: string[] = ['data.name'],
limit = 10000,
): Promise<FlatDoc<T>[]> {
if (!keyword.trim()) {
return this.getAll() as Promise<FlatDoc<T>[]>;
}
const pattern = this.toContainsRegex(keyword);
const queries = fields.map((field) =>
this.raw.find({
selector: { entity: this.entity, [field]: { $regex: pattern } },
limit,
}),
);
const results = await Promise.all(queries);
const seen = new Set<string>();
const merged = results
.flatMap((r) => r.docs)
.filter((doc: any) => {
if (seen.has(doc._id)) return false;
seen.add(doc._id);
return true;
});
return (merged as CouchEnvelope<T>[]).map((doc) => this.flatten(doc));
}
/**
* Create a Mango index to speed up `find()` / `search()` queries.
* Safe to call multiple times — PouchDB no-ops if an identical index already exists.
*
* @example
* ```typescript
* await db.createIndex(['entity', 'data.name']);
* await db.createIndex(['entity', '_id'], 'idx-entity-id');
* ```
*/
async createIndex(fields: string[], name?: string): Promise<PouchDB.Find.CreateIndexResponse<object>> {
return this.raw.createIndex({
index: { fields, ...(name ? { name } : {}) },
});
}
/**
* List currently defined indexes. Useful for debugging with `explain()`.
*/
async listIndexes(): Promise<PouchDB.Find.GetIndexesResponse<object>> {
return this.raw.getIndexes();
}
/**
* Explain how a given selector will be executed — shows which index (if any) is used.
* Returns `index.name === '_all_docs'` when the query falls back to a full scan.
*/
async explain<T extends object = DefaultEntity>(options: PouchDB.Find.FindRequest<T>): Promise<any> {
return (this.raw as any).explain(options as PouchDB.Find.FindRequest<object>);
}
}
@@ -0,0 +1,297 @@
import PouchDB from 'pouchdb-browser';
import PouchDBFind from 'pouchdb-find';
// Register the find plugin globally
PouchDB.plugin(PouchDBFind);
// ─── Types ──────────────────────────────────────────────────────
/**
* 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;
}
export type SyncStatus = 'connected' | 'disconnected';
/**
* 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.
*
* All infrastructure concerns live here: CRUD, bulk operations,
* sync management, change listeners, and lifecycle.
*
* @example
* ```typescript
* const db = new PouchService<Item>({ localName: 'items' });
* await db.create({ _id: 'item-001', name: 'Widget', price: 9.99 });
* const results = await db.find({ selector: { price: { $gt: 5 } } });
* ```
*/
export class PouchService<DefaultType extends object = any> {
/** The underlying raw PouchDB instance (escape hatch for advanced usage). */
readonly raw: PouchDB.Database;
protected liveSyncHandler: PouchDB.Replication.Sync<object> | null = null;
protected listeners = new Set<() => void>();
protected changesFeed: PouchDB.Core.Changes<object> | null = null;
protected remoteUrl?: string;
protected statusListeners = new Set<(status: SyncStatus) => void>();
protected _status: SyncStatus = 'disconnected';
constructor(config: PouchConfig) {
this.raw = new PouchDB(config.localName);
if (config.remoteUrl) {
this.remoteUrl = config.remoteUrl;
this.startLiveSync();
}
}
// ─── Sync Management ────────────────────────────────────────
private startLiveSync(): void {
if (!this.remoteUrl || this.liveSyncHandler) return;
this.liveSyncHandler = this.raw.sync(this.remoteUrl, {
live: true,
retry: true,
});
this.setStatus('connected');
this.liveSyncHandler
.on('paused', () => this.setStatus('connected'))
.on('active', () => this.setStatus('connected'))
.on('denied', () => this.setStatus('disconnected'))
.on('error', () => this.setStatus('disconnected'))
.on('complete', () => this.setStatus('disconnected'));
}
protected setStatus(status: SyncStatus): void {
if (this._status === status) return;
this._status = status;
this.statusListeners.forEach((cb) => cb(status));
}
/** Current sync connection status. */
get status(): SyncStatus {
return this._status;
}
/** Turn sync ON for this DB. No-op if already connected or no remoteUrl configured. */
connect(): void {
this.startLiveSync();
}
/** Turn sync OFF for this DB without destroying local data. */
disconnect(): void {
if (this.liveSyncHandler) {
this.liveSyncHandler.cancel();
this.liveSyncHandler = null;
}
this.setStatus('disconnected');
}
/** Toggle sync on/off, returns the resulting status. */
toggleConnection(): SyncStatus {
if (this._status === 'connected') {
this.disconnect();
} else {
this.connect();
}
return this._status;
}
/**
* Cancel the live sync connection (if active) without destroying the database.
* Alias for `disconnect()`.
*/
cancelSync(): void {
this.disconnect();
}
/** Subscribe to connection status changes. Returns unsubscribe function. */
onStatusChange(callback: (status: SyncStatus) => void): () => void {
this.statusListeners.add(callback);
return () => this.statusListeners.delete(callback);
}
// ─── 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>);
}
/**
* Create multiple documents in batches.
* @param dataList - Array of documents to create.
* @param batchSize - Number of documents per batch (default: 100).
*/
async createBulk<T extends object = DefaultType>(
dataList: T[],
batchSize = 100,
): Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]> {
const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = [];
for (let i = 0; i < dataList.length; i += batchSize) {
const batch = dataList.slice(i, i + batchSize);
const response = await this.raw.bulkDocs(batch as PouchDB.Core.Document<T>[]);
results.push(...response);
}
return results;
}
/**
* 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);
}
/**
* Create or update a document by ID.
* If the document exists, merges new data. If not, creates it.
*/
async upsert<T extends object = DefaultType>(id: string, data: T): Promise<PouchDB.Core.Response> {
const existing = await this.raw.get(id).catch(() => null);
if (existing) {
return this.raw.put({ ...existing, ...data });
}
return this.raw.put({ ...data, _id: id } as PouchDB.Core.Document<T>);
}
/**
* 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 db.find({ selector: { category: 'electronics' } });
* const expensive = await db.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);
}
}
// ─── Lifecycle ──────────────────────────────────────────────
/**
* 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.liveSyncHandler) {
this.liveSyncHandler.cancel();
this.liveSyncHandler = null;
}
if (this.changesFeed) {
this.changesFeed.cancel();
this.changesFeed = null;
}
this.listeners.clear();
this.statusListeners.clear();
await this.raw.destroy();
}
/**
* 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);
};
}
}