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>
187 lines
8.0 KiB
TypeScript
187 lines
8.0 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);
|
||
|
||
// #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 = 'before�after';
|
||
|
||
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();
|
||
});
|
||
});
|