Files
Toju/toju-app/src/app/infrastructure/persistence/README.md
Myx bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00

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.