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.
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.
// 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[]>;
}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 (a thin delegation to the provider-agnostic AI gateway in src/core/ai/gateway.ts). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), 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.
+-------------------+
| 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.
Dependencies: postgres (porsager/postgres), pgvector
Postgres-specific features used:
tsvector+GINindex for full-text search withts_rankweightingpgvectorHNSW index for cosine similarity vector searchpg_trgm+GINfor 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.
Defense-in-depth layer for Postgres deployments that want the database itself
to enforce source isolation, in addition to the mandatory app-layer filters
(sourceScopeOpts — layer 1, always on).
Mechanism. With GBRAIN_RLS_SCOPE_BINDING=1 (or true), the engine's
source-scoped read methods wrap their queries in a transaction that first runs
SELECT set_config('app.scopes', $1, true) — the value is a bound parameter
(federated sourceIds CSV > scalar sourceId > '*' for unscoped internal
reads), transaction-local (equivalent to SET LOCAL, which itself can't take
bound params). An RLS policy can then filter rows by
current_setting('app.scopes', true).
Default off. With the env var unset, reads call through on the shared pool
exactly as before — no per-read transaction, no pool-slot hold (the search
methods keep the transaction they always had for their SET LOCAL statement_timeout). Existing operators see zero behavior change.
Enabling it (operator-managed SQL; gbrain ships no DDL for this):
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
CREATE POLICY pages_scope_filter ON pages
USING (current_setting('app.scopes', true) = '*'
OR source_id = ANY(string_to_array(current_setting('app.scopes', true), ',')));
-- Required: connections that don't run through the scoped read helper
-- (admin, autopilot, cycle, writes) must default to unscoped, or they
-- see zero rows once the policy exists:
ALTER ROLE <runtime-role> SET app.scopes = '*';
-- If the runtime role OWNS the table, RLS is skipped for it unless forced:
ALTER TABLE pages FORCE ROW LEVEL SECURITY;Safe to enable in either order: the env var without a policy is a no-op setting; a policy without the env var is enforced only via the role default.
Honest caveat: only read paths routed through the scoped helper carry a
per-request scope binding — unwrapped paths (writes, admin/maintenance reads)
run under the role default and are not backstopped per caller. This is layer 2;
the app-layer source filters remain layer 1 and stay mandatory. Behavioral pins
live in test/postgres-engine-rls-scope.test.ts.
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.tsfor DDL (pgvector extension, pg_trgm, triggers, indexes) - Parameterized queries throughout (shared utilities in
src/core/utils.ts) hybridSearchkeyword-only fallback whenOPENAI_API_KEYis 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.
Symptom: every PGLite-touching command dies at startup with
PGLite failed to initialize its WASM runtime … Aborted(). Build with -sASSERTIONS for more info. — commonly first seen right after a macOS
upgrade.
Real root cause: corrupt WAL/checkpoint state in the data dir after an unclean shutdown (the OS-upgrade reboot kills gbrain mid-write and tears the write-ahead log; every subsequent open fails WAL replay inside WASM and Emscripten surfaces only the opaque abort). It is not a macOS/WASM incompatibility — the same signature reproduces across macOS versions and on Linux, and rebuilding the data dir on the same OS fixes it. No pglite or Bun version bump changes it.
Recovery ladder (top rung first):
-
Auto-repair (default).
PGLiteEngine.connect()detects the abort, backs uppg_wal/+pg_controlinto a sibling<dataDir>.wal-repair-backup-<ts>/dir, resets the WAL in place (pg_resetwal semantics — data files preserved; transactions not checkpointed before the corruption may be lost), and retries once. On success it prints a loud stderr notice naming the backup and recommendinggbrain doctor. Safety bounds: repair only runs under a cleanly-acquired data-dir lock (never after reaping another process's lock), skips for a cooldown window after a failed attempt (GBRAIN_PGLITE_WAL_REPAIR_COOLDOWN_SECONDS, default 3600), reuses one backup per corruption episode (newest 3 episodes retained), and restores the original files if the retry still fails. Kill-switch:GBRAIN_PGLITE_WAL_REPAIR=off. -
Manual repair.
gbrain pglite-repair --dry-rundiagnoses the data dir (read-only);gbrain pglite-repair --yesruns the same in-place WAL reset deliberately. Refuses when another gbrain process holds the brain (a livegbrain serveis named explicitly) and never force-removes.gbrain-lock. -
Rebuild.
gbrain reinit-pglite(embedding model/dimensions default from your config) wipes and re-creates the brain from your brain repo, or manually: back up~/.gbrain, movebrain.pgliteaside,gbrain init --pglite, re-add sources,gbrain sync,gbrain embed. Required for catalog corruption (58P01 / pgvector load failure) — WAL repair cannot fix that class. -
Switch engines.
gbrain init --supabase, or native Postgres + pgvector (recipe below, contributed by @roysaurav):brew install postgresql@17 brew services start postgresql@17 createdb gbrain cd /tmp && git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git cd pgvector && make && make install psql gbrain -c "CREATE EXTENSION IF NOT EXISTS vector;" # ~/.gbrain/config.json: { "engine": "postgres", # "database_url": "postgresql://localhost:5432/gbrain" } gbrain apply-migrations --yes && gbrain doctor
gbrain doctor runs a pglite_data_dir check whenever a PGLite brain fails
to connect: it diagnoses the dir from disk, names the repair command, reports
retained repair backups, and escalates when repairs keep recurring (that
means the unclean-shutdown genesis is still active — see the ladder's rung 4).
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.stringifyform the grep misses. Sanctioned escapes:$N::text::jsonb,$N::text[],executeRawJsonb,sql.json, or an inlinejsonb-guard-okcomment.
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.
- Create
src/core/<name>-engine.tsimplementingBrainEngine - Add to engine factory in
src/core/engine-factory.ts:The factory uses dynamic imports so engines are only loaded when selected.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}`); } }
- Store engine type in
~/.gbrain/config.json:{ "engine": "myengine", ... } - Add tests. The test suite should be engine-agnostic where possible... same test cases, different engine constructor.
- Document in this file + add a design doc in
docs/
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)
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 | 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 |
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.