309 lines
13 KiB
MySQL
309 lines
13 KiB
MySQL
|
|
-- ============================================================================
|
||
|
|
-- One-shot drift reconciliation #2 against CT 102's `tasks` database.
|
||
|
|
--
|
||
|
|
-- Background (read 2026-06-02-db-drift-reconcile.sql first for full history):
|
||
|
|
-- The CT 102 DB was bootstrapped from `drizzle-kit push`, which writes
|
||
|
|
-- schema directly and never records ledger rows. The 2026-06-02 reconcile
|
||
|
|
-- stamped migrations 0000-0007 as applied AND created the two tables
|
||
|
|
-- that script could see were missing (markdown_backlog_items,
|
||
|
|
-- cursor_sync_mappings). It did NOT verify every other table from
|
||
|
|
-- every other migration was present — it trusted the historical
|
||
|
|
-- `drizzle-kit push` had landed them.
|
||
|
|
--
|
||
|
|
-- That assumption broke today (2026-06-05) when SSO via Authentik failed
|
||
|
|
-- on the OAuth callback with:
|
||
|
|
-- [auth][cause]: relation "accounts" does not exist
|
||
|
|
-- `accounts` is defined in migration 0000 (lines 67-80 of
|
||
|
|
-- 0000_nervous_ogun.sql) but never made it onto CT 102. Credentials
|
||
|
|
-- login worked all along because it only reads `users`; OAuth providers
|
||
|
|
-- funnel through `resolveOAuthUser` in apps/web/lib/auth.ts, which
|
||
|
|
-- writes to `accounts` and `user_email_identities`.
|
||
|
|
--
|
||
|
|
-- Because the 2026-06-02 script stamped 0000 as applied, drizzle's
|
||
|
|
-- migrator will not replay it. The only safe fix is another idempotent
|
||
|
|
-- one-shot.
|
||
|
|
--
|
||
|
|
-- This script (idempotent, safe to run more than once):
|
||
|
|
-- 1. Creates every auth-adjacent table that *might* be missing from
|
||
|
|
-- migrations 0000, 0005, 0006, 0007, 0009, at their final post-0009
|
||
|
|
-- shape. CREATE TABLE IF NOT EXISTS makes already-present tables a
|
||
|
|
-- no-op.
|
||
|
|
-- 2. Adds the matching FKs and indexes, each guarded with a DO block so
|
||
|
|
-- duplicate_object / duplicate_table errors are swallowed.
|
||
|
|
-- 3. Backfills user_email_identities from existing users (matches the
|
||
|
|
-- seed step in migration 0005).
|
||
|
|
-- 4. Does NOT touch drizzle.__drizzle_migrations. The ledger is already
|
||
|
|
-- consistent — see step 3 of the operator notes at the bottom for
|
||
|
|
-- the verification query.
|
||
|
|
--
|
||
|
|
-- Run as:
|
||
|
|
-- psql "$DATABASE_URL" -f 2026-06-05-db-drift-reconcile-oauth-tables.sql
|
||
|
|
--
|
||
|
|
-- After running, `pnpm db:migrate` stays a clean no-op and Authentik /
|
||
|
|
-- GitHub / Google sign-in will work.
|
||
|
|
-- ============================================================================
|
||
|
|
|
||
|
|
BEGIN;
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 1. accounts (migration 0000) — the table whose absence is breaking SSO.
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "accounts" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"user_id" uuid NOT NULL,
|
||
|
|
"type" varchar(255) NOT NULL,
|
||
|
|
"provider" varchar(255) NOT NULL,
|
||
|
|
"provider_account_id" varchar(255) NOT NULL,
|
||
|
|
"refresh_token" text,
|
||
|
|
"access_token" text,
|
||
|
|
"expires_at" integer,
|
||
|
|
"token_type" varchar(255),
|
||
|
|
"scope" varchar(255),
|
||
|
|
"id_token" text,
|
||
|
|
"session_state" varchar(255)
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "accounts"
|
||
|
|
ADD CONSTRAINT "accounts_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "accounts_provider_provider_account_id_unique"
|
||
|
|
ON "accounts" USING btree ("provider", "provider_account_id");
|
||
|
|
CREATE INDEX IF NOT EXISTS "accounts_user_id_idx"
|
||
|
|
ON "accounts" USING btree ("user_id");
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 2. sessions + verification_tokens (migration 0000).
|
||
|
|
-- Not strictly required at runtime since session.strategy = "jwt", but
|
||
|
|
-- keeping the schema honest avoids the next "wait, that's missing too?"
|
||
|
|
-- moment. Skipped harmlessly if already present.
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "sessions" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"session_token" varchar(255) NOT NULL,
|
||
|
|
"user_id" uuid NOT NULL,
|
||
|
|
"expires" timestamp with time zone NOT NULL,
|
||
|
|
CONSTRAINT "sessions_session_token_unique" UNIQUE ("session_token")
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "sessions"
|
||
|
|
ADD CONSTRAINT "sessions_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS "sessions_user_id_idx"
|
||
|
|
ON "sessions" USING btree ("user_id");
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "verification_tokens" (
|
||
|
|
"identifier" varchar(255) NOT NULL,
|
||
|
|
"token" varchar(255) NOT NULL,
|
||
|
|
"expires" timestamp with time zone NOT NULL,
|
||
|
|
CONSTRAINT "verification_tokens_identifier_token_pk"
|
||
|
|
PRIMARY KEY ("identifier", "token")
|
||
|
|
);
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 3. user_email_identities (migration 0005).
|
||
|
|
-- The OAuth path also writes here via ensureUserIdByVerifiedEmail, so
|
||
|
|
-- its absence would re-break SSO right after we fixed `accounts`.
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "user_email_identities" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"user_id" uuid NOT NULL,
|
||
|
|
"email" varchar(255) NOT NULL,
|
||
|
|
"verified_at" timestamp with time zone,
|
||
|
|
"source" varchar(30) NOT NULL,
|
||
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||
|
|
"last_used_at" timestamp with time zone
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "user_email_identities"
|
||
|
|
ADD CONSTRAINT "user_email_identities_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS "user_email_identities_user_id_idx"
|
||
|
|
ON "user_email_identities" USING btree ("user_id");
|
||
|
|
CREATE INDEX IF NOT EXISTS "user_email_identities_email_idx"
|
||
|
|
ON "user_email_identities" USING btree ("email");
|
||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_identities_user_id_email_unique"
|
||
|
|
ON "user_email_identities" USING btree ("user_id", "email");
|
||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "user_email_identities_verified_email_unique"
|
||
|
|
ON "user_email_identities" USING btree ("email")
|
||
|
|
WHERE "user_email_identities"."verified_at" IS NOT NULL;
|
||
|
|
|
||
|
|
-- Backfill (matches the seed in migration 0005). ON CONFLICT makes this
|
||
|
|
-- safe to re-run.
|
||
|
|
INSERT INTO "user_email_identities" ("user_id", "email", "verified_at", "source", "created_at")
|
||
|
|
SELECT "id", lower("email"), "created_at", 'primary', "created_at"
|
||
|
|
FROM "users"
|
||
|
|
ON CONFLICT ("user_id", "email") DO NOTHING;
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 4. workspace_invites (migration 0006).
|
||
|
|
-- Not on the SSO critical path, but the invite-acceptance flow lands in
|
||
|
|
-- the same auth.ts module and will hit a missing-table error the first
|
||
|
|
-- time a workspace admin clicks "invite".
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "workspace_invites" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"workspace_id" uuid NOT NULL,
|
||
|
|
"email" varchar(255) NOT NULL,
|
||
|
|
"role" varchar(20) NOT NULL,
|
||
|
|
"invited_by_user_id" uuid NOT NULL,
|
||
|
|
"token" varchar(128) NOT NULL,
|
||
|
|
"expires_at" timestamp with time zone DEFAULT now() + interval '14 days' NOT NULL,
|
||
|
|
"accepted_at" timestamp with time zone,
|
||
|
|
"revoked_at" timestamp with time zone,
|
||
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "workspace_invites"
|
||
|
|
ADD CONSTRAINT "workspace_invites_workspace_id_workspaces_id_fk"
|
||
|
|
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "workspace_invites"
|
||
|
|
ADD CONSTRAINT "workspace_invites_invited_by_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS "workspace_invites_workspace_id_idx"
|
||
|
|
ON "workspace_invites" USING btree ("workspace_id");
|
||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_invites_token_unique"
|
||
|
|
ON "workspace_invites" USING btree ("token");
|
||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_invites_open_email_unique"
|
||
|
|
ON "workspace_invites" USING btree ("workspace_id", "email")
|
||
|
|
WHERE "workspace_invites"."accepted_at" IS NULL
|
||
|
|
AND "workspace_invites"."revoked_at" IS NULL;
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 5. audit_log (migration 0007).
|
||
|
|
-- The 2026-06-02 notes claimed audit_log was added manually, but if a
|
||
|
|
-- fresh DB ever skipped that step this guards it.
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "audit_log" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"workspace_id" uuid NOT NULL,
|
||
|
|
"actor_user_id" uuid,
|
||
|
|
"action" varchar(100) NOT NULL,
|
||
|
|
"target_type" varchar(50) NOT NULL,
|
||
|
|
"target_id" uuid,
|
||
|
|
"metadata" jsonb,
|
||
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "audit_log"
|
||
|
|
ADD CONSTRAINT "audit_log_workspace_id_workspaces_id_fk"
|
||
|
|
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "audit_log"
|
||
|
|
ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE set null ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS "audit_log_workspace_id_created_at_idx"
|
||
|
|
ON "audit_log" USING btree ("workspace_id", "created_at");
|
||
|
|
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx"
|
||
|
|
ON "audit_log" USING btree ("actor_user_id");
|
||
|
|
CREATE INDEX IF NOT EXISTS "audit_log_action_idx"
|
||
|
|
ON "audit_log" USING btree ("action");
|
||
|
|
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
-- 6. agent_runs (migration 0009).
|
||
|
|
-- Added after the 2026-06-02 reconcile, so it *should* be present from
|
||
|
|
-- a normal `pnpm db:migrate` run. Guard it anyway since this script is
|
||
|
|
-- meant to leave the schema in a known state.
|
||
|
|
-- ----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS "agent_runs" (
|
||
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||
|
|
"workspace_id" uuid NOT NULL,
|
||
|
|
"backlog_item_id" uuid NOT NULL,
|
||
|
|
"actor_user_id" uuid,
|
||
|
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||
|
|
"finished_at" timestamp with time zone,
|
||
|
|
"outcome" varchar(20),
|
||
|
|
"error" text,
|
||
|
|
"tokens_input" integer,
|
||
|
|
"tokens_output" integer,
|
||
|
|
"tokens_total" integer,
|
||
|
|
"notes" text,
|
||
|
|
"metadata" jsonb
|
||
|
|
);
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "agent_runs"
|
||
|
|
ADD CONSTRAINT "agent_runs_workspace_id_workspaces_id_fk"
|
||
|
|
FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "agent_runs"
|
||
|
|
ADD CONSTRAINT "agent_runs_backlog_item_id_markdown_backlog_items_id_fk"
|
||
|
|
FOREIGN KEY ("backlog_item_id") REFERENCES "public"."markdown_backlog_items"("id")
|
||
|
|
ON DELETE cascade ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
DO $$ BEGIN
|
||
|
|
ALTER TABLE "agent_runs"
|
||
|
|
ADD CONSTRAINT "agent_runs_actor_user_id_users_id_fk"
|
||
|
|
FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id")
|
||
|
|
ON DELETE set null ON UPDATE no action;
|
||
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS "agent_runs_workspace_id_started_at_idx"
|
||
|
|
ON "agent_runs" USING btree ("workspace_id", "started_at" DESC NULLS LAST);
|
||
|
|
CREATE INDEX IF NOT EXISTS "agent_runs_backlog_item_id_started_at_idx"
|
||
|
|
ON "agent_runs" USING btree ("backlog_item_id", "started_at" DESC NULLS LAST);
|
||
|
|
|
||
|
|
COMMIT;
|
||
|
|
|
||
|
|
-- ============================================================================
|
||
|
|
-- Operator post-checks (run by hand, not part of the transaction above):
|
||
|
|
--
|
||
|
|
-- 1. Confirm every expected table is present:
|
||
|
|
-- SELECT tablename FROM pg_tables
|
||
|
|
-- WHERE schemaname = 'public'
|
||
|
|
-- AND tablename IN ('accounts','sessions','verification_tokens',
|
||
|
|
-- 'user_email_identities','workspace_invites',
|
||
|
|
-- 'audit_log','agent_runs')
|
||
|
|
-- ORDER BY tablename;
|
||
|
|
-- Expect all 7.
|
||
|
|
--
|
||
|
|
-- 2. Sanity-check the OAuth path by signing in via Authentik. The
|
||
|
|
-- callback at /api/auth/callback/authentik should now write a row
|
||
|
|
-- to `accounts` and `user_email_identities` instead of 500'ing.
|
||
|
|
--
|
||
|
|
-- 3. Confirm the drizzle ledger is still consistent:
|
||
|
|
-- SELECT hash, created_at
|
||
|
|
-- FROM drizzle.__drizzle_migrations
|
||
|
|
-- ORDER BY created_at;
|
||
|
|
-- Expect 10 rows ending at 0009_loving_rogue's hash
|
||
|
|
-- (cf. packages/database/migrations/meta/_journal.json). If 0008 or
|
||
|
|
-- 0009 are missing, run `pnpm db:migrate` from the repo root and
|
||
|
|
-- they will be applied + stamped normally.
|
||
|
|
-- ============================================================================
|