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,
];
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
// ---------------------------------------------------------------------------
// Database singleton
// ---------------------------------------------------------------------------
@@ -72,11 +74,39 @@ export function initDatabase(): Database {
// Run migrations
runMigrations(db);
ensureRepositoryReviewPromptsSchema(db);
console.log(`📦 Database initialized at ${dbPath}`);
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.
*/

View File

@@ -7,7 +7,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
up(db: Database): void {
db.exec(`
CREATE TABLE repository_review_prompts (
CREATE TABLE IF NOT EXISTS repository_review_prompts (
full_name TEXT PRIMARY KEY,
project_prompt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -15,7 +15,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
`);
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 {
full_name: string;
@@ -10,16 +12,43 @@ function toFullName(owner: string, repo: string): string {
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 = {
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
const db = getDatabase();
return (
(db
.query(
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
)
.get(fullName) as RepositoryReviewPromptRow | null) || null
);
return withPromptTableHeal('getByFullName', () => {
const db = getDatabase();
return (
(db
.query(
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
)
.get(fullName) as RepositoryReviewPromptRow | null) || null
);
});
},
getProjectPrompt(owner: string, repo: string): string | undefined {
@@ -30,25 +59,27 @@ export const repositoryReviewPromptRepo = {
},
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
const db = getDatabase();
const normalized = projectPrompt.trim();
if (!normalized) {
throw new Error('projectPrompt must be non-empty');
}
db.query(
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(full_name) DO UPDATE SET
project_prompt = excluded.project_prompt,
updated_at = datetime('now')`
).run(fullName, normalized);
return withPromptTableHeal('upsertByFullName', () => {
const db = getDatabase();
db.query(
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(full_name) DO UPDATE SET
project_prompt = excluded.project_prompt,
updated_at = datetime('now')`
).run(fullName, normalized);
const row = this.getByFullName(fullName);
if (!row) {
throw new Error('Failed to load repository review prompt after upsert');
}
return row;
const row = this.getByFullName(fullName);
if (!row) {
throw new Error('Failed to load repository review prompt after upsert');
}
return row;
});
},
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
@@ -56,11 +87,13 @@ export const repositoryReviewPromptRepo = {
},
deleteByFullName(fullName: string): boolean {
const db = getDatabase();
const result = db
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
.run(fullName);
return result.changes > 0;
return withPromptTableHeal('deleteByFullName', () => {
const db = getDatabase();
const result = db
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
.run(fullName);
return result.changes > 0;
});
},
clearProjectPrompt(owner: string, repo: string): boolean {
@@ -73,22 +106,83 @@ export const repositoryReviewPromptRepo = {
}
const db = getDatabase();
const placeholders = fullNames.map(() => '?').join(', ');
const rows = db
.query(
`SELECT full_name, project_prompt
FROM repository_review_prompts
WHERE full_name IN (${placeholders})`
)
.all(...fullNames) as Array<Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>>;
const loadPromptMap = (): Record<string, string> => {
const placeholders = fullNames.map(() => '?').join(', ');
const rows = db
.query(
`SELECT full_name, project_prompt
FROM repository_review_prompts
WHERE full_name IN (${placeholders})`
)
.all(...fullNames) as Array<
Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>
>;
const map: Record<string, string> = {};
for (const row of rows) {
const normalized = row.project_prompt.trim();
if (normalized) {
map[row.full_name] = normalized;
const map: Record<string, string> = {};
for (const row of rows) {
const normalized = row.project_prompt.trim();
if (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;
},
};