Files
gbrain/test/e2e/jsonb-batch-poison-postgres.test.ts
T
f7f8512b14 v0.42.28.0 fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) (#1927)
* 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>
2026-06-07 08:05:34 -07:00

104 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, emdash } 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();
});
});