Files
TX/lib/db/scripts/pre-push-cleanup.ts
T
riyadhafraa a8467f810b Task #148: Make pnpm --filter @workspace/db run push work without manual SQL
## Original task
`pnpm --filter @workspace/db run push` was failing with a duplicate-key
error on `app_permissions` (and a missing FK on
`executive_meeting_notifications`) because legacy data in the dev DB
violates constraints the schema now declares. Devs had to drop into psql
to fix it, which made bootstrapping painful and left
`role_permission_audit` reliant on hand-applied SQL.

## Changes
- Added `lib/db/scripts/pre-push-cleanup.ts`, an idempotent cleanup that:
  - Collapses duplicate `app_permissions` rows to one per
    `(app_id, permission_id)` so the composite PK can be added.
  - Deletes orphan `executive_meeting_notifications` rows so the new
    `ON DELETE CASCADE` FK can be added.
  - Skips both checks when the tables don't exist yet (fresh DB
    no-op).
- Wired the script into `lib/db/package.json` so both `push` and
  `push-force` run cleanup first (`pnpm run pre-push-cleanup && drizzle-kit push ...`).
- Added `tsx` to `lib/db` devDependencies (catalog version) so the
  package can run the cleanup without leaning on another workspace.
- Updated `replit.md` Deployment / Migration Runbook to reflect that
  cleanup is now automatic — no manual SQL required in any environment.

## Verification
- `pnpm --filter @workspace/db run push` now collapses 2 duplicate
  groups + removes 1978 orphan notifications, then succeeds with
  `[✓] Changes applied`.
- Re-running push is idempotent: second run reports no duplicates and
  no orphans, then succeeds.
- `pnpm --filter @workspace/db run push-force` (used by
  `scripts/post-merge.sh`) was also verified end-to-end.
- Confirmed `app_permissions` now has the composite PK,
  `executive_meeting_notifications` has the cascade FK, and
  `role_permission_audit` matches the schema.

## Notes
- A pre-existing unrelated typecheck error in
  `artifacts/api-server/src/routes/executive-meetings.ts` (font
  settings `scope` overload) was confirmed to exist on `main` before
  any changes here and is out of scope for this task.
- Proposed follow-up #213 to move from `push`/`push-force` to
  versioned `drizzle-kit migrate` so legacy-data backfills are checked
  in instead of living in a generic pre-push script.

Replit-Task-Id: 2efc4e22-0c65-48a1-b573-319474698b96
2026-04-30 12:07:19 +00:00

97 lines
3.1 KiB
TypeScript

import pg from "pg";
const { Client } = pg;
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set. Did you forget to provision a database?",
);
}
async function tableExists(client: pg.Client, table: string): Promise<boolean> {
const result = await client.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
[table],
);
return (result.rowCount ?? 0) > 0;
}
async function main(): Promise<void> {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query("BEGIN");
if (await tableExists(client, "app_permissions")) {
const dupes = await client.query(
`SELECT
count(*)::int AS dup_groups,
coalesce(sum(c - 1), 0)::int AS extra_rows
FROM (
SELECT app_id, permission_id, count(*) AS c
FROM app_permissions
GROUP BY app_id, permission_id
HAVING count(*) > 1
) d`,
);
const dupGroups = (dupes.rows[0]?.dup_groups ?? 0) as number;
const extraRows = (dupes.rows[0]?.extra_rows ?? 0) as number;
if (dupGroups > 0) {
await client.query(
`CREATE TEMP TABLE app_permissions_dedup ON COMMIT DROP AS
SELECT DISTINCT app_id, permission_id FROM app_permissions`,
);
await client.query(`DELETE FROM app_permissions`);
await client.query(
`INSERT INTO app_permissions (app_id, permission_id)
SELECT app_id, permission_id FROM app_permissions_dedup`,
);
console.log(
`[pre-push] Removed ${extraRows} duplicate app_permissions row(s) across ${dupGroups} (app_id, permission_id) key(s).`,
);
} else {
console.log("[pre-push] app_permissions has no duplicates.");
}
} else {
console.log("[pre-push] app_permissions table not present yet — skipping dedup.");
}
if (
(await tableExists(client, "executive_meeting_notifications")) &&
(await tableExists(client, "executive_meetings"))
) {
const orphan = await client.query(
`DELETE FROM executive_meeting_notifications n
WHERE n.meeting_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM executive_meetings m WHERE m.id = n.meeting_id
)`,
);
const removed = orphan.rowCount ?? 0;
if (removed > 0) {
console.log(
`[pre-push] Removed ${removed} orphan executive_meeting_notifications row(s).`,
);
} else {
console.log("[pre-push] No orphan executive_meeting_notifications rows.");
}
} else {
console.log(
"[pre-push] executive_meeting_notifications/executive_meetings not both present — skipping orphan cleanup.",
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
await client.end();
}
}
main().catch((err) => {
console.error("[pre-push] cleanup failed:", err);
process.exit(1);
});