mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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>
This commit is contained in:
co-authored by
Garry Tan
Forge
Claude Fable 5
parent
9f7244a77f
commit
9ed53e4e1c
+43
-33
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user