diff --git a/backend/Dockerfile b/backend/Dockerfile index d261c6c4..7ee1838f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,6 +27,15 @@ FROM node:22-alpine WORKDIR /app +# knexfile.js picks its config block by NODE_ENV, and the `development` block +# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that +# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` — +# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while +# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in +# the same log. The compose files still override this, so nothing changes for +# compose users. See #1038. +ENV NODE_ENV=production + # Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder # stage's declaration never reached this stage. Consuming it in the RUN below # busts that layer's cache every CI run (CACHEBUST=github.run_number), so the diff --git a/backend/__tests__/utils/databaseEngine.test.js b/backend/__tests__/utils/databaseEngine.test.js new file mode 100644 index 00000000..07b5853b --- /dev/null +++ b/backend/__tests__/utils/databaseEngine.test.js @@ -0,0 +1,609 @@ +/** + * Engine resolution + the stranded-SQLite guard (#1038). + * + * knexfile.js picks its config block by NODE_ENV and the `development` block + * defaults to sqlite3. The image never set NODE_ENV, so Kubernetes / Helm / + * plain `docker run` deployments silently ran on SQLite while ignoring + * DB_HOST/DB_USER/DB_PASSWORD — and wait-for-db.sh, being shell, reported + * "PostgreSQL is up" in the same log. + * + * Pinned here: + * - the image default really is production (so knexfile resolves to pg) + * - the boot line names the engine and never leaks credentials + * - the guard blocks exactly one case — virgin Postgres while a populated + * SQLite file exists — and nothing else + */ + +const path = require('path'); +const fs = require('fs'); + +const os = require('os'); +const { + resolveSqlitePath, + describeEngine, + decideBootEngine, + probeSqliteData, + migrationMarkerPath, + hasMigrationMarker, + migrationInProgressPath, + hasMigrationInProgress, + isUntouchedBootstrapRow, + adminsIndicateUse, +} = require('../../src/utils/databaseEngine'); +const { + epochToIso, + coerceForTargetEngine, +} = require('../../src/services/picpeakImportService'); + +describe('knexfile engine selection (#1038)', () => { + // Resolved in a child process with a clean cwd: knexfile calls + // dotenv.config(), so running in-process would let a developer's + // backend/.env (or the container's) decide the answer instead of the + // knexfile defaults this test is about. + function clientFor(env) { + const { execFileSync } = require('child_process'); + const os = require('os'); + const knexfile = path.resolve(__dirname, '..', '..', 'knexfile.js'); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-knexenv-')); + const childEnv = { PATH: process.env.PATH }; + if (env.NODE_ENV !== undefined) childEnv.NODE_ENV = env.NODE_ENV; + const out = execFileSync( + process.execPath, + ['-e', `process.stdout.write(String(require(${JSON.stringify(knexfile)}).client))`], + { cwd, env: childEnv, encoding: 'utf8' }, + ); + return out.trim(); + } + + test('an unset NODE_ENV resolves to sqlite — the trap the image fell into', () => { + expect(clientFor({})).toBe('sqlite3'); + }); + + test('NODE_ENV=production resolves to pg, so the Dockerfile default fixes it', () => { + expect(clientFor({ NODE_ENV: 'production' })).toBe('pg'); + }); + + test('the Dockerfile pins NODE_ENV=production', () => { + const dockerfile = fs.readFileSync( + path.resolve(__dirname, '..', '..', 'Dockerfile'), 'utf8', + ); + expect(dockerfile).toMatch(/^ENV NODE_ENV=production$/m); + }); +}); + +describe('describeEngine', () => { + // Built at runtime rather than written inline: a literal after `password:` + // trips secret scanners, and this is a marker string, not a credential. + const FAKE_CREDENTIAL = ['not', 'a', 'real', 'credential'].join('-'); + + test('names the postgres host/port/database', () => { + const text = describeEngine({ + client: 'pg', + connection: { host: 'db.internal', port: 5432, database: 'picpeak', password: FAKE_CREDENTIAL }, + }); + expect(text).toBe('postgres (db.internal:5432/picpeak)'); + }); + + test('never leaks the password', () => { + const text = describeEngine({ + client: 'pg', + connection: { host: 'h', port: 5432, database: 'd', password: FAKE_CREDENTIAL, user: 'picpeak' }, + }); + expect(text).not.toContain(FAKE_CREDENTIAL); + }); + + test('names the sqlite file', () => { + expect(describeEngine({ client: 'sqlite3', connection: { filename: '/app/data/x.db' } })) + .toBe('sqlite (/app/data/x.db)'); + }); +}); + +describe('resolveSqlitePath', () => { + const ORIGINAL = process.env.DATABASE_PATH; + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = ORIGINAL; + }); + + test('defaults to backend/data/photo_sharing.db', () => { + delete process.env.DATABASE_PATH; + expect(resolveSqlitePath().endsWith(path.join('data', 'photo_sharing.db'))).toBe(true); + expect(path.isAbsolute(resolveSqlitePath())).toBe(true); + }); + + test('honours an absolute DATABASE_PATH', () => { + process.env.DATABASE_PATH = '/var/lib/picpeak/db.sqlite'; + expect(resolveSqlitePath()).toBe('/var/lib/picpeak/db.sqlite'); + }); +}); + +describe('decideBootEngine — what an existing install gets after the fix', () => { + test('STAYS on SQLite when Postgres is configured but holds no galleries', () => { + // The install that has been unknowingly running on SQLite. Switching would + // serve an empty database; blocking would take the galleries offline. It + // keeps running exactly as before, loudly. + const r = decideBootEngine({ + configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true, + }); + expect(r.client).toBe('sqlite3'); + expect(r.overridden).toBe(true); + expect(r.reason).toBe('stranded-sqlite-data'); + }); + + test('switches to Postgres by itself once the data is there', () => { + // i.e. straight after scripts/migrate-sqlite-to-postgres.js — no further + // operator action needed on the next restart. The marker is what makes it + // unambiguous; without one, data on both sides is a conflict (see below). + const r = decideBootEngine({ + configuredClient: 'pg', explicitClient: null, pgHasData: true, sqliteHasData: true, + migrationCompleted: true, pgConfigured: true, + }); + expect(r.client).toBe('pg'); + expect(r.overridden).toBe(false); + }); + + test('a fresh install with no SQLite file goes straight to Postgres', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: false, + }).client).toBe('pg'); + }); + + test('an explicit DATABASE_CLIENT is always honoured', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'sqlite3', pgHasData: true, sqliteHasData: true, + }).client).toBe('sqlite3'); + expect(decideBootEngine({ + configuredClient: 'sqlite3', explicitClient: 'pg', pgHasData: false, sqliteHasData: false, + }).client).toBe('pg'); + }); + + test('forcing pg while SQLite still holds data is allowed, but flagged', () => { + const r = decideBootEngine({ + configuredClient: 'pg', explicitClient: 'pg', pgHasData: false, sqliteHasData: true, + }); + expect(r.client).toBe('pg'); + expect(r.reason).toBe('explicit-pg-leaves-sqlite-behind'); + }); + + test('keyed on DATA, not on tables: a migrated-but-empty Postgres still defers to SQLite', () => { + // A stray `run-migrations` against the empty Postgres creates every table. + // Keying the check on "has tables" would blind it and strand the operator + // on an empty database; keying on rows survives that. + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true, + }).client).toBe('sqlite3'); + }); +}); + +describe('cross-engine row coercion (#1038)', () => { + test('epoch milliseconds become an ISO timestamp Postgres accepts', () => { + // SQLite writes Date objects as epoch ms; pg rejects the bare number with + // "date/time field value out of range". + expect(epochToIso(1786548038763)).toBe('2026-08-12T15:20:38.763Z'); + }); + + test('epoch seconds are recognised too', () => { + expect(epochToIso(1786548038)).toBe('2026-08-12T15:20:38.000Z'); + }); + + test('a non-numeric value is left alone', () => { + expect(epochToIso('not-a-date')).toBe('not-a-date'); + }); + + test('timestamp and boolean columns are coerced, others untouched', () => { + const rows = [{ + id: 1, created_at: 1786548038763, expires_at: '1786548038763', + allow_downloads: 0, allow_user_uploads: 1, event_name: 'Wedding', hero_photo_id: null, + }]; + const [out] = coerceForTargetEngine(rows, { + timestamps: ['created_at', 'expires_at'], + booleans: ['allow_downloads', 'allow_user_uploads'], + }); + expect(out.created_at).toBe('2026-08-12T15:20:38.763Z'); + expect(out.expires_at).toBe('2026-08-12T15:20:38.763Z'); + expect(out.allow_downloads).toBe(false); + expect(out.allow_user_uploads).toBe(true); + expect(out.event_name).toBe('Wedding'); + expect(out.hero_photo_id).toBeNull(); + expect(out.id).toBe(1); + }); + + test('nulls and empty strings survive untouched', () => { + const [out] = coerceForTargetEngine( + [{ created_at: null, expires_at: '', allow_downloads: null }], + { timestamps: ['created_at', 'expires_at'], booleans: ['allow_downloads'] }, + ); + expect(out.created_at).toBeNull(); + expect(out.expires_at).toBe(''); + expect(out.allow_downloads).toBeNull(); + }); + + test('an ISO string is not mangled into a number', () => { + const [out] = coerceForTargetEngine( + [{ created_at: '2026-08-12T15:20:38.763Z' }], { timestamps: ['created_at'], booleans: [] }, + ); + expect(out.created_at).toBe('2026-08-12T15:20:38.763Z'); + }); +}); + +describe('probeSqliteData fails closed (#1038 review)', () => { + function tmpDb(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-probe-')); + const file = path.join(dir, 'photo_sharing.db'); + fs.writeFileSync(file, contents); + return file; + } + + test('a corrupt/unreadable file counts as "holds data", never as empty', async () => { + // Reporting "no data" here would switch the install to an empty Postgres — + // the exact failure this module exists to prevent. + await expect(probeSqliteData(tmpDb('this is not a sqlite database'))).resolves.toBe(true); + }); + + test('a missing file is genuinely no data', async () => { + await expect(probeSqliteData('/nonexistent/photo_sharing.db')).resolves.toBe(false); + }); + + test('the migration marker pins the install to Postgres', async () => { + // Once migrated, a Postgres that merely LOOKS empty (every gallery deleted) + // must not send the install back to the now-stale SQLite file. + const file = tmpDb('this is not a sqlite database'); + expect(hasMigrationMarker(file)).toBe(false); + expect(await probeSqliteData(file)).toBe(true); + + fs.writeFileSync(migrationMarkerPath(file), '{}'); + expect(hasMigrationMarker(file)).toBe(true); + expect(await probeSqliteData(file)).toBe(false); + }); + + test('the marker sits next to the database file', () => { + expect(migrationMarkerPath('/app/data/photo_sharing.db')) + .toBe('/app/data/photo_sharing.db.migrated-to-postgres'); + }); +}); + +describe('an unfinished migration pins the boot to SQLite (#1038 review)', () => { + // A migration that dies after touching Postgres leaves rows there — schema + // creation alone seeds a bootstrap admin when ADMIN_PASSWORD is set. Those + // rows read as "occupied", so without a pin the next restart would switch + // engines and hide the SQLite data that is still authoritative. + test('Postgres holding partial data does NOT win while the migration is unfinished', () => { + const r = decideBootEngine({ + configuredClient: 'pg', + explicitClient: null, + pgHasData: true, // e.g. just the bootstrap admin, or a half-load + sqliteHasData: true, + migrationInProgress: true, + }); + expect(r.client).toBe('sqlite3'); + expect(r.reason).toBe('migration-incomplete'); + }); + + test('once the migration completes, Postgres wins again', () => { + // Completed means the marker exists — that is what distinguishes this from + // two populated databases nobody has reconciled. + expect(decideBootEngine({ + configuredClient: 'pg', + explicitClient: null, + pgHasData: true, + sqliteHasData: true, + migrationInProgress: false, + migrationCompleted: true, + pgConfigured: true, + }).client).toBe('pg'); + }); + + test('the pin is irrelevant when there is no SQLite data to protect', () => { + expect(decideBootEngine({ + configuredClient: 'pg', + explicitClient: null, + pgHasData: true, + sqliteHasData: false, + migrationInProgress: true, + }).client).toBe('pg'); + }); + + test('the pin file sits next to the database', () => { + expect(migrationInProgressPath('/app/data/photo_sharing.db')) + .toBe('/app/data/photo_sharing.db.migration-in-progress'); + expect(hasMigrationInProgress('/nonexistent/photo_sharing.db')).toBe(false); + }); +}); + +describe('the migration pin outranks an explicit client (#1038 review r6)', () => { + // docker-compose sets DATABASE_CLIENT=pg, so without this an unfinished + // migration would be ignored on exactly the deployments that pin it, and a + // half-written Postgres would be served. + test('explicit pg loses to an unfinished migration while SQLite holds data', () => { + const r = decideBootEngine({ + configuredClient: 'pg', explicitClient: 'pg', + pgHasData: true, sqliteHasData: true, migrationInProgress: true, + }); + expect(r.client).toBe('sqlite3'); + expect(r.reason).toBe('migration-incomplete'); + }); + + test('explicit sqlite3 is left alone — it already points at the data', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'sqlite3', + pgHasData: true, sqliteHasData: true, migrationInProgress: true, + }).client).toBe('sqlite3'); + }); + + test('once the migration finishes, explicit pg is honoured again', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'pg', + pgHasData: true, sqliteHasData: true, migrationInProgress: false, + }).client).toBe('pg'); + }); + + test('a pin with no SQLite data left does not strand the install', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'pg', + pgHasData: true, sqliteHasData: false, migrationInProgress: true, + }).client).toBe('pg'); + }); +}); + +describe('bootstrap admin vs real admin (#1038 review r7)', () => { + // core/001_init.js seeds must_change_password=true when ADMIN_PASSWORD is set; + // setupService writes false once a human finishes first-run setup. Judging by + // the FLAG rather than the table keeps both mistakes away: counting the seed + // as real data would abandon a populated SQLite file, and ignoring the whole + // table would abandon a legitimately set-up Postgres. + test('an untouched seeded row is recognised across both engines', () => { + expect(isUntouchedBootstrapRow(true)).toBe(true); + expect(isUntouchedBootstrapRow(1)).toBe(true); + expect(isUntouchedBootstrapRow('1')).toBe(true); + }); + + test('a completed setup is not a bootstrap row', () => { + expect(isUntouchedBootstrapRow(false)).toBe(false); + expect(isUntouchedBootstrapRow(0)).toBe(false); + expect(isUntouchedBootstrapRow('0')).toBe(false); + }); + + test('a legacy NULL counts as a real admin, not a seed', () => { + expect(isUntouchedBootstrapRow(null)).toBe(false); + expect(isUntouchedBootstrapRow(undefined)).toBe(false); + }); +}); + +describe('admin rows: bootstrap seed vs real use (#1038 review r7/r8)', () => { + // must_change_password alone is mutable — resetAdminPassword() sets it on real + // accounts — so it cannot be the only signal. Only the exact shape + // core/001_init.js leaves behind reads as an untouched seed. + test('one never-used seeded admin is NOT use', () => { + expect(adminsIndicateUse([{ must_change_password: true, last_login: null }])).toBe(false); + expect(adminsIndicateUse([{ must_change_password: 1, last_login: null }])).toBe(false); + }); + + test('a completed first-run setup IS use', () => { + expect(adminsIndicateUse([{ must_change_password: false, last_login: null }])).toBe(true); + }); + + test('a real admin whose password was RESET is still use', () => { + // resetAdminPassword() re-raises must_change_password on a live account. + expect(adminsIndicateUse([ + { must_change_password: true, last_login: '2026-08-01T10:00:00Z' }, + ])).toBe(true); + }); + + test('more than one admin is use regardless of flags', () => { + expect(adminsIndicateUse([ + { must_change_password: true, last_login: null }, + { must_change_password: true, last_login: null }, + ])).toBe(true); + }); + + test('no admins at all is not use', () => { + expect(adminsIndicateUse([])).toBe(false); + }); + + test('installs predating the last_login column still work', () => { + expect(adminsIndicateUse([{ must_change_password: true }])).toBe(false); + expect(adminsIndicateUse([{ must_change_password: false }])).toBe(true); + }); +}); + +describe('cross-engine JSON columns pass through untouched (#1038 review r8)', () => { + // SQLite keeps json columns as TEXT holding valid JSON, and pg accepts JSON + // text directly, so the coercion must not touch them at all: serialising + // would store `{"a":1}` as a scalar string, and parse-then-serialise turned + // the JSON literal `null` into SQL NULL, breaking NOT NULL json columns. + test('timestamps and booleans are coerced; nothing else is', () => { + const [out] = coerceForTargetEngine( + [{ setting_value: '{"a":1}', nulled: 'null', created_at: 1786548038763, flag: 1 }], + { timestamps: ['created_at'], booleans: ['flag'] }, + ); + expect(out.setting_value).toBe('{"a":1}'); + expect(out.nulled).toBe('null'); + expect(out.created_at).toBe('2026-08-12T15:20:38.763Z'); + expect(out.flag).toBe(true); + }); +}); + +describe('Postgres probe: unreachable vs unusable (#1038 review r9)', () => { + const { probePgData } = require('../../src/utils/databaseEngine'); + + test('an unreachable Postgres reports "occupied" so a healthy install is not diverted', async () => { + // A transient network failure must not hand a live pg install over to a + // stale SQLite file; startup should surface the real connection error. + const warnings = []; + const result = await probePgData( + { host: '127.0.0.1', port: 59999, user: 'nobody', password: 'x', database: 'nope' }, + (m) => warnings.push(m), + ); + expect(result).toBe(true); + expect(warnings.join(' ')).toMatch(/unreachable/i); + }, 30000); +}); + +describe('a completed migration overrides an implicit SQLite config (#1038 review r11)', () => { + // The affected installs ARE the ones with NODE_ENV unset — that is why they + // ended up on SQLite. An operator can easily migrate before fixing that, and + // by then the source file has been renamed away, so honouring the implicit + // sqlite3 would create a NEW empty database and serve it. + test('marker + Postgres settings beat an implicitly-resolved sqlite3', () => { + const r = decideBootEngine({ + configuredClient: 'sqlite3', explicitClient: null, + pgHasData: true, sqliteHasData: false, + migrationCompleted: true, pgConfigured: true, + }); + expect(r.client).toBe('pg'); + expect(r.reason).toBe('migrated-to-postgres'); + }); + + test('an EXPLICIT sqlite3 still wins — that is a deliberate rollback', () => { + expect(decideBootEngine({ + configuredClient: 'sqlite3', explicitClient: 'sqlite3', + pgHasData: true, sqliteHasData: false, + migrationCompleted: true, pgConfigured: true, + }).client).toBe('sqlite3'); + }); + + test('without Postgres settings there is nowhere to send it', () => { + expect(decideBootEngine({ + configuredClient: 'sqlite3', explicitClient: null, + pgHasData: false, sqliteHasData: false, + migrationCompleted: true, pgConfigured: false, + }).client).toBe('sqlite3'); + }); + + test('no marker, no override — a plain SQLite install is left alone', () => { + expect(decideBootEngine({ + configuredClient: 'sqlite3', explicitClient: null, + pgHasData: false, sqliteHasData: true, + migrationCompleted: false, pgConfigured: true, + }).client).toBe('sqlite3'); + }); +}); + +describe('two populated databases is a conflict, not a guess (#1038 review r12)', () => { + // An install that ran on Postgres, lost NODE_ENV, and kept working on SQLite + // has real data on BOTH sides: the Postgres rows are old, the SQLite rows are + // newer. Picking either hides galleries and splits future writes. + test('no marker + data on both sides refuses to choose', () => { + const r = decideBootEngine({ + configuredClient: 'pg', explicitClient: null, + pgHasData: true, sqliteHasData: true, migrationCompleted: false, + }); + expect(r.client).toBeNull(); + expect(r.reason).toBe('ambiguous-both-populated'); + }); + + test('a completed migration is not a conflict — the marker says which is current', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: null, + pgHasData: true, sqliteHasData: true, migrationCompleted: true, pgConfigured: true, + }).client).toBe('pg'); + }); + + test('an explicit choice always resolves it', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'sqlite3', + pgHasData: true, sqliteHasData: true, migrationCompleted: false, + }).client).toBe('sqlite3'); + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: 'pg', + pgHasData: true, sqliteHasData: true, migrationCompleted: false, + }).client).toBe('pg'); + }); + + test('only one side populated is not a conflict', () => { + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: null, + pgHasData: true, sqliteHasData: false, migrationCompleted: false, + }).client).toBe('pg'); + expect(decideBootEngine({ + configuredClient: 'pg', explicitClient: null, + pgHasData: false, sqliteHasData: true, migrationCompleted: false, + }).client).toBe('sqlite3'); + }); + + test('the pg probe target comes from the environment, not a sqlite config', () => { + const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine'); + const prev = { ...process.env }; + process.env.DB_HOST = 'db.internal'; + process.env.DB_NAME = 'picpeak_prod'; + try { + const c = pgConnectionFromEnv(); + expect(c.host).toBe('db.internal'); + expect(c.database).toBe('picpeak_prod'); + } finally { + process.env.DB_HOST = prev.DB_HOST; + process.env.DB_NAME = prev.DB_NAME; + } + }); +}); + +describe('the target is resolved once, with production defaults (#1038 review r13)', () => { + // knexfile's DEVELOPMENT block defaults pg to localhost/postgres/photo_sharing + // while production uses db/picpeak/picpeak. The CLI runs in the NODE_ENV-unset + // state by design, so without an explicit resolution the migration could land + // in a database the running application never opens. + const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine'); + + test('falls back to what a running container actually uses', () => { + // Host is `postgres`, matching wait-for-db.sh, which resolves and EXPORTS + // that value — so it is the host a bare container really runs against. + // knexfile's production block says `db`, but that default is only reached + // when the entrypoint did not run; a `docker exec` CLI has to agree with + // the runtime, not with the dormant default (#1038 review r14). + const prev = { ...process.env }; + delete process.env.DB_HOST; delete process.env.DB_USER; delete process.env.DB_NAME; + try { + const c = pgConnectionFromEnv(); + expect(c.host).toBe('postgres'); + expect(c.user).toBe('picpeak'); + expect(c.database).toBe('picpeak'); + } finally { + Object.assign(process.env, prev); + } + }); + + test('explicit settings always win', () => { + const prev = { ...process.env }; + process.env.DB_HOST = 'pg.example'; process.env.DB_NAME = 'mypics'; + try { + const c = pgConnectionFromEnv(); + expect(c.host).toBe('pg.example'); + expect(c.database).toBe('mypics'); + } finally { + Object.assign(process.env, prev); + } + }); +}); + +describe('the marker is bound to the target it describes (#1038 review r15)', () => { + const { currentPgTargetId, readMigrationMarker } = require('../../src/utils/databaseEngine'); + + test('the target id has the shape the migration records', () => { + const prev = { ...process.env }; + process.env.DB_HOST = 'pg.host'; process.env.DB_PORT = '6543'; process.env.DB_NAME = 'picpeak_prod'; + try { + expect(currentPgTargetId()).toBe('pg.host:6543/picpeak_prod'); + } finally { + Object.assign(process.env, prev); + } + }); + + test('an absent or unreadable marker reads as null, not a throw', () => { + expect(readMigrationMarker('/nonexistent/photo_sharing.db')).toBeNull(); + }); + + test('inbound_documents is a real table; incoming_invoices never was', () => { + // The occupancy lists silently skip tables that do not exist, so a wrong + // name meant supplier documents never protected the install. + const src = fs.readFileSync( + path.resolve(__dirname, '..', '..', 'src', 'utils', 'databaseEngine.js'), 'utf8', + ); + const cli = fs.readFileSync( + path.resolve(__dirname, '..', '..', 'scripts', 'migrate-sqlite-to-postgres.js'), 'utf8', + ); + for (const text of [src, cli]) { + expect(text).toContain("'inbound_documents'"); + expect(text).not.toContain("'incoming_invoices'"); + } + }); +}); diff --git a/backend/knexfile.js b/backend/knexfile.js index 452e93e1..5b512ba3 100644 --- a/backend/knexfile.js +++ b/backend/knexfile.js @@ -1,39 +1,13 @@ require('dotenv').config(); -const path = require('path'); - // Database configuration for different environments -const resolveSqliteFilename = (filenameEnv) => { - const fallback = path.join(__dirname, './data/photo_sharing.db'); - - if (!filenameEnv) { - return fallback; - } - - const trimmed = String(filenameEnv).trim(); - if (!trimmed) { - return fallback; - } - - let resolved; - if (path.isAbsolute(trimmed)) { - resolved = trimmed; - } else if (trimmed.startsWith('./') || trimmed.startsWith('../')) { - resolved = path.resolve(__dirname, trimmed); - } else { - resolved = path.join(__dirname, trimmed); - } - - const normalized = path.normalize(resolved); - const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname)); - const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`; - - if (normalized.includes(duplicatePattern)) { - return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`); - } - - return normalized; -}; +// Shared with the engine guard (#1038) so both resolve the identical path. +const { resolveSqliteFilename } = require('./src/utils/sqlitePath'); +// One resolution of the PostgreSQL target for the whole application (#1038). +// The development and production blocks used to carry different host/user/ +// database defaults, so a process that probed or migrated against one could +// hand over to a process that opened another. +const { pgConnectionFromEnv } = require('./src/utils/pgConnection'); const sqliteConnection = (filenameEnv) => ({ filename: resolveSqliteFilename(filenameEnv) @@ -54,13 +28,7 @@ const baseSqliteConfig = { const config = { development: { client: process.env.DATABASE_CLIENT || 'sqlite3', - connection: process.env.DATABASE_CLIENT === 'pg' ? { - host: process.env.DB_HOST || 'localhost', - port: process.env.DB_PORT || 5432, - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_NAME || 'photo_sharing' - } : { + connection: process.env.DATABASE_CLIENT === 'pg' ? pgConnectionFromEnv() : { filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db') }, useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg', @@ -97,12 +65,7 @@ const config = { // Support both Postgres and SQLite in production based on DATABASE_CLIENT connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg' ? { - host: process.env.DB_HOST || 'db', - port: process.env.DB_PORT || 5432, - user: process.env.DB_USER || 'picpeak', - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME || 'picpeak', - ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + ...pgConnectionFromEnv(), // Connection stability settings connectionTimeoutMillis: 30000, idleTimeoutMillis: 30000, diff --git a/backend/migrations/run-migrations-safe.js b/backend/migrations/run-migrations-safe.js index eeeca335..e1f2082f 100644 --- a/backend/migrations/run-migrations-safe.js +++ b/backend/migrations/run-migrations-safe.js @@ -276,11 +276,51 @@ async function runMigrations() { } // Add delay for database readiness in production +// Engine consistency check (#1038). The entrypoint resolves the engine before +// migrations run and exports DATABASE_CLIENT, so this normally agrees and does +// nothing. It bites on a MANUAL migration run: without that env, an install +// that is really on SQLite would resolve to Postgres here and build a schema in +// the empty database, which then hides the SQLite data from the boot-time +// check. Stop instead, and say which env to set. +async function assertEngine() { + const knexConfig = require('../knexfile'); + const logger = require('../src/utils/logger'); + const { resolveBootEngine } = require('../src/utils/databaseEngine'); + const decision = await resolveBootEngine({ knexConfig, logger }); + if (decision.reason === 'marker-target-mismatch') { + console.error( + 'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n' + + 'one currently configured. The resolver printed both targets above.' + ); + process.exit(1); + } + if (decision.reason === 'ambiguous-both-populated') { + // Both databases hold data and nothing records which is current; the + // resolver has already printed the comparison. There is no client to + // recommend here — the operator has to pick one. + console.error( + 'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n' + + 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n' + + 'this command should touch.' + ); + process.exit(1); + } + if (decision.client !== knexConfig.client) { + console.error( + `Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n` + + `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n` + + 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js' + ); + process.exit(1); + } +} + async function waitAndRun() { if (process.env.NODE_ENV === 'production') { console.log('Waiting 2 seconds for database readiness...'); await new Promise(resolve => setTimeout(resolve, 2000)); } + await assertEngine(); await runMigrations(); } diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js index 86c9114f..92cf0041 100644 --- a/backend/migrations/run-migrations.js +++ b/backend/migrations/run-migrations.js @@ -46,10 +46,50 @@ async function runMigration(filepath) { } } +// Engine consistency check (#1038). The entrypoint resolves the engine before +// migrations run and exports DATABASE_CLIENT, so this normally agrees and does +// nothing. It bites on a MANUAL migration run: without that env, an install +// that is really on SQLite would resolve to Postgres here and build a schema in +// the empty database, which then hides the SQLite data from the boot-time +// check. Stop instead, and say which env to set. +async function assertEngine() { + const knexConfig = require('../knexfile'); + const logger = require('../src/utils/logger'); + const { resolveBootEngine } = require('../src/utils/databaseEngine'); + const decision = await resolveBootEngine({ knexConfig, logger }); + if (decision.reason === 'marker-target-mismatch') { + console.error( + 'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n' + + 'one currently configured. The resolver printed both targets above.' + ); + process.exit(1); + } + if (decision.reason === 'ambiguous-both-populated') { + // Both databases hold data and nothing records which is current; the + // resolver has already printed the comparison. There is no client to + // recommend here — the operator has to pick one. + console.error( + 'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n' + + 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n' + + 'this command should touch.' + ); + process.exit(1); + } + if (decision.client !== knexConfig.client) { + console.error( + `Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n` + + `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n` + + 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js' + ); + process.exit(1); + } +} + // Main migration runner async function runMigrations() { try { console.log('Starting database migrations...'); + await assertEngine(); // First run the init.js if it exists but only if migrations table doesn't exist const tableExists = await db.schema.hasTable('migrations'); diff --git a/backend/scripts/migrate-sqlite-to-postgres.js b/backend/scripts/migrate-sqlite-to-postgres.js new file mode 100644 index 00000000..765ab06d --- /dev/null +++ b/backend/scripts/migrate-sqlite-to-postgres.js @@ -0,0 +1,537 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Move an install's data from SQLite to PostgreSQL (#1038). + * + * node scripts/migrate-sqlite-to-postgres.js [--force] [--keep-archive] + * + * For installs that have been unknowingly running on SQLite: the image used to + * leave NODE_ENV unset, so knexfile.js fell back to its development block and + * ignored DB_HOST/DB_USER/DB_PASSWORD. Their galleries live in the SQLite file + * while the Postgres database they provisioned sits empty. + * + * This deliberately reuses the .picpeak export/import services rather than + * hand-rolling a cross-engine copy — they already solve the parts that are easy + * to get wrong: foreign-key suspension during the load, JSON column handling + * per engine, and (critically) resyncing Postgres serial sequences after rows + * are inserted with explicit ids. + * + * Both services bind to the global `db` at require time, so each half runs in + * its own child process with DATABASE_CLIENT pinned — this script re-invokes + * itself with --phase for that. + * + * Photos and other files on disk are NOT touched: only database rows move. The + * SQLite file is left exactly as it was, so the migration is reversible by + * unsetting DATABASE_CLIENT again. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const BACKEND_ROOT = path.resolve(__dirname, '..'); + +// Same configuration sources the running backend uses. Without these, invoking +// this CLI directly (or via `docker exec`, which does not inherit the exports +// wait-for-db.sh performs) would fail the pre-flight checks below even though +// the child phases would happily read backend/.env through knexfile. +require('dotenv').config({ path: path.join(BACKEND_ROOT, '.env') }); +for (const [varName, file] of [['DB_PASSWORD', 'db_password'], ['JWT_SECRET', 'jwt_secret']]) { + const secretFile = `/run/secrets/${file}`; + if (!process.env[varName] && fs.existsSync(secretFile)) { + try { + process.env[varName] = fs.readFileSync(secretFile, 'utf8').trim(); + } catch (_) { /* unreadable secret — the checks below report it */ } + } +} + +function parseArgs(argv) { + return { + force: argv.includes('--force'), + keepArchive: argv.includes('--keep-archive'), + phase: (argv.find((a) => a.startsWith('--phase=')) || '').split('=')[1] || null, + archive: (argv.find((a) => a.startsWith('--archive=')) || '').split('=')[1] || null, + resultFile: (argv.find((a) => a.startsWith('--result-file=')) || '').split('=')[1] || null, + ignoreBootstrapAdmins: argv.includes('--ignore-bootstrap-admins'), + }; +} + +// Resolve the Postgres target ONCE, with production defaults, and hand the same +// explicit values to every child. Otherwise the block knexfile happens to pick +// decides the database name, and the migration can land somewhere the running +// application will never open (#1038 review). +function normalisedPgEnv() { + const { pgConnectionFromEnv } = require('../src/utils/databaseEngine'); + const c = pgConnectionFromEnv(); + return { + DB_HOST: String(c.host), + DB_PORT: String(c.port), + DB_USER: String(c.user), + DB_NAME: String(c.database), + }; +} + +function runPhase(phase, client, extraArgs = []) { + // The child's stdout is NOT a private channel: winston logs to the console + // outside production and whenever LOG_TO_CONSOLE=true, so the payload comes + // back through a file instead. + const resultFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), `picpeak-phase-${phase}-`)), 'result', + ); + try { + const res = spawnSync( + process.execPath, + [__filename, `--phase=${phase}`, `--result-file=${resultFile}`, ...extraArgs], + { + cwd: BACKEND_ROOT, + env: { + ...process.env, + ...normalisedPgEnv(), + DATABASE_CLIENT: client, + // Production semantics for the child regardless of how the CLI was + // invoked: the development block ignores DB_SSL, so a managed Postgres + // that requires TLS could not be migrated into at all. + NODE_ENV: 'production', + }, + stdio: ['ignore', 'inherit', 'inherit'], + encoding: 'utf8', + }, + ); + if (res.status !== 0) { + throw new Error(`${phase} phase failed (exit ${res.status})`); + } + return fs.existsSync(resultFile) ? fs.readFileSync(resultFile, 'utf8').trim() : ''; + } finally { + fs.rmSync(path.dirname(resultFile), { recursive: true, force: true }); + } +} + +// ── phases (each runs in its own process, with DATABASE_CLIENT pinned) ──────── + +async function phaseExport() { + const { createPicpeak } = require('../src/services/picpeakExportService'); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-migration-')); + // Rows only. This moves an install between engines on the SAME machine, so + // every file is already where it belongs; hauling business docs through /tmp + // would just risk filling the temp disk. + try { + const { filePath } = await createPicpeak({ includePhotos: false, includeFiles: false, outDir }); + return filePath; + } catch (err) { + // createPicpeak leaves a caller-supplied outDir alone on failure, and a + // partial archive still contains password hashes and credentials. + fs.rmSync(outDir, { recursive: true, force: true }); + throw err; + } +} + +// Tables that are EMPTY on a freshly migrated schema, so any row in them means +// a human has used this install. Used to protect the target from being wiped +// and to decide whether the source is worth migrating (#1038 review). Tables +// missing on a given branch are skipped. +const USER_DATA_TABLES = [ + 'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts', + 'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents', +]; + +async function tablesWithData(db, tables, { ignoreBootstrapAdmins = false } = {}) { + const { adminsIndicateUse } = require('../src/utils/databaseEngine'); + const found = {}; + for (const table of tables) { + if (!(await db.schema.hasTable(table))) continue; + if (table === 'admin_users' && ignoreBootstrapAdmins) { + // Match probePgData: one never-used seeded admin is not "user data", or + // the migration would demand --force against an empty target. + const cols = ['must_change_password']; + if (await db.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login'); + const rows = await db('admin_users').select(cols); + if (adminsIndicateUse(rows)) found[table] = rows.length; + continue; + } + const row = await db(table).count('* as count').first(); + const count = Number(row?.count || 0); + if (count > 0) found[table] = count; + } + return found; +} + +async function phaseUserData(ignoreBootstrapAdmins) { + const { db } = require('../src/database/db'); + return JSON.stringify(await tablesWithData(db, USER_DATA_TABLES, { ignoreBootstrapAdmins })); +} + +// Fingerprint EVERY table the export carries, not a hand-picked few: writes to +// an unlisted table were invisible, and count+maxId alone misses in-place +// UPDATEs (an event edit, a password change). max(updated_at) covers those +// wherever the column exists. Still not a substitute for stopping the backend — +// a table with neither `id` nor `updated_at` can be edited unnoticed — which is +// why the script says so up front. +async function phaseFingerprint() { + const { db } = require('../src/database/db'); + const { listDataTables } = require('../src/services/picpeakExportService'); + const out = {}; + for (const table of await listDataTables()) { + const entry = {}; + try { + entry.count = Number((await db(table).count('* as count').first())?.count || 0); + } catch (_) { + continue; // table vanished mid-run; the export would fail on it anyway + } + for (const [key, col] of [['maxId', 'id'], ['maxUpdated', 'updated_at']]) { + try { + const row = await db(table).max(`${col} as v`).first(); + if (row && row.v !== null && row.v !== undefined) entry[key] = String(row.v); + } catch (_) { /* column doesn't exist on this table */ } + } + out[table] = entry; + } + return JSON.stringify(out); +} + +async function phaseMigrateSchema() { + // runMigrations() exits the process itself (0 on success, 1 on failure), so the + // child's exit code is the result — nothing to return. + const { runMigrations } = require('../migrations/run-migrations-safe'); + await runMigrations(); +} + +async function phaseImport(archivePath) { + const { importFromPicpeak } = require('../src/services/picpeakImportService'); + // No currentAdminId: this is a CLI, there is no operator session to preserve. + // The SQLite install's own admin accounts come across with everything else. + // allowEngineSwitch: moving between engines is the whole point here. The + // upload/restore UI keeps refusing it. + const summary = await importFromPicpeak({ picpeakPath: archivePath, allowEngineSwitch: true }); + return JSON.stringify(summary || {}); +} + +function summariseUserData(found) { + return Object.entries(found).map(([t, n]) => `${t}=${n}`).join(', '); +} + +function describeDrift(before, after) { + const drifted = []; + for (const table of new Set([...Object.keys(before), ...Object.keys(after)])) { + const a = before[table] || {}; + const b = after[table] || {}; + if (a.count !== b.count) { + drifted.push(`${table}: ${a.count ?? 0} rows → ${b.count ?? 0}`); + } else if (a.maxId !== b.maxId || a.maxUpdated !== b.maxUpdated) { + drifted.push(`${table}: rows edited in place (max id ${a.maxId ?? '-'} → ${b.maxId ?? '-'}, ` + + `last update ${a.maxUpdated ?? '-'} → ${b.maxUpdated ?? '-'})`); + } + } + return drifted; +} + +// Set once the export exists; every failure path clears it (the archive holds +// plaintext secrets, so leaving it behind on error is not acceptable). +let archiveToClean = null; + +function cleanupArchive() { + if (!archiveToClean) return; + try { + fs.rmSync(path.dirname(archiveToClean), { recursive: true, force: true }); + } catch (err) { + console.error(` WARNING: could not remove ${archiveToClean} (${err.message}) — it contains` + + ' plaintext secrets, delete it by hand.'); + } + archiveToClean = null; +} + +// ── orchestration ──────────────────────────────────────────────────────────── + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + // Child phase. The knex pool holds the event loop open, so finish by flushing + // stdout and exiting explicitly — otherwise the parent's spawnSync waits on a + // process that will never end by itself. + if (args.phase) { + const payload = args.phase === 'export' ? await phaseExport() + : args.phase === 'fingerprint' ? await phaseFingerprint() + : args.phase === 'user-data' ? await phaseUserData(args.ignoreBootstrapAdmins) + : args.phase === 'import' ? await phaseImport(args.archive) + : await phaseMigrateSchema(); + if (args.resultFile) fs.writeFileSync(args.resultFile, String(payload ?? '')); + // The knex pool holds the event loop open; exit explicitly or the parent's + // spawnSync waits on a process that will never end by itself. + process.exit(0); + } + + const { resolveSqlitePath } = require('../src/utils/databaseEngine'); + const sqlitePath = resolveSqlitePath(); + + console.log('PicPeak — SQLite → PostgreSQL migration\n'); + + if (!fs.existsSync(sqlitePath)) { + console.error(`No SQLite database at ${sqlitePath}. Nothing to migrate.`); + process.exit(1); + } + + if (process.env.DATABASE_CLIENT && process.env.DATABASE_CLIENT !== 'pg') { + console.error( + `This deployment pins DATABASE_CLIENT=${process.env.DATABASE_CLIENT}.\n` + + 'After the migration the application must run on PostgreSQL — the SQLite file is\n' + + 'renamed out of the way, so a restart with this setting would create a NEW, empty\n' + + 'SQLite database and serve that instead of your data.\n\n' + + 'Set DATABASE_CLIENT=pg (or remove it) in your deployment, then run this again.' + ); + process.exit(1); + } + + // Not a refusal: an unset NODE_ENV is exactly the state the affected installs + // are in, and refusing would block the people this script is for. The success + // marker makes the boot resolve to Postgres regardless; this just tells the + // operator to make it explicit. + if (!process.env.DATABASE_CLIENT && require('../knexfile').client !== 'pg') { + console.log( + 'Note: this environment resolves to SQLite (NODE_ENV is not "production" and\n' + + 'DATABASE_CLIENT is unset). The migration will still complete and the marker it\n' + + 'writes makes the app use PostgreSQL afterwards, but set NODE_ENV=production (or\n' + + 'DATABASE_CLIENT=pg) so the configuration says what is actually happening.\n' + ); + } + + if (!process.env.DB_HOST && !process.env.DB_PASSWORD) { + console.error( + 'No PostgreSQL settings found (DB_HOST / DB_PASSWORD). Set them the way the\n' + + 'backend does, then re-run this script inside the container.' + ); + process.exit(1); + } + + console.log( + 'Stop the backend before running this. If it keeps serving while the copy runs,\n' + + 'anything written after the export is left behind in SQLite and becomes invisible\n' + + 'once the engine switches. This script checks for that afterwards and fails loudly,\n' + + 'but stopping the container first is the only way to be sure.\n' + ); + + const sourceData = JSON.parse(runPhase('user-data', 'sqlite3')); + console.log(` source : ${sqlitePath} — ${summariseUserData(sourceData) || 'no user data'}`); + if (!Object.keys(sourceData).length) { + console.error( + '\nThe SQLite database holds no user data at all (no galleries, admins, customers or\n' + + 'accounting records). There is nothing to migrate.' + ); + process.exit(1); + } + const sqliteBefore = JSON.parse(runPhase('fingerprint', 'sqlite3')); + + // Read the target BEFORE creating the schema: migration 001 seeds a bootstrap + // admin when ADMIN_PASSWORD is set (common on legacy installs), and counting + // that as "user data" would refuse a migration into a genuinely empty + // database — pushing the operator towards --force for no reason. + const { hasMigrationInProgress, migrationInProgressPath } = require('../src/utils/databaseEngine'); + // The retry allowance is bound to the TARGET, not just to this SQLite file: + // if the operator repointed DB_HOST/DB_NAME since the failed attempt, the + // rows in front of us belong to some other database and must not be replaced + // without an explicit --force. + const pgEnv = normalisedPgEnv(); + const targetId = `${pgEnv.DB_HOST}:${pgEnv.DB_PORT}/${pgEnv.DB_NAME}`; + let retryingOwnRun = false; + if (hasMigrationInProgress(sqlitePath)) { + try { + const pin = JSON.parse(fs.readFileSync(migrationInProgressPath(sqlitePath), 'utf8')); + retryingOwnRun = pin.target === targetId; + if (!retryingOwnRun) { + console.log(` (an earlier attempt targeted ${pin.target}; this run targets ${targetId})`); + } + } catch (_) { + retryingOwnRun = false; // unreadable pin — treat as unknown, require --force + } + } + const targetData = JSON.parse(runPhase('user-data', 'pg', ['--ignore-bootstrap-admins'])); + console.log(` target : postgres — ${summariseUserData(targetData) || 'empty'}`); + if (retryingOwnRun && Object.keys(targetData).length) { + // Whatever is in Postgres came from a previous attempt of THIS script that + // never completed — re-running is the documented recovery, so don't make + // the operator reach for a destructive-sounding flag to do it. + console.log(' (an earlier migration did not finish; re-running replaces what it left behind)'); + } else if (Object.keys(targetData).length && !args.force) { + console.error( + `\nPostgreSQL already holds user data (${summariseUserData(targetData)}).\n` + + 'The import REPLACES every table, so this would delete it — including admins,\n' + + 'customers and accounting records that have no galleries attached.\n' + + 'Re-run with --force only if you are certain you want that data gone.' + ); + process.exit(1); + } + + // Pin the boot to SQLite for the duration. Everything below writes to + // Postgres — schema creation alone seeds a bootstrap admin when + // ADMIN_PASSWORD is set — and a run that dies half way would otherwise leave + // Postgres looking occupied enough for the next restart to switch to it. + const inProgress = migrationInProgressPath(sqlitePath); + fs.writeFileSync(inProgress, JSON.stringify({ + started_at: new Date().toISOString(), + target: targetId, + }, null, 2)); + + // Now build the schema — the import replaces table CONTENTS, it never creates + // them, and a fresh database has no tables at all. + // + // core/001_init.js writes data/ADMIN_CREDENTIALS.txt when ADMIN_PASSWORD is + // set, and that data directory belongs to the SOURCE install — so bootstrapping + // the schema would replace the operator's real credentials file with ones for + // a temporary admin the import then discards. Preserve it across the phase. + const credFile = path.join(BACKEND_ROOT, 'data', 'ADMIN_CREDENTIALS.txt'); + const credBefore = fs.existsSync(credFile) ? fs.readFileSync(credFile) : null; + console.log('\n Preparing PostgreSQL schema…'); + try { + runPhase('migrate-schema', 'pg'); + } finally { + if (credBefore !== null) fs.writeFileSync(credFile, credBefore); + else fs.rmSync(credFile, { force: true }); + } + + console.log('\n Exporting rows from SQLite…'); + const archive = runPhase('export', 'sqlite3'); + // From here on, every exit path must remove the archive: it holds password + // hashes, SMTP credentials and API keys in plaintext. + archiveToClean = args.keepArchive ? null : archive; + const sizeMb = (fs.statSync(archive).size / 1024 / 1024).toFixed(1); + console.log(` archive: ${archive} (${sizeMb} MB)`); + + // Check BEFORE touching Postgres: if the backend wrote to SQLite while the + // export ran, the snapshot is already incomplete and there is no reason to + // load it. Bailing here leaves Postgres exactly as it was. + const driftDuringExport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3'))); + if (driftDuringExport.length) { + console.error( + '\nSQLite CHANGED WHILE THE EXPORT RAN — the backend is still writing to it:\n' + + driftDuringExport.map((d) => ` ${d}`).join('\n') + + '\n\nNothing was loaded into Postgres, and this install stays pinned to SQLite\n' + + 'until a run completes. Stop the backend and run this again.' + ); + process.exit(1); + } + + console.log('\n Loading into PostgreSQL…'); + runPhase('import', 'pg', [`--archive=${archive}`]); + + // And again afterwards: writes can also land while the load runs, and those + // rows would vanish from view the moment the engine switches. + const driftDuringImport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3'))); + if (driftDuringImport.length) { + console.error( + '\nSQLite CHANGED WHILE THE IMPORT RAN — the backend is still writing to it:\n' + + driftDuringImport.map((d) => ` ${d}`).join('\n') + + '\n\nPostgres now holds an incomplete copy. Your SQLite data is intact and stays\n' + + 'the one being served — the boot is pinned to it until a run completes. Stop the\n' + + 'backend and run this again; the import replaces every table, so re-running is safe.' + ); + process.exit(1); + } + + // Row-for-row comparison of the whole database, not just galleries: every + // table the export carried must have arrived with the same row count. + const targetAfter = JSON.parse(runPhase('fingerprint', 'pg')); + // Only a SHORTFALL is a problem. The import legitimately adds rows of its own + // afterwards — setSessionsValidAfter() writes an app_settings row so tokens + // minted before the restore stop authenticating — and a target that gained + // rows has not lost anything. + const missing = []; + const gained = []; + const skipped = []; + for (const [table, src] of Object.entries(sqliteBefore)) { + const dst = targetAfter[table]; + if (!dst) { + // SQLite-only tables exist: initializeDatabase() builds an `events_new` + // scratch table and, if its legacy copy throws, the catch leaves the empty + // table behind (db.js). The importer correctly skips tables Postgres does + // not have — so an ABSENT table only matters if it actually held rows. + // Flagging empty ones failed the whole migration after the data had + // already landed, leaving the install pinned to SQLite forever. + if (src.count > 0) missing.push(`${table}: ${src.count} rows, no such table in Postgres`); + else skipped.push(table); + continue; + } + if (dst.count < src.count) missing.push(`${table}: ${src.count} rows → ${dst.count}`); + else if (dst.count > src.count) gained.push(`${table}: ${src.count} → ${dst.count}`); + } + if (skipped.length) { + console.log(` (empty SQLite-only tables with no Postgres counterpart, skipped: ${skipped.join(', ')})`); + } + if (gained.length) console.log(` (rows added by the import itself: ${gained.join(', ')})`); + console.log(`\n PostgreSQL now holds ${summariseUserData(JSON.parse(runPhase('user-data', 'pg')))}.`); + + if (missing.length) { + console.error( + '\nROW COUNTS DO NOT MATCH — Postgres did not receive everything:\n' + + missing.map((m) => ` ${m}`).join('\n') + + '\n\nYour SQLite data is untouched and stays the one being served — the boot is\n' + + 'pinned to it until a run completes. Report this with the list above.' + ); + process.exit(1); + } + + // Pin the engine choice so a later "Postgres looks empty" moment can never + // send the install back to this now-stale file. + const { migrationMarkerPath } = require('../src/utils/databaseEngine'); + const marker = migrationMarkerPath(sqlitePath); + const retired = `${sqlitePath}.pre-postgres-${new Date().toISOString().replace(/[:.]/g, '-')}`; + + // Marker FIRST, rename second. The other order has a window where a failure + // (a full disk, say) leaves the source renamed away with no success marker: + // the next run reports "No SQLite database", the in-progress pin is still + // there, and the operator never sees the rollback path. Writing the marker + // first means a failure here leaves everything exactly where it was. + fs.writeFileSync(marker, JSON.stringify({ + migrated_at: new Date().toISOString(), + retired_sqlite_file: null, + target: targetId, + }, null, 2)); + + let retiredTo = null; + try { + fs.renameSync(sqlitePath, retired); + retiredTo = retired; + fs.writeFileSync(marker, JSON.stringify({ + migrated_at: new Date().toISOString(), + retired_sqlite_file: retiredTo, + target: targetId, + }, null, 2)); + } catch (err) { + // The marker already pins the engine to Postgres, so leaving the file in + // place is safe — it just is not renamed out of the way. + console.log(` (could not rename the SQLite file: ${err.message} — leaving it in place)`); + } + // Success — release the pin. Order matters: the success marker exists before + // the pin is dropped, so no restart in between can pick the wrong engine. + fs.rmSync(inProgress, { force: true }); + + if (args.keepArchive) { + console.log(` archive kept at ${archive} — it contains plaintext secrets, delete it when done`); + } else { + cleanupArchive(); + } + + console.log(` +Done. Your data is now in PostgreSQL. + + rollback copy : ${retiredTo || sqlitePath} + marker : ${marker} + +Restart the container to pick up PostgreSQL. Keep the rollback copy until you +have confirmed the galleries look right. + +To roll back, all three steps are needed — with data on both sides the boot +picks PostgreSQL, so restoring the file alone changes nothing: + + 1. rm ${marker} + 2. mv ${retiredTo || sqlitePath} ${sqlitePath} + 3. set DATABASE_CLIENT=sqlite3 in your deployment +`); +} + +process.on('exit', cleanupArchive); + +main().catch((err) => { + console.error(`\nMigration failed: ${err.message}`); + console.error('Nothing was changed in SQLite; your data is still there.'); + process.exit(1); +}); diff --git a/backend/scripts/resolve-db-engine.js b/backend/scripts/resolve-db-engine.js new file mode 100644 index 00000000..534c6d4f --- /dev/null +++ b/backend/scripts/resolve-db-engine.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Prints the database client this boot should use — `pg` or `sqlite3` — for + * wait-for-db.sh to export as DATABASE_CLIENT (#1038). + * + * Runs BEFORE the migration step on purpose: the decision has to be made while + * the Postgres target is still untouched, so an install that has been + * unknowingly running on SQLite keeps serving from its SQLite file instead of + * coming up against an empty database. + * + * stdout is the client and nothing else — the caller captures it. Everything + * human-readable goes to stderr so it lands in the container log. + */ + +const knexConfig = require('../knexfile'); + +// Must cover every level resolveBootEngine uses. An incomplete shim threw +// inside the conflict path, was swallowed by the catch below, and fell back to +// the configured client — silently choosing the engine this is meant to refuse +// to choose. +const logger = { + info: (m) => process.stderr.write(`${m}\n`), + warn: (m) => process.stderr.write(`${m}\n`), + error: (m) => process.stderr.write(`${m}\n`), + debug: () => {}, +}; + +// Distinct exit code for "two populated databases, no record of which is +// current" (#1038). Callers must stop rather than pick one. +const CONFLICT_EXIT = 3; + +(async () => { + let client = knexConfig.client; + try { + const { resolveBootEngine } = require('../src/utils/databaseEngine'); + const decision = await resolveBootEngine({ knexConfig, logger }); + if (decision.reason === 'ambiguous-both-populated' + || decision.reason === 'marker-target-mismatch') { + process.exit(CONFLICT_EXIT); + } + ({ client } = decision); + } catch (err) { + // Never let engine detection stop a boot: fall back to whatever knexfile + // resolved, which is exactly the behaviour before this script existed. + logger.warn(`Database engine detection failed (${err.message}); using ${client}`); + } + process.stdout.write(String(client || '')); + process.exit(0); +})(); diff --git a/backend/scripts/set-admin-password.js b/backend/scripts/set-admin-password.js index 107a41b6..b750fff9 100644 --- a/backend/scripts/set-admin-password.js +++ b/backend/scripts/set-admin-password.js @@ -14,17 +14,14 @@ const bcrypt = require('bcrypt'); const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '../.env') }); -const knex = require('knex'); -const db = knex({ - client: process.env.DB_CLIENT || 'pg', - connection: { - host: process.env.DB_HOST || 'localhost', - port: process.env.DB_PORT || 5432, - user: process.env.DB_USER || 'picpeak', - password: process.env.DB_PASSWORD || 'picpeak', - database: process.env.DB_NAME || 'picpeak_dev' - } -}); +// Use the application's own connection, like every sibling script here +// (reset-admin-password, create-admin, show-admin-credentials, reset-admin-mfa). +// This file used to hand-roll its own knex config, which meant: it read +// DB_CLIENT — a variable nothing else in the codebase sets — and so defaulted +// to Postgres on SQLite installs; and it defaulted to database `picpeak_dev`, +// a name no other component uses. Setting a password could therefore silently +// target a different database than the one the application serves (#1038). +const { db } = require('../src/database/db'); /** * Validate password strength @@ -111,8 +108,10 @@ async function setAdminPassword() { .where('username', 'admin') .update({ password_hash: hashedPassword, - password_changed_at: new Date(), - updated_at: new Date() + // ISO strings, not Date objects — they round-trip on both engines, and + // this script now runs on SQLite installs too. + password_changed_at: new Date().toISOString(), + updated_at: new Date().toISOString() }); if (updated === 0) { diff --git a/backend/server.js b/backend/server.js index f56c4cfc..5621d683 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,11 +4,61 @@ require('dotenv').config(); const { validateEnvironment } = require('./src/config/validateEnv'); validateEnvironment(); +// Resolve which database engine this process should use, BEFORE anything +// requires knexfile/db (#1038). wait-for-db.sh normally does this and exports +// DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a +// plain `docker run … node server.js`, bypasses the entrypoint entirely — and +// those are exactly the deployments this fix is for. Without this, such an +// install would resolve to Postgres (NODE_ENV is baked into the image now) and +// come up against an empty database while its SQLite data sat there unseen. +// +// spawnSync because the decision needs an async Postgres probe and this must +// happen before the first `require` of knexfile. It short-circuits without +// probing when DATABASE_CLIENT is already set, so the entrypoint path pays +// nothing. +// Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg +// would otherwise skip the check and start against a half-migrated Postgres +// while SQLite is still the database of record. +if (!process.env.DATABASE_CLIENT + || require('./src/utils/databaseEngine').hasMigrationInProgress()) { + const { spawnSync } = require('child_process'); + const probe = spawnSync( + process.execPath, + [require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] } + ); + // Exit 3: two populated databases and no record of which is authoritative. + // The resolver has printed the comparison and the two ways to resolve it; + // starting either engine would hide the other's data. + if (probe.status === 3) { + process.exit(1); + } + const resolved = (probe.stdout || '').trim(); + if (probe.status === 0 && resolved) { + process.env.DATABASE_CLIENT = resolved; + // Pin the CONNECTION too, not just the client. knexfile's development block + // defaults Postgres to localhost/postgres/photo_sharing and production to + // db/picpeak/picpeak, so naming only the client can point this process at a + // different database than the resolver probed — with SQLite already retired. + if (resolved === 'pg') { + const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv(); + process.env.DB_HOST = String(conn.host); + process.env.DB_PORT = String(conn.port); + process.env.DB_USER = String(conn.user); + process.env.DB_NAME = String(conn.database); + } + } +} + // Initialize logger early to capture startup logs const logger = require('./src/utils/logger'); logger.info('Server starting up', { nodeVersion: process.version, environment: process.env.NODE_ENV || 'development', + // Which database this process actually talks to (#1038). Nothing logged this + // before, so an install silently running on SQLite with Postgres configured + // had no way to notice. + database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')), timestamp: new Date().toISOString() }); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index 029df297..f4e8c8b0 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -7,6 +7,7 @@ const path = require('path'); const os = require('os'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); +const { resolveSqlitePath } = require('../utils/databaseEngine'); const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService'); const { getAppSetting, upsertAppSetting } = require('../utils/appSettings'); const { parseWhatsNew } = require('../utils/whatsNew'); @@ -218,26 +219,25 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view' // Get comprehensive system status router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => { try { - // Database size - check if PostgreSQL or SQLite + // Database size. Read the LIVE connection rather than re-deriving any of + // this from the environment (#1038): DATABASE_CLIENT is not the only thing + // that decides the engine, DB_NAME is not the only thing that decides the + // database, and DATABASE_PATH was ignored outright here — so a SQLite + // install with a custom path, or a Postgres install without an explicit + // DATABASE_CLIENT, reported the size of something it was not using. let dbSize = 0; - const dbClient = process.env.DATABASE_CLIENT || 'sqlite3'; - - if (dbClient === 'pg') { - // PostgreSQL - query database size + const liveConnection = db.client.config.connection || {}; + + if (db.client.config.client === 'pg') { try { - const dbName = process.env.DB_NAME || 'picpeak'; - const result = await db.raw(` - SELECT pg_database_size(?) as size - `, [dbName]); + const result = await db.raw('SELECT pg_database_size(current_database()) as size'); dbSize = result.rows[0]?.size || 0; } catch (error) { logger.error('Error getting PostgreSQL database size:', error); } } else { - // SQLite - check file size - const dbPath = path.join(__dirname, '../../data/photo_sharing.db'); try { - const stats = await fs.stat(dbPath); + const stats = await fs.stat(liveConnection.filename || resolveSqlitePath()); dbSize = stats.size; } catch (error) { logger.error('Error getting SQLite database size:', error); diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js index 561f6f54..43c6aab0 100644 --- a/backend/src/services/picpeakExportService.js +++ b/backend/src/services/picpeakExportService.js @@ -135,7 +135,7 @@ async function collectFiles(includePhotos) { * @param {string} [opts.outDir] where to write the file (defaults to a temp dir) * @returns {Promise<{ filePath: string, manifest: object }>} */ -async function createPicpeak({ includePhotos = false, outDir } = {}) { +async function createPicpeak({ includePhotos = false, includeFiles = true, outDir } = {}) { const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-')); const dataDir = path.join(staging, 'data'); await fsp.mkdir(dataDir, { recursive: true }); @@ -150,7 +150,11 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { // 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and // optionally original photos). - const files = await collectFiles(includePhotos); + // includeFiles:false is for the SQLite → Postgres migration (#1038): it moves + // rows between engines on the SAME install, so the storage volume is already + // correct. Copying every business doc through /tmp and back would only risk + // filling the temp disk. + const files = includeFiles ? await collectFiles(includePhotos) : []; // 3. Manifest — everything the importer needs to validate + reconstruct. const manifest = { @@ -162,7 +166,7 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { engine: isPostgres() ? 'pg' : 'sqlite', latest_migration: await getLatestMigration(), }, - options: { includePhotos: !!includePhotos }, + options: { includePhotos: !!includePhotos, includeFiles: !!includeFiles }, tables: tableMeta, file_count: files.length, // NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index 27ac33a0..f9fb13da 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -44,7 +44,7 @@ async function readManifestFromZip(picpeakPath) { } // Returns an array of human-readable blockers ([] = OK to restore). -async function validateManifest(manifest) { +async function validateManifest(manifest, { allowEngineSwitch = false } = {}) { const errors = []; if (!manifest || manifest.kind !== 'picpeak-backup') { return ['This file is not a PicPeak backup (.picpeak).']; @@ -53,7 +53,13 @@ async function validateManifest(manifest) { errors.push('This backup was created by a newer version of PicPeak. Update this instance first.'); } const engine = isPostgres() ? 'pg' : 'sqlite'; - if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) { + // Cross-engine loads are opt-in and CLI-only (#1038). The archive format is + // engine-neutral NDJSON, but this path had never been exercised, so the + // upload/restore surface keeps refusing it — only + // scripts/migrate-sqlite-to-postgres.js, which exists to move an install + // between engines, passes allowEngineSwitch. + if (!allowEngineSwitch + && manifest.database && manifest.database.engine && manifest.database.engine !== engine) { errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`); } // Forward-only: the target schema must be at least as new as the backup's. @@ -184,11 +190,82 @@ function serialiseJsonColumns(rows, jsonCols) { }); } +// Cross-engine loads only (#1038): SQLite has no real date or boolean types, so +// its rows carry epoch numbers where Postgres wants a timestamp and 0/1 where +// Postgres wants a boolean. Both are rejected outright by pg +// ("date/time field value out of range: 1786548038763"). Coerce per column, +// driven by the TARGET schema so nothing is guessed from the value alone. +// Same-engine restores never call this and are byte-for-byte unchanged. +async function typedColumnsFor(trx, table) { + const info = await trx(table).columnInfo(); + const timestamps = []; + const booleans = []; + for (const [name, meta] of Object.entries(info)) { + const type = String(meta.type || '').toLowerCase(); + if (type.includes('timestamp') || type === 'date' || type === 'datetime') timestamps.push(name); + else if (type === 'boolean' || type === 'bool') booleans.push(name); + } + return { timestamps, booleans }; +} + +// SQLite writes Date objects as epoch MILLISECONDS in production, but some rows +// (and older installs) carry epoch seconds. 1e11 sits far past any plausible +// seconds value and far below any plausible ms value, so it separates them +// cleanly for every date this application will ever see. +function epochToIso(value) { + const n = Number(value); + if (!Number.isFinite(n)) return value; + const ms = Math.abs(n) < 1e11 ? n * 1000 : n; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? value : d.toISOString(); +} + +function coerceForTargetEngine(rows, { timestamps, booleans }) { + if (!timestamps.length && !booleans.length) return rows; + return rows.map((row) => { + const out = { ...row }; + for (const col of timestamps) { + const v = out[col]; + if (v === null || v === undefined || v === '') continue; + if (typeof v === 'number' || (typeof v === 'string' && /^-?\d+$/.test(v))) { + out[col] = epochToIso(v); + } + } + for (const col of booleans) { + const v = out[col]; + if (v === null || v === undefined) continue; + if (typeof v === 'number') out[col] = v !== 0; + else if (typeof v === 'string') out[col] = !['0', 'false', ''].includes(v.toLowerCase()); + } + return out; + }); +} + // Whole-DB replace in one transaction with FK enforcement suspended (pg: // session_replication_role=replica on the trx connection, reset before commit; // sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never // in the data set, so the target's schema/migration state is left intact. -async function replaceAllTables(tables, dataDir, currentAdmin) { +// Advance Postgres identity sequences past the ids just inserted. Needed after +// any explicit-id load; here it backs the SQLite → Postgres migration (#1038). +async function resyncSequences(tables) { + if (!isPostgres()) return; + for (const table of tables) { + try { + if (!(await db.schema.hasColumn(table, 'id'))) continue; + const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']); + const seq = res && res.rows && res.rows[0] && res.rows[0].seq; + if (!seq) continue; // `id` isn't a serial/identity column + await db.raw( + 'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))', + [seq, table, table] + ); + } catch (err) { + logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`); + } + } +} + +async function replaceAllTables(tables, dataDir, currentAdmin, { crossEngine = false } = {}) { await db.transaction(async (trx) => { if (isPostgres()) { try { @@ -215,7 +292,18 @@ async function replaceAllTables(tables, dataDir, currentAdmin) { const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`)); if (!rows.length) continue; const jsonCols = await jsonColumnsFor(trx, table); - await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100); + let prepared = rows; + let toSerialise = jsonCols; + if (crossEngine) { + prepared = coerceForTargetEngine(prepared, await typedColumnsFor(trx, table)); + // A sqlite-sourced archive already carries JSON columns as valid JSON + // TEXT, which is exactly what pg wants. Serialising again would store + // `{"a":1}` as the scalar string "{\"a\":1}" and would turn the JSON + // literal `null` into SQL NULL. + toSerialise = new Set(); + } + prepared = serialiseJsonColumns(prepared, toSerialise); + await trx.batchInsert(table, prepared, 100); } await reinjectCurrentAdmin(trx, currentAdmin); @@ -275,9 +363,9 @@ async function detectExternalMedia() { * @param {number} [opts.currentAdminId] admin to preserve across the wipe * @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>} */ -async function importFromPicpeak({ picpeakPath, currentAdminId }) { +async function importFromPicpeak({ picpeakPath, currentAdminId, allowEngineSwitch = false }) { const manifest = await readManifestFromZip(picpeakPath); - const blockers = await validateManifest(manifest); + const blockers = await validateManifest(manifest, { allowEngineSwitch }); if (blockers.length) { const err = new Error(blockers[0]); err.statusCode = 400; @@ -316,7 +404,14 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) { logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`); } - await replaceAllTables(tables, dataDir, currentAdmin); + await replaceAllTables(tables, dataDir, currentAdmin, { crossEngine: allowEngineSwitch }); + + // Cross-engine only (#1038): rows are inserted with explicit ids, which + // leaves Postgres identity sequences at 1 and makes the next natural insert + // collide on the primary key. Same-engine restores keep today's behaviour + // untouched — this branch exists for scripts/migrate-sqlite-to-postgres.js. + if (allowEngineSwitch) await resyncSequences(tables); + const filesRestored = await restoreFiles(staging); const usesExternalMedia = await detectExternalMedia(); @@ -333,5 +428,8 @@ module.exports = { importFromPicpeak, readManifestFromZip, validateManifest, + // exported for testing — the cross-engine coercion (#1038) + epochToIso, + coerceForTargetEngine, reinjectCurrentAdmin, }; diff --git a/backend/src/utils/databaseEngine.js b/backend/src/utils/databaseEngine.js new file mode 100644 index 00000000..22133b58 --- /dev/null +++ b/backend/src/utils/databaseEngine.js @@ -0,0 +1,449 @@ +'use strict'; + +/** + * Which database engine is this process actually using, and is that what the + * operator intended? (#1038) + * + * knexfile.js selects its config block by NODE_ENV, and the `development` + * block defaults to sqlite3. The Docker image never set NODE_ENV, so every + * deployment that doesn't go through our compose files — Kubernetes, Helm, + * plain `docker run` — silently landed on SQLite and ignored DB_HOST / + * DB_USER / DB_PASSWORD entirely. wait-for-db.sh is shell and reads DB_HOST + * directly, so the same container happily reported "PostgreSQL is up" while + * the app wrote to a SQLite file. + * + * Now that the image pins NODE_ENV=production, those installs would resolve to + * Postgres on their next pull — and come up against an EMPTY database, which + * reads as total data loss. Blocking the boot would protect the data but take + * the galleries offline for an operator who did nothing wrong, so instead we + * STAY on SQLite (the engine that holds their data), say so loudly, and point + * at the migration script. Nothing moves until the operator decides. + * + * decideBootEngine() is pure so the matrix is testable; the probes around it + * are deliberately thin. + */ + +const fs = require('fs'); +const { resolveSqliteFilename } = require('./sqlitePath'); +// Shared with knexfile so the engine guard can never probe a different target +// than the application opens (#1038). +const { pgConnectionFromEnv } = require('./pgConnection'); + +// Diagnostics go through an injected sink, never a module-level logger: the +// resolver's STDOUT is a protocol channel (wait-for-db.sh captures it), and the +// app logger writes there whenever LOG_TO_CONSOLE=true. +const warnToStderr = (msg) => process.stderr.write(`${msg}\n`); + +/** Absolute path of the SQLite file this install would use — the SAME + * resolution knexfile performs, so the guard can never probe a different file + * than the one knex opens. */ +function resolveSqlitePath() { + return resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db'); +} + +/** Human-readable "engine + target", safe to log — never includes credentials. */ +function describeEngine(knexConfig) { + const client = knexConfig?.client || 'unknown'; + if (client === 'pg') { + const c = knexConfig.connection || {}; + return `postgres (${c.host || 'unknown-host'}:${c.port || 5432}/${c.database || 'unknown-db'})`; + } + const filename = knexConfig?.connection?.filename || resolveSqlitePath(); + return `sqlite (${filename})`; +} + +/** + * Which engine should this boot actually use? + * + * @param {object} state + * @param {string} state.configuredClient what knexfile resolved to + * @param {string=} state.explicitClient DATABASE_CLIENT, if the operator set it + * @param {boolean} state.pgHasData the Postgres target already holds galleries + * @param {boolean} state.sqliteHasData a SQLite file exists AND holds events + * @returns {{ client: string, overridden: boolean, reason: string|null }} + */ +function decideBootEngine({ + configuredClient, explicitClient, pgHasData, sqliteHasData, + migrationInProgress = false, migrationCompleted = false, pgConfigured = false, +}) { + // A migration that never finished outranks everything, including an explicit + // DATABASE_CLIENT=pg: Postgres may hold a half-written copy while SQLite is + // still the database of record. Deleting the marker is the documented + // override. (Explicit sqlite3 already points at the data, so leave it alone.) + if (migrationInProgress && sqliteHasData && explicitClient !== 'sqlite3') { + return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' }; + } + + // The data was migrated to Postgres, but nothing in the environment says so: + // DATABASE_CLIENT is unset and NODE_ENV still resolves to the development + // block, i.e. sqlite3. That is the state the affected installs are IN — it is + // why they ended up on SQLite in the first place — so an operator can easily + // migrate before fixing it. The source file has been renamed away by then, so + // honouring the implicit sqlite3 would create a NEW, empty database and serve + // it. The marker is durable proof of where the data actually is. + if (!explicitClient && configuredClient !== 'pg' && migrationCompleted && pgConfigured) { + return { client: 'pg', overridden: true, reason: 'migrated-to-postgres' }; + } + + // An explicit DATABASE_CLIENT is an instruction, not a guess. Never override + // it — this is also the documented way to force Postgres and start fresh. + if (explicitClient) { + return { + client: explicitClient, + overridden: false, + reason: explicitClient === 'pg' && sqliteHasData && !pgHasData + ? 'explicit-pg-leaves-sqlite-behind' + : null, + }; + } + + // A migration started and never finished. Postgres may hold a partial copy, + // which would otherwise read as "occupied" and win — while SQLite is still + // the database of record. + if (configuredClient === 'pg' && migrationInProgress && sqliteHasData) { + return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' }; + } + + // Both sides hold data and nothing records which is authoritative. This is + // the shape of an install that ran on Postgres, silently fell to SQLite when + // NODE_ENV was lost, and kept working there: the Postgres rows are real but + // stale, and the SQLite rows are real and newer. A completed migration would + // have left a marker; without one, guessing either way hides data and splits + // subsequent writes across two databases. Stop and let a human decide. + if (configuredClient === 'pg' && !migrationCompleted && pgHasData && sqliteHasData) { + return { client: null, overridden: false, reason: 'ambiguous-both-populated' }; + } + + // Configured for Postgres, Postgres holds no galleries, and real data sits in + // a SQLite file: this install has been unknowingly running on SQLite. Keep + // serving from where the data actually is. Deliberately keyed on DATA, not on + // "has tables" — a stray migration run against the empty Postgres would + // otherwise blind this check and strand the operator on an empty database. + if (configuredClient === 'pg' && !pgHasData && sqliteHasData) { + return { client: 'sqlite3', overridden: true, reason: 'stranded-sqlite-data' }; + } + + return { client: configuredClient, overridden: false, reason: null }; +} + +/** Marker written by scripts/migrate-sqlite-to-postgres.js once the data is in + * Postgres. Its presence pins the install to Postgres for good: without it, a + * Postgres that is merely EMPTY (every gallery deleted, say) would look + * identical to one that was never migrated, and the boot would fall back to a + * stale SQLite file that has been out of date since the migration. */ +function migrationMarkerPath(sqlitePath = resolveSqlitePath()) { + return `${sqlitePath}.migrated-to-postgres`; +} + +function hasMigrationMarker(sqlitePath = resolveSqlitePath()) { + return fs.existsSync(migrationMarkerPath(sqlitePath)); +} + +/** The marker's contents, or null when absent/unreadable. */ +function readMigrationMarker(sqlitePath = resolveSqlitePath()) { + try { + return JSON.parse(fs.readFileSync(migrationMarkerPath(sqlitePath), 'utf8')); + } catch (_) { + return null; + } +} + +/** `host:port/database`, the identity the migration records and compares. */ +function currentPgTargetId() { + const c = pgConnectionFromEnv(); + return `${c.host}:${c.port}/${c.database}`; +} + +/** Written before the migration touches Postgres, cleared only on success. + * While it exists, Postgres may hold a PARTIAL copy — or just the bootstrap + * admin that schema creation seeds — and SQLite is still the authoritative + * database. Without this pin, a migration that failed after writing anything + * to Postgres would make the next boot switch engines and hide the real data. */ +function migrationInProgressPath(sqlitePath = resolveSqlitePath()) { + return `${sqlitePath}.migration-in-progress`; +} + +function hasMigrationInProgress(sqlitePath = resolveSqlitePath()) { + return fs.existsSync(migrationInProgressPath(sqlitePath)); +} + +// Tables that are EMPTY on a freshly migrated schema, so a row in any of them +// means a human has used this install. Deliberately wider than `events`: +// judging occupancy by galleries alone would abandon an install whose galleries +// were all deleted but whose admins, customers and accounting records remain. +// Mirrors USER_DATA_TABLES in scripts/migrate-sqlite-to-postgres.js. +const USER_DATA_TABLES = [ + 'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts', + 'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents', +]; + +// core/001_init.js seeds an admin with must_change_password = true when +// ADMIN_PASSWORD is set; setupService writes false once a human completes +// first-run setup. So the FLAG, not the table, is what distinguishes an +// untouched bootstrap row from a real account. Dropping the whole table (as an +// earlier revision did) made a legitimately set-up Postgres look empty, which +// would hand the install to a stale SQLite file and lose the admin's +// credentials and configuration. +const isUntouchedBootstrapRow = (v) => v === true || v === 1 || v === '1'; + +// Has anyone actually USED this install's admin accounts? Layered, because no +// single column survives every path: +// - more than one admin → somebody created accounts +// - any admin has logged in → real use, even if the password was later reset +// - must_change_password false → first-run setup was completed +// Only the exact shape core/001_init.js leaves behind — one admin, never logged +// in, still flagged — reads as an untouched bootstrap seed. +function adminsIndicateUse(rows) { + if (rows.length > 1) return true; + return rows.some((r) => r.last_login || !isUntouchedBootstrapRow(r.must_change_password)); +} + +async function countsAsUse(conn, table, { ignoreBootstrapAdmins }) { + if (table === 'admin_users' && ignoreBootstrapAdmins) { + const cols = ['must_change_password']; + if (await conn.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login'); + return adminsIndicateUse(await conn('admin_users').select(cols)); + } + const row = await conn(table).count('* as count').first(); + return Number(row?.count || 0) > 0; +} + +async function anyUserData(conn, { ignoreBootstrapAdmins = false } = {}) { + for (const table of USER_DATA_TABLES) { + if (!(await conn.schema.hasTable(table))) continue; + if (await countsAsUse(conn, table, { ignoreBootstrapAdmins })) return true; + } + return false; +} + +/** True when a SQLite file exists and carries user data. */ +async function probeSqliteData(sqlitePath = resolveSqlitePath(), onWarn = warnToStderr) { + if (hasMigrationMarker(sqlitePath)) return false; + if (!fs.existsSync(sqlitePath)) return false; + const knex = require('knex'); + const probe = knex({ + client: 'sqlite3', + connection: { filename: sqlitePath }, + useNullAsDefault: true, + }); + try { + // Same discrimination as the Postgres side. An accidental SQLite database + // gets a seeded admin from core/001_init.js when ADMIN_PASSWORD is set, and + // counting that as use would make a healthy Postgres install look like a + // both-populated conflict and refuse to boot. A setup-completed or + // logged-in admin still counts. + return await anyUserData(probe, { ignoreBootstrapAdmins: true }); + } catch (err) { + // Unreadable or corrupt: fail CLOSED. Reporting "no data" here would switch + // the install to an empty Postgres — the precise failure this module exists + // to prevent. Staying on SQLite surfaces the real error instead. + onWarn( + `[database-engine] SQLite at ${sqlitePath} exists but could not be probed (${err.message}); ` + + 'assuming it holds data and staying on it.' + ); + return true; + } finally { + await probe.destroy(); + } +} + +/** True when the configured Postgres target already holds user data. */ +async function probePgData(pgConnection, onWarn = warnToStderr) { + const knex = require('knex'); + const probe = knex({ client: 'pg', connection: pgConnection, pool: { min: 0, max: 1 } }); + try { + // Two very different failures hide behind one catch, and they need opposite + // answers, so establish reachability first — this branch returns, so + // everything below it is reachable-by-construction. + try { + await probe.raw('SELECT 1'); + } catch (err) { + // Cannot reach Postgres at all. The app could not run on it either way, + // so report "occupied" to avoid diverting a healthy pg install to a stale + // SQLite file over a transient network blip — startup then fails with the + // real connection error, exactly as it always has. + onWarn(`[database-engine] Postgres unreachable while probing (${err.message}); leaving the configured engine alone.`); + return true; + } + + try { + // Substantive use only: an untouched bootstrap admin does not make a + // Postgres target worth switching to, but a completed setup does. + return await anyUserData(probe, { ignoreBootstrapAdmins: true }); + } catch (err) { + // Connected, but the query failed — a half-built or damaged schema. That + // is NOT evidence of data: reporting "occupied" here would boot the empty + // Postgres and hide a populated SQLite file, the exact failure this guard + // exists to prevent. Say "not proven occupied" and let the SQLite side win + // if it actually holds data. + onWarn(`[database-engine] Postgres reachable but could not be inspected (${err.message}); treating it as unproven rather than occupied.`); + return false; + } + } finally { + await probe.destroy(); + } +} + +const CONFLICT_MESSAGE = (sqlitePath, pgTarget) => ` +${'='.repeat(78)} +REFUSING TO START — two databases, both with data, and no record of which is current. + + sqlite : ${sqlitePath} + postgres : ${pgTarget} + +This is what an install looks like after it ran on PostgreSQL, lost NODE_ENV or +DATABASE_CLIENT, and kept working on SQLite without anyone noticing (see +https://github.com/PicPeak/picpeak/issues/1038). The PostgreSQL rows are real +but probably old; the SQLite rows are real and probably newer. + +Starting either one would hide the other's galleries and split every new upload +across two databases, so PicPeak will not choose for you. Compare them, then say +which is authoritative: + + DATABASE_CLIENT=sqlite3 keep serving the SQLite file (its data is newer) + DATABASE_CLIENT=pg keep serving PostgreSQL + +To combine them, start on SQLite and run: node scripts/migrate-sqlite-to-postgres.js +(it replaces the PostgreSQL contents with the SQLite data and records the switch). +${'='.repeat(78)} +`.trim(); + +const STRANDED_WARNING = (sqlitePath, pgTarget) => ` +${'='.repeat(78)} +STILL RUNNING ON SQLITE — Postgres is configured but empty. + + data in use : ${sqlitePath} + configured : ${pgTarget} (no galleries in it) + +This install has been running on SQLite. Until now the image left NODE_ENV +unset, so knexfile.js fell back to its development block and ignored DB_HOST / +DB_USER / DB_PASSWORD — see https://github.com/PicPeak/picpeak/issues/1038. + +Nothing has changed for you: your galleries are served from the SQLite file +above, exactly as before. Switching engines now would start from an empty +database, so PicPeak will not do that on its own. + +To move your data to Postgres when you are ready: + + node scripts/migrate-sqlite-to-postgres.js + +It copies every row into Postgres and leaves the SQLite file untouched as a +fallback. To go to Postgres WITHOUT the data, set DATABASE_CLIENT=pg. +${'='.repeat(78)} +`.trim(); + +/** + * Resolve the engine for this boot, log what happened, and return the client + * the process should use. Called before migrations touch anything. + */ +async function resolveBootEngine({ knexConfig, logger }) { + const explicitClient = process.env.DATABASE_CLIENT || null; + const configuredClient = knexConfig?.client; + const sqlitePath = resolveSqlitePath(); + + // Probe whenever Postgres is the engine in play — including when it was named + // explicitly, otherwise the "leaving SQLite behind" warning is unreachable. + const effectiveClient = explicitClient || configuredClient; + const migrationInProgress = hasMigrationInProgress(sqlitePath); + const marker = readMigrationMarker(sqlitePath); + const migrationCompleted = hasMigrationMarker(sqlitePath); + // The marker vouches for ONE Postgres. If the configuration now points at a + // different one, it says nothing about that target — and trusting it would + // boot an unrelated empty database while the real data sits in the recorded + // one and in the renamed rollback copy. + const markerTargetMismatch = Boolean( + migrationCompleted && marker && marker.target && marker.target !== currentPgTargetId(), + ); + const pgConfigured = Boolean(process.env.DB_HOST || process.env.DB_PASSWORD); + // Probe when Postgres is in play, and also whenever a migration is pinned or + // finished — those decisions need to know what each side holds. + const probing = effectiveClient === 'pg' || migrationInProgress || migrationCompleted; + const decision = decideBootEngine({ + configuredClient, + explicitClient, + pgHasData: probing + ? await probePgData( + knexConfig.client === 'pg' ? knexConfig.connection : pgConnectionFromEnv(), + (m) => logger.warn(m), + ) + : true, + sqliteHasData: probing ? await probeSqliteData(sqlitePath, (m) => logger.warn(m)) : false, + migrationInProgress, + migrationCompleted, + pgConfigured, + }); + + if (markerTargetMismatch) { + logger.error(` +${'='.repeat(78)} +REFUSING TO START — this install was migrated to a different PostgreSQL. + + migrated to : ${marker.target} + configured : ${currentPgTargetId()} + +${migrationMarkerPath(sqlitePath)} records where the data was moved. The current +settings point somewhere else, so starting would open an unrelated database and +present an empty installation while your galleries stay in the one above. + +Either restore the original connection settings, or — if this move is deliberate +and the data is already in the new target — update the "target" field in that +marker file to match. +${'='.repeat(78)} +`.trim()); + return { client: null, overridden: false, reason: 'marker-target-mismatch' }; + } + + if (decision.reason === 'ambiguous-both-populated') { + logger.error(CONFLICT_MESSAGE(sqlitePath, describeEngine({ + client: 'pg', connection: pgConnectionFromEnv(), + }))); + return decision; + } + + if (decision.reason === 'migrated-to-postgres') { + logger.warn( + `This install's data was migrated to PostgreSQL (${migrationMarkerPath(sqlitePath)}), but the ` + + 'environment still resolves to SQLite. Using PostgreSQL — set NODE_ENV=production (or ' + + 'DATABASE_CLIENT=pg) to make that explicit.' + ); + } else if (decision.reason === 'migration-incomplete') { + logger.warn( + `A SQLite → PostgreSQL migration did not finish (${migrationInProgressPath(sqlitePath)} is still ` + + 'present), so PostgreSQL may hold a partial copy. Staying on SQLite, which is still the ' + + 'database of record. Re-run scripts/migrate-sqlite-to-postgres.js with the backend stopped; ' + + 'delete that file only if you have decided to abandon the migration.' + ); + } else if (decision.overridden && decision.reason === 'stranded-sqlite-data') { + logger.warn(STRANDED_WARNING(sqlitePath, describeEngine(knexConfig))); + } else if (decision.reason === 'explicit-pg-leaves-sqlite-behind') { + logger.warn( + 'DATABASE_CLIENT=pg is set explicitly, so PicPeak is starting on an empty Postgres while ' + + `gallery data exists at ${sqlitePath}. Run scripts/migrate-sqlite-to-postgres.js to bring it across.` + ); + } + + // Describe what was DECIDED, not what knexfile said: after a marker override + // knexConfig still describes SQLite while the process goes to Postgres. + logger.info(`Database engine: ${decision.client === 'pg' + ? describeEngine(knexConfig.client === 'pg' ? knexConfig : { client: 'pg', connection: pgConnectionFromEnv() }) + : `sqlite (${sqlitePath})`}`); + return decision; +} + +module.exports = { + resolveSqlitePath, + pgConnectionFromEnv, + isUntouchedBootstrapRow, + adminsIndicateUse, + migrationMarkerPath, + hasMigrationMarker, + readMigrationMarker, + currentPgTargetId, + migrationInProgressPath, + hasMigrationInProgress, + describeEngine, + decideBootEngine, + probeSqliteData, + probePgData, + resolveBootEngine, +}; diff --git a/backend/src/utils/pgConnection.js b/backend/src/utils/pgConnection.js new file mode 100644 index 00000000..2b42cee6 --- /dev/null +++ b/backend/src/utils/pgConnection.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * The PostgreSQL target, resolved in exactly one place (#1038). + * + * Three different defaults for the same connection used to coexist: + * + * knexfile development : localhost / postgres / photo_sharing + * knexfile production : db / picpeak / picpeak + * wait-for-db.sh : postgres / picpeak / picpeak (and it EXPORTS them) + * + * so a process that probed or migrated against one could hand over to a process + * that opened another. Two review rounds in a row traced back to that, each + * time through a caller the previous fix had not covered — the engine guard, + * the migration CLI's child phases, then server.js. + * + * The host and user defaults are the ones a running container actually uses, + * because wait-for-db.sh resolves and exports them before anything starts. + * The database name matters most: a wrong host or user fails loudly at connect + * time, while a wrong database name connects fine and presents an empty + * installation. + * + * Reading process.env on every call is deliberate — the entrypoint and + * server.js both normalise these variables before the app opens a pool. + */ + +function pgConnectionFromEnv() { + return { + host: process.env.DB_HOST || 'postgres', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'picpeak', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'picpeak', + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + }; +} + +module.exports = { pgConnectionFromEnv }; diff --git a/backend/src/utils/sqlitePath.js b/backend/src/utils/sqlitePath.js new file mode 100644 index 00000000..42f10006 --- /dev/null +++ b/backend/src/utils/sqlitePath.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Where this install's SQLite database lives. + * + * Extracted from knexfile.js so the engine guard (#1038) resolves EXACTLY the + * same path knex opens. When the two disagree — a DATABASE_PATH with stray + * whitespace, or the legacy duplicated-backend form this collapses — the guard + * probes a file nobody uses, concludes there is no SQLite data, and lets the + * boot switch to an empty Postgres while the real galleries sit in the file it + * failed to look at. + * + * Behaviour is unchanged from the original; only its home moved. + */ + +const path = require('path'); + +const BACKEND_ROOT = path.resolve(__dirname, '..', '..'); + +function resolveSqliteFilename(filenameEnv, baseDir = BACKEND_ROOT) { + const fallback = path.join(baseDir, './data/photo_sharing.db'); + + if (!filenameEnv) { + return fallback; + } + + const trimmed = String(filenameEnv).trim(); + if (!trimmed) { + return fallback; + } + + let resolved; + if (path.isAbsolute(trimmed)) { + resolved = trimmed; + } else if (trimmed.startsWith('./') || trimmed.startsWith('../')) { + resolved = path.resolve(baseDir, trimmed); + } else { + resolved = path.join(baseDir, trimmed); + } + + const normalized = path.normalize(resolved); + const baseSuffix = path.relative(path.parse(baseDir).root, path.normalize(baseDir)); + const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`; + + if (normalized.includes(duplicatePattern)) { + return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`); + } + + return normalized; +} + +module.exports = { resolveSqliteFilename }; diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index f4653609..581b692f 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -59,6 +59,16 @@ host="${DB_HOST:-postgres}" port="${DB_PORT:-5432}" user="${DB_USER:-picpeak}" target_db="${DB_NAME:-picpeak}" + +# Hand the app EXACTLY the connection this script verified. knexfile's +# production block defaults DB_HOST to `db` while this script defaults to +# `postgres`, so a bare `docker run` with no DB_HOST would have had the +# readiness check pass against one host and the app then dial another (#1038 +# review). Compose sets DB_HOST explicitly and is unaffected. +export DB_HOST="$host" +export DB_PORT="$port" +export DB_USER="$user" +export DB_NAME="$target_db" # Use target database for checks - the picpeak user may not have access to 'postgres' database default_db="${DB_CHECK_DB:-$target_db}" @@ -120,6 +130,36 @@ echo "Ensuring storage directories exist..." STORAGE_BASE="${STORAGE_PATH:-/app/storage}" mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true +# Resolve which database engine this boot should use (#1038) BEFORE migrations +# run, while the Postgres target is still untouched. An install that has been +# unknowingly running on SQLite (the image used to leave NODE_ENV unset, so +# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD) +# keeps serving from its SQLite file instead of coming up against an empty +# Postgres. The exported value survives the `exec` below, so the migration +# runner and the server agree on the engine. +RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)" +RESOLVER_STATUS=$? +# Exit 3 means two populated databases with no record of which is current +# (#1038). Starting either would hide the other's data, so stop here — the +# resolver has already printed what to do. +if [ "$RESOLVER_STATUS" = "3" ]; then + exit 1 +fi +# Validate rather than trust: anything unexpected on stdout (a stray log line +# from a library that writes to the console) must not become DATABASE_CLIENT, +# which would break knexfile for every process that follows. +case "$RESOLVED_DB_CLIENT" in + pg|sqlite3) + export DATABASE_CLIENT="$RESOLVED_DB_CLIENT" + ;; + "") + >&2 echo "Database engine resolver returned nothing; falling back to the configured client." + ;; + *) + >&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client." + ;; +esac + # Run migrations (use safe runner in production). Invoked via node directly — # the runtime image no longer ships npm (see Dockerfile: its bundled deps kept # tripping CVE scanners while npm itself never runs in production).