mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
// gbrain#1861 regression — batch inserts must survive free-text "poison"
|
||
// payloads (calendar/Zoom context: commas, quotes, backslashes, braces,
|
||
// em-dashes) that the old unnest(${arr}::text[]) array-literal path rejected
|
||
// with Postgres "malformed array literal".
|
||
//
|
||
// PGLite half (always-on). PGLite uses a different array serializer than
|
||
// postgres.js, so it may not reproduce the ORIGINAL crash — but it locks the
|
||
// new jsonb_to_recordset path's behavior everywhere CI runs. The Postgres lane
|
||
// (test/e2e/jsonb-batch-poison-postgres.test.ts) is the one that actually
|
||
// crashed pre-fix.
|
||
|
||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||
|
||
// The exact shape from the #1861 crash report: Zoom URL with ?pwd=, commas,
|
||
// double-quotes, a Windows backslash path, braces, and an em-dash.
|
||
const POISON =
|
||
'Zoom: https://zoom.us/j/95178948505?pwd=YmdFRWxXbWZadlNkaG9iNC9CYW12QT09, ' +
|
||
'"Q2 sync" — notes {a,b}, path C:\\Users\\x, em–dash } and { trailing';
|
||
|
||
// U+0000 — Postgres jsonb rejects it; the row builders strip it. Built via
|
||
// fromCharCode so no literal NUL byte ever lands in this source file.
|
||
const NUL = String.fromCharCode(0);
|
||
|
||
// #2011 — a lone UTF-16 high surrogate (no low partner). This is what excerpt()
|
||
// left behind when a context window boundary sliced an emoji; Postgres rejects
|
||
// it inside the ::jsonb cast and aborts the whole batch. The row builders now
|
||
// well-form it to U+FFFD. Built via fromCharCode so no lone surrogate is ever a
|
||
// literal in this source file. PGLite serializes jsonb differently and may not
|
||
// reproduce the original Postgres crash, but it still locks the JS-side
|
||
// sanitization (buildLinkRows/...): the stored value must be well-formed.
|
||
const LONE_HI = String.fromCharCode(0xd83c);
|
||
const SURROGATE = `before${LONE_HI}after`;
|
||
const SURROGATE_CLEAN = 'before�after';
|
||
|
||
let engine: PGLiteEngine;
|
||
|
||
beforeAll(async () => {
|
||
engine = new PGLiteEngine();
|
||
await engine.connect({});
|
||
await engine.initSchema();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await engine.disconnect();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await resetPgliteState(engine);
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
describe('addLinksBatch — JSONB poison (#1861)', () => {
|
||
it('round-trips calendar free-text context without throwing', 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).toHaveLength(1);
|
||
expect(links[0].context).toBe(POISON); // byte-identical
|
||
});
|
||
|
||
it('strips embedded NUL but preserves the rest', 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('null origin_slug leaves origin_page_id NULL', async () => {
|
||
await seed('a'); await seed('b');
|
||
await engine.addLinksBatch([
|
||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ origin_page_id: number | null }>(
|
||
`SELECT l.origin_page_id FROM links l JOIN pages p ON p.id = l.from_page_id
|
||
WHERE p.slug = 'a'`,
|
||
);
|
||
expect(rows[0].origin_page_id).toBeNull();
|
||
});
|
||
|
||
it('collapses an intra-batch duplicate (ON CONFLICT DO NOTHING)', async () => {
|
||
await seed('a'); await seed('b');
|
||
const dup = { from_slug: 'a', to_slug: 'b', link_type: 'mentions',
|
||
context: POISON, link_source: 'manual' as const };
|
||
const n = await engine.addLinksBatch([dup, dup]);
|
||
expect(n).toBe(1);
|
||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||
expect(links).toHaveLength(1);
|
||
});
|
||
|
||
it('round-trips a non-null link_kind through the jsonb recordset', async () => {
|
||
await seed('a'); await seed('b');
|
||
await engine.addLinksBatch([
|
||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', context: POISON,
|
||
link_source: 'mentions', link_kind: 'typed_ner' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ link_kind: string | null }>(
|
||
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
|
||
WHERE p.slug = 'a'`,
|
||
);
|
||
expect(rows[0].link_kind).toBe('typed_ner'); // new recordset column, exercised non-NULL
|
||
});
|
||
|
||
it('leaves link_kind NULL when omitted (legacy/plain)', async () => {
|
||
await seed('a'); await seed('b');
|
||
await engine.addLinksBatch([
|
||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ link_kind: string | null }>(
|
||
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
|
||
WHERE p.slug = 'a'`,
|
||
);
|
||
expect(rows[0].link_kind).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('addTimelineEntriesBatch — JSONB poison (#1861)', () => {
|
||
it('round-trips free-text summary/detail/source without throwing', 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('strips embedded NUL in summary', async () => {
|
||
await seed('m');
|
||
await engine.addTimelineEntriesBatch([
|
||
{ slug: 'm', date: '2026-06-04', summary: `a${NUL}b`, detail: '' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ summary: string }>(
|
||
`SELECT te.summary FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||
WHERE p.slug = 'm'`,
|
||
);
|
||
expect(rows[0].summary).toBe('ab');
|
||
});
|
||
});
|
||
|
||
describe('addTakesBatch — JSONB poison + native-type parity (#1861)', () => {
|
||
it('round-trips free-text claim and native number/bool/null fields', 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 }, // since_date/until_date/source omitted -> null
|
||
]);
|
||
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); // free-text claim survives
|
||
expect(Number(rows[0].weight)).toBeCloseTo(0.75, 5); // 0.74 -> 0.05 grid
|
||
expect(rows[0].active).toBe(false); // JSON boolean round-trips
|
||
expect(rows[0].since_date).toBeNull(); // omitted -> SQL NULL
|
||
});
|
||
|
||
it('strips embedded NUL from the free-text claim', async () => {
|
||
await seed('tp-nul');
|
||
const pid = await pageId('tp-nul');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: `a${NUL}b`, kind: 'fact', holder: 'h' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ claim: string }>(
|
||
`SELECT claim FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE p.slug = 'tp-nul' AND t.row_num = 1`,
|
||
);
|
||
expect(rows[0].claim).toBe('ab');
|
||
});
|
||
|
||
it('clamps out-of-range weight (>1) to 1.0', async () => {
|
||
await seed('tp2');
|
||
const pid = await pageId('tp2');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h', weight: 1.5 },
|
||
]);
|
||
const rows = await engine.executeRaw<{ weight: number | string }>(
|
||
`SELECT weight FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE p.slug = 'tp2' AND t.row_num = 1`,
|
||
);
|
||
expect(Number(rows[0].weight)).toBeCloseTo(1.0, 5);
|
||
});
|
||
|
||
it('DO UPDATE upserts an existing (page_id, row_num)', async () => {
|
||
await seed('tp3');
|
||
const pid = await pageId('tp3');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: 'first', kind: 'fact', holder: 'h' },
|
||
]);
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: 'second', kind: 'fact', holder: 'h' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ claim: string; n: number | string }>(
|
||
`SELECT claim, (SELECT count(*) FROM takes t2 JOIN pages p2 ON p2.id = t2.page_id
|
||
WHERE p2.slug = 'tp3') AS n
|
||
FROM takes t JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp3' AND t.row_num = 1`,
|
||
);
|
||
expect(rows[0].claim).toBe('second');
|
||
expect(Number(rows[0].n)).toBe(1);
|
||
});
|
||
|
||
it('round-trips nullable/native take fields (superseded_by, until_date, non-null source)', async () => {
|
||
await seed('tp4');
|
||
const pid = await pageId('tp4');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h',
|
||
superseded_by: 7, until_date: '2027-01-01', source: POISON },
|
||
]);
|
||
const rows = await engine.executeRaw<{
|
||
superseded_by: number | string | null; until_date: string | null; source: string;
|
||
}>(`SELECT superseded_by, until_date::text AS until_date, source FROM takes t
|
||
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp4' AND t.row_num = 1`);
|
||
expect(Number(rows[0].superseded_by)).toBe(7); // native int column
|
||
expect(rows[0].until_date).toBe('2027-01-01'); // text/date round-trip
|
||
expect(rows[0].source).toBe(POISON); // non-null free-text source
|
||
});
|
||
});
|
||
|
||
describe('scalar addLink — NUL strip on free-text context (#1861 codex #3)', () => {
|
||
it('addLink strips NUL from context', async () => {
|
||
await seed('a'); await seed('b');
|
||
await engine.addLink('a', 'b', `before${NUL}after`, 'mentions', 'manual');
|
||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||
expect(links[0].context).toBe('beforeafter');
|
||
});
|
||
});
|
||
|
||
describe('#2011 — lone-surrogate well-forming across all prose write paths', () => {
|
||
it('batch link context: lone surrogate well-formed, insert succeeds', async () => {
|
||
await seed('a'); await seed('b');
|
||
const n = await engine.addLinksBatch([
|
||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', context: SURROGATE, link_source: 'manual' },
|
||
]);
|
||
expect(n).toBe(1);
|
||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||
expect(links[0].context).toBe(SURROGATE_CLEAN);
|
||
expect(links[0].context.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
it('batch timeline summary/detail/source: all lone surrogates well-formed', async () => {
|
||
await seed('m');
|
||
const n = await engine.addTimelineEntriesBatch([
|
||
{ slug: 'm', date: '2026-06-04', source: SURROGATE, summary: SURROGATE, detail: SURROGATE },
|
||
]);
|
||
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 = 'm'`,
|
||
);
|
||
expect(rows[0].summary).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].detail).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].source).toBe(SURROGATE_CLEAN); // D2: timeline source now sanitized
|
||
expect(rows[0].source.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
it('batch take claim: lone surrogate well-formed', async () => {
|
||
await seed('tk');
|
||
const pid = await pageId('tk');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: SURROGATE, kind: 'fact', holder: 'h' },
|
||
]);
|
||
const rows = await engine.executeRaw<{ claim: string }>(
|
||
`SELECT claim FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE p.slug = 'tk' AND t.row_num = 1`,
|
||
);
|
||
expect(rows[0].claim).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].claim.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
it('batch take source (free-text provenance): lone surrogate well-formed', async () => {
|
||
await seed('tks');
|
||
const pid = await pageId('tks');
|
||
await engine.addTakesBatch([
|
||
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h', source: SURROGATE },
|
||
]);
|
||
const rows = await engine.executeRaw<{ source: string }>(
|
||
`SELECT source FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE p.slug = 'tks' AND t.row_num = 1`,
|
||
);
|
||
expect(rows[0].source).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].source.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
it('scalar addLink: lone surrogate in context well-formed', 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);
|
||
expect(links[0].context.isWellFormed()).toBe(true);
|
||
});
|
||
|
||
it('scalar addTimelineEntry: lone surrogate in summary/detail/source well-formed', async () => {
|
||
await seed('sm');
|
||
await engine.addTimelineEntry('sm', {
|
||
date: '2026-06-05', source: SURROGATE, summary: SURROGATE, detail: SURROGATE,
|
||
});
|
||
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 = 'sm'`,
|
||
);
|
||
expect(rows[0].summary).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].detail).toBe(SURROGATE_CLEAN);
|
||
expect(rows[0].source).toBe(SURROGATE_CLEAN);
|
||
});
|
||
|
||
it('fail-closed: identity fields are NOT sanitized — a lone surrogate in to_slug rejects the batch', async () => {
|
||
// The NUL/surrogate policy deliberately leaves identity fields raw so a
|
||
// malformed slug can never be silently normalized into one that matches a
|
||
// different page. This PGLite build rejects the lone surrogate at the
|
||
// ::jsonb cast (22P02) — a loud failure, exactly the intended boundary
|
||
// (and proof we did NOT extend sanitizeForJsonb to identity columns).
|
||
await seed('idfrom'); await seed('idto');
|
||
await expect(
|
||
engine.addLinksBatch([
|
||
{ from_slug: 'idfrom', to_slug: `idto${LONE_HI}`, link_type: 'mentions', link_source: 'manual' },
|
||
]),
|
||
).rejects.toThrow();
|
||
const links = await engine.getLinks('idfrom', { sourceId: 'default' });
|
||
expect(links).toHaveLength(0);
|
||
});
|
||
});
|