6.1 KiB
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
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 | |
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).
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")<br/>.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.
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
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.