Merge pull request 'feat(core-storage): add PouchEnvelope tests and LocalStorageService implementation' (#21) from core/new-feature-pouch into main

Reviewed-on: eigen/fe-monorepo-template#21
This commit is contained in:
2026-07-06 04:54:01 +00:00
17 changed files with 1568 additions and 215 deletions
@@ -116,7 +116,7 @@ const theme = await appStorage.getItem('THEME'); // Plaintext on disk
## 🔄 Offline-First Document Storage (PouchDB & CouchDB)
For complex, document-oriented data that requires fault-tolerance, offline support, and bi-directional cloud synchronization, we use the `PouchDatabaseManager`. This layer is powered by [PouchDB](https://pouchdb.com/) syncing to [CouchDB](https://couchdb.apache.org/).
For complex, document-oriented data that requires fault-tolerance, offline support, and bi-directional cloud synchronization, we use the `PouchDBManager`. This layer is powered by [PouchDB](https://pouchdb.com/) syncing to [CouchDB](https://couchdb.apache.org/).
### Architecture
@@ -134,7 +134,7 @@ graph TD
end
subgraph CoreStorage ["@repo/core-storage Engine"]
MGR[PouchDatabaseManager Factory]
MGR[PouchDBManager Factory]
L_SALES[(Local PouchDB: Sales)]
L_INV[(Local PouchDB: Inventory)]
end
@@ -168,13 +168,13 @@ graph TD
### 1. Initialization (IoC Factory)
The `PouchDatabaseManager` acts as a central singleton. It registers and manages all database instances. If a remote URL is provided, it automatically handles background synchronization.
The `PouchDBManager` acts as a central singleton. It registers and manages all database instances. If a remote URL is provided, it automatically handles background synchronization.
```typescript
import { PouchDatabaseManager } from '@repo/core-storage';
import { PouchDBManager } from '@repo/core-storage';
import type { Item } from './types';
export const dbManager = new PouchDatabaseManager();
export const dbManager = new PouchDBManager();
export const itemDB = dbManager.register<Item>({
localName: 'items_db',
@@ -184,7 +184,7 @@ export const itemDB = dbManager.register<Item>({
### 2. CRUD & MongoDB-style Queries
The registered database returns a `PouchDatabaseWrapper`. This wrapper fully abstracts the raw PouchDB API into clean, Promise-based helpers, auto-handling `_rev` conflicts.
The registered database returns a `PouchService` instance. This wrapper fully abstracts the raw PouchDB API into clean, Promise-based helpers, auto-handling `_rev` conflicts.
| Method | Description |
|---|---|
@@ -231,6 +231,38 @@ export function InventoryList() {
}
```
### 4. Envelope Pattern (`PouchEnvelopeDBManager`)
If you want to store multiple types of entities (e.g. `items`, `bookings`, `activities`) in a single CouchDB/PouchDB database to simplify sync setup, use the **Envelope Pattern**.
Instead of `PouchDBManager`, instantiate a `PouchEnvelopeDBManager`. It provides the exact same `PouchService` API (CRUD + Find), but automatically wraps documents into an envelope format internally: `{ _id: "entityName:businessId", entity: "entityName", data: { ... } }`.
```typescript
import { PouchEnvelopeDBManager } from '@repo/core-storage';
import type { ItemEntity, BookingEntity } from './types';
export const envelopeDbManager = new PouchEnvelopeDBManager();
// Registers to the SAME database 'master_db', but scoped to 'item'
export const itemDB = envelopeDbManager.register<ItemEntity>({
localName: 'master_db',
remoteUrl: 'http://admin:pass@localhost:5984/master_db'
}, 'item');
// Registers to the SAME database 'master_db', but scoped to 'booking'
export const bookingDB = envelopeDbManager.register<BookingEntity>({
localName: 'master_db',
remoteUrl: 'http://admin:pass@localhost:5984/master_db'
}, 'booking');
// API usage remains identical!
await itemDB.create({ _id: '123', name: 'Widget' }); // Stored as "item:123"
const items = await itemDB.getAll(); // Only returns documents where entity === 'item'
// Unique to PouchEnvelopeService: Cross-field keyword searching
const results = await itemDB.search('widget keyword', ['data.name', 'data.sku']);
```
### ✅ Do's and ❌ Don'ts for PouchDB
* **✅ DO use `.onChange()`** to make your UI reactive to background cloud syncs.
+98 -3
View File
@@ -1,12 +1,13 @@
import { useEffect, useState, useCallback } from 'react';
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
import { itemDB, posConfigDB } from '../../core/storage/pouch-db';
import { itemDB, posConfigDB, newItemDB } from '../../core/storage/pouch-db';
import type { ItemEntity, POSConfigurationEntity } from '../../core/storage/pouch-db/entities';
export default function PouchSample() {
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
const [items, setItems] = useState<ItemEntity[]>([]);
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
// Load initial data
const loadData = useCallback(async () => {
@@ -16,7 +17,13 @@ export default function PouchSample() {
const allItems = await itemDB.find({ selector: {} });
setItems(allItems);
console.log({ allConfigs, allItems });
// We can use getAll() or find() on Envelope DB. It's automatically scoped.
const allNewItems = await newItemDB.getAll();
console.log('allNewItems', allNewItems);
setNewItems(allNewItems as ItemEntity[]);
console.log({ allConfigs, allItems, allNewItems });
} catch (err) {
console.error('Failed to load PouchDB data', err);
}
@@ -35,10 +42,15 @@ export default function PouchSample() {
loadData();
});
const unsubscribeNewItems = newItemDB.onChange(() => {
loadData();
});
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
return () => {
unsubscribeItems();
unsubscribePos();
unsubscribeNewItems();
};
}, [loadData]);
@@ -102,10 +114,40 @@ export default function PouchSample() {
}
};
// ─── New Items Envelope Handlers ────────────────────────────────
const handleAddNewItem = async () => {
try {
const id = `new-item-${Date.now()}`;
await newItemDB.create({
_id: id,
name: 'ENVELOPE ITEM TEST',
base_price: '100000',
item_type: 'souvenir',
usage_type: 'retail',
item_category: [{ name: 'Merchandise' }],
});
loadData();
} catch (err) {
console.error('Failed to add new item', err);
}
};
const handleDeleteNewItem = async (id: string) => {
try {
await newItemDB.delete(id);
loadData();
} catch (err) {
console.error('Failed to delete new item', err);
}
};
const handleClearAll = async () => {
try {
await posConfigDB.cleanAllData();
await itemDB.cleanAllData();
await newItemDB.cleanAllData();
loadData();
} catch (err) {
console.error('Failed to clear data', err);
@@ -124,7 +166,7 @@ export default function PouchSample() {
{/* Items Inventory Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
<Title order={4}>Items Database</Title>
<Title order={4}>Items Database (Standard)</Title>
<Button onClick={handleAddItem} color="success">
Inject Mock ERP Item
</Button>
@@ -174,6 +216,59 @@ export default function PouchSample() {
</div>
</Card>
{/* New Items Envelope Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
<Title order={4}>New Items Database (Envelope Pattern)</Title>
<Button onClick={handleAddNewItem} color="brand">
Inject Mock Envelope Item
</Button>
</Group>
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{newItems.length > 0 ? (
newItems.map((item) => (
<Table.Tr key={item._id}>
<Table.Td>{item._id}</Table.Td>
<Table.Td>{item.name}</Table.Td>
<Table.Td>
<Badge color="violet" variant="light">
{item.item_type}
</Badge>
</Table.Td>
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
<Table.Td>
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteNewItem(item._id)}>
Delete
</Button>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={6} align="center">
<Text c="dimmed">No items found.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
</Card>
{/* POS Configuration Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
@@ -1,2 +1,3 @@
export * from './item.pouchdb.entity';
export * from './pos-configuration.pouchdb.entity';
export * from './new-item.pouchdb.entity';
@@ -0,0 +1,51 @@
export interface SeasonType {
name: string;
}
export interface SeasonPeriod {
start_date: string;
end_date: string;
season_type: SeasonType;
}
export interface ItemRate {
id: string;
price: string;
season_period: SeasonPeriod;
}
export interface ItemCategory {
id: string;
name: string;
}
/**
* Detailed interface for an Item entity, based on the provided JSON sample.
* This represents the business data payload (`data` property in the envelope).
*/
export interface NewItemData {
id: string;
creator_id: string;
creator_name: string;
created_at: string;
updated_at: string;
status: 'active' | 'inactive' | string;
item_type: string;
hpp: string;
sales_margin: string;
share_profit: string;
total_price: number;
base_price: string;
play_estimation: number;
use_queue: boolean;
show_to_booking: boolean;
breakdown_bundling: boolean;
limit_type: string;
limit_value: number;
item_category_id: string;
item_category: ItemCategory;
item_rates: ItemRate[];
price: string;
qty: number;
name: string;
}
+14 -4
View File
@@ -3,16 +3,17 @@
*
* This module demonstrates the IoC pattern: the consuming app decides
* which databases to create and where they sync to. The core engine
* (`PouchDatabaseManager`) has zero knowledge of business domains.
* (`PouchDBManager` and `PouchEnvelopeManager`) has zero knowledge of business domains.
*/
import { ENV } from '../../environment';
import { PouchDatabaseManager } from '@repo/core-storage';
import { PouchDBManager, PouchEnvelopeDBManager } from '@repo/core-storage';
import { ItemEntity, POSConfigurationEntity } from './entities';
// ─── Manager Singleton ──────────────────────────────────────────
// ─── Manager Singletons ──────────────────────────────────────────
export const dbManager = new PouchDatabaseManager();
export const dbManager = new PouchDBManager();
export const envelopeDbManager = new PouchEnvelopeDBManager();
// ─── Helper: Build Secure Remote URL ────────────────────────────
@@ -54,3 +55,12 @@ export const itemDB = dbManager.register<ItemEntity>({
localName: 'item',
remoteUrl: buildRemoteUrl('item'),
});
/** New Items database using the Envelope Service pattern. */
export const newItemDB = envelopeDbManager.register(
{
localName: 'master_data',
remoteUrl: buildRemoteUrl('master_data'),
},
'item',
);
+7 -7
View File
@@ -1,10 +1,10 @@
// ─── Interfaces ─────────────────────────────────────────────────
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';
export type { IStorageService } from './types/storage.interface';
export type { StorageOptions } from './local-storage/local-storage.service';
export type { IndexedDBConfig } from './indexed-db/indexed-db.service';
export type { PouchConfig, CouchEnvelope, FlatDoc, SyncStatus } from './pouch-db';
// ─── Service Classes ────────────────────────────────────────────
export { LocalStorageService, createLocalStorage } from './local-storage.service';
export { IndexedDBService, createIndexedDB } from './indexed-db.service';
export { PouchDatabaseManager, PouchDatabaseWrapper } from './pouch';
export { LocalStorageService, createLocalStorage } from './local-storage/local-storage.service';
export { IndexedDBService, createIndexedDB } from './indexed-db/indexed-db.service';
export { PouchDBManager, PouchEnvelopeDBManager, PouchService, PouchEnvelopeService } from './pouch-db';
@@ -1,6 +1,6 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import type { StorageOptions } from './local-storage.service';
import type { IStorageService } from '../types/storage.interface';
import type { StorageOptions } from '../local-storage/local-storage.service';
// ─── Types ──────────────────────────────────────────────────────
@@ -19,11 +19,7 @@ export interface IndexedDBConfig<TKey extends string> extends StorageOptions<TKe
* Open (or create) an IndexedDB database with a simple key-value store.
* Returns a Promise that resolves with the IDBDatabase instance.
*/
function openDatabase(
dbName: string,
storeName: string,
version: number,
): Promise<IDBDatabase> {
function openDatabase(dbName: string, storeName: string, version: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
@@ -104,13 +100,9 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
this.validateKey(key);
const db = await this.getDB();
const serialized = JSON.stringify(value);
const payload = this.shouldEncrypt(key)
? this.encryption.encrypt(serialized)
: serialized;
const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized;
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.put(payload, key as string),
);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string));
}
async getItem<T>(key: TKey): Promise<T | null> {
@@ -143,16 +135,12 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
async removeItem(key: TKey): Promise<void> {
this.validateKey(key);
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.delete(key as string),
);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string));
}
async clear(): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.clear(),
);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear());
}
async hasItem(key: TKey): Promise<boolean> {
@@ -172,8 +160,6 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
}
}
export function createIndexedDB<TKey extends string>(
config: IndexedDBConfig<TKey>
): IStorageService<TKey> {
export function createIndexedDB<TKey extends string>(config: IndexedDBConfig<TKey>): IStorageService<TKey> {
return new IndexedDBService<TKey>(config);
}
@@ -1,5 +1,5 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import type { IStorageService } from '../types/storage.interface';
export interface StorageOptions<TKey extends string> {
encryptedKeys?: Set<TKey>;
@@ -84,8 +84,6 @@ export class LocalStorageService<TKey extends string> implements IStorageService
}
}
export function createLocalStorage<TKey extends string>(
options?: StorageOptions<TKey>
): IStorageService<TKey> {
export function createLocalStorage<TKey extends string>(options?: StorageOptions<TKey>): IStorageService<TKey> {
return new LocalStorageService<TKey>(options);
}
@@ -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>);
}
}
@@ -4,7 +4,7 @@ import PouchDBFind from 'pouchdb-find';
// Register the find plugin globally
PouchDB.plugin(PouchDBFind);
// ─── Configuration Interface ────────────────────────────────────
// ─── Types ──────────────────────────────────────────────────────
/**
* Configuration for creating a new PouchDB database instance.
@@ -21,53 +21,109 @@ export interface PouchConfig {
remoteUrl?: string;
}
// ─── PouchDatabaseWrapper ───────────────────────────────────────
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 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 } } });
* 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 PouchDatabaseWrapper<DefaultType extends object = any> {
export class PouchService<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;
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);
// Set up bi-directional live sync if a remote URL is provided
if (config.remoteUrl) {
this.syncHandler = this.raw.sync(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');
// 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.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'));
}
});
this.syncHandler.on('denied', (err: unknown) => {
console.warn(`[PouchDB Sync] Denied on "${config.localName}":`, err);
});
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 ────────────────────────────────────────
@@ -79,6 +135,26 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
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.
@@ -89,6 +165,18 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
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.
@@ -111,9 +199,7 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
*/
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);
return result.rows.filter((row) => !row.id.startsWith('_design/')).map((row) => row.doc as unknown as T);
}
/**
@@ -123,7 +209,9 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
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)
.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);
}
@@ -132,8 +220,8 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
*
* @example
* ```typescript
* const electronics = await itemDB.find({ selector: { category: 'electronics' } });
* const expensive = await itemDB.find({ selector: { price: { $gt: 100 } }, limit: 10 });
* 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[]> {
@@ -160,31 +248,26 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
}
}
// ─── 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.syncHandler) {
this.syncHandler.cancel();
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();
}
/**
* 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.
@@ -193,13 +276,16 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
this.listeners.add(callback);
if (!this.changesFeed) {
this.changesFeed = this.raw.changes({
this.changesFeed = this.raw
.changes({
since: 'now',
live: true,
include_docs: true
}).on('change', () => {
this.listeners.forEach(cb => cb());
}).on('error', (err) => {
include_docs: true,
})
.on('change', () => {
this.listeners.forEach((cb) => cb());
})
.on('error', (err) => {
console.warn(`[PouchDB Listener Error]`, err);
});
}
@@ -209,81 +295,3 @@ export class PouchDatabaseWrapper<DefaultType extends object = any> {
};
}
}
// ─── 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,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import PouchDB from 'pouchdb-core';
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
import PouchDBFind from 'pouchdb-find';
@@ -10,35 +10,64 @@ PouchDB.plugin(PouchDBFind);
PouchDB.plugin(PouchDBMapReduce);
/**
* Since the tests run in Node (not a browser), we cannot use PouchDatabaseManager
* Since the tests run in Node (not a browser), we cannot use PouchBase
* directly because it imports `pouchdb-browser` which requires `self`.
* Instead, we test the CRUD logic by creating a lightweight test wrapper
* that mirrors PouchDatabaseWrapper's methods using the memory-backed PouchDB.
*
* We create a TestPouchBase helper that mirrors PouchBase's methods
* using the memory-backed PouchDB, exercising the same logic paths.
*/
function createTestDB(name: string) {
const raw = new PouchDB(name, { adapter: 'memory' });
return {
raw,
// ─── CRUD ─────────────────────────────────────────────────
async create<T extends object>(data: T) {
return raw.put(data as PouchDB.Core.Document<T>);
},
async createBulk<T extends object>(
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 raw.bulkDocs(
batch as PouchDB.Core.Document<T>[],
);
results.push(...response);
}
return results;
},
async update<T extends object>(id: string, data: Partial<T>) {
const existing = await raw.get(id);
const merged = { ...existing, ...data };
return raw.put(merged);
},
async upsert<T extends object>(id: string, data: T) {
const existing = await raw.get(id).catch(() => null);
if (existing) {
return raw.put({ ...existing, ...data });
}
return raw.put({ ...data, _id: id } as PouchDB.Core.Document<T>);
},
async delete(id: string) {
const doc = await raw.get(id);
return raw.remove(doc);
},
// ─── Reads ────────────────────────────────────────────────
async getOne<T>(id: string) {
return raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
return raw.get<T>(id) as Promise<
T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta
>;
},
async getAll<T>() {
@@ -56,10 +85,14 @@ function createTestDB(name: string) {
},
async find<T extends object>(options: PouchDB.Find.FindRequest<T>) {
const result = await raw.find(options as PouchDB.Find.FindRequest<object>);
const result = await raw.find(
options as PouchDB.Find.FindRequest<object>,
);
return result.docs as unknown as T[];
},
// ─── Bulk ─────────────────────────────────────────────────
async cleanAllData() {
const result = await raw.allDocs();
const deletions = result.rows
@@ -74,18 +107,48 @@ function createTestDB(name: string) {
}
},
// ─── Lifecycle ────────────────────────────────────────────
listeners: new Set<() => void>(),
changesFeed: null as PouchDB.Core.Changes<object> | null,
onChange(callback: () => void): () => void {
this.listeners.add(callback);
if (!this.changesFeed) {
this.changesFeed = 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);
};
},
async destroy() {
if (this.changesFeed) {
this.changesFeed.cancel();
this.changesFeed = null;
}
this.listeners.clear();
await raw.destroy();
},
};
}
describe('PouchDatabaseWrapper CRUD Operations', () => {
// ─── Test Suite ───────────────────────────────────────────────────
describe('PouchBase — Core CRUD Operations', () => {
let db: ReturnType<typeof createTestDB>;
beforeEach(() => {
// Use a unique name per test to avoid cross-contamination
db = createTestDB(`test_db_${Date.now()}_${Math.random().toString(36).slice(2)}`);
db = createTestDB(
`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`,
);
});
afterEach(async () => {
@@ -107,7 +170,39 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
it('should throw a conflict if creating with a duplicate _id', async () => {
await db.create({ _id: 'dup-001', name: 'First' });
await expect(db.create({ _id: 'dup-001', name: 'Second' })).rejects.toThrow();
await expect(
db.create({ _id: 'dup-001', name: 'Second' }),
).rejects.toThrow();
});
});
// ─── createBulk ───────────────────────────────────────────────
describe('createBulk', () => {
it('should create multiple documents in a single batch', async () => {
const docs = [
{ _id: 'bulk-1', val: 1 },
{ _id: 'bulk-2', val: 2 },
{ _id: 'bulk-3', val: 3 },
];
const results = await db.createBulk(docs);
expect(results).toHaveLength(3);
results.forEach((r: any) => expect(r.ok).toBe(true));
const all = await db.getAll<{ val: number }>();
expect(all).toHaveLength(3);
});
it('should handle batching for large datasets', async () => {
const docs = Array.from({ length: 250 }, (_, i) => ({
_id: `batch-${i}`,
val: i,
}));
const results = await db.createBulk(docs, 100);
expect(results).toHaveLength(250);
const all = await db.getAll();
expect(all).toHaveLength(250);
});
});
@@ -117,7 +212,9 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
it('should retrieve a document by id', async () => {
await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 });
const doc = await db.getOne<{ product: string; price: number }>('fetch-001');
const doc = await db.getOne<{ product: string; price: number }>(
'fetch-001',
);
expect(doc._id).toBe('fetch-001');
expect(doc.product).toBe('Widget');
expect(doc.price).toBe(9.99);
@@ -137,7 +234,11 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
const res = await db.update('upd-001', { count: 42, extra: 'field' });
expect(res.ok).toBe(true);
const updated = await db.getOne<{ name: string; count: number; extra: string }>('upd-001');
const updated = await db.getOne<{
name: string;
count: number;
extra: string;
}>('upd-001');
expect(updated.name).toBe('Original'); // untouched
expect(updated.count).toBe(42); // updated
expect(updated.extra).toBe('field'); // newly added
@@ -148,6 +249,36 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
});
});
// ─── upsert ───────────────────────────────────────────────────
describe('upsert', () => {
it('should create a new document when it does not exist', async () => {
const res = await db.upsert('ups-001', {
name: 'NewItem',
price: 19.99,
});
expect(res.ok).toBe(true);
const doc = await db.getOne<{ name: string; price: number }>('ups-001');
expect(doc.name).toBe('NewItem');
expect(doc.price).toBe(19.99);
});
it('should update an existing document when it already exists', async () => {
await db.create({ _id: 'ups-002', name: 'Original', count: 1 });
const res = await db.upsert('ups-002', {
name: 'Updated',
count: 99,
} as any);
expect(res.ok).toBe(true);
const doc = await db.getOne<{ name: string; count: number }>('ups-002');
expect(doc.name).toBe('Updated');
expect(doc.count).toBe(99);
});
});
// ─── delete ───────────────────────────────────────────────────
describe('delete', () => {
@@ -254,4 +385,32 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
expect(all).toHaveLength(1);
});
});
// ─── onChange ──────────────────────────────────────────────────
describe('onChange', () => {
it('should fire listener when a document is created', async () => {
const listener = vi.fn();
const unsubscribe = db.onChange(listener);
await db.create({ _id: 'change-001', name: 'trigger' });
// Changes feed is async — give it a moment
await new Promise((resolve) => setTimeout(resolve, 200));
expect(listener).toHaveBeenCalled();
unsubscribe();
});
it('should stop firing after unsubscribe', async () => {
const listener = vi.fn();
const unsubscribe = db.onChange(listener);
unsubscribe();
await db.create({ _id: 'change-002', name: 'silent' });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(listener).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,556 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import PouchDB from 'pouchdb-core';
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
import PouchDBFind from 'pouchdb-find';
import PouchDBMapReduce from 'pouchdb-mapreduce';
// Build a minimal PouchDB for testing: core + memory adapter + find
PouchDB.plugin(PouchDBAdapterMemory);
PouchDB.plugin(PouchDBFind);
PouchDB.plugin(PouchDBMapReduce);
// ─── Types ────────────────────────────────────────────────────────
interface CouchEnvelope<T = Record<string, any>> {
_id: string;
_rev?: string;
entity: string;
data: T;
}
type FlatDoc<T> = T & { _id: string; _rev: string };
// ─── Test Helper ──────────────────────────────────────────────────
/**
* Creates a test wrapper that mirrors PouchEnvelope's overridden behavior
* using an in-memory PouchDB adapter for Node.js testing.
*/
function createTestEnvelopeDB(name: string, entity: string) {
const raw = new PouchDB(name, { adapter: 'memory' });
function buildEnvelope(id: string, payload: Record<string, any>): Omit<CouchEnvelope, '_rev'> {
return { _id: `${entity}:${id}`, entity, data: payload };
}
function flatten<T>(doc: CouchEnvelope<T>): FlatDoc<T> {
return { ...doc.data, _id: doc._id, _rev: doc._rev! } as FlatDoc<T>;
}
function extractId(data: Record<string, any>): string {
const id = data._id ?? data.id;
if (!id) throw new Error('Missing _id or id');
return String(id);
}
function toContainsRegex(keyword: string): string {
return keyword
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/[a-zA-Z]/g, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
}
return {
raw,
entity,
// ─── Overridden CRUD (envelope-aware) ─────────────────────
async create<T extends object>(data: T) {
const record = data as Record<string, any>;
const id = extractId(record);
const doc = buildEnvelope(id, record);
return raw.put(doc);
},
async createBulk<T extends object>(
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 = extractId(record);
return buildEnvelope(id, record);
});
const response = await raw.bulkDocs(batch);
results.push(...response);
}
return results;
},
async update<T extends object>(id: string, data: Partial<T>) {
const docId = `${entity}:${id}`;
const existing = (await raw.get(docId)) as CouchEnvelope;
const mergedPayload = { ...existing.data, ...data };
const doc = buildEnvelope(id, mergedPayload);
return raw.put({ ...doc, _rev: existing._rev } as any);
},
async upsert<T extends object>(id: string, data: T) {
const docId = `${entity}:${id}`;
const existing = (await 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 = buildEnvelope(id, payload);
return raw.put(existing ? { ...doc, _rev: (existing as any)._rev } : (doc as any));
},
async delete(id: string) {
const docId = `${entity}:${id}`;
const existing = await raw.get(docId);
return raw.remove(existing);
},
// ─── Overridden Reads (envelope-aware) ────────────────────
async getOne<T>(id: string): Promise<FlatDoc<T>> {
const docId = `${entity}:${id}`;
const doc = (await raw.get(docId)) as CouchEnvelope<T>;
return flatten(doc);
},
async getAll<T>(): Promise<FlatDoc<T>[]> {
const result = await raw.find({
selector: { entity },
limit: 10000,
});
return (result.docs as CouchEnvelope<T>[]).map((doc) => flatten(doc));
},
async getSome<T>(ids: string[]): Promise<FlatDoc<T>[]> {
const prefixedIds = ids.map((id) => `${entity}:${id}`);
const result = await raw.allDocs({
keys: prefixedIds,
include_docs: true,
});
return result.rows
.filter((row): row is any => !('error' in row) && !!(row as any).doc)
.map((row: any) => flatten(row.doc as CouchEnvelope<T>));
},
async find<T extends object>(options: PouchDB.Find.FindRequest<T>) {
const result = await raw.find({
...options,
selector: {
entity,
...(options.selector as Record<string, any>),
},
} as PouchDB.Find.FindRequest<object>);
return (result.docs as CouchEnvelope<T>[]).map((doc) => flatten(doc)) as FlatDoc<T>[];
},
// ─── Overridden Bulk (entity-scoped) ──────────────────────
async cleanAllData() {
const result = await raw.find({
selector: { 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 raw.bulkDocs(deletions);
}
},
// ─── New Methods (envelope-only) ──────────────────────────
async search<T>(keyword: string, fields: string[] = ['data.name'], limit = 10000): Promise<FlatDoc<T>[]> {
if (!keyword.trim()) {
const result = await raw.find({
selector: { entity },
limit: 10000,
});
return (result.docs as CouchEnvelope<T>[]).map((doc) => flatten(doc));
}
const pattern = toContainsRegex(keyword);
const queries = fields.map((field) =>
raw.find({
selector: { 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) => flatten(doc));
},
async createIndex(fields: string[], name?: string) {
return raw.createIndex({
index: { fields, ...(name ? { name } : {}) },
});
},
async listIndexes() {
return raw.getIndexes();
},
// ─── Lifecycle ────────────────────────────────────────────
async destroy() {
await raw.destroy();
},
};
}
// ─── Test Suite ───────────────────────────────────────────────────
describe('PouchEnvelope — Envelope-Aware Operations', () => {
let db: ReturnType<typeof createTestEnvelopeDB>;
const ENTITY = 'item';
beforeEach(() => {
db = createTestEnvelopeDB(`test_envelope_${Date.now()}_${Math.random().toString(36).slice(2)}`, ENTITY);
});
afterEach(async () => {
try {
await db.destroy();
} catch {
// Already destroyed
}
});
// ─── Inheritance Verification ─────────────────────────────────
describe('inheritance', () => {
it('should store the entity name', () => {
expect(db.entity).toBe(ENTITY);
});
});
// ─── create (override) ────────────────────────────────────────
describe('create (override)', () => {
it('should store a document as an envelope with entity prefix', async () => {
const res = await db.create({
_id: 'abc-123',
name: 'Widget',
price: 9.99,
});
expect(res.ok).toBe(true);
expect(res.id).toBe('item:abc-123');
// Verify raw envelope structure
const rawDoc = (await db.raw.get('item:abc-123')) as any;
expect(rawDoc.entity).toBe('item');
expect(rawDoc.data.name).toBe('Widget');
expect(rawDoc.data.price).toBe(9.99);
});
it('should throw when creating without _id or id', async () => {
await expect(db.create({ name: 'NoId' } as any)).rejects.toThrow('Missing _id or id');
});
it('should support "id" field as an alternative to "_id"', async () => {
const res = await db.create({
id: 'alt-001',
name: 'AltId',
} as any);
expect(res.ok).toBe(true);
expect(res.id).toBe('item:alt-001');
});
});
// ─── createBulk (override) ────────────────────────────────────
describe('createBulk (override)', () => {
it('should batch-create envelope documents', async () => {
const items = [
{ _id: 'b1', name: 'Item 1' },
{ _id: 'b2', name: 'Item 2' },
{ _id: 'b3', name: 'Item 3' },
];
const results = await db.createBulk(items);
expect(results).toHaveLength(3);
results.forEach((r: any) => expect(r.ok).toBe(true));
// Verify they're stored as envelopes
const rawDoc = (await db.raw.get('item:b2')) as any;
expect(rawDoc.entity).toBe('item');
expect(rawDoc.data.name).toBe('Item 2');
});
it('should handle batching for datasets exceeding batchSize', async () => {
const items = Array.from({ length: 150 }, (_, i) => ({
_id: `bulk-${i}`,
val: i,
}));
const results = await db.createBulk(items, 50);
expect(results).toHaveLength(150);
const all = await db.getAll();
expect(all).toHaveLength(150);
});
});
// ─── getOne (override) ────────────────────────────────────────
describe('getOne (override)', () => {
it('should return a flattened document (envelope stripped)', async () => {
await db.create({ _id: 'get-001', name: 'Widget', price: 9.99 });
const doc = await db.getOne<{ name: string; price: number }>('get-001');
expect(doc._id).toBe('item:get-001');
expect(doc._rev).toBeDefined();
expect(doc.name).toBe('Widget');
expect(doc.price).toBe(9.99);
// Should NOT have envelope fields at top level
expect((doc as any).entity).toBeUndefined();
expect((doc as any).data).toBeUndefined();
});
it('should throw for a non-existent business id', async () => {
await expect(db.getOne('ghost')).rejects.toThrow();
});
});
// ─── update (override) ────────────────────────────────────────
describe('update (override)', () => {
it('should update data inside the envelope, preserving structure', async () => {
await db.create({ _id: 'upd-001', name: 'Original', count: 1 });
const res = await db.update('upd-001', { count: 42, extra: 'new' });
expect(res.ok).toBe(true);
// Verify envelope structure is preserved
const rawDoc = (await db.raw.get('item:upd-001')) as any;
expect(rawDoc.entity).toBe('item');
expect(rawDoc.data.name).toBe('Original');
expect(rawDoc.data.count).toBe(42);
expect(rawDoc.data.extra).toBe('new');
// Verify flattened read
const doc = await db.getOne<{
name: string;
count: number;
extra: string;
}>('upd-001');
expect(doc.name).toBe('Original');
expect(doc.count).toBe(42);
});
it('should throw when updating a non-existent document', async () => {
await expect(db.update('ghost', { name: 'nope' })).rejects.toThrow();
});
});
// ─── upsert (override) ────────────────────────────────────────
describe('upsert (override)', () => {
it('should create an envelope when the document does not exist', async () => {
const res = await db.upsert('ups-001', {
name: 'NewItem',
price: 19.99,
} as any);
expect(res.ok).toBe(true);
const rawDoc = (await db.raw.get('item:ups-001')) as any;
expect(rawDoc.entity).toBe('item');
expect(rawDoc.data.name).toBe('NewItem');
});
it('should update the envelope data when the document exists', async () => {
await db.create({ _id: 'ups-002', name: 'Original', count: 1 });
const res = await db.upsert('ups-002', {
name: 'Updated',
count: 99,
} as any);
expect(res.ok).toBe(true);
const doc = await db.getOne<{ name: string; count: number }>('ups-002');
expect(doc.name).toBe('Updated');
expect(doc.count).toBe(99);
});
});
// ─── delete (override) ────────────────────────────────────────
describe('delete (override)', () => {
it('should remove a document using the business id', async () => {
await db.create({ _id: 'del-001', name: 'ToDelete' });
const res = await db.delete('del-001');
expect(res.ok).toBe(true);
await expect(db.getOne('del-001')).rejects.toThrow();
});
});
// ─── getAll (override) ────────────────────────────────────────
describe('getAll (override)', () => {
it('should return only documents of this entity, flattened', async () => {
// Insert envelope docs for this entity
await db.create({ _id: 'a1', val: 10 });
await db.create({ _id: 'a2', val: 20 });
// Insert a doc for a DIFFERENT entity directly into raw
await db.raw.put({
_id: 'other:x1',
entity: 'other',
data: { val: 999 },
});
const all = await db.getAll<{ val: number }>();
expect(all).toHaveLength(2);
expect(all.every((d: any) => d._id.startsWith('item:'))).toBe(true);
});
it('should return empty array when no documents of this entity exist', async () => {
const all = await db.getAll();
expect(all).toEqual([]);
});
});
// ─── getSome (override) ───────────────────────────────────────
describe('getSome (override)', () => {
it('should return flattened documents for the given business ids', async () => {
await db.create({ _id: 's1', v: 10 });
await db.create({ _id: 's2', v: 20 });
await db.create({ _id: 's3', v: 30 });
const some = await db.getSome<{ v: number }>(['s1', 's3']);
expect(some).toHaveLength(2);
expect(some.map((d) => d.v).sort()).toEqual([10, 30]);
expect(some.every((d) => d._id.startsWith('item:'))).toBe(true);
});
it('should silently skip missing business ids', async () => {
await db.create({ _id: 'exists', v: 1 });
const some = await db.getSome<{ v: number }>(['exists', 'ghost']);
expect(some).toHaveLength(1);
});
});
// ─── find (override) ──────────────────────────────────────────
describe('find (override)', () => {
it('should auto-scope queries to this entity', async () => {
await db.create({ _id: 'f1', category: 'electronics', price: 100 });
await db.create({ _id: 'f2', category: 'clothing', price: 50 });
// Insert another entity directly
await db.raw.put({
_id: 'other:f3',
entity: 'other',
data: { category: 'electronics', price: 500 },
});
const results = await db.find<{
category: string;
price: number;
}>({
selector: { 'data.category': 'electronics' } as any,
});
expect(results).toHaveLength(1); // Only this entity
expect(results[0]._id).toBe('item:f1');
});
});
// ─── cleanAllData (override) ──────────────────────────────────
describe('cleanAllData (override)', () => {
it('should remove only this entity docs, leaving others intact', async () => {
await db.create({ _id: 'c1', name: 'ItemOne' });
await db.create({ _id: 'c2', name: 'ItemTwo' });
// Insert a different entity
await db.raw.put({
_id: 'other:o1',
entity: 'other',
data: { name: 'OtherDoc' },
});
await db.cleanAllData();
// This entity's docs should be gone
const items = await db.getAll();
expect(items).toHaveLength(0);
// Other entity's doc should still exist
const otherDoc = await db.raw.get('other:o1');
expect(otherDoc).toBeDefined();
});
});
// ─── search (new method) ──────────────────────────────────────
describe('search', () => {
it('should find documents matching a keyword in data fields', async () => {
await db.create({ _id: 's1', name: 'Widget Pro' });
await db.create({ _id: 's2', name: 'Gadget' });
await db.create({ _id: 's3', name: 'Widget Basic' });
const results = await db.search<{ name: string }>('widget', ['data.name']);
expect(results).toHaveLength(2);
expect(results.every((r) => r.name.toLowerCase().includes('widget'))).toBe(true);
});
it('should be case-insensitive', async () => {
await db.create({ _id: 'ci1', name: 'UPPERCASE' });
await db.create({ _id: 'ci2', name: 'lowercase' });
await db.create({ _id: 'ci3', name: 'MiXeDcAsE' });
const results = await db.search<{ name: string }>('case', ['data.name']);
expect(results).toHaveLength(3); // All three contain 'case' (case-insensitive)
});
it('should return all entity docs when keyword is empty', async () => {
await db.create({ _id: 'e1', name: 'A' });
await db.create({ _id: 'e2', name: 'B' });
const results = await db.search('');
expect(results).toHaveLength(2);
});
it('should deduplicate results across multiple field queries', async () => {
await db.create({ _id: 'dup1', name: 'Widget', sku: 'WDG-001' });
const results = await db.search<{ name: string; sku: string }>('widget', ['data.name', 'data.sku']);
// Should not duplicate even if 'widget' matches in name
const ids = results.map((r) => r._id);
expect(new Set(ids).size).toBe(ids.length);
});
});
// ─── createIndex / listIndexes ────────────────────────────────
describe('createIndex / listIndexes', () => {
it('should create and list a Mango index', async () => {
await db.createIndex(['entity', 'data.name'], 'idx-entity-name');
const indexes = await db.listIndexes();
const names = indexes.indexes.map((idx: any) => idx.name);
expect(names).toContain('idx-entity-name');
});
it('should be idempotent (safe to call multiple times)', async () => {
await db.createIndex(['entity'], 'idx-entity');
await db.createIndex(['entity'], 'idx-entity'); // no-op
const indexes = await db.listIndexes();
const entityIndexes = indexes.indexes.filter((idx: any) => idx.name === 'idx-entity');
expect(entityIndexes).toHaveLength(1);
});
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalStorageService } from './local-storage.service';
import { LocalStorageService } from '../local-storage/local-storage.service';
// ─── Mock @repo/utils EncryptionUtils ───────────────────────────
@@ -21,11 +21,21 @@ const store: Record<string, string> = {};
const mockLocalStorage: Storage = {
getItem: vi.fn((key: string): string | null => store[key] ?? null),
setItem: vi.fn((key: string, value: string): void => { store[key] = value; }),
removeItem: vi.fn((key: string): void => { delete store[key]; }),
clear: vi.fn((): void => { for (const key of Object.keys(store)) delete store[key]; }),
get length() { return Object.keys(store).length; },
key(index: number): string | null { return Object.keys(store)[index] ?? null; },
setItem: vi.fn((key: string, value: string): void => {
store[key] = value;
}),
removeItem: vi.fn((key: string): void => {
delete store[key];
}),
clear: vi.fn((): void => {
for (const key of Object.keys(store)) delete store[key];
}),
get length() {
return Object.keys(store).length;
},
key(index: number): string | null {
return Object.keys(store)[index] ?? null;
},
};
// Install mock
@@ -52,10 +62,7 @@ const ENCRYPTED_KEYS = new Set<TestStorageKeyValue>([
TestStorageKey.USER_PROFILE,
]);
const PLAIN_KEYS = new Set<TestStorageKeyValue>([
TestStorageKey.THEME,
TestStorageKey.LOCALE,
]);
const PLAIN_KEYS = new Set<TestStorageKeyValue>([TestStorageKey.THEME, TestStorageKey.LOCALE]);
interface TestUser {
id: number;
@@ -76,7 +83,7 @@ describe('LocalStorageService', () => {
// Pass mock encryption utils to avoid importing real crypto-js
storage = new LocalStorageService<TestStorageKeyValue>(
{ encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS },
mockEncryptionUtils as never
mockEncryptionUtils as never,
);
});
@@ -85,7 +92,7 @@ describe('LocalStorageService', () => {
// Cast a rogue key to bypass TS for the runtime check test
const rogueKey = 'unregistered_key' as TestStorageKeyValue;
await expect(storage.setItem(rogueKey, 'data')).rejects.toThrowError(
"[Storage Engine] Security Exception: Key 'unregistered_key' is not registered and cannot be accessed."
"[Storage Engine] Security Exception: Key 'unregistered_key' is not registered and cannot be accessed.",
);
});
});
@@ -104,10 +111,7 @@ describe('LocalStorageService', () => {
await storage.setItem(TestStorageKey.LOCALE, 'en-US');
expect(mockEncrypt).not.toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
TestStorageKey.LOCALE,
'"en-US"',
);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LOCALE, '"en-US"');
});
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
@@ -156,9 +160,7 @@ describe('LocalStorageService', () => {
const result = await storage.getItem<string>(TestStorageKey.THEME);
expect(result).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse key'),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse key'));
// Corrupt entry should be cleaned up
expect(store[TestStorageKey.THEME]).toBeUndefined();
warnSpy.mockRestore();