diff --git a/src/cli.ts b/src/cli.ts index 804d7dbf3..17a4c779a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown { } // CLI-only commands that bypass the operation layer -export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']); +export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'facts', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']); // CLI-only commands whose handlers print their own --help text. These are // excluded from the generic short-circuit so detailed per-command and // per-subcommand usage stays reachable. @@ -991,6 +991,8 @@ const THIN_CLIENT_REFUSED_COMMANDS = new Set([ // hint pointing at the routable MCP tools; per-subcommand splits are // a v0.31.x follow-up TODO. 'takes', 'sources', + // #1867: fence-backfill edits local .md fences + stamps the local DB. + 'facts', // v0.32 thin-client routing audit (Codex round 2 findings #2, #4): // - `pages` purge-deleted is admin+localOnly (operations.ts:856-864) // - `files` list / file_url MCP ops are localOnly (operations.ts:1769-1879) @@ -1026,6 +1028,7 @@ const THIN_CLIENT_REFUSE_HINTS: Record = { migrate: "migrate runs on the host's local engine. Run on the host machine.", 'apply-migrations': 'schema migrations run on the host. SSH and run there.', 'repair-jsonb': 'repair-jsonb operates on the local DB only.', + facts: 'facts fence-backfill edits local entity-page fences. Run on the host machine.', integrity: 'integrity scans local files. Run on the host machine.', serve: 'serve starts a server. Run on the host, not the thin client.', dream: 'dream runs the autopilot cycle on the host. `gbrain remote ping` queues one. (Native `gbrain dream` thin-client routing planned for v0.31.2.)', @@ -1855,6 +1858,13 @@ async function handleCliOnly(command: string, args: string[]) { await runEdgesBackfill(engine, args); break; } + case 'facts': { + // #1867 — re-runnable fence-backfill for row_num-NULL legacy fact + // rows (idempotent v0_32_2 phase B, exposed as an operator command). + const { runFactsCommand } = await import('./commands/facts.ts'); + await runFactsCommand(engine, args); + break; + } case 'whoknows': { // v0.33 (Issue #?): expertise + relationship-proximity routing. // MCP op `find_experts` (read-scoped) backs the same code path; CLI @@ -2335,6 +2345,7 @@ TOOLS check-backlinks [dir] Find/fix missing back-links across brain lint [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter orphans [--json] [--count] Find pages with no inbound wikilinks + facts fence-backfill [--dry-run] Fence legacy fact rows (row_num NULL) onto entity pages salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type) transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only) diff --git a/src/commands/facts.ts b/src/commands/facts.ts new file mode 100644 index 000000000..616a2b6e8 --- /dev/null +++ b/src/commands/facts.ts @@ -0,0 +1,47 @@ +/** + * gbrain facts — fact-store maintenance surface (#1867). + * + * `fence-backfill` re-runs the v0_32_2 fence-backfill phase on demand. + * Remote `extract_facts` deposits that predate the fence-write backstop + * (and any legacy DB-only insert) leave `row_num IS NULL` rows that the + * cycle extract_facts guard refuses to reconcile past — previously the + * only remedy was the one-shot v0_32_2 migration, which the ledger marks + * complete and never re-runs. The phase is idempotent (only touches + * `row_num IS NULL` rows), so exposing it as a command is safe to re-run + * any time the backlog reappears. + */ +import type { BrainEngine } from '../core/engine.ts'; +import { phaseBFenceFacts } from './migrations/v0_32_2.ts'; + +function printHelp(): void { + process.stderr.write( + `Usage: gbrain facts fence-backfill [--dry-run]\n\n` + + `Fence-backfill: appends every legacy fact row (row_num IS NULL) to its\n` + + `entity page's \`## Facts\` fence and stamps row_num + source_markdown_slug\n` + + `back onto the DB row. Idempotent — re-runs only pick up rows still\n` + + `missing a fence assignment. Clears the backlog that makes the cycle's\n` + + `extract_facts phase skip fence→DB reconciliation.\n\n` + + ` --dry-run report what would be fenced; no FS or DB writes\n`, + ); +} + +export async function runFactsCommand(engine: BrainEngine, args: string[]): Promise { + const sub = args[0]; + if (!sub || sub === '--help' || sub === '-h') { + printHelp(); + return; + } + if (sub !== 'fence-backfill') { + process.stderr.write(`Unknown facts subcommand: ${sub}\n`); + printHelp(); + process.exitCode = 1; + return; + } + + const dryRun = args.includes('--dry-run'); + const result = await phaseBFenceFacts(engine, { dryRun }); + process.stderr.write( + `fence-backfill: ${result.status}${result.detail ? ` — ${result.detail}` : ''}\n`, + ); + if (result.status === 'failed') process.exitCode = 1; +} diff --git a/src/commands/migrations/v0_32_2.ts b/src/commands/migrations/v0_32_2.ts index 51286ccc7..f13889ea9 100644 --- a/src/commands/migrations/v0_32_2.ts +++ b/src/commands/migrations/v0_32_2.ts @@ -39,6 +39,7 @@ import type { BrainEngine } from '../../core/engine.ts'; import { loadConfig, toEngineConfig } from '../../core/config.ts'; import { createEngine } from '../../core/engine-factory.ts'; import { upsertFactRow, parseFactsFence } from '../../core/facts-fence.ts'; +import { resolvePageFilePath } from '../../core/markdown.ts'; let testEngineOverride: BrainEngine | null = null; export function __setTestEngineOverride(engine: BrainEngine | null): void { @@ -148,9 +149,16 @@ function isLocalPathDirty(localPath: string): boolean { } } -async function phaseBFenceFacts( +/** + * Exported (not just via `__testing`) because `gbrain facts fence-backfill` + * (#1867) re-runs this phase on demand: remote `extract_facts` deposits that + * predate the fence-write backstop leave row_num-NULL rows the cycle guard + * refuses to reconcile past. The phase is idempotent (only touches + * `row_num IS NULL` rows), so re-running is always safe. + */ +export async function phaseBFenceFacts( engine: BrainEngine | null, - opts: OrchestratorOpts, + opts: Pick, ): Promise { if (opts.dryRun) { // Dry-run: report what WOULD happen without touching FS or DB. @@ -238,7 +246,11 @@ async function phaseBFenceFacts( for (const [key, group] of groups) { const [sourceId, entitySlug] = key.split('\0'); const localPath = localPathById.get(sourceId)!; - const filePath = join(localPath, `${entitySlug}.md`); + // resolvePageFilePath, NOT a bare join — non-default sources fence + // into `/.sources//.md`, the same path the + // fence-write backstop and put_page write-through compute. A bare + // join here diverges fence and DB for non-default sources (#2044). + const filePath = resolvePageFilePath(localPath, entitySlug, sourceId); const tmpPath = `${filePath}.tmp`; try { @@ -381,7 +393,7 @@ async function phaseCVerify( for (const g of groups) { const localPath = localPathById.get(g.source_id); if (!localPath) continue; - const filePath = join(localPath, `${g.source_markdown_slug}.md`); + const filePath = resolvePageFilePath(localPath, g.source_markdown_slug, g.source_id); if (!existsSync(filePath)) { mismatches.push(`${g.source_markdown_slug} (file missing)`); continue; diff --git a/src/core/cycle/extract-facts.ts b/src/core/cycle/extract-facts.ts index d805fdec4..9a919fa21 100644 --- a/src/core/cycle/extract-facts.ts +++ b/src/core/cycle/extract-facts.ts @@ -176,9 +176,11 @@ export async function runExtractFacts( if (legacyCount > 0) { result.guardTriggered = true; result.warnings.push( - `extract_facts: ${legacyCount} legacy v0.31 fact rows pending fence backfill. ` + - `Run \`gbrain apply-migrations --yes\` to complete v0_32_2 before this phase ` + - `can safely reconcile fence → DB.`, + `extract_facts: ${legacyCount} legacy fact rows pending fence backfill ` + + `(row_num IS NULL — v0.31 rows or remote extract_facts deposits that ` + + `predate the fence backstop). Run \`gbrain facts fence-backfill\` ` + + `(idempotent, re-runnable) before this phase can safely reconcile ` + + `fence → DB.`, ); return result; } diff --git a/src/core/engine.ts b/src/core/engine.ts index a389d3091..4e98e9ac4 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -1739,13 +1739,14 @@ export interface BrainEngine { * single-row supersede flow because fence reconciliation is the canonical * source-of-truth direction, not the consolidator path. * - * Insertion is atomic per call: all rows commit in a single transaction - * or none commit (the transaction rolls back on any constraint - * violation, e.g. the v51 partial UNIQUE index on - * `(source_id, source_markdown_slug, row_num)`). + * Insertion runs in a single transaction. A collision on the v51 + * partial UNIQUE index `(source_id, source_markdown_slug, row_num)` + * skips ONLY that row (ON CONFLICT DO NOTHING, #2044) — the rest of + * the batch still commits, so a redundant deposit against an + * already-indexed fence row is idempotent instead of a hard failure. * - * Returns the inserted ids in input-order so callers can correlate - * fence-row → DB-id without a separate lookup. + * Returns the inserted ids in input-order (colliding rows omitted) so + * callers can correlate fence-row → DB-id without a separate lookup. */ insertFacts( rows: Array, diff --git a/src/core/facts/fence-write.ts b/src/core/facts/fence-write.ts index ae35769fb..2b7e0cbc2 100644 --- a/src/core/facts/fence-write.ts +++ b/src/core/facts/fence-write.ts @@ -218,11 +218,27 @@ export async function writeFactsToFence( } // 2. Upsert each fact onto the fence in input order. row_num - // monotonically increases (max-existing + 1 per call, append-only). + // monotonically increases, append-only, seeded from the MAX of + // the fence and the DB index (#2044): when fence and DB have + // diverged (e.g. legacy writes that stamped DB rows against a + // fence at a path this code no longer reads), fence-max+1 can + // collide with an existing DB row_num, tripping + // idx_facts_fence_key and rolling back the whole batch. + 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`, + [target.sourceId, target.slug], + ); + const dbMaxRowNum = Number(dbMaxRows[0]?.max ?? 0) || 0; + const fenceMaxRowNum = parseFactsFence(body).facts + .reduce((m, f) => Math.max(m, f.rowNum), 0); + let nextRowNum = Math.max(fenceMaxRowNum, dbMaxRowNum) + 1; + const assignedRowNums: number[] = []; for (const f of facts) { const validFromStr = (f.validFrom ?? new Date()).toISOString().slice(0, 10); const { body: updated, rowNum } = upsertFactRow(body, { + rowNum: nextRowNum++, claim: f.fact, kind: (f.kind ?? 'fact') as 'fact' | 'event' | 'preference' | 'commitment' | 'belief', confidence: f.confidence ?? 1.0, diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 54efd0873..f64cf6b52 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -4102,11 +4102,13 @@ export class PGLiteEngine implements BrainEngine { ): Promise<{ inserted: number; ids: number[] }> { if (rows.length === 0) return { inserted: 0, ids: [] }; - // Single transaction so the v51 partial UNIQUE index can roll back the - // whole batch on constraint violation. Per-row INSERTs (not multi-row - // VALUES) keep the embedding-vs-no-embedding branching readable; batch - // sizes are small (5-30 rows per page in practice) so the loop overhead - // is negligible vs the embedding compute cost. + // Single transaction; per-row INSERTs (not multi-row VALUES) keep the + // embedding-vs-no-embedding branching readable; batch sizes are small + // (5-30 rows per page in practice) so the loop overhead is negligible + // vs the embedding compute cost. #2044: ON CONFLICT DO NOTHING on the + // v51 partial UNIQUE index makes a residual fence/DB row_num collision + // skip that row instead of rolling back the whole batch (parity with + // postgres-engine.ts). const ids = await this.db.transaction(async (tx) => { const out: number[] = []; for (const input of rows) { @@ -4149,7 +4151,11 @@ export class PGLiteEngine implements BrainEngine { $14, $15, $16, $17, $18, $19, $20 - ) RETURNING id` + ) + ON CONFLICT (source_id, source_markdown_slug, row_num) + WHERE row_num IS NOT NULL + DO NOTHING + RETURNING id` : `INSERT INTO facts ( source_id, entity_slug, fact, kind, visibility, notability, context, valid_from, valid_until, source, source_session, confidence, @@ -4163,12 +4169,16 @@ export class PGLiteEngine implements BrainEngine { $15, $16, $17, $18, $19, $20, $21 - ) RETURNING id`, + ) + ON CONFLICT (source_id, source_markdown_slug, row_num) + WHERE row_num IS NOT NULL + DO NOTHING + RETURNING id`, embedStr === null ? [ctx.source_id, entitySlug, input.fact, kind, visibility, notability, context, validFrom, validUntil, input.source, sourceSession, confidence, embeddedAt, input.row_num, input.source_markdown_slug, claimMetric, claimValue, claimUnit, claimPeriod, eventType] : [ctx.source_id, entitySlug, input.fact, kind, visibility, notability, context, validFrom, validUntil, input.source, sourceSession, confidence, embedStr, embeddedAt, input.row_num, input.source_markdown_slug, claimMetric, claimValue, claimUnit, claimPeriod, eventType], ); - out.push(ins.rows[0].id); + if (ins.rows[0]) out.push(ins.rows[0].id); } return out; }); diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index b42f2a909..0f7a6a1a1 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -4302,10 +4302,12 @@ export class PostgresEngine implements BrainEngine { // ONCE per process so the cast matches the actual column type // (halfvec vs vector). The probe is cached after first call. const castSuffix = await this.resolveFactsEmbeddingCast(); - // Single transaction so the v51 partial UNIQUE index can roll back - // the whole batch on constraint violation. Per-row INSERTs (not - // multi-row VALUES) keep the embedding-vs-no-embedding branching - // readable; batch sizes are small (5-30 rows per page in practice). + // Single transaction; per-row INSERTs (not multi-row VALUES) keep the + // embedding-vs-no-embedding branching readable; batch sizes are small + // (5-30 rows per page in practice). #2044: ON CONFLICT DO NOTHING on + // the v51 partial UNIQUE index makes a residual fence/DB row_num + // collision skip that row instead of rolling back the whole batch — + // the fence stays system-of-record and reconciliation catches up. // No supersede flow in this path — fence reconciliation is the // canonical source-of-truth direction, not the consolidator path. const ids = await sql.begin(async (tx) => { @@ -4346,9 +4348,13 @@ export class PostgresEngine implements BrainEngine { ${input.row_num}, ${input.source_markdown_slug}, ${claimMetric}, ${claimValue}, ${claimUnit}, ${claimPeriod}, ${eventType} - ) RETURNING id + ) + ON CONFLICT (source_id, source_markdown_slug, row_num) + WHERE row_num IS NOT NULL + DO NOTHING + RETURNING id `; - out.push(Number(ins[0].id)); + if (ins[0]) out.push(Number(ins[0].id)); } return out; }); diff --git a/test/extract-facts-phase.test.ts b/test/extract-facts-phase.test.ts index 1fdd24ef5..f8d441bac 100644 --- a/test/extract-facts-phase.test.ts +++ b/test/extract-facts-phase.test.ts @@ -304,7 +304,9 @@ describe('runExtractFacts — empty-fence guard (Codex R2-#7)', () => { expect(r.legacyRowsPending).toBe(1); expect(r.factsInserted).toBe(0); expect(r.factsDeleted).toBe(0); - expect(r.warnings.some(w => w.includes('apply-migrations'))).toBe(true); + // #1867: the remedy hint points at the re-runnable backfill command, + // not the one-shot v0_32_2 migration (which the ledger never re-runs). + expect(r.warnings.some(w => w.includes('gbrain facts fence-backfill'))).toBe(true); // Legacy row was NOT touched. // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/test/facts-fence-backfill-command.test.ts b/test/facts-fence-backfill-command.test.ts new file mode 100644 index 000000000..4f4ac6ebe --- /dev/null +++ b/test/facts-fence-backfill-command.test.ts @@ -0,0 +1,102 @@ +/** + * #1867 — `gbrain facts fence-backfill` command tests. + * + * The command re-runs the (idempotent) v0_32_2 phase B on demand so + * row_num-NULL backlogs — remote extract_facts deposits that predate + * the fence-write backstop — can be cleared without re-running the + * one-shot migration. Real PGLite + real tempdir filesystem. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { runFactsCommand } from '../src/commands/facts.ts'; + +let engine: PGLiteEngine; +let brainDir: string; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); + try { + if (brainDir) rmSync(brainDir, { recursive: true, force: true }); + } catch { /* best-effort */ } +}); + +beforeEach(async () => { + brainDir = mkdtempSync(join(tmpdir(), 'facts-backfill-cmd-test-')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query('DELETE FROM facts'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `UPDATE sources SET local_path = $1 WHERE id = 'default'`, + [brainDir], + ); +}); + +async function seedLegacyFact(fact: string): Promise { + // The row_num-NULL shape a remote extract_facts deposit leaves behind + // when it lands via the legacy DB-only path. + // 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) + VALUES ('default', 'people/alice', $1, 'fact', 'private', 'medium', + now(), 'mcp:extract_facts', 1.0)`, + [fact], + ); +} + +describe('gbrain facts fence-backfill', () => { + test('fences row_num-NULL rows and stamps the DB', async () => { + await seedLegacyFact('Deposited remotely'); + + await runFactsCommand(engine, ['fence-backfill']); + + // The fence exists on disk with the claim. + const filePath = join(brainDir, 'people/alice.md'); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, 'utf-8')).toContain('Deposited remotely'); + + // The backlog is cleared: no row_num-NULL rows remain. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + 'SELECT row_num, source_markdown_slug FROM facts', + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0].row_num).toBe(1); + expect(rows.rows[0].source_markdown_slug).toBe('people/alice'); + }); + + test('re-run is a no-op (idempotent)', async () => { + await seedLegacyFact('Deposited remotely'); + await runFactsCommand(engine, ['fence-backfill']); + await runFactsCommand(engine, ['fence-backfill']); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query('SELECT id FROM facts'); + expect(rows.rows).toHaveLength(1); + const body = readFileSync(join(brainDir, 'people/alice.md'), 'utf-8'); + expect(body.match(/Deposited remotely/g)).toHaveLength(1); + }); + + test('--dry-run reports without writing', async () => { + await seedLegacyFact('Deposited remotely'); + await runFactsCommand(engine, ['fence-backfill', '--dry-run']); + + expect(existsSync(join(brainDir, 'people/alice.md'))).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + 'SELECT row_num FROM facts', + ); + expect(rows.rows[0].row_num).toBeNull(); + }); +}); diff --git a/test/fence-write.test.ts b/test/fence-write.test.ts index 3bcb0c05b..293e37ae0 100644 --- a/test/fence-write.test.ts +++ b/test/fence-write.test.ts @@ -292,6 +292,49 @@ describe('lookupSourceLocalPath', () => { }); }); +describe('writeFactsToFence — fence/DB divergence (#2044)', () => { + test('seeds row_num past the DB max when the fence lags the DB', async () => { + // Simulate the divergence class from #2044: DB rows were stamped with + // row_nums against a fence written at a path this code no longer reads + // (e.g. the pre-"Local patch 2026-06-11" wrong-path writes). The page + // on disk has NO fence, but the DB already holds row_num 1..3 for the + // slug. Pre-fix, the next deposit re-assigned row_num=1 from fence + // text alone and the whole insertFacts batch failed on + // idx_facts_fence_key. + 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/dana', $1, 'fact', 'private', 'medium', + now(), 'mcp:extract_facts', 1.0, $2, 'people/dana')`, + [`old claim ${n}`, n], + ); + } + + const result = await writeFactsToFence( + engine, + { sourceId: 'default', localPath: brainDir, slug: 'people/dana' }, + [baseInput({ fact: 'second deposit' })], + ); + + expect(result.fenceWriteFailed).toBeUndefined(); + expect(result.inserted).toBe(1); + + // The new row landed PAST the DB max, not at fence-max+1 (= 1). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + 'SELECT row_num FROM facts WHERE id = $1', + [result.ids[0]], + ); + expect(rows.rows[0].row_num).toBe(4); + + // And the on-disk fence carries the same row_num — fence and DB agree. + const body = readFileSync(join(brainDir, 'people/dana.md'), 'utf-8'); + expect(body).toContain('| 4 | second deposit |'); + }); +}); + // Cleanup any leftover tempdirs after the whole suite. afterAll(() => { // No-op: each test cleaned up via the beforeEach; this is a safety net. diff --git a/test/insert-facts-batch.test.ts b/test/insert-facts-batch.test.ts index e58c3adb4..e71e649a2 100644 --- a/test/insert-facts-batch.test.ts +++ b/test/insert-facts-batch.test.ts @@ -5,7 +5,7 @@ * - Batch insert N rows persists row_num + source_markdown_slug * - Empty batch is a no-op * - Returns ids in input-order - * - v51 partial UNIQUE index rolls back the whole batch on a collision + * - v51 partial UNIQUE collision skips only the colliding row (#2044) * - deleteFactsForPage scopes by (source_id, source_markdown_slug); * never touches other pages or pre-v51 NULL-source_markdown_slug rows * - deleteFactsForPage on an empty page returns deleted:0 (idempotent) @@ -135,30 +135,29 @@ describe('engine.insertFacts — batch insert', () => { }); }); - test('v51 partial UNIQUE index rolls back the whole batch on collision', async () => { + test('v51 partial UNIQUE collision skips ONLY the colliding row (#2044)', async () => { // Seed row #1 first. await engine.insertFacts([fixtureFact(1, { fact: 'seeded' })], { source_id: 'default' }); - // Now try to batch-insert rows that include a colliding row_num=1. - let threw = false; - try { - await engine.insertFacts( - [ - fixtureFact(2, { fact: 'second' }), - fixtureFact(1, { fact: 'collides' }), // row_num=1 on same (source_id, source_markdown_slug) - fixtureFact(3, { fact: 'third' }), - ], - { source_id: 'default' }, - ); - } catch { - threw = true; - } - expect(threw).toBe(true); + // Batch-insert rows that include a colliding row_num=1. Pre-#2044 this + // threw and rolled back the whole batch, making a second remote + // extract_facts deposit to an already-fenced page a hard failure. Now + // ON CONFLICT DO NOTHING skips the colliding row and keeps the rest. + const result = await engine.insertFacts( + [ + fixtureFact(2, { fact: 'second' }), + fixtureFact(1, { fact: 'collides' }), // row_num=1 on same (source_id, source_markdown_slug) + fixtureFact(3, { fact: 'third' }), + ], + { source_id: 'default' }, + ); + expect(result.inserted).toBe(2); + expect(result.ids).toHaveLength(2); - // Verify the transaction rolled back — only the seeded row should remain. + // The seeded row survives untouched; the colliding claim is skipped. // eslint-disable-next-line @typescript-eslint/no-explicit-any const rows = await (engine as any).db.query('SELECT fact FROM facts ORDER BY id'); - expect(rows.rows.map((r: { fact: string }) => r.fact)).toEqual(['seeded']); + expect(rows.rows.map((r: { fact: string }) => r.fact)).toEqual(['seeded', 'second', 'third']); }); test('different source_markdown_slug values DO NOT collide on the same row_num', async () => {