migration.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. #!/usr/bin/env bun
  2. import { $ } from "bun"
  3. import fs from "fs/promises"
  4. import os from "os"
  5. import path from "path"
  6. import { pathToFileURL } from "url"
  7. const root = path.resolve(import.meta.dirname, "../../..")
  8. const sqlDir = path.join(root, "packages/core/migration")
  9. const tsDir = path.join(root, "packages/core/src/database/migration")
  10. const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
  11. if (Bun.argv.includes("--check")) {
  12. await check()
  13. process.exit(0)
  14. }
  15. await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core"))
  16. const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir })))
  17. .map((file) => file.split("/")[0])
  18. .filter((name) => name !== undefined)
  19. .sort()
  20. for (const name of sqlMigrations) {
  21. if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
  22. await Bun.write(
  23. path.join(tsDir, `${name}.ts`),
  24. renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text()),
  25. )
  26. }
  27. await Bun.write(registry, renderRegistry(sqlMigrations))
  28. async function check() {
  29. const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-"))
  30. const output = path.join(temporary, "migration")
  31. try {
  32. await fs.cp(sqlDir, output, { recursive: true })
  33. const config = path.join(temporary, "drizzle.config.ts")
  34. await Bun.write(
  35. config,
  36. `import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)}
  37. export default { ...config, out: ${JSON.stringify(output)} }
  38. `,
  39. )
  40. const before = await snapshot(output)
  41. await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core"))
  42. const after = await snapshot(output)
  43. if (JSON.stringify(after) !== JSON.stringify(before)) {
  44. throw new Error(
  45. "Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.",
  46. )
  47. }
  48. const migrations = before
  49. .map((entry) => entry.path.split("/")[0])
  50. .filter((name, index, all) => name !== undefined && all.indexOf(name) === index)
  51. .sort()
  52. for (const name of migrations) {
  53. if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
  54. throw new Error(
  55. `Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`,
  56. )
  57. }
  58. if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
  59. throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
  60. }
  61. } finally {
  62. await fs.rm(temporary, { recursive: true, force: true })
  63. }
  64. }
  65. async function snapshot(directory: string) {
  66. const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true }))
  67. return Promise.all(
  68. files.sort().map(async (file) => ({ path: file, contents: await Bun.file(path.join(directory, file)).text() })),
  69. )
  70. }
  71. function renderMigration(name: string, sql: string) {
  72. return `import { Effect } from "effect"
  73. import type { DatabaseMigration } from "../migration"
  74. export default {
  75. id: ${JSON.stringify(name)},
  76. up(tx) {
  77. return Effect.gen(function* () {
  78. ${sql
  79. .split("--> statement-breakpoint")
  80. .map((statement) => statement.trim())
  81. .filter((statement) => statement.length > 0)
  82. .map(renderRun)
  83. .join("\n")}
  84. })
  85. },
  86. } satisfies DatabaseMigration.Migration
  87. `
  88. }
  89. function renderRun(statement: string) {
  90. const lines = statement.replaceAll("\t", " ").split("\n")
  91. if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)`
  92. return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)`
  93. }
  94. function escapeTemplate(line: string) {
  95. return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
  96. }
  97. function renderRegistry(names: string[]) {
  98. return `import type { DatabaseMigration } from "./migration"
  99. export const migrations = (
  100. await Promise.all([
  101. ${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
  102. ])
  103. ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
  104. `
  105. }