Merge branch 'main' of https://git.eigen.co.id/eigen/fe-monorepo-template into core/page-provider
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
+8
-22
@@ -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);
|
||||
}
|
||||
+2
-4
@@ -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,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<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 ────────────────────────────────────────
|
||||
|
||||
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 } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<Item>({ localName: 'items' });
|
||||
* await db.create({ _id: 'item-001', name: 'Widget', price: 9.99 });
|
||||
* const results = await db.find({ selector: { price: { $gt: 5 } } });
|
||||
* ```
|
||||
*/
|
||||
export class PouchService<DefaultType extends object = any> {
|
||||
/** The underlying raw PouchDB instance (escape hatch for advanced usage). */
|
||||
readonly raw: PouchDB.Database;
|
||||
|
||||
protected liveSyncHandler: PouchDB.Replication.Sync<object> | null = null;
|
||||
protected listeners = new Set<() => void>();
|
||||
protected changesFeed: PouchDB.Core.Changes<object> | null = null;
|
||||
protected remoteUrl?: string;
|
||||
protected statusListeners = new Set<(status: SyncStatus) => void>();
|
||||
protected _status: SyncStatus = 'disconnected';
|
||||
|
||||
constructor(config: PouchConfig) {
|
||||
this.raw = new PouchDB(config.localName);
|
||||
|
||||
if (config.remoteUrl) {
|
||||
this.remoteUrl = config.remoteUrl;
|
||||
this.startLiveSync();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync Management ────────────────────────────────────────
|
||||
|
||||
private startLiveSync(): void {
|
||||
if (!this.remoteUrl || this.liveSyncHandler) return;
|
||||
|
||||
this.liveSyncHandler = this.raw.sync(this.remoteUrl, {
|
||||
live: true,
|
||||
retry: true,
|
||||
});
|
||||
this.setStatus('connected');
|
||||
|
||||
this.liveSyncHandler
|
||||
.on('paused', () => this.setStatus('connected'))
|
||||
.on('active', () => this.setStatus('connected'))
|
||||
.on('denied', () => this.setStatus('disconnected'))
|
||||
.on('error', () => this.setStatus('disconnected'))
|
||||
.on('complete', () => this.setStatus('disconnected'));
|
||||
}
|
||||
|
||||
protected setStatus(status: SyncStatus): void {
|
||||
if (this._status === status) return;
|
||||
this._status = status;
|
||||
this.statusListeners.forEach((cb) => cb(status));
|
||||
}
|
||||
|
||||
/** Current sync connection status. */
|
||||
get status(): SyncStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T extends object = DefaultType>(data: T): Promise<PouchDB.Core.Response> {
|
||||
return this.raw.put(data as PouchDB.Core.Document<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple documents in batches.
|
||||
* @param dataList - Array of documents to create.
|
||||
* @param batchSize - Number of documents per batch (default: 100).
|
||||
*/
|
||||
async createBulk<T extends object = DefaultType>(
|
||||
dataList: T[],
|
||||
batchSize = 100,
|
||||
): Promise<(PouchDB.Core.Response | PouchDB.Core.Error)[]> {
|
||||
const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = [];
|
||||
|
||||
for (let i = 0; i < dataList.length; i += batchSize) {
|
||||
const batch = dataList.slice(i, i + batchSize);
|
||||
const response = await this.raw.bulkDocs(batch as PouchDB.Core.Document<T>[]);
|
||||
results.push(...response);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing document by ID.
|
||||
* Automatically fetches the latest `_rev` to prevent conflict errors.
|
||||
*/
|
||||
async update<T extends object = DefaultType>(id: string, data: Partial<T>): Promise<PouchDB.Core.Response> {
|
||||
const existing = await this.raw.get(id);
|
||||
const merged = { ...existing, ...data };
|
||||
return this.raw.put(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a document by ID.
|
||||
* If the document exists, merges new data. If not, creates it.
|
||||
*/
|
||||
async upsert<T extends object = DefaultType>(id: string, data: T): Promise<PouchDB.Core.Response> {
|
||||
const existing = await this.raw.get(id).catch(() => null);
|
||||
if (existing) {
|
||||
return this.raw.put({ ...existing, ...data });
|
||||
}
|
||||
return this.raw.put({ ...data, _id: id } as PouchDB.Core.Document<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document by ID.
|
||||
* Automatically fetches the latest `_rev` before removal.
|
||||
*/
|
||||
async delete(id: string): Promise<PouchDB.Core.Response> {
|
||||
const doc = await this.raw.get(id);
|
||||
return this.raw.remove(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single document by ID.
|
||||
*/
|
||||
async getOne<T = DefaultType>(id: string): Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta> {
|
||||
return this.raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all documents from the database.
|
||||
* Returns a clean array of document objects (excludes PouchDB design docs).
|
||||
*/
|
||||
async getAll<T = DefaultType>(): Promise<T[]> {
|
||||
const result = await this.raw.allDocs({ include_docs: true });
|
||||
return result.rows.filter((row) => !row.id.startsWith('_design/')).map((row) => row.doc as unknown as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve multiple documents by their IDs.
|
||||
* Returns a clean array of found documents (silently skips missing/errored entries).
|
||||
*/
|
||||
async getSome<T = DefaultType>(ids: string[]): Promise<T[]> {
|
||||
const result = await this.raw.allDocs({ keys: ids, include_docs: true });
|
||||
return result.rows
|
||||
.filter(
|
||||
(row): row is PouchDB.Core.AllDocsResponse<object>['rows'][number] => !('error' in row) && !!(row as any).doc,
|
||||
)
|
||||
.map((row) => (row as any).doc as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query documents using MongoDB-style selectors (powered by `pouchdb-find`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const electronics = await db.find({ selector: { category: 'electronics' } });
|
||||
* const expensive = await db.find({ selector: { price: { $gt: 100 } }, limit: 10 });
|
||||
* ```
|
||||
*/
|
||||
async find<T extends object = DefaultType>(options: PouchDB.Find.FindRequest<T>): Promise<T[]> {
|
||||
const result = await this.raw.find(options as PouchDB.Find.FindRequest<object>);
|
||||
return result.docs as unknown as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all documents from the database while keeping the database itself intact.
|
||||
* Useful for "clear cache" or "reset local data" flows.
|
||||
*/
|
||||
async cleanAllData(): Promise<void> {
|
||||
const result = await this.raw.allDocs();
|
||||
const deletions = result.rows
|
||||
.filter((row) => !row.id.startsWith('_design/'))
|
||||
.map((row) => ({
|
||||
_id: row.id,
|
||||
_rev: row.value.rev,
|
||||
_deleted: true as const,
|
||||
}));
|
||||
|
||||
if (deletions.length > 0) {
|
||||
await this.raw.bulkDocs(deletions);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cancel any active sync and completely destroy the local database.
|
||||
* After calling this, the wrapper instance should not be used again.
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
if (this.liveSyncHandler) {
|
||||
this.liveSyncHandler.cancel();
|
||||
this.liveSyncHandler = null;
|
||||
}
|
||||
if (this.changesFeed) {
|
||||
this.changesFeed.cancel();
|
||||
this.changesFeed = null;
|
||||
}
|
||||
this.listeners.clear();
|
||||
this.statusListeners.clear();
|
||||
await this.raw.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time changes in the database.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onChange(callback: () => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
|
||||
if (!this.changesFeed) {
|
||||
this.changesFeed = this.raw
|
||||
.changes({
|
||||
since: 'now',
|
||||
live: true,
|
||||
include_docs: true,
|
||||
})
|
||||
.on('change', () => {
|
||||
this.listeners.forEach((cb) => cb());
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.warn(`[PouchDB Listener Error]`, err);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── New Methods (no base equivalent) ───────────────────────
|
||||
|
||||
/**
|
||||
* 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 = DefaultType>(options: PouchDB.Find.FindRequest<T>): Promise<any> {
|
||||
return (this.raw as any).explain(options as PouchDB.Find.FindRequest<object>);
|
||||
}
|
||||
}
|
||||
@@ -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<DefaultType extends object = any> {
|
||||
/** The underlying raw PouchDB instance (escape hatch for advanced usage). */
|
||||
readonly raw: PouchDB.Database;
|
||||
|
||||
private syncHandler: PouchDB.Replication.Sync<object> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private changesFeed: PouchDB.Core.Changes<object> | null = null;
|
||||
|
||||
constructor(config: PouchConfig) {
|
||||
this.raw = new PouchDB(config.localName);
|
||||
|
||||
// Set up bi-directional live sync if a remote URL is provided
|
||||
if (config.remoteUrl) {
|
||||
this.syncHandler = this.raw.sync(config.remoteUrl, {
|
||||
live: true,
|
||||
retry: true,
|
||||
});
|
||||
|
||||
// Fault-tolerant error handling — prevents app crashes when CouchDB is unreachable
|
||||
this.syncHandler.on('error', (err: unknown) => {
|
||||
console.warn(`[PouchDB Sync] Error on "${config.localName}":`, err);
|
||||
});
|
||||
|
||||
this.syncHandler.on('paused', (info: unknown) => {
|
||||
if (info) {
|
||||
console.warn(`[PouchDB Sync] Paused on "${config.localName}":`, info);
|
||||
}
|
||||
});
|
||||
|
||||
this.syncHandler.on('denied', (err: unknown) => {
|
||||
console.warn(`[PouchDB Sync] Denied on "${config.localName}":`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new document. If `_id` is not provided in data, PouchDB generates one.
|
||||
*/
|
||||
async create<T extends object = DefaultType>(data: T): Promise<PouchDB.Core.Response> {
|
||||
return this.raw.put(data as PouchDB.Core.Document<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing document by ID.
|
||||
* Automatically fetches the latest `_rev` to prevent conflict errors.
|
||||
*/
|
||||
async update<T extends object = DefaultType>(id: string, data: Partial<T>): Promise<PouchDB.Core.Response> {
|
||||
const existing = await this.raw.get(id);
|
||||
const merged = { ...existing, ...data };
|
||||
return this.raw.put(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document by ID.
|
||||
* Automatically fetches the latest `_rev` before removal.
|
||||
*/
|
||||
async delete(id: string): Promise<PouchDB.Core.Response> {
|
||||
const doc = await this.raw.get(id);
|
||||
return this.raw.remove(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single document by ID.
|
||||
*/
|
||||
async getOne<T = DefaultType>(id: string): Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta> {
|
||||
return this.raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all documents from the database.
|
||||
* Returns a clean array of document objects (excludes PouchDB design docs).
|
||||
*/
|
||||
async getAll<T = DefaultType>(): Promise<T[]> {
|
||||
const result = await this.raw.allDocs({ include_docs: true });
|
||||
return result.rows
|
||||
.filter((row) => !row.id.startsWith('_design/'))
|
||||
.map((row) => row.doc as unknown as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve multiple documents by their IDs.
|
||||
* Returns a clean array of found documents (silently skips missing/errored entries).
|
||||
*/
|
||||
async getSome<T = DefaultType>(ids: string[]): Promise<T[]> {
|
||||
const result = await this.raw.allDocs({ keys: ids, include_docs: true });
|
||||
return result.rows
|
||||
.filter((row): row is PouchDB.Core.AllDocsResponse<object>['rows'][number] => !('error' in row) && !!(row as any).doc)
|
||||
.map((row) => (row as any).doc as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query documents using MongoDB-style selectors (powered by `pouchdb-find`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const electronics = await itemDB.find({ selector: { category: 'electronics' } });
|
||||
* const expensive = await itemDB.find({ selector: { price: { $gt: 100 } }, limit: 10 });
|
||||
* ```
|
||||
*/
|
||||
async find<T extends object = DefaultType>(options: PouchDB.Find.FindRequest<T>): Promise<T[]> {
|
||||
const result = await this.raw.find(options as PouchDB.Find.FindRequest<object>);
|
||||
return result.docs as unknown as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all documents from the database while keeping the database itself intact.
|
||||
* Useful for "clear cache" or "reset local data" flows.
|
||||
*/
|
||||
async cleanAllData(): Promise<void> {
|
||||
const result = await this.raw.allDocs();
|
||||
const deletions = result.rows
|
||||
.filter((row) => !row.id.startsWith('_design/'))
|
||||
.map((row) => ({
|
||||
_id: row.id,
|
||||
_rev: row.value.rev,
|
||||
_deleted: true as const,
|
||||
}));
|
||||
|
||||
if (deletions.length > 0) {
|
||||
await this.raw.bulkDocs(deletions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any active sync and completely destroy the local database.
|
||||
* After calling this, the wrapper instance should not be used again.
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
if (this.syncHandler) {
|
||||
this.syncHandler.cancel();
|
||||
}
|
||||
if (this.changesFeed) {
|
||||
this.changesFeed.cancel();
|
||||
}
|
||||
this.listeners.clear();
|
||||
await this.raw.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the live sync connection (if active) without destroying the database.
|
||||
*/
|
||||
cancelSync(): void {
|
||||
if (this.syncHandler) {
|
||||
this.syncHandler.cancel();
|
||||
this.syncHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time changes in the database.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onChange(callback: () => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
|
||||
if (!this.changesFeed) {
|
||||
this.changesFeed = this.raw.changes({
|
||||
since: 'now',
|
||||
live: true,
|
||||
include_docs: true
|
||||
}).on('change', () => {
|
||||
this.listeners.forEach(cb => cb());
|
||||
}).on('error', (err) => {
|
||||
console.warn(`[PouchDB Listener Error]`, err);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PouchDatabaseManager ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* IoC Factory for managing multiple PouchDB database instances with optional
|
||||
* bi-directional CouchDB synchronization.
|
||||
*
|
||||
* `register()` returns a `PouchDatabaseWrapper` with full CRUD + query helpers,
|
||||
* so developers never need to pass `dbName` into each operation.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dbManager = new PouchDatabaseManager();
|
||||
*
|
||||
* const itemDB = dbManager.register({ localName: 'items' });
|
||||
* await itemDB.create({ _id: 'item-001', name: 'Widget', price: 9.99 });
|
||||
*
|
||||
* const results = await itemDB.find({ selector: { price: { $gt: 5 } } });
|
||||
* console.log(results); // [{ _id: 'item-001', name: 'Widget', price: 9.99, ... }]
|
||||
*
|
||||
* const salesDB = dbManager.register({
|
||||
* localName: 'sales',
|
||||
* remoteUrl: 'http://admin:pass@localhost:5984/sales_db',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class PouchDatabaseManager {
|
||||
private databases = new Map<string, PouchDatabaseWrapper<any>>();
|
||||
|
||||
/**
|
||||
* Register and initialize a new PouchDB database.
|
||||
* If a database with the same `localName` already exists, returns the existing wrapper.
|
||||
*
|
||||
* @param config - Configuration for the database instance.
|
||||
* @returns A `PouchDatabaseWrapper` with CRUD + query helpers.
|
||||
*/
|
||||
register<T extends object = any>(config: PouchConfig): PouchDatabaseWrapper<T> {
|
||||
if (this.databases.has(config.localName)) {
|
||||
return this.databases.get(config.localName) as PouchDatabaseWrapper<T>;
|
||||
}
|
||||
|
||||
const wrapper = new PouchDatabaseWrapper<T>(config);
|
||||
this.databases.set(config.localName, wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a previously registered database wrapper by its local name.
|
||||
*/
|
||||
get<T extends object = any>(localName: string): PouchDatabaseWrapper<T> | undefined {
|
||||
return this.databases.get(localName) as PouchDatabaseWrapper<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a specific registered database and remove it from the manager.
|
||||
*/
|
||||
async destroy(localName: string): Promise<void> {
|
||||
const wrapper = this.databases.get(localName);
|
||||
if (!wrapper) return;
|
||||
await wrapper.destroy();
|
||||
this.databases.delete(localName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all managed databases. Useful for logout/cleanup scenarios.
|
||||
*/
|
||||
async destroyAll(): Promise<void> {
|
||||
const names = Array.from(this.databases.keys());
|
||||
await Promise.all(names.map((name) => this.destroy(name)));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all currently registered database names.
|
||||
*/
|
||||
listDatabases(): string[] {
|
||||
return Array.from(this.databases.keys());
|
||||
}
|
||||
}
|
||||
+172
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+21
-19
@@ -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();
|
||||
Reference in New Issue
Block a user