From 7af16ee4e61e6a2516bb19918298e4107dcb0c46 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 16 Aug 2026 13:13:29 +0000 Subject: [PATCH] fix(db): replace DROP-based reset with TRUNCATE RESTART IDENTITY CASCADE GRO-2722: packages/db/src/reset.ts and apps/api/src/db/reset.ts no longer emit any DROP TABLE / DROP TYPE / DROP SCHEMA / DROP DATABASE DDL. The four destructive DO-blocks are replaced with a single: TRUNCATE RESTART IDENTITY CASCADE Table names are enumerated dynamically via pg_tables WHERE schemaname='public' so new tables are picked up automatically. The drizzle schema and __drizzle_migrations table are untouched (different schema), keeping drizzle-kit migrate a no-op on an already-migrated DB. Preserves: production guard (NODE_ENV=production && ALLOW_RESET!='true'), advisory lock, migrations (drizzle-kit migrate), and seed (runSeedBody). Fixes the GRO-2678 prod outage root cause. Co-Authored-By: Paperclip --- apps/api/src/db/reset.ts | 66 ++++++++++++++++------------------- packages/db/src/reset.ts | 74 +++++++++++++++++----------------------- 2 files changed, 62 insertions(+), 78 deletions(-) diff --git a/apps/api/src/db/reset.ts b/apps/api/src/db/reset.ts index 41c3ce8..a9e5213 100644 --- a/apps/api/src/db/reset.ts +++ b/apps/api/src/db/reset.ts @@ -1,10 +1,17 @@ /** - * reset.ts — Drop all application tables and re-run migrations + seed. + * reset.ts — Truncate all public tables and restart identity sequences. * - * Intended for local development only. Never run against production. + * Schema-safe: never issues destructive DDL against any schema. + * The drizzle schema and __drizzle_migrations table are preserved so + * drizzle-kit migrate remains a no-op on an already-migrated DB. + * + * NOTE: this file is NOT the deployed reset entrypoint — the reset image + * builds from packages/db and runs `pnpm --filter @groombook/db reset`. + * apps/api db:reset delegates there too (see apps/api/package.json). + * Keep in sync with packages/db/src/reset.ts (GRO-2722). * * Usage: - * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + * DATABASE_URL=postgres://... npx tsx apps/api/src/db/reset.ts */ import postgres from "postgres"; @@ -23,43 +30,30 @@ async function reset() { const client = postgres(url, { max: 1 }); - console.log("Dropping all application tables...\n"); + console.log("Truncating all public tables...\n"); - // Drop in dependency order (children before parents) - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' - ) LOOP - EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; - END $$; + // Enumerate all base tables in the public schema dynamically, then + // issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles + // are not a problem. The drizzle schema and __drizzle_migrations are + // intentionally excluded (different schema) so drizzle-kit migrate + // stays a no-op on an already-migrated DB. + const tables = await client<{ tablename: string }[]>` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' `; - // Drop custom enums - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT typname FROM pg_type - WHERE typtype = 'e' AND typnamespace = ( - SELECT oid FROM pg_namespace WHERE nspname = 'public' - ) - ) LOOP - EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; - END LOOP; - END $$; - `; + if (tables.length > 0) { + // Double-quote each identifier (escaping embedded quotes) to handle + // any table name safely without a pg-specific quote_ident helper. + const tableList = tables + .map((t) => `"${t.tablename.replace(/"/g, '""')}"`) + .join(", "); + await client.unsafe( + `TRUNCATE ${tableList} RESTART IDENTITY CASCADE`, + ); + } - // Drop the drizzle migrations tracking table - await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; - await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; - - console.log("✓ All tables and enums dropped\n"); + console.log("✓ All public tables truncated, sequences reset\n"); await client.end(); } diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index 7f12970..173d67e 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1,15 +1,14 @@ /** - * reset.ts — Drop all application tables, re-run migrations, and re-seed. + * reset.ts — Truncate all public tables and restart identity sequences. * - * Intended for local development only. Never run against production. + * Schema-safe: never issues destructive DDL against any schema. + * The drizzle schema and __drizzle_migrations table are preserved so + * drizzle-kit migrate remains a no-op on an already-migrated DB. * - * Usage: - * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts - * - * GRO-2139: the entire drop→migrate→seed chain runs inside a single + * GRO-2139: the entire truncate→migrate→seed chain runs inside a single * Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent * `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at - * the top of the hour) cannot interleave between `reset.ts` (DROP) + * the top of the hour) cannot interleave between `reset.ts` (TRUNCATE) * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`. * * Why this matters: `seed.ts` derives every primary key from a single @@ -22,10 +21,14 @@ * GRO-2123 added the advisory lock around `runSeedBody` but left * `reset.ts` and `drizzle-kit migrate` outside the lock. This script * now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock` - * pins the lock to one reserved session and the DROP → migrate → seed + * pins the lock to one reserved session and the TRUNCATE → migrate → seed * work runs on the rest of the pool, so the lock guarantees mutual * exclusion against any concurrent seeder for the entire chain. * + * For a full local schema teardown (nuke tables, enums, drizzle schema) + * use `pnpm --filter @groombook/db db:nuke` instead — never change this + * script to be destructive (GRO-2722 / GRO-2678 prod incident). + * * See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and * `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same * `seed.ts` code path on the same database in `groombook-dev`. @@ -67,7 +70,7 @@ async function reset() { // Pool sizing is load-bearing here. `withSeedAdvisoryLock` does // `pool.reserve()` to pin the advisory lock to one dedicated session // (a session-level lock released on a *different* pooled connection is - // a no-op), and the DROP / migrate / seed work then runs on the + // a no-op), and the TRUNCATE / migrate / seed work then runs on the // *remaining* pooled connections. The lock provides mutual exclusion // across processes regardless of how many connections the work uses — // it does NOT require the work to share the lock's session. @@ -82,43 +85,30 @@ async function reset() { try { await withSeedAdvisoryLock(client, async () => { - console.log("Dropping all application tables...\n"); + console.log("Truncating all public tables...\n"); - // Drop dependencies (tables) first - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' - ) LOOP - EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; - END $$; + // Enumerate all base tables in the public schema dynamically, then + // issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles + // are not a problem. The drizzle schema and __drizzle_migrations are + // intentionally excluded (different schema) so drizzle-kit migrate + // stays a no-op on an already-migrated DB. + const tables = await client<{ tablename: string }[]>` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' `; - // Drop custom enums - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT typname FROM pg_type - WHERE typtype = 'e' AND typnamespace = ( - SELECT oid FROM pg_namespace WHERE nspname = 'public' - ) - ) LOOP - EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; - END LOOP; - END $$; - `; + if (tables.length > 0) { + // Double-quote each identifier (escaping embedded quotes) to handle + // any table name safely without a pg-specific quote_ident helper. + const tableList = tables + .map((t) => `"${t.tablename.replace(/"/g, '""')}"`) + .join(", "); + await client.unsafe( + `TRUNCATE ${tableList} RESTART IDENTITY CASCADE`, + ); + } - // Drop the drizzle migrations tracking table - await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; - await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; - - console.log("✓ All tables and enums dropped\n"); + console.log("✓ All public tables truncated, sequences reset\n"); console.log("Running migrations..."); // GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips