fix(doctor): use active engine for PGLite probes (#1183)

This commit is contained in:
duncanclaw
2026-07-16 20:49:31 -07:00
committed by GitHub
parent 79d8c6773e
commit e78f8a1590
2 changed files with 85 additions and 42 deletions
+66 -42
View File
@@ -326,6 +326,70 @@ export async function whoknowsHealthCheck(_engine: BrainEngine): Promise<Check>
}
}
/**
* Doctor check: pgvector availability.
*
* Use the active engine instead of the module-level Postgres singleton.
* PGLite exposes pg_extension through its engine connection, but does not
* connect db.ts's Postgres singleton; using db.getConnection() here turns a
* healthy PGLite brain into a false warning.
*/
export async function pgvectorCheck(engine: BrainEngine): Promise<Check> {
try {
const ext = await engine.executeRaw<{ extname: string }>(
`SELECT extname FROM pg_extension WHERE extname = 'vector'`,
);
if (ext.length > 0) {
return { name: 'pgvector', status: 'ok', message: 'Extension installed' };
}
return { name: 'pgvector', status: 'fail', message: 'Extension not found. Run: CREATE EXTENSION vector;' };
} catch {
return { name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' };
}
}
/**
* Doctor check: JSONB columns are not double-encoded as strings.
*
* This check is valid on both Postgres and PGLite. Route through
* engine.executeRaw() so embedded PGLite brains are checked through their
* actual connection instead of the unrelated Postgres singleton.
*/
export async function jsonbIntegrityCheck(
engine: BrainEngine,
progress?: Pick<ProgressReporter, 'heartbeat'>,
): Promise<Check> {
try {
const targets: Array<{ table: string; col: string; expected: 'object' | 'array' }> = [
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
{ table: 'page_versions', col: 'frontmatter', expected: 'object' },
];
let totalBad = 0;
const breakdown: string[] = [];
for (const { table, col } of targets) {
progress?.heartbeat(`jsonb_integrity.${table}.${col}`);
const rows = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM ${table} WHERE jsonb_typeof(${col}) = 'string'`,
);
const n = Number(rows[0]?.n ?? 0);
if (n > 0) { totalBad += n; breakdown.push(`${table}.${col}=${n}`); }
}
if (totalBad === 0) {
return { name: 'jsonb_integrity', status: 'ok', message: 'All JSONB columns store objects/arrays' };
}
return {
name: 'jsonb_integrity',
status: 'warn',
message: `${totalBad} row(s) double-encoded (${breakdown.join(', ')}). Fix: gbrain repair-jsonb`,
};
} catch {
return { name: 'jsonb_integrity', status: 'warn', message: 'Could not check JSONB integrity' };
}
}
export async function takesWeightGridCheck(engine: BrainEngine): Promise<Check> {
try {
const rows = await engine.executeRaw<{ off_grid: string | number; total: string | number }>(
@@ -5189,17 +5253,7 @@ export async function buildChecks(
// 4. pgvector extension
progress.heartbeat('pgvector');
try {
const sql = db.getConnection();
const ext = await sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length > 0) {
checks.push({ name: 'pgvector', status: 'ok', message: 'Extension installed' });
} else {
checks.push({ name: 'pgvector', status: 'fail', message: 'Extension not found. Run: CREATE EXTENSION vector;' });
}
} catch {
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
}
checks.push(await pgvectorCheck(engine));
// 4b. PgBouncer / prepared-statement compatibility.
// URL-only inspection — no DB roundtrip — so this is cheap and works
@@ -5985,37 +6039,7 @@ export async function buildChecks(
// surface matches `repair-jsonb` (the previous 4-target scan missed a
// repair target, per #254/Codex review).
progress.heartbeat('jsonb_integrity');
try {
const sql = db.getConnection();
const targets: Array<{ table: string; col: string; expected: 'object' | 'array' }> = [
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
{ table: 'page_versions', col: 'frontmatter', expected: 'object' },
];
let totalBad = 0;
const breakdown: string[] = [];
for (const { table, col } of targets) {
progress.heartbeat(`jsonb_integrity.${table}.${col}`);
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${table} WHERE jsonb_typeof(${col}) = 'string'`,
);
const n = Number((rows as any)[0]?.n ?? 0);
if (n > 0) { totalBad += n; breakdown.push(`${table}.${col}=${n}`); }
}
if (totalBad === 0) {
checks.push({ name: 'jsonb_integrity', status: 'ok', message: 'All JSONB columns store objects/arrays' });
} else {
checks.push({
name: 'jsonb_integrity',
status: 'warn',
message: `${totalBad} row(s) double-encoded (${breakdown.join(', ')}). Fix: gbrain repair-jsonb`,
});
}
} catch {
checks.push({ name: 'jsonb_integrity', status: 'warn', message: 'Could not check JSONB integrity' });
}
checks.push(await jsonbIntegrityCheck(engine, progress));
// 10b. Takes weight grid integrity (v0.32 — EXP-2).
//
+19
View File
@@ -101,6 +101,25 @@ describe('doctor command', () => {
expect(source).toMatch(/table:\s*'files'.*col:\s*'metadata'/);
});
test('pgvector and jsonb_integrity checks use the active PGLite engine', async () => {
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { pgvectorCheck, jsonbIntegrityCheck } = await import('../src/commands/doctor.ts');
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
try {
const pgvector = await pgvectorCheck(engine);
expect(pgvector.name).toBe('pgvector');
expect(pgvector.status).toBe('ok');
const jsonb = await jsonbIntegrityCheck(engine);
expect(jsonb.name).toBe('jsonb_integrity');
expect(jsonb.status).toBe('ok');
} finally {
await engine.disconnect();
}
});
// v0.31.2 — facts_extraction_health check added in PR1 commit 12.
// Reads ingest_log rows with source_type='facts:absorb' (written by
// writeFactsAbsorbLog from src/core/facts/absorb-log.ts), groups by