diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index 36d34de..7f12970 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1 +1,152 @@ -LyoqCiAqIHJlc2V0LnRzIOKAlCBEcm9wIGFsbCBhcHBsaWNhdGlvbiB0YWJsZXMsIHJlLXJ1biBtaWdyYXRpb25zLCBhbmQgcmUtc2VlZC4KICoKICogSW50ZW5kZWQgZm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHkuIE5ldmVyIHJ1biBhZ2FpbnN0IHByb2R1Y3Rpb24uCiAqCiAqIFVzYWdlOgogKiAgIERBVEFCQVNFX1VSTD1wb3N0Z3JlczovLy4uLiBucHggdHN4IHBhY2thZ2VzL2RiL3NyYy9yZXNldC50cwogKgogKiBHUk8tMjEzOTogdGhlIGVudGlyZSBkcm9w4oaSbWlncmF0ZeKGknNlZWQgY2hhaW4gcnVucyBpbnNpZGUgYSBzaW5nbGUKICogUG9zdGdyZXMgYWR2aXNvcnkgbG9jayAoU0VFRF9BRFZJU09SWV9MT0NLX0tFWSkgc28gYSBjb25jdXJyZW50CiAqIGBzZWVkLnRzYCAoZS5nLiB0aGUgZGV2IGBzZWVkLXRlc3QtZGF0YS0qYCBKb2IgYmVpbmcgcmVjcmVhdGVkIGF0CiAqIHRoZSB0b3Agb2YgdGhlIGhvdXIpIGNhbm5vdCBpbnRlcmxlYXZlIGJldHdlZW4gYHJlc2V0LnRzYCAoRFJPUCkKICogYW5kIGBzZWVkLnRzYCAoVFJVTkNBVEUraW5zZXJ0KSBhbmQgY29sbGlkZSBvbiBgaW52b2ljZXNfcGtleWAuCiAqCiAqIFdoeSB0aGlzIG1hdHRlcnM6IGBzZWVkLnRzYCBkZXJpdmVzIGV2ZXJ5IHByaW1hcnkga2V5IGZyb20gYSBzaW5nbGUKICogc2hhcmVkIE11bGJlcnJ5MzIgUFJORyBzZWVkZWQgd2l0aCA0MiAoc2VlIGBjcmVhdGVQcm5nKDQyKWAgYW5kCiAqIGB1dWlkKClgIGluIHNlZWQudHMpLiBUd28gY29uY3VycmVudCBzYW1lLXByb2ZpbGUgc2VlZGVycyB0aGVyZWZvcmUKICogZW1pdCAqaWRlbnRpY2FsKiBpZHMgZm9yIHRoZSBzYW1lIGxvZ2ljYWwgcm93LCBhbmQgYW55IG1vbWVudAogKiBiZXR3ZWVuIGEgY29uY3VycmVudCBgc2VlZC50c2AgVFJVTkNBVEUgYW5kIElOU0VSVCBpcyBleGFjdGx5IHRoZQogKiB3aW5kb3cgaW4gd2hpY2ggdGhlIHNlY29uZCBzZWVkZXIncyBJTlNFUlQgY2FuIGhpdCBhIHBrZXkgYWxyZWFkeQogKiB0YWtlbiBieSB0aGUgZmlyc3QuIFByZS1HUk8tMjEyMyB0aGlzIHJhY2VkIHVuY29uZGl0aW9uYWxseTsKICogR1JPLTIxMjMgYWRkZWQgdGhlIGFkdmlzb3J5IGxvY2sgYXJvdW5kIGBydW5TZWVkQm9keWAgYnV0IGxlZnQKICogYHJlc2V0LnRzYCBhbmQgYGRyaXp6bGUta2l0IG1pZ3JhdGVgIG91dHNpZGUgdGhlIGxvY2suIFRoaXMgc2NyaXB0CiAqIG5vdyB3cmFwcyB0aGUgKndob2xlKiBjaGFpbiBpbiB0aGUgc2FtZSBsb2NrOiBgd2l0aFNlZWRBZHZpc29yeUxvY2tgCiAqIHBpbnMgdGhlIGxvY2sgdG8gb25lIHJlc2VydmVkIHNlc3Npb24gYW5kIHRoZSBEUk9QIOKGkiBtaWdyYXRlIOKGkiBzZWVkCiAqIHdvcmsgcnVucyBvbiB0aGUgcmVzdCBvZiB0aGUgcG9vbCwgc28gdGhlIGxvY2sgZ3VhcmFudGVlcyBtdXR1YWwKICogZXhjbHVzaW9uIGFnYWluc3QgYW55IGNvbmN1cnJlbnQgc2VlZGVyIGZvciB0aGUgZW50aXJlIGNoYWluLgogKgogKiBTZWU6IGdyb29tYm9vay9pbmZyYSBgYXBwcy9iYXNlL3Jlc2V0LWNyb25qb2IueWFtbGAgKENyb25Kb2IpIGFuZAogKiBgYXBwcy9iYXNlL3NlZWQtam9iLnlhbWxgIChvbmUtc2hvdCBKb2IpIOKAlCBib3RoIGludm9rZSB0aGUgc2FtZQogKiBgc2VlZC50c2AgY29kZSBwYXRoIG9uIHRoZSBzYW1lIGRhdGFiYXNlIGluIGBncm9vbWJvb2stZGV2YC4KICovCmltcG9ydCBwb3N0Z3JlcyBmcm9tICJwb3N0Z3JlcyI7CmltcG9ydCB7IGRyaXp6bGUgfSBmcm9tICJkcml6emxlLW9ybS9wb3N0Z3Jlcy1qcyI7CmltcG9ydCB7IGV4ZWNTeW5jIH0gZnJvbSAibm9kZTpjaGlsZF9wcm9jZXNzIjsKaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gIm5vZGU6dXJsIjsKaW1wb3J0IHsgZGlybmFtZSwgcmVzb2x2ZSB9IGZyb20gIm5vZGU6cGF0aCI7CmltcG9ydCAqIGFzIHNjaGVtYSBmcm9tICIuL3NjaGVtYS5qcyI7CmltcG9ydCB7CiAgU0VFRF9BRFZJU09SWV9MT0NLX0tFWSwKICB3aXRoU2VlZEFkdmlzb3J5TG9jaywKICBnZXRQcm9maWxlLAogIHJ1blNlZWRCb2R5LAogIHByb2ZpbGVzLAp9IGZyb20gIi4vc2VlZC5qcyI7Cgpjb25zdCBfX2ZpbGVuYW1lID0gZmlsZVVSTFRvUGF0aChpbXBvcnQubWV0YS51cmwpOwpjb25zdCBfX2Rpcm5hbWUgPSBkaXJuYW1lKF9fZmlsZW5hbWUpOwoKYXN5bmMgZnVuY3Rpb24gcmVzZXQoKSB7CiAgY29uc3QgdXJsID0gcHJvY2Vzcy5lbnYuREFUQUJBU0VfVVJMOwogIGlmICghdXJsKSB7CiAgICBjb25zb2xlLmVycm9yKCJEQVRBQkFTRV9VUkwgaXMgbm90IHNldCIpOwogICAgcHJvY2Vzcy5leGl0KDEpOwogIH0KCiAgaWYgKAogICAgcHJvY2Vzcy5lbnYuTk9ERV9FTlYgPT09ICJwcm9kdWN0aW9uIiAmJgogICAgcHJvY2Vzcy5lbnYuQUxMT1dfUkVTRVQgIT09ICJ0cnVlIgogICkgewogICAgY29uc29sZS5lcnJvcigKICAgICAgIltGQVRBTF0gZGI6cmVzZXQgbXVzdCBub3QgYmUgcnVuIGluIHByb2R1Y3Rpb24gd2l0aG91dCBBTExPV19SRVNFVD10cnVlLiIsCiAgICApOwogICAgcHJvY2Vzcy5leGl0KDEpOwogIH0KCiAgLy8gUG9vbCBzaXppbmcgaXMgbG9hZC1iZWFyaW5nIGhlcmUuIGB3aXRoU2VlZEFkdmlzb3J5TG9ja2AgZG9lcwogIC8vIGBwb29sLnJlc2VydmUoKWAgdG8gcGluIHRoZSBhZHZpc29yeSBsb2NrIHRvIG9uZSBkZWRpY2F0ZWQgc2Vzc2lvbgogIC8vIChhIHNlc3Npb24tbGV2ZWwgbG9jayByZWxlYXNlZCBvbiBhICpkaWZmZXJlbnQqIHBvb2xlZCBjb25uZWN0aW9uIGlzCiAgLy8gYSBuby1vcCksIGFuZCB0aGUgRFJPUCAvIG1pZ3JhdGUgLyBzZWVkIHdvcmsgdGhlbiBydW5zIG9uIHRoZQogIC8vICpyZW1haW5pbmcqIHBvb2xlZCBjb25uZWN0aW9ucy4gVGhlIGxvY2sgcHJvdmlkZXMgbXV0dWFsIGV4Y2x1c2lvbgogIC8vIGFjcm9zcyBwcm9jZXNzZXMgcmVnYXJkbGVzcyBvZiBob3cgbWFueSBjb25uZWN0aW9ucyB0aGUgd29yayB1c2VzIOKAlAogIC8vIGl0IGRvZXMgTk9UIHJlcXVpcmUgdGhlIHdvcmsgdG8gc2hhcmUgdGhlIGxvY2sncyBzZXNzaW9uLgogIC8vCiAgLy8gVGhlcmVmb3JlIGBtYXhgIG11c3QgYmUgPj0gMjogMSByZXNlcnZlZCBmb3IgdGhlIGxvY2sgKyA+PTEgZnJlZSBmb3IKICAvLyB0aGUgd29yay4gYG1heDogMWAgd291bGQgbGV0IGByZXNlcnZlKClgIGNvbnN1bWUgdGhlIG9ubHkgY29ubmVjdGlvbgogIC8vIGFuZCBldmVyeSBxdWVyeSBpbnNpZGUgdGhlIGNhbGxiYWNrIHdvdWxkIGJsb2NrIGZvcmV2ZXIgd2FpdGluZyBmb3IKICAvLyBhIGNvbm5lY3Rpb24gdGhhdCBuZXZlciBmcmVlcyAoY29ubmVjdGlvbi1zdGFydmF0aW9uIGRlYWRsb2NrKS4gV2UKICAvLyB1c2UgYG1heDogNmAgdG8gbWF0Y2ggYHNlZWQoKWAncyBoZWFkcm9vbSAoMSByZXNlcnZlZCArIDUgd29yaykuCiAgY29uc3QgY2xpZW50ID0gcG9zdGdyZXModXJsLCB7IG1heDogNiB9KTsKICBjb25zdCBkYiA9IGRyaXp6bGUoY2xpZW50LCB7IHNjaGVtYSB9KTsKCiAgdHJ5IHsKICAgIGF3YWl0IHdpdGhTZWVkQWR2aXNvcnlMb2NrKGNsaWVudCwgYXN5bmMgKCkgPT4gewogICAgICBjb25zb2xlLmxvZygiRHJvcHBpbmcgYWxsIGFwcGxpY2F0aW9uIHRhYmxlcy4uLlxuIik7CgogICAgICAvLyBEcm9wIGRlcGVuZGVuY2llcyAodGFibGVzKSBmaXJzdAogICAgICBhd2FpdCBjbGllbnRgCiAgICAgICAgRE8gJCQgREVDTEFSRQogICAgICAgICAgciBSRUNPUkQ7CiAgICAgICAgQkVHSU4KICAgICAgICAgIEZPUiByIElOICgKICAgICAgICAgICAgU0VMRUNUIHRhYmxlbmFtZSBGUk9NIHBnX3RhYmxlcwogICAgICAgICAgICBXSEVSRSBzY2hlbWFuYW1lID0gJ3B1YmxpYycKICAgICAgICAgICkgTE9PUAogICAgICAgICAgICBFWEVDVVRFICdEUk9QIFRBQkxFIElGIEVYSVNUUyBwdWJsaWMuJyB8fCBxdW90ZV9pZGVudChyLnRhYmxlbmFtZSkgfHwgJyBDQVNDQURFJzsKICAgICAgICAgIEVORCBMT09QOwogICAgICAgIEVORCAkJDsKICAgICAgYDsKCiAgICAgIC8vIERyb3AgY3VzdG9tIGVudW1zCiAgICAgIGF3YWl0IGNsaWVudGAKICAgICAgICBETyAkJCBERUNMQVJFCiAgICAgICAgICByIFJFQ09SRDsKICAgICAgICBCRUdJTgogICAgICAgICAgRk9SIHIgSU4gKAogICAgICAgICAgICBTRUxFQ1QgdHlwbmFtZSBGUk9NIHBnX3R5cGUKICAgICAgICAgICAgV0hFUkUgdHlwdHlwZSA9ICdlJyBBTkQgdHlwbmFtZXNwYWNlID0gKAogICAgICAgICAgICAgIFNFTEVDVCBvaWQgRlJPTSBwZ19uYW1lc3BhY2UgV0hFUkUgbnNwbmFtZSA9ICdwdWJsaWMnCiAgICAgICAgICAgICkKICAgICAgICAgICkgTE9PUAogICAgICAgICAgICBFWEVDVVRFICdEUk9QIFRZUEUgSUYgRVhJU1RTICcgfHwgcXVvdGVfaWRlbnQoci50eXBuYW1lKSB8fCAnIENBU0NBREUnOwogICAgICAgICAgRU5EIExPT1A7CiAgICAgICAgRU5EICQkOwogICAgICBgOwoKICAgICAgLy8gRHJvcCB0aGUgZHJpenpsZSBtaWdyYXRpb25zIHRyYWNraW5nIHRhYmxlCiAgICAgIGF3YWl0IGNsaWVudGBEUk9QIFRBQkxFIElGIEVYSVNUUyBkcml6emxlLl9fZHJpenpsZV9taWdyYXRpb25zIENBU0NBREVgOwogICAgICBhd2FpdCBjbGllbnRgRFJPUCBTQ0hFTUEgSUYgRVhJU1RTIGRyaXp6bGUgQ0FTQ0FERWA7CgogICAgICBjb25zb2xlLmxvZygi4pyTIEFsbCB0YWJsZXMgYW5kIGVudW1zIGRyb3BwZWRcbiIpOwoKICAgICAgY29uc29sZS5sb2coIlJ1bm5pbmcgbWlncmF0aW9ucy4uLiIpOwogICAgICAvLyBHUk8tMjY3MjogZHJpenpsZS1vcm0ncyBtaWdyYXRlKCkgaGFzIGEgaGlnaC13YXRlci1tYXJrIGJ1ZyB0aGF0IHNraXBzCiAgICAgIC8vIG1pZ3JhdGlvbnMgd2l0aCBzdGFsZSBgd2hlbmAgdGltZXN0YW1wcyAoMDAwMSwgMDAwMywgMDAxMCwgMDAxMSkuIFVzZQogICAgICAvLyBkcml6emxlLWtpdCBpbnN0ZWFkIC0tIGl0IGFwcGxpZXMgbWlncmF0aW9ucyBieSBoYXNoLCBtYXRjaGluZyB0aGUgSzhzCiAgICAgIC8vIG1pZ3JhdGUgSm9iIGJlaGF2aW91ciBleGFjdGx5LgogICAgICBleGVjU3luYygicG5wbSBleGVjIGRyaXp6bGUta2l0IG1pZ3JhdGUiLCB7CiAgICAgICAgc3RkaW86ICJpbmhlcml0IiwKICAgICAgICBlbnY6IHsgLi4ucHJvY2Vzcy5lbnYgfSwKICAgICAgICBjd2Q6IHJlc29sdmUoX19kaXJuYW1lLCAiLi4iKSwKICAgICAgfSk7CiAgICAgIGNvbnNvbGUubG9nKCLinJMgTWlncmF0aW9ucyBhcHBsaWVkXG4iKTsKCiAgICAgIGNvbnNvbGUubG9nKCJTZWVkaW5nIGRhdGFiYXNlLi4uIik7CiAgICAgIGNvbnN0IHByb2ZpbGUgPSBnZXRQcm9maWxlKCk7CiAgICAgIGNvbnN0IGNmZyA9IHByb2ZpbGVzW3Byb2ZpbGVdOwogICAgICBhd2FpdCBydW5TZWVkQm9keShjbGllbnQsIGRiLCBwcm9maWxlLCBjZmcpOwogICAgfSk7CgogICAgY29uc29sZS5sb2coCiAgICAgIGBcbuKckyBSZXNldCBjb21wbGV0ZSAoYWR2aXNvcnkgbG9jayBrZXk9MHgke1NFRURfQURWSVNPUllfTE9DS19LRVkudG9TdHJpbmcoMTYpfSlgLAogICAgKTsKICB9IGZpbmFsbHkgewogICAgYXdhaXQgY2xpZW50LmVuZCgpOwogIH0KfQoKcmVzZXQoKS5jYXRjaCgoZXJyKSA9PiB7CiAgY29uc29sZS5lcnJvcigiUmVzZXQgZmFpbGVkOiIsIGVycik7CiAgcHJvY2Vzcy5leGl0KDEpOwp9KTsK \ No newline at end of file +/** + * 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 { execSync } from "node:child_process"; +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); + +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..."); + // GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips + // migrations with stale `when` timestamps (0001, 0003, 0010, 0011). Use + // drizzle-kit instead -- it applies migrations by hash, matching the K8s + // migrate Job behaviour exactly. + execSync("pnpm exec drizzle-kit migrate", { + stdio: "inherit", + env: { ...process.env }, + cwd: resolve(__dirname, ".."), + }); + 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); +});