fix: recover empty database migrations and persist Docker icons
This commit is contained in:
1 parent
2db0c75498
commit
35bd2e1828
20 files changed
+577
-62
No files matched your search
+161
-17
@@ -1,19 +1,163 @@
|
||||
require('dotenv').config({ quiet: true });
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const command = process.argv[2];
|
||||
if (!['deploy', 'status'].includes(command)) process.exit(1);
|
||||
const p = spawnSync(
|
||||
process.execPath,
|
||||
[require.resolve('prisma/build/index.js'), 'migrate', command],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
// Prisma datasource lines can expose local connection metadata; omit them.
|
||||
const output = (p.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((s) => !s.startsWith('Datasource ') && !s.includes('Environment variables'))
|
||||
.join('\n');
|
||||
if (p.status === 0) console.log(output);
|
||||
else
|
||||
console.error(
|
||||
'Database migration command failed. Check local database access and migration compatibility. No reset was performed.',
|
||||
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),
|
||||
);
|
||||
process.exitCode = p.status || 0;
|
||||
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;
|
||||
}
|
||||
@@ -4,8 +4,9 @@ const mysql = require('mysql2/promise');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { mkdtempSync, rmSync } = require('node:fs');
|
||||
const { join, resolve, dirname, basename } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const pkg = require('../package.json');
|
||||
async function main() {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
@@ -17,9 +18,11 @@ async function main() {
|
||||
password: decodeURIComponent(url.password),
|
||||
});
|
||||
let api;
|
||||
let iconDirectory;
|
||||
try {
|
||||
await connection.query('CREATE DATABASE `' + name + '`');
|
||||
url.pathname = '/' + name;
|
||||
iconDirectory = mkdtempSync(join(tmpdir(), 'wp_test_icons_'));
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on('error', reject);
|
||||
@@ -31,6 +34,7 @@ async function main() {
|
||||
const env = {
|
||||
...process.env,
|
||||
DATABASE_URL: url.toString(),
|
||||
ICON_STORAGE_DIR: iconDirectory,
|
||||
ADMIN_USERNAME: 'admin',
|
||||
ADMIN_PASSWORD: 'admin',
|
||||
PORT: String(port),
|
||||
@@ -44,14 +48,7 @@ async function main() {
|
||||
TEST_API_URL: 'http://127.0.0.1:' + port + '/api',
|
||||
};
|
||||
const run = (args) => spawnSync(process.execPath, args, { env, encoding: 'utf8' });
|
||||
// Historical 005-007 directories sort before the initial migration. Use the
|
||||
// current schema only in this disposable database, then exercise our SQL.
|
||||
const migration = run([
|
||||
require.resolve('prisma/build/index.js'),
|
||||
'db',
|
||||
'push',
|
||||
'--skip-generate',
|
||||
]);
|
||||
const migration = run([require.resolve('./database.cjs'), 'deploy']);
|
||||
if (migration.status !== 0) {
|
||||
const safe = (migration.stdout + migration.stderr)
|
||||
.split(/\r?\n/)
|
||||
@@ -60,31 +57,6 @@ async function main() {
|
||||
console.error(safe);
|
||||
throw Error('Disposable database migration failed');
|
||||
}
|
||||
await connection.query('USE `' + name + '`');
|
||||
// db push omits the historical column comments asserted by integration tests.
|
||||
await connection.query(
|
||||
"ALTER TABLE `User` MODIFY COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序'",
|
||||
);
|
||||
await connection.query(
|
||||
'ALTER TABLE `User` DROP COLUMN `role`, DROP COLUMN `banned`, DROP COLUMN `mustChangePassword`',
|
||||
);
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(__dirname, '../prisma/migrations/20261005130000_admin_accounts/migration.sql'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// Verify the icon column removal migration against its former shape too.
|
||||
await connection.query('ALTER TABLE `Icon` ADD COLUMN `source` VARCHAR(500) NULL');
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../prisma/migrations/20261005150000_remove_icon_source/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const build = run([require.resolve('typescript/bin/tsc')]);
|
||||
if (build.status !== 0) {
|
||||
console.log(build.stdout);
|
||||
@@ -110,6 +82,7 @@ async function main() {
|
||||
'test/admin-integration.test.ts',
|
||||
'test/mcp.test.ts',
|
||||
'test/oauth-duration.test.ts',
|
||||
'test/database-migrations.test.ts',
|
||||
];
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
@@ -125,6 +98,12 @@ async function main() {
|
||||
}
|
||||
await connection.query('DROP DATABASE `' + name + '`');
|
||||
await connection.end();
|
||||
if (
|
||||
iconDirectory &&
|
||||
dirname(resolve(iconDirectory)) === resolve(tmpdir()) &&
|
||||
basename(iconDirectory).startsWith('wp_test_icons_')
|
||||
)
|
||||
rmSync(iconDirectory, { recursive: true, force: true });
|
||||
console.log('Disposable test database and API cleaned up.');
|
||||
}
|
||||
}
|
||||
@@ -133,7 +112,7 @@ main().catch((e) => {
|
||||
'Isolated test run failed: ' +
|
||||
(/Disposable|API build/.test(e.message)
|
||||
? e.message
|
||||
: 'check test configuration or database access'),
|
||||
: 'check test configuration or database access (' + (e.code || e.name) + ')'),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user