Files
gbrain/test/e2e/postgres-jsonb.test.ts
T
c0b621923b fix: JSONB double-encode + splitBody wiki + parseEmbedding (v0.12.1) (#196)
* fix: splitBody and inferType for wiki-style markdown content

- splitBody now requires explicit timeline sentinel (<!-- timeline -->,
  --- timeline ---, or --- directly before ## Timeline / ## History).
  A bare --- in body text is a markdown horizontal rule, not a separator.
  This fixes the 83% content truncation @knee5 reported on a 1,991-article
  wiki where 4,856 of 6,680 wikilinks were lost.

- serializeMarkdown emits <!-- timeline --> sentinel for round-trip stability.

- inferType extended with /writing/, /wiki/analysis/, /wiki/guides/,
  /wiki/hardware/, /wiki/architecture/, /wiki/concepts/. Path order is
  most-specific-first so projects/blog/writing/essay.md → writing,
  not project.

- PageType union extended: writing, analysis, guide, hardware, architecture.

Updates test/import-file.test.ts to use the new sentinel.

Co-Authored-By: @knee5 (PR #187)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: JSONB double-encode bug on Postgres + parseEmbedding NaN scores

Two related Postgres-string-typed-data bugs that PGLite hid:

1. JSONB double-encode (postgres-engine.ts:107,668,846 + files.ts:254):
   ${JSON.stringify(value)}::jsonb in postgres.js v3 stringified again
   on the wire, storing JSONB columns as quoted string literals. Every
   frontmatter->>'key' returned NULL on Postgres-backed brains; GIN
   indexes were inert. Switched to sql.json(value), which is the
   postgres.js-native JSONB encoder (Parameter with OID 3802).
   Affected columns: pages.frontmatter, raw_data.data,
   ingest_log.pages_updated, files.metadata. page_versions.frontmatter
   is downstream via INSERT...SELECT and propagates the fix.

2. pgvector embeddings returning as strings (utils.ts):
   getEmbeddingsByChunkIds returned "[0.1,0.2,...]" instead of
   Float32Array on Supabase, producing [NaN] cosine scores.
   Adds parseEmbedding() helper handling Float32Array, numeric arrays,
   and pgvector string format. Throws loud on malformed vectors
   (per Codex's no-silent-NaN requirement); returns null for
   non-vector strings (treated as "no embedding here"). rowToChunk
   delegates to parseEmbedding.

E2E regression test at test/e2e/postgres-jsonb.test.ts asserts
jsonb_typeof = 'object' AND col->>'k' returns expected scalar across
all 5 affected columns — the test that should have caught the original
bug. Runs in CI via the existing pgvector service.

Co-Authored-By: @knee5 (PR #187 — JSONB triple-fix)
Co-Authored-By: @leonardsellem (PR #175 — parseEmbedding)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extract wikilink syntax with ancestor-search slug resolution

extractMarkdownLinks now handles [[page]] and [[page|Display Text]]
alongside standard [text](page.md). For wiki KBs where authors omit
leading ../ (thinking in wiki-root-relative terms), resolveSlug
walks ancestor directories until it finds a matching slug.

Without this, wikilinks under tech/wiki/analysis/ targeting
[[../../finance/wiki/concepts/foo]] silently dangled when the
correct relative depth was 3 × ../ instead of 2.

Co-Authored-By: @knee5 (PR #187)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: gbrain repair-jsonb + v0.12.1 migration + CI grep guard

- New gbrain repair-jsonb command. Detects rows where
  jsonb_typeof(col) = 'string' and rewrites them via
  (col #>> '{}')::jsonb across 5 affected columns:
  pages.frontmatter, raw_data.data, ingest_log.pages_updated,
  files.metadata, page_versions.frontmatter. Idempotent — re-running
  is a no-op. PGLite engines short-circuit cleanly (the bug never
  affected the parameterized encode path PGLite uses). --dry-run
  shows what would be repaired; --json for scripting.

- New v0_12_1.ts migration orchestrator. Phases: schema → repair → verify.
  Modeled on v0_12_0 pattern, registered in migrations/index.ts.
  Runs automatically via gbrain upgrade / apply-migrations.

- CI grep guard at scripts/check-jsonb-pattern.sh fails the build if
  anyone reintroduces the ${JSON.stringify(x)}::jsonb interpolation
  pattern. Wired into bun test via package.json. Best-effort static
  analysis (multi-line and helper-wrapped variants are caught by the
  E2E round-trip test instead).

- Updates apply-migrations.test.ts expectations to account for the new
  v0.12.1 entry in the registry.

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

* chore: bump version and changelog (v0.12.1)

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

* docs: update project documentation for v0.12.1

- CLAUDE.md: document repair-jsonb command, v0_12_1 migration,
  splitBody sentinel contract, inferType wiki subtypes, CI grep
  guard, new test files (repair-jsonb, migrations-v0_12_1, markdown)
- README.md: add gbrain repair-jsonb to ADMIN command reference
- INSTALL_FOR_AGENTS.md: fix verification count (6 -> 7), add
  v0.12.1 upgrade guidance for Postgres brains
- docs/GBRAIN_VERIFY.md: add check #8 for JSONB integrity on
  Postgres-backed brains
- docs/UPGRADING_DOWNSTREAM_AGENTS.md: add v0.12.1 section with
  migration steps, splitBody contract, wiki subtype inference
- skills/migrate/SKILL.md: document native wikilink extraction
  via gbrain extract links (v0.12.1+)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:14:24 +08:00

175 lines
5.7 KiB
TypeScript

/**
* E2E JSONB round-trip tests — the test that should have caught the v0.12.0
* silent-data-loss bug originally.
*
* v0.12.0-and-earlier wrote JSONB columns via `${JSON.stringify(value)}::jsonb`
* which postgres.js v3 stringified again on the wire. Result: every JSONB
* column stored a quoted-string literal instead of an object. Every
* `frontmatter->>'key'` query returned NULL. PGLite was unaffected (different
* driver path), which is why every previous unit test passed while real
* Postgres-backed brains silently lost data.
*
* These tests exercise each of the four JSONB write sites and assert that:
* 1. `jsonb_typeof(col) = 'object'` (or 'array' for array-shaped values)
* — proves the column is a real JSONB structure, not a string literal.
* 2. `col->>'key'` returns the expected scalar — proves downstream queries
* and GIN indexes will work as intended.
*
* Without these E2E assertions, the CI grep guard in scripts/check-jsonb-pattern.sh
* is the only protection — and it doesn't catch helper-wrapped or multi-line
* variants of the buggy pattern.
*
* Run: DATABASE_URL=... bun test test/e2e/postgres-jsonb.test.ts
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import {
hasDatabase, setupDB, teardownDB, getEngine, getConn,
} from './helpers.ts';
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
if (skip) {
console.log('Skipping E2E JSONB round-trip tests (DATABASE_URL not set)');
}
describeE2E('Postgres JSONB round-trip — frontmatter / data / pages_updated / metadata', () => {
beforeAll(async () => { await setupDB(); });
afterAll(async () => { await teardownDB(); });
test('pages.frontmatter — putPage stores object, not string literal', async () => {
const engine = getEngine();
const conn = getConn();
await engine.putPage('jsonb-test/frontmatter', {
type: 'concept',
title: 'JSONB roundtrip',
compiled_truth: 'body',
frontmatter: { author: 'garry', score: 7, tags: ['x', 'y'] },
});
const rows = await conn.unsafe(`
SELECT
jsonb_typeof(frontmatter) AS jt,
frontmatter->>'author' AS author,
frontmatter->>'score' AS score,
frontmatter->'tags' AS tags
FROM pages
WHERE slug = 'jsonb-test/frontmatter'
`);
expect(rows).toHaveLength(1);
expect(rows[0].jt).toBe('object');
expect(rows[0].author).toBe('garry');
expect(rows[0].score).toBe('7');
expect(rows[0].tags).toEqual(['x', 'y']);
});
test('raw_data.data — putRawData stores object, not string literal', async () => {
const engine = getEngine();
const conn = getConn();
await engine.putPage('jsonb-test/raw', { type: 'concept', title: 't', compiled_truth: '' });
await engine.putRawData('jsonb-test/raw', 'unit-test', { kind: 'fixture', count: 42 });
const rows = await conn.unsafe(`
SELECT
jsonb_typeof(rd.data) AS jt,
rd.data->>'kind' AS kind,
rd.data->>'count' AS count
FROM raw_data rd
JOIN pages p ON p.id = rd.page_id
WHERE p.slug = 'jsonb-test/raw' AND rd.source = 'unit-test'
`);
expect(rows).toHaveLength(1);
expect(rows[0].jt).toBe('object');
expect(rows[0].kind).toBe('fixture');
expect(rows[0].count).toBe('42');
});
test('ingest_log.pages_updated — logIngest stores array, not string literal', async () => {
const engine = getEngine();
const conn = getConn();
await engine.logIngest({
source_type: 'unit-test',
source_ref: 'jsonb-roundtrip',
pages_updated: ['a/b', 'c/d', 'e/f'],
summary: 'roundtrip-check',
});
const rows = await conn.unsafe(`
SELECT
jsonb_typeof(pages_updated) AS jt,
pages_updated->>0 AS first,
jsonb_array_length(pages_updated) AS len
FROM ingest_log
WHERE source_ref = 'jsonb-roundtrip'
`);
expect(rows).toHaveLength(1);
expect(rows[0].jt).toBe('array');
expect(rows[0].first).toBe('a/b');
expect(rows[0].len).toBe(3);
});
test('files.metadata — write site uses sql.json, not string interpolation', async () => {
const conn = getConn();
// Mimic the write at src/commands/files.ts:254 (the bonus fix).
await conn`
INSERT INTO files (filename, storage_path, mime_type, size_bytes, content_hash, metadata)
VALUES (
'roundtrip.bin',
'unit-test/roundtrip.bin',
'application/octet-stream',
${0},
'sha256:test',
${conn.json({ type: 'archive', upload_method: 'unit-test' })}
)
`;
const rows = await conn.unsafe(`
SELECT
jsonb_typeof(metadata) AS jt,
metadata->>'type' AS type,
metadata->>'upload_method' AS method
FROM files
WHERE storage_path = 'unit-test/roundtrip.bin'
`);
expect(rows).toHaveLength(1);
expect(rows[0].jt).toBe('object');
expect(rows[0].type).toBe('archive');
expect(rows[0].method).toBe('unit-test');
});
test('page_versions.frontmatter — INSERT...SELECT propagates object shape', async () => {
const engine = getEngine();
const conn = getConn();
await engine.putPage('jsonb-test/versioned', {
type: 'concept',
title: 'versioned',
compiled_truth: 'v1',
frontmatter: { mood: 'happy' },
});
await engine.createVersion('jsonb-test/versioned');
const rows = await conn.unsafe(`
SELECT
jsonb_typeof(pv.frontmatter) AS jt,
pv.frontmatter->>'mood' AS mood
FROM page_versions pv
JOIN pages p ON p.id = pv.page_id
WHERE p.slug = 'jsonb-test/versioned'
`);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0].jt).toBe('object');
expect(rows[0].mood).toBe('happy');
});
});