# 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. Persisted data is treated as belonging to the authenticated user that created it. In the browser runtime, IndexedDB is user-scoped: the renderer opens a per-user database for the active account and switches scopes during authentication so one account never boots into another account's stored rooms, messages, or settings. ## 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. Those values use user-scoped storage keys so each account restores its own resume state instead of overwriting another user's snapshot. ## 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. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. 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.