/** * reset.ts — Drop all application tables, re-run migrations, and re-seed. * * Intended for local development only. Never run against production. * * Usage: * 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 * `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at * the top of the hour) cannot interleave between `reset.ts` (DROP) * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`. * * Why this matters: `seed.ts` derives every primary key from a single * shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and * `uuid()` in seed.ts). Two concurrent same-profile seeders therefore * emit *identical* ids for the same logical row, and any moment * between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the * window in which the second seeder's INSERT can hit a pkey already * taken by the first. Pre-GRO-2123 this raced unconditionally; * 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 * work runs on the rest of the pool, so the lock guarantees mutual * exclusion against any concurrent seeder for the entire chain. * * 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`. */ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import * as schema from "./schema.js"; import { SEED_ADVISORY_LOCK_KEY, withSeedAdvisoryLock, getProfile, runSeedBody, profiles, } from "./seed.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations"); async function reset() { const url = process.env.DATABASE_URL; if (!url) { console.error("DATABASE_URL is not set"); process.exit(1); } if ( process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true" ) { console.error( "[FATAL] db:reset must not be run in production without ALLOW_RESET=true.", ); process.exit(1); } // 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 // *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. // // Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for // the work. `max: 1` would let `reserve()` consume the only connection // and every query inside the callback would block forever waiting for // a connection that never frees (connection-starvation deadlock). We // use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work). const client = postgres(url, { max: 6 }); const db = drizzle(client, { schema }); try { await withSeedAdvisoryLock(client, async () => { console.log("Dropping all application 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 $$; `; // 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 $$; `; // 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("Running migrations..."); await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); console.log("✓ Migrations applied\n"); console.log("Seeding database..."); const profile = getProfile(); const cfg = profiles[profile]; await runSeedBody(client, db, profile, cfg); }); console.log( `\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`, ); } finally { await client.end(); } } reset().catch((err) => { console.error("Reset failed:", err); process.exit(1); });