migration.ts 6.1 KB

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