From b6e6ee0927eb757b86ee426bf8eed84ae633621a Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 26 Mar 2026 21:45:11 +0800 Subject: [PATCH] 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) --- src/db/database.ts | 30 +++ .../003_repository_review_prompts.ts | 4 +- .../repository-review-prompt-repo.ts | 176 ++++++++++++++---- 3 files changed, 167 insertions(+), 43 deletions(-) diff --git a/src/db/database.ts b/src/db/database.ts index 2ad2fb4..1bbbc71 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -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. */ diff --git a/src/db/migrations/003_repository_review_prompts.ts b/src/db/migrations/003_repository_review_prompts.ts index 9949b52..8d4c90d 100644 --- a/src/db/migrations/003_repository_review_prompts.ts +++ b/src/db/migrations/003_repository_review_prompts.ts @@ -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)' ); }, }; diff --git a/src/db/repositories/repository-review-prompt-repo.ts b/src/db/repositories/repository-review-prompt-repo.ts index dcffa46..f2a961c 100644 --- a/src/db/repositories/repository-review-prompt-repo.ts +++ b/src/db/repositories/repository-review-prompt-repo.ts @@ -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(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>; + const loadPromptMap = (): Record => { + 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 + >; - const map: Record = {}; - for (const row of rows) { - const normalized = row.project_prompt.trim(); - if (normalized) { - map[row.full_name] = normalized; + const map: Record = {}; + 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; }, };