Spec Documentation (18 files, 680KB): - Expanded all 15 existing spec files with deep detail from source code - Created 3 new specs: DM-SYSTEM, THEME-SYSTEM, RECONNECTION - Created E2E-BEST-PRACTICES spec - Audited all specs against source: fixed 50 errors Unit Tests (143 new): - Go: dm_queries_test (21), dm_handler_test (17), dm_handlers_test (18), ringbuffer_test (22) - TS: dm-store (16), disposable (14), themes security (17), ws reconnection (8), dispatcher DM (2) E2E Tests (22 mocked + 6 native specs): - New: dm-system, theme-persistence, reconnection (mocked + native) - Fixed 12 fake assertions, 18 hardcoded timeouts, 5 stale selectors - Persistent fixture: login once per run instead of per test - ensureLoggedIn with exponential backoff for rate limiting Security Fixes: - DM auth bypass: added IsDMParticipant to handleGetPins, handleSetPinned, handleSearch - LiveKit InsecureVerifier replaced with PinnedVerifier (TOFU from shared cert store) - IDOR leak: handleChatEdit/Delete now return opaque error codes - CSS injection: added deny-list for dangerous CSS functions in themes - BANNED error now triggers logout instead of infinite reconnect - CredFree leak fixed: Windows credential memory freed before parsing - Login lockout off-by-one: limit=9 so 10th failure triggers lockout Stability Fixes: - Rate limiter StartCleanup goroutine now started (prevents memory leak) - Voice mute/deafen rate limiting added (2/sec, matching camera/screenshare) - DM typing no longer echoes back to sender - Accept loop spin protection (5 consecutive error limit) - voice_config protocol drift resolved (3 missing fields added) - Login rate limit set to 60/min (spec updated, 10-failure lockout is real protection) - Hardcoded roleNameToId replaced with dynamic lookup from ready payload Co-Authored-By: claude-flow <ruv@ruv.net>
56 KiB
Database Schema (SQLite) -- Comprehensive Reference
Single file: data/chatserver.db. Pure-Go driver modernc.org/sqlite (no CGO).
Migrations run automatically on startup via db.Migrate().
Table of Contents
- Database Configuration
- Migration System
- Sentinel Errors
- Tables
- Indexes
- Permission Bitfield System
- Queries by Domain
- Query Patterns
Database Configuration
The database is opened and configured in db.Open(). Every connection has
the following PRAGMAs applied at startup before any queries execute:
| PRAGMA | Value | Purpose |
|---|---|---|
journal_mode |
WAL |
Write-Ahead Logging. Allows concurrent readers while one writer operates. Dramatically improves read throughput for a single-writer system. |
foreign_keys |
ON |
Enforces all REFERENCES constraints. Without this, SQLite silently ignores foreign keys. |
busy_timeout |
5000 |
Waits up to 5 seconds for the write lock instead of returning SQLITE_BUSY immediately. Prevents transient failures under load. |
synchronous |
NORMAL |
Safe with WAL mode. Reduces fsync calls compared to FULL. Data is durable against application crashes; only an OS crash during a checkpoint could theoretically lose committed data. |
temp_store |
MEMORY |
Temporary tables and indices are stored in RAM instead of on disk. Speeds up sorting, grouping, and complex queries. |
mmap_size |
268435456 |
256 MB memory-mapped I/O. The OS maps the database file directly into the process address space, avoiding read() system calls for hot pages. |
cache_size |
-64000 |
Negative value = kilobytes. Sets the page cache to 64 MB (approximately 15,000 pages at 4 KB page size). Keeps frequently accessed pages in memory. |
Connection Pooling
sqlDB.SetMaxOpenConns(1)
SQLite only allows one writer at a time. The pool is pinned to a single
connection so concurrent goroutines queue on the Go side (via sync.Mutex
inside database/sql) rather than getting SQLITE_BUSY errors. For
:memory: databases this also guarantees all callers share the same state.
Reads and writes are serialized through this single connection. WAL mode still benefits performance because the single connection can read its own uncommitted data, and the OS-level mmap avoids redundant I/O.
Shutdown
On db.Close(), PRAGMA optimize is executed first. This lets SQLite
analyze and update query planner statistics based on actual usage during the
session, improving plan quality on the next startup.
Migration System
Source: db/migrate.go
Tracking Table
CREATE TABLE IF NOT EXISTS schema_versions (
version TEXT PRIMARY KEY, -- migration filename (e.g. "001_initial_schema.sql")
applied_at TEXT NOT NULL DEFAULT (datetime('now')) -- UTC timestamp of application
);
How It Works
- Collect all
.sqlfiles from the embeddedmigrations/FS, sorted lexicographically by filename. - Seed existing databases: If
schema_versionsdoes not exist butusersdoes, this is a pre-tracking database. All migration filenames are inserted intoschema_versionswithout executing the SQL, preventing destructive re-application. - Apply new migrations: For each
.sqlfile not yet inschema_versions:- Begin a transaction.
- Execute the SQL.
- Insert the filename into
schema_versionsinside the same transaction. - Commit. If the server crashes between commit and the next migration, the already-applied migration is recorded and will not re-run.
Migration History
| File | Description |
|---|---|
001_initial_schema.sql |
All core tables: roles, users, sessions, channels, channel_overrides, messages, messages_fts, attachments, reactions, invites, read_states, audit_log, login_attempts, settings, emoji, sounds. Default roles and settings inserted. |
002_voice_states.sql |
Adds voice_states table with user_id (PK), channel_id, muted, deafened, speaking, joined_at. Adds idx_voice_states_channel index. |
003_audit_log.sql |
Recreates audit_log with Phase-6 column names: actor_id (was user_id), detail (was details), created_at (was timestamp). Adds idx_audit_log_actor index. |
003_voice_optimization.sql |
Adds camera and screenshare columns to voice_states. Adds voice_max_users, voice_quality, mixing_threshold, voice_max_video to channels. |
004_fix_member_permissions.sql |
Fixes Member role: adds READ_MESSAGES, ATTACH_FILES, ADD_REACTIONS bits. New value: 0x663 (1635). |
005_channel_overrides_index.sql |
Adds composite index idx_channel_overrides_channel_role on (channel_id, role_id). |
006_member_video_permissions.sql |
Adds USE_VIDEO and SHARE_SCREEN to Member role. New value: 0x1E63 (7779). |
007_attachment_dimensions.sql |
Adds width and height nullable INTEGER columns to attachments. |
008_dm_tables.sql |
Adds dm_participants table (composite PK: channel_id, user_id) and dm_open_state table. Adds idx_dm_participants_user. |
Note: 003_audit_log.sql and 003_voice_optimization.sql both have the
003 prefix. Lexicographic ordering places 003_audit_log.sql before
003_voice_optimization.sql, so they apply in that order.
Sentinel Errors
Source: db/errors.go
The db package defines four sentinel errors. All query functions wrap
errors using fmt.Errorf("...: %w", err) so callers can check with
errors.Is():
| Error | Value | Usage |
|---|---|---|
ErrNotFound |
"not found" |
Resource does not exist (e.g., message not found on edit/delete, setting key missing). |
ErrForbidden |
"forbidden" |
Caller lacks permission (e.g., editing another user's message). |
ErrConflict |
"conflict" |
Uniqueness constraint violation (e.g., duplicate username on registration). |
ErrBanned |
"banned" |
User is banned from the server. |
Query functions return nil, nil (not ErrNotFound) when a lookup returns
no rows for GetUserByID, GetChannel, GetInvite, GetVoiceState, etc.
The ErrNotFound sentinel is used for operations that fail when the resource
is absent (e.g., EditMessage, DeleteMessage, SetMessagePinned,
UseInviteAtomic).
Tables
roles
Defines permission tiers. Created before users due to FK dependency.
CREATE TABLE roles (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- Stable numeric ID
name TEXT NOT NULL UNIQUE, -- Display name: "Owner", "Admin", etc.
color TEXT, -- Hex color (e.g., "#E74C3C") for UI display, NULL = default text color
permissions INTEGER NOT NULL DEFAULT 0, -- Bitfield of granted permissions (see Permission Bitfield section)
position INTEGER NOT NULL DEFAULT 0, -- Hierarchy rank: higher number = more authority. Used for role ordering and permission checks.
is_default INTEGER NOT NULL DEFAULT 0 -- 1 = auto-assigned to new users on registration. Exactly one role should have this set.
);
Default seed data (inserted via INSERT OR IGNORE in migration 001):
| id | name | color | permissions | position | is_default | Notes |
|---|---|---|---|---|---|---|
| 1 | Owner | #E74C3C |
0x7FFFFFFF (2147483647) |
100 | 0 | All 31 permission bits set. Highest authority. |
| 2 | Admin | #F39C12 |
0x3FFFFFFF (1073741823) |
80 | 0 | All bits except ADMINISTRATOR (bit 30). |
| 3 | Moderator | #3498DB |
0x000FFFFF (1048575) |
60 | 0 | Bits 0-19: all message, voice, and moderation permissions. |
| 4 | Member | NULL | 0x1E63 (7779) |
40 | 1 | SEND_MESSAGES, READ_MESSAGES, ATTACH_FILES, ADD_REACTIONS, CONNECT_VOICE, SPEAK_VOICE, USE_VIDEO, SHARE_SCREEN. Updated by migrations 004 and 006. |
users
Every registered account. The first user registered is assigned the Owner role by the registration handler (not by the schema).
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- Unique user identifier, monotonically increasing.
username TEXT NOT NULL UNIQUE COLLATE NOCASE, -- Display name. COLLATE NOCASE ensures case-insensitive uniqueness: "Alice" and "alice" cannot both exist. Lookups are also case-insensitive.
password TEXT NOT NULL, -- bcrypt hash (never plaintext). Generated during registration, verified during login.
avatar TEXT, -- Filename of avatar image in uploads/ directory, or NULL if no avatar is set.
role_id INTEGER NOT NULL DEFAULT 4 -- FK to roles.id. Default 4 = Member (the is_default role). Changed via admin role assignment.
REFERENCES roles(id),
totp_secret TEXT, -- Encrypted TOTP secret for 2FA. NULL = 2FA not enabled. When set, login requires a valid TOTP code.
status TEXT NOT NULL DEFAULT 'offline', -- Presence status. Valid values: "online", "idle", "dnd", "offline". Never "invisible". Updated on connect/disconnect/user action.
created_at TEXT NOT NULL DEFAULT (datetime('now')), -- ISO 8601 UTC registration timestamp.
last_seen TEXT, -- ISO 8601 UTC timestamp of last activity. Updated by UpdateUserStatus. NULL until first activity.
banned INTEGER NOT NULL DEFAULT 0, -- 0 = not banned, 1 = banned. Checked during session validation.
ban_reason TEXT, -- Human-readable ban reason shown to the user. NULL when not banned.
ban_expires TEXT -- ISO 8601 UTC expiry for temporary bans. NULL = permanent ban. Compared with strftime for auto-expiry.
);
Startup behavior: ResetAllUserStatuses() sets all non-offline users
to 'offline' on server start, clearing stale state from previous runs.
sessions
Token-based authentication. Each login creates a session; the token is sent
in the Authorization header (or WebSocket auth message).
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL -- FK to the owning user. ON DELETE CASCADE: banning/deleting a user removes all sessions.
REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- SHA-256 hash of the 256-bit random session token. The plaintext token is sent to the client; only the hash is stored. Compared via constant-time hash comparison.
device TEXT, -- User-Agent or client identifier string. Informational only, shown in session management.
ip_address TEXT, -- Client IP at session creation time. Used for audit/display.
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),-- Updated by TouchSession on each authenticated request.
expires_at TEXT NOT NULL -- 30 days from creation (sessionTTL = 30*24*time.Hour). Expired sessions are purged by DeleteExpiredSessions.
);
Session TTL: 30 days, defined as const sessionTTL = 30 * 24 * time.Hour
in models.go.
channels
All channel types (text, voice, announcement, dm) share this table. DM
channels have type = 'dm' and an empty name.
CREATE TABLE channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- Channel display name. Empty string for DM channels.
type TEXT NOT NULL DEFAULT 'text', -- Channel type: "text", "voice", "announcement", "dm". No CHECK constraint; validated at the application layer.
category TEXT, -- Category grouping name (e.g., "General", "Gaming"). NULL = uncategorized. Stored as NULL via nullableString() helper when empty.
topic TEXT, -- Channel description/topic. NULL = no topic set.
position INTEGER NOT NULL DEFAULT 0, -- Sort order within the channel list. Lower = higher in list. Channels with same position sort by id.
slow_mode INTEGER NOT NULL DEFAULT 0, -- Cooldown in seconds between user messages. 0 = disabled.
archived INTEGER NOT NULL DEFAULT 0, -- 0 = active, 1 = archived (read-only).
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0, -- Maximum users in voice channel. 0 = unlimited. Added in migration 003_voice_optimization.
voice_quality TEXT, -- "low", "medium", "high", or NULL (use server default from settings). Added in migration 003_voice_optimization.
mixing_threshold INTEGER, -- Audio mixing threshold override. NULL = use server default. Added in migration 003_voice_optimization.
voice_max_video INTEGER NOT NULL DEFAULT 25 -- Maximum simultaneous video streams. Added in migration 003_voice_optimization.
);
channel_overrides
Per-channel permission overrides for specific roles. Allows granting or revoking permissions beyond what the role's base permissions provide.
CREATE TABLE channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, -- Which channel this override applies to. Cascade-deletes when channel is removed.
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, -- Which role this override applies to. Cascade-deletes when role is removed.
allow INTEGER NOT NULL DEFAULT 0, -- Permission bits to GRANT in addition to role base. Bitwise OR with base permissions.
deny INTEGER NOT NULL DEFAULT 0, -- Permission bits to REVOKE from role base. Bitwise AND NOT with base permissions. Deny takes precedence over allow.
UNIQUE(channel_id, role_id) -- One override per channel-role pair. Enforced at the schema level.
);
Effective permission calculation (application layer):
effective = (base_permissions & ~deny) | allow
Deny is applied first (strips bits), then allow is applied (adds bits). Allow takes precedence when both target the same bit.
messages
All chat messages across all channel types (text, DM, announcement).
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- Monotonically increasing. Used for cursor-based pagination (before parameter) and read-state tracking.
channel_id INTEGER NOT NULL -- Channel this message belongs to.
REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id), -- Author. No ON DELETE CASCADE: messages persist if user is deleted (preserves history).
content TEXT NOT NULL, -- Message text. Sanitized before storage. May contain URLs (including GIF URLs from Tenor).
reply_to INTEGER -- ID of the message being replied to. NULL if not a reply.
REFERENCES messages(id) ON DELETE SET NULL,-- SET NULL: if the parent message is deleted, the reply becomes a standalone message.
edited_at TEXT, -- ISO 8601 UTC timestamp of last edit. NULL if never edited. Set by EditMessage.
deleted INTEGER NOT NULL DEFAULT 0, -- 0 = visible, 1 = soft-deleted. Soft-deleted messages are excluded from queries but retained in the database. UI shows "[message deleted]".
pinned INTEGER NOT NULL DEFAULT 0, -- 0 = not pinned, 1 = pinned. Pinned messages appear in the pins panel.
timestamp TEXT NOT NULL DEFAULT (datetime('now')) -- Creation timestamp. ISO 8601 UTC.
);
Soft deletion: Messages are never physically removed by user action.
DeleteMessage sets deleted = 1. All read queries filter with
WHERE deleted = 0 except GetMessage (which returns deleted messages
so the deletion event can be broadcast).
messages_fts (FTS5 Virtual Table)
Full-text search index synchronized with the messages table via triggers.
CREATE VIRTUAL TABLE messages_fts USING fts5(
content, -- Indexed column: message text content
content='messages', -- Content table: FTS5 reads content from the messages table (content-sync mode)
content_rowid='id' -- Row ID mapping: FTS5 rowid = messages.id
);
Content-sync mode: The FTS5 table does not store a copy of the content.
It stores only the inverted index (term positions, document frequencies).
When you query the FTS table, it joins back to messages via rowid = id
to retrieve the actual text. This saves storage but requires the triggers
below to keep the index consistent.
Synchronization triggers:
-- AFTER INSERT: Index new message content.
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
-- AFTER DELETE: Remove from index. The special 'delete' command tells FTS5
-- to remove the entry for the given rowid/content pair.
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
END;
-- AFTER UPDATE: Delete old entry, insert new. This handles message edits.
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
Search query (used by SearchMessages):
SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp
FROM messages_fts f
JOIN messages m ON f.rowid = m.id
JOIN channels c ON m.channel_id = c.id
JOIN users u ON m.user_id = u.id
WHERE messages_fts MATCH ? AND m.deleted = 0
ORDER BY rank LIMIT ?
The MATCH operator accepts FTS5 query syntax: simple terms, phrase
queries ("exact phrase"), prefix queries (term*), boolean operators
(AND, OR, NOT). The rank column is a built-in BM25 relevance
score (lower = more relevant).
attachments
File uploads linked to messages. Uses UUID primary keys (not AUTOINCREMENT).
CREATE TABLE attachments (
id TEXT PRIMARY KEY, -- UUID string generated by the upload handler. Text PK, not integer.
message_id INTEGER -- FK to the message this file is attached to. NULL during upload (before the message is sent). Linked by LinkAttachmentsToMessage after message creation.
REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- Original filename as uploaded by the user (e.g., "photo.jpg").
stored_as TEXT NOT NULL, -- UUID-based filename on disk in the uploads/ directory. Prevents collisions and path traversal.
mime_type TEXT NOT NULL, -- MIME type (e.g., "image/png", "application/pdf"). Set during upload based on content detection.
size INTEGER NOT NULL, -- File size in bytes. Validated against max_upload_bytes setting.
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
width INTEGER, -- Image width in pixels. NULL for non-image files. Added in migration 007.
height INTEGER -- Image height in pixels. NULL for non-image files. Added in migration 007.
);
Upload flow: Attachments are created with message_id = NULL during
the upload phase. When the user sends a message with attachment_ids,
LinkAttachmentsToMessage atomically links them using
WHERE message_id IS NULL to prevent double-linking in a race.
Orphan cleanup: DeleteOrphanedAttachments(cutoff) removes records
where message_id IS NULL AND uploaded_at < cutoff, returning stored
filenames for disk cleanup.
reactions
Emoji reactions on messages. One row per user-emoji-message combination.
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, -- Cascade: deleting a message removes its reactions.
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Cascade: deleting a user removes their reactions.
emoji TEXT NOT NULL, -- Unicode emoji character or custom emoji shortcode.
UNIQUE(message_id, user_id, emoji) -- Each user can react with a given emoji only once per message. Adding a duplicate fails with a constraint error.
);
invites
Server invite codes for user registration (registration is invite-only;
registration_open setting is always 0).
CREATE TABLE invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, -- Random 8-byte (16-char hex) code. Generated by generateInviteCode() using crypto/rand.
created_by INTEGER NOT NULL REFERENCES users(id), -- User who created the invite.
redeemed_by INTEGER REFERENCES users(id), -- Legacy column. Not used by current code (UseInviteAtomic increments use_count instead).
max_uses INTEGER, -- Maximum number of redemptions. NULL = unlimited uses.
use_count INTEGER NOT NULL DEFAULT 0, -- Current number of times this invite has been used.
expires_at TEXT, -- ISO 8601 UTC expiry. NULL = never expires.
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0 -- 0 = active, 1 = revoked by admin/creator. Revoked invites cannot be used.
);
Atomic use (UseInviteAtomic): A single UPDATE statement validates
all conditions and increments use_count atomically, preventing TOCTOU
races:
UPDATE invites SET use_count = use_count + 1
WHERE code = ? AND revoked = 0
AND (max_uses IS NULL OR use_count < max_uses)
AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))
If zero rows are affected, the invite is invalid (missing, revoked, expired, or exhausted).
read_states
Tracks per-user, per-channel read position for unread counting.
CREATE TABLE read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0, -- ID of the last message the user has "read". Messages with id > this value are considered unread.
mention_count INTEGER NOT NULL DEFAULT 0, -- Number of unread @mentions. Currently maintained but not actively used by the client.
PRIMARY KEY (user_id, channel_id) -- Composite PK: one read state per user per channel. Upserted via ON CONFLICT.
);
Upsert pattern (UpdateReadState):
INSERT INTO read_states (user_id, channel_id, last_message_id) VALUES (?, ?, ?)
ON CONFLICT(user_id, channel_id) DO UPDATE SET last_message_id = excluded.last_message_id
audit_log
Tracks administrative and security-relevant actions. Recreated in migration
003 with renamed columns (actor_id instead of user_id, detail instead
of details, created_at instead of timestamp).
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL DEFAULT 0, -- User who performed the action. 0 = system action. NOT a foreign key (actor may be deleted).
action TEXT NOT NULL, -- Action identifier: "user_ban", "user_unban", "channel_create", "channel_delete", "role_change", "invite_create", "invite_revoke", etc.
target_type TEXT NOT NULL DEFAULT '', -- Type of entity acted upon: "user", "channel", "message", "role", "invite".
target_id INTEGER NOT NULL DEFAULT 0, -- ID of the target entity.
detail TEXT NOT NULL DEFAULT '', -- JSON string with extra context (e.g., ban reason, old/new role names).
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Query: GetAuditLog LEFT JOINs users on actor_id to include the
actor's username (or empty string if the actor has been deleted).
login_attempts
Rate limiting for authentication. Tracks every login attempt by IP.
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL, -- Client IP address.
username TEXT, -- Username attempted. NULL for unknown/invalid usernames.
success INTEGER NOT NULL DEFAULT 0, -- 0 = failed, 1 = successful.
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
Note: The current auth system uses an in-memory sliding-window rate
limiter (auth/ratelimit.go) instead of querying this table. The table
exists in the schema but is not actively read by the rate limiter. Lockout
occurs after 10 consecutive failures from the same IP (15-minute lockout).
The table could be used for audit/forensics but is not cleaned up
automatically.
settings
Key-value store for server-wide configuration.
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
Default values (inserted by migration 001):
| Key | Default Value | Description |
|---|---|---|
server_name |
"OwnCord Server" |
Display name in the client UI. |
server_icon |
"" (empty) |
Server icon filename. |
motd |
"Welcome!" |
Message of the day shown to connecting clients. |
max_upload_bytes |
"26214400" |
25 MB upload limit (stored as string, parsed to int). |
voice_quality |
"high" |
Default voice quality for channels without an override. |
require_2fa |
"0" |
Whether TOTP 2FA is mandatory for all users. |
registration_open |
"0" |
Always 0. Registration requires an invite code. |
backup_schedule |
"daily" |
How often automatic backups run. |
backup_retention |
"7" |
Number of backup files to retain. |
schema_version |
"1" |
Legacy version counter (superseded by schema_versions table). |
Upsert pattern (SetSetting):
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
emoji
Custom server emoji (user-uploaded).
CREATE TABLE emoji (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shortcode TEXT NOT NULL UNIQUE, -- Emoji shortcode (e.g., ":pepe:"). Must be unique.
filename TEXT NOT NULL, -- File stored in uploads/emoji/ directory.
uploaded_by INTEGER NOT NULL REFERENCES users(id), -- User who uploaded the emoji.
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
sounds
Soundboard sounds for voice channels.
CREATE TABLE sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- Display name of the sound.
filename TEXT NOT NULL, -- File stored in uploads/sounds/ directory.
duration_ms INTEGER NOT NULL, -- Duration in milliseconds. Used for UI display and playback scheduling.
uploaded_by INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
voice_states
Tracks which voice channel each user is currently connected to and their
audio/video state. Ephemeral: all rows are deleted on server startup
via ClearAllVoiceStates() since voice connections do not survive restarts.
CREATE TABLE voice_states (
user_id INTEGER PRIMARY KEY -- One voice state per user (can only be in one voice channel at a time). PK = unique + indexed.
REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL -- The voice channel the user is connected to.
REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0, -- 0 = unmuted, 1 = muted (user self-mute or server mute).
deafened INTEGER NOT NULL DEFAULT 0, -- 0 = not deafened, 1 = deafened.
speaking INTEGER NOT NULL DEFAULT 0, -- 0 = silent, 1 = currently speaking. Updated by voice activity detection.
camera INTEGER NOT NULL DEFAULT 0, -- 0 = camera off, 1 = camera on. Added in migration 003_voice_optimization.
screenshare INTEGER NOT NULL DEFAULT 0, -- 0 = not sharing, 1 = screen sharing. Added in migration 003_voice_optimization.
joined_at TEXT NOT NULL DEFAULT (datetime('now'))-- When the user joined the voice channel. Used for ordering users in the channel.
);
Join behavior (JoinVoiceChannel): Uses INSERT ... ON CONFLICT DO UPDATE
to atomically join a channel. If the user is already in a different channel,
the row is replaced (all flags reset to 0, joined_at reset to now).
dm_participants
Links users to their shared DM channel. Each DM channel has exactly two participant rows.
CREATE TABLE dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id) -- Composite PK: one entry per user per DM channel.
);
Note: Migration 008 uses (channel_id, user_id) as the primary key. The
original SCHEMA.md described a different schema with user_a_id/user_b_id
columns and channel_id as PK -- the migration (source of truth) uses the
normalized form with separate rows per participant.
dm_open_state
Tracks whether a user has a DM channel visible in their sidebar. Users can close DMs (hiding them) without deleting the channel or its messages.
CREATE TABLE dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),-- When the DM was last opened. Used for sorting DMs by most recent activity when no messages exist.
PRIMARY KEY (user_id, channel_id)
);
Open/Close:
OpenDM:INSERT OR IGNORE-- idempotent, no-op if already open.CloseDM:DELETE-- removes the row.- Auto-reopen: When a message arrives in a closed DM, the server calls
OpenDMbefore broadcasting the message event.
schema_versions
Migration tracking table, created by migrate.go.
CREATE TABLE IF NOT EXISTS schema_versions (
version TEXT PRIMARY KEY, -- Migration filename (e.g., "001_initial_schema.sql").
applied_at TEXT NOT NULL DEFAULT (datetime('now')) -- UTC timestamp when the migration was applied.
);
Indexes
Every index, what table it covers, and why it exists:
| Index Name | Table | Columns | Purpose |
|---|---|---|---|
idx_sessions_token |
sessions | (token) |
Fast session lookup by token hash on every authenticated request. Without this, every API call would require a full table scan. |
idx_sessions_user |
sessions | (user_id) |
Fast deletion of all sessions for a user (force logout, ban). Also used by GetUserSessions. |
idx_messages_channel |
messages | (channel_id, id DESC) |
Primary query pattern: "get latest N messages in channel X". The compound index covers both the WHERE clause and ORDER BY in a single index scan, avoiding a filesort. DESC ensures newest messages are found first. |
idx_messages_user |
messages | (user_id) |
Supports queries filtering by author (e.g., "messages by user X"). Currently used by potential future moderation queries. |
idx_invites_code |
invites | (code) |
Fast invite validation during registration. The code column is already UNIQUE, so this index is redundant (SQLite auto-creates an index for UNIQUE constraints). Kept for documentation clarity. |
idx_audit_timestamp |
audit_log | (created_at DESC) |
Pagination of audit log entries (newest first). The GetAuditLog query orders by id DESC, but this index on created_at DESC still helps when filtering by time range. |
idx_audit_log_actor |
audit_log | (actor_id) |
Filter audit log entries by actor (e.g., "what did user X do?"). Added in migration 003. |
idx_login_ip |
login_attempts | (ip_address, timestamp) |
Rate limiting: "how many failed attempts from IP X in the last 15 minutes?" The compound index supports both the WHERE filter on IP and the time-range condition. |
idx_voice_states_channel |
voice_states | (channel_id) |
Fast lookup of all users in a given voice channel. Used by GetChannelVoiceStates, CountChannelVoiceUsers, and CountActiveCameras. |
idx_channel_overrides_channel_role |
channel_overrides | (channel_id, role_id) |
Covers the GetChannelPermissions query exactly (WHERE channel_id = ? AND role_id = ?). Added in migration 005 to prevent N+1 degradation when listing channels with permission checks. |
idx_dm_participants_user |
dm_participants | (user_id) |
Supports the DM channel lookup query in GetOrCreateDMChannel, which JOINs dm_participants on user_id. Without this, finding a DM between two users would scan the entire table. |
Implicit indexes (created automatically by SQLite):
- Every
PRIMARY KEYcolumn has an implicit unique index. - Every
UNIQUEconstraint has an implicit index. - Composite PKs (
read_states,dm_participants,dm_open_state) have implicit indexes on the combined columns.
Permission Bitfield System
Permissions are stored as an integer bitfield (Go int64, 31 bits used) in roles.permissions,
channel_overrides.allow, and channel_overrides.deny.
Bit Map
| Bit | Hex | Decimal | Name | Description |
|---|---|---|---|---|
| 0 | 0x1 |
1 | SEND_MESSAGES |
Post messages in text channels. |
| 1 | 0x2 |
2 | READ_MESSAGES |
View messages in text channels. Without this, the channel is invisible. |
| 5 | 0x20 |
32 | ATTACH_FILES |
Upload file attachments to messages. |
| 6 | 0x40 |
64 | ADD_REACTIONS |
Add emoji reactions to messages. |
| 8 | 0x100 |
256 | USE_SOUNDBOARD |
Play sounds from the soundboard in voice channels. |
| 9 | 0x200 |
512 | CONNECT_VOICE |
Join voice channels. |
| 10 | 0x400 |
1024 | SPEAK_VOICE |
Transmit audio in voice channels. |
| 11 | 0x800 |
2048 | USE_VIDEO |
Enable camera in voice channels. Added to Member role in migration 006. |
| 12 | 0x1000 |
4096 | SHARE_SCREEN |
Share screen in voice channels. Added to Member role in migration 006. |
| 16 | 0x10000 |
65536 | MANAGE_MESSAGES |
Delete other users' messages, pin/unpin messages. |
| 17 | 0x20000 |
131072 | MANAGE_CHANNELS |
Create, edit, delete channels. |
| 18 | 0x40000 |
262144 | KICK_MEMBERS |
Kick users from the server. |
| 19 | 0x80000 |
524288 | BAN_MEMBERS |
Ban/unban users. |
| 20 | 0x100000 |
1048576 | MUTE_MEMBERS |
Server-side mute/deafen other users in voice. |
| 24 | 0x1000000 |
16777216 | MANAGE_ROLES |
Create, edit, delete roles. |
| 25 | 0x2000000 |
33554432 | MANAGE_SERVER |
Modify server settings (name, icon, MOTD, etc.). |
| 26 | 0x4000000 |
67108864 | MANAGE_INVITES |
Create and revoke invite codes. |
| 27 | 0x8000000 |
134217728 | VIEW_AUDIT_LOG |
View the audit log. |
| 30 | 0x40000000 |
1073741824 | ADMINISTRATOR |
Bypasses ALL permission checks. Only Owner role has this by default. |
Bits 2-4, 7, 13-15, 21-23, 28-29, 31 are reserved (unused, value 0).
Permission Checking Logic
Permission checks happen in the application layer (not SQL). The algorithm for a user in a specific channel:
1. Get the user's role -> role.Permissions (base)
2. If (base & ADMINISTRATOR) != 0 -> ALLOW everything (short-circuit)
3. Get channel_overrides for (channel_id, role_id) -> allow, deny
4. effective = (base | allow) & ~deny
5. Check: (effective & required_permission) != 0
DM channels bypass role permissions entirely. DM authorization uses
IsDMParticipant(userID, channelID) instead of role-based checks. Every
handler touching a channel must branch on channel.Type == "dm".
Default Role Permission Values
| Role | Hex | Binary (relevant bits) | Permissions |
|---|---|---|---|
| Owner | 0x7FFFFFFF |
All 31 bits set | Everything including ADMINISTRATOR |
| Admin | 0x3FFFFFFF |
Bits 0-29 set | Everything except ADMINISTRATOR |
| Moderator | 0x000FFFFF |
Bits 0-19 set | All message + voice + moderation |
| Member | 0x1E63 |
Bits 0,1,5,6,9,10,11,12 | Send, read, attach, react, voice connect/speak, video, screen share |
Queries by Domain
All queries use parameterized bind parameters (?) to prevent SQL
injection. No raw string interpolation is used in queries except for the
VACUUM INTO backup command (which has structural validation guards).
User Operations
Source: db/auth_queries.go, db/admin_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
CreateUser |
INSERT INTO users (username, password, role_id) VALUES (?, ?, ?) |
Returns LastInsertId(). |
GetUserByUsername |
SELECT ... FROM users WHERE username = ? COLLATE NOCASE |
Case-insensitive lookup. Returns nil, nil if not found. |
GetUserByID |
SELECT ... FROM users WHERE id = ? |
Returns nil, nil if not found. |
UpdateUserStatus |
UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ? |
Also updates last_seen. |
ResetAllUserStatuses |
UPDATE users SET status = 'offline' WHERE status != 'offline' |
Called on server startup. |
BanUser |
UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ? |
expires is ISO 8601 or NULL (permanent). |
UnbanUser |
UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ? |
Clears all ban fields. |
UpdateUserRole |
UPDATE users SET role_id = ? WHERE id = ? |
Admin role change. |
ListAllUsers |
SELECT u.*, COALESCE(r.name, '') FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.id ASC LIMIT ? OFFSET ? |
Paginated, includes role name. |
ListMembers |
SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 ORDER BY u.username ASC |
For ready payload. Excludes banned users. Role name lowercased. |
UserCount |
SELECT COUNT(*) FROM users |
Used to determine if this is the first user (assigned Owner role). |
Session Operations
Source: db/auth_queries.go, db/admin_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
CreateSession |
INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?) |
expires_at = now + 30 days. token is SHA-256 hash. |
GetSessionByTokenHash |
SELECT ... FROM sessions WHERE token = ? |
Returns nil, nil if not found. |
GetSessionWithBanStatus |
SELECT s.*, u.banned, u.ban_reason, u.ban_expires FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ? |
Single query instead of two round-trips. Used by auth middleware. |
DeleteSession |
DELETE FROM sessions WHERE token = ? |
Logout. |
DeleteExpiredSessions |
DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now') |
Periodic cleanup. Uses strftime for format-agnostic comparison. |
TouchSession |
UPDATE sessions SET last_used = datetime('now') WHERE token = ? |
Updates activity timestamp on each request. |
ForceLogoutUser |
DELETE FROM sessions WHERE user_id = ? |
Removes all sessions for a user (admin action). |
GetUserSessions |
SELECT ... FROM sessions WHERE user_id = ? ORDER BY created_at DESC |
Lists all active sessions for admin UI. |
Channel Operations
Source: db/channel_queries.go, db/admin_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
ListChannels |
SELECT ... FROM channels ORDER BY position ASC, id ASC |
Returns all channels. Secondary sort by id ensures stable ordering. |
GetChannel |
SELECT ... FROM channels WHERE id = ? |
Returns nil, nil if not found. |
CreateChannel |
INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?) |
category and topic stored as NULL when empty via nullableString(). |
AdminCreateChannel |
Same as CreateChannel but via admin path |
Identical SQL. |
UpdateChannel |
UPDATE channels SET name = ?, topic = ?, slow_mode = ? WHERE id = ? |
Basic channel edit. |
AdminUpdateChannel |
UPDATE channels SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ? WHERE id = ? |
Full admin edit including position and archive. |
SetChannelSlowMode |
UPDATE channels SET slow_mode = ? WHERE id = ? |
Single-field update. |
SetChannelVoiceMaxUsers |
UPDATE channels SET voice_max_users = ? WHERE id = ? |
Single-field update. |
DeleteChannel / AdminDeleteChannel |
DELETE FROM channels WHERE id = ? |
Cascades to messages, overrides, etc. |
GetChannelPermissions |
SELECT allow, deny FROM channel_overrides WHERE channel_id = ? AND role_id = ? |
Returns (0, 0, nil) when no override exists. |
GetAllChannelPermissionsForRole |
SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = ? |
Batch fetch all overrides for a role. Returns map[channelID]ChannelOverride. Eliminates N+1 queries. |
Message Operations
Source: db/message_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
CreateMessage |
INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?) |
Returns LastInsertId(). Triggers FTS5 insert. |
GetMessage |
SELECT ... FROM messages WHERE id = ? |
Returns deleted messages too (for broadcast). nil, nil if not found. |
GetMessages |
SELECT m.*, u.username, u.avatar FROM messages m JOIN users u ... WHERE m.channel_id = ? [AND m.id < ?] AND m.deleted = 0 ORDER BY m.id DESC LIMIT ? |
Cursor-based pagination. before parameter for infinite scroll. |
GetMessagesForAPI |
Same as GetMessages but with batch reaction/attachment enrichment |
Returns []MessageAPIResponse with nested user, reactions, attachments. |
EditMessage |
UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ? |
Ownership check first: returns ErrForbidden if msg.UserID != userID. Triggers FTS5 update. |
DeleteMessage |
UPDATE messages SET deleted = 1 WHERE id = ? |
Soft delete. Ownership or moderator check. Returns ErrNotFound / ErrForbidden. |
SetMessagePinned |
UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0 |
Returns ErrNotFound if message missing or deleted. |
GetPinnedMessages |
SELECT ... WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0 ORDER BY m.id DESC |
Returns all pinned messages with enrichment. |
GetLatestMessageID |
SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0 |
Returns 0 if channel has no messages. |
Full-Text Search
Source: db/message_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
SearchMessages |
SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id JOIN channels c ON m.channel_id = c.id JOIN users u ON m.user_id = u.id WHERE messages_fts MATCH ? [AND m.channel_id = ?] AND m.deleted = 0 ORDER BY rank LIMIT ? |
Returns empty slice for empty query or limit < 1. Optional channel scoping. BM25 relevance ranking. |
Reaction Operations
Source: db/message_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
AddReaction |
INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?) |
UNIQUE constraint prevents duplicates. |
RemoveReaction |
DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ? |
Returns ErrNotFound if no row affected. |
GetReactions |
SELECT emoji, COUNT(*) FROM reactions WHERE message_id = ? GROUP BY emoji |
Aggregated counts per emoji. |
getReactionsBatch |
Dynamic IN-clause query with MAX(CASE WHEN r.user_id = ? THEN 1 ELSE 0 END) as me |
Batch fetch for multiple messages. Returns map[messageID][]ReactionInfo with per-user "me" flag. |
Read State Operations
Source: db/message_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
UpdateReadState |
INSERT INTO read_states ... ON CONFLICT DO UPDATE SET last_message_id = excluded.last_message_id |
Upsert: creates or updates. |
GetChannelUnreadCounts |
Complex query joining channels, messages, and read_states with COUNT(CASE WHEN ...) | Returns map[channelID]ChannelUnread for text channels only. |
Invite Operations
Source: db/auth_queries.go, db/invite_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
CreateInvite |
INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?) |
Code: 8 random bytes as hex. |
GetInvite |
SELECT ... FROM invites WHERE code = ? |
Returns nil, nil if not found. |
UseInviteAtomic |
See invites table section above | Single atomic UPDATE with all validation. |
RevokeInvite |
UPDATE invites SET revoked = 1 WHERE code = ? |
Marks as revoked. |
ListInvites |
SELECT ... FROM invites ORDER BY created_at DESC |
All invites, newest first. |
Role Operations
Source: db/role_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
GetRoleByID |
SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ? |
Returns nil, nil if not found. |
ListRoles |
SELECT ... FROM roles ORDER BY position DESC |
Highest position first (Owner at top). |
Voice Operations
Source: db/voice_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
JoinVoiceChannel |
INSERT INTO voice_states ... ON CONFLICT(user_id) DO UPDATE SET channel_id = excluded.channel_id, muted = 0, deafened = 0, speaking = 0, camera = 0, screenshare = 0, joined_at = datetime('now') |
Atomic join/switch. Resets all flags. |
LeaveVoiceChannel |
DELETE FROM voice_states WHERE user_id = ? |
Safe to call when not in a channel. |
GetVoiceState |
SELECT vs.*, u.username FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.user_id = ? |
Returns nil, nil if user is not in voice. |
GetChannelVoiceStates |
SELECT vs.*, u.username FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.channel_id = ? ORDER BY vs.joined_at ASC |
All users in a channel, ordered by join time. |
GetAllVoiceStates |
Same but no WHERE clause, ORDER BY vs.channel_id, vs.joined_at ASC |
Used at startup for ready payload. Avoids N+1. |
UpdateVoiceMute |
UPDATE voice_states SET muted = ? WHERE user_id = ? |
No-op if user not in voice. |
UpdateVoiceDeafen |
UPDATE voice_states SET deafened = ? WHERE user_id = ? |
No-op if user not in voice. |
UpdateVoiceCamera |
UPDATE voice_states SET camera = ? WHERE user_id = ? |
Camera toggle. |
UpdateVoiceScreenshare |
UPDATE voice_states SET screenshare = ? WHERE user_id = ? |
Screen share toggle. |
ClearVoiceState |
DELETE FROM voice_states WHERE user_id = ? |
On disconnect. Alias for LeaveVoiceChannel. |
ClearAllVoiceStates |
DELETE FROM voice_states |
Server startup cleanup. |
CountChannelVoiceUsers |
SELECT COUNT(*) FROM voice_states WHERE channel_id = ? |
For enforcing voice_max_users. |
CountActiveCameras |
SELECT COUNT(*) FROM voice_states WHERE channel_id = ? AND camera = 1 |
For enforcing voice_max_video. Uses DB as source of truth (serialized by SQLite) rather than querying LiveKit. |
DM Operations
Source: db/dm_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
GetOrCreateDMChannel |
Transactional: SELECT existing DM channel or INSERT new channel + participants + open state | Uses IMMEDIATE transaction (serializable isolation) to prevent TOCTOU races. If DM exists, also re-opens it for the initiator (INSERT OR IGNORE INTO dm_open_state). |
GetUserDMChannels |
Complex 6-table join: dm_open_state -> channels -> dm_participants -> users + LEFT JOIN messages (latest) + LEFT JOIN messages (unread count) + LEFT JOIN read_states |
Returns []DMChannelInfo with recipient info, last message preview, unread count. Ordered by most recent activity. |
OpenDM |
INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?) |
Idempotent. |
CloseDM |
DELETE FROM dm_open_state WHERE user_id = ? AND channel_id = ? |
Hides the DM from sidebar. Does not delete messages. |
IsDMParticipant |
SELECT user_id FROM dm_participants WHERE user_id = ? AND channel_id = ? |
Authorization check. Returns bool. Used by every handler that touches a DM channel. |
GetDMParticipantIDs |
SELECT user_id FROM dm_participants WHERE channel_id = ? |
Returns both user IDs. Used for targeted event broadcasting. |
GetDMRecipient |
SELECT user_id FROM dm_participants WHERE channel_id = ? AND user_id != ? LIMIT 1 then GetUserByID |
Returns the other participant's full User record. Two queries (could be optimized to one join). |
Attachment Operations
Source: db/attachment_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
CreateAttachment |
INSERT INTO attachments (id, filename, stored_as, mime_type, size, width, height) VALUES (?, ?, ?, ?, ?, ?, ?) |
message_id is NOT set at upload time (left NULL). |
GetAttachmentByID |
SELECT ... FROM attachments WHERE id = ? |
Returns nil, nil if not found. |
LinkAttachmentsToMessage |
UPDATE attachments SET message_id = ? WHERE id IN (?,?,...) AND message_id IS NULL |
Dynamic IN-clause. AND message_id IS NULL prevents double-linking in concurrent races. Returns rows affected. |
GetAttachmentsByMessageIDs |
SELECT id, message_id, filename, size, mime_type, width, height FROM attachments WHERE message_id IN (?,?,...) |
Batch fetch for message enrichment. URL computed as /api/v1/files/{id}. Returns map[msgID][]AttachmentInfo. |
DeleteOrphanedAttachments |
SELECT stored_as FROM attachments WHERE message_id IS NULL AND uploaded_at < ? then DELETE |
Two-phase: first query filenames for disk cleanup, then delete records. Returns filenames for the caller to remove from disk. |
Admin / Settings / Audit
Source: db/admin_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
GetServerStats |
4 separate COUNT(*) queries + 2 PRAGMA queries | Returns user count, message count (non-deleted), channel count, active invite count, DB size (page_count * page_size). |
GetSetting |
SELECT value FROM settings WHERE key = ? |
Returns ErrNotFound (wrapped) when key is missing. |
SetSetting |
INSERT ... ON CONFLICT(key) DO UPDATE SET value = excluded.value |
Upsert. |
GetAllSettings |
SELECT key, value FROM settings |
Returns map[string]string. |
LogAudit |
INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) VALUES (?, ?, ?, ?, ?) |
Timestamps auto-set. |
GetAuditLog |
SELECT a.*, COALESCE(u.username, '') FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ? |
Paginated, newest first. LEFT JOIN handles deleted actors. |
Backup Operations
Source: db/admin_queries.go
| Function | Query Pattern | Notes |
|---|---|---|
BackupTo |
VACUUM INTO '{path}' |
Creates an online backup. Delegates to BackupToSafe with data/backups as safe root. |
BackupToSafe |
Same SQL, with path validation | Security: Path is validated against a safe root directory (must be a subdirectory). Characters ', ", ;, --, \x00 are rejected as defense-in-depth. VACUUM INTO does not support bind parameters, so structural validation is critical. |
Query Patterns
Prepared Statements
The codebase does not use explicitly prepared statements
(sql.Prepare). Instead, all queries pass SQL strings directly to
db.Exec(), db.Query(), and db.QueryRow(). The database/sql
package internally caches prepared statements when using parameterized
queries, so the performance difference is negligible for a single-connection
pool.
Transaction Handling
Transactions are used in two places:
-
Migrations (
migrate.go): Each migration executes within a transaction. The migration SQL and its tracking record are committed atomically. -
DM channel creation (
dm_queries.go):GetOrCreateDMChannelusesBeginTxwithsql.LevelSerializableisolation to prevent TOCTOU races. The transaction covers: lookup existing DM -> create channel -> insert participants -> insert open state -> commit.
All other operations are single-statement and rely on SQLite's implicit auto-commit transactions.
Batch Query Pattern
Several functions use dynamic IN (?, ?, ...) clauses for batch
operations:
getReactionsBatch: Aggregates reactions for multiple message IDs.GetAttachmentsByMessageIDs: Fetches attachments for multiple messages.LinkAttachmentsToMessage: Links multiple attachment IDs to a message.
These build placeholder strings dynamically using strings.Builder and
pass args as []any slices. This avoids N+1 query problems when enriching
message lists.
Scan Helpers
The codebase uses dedicated scan functions to handle SQLite's lack of native boolean type:
scanUser: Scansbannedasint, converts tobool.scanMessage: Scansdeletedandpinnedasint, converts tobool.scanVoiceState/scanVoiceStateRow: Scans all flag columns asint.scanChannel: Scansarchivedasint.
Pattern: field = intValue != 0
NULL Handling
nullableString(s string) any: Returnsnilwhensis empty, so empty strings are stored as SQL NULL in optional TEXT columns (category,topic). Read back withCOALESCE(column, '').boolToInt(b bool) int: Converts Goboolto0/1for SQLite storage.- Optional fields use Go pointer types (
*string,*int,*int64) to represent SQL NULLs.sql.NullInt64is used inGetUserDMChannels.
Datetime Conventions
- All timestamps stored as ISO 8601 UTC strings:
"2006-01-02T15:04:05Z"(Go reference format) ordatetime('now')(SQLite function). - Comparison uses
strftime('%s', column)to convert to Unix timestamps for reliable ordering across format variations. COALESCEhandles NULL timestamps in joins (e.g., DM channels with no messages yet).