migration.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. #!/usr/bin/env bun
  2. import fs from "fs/promises"
  3. import os from "os"
  4. import path from "path"
  5. import { pathToFileURL } from "url"
  6. import { parseArgs } from "util"
  7. const root = path.resolve(import.meta.dirname, "../../..")
  8. const snapshot = path.join(root, "packages/core/schema.json")
  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. const schema = path.join(root, "packages/core/src/database/schema.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 generate()
  24. async function generate() {
  25. const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-"))
  26. const incremental = path.join(temporary, "incremental")
  27. const full = path.join(temporary, "full")
  28. try {
  29. await fs.mkdir(incremental)
  30. await fs.mkdir(path.join(incremental, "baseline"))
  31. await fs.copyFile(snapshot, path.join(incremental, "baseline/snapshot.json"))
  32. await drizzle(temporary, incremental, args.values.name)
  33. const generated = await generatedMigrations(incremental)
  34. if (generated.length > 1) throw new Error(`Expected one generated migration, found ${generated.length}.`)
  35. const name = generated[0]
  36. if (name) {
  37. const target = path.join(tsDir, `${name}.ts`)
  38. if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
  39. await Bun.write(
  40. target,
  41. await formatTypescript(
  42. renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
  43. ),
  44. )
  45. await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
  46. }
  47. await fs.mkdir(full)
  48. await drizzle(temporary, full, "schema")
  49. await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
  50. await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
  51. } finally {
  52. await fs.rm(temporary, { recursive: true, force: true })
  53. }
  54. }
  55. async function check() {
  56. const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-"))
  57. const incremental = path.join(temporary, "incremental")
  58. const full = path.join(temporary, "full")
  59. try {
  60. await fs.mkdir(incremental)
  61. await fs.mkdir(path.join(incremental, "baseline"))
  62. await fs.copyFile(snapshot, path.join(incremental, "baseline/snapshot.json"))
  63. await drizzle(temporary, incremental)
  64. if ((await generatedMigrations(incremental)).length > 0) {
  65. throw new Error(
  66. "Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.",
  67. )
  68. }
  69. await fs.mkdir(full)
  70. await drizzle(temporary, full, "schema")
  71. if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
  72. throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
  73. }
  74. const migrations = await typescriptMigrations()
  75. if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
  76. throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
  77. }
  78. } finally {
  79. await fs.rm(temporary, { recursive: true, force: true })
  80. }
  81. }
  82. async function drizzle(temporary: string, output: string, name?: string) {
  83. const config = path.join(temporary, `${path.basename(output)}.config.ts`)
  84. await Bun.write(
  85. config,
  86. `import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)}
  87. export default { ...config, out: ${JSON.stringify(output)} }
  88. `,
  89. )
  90. const child = Bun.spawn(["bun", "drizzle-kit", "generate", "--config", config, ...(name ? ["--name", name] : [])], {
  91. cwd: path.join(root, "packages/core"),
  92. stdin: "inherit",
  93. stdout: "inherit",
  94. stderr: "inherit",
  95. })
  96. const exit = await child.exited
  97. if (exit !== 0) throw new Error(`Drizzle generation failed with exit code ${exit}.`)
  98. }
  99. async function generatedMigrations(directory: string) {
  100. return (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
  101. .map((file) => file.split("/")[0])
  102. .filter((name): name is string => name !== undefined)
  103. .sort()
  104. }
  105. async function generatedSql(directory: string) {
  106. const generated = await generatedMigrations(directory)
  107. if (generated.length !== 1) throw new Error(`Expected one full schema migration, found ${generated.length}.`)
  108. return Bun.file(path.join(directory, generated[0]!, "migration.sql")).text()
  109. }
  110. async function typescriptMigrations() {
  111. return (await Array.fromAsync(new Bun.Glob("*.ts").scan({ cwd: tsDir })))
  112. .map((file) => path.basename(file, ".ts"))
  113. .sort()
  114. }
  115. function renderMigration(name: string, sql: string) {
  116. return `import { Effect } from "effect"
  117. import type { DatabaseMigration } from "../migration"
  118. export default {
  119. id: ${JSON.stringify(name)},
  120. up(tx) {
  121. return Effect.gen(function* () {
  122. ${renderStatements(sql)}
  123. })
  124. },
  125. } satisfies DatabaseMigration.Migration
  126. `
  127. }
  128. function renderSchema(sql: string) {
  129. return `import { Effect } from "effect"
  130. import type { DatabaseMigration } from "./migration"
  131. export default {
  132. up(tx) {
  133. return Effect.gen(function* () {
  134. ${renderStatements(sql)}
  135. })
  136. },
  137. } satisfies Omit<DatabaseMigration.Migration, "id">
  138. `
  139. }
  140. function renderStatements(sql: string) {
  141. return sql
  142. .split("--> statement-breakpoint")
  143. .map((statement) => statement.trim())
  144. .filter((statement) => statement.length > 0)
  145. .map(renderRun)
  146. .join("\n")
  147. }
  148. function renderRun(statement: string) {
  149. const lines = statement.replaceAll("\t", " ").split("\n")
  150. if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)`
  151. return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)`
  152. }
  153. function escapeTemplate(line: string) {
  154. return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
  155. }
  156. async function formatTypescript(input: string) {
  157. const prettier = await import("prettier")
  158. const typescript = await import("prettier/plugins/typescript")
  159. const estree = await import("prettier/plugins/estree")
  160. return prettier.format(input, {
  161. parser: "typescript",
  162. plugins: [typescript.default, estree.default],
  163. semi: false,
  164. printWidth: 120,
  165. })
  166. }
  167. function renderRegistry(names: string[]) {
  168. return `import type { DatabaseMigration } from "./migration"
  169. export const migrations = (
  170. await Promise.all([
  171. ${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
  172. ])
  173. ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
  174. `
  175. }