Persistence
Graphorin is local-first: by default, every byte the framework persists lives in a single SQLite database on the user's machine. The default storage adapter (@graphorin/store-sqlite) is built on:
better-sqlite3(MIT) - the synchronous SQLite driver.sqlite-vec(Apache-2.0 OR MIT) - the vector-search extension that backs semantic memory.- FTS5 - the SQLite-bundled full-text index that powers hybrid search.
Architecture
A single SQLite database file holds every Graphorin table. The adapter exposes typed sub-stores for memory, checkpoints, sessions, triggers, embeddings, authTokens, oauthServers, and idempotency. (The audit log is not a sub-store - it lives in its own, always-encrypted database; see below.) Each sub-store is the implementation of a contract declared in @graphorin/core/contracts (the one exception is idempotency, whose IdempotencyStore contract is declared in the adapter package itself) - your application can swap in a different adapter without touching the rest of the framework.
Wiring the default
import { createSqliteStore } from '@graphorin/store-sqlite';
const sqlite = await createSqliteStore({
path: './assistant.db',
});
await sqlite.init(); // run pending migrationscreateSqliteStore({...}) returns a typed object with memory, checkpoints, sessions, triggers, embeddings, authTokens, oauthServers, and idempotency sub-stores, the raw connection, plus top-level init() / close() methods.
Migrations
Every Graphorin package owns its own SQL migrations and registers them through the migration registry convention. On sqlite.init() the registry runs pending migrations in version order; each migration is wrapped in a transaction and recorded in the schema_migrations table.
Migrations are forward-only. Down-migrations are not supported until the framework reaches 1.0.
Hybrid search
The default is Reciprocal Rank Fusion with k=60. A different reranker (e.g. cross-encoder, LLM judge) is one call away - see Memory system for the swap.
Never VACUUM the database
FTS5 hits map back to base rows by implicit rowid, and VACUUM can renumber rowids - silently corrupting search. Use the graphorin storage encrypt / rekey maintenance path (it copies the database through the online page-level backup, preserving rowids); each open also runs a cheap FTS↔rowid integrity check and warns on drift. See Storage.
Bi-temporal storage
Fact rows in semantic memory are bi-temporal:
| Column | Meaning |
|---|---|
validFrom | When the fact became true (defaults to write time). |
validTo | When the fact ceased to be true (NULL = still valid; closed on supersede). |
createdAt | When the row was written (immutable; SQL column created_at). |
supersededBy | Pointer to the row that replaced it (when applicable). |
importance | Soft salience hint in [0, 1] used by forgetting (NULL = neutral). |
provenance | Origin tag - user / tool / extraction / reflection / induction / imported. |
status | Recall-trust gate - active or quarantined. |
Old facts are superseded, never silently overwritten - every change is auditable. Because validTo is closed on supersede, you can read memory as of any past instant (search(scope, query, { asOf }), exposed as the fact_search tool's asOf argument) and trace a single fact's full supersede chain (semantic.history(...), the fact_history tool).
Memory graph & derived stores
Beyond the bi-temporal fact rows, the memory sub-store persists two further structures (added in migrations 013-017):
- An entity graph -
entities,fact_entities, and an append-onlyentity_mergesledger - backs one-hop associative search. Entity merges are reversible and fully audited; one-hop expansion runs as a recursive CTE entirely in SQLite. - Reflection insights - synthesised higher-order knowledge with mandatory citations, kept in their own FTS-indexed table and quarantined until validated.
See Memory system for how these are produced and queried.
Optional encryption-at-rest
@graphorin/store-sqlite-encrypted is an opt-in companion that pulls in better-sqlite3-multiple-ciphers (MIT) - a drop-in fork of better-sqlite3 that bundles the SQLite3MultipleCiphers extension (SQLCipher v4 compatible). The audit log is always encrypted (mandatory); the main database is encrypted on demand by passing an encryption block to createSqliteStore:
import { resolveSecret } from '@graphorin/security';
import { createSqliteStore } from '@graphorin/store-sqlite';
const sqlite = await createSqliteStore({
path: './assistant.db',
encryption: {
enabled: true,
cipher: 'sqlcipher',
passphraseResolver: async () => {
const value = await resolveSecret('keyring:graphorin_db_key?service=graphorin');
return value.reveal();
},
},
});
await sqlite.init();Installing @graphorin/store-sqlite-encrypted registers the cipher peer driver. The package also exposes encryptDatabase(...), rekeyDatabase(...), and cipherIntegrityCheck(...) - the runners that back graphorin storage encrypt / rekey (both runners finish with an automatic integrity check of the result). The passphrase is resolved through the same SecretRef pipeline as every other secret. See Secrets.
Embedder model storage
Embeddings produced by @graphorin/embedder-transformersjs are tagged with the canonical embedder id ('<provider>:<model>@<dim>') at write time. The storage layer's EmbeddingMetaRepository enforces a lock-on-first policy by default - silent embedder swaps fail-fast with an actionable error pointing at the planned migration. See Memory system § Embedder migration.
File layout
./assistant.db - main database (memory, sessions, checkpoints, triggers, embeddings)
./assistant.db-wal - write-ahead log
./assistant.db-shm - shared-memory file
./audit.db - encrypted audit log (derived next to the main database)
./secrets.kse - encrypted-file secrets store (when used; its path comes from the secret ref)The audit log defaults to audit.db in the main database's directory (audit.path overrides it). Server deployments default the main database itself to ./.graphorin/data.db, so there everything above lands inside ./.graphorin/.
Process hardening
The CLI command graphorin doctor audits POSIX file modes on the .graphorin home - the config file, the database, the audit log, and the secrets store - and additionally checks the systemd unit hardening where applicable:
graphorin doctorRecommended defaults are 0700 for the .graphorin home directory and 0600 for the config file, the main database, the audit log, and the secrets store.
Pluggable adapters
The contracts in @graphorin/core/contracts are deliberately small. Build a non-SQLite adapter (Postgres, libSQL, DuckDB, in-memory) by implementing the MemoryStore, SessionStore, CheckpointStore, TriggerStore, AuthTokenStore, OAuthServerStore, and SecretsStore interfaces (embedding metadata and the audit database are store-sqlite internals, not core contracts). Existing packages depend only on the contracts.
For memory specifically, the core MemoryStore is the baseline: an adapter implementing only it works, and @graphorin/memory degrades gracefully (vector search, decay, consolidation, insights, graph expansion, and conflict audit switch off where the surface is absent). Full parity with the sqlite adapter is described by MemoryStoreAdapter plus the optional *MemoryStoreExt interfaces (SemanticMemoryStoreExt, ConsolidatorMemoryStoreExt, InsightMemoryStoreExt, GraphMemoryStoreExt, ProceduralMemoryStoreExt, ...) - all exported from the root of @graphorin/memory. Every Ext member is optional by contract; a type test in the memory package pins MemoryStore extends MemoryStoreAdapter so a core-only adapter can never stop compiling.
Next steps
- Memory system - what each sub-store actually stores.
- Workflow engine - durable checkpoints.
- Secrets - encrypted-file store + audit log.
- Standalone server - REST endpoints over the same storage.