feat(core-storage): add PouchEnvelope tests and LocalStorageService implementation

- Introduced tests for PouchEnvelope to validate envelope-aware CRUD operations using an in-memory PouchDB.
- Implemented LocalStorageService with encryption support for sensitive keys, including tests for setItem, getItem, removeItem, and clear methods.
- Defined a generic storage interface (IStorageService) to enforce type-safe serialization/deserialization across storage implementations.
This commit is contained in:
Firman Ramdhani
2026-07-06 11:38:37 +07:00
parent 7130eb3fe3
commit d1ce292e3c
17 changed files with 1568 additions and 215 deletions
+98 -3
View File
@@ -1,12 +1,13 @@
import { useEffect, useState, useCallback } from 'react';
import { Button, Card, Group, Stack, Title, Text, Table, Badge } from '@repo/ui/components';
import { itemDB, posConfigDB } from '../../core/storage/pouch-db';
import { itemDB, posConfigDB, newItemDB } from '../../core/storage/pouch-db';
import type { ItemEntity, POSConfigurationEntity } from '../../core/storage/pouch-db/entities';
export default function PouchSample() {
const [configs, setConfigs] = useState<POSConfigurationEntity[]>([]);
const [items, setItems] = useState<ItemEntity[]>([]);
const [newItems, setNewItems] = useState<ItemEntity[]>([]);
// Load initial data
const loadData = useCallback(async () => {
@@ -16,7 +17,13 @@ export default function PouchSample() {
const allItems = await itemDB.find({ selector: {} });
setItems(allItems);
console.log({ allConfigs, allItems });
// We can use getAll() or find() on Envelope DB. It's automatically scoped.
const allNewItems = await newItemDB.getAll();
console.log('allNewItems', allNewItems);
setNewItems(allNewItems as ItemEntity[]);
console.log({ allConfigs, allItems, allNewItems });
} catch (err) {
console.error('Failed to load PouchDB data', err);
}
@@ -35,10 +42,15 @@ export default function PouchSample() {
loadData();
});
const unsubscribeNewItems = newItemDB.onChange(() => {
loadData();
});
// 3. CRITICAL: Cleanup to prevent memory leaks on unmount
return () => {
unsubscribeItems();
unsubscribePos();
unsubscribeNewItems();
};
}, [loadData]);
@@ -102,10 +114,40 @@ export default function PouchSample() {
}
};
// ─── New Items Envelope Handlers ────────────────────────────────
const handleAddNewItem = async () => {
try {
const id = `new-item-${Date.now()}`;
await newItemDB.create({
_id: id,
name: 'ENVELOPE ITEM TEST',
base_price: '100000',
item_type: 'souvenir',
usage_type: 'retail',
item_category: [{ name: 'Merchandise' }],
});
loadData();
} catch (err) {
console.error('Failed to add new item', err);
}
};
const handleDeleteNewItem = async (id: string) => {
try {
await newItemDB.delete(id);
loadData();
} catch (err) {
console.error('Failed to delete new item', err);
}
};
const handleClearAll = async () => {
try {
await posConfigDB.cleanAllData();
await itemDB.cleanAllData();
await newItemDB.cleanAllData();
loadData();
} catch (err) {
console.error('Failed to clear data', err);
@@ -124,7 +166,7 @@ export default function PouchSample() {
{/* Items Inventory Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
<Title order={4}>Items Database</Title>
<Title order={4}>Items Database (Standard)</Title>
<Button onClick={handleAddItem} color="success">
Inject Mock ERP Item
</Button>
@@ -174,6 +216,59 @@ export default function PouchSample() {
</div>
</Card>
{/* New Items Envelope Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
<Title order={4}>New Items Database (Envelope Pattern)</Title>
<Button onClick={handleAddNewItem} color="brand">
Inject Mock Envelope Item
</Button>
</Group>
<div className="max-h-[400px] overflow-y-auto border border-gray-200 rounded-lg scrollbar-thin scrollbar-thumb-gray-300">
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">ID</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Name</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Type</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Base Price</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Rates Count</Table.Th>
<Table.Th className="sticky top-0 bg-white z-10 shadow-sm">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{newItems.length > 0 ? (
newItems.map((item) => (
<Table.Tr key={item._id}>
<Table.Td>{item._id}</Table.Td>
<Table.Td>{item.name}</Table.Td>
<Table.Td>
<Badge color="violet" variant="light">
{item.item_type}
</Badge>
</Table.Td>
<Table.Td>${Number(item.base_price).toFixed(2)}</Table.Td>
<Table.Td>{item.item_rates?.length || 0}</Table.Td>
<Table.Td>
<Button size="xs" color="error" variant="subtle" onClick={() => handleDeleteNewItem(item._id)}>
Delete
</Button>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={6} align="center">
<Text c="dimmed">No items found.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
</Card>
{/* POS Configuration Table */}
<Card withBorder shadow="sm" radius="md" p="md">
<Group justify="space-between" mb="md">
@@ -1,2 +1,3 @@
export * from './item.pouchdb.entity';
export * from './pos-configuration.pouchdb.entity';
export * from './new-item.pouchdb.entity';
@@ -0,0 +1,51 @@
export interface SeasonType {
name: string;
}
export interface SeasonPeriod {
start_date: string;
end_date: string;
season_type: SeasonType;
}
export interface ItemRate {
id: string;
price: string;
season_period: SeasonPeriod;
}
export interface ItemCategory {
id: string;
name: string;
}
/**
* Detailed interface for an Item entity, based on the provided JSON sample.
* This represents the business data payload (`data` property in the envelope).
*/
export interface NewItemData {
id: string;
creator_id: string;
creator_name: string;
created_at: string;
updated_at: string;
status: 'active' | 'inactive' | string;
item_type: string;
hpp: string;
sales_margin: string;
share_profit: string;
total_price: number;
base_price: string;
play_estimation: number;
use_queue: boolean;
show_to_booking: boolean;
breakdown_bundling: boolean;
limit_type: string;
limit_value: number;
item_category_id: string;
item_category: ItemCategory;
item_rates: ItemRate[];
price: string;
qty: number;
name: string;
}
+14 -4
View File
@@ -3,16 +3,17 @@
*
* This module demonstrates the IoC pattern: the consuming app decides
* which databases to create and where they sync to. The core engine
* (`PouchDatabaseManager`) has zero knowledge of business domains.
* (`PouchDBManager` and `PouchEnvelopeManager`) has zero knowledge of business domains.
*/
import { ENV } from '../../environment';
import { PouchDatabaseManager } from '@repo/core-storage';
import { PouchDBManager, PouchEnvelopeDBManager } from '@repo/core-storage';
import { ItemEntity, POSConfigurationEntity } from './entities';
// ─── Manager Singleton ──────────────────────────────────────────
// ─── Manager Singletons ──────────────────────────────────────────
export const dbManager = new PouchDatabaseManager();
export const dbManager = new PouchDBManager();
export const envelopeDbManager = new PouchEnvelopeDBManager();
// ─── Helper: Build Secure Remote URL ────────────────────────────
@@ -54,3 +55,12 @@ export const itemDB = dbManager.register<ItemEntity>({
localName: 'item',
remoteUrl: buildRemoteUrl('item'),
});
/** New Items database using the Envelope Service pattern. */
export const newItemDB = envelopeDbManager.register(
{
localName: 'master_data',
remoteUrl: buildRemoteUrl('master_data'),
},
'item',
);