From 52fffc7ecc090d95dd69ae0a7b7f65d0d3c07646 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 22 Jul 2026 11:46:22 -0700 Subject: [PATCH] fix(facts): seed phase B backfill row_nums past the DB max (#2044 class) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phaseBFenceFacts appended at fence-max+1 with no DB seeding, so on a diverged page (stamped DB rows + missing fence at the resolved path — the same divergence class the writeFactsToFence fix handles) the post-rename UPDATE hit idx_facts_fence_key, the page failed, and the already-renamed fence disagreed with the DB while the row_num-NULL backlog persisted. Seed appended row_nums from MAX(fence max, DB max) per (source_id, slug), mirroring the fence-write fix. Repro test added. Co-Authored-By: Claude Fable 5 --- src/commands/migrations/v0_32_2.ts | 16 ++++++++++++ test/facts-fence-backfill-command.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/commands/migrations/v0_32_2.ts b/src/commands/migrations/v0_32_2.ts index f13889ea9..ccec89e49 100644 --- a/src/commands/migrations/v0_32_2.ts +++ b/src/commands/migrations/v0_32_2.ts @@ -281,6 +281,21 @@ export async function phaseBFenceFacts( const existingFence = parseFactsFence(body); const existingKeySet = new Set(existingFence.facts.map(f => `${f.claim}\0${f.source ?? ''}`)); + // Seed appended row_nums from MAX(fence max, DB max) — same #2044 + // divergence guard as writeFactsToFence. When the fence at the + // resolved path is missing/behind but the DB already holds stamped + // rows for this slug (legacy wrong-path fence writes), fence-max+1 + // collides with idx_facts_fence_key on the post-rename UPDATE, + // failing the page and leaving fence and DB disagreeing. + const dbMaxRows = await engine.executeRaw<{ max: number | string | null }>( + `SELECT MAX(row_num) AS max FROM facts + WHERE source_id = $1 AND source_markdown_slug = $2`, + [sourceId, entitySlug], + ); + const dbMaxRowNum = Number(dbMaxRows[0]?.max ?? 0) || 0; + const fenceMaxRowNum = existingFence.facts.reduce((m, f) => Math.max(m, f.rowNum), 0); + let nextRowNum = Math.max(fenceMaxRowNum, dbMaxRowNum) + 1; + const assignments: Array<{ id: string; row_num: number }> = []; for (const row of group) { const key = `${row.fact}\0${row.source ?? ''}`; @@ -303,6 +318,7 @@ export async function phaseBFenceFacts( .toISOString().slice(0, 10) : undefined; const { body: updated, rowNum } = upsertFactRow(body, { + rowNum: nextRowNum++, claim: row.fact, kind: row.kind, confidence: row.confidence, diff --git a/test/facts-fence-backfill-command.test.ts b/test/facts-fence-backfill-command.test.ts index 4f4ac6ebe..084ea3b58 100644 --- a/test/facts-fence-backfill-command.test.ts +++ b/test/facts-fence-backfill-command.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { runFactsCommand } from '../src/commands/facts.ts'; +import { phaseBFenceFacts } from '../src/commands/migrations/v0_32_2.ts'; let engine: PGLiteEngine; let brainDir: string; @@ -99,4 +100,34 @@ describe('gbrain facts fence-backfill', () => { ); expect(rows.rows[0].row_num).toBeNull(); }); + + test('diverged page: appends past the DB row_num max instead of colliding (#2044 class)', async () => { + // The #2044 divergence shape the backfill must survive: the DB already + // holds stamped rows 1..3 for the slug (legacy wrong-path fence write), + // but the fence at the resolved path is missing. Fence-max+1 (= 1) would + // collide with the stamped rows on the post-rename UPDATE, failing the + // page and leaving the renamed fence disagreeing with the DB. + for (let n = 1; n <= 3; n++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, row_num, source_markdown_slug) + VALUES ('default', 'people/alice', $1, 'fact', 'private', 'medium', + now(), 'mcp:extract_facts', 1.0, $2, 'people/alice')`, + [`stamped ${n}`, n], + ); + } + await seedLegacyFact('Deposited remotely'); + + const result = await phaseBFenceFacts(engine, { dryRun: false }); + expect(result.status).toBe('complete'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + `SELECT row_num FROM facts WHERE fact = 'Deposited remotely'`, + ); + expect(rows.rows[0].row_num).toBe(4); + const body = readFileSync(join(brainDir, 'people/alice.md'), 'utf-8'); + expect(body).toContain('| 4 | Deposited remotely |'); + }); });