| #!/usr/bin/env bun |
|
|
| import { $ } from "bun" |
| import fs from "fs/promises" |
| import os from "os" |
| import path from "path" |
| import { pathToFileURL } from "url" |
| import { parseArgs } from "util" |
|
|
| const root = path.resolve(import.meta.dirname, "../../..") |
| const snapshot = path.join(root, "packages/core/schema.json") |
| const tsDir = path.join(root, "packages/core/src/database/migration") |
| const registry = path.join(root, "packages/core/src/database/migration.gen.ts") |
| const schema = path.join(root, "packages/core/src/database/schema.gen.ts") |
| const args = parseArgs({ |
| args: process.argv.slice(2), |
| options: { |
| check: { type: "boolean" }, |
| name: { type: "string" }, |
| }, |
| }) |
|
|
| if (args.values.check) { |
| await check() |
| process.exit(0) |
| } |
|
|
| await generate() |
|
|
| async function generate() { |
| const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-")) |
| const incremental = path.join(temporary, "incremental") |
| const full = path.join(temporary, "full") |
| try { |
| await fs.mkdir(incremental) |
| await fs.mkdir(path.join(incremental, "baseline")) |
| await fs.copyFile(snapshot, path.join(incremental, "baseline/snapshot.json")) |
| await drizzle(temporary, incremental, args.values.name) |
|
|
| const generated = await generatedMigrations(incremental) |
| if (generated.length > 1) throw new Error(`Expected one generated migration, found ${generated.length}.`) |
| const name = generated[0] |
| if (name) { |
| const target = path.join(tsDir, `${name}.ts`) |
| if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) |
| await Bun.write( |
| target, |
| await formatTypescript( |
| renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), |
| ), |
| ) |
| await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) |
| } |
|
|
| await fs.mkdir(full) |
| await drizzle(temporary, full, "schema") |
| await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full)))) |
| await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations()))) |
| } finally { |
| await fs.rm(temporary, { recursive: true, force: true }) |
| } |
| } |
|
|
| async function check() { |
| const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-")) |
| const incremental = path.join(temporary, "incremental") |
| const full = path.join(temporary, "full") |
| try { |
| await fs.mkdir(incremental) |
| await fs.mkdir(path.join(incremental, "baseline")) |
| await fs.copyFile(snapshot, path.join(incremental, "baseline/snapshot.json")) |
| await drizzle(temporary, incremental) |
| if ((await generatedMigrations(incremental)).length > 0) { |
| throw new Error( |
| "Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.", |
| ) |
| } |
|
|
| await fs.mkdir(full) |
| await drizzle(temporary, full, "schema") |
| if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) { |
| throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") |
| } |
|
|
| const migrations = await typescriptMigrations() |
| if ((await Bun.file(registry).text()) !== (await formatTypescript(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 drizzle(temporary: string, output: string, name?: string) { |
| const config = path.join(temporary, `${path.basename(output)}.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)} } |
| `, |
| ) |
| await $`bun drizzle-kit generate --config ${config} ${name ? ["--name", name] : []}`.cwd( |
| path.join(root, "packages/core"), |
| ) |
| } |
|
|
| async function generatedMigrations(directory: string) { |
| return (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory }))) |
| .map((file) => file.split("/")[0]) |
| .filter((name): name is string => name !== undefined) |
| .sort() |
| } |
|
|
| async function generatedSql(directory: string) { |
| const generated = await generatedMigrations(directory) |
| if (generated.length !== 1) throw new Error(`Expected one full schema migration, found ${generated.length}.`) |
| return Bun.file(path.join(directory, generated[0]!, "migration.sql")).text() |
| } |
|
|
| async function typescriptMigrations() { |
| return (await Array.fromAsync(new Bun.Glob("*.ts").scan({ cwd: tsDir }))) |
| .map((file) => path.basename(file, ".ts")) |
| .sort() |
| } |
|
|
| function renderMigration(name: string, sql: string) { |
| return `import { Effect } from "effect" |
| import type { DatabaseMigration } from "../migration" |
| |
| export default { |
| id: ${JSON.stringify(name)}, |
| up(tx) { |
| return Effect.gen(function* () { |
| ${renderStatements(sql)} |
| }) |
| }, |
| } satisfies DatabaseMigration.Migration |
| ` |
| } |
|
|
| function renderSchema(sql: string) { |
| return `import { Effect } from "effect" |
| import type { DatabaseMigration } from "./migration" |
| |
| export default { |
| up(tx) { |
| return Effect.gen(function* () { |
| ${renderStatements(sql)} |
| }) |
| }, |
| } satisfies Omit<DatabaseMigration.Migration, "id"> |
| ` |
| } |
|
|
| function renderStatements(sql: string) { |
| return sql |
| .split("--> statement-breakpoint") |
| .map((statement) => statement.trim()) |
| .filter((statement) => statement.length > 0) |
| .map(renderRun) |
| .join("\n") |
| } |
|
|
| function renderRun(statement: string) { |
| const lines = statement.replaceAll("\t", " ").split("\n") |
| if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)` |
| return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)` |
| } |
|
|
| function escapeTemplate(line: string) { |
| return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") |
| } |
|
|
| async function formatTypescript(input: string) { |
| const prettier = await import("prettier") |
| const typescript = await import("prettier/plugins/typescript") |
| const estree = await import("prettier/plugins/estree") |
| return prettier.format(input, { |
| parser: "typescript", |
| plugins: [typescript.default, estree.default], |
| semi: false, |
| printWidth: 120, |
| }) |
| } |
|
|
| function renderRegistry(names: string[]) { |
| return `import type { DatabaseMigration } from "./migration" |
| |
| export const migrations = ( |
| await Promise.all([ |
| ${names.map((name) => ` import("./migration/${name}"),`).join("\n")} |
| ]) |
| ).map((module) => module.default) satisfies DatabaseMigration.Migration[] |
| ` |
| } |
|
|