Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions scripts/apply-sql-migrations.mjs
Original file line number Diff line number Diff line change
@@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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();
}
26 changes: 25 additions & 1 deletion scripts/db-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
40 changes: 40 additions & 0 deletions tests/sql-migrations.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
});