Files
gbrain/test/pglite-engine.test.ts
Garry Tan 6c7d2ed30b feat: PGLite engine — local brain, zero infrastructure (v0.7.0) (#41)
* refactor: extract shared utils, add runMigration + getChunksWithEmbeddings to BrainEngine

Extract validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult
from postgres-engine.ts into shared utils.ts. Add rowToChunk includeEmbedding
parameter for migration support.

Add two new methods to BrainEngine interface:
- runMigration(version, sql) — replaces internal eng.sql access in migrate.ts
- getChunksWithEmbeddings(slug) — returns chunks with embedding data for migration

Replace 'sqlite' with 'pglite' in EngineConfig and GBrainConfig types.
Fix loadConfig to infer engine from database_path.

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

* feat: pluggable engine factory + hybridSearch keyword-only fallback

Add createEngine() factory with dynamic imports so PGLite WASM is never
loaded for Postgres users. Wire CLI to use factory instead of hardcoded
PostgresEngine.

Force workers=1 for PGLite imports (single-connection architecture).

Fix hybridSearch to check OPENAI_API_KEY before calling embed(). When
unset, returns keyword-only results instead of throwing. Critical for
local PGLite users who don't need vector search.

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

* feat: PGLiteEngine — embedded Postgres 17.5 via WASM, same SQL everywhere

Full BrainEngine implementation (37 methods) using @electric-sql/pglite.
Same SQL as PostgresEngine — tsvector triggers, pgvector HNSW, pg_trgm
fuzzy matching, recursive CTEs, JSONB. Only the driver call syntax differs
(parameterized queries instead of tagged templates).

PGLite schema is the Postgres schema minus RLS, advisory locks, and
remote auth tables (access_tokens, mcp_request_log, files).

No server. No subscription. One directory. Works offline.

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

* feat: smart init (PGLite default) + bidirectional engine migration

gbrain init now defaults to PGLite — brain ready in 2 seconds, no
server needed. Scans target directory: <1000 .md files = PGLite,
>=1000 = suggests Supabase. --supabase and --pglite flags override.

gbrain migrate --to supabase/pglite transfers all data between engines
with manifest-based resume. Copies pages, chunks (with embeddings),
tags, timeline, raw data, links, and config. --force overwrites
non-empty target.

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

* test: 60 new tests for PGLite engine, utils, and factory

41 PGLite engine tests covering all 37 BrainEngine methods: CRUD,
tsvector keyword search, pg_trgm fuzzy matching, chunk upsert with
COALESCE, graph traversal via recursive CTE, transactions, cascade
deletes, stats/health, and embedding round-trip.

14 shared utility tests (validateSlug, contentHash, row mappers).
5 engine factory tests (dispatch, error messages).

All run in-memory — zero Docker, zero DATABASE_URL, instant in CI.

Add P0 TODO: submit Bun PR for WASM embedding in bun build --compile
(oven-sh/bun#15032).

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

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

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

* docs: update project documentation for v0.7.0 PGLite engine

- CLAUDE.md: add PGLite key files, update architecture, add migrate command, add 3 test files
- README.md: PGLite as default init, zero-config getting started, migration path to Supabase
- docs/ENGINES.md: PGLiteEngine shipped (v0.7), capability matrix, migration docs
- docs/SQLITE_ENGINE.md: marked superseded by PGLite

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

* chore: remove stale v0.4 README update prompt

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

* chore: remove SQLITE_ENGINE.md (superseded by PGLite)

PGLite uses the same SQL as Postgres, making a separate SQLite
engine unnecessary. docs/ENGINES.md covers PGLiteEngine.

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

* docs: update README step 2 to default to PGLite

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

* docs: add schema setup step and install-all-integrations step to README

Step 3 now tells agents to read GBRAIN_RECOMMENDED_SCHEMA.md and set up
the MECE directory structure before importing. Step 7 tells agents to
install every available integration recipe, not just list them.

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

* docs: update install goal to match full opinionated setup

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

* docs: add 'Need an AI agent first?' section with one-click deploy links

New users who don't have OpenClaw or Hermes Agent get pointed to
AlphaClaw on Render and the Hermes Agent Railway template. One click
each. Claude Code mentioned for users who already have it.

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

* fix: add migrate to CLI_ONLY + help output, fix standalone example

- migrate command was missing from CLI_ONLY set (errored as "Unknown command")
- migrate now shows in --help under SETUP
- init help line shows --pglite flag
- standalone CLI example uses gbrain init (not --supabase)

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

* docs: set realistic time expectation (~30 min to working brain)

DB is 2 seconds. But schema + import + embeddings + integrations
is 15-30 minutes. The agent does the work, you answer API key
questions. Don't oversell time-to-value.

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

* docs: fix AlphaClaw Render requirement (8GB+ RAM, not free tier)

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

* docs: final README polish for launch

- GOAL line: "Garry Tan's exact setup" (not Claude Code specific)
- Remove markdown links from code block (won't render)
- STEP 2 renamed from "START HERE" to "DATABASE"
- Tighten Supabase fallback text

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

* docs: remove duplicate old install block from README

The v0.5-era "With OpenClaw or Hermes Agent" paste block was
superseded by the top-level "Start here" block. Having both
confused users and the old one still said --supabase as step 2.

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

* docs: clean up README consistency and remove duplicated content

- Remove duplicate "Try it" section (old 4-act walkthrough that
  repeated the install flow and contradicted "~30 min" with "90 sec")
- Remove duplicate Setup section (third repetition of gbrain init)
- Fix brain.db → brain.pglite (actual default path)
- Fix "coming in v0.7" → "not yet implemented" (we ARE v0.7)
- Remove "You don't need Postgres" (confusing since PGLite IS Postgres)
- Deduplicate "competitive dynamics" query (appeared 3 times)
- Collapse redundant standalone CLI section

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:01:09 -10:00

496 lines
20 KiB
TypeScript

/**
* PGLite Engine Tests — validates all 37 BrainEngine methods against PGLite (in-memory).
*
* No Docker, no DATABASE_URL, no external dependencies. Runs instantly in CI.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import type { PageInput, ChunkInput } from '../src/core/types.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({}); // in-memory
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
// Helper to reset data between test groups
async function truncateAll() {
const tables = [
'content_chunks', 'links', 'tags', 'raw_data',
'timeline_entries', 'page_versions', 'ingest_log', 'pages',
];
for (const t of tables) {
await (engine as any).db.exec(`DELETE FROM ${t}`);
}
}
const testPage: PageInput = {
type: 'concept',
title: 'Test Page',
compiled_truth: 'This is a test page about NovaMind AI agents.',
timeline: '2024-01-15: Founded NovaMind',
};
// ─────────────────────────────────────────────────────────────────
// Pages CRUD
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Pages', () => {
beforeEach(truncateAll);
test('putPage + getPage round trip', async () => {
const page = await engine.putPage('test/hello', testPage);
expect(page.slug).toBe('test/hello');
expect(page.title).toBe('Test Page');
expect(page.type).toBe('concept');
expect(page.compiled_truth).toContain('NovaMind');
const fetched = await engine.getPage('test/hello');
expect(fetched).not.toBeNull();
expect(fetched!.title).toBe('Test Page');
expect(fetched!.content_hash).toBeTruthy();
});
test('putPage upserts on conflict', async () => {
await engine.putPage('test/upsert', testPage);
const updated = await engine.putPage('test/upsert', {
...testPage,
title: 'Updated Title',
});
expect(updated.title).toBe('Updated Title');
const all = await engine.listPages();
const matches = all.filter(p => p.slug === 'test/upsert');
expect(matches.length).toBe(1);
});
test('getPage returns null for missing slug', async () => {
const result = await engine.getPage('nonexistent/slug');
expect(result).toBeNull();
});
test('deletePage removes page', async () => {
await engine.putPage('test/delete-me', testPage);
await engine.deletePage('test/delete-me');
const result = await engine.getPage('test/delete-me');
expect(result).toBeNull();
});
test('listPages with type filter', async () => {
await engine.putPage('people/alice', { ...testPage, type: 'person', title: 'Alice' });
await engine.putPage('concepts/rag', { ...testPage, type: 'concept', title: 'RAG' });
const people = await engine.listPages({ type: 'person' });
expect(people.length).toBe(1);
expect(people[0].title).toBe('Alice');
});
test('listPages with tag filter', async () => {
await engine.putPage('test/tagged', testPage);
await engine.addTag('test/tagged', 'special');
const tagged = await engine.listPages({ tag: 'special' });
expect(tagged.length).toBe(1);
expect(tagged[0].slug).toBe('test/tagged');
});
test('resolveSlugs exact match', async () => {
await engine.putPage('test/exact', testPage);
const slugs = await engine.resolveSlugs('test/exact');
expect(slugs).toEqual(['test/exact']);
});
test('resolveSlugs fuzzy match via pg_trgm', async () => {
await engine.putPage('people/sarah-chen', { ...testPage, title: 'Sarah Chen' });
const slugs = await engine.resolveSlugs('sarah');
expect(slugs.length).toBeGreaterThan(0);
expect(slugs).toContain('people/sarah-chen');
});
test('updateSlug renames page', async () => {
await engine.putPage('test/old-name', testPage);
await engine.updateSlug('test/old-name', 'test/new-name');
expect(await engine.getPage('test/old-name')).toBeNull();
expect((await engine.getPage('test/new-name'))?.title).toBe('Test Page');
});
test('validateSlug rejects path traversal', async () => {
expect(() => engine.putPage('../etc/passwd', testPage)).toThrow();
});
test('validateSlug rejects leading slash', async () => {
expect(() => engine.putPage('/absolute/path', testPage)).toThrow();
});
test('validateSlug normalizes to lowercase', async () => {
const page = await engine.putPage('Test/UPPER', testPage);
expect(page.slug).toBe('test/upper');
});
});
// ─────────────────────────────────────────────────────────────────
// Search (tsvector triggers + FTS)
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Search', () => {
beforeAll(async () => {
await truncateAll();
await engine.putPage('companies/novamind', {
type: 'company', title: 'NovaMind',
compiled_truth: 'NovaMind builds AI agents for enterprise automation.',
});
await engine.upsertChunks('companies/novamind', [
{ chunk_index: 0, chunk_text: 'NovaMind builds AI agents for enterprise', chunk_source: 'compiled_truth' },
]);
await engine.putPage('concepts/rag', {
type: 'concept', title: 'Retrieval-Augmented Generation',
compiled_truth: 'RAG combines retrieval with generation for better answers.',
});
await engine.upsertChunks('concepts/rag', [
{ chunk_index: 0, chunk_text: 'RAG combines retrieval with generation', chunk_source: 'compiled_truth' },
]);
});
test('searchKeyword returns results for matching term', async () => {
const results = await engine.searchKeyword('NovaMind');
expect(results.length).toBeGreaterThan(0);
expect(results[0].slug).toBe('companies/novamind');
});
test('searchKeyword returns empty for non-matching term', async () => {
const results = await engine.searchKeyword('xyznonexistent');
expect(results.length).toBe(0);
});
test('tsvector trigger populates search_vector on insert', async () => {
// Verify the PL/pgSQL trigger fires and search_vector is populated
const results = await engine.searchKeyword('enterprise automation');
expect(results.length).toBeGreaterThan(0);
});
test('searchVector returns empty when no embeddings', async () => {
const fakeEmbedding = new Float32Array(1536);
const results = await engine.searchVector(fakeEmbedding);
expect(results.length).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────
// Chunks
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Chunks', () => {
beforeEach(truncateAll);
test('upsertChunks + getChunks round trip', async () => {
await engine.putPage('test/chunks', testPage);
await engine.upsertChunks('test/chunks', [
{ chunk_index: 0, chunk_text: 'Chunk zero', chunk_source: 'compiled_truth' },
{ chunk_index: 1, chunk_text: 'Chunk one', chunk_source: 'compiled_truth' },
]);
const chunks = await engine.getChunks('test/chunks');
expect(chunks.length).toBe(2);
expect(chunks[0].chunk_text).toBe('Chunk zero');
expect(chunks[1].chunk_text).toBe('Chunk one');
});
test('upsertChunks removes orphan chunks', async () => {
await engine.putPage('test/orphan', testPage);
await engine.upsertChunks('test/orphan', [
{ chunk_index: 0, chunk_text: 'Keep', chunk_source: 'compiled_truth' },
{ chunk_index: 1, chunk_text: 'Remove', chunk_source: 'compiled_truth' },
]);
// Re-upsert with only index 0
await engine.upsertChunks('test/orphan', [
{ chunk_index: 0, chunk_text: 'Updated', chunk_source: 'compiled_truth' },
]);
const chunks = await engine.getChunks('test/orphan');
expect(chunks.length).toBe(1);
expect(chunks[0].chunk_text).toBe('Updated');
});
test('upsertChunks throws for missing page', async () => {
await expect(
engine.upsertChunks('nonexistent/page', [
{ chunk_index: 0, chunk_text: 'test', chunk_source: 'compiled_truth' },
])
).rejects.toThrow('Page not found');
});
test('deleteChunks removes all chunks for page', async () => {
await engine.putPage('test/delete-chunks', testPage);
await engine.upsertChunks('test/delete-chunks', [
{ chunk_index: 0, chunk_text: 'Gone', chunk_source: 'compiled_truth' },
]);
await engine.deleteChunks('test/delete-chunks');
const chunks = await engine.getChunks('test/delete-chunks');
expect(chunks.length).toBe(0);
});
test('getChunksWithEmbeddings returns embedding data', async () => {
await engine.putPage('test/embed', testPage);
const embedding = new Float32Array(1536).fill(0.1);
await engine.upsertChunks('test/embed', [
{ chunk_index: 0, chunk_text: 'With embedding', chunk_source: 'compiled_truth', embedding },
]);
const chunks = await engine.getChunksWithEmbeddings('test/embed');
expect(chunks.length).toBe(1);
expect(chunks[0].embedding).not.toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────
// Links + Graph
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Links', () => {
beforeEach(async () => {
await truncateAll();
await engine.putPage('people/alice', { ...testPage, type: 'person', title: 'Alice' });
await engine.putPage('companies/acme', { ...testPage, type: 'company', title: 'ACME' });
await engine.putPage('companies/beta', { ...testPage, type: 'company', title: 'Beta' });
});
test('addLink + getLinks', async () => {
await engine.addLink('people/alice', 'companies/acme', 'works at', 'employment');
const links = await engine.getLinks('people/alice');
expect(links.length).toBe(1);
expect(links[0].to_slug).toBe('companies/acme');
});
test('getBacklinks', async () => {
await engine.addLink('people/alice', 'companies/acme');
const backlinks = await engine.getBacklinks('companies/acme');
expect(backlinks.length).toBe(1);
expect(backlinks[0].from_slug).toBe('people/alice');
});
test('removeLink', async () => {
await engine.addLink('people/alice', 'companies/acme');
await engine.removeLink('people/alice', 'companies/acme');
const links = await engine.getLinks('people/alice');
expect(links.length).toBe(0);
});
test('traverseGraph with depth', async () => {
await engine.addLink('people/alice', 'companies/acme');
await engine.addLink('companies/acme', 'companies/beta');
const graph = await engine.traverseGraph('people/alice', 2);
expect(graph.length).toBeGreaterThanOrEqual(2);
const slugs = graph.map(n => n.slug);
expect(slugs).toContain('people/alice');
expect(slugs).toContain('companies/acme');
});
});
// ─────────────────────────────────────────────────────────────────
// Tags
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Tags', () => {
beforeEach(async () => {
await truncateAll();
await engine.putPage('test/tags', testPage);
});
test('addTag + getTags', async () => {
await engine.addTag('test/tags', 'alpha');
await engine.addTag('test/tags', 'beta');
const tags = await engine.getTags('test/tags');
expect(tags).toEqual(['alpha', 'beta']);
});
test('removeTag', async () => {
await engine.addTag('test/tags', 'remove-me');
await engine.removeTag('test/tags', 'remove-me');
const tags = await engine.getTags('test/tags');
expect(tags).not.toContain('remove-me');
});
test('duplicate tag is idempotent', async () => {
await engine.addTag('test/tags', 'dup');
await engine.addTag('test/tags', 'dup');
const tags = await engine.getTags('test/tags');
expect(tags.filter(t => t === 'dup').length).toBe(1);
});
});
// ─────────────────────────────────────────────────────────────────
// Timeline
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Timeline', () => {
beforeEach(async () => {
await truncateAll();
await engine.putPage('test/timeline', testPage);
});
test('addTimelineEntry + getTimeline', async () => {
await engine.addTimelineEntry('test/timeline', {
date: '2024-01-15', summary: 'Founded', detail: 'Company founded',
});
const entries = await engine.getTimeline('test/timeline');
expect(entries.length).toBe(1);
expect(entries[0].summary).toBe('Founded');
});
test('getTimeline with date range', async () => {
await engine.addTimelineEntry('test/timeline', { date: '2024-01-01', summary: 'Jan' });
await engine.addTimelineEntry('test/timeline', { date: '2024-06-01', summary: 'Jun' });
await engine.addTimelineEntry('test/timeline', { date: '2024-12-01', summary: 'Dec' });
const filtered = await engine.getTimeline('test/timeline', {
after: '2024-03-01', before: '2024-09-01',
});
expect(filtered.length).toBe(1);
expect(filtered[0].summary).toBe('Jun');
});
});
// ─────────────────────────────────────────────────────────────────
// Raw Data, Versions, Config, IngestLog
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: RawData', () => {
beforeEach(async () => {
await truncateAll();
await engine.putPage('test/raw', testPage);
});
test('putRawData + getRawData', async () => {
await engine.putRawData('test/raw', 'crunchbase', { funding: '$10M' });
const data = await engine.getRawData('test/raw', 'crunchbase');
expect(data.length).toBe(1);
expect((data[0].data as any).funding).toBe('$10M');
});
});
describe('PGLiteEngine: Versions', () => {
beforeEach(async () => {
await truncateAll();
await engine.putPage('test/version', testPage);
});
test('createVersion + getVersions', async () => {
const v = await engine.createVersion('test/version');
expect(v.compiled_truth).toBe(testPage.compiled_truth);
const versions = await engine.getVersions('test/version');
expect(versions.length).toBe(1);
});
test('revertToVersion restores content', async () => {
await engine.createVersion('test/version');
await engine.putPage('test/version', { ...testPage, compiled_truth: 'Changed' });
const versions = await engine.getVersions('test/version');
await engine.revertToVersion('test/version', versions[0].id);
const page = await engine.getPage('test/version');
expect(page!.compiled_truth).toBe(testPage.compiled_truth);
});
});
describe('PGLiteEngine: Config', () => {
test('getConfig + setConfig', async () => {
await engine.setConfig('test_key', 'test_value');
const val = await engine.getConfig('test_key');
expect(val).toBe('test_value');
});
test('getConfig returns null for missing key', async () => {
const val = await engine.getConfig('nonexistent_key');
expect(val).toBeNull();
});
});
describe('PGLiteEngine: IngestLog', () => {
test('logIngest + getIngestLog', async () => {
await engine.logIngest({
source_type: 'git', source_ref: '/tmp/test-repo',
pages_updated: ['test/a', 'test/b'], summary: 'Imported 2 pages',
});
const log = await engine.getIngestLog({ limit: 10 });
expect(log.length).toBeGreaterThan(0);
expect(log[0].source_type).toBe('git');
});
});
// ─────────────────────────────────────────────────────────────────
// Stats + Health
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Stats & Health', () => {
beforeAll(async () => {
await truncateAll();
await engine.putPage('test/stats', testPage);
await engine.upsertChunks('test/stats', [
{ chunk_index: 0, chunk_text: 'chunk', chunk_source: 'compiled_truth' },
]);
await engine.addTag('test/stats', 'stat-tag');
});
test('getStats returns correct counts', async () => {
const stats = await engine.getStats();
expect(stats.page_count).toBe(1);
expect(stats.chunk_count).toBe(1);
expect(stats.tag_count).toBe(1);
expect(stats.pages_by_type.concept).toBe(1);
});
test('getHealth returns coverage metrics', async () => {
const health = await engine.getHealth();
expect(health.page_count).toBe(1);
expect(health.missing_embeddings).toBe(1); // chunk has no embedding
expect(health.embed_coverage).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────
// Transactions
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Transactions', () => {
beforeEach(truncateAll);
test('transaction commits on success', async () => {
await engine.transaction(async (tx) => {
await tx.putPage('test/tx-ok', testPage);
});
const page = await engine.getPage('test/tx-ok');
expect(page).not.toBeNull();
});
test('transaction rolls back on error', async () => {
try {
await engine.transaction(async (tx) => {
await tx.putPage('test/tx-fail', testPage);
throw new Error('Deliberate rollback');
});
} catch { /* expected */ }
const page = await engine.getPage('test/tx-fail');
expect(page).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────
// Cascade deletes
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: Cascade deletes', () => {
test('deleting a page cascades to chunks, tags, links', async () => {
await engine.putPage('test/cascade', testPage);
await engine.upsertChunks('test/cascade', [
{ chunk_index: 0, chunk_text: 'cascade chunk', chunk_source: 'compiled_truth' },
]);
await engine.addTag('test/cascade', 'cascade-tag');
await engine.deletePage('test/cascade');
const chunks = await engine.getChunks('test/cascade');
expect(chunks.length).toBe(0);
const tags = await engine.getTags('test/cascade');
expect(tags.length).toBe(0);
});
});