File size: 6,718 Bytes
3e05655 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | #!/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[]
`
}
|