diff --git a/Dockerfile b/Dockerfile index c359527e..bcbb7e48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,7 @@ COPY --from=builder --chown=nuxtjs:nodejs /app/server/db ./server/db COPY --from=builder --chown=nuxtjs:nodejs /app/scripts/seed-categories.ts ./scripts/seed-categories.ts COPY --from=builder --chown=nuxtjs:nodejs /app/scripts/db-push.sh ./scripts/db-push.sh COPY --from=builder --chown=nuxtjs:nodejs /app/scripts/with-migration-lock.mjs ./scripts/with-migration-lock.mjs +COPY --from=builder --chown=nuxtjs:nodejs /app/scripts/apply-sql-migrations.mjs ./scripts/apply-sql-migrations.mjs COPY --from=builder --chown=nuxtjs:nodejs /app/scripts/entrypoint.sh ./scripts/entrypoint.sh COPY --from=builder --chown=nuxtjs:nodejs /app/node_modules ./node_modules diff --git a/scripts/apply-sql-migrations.mjs b/scripts/apply-sql-migrations.mjs new file mode 100644 index 00000000..9feff154 --- /dev/null +++ b/scripts/apply-sql-migrations.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Apply the guarded SQL migrations before drizzle-kit push. Without a TTY, +// push cannot answer its prompts (e.g. a unique constraint on a non-empty +// table) and leaves the schema behind: once these files ran, the schema +// already matches and push has nothing left to ask. +// Files from FIRST_GUARDED on are idempotent (IF NOT EXISTS, duplicate_object +// handlers) and safe to rerun on every start; earlier ones are not, and push +// already brought every database past them. +// Usage: node scripts/apply-sql-migrations.mjs + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import postgres from 'postgres'; + +export const MIGRATIONS_DIR = 'server/db/migrations'; +export const FIRST_GUARDED = 13; + +/** Guarded migration files, in the order they must run */ +export function guardedMigrations(names) { + return names + .filter((name) => /^\d{4}_.+\.sql$/.test(name)) + .filter((name) => Number(name.slice(0, 4)) >= FIRST_GUARDED) + .sort(); +} + +async function main() { + const files = guardedMigrations(await readdir(MIGRATIONS_DIR)); + const sql = postgres(process.env.DATABASE_URL, { max: 1, onnotice: () => {} }); + try { + // Fresh database: these files alter tables push has not created yet. + // Push builds the whole schema from scratch, and on empty tables it has + // nothing to ask + const [{ ready }] = await sql`SELECT to_regclass('public.users') IS NOT NULL AS ready`; + if (!ready) { + console.log(' (fresh database: push creates the schema)'); + return; + } + for (const file of files) { + // No parameters: simple query protocol, several statements per file + await sql.unsafe(await readFile(join(MIGRATIONS_DIR, file), 'utf8')); + console.log(` ✓ ${file}`); + } + } finally { + await sql.end(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await main(); +} diff --git a/scripts/db-push.sh b/scripts/db-push.sh index e99ef257..3c08c63e 100755 --- a/scripts/db-push.sh +++ b/scripts/db-push.sh @@ -33,7 +33,31 @@ else fi echo "" -DATABASE_URL="$DATABASE_URL" npx drizzle-kit push "$@" +echo "📜 Applying guarded SQL migrations..." +DATABASE_URL="$DATABASE_URL" node ./scripts/apply-sql-migrations.mjs +echo "" + +if [ -t 0 ] && [ -t 1 ]; then + # Interactive: drizzle-kit can ask its questions + DATABASE_URL="$DATABASE_URL" npx drizzle-kit push "$@" +else + # drizzle-kit exits 0 when it needs a prompt it cannot show: catch the + # error in its output, or the app would start against a stale schema + # The exit status goes through a file: sh has no pipefail, and tee succeeds + PUSH_LOG=$(mktemp) + PUSH_STATUS=$(mktemp) + { + DATABASE_URL="$DATABASE_URL" npx drizzle-kit push "$@" 2>&1 + echo $? > "$PUSH_STATUS" + } | tee "$PUSH_LOG" + if [ "$(cat "$PUSH_STATUS")" != 0 ] || grep -q -E '^(Error|error):' "$PUSH_LOG"; then + rm -f "$PUSH_LOG" "$PUSH_STATUS" + echo "" + echo "❌ Schema push failed: see the error above" + exit 1 + fi + rm -f "$PUSH_LOG" "$PUSH_STATUS" +fi echo "" echo "✅ Schema pushed successfully" diff --git a/tests/sql-migrations.test.ts b/tests/sql-migrations.test.ts new file mode 100644 index 00000000..730f45b5 --- /dev/null +++ b/tests/sql-migrations.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + guardedMigrations, + MIGRATIONS_DIR, +} from '../scripts/apply-sql-migrations.mjs'; + +describe('guardedMigrations', () => { + it('keeps numbered .sql files from 0013 on, in order', () => { + expect( + guardedMigrations([ + '0019_b.sql', + '0012_old.sql', + 'meta', + '0013_a.sql', + 'notes.md', + ]) + ).toEqual(['0013_a.sql', '0019_b.sql']); + }); +}); + +// They run on every start: a statement that fails the second time would +// stop the app from booting +describe('guarded migration files', () => { + const files = guardedMigrations(readdirSync(MIGRATIONS_DIR)); + + it.each(files)('%s can run twice', (file) => { + const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8'); + for (const m of sql.matchAll(/CREATE (UNIQUE )?(TABLE|INDEX)\b(?! IF NOT EXISTS)/g)) { + throw new Error(`${file}: unguarded "${m[0]}"`); + } + for (const m of sql.matchAll(/ADD COLUMN\b(?! IF NOT EXISTS)/g)) { + throw new Error(`${file}: unguarded "${m[0]}"`); + } + if (/ADD CONSTRAINT/.test(sql)) { + expect(sql).toMatch(/EXCEPTION WHEN duplicate_object/); + } + }); +});