Skip to content

Commit 7078153

Browse files
hubyrodclaude
andcommitted
Run DB migrations automatically on server startup
Extract migration logic into reusable runMigrations() in src/db.ts, called from instrumentation register() before any DB queries. Simplify scripts/migrate.ts to reuse the same function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 268faa1 commit 7078153

3 files changed

Lines changed: 64 additions & 82 deletions

File tree

instrumentation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { join } from "node:path";
12
import { loadAppConfig } from "@/src/config";
2-
import { getAllRoutes } from "@/src/db";
3+
import { runMigrations, getAllRoutes } from "@/src/db";
34
import { validateConnection, type SlashworkConnection } from "@/src/slashwork";
45
import { validateCalendarAuth } from "@/src/calendar/auth";
56
import { startCalendarPoller } from "@/src/calendar/poller";
@@ -11,6 +12,8 @@ function log(level: string, message: string) {
1112
export async function register() {
1213
const config = loadAppConfig();
1314

15+
await runMigrations(join(process.cwd(), "migrations"), log);
16+
1417
log("info", `Slashwork URL: ${config.slashwork.graphqlUrl}`);
1518

1619
try {

scripts/migrate.ts

Lines changed: 9 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,15 @@
1-
import pg from "pg";
2-
import { readdir, readFile } from "node:fs/promises";
31
import { join } from "node:path";
2+
import { runMigrations } from "../src/db";
43

5-
const connectionString = process.env.POSTGRESQL_ADDON_URI;
6-
if (!connectionString) {
7-
console.error("POSTGRESQL_ADDON_URI environment variable is required");
8-
process.exit(1);
9-
}
10-
11-
const pool = new pg.Pool({ connectionString });
124
const migrationsDir = join(import.meta.dirname, "..", "migrations");
135

14-
async function ensureMigrationsTable() {
15-
await pool.query(`
16-
CREATE TABLE IF NOT EXISTS schema_migrations (
17-
version TEXT PRIMARY KEY,
18-
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
19-
)
20-
`);
21-
}
22-
23-
async function getAppliedMigrations(): Promise<Set<string>> {
24-
const result = await pool.query<{ version: string }>(
25-
"SELECT version FROM schema_migrations ORDER BY version",
26-
);
27-
return new Set(result.rows.map((r) => r.version));
28-
}
29-
30-
async function getPendingMigrations(applied: Set<string>): Promise<string[]> {
31-
const files = await readdir(migrationsDir);
32-
return files
33-
.filter((f) => f.endsWith(".sql"))
34-
.sort()
35-
.filter((f) => !applied.has(f));
36-
}
37-
38-
async function runMigration(filename: string) {
39-
const sql = await readFile(join(migrationsDir, filename), "utf-8");
40-
const client = await pool.connect();
41-
try {
42-
await client.query("BEGIN");
43-
await client.query(sql);
44-
await client.query(
45-
"INSERT INTO schema_migrations (version) VALUES ($1)",
46-
[filename],
47-
);
48-
await client.query("COMMIT");
49-
} catch (err) {
50-
await client.query("ROLLBACK");
51-
throw err;
52-
} finally {
53-
client.release();
54-
}
55-
}
56-
57-
async function migrate() {
58-
await ensureMigrationsTable();
59-
const applied = await getAppliedMigrations();
60-
61-
if (applied.size > 0) {
62-
console.log(`Already applied: ${[...applied].join(", ")}`);
63-
}
64-
65-
const pending = await getPendingMigrations(applied);
66-
if (pending.length === 0) {
67-
console.log("No pending migrations.");
68-
await pool.end();
69-
return;
70-
}
71-
72-
console.log(`Pending: ${pending.join(", ")}`);
73-
74-
for (const filename of pending) {
75-
console.log(`Applying ${filename}...`);
76-
await runMigration(filename);
77-
console.log(` Done.`);
78-
}
79-
80-
console.log(`Applied ${pending.length} migration(s).`);
81-
await pool.end();
6+
function log(level: string, message: string) {
7+
console.log(`[${level}] ${message}`);
828
}
839

84-
migrate().catch((err) => {
85-
console.error("Migration failed:", err);
86-
process.exit(1);
87-
});
10+
runMigrations(migrationsDir, log)
11+
.then(() => process.exit(0))
12+
.catch((err) => {
13+
console.error("Migration failed:", err);
14+
process.exit(1);
15+
});

src/db.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import pg from "pg";
2+
import { readdir, readFile } from "node:fs/promises";
3+
import { join } from "node:path";
24

35
let pool: pg.Pool | null = null;
46

@@ -13,6 +15,55 @@ function getPool(): pg.Pool {
1315
return pool;
1416
}
1517

18+
export async function runMigrations(
19+
migrationsDir: string,
20+
log: (level: string, message: string) => void = () => {},
21+
): Promise<void> {
22+
const db = getPool();
23+
24+
await db.query(`
25+
CREATE TABLE IF NOT EXISTS schema_migrations (
26+
version TEXT PRIMARY KEY,
27+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
28+
)
29+
`);
30+
31+
const applied = new Set(
32+
(await db.query<{ version: string }>("SELECT version FROM schema_migrations ORDER BY version"))
33+
.rows.map((r) => r.version),
34+
);
35+
36+
const files = await readdir(migrationsDir);
37+
const pending = files
38+
.filter((f) => f.endsWith(".sql"))
39+
.sort()
40+
.filter((f) => !applied.has(f));
41+
42+
if (pending.length === 0) {
43+
log("info", "Migrations: all up to date");
44+
return;
45+
}
46+
47+
for (const filename of pending) {
48+
const sql = await readFile(join(migrationsDir, filename), "utf-8");
49+
const client = await db.connect();
50+
try {
51+
await client.query("BEGIN");
52+
await client.query(sql);
53+
await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [filename]);
54+
await client.query("COMMIT");
55+
log("info", `Migrations: applied ${filename}`);
56+
} catch (err) {
57+
await client.query("ROLLBACK");
58+
throw err;
59+
} finally {
60+
client.release();
61+
}
62+
}
63+
64+
log("info", `Migrations: applied ${pending.length} migration(s)`);
65+
}
66+
1667
export interface ResolvedRoute {
1768
targetId: string;
1869
authToken: string;

0 commit comments

Comments
 (0)