Hub database schema
This document is the canonical reference for the phlix-hub MySQL schema. The migration files in migrations/ are the source of truth; this doc explains the why and the how it fits together.
All tables:
- Use
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci. - Have a
CHAR(36)UUID primary key namedid. - Use
DATETIMEfor timestamps (created_at,updated_at,last_seen_at, …) withDEFAULT CURRENT_TIMESTAMPandON UPDATE CURRENT_TIMESTAMPwhere appropriate.
The hub deployment is single-primary — Group Replication multi-primary mode is not supported because the schema relies on ON DELETE CASCADE foreign keys that multi-primary rejects.
Entity-relationship diagram
erDiagram
users ||--o{ servers : "owns"
users ||--o{ webhooks : "owns"
users ||--o{ shared_libraries : "grants"
users ||--o{ shared_libraries : "receives"
servers ||--o{ server_heartbeats : "emits"
servers ||--o{ shared_libraries : "scoped to"
servers ||--o{ relay_sessions : "holds open"
server_claims }o--|| servers : "pairs into"
users {
char(36) id PK
varchar(64) username UK
varchar(255) email UK
varchar(255) password_hash
varchar(128) display_name
tinyint is_admin
datetime created_at
datetime updated_at
}
servers {
char(36) id PK
char(36) user_id FK
varchar(128) server_name
varchar(32) version
text jwks_json
text hostname_candidates_json
enum status
datetime last_seen_at
int_unsigned enrolled_at
datetime created_at
datetime updated_at
}
server_claims {
char(36) id PK
varchar(16) claim_code UK
char(36) user_id
varchar(128) server_name
varchar(32) version
text jwks_json
text hostname_candidates_json
enum status
datetime expires_at
datetime created_at
datetime paired_at
char(36) paired_server_id
}
server_heartbeats {
char(36) id PK
char(36) server_id FK
varchar(32) version
int uptime_seconds
int active_sessions
int active_transcodes
text hostname_candidates_json
datetime received_at
datetime sent_at
}
shared_libraries {
char(36) id PK
char(36) owner_user_id FK
char(36) grantee_user_id FK
char(36) server_id FK
char(36) library_id
varchar(128) library_name
datetime created_at
datetime revoked_at
}
relay_sessions {
char(36) id PK
char(36) server_id FK
varchar(128) worker_node
datetime opened_at
datetime closed_at
bigint bytes_in
bigint bytes_out
int_unsigned last_frame_at
varchar(64) close_reason
}
webhooks {
char(36) id PK
char(36) user_id FK
varchar(128) name
varchar(512) target_url
varchar(255) secret
text event_aliases_json
text template_json
tinyint enabled
datetime created_at
datetime updated_at
datetime last_delivery_at
varchar(16) last_delivery_status
}Tables
users
Migration: 001_users.sql
The authoritative directory of hub accounts. Hub login (B.7) reads from here; everything else (servers, sharing, webhooks) hangs off the user row.
idCHAR(36)— UUID, primary key.usernameVARCHAR(64)— unique, human-friendly handle.emailVARCHAR(255)— unique.password_hashVARCHAR(255)— Argon2ID (PHPpassword_hash,PASSWORD_ARGON2ID).display_nameVARCHAR(128)— optional friendly name.is_adminTINYINT(1)—1grants hub-admin UI access.created_at,updated_atDATETIME.
Indexes: primary id; unique uk_users_username; unique uk_users_email.
Read/write by: signup, login, refresh, password reset (B.7), the dashboard "me" page.
servers
Migrations: 002_servers.sql, 007_server_claims_and_servers.sql, 008_subdomain_allocation.sql, 012_enrolled_at_and_last_frame_at.sql
One row per Phlix server claimed to the hub. The hub does not store library or media data here — only what's needed for relay (jwks_json, hostname_candidates_json) and presence (status, last_seen_at).
idCHAR(36)— UUID, primary key.user_idCHAR(36)— owner, FK →users.id(CASCADE).server_nameVARCHAR(128).versionVARCHAR(32)— last reported version (semver).jwks_jsonTEXT— the server's published JWKS, for verifying signed inbound requests.hostname_candidates_jsonTEXT— JSON array of candidate hostnames the hub may try to reach the server directly.statusENUM('online','offline','claiming','disabled').last_seen_atDATETIME— last heartbeat receipt.enrolled_atINT UNSIGNED NULL— unix timestamp written when a paired claim is promoted into aserversrow byClaimRequestHandler. Nullable so rows that pre-date migration012(back-filled fromcreated_at) and any future row that forgets to set it are still well-formed.created_at,updated_atDATETIME.
Indexes: primary id; ix_servers_user_id; ix_servers_last_seen.
Foreign keys: fk_servers_user → users.id ON DELETE CASCADE.
Read/write by: POST /api/v1/server-claims (C.3), POST /api/v1/servers/{id}/heartbeat (C.3), the dashboard server list.
server_claims
Migration: 002_servers.sql
Pending or completed claim codes minted by the hub. The hub mints a short code (claim_code, e.g. "ABCD-1234"), the user enters it on the hub UI to bind a server to their account, the hub then promotes the claim to a row in servers.
idCHAR(36)— UUID, primary key (the opaque claim id).claim_codeVARCHAR(16)— unique, human-pasteable code.user_idCHAR(36)— NULL until the claim is paired.server_name,version,jwks_json,hostname_candidates_json— captured from the server's claim request body; copied verbatim into theserversrow on pairing.statusENUM('pending','paired','expired','revoked').expires_atDATETIME— TTL for the claim code.created_at,paired_atDATETIME.paired_server_idCHAR(36)— set when the claim is paired; referencesservers.idbut is not a hard FK because the server row is created lazily.
Indexes: primary id; unique uk_server_claims_code; ix_server_claims_status_expires (for the GC cron).
Read/write by: POST /api/v1/server-claims (C.3), POST /api/v1/server-claims/{code}/pair (C.3), expiration GC.
server_heartbeats
Migrations: 002_servers.sql, 006_server_heartbeats_sent_at.sql
Recent heartbeats from each server. Used to power the dashboard's "last activity" and "sessions / transcodes right now" widgets.
idCHAR(36)— UUID.server_idCHAR(36)— FK →servers.id(CASCADE).version,uptime_seconds,active_sessions,active_transcodes— heartbeat payload.hostname_candidates_jsonTEXT— fresh hostname candidates the hub uses to refresh the parentservers.hostname_candidates_json.received_atDATETIME— hub clock, set on insert (DEFAULT CURRENT_TIMESTAMP).sent_atDATETIME NULL— server clock at heartbeat send time, populated fromHeartbeatDto::$timestamp(Phlix\Shared\Hub\HeartbeatDto). Compared againstreceived_atfor clock-skew detection. Nullable because rows written before migration006(and any future heartbeat that omits the field) carry no server-side timestamp.
Indexes: primary id; ix_server_heartbeats_server_time (server_id, received_at).
Foreign keys: fk_server_heartbeats_server → servers.id ON DELETE CASCADE.
Retention: keep last-N per server (cron pruning lands later); treat any older row as discardable.
Read/write by: POST /api/v1/servers/{id}/heartbeat (C.3), dashboard analytics.
shared_libraries
Migration: 003_shared_libraries.sql
Grant rows: "owner X lets grantee Y read library L on server S". The library_id is the server-side library UUID — the hub does not introspect the server's library tree.
idCHAR(36)— UUID.owner_user_idCHAR(36)— FK →users.id(CASCADE).grantee_user_idCHAR(36)— FK →users.id(CASCADE).server_idCHAR(36)— FK →servers.id(CASCADE).library_idCHAR(36)— opaque to the hub.library_nameVARCHAR(128)— denormalised display label.created_at,revoked_atDATETIME(revoked_atIS NULL means the grant is active).
Indexes: primary id; unique uk_shared_libraries (server_id, library_id, grantee_user_id); ix_shared_libraries_grantee; ix_shared_libraries_owner.
Foreign keys: three CASCADE FKs (owner, grantee, server).
Read/write by: POST /api/v1/users/{id}/shared, DELETE /api/v1/shared-libraries/{id} (C.9), the dashboard's "shared with me" list.
relay_sessions
Migrations: 004_relay_sessions.sql, 012_enrolled_at_and_last_frame_at.sql
Audit + dashboard rows for the WebSocket relay tunnel the hub maintains with each server. One row per connection lifetime; byte counters tick during the session, closed_at + close_reason finalise it on disconnect.
idCHAR(36)— UUID.server_idCHAR(36)— FK →servers.id(CASCADE).worker_nodeVARCHAR(128)— which hub worker terminates the WS (matters in multi-node deployments).opened_at,closed_atDATETIME.bytes_in,bytes_outBIGINT UNSIGNED.last_frame_atINT UNSIGNED NULL— unix timestamp of the most recent frame seen in either direction. Updated byRelaySessionManager::recordBytesIn()/recordBytesOut(). Nullable because sessions opened before migration012have no per-frame activity data.close_reasonVARCHAR(64)—"client-disconnect","timeout","server-shutdown", etc.
Indexes: primary id; ix_relay_sessions_server (server_id, opened_at); ix_relay_sessions_open (server_id, closed_at).
Foreign keys: fk_relay_sessions_server → servers.id ON DELETE CASCADE.
Read/write by: /api/v1/servers/{id}/relay (C.6), dashboard bandwidth analytics.
webhooks
Migration: 005_webhooks.sql
User-defined HTTP callbacks the hub delivers when a subscribed event alias fires. Event aliases are the phlix.* strings from Phlix\Shared\Plugin\EventNameMap.
idCHAR(36)— UUID.user_idCHAR(36)— FK →users.id(CASCADE).nameVARCHAR(128)— user label.target_urlVARCHAR(512).secretVARCHAR(255)— HMAC signing secret, optional.event_aliases_jsonTEXT— JSON array ofphlix.*aliases.template_jsonTEXT— handlebars body template (added in L.1).enabledTINYINT(1).created_at,updated_at,last_delivery_atDATETIME.last_delivery_statusVARCHAR(16)—"ok","4xx","5xx","timeout".
Indexes: primary id; ix_webhooks_user.
Foreign keys: fk_webhooks_user → users.id ON DELETE CASCADE.
Read/write by: /api/v1/webhooks/* (L.1+), the webhook dispatcher worker.
Migration runner
scripts/run-migrations.php wraps Phlix\Hub\Common\Database\MigrationRunner. The runner:
- Creates a
migrationstracking table on first run (CREATE TABLE IF NOT EXISTS migrations (filename VARCHAR(255) PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)). - Discovers
migrations/*.sqlin lexicographic order. - For each file not yet in the tracking table:
- Strips
--comments and blank lines. - Splits on
;into individual statements. - Executes each statement.
- Inserts a row into
migrationsonce the file completes.
- Strips
- Re-running is a no-op (idempotent).
Every business-table file also uses CREATE TABLE IF NOT EXISTS as a belt-and-braces guard for the case where the tracking table is dropped but the schema persists.
Test environment
The integration test (tests/Integration/Migrations/MigrationRunnerIntegrationTest.php) applies all five migrations against a real MySQL test database configured via HUB_TEST_DB_* environment variables and asserts:
- Every business table exists after the run.
- Re-running the runner applies nothing new.
- The
uk_users_emailunique constraint rejects duplicate emails. fk_servers_usercascades on user delete.- All seven foreign-key constraint names are present in
information_schema.TABLE_CONSTRAINTS.
The suite is skipped automatically when:
HUB_TEST_DB_*is not set, or- the test database runs MySQL Group Replication with
group_replication_enforce_update_everywhere_checks=ON(which forbids CASCADE foreign keys).