Core Table Relationships

The schema organizes around three conceptual groups: identity and tenancy (servers, users, memberships, roles), chat (channels, messages, reactions, attachments), and billing. The diagram below shows the primary foreign-key relationships.

IDENTITY users ULID pk, email accounts Better Auth-owned sessions Better Auth-owned servers tenant root CHAT server_memberships PK (server_id, user_id) roles server-owned membership_roles PK (server,user,role) channels server_id tenant channel_role_overrides channel × role messages sort_id · nonce message_reactions emoji · user_id message_attachments R2 object key message_mentions user_id · type channel_read_states last_read_sort_id BILLING billing_subscriptions server × user entitlements

Identity & Tenancy Tables

These tables establish the tenant boundary. Every downstream table references server_id for tenancy isolation. Better Auth owns accounts and sessions entirely; all other rows are app-owned.

users                        -- ULID pk, display_name, avatar_url, created_at
accounts / sessions          -- Better Auth-owned; credential + session store
servers                      -- ULID pk, slug, name, default_permissions (bitset cache)
server_memberships           -- PK (server_id, user_id); joined_at, status
roles                        -- ULID pk, server_id, name, rank (lower = stronger)
membership_roles             -- PK (server_id, user_id, role_id)
channels                     -- ULID pk, server_id, type, name, sort_order
channel_role_overrides       -- channel_id × role_id; capability + effect rows
channel_member_overrides     -- channel_id × user_id; direct capability grants

Chat Tables

The messages table is the core of the chat domain. Its sort_id enables efficient range queries for all paging primitives without relying on sequence numbers.

messages (
  id              text PRIMARY KEY,           -- ULID
  channel_id      text NOT NULL,
  user_id         text NOT NULL,
  sort_id         text COLLATE "C" NOT NULL,  -- bytewise lexical = time order
  client_nonce    text NOT NULL,              -- idempotency key
  content         jsonb NOT NULL,             -- link embeds, rich text body
  reply_to_message_id text,
  thread_id       text,
  edited_at       timestamptz,
  deleted_at      timestamptz,
  created_at      timestamptz NOT NULL DEFAULT now(),
  UNIQUE (channel_id, client_nonce)           -- non-negotiable idempotency
)

message_revisions          -- append-only edit history per message
message_reactions          -- emoji · user_id · message_id; UNIQUE (msg,user,emoji)
thread_subscriptions       -- user × thread subscription preferences
channel_read_states        -- user × channel; last_read_sort_id + mention_count
message_mentions           -- message_id × target_user_id × mention_type
attachments                -- ULID pk; R2 key, mime_type, size, scan_status
message_attachments        -- message_id × attachment_id join
message_send_receipts      -- server ack for outbox idempotency; keyed on client_nonce

Key Schema Rules

Rule Decision
ULID text PKs on entities Client-generatable, globally sortable, no sequence coordination required. Enables offline-first ID creation in the outbox.
sort_id text COLLATE "C" Bytewise lexical order equals time order. Enables all range cursor queries (before, after, around) with a simple index scan on (channel_id, sort_id).
UNIQUE(channel_id, client_nonce) Non-negotiable message idempotency. The database enforces at-most-once semantics regardless of how many times the client retransmits an outbox item.
Money as integer minor units All monetary values stored as integer minor units (e.g. cents) plus ISO 4217 currency code column. Never floating point. Prevents rounding error in ledger arithmetic.
text + CHECK constraints (not enums) Easy Zero/TypeScript introspection, additive rollout without schema migrations for new values. Enums require DDL changes to extend.
JSONB only for bounded non-relational payloads Permitted for rich text content bodies and link embeds. Never used for IDs, permissions, or relational keys that need indexing or foreign key constraints.

Courses Tables

Courses are a first-class launch feature, not a later add-on. The schema supports TRW-style learning: modules, prerequisites, and server-authoritative completion-to-role rewards.

course_spaces          -- grouping container within a server
course_sections        -- ordered sections within a space
courses                -- ULID pk, server_id, title, published_at
course_modules         -- ordered modules within a course
lessons                -- ULID pk, module_id, title, content_type
lesson_assets          -- R2-backed media assets per lesson
course_enrollments     -- user × course; enrolled_at, status
lesson_progress        -- user × lesson; completed_at
course_progress        -- user × course; percent_complete, completed_at
course_prerequisites   -- prerequisite graph (course_id → required_course_id)
completion_rewards     -- course_id → role_id; granted on completion

Billing Tables

Billing is backed by an immutable double-entry ledger. Every financial fact is a journal entry. Derived state (entitlements, subscription status) is always recomputable from the ledger.

billing_events         -- raw payment gateway events (append-only)
payment_intents        -- NMI/value.io intent lifecycle
billing_subscriptions  -- server × user; plan, status, period_end
ledger_accounts        -- chart of accounts (revenue, receivable, payable, etc.)
journal_entries        -- balanced header (metadata, idempotency key)
postings               -- debit/credit rows; always sum to zero per journal entry
refunds                -- links to original payment_intent; posts reversal entries
disputes               -- chargeback lifecycle; posts chargeback + reserve entries
payouts                -- seller payout batches; links to posted entries
affiliate_commissions  -- referral commissions; links to originating payment
products               -- sellable product catalog per server
plans                  -- recurring plan definitions (price, interval, currency)
entitlements           -- effective capability grants derived from subscriptions

Zero Publication Rules

Hot Window — Zero Published

Messages and related rows within the 90-day window are published via zero-cache to client replicas. Clients receive live push updates and execute all paging queries locally.

Archive — Authorized API Only

Messages older than 90 days are served through an authorized read-only history API. Archive rows are never promoted into fake Zero rows and never inserted into the client replica. The boundary is a hard architectural seam.

Billing Internals — Server Only

All billing tables are server-side only. Zero publishes only the safe effective entitlements view. Ledger accounts, journal entries, postings, and payment intents never appear in the Zero publication.


Research Links

Core Data Model Spec (doc 08) → Final Recommendation (doc 00) →