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)
|
## 🔄 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
|
### Architecture
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ graph TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph CoreStorage ["@repo/core-storage Engine"]
|
subgraph CoreStorage ["@repo/core-storage Engine"]
|
||||||
MGR[PouchDatabaseManager Factory]
|
MGR[PouchDBManager Factory]
|
||||||
L_SALES[(Local PouchDB: Sales)]
|
L_SALES[(Local PouchDB: Sales)]
|
||||||
L_INV[(Local PouchDB: Inventory)]
|
L_INV[(Local PouchDB: Inventory)]
|
||||||
end
|
end
|
||||||
@@ -168,13 +168,13 @@ graph TD
|
|||||||
|
|
||||||
### 1. Initialization (IoC Factory)
|
### 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
|
```typescript
|
||||||
import { PouchDatabaseManager } from '@repo/core-storage';
|
import { PouchDBManager } from '@repo/core-storage';
|
||||||
import type { Item } from './types';
|
import type { Item } from './types';
|
||||||
|
|
||||||
export const dbManager = new PouchDatabaseManager();
|
export const dbManager = new PouchDBManager();
|
||||||
|
|
||||||
export const itemDB = dbManager.register<Item>({
|
export const itemDB = dbManager.register<Item>({
|
||||||
localName: 'items_db',
|
localName: 'items_db',
|
||||||
@@ -184,7 +184,7 @@ export const itemDB = dbManager.register<Item>({
|
|||||||
|
|
||||||
### 2. CRUD & MongoDB-style Queries
|
### 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 |
|
| 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's and ❌ Don'ts for PouchDB
|
||||||
|
|
||||||
* **✅ DO use `.onChange()`** to make your UI reactive to background cloud syncs.
|
* **✅ DO use `.onChange()`** to make your UI reactive to background cloud syncs.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
|
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';
|
import type { ItemEntity, POSConfigurationEntity } from '../../core/storage/pouch-db/entities';
|
||||||
|
|
||||||
export default function PouchSample() {
|
export default function PouchSample() {
|
||||||
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
|
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
|
||||||
const [items, setItems] = useState<ItemEntity[]>([]);
|
const [items, setItems] = useState<ItemEntity[]>([]);
|
||||||
|
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -16,7 +17,13 @@ export default function PouchSample() {
|
|||||||
|
|
||||||
const allItems = await itemDB.find({ selector: {} });
|
const allItems = await itemDB.find({ selector: {} });
|
||||||
setItems(allItems);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load PouchDB data', err);
|
console.error('Failed to load PouchDB data', err);
|
||||||
}
|
}
|
||||||
@@ -35,10 +42,15 @@ export default function PouchSample() {
|
|||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unsubscribeNewItems = newItemDB.onChange(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
|
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeItems();
|
unsubscribeItems();
|
||||||
unsubscribePos();
|
unsubscribePos();
|
||||||
|
unsubscribeNewItems();
|
||||||
};
|
};
|
||||||
}, [loadData]);
|
}, [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 () => {
|
const handleClearAll = async () => {
|
||||||
try {
|
try {
|
||||||
await posConfigDB.cleanAllData();
|
await posConfigDB.cleanAllData();
|
||||||
await itemDB.cleanAllData();
|
await itemDB.cleanAllData();
|
||||||
|
await newItemDB.cleanAllData();
|
||||||
loadData();
|
loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear data', err);
|
console.error('Failed to clear data', err);
|
||||||
@@ -124,7 +166,7 @@ export default function PouchSample() {
|
|||||||
{/* Items Inventory Table */}
|
{/* Items Inventory Table */}
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
<Card withBorder shadow="sm" radius="md" p="md">
|
||||||
<Group justify="space-between" mb="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">
|
<Button onClick={handleAddItem} color="success">
|
||||||
Inject Mock ERP Item
|
Inject Mock ERP Item
|
||||||
</Button>
|
</Button>
|
||||||
@@ -174,6 +216,59 @@ export default function PouchSample() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* POS Configuration Table */}
|
||||||
<Card withBorder shadow="sm" radius="md" p="md">
|
<Card withBorder shadow="sm" radius="md" p="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './item.pouchdb.entity';
|
export * from './item.pouchdb.entity';
|
||||||
export * from './pos-configuration.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
|
* This module demonstrates the IoC pattern: the consuming app decides
|
||||||
* which databases to create and where they sync to. The core engine
|
* 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 { ENV } from '../../environment';
|
||||||
import { PouchDatabaseManager } from '@repo/core-storage';
|
import { PouchDBManager, PouchEnvelopeDBManager } from '@repo/core-storage';
|
||||||
|
|
||||||
import { ItemEntity, POSConfigurationEntity } from './entities';
|
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 ────────────────────────────
|
// ─── Helper: Build Secure Remote URL ────────────────────────────
|
||||||
|
|
||||||
@@ -54,3 +55,12 @@ export const itemDB = dbManager.register<ItemEntity>({
|
|||||||
localName: 'item',
|
localName: 'item',
|
||||||
remoteUrl: buildRemoteUrl('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 ─────────────────────────────────────────────────
|
// ─── Interfaces ─────────────────────────────────────────────────
|
||||||
export type { IStorageService } from './storage.interface';
|
export type { IStorageService } from './types/storage.interface';
|
||||||
export type { StorageOptions } from './local-storage.service';
|
export type { StorageOptions } from './local-storage/local-storage.service';
|
||||||
export type { IndexedDBConfig } from './indexed-db.service';
|
export type { IndexedDBConfig } from './indexed-db/indexed-db.service';
|
||||||
export type { PouchConfig } from './pouch';
|
export type { PouchConfig, CouchEnvelope, FlatDoc, SyncStatus } from './pouch-db';
|
||||||
|
|
||||||
// ─── Service Classes ────────────────────────────────────────────
|
// ─── Service Classes ────────────────────────────────────────────
|
||||||
export { LocalStorageService, createLocalStorage } from './local-storage.service';
|
export { LocalStorageService, createLocalStorage } from './local-storage/local-storage.service';
|
||||||
export { IndexedDBService, createIndexedDB } from './indexed-db.service';
|
export { IndexedDBService, createIndexedDB } from './indexed-db/indexed-db.service';
|
||||||
export { PouchDatabaseManager, PouchDatabaseWrapper } from './pouch';
|
export { PouchDBManager, PouchEnvelopeDBManager, PouchService, PouchEnvelopeService } from './pouch-db';
|
||||||
|
|||||||
+8
-22
@@ -1,6 +1,6 @@
|
|||||||
import { EncryptionUtils } from '@repo/utils';
|
import { EncryptionUtils } from '@repo/utils';
|
||||||
import type { IStorageService } from './storage.interface';
|
import type { IStorageService } from '../types/storage.interface';
|
||||||
import type { StorageOptions } from './local-storage.service';
|
import type { StorageOptions } from '../local-storage/local-storage.service';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────
|
// ─── 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.
|
* Open (or create) an IndexedDB database with a simple key-value store.
|
||||||
* Returns a Promise that resolves with the IDBDatabase instance.
|
* Returns a Promise that resolves with the IDBDatabase instance.
|
||||||
*/
|
*/
|
||||||
function openDatabase(
|
function openDatabase(dbName: string, storeName: string, version: number): Promise<IDBDatabase> {
|
||||||
dbName: string,
|
|
||||||
storeName: string,
|
|
||||||
version: number,
|
|
||||||
): Promise<IDBDatabase> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(dbName, version);
|
const request = indexedDB.open(dbName, version);
|
||||||
|
|
||||||
@@ -104,13 +100,9 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
|
|||||||
this.validateKey(key);
|
this.validateKey(key);
|
||||||
const db = await this.getDB();
|
const db = await this.getDB();
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
const payload = this.shouldEncrypt(key)
|
const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized;
|
||||||
? this.encryption.encrypt(serialized)
|
|
||||||
: serialized;
|
|
||||||
|
|
||||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string));
|
||||||
store.put(payload, key as string),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getItem<T>(key: TKey): Promise<T | null> {
|
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> {
|
async removeItem(key: TKey): Promise<void> {
|
||||||
this.validateKey(key);
|
this.validateKey(key);
|
||||||
const db = await this.getDB();
|
const db = await this.getDB();
|
||||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string));
|
||||||
store.delete(key as string),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
const db = await this.getDB();
|
const db = await this.getDB();
|
||||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear());
|
||||||
store.clear(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasItem(key: TKey): Promise<boolean> {
|
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>(
|
export function createIndexedDB<TKey extends string>(config: IndexedDBConfig<TKey>): IStorageService<TKey> {
|
||||||
config: IndexedDBConfig<TKey>
|
|
||||||
): IStorageService<TKey> {
|
|
||||||
return new IndexedDBService<TKey>(config);
|
return new IndexedDBService<TKey>(config);
|
||||||
}
|
}
|
||||||
+2
-4
@@ -1,5 +1,5 @@
|
|||||||
import { EncryptionUtils } from '@repo/utils';
|
import { EncryptionUtils } from '@repo/utils';
|
||||||
import type { IStorageService } from './storage.interface';
|
import type { IStorageService } from '../types/storage.interface';
|
||||||
|
|
||||||
export interface StorageOptions<TKey extends string> {
|
export interface StorageOptions<TKey extends string> {
|
||||||
encryptedKeys?: Set<TKey>;
|
encryptedKeys?: Set<TKey>;
|
||||||
@@ -84,8 +84,6 @@ export class LocalStorageService<TKey extends string> implements IStorageService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalStorage<TKey extends string>(
|
export function createLocalStorage<TKey extends string>(options?: StorageOptions<TKey>): IStorageService<TKey> {
|
||||||
options?: StorageOptions<TKey>
|
|
||||||
): IStorageService<TKey> {
|
|
||||||
return new LocalStorageService<TKey>(options);
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+174
-15
@@ -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 PouchDB from 'pouchdb-core';
|
||||||
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
|
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
|
||||||
import PouchDBFind from 'pouchdb-find';
|
import PouchDBFind from 'pouchdb-find';
|
||||||
@@ -10,35 +10,64 @@ PouchDB.plugin(PouchDBFind);
|
|||||||
PouchDB.plugin(PouchDBMapReduce);
|
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`.
|
* 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) {
|
function createTestDB(name: string) {
|
||||||
const raw = new PouchDB(name, { adapter: 'memory' });
|
const raw = new PouchDB(name, { adapter: 'memory' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
|
|
||||||
|
// ─── CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async create<T extends object>(data: T) {
|
async create<T extends object>(data: T) {
|
||||||
return raw.put(data as PouchDB.Core.Document<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>) {
|
async update<T extends object>(id: string, data: Partial<T>) {
|
||||||
const existing = await raw.get(id);
|
const existing = await raw.get(id);
|
||||||
const merged = { ...existing, ...data };
|
const merged = { ...existing, ...data };
|
||||||
return raw.put(merged);
|
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) {
|
async delete(id: string) {
|
||||||
const doc = await raw.get(id);
|
const doc = await raw.get(id);
|
||||||
return raw.remove(doc);
|
return raw.remove(doc);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Reads ────────────────────────────────────────────────
|
||||||
|
|
||||||
async getOne<T>(id: string) {
|
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>() {
|
async getAll<T>() {
|
||||||
@@ -56,10 +85,14 @@ function createTestDB(name: string) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async find<T extends object>(options: PouchDB.Find.FindRequest<T>) {
|
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[];
|
return result.docs as unknown as T[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Bulk ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async cleanAllData() {
|
async cleanAllData() {
|
||||||
const result = await raw.allDocs();
|
const result = await raw.allDocs();
|
||||||
const deletions = result.rows
|
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() {
|
async destroy() {
|
||||||
|
if (this.changesFeed) {
|
||||||
|
this.changesFeed.cancel();
|
||||||
|
this.changesFeed = null;
|
||||||
|
}
|
||||||
|
this.listeners.clear();
|
||||||
await raw.destroy();
|
await raw.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PouchDatabaseWrapper CRUD Operations', () => {
|
// ─── Test Suite ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PouchBase — Core CRUD Operations', () => {
|
||||||
let db: ReturnType<typeof createTestDB>;
|
let db: ReturnType<typeof createTestDB>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Use a unique name per test to avoid cross-contamination
|
db = createTestDB(
|
||||||
db = createTestDB(`test_db_${Date.now()}_${Math.random().toString(36).slice(2)}`);
|
`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -107,7 +170,39 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
|
|||||||
|
|
||||||
it('should throw a conflict if creating with a duplicate _id', async () => {
|
it('should throw a conflict if creating with a duplicate _id', async () => {
|
||||||
await db.create({ _id: 'dup-001', name: 'First' });
|
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 () => {
|
it('should retrieve a document by id', async () => {
|
||||||
await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 });
|
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._id).toBe('fetch-001');
|
||||||
expect(doc.product).toBe('Widget');
|
expect(doc.product).toBe('Widget');
|
||||||
expect(doc.price).toBe(9.99);
|
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' });
|
const res = await db.update('upd-001', { count: 42, extra: 'field' });
|
||||||
expect(res.ok).toBe(true);
|
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.name).toBe('Original'); // untouched
|
||||||
expect(updated.count).toBe(42); // updated
|
expect(updated.count).toBe(42); // updated
|
||||||
expect(updated.extra).toBe('field'); // newly added
|
expect(updated.extra).toBe('field'); // newly added
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when updating a non-existent document', async () => {
|
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 ───────────────────────────────────────────────────
|
// ─── delete ───────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
@@ -254,4 +385,32 @@ describe('PouchDatabaseWrapper CRUD Operations', () => {
|
|||||||
expect(all).toHaveLength(1);
|
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 { 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 ───────────────────────────
|
// ─── Mock @repo/utils EncryptionUtils ───────────────────────────
|
||||||
|
|
||||||
@@ -21,11 +21,21 @@ const store: Record<string, string> = {};
|
|||||||
|
|
||||||
const mockLocalStorage: Storage = {
|
const mockLocalStorage: Storage = {
|
||||||
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
||||||
setItem: vi.fn((key: string, value: string): void => { store[key] = value; }),
|
setItem: vi.fn((key: string, value: string): void => {
|
||||||
removeItem: vi.fn((key: string): void => { delete store[key]; }),
|
store[key] = value;
|
||||||
clear: vi.fn((): void => { for (const key of Object.keys(store)) delete store[key]; }),
|
}),
|
||||||
get length() { return Object.keys(store).length; },
|
removeItem: vi.fn((key: string): void => {
|
||||||
key(index: number): string | null { return Object.keys(store)[index] ?? null; },
|
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
|
// Install mock
|
||||||
@@ -52,10 +62,7 @@ const ENCRYPTED_KEYS = new Set<TestStorageKeyValue>([
|
|||||||
TestStorageKey.USER_PROFILE,
|
TestStorageKey.USER_PROFILE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PLAIN_KEYS = new Set<TestStorageKeyValue>([
|
const PLAIN_KEYS = new Set<TestStorageKeyValue>([TestStorageKey.THEME, TestStorageKey.LOCALE]);
|
||||||
TestStorageKey.THEME,
|
|
||||||
TestStorageKey.LOCALE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface TestUser {
|
interface TestUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,7 +83,7 @@ describe('LocalStorageService', () => {
|
|||||||
// Pass mock encryption utils to avoid importing real crypto-js
|
// Pass mock encryption utils to avoid importing real crypto-js
|
||||||
storage = new LocalStorageService<TestStorageKeyValue>(
|
storage = new LocalStorageService<TestStorageKeyValue>(
|
||||||
{ encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS },
|
{ 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
|
// Cast a rogue key to bypass TS for the runtime check test
|
||||||
const rogueKey = 'unregistered_key' as TestStorageKeyValue;
|
const rogueKey = 'unregistered_key' as TestStorageKeyValue;
|
||||||
await expect(storage.setItem(rogueKey, 'data')).rejects.toThrowError(
|
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');
|
await storage.setItem(TestStorageKey.LOCALE, 'en-US');
|
||||||
|
|
||||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LOCALE, '"en-US"');
|
||||||
TestStorageKey.LOCALE,
|
|
||||||
'"en-US"',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
|
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
|
||||||
@@ -156,9 +160,7 @@ describe('LocalStorageService', () => {
|
|||||||
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse key'));
|
||||||
expect.stringContaining('Failed to parse key'),
|
|
||||||
);
|
|
||||||
// Corrupt entry should be cleaned up
|
// Corrupt entry should be cleaned up
|
||||||
expect(store[TestStorageKey.THEME]).toBeUndefined();
|
expect(store[TestStorageKey.THEME]).toBeUndefined();
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
Reference in New Issue
Block a user