Pluggable Engine Architecture
The idea
Every ModusBrain operation goes throughBrainEngine. 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:
The engine interface means we don’t have to choose. PGLite is the zero-friction default. Supabase is the production scale path.
modusbrain migrate --to supabase/pglite moves between them.
The interface
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
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+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)
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.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
~/.modusbrain/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)
Migration:
modusbrain migrate --to supabase exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. modusbrain migrate --to pglite goes the other direction. Bidirectional, lossless.
JSONB writes: never double-encode (the #2339 trap)
Writing a JS value into ajsonb 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).
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.
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
- 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. - Store engine type in
~/.modusbrain/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/
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 inBrainEngine. 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
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.