fix(db): self-heal missing repository prompt schema

Recover inconsistent SQLite states where migration v3 is marked applied but repository_review_prompts objects are absent, preventing admin repository listing failures in docker deployments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
jeffusion
2026-03-26 21:45:11 +08:00
committed by 路遥知码力
parent 22b603258a
commit b6e6ee0927
3 changed files with 167 additions and 43 deletions

View File

@@ -33,6 +33,8 @@ const MIGRATIONS: Migration[] = [
migration003RepositoryReviewPrompts, migration003RepositoryReviewPrompts,
]; ];
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Database singleton // Database singleton
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -72,11 +74,39 @@ export function initDatabase(): Database {
// Run migrations // Run migrations
runMigrations(db); runMigrations(db);
ensureRepositoryReviewPromptsSchema(db);
console.log(`📦 Database initialized at ${dbPath}`); console.log(`📦 Database initialized at ${dbPath}`);
return db; return db;
} }
function doesTableExist(database: Database, tableName: string): boolean {
const row = database
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { name?: string } | null;
return row?.name === tableName;
}
export function ensureRepositoryReviewPromptsSchema(database: Database = getDatabase()): void {
if (doesTableExist(database, REPOSITORY_REVIEW_PROMPTS_TABLE)) {
return;
}
console.warn(
`⚠️ Detected inconsistent DB state: table '${REPOSITORY_REVIEW_PROMPTS_TABLE}' is missing. Rebuilding schema.`
);
database.transaction(() => {
migration003RepositoryReviewPrompts.up(database);
if (doesTableExist(database, '_migrations')) {
database
.query('INSERT OR IGNORE INTO _migrations (version, name) VALUES (?, ?)')
.run(migration003RepositoryReviewPrompts.version, migration003RepositoryReviewPrompts.name);
}
})();
}
/** /**
* Get the database instance. Throws if not initialized. * Get the database instance. Throws if not initialized.
*/ */

View File

@@ -7,7 +7,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
up(db: Database): void { up(db: Database): void {
db.exec(` db.exec(`
CREATE TABLE repository_review_prompts ( CREATE TABLE IF NOT EXISTS repository_review_prompts (
full_name TEXT PRIMARY KEY, full_name TEXT PRIMARY KEY,
project_prompt TEXT NOT NULL, project_prompt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -15,7 +15,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
`); `);
db.exec( db.exec(
'CREATE INDEX idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)' 'CREATE INDEX IF NOT EXISTS idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
); );
}, },
}; };

View File

@@ -1,4 +1,6 @@
import { getDatabase } from '../database'; import { toErrorLogMeta } from '../../utils/error-log';
import { logger } from '../../utils/logger';
import { ensureRepositoryReviewPromptsSchema, getDatabase } from '../database';
export interface RepositoryReviewPromptRow { export interface RepositoryReviewPromptRow {
full_name: string; full_name: string;
@@ -10,16 +12,43 @@ function toFullName(owner: string, repo: string): string {
return `${owner}/${repo}`; return `${owner}/${repo}`;
} }
function isMissingPromptTableError(error: unknown): boolean {
return (
error instanceof Error && error.message.includes('no such table: repository_review_prompts')
);
}
function withPromptTableHeal<T>(operation: string, run: () => T): T {
try {
return run();
} catch (error: unknown) {
if (!isMissingPromptTableError(error)) {
throw error;
}
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
operation,
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
error: toErrorLogMeta(error),
});
ensureRepositoryReviewPromptsSchema();
return run();
}
}
export const repositoryReviewPromptRepo = { export const repositoryReviewPromptRepo = {
getByFullName(fullName: string): RepositoryReviewPromptRow | null { getByFullName(fullName: string): RepositoryReviewPromptRow | null {
const db = getDatabase(); return withPromptTableHeal('getByFullName', () => {
return ( const db = getDatabase();
(db return (
.query( (db
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?' .query(
) 'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
.get(fullName) as RepositoryReviewPromptRow | null) || null )
); .get(fullName) as RepositoryReviewPromptRow | null) || null
);
});
}, },
getProjectPrompt(owner: string, repo: string): string | undefined { getProjectPrompt(owner: string, repo: string): string | undefined {
@@ -30,25 +59,27 @@ export const repositoryReviewPromptRepo = {
}, },
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow { upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
const db = getDatabase();
const normalized = projectPrompt.trim(); const normalized = projectPrompt.trim();
if (!normalized) { if (!normalized) {
throw new Error('projectPrompt must be non-empty'); throw new Error('projectPrompt must be non-empty');
} }
db.query( return withPromptTableHeal('upsertByFullName', () => {
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at) const db = getDatabase();
VALUES (?, ?, datetime('now')) db.query(
ON CONFLICT(full_name) DO UPDATE SET `INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
project_prompt = excluded.project_prompt, VALUES (?, ?, datetime('now'))
updated_at = datetime('now')` ON CONFLICT(full_name) DO UPDATE SET
).run(fullName, normalized); project_prompt = excluded.project_prompt,
updated_at = datetime('now')`
).run(fullName, normalized);
const row = this.getByFullName(fullName); const row = this.getByFullName(fullName);
if (!row) { if (!row) {
throw new Error('Failed to load repository review prompt after upsert'); throw new Error('Failed to load repository review prompt after upsert');
} }
return row; return row;
});
}, },
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow { setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
@@ -56,11 +87,13 @@ export const repositoryReviewPromptRepo = {
}, },
deleteByFullName(fullName: string): boolean { deleteByFullName(fullName: string): boolean {
const db = getDatabase(); return withPromptTableHeal('deleteByFullName', () => {
const result = db const db = getDatabase();
.query('DELETE FROM repository_review_prompts WHERE full_name = ?') const result = db
.run(fullName); .query('DELETE FROM repository_review_prompts WHERE full_name = ?')
return result.changes > 0; .run(fullName);
return result.changes > 0;
});
}, },
clearProjectPrompt(owner: string, repo: string): boolean { clearProjectPrompt(owner: string, repo: string): boolean {
@@ -73,22 +106,83 @@ export const repositoryReviewPromptRepo = {
} }
const db = getDatabase(); const db = getDatabase();
const placeholders = fullNames.map(() => '?').join(', '); const loadPromptMap = (): Record<string, string> => {
const rows = db const placeholders = fullNames.map(() => '?').join(', ');
.query( const rows = db
`SELECT full_name, project_prompt .query(
FROM repository_review_prompts `SELECT full_name, project_prompt
WHERE full_name IN (${placeholders})` FROM repository_review_prompts
) WHERE full_name IN (${placeholders})`
.all(...fullNames) as Array<Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>>; )
.all(...fullNames) as Array<
Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>
>;
const map: Record<string, string> = {}; const map: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
const normalized = row.project_prompt.trim(); const normalized = row.project_prompt.trim();
if (normalized) { if (normalized) {
map[row.full_name] = normalized; map[row.full_name] = normalized;
}
} }
return map;
};
try {
return loadPromptMap();
} catch (error: unknown) {
if (isMissingPromptTableError(error)) {
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
});
try {
ensureRepositoryReviewPromptsSchema(db);
return loadPromptMap();
} catch (healError: unknown) {
logger.error('自愈 repository_review_prompts 表后重试失败,降级返回空提示词映射', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
originalError: toErrorLogMeta(error),
healError: toErrorLogMeta(healError),
});
return {};
}
}
let tableExists: boolean | null = null;
let latestMigrationVersion: number | null = null;
try {
const tableRow = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get('repository_review_prompts') as { name?: string } | null;
tableExists = tableRow?.name === 'repository_review_prompts';
const migrationRow = db
.query('SELECT version FROM _migrations ORDER BY version DESC LIMIT 1')
.get() as { version?: number } | null;
latestMigrationVersion = migrationRow?.version ?? null;
} catch (inspectError: unknown) {
logger.warn('查询项目级提示词失败后,诊断数据库状态时发生错误', {
inspectError: toErrorLogMeta(inspectError),
});
}
logger.error('批量查询项目级提示词失败', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
tableExists,
latestMigrationVersion,
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
error: toErrorLogMeta(error),
});
throw error;
} }
return map;
}, },
}; };