Files
gbrain/src/core/batch-rows.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

186 lines
8.3 KiB
TypeScript

/**
* Shared batch-insert row builders (gbrain#1861).
*
* WHY THIS FILE EXISTS
* --------------------
* `addLinksBatch` / `addTimelineEntriesBatch` / `addTakesBatch` used to bind
* free text through `unnest(${arr}::text[])`. postgres.js serializes a JS
* string[] into a Postgres `text[]` literal (`{"...","..."}`); calendar/Zoom
* context strings (commas, quotes, braces, em-dashes) produced a literal that
* Postgres `array_in` rejected -> "malformed array literal", which aborted the
* whole `extract links --stale` sweep. The fix passes the batch as a single
* JSONB document via `jsonb_to_recordset((($1::jsonb)->'rows'))`, which encodes
* arbitrary free text safely and dodges the 65535-bind-param cap.
*
* Both engines (postgres.js and PGLite) must build the SAME row objects or they
* drift, so the object construction lives here once and both engines import it.
*
* LinkBatchInput[] ---+
* TimelineInput[] ----+--> build*Rows() --> [{...}, ...] --> { rows } wrapper
* TakeBatchInput[] ---+ | |
* sanitizeForJsonb executeRawJsonb
* free-text fields only $1::jsonb -> 'rows'
* jsonb_to_recordset(...)
*
* NUL + SURROGATE POLICY (codex P0 hardening; #2011): Postgres `jsonb` rejects
* both the Unicode NUL escape AND an unpaired UTF-16 surrogate half, and Postgres
* `text` cannot store a NUL either, so the OLD `unnest(::text[])` path rejected
* (errored) any row carrying either. We deliberately PRESERVE that reject
* semantics for IDENTITY and security-relevant fields: slugs, source_ids,
* `holder`, `kind`, dates, and the enum-ish `link_type` / `link_source` /
* `origin_slug` / `origin_field`. Those are left UN-sanitized, so junk in them
* still errors the batch and can never silently retarget a row to a different
* page/source or normalize a `holder` past the read-side `holder = ANY(allowlist)`
* privacy filter.
*
* `sanitizeForJsonb` (strip NUL + well-form lone surrogates) is applied ONLY to
* genuinely free-prose body fields where junk plausibly arrives from
* calendar/meeting/LLM/OCR content and where dropping the whole batch would be
* the worse outcome: `context` (links), `summary` + `detail` + `source`
* (timeline), `claim` + `source` (takes). A lone surrogate is normalized to
* U+FFFD; NUL is removed. Timeline `source` is free-text here (it round-trips
* arbitrary provenance labels and is treated as free text by both engines), so it
* is sanitized too even though it participates in `idx_timeline_dedup` — replacing
* a malformed half-char before dedup is correct, not lossy (#2011). Takes `source`
* is likewise free-text LLM-extracted provenance (not an identity/dedup field —
* the takes key is `(page_id, row_num)`), so it is sanitized as well. Commas, quotes,
* braces, and em-dashes are exactly what JSONB encodes correctly and are never
* touched; stripping them would corrupt user data.
*
* DEFAULTING NOTE: the builders reproduce each method's exact pre-#1861
* defaulting. `|| ''` / `|| 'markdown'` / `|| 'default'` collapse empty strings;
* `origin_slug` / `origin_field` use truthy-`|| null` (empty string -> null,
* which the LEFT JOIN treats as no-match); `link_kind` uses `?? null` (empty
* string preserved). Do NOT "simplify" `||` to `??`; it changes empty-string
* behavior.
*
* BATCH SIZE: one JSONB parameter dodges the 65535-param cap but is not
* unbounded; it has a server-side datum/parse-memory ceiling. In-tree callers
* batch small (extract links ~100/batch, NER ~500), well within budget. Direct
* engine callers passing arbitrarily large batches should chunk (~1-5K rows).
*/
import type { LinkBatchInput, TimelineBatchInput, TakeBatchInput } from './engine.ts';
import { normalizeWeightForStorage } from './takes-fence.ts';
import { ensureWellFormed } from './text-safe.ts';
/**
* Strip Unicode NUL (U+0000) from a free-text body field. Fast-path the common
* case (no NUL) so the regex replace only runs when a NUL is actually present.
* Only call this on free-prose columns, never on identity/security fields (see
* the NUL POLICY note above).
*
* This is the NUL-only primitive. For anything serialized into a `::jsonb` cast,
* prefer `sanitizeForJsonb` below — NUL alone does not protect against the lone
* surrogate that aborts a jsonb batch (#2011). Exported only as the building
* block for `sanitizeForJsonb`; don't reach for it directly on a jsonb path.
*/
export const stripNul = (s: string): string => (s.includes('\0') ? s.replace(/\0/g, '') : s);
/**
* Sanitize a free-text body field for JSONB serialization: strip NUL (#1861)
* AND well-form unpaired UTF-16 surrogates (#2011). A lone surrogate half (left
* behind when a slicer like `excerpt()` cuts a window boundary through an emoji)
* is rejected by Postgres inside a `::jsonb` cast and aborts the whole batch;
* `ensureWellFormed` replaces it with U+FFFD. Like `stripNul`, call this ONLY on
* free-prose body columns, NEVER on identity/security fields (see NUL POLICY).
*/
export const sanitizeForJsonb = (s: string): string => ensureWellFormed(stripNul(s));
/** One links row, keys === the jsonb_to_recordset column list. */
export interface LinkRow {
from_slug: string;
to_slug: string;
link_type: string;
context: string;
link_source: string;
origin_slug: string | null;
origin_field: string | null;
from_source_id: string;
to_source_id: string;
origin_source_id: string;
link_kind: string | null;
}
/** One timeline row, keys === the jsonb_to_recordset column list. */
export interface TimelineRow {
slug: string;
date: string;
source: string;
summary: string;
detail: string;
source_id: string;
}
/** One takes row, keys === the jsonb_to_recordset column list. Numbers/booleans
* stay JSON-native so the recordset can declare native column types. */
export interface TakeRow {
page_id: number;
row_num: number;
claim: string;
kind: string;
holder: string;
weight: number;
since_date: string | null;
until_date: string | null;
source: string | null;
superseded_by: number | null;
active: boolean;
}
export function buildLinkRows(links: LinkBatchInput[]): LinkRow[] {
return links.map(l => ({
from_slug: l.from_slug,
to_slug: l.to_slug,
link_type: l.link_type || '',
context: sanitizeForJsonb(l.context || ''), // free-text body: NUL + lone-surrogate sanitized
link_source: l.link_source || 'markdown',
origin_slug: l.origin_slug || null,
origin_field: l.origin_field || null,
from_source_id: l.from_source_id || 'default',
to_source_id: l.to_source_id || 'default',
origin_source_id: l.origin_source_id || 'default',
link_kind: l.link_kind ?? null,
}));
}
export function buildTimelineRows(entries: TimelineBatchInput[]): TimelineRow[] {
return entries.map(e => ({
slug: e.slug,
date: e.date,
source: sanitizeForJsonb(e.source || ''), // free-text body: NUL + lone-surrogate sanitized (#2011)
summary: sanitizeForJsonb(e.summary), // free-text body: NUL + lone-surrogate sanitized
detail: sanitizeForJsonb(e.detail || ''), // free-text body: NUL + lone-surrogate sanitized
source_id: e.source_id || 'default',
}));
}
/**
* Build takes rows AND report how many weights were clamped, so the caller can
* emit the TAKES_WEIGHT_CLAMPED stderr counter exactly as before. Weight
* normalization (clamp to [0,1] + round to 0.05 grid) stays centralized here.
*/
export function buildTakeRows(rowsIn: TakeBatchInput[]): { rows: TakeRow[]; weightClamped: number } {
let weightClamped = 0;
const rows = rowsIn.map(r => {
const { weight, clamped } = normalizeWeightForStorage(r.weight);
if (clamped) weightClamped++;
return {
page_id: r.page_id,
row_num: r.row_num,
claim: sanitizeForJsonb(r.claim), // free-text body: NUL + lone-surrogate sanitized
kind: r.kind,
holder: r.holder,
weight,
since_date: r.since_date ?? null,
until_date: r.until_date ?? null,
// free-text provenance → NUL + lone-surrogate sanitized (#2011); same
// jsonb crash class as claim. null/undefined stay null; '' stays ''.
source: r.source == null ? null : sanitizeForJsonb(r.source),
superseded_by: r.superseded_by ?? null,
active: r.active ?? true,
};
});
return { rows, weightClamped };
}