From 87834a7fff57a53bb1060ad7061dd6d279922f42 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 14 May 2026 21:21:23 +0200 Subject: [PATCH] fix(install): defer events.hero_photo_id FK to break circular reference (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause behind MrGabri's fresh-Postgres install crash, surfaced by his second log dump after #488 silenced the FATAL noise: Initial setup failed: error: alter table "events" add constraint "events_hero_photo_id_foreign" foreign key ("hero_photo_id") references "photos" ("id") on delete SET NULL - relation "photos" does not exist initializeDatabase() in src/database/db.js declared the FK inline at events createTable (line 89), but the photos table is created later in the same function (line 203). On Postgres this is a hard error — the referenced table must exist at FK-declaration time. SQLite silently tolerated it because its FK enforcement is lazy and the inline declaration just became a column with no FK metadata. Why no existing Postgres install hit it: initializeDatabase only runs the createTable block on `if (!hasEventsTable)`. Once a deployment has the events table from any prior run, the path is skipped. So the bug only ever fires on a truly fresh Postgres install — which is exactly MrGabri's scenario, and which our smoke suite never exercises (it runs against a long-lived dev stack). Fix: - events createTable: drop the inline FK; column declared as a plain integer with an explainer comment. - After both tables exist (post photos createTable): db.schema .alterTable('events').foreign('hero_photo_id').references... Wrapped in a try/catch that swallows "already exists" so re-runs on installs that previously got into a half-state don't fail boot. Verified by docker compose down -v + up against the dev stack — no FK error, all migrations apply, FK present in pg_constraint with the expected definition. --- backend/src/database/db.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 910966ef..833569d8 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -86,7 +86,16 @@ async function initializeDatabase() { table.boolean('disable_right_click').defaultTo(false); table.boolean('watermark_downloads').defaultTo(false); table.text('watermark_text'); - table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); + // events.hero_photo_id → photos.id is a forward reference (the + // photos table is created later in this same function). Postgres + // rejects FK declarations that reference a non-existent table at + // CREATE TABLE time, so the constraint is added below as an + // ALTER TABLE *after* the photos table exists. SQLite previously + // tolerated the inline declaration because its FK enforcement is + // lazy — the inline form silently became a column with no FK + // metadata. Both backends now go through the same code path. + // (#484, MrGabri's reproduction.) + table.integer('hero_photo_id'); table.boolean('require_password').defaultTo(true); }); } else { @@ -213,6 +222,25 @@ async function initializeDatabase() { table.integer('view_count').defaultTo(0); table.integer('download_count').defaultTo(0); }); + + // Deferred FK: events.hero_photo_id → photos.id. See the comment + // on the events createTable above for why this can't be inline. + // Wrapped in try/catch so a re-run path or an SQLite install that + // already accepted the inline (no-op) declaration doesn't fail + // boot when the constraint already exists in some shape. + try { + await db.schema.alterTable('events', (table) => { + table.foreign('hero_photo_id') + .references('id').inTable('photos') + .onDelete('SET NULL'); + }); + } catch (err) { + const msg = err?.message || ''; + if (!/already exists|duplicate|exists/i.test(msg)) { + throw err; + } + // Constraint already in place — fine, carry on. + } } // Access logs table