20 KiB
Database Schema
Version: 1.0
Status: Specification and implementation guide
Overview
Vnox uses SQLite for local deployment and supports PostgreSQL for larger deployments.
Database schema defines storage for:
- Users and identity
- Guilds, channels, categories
- Roles and permissions
- Messages and history
- Direct messages
- Presence and online status
- Audit logs
All tables use UUID for primary keys (no auto-increment) for federation compatibility.
Core Tables
users
Global user identity across all Vnox instances.
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
identity_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_users_username ON users(username);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | UUID |
| username | TEXT | NOT NULL | Display name |
| identity_key | TEXT | NOT NULL | Ed25519 public key |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
sessions
Active user sessions on a gateway instance.
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
session_token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Session UUID |
| user_id | TEXT | FK users | User owning session |
| session_token | TEXT | UNIQUE | Auth token |
| expires_at | TIMESTAMP | NOT NULL | Expiration time |
| created_at | TIMESTAMP | NOT NULL | Creation time |
Guild Tables
guilds
Guild entities (communities/servers).
CREATE TABLE guilds (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
banner TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id)
);
CREATE INDEX idx_guilds_owner ON guilds(owner_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Guild UUID |
| owner_id | TEXT | FK users | Guild owner |
| name | TEXT | NOT NULL | Guild name |
| description | TEXT | Guild description | |
| icon | TEXT | Asset ID | |
| banner | TEXT | Asset ID | |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
guild_members
Guild membership tracking.
CREATE TABLE guild_members (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
nickname TEXT,
joined_at TIMESTAMP NOT NULL,
timeout_until TIMESTAMP,
PRIMARY KEY (guild_id, user_id),
FOREIGN KEY(guild_id) REFERENCES guilds(id),
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX idx_guild_members_user ON guild_members(user_id);
CREATE INDEX idx_guild_members_joined ON guild_members(joined_at);
| Column | Type | Constraints | Description |
|---|---|---|---|
| guild_id | TEXT | PRIMARY KEY | Guild UUID |
| user_id | TEXT | PRIMARY KEY | User UUID |
| nickname | TEXT | Guild-specific nick | |
| joined_at | TIMESTAMP | NOT NULL | Join time |
| timeout_until | TIMESTAMP | Moderation timeout |
categories
Channel categories for organization.
CREATE TABLE categories (
id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(guild_id) REFERENCES guilds(id)
);
CREATE INDEX idx_categories_guild ON categories(guild_id);
CREATE INDEX idx_categories_position ON categories(guild_id, position);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Category UUID |
| guild_id | TEXT | FK guilds | Parent guild |
| name | TEXT | NOT NULL | Display name |
| position | INTEGER | NOT NULL | Sort order |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
channels
Communication channels (text, voice, announcements).
CREATE TABLE channels (
id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
category_id TEXT,
type TEXT NOT NULL,
name TEXT NOT NULL,
topic TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(guild_id) REFERENCES guilds(id),
FOREIGN KEY(category_id) REFERENCES categories(id)
);
CREATE INDEX idx_channels_guild ON channels(guild_id);
CREATE INDEX idx_channels_category ON channels(category_id);
CREATE INDEX idx_channels_position ON channels(guild_id, position);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Channel UUID |
| guild_id | TEXT | FK guilds | Parent guild |
| category_id | TEXT | FK categories | Parent category |
| type | TEXT | NOT NULL | TEXT/VOICE/etc |
| name | TEXT | NOT NULL | Display name |
| topic | TEXT | Channel description | |
| position | INTEGER | NOT NULL | Sort order |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
Role & Permission Tables
roles
Guild roles with permission bitmasks.
CREATE TABLE roles (
id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
name TEXT NOT NULL,
color INTEGER NOT NULL DEFAULT 0,
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
hoist INTEGER NOT NULL DEFAULT 0,
mentionable INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(guild_id) REFERENCES guilds(id)
);
CREATE INDEX idx_roles_guild ON roles(guild_id);
CREATE INDEX idx_roles_position ON roles(guild_id, position);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Role UUID |
| guild_id | TEXT | FK guilds | Parent guild |
| name | TEXT | NOT NULL | Display name |
| color | INTEGER | NOT NULL | RGB color value |
| permissions | INTEGER | NOT NULL | Bitmask (u128) |
| position | INTEGER | NOT NULL | Hierarchy position |
| hoist | INTEGER | NOT NULL | Display separately |
| mentionable | INTEGER | NOT NULL | Can be @mentioned |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
member_roles
Many-to-many mapping of members to roles.
CREATE TABLE member_roles (
member_guild_id TEXT NOT NULL,
member_user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
PRIMARY KEY (member_guild_id, member_user_id, role_id),
FOREIGN KEY(member_guild_id, member_user_id)
REFERENCES guild_members(guild_id, user_id),
FOREIGN KEY(role_id) REFERENCES roles(id)
);
CREATE INDEX idx_member_roles_role ON member_roles(role_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
| member_guild_id | TEXT | PRIMARY KEY | Guild UUID |
| member_user_id | TEXT | PRIMARY KEY | User UUID |
| role_id | TEXT | PRIMARY KEY | Role UUID |
channel_overrides
Role and user permission overrides per channel.
CREATE TABLE channel_overrides (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
target_id TEXT NOT NULL,
target_type TEXT NOT NULL,
allow_mask INTEGER NOT NULL DEFAULT 0,
deny_mask INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(channel_id) REFERENCES channels(id)
);
CREATE INDEX idx_channel_overrides_channel
ON channel_overrides(channel_id);
CREATE INDEX idx_channel_overrides_target
ON channel_overrides(target_id, target_type);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Override UUID |
| channel_id | TEXT | FK channels | Channel |
| target_id | TEXT | NOT NULL | Role or User UUID |
| target_type | TEXT | NOT NULL | "ROLE" or "USER" |
| allow_mask | INTEGER | NOT NULL | Allowed permissions |
| deny_mask | INTEGER | NOT NULL | Denied permissions |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last modification |
Message Tables
messages
Text messages in channels.
CREATE TABLE messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
deleted_at TIMESTAMP,
FOREIGN KEY(channel_id) REFERENCES channels(id),
FOREIGN KEY(sender_id) REFERENCES users(id)
);
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at DESC);
CREATE INDEX idx_messages_sender ON messages(sender_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Message UUID |
| channel_id | TEXT | FK channels | Parent channel |
| sender_id | TEXT | FK users | Message author |
| content | TEXT | NOT NULL | Encrypted content |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | Edit time | |
| deleted_at | TIMESTAMP | Deletion time (soft) |
message_reactions
Emoji reactions on messages.
CREATE TABLE message_reactions (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (message_id, user_id, emoji),
FOREIGN KEY(message_id) REFERENCES messages(id),
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX idx_reactions_message ON message_reactions(message_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
| message_id | TEXT | PRIMARY KEY | Message UUID |
| user_id | TEXT | PRIMARY KEY | User UUID |
| emoji | TEXT | PRIMARY KEY | Emoji character |
| created_at | TIMESTAMP | NOT NULL | Reaction time |
Direct Message Tables
direct_messages
DM channel tracking (not per-guild).
CREATE TABLE direct_messages (
dm_id TEXT PRIMARY KEY,
user_id_1 TEXT NOT NULL,
user_id_2 TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY(user_id_1) REFERENCES users(id),
FOREIGN KEY(user_id_2) REFERENCES users(id)
);
CREATE UNIQUE INDEX idx_dm_users
ON direct_messages(
MIN(user_id_1, user_id_2),
MAX(user_id_1, user_id_2)
);
| Column | Type | Constraints | Description |
|---|---|---|---|
| dm_id | TEXT | PRIMARY KEY | DM UUID |
| user_id_1 | TEXT | FK users | First user (lexicog) |
| user_id_2 | TEXT | FK users | Second user |
| created_at | TIMESTAMP | NOT NULL | Creation time |
| updated_at | TIMESTAMP | NOT NULL | Last message time |
dm_messages
Direct messages (archived).
CREATE TABLE dm_messages (
id TEXT PRIMARY KEY,
dm_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY(dm_id) REFERENCES direct_messages(dm_id),
FOREIGN KEY(sender_id) REFERENCES users(id)
);
CREATE INDEX idx_dm_messages_dm ON dm_messages(dm_id, created_at DESC);
CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Message UUID |
| dm_id | TEXT | FK dm tables | Parent DM |
| sender_id | TEXT | FK users | Message author |
| content | TEXT | NOT NULL | Encrypted content |
| created_at | TIMESTAMP | NOT NULL | Creation time |
Invitation Tables
invites
Guild invitations.
CREATE TABLE invites (
id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
creator_id TEXT NOT NULL,
code TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP,
max_uses INTEGER,
uses INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY(guild_id) REFERENCES guilds(id),
FOREIGN KEY(creator_id) REFERENCES users(id)
);
CREATE INDEX idx_invites_guild ON invites(guild_id);
CREATE INDEX idx_invites_code ON invites(code);
CREATE INDEX idx_invites_expires ON invites(expires_at);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Invite UUID |
| guild_id | TEXT | FK guilds | Target guild |
| creator_id | TEXT | FK users | Creator |
| code | TEXT | UNIQUE | Invite slug |
| expires_at | TIMESTAMP | Expiration time | |
| max_uses | INTEGER | Use limit | |
| uses | INTEGER | NOT NULL | Current uses |
| created_at | TIMESTAMP | NOT NULL | Creation time |
Presence Tables
presence
User online status and activity.
CREATE TABLE presence (
user_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
activity_type TEXT,
activity_text TEXT,
last_seen TIMESTAMP NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX idx_presence_status ON presence(status);
CREATE INDEX idx_presence_last_seen ON presence(last_seen);
| Column | Type | Constraints | Description |
|---|---|---|---|
| user_id | TEXT | PRIMARY KEY | User UUID |
| status | TEXT | NOT NULL | ONLINE/IDLE/etc |
| activity_type | TEXT | PLAYING/LISTENING | |
| activity_text | TEXT | Activity description | |
| last_seen | TIMESTAMP | NOT NULL | Last activity time |
Friendship Tables
friends
User friendship relationships.
CREATE TABLE friends (
requester_id TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (requester_id, target_id),
FOREIGN KEY(requester_id) REFERENCES users(id),
FOREIGN KEY(target_id) REFERENCES users(id)
);
CREATE INDEX idx_friends_target ON friends(target_id, status);
| Column | Type | Constraints | Description |
|---|---|---|---|
| requester_id | TEXT | PRIMARY KEY | Request originator |
| target_id | TEXT | PRIMARY KEY | Request recipient |
| status | TEXT | NOT NULL | pending/accepted/blocked |
| created_at | TIMESTAMP | NOT NULL | Request time |
Audit Log Table
audit_logs
Moderation and administrative actions.
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_id TEXT,
target_type TEXT,
reason TEXT,
changes TEXT,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY(guild_id) REFERENCES guilds(id),
FOREIGN KEY(actor_id) REFERENCES users(id)
);
CREATE INDEX idx_audit_logs_guild ON audit_logs(guild_id, created_at DESC);
CREATE INDEX idx_audit_logs_actor ON audit_logs(actor_id);
CREATE INDEX idx_audit_logs_target
ON audit_logs(target_id, target_type);
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | TEXT | PRIMARY KEY | Log UUID |
| guild_id | TEXT | FK guilds | Guild context |
| actor_id | TEXT | FK users | Admin/moderator |
| action | TEXT | NOT NULL | ACTION_TYPE |
| target_id | TEXT | Affected entity | |
| target_type | TEXT | Entity type | |
| reason | TEXT | Reason for action | |
| changes | TEXT | JSON of old/new | |
| created_at | TIMESTAMP | NOT NULL | Action time |
Migration Strategy
Phase 1.1 (Current)
-- Already exists
CREATE TABLE users
CREATE TABLE sessions
CREATE TABLE channels
CREATE TABLE messages
CREATE TABLE direct_messages
CREATE TABLE dm_messages
Phase 1.2
-- Add guild system
CREATE TABLE guilds
CREATE TABLE guild_members
CREATE TABLE categories
CREATE TABLE roles
CREATE TABLE member_roles
CREATE TABLE channel_overrides
CREATE TABLE invites
CREATE TABLE presence
CREATE TABLE friends
CREATE TABLE audit_logs
Backward Compatibility
During transition to guilds:
- Create a default guild per deployment
- Migrate existing channels to default guild
- Add all existing users as guild members
Performance Considerations
Indexes
Key composite indexes:
messages(channel_id, created_at DESC)— range queriesdm_messages(dm_id, created_at DESC)— paginationpresence(status, last_seen)— online user queriesguild_members(guild_id, joined_at)— membership lists
Query Patterns
Optimize for:
- Message history fetch (paginated, DESC)
- Channel member list
- Permission resolution (cached)
- Online presence broadcast
- Recent DM threads
Caching
Recommended client-side caches:
- Guild data (invalidated on GUILD_UPDATE)
- Channel list (invalidated on CHANNEL_CREATE/DELETE)
- User presence (invalidated on PRESENCE_UPDATE)
- Role permissions (cached by guild)
PostgreSQL vs SQLite
SQLite (Development, Single-Server)
[database]
type = "sqlite"
path = "data/vnox.db"
PostgreSQL (Production, Distributed)
[database]
type = "postgres"
url = "postgresql://user:pass@localhost/vnox"
pool_size = 32
Both use identical schema; driver handles translation of:
INTEGER↔BIGINTTEXT↔VARCHAR- UUID handling