mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) addLinksBatch/addTimelineEntriesBatch/addTakesBatch passed free text through unnest(${arr}::text[]); postgres.js serialized it to a Postgres text[] literal that array_in rejected ("malformed array literal") on calendar/Zoom context, aborting the whole `extract links --stale` sweep. Bind the batch as one JSONB doc via jsonb_to_recordset(($1::jsonb)->'rows') through the audited executeRawJsonb contract instead. Shared row builders (src/core/batch-rows.ts) keep both engines byte-identical; NUL is stripped only from free-text body fields (context/summary/detail/claim), while identity/security fields (slugs/source_ids/holder/kind/dates) still reject NUL. addTakesBatch is now batchRetry-wrapped ('addTakesBatch' audit site) and its BrainEngine signature takes BatchOpts. Scalar addLink context is NUL-stripped too. Regression tests on both engines: PGLite always-on poison/NUL/parity suite + DATABASE_URL-gated Postgres lane (the engine that actually crashed). * test: make "no Anthropic key" tests hermetic via withoutAnthropicKey hasAnthropicKey() reads both ANTHROPIC_API_KEY and ~/.gbrain config; tests that only deleted the env var fired a real LLM call on configured machines (warning flipped NO_ANTHROPIC_API_KEY -> LLM_OUTPUT_NOT_JSON). New test/helpers/no-anthropic-key.ts neutralizes both sources (env + GBRAIN_HOME temp dir) for the duration of the call. Refactors the five no-key tests in think-pipeline + takes-mcp-allowlist to use it, including two that previously passed only by luck of the live LLM output. * chore: docs + version bump (v0.42.28.0) KEY_FILES.md/RETRIEVAL.md describe the jsonb_to_recordset batch path; TODOS.md files the #1861 follow-ups (element-isolation, remaining ::text[] sites, shared SQL-string hoist, batch-insert edge-case tests). CHANGELOG + VERSION + package.json to 0.42.28.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync TESTING.md batch-insert references for v0.42.28.0 The #1861 fix migrated links/timeline/takes batch inserts from unnest(::text[]) to jsonb_to_recordset. Update the stale "postgres-js unnest() binding" note and add the two new poison-regression test files (test/links-timeline-jsonb-poison.test.ts PGLite half, test/e2e/jsonb-batch-poison-postgres.test.ts Postgres lane) to the inventory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sql-query): reject top-level array jsonb params in executeRawJsonb (#1861 P2a) The "no top-level array" rule was only a comment. A bare JS array bound to a $N::jsonb position can serialize as a Postgres array literal (not jsonb) through postgres.js, silently re-entering the "malformed array literal" class #1861 just escaped. executeRawJsonb now throws a clear error steering callers to the { rows: [...] } object wrapper. Verified breaks zero call sites (all pass objects or null). Codex adversarial P2a; batch-size enforcement (P2b) filed as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
// gbrain#1861 regression — Postgres lane (DATABASE_URL-gated).
|
||
//
|
||
// This is the engine that actually crashed: postgres.js serialized free-text
|
||
// `context` into a Postgres text[] literal that `array_in` rejected with
|
||
// "malformed array literal", aborting the whole `extract links --stale` sweep.
|
||
// The PGLite sibling (test/links-timeline-jsonb-poison.test.ts) may not
|
||
// reproduce the original crash because PGLite uses a different array serializer,
|
||
// so this gated test is the true regression lock. It runs in CI lanes that set
|
||
// DATABASE_URL and skips gracefully elsewhere.
|
||
|
||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
|
||
import type { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
||
|
||
const SKIP = !hasDatabase();
|
||
const d = SKIP ? describe.skip : describe;
|
||
|
||
const POISON =
|
||
'Zoom: https://zoom.us/j/95178948505?pwd=YmdFRWxXbWZadlNkaG9iNC9CYW12QT09, ' +
|
||
'"Q2 sync" — notes {a,b}, path C:\\Users\\x, em–dash } and { trailing';
|
||
const NUL = String.fromCharCode(0);
|
||
|
||
let engine: PostgresEngine;
|
||
|
||
async function seed(slug: string) {
|
||
await engine.putPage(slug, {
|
||
title: slug, type: 'concept' as never,
|
||
compiled_truth: 'body long enough to pass any minimum length backstop',
|
||
timeline: '', frontmatter: {}, source_path: `${slug}.md`,
|
||
});
|
||
}
|
||
|
||
async function pageId(slug: string): Promise<number> {
|
||
const rows = await engine.executeRaw<{ id: number }>(
|
||
`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug],
|
||
);
|
||
return rows[0].id;
|
||
}
|
||
|
||
d('JSONB batch poison — Postgres (#1861)', () => {
|
||
beforeAll(async () => { engine = await setupDB(); });
|
||
afterAll(async () => { await teardownDB(); });
|
||
beforeEach(async () => {
|
||
// Clean the three target tables between cases.
|
||
const conn = getConn();
|
||
await conn.unsafe('TRUNCATE links, timeline_entries, takes, pages CASCADE');
|
||
});
|
||
|
||
it('addLinksBatch round-trips poison context (the original crash)', async () => {
|
||
await seed('from-page'); await seed('to-page');
|
||
const n = await engine.addLinksBatch([
|
||
{ from_slug: 'from-page', to_slug: 'to-page', link_type: 'mentions',
|
||
context: POISON, link_source: 'manual' },
|
||
]);
|
||
expect(n).toBe(1);
|
||
const links = await engine.getLinks('from-page', { sourceId: 'default' });
|
||
expect(links[0].context).toBe(POISON);
|
||
});
|
||
|
||
it('strips embedded NUL in link context', async () => {
|
||
await seed('a'); await seed('b');
|
||
await engine.addLinksBatch([
|
||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions',
|
||
context: `before${NUL}after`, link_source: 'manual' },
|
||
]);
|
||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||
expect(links[0].context).toBe('beforeafter');
|
||
});
|
||
|
||
it('addTimelineEntriesBatch round-trips poison summary/detail/source', async () => {
|
||
await seed('meeting-page');
|
||
const n = await engine.addTimelineEntriesBatch([
|
||
{ slug: 'meeting-page', date: '2026-06-04', source: POISON,
|
||
summary: POISON, detail: POISON },
|
||
]);
|
||
expect(n).toBe(1);
|
||
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
|
||
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
|
||
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'meeting-page'`,
|
||
);
|
||
expect(rows[0].summary).toBe(POISON);
|
||
expect(rows[0].detail).toBe(POISON);
|
||
expect(rows[0].source).toBe(POISON);
|
||
});
|
||
|
||
it('addTakesBatch round-trips poison claim + native number/bool/null', async () => {
|
||
await seed('take-page');
|
||
const pid = await pageId('take-page');
|
||
const n = await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: POISON, kind: 'fact', holder: 'garry',
|
||
weight: 0.74, active: false },
|
||
]);
|
||
expect(n).toBe(1);
|
||
const rows = await engine.executeRaw<{
|
||
claim: string; weight: number | string; active: boolean; since_date: string | null;
|
||
}>(`SELECT claim, weight, active, since_date FROM takes t
|
||
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'take-page' AND t.row_num = 1`);
|
||
expect(rows[0].claim).toBe(POISON);
|
||
expect(Number(rows[0].weight)).toBeCloseTo(0.75, 5);
|
||
expect(rows[0].active).toBe(false);
|
||
expect(rows[0].since_date).toBeNull();
|
||
});
|
||
});
|