Files
gbrain/test/e2e/jsonb-batch-poison-postgres.test.ts
T
ecd6ae8772 v0.42.40.0 fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011) (#2031)
* fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011)

excerpt() in link-extraction.ts sliced the link-context window by raw UTF-16
index, so a boundary landing inside a non-BMP char (emoji, math, CJK) left an
unpaired surrogate half in `context`. Serialized to JSONB for the
jsonb_to_recordset batch insert, Postgres rejects it at the ::jsonb cast and
aborts the whole batch — wedging `extract --stale` because the staleness
bookmark only advances on a clean finish.

- text-safe.ts: new ensureWellFormed() (Bun isWellFormed/toWellFormed) — one
  shared surrogate-cleaning primitive.
- link-extraction.ts: excerpt() well-forms the slice (root-cause fix).
- batch-rows.ts: new sanitizeForJsonb() = ensureWellFormed(stripNul(s)) applied
  to every free-text body field (link context; timeline summary/detail/source;
  take claim/source). Identity/security fields stay un-sanitized and fail closed.
- postgres-engine.ts + pglite-engine.ts: scalar addLink + addTimelineEntry use
  sanitizeForJsonb too, matching the batch path on both engines.
- brainstorm/orchestrator.ts: consolidate hand-rolled sanitizeUnicode onto
  ensureWellFormed (also fixes consecutive-lone-surrogate mishandling).

Tests: ensureWellFormed unit cases (incl. consecutive lone surrogates), an
excerpt window-split regression, PGLite + Postgres-e2e surrogate cases across
all free-text fields and scalar paths, and fail-closed identity-field tests
proving sanitization was NOT extended to slugs/holders.

* v0.42.39.0 chore: bump version and changelog (#2011)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.42.39.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.40.0 chore: re-slot release version (was 0.42.39.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: fix env-mutation isolation violations in retrieval-reflex tests

check:test-isolation (rule R1) flags direct process.env mutation in
non-serial test files — bun's parallel runner loads multiple files into
one process, so a leaked GBRAIN_RETRIEVAL_REFLEX flips reflex behavior
in unrelated tests. Both files landed via the #2019 merge; convert the
beforeEach/afterEach env juggling to the canonical withEnv() wrapper,
which restores the prior value via try/finally even on throw.

Fixes the failing `verify` CI check on #2031 (and the `test-status`
aggregate that inherits it). All 30 verify checks green locally.

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-10 22:00:36 -07:00

187 lines
8.0 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);
// #2011 — lone UTF-16 high surrogate. THIS is the value that crashed Postgres at
// the ::jsonb cast (22P02) and aborted `extract --stale` on a managed Supabase
// brain. The PGLite sibling rejects it too on current builds, but this lane is
// the production engine the bug was reported against. Built via fromCharCode so
// no lone surrogate is a literal in this file.
const LONE_HI = String.fromCharCode(0xd83c);
const SURROGATE = `before${LONE_HI}after`;
const SURROGATE_CLEAN = 'beforeafter';
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();
});
// ── #2011: lone-surrogate ::jsonb crash lock (the abort this PR fixes) ──
it('addLinksBatch survives a lone surrogate in context (the #2011 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: SURROGATE, link_source: 'manual' },
]);
expect(n).toBe(1); // pre-fix: threw "invalid input syntax for type json"
const links = await engine.getLinks('from-page', { sourceId: 'default' });
expect(links[0].context).toBe(SURROGATE_CLEAN);
expect(links[0].context.isWellFormed()).toBe(true);
});
it('addTimelineEntriesBatch survives a lone surrogate in source (D2)', async () => {
await seed('meeting-page');
const n = await engine.addTimelineEntriesBatch([
{ slug: 'meeting-page', date: '2026-06-04', source: SURROGATE,
summary: SURROGATE, detail: SURROGATE },
]);
expect(n).toBe(1);
const rows = await engine.executeRaw<{ summary: string; source: string }>(
`SELECT te.summary, 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(SURROGATE_CLEAN);
expect(rows[0].source).toBe(SURROGATE_CLEAN);
});
it('addTakesBatch survives a lone surrogate in claim and source', async () => {
await seed('take-page');
const pid = await pageId('take-page');
const n = await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: SURROGATE, kind: 'fact', holder: 'h', source: SURROGATE },
]);
expect(n).toBe(1);
const rows = await engine.executeRaw<{ claim: string; source: string }>(
`SELECT claim, source 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(SURROGATE_CLEAN);
expect(rows[0].source).toBe(SURROGATE_CLEAN); // free-text provenance, sanitized too
});
it('scalar addLink + addTimelineEntry survive a lone surrogate', async () => {
await seed('sa'); await seed('sb');
await engine.addLink('sa', 'sb', SURROGATE, 'mentions', 'manual');
const links = await engine.getLinks('sa', { sourceId: 'default' });
expect(links[0].context).toBe(SURROGATE_CLEAN);
await engine.addTimelineEntry('sa', {
date: '2026-06-05', source: SURROGATE, summary: SURROGATE, detail: SURROGATE,
});
const rows = await engine.executeRaw<{ source: string }>(
`SELECT te.source FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = 'sa'`,
);
expect(rows[0].source).toBe(SURROGATE_CLEAN);
});
it('fail-closed: a lone surrogate in an IDENTITY field still rejects the batch', async () => {
// The NUL/surrogate policy deliberately leaves identity fields raw so a
// malformed slug/holder can never silently retarget a row. On Postgres the
// raw lone surrogate hits the ::jsonb cast and rejects the whole batch —
// loud failure, not silent normalization. This locks that we did NOT extend
// sanitization to identity fields.
await seed('idfrom'); await seed('idto');
await expect(
engine.addLinksBatch([
{ from_slug: `idfrom${LONE_HI}`, to_slug: 'idto', link_type: 'mentions', link_source: 'manual' },
]),
).rejects.toThrow();
});
});