# 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(namespace: string, key: string): Promise; /** Persist a value under namespace/key. Overwrites existing value. */ set(namespace: string, key: string, value: T): Promise; /** Remove a single item. No-op if key does not exist. */ delete(namespace: string, key: string): Promise; /** List all keys within a namespace. Returns empty array if namespace is empty. */ list(namespace: string): Promise; /** Query all items in a namespace, filtering by predicate. */ query(namespace: string, predicate: (item: T) => boolean): Promise; /** Remove all data within a namespace. */ clear(namespace: string): Promise; /** Export entire namespace as a key-value record. Used for migration and backup. */ export(namespace: string): Promise>; /** Import a key-value record into a namespace. Merges with existing data by default. */ import(namespace: string, data: Record): Promise; } ``` 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(null); interface StorageProviderProps { adapter?: StorageService; children: React.ReactNode; } export function StorageProvider({ adapter, children }: StorageProviderProps) { const service = useMemo(() => { return adapter ?? createDefaultAdapter(); }, [adapter]); return ( {children} ); } ``` 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(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(namespace, key), set: (key: string, value: T) => service.set(namespace, key, value), delete: (key: string) => service.delete(namespace, key), list: () => service.list(namespace), query: (predicate: (item: T) => boolean) => service.query(namespace, predicate), clear: () => service.clear(namespace), export: () => service.export(namespace), import: (data: Record) => service.import(namespace, data), }), [service, namespace]); } ``` **Usage in a module:** ```typescript function RegistraturaPage() { const storage = useStorage('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 { 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(namespace: string, key: string, value: T): Promise { 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(namespace: string) { const storage = useStorage(namespace); const [items, setItems] = useState([]); async function update(id: string, patch: Partial) { // 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 = new Map(); private cacheTTL = 30_000; // 30 seconds async get(namespace: string, key: string): Promise { 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(namespace, key); if (result !== null) { this.cache.set(cacheKey, { value: result, timestamp: Date.now() }); } return result; } async set(namespace: string, key: string, value: T): Promise { 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 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.