# Persistence Infrastructure Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime. ## Files ``` persistence/ ├── app-resume.storage.ts localStorage helpers for launch settings and last viewed chat ├── index.ts Barrel (exports DatabaseService and storage helpers) ├── database.service.ts Platform-agnostic facade ├── browser-database.service.ts IndexedDB backend (web) └── electron-database.service.ts IPC/SQLite backend (desktop) ``` `app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite. ## Platform routing ```mermaid graph TD Consumer[Store effects / facades / components] Consumer --> Facade[DatabaseService
facade] Facade -->|isBrowser?| Decision{Platform} Decision -- Browser --> IDB[BrowserDatabaseService
IndexedDB] Decision -- Electron --> IPC[ElectronDatabaseService
IPC to main process] IPC --> Main[Electron main process
TypeORM + SQLite] click Facade "database.service.ts" "DatabaseService - platform-agnostic facade" _blank click IDB "browser-database.service.ts" "IndexedDB backend for web" _blank click IPC "electron-database.service.ts" "IPC client for Electron" _blank ``` `DatabaseService` is an `@Injectable({ providedIn: 'root' })` that injects both backends and delegates every call to whichever one matches the current platform. Consumers never import a backend directly. ## Object stores / tables Both backends store the same entity types: | Store | Key | Indexes | Description | |---|---|---|---| | `messages` | `id` | `roomId` | Chat messages, sorted by timestamp | | `users` | `oderId` | | User profiles | | `rooms` | `id` | | Server/room metadata | | `reactions` | `oderId-emoji-messageId` | | Emoji reactions, deduplicated per user | | `bans` | `oderId` | | Active bans per room | | `attachments` | `id` | | File/image metadata tied to messages | | `meta` | `key` | | Key-value pairs (e.g. `currentUserId`) | The IndexedDB schema is at version 2. The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative mixed text-and-voice channel list from server-directory responses so every member converges on the same room structure. ## How the two backends differ ### Browser (IndexedDB) All operations run inside IndexedDB transactions in the renderer thread. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string). ```mermaid sequenceDiagram participant Eff as NgRx Effect participant DB as DatabaseService participant BDB as BrowserDatabaseService participant IDB as IndexedDB Eff->>DB: getMessages(roomId, 50) DB->>BDB: getMessages(roomId, 50) BDB->>IDB: tx.objectStore("messages")
.index("roomId").getAll(roomId) IDB-->>BDB: Message[] Note over BDB: Sort by timestamp, slice, normalise BDB-->>DB: Message[] DB-->>Eff: Message[] ``` ### Electron (SQLite via IPC) The renderer sends structured command/query objects through the Electron preload bridge. The main process handles them with TypeORM against a local SQLite file. No database logic runs in the renderer. The Electron schema now normalises reaction rows and room channel/member rosters into separate SQLite tables instead of storing those arrays inline on the parent message or room rows. The renderer-facing API is unchanged: CQRS handlers rehydrate the same `Message` and `Room` payloads before returning them over IPC. ```mermaid sequenceDiagram participant Eff as NgRx Effect participant DB as DatabaseService participant EDB as ElectronDatabaseService participant IPC as Preload Bridge participant Main as Main Process
TypeORM + SQLite Eff->>DB: saveMessage(msg) DB->>EDB: saveMessage(msg) EDB->>IPC: api.command({type: "save-message", payload: {message}}) IPC->>Main: ipcRenderer.invoke Main-->>IPC: void IPC-->>EDB: Promise resolves EDB-->>DB: void DB-->>Eff: void ``` The Electron backend's `initialize()` is a no-op because the main process creates the database before the renderer window opens. ## API surface Every method on `DatabaseService` maps 1:1 to both backends: **Messages**: `saveMessage`, `getMessages`, `getMessageById`, `deleteMessage`, `updateMessage`, `clearRoomMessages` **Reactions**: `saveReaction`, `removeReaction`, `getReactionsForMessage` **Users**: `saveUser`, `getUser`, `getCurrentUser`, `setCurrentUserId`, `getUsersByRoom`, `updateUser` **Rooms**: `saveRoom`, `getRoom`, `getAllRooms`, `deleteRoom`, `updateRoom` **Bans**: `saveBan`, `removeBan`, `getBansForRoom`, `isUserBanned` **Attachments**: `saveAttachment`, `getAttachmentsForMessage`, `getAllAttachments`, `deleteAttachmentsForMessage` **Lifecycle**: `initialize`, `clearAllData` The facade also exposes an `isReady` signal that flips to `true` after `initialize()` completes, so components can gate rendering until the DB is available.