mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
175 lines
5.7 KiB
TypeScript
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');
|
|
});
|
|
});
|