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 <all public tables> 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 <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-08-16 13:13:29 +00:00
parent 413849f066
commit 7af16ee4e6
2 changed files with 62 additions and 78 deletions
+30 -36
View File
@@ -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: * 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"; import postgres from "postgres";
@@ -23,43 +30,30 @@ async function reset() {
const client = postgres(url, { max: 1 }); 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) // Enumerate all base tables in the public schema dynamically, then
await client` // issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles
DO $$ DECLARE // are not a problem. The drizzle schema and __drizzle_migrations are
r RECORD; // intentionally excluded (different schema) so drizzle-kit migrate
BEGIN // stays a no-op on an already-migrated DB.
FOR r IN ( const tables = await client<{ tablename: string }[]>`
SELECT tablename FROM pg_tables SELECT tablename FROM pg_tables
WHERE schemaname = 'public' WHERE schemaname = 'public'
) LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`; `;
// Drop custom enums if (tables.length > 0) {
await client` // Double-quote each identifier (escaping embedded quotes) to handle
DO $$ DECLARE // any table name safely without a pg-specific quote_ident helper.
r RECORD; const tableList = tables
BEGIN .map((t) => `"${t.tablename.replace(/"/g, '""')}"`)
FOR r IN ( .join(", ");
SELECT typname FROM pg_type await client.unsafe(
WHERE typtype = 'e' AND typnamespace = ( `TRUNCATE ${tableList} RESTART IDENTITY CASCADE`,
SELECT oid FROM pg_namespace WHERE nspname = 'public' );
) }
) LOOP
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop the drizzle migrations tracking table console.log("✓ All public tables truncated, sequences reset\n");
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");
await client.end(); await client.end();
} }
+32 -42
View File
@@ -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: * GRO-2139: the entire truncate→migrate→seed chain runs inside a single
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
*
* GRO-2139: the entire drop→migrate→seed chain runs inside a single
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent * Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at * `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`. * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
* *
* Why this matters: `seed.ts` derives every primary key from a single * 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 * GRO-2123 added the advisory lock around `runSeedBody` but left
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script * `reset.ts` and `drizzle-kit migrate` outside the lock. This script
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock` * 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 * work runs on the rest of the pool, so the lock guarantees mutual
* exclusion against any concurrent seeder for the entire chain. * 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 * See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same * `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
* `seed.ts` code path on the same database in `groombook-dev`. * `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 sizing is load-bearing here. `withSeedAdvisoryLock` does
// `pool.reserve()` to pin the advisory lock to one dedicated session // `pool.reserve()` to pin the advisory lock to one dedicated session
// (a session-level lock released on a *different* pooled connection is // (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 // *remaining* pooled connections. The lock provides mutual exclusion
// across processes regardless of how many connections the work uses — // across processes regardless of how many connections the work uses —
// it does NOT require the work to share the lock's session. // it does NOT require the work to share the lock's session.
@@ -82,43 +85,30 @@ async function reset() {
try { try {
await withSeedAdvisoryLock(client, async () => { await withSeedAdvisoryLock(client, async () => {
console.log("Dropping all application tables...\n"); console.log("Truncating all public tables...\n");
// Drop dependencies (tables) first // Enumerate all base tables in the public schema dynamically, then
await client` // issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles
DO $$ DECLARE // are not a problem. The drizzle schema and __drizzle_migrations are
r RECORD; // intentionally excluded (different schema) so drizzle-kit migrate
BEGIN // stays a no-op on an already-migrated DB.
FOR r IN ( const tables = await client<{ tablename: string }[]>`
SELECT tablename FROM pg_tables SELECT tablename FROM pg_tables
WHERE schemaname = 'public' WHERE schemaname = 'public'
) LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`; `;
// Drop custom enums if (tables.length > 0) {
await client` // Double-quote each identifier (escaping embedded quotes) to handle
DO $$ DECLARE // any table name safely without a pg-specific quote_ident helper.
r RECORD; const tableList = tables
BEGIN .map((t) => `"${t.tablename.replace(/"/g, '""')}"`)
FOR r IN ( .join(", ");
SELECT typname FROM pg_type await client.unsafe(
WHERE typtype = 'e' AND typnamespace = ( `TRUNCATE ${tableList} RESTART IDENTITY CASCADE`,
SELECT oid FROM pg_namespace WHERE nspname = 'public' );
) }
) LOOP
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop the drizzle migrations tracking table console.log("✓ All public tables truncated, sequences reset\n");
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("Running migrations..."); console.log("Running migrations...");
// GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips // GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips