# Persistence Infrastructure

Offline-first storage layer that keeps messages, users, rooms, reactions, custom emoji, 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<br/>facade]
    Facade -->|isBrowser?| Decision{Platform}
    Decision -- Browser --> IDB[BrowserDatabaseService<br/>IndexedDB]
    Decision -- Electron --> IPC[ElectronDatabaseService<br/>IPC to main process]
    IPC --> Main[Electron main process<br/>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 |
| `customEmojis` / `custom_emojis` | `id` | `updatedAt`, `creatorUserId` | Known custom emoji image assets synced over peer data channels; `savedByUser` controls picker/library membership |
| `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 3.

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, optionally filter to a text channel, 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, 0, channelId?)
    DB->>BDB: getMessages(roomId, 50, 0, channelId?)
    BDB->>IDB: tx.objectStore("messages")<br/>.index("roomId").getAll(roomId)
    IDB-->>BDB: Message[]
    Note over BDB: Optional channel filter, sort, 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.

Electron room membership is user-scoped through `room_owners`, and messages carry `ownerUserId`. Auth setup writes the current user ID to the database before room loading, so the discovery pages (`/dashboard`, `/servers`), the server rail, and local history only hydrate rooms/messages owned by the active account. A room row can still hold shared server metadata for the same server ID, but each account has its own ownership edge and message history.

```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<br/>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`

**Custom emoji**: `saveCustomEmoji`, `getCustomEmojis`, `deleteCustomEmoji`

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