/** * 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']); }, 30_000); 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'); }); });