8809564aad
Reshaped onto main after #1039 landed the coercion engine (typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now only the policy delta on top of it: - validateManifest: replace the CLI-only allowEngineSwitch opt-in with a direction rule — sqlite → pg allowed (upload UI and CLI alike), pg → sqlite refused with a message naming the supported direction - importFromPicpeak: derive crossEngine from the manifest's engine (absent field = target engine, the exact pre-change behavior), log it, return it; route passes it through - scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop the flag - restore card: direction stated in the intro, cross-engine notice after a converting restore; both strings in en.json + de.json; removed the orphaned settings.backup.picpeak locale node (unreferenced, stale copy) - picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds, numeric strings), coerceForTargetEngine units, plus PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
538 lines
24 KiB
JavaScript
538 lines
24 KiB
JavaScript
#!/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.
|
|
// sqlite → pg is allowed by validateManifest's direction policy (#1041) —
|
|
// the same gate the upload/restore UI uses, no separate opt-in flag.
|
|
const summary = await importFromPicpeak({ picpeakPath: archivePath });
|
|
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);
|
|
});
|