Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
691
docs/architecture/STORAGE-LAYER.md
Normal file
691
docs/architecture/STORAGE-LAYER.md
Normal file
@@ -0,0 +1,691 @@
|
||||
# Storage Layer Architecture
|
||||
|
||||
> Internal reference for the ArchiTools storage abstraction layer.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools uses an adapter-based storage abstraction that decouples all module data operations from the underlying persistence mechanism. The default adapter uses `localStorage` for zero-infrastructure demo/development mode. The architecture supports swapping to REST API, MinIO, or database backends without changing module code.
|
||||
|
||||
**Key principle:** No module may import `localStorage`, `fetch` to a storage endpoint, or any persistence API directly. All data access flows through the `StorageService` interface via the `useStorage` hook.
|
||||
|
||||
---
|
||||
|
||||
## StorageService Interface
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/types.ts
|
||||
|
||||
interface StorageService {
|
||||
/** Retrieve a single item by namespace and key. Returns null if not found. */
|
||||
get<T>(namespace: string, key: string): Promise<T | null>;
|
||||
|
||||
/** Persist a value under namespace/key. Overwrites existing value. */
|
||||
set<T>(namespace: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/** Remove a single item. No-op if key does not exist. */
|
||||
delete(namespace: string, key: string): Promise<void>;
|
||||
|
||||
/** List all keys within a namespace. Returns empty array if namespace is empty. */
|
||||
list(namespace: string): Promise<string[]>;
|
||||
|
||||
/** Query all items in a namespace, filtering by predicate. */
|
||||
query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]>;
|
||||
|
||||
/** Remove all data within a namespace. */
|
||||
clear(namespace: string): Promise<void>;
|
||||
|
||||
/** Export entire namespace as a key-value record. Used for migration and backup. */
|
||||
export(namespace: string): Promise<Record<string, unknown>>;
|
||||
|
||||
/** Import a key-value record into a namespace. Merges with existing data by default. */
|
||||
import(namespace: string, data: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
All methods are async. Even `LocalStorageAdapter` returns promises to maintain interface compatibility and allow drop-in replacement with async backends.
|
||||
|
||||
---
|
||||
|
||||
## Adapter Pattern
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Module code
|
||||
↓
|
||||
useStorage(namespace) hook
|
||||
↓
|
||||
StorageProvider context
|
||||
↓
|
||||
StorageService interface
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ LocalStorageAdapter (default) │
|
||||
│ ApiStorageAdapter (future) │
|
||||
│ MinioAdapter (future) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Adapter Implementations
|
||||
|
||||
#### `LocalStorageAdapter`
|
||||
|
||||
**File:** `src/lib/storage/adapters/local-storage.ts`
|
||||
|
||||
- Default adapter, used in demo mode and local development.
|
||||
- Client-only. Must not be invoked during SSR; all calls should be guarded or deferred to `useEffect`.
|
||||
- Storage key format: `architools::{namespace}::{key}`
|
||||
- Values are JSON-serialized via `JSON.stringify` / `JSON.parse`.
|
||||
- Enforces per-namespace size quota (default: 2 MB per namespace, configurable).
|
||||
- Total localStorage budget: 5 MB (browser limit). The adapter tracks usage and throws `StorageQuotaExceededError` when limits are hit.
|
||||
|
||||
```typescript
|
||||
// Key format
|
||||
function storageKey(namespace: string, key: string): string {
|
||||
return `architools::${namespace}::${key}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Size tracking:**
|
||||
|
||||
```typescript
|
||||
// Approximate size calculation for quota enforcement
|
||||
function byteSize(value: string): number {
|
||||
return new Blob([value]).size;
|
||||
}
|
||||
```
|
||||
|
||||
#### `ApiStorageAdapter` (Future)
|
||||
|
||||
**File:** `src/lib/storage/adapters/api-storage.ts`
|
||||
|
||||
- Communicates with a REST API backend (`STORAGE_API_URL`).
|
||||
- Endpoint mapping:
|
||||
- `GET /storage/{namespace}/{key}` — get
|
||||
- `PUT /storage/{namespace}/{key}` — set
|
||||
- `DELETE /storage/{namespace}/{key}` — delete
|
||||
- `GET /storage/{namespace}` — list
|
||||
- `POST /storage/{namespace}/query` — query (sends predicate as filter object, not function)
|
||||
- `DELETE /storage/{namespace}` — clear
|
||||
- `GET /storage/{namespace}/export` — export
|
||||
- `POST /storage/{namespace}/import` — import
|
||||
- Authentication: Bearer token from Authentik session (when auth is enabled).
|
||||
- Query method: The API adapter cannot serialize JavaScript predicates. Instead, `query` fetches all items and filters client-side, or accepts a structured filter object for server-side filtering.
|
||||
|
||||
#### `MinioAdapter` (Future)
|
||||
|
||||
**File:** `src/lib/storage/adapters/minio-storage.ts`
|
||||
|
||||
- For file/object storage (signatures, stamps, templates, uploaded documents).
|
||||
- Not a general key-value store; used alongside `ApiStorageAdapter` for binary assets.
|
||||
- Object path format: `{bucket}/{namespace}/{key}`
|
||||
- Presigned URLs for browser uploads/downloads.
|
||||
- Server-side only (access keys must not be exposed to client).
|
||||
|
||||
---
|
||||
|
||||
## Namespace Isolation
|
||||
|
||||
Every module operates within its own namespace. Namespaces are string identifiers that partition data to prevent collisions.
|
||||
|
||||
### Assigned Namespaces
|
||||
|
||||
| Module | Namespace |
|
||||
|---------------------|-----------------------|
|
||||
| Dashboard | `dashboard` |
|
||||
| Registratura | `registratura` |
|
||||
| Email Signature | `email-signature` |
|
||||
| Word XML Generators | `word-xml` |
|
||||
| Digital Signatures | `digital-signatures` |
|
||||
| Password Vault | `password-vault` |
|
||||
| IT Inventory | `it-inventory` |
|
||||
| Address Book | `address-book` |
|
||||
| Prompt Generator | `prompt-generator` |
|
||||
| Word Templates | `word-templates` |
|
||||
| Tag Manager | `tag-manager` |
|
||||
| Mini Utilities | `mini-utilities` |
|
||||
| AI Chat | `ai-chat` |
|
||||
| System/Settings | `system` |
|
||||
|
||||
### Namespace Rules
|
||||
|
||||
1. Namespaces are lowercase kebab-case strings.
|
||||
2. A module must never read or write to another module's namespace directly.
|
||||
3. Cross-module data access happens through shared services (e.g., the tag service reads from `tag-manager` namespace on behalf of any module).
|
||||
4. The `system` namespace is reserved for platform-level configuration (theme preference, sidebar state, feature flag overrides).
|
||||
|
||||
---
|
||||
|
||||
## StorageProvider and useStorage Hook
|
||||
|
||||
### StorageProvider
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/provider.tsx
|
||||
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
interface StorageContextValue {
|
||||
service: StorageService;
|
||||
}
|
||||
|
||||
const StorageContext = createContext<StorageContextValue | null>(null);
|
||||
|
||||
interface StorageProviderProps {
|
||||
adapter?: StorageService;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StorageProvider({ adapter, children }: StorageProviderProps) {
|
||||
const service = useMemo(() => {
|
||||
return adapter ?? createDefaultAdapter();
|
||||
}, [adapter]);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={{ service }}>
|
||||
{children}
|
||||
</StorageContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `StorageProvider` wraps the application in `src/app/layout.tsx`. Adapter selection is determined at startup based on the `NEXT_PUBLIC_STORAGE_ADAPTER` environment variable.
|
||||
|
||||
### Adapter Factory
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/factory.ts
|
||||
|
||||
export function createDefaultAdapter(): StorageService {
|
||||
const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER ?? 'localStorage';
|
||||
|
||||
switch (adapterType) {
|
||||
case 'localStorage':
|
||||
return new LocalStorageAdapter();
|
||||
case 'api':
|
||||
return new ApiStorageAdapter(process.env.STORAGE_API_URL!);
|
||||
default:
|
||||
throw new Error(`Unknown storage adapter: ${adapterType}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `useStorage` Hook
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/hooks.ts
|
||||
|
||||
export function useStorage<T = unknown>(namespace: string) {
|
||||
const context = useContext(StorageContext);
|
||||
if (!context) {
|
||||
throw new Error('useStorage must be used within StorageProvider');
|
||||
}
|
||||
const { service } = context;
|
||||
|
||||
return useMemo(() => ({
|
||||
get: (key: string) => service.get<T>(namespace, key),
|
||||
set: (key: string, value: T) => service.set<T>(namespace, key, value),
|
||||
delete: (key: string) => service.delete(namespace, key),
|
||||
list: () => service.list(namespace),
|
||||
query: (predicate: (item: T) => boolean) => service.query<T>(namespace, predicate),
|
||||
clear: () => service.clear(namespace),
|
||||
export: () => service.export(namespace),
|
||||
import: (data: Record<string, unknown>) => service.import(namespace, data),
|
||||
}), [service, namespace]);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in a module:**
|
||||
|
||||
```typescript
|
||||
function RegistraturaPage() {
|
||||
const storage = useStorage<RegistryEntry>('registratura');
|
||||
|
||||
async function loadEntries() {
|
||||
const keys = await storage.list();
|
||||
// ...
|
||||
}
|
||||
|
||||
async function saveEntry(entry: RegistryEntry) {
|
||||
await storage.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Serialization
|
||||
|
||||
### Rules
|
||||
|
||||
1. All structured data is serialized as JSON via `JSON.stringify`.
|
||||
2. Dates are stored as ISO 8601 strings (`"2025-03-15T10:30:00.000Z"`), never as `Date` objects.
|
||||
3. Binary data (images, files) is not stored in the key-value layer. Use MinIO or base64-encoded strings (for small assets under 100 KB only, e.g., signature images in `digital-signatures`).
|
||||
4. `undefined` values are stripped during serialization. Use `null` for intentional absence.
|
||||
5. Keys must be valid URL path segments: lowercase alphanumeric, hyphens, and underscores only.
|
||||
|
||||
### Validation
|
||||
|
||||
```typescript
|
||||
const KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
function validateKey(key: string): void {
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new StorageKeyError(`Invalid storage key: "${key}"`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### localStorage to API Backend
|
||||
|
||||
The export/import methods on `StorageService` enable namespace-level data migration.
|
||||
|
||||
**Migration flow:**
|
||||
|
||||
```
|
||||
1. User triggers export from admin panel
|
||||
2. For each namespace:
|
||||
a. Call oldAdapter.export(namespace) → JSON record
|
||||
b. Download as backup file (optional)
|
||||
c. Call newAdapter.import(namespace, data)
|
||||
d. Verify item count matches
|
||||
3. Switch NEXT_PUBLIC_STORAGE_ADAPTER to 'api'
|
||||
4. Restart application
|
||||
```
|
||||
|
||||
**Migration script (admin utility):**
|
||||
|
||||
```typescript
|
||||
async function migrateStorage(
|
||||
source: StorageService,
|
||||
target: StorageService,
|
||||
namespaces: string[]
|
||||
): Promise<MigrationReport> {
|
||||
const report: MigrationReport = { namespaces: [], errors: [] };
|
||||
|
||||
for (const ns of namespaces) {
|
||||
try {
|
||||
const data = await source.export(ns);
|
||||
const keyCount = Object.keys(data).length;
|
||||
await target.import(ns, data);
|
||||
const verifyKeys = await target.list(ns);
|
||||
|
||||
report.namespaces.push({
|
||||
namespace: ns,
|
||||
exported: keyCount,
|
||||
imported: verifyKeys.length,
|
||||
success: keyCount === verifyKeys.length,
|
||||
});
|
||||
} catch (error) {
|
||||
report.errors.push({ namespace: ns, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
```
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
Each namespace should store a `_meta` key containing schema version:
|
||||
|
||||
```typescript
|
||||
interface NamespaceMeta {
|
||||
schemaVersion: number;
|
||||
lastMigration: string; // ISO 8601
|
||||
itemCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
On application startup, the storage layer checks `_meta.schemaVersion` and runs registered migration functions if the version is behind.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_STORAGE_ADAPTER` | `localStorage` | Active adapter: `localStorage`, `api` |
|
||||
| `STORAGE_API_URL` | — | REST API endpoint (for `api` adapter) |
|
||||
| `MINIO_ENDPOINT` | — | MinIO server address |
|
||||
| `MINIO_ACCESS_KEY` | — | MinIO access key (server-side only) |
|
||||
| `MINIO_SECRET_KEY` | — | MinIO secret key (server-side only) |
|
||||
| `MINIO_BUCKET` | `architools` | MinIO bucket name |
|
||||
|
||||
Variables prefixed with `NEXT_PUBLIC_` are available on the client. MinIO credentials are server-side only and must never be exposed to the browser.
|
||||
|
||||
---
|
||||
|
||||
## Size Limits and Quotas
|
||||
|
||||
### localStorage Adapter
|
||||
|
||||
| Limit | Value | Notes |
|
||||
|---|---|---|
|
||||
| Browser total | ~5 MB | Varies by browser. Chrome/Firefox: 5 MB per origin. |
|
||||
| Per-namespace soft limit | 2 MB | Configurable in adapter constructor. |
|
||||
| Per-value max size | 1 MB | Prevents accidental storage of large blobs. |
|
||||
| Key length max | 128 characters | Including namespace prefix. |
|
||||
|
||||
When a quota is exceeded, the adapter throws `StorageQuotaExceededError`. Module code must handle this gracefully — typically by showing a user-facing error and suggesting data export/cleanup.
|
||||
|
||||
### Quota Enforcement
|
||||
|
||||
```typescript
|
||||
class LocalStorageAdapter implements StorageService {
|
||||
private namespaceQuota: number; // bytes, default 2 * 1024 * 1024
|
||||
|
||||
async set<T>(namespace: string, key: string, value: T): Promise<void> {
|
||||
const serialized = JSON.stringify(value);
|
||||
const size = byteSize(serialized);
|
||||
|
||||
if (size > this.maxValueSize) {
|
||||
throw new StorageQuotaExceededError(
|
||||
`Value exceeds max size: ${size} bytes (limit: ${this.maxValueSize})`
|
||||
);
|
||||
}
|
||||
|
||||
const currentUsage = await this.getNamespaceSize(namespace);
|
||||
if (currentUsage + size > this.namespaceQuota) {
|
||||
throw new StorageQuotaExceededError(
|
||||
`Namespace "${namespace}" quota exceeded: ${currentUsage + size} bytes (limit: ${this.namespaceQuota})`
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey(namespace, key), serialized);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/errors.ts
|
||||
|
||||
export class StorageError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageKeyError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'INVALID_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageQuotaExceededError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'QUOTA_EXCEEDED');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageNotFoundError extends StorageError {
|
||||
constructor(namespace: string, key: string) {
|
||||
super(`Key "${key}" not found in namespace "${namespace}"`, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageNetworkError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'NETWORK_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageSerializationError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'SERIALIZATION_ERROR');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**In adapters:** Adapters catch raw errors (JSON parse failures, network timeouts, `QuotaExceededError` from the browser) and re-throw as typed `StorageError` subclasses.
|
||||
|
||||
**In hooks/modules:** Use try/catch with specific error type checks:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await storage.set(entry.id, entry);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageQuotaExceededError) {
|
||||
toast.error('Spațiul de stocare este plin. Exportați datele vechi.');
|
||||
} else if (error instanceof StorageNetworkError) {
|
||||
toast.error('Eroare de conexiune. Încercați din nou.');
|
||||
} else {
|
||||
toast.error('Eroare la salvare.');
|
||||
console.error('[Storage]', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Patterns
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
Used for fast UI response when data loss risk is low (e.g., updating a tag, toggling a flag).
|
||||
|
||||
```typescript
|
||||
function useOptimisticUpdate<T extends BaseEntity>(namespace: string) {
|
||||
const storage = useStorage<T>(namespace);
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
|
||||
async function update(id: string, patch: Partial<T>) {
|
||||
// 1. Apply optimistically to local state
|
||||
const previous = [...items];
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, ...patch, updatedAt: new Date().toISOString() } : item
|
||||
));
|
||||
|
||||
try {
|
||||
// 2. Persist to storage
|
||||
const current = await storage.get(id);
|
||||
if (current) {
|
||||
await storage.set(id, { ...current, ...patch, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
} catch (error) {
|
||||
// 3. Rollback on failure
|
||||
setItems(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { items, setItems, update };
|
||||
}
|
||||
```
|
||||
|
||||
### Pessimistic Updates
|
||||
|
||||
Used when data integrity is critical (e.g., registry entries, password vault). The UI waits for confirmation before updating local state.
|
||||
|
||||
```typescript
|
||||
async function saveEntry(entry: RegistryEntry) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await storage.set(entry.id, entry);
|
||||
// Only update UI after successful persistence
|
||||
setEntries(prev => [...prev, entry]);
|
||||
} catch (error) {
|
||||
// UI remains unchanged; show error
|
||||
handleStorageError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
| Scenario | Pattern | Reason |
|
||||
|---|---|---|
|
||||
| Tag edits, dashboard widget reorder | Optimistic | Low risk, frequent interaction |
|
||||
| Registry entry creation | Pessimistic | Data integrity matters |
|
||||
| Password vault writes | Pessimistic | Security-sensitive |
|
||||
| Bulk import | Pessimistic with progress | Large operation, needs feedback |
|
||||
| Settings/preferences | Optimistic | Trivial to re-apply |
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### localStorage Adapter
|
||||
|
||||
No caching layer needed. `localStorage` is synchronous and in-memory in the browser. The async wrapper adds negligible overhead.
|
||||
|
||||
### API Adapter (Future)
|
||||
|
||||
The API adapter should implement a write-through cache:
|
||||
|
||||
```typescript
|
||||
class ApiStorageAdapter implements StorageService {
|
||||
private cache: Map<string, { value: unknown; timestamp: number }> = new Map();
|
||||
private cacheTTL = 30_000; // 30 seconds
|
||||
|
||||
async get<T>(namespace: string, key: string): Promise<T | null> {
|
||||
const cacheKey = `${namespace}::${key}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
||||
return cached.value as T;
|
||||
}
|
||||
|
||||
const result = await this.fetchFromApi<T>(namespace, key);
|
||||
if (result !== null) {
|
||||
this.cache.set(cacheKey, { value: result, timestamp: Date.now() });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async set<T>(namespace: string, key: string, value: T): Promise<void> {
|
||||
await this.putToApi(namespace, key, value);
|
||||
// Write-through: update cache immediately
|
||||
this.cache.set(`${namespace}::${key}`, { value, timestamp: Date.now() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cache invalidation happens on `set`, `delete`, and `clear`. The `query` method always bypasses cache and fetches fresh data.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Tab Synchronization
|
||||
|
||||
### Problem
|
||||
|
||||
When using `localStorage`, multiple browser tabs may read stale data if one tab writes and another has already loaded its state.
|
||||
|
||||
### Solution
|
||||
|
||||
The `LocalStorageAdapter` listens to the `storage` event, which fires when another tab modifies `localStorage`:
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/adapters/local-storage.ts
|
||||
|
||||
class LocalStorageAdapter implements StorageService {
|
||||
private listeners: Map<string, Set<(key: string) => void>> = new Map();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', this.handleStorageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStorageEvent = (event: StorageEvent) => {
|
||||
if (!event.key?.startsWith('architools::')) return;
|
||||
|
||||
const [, namespace, key] = event.key.split('::');
|
||||
const nsListeners = this.listeners.get(namespace);
|
||||
if (nsListeners) {
|
||||
nsListeners.forEach(listener => listener(key));
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe to changes in a namespace from other tabs. */
|
||||
onExternalChange(namespace: string, callback: (key: string) => void): () => void {
|
||||
if (!this.listeners.has(namespace)) {
|
||||
this.listeners.set(namespace, new Set());
|
||||
}
|
||||
this.listeners.get(namespace)!.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.listeners.get(namespace)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', this.handleStorageEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Integration
|
||||
|
||||
```typescript
|
||||
export function useStorageSync(namespace: string, onSync: () => void) {
|
||||
const { service } = useContext(StorageContext)!;
|
||||
|
||||
useEffect(() => {
|
||||
if ('onExternalChange' in service) {
|
||||
const unsubscribe = (service as LocalStorageAdapter).onExternalChange(
|
||||
namespace,
|
||||
() => onSync()
|
||||
);
|
||||
return unsubscribe;
|
||||
}
|
||||
}, [service, namespace, onSync]);
|
||||
}
|
||||
```
|
||||
|
||||
Modules that display lists (registratura, address book) should call `useStorageSync` to re-fetch data when another tab makes changes.
|
||||
|
||||
### Limitations
|
||||
|
||||
- The `storage` event only fires in _other_ tabs, not the tab that made the change.
|
||||
- The API adapter does not support cross-tab sync natively. If needed, it would require WebSocket or polling.
|
||||
- Cross-tab sync is best-effort, not transactional. Two tabs writing the same key simultaneously can cause last-write-wins conflicts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/lib/storage/
|
||||
├── types.ts # StorageService interface, NamespaceMeta
|
||||
├── errors.ts # Error classes
|
||||
├── factory.ts # createDefaultAdapter()
|
||||
├── provider.tsx # StorageProvider context
|
||||
├── hooks.ts # useStorage, useStorageSync
|
||||
├── constants.ts # Namespace list, quota defaults
|
||||
└── adapters/
|
||||
├── local-storage.ts # LocalStorageAdapter
|
||||
├── api-storage.ts # ApiStorageAdapter (stub)
|
||||
└── minio-storage.ts # MinioAdapter (stub)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Adapters are tested with a shared test suite that runs against the `StorageService` interface. Swap the adapter instance to test each implementation identically.
|
||||
- `LocalStorageAdapter` tests use a mock `localStorage` (e.g., `jest-localstorage-mock` or a simple in-memory Map).
|
||||
- Integration tests verify namespace isolation: writing to namespace A must never affect namespace B.
|
||||
- Quota tests verify that `StorageQuotaExceededError` is thrown at the correct thresholds.
|
||||
Reference in New Issue
Block a user