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:
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user