# 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.