Files
WorthPath/apps/api/scripts/database.cjs
T

164 lines
6.3 KiB
JavaScript

require('dotenv').config({ quiet: true });
const { spawnSync } = require('node:child_process');
const { mkdtempSync, copyFileSync, mkdirSync, rmSync } = require('node:fs');
const { join, resolve, dirname, basename } = require('node:path');
const { tmpdir } = require('node:os');
const { createHash } = require('node:crypto');
const mysql = require('mysql2/promise');
// Preserve historical names and SQL. Deploy their prerequisites first on a new database.
const initialMigrations = [
'202610010001_initial',
'202610010002_import_origin',
'202610010003_revision_sequence',
'202610010004_privacy_time_settings',
];
const schema = resolve(__dirname, '../prisma/schema.prisma');
const migrations = resolve(__dirname, '../prisma/migrations');
function bootstrapPlan(tables, history) {
const applied = new Set(
history.filter((r) => r.finished_at && !r.rolled_back_at).map((r) => r.migration_name),
);
const failed = history.filter((r) => !r.finished_at && !r.rolled_back_at);
const businessTables = tables.filter((name) => name.toLowerCase() !== '_prisma_migrations');
const recover =
failed.length === 1 &&
failed[0].migration_name === '005_account_icons' &&
Number(failed[0].applied_steps_count) === 0 &&
/1824/.test(failed[0].logs || '') &&
/Failed to open the referenced table/i.test(failed[0].logs || '') &&
businessTables.length === 0 &&
applied.size === 0;
if (failed.length && !recover)
throw new Error(
'Unresolved migration requires manual recovery; no database reset was performed.',
);
if (initialMigrations.every((name) => applied.has(name)))
return { bootstrap: false, recover: false };
const prefix = initialMigrations.slice(0, applied.size);
if (
[...applied].some((name) => !prefix.includes(name)) ||
(businessTables.length && !applied.size)
)
throw new Error('Unrecognized partial schema/history; no bootstrap or reset was performed.');
return { bootstrap: true, recover };
}
function runPrisma(args) {
const p = spawnSync(process.execPath, [require.resolve('prisma/build/index.js'), ...args], {
encoding: 'utf8',
});
const output = (p.stdout || '')
.split(/\r?\n/)
.filter((s) => !s.startsWith('Datasource ') && !s.includes('Environment variables'))
.join('\n');
if (p.status === 0) {
if (output.trim()) console.log(output);
} else {
const codes = [...new Set(((p.stdout || '') + (p.stderr || '')).match(/P\d{4}/g) || [])];
console.error(
'Prisma command failed' +
(codes.length ? ': ' + codes.join(', ') : '') +
'. No database reset was performed.',
);
}
return p.status === 0 ? 0 : p.status || 1;
}
async function deploy() {
const url = new URL(process.env.DATABASE_URL);
const database = decodeURIComponent(url.pathname.slice(1));
if (url.protocol !== 'mysql:' || !database) throw new Error('Invalid MySQL configuration.');
const connection = await mysql.createConnection({
host: url.hostname,
port: Number(url.port || 3306),
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
charset: 'utf8mb4',
connectTimeout: 10000,
});
const lock =
'worthpath:migrate:' +
createHash('sha256').update(database.toLowerCase()).digest('hex').slice(0, 32);
let locked = false,
folder;
try {
const [lockRows] = await connection.execute('SELECT GET_LOCK(?, 30) AS acquired', [lock]);
if (Number(lockRows[0].acquired) !== 1) throw new Error('Migration lock is busy; retry later.');
locked = true;
const [rows] = await connection.execute(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?',
[database],
);
const tables = rows.map((r) => r.TABLE_NAME);
const quotedDatabase = '`' + database.replace(/`/g, '``') + '`';
const [history] = tables.some((name) => name.toLowerCase() === '_prisma_migrations')
? await connection.query(
'SELECT migration_name, finished_at, rolled_back_at, applied_steps_count, logs FROM ' +
quotedDatabase +
'.`_prisma_migrations`',
)
: [[]];
const plan = bootstrapPlan(tables, history);
if (plan.recover) {
console.log('Verified empty database: recovering the failed 005_account_icons attempt.');
const status = runPrisma([
'migrate',
'resolve',
'--rolled-back',
'005_account_icons',
'--schema',
schema,
]);
if (status) return status;
}
if (plan.bootstrap) {
folder = mkdtempSync(join(tmpdir(), 'worthpath-migrations-'));
copyFileSync(schema, join(folder, 'schema.prisma'));
mkdirSync(join(folder, 'migrations'));
copyFileSync(
join(migrations, 'migration_lock.toml'),
join(folder, 'migrations/migration_lock.toml'),
);
for (const name of initialMigrations) {
mkdirSync(join(folder, 'migrations', name));
copyFileSync(
join(migrations, name, 'migration.sql'),
join(folder, 'migrations', name, 'migration.sql'),
);
}
console.log('Deploying initial prerequisites before historical 005-007 migrations.');
const status = runPrisma(['migrate', 'deploy', '--schema', join(folder, 'schema.prisma')]);
if (status) return status;
}
return runPrisma(['migrate', 'deploy', '--schema', schema]);
} finally {
if (
folder &&
dirname(resolve(folder)) === resolve(tmpdir()) &&
basename(folder).startsWith('worthpath-migrations-')
)
rmSync(folder, { recursive: true, force: true });
if (locked) await connection.execute('SELECT RELEASE_LOCK(?)', [lock]);
await connection.end();
}
}
module.exports = { bootstrapPlan, initialMigrations };
if (require.main === module) {
const command = process.argv[2];
if (command === 'status') process.exitCode = runPrisma(['migrate', 'status', '--schema', schema]);
else if (command === 'deploy')
deploy()
.then((code) => {
process.exitCode = code;
})
.catch((error) => {
const safe = /^(Unresolved migration|Unrecognized partial|Migration lock)/.test(
error.message,
)
? error.message
: 'Database migration command failed. Check configuration and database access. No reset was performed.';
console.error(safe);
process.exitCode = 1;
});
else process.exitCode = 1;
}