fix: db migrate

This commit is contained in:
johnyeo
2026-06-02 19:17:28 +01:00
parent 57f6c4482e
commit 35382d16a9
4 changed files with 107 additions and 10 deletions

2
ai

Submodule ai updated: 0d561b1747...794c164aed

View File

@@ -13,7 +13,9 @@ bun db mark-applied [--env=dev|staging|prod] # seed drizzle.__drizzle_migration
bun db rebase # auto-resolve a local migration that collided with origin/dev
```
`migrate` and `migrate:dry` also run a safety check that **refuses to apply any pending migration containing `CREATE INDEX`, `DROP INDEX`, or `REINDEX` without `CONCURRENTLY`**. Those DDL statements take an ACCESS EXCLUSIVE lock and can block reads/writes on busy tables. To get through the check: either rewrite the SQL with `CONCURRENTLY` and apply manually + `mark-applied`, or add `.concurrently()` to the index in your schema and regenerate.
`migrate` applies pending migrations directly via `pg` (using drizzle's own `readMigrationFiles` for parsing + hashing), so the tracking table stays compatible with drizzle and `mark-applied`. Unlike drizzle's built-in `migrate()` — which wraps every migration in a single transaction — our executor runs any statement containing `CONCURRENTLY` in autocommit, so `CREATE INDEX CONCURRENTLY` migrations apply normally. Everything else still runs in a per-migration transaction.
`migrate` and `migrate:dry` also run a safety check that **refuses to apply any pending migration containing `CREATE INDEX`, `DROP INDEX`, or `REINDEX` without `CONCURRENTLY`**. Those DDL statements take an ACCESS EXCLUSIVE lock and can block reads/writes on busy tables. To get through the check, make the index concurrent: rewrite the SQL with `CONCURRENTLY`, or add `.concurrently()` to the index in your schema and regenerate. Concurrent index migrations then apply through `bun db migrate` with no manual step.
`--env` defaults to `dev`. `generate` and `rebase` never touch a DB and don't take `--env`.
@@ -133,17 +135,20 @@ scripts/db/
├── commands/
│ ├── help.ts
│ ├── generate.ts # passthrough to `bun -F @autumn/shared db:generate`
│ ├── migrate.ts # passthrough to `bun -F @autumn/shared db:migrate`
│ ├── migrate.ts # applies pending migrations (CONCURRENTLY-aware executor)
│ ├── markApplied.ts # seeds drizzle.__drizzle_migrations
│ └── rebase.ts # auto-resolves duplicate-idx conflicts
├── helpers/
│ ├── applyMigrations.ts # per-migration executor: autocommit for CONCURRENTLY, tx otherwise
│ ├── env.ts # --env parsing + infisical wrap + DATABASE_URL host extraction
│ ├── pendingMigrations.ts # computes pending set from _journal.json vs tracking table
│ ├── safetyCheck.ts # flags non-CONCURRENTLY index DDL
│ ├── paths.ts # canonical paths to shared/drizzle/ and meta/
│ └── spawn.ts # thin child_process.spawn wrapper
└── pull.ts # unrelated — customer data pull (legacy)
```
`shared/package.json` still owns the implementation of `db:generate` and `db:migrate` (which are what the CLI shells out to under the hood). The unified `bun db` interface lives at the repo root.
`shared/package.json` still owns `db:generate` (which `generate` shells out to). `migrate` no longer delegates to drizzle-kit — it reads the committed migrations with drizzle's `readMigrationFiles` and applies them itself so `CONCURRENTLY` works. The unified `bun db` interface lives at the repo root.
---

View File

@@ -1,7 +1,8 @@
import { readMigrationFiles } from "drizzle-orm/migrator";
import pg from "pg";
import { run } from "../helpers/spawn.ts";
import { REPO_ROOT } from "../helpers/paths.ts";
import { MIGRATIONS_DIR } from "../helpers/paths.ts";
import { type Env, targetHost, wrapInInfisical } from "../helpers/env.ts";
import { applyMigration } from "../helpers/applyMigrations.ts";
import {
getPendingMigrations,
type PendingMigration,
@@ -82,10 +83,41 @@ export async function cmdMigrate(
process.exit(1);
}
const { code } = await run("bun", ["-F", "@autumn/shared", "db:migrate"], {
cwd: REPO_ROOT,
});
process.exit(code);
await applyPending(databaseUrl, pending);
}
/**
* Applies pending migrations using drizzle's own readMigrationFiles (so hashes
* match the tracking table drizzle/mark-applied write) but our own executor,
* which — unlike drizzle's migrate() — can run CONCURRENTLY outside a transaction.
*/
async function applyPending(
databaseUrl: string,
pending: PendingMigration[],
): Promise<void> {
const pendingByMillis = new Map(pending.map((m) => [m.when, m.tag]));
const toApply = readMigrationFiles({ migrationsFolder: MIGRATIONS_DIR })
.filter((m) => pendingByMillis.has(m.folderMillis))
.sort((a, b) => a.folderMillis - b.folderMillis);
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
try {
for (const migration of toApply) {
const tag = pendingByMillis.get(migration.folderMillis) ?? "migration";
const { transactional } = await applyMigration(client, migration);
console.log(` applied ${tag}${transactional ? "" : " (concurrent)"}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`\nmigration failed: ${message}`);
process.exitCode = 1;
return;
} finally {
await client.end();
}
console.log(`done — applied ${toApply.length} migration(s)`);
}
type FlaggedBlocker = {

View File

@@ -0,0 +1,60 @@
import type { MigrationMeta } from "drizzle-orm/migrator";
import type pg from "pg";
// CONCURRENTLY (e.g. CREATE INDEX CONCURRENTLY) cannot run inside a transaction
// block. drizzle's own migrate() wraps everything in one transaction, so those
// statements are applied here in autocommit instead.
const NON_TRANSACTIONAL = /\bCONCURRENTLY\b/i;
const TRACKING_TABLE = `"drizzle"."__drizzle_migrations"`;
async function recordApplied(
client: pg.Client,
migration: MigrationMeta,
): Promise<void> {
await client.query(
`INSERT INTO ${TRACKING_TABLE} ("hash", "created_at") VALUES ($1, $2)`,
[migration.hash, migration.folderMillis],
);
}
export type ApplyResult = { transactional: boolean };
/**
* Applies one migration's statements. If any statement is non-transactional
* (CONCURRENTLY), the whole migration runs in autocommit; otherwise it's wrapped
* in a single transaction so DDL + tracking row commit atomically — matching
* drizzle's own per-migration semantics.
*/
export async function applyMigration(
client: pg.Client,
migration: MigrationMeta,
): Promise<ApplyResult> {
const statements = migration.sql
.map((statement) => statement.trim())
.filter(Boolean);
const nonTransactional = statements.some((statement) =>
NON_TRANSACTIONAL.test(statement),
);
if (nonTransactional) {
for (const statement of statements) {
await client.query(statement);
}
await recordApplied(client, migration);
return { transactional: false };
}
await client.query("BEGIN");
try {
for (const statement of statements) {
await client.query(statement);
}
await recordApplied(client, migration);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
return { transactional: true };
}