Files
gbrain/test/e2e/code-edges-jsonb-postgres.test.ts
T
9ed53e4e1c fix(code-edges): per-row $n::text::jsonb binds in addCodeEdges — Bun SQL mis-encodes jsonb[] arrays (#2968) (#3020)
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 <garrytan@gmail.com>
Co-authored-by: Forge (Ron) <forge@zsimovan.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:51:47 -07:00

148 lines
5.1 KiB
TypeScript

/**
* 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));
});
});