From 58e91a21fabfc0a08e10e744f43fa769224adb2c Mon Sep 17 00:00:00 2001 From: RomeoCavazza Date: Thu, 25 Jun 2026 03:57:14 +0200 Subject: [PATCH] refactor(schema): split baseline migrations by domain --- .gitattributes | 2 + server/migrations/0001_init.sql | 100 ------------------ server/migrations/0001_users.sql | 9 ++ server/migrations/0002_teams_rbac.sql | 23 ++++ server/migrations/0003_auth_sessions.sql | 11 ++ server/migrations/0003_team_bans.sql | 19 ---- server/migrations/0004_incidents.sql | 15 +++ server/migrations/0004_private_messages.sql | 25 ----- server/migrations/0005_releases.sql | 39 ------- server/migrations/0005_timeline.sql | 12 +++ server/migrations/0006_external_secrets.sql | 13 +++ ...s.sql => 0007_timeline_edit_reactions.sql} | 13 +-- server/migrations/0008_team_bans.sql | 19 ++++ server/migrations/0009_private_messages.sql | 20 ++++ server/migrations/0010_releases.sql | 39 +++++++ 15 files changed, 165 insertions(+), 194 deletions(-) create mode 100644 .gitattributes delete mode 100644 server/migrations/0001_init.sql create mode 100644 server/migrations/0001_users.sql create mode 100644 server/migrations/0002_teams_rbac.sql create mode 100644 server/migrations/0003_auth_sessions.sql delete mode 100644 server/migrations/0003_team_bans.sql create mode 100644 server/migrations/0004_incidents.sql delete mode 100644 server/migrations/0004_private_messages.sql delete mode 100644 server/migrations/0005_releases.sql create mode 100644 server/migrations/0005_timeline.sql create mode 100644 server/migrations/0006_external_secrets.sql rename server/migrations/{0002_timeline_edit_reactions.sql => 0007_timeline_edit_reactions.sql} (51%) create mode 100644 server/migrations/0008_team_bans.sql create mode 100644 server/migrations/0009_private_messages.sql create mode 100644 server/migrations/0010_releases.sql diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77eb428 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.sqlx/** linguist-generated=true + diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql deleted file mode 100644 index a791774..0000000 --- a/server/migrations/0001_init.sql +++ /dev/null @@ -1,100 +0,0 @@ --- OpsWarden — schema dictionary (single init migration). --- --- One file, every table. We deliberately keep the whole schema here rather than --- splitting it across many migrations: it reads as a dictionary of the domain's --- persisted state, and sqlx compiles every `query!` against it in one place. --- New tables get a new commented section below, not a new file. - --- ============================================================================ --- Users — authentication identities (see `domain::user`). --- ============================================================================ -create table if not exists users ( - id uuid primary key, - email text unique not null, - password_hash text not null, - created_at timestamptz not null default now() -); - --- ============================================================================ --- Teams & RBAC — teams and their role-scoped membership (see `domain::team`). --- ============================================================================ -create table if not exists teams ( - id uuid primary key, - name text not null, - -- Human-friendly join handle, e.g. `OPS-A7B9X2` (see `InvitationCode`). - invitation_code text unique not null, - created_at timestamptz not null default now() -); - -create table if not exists team_members ( - team_id uuid not null references teams (id) on delete cascade, - user_id uuid not null references users (id) on delete cascade, - -- RBAC role; mirrors the `domain::team::Role` enum. - role text not null check (role in ('observer', 'responder', 'manager')), - joined_at timestamptz not null default now(), - -- A user holds at most one role per team. - primary key (team_id, user_id) -); - --- Single-Manager invariant enforced at the storage layer: a team may hold at --- most one Manager. Mirrors `domain::team::plan_manager_transfer`, so a faulty --- non-atomic write can never produce two Managers. -create unique index if not exists one_manager_per_team - on team_members (team_id) - where role = 'manager'; - --- ============================================================================ --- Auth sessions — revoked bearer tokens kept until natural expiration. --- ============================================================================ -create table if not exists revoked_tokens ( - token_hash text primary key, - expires_at timestamptz not null, - revoked_at timestamptz not null default now() -); - -create index if not exists revoked_tokens_expires_at_idx - on revoked_tokens (expires_at); - --- ============================================================================ --- Incidents — lifecycle state tracked inside a team workspace. --- ============================================================================ -create table if not exists incidents ( - id uuid primary key, - team_id uuid not null references teams (id) on delete cascade, - title text not null, - status text not null check (status in ('open', 'acknowledged', 'escalated', 'resolved')), - severity text not null check (severity in ('low', 'medium', 'high', 'critical')), - -- Responder assigned by a Manager; nullable (unassigned), kept on user delete. - assignee_id uuid references users (id) on delete set null, - created_at timestamptz not null -); - -create index if not exists incidents_team_created_idx - on incidents (team_id, created_at desc); - --- ============================================================================ --- Incident timeline — timestamped entries authored by team members. --- ============================================================================ -create table if not exists timeline_entries ( - id uuid primary key, - incident_id uuid not null references incidents (id) on delete cascade, - author_id uuid not null references users (id) on delete restrict, - content text not null, - created_at timestamptz not null -); - -create index if not exists timeline_entries_incident_created_idx - on timeline_entries (incident_id, created_at desc); - --- ============================================================================ --- External secrets — encrypted third-party credentials (see `ports::SecretVault` --- / `adapters::pg::vault`). AES-256-GCM: only the per-row nonce and ciphertext --- are stored, so a raw `SELECT * FROM external_secrets` reveals nothing usable. --- Keyed by service name ("github", later "slack", …). --- ============================================================================ -create table if not exists external_secrets ( - service text primary key, - nonce bytea not null, - ciphertext bytea not null, - updated_at timestamptz not null default now() -); diff --git a/server/migrations/0001_users.sql b/server/migrations/0001_users.sql new file mode 100644 index 0000000..e0a1ba9 --- /dev/null +++ b/server/migrations/0001_users.sql @@ -0,0 +1,9 @@ +-- Users — authentication identities (see `domain::user`). + +create table if not exists users ( + id uuid primary key, + email text unique not null, + password_hash text not null, + created_at timestamptz not null default now() +); + diff --git a/server/migrations/0002_teams_rbac.sql b/server/migrations/0002_teams_rbac.sql new file mode 100644 index 0000000..415eb15 --- /dev/null +++ b/server/migrations/0002_teams_rbac.sql @@ -0,0 +1,23 @@ +-- Teams & RBAC — teams and their role-scoped membership. + +create table if not exists teams ( + id uuid primary key, + name text not null, + -- Human-friendly join handle, e.g. `OPS-A7B9X2`. + invitation_code text unique not null, + created_at timestamptz not null default now() +); + +create table if not exists team_members ( + team_id uuid not null references teams (id) on delete cascade, + user_id uuid not null references users (id) on delete cascade, + -- Mirrors the `domain::team::Role` enum. + role text not null check (role in ('observer', 'responder', 'manager')), + joined_at timestamptz not null default now(), + primary key (team_id, user_id) +); + +-- Storage-level guard for the single-Manager invariant. +create unique index if not exists team_members_one_manager_idx + on team_members (team_id) + where role = 'manager'; diff --git a/server/migrations/0003_auth_sessions.sql b/server/migrations/0003_auth_sessions.sql new file mode 100644 index 0000000..96b7f6f --- /dev/null +++ b/server/migrations/0003_auth_sessions.sql @@ -0,0 +1,11 @@ +-- Auth sessions — revoked bearer tokens kept until natural expiration. + +create table if not exists revoked_tokens ( + token_hash text primary key, + expires_at timestamptz not null, + revoked_at timestamptz not null default now() +); + +create index if not exists revoked_tokens_expires_at_idx + on revoked_tokens (expires_at); + diff --git a/server/migrations/0003_team_bans.sql b/server/migrations/0003_team_bans.sql deleted file mode 100644 index 7fdb154..0000000 --- a/server/migrations/0003_team_bans.sql +++ /dev/null @@ -1,19 +0,0 @@ --- RTC2 moderation: per-team bans that block (re)joining. --- One ban row per (team, user) — the composite primary key is the unique --- constraint, so re-banning upserts rather than duplicating. A NULL expires_at --- means a permanent ban; a non-NULL value is a temporary ban that stops --- blocking once it passes. -CREATE TABLE IF NOT EXISTS team_bans ( - team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, - expires_at timestamptz, - reason text, - -- The moderator who issued the ban. Nullable + ON DELETE SET NULL so deleting - -- the moderator's account later never fails on this FK; the ban record (and - -- its block) survives, it just loses the "issued by" attribution. - created_by uuid REFERENCES users(id) ON DELETE SET NULL, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (team_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_team_bans_team ON team_bans (team_id); diff --git a/server/migrations/0004_incidents.sql b/server/migrations/0004_incidents.sql new file mode 100644 index 0000000..15f0be4 --- /dev/null +++ b/server/migrations/0004_incidents.sql @@ -0,0 +1,15 @@ +-- Incidents — lifecycle state tracked inside a team workspace. + +create table if not exists incidents ( + id uuid primary key, + team_id uuid not null references teams (id) on delete cascade, + title text not null, + status text not null check (status in ('open', 'acknowledged', 'escalated', 'resolved')), + severity text not null check (severity in ('low', 'medium', 'high', 'critical')), + -- Responder assigned by a Manager; nullable means unassigned. + assignee_id uuid references users (id) on delete set null, + created_at timestamptz not null +); + +create index if not exists incidents_team_created_at_idx + on incidents (team_id, created_at desc); diff --git a/server/migrations/0004_private_messages.sql b/server/migrations/0004_private_messages.sql deleted file mode 100644 index c340336..0000000 --- a/server/migrations/0004_private_messages.sql +++ /dev/null @@ -1,25 +0,0 @@ --- RTC2 private messages: strictly bilateral 1-to-1 direct messages between two --- users. Not tied to an incident/release/team — the conversation is keyed only --- by its two participants. Both participants read the same history; the stored --- (sender_id, recipient_id) keeps authorship unambiguous while reads fetch both --- directions. Content length is bounded in the domain (`PrivateMessage`, 2000 --- chars); the column itself stays plain `text`. --- --- ON DELETE CASCADE on both participants: deleting an account removes that --- user's private messages. The conversation history therefore only lives while --- both participants exist — an accepted simplification for now (account deletion --- is already guarded elsewhere), revisited if PM retention becomes a concern. -CREATE TABLE IF NOT EXISTS private_messages ( - id uuid PRIMARY KEY, - sender_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, - recipient_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, - content text NOT NULL, - created_at timestamptz NOT NULL -); - --- A conversation read fetches both directions between two users, newest first. --- Two directed indexes cover both legs of the `(a->b) OR (b->a)` filter. -CREATE INDEX IF NOT EXISTS idx_private_messages_sender_recipient - ON private_messages (sender_id, recipient_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_private_messages_recipient_sender - ON private_messages (recipient_id, sender_id, created_at DESC); diff --git a/server/migrations/0005_releases.sql b/server/migrations/0005_releases.sql deleted file mode 100644 index 6aab53b..0000000 --- a/server/migrations/0005_releases.sql +++ /dev/null @@ -1,39 +0,0 @@ --- VIGIL Phase 1 core: Releases — planned deployments coordinated in real time, --- composed of sequential validated steps, that an active linked Incident blocks. --- --- `base_state` is the stored lifecycle (created/in_progress/completed/cancelled). --- The effective `blocked` state is NEVER stored: it is derived at read/emit time --- as "base_state = in_progress AND >= 1 linked incident is not resolved", so a --- release auto-unblocks the moment its last active linked incident resolves. -CREATE TABLE IF NOT EXISTS releases ( - id uuid PRIMARY KEY, - team_id uuid NOT NULL REFERENCES teams (id) ON DELETE CASCADE, - title text NOT NULL, - base_state text NOT NULL CHECK (base_state IN ('created', 'in_progress', 'completed', 'cancelled')), - created_at timestamptz NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_releases_team ON releases (team_id, created_at DESC); - --- Ordered steps; a step validates only after the previous one. `validated_by` --- keeps attribution and survives kick/ban (moderation only removes team --- membership, not the user account). ON DELETE SET NULL so deleting the *account* --- later never fails on this FK; the validation record (and its order) survives. -CREATE TABLE IF NOT EXISTS release_steps ( - release_id uuid NOT NULL REFERENCES releases (id) ON DELETE CASCADE, - position integer NOT NULL, - name text NOT NULL, - validated_by uuid REFERENCES users (id) ON DELETE SET NULL, - validated_at timestamptz, - PRIMARY KEY (release_id, position) -); - --- Many-to-many incident links. A release is blocked while ANY linked incident is --- active (status <> 'resolved'); it unblocks once all linked incidents resolve. -CREATE TABLE IF NOT EXISTS release_incidents ( - release_id uuid NOT NULL REFERENCES releases (id) ON DELETE CASCADE, - incident_id uuid NOT NULL REFERENCES incidents (id) ON DELETE CASCADE, - PRIMARY KEY (release_id, incident_id) -); - -CREATE INDEX IF NOT EXISTS idx_release_incidents_incident ON release_incidents (incident_id); diff --git a/server/migrations/0005_timeline.sql b/server/migrations/0005_timeline.sql new file mode 100644 index 0000000..1d67bc7 --- /dev/null +++ b/server/migrations/0005_timeline.sql @@ -0,0 +1,12 @@ +-- Incident timeline — timestamped entries authored by team members. + +create table if not exists timeline_entries ( + id uuid primary key, + incident_id uuid not null references incidents (id) on delete cascade, + author_id uuid not null references users (id) on delete restrict, + content text not null, + created_at timestamptz not null +); + +create index if not exists timeline_entries_incident_created_at_idx + on timeline_entries (incident_id, created_at desc); diff --git a/server/migrations/0006_external_secrets.sql b/server/migrations/0006_external_secrets.sql new file mode 100644 index 0000000..c79d7d9 --- /dev/null +++ b/server/migrations/0006_external_secrets.sql @@ -0,0 +1,13 @@ +-- External secrets — encrypted third-party credentials. +-- +-- AES-256-GCM: only the per-row nonce and ciphertext are stored, so a raw +-- `SELECT * FROM external_secrets` reveals nothing usable. Keyed by service +-- name ("github", later "slack", ...). + +create table if not exists external_secrets ( + service text primary key, + nonce bytea not null, + ciphertext bytea not null, + updated_at timestamptz not null default now() +); + diff --git a/server/migrations/0002_timeline_edit_reactions.sql b/server/migrations/0007_timeline_edit_reactions.sql similarity index 51% rename from server/migrations/0002_timeline_edit_reactions.sql rename to server/migrations/0007_timeline_edit_reactions.sql index 082793d..7f2d3e0 100644 --- a/server/migrations/0002_timeline_edit_reactions.sql +++ b/server/migrations/0007_timeline_edit_reactions.sql @@ -1,21 +1,12 @@ --- OpsWarden — RTC 2 timeline edit + reactions. --- --- A separate versioned migration (not a new section in 0001): `sqlx::migrate!` --- checksum-locks an applied migration, and the running dev database already has --- 0001 applied with live data we must not drop. New incremental schema therefore --- ships as its own file. +-- Timeline edit + reactions. --- ============================================================================ -- Timeline editing — mark an entry as edited while preserving created_at. --- ============================================================================ alter table timeline_entries add column if not exists edited_at timestamptz; --- ============================================================================ -- Timeline reactions — persistent emoji reactions per timeline entry. -- The composite primary key makes a (user, emoji) reaction on an entry unique, -- so a user can never duplicate the same emoji on the same entry. --- ============================================================================ create table if not exists timeline_reactions ( entry_id uuid not null references timeline_entries (id) on delete cascade, user_id uuid not null references users (id) on delete cascade, @@ -24,5 +15,5 @@ create table if not exists timeline_reactions ( primary key (entry_id, user_id, emoji) ); -create index if not exists timeline_reactions_entry_idx +create index if not exists timeline_reactions_entry_id_idx on timeline_reactions (entry_id); diff --git a/server/migrations/0008_team_bans.sql b/server/migrations/0008_team_bans.sql new file mode 100644 index 0000000..b18c9d6 --- /dev/null +++ b/server/migrations/0008_team_bans.sql @@ -0,0 +1,19 @@ +-- Team moderation — per-team bans that block rejoining. +-- +-- One ban row per (team, user). A NULL expires_at means a permanent ban; a +-- non-NULL value is a temporary ban that stops blocking once it passes. + +create table if not exists team_bans ( + team_id uuid not null references teams (id) on delete cascade, + user_id uuid not null references users (id) on delete cascade, + expires_at timestamptz, + reason text, + -- Nullable + ON DELETE SET NULL so deleting the moderator's account later + -- never fails on this FK; the ban survives without attribution. + created_by uuid references users (id) on delete set null, + created_at timestamptz not null default now(), + primary key (team_id, user_id) +); + +create index if not exists team_bans_team_id_idx + on team_bans (team_id); diff --git a/server/migrations/0009_private_messages.sql b/server/migrations/0009_private_messages.sql new file mode 100644 index 0000000..9ac150c --- /dev/null +++ b/server/migrations/0009_private_messages.sql @@ -0,0 +1,20 @@ +-- Private messages — bilateral 1-to-1 direct messages. +-- +-- Not tied to an incident, release, or team. Authorization is enforced by the +-- app layer through shared-team membership; the stored pair keeps authorship +-- unambiguous while reads fetch both directions. + +create table if not exists private_messages ( + id uuid primary key, + sender_id uuid not null references users (id) on delete cascade, + recipient_id uuid not null references users (id) on delete cascade, + content text not null, + created_at timestamptz not null +); + +-- A conversation read fetches both directions between two users, newest first. +create index if not exists private_messages_sender_recipient_created_at_idx + on private_messages (sender_id, recipient_id, created_at desc); + +create index if not exists private_messages_recipient_sender_created_at_idx + on private_messages (recipient_id, sender_id, created_at desc); diff --git a/server/migrations/0010_releases.sql b/server/migrations/0010_releases.sql new file mode 100644 index 0000000..632b82e --- /dev/null +++ b/server/migrations/0010_releases.sql @@ -0,0 +1,39 @@ +-- Releases — planned deployments coordinated in real time. +-- +-- `base_state` is the stored lifecycle (created/in_progress/completed/cancelled). +-- The effective `blocked` state is derived at read/emit time as: +-- base_state = in_progress AND >= 1 linked incident is not resolved. + +create table if not exists releases ( + id uuid primary key, + team_id uuid not null references teams (id) on delete cascade, + title text not null, + base_state text not null check (base_state in ('created', 'in_progress', 'completed', 'cancelled')), + created_at timestamptz not null +); + +create index if not exists releases_team_created_at_idx + on releases (team_id, created_at desc); + +-- Ordered steps; a step validates only after the previous one. +-- `validated_by` survives kick/ban because moderation removes membership, not +-- the user account. ON DELETE SET NULL handles later account deletion. +create table if not exists release_steps ( + release_id uuid not null references releases (id) on delete cascade, + position integer not null, + name text not null, + validated_by uuid references users (id) on delete set null, + validated_at timestamptz, + primary key (release_id, position) +); + +-- Many-to-many incident links. A release is blocked while any linked incident is +-- active (status <> 'resolved'); it unblocks once all linked incidents resolve. +create table if not exists release_incidents ( + release_id uuid not null references releases (id) on delete cascade, + incident_id uuid not null references incidents (id) on delete cascade, + primary key (release_id, incident_id) +); + +create index if not exists release_incidents_incident_id_idx + on release_incidents (incident_id);