From 9ed53e4e1c54d6e8738fec4b7c126d34a2edb692 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:51:47 -0700 Subject: [PATCH] =?UTF-8?q?fix(code-edges):=20per-row=20$n::text::jsonb=20?= =?UTF-8?q?binds=20in=20addCodeEdges=20=E2=80=94=20Bun=20SQL=20mis-encodes?= =?UTF-8?q?=20jsonb[]=20arrays=20(#2968)=20(#3020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun SQL double-encodes ::jsonb[] array binds on the Postgres engine: every edge_metadata element landed as a jsonb string scalar instead of an object, so the resolver's `edge_metadata || jsonb_build_object(...)` UPDATE produced a jsonb array and resolved_chunk_id was never readable — code_callers / code_callees / code_blast returned nothing on Postgres-engine brains while the resolver logged edges_resolved > 0. PGLite was unaffected (per-row placeholders already). Rewrites both inserts (code_edges_chunk, code_edges_symbol) to per-row $n::text::jsonb placeholders via sql.unsafe — the same shape executeRawJsonb and the PGLite engine use. Adds the DATABASE_URL-gated Postgres regression test this class requires (PGLite cannot reproduce it): asserts jsonb_typeof(edge_metadata) = 'object' for resolved + unresolved inserts and that the resolver-style || UPDATE keeps object shape. Verified the test fails 3/3 against the pre-fix code and passes with the fix. Takeover of #2968 by @zsimovanforgeops with the missing regression test added. Co-authored-by: Garry Tan Co-authored-by: Forge (Ron) Co-authored-by: Claude Fable 5 --- src/core/postgres-engine.ts | 76 ++++++----- test/e2e/code-edges-jsonb-postgres.test.ts | 147 +++++++++++++++++++++ 2 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 test/e2e/code-edges-jsonb-postgres.test.ts diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 43decdb52..071c69b4e 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -5825,43 +5825,53 @@ export class PostgresEngine implements BrainEngine { const unresolved = edges.filter(e => e.to_chunk_id == null); if (resolved.length > 0) { - const fromIds = resolved.map(e => e.from_chunk_id); - const toIds = resolved.map(e => e.to_chunk_id as number); - const fromQual = resolved.map(e => e.from_symbol_qualified); - const toQual = resolved.map(e => e.to_symbol_qualified); - const edgeTypes = resolved.map(e => e.edge_type); - const metas = resolved.map(e => JSON.stringify(e.edge_metadata ?? {})); - const sources = resolved.map(e => e.source_id ?? 'default'); - const res = await sql` - INSERT INTO code_edges_chunk (from_chunk_id, to_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id) - SELECT * FROM unnest( - ${fromIds}::int[], ${toIds}::int[], - ${fromQual}::text[], ${toQual}::text[], - ${edgeTypes}::text[], ${metas}::jsonb[], - ${sources}::text[] - ) - ON CONFLICT (from_chunk_id, to_chunk_id, edge_type) DO NOTHING - `; + // Per-row placeholders with $n::text::jsonb for edge_metadata. Bun SQL + // mis-encodes jsonb[] array binds (double-encoded strings landed in + // edge_metadata — the resolver then read `"{}"` scalars and 0 edges ever + // resolved). ::text::jsonb per row is the codebase-wide safe shape + // (executeRawJsonb, PGLite's addCodeEdges). + const rowParts: string[] = []; + const params: unknown[] = []; + let p = 1; + for (const e of resolved) { + rowParts.push(`($${p++}::int, $${p++}::int, $${p++}, $${p++}, $${p++}, $${p++}::text::jsonb, $${p++})`); + params.push( + e.from_chunk_id, e.to_chunk_id as number, + e.from_symbol_qualified, e.to_symbol_qualified, e.edge_type, + JSON.stringify(e.edge_metadata ?? {}), + e.source_id ?? 'default', + ); + } + const res = await sql.unsafe( + `INSERT INTO code_edges_chunk + (from_chunk_id, to_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id) + VALUES ${rowParts.join(', ')} + ON CONFLICT (from_chunk_id, to_chunk_id, edge_type) DO NOTHING`, + params as never[], + ); inserted += (res as unknown as { count: number }).count ?? 0; } if (unresolved.length > 0) { - const fromIds = unresolved.map(e => e.from_chunk_id); - const fromQual = unresolved.map(e => e.from_symbol_qualified); - const toQual = unresolved.map(e => e.to_symbol_qualified); - const edgeTypes = unresolved.map(e => e.edge_type); - const metas = unresolved.map(e => JSON.stringify(e.edge_metadata ?? {})); - const sources = unresolved.map(e => e.source_id ?? 'default'); - const res = await sql` - INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id) - SELECT * FROM unnest( - ${fromIds}::int[], - ${fromQual}::text[], ${toQual}::text[], - ${edgeTypes}::text[], ${metas}::jsonb[], - ${sources}::text[] - ) - ON CONFLICT (from_chunk_id, to_symbol_qualified, edge_type) DO NOTHING - `; + const rowParts: string[] = []; + const params: unknown[] = []; + let p = 1; + for (const e of unresolved) { + rowParts.push(`($${p++}::int, $${p++}, $${p++}, $${p++}, $${p++}::text::jsonb, $${p++})`); + params.push( + e.from_chunk_id, + e.from_symbol_qualified, e.to_symbol_qualified, e.edge_type, + JSON.stringify(e.edge_metadata ?? {}), + e.source_id ?? 'default', + ); + } + const res = await sql.unsafe( + `INSERT INTO code_edges_symbol + (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id) + VALUES ${rowParts.join(', ')} + ON CONFLICT (from_chunk_id, to_symbol_qualified, edge_type) DO NOTHING`, + params as never[], + ); inserted += (res as unknown as { count: number }).count ?? 0; } diff --git a/test/e2e/code-edges-jsonb-postgres.test.ts b/test/e2e/code-edges-jsonb-postgres.test.ts new file mode 100644 index 000000000..715837740 --- /dev/null +++ b/test/e2e/code-edges-jsonb-postgres.test.ts @@ -0,0 +1,147 @@ +/** + * Postgres-only regression for addCodeEdges jsonb encoding (#2968). + * + * Bun SQL mis-encodes `::jsonb[]` array binds: each element arrives as a + * double-encoded JSON string (jsonb_typeof = 'string'), not an object. The + * symbol resolver's `edge_metadata || jsonb_build_object(...)` UPDATE then + * concatenates onto a string scalar and produces a jsonb array, so + * resolved_chunk_id never lands and code_callers/code_callees return nothing. + * + * PGLite cannot reproduce this class (its addCodeEdges always used per-row + * placeholders), so this is DATABASE_URL-gated per the engine-parity + * convention. Pins the per-row `$n::text::jsonb` shape: every inserted + * edge_metadata must be jsonb_typeof = 'object' and round-trip its fields. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { setupDB, teardownDB, hasDatabase } from './helpers.ts'; +import type { PostgresEngine } from '../../src/core/postgres-engine.ts'; + +const skip = !hasDatabase(); +const describeIfDB = skip ? describe.skip : describe; + +let engine: PostgresEngine; +let chunkA: number; +let chunkB: number; + +beforeAll(async () => { + if (skip) return; + engine = await setupDB(); + + await engine.putPage('src-a-ts', { + type: 'code', page_kind: 'code', + title: 'src/a.ts (typescript)', + compiled_truth: 'export function run() { return helper(); }', + timeline: '', + }); + await engine.upsertChunks('src-a-ts', [{ + chunk_index: 0, + chunk_text: 'export function run() { return helper(); }', + chunk_source: 'compiled_truth', + language: 'typescript', + symbol_name: 'run', + symbol_type: 'function', + symbol_name_qualified: 'run', + }]); + + await engine.putPage('src-b-ts', { + type: 'code', page_kind: 'code', + title: 'src/b.ts (typescript)', + compiled_truth: 'export function helper() { return 1; }', + timeline: '', + }); + await engine.upsertChunks('src-b-ts', [{ + chunk_index: 0, + chunk_text: 'export function helper() { return 1; }', + chunk_source: 'compiled_truth', + language: 'typescript', + symbol_name: 'helper', + symbol_type: 'function', + symbol_name_qualified: 'helper', + }]); + + chunkA = (await engine.getChunks('src-a-ts'))[0]!.id; + chunkB = (await engine.getChunks('src-b-ts'))[0]!.id; +}); + +afterAll(async () => { + if (skip) return; + await teardownDB(); +}); + +describeIfDB('addCodeEdges jsonb encoding — Postgres regression (#2968)', () => { + test('resolved edges land as jsonb objects, not double-encoded strings', async () => { + const inserted = await engine.addCodeEdges([{ + from_chunk_id: chunkA, + to_chunk_id: chunkB, + from_symbol_qualified: 'run', + to_symbol_qualified: 'helper', + edge_type: 'calls', + edge_metadata: { line: 1, via: 'direct' }, + }]); + expect(inserted).toBe(1); + + const rows = await engine.executeRaw<{ kind: string; line: string | null }>( + `SELECT jsonb_typeof(edge_metadata) AS kind, edge_metadata->>'line' AS line + FROM code_edges_chunk + WHERE from_chunk_id = $1 AND to_chunk_id = $2 AND edge_type = 'calls'`, + [chunkA, chunkB], + ); + expect(rows.length).toBe(1); + expect(rows[0]!.kind).toBe('object'); + expect(rows[0]!.line).toBe('1'); + }); + + test('unresolved edges land as jsonb objects (empty metadata defaults to {})', async () => { + const inserted = await engine.addCodeEdges([ + { + from_chunk_id: chunkA, + to_chunk_id: null, + from_symbol_qualified: 'run', + to_symbol_qualified: 'phantom', + edge_type: 'calls', + edge_metadata: { line: 2 }, + }, + { + from_chunk_id: chunkA, + to_chunk_id: null, + from_symbol_qualified: 'run', + to_symbol_qualified: 'ghost', + edge_type: 'calls', + }, + ]); + expect(inserted).toBe(2); + + const rows = await engine.executeRaw<{ to_symbol_qualified: string; kind: string }>( + `SELECT to_symbol_qualified, jsonb_typeof(edge_metadata) AS kind + FROM code_edges_symbol + WHERE from_chunk_id = $1`, + [chunkA], + ); + expect(rows.length).toBe(2); + for (const row of rows) { + expect(row.kind).toBe('object'); + } + }); + + test('resolver-style || UPDATE keeps object shape (the corruption symptom)', async () => { + // The production resolver runs `edge_metadata || jsonb_build_object(...)`. + // On a double-encoded string scalar this yields a jsonb ARRAY and the + // resolved_chunk_id key never becomes readable. Pin the healthy path. + await engine.executeRaw( + `UPDATE code_edges_symbol + SET edge_metadata = edge_metadata || jsonb_build_object('resolved_chunk_id', $1::int) + WHERE from_chunk_id = $2 AND to_symbol_qualified = 'phantom'`, + [chunkB, chunkA], + ); + const rows = await engine.executeRaw<{ kind: string; resolved: string | null }>( + `SELECT jsonb_typeof(edge_metadata) AS kind, + edge_metadata->>'resolved_chunk_id' AS resolved + FROM code_edges_symbol + WHERE from_chunk_id = $1 AND to_symbol_qualified = 'phantom'`, + [chunkA], + ); + expect(rows.length).toBe(1); + expect(rows[0]!.kind).toBe('object'); + expect(rows[0]!.resolved).toBe(String(chunkB)); + }); +});