f7f90a71fc
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Images (push) Successful in 37s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 37s
CI / Build & Push Docker Images (pull_request) Successful in 42s
drizzle-orm's migrate() has a high-water-mark (HWM) bug: on a fresh DB, migration 0000 sets the watermark to 2026-03-17. Migrations 0001, 0003, 0010, 0011 have stale 2025-era `when` timestamps and are silently skipped. Migration 0003 (recurring_series) is the blocker — its skip leaves `recurring_series`, `appointments.series_id`, and `appointments.series_index` missing. A downstream migration inside migrate()'s single Postgres transaction then fails, rolling back everything including 0000's `staff` and `services` tables. Replace the drizzle-orm migrate() call with `pnpm exec drizzle-kit migrate` (hash-based). drizzle-kit applies every unhashed migration regardless of `when` ordering, matching the K8s migrate Job exactly.
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|