From e733d47124ca0bc182b35149aac670a639635db9 Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:36:15 +0100 Subject: [PATCH 1/2] fix(db): apply guarded sql migrations before push and fail on push errors Without a TTY, drizzle-kit push could not answer its prompt about the users_api_key_unique constraint, exited 0 anyway, and the app started against a stale schema: every login failed on the missing api_key column. Guarded migrations (0013+) now run before push so it has nothing left to ask, and a non-interactive push whose output reports an error stops the start instead of printing success. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 + scripts/apply-sql-migrations.mjs | 42 ++++++++++++++++++++++++++++++++ scripts/db-push.sh | 21 +++++++++++++++- tests/sql-migrations.test.ts | 40 ++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 scripts/apply-sql-migrations.mjs create mode 100644 tests/sql-migrations.test.ts 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..eee9fca6 --- /dev/null +++ b/scripts/apply-sql-migrations.mjs @@ -0,0 +1,42 @@ +#!/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 { + 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..479c5ee6 100755 --- a/scripts/db-push.sh +++ b/scripts/db-push.sh @@ -33,7 +33,26 @@ 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 + PUSH_LOG=$(mktemp) + DATABASE_URL="$DATABASE_URL" npx drizzle-kit push "$@" 2>&1 | tee "$PUSH_LOG" + if grep -q -E '^(Error|error):' "$PUSH_LOG"; then + rm -f "$PUSH_LOG" + echo "" + echo "❌ Schema push failed: see the error above" + exit 1 + fi + rm -f "$PUSH_LOG" +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/); + } + }); +}); From 32c05cf5c526cf9912a29d3774bfae5055daa31a Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:42:05 +0100 Subject: [PATCH 2/2] fix(db): skip guarded migrations on a fresh database and keep push exit status On an empty database the guarded files altered tables push had not created yet, so startup failed before push. They now run only once the base schema exists. The push status went through tee, which always succeeds: it is now saved to a file and checked along with the logged errors. Co-Authored-By: Claude Opus 5.5 --- scripts/apply-sql-migrations.mjs | 8 ++++++++ scripts/db-push.sh | 13 +++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/apply-sql-migrations.mjs b/scripts/apply-sql-migrations.mjs index eee9fca6..9feff154 100644 --- a/scripts/apply-sql-migrations.mjs +++ b/scripts/apply-sql-migrations.mjs @@ -27,6 +27,14 @@ 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')); diff --git a/scripts/db-push.sh b/scripts/db-push.sh index 479c5ee6..3c08c63e 100755 --- a/scripts/db-push.sh +++ b/scripts/db-push.sh @@ -43,15 +43,20 @@ if [ -t 0 ] && [ -t 1 ]; then 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) - DATABASE_URL="$DATABASE_URL" npx drizzle-kit push "$@" 2>&1 | tee "$PUSH_LOG" - if grep -q -E '^(Error|error):' "$PUSH_LOG"; then - rm -f "$PUSH_LOG" + 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" + rm -f "$PUSH_LOG" "$PUSH_STATUS" fi echo ""