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
10 changed files with 139 additions and 112 deletions
+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;
+4 -13
View File
@@ -14,7 +14,7 @@
*/
import type { BrainEngine } from './engine.ts';
import { PGVECTOR_HNSW_VECTOR_MAX_DIMS, hnswMaxDimsForType } from './vector-index.ts';
import { PGVECTOR_HNSW_VECTOR_MAX_DIMS } from './vector-index.ts';
import { gbrainPath } from './config.ts';
import { resolveRecipe } from './ai/model-resolver.ts';
import type { Recipe } from './ai/types.ts';
@@ -609,17 +609,6 @@ export function buildFactsAlterRecipe(
const opclass = columnType === 'halfvec' ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const targetType = columnType === 'halfvec' ? `halfvec(${configuredDims})` : `vector(${configuredDims})`;
const dimsChanged = columnDims !== configuredDims;
const hnswMaxDims = hnswMaxDimsForType(columnType);
const indexLines = configuredDims <= hnswMaxDims
? [
`CREATE INDEX idx_facts_embedding_hnsw`,
` ON facts USING hnsw (embedding ${opclass})`,
` WHERE embedding IS NOT NULL AND expired_at IS NULL;`,
]
: [
`-- Skip reindex. ${columnType}(${configuredDims}) exceeds pgvector's HNSW cap of ${hnswMaxDims};`,
`-- fact similarity falls back to exact scans.`,
];
return [
`-- ALTER ${columnType}(${columnDims}) → ${columnType}(${configuredDims}) on indexed column.`,
`-- HOLD a maintenance window: this rewrites every row's embedding.`,
@@ -640,7 +629,9 @@ export function buildFactsAlterRecipe(
: []),
`ALTER TABLE facts ALTER COLUMN embedding TYPE ${targetType}`,
` USING embedding::${targetType};`,
...indexLines,
`CREATE INDEX idx_facts_embedding_hnsw`,
` ON facts USING hnsw (embedding ${opclass})`,
` WHERE embedding IS NOT NULL AND expired_at IS NULL;`,
].join('\n');
}
+8 -21
View File
@@ -1,7 +1,6 @@
import type { BrainEngine } from './engine.ts';
import { slugifyPath } from './sync.ts';
import { getFtsLanguage } from './fts-language.ts';
import { hnswMaxDimsForType } from './vector-index.ts';
/**
* Schema migrations run automatically on initSchema().
@@ -2277,19 +2276,11 @@ export const MIGRATIONS: Migration[] = [
useHalfvec = true;
}
const columnType = useHalfvec ? 'halfvec' : 'vector';
const vecType = columnType.toUpperCase();
const vecType = useHalfvec ? 'HALFVEC' : 'VECTOR';
// HNSW operator class must match the column type:
// VECTOR(n) → vector_cosine_ops
// HALFVEC(n) → halfvec_cosine_ops
const opclass = useHalfvec ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const hnswMaxDims = hnswMaxDimsForType(columnType);
const factsEmbeddingIndexSql = embeddingDim <= hnswMaxDims
? `CREATE INDEX IF NOT EXISTS idx_facts_embedding_hnsw
ON facts USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL AND expired_at IS NULL;`
: `-- idx_facts_embedding_hnsw skipped: pgvector HNSW ${columnType} indexes support
-- at most ${hnswMaxDims} dimensions; exact vector scans remain available.`;
// FK to sources is added in a separate ALTER TABLE rather than inline
// on the column. Inline `REFERENCES` worked on PGLite but silently
// got dropped by postgres.js's `unsafe()` multi-statement path on
@@ -2363,7 +2354,9 @@ export const MIGRATIONS: Migration[] = [
ON facts(source_id, entity_slug)
WHERE consolidated_at IS NULL AND expired_at IS NULL;
${factsEmbeddingIndexSql}
CREATE INDEX IF NOT EXISTS idx_facts_embedding_hnsw
ON facts USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL AND expired_at IS NULL;
`;
await engine.runMigration(40, factsDDL);
@@ -2877,16 +2870,8 @@ export const MIGRATIONS: Migration[] = [
useHalfvec = true;
}
const columnType = useHalfvec ? 'halfvec' : 'vector';
const vecType = columnType.toUpperCase();
const vecType = useHalfvec ? 'HALFVEC' : 'VECTOR';
const opclass = useHalfvec ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
const hnswMaxDims = hnswMaxDimsForType(columnType);
const queryCacheEmbeddingIndexSql = embeddingDim <= hnswMaxDims
? `CREATE INDEX IF NOT EXISTS idx_query_cache_embedding_hnsw
ON query_cache USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL;`
: `-- idx_query_cache_embedding_hnsw skipped: pgvector HNSW ${columnType} indexes support
-- at most ${hnswMaxDims} dimensions; exact vector scans remain available.`;
const ddl = `
CREATE TABLE IF NOT EXISTS query_cache (
@@ -2905,7 +2890,9 @@ export const MIGRATIONS: Migration[] = [
CREATE INDEX IF NOT EXISTS idx_query_cache_source_created
ON query_cache(source_id, created_at DESC);
${queryCacheEmbeddingIndexSql}
CREATE INDEX IF NOT EXISTS idx_query_cache_embedding_hnsw
ON query_cache USING hnsw (embedding ${opclass})
WHERE embedding IS NOT NULL;
`;
await engine.runMigration(55, ddl);
+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]);
}
-5
View File
@@ -17,7 +17,6 @@
import type { BrainEngine } from './engine.ts';
export const PGVECTOR_HNSW_VECTOR_MAX_DIMS = 2000;
export const PGVECTOR_HNSW_HALFVEC_MAX_DIMS = 4000;
const CHUNK_EMBEDDING_HNSW_INDEX =
'CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);';
@@ -30,10 +29,6 @@ export function chunkEmbeddingIndexSql(dims: number): string {
].join('\n');
}
export function hnswMaxDimsForType(columnType: 'vector' | 'halfvec'): number {
return columnType === 'halfvec' ? PGVECTOR_HNSW_HALFVEC_MAX_DIMS : PGVECTOR_HNSW_VECTOR_MAX_DIMS;
}
export function applyChunkEmbeddingIndexPolicy(sql: string, dims: number): string {
return sql.replaceAll(CHUNK_EMBEDDING_HNSW_INDEX, chunkEmbeddingIndexSql(dims));
}
+3 -11
View File
@@ -122,9 +122,9 @@ describe('buildFactsAlterRecipe', () => {
});
test('vector recipe uses vector_cosine_ops + vector(N) USING cast', () => {
const recipe = buildFactsAlterRecipe(1024, 1536, 'vector');
expect(recipe).toContain('vector(1536)');
expect(recipe).toContain('USING embedding::vector(1536)');
const recipe = buildFactsAlterRecipe(1024, 2048, 'vector');
expect(recipe).toContain('vector(2048)');
expect(recipe).toContain('USING embedding::vector(2048)');
expect(recipe).toContain('vector_cosine_ops');
expect(recipe).not.toContain('halfvec_cosine_ops');
});
@@ -163,14 +163,6 @@ describe('buildFactsAlterRecipe', () => {
expect(recipe).not.toContain('UPDATE facts SET embedding = NULL');
expect(recipe).toContain('USING embedding::vector(1536)');
});
test('halfvec recipe skips HNSW rebuild above pgvector cap', () => {
const recipe = buildFactsAlterRecipe(1536, 4096, 'halfvec');
expect(recipe).toContain('halfvec(4096)');
expect(recipe).toContain('Skip reindex');
expect(recipe).toContain("exceeds pgvector's HNSW cap of 4000");
expect(recipe).not.toMatch(/CREATE INDEX idx_facts_embedding_hnsw[\s\S]*USING hnsw/);
});
});
describe('FactsEmbeddingDimMismatchError', () => {
-57
View File
@@ -11,7 +11,6 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
@@ -94,60 +93,4 @@ describe('migration v45 facts column shape', () => {
);
expect(after[0].udt_name).toBe(before[0].udt_name);
});
});
describe('migration v45/v55 large-dim HNSW policy', () => {
let largeDimEngine: PGLiteEngine;
beforeAll(async () => {
configureGateway({
embedding_model: 'litellm:custom-4096d',
embedding_dimensions: 4096,
env: { ...process.env },
});
largeDimEngine = new PGLiteEngine();
await largeDimEngine.connect({});
await largeDimEngine.initSchema();
});
afterAll(async () => {
await largeDimEngine.disconnect();
resetGateway();
});
test('4096d init skips unsupported HNSW indexes but keeps vector columns', async () => {
const formatRows = await largeDimEngine.executeRaw<{ format_type: string }>(
`SELECT format_type(atttypid, atttypmod) AS format_type
FROM pg_attribute
WHERE attrelid = 'facts'::regclass AND attname = 'embedding'`,
);
expect(formatRows[0]?.format_type).toMatch(/(halfvec|vector)\(4096\)/);
const indexRows = await largeDimEngine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'facts'
AND indexname = 'idx_facts_embedding_hnsw'
) AS exists`,
);
expect(indexRows[0]?.exists).toBe(false);
const queryCacheFormatRows = await largeDimEngine.executeRaw<{ format_type: string }>(
`SELECT format_type(atttypid, atttypmod) AS format_type
FROM pg_attribute
WHERE attrelid = 'query_cache'::regclass AND attname = 'embedding'`,
);
expect(queryCacheFormatRows[0]?.format_type).toMatch(/(halfvec|vector)\(4096\)/);
const queryCacheIndexRows = await largeDimEngine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'query_cache'
AND indexname = 'idx_query_cache_embedding_hnsw'
) AS exists`,
);
expect(queryCacheIndexRows[0]?.exists).toBe(false);
}, 60000);
});
+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.