Compare commits

..
Author SHA1 Message Date
583975dee8 fix(sync): guard putPage 0-row RETURNING + reclassify sync failure copy (#2189)
Root cause of #2189's opaque per-file crash: putPage's INSERT … ON CONFLICT
DO UPDATE … RETURNING can yield 0 rows when brain-local DB state (e.g. a
BEFORE trigger) suppresses the write, and rowToPage(rows[0]) then died with
"undefined is not an object (evaluating 'row.deleted_at')". Both engines now
throw a descriptive error naming the slug + source_id so the failure is
diagnosable per-file instead of an anonymous TypeError.

Also lands the salvageable parts of community PR #2586 (takeover): the
"failed to parse / fix the frontmatter" copy misclassified runtime import
errors as YAML problems — reworded to "failed to import" — plus its
first-code-sync PGLite smoke test.

New regression test reproduces the exact 0-row RETURNING state via a
suppressing trigger: fails pre-fix with the reported crash signature,
passes with the guard.

Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:41:32 -07:00
8 changed files with 128 additions and 167 deletions
+1 -4
View File
@@ -935,10 +935,7 @@ export function formatResult(opName: string, result: unknown): string {
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage !== undefined) {
lines.push(`Timeline coverage (entity pages): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage_score !== undefined) {
lines.push(`Timeline density (all pages): ${h.timeline_coverage_score}/15 (whole-brain brain-score component)`);
lines.push(`Timeline coverage (entities): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (Array.isArray(h.most_connected) && h.most_connected.length > 0) {
lines.push('Most connected entities:');
+3 -3
View File
@@ -5868,12 +5868,12 @@ export async function buildChecks(
message: `Only code/test fixture entity pages found (${entityCount}); graph_coverage not applicable`,
});
} else if (linkCoverage >= 0.5 && timelineCoverage >= 0.5) {
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}%` });
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%` });
} else {
checks.push({
name: 'graph_coverage',
status: 'warn',
message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
});
}
@@ -5885,7 +5885,7 @@ export async function buildChecks(
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline density (all pages) ${health.timeline_coverage_score}/15`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
+5 -5
View File
@@ -197,7 +197,7 @@ export interface SyncResult {
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
/**
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
*
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
await clearOpCheckpoint(engine, ckpt.target);
};
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
// row) and is then deleted/renamed-away never re-enters failedFiles and never
// imports, so its row would never clear and would age doctor to a permanent
// FAIL. Treat removed paths as resolved so the ledger self-heals.
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
serr(
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
`${codeBreakdown}\n\n` +
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
`acknowledge and move on. A file that keeps failing auto-skips after ` +
`${resolveAutoSkipThreshold()} consecutive syncs.`,
);
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
+10
View File
@@ -1058,6 +1058,16 @@ export class PGLiteEngine implements BrainEngine {
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
);
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0] as Record<string, unknown>);
}
+10
View File
@@ -1119,6 +1119,16 @@ export class PostgresEngine implements BrainEngine {
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
`;
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0]);
}
@@ -1,155 +0,0 @@
/**
* Issue #2298 timeline metric presentation contract.
*
* Authoritative upstream semantics (src/core/types.ts):
* - Metric A `timeline_coverage` (entity-scoped, fraction 01):
* eligible entity pages WITH a timeline entry / eligible entity pages
* -> surfaced by `graph_coverage` check AND `get_health` CLI entity line.
* - Metric B `timeline_coverage_score` (whole-brain, 015 brain-score component):
* all pages WITH a timeline entry / all pages
* -> surfaced by `brain_score` component breakdown AND (separately) CLI.
*
* The two have DIFFERENT numerators/denominators. This PR labels each
* explicitly and keeps BOTH the entity CLI line and the whole-brain line.
*
* Tests (no private EriadorMu data, no production/home DB, no network):
* - numeric denominator assertions (Metric A = 50%, Metric B = 4/15)
* - doctor rendered-message assertions (exact labels, no ambiguous old label)
* - CLI rendered-output assertions (exact lines, guard matrix)
* - red/green: same assertions FAIL on origin/master, PASS on this branch
*
* Scoring formula UNCHANGED. Canonical PGLite fixture via resetPgliteState.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { buildChecks } from '../src/commands/doctor.ts';
import { formatResult } from '../src/cli.ts';
let engine: PGLiteEngine;
async function seedFourPages(eng: PGLiteEngine): Promise<void> {
const sql = sqlQueryForEngine(eng);
// 2 eligible entity pages, 2 technical/non-entity pages.
// Only ONE entity page has a timeline entry; only ONE total page does.
await sql`
INSERT INTO pages (slug, source_id, type, title, compiled_truth, frontmatter, content_hash, created_at, updated_at)
VALUES
('acme-example', 'default', 'company', 'Acme', '', '{}', 'h1', now(), now()),
('alice-example', 'default', 'person', 'Alice', '', '{}', 'h2', now(), now()),
('technical-a', 'default', 'note', 'Tech A', '', '{}', 'h3', now(), now()),
('technical-b', 'default', 'note', 'Tech B', '', '{}', 'h4', now(), now())
`;
const companyId = (await sql`SELECT id FROM pages WHERE slug='acme-example'`)[0].id as number;
await sql`INSERT INTO timeline_entries (page_id, date, source, summary, detail)
VALUES (${companyId}, CURRENT_DATE, 'test', 'milestone', '{}')`;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('issue #2298 — numeric denominator semantics', () => {
test('entity timeline coverage = 1/2 = 50% (2 eligible entities, 1 with timeline)', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(health.timeline_coverage).toBeDefined();
expect(Math.round((health.timeline_coverage ?? 0) * 100)).toBe(50);
});
test('whole-brain timeline density = 1/4 -> score 4/15 (4 total pages, 1 with timeline)', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(health.timeline_coverage_score).toBeDefined();
expect(health.timeline_coverage_score).toBe(4);
});
test('the two metrics use independent denominators', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(Math.round((health.timeline_coverage ?? 0) * 100)).toBe(50);
expect(health.timeline_coverage_score ?? 0).toBe(4);
// 50% (entity, /2) != 26.7% (whole-brain, /4). Provably distinct.
expect(Math.round(((health.timeline_coverage_score ?? 0) / 15) * 100)).not.toBe(50);
});
});
describe('issue #2298 — doctor rendered-message contract', () => {
test('graph_coverage renders entity-scoped label with 50%', async () => {
await seedFourPages(engine);
const checks = await buildChecks(engine, [], null);
const graph = checks.find((c) => c.name === 'graph_coverage');
expect(graph, 'graph_coverage check must be present').toBeDefined();
expect(graph!.message).toContain('entity timeline coverage 50%');
// ambiguous old label must NOT be present
expect(graph!.message).not.toMatch(/timeline 50%/);
expect(graph!.message).not.toMatch(/timeline \(entity, brain score\)/);
});
test('brain_score renders whole-brain density label 4/15', async () => {
await seedFourPages(engine);
const checks = await buildChecks(engine, [], null);
const brain = checks.find((c) => c.name === 'brain_score');
expect(brain, 'brain_score check must be present').toBeDefined();
expect(brain!.message).toContain('timeline density (all pages) 4/15');
// wrong labels must NOT be present
expect(brain!.message).not.toMatch(/timeline 4\/15/);
expect(brain!.message).not.toMatch(/timeline \(entity, brain score\)/);
// brain-score component must NOT carry the word "entity" (it is whole-brain)
const timelinePart = brain!.message.split('timeline density (all pages) 4/15')[0] + 'timeline density (all pages) 4/15';
expect(timelinePart).not.toMatch(/entity/);
});
});
describe('issue #2298 — CLI get_health rendered-output contract', () => {
function fakeHealth(overrides: Record<string, unknown>): any {
return {
embed_coverage: 1, missing_embeddings: 0, stale_pages: 0, orphan_pages: 0,
link_coverage: 1, timeline_coverage: 0.5, timeline_coverage_score: 4,
most_connected: [], ...overrides,
};
}
test('both entity and whole-brain lines render, no undefined/15', () => {
const out = formatResult('get_health', fakeHealth({}));
expect(out).toContain('Timeline coverage (entity pages): 50.0%');
expect(out).toContain('Timeline density (all pages): 4/15');
expect(out).not.toContain('undefined/15');
expect(out).not.toContain('Timeline coverage (entities)');
expect(out).not.toMatch(/timeline \(entity, brain score\)/);
expect(out).not.toMatch(/bare "timeline 4\/15"/);
});
test('guard matrix: entity present, whole-brain absent -> only entity line', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage_score: undefined }));
expect(out).toContain('Timeline coverage (entity pages): 50.0%');
expect(out).not.toContain('Timeline density (all pages)');
expect(out).not.toContain('undefined/15');
});
test('guard matrix: whole-brain present, entity absent -> only whole-brain line', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage: undefined }));
expect(out).toContain('Timeline density (all pages): 4/15');
expect(out).not.toContain('Timeline coverage (entity pages)');
expect(out).not.toContain('undefined/15');
});
test('guard matrix: both absent -> neither timeline line, never undefined/15', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage: undefined, timeline_coverage_score: undefined }));
expect(out).not.toContain('Timeline coverage (entity pages)');
expect(out).not.toContain('Timeline density (all pages)');
expect(out).not.toContain('undefined/15');
});
});
+74
View File
@@ -0,0 +1,74 @@
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
// naming the slug + source_id so the failure is diagnosable per-file.
//
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
// exercises the PGLite side, where the issue was reported.
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Simulate the reporter's state-dependent failure: a trigger that
// suppresses inserts for one slug, making RETURNING produce no row.
await engine.executeRaw(`
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
BEGIN
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await engine.executeRaw(`
CREATE TRIGGER suppress_pages_insert_trg
BEFORE INSERT ON pages
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
`);
});
afterAll(async () => {
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
await engine.disconnect();
});
describe('putPage RETURNING guard (#2189)', () => {
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
let err: Error | undefined;
try {
await engine.putPage('suppressed-page', {
type: 'code',
title: 'Suppressed',
compiled_truth: 'x',
timeline: '',
});
} catch (e) {
err = e as Error;
}
expect(err).toBeDefined();
expect(err!.message).toContain('putPage');
expect(err!.message).toContain("slug='suppressed-page'");
expect(err!.message).toContain("source_id='default'");
// The pre-fix crash signature must be gone.
expect(err!.message).not.toContain('deleted_at');
});
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
const page = await engine.putPage('normal-page', {
type: 'concept',
title: 'Normal',
compiled_truth: 'y',
timeline: '',
});
expect(page.slug).toBe('normal-page');
expect(page.source_id).toBe('default');
});
});
+25
View File
@@ -375,6 +375,31 @@ describe('performSync dry-run never writes', () => {
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('first PGLite code sync imports code files without runtime failures', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'src'), { recursive: true });
writeFileSync(
join(repoPath, 'src/example.ts'),
'export function add(left: number, right: number) { return left + right; }\n',
);
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
strategy: 'code',
});
expect(result.status).toBe('first_sync');
expect(result.added).toBe(1);
expect(result.failedFiles ?? 0).toBe(0);
const page = await engine.getPage('src-example-ts');
expect(page?.type).toBe('code');
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.