-- ============================================================================ -- 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. -- ============================================================================