feat(teams): core schema for Teams, membership, sync state and moderation
The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5,
§2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign
keys rather than left to whatever the defaults happened to be.
Every table is core-internal (§10.3): a module populates them through the team
provider and must never read or write one directly. They carry no <moduleId>_
prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these
are core's.
team_forum_grants lands in this phase rather than in phase 4, so the four-path
resolver is written once and its non-contamination tests are real. Nothing
writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are
phase 4's.
Two departures from the SQL as TEAMS.md sketched it, both recorded in the file:
- team_forum_grants.user_id is nullable with ON DELETE SET NULL, following
§2.10 (the audit trail of who granted whom must survive the account) rather
than §2.5's CASCADE.
- its uniqueness marker is derived from revoked_at alone, with user_id moved
into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id,
NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET
NULL on a foreign key whose column is a base column of a STORED generated
column (error 1901). The semantics are identical -- at most one active grant
per (team, user), unlimited revoked rows.
Verified by running ensureSchema() against MariaDB 11: all six tables create,
both generated columns materialise, and every foreign key's delete rule matches
§2.10's table. The uniqueness encoding was checked directly -- a second active
grant for the same (team, user) is rejected 1062 while revoked rows accumulate
freely.
Refs docs/website/TEAMS.md Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -845,6 +845,202 @@ CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
|
||||
--
|
||||
-- A Team is a core platform entity POPULATED by a module and owned by core. The
|
||||
-- module answers "what teams exist and who is in them" through the team provider
|
||||
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
|
||||
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
|
||||
-- never read or write one, even though a module is what fills them.
|
||||
--
|
||||
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
|
||||
-- prefix rule binds modules, and these are core's.
|
||||
|
||||
-- The Team itself. `external_id` is the module's own stable identity for it
|
||||
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
|
||||
--
|
||||
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
|
||||
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
|
||||
-- activity, its grants and its forum as a read-only record. What staff can change
|
||||
-- is display_name_override, which changes what is RENDERED and never what the row
|
||||
-- IS — identity and display are different things and only identity is frozen.
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
|
||||
external_id VARCHAR(191) NOT NULL, -- opaque to core
|
||||
name VARCHAR(160) NOT NULL,
|
||||
abbr VARCHAR(32) NULL,
|
||||
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
|
||||
status ENUM('active','archived') NOT NULL DEFAULT 'active',
|
||||
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
|
||||
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
|
||||
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
|
||||
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
|
||||
-- Public suppression, independent of status. A hidden Team still works
|
||||
-- completely for its own members; it is absent from public surfaces (§2.8).
|
||||
hidden TINYINT(1) NOT NULL DEFAULT 0,
|
||||
hidden_reason ENUM('reserved_name','staff') NULL,
|
||||
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
|
||||
-- Set once staff have made an explicit decision about the name. Re-screening
|
||||
-- runs on every sync, and this is what stops it re-hiding a Team a human has
|
||||
-- already allowed — without it the override would be undone every 15 minutes.
|
||||
name_reviewed_at DATETIME NULL,
|
||||
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
|
||||
display_name_override VARCHAR(160) NULL,
|
||||
-- The successor row written at archive time when this Team was renamed, so the
|
||||
-- old slug can still resolve and explain itself rather than 404 (§2.2).
|
||||
succeeded_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at DATETIME NULL,
|
||||
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
|
||||
-- A generated column is how "unique among ACTIVE rows only" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
|
||||
-- any number of archived rows may share an external_id.
|
||||
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
|
||||
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
|
||||
UNIQUE KEY uq_teams_active (module_id, active_key),
|
||||
UNIQUE KEY uq_teams_active_slug (active_slug),
|
||||
INDEX idx_teams_status (status),
|
||||
INDEX idx_teams_slug (slug),
|
||||
INDEX idx_teams_review (hidden, hidden_reason),
|
||||
-- Self-referential and deliberately SET NULL: a successor may itself be archived
|
||||
-- and eventually pruned, and losing the pointer must not take the old row with it.
|
||||
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
|
||||
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
|
||||
-- deleted so history and rejoin detection survive, and so the activity feed can
|
||||
-- still name a departed member.
|
||||
--
|
||||
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
|
||||
-- resolves it, because doing so would be core reading a module's table by name.
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
|
||||
display_name VARCHAR(160) NULL, -- in-game name
|
||||
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
|
||||
is_leader TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('active','departed') NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
departed_at DATETIME NULL,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
|
||||
-- character from the guild — only the link to the site goes.
|
||||
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_team_members_user (user_id),
|
||||
INDEX idx_team_members_status (team_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Freshness of the module's answer. One row per module. THE table invariant 1
|
||||
-- ("module unavailability is staleness, never emptiness") is enforced against.
|
||||
CREATE TABLE IF NOT EXISTS team_sync_state (
|
||||
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_success_at DATETIME NULL,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
|
||||
-- answer is remembered here and applied only if the NEXT one agrees.
|
||||
pending_empty_since DATETIME NULL,
|
||||
INDEX idx_team_sync_success (last_success_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
|
||||
-- time. The projection is never mutated: the sync keeps writing what the game
|
||||
-- says and this keeps saying what staff decided, which is the whole point — an
|
||||
-- override the sync clobbered every 15 minutes would be useless.
|
||||
CREATE TABLE IF NOT EXISTS team_leader_overrides (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL,
|
||||
effect ENUM('grant','deny') NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
|
||||
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
|
||||
-- generated column is how "one active grant per (team,user)" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
|
||||
--
|
||||
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
|
||||
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
|
||||
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
|
||||
--
|
||||
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
|
||||
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
|
||||
-- trail of who granted whom, which is exactly what an audit exists to survive. The
|
||||
-- username snapshots keep the record readable after the account is gone.
|
||||
--
|
||||
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
|
||||
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
|
||||
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
|
||||
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
|
||||
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
|
||||
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
|
||||
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
|
||||
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
|
||||
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_grants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
user_id INT NULL,
|
||||
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
|
||||
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
|
||||
granted_username VARCHAR(32) NULL, -- snapshot of the actor
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
reason VARCHAR(255) NULL,
|
||||
revoked_by INT NULL,
|
||||
revoked_username VARCHAR(32) NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
revoke_reason VARCHAR(255) NULL,
|
||||
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
|
||||
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
|
||||
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfg_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
||||
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||
-- moderator asked to publish this name and an admin refused" is the record worth
|
||||
-- having.
|
||||
--
|
||||
-- `action` + `payload` means a fourth gated action is an enum value rather than a
|
||||
-- schema change. That is room to extend, not an invitation: nothing else is gated
|
||||
-- today, and nothing should be without asking §2.9's question first.
|
||||
CREATE TABLE IF NOT EXISTS team_moderation_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
|
||||
payload JSON NULL, -- e.g. { "displayName": "…" }
|
||||
reason VARCHAR(255) NULL,
|
||||
requested_by INT NULL,
|
||||
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
|
||||
decided_by INT NULL,
|
||||
decided_username VARCHAR(32) NULL,
|
||||
decided_at DATETIME NULL,
|
||||
decision_note VARCHAR(255) NULL,
|
||||
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tmr_queue (status, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
Reference in New Issue
Block a user