Skip to content

Database

Klaxon uses PostgreSQL 16+ with schema managed by sqlx migrations. Both Rust binaries (klaxon-server and klaxon-auth) share the same database and run migrations on startup — whichever starts first wins the race; the migration system is idempotent.

Schema Overview

20 SQL migrations define the full schema. Every tenant-scoped table carries org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE; application code always filters by org_id.

Core tables

TablePurpose
orgsOrganizations (tenants). is_personal = true for auto-created single-user orgs
usersUser accounts
user_identitiesLinks a user to an IdP (provider, provider_user_id)
org_membershipsWhich users belong to which orgs (with a role_id)
agentsRegistered MCP agents (API-only clients)
sessionsUser bearer tokens (one-per-login; refresh on OAuth flow)
api_keysAgent-scoped bearer tokens
magic_linksMagic-link login tokens (transient)

Items & activity

TablePurpose
klaxon_itemsNotifications / questions / forms — the core entity
klaxon_commentsItem discussion threads
klaxon_attachmentsFile metadata
attachment_blobsBYTEA storage for uploaded files
klaxon_watchersPer-item subscriber list
klaxon_auditAppend-only mutation log
klaxon_templatesReusable form schemas
item_responsesForm answers (structured, validated at submit)
saved_viewsNamed filter presets (per user)

Channels & routing

TablePurpose
channelsItem grouping — open / private / direct / team-scoped
channel_membershipsUser ↔ channel junction
channel_rulesPer-user notification prefs
settingsOrg-level key/value config
emailed_itemsEmail-send dedupe tracking

Teams (migration 0017)

TablePurpose
teamsNamed subgroups within an org
team_membershipsUser ↔ team junction with role (lead / member)

Invitations (migration 0018)

TablePurpose
invitationsEmail + link invites, email-locked when email set

Roles & permissions (migration 0020)

TablePurpose
rolesSystem roles (org_id = NULL) + org-local custom roles

Billing (migrations 0016 + 0019)

TablePurpose
plansCatalog (free / team / enterprise) with limits and features JSONB
org_subscriptionsOne row per org, tracks plan + Stripe IDs + status
notification_countersPer-org monthly meter, one row per (org_id, period_month)

Delivery outboxes & push

TablePurpose
notification_queuePush delivery outbox (transient)
push_subscriptionsFCM device tokens + Web Push subscriptions
webhooksOutbound webhook configs
webhook_deliveriesWebhook delivery outbox (transient)

OAuth 2.1

TablePurpose
oauth_clientsRegistered OAuth clients (RFC 7591 dynamic registration)
oauth_authorization_codesShort-lived codes between /authorize and /token
oauth_tokensAccess + refresh tokens; refresh rotates on use

Enum types

TypeValues
item_levelinfo, warning, error, success
item_statusopen, answered, dismissed, expired, withdrawn
actor_typeuser, agent, system
channel_modeopen, private, direct

The legacy user_role enum was dropped in migration 0020_roles.sql — roles are now first-class rows joined via role_id.

Key indexes

IndexPurpose
idx_items_org_cursorCursor-based pagination: (org_id, created_at DESC, id DESC)
idx_items_org_openPartial: open non-archived items (default list view)
idx_items_snoozedPartial: snoozed items due for wake
idx_items_deadlinePartial: items with deadlines
idx_items_tagsGIN: tag containment queries
idx_items_ftsGIN: full-text search on title + message
idx_api_keys_activePartial: non-revoked keys (fast auth lookup)
idx_oauth_tokens_hashUnique on token_hash for bearer lookup
idx_org_memberships_role(role_id) — permission checks

Triggers

  • Every table with an updated_at column has a BEFORE UPDATE trigger that sets updated_at = now().
  • org_subscriptions_personal_pin (migration 0016) rejects any UPDATE that would change plan_id on an org where is_personal = true.

Migrations

Migrations run automatically on server startup:

rust
sqlx::migrate!().run(&pool).await?;

Migration history

#FilePurpose
00010001_initial.sqlCore schema: orgs / users / items / channels / audit
00020002_user_roles.sql(superseded by 0020) role enum on memberships
00030003_webhooks.sqlOutbound webhook configs + delivery queue
00040004_channel_routes.sqlPer-channel routing + quorum JSONB
00050005_magic_links.sqlMagic-link login tokens
00060006_callback_url.sqlcallback_url on items
00070007_email_prefs.sqlEmail prefs + emailed_items dedupe
00080008_attachment_blobs.sqlBYTEA attachment storage
00090009_item_responses.sqlStructured form answers table
00100010_user_identities.sqlIdP linkage for users
00110011_oauth_clients.sqlOAuth client registry
00120012_oauth_authorization_codes.sqlShort-lived auth codes
00130013_oauth_tokens.sqlAccess + refresh tokens with rotation
00140014_magic_links_optional_org.sqlMagic-link signup without preexisting org
00150015_multi_org_membership.sqlUsers can belong to many orgs
00160016_billing.sqlPlans + org_subscriptions + Stripe plumbing
00170017_teams.sqlTeams + team_memberships + team-scoped channels
00180018_invitations.sqlEmail + link invitations
00190019_plan_limits.sqlRefresh plan limits + notification_counters
00200020_roles.sqlCustom roles + permission strings

Multi-tenancy

Every tenant-scoped table has org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE. Application code always filters by org_id. Row-Level Security (RLS) is planned but not yet enforced at the database layer — don't rely on it.

Event broadcast

The server uses Postgres LISTEN/NOTIFY on the klaxon_events channel:

sql
SELECT pg_notify('klaxon_events', '{"org_id":"...","kind":"item.created","id":"..."}');

All server replicas subscribe and forward events to connected WebSocket / SSE clients.

Demo-data seed

A canonical seed lives at crates/klaxon-server/fixtures/seed.sql. It populates every table so e2e tests and marketing screenshots have realistic content. A test at crates/klaxon-server/tests/seed.rs loads it against a fresh DB — keep it green when adding schema.

Backups

For managed PostgreSQL (RDS, Cloud SQL, CNPG), use the provider's automated backup. For self-hosted:

bash
pg_dump -U klaxon -h localhost klaxon > backup.sql