diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts
index df83857c6..8c5f1f6cc 100644
--- a/packages/core/script/migration.ts
+++ b/packages/core/script/migration.ts
@@ -1,13 +1,21 @@
#!/usr/bin/env bun
import { $ } from "bun"
+import fs from "fs/promises"
+import os from "os"
import path from "path"
+import { pathToFileURL } from "url"
const root = path.resolve(import.meta.dirname, "../../..")
const sqlDir = path.join(root, "packages/core/migration")
const tsDir = path.join(root, "packages/core/src/database/migration")
const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
+if (Bun.argv.includes("--check")) {
+ await check()
+ process.exit(0)
+}
+
await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core"))
const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir })))
@@ -22,6 +30,49 @@ for (const name of sqlMigrations) {
await Bun.write(registry, renderRegistry(sqlMigrations))
+async function check() {
+ const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-"))
+ const output = path.join(temporary, "migration")
+ try {
+ await fs.cp(sqlDir, output, { recursive: true })
+ const config = path.join(temporary, "drizzle.config.ts")
+ await Bun.write(
+ config,
+ `import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)}
+
+export default { ...config, out: ${JSON.stringify(output)} }
+`,
+ )
+ const before = await snapshot(output)
+ await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core"))
+ const after = await snapshot(output)
+ if (JSON.stringify(after) !== JSON.stringify(before)) {
+ throw new Error("Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.")
+ }
+
+ const migrations = before
+ .map((entry) => entry.path.split("/")[0])
+ .filter((name, index, all) => name !== undefined && all.indexOf(name) === index)
+ .sort()
+ for (const name of migrations) {
+ if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
+ throw new Error(`Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`)
+ }
+ if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
+ throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
+ }
+ } finally {
+ await fs.rm(temporary, { recursive: true, force: true })
+ }
+}
+
+async function snapshot(directory: string) {
+ const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true }))
+ return Promise.all(
+ files.sort().map(async (file) => ({ path: file, contents: await Bun.file(path.join(directory, file)).text() })),
+ )
+}
+
function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts
index 4df5982e4..8e7ee59a4 100644
--- a/packages/core/test/database-migration.test.ts
+++ b/packages/core/test/database-migration.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
+import { $ } from "bun"
+import { fileURLToPath } from "url"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
@@ -13,6 +15,12 @@ const run = (effect: Effect.Effect) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => {
+ test("declared schema has no ungenerated migrations", async () => {
+ const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`.quiet().nothrow()
+ expect(result.exitCode, result.stderr.toString()).toBe(0)
+ expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
+ }, 30_000)
+
test("applies tracked migrations to an empty database", async () => {
await run(
Effect.gen(function* () {