diff --git a/apps/docs-dev/src/packages/core-storage/index.md b/apps/docs-dev/src/packages/core-storage/index.md index f95fbe9..afd15c9 100644 --- a/apps/docs-dev/src/packages/core-storage/index.md +++ b/apps/docs-dev/src/packages/core-storage/index.md @@ -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({ localName: 'items_db', @@ -184,7 +184,7 @@ export const itemDB = dbManager.register({ ### 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({ + 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({ + 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. diff --git a/apps/web/src/apps/showcase/pouch-sample.tsx b/apps/web/src/apps/showcase/pouch-sample.tsx index 2ab3b51..07b4e1c 100644 --- a/apps/web/src/apps/showcase/pouch-sample.tsx +++ b/apps/web/src/apps/showcase/pouch-sample.tsx @@ -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([]); const [items, setItems] = useState([]); + const [newItems, setNewItems] = useState([]); // 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 */} - Items Database + Items Database (Standard) @@ -174,6 +216,59 @@ export default function PouchSample() { + {/* New Items Envelope Table */} + + + New Items Database (Envelope Pattern) + + + +
+ + + + ID + Name + Type + Base Price + Rates Count + Actions + + + + {newItems.length > 0 ? ( + newItems.map((item) => ( + + {item._id} + {item.name} + + + {item.item_type} + + + ${Number(item.base_price).toFixed(2)} + {item.item_rates?.length || 0} + + + + + )) + ) : ( + + + No items found. + + + )} + +
+
+
+ {/* POS Configuration Table */} diff --git a/apps/web/src/core/storage/pouch-db/entities/index.ts b/apps/web/src/core/storage/pouch-db/entities/index.ts index 4baf085..686679f 100644 --- a/apps/web/src/core/storage/pouch-db/entities/index.ts +++ b/apps/web/src/core/storage/pouch-db/entities/index.ts @@ -1,2 +1,3 @@ export * from './item.pouchdb.entity'; export * from './pos-configuration.pouchdb.entity'; +export * from './new-item.pouchdb.entity'; diff --git a/apps/web/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts b/apps/web/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts new file mode 100644 index 0000000..c03b4a4 --- /dev/null +++ b/apps/web/src/core/storage/pouch-db/entities/new-item.pouchdb.entity.ts @@ -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; +} diff --git a/apps/web/src/core/storage/pouch-db/index.ts b/apps/web/src/core/storage/pouch-db/index.ts index 42c7fdf..6d6f195 100644 --- a/apps/web/src/core/storage/pouch-db/index.ts +++ b/apps/web/src/core/storage/pouch-db/index.ts @@ -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({ 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', +); diff --git a/packages/core-storage/src/index.ts b/packages/core-storage/src/index.ts index 65d9290..319ad14 100644 --- a/packages/core-storage/src/index.ts +++ b/packages/core-storage/src/index.ts @@ -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'; diff --git a/packages/core-storage/src/indexed-db.service.ts b/packages/core-storage/src/indexed-db/indexed-db.service.ts similarity index 89% rename from packages/core-storage/src/indexed-db.service.ts rename to packages/core-storage/src/indexed-db/indexed-db.service.ts index 4713c28..c04cd85 100644 --- a/packages/core-storage/src/indexed-db.service.ts +++ b/packages/core-storage/src/indexed-db/indexed-db.service.ts @@ -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 extends StorageOptions { +function openDatabase(dbName: string, storeName: string, version: number): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, version); @@ -104,13 +100,9 @@ export class IndexedDBService implements IStorageService - store.put(payload, key as string), - ); + await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string)); } async getItem(key: TKey): Promise { @@ -143,16 +135,12 @@ export class IndexedDBService implements IStorageService { 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 { 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 { @@ -172,8 +160,6 @@ export class IndexedDBService implements IStorageService( - config: IndexedDBConfig -): IStorageService { +export function createIndexedDB(config: IndexedDBConfig): IStorageService { return new IndexedDBService(config); } diff --git a/packages/core-storage/src/local-storage.service.ts b/packages/core-storage/src/local-storage/local-storage.service.ts similarity index 93% rename from packages/core-storage/src/local-storage.service.ts rename to packages/core-storage/src/local-storage/local-storage.service.ts index 70be514..7675a98 100644 --- a/packages/core-storage/src/local-storage.service.ts +++ b/packages/core-storage/src/local-storage/local-storage.service.ts @@ -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 { encryptedKeys?: Set; @@ -84,8 +84,6 @@ export class LocalStorageService implements IStorageService } } -export function createLocalStorage( - options?: StorageOptions -): IStorageService { +export function createLocalStorage(options?: StorageOptions): IStorageService { return new LocalStorageService(options); } diff --git a/packages/core-storage/src/pouch-db/index.ts b/packages/core-storage/src/pouch-db/index.ts new file mode 100644 index 0000000..7463529 --- /dev/null +++ b/packages/core-storage/src/pouch-db/index.ts @@ -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'; diff --git a/packages/core-storage/src/pouch-db/managers/pouch-envelope.manager.ts b/packages/core-storage/src/pouch-db/managers/pouch-envelope.manager.ts new file mode 100644 index 0000000..aa417c5 --- /dev/null +++ b/packages/core-storage/src/pouch-db/managers/pouch-envelope.manager.ts @@ -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(config: PouchConfig, entity?: string): PouchEnvelopeService { + 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; + } + + const wrapper = new PouchEnvelopeService(config, entity); + this.databases.set(config.localName, wrapper); + return wrapper; + } + + /** + * Retrieve a previously registered envelope database wrapper by its local name. + */ + override get(localName: string): PouchEnvelopeService | undefined { + return super.get(localName) as PouchEnvelopeService | undefined; + } +} diff --git a/packages/core-storage/src/pouch-db/managers/pouch.manager.ts b/packages/core-storage/src/pouch-db/managers/pouch.manager.ts new file mode 100644 index 0000000..3785d37 --- /dev/null +++ b/packages/core-storage/src/pouch-db/managers/pouch.manager.ts @@ -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>(); + + /** + * 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(config: PouchConfig): PouchService { + if (this.databases.has(config.localName)) { + return this.databases.get(config.localName) as PouchService; + } + + const wrapper = new PouchService(config); + this.databases.set(config.localName, wrapper); + return wrapper; + } + + /** + * Retrieve a previously registered database wrapper by its local name. + */ + get(localName: string): PouchService | undefined { + return this.databases.get(localName) as PouchService | undefined; + } + + /** + * Destroy a specific registered database and remove it from the manager. + */ + async destroy(localName: string): Promise { + 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 { + 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()); + } +} diff --git a/packages/core-storage/src/pouch-db/services/pouch-envelope.service.ts b/packages/core-storage/src/pouch-db/services/pouch-envelope.service.ts new file mode 100644 index 0000000..8bb441f --- /dev/null +++ b/packages/core-storage/src/pouch-db/services/pouch-envelope.service.ts @@ -0,0 +1,304 @@ +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> { + _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 & { _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({ 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 extends PouchService { + protected entity: string; + + constructor(config: PouchConfig, entity: string) { + super(config); + this.entity = entity; + } + + // ─── Private Helpers ──────────────────────────────────────── + + private buildEnvelope(id: string, payload: Record): Omit { + return { + _id: `${this.entity}:${id}`, + entity: this.entity, + data: payload, + }; + } + + private flatten(doc: CouchEnvelope): FlatDoc { + return { + ...doc.data, + _id: doc._id, + _rev: doc._rev!, + } as FlatDoc; + } + + /** + * Extract the business ID from data. + * Supports `_id` (PouchDB convention) and `id` (common DTO pattern). + */ + private extractId(data: Record): 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(data: T): Promise { + const record = data as Record; + 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( + 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; + 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( + id: string, + data: Partial, + ): Promise { + 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(id: string, data: T): Promise { + 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) } : (data as Record); + 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 { + 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(id: string): Promise { + const docId = `${this.entity}:${id}`; + const doc = (await this.raw.get(docId)) as CouchEnvelope; + 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(): Promise { + const result = await this.raw.find({ + selector: { entity: this.entity }, + limit: 10000, + }); + return (result.docs as CouchEnvelope[]).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(ids: string[]): Promise { + 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['rows'][number] => !('error' in row) && !!(row as any).doc, + ) + .map((row) => this.flatten((row as any).doc as CouchEnvelope)) 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(options: PouchDB.Find.FindRequest): Promise { + const result = await this.raw.find({ + ...options, + selector: { + entity: this.entity, + ...(options.selector as Record), + }, + } as PouchDB.Find.FindRequest); + return (result.docs as CouchEnvelope[]).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 { + 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( + keyword: string, + fields: string[] = ['data.name'], + limit = 10000, + ): Promise[]> { + if (!keyword.trim()) { + return this.getAll() as Promise[]>; + } + + 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(); + 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[]).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> { + return this.raw.createIndex({ + index: { fields, ...(name ? { name } : {}) }, + }); + } +} diff --git a/packages/core-storage/src/pouch-db/services/pouch.service.ts b/packages/core-storage/src/pouch-db/services/pouch.service.ts new file mode 100644 index 0000000..befae20 --- /dev/null +++ b/packages/core-storage/src/pouch-db/services/pouch.service.ts @@ -0,0 +1,324 @@ +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({ 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 { + /** The underlying raw PouchDB instance (escape hatch for advanced usage). */ + readonly raw: PouchDB.Database; + + protected liveSyncHandler: PouchDB.Replication.Sync | null = null; + protected listeners = new Set<() => void>(); + protected changesFeed: PouchDB.Core.Changes | 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; + } + + /** + * Build a case-insensitive regex pattern for keyword search + * without relying on `$options` (which PouchDB doesn't support). + */ + toContainsRegex(keyword: string): string { + return keyword + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape special regex chars + .replace(/[a-zA-Z]/g, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`); // case-insensitive + } + + /** 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(data: T): Promise { + return this.raw.put(data as PouchDB.Core.Document); + } + + /** + * Create multiple documents in batches. + * @param dataList - Array of documents to create. + * @param batchSize - Number of documents per batch (default: 100). + */ + async createBulk( + 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[]); + results.push(...response); + } + + return results; + } + + /** + * Update an existing document by ID. + * Automatically fetches the latest `_rev` to prevent conflict errors. + */ + async update(id: string, data: Partial): Promise { + 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(id: string, data: T): Promise { + 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); + } + + /** + * Delete a document by ID. + * Automatically fetches the latest `_rev` before removal. + */ + async delete(id: string): Promise { + const doc = await this.raw.get(id); + return this.raw.remove(doc); + } + + /** + * Retrieve a single document by ID. + */ + async getOne(id: string): Promise { + return this.raw.get(id) as Promise; + } + + /** + * Retrieve all documents from the database. + * Returns a clean array of document objects (excludes PouchDB design docs). + */ + async getAll(): Promise { + 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(ids: string[]): Promise { + const result = await this.raw.allDocs({ keys: ids, include_docs: true }); + return result.rows + .filter( + (row): row is PouchDB.Core.AllDocsResponse['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(options: PouchDB.Find.FindRequest): Promise { + const result = await this.raw.find(options as PouchDB.Find.FindRequest); + 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 { + 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 { + 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); + }; + } + + // ─── New Methods (no base equivalent) ─────────────────────── + + /** + * List currently defined indexes. Useful for debugging with `explain()`. + */ + async listIndexes(): Promise> { + 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(options: PouchDB.Find.FindRequest): Promise { + return (this.raw as any).explain(options as PouchDB.Find.FindRequest); + } +} diff --git a/packages/core-storage/src/pouch.ts b/packages/core-storage/src/pouch.ts deleted file mode 100644 index 041587e..0000000 --- a/packages/core-storage/src/pouch.ts +++ /dev/null @@ -1,289 +0,0 @@ -import PouchDB from 'pouchdb-browser'; -import PouchDBFind from 'pouchdb-find'; - -// Register the find plugin globally -PouchDB.plugin(PouchDBFind); - -// ─── Configuration Interface ──────────────────────────────────── - -/** - * Configuration for creating a new PouchDB database instance. - * Follows Inversion of Control — the consuming app decides names and remote URLs. - */ -export interface PouchConfig { - /** Name of the local PouchDB database (stored in IndexedDB by the browser). */ - localName: string; - /** - * Optional remote CouchDB URL for bi-directional live sync. - * Should include credentials if authentication is required. - * Example: `http://user:password@host:port/db_name` - */ - remoteUrl?: string; -} - -// ─── PouchDatabaseWrapper ─────────────────────────────────────── - -/** - * Object-Oriented wrapper around a single PouchDB instance. - * Provides strictly-typed CRUD + query helpers so developers never interact - * with the raw PouchDB API or pass `dbName` repeatedly. - * - * @example - * ```typescript - * const itemDB = dbManager.register({ localName: 'items' }); - * await itemDB.create({ _id: 'item-001', name: 'Widget', price: 9.99 }); - * const results = await itemDB.find({ selector: { price: { $gt: 5 } } }); - * ``` - */ -export class PouchDatabaseWrapper { - /** The underlying raw PouchDB instance (escape hatch for advanced usage). */ - readonly raw: PouchDB.Database; - - private syncHandler: PouchDB.Replication.Sync | null = null; - private listeners = new Set<() => void>(); - private changesFeed: PouchDB.Core.Changes | null = null; - - constructor(config: PouchConfig) { - this.raw = new PouchDB(config.localName); - - // Set up bi-directional live sync if a remote URL is provided - if (config.remoteUrl) { - this.syncHandler = this.raw.sync(config.remoteUrl, { - live: true, - retry: true, - }); - - // Fault-tolerant error handling — prevents app crashes when CouchDB is unreachable - this.syncHandler.on('error', (err: unknown) => { - console.warn(`[PouchDB Sync] Error on "${config.localName}":`, err); - }); - - this.syncHandler.on('paused', (info: unknown) => { - if (info) { - console.warn(`[PouchDB Sync] Paused on "${config.localName}":`, info); - } - }); - - this.syncHandler.on('denied', (err: unknown) => { - console.warn(`[PouchDB Sync] Denied on "${config.localName}":`, err); - }); - } - } - - // ─── CRUD Operations ──────────────────────────────────────── - - /** - * Create a new document. If `_id` is not provided in data, PouchDB generates one. - */ - async create(data: T): Promise { - return this.raw.put(data as PouchDB.Core.Document); - } - - /** - * Update an existing document by ID. - * Automatically fetches the latest `_rev` to prevent conflict errors. - */ - async update(id: string, data: Partial): Promise { - const existing = await this.raw.get(id); - const merged = { ...existing, ...data }; - return this.raw.put(merged); - } - - /** - * Delete a document by ID. - * Automatically fetches the latest `_rev` before removal. - */ - async delete(id: string): Promise { - const doc = await this.raw.get(id); - return this.raw.remove(doc); - } - - /** - * Retrieve a single document by ID. - */ - async getOne(id: string): Promise { - return this.raw.get(id) as Promise; - } - - /** - * Retrieve all documents from the database. - * Returns a clean array of document objects (excludes PouchDB design docs). - */ - async getAll(): Promise { - 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(ids: string[]): Promise { - const result = await this.raw.allDocs({ keys: ids, include_docs: true }); - return result.rows - .filter((row): row is PouchDB.Core.AllDocsResponse['rows'][number] => !('error' in row) && !!(row as any).doc) - .map((row) => (row as any).doc as T); - } - - /** - * Query documents using MongoDB-style selectors (powered by `pouchdb-find`). - * - * @example - * ```typescript - * const electronics = await itemDB.find({ selector: { category: 'electronics' } }); - * const expensive = await itemDB.find({ selector: { price: { $gt: 100 } }, limit: 10 }); - * ``` - */ - async find(options: PouchDB.Find.FindRequest): Promise { - const result = await this.raw.find(options as PouchDB.Find.FindRequest); - 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 { - const result = await this.raw.allDocs(); - const deletions = result.rows - .filter((row) => !row.id.startsWith('_design/')) - .map((row) => ({ - _id: row.id, - _rev: row.value.rev, - _deleted: true as const, - })); - - if (deletions.length > 0) { - await this.raw.bulkDocs(deletions); - } - } - - /** - * Cancel any active sync and completely destroy the local database. - * After calling this, the wrapper instance should not be used again. - */ - async destroy(): Promise { - if (this.syncHandler) { - this.syncHandler.cancel(); - } - if (this.changesFeed) { - this.changesFeed.cancel(); - } - this.listeners.clear(); - await this.raw.destroy(); - } - - /** - * Cancel the live sync connection (if active) without destroying the database. - */ - cancelSync(): void { - if (this.syncHandler) { - this.syncHandler.cancel(); - this.syncHandler = null; - } - } - - /** - * Subscribe to real-time changes in the database. - * Returns an unsubscribe function. - */ - onChange(callback: () => void): () => void { - this.listeners.add(callback); - - if (!this.changesFeed) { - this.changesFeed = this.raw.changes({ - since: 'now', - live: true, - include_docs: true - }).on('change', () => { - this.listeners.forEach(cb => cb()); - }).on('error', (err) => { - console.warn(`[PouchDB Listener Error]`, err); - }); - } - - return () => { - this.listeners.delete(callback); - }; - } -} - -// ─── PouchDatabaseManager ─────────────────────────────────────── - -/** - * IoC Factory for managing multiple PouchDB database instances with optional - * bi-directional CouchDB synchronization. - * - * `register()` returns a `PouchDatabaseWrapper` with full CRUD + query helpers, - * so developers never need to pass `dbName` into each operation. - * - * @example - * ```typescript - * const dbManager = new PouchDatabaseManager(); - * - * const itemDB = dbManager.register({ localName: 'items' }); - * await itemDB.create({ _id: 'item-001', name: 'Widget', price: 9.99 }); - * - * const results = await itemDB.find({ selector: { price: { $gt: 5 } } }); - * console.log(results); // [{ _id: 'item-001', name: 'Widget', price: 9.99, ... }] - * - * const salesDB = dbManager.register({ - * localName: 'sales', - * remoteUrl: 'http://admin:pass@localhost:5984/sales_db', - * }); - * ``` - */ -export class PouchDatabaseManager { - private databases = new Map>(); - - /** - * 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(config: PouchConfig): PouchDatabaseWrapper { - if (this.databases.has(config.localName)) { - return this.databases.get(config.localName) as PouchDatabaseWrapper; - } - - const wrapper = new PouchDatabaseWrapper(config); - this.databases.set(config.localName, wrapper); - return wrapper; - } - - /** - * Retrieve a previously registered database wrapper by its local name. - */ - get(localName: string): PouchDatabaseWrapper | undefined { - return this.databases.get(localName) as PouchDatabaseWrapper | undefined; - } - - /** - * Destroy a specific registered database and remove it from the manager. - */ - async destroy(localName: string): Promise { - 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 { - 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()); - } -} diff --git a/packages/core-storage/tests/pouch.test.ts b/packages/core-storage/src/tests/pouch-base.test.ts similarity index 56% rename from packages/core-storage/tests/pouch.test.ts rename to packages/core-storage/src/tests/pouch-base.test.ts index 9881dff..ce00695 100644 --- a/packages/core-storage/tests/pouch.test.ts +++ b/packages/core-storage/src/tests/pouch-base.test.ts @@ -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(data: T) { return raw.put(data as PouchDB.Core.Document); }, + async createBulk( + 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[], + ); + results.push(...response); + } + return results; + }, + async update(id: string, data: Partial) { const existing = await raw.get(id); const merged = { ...existing, ...data }; return raw.put(merged); }, + async upsert(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); + }, + async delete(id: string) { const doc = await raw.get(id); return raw.remove(doc); }, + // ─── Reads ──────────────────────────────────────────────── + async getOne(id: string) { - return raw.get(id) as Promise; + return raw.get(id) as Promise< + T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta + >; }, async getAll() { @@ -56,10 +85,14 @@ function createTestDB(name: string) { }, async find(options: PouchDB.Find.FindRequest) { - const result = await raw.find(options as PouchDB.Find.FindRequest); + const result = await raw.find( + options as PouchDB.Find.FindRequest, + ); 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 | 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; 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,10 +234,14 @@ 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 + expect(updated.count).toBe(42); // updated + expect(updated.extra).toBe('field'); // newly added }); it('should throw when updating a non-existent document', async () => { @@ -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(); + }); + }); }); diff --git a/packages/core-storage/src/tests/pouch-envelope.test.ts b/packages/core-storage/src/tests/pouch-envelope.test.ts new file mode 100644 index 0000000..c3deb1a --- /dev/null +++ b/packages/core-storage/src/tests/pouch-envelope.test.ts @@ -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> { + _id: string; + _rev?: string; + entity: string; + data: T; +} + +type FlatDoc = 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): Omit { + return { _id: `${entity}:${id}`, entity, data: payload }; + } + + function flatten(doc: CouchEnvelope): FlatDoc { + return { ...doc.data, _id: doc._id, _rev: doc._rev! } as FlatDoc; + } + + function extractId(data: Record): 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(data: T) { + const record = data as Record; + const id = extractId(record); + const doc = buildEnvelope(id, record); + return raw.put(doc); + }, + + async createBulk( + 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; + const id = extractId(record); + return buildEnvelope(id, record); + }); + const response = await raw.bulkDocs(batch); + results.push(...response); + } + return results; + }, + + async update(id: string, data: Partial) { + 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(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) } : (data as Record); + 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(id: string): Promise> { + const docId = `${entity}:${id}`; + const doc = (await raw.get(docId)) as CouchEnvelope; + return flatten(doc); + }, + + async getAll(): Promise[]> { + const result = await raw.find({ + selector: { entity }, + limit: 10000, + }); + return (result.docs as CouchEnvelope[]).map((doc) => flatten(doc)); + }, + + async getSome(ids: string[]): Promise[]> { + 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)); + }, + + async find(options: PouchDB.Find.FindRequest) { + const result = await raw.find({ + ...options, + selector: { + entity, + ...(options.selector as Record), + }, + } as PouchDB.Find.FindRequest); + return (result.docs as CouchEnvelope[]).map((doc) => flatten(doc)) as FlatDoc[]; + }, + + // ─── 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(keyword: string, fields: string[] = ['data.name'], limit = 10000): Promise[]> { + if (!keyword.trim()) { + const result = await raw.find({ + selector: { entity }, + limit: 10000, + }); + return (result.docs as CouchEnvelope[]).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(); + 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[]).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; + 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); + }); + }); +}); diff --git a/packages/core-storage/src/storage.test.ts b/packages/core-storage/src/tests/storage.test.ts similarity index 91% rename from packages/core-storage/src/storage.test.ts rename to packages/core-storage/src/tests/storage.test.ts index ee865ba..f3a34cc 100644 --- a/packages/core-storage/src/storage.test.ts +++ b/packages/core-storage/src/tests/storage.test.ts @@ -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 = {}; 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([ TestStorageKey.USER_PROFILE, ]); -const PLAIN_KEYS = new Set([ - TestStorageKey.THEME, - TestStorageKey.LOCALE, -]); +const PLAIN_KEYS = new Set([TestStorageKey.THEME, TestStorageKey.LOCALE]); interface TestUser { id: number; @@ -75,8 +82,8 @@ describe('LocalStorageService', () => { for (const key of Object.keys(store)) delete store[key]; // Pass mock encryption utils to avoid importing real crypto-js storage = new LocalStorageService( - { encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS }, - mockEncryptionUtils as never + { encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS }, + 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(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(); diff --git a/packages/core-storage/src/storage.interface.ts b/packages/core-storage/src/types/storage.interface.ts similarity index 100% rename from packages/core-storage/src/storage.interface.ts rename to packages/core-storage/src/types/storage.interface.ts