CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
migrate.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 2 | * Migration runner — executes every `drizzle/*.sql` file in order. | |
| 3 | * Tracks applied migrations in a dedicated table so repeat runs are idempotent. | |
| 6f3ce18 | 4 | * |
| 5 | * Dual-driver: uses Neon HTTP for *.neon.tech URLs, postgres.js TCP for | |
| 6 | * everything else (localhost, RDS, Supabase, plain Postgres). | |
| 3ef4c9d | 7 | */ |
| 8 | ||
| 9 | import { readdir, readFile } from "fs/promises"; | |
| 10 | import { join } from "path"; | |
| fc1817a | 11 | import { neon } from "@neondatabase/serverless"; |
| 6f3ce18 | 12 | import postgres from "postgres"; |
| fc1817a | 13 | import { config } from "../lib/config"; |
| 6f3ce18 | 14 | import { isNeonUrl } from "./index"; |
| 15 | ||
| 16 | type Exec = (query: string, params?: unknown[]) => Promise<unknown>; | |
| fc1817a | 17 | |
| 2397b12 | 18 | /** |
| 19 | * Split a migration file into individual SQL statements. | |
| 20 | * | |
| 21 | * Two formats are supported: | |
| 22 | * 1. Drizzle-generated migrations use `--> statement-breakpoint` markers | |
| 23 | * between statements. Detect and split on those. | |
| 24 | * 2. Hand-written migrations (like `0000_init.sql`) just have multiple | |
| 25 | * `CREATE TABLE` etc statements separated by `;`. Detect when the | |
| 26 | * drizzle marker isn't present and fall back to a semicolon split | |
| 27 | * that's safe for our schemas (no PL/pgSQL `$$...$$` blocks today). | |
| 28 | * | |
| 29 | * Comment-only lines (`--`) are stripped before splitting so a `;` | |
| 30 | * inside a comment doesn't trigger a false split. Empty fragments are | |
| 31 | * dropped. Trailing semicolons are removed. | |
| 32 | */ | |
| 33 | function splitMigrationStatements(content: string): string[] { | |
| 34 | if (/-->\s*statement-breakpoint/.test(content)) { | |
| 35 | return content | |
| 36 | .split(/-->\s*statement-breakpoint/) | |
| 37 | .map((s) => | |
| 38 | s | |
| 39 | .split("\n") | |
| 40 | .filter((line) => !line.trim().startsWith("--")) | |
| 41 | .join("\n") | |
| 42 | .trim() | |
| 43 | ) | |
| 44 | .filter((s) => s.length > 0); | |
| 45 | } | |
| 46 | // No breakpoint markers — split on `;` at end of (logical) line. Strip | |
| 47 | // pure-comment lines first. | |
| 48 | const stripped = content | |
| 49 | .split("\n") | |
| 50 | .filter((line) => !line.trim().startsWith("--")) | |
| 51 | .join("\n"); | |
| 52 | return stripped | |
| 53 | .split(/;\s*\n/) | |
| 54 | .map((s) => s.trim().replace(/;\s*$/, "").trim()) | |
| 55 | .filter((s) => s.length > 0); | |
| 56 | } | |
| 57 | ||
| fc1817a | 58 | async function runMigrations() { |
| 3ef4c9d | 59 | if (!config.databaseUrl) { |
| 60 | throw new Error("DATABASE_URL is not set"); | |
| 61 | } | |
| 6f3ce18 | 62 | |
| 63 | let exec: Exec; | |
| 64 | let cleanup: (() => Promise<void>) | null = null; | |
| 65 | ||
| 66 | if (isNeonUrl(config.databaseUrl)) { | |
| cab3dc9 | 67 | // @neondatabase/serverless 1.x: the tagged-template factory only accepts |
| 68 | // a TemplateStringsArray. Use the .query(string, params) helper for the | |
| 69 | // dynamic-string flow this migration runner needs. | |
| 6f3ce18 | 70 | const neonSql = neon(config.databaseUrl); |
| cab3dc9 | 71 | exec = (q, p = []) => neonSql.query(q, p); |
| 6f3ce18 | 72 | } else { |
| 73 | const client = postgres(config.databaseUrl, { max: 1, prepare: false }); | |
| 74 | exec = (q, p) => | |
| 75 | p && p.length > 0 ? client.unsafe(q, p as never[]) : client.unsafe(q); | |
| 76 | cleanup = async () => { | |
| 77 | await client.end({ timeout: 5 }); | |
| 78 | }; | |
| 3ef4c9d | 79 | } |
| 80 | ||
| 6f3ce18 | 81 | try { |
| 82 | // Ensure tracking table exists | |
| 83 | await exec(` | |
| 84 | CREATE TABLE IF NOT EXISTS "_migrations" ( | |
| 85 | "name" text PRIMARY KEY, | |
| 86 | "applied_at" timestamp DEFAULT now() NOT NULL | |
| 87 | ) | |
| 88 | `); | |
| 89 | ||
| 90 | const appliedRows = (await exec(`SELECT name FROM _migrations`)) as Array<{ | |
| 91 | name: string; | |
| 92 | }>; | |
| 93 | const appliedSet = new Set(appliedRows.map((r) => r.name)); | |
| 94 | ||
| 95 | const migrationsDir = join(process.cwd(), "drizzle"); | |
| 96 | const files = (await readdir(migrationsDir)) | |
| 97 | .filter((f) => f.endsWith(".sql")) | |
| 98 | .sort(); | |
| 99 | ||
| 100 | if (files.length === 0) { | |
| 101 | console.log("No migration files found."); | |
| 102 | return; | |
| 3ef4c9d | 103 | } |
| 104 | ||
| 6f3ce18 | 105 | for (const file of files) { |
| 106 | if (appliedSet.has(file)) { | |
| 107 | console.log(`[migrate] ${file} — already applied, skipping`); | |
| 108 | continue; | |
| 109 | } | |
| 110 | ||
| 111 | console.log(`[migrate] applying ${file}...`); | |
| 112 | const content = await readFile(join(migrationsDir, file), "utf8"); | |
| 2397b12 | 113 | const statements = splitMigrationStatements(content); |
| 6f3ce18 | 114 | |
| 2397b12 | 115 | for (const stripped of statements) { |
| 6f3ce18 | 116 | try { |
| 117 | await exec(stripped); | |
| 118 | } catch (err) { | |
| 119 | const msg = err instanceof Error ? err.message : String(err); | |
| 120 | if ( | |
| 121 | msg.includes("already exists") || | |
| 122 | msg.includes("duplicate_column") || | |
| 123 | msg.includes("duplicate_object") | |
| 124 | ) { | |
| 125 | console.warn( | |
| 126 | `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)` | |
| 127 | ); | |
| 128 | continue; | |
| 129 | } | |
| 130 | console.error( | |
| 131 | `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}` | |
| 3ef4c9d | 132 | ); |
| 6f3ce18 | 133 | throw err; |
| 3ef4c9d | 134 | } |
| 135 | } | |
| 6f3ce18 | 136 | |
| 137 | await exec(`INSERT INTO _migrations (name) VALUES ($1)`, [file]); | |
| 138 | console.log(`[migrate] ${file} applied`); | |
| 3ef4c9d | 139 | } |
| 140 | ||
| 6f3ce18 | 141 | console.log("[migrate] all migrations complete"); |
| 142 | } finally { | |
| 143 | if (cleanup) await cleanup(); | |
| 3ef4c9d | 144 | } |
| fc1817a | 145 | } |
| 146 | ||
| 147 | runMigrations().catch((err) => { | |
| 148 | console.error("Migration failed:", err); | |
| 149 | process.exit(1); | |
| 150 | }); |