128 lines
6.9 KiB
Markdown
128 lines
6.9 KiB
Markdown
# 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.
|