migration.ts 4.3 KB

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