2026-07-19 12:18:20 +00:00
# Data Model
**Verified against:** commit `ddc49f0` , 2026-07-19
The canonical schema is the ordered migration set `Server/migrations/001– 015`
(embedded via `go:embed` , applied by the custom runner in `Server/db/migrate.go` ,
tracked in the `schema_versions` table). SQLite is the only supported engine —
`Server/main.go` rejects any other `database.type` at startup.
## D5 — Entity-relationship overview
All 23 application tables, grouped by domain. Junction/leaf detail columns are
elided; the goal is the relationship graph, not full DDL (see `docs/schema.md`
for DDL — note it is currently 6 migrations behind, see
[audit-2026-07-19.md §2 ](../audit-2026-07-19.md )).
```mermaid
erDiagram
%% ── Identity & access ──
roles ||--o{ users : "role_id"
users ||--o{ sessions : "user_id"
roles ||--o{ channel_overrides : "role_id"
channels ||--o{ channel_overrides : "channel_id"
users ||--o{ user_blocks : "blocker_id / blocked_id"
users ||--o{ invites : "created_by / redeemed_by"
%% ── Messaging ──
channels ||--o{ messages : "channel_id"
users ||--o{ messages : "user_id"
messages ||--o{ messages : "reply_to"
messages ||--o{ attachments : "message_id"
messages ||--o{ reactions : "message_id"
users ||--o{ reactions : "user_id"
users ||--o{ read_states : "user_id"
channels ||--o{ read_states : "channel_id"
%% ── Direct messages (channels with type='dm') ──
channels ||--o{ dm_participants : "channel_id"
users ||--o{ dm_participants : "user_id"
channels ||--o{ dm_open_state : "channel_id"
users ||--o{ dm_open_state : "user_id"
%% ── Voice ──
users ||--o| voice_states : "user_id (PK)"
channels ||--o{ voice_states : "channel_id"
%% ── Plugins ──
plugins ||--o{ plugin_kv : "plugin_id"
%% ── Standalone (no FK edges) ──
events
settings
audit_log
login_attempts
rate_lockouts
emoji
sounds
users {
int id PK
int role_id FK
string username
string password_hash
bool banned
}
roles {
int id PK
string name
int permissions "31-bit bitfield"
int position
}
channels {
int id PK
string name
string type "text | voice | dm (trigger-enforced)"
}
messages {
int id PK
int channel_id FK
int user_id FK
int reply_to FK
string content
}
attachments {
string id PK "UUID"
int message_id FK
int uploader_id "added by 010"
}
events {
int seq PK "AUTOINCREMENT, hub seq seeded from MAX(seq)"
string type
string payload
}
```
### Domain notes
| Domain | Tables | Notes |
|--------|--------|-------|
| Identity & access | `roles` , `users` , `sessions` , `channel_overrides` , `user_blocks` , `invites` , `login_attempts` , `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions` ; channel overrides use Discord semantics `(role &^ deny) \| allow` . `rate_lockouts` (011) persists rate-limiter lockouts across restarts. |
2026-07-19 16:00:18 +00:00
| Messaging | `channels` , `messages` , `attachments` , `reactions` , `read_states` , `emoji` | `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement` ). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. |
2026-07-19 12:18:20 +00:00
| Direct messages | `dm_participants` , `dm_open_state` | DMs are `channels` rows with `type='dm'` ; these tables track membership and per-user open/closed UI state (009). |
| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md ](websocket.md )); written by the async `EventPersister` , pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
| Plugins | `plugins` , `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)` . |
2026-07-19 13:51:44 +00:00
| Ops | `settings` , `audit_log` , `sounds` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting` ). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. |
2026-07-19 12:18:20 +00:00
2026-07-19 16:33:58 +00:00
### How the schema is accessed
2026-07-19 12:18:20 +00:00
2026-07-19 16:33:58 +00:00
The `Server/db` package is the single data layer. Its methods live in
`*_queries.go` and mostly delegate to the sqlc-generated `Server/db/dbgen` code
(D2), so sqlc is the type-checked query layer rather than dead generated code; a
documented remainder of raw queries stays hand-written where sqlc can't express
them (variable-length `IN` lists, FTS, multi-statement transactions,
PRAGMA/VACUUM) — see [plans/sqlc-adoption.md ](../plans/sqlc-adoption.md ).
2026-07-19 12:18:20 +00:00
2026-07-19 16:33:58 +00:00
Consumers depend on narrow interfaces that `*db.DB` satisfies rather than on the
concrete type: `service.Store` (the service layer), `ws.EventStore` (cold-tier
replay), and `plugin.PluginStore` (plugin registry). D3 removed the former
`store` package (a pass-through `SQLiteStore` plus a `MemStore` test double);
tests now run against a real in-memory SQLite `db` . The residual style issue is
that many `Server/api` handlers and the `Server/admin` package still take a raw
`*db.DB` directly instead of going through the service layer — the remaining
consolidation work (audit A-2026-07-06).
2026-07-19 12:18:20 +00:00
**Source of truth:** `Server/migrations/*.sql` (schema), `Server/db/migrate.go`
2026-07-19 16:33:58 +00:00
(runner), `Server/db/*_queries.go` (live queries), `Server/service/datastore.go`
(service interface), `sqlc.yaml` + `Server/db/dbgen/` (generated query layer).