From 392deb9cbe9f55a45f7d70ec02d8ace0daa12f43 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 19:01:16 +0200 Subject: [PATCH] Apply pending migrations during development reloads --- README.md | 7 +++-- src/db/development-start.test.ts | 53 ++++++++++++++++++++++++++++++++ src/index.ts | 7 +++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/db/development-start.test.ts diff --git a/README.md b/README.md index 1f86dd0..1e8b544 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,11 @@ bun run db:migrate ``` Commit the generated `drizzle/` files alongside schema changes. `bun dev` and -`bun start` apply pending migrations before starting the server. The initial -migration creates the users and sessions tables. +`bun start` apply pending migrations before starting the server. Development also +checks for pending migrations when the server entrypoint is hot-reloaded, so new +routes do not run against an older schema. After adding only migration files, +run `bun run db:migrate` or restart `bun dev`. The initial migration creates the +users and sessions tables. Import `db` from `src/db/index.ts` in backend code to query the database. `GET /api/health` checks the database connection before returning success. diff --git a/src/db/development-start.test.ts b/src/db/development-start.test.ts new file mode 100644 index 0000000..ee7b7ad --- /dev/null +++ b/src/db/development-start.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("development entrypoint upgrades an existing database before serving requests", async () => { + const directory = mkdtempSync(join(tmpdir(), "minabot-dev-upgrade-")); + const path = join(directory, "app.sqlite"); + const sqlite = new Database(path); + let server: ReturnType | undefined; + try { + const migrations = join(directory, "migrations"); + mkdirSync(join(migrations, "meta"), { recursive: true }); + const journal = JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8")); + journal.entries = journal.entries.slice(0, 6); + writeFileSync(join(migrations, "meta/_journal.json"), JSON.stringify(journal)); + for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(migrations, `${entry.tag}.sql`)); + migrate(drizzle(sqlite), { migrationsFolder: migrations }); + sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('existing', 'existing', 'Existing user', 0, 0)"); + expect(sqlite.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()).toBeNull(); + + // Launch directly, as Bun does when re-evaluating the entrypoint during HMR; + // deliberately skip package.json's separate migration command. + server = Bun.spawn([process.execPath, "src/index.ts"], { + env: { ...process.env, NODE_ENV: "development", DATABASE_PATH: path, PORT: "0", APP_ORIGIN: "http://127.0.0.1", DISCORD_BOT_TOKEN: "", DISCORD_ALLOWED_USER_IDS: "" }, + stdout: "pipe", stderr: "pipe", + }); + const reader = (server.stdout as ReadableStream).getReader(); + let output = ""; + while (!output.includes("Server running at")) { + const chunk = await reader.read(); + if (chunk.done) throw new Error("Development server exited before it was ready"); + output += new TextDecoder().decode(chunk.value); + } + reader.releaseLock(); + const origin = output.match(/Server running at (http:\/\/[^\s]+)/)?.[1]; + expect(origin).toBeDefined(); + expect((await fetch(new URL("/api/health", origin))).status).toBe(200); + expect(sqlite.query("SELECT COUNT(*) AS count FROM __drizzle_migrations").get()).toEqual({ count: JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8")).entries.length }); + expect(sqlite.query("SELECT enabled FROM reminder_settings").all()).toEqual([]); + expect(sqlite.query("SELECT retry_at FROM reminder_deliveries").all()).toEqual([]); + expect(sqlite.query("SELECT blocked_until FROM reminder_worker_state").all()).toEqual([]); + expect(sqlite.query("SELECT username FROM users WHERE id = 'existing'").get()).toEqual({ username: "Existing user" }); + } finally { + server?.kill(); + if (server) await server.exited; + sqlite.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 15000); diff --git a/src/index.ts b/src/index.ts index 9a7a80d..122eebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,13 @@ import { createApi } from "./api"; import { readAuthConfig } from "./auth/config"; import { readDiscordSharingConfig } from "./sharing/config"; import index from "./index.html"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; + +// Hot reloads skip the package script's migration step. Apply pending changes +// before the reloaded development server accepts requests. +if (process.env.NODE_ENV !== "production") { + migrate(db, { migrationsFolder: "./drizzle" }); +} const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());