Files
gbrain/docs/ENGINES.md
T
814258dda6 v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard (#2375)
* fix(sync): op_checkpoints pin write double-encodes jsonb — every sync aborts (#2339)

recordCompleted bound JSON.stringify(array) to a $3::jsonb param via postgres.js
.unsafe(), double-encoding it into a jsonb string scalar that violates the v119
op_checkpoints_completed_keys_array CHECK — aborting every multi-source sync on
real Postgres at the first checkpoint write. PGLite parses the string silently,
which is why unit tests stayed green and it shipped. Cast through $3::text::jsonb
so the text->jsonb cast parses a genuine array. Adds a DATABASE_URL-gated parity
test + a dedicated Postgres CI job so the guard can never silently skip.

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

* fix(db): sweep positional jsonb double-encode sites + AST CI guard (#2324)

Every executeRaw/.unsafe site that bound JSON.stringify(x) to a bare positional
jsonb cast double-encodes on real Postgres (same class as #2339). Sweep them all
to the text::jsonb form across query-cache, sources-ops, llm-base,
calibration-profile, impact-capture, subagent, receipt-write, traversal-cache,
symbol-resolver, and the agent/sources commands. Adds scripts/check-jsonb-params.mjs
(AST-lite scanner for the positional form the legacy template grep misses, incl.
generic-typed calls), wired into check-jsonb-pattern.sh, with a self-test. PGLite's
native db.query is not scanned — it parses text to jsonb natively, so the bug can't
occur there.

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

* fix(search,eval): alias-hop injected results carry page_id (contradiction-probe crash)

applyAliasHop injected synthetic SearchResults without page_id (the `as
SearchResult` cast hid the missing field), so listActiveTakesForPages bound
undefined/NaN into ANY($1::int[]) and crashed the whole contradiction probe on
real Postgres. Stamp page_id=page.id at the injection site and add a finite-id
filter in generateIntraPagePairs as a defensive backstop (mirrors hybrid.ts:63).

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

* docs(engines): positional jsonb binding rule (text::jsonb vs the double-encode trap)

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

* v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard

Bumps VERSION + package.json to 0.42.53.0, adds the CHANGELOG entry, and
regenerates llms-full.txt. Ships the #2339 sync-abort hotfix, the repo-wide
positional jsonb double-encode sweep, the alias-hop contradiction-probe crash
fix, and the new positional-form CI guard.

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

* docs: post-ship sync — jsonb invariant now covers the positional form + new guard

CLAUDE.md JSONB invariant + KEY_FILES (sql-query, check-jsonb-pattern, op-checkpoint)
now describe the #2339 positional double-encode class, the $N::text::jsonb fix, and
the new check-jsonb-params.mjs guard. Regenerates llms-full.txt.

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-24 06:05:16 -07:00

13 KiB

Pluggable Engine Architecture

The idea

Every GBrain operation goes through BrainEngine. The engine is the contract between "what the brain can do" and "how it's stored." Swap the engine, keep everything else.

v0 shipped PostgresEngine backed by Supabase. v0.7 adds PGLiteEngine -- embedded Postgres 17.5 via WASM (@electric-sql/pglite), zero-config default. The interface is designed so a DuckDBEngine, TursoEngine, or any custom backend could slot in without touching the CLI, MCP server, skills, or any consumer code.

Why this matters

Different users have different constraints:

User Needs Best engine
Getting started Zero-config, no accounts, no server PGLiteEngine (default since v0.7)
Power user (you) World-class search, 7K+ pages, zero-ops PostgresEngine + Supabase
Open source hacker Single file, no server, git-friendly PGLiteEngine
Team/enterprise Multi-user, RLS, audit trail PostgresEngine + self-hosted
Researcher Analytics, bulk exports, embeddings DuckDBEngine (someday)
Edge/mobile Offline-first, sync later PGLiteEngine + sync (someday)

The engine interface means we don't have to choose. PGLite is the zero-friction default. Supabase is the production scale path. gbrain migrate --to supabase/pglite moves between them.

The interface

// src/core/engine.ts

export interface BrainEngine {
  // Lifecycle
  connect(config: EngineConfig): Promise<void>;
  disconnect(): Promise<void>;
  initSchema(): Promise<void>;
  transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T>;

  // Pages CRUD
  getPage(slug: string): Promise<Page | null>;
  putPage(slug: string, page: PageInput): Promise<Page>;
  deletePage(slug: string): Promise<void>;
  listPages(filters: PageFilters): Promise<Page[]>;

  // Search
  searchKeyword(query: string, opts?: SearchOpts): Promise<SearchResult[]>;
  searchVector(embedding: Float32Array, opts?: SearchOpts): Promise<SearchResult[]>;

  // Chunks
  upsertChunks(slug: string, chunks: ChunkInput[]): Promise<void>;
  getChunks(slug: string): Promise<Chunk[]>;

  // Links
  addLink(from: string, to: string, context?: string, linkType?: string): Promise<void>;
  removeLink(from: string, to: string): Promise<void>;
  getLinks(slug: string): Promise<Link[]>;
  getBacklinks(slug: string): Promise<Link[]>;
  traverseGraph(slug: string, depth?: number): Promise<GraphNode[]>;

  // Tags
  addTag(slug: string, tag: string): Promise<void>;
  removeTag(slug: string, tag: string): Promise<void>;
  getTags(slug: string): Promise<string[]>;

  // Timeline
  addTimelineEntry(slug: string, entry: TimelineInput): Promise<void>;
  getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]>;

  // Raw data
  putRawData(slug: string, source: string, data: object): Promise<void>;
  getRawData(slug: string, source?: string): Promise<RawData[]>;

  // Versions
  createVersion(slug: string): Promise<PageVersion>;
  getVersions(slug: string): Promise<PageVersion[]>;
  revertToVersion(slug: string, versionId: number): Promise<void>;

  // Stats + health
  getStats(): Promise<BrainStats>;
  getHealth(): Promise<BrainHealth>;

  // Ingest log
  logIngest(entry: IngestLogInput): Promise<void>;
  getIngestLog(opts?: IngestLogOpts): Promise<IngestLogEntry[]>;

  // Config
  getConfig(key: string): Promise<string | null>;
  setConfig(key: string, value: string): Promise<void>;

  // Migration + advanced (added v0.7)
  runMigration(sql: string): Promise<void>;
  getChunksWithEmbeddings(slug: string): Promise<ChunkWithEmbedding[]>;
}

Key design choices

Slug-based API, not ID-based. Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific.

Embedding is NOT in the engine. The engine stores embeddings and searches by vector, but it doesn't generate embeddings. src/core/embedding.ts handles that. This is intentional: embedding is an external API call (OpenAI), not a storage concern. All engines share the same embedding service.

Chunking is NOT in the engine. Same logic. src/core/chunkers/ handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers.

Search returns SearchResult[], not raw rows. The engine is responsible for its own search implementation (tsvector vs FTS5, pgvector vs sqlite-vss) but must return a uniform result type. RRF fusion and dedup happen above the engine, in src/core/search/hybrid.ts.

traverseGraph exists but is engine-specific. Postgres uses recursive CTEs. SQLite would use a loop with depth tracking. The interface is the same: give me a slug and max depth, return the graph.

How search works across engines

                        +-------------------+
                        |  hybrid.ts        |
                        |  (RRF fusion +    |
                        |   dedup, shared)  |
                        +--------+----------+
                                 |
                    +------------+------------+
                    |                         |
           +--------v--------+       +--------v--------+
           | engine.search   |       | engine.search   |
           |   Keyword()     |       |   Vector()      |
           +-----------------+       +-----------------+
                    |                         |
        +-----------+-----------+   +---------+---------+
        |                       |   |                   |
+-------v-------+  +-------v---+   +-------v---+  +----v--------+
| Postgres:     |  | PGLite:   |   | Postgres: |  | PGLite:     |
| tsvector +    |  | tsvector +|   | pgvector  |  | pgvector    |
| ts_rank +     |  | ts_rank   |   | HNSW      |  | HNSW        |
| websearch_to_ |  | (same SQL)|   | cosine    |  | cosine      |
| tsquery       |  |           |   |           |  | (same SQL)  |
+---------------+  +-----------+   +-----------+  +-------------+

RRF fusion, multi-query expansion, and 4-layer dedup are engine-agnostic. They operate on SearchResult[] arrays. Only the raw keyword and vector searches are engine-specific.

PostgresEngine (v0, ships)

Dependencies: postgres (porsager/postgres), pgvector

Postgres-specific features used:

  • tsvector + GIN index for full-text search with ts_rank weighting
  • pgvector HNSW index for cosine similarity vector search
  • pg_trgm + GIN for fuzzy slug resolution
  • Recursive CTEs for graph traversal
  • Trigger-based search_vector (spans pages + timeline_entries)
  • JSONB for frontmatter with GIN index
  • Connection pooling via Supabase Supavisor (port 6543)

Hosting: Supabase Pro ($25/mo). Zero-ops. Managed Postgres with pgvector built in.

Why not self-hosted for v0: The brain should be infrastructure agents use, not something you maintain. Self-hosted Postgres with Docker is a welcome community PR, but v0 optimizes for zero ops.

PGLiteEngine (v0.7, ships)

Dependencies: @electric-sql/pglite (v0.4.4+)

What it is: Embedded Postgres 17.5 compiled to WASM via ElectricSQL's PGLite. Runs in-process, no server, no Docker, no accounts. Same SQL as PostgresEngine -- not a separate dialect. All 37 BrainEngine methods implemented.

PGLite-specific details:

  • Uses pglite-schema.ts for DDL (pgvector extension, pg_trgm, triggers, indexes)
  • Parameterized queries throughout (shared utilities in src/core/utils.ts)
  • hybridSearch keyword-only fallback when OPENAI_API_KEY is not set
  • Data stored at ~/.gbrain/brain.db (configurable)
  • pgvector HNSW index for cosine similarity vector search (same as Postgres)
  • tsvector + ts_rank for full-text search (same as Postgres)
  • pg_trgm for fuzzy slug resolution (same as Postgres)

When to use PGLite vs Postgres:

Factor PGLite PostgresEngine + Supabase
Setup gbrain init (zero-config) Account + connection string
Scale Good for < 1,000 files Production-proven at 10K+
Multi-device Single machine only Any device via remote MCP
Cost Free Supabase Pro ($25/mo)
Concurrency Single process Connection pooling
Backups Manual (file copy) Managed by Supabase

Migration: gbrain migrate --to supabase exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. gbrain migrate --to pglite goes the other direction. Bidirectional, lossless.

JSONB writes: never double-encode (the #2339 trap)

Writing a JS value into a jsonb column has exactly two correct forms. Get this wrong and the write succeeds on PGLite but stores a jsonb string scalar on real Postgres — col ->> 'k' returns NULL, jsonb_array_elements throws, and a jsonb_typeof = 'array' CHECK rejects the row (this aborted every sync in #2339).

Form Verdict
Template tag: sql`... ${sql.json(obj)}` (postgres-engine only) native jsonb serialization
Positional raw call, raw object: executeRawJsonb(engine, sql, scalars, [obj]) object reaches the wire as jsonb
Positional raw call, stringified: executeRaw(\... $N::text::jsonb`, [JSON.stringify(x)])` binds as text, the cast parses it
Positional raw call, BARE cast: executeRaw(\... $N::jsonb`, [JSON.stringify(x)])` double-encodes under postgres.js .unsafe()
Template literal interpolation: `... ${JSON.stringify(x)}::jsonb` double-encodes

Why: postgres.js .unsafe(sql, params) (the path behind executeRaw / executeRawDirect) binds a JS string as a text param. A bare $N::jsonb cast then wraps that already-JSON string into a jsonb scalar string instead of parsing it. Casting through $N::text::jsonb forces a text→jsonb parse. PGLite's db.query parses text→jsonb natively, so it hides the bug — which is why a regression only shows up on Postgres (and why the parity test must run there).

Two CI guards enforce this, both wired into scripts/check-jsonb-pattern.sh:

  • the template-tag grep (${JSON.stringify(x)}::jsonb), and
  • scripts/check-jsonb-params.mjs, an AST-lite scanner for the positional $N::jsonb + JSON.stringify form the grep misses. Sanctioned escapes: $N::text::jsonb, $N::text[], executeRawJsonb, sql.json, or an inline jsonb-guard-ok comment.

The real backstop is test/e2e/op-checkpoint-jsonb-parity.test.ts + test/e2e/jsonb-roundtrip.test.ts, which round-trip writes through real Postgres and assert jsonb_typeof — the assertion PGLite cannot make.

Adding a new engine

  1. Create src/core/<name>-engine.ts implementing BrainEngine
  2. Add to engine factory in src/core/engine-factory.ts:
    export function createEngine(type: string): BrainEngine {
      switch (type) {
        case 'pglite': return new PGLiteEngine();
        case 'postgres': return new PostgresEngine();
        case 'myengine': return new MyEngine();
        default: throw new Error(`Unknown engine: ${type}`);
      }
    }
    
    The factory uses dynamic imports so engines are only loaded when selected.
  3. Store engine type in ~/.gbrain/config.json: { "engine": "myengine", ... }
  4. Add tests. The test suite should be engine-agnostic where possible... same test cases, different engine constructor.
  5. Document in this file + add a design doc in docs/

What you DON'T need to touch

  • src/cli.ts (dispatches to engine, doesn't know which one)
  • src/mcp/server.ts (same)
  • src/core/chunkers/* (shared across engines)
  • src/core/embedding.ts (shared across engines)
  • src/core/search/hybrid.ts, expansion.ts, dedup.ts (shared, operate on SearchResult[])
  • skills/* (fat markdown, engine-agnostic)

What you DO need to implement

Every method in BrainEngine. The full interface. No optional methods, no feature flags. If your engine can't do vector search (e.g., a pure-text engine), implement searchVector to return [] and document the limitation.

Capability matrix

Capability PostgresEngine PGLiteEngine Notes
CRUD Full Full Same SQL
Keyword search tsvector + ts_rank tsvector + ts_rank Identical (real Postgres)
Vector search pgvector HNSW pgvector HNSW Identical (real Postgres)
Fuzzy slug pg_trgm pg_trgm Identical (real Postgres)
Graph traversal Recursive CTE Recursive CTE Same SQL
Transactions Full ACID Full ACID Both support this
JSONB queries GIN index GIN index Identical
Concurrent access Connection pooling Single process PGLite limitation
Hosting Supabase, self-hosted, Docker Local file
Migration methods runMigration, getChunksWithEmbeddings Same Added v0.7

Future engine ideas

TursoEngine. libSQL (SQLite fork) with embedded replicas and HTTP edge access. Would give SQLite's simplicity with cloud sync. Interesting for mobile/edge use cases.

DuckDBEngine. Analytical workloads. Bulk exports, embedding analysis, brain-wide statistics. Not for OLTP. Could be a secondary engine for analytics alongside Postgres for operations.

Custom/Remote. The interface is clean enough that someone could build an engine backed by any storage: Firestore, DynamoDB, a REST API, even a flat file system. The interface doesn't assume SQL.

Note: The original SQLite engine plan (docs/SQLITE_ENGINE.md) was superseded by PGLite. PGLite uses the same SQL as Postgres, eliminating the need for a separate SQLite dialect with FTS5/sqlite-vss translation.