Skip to content

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

ts
import { createSqliteStore } from '@graphorin/store-sqlite';

const sqlite = await createSqliteStore({
  path: './assistant.db',
});

await sqlite.init(); // run pending migrations

createSqliteStore({...}) 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.

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:

ColumnMeaning
validFromWhen the fact became true (defaults to write time).
validToWhen the fact ceased to be true (NULL = still valid; closed on supersede).
createdAtWhen the row was written (immutable; SQL column created_at).
supersededByPointer to the row that replaced it (when applicable).
importanceSoft salience hint in [0, 1] used by forgetting (NULL = neutral).
provenanceOrigin tag - user / tool / extraction / reflection / induction / imported.
statusRecall-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-only entity_merges ledger - 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:

ts
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

text
./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:

bash
graphorin doctor

Recommended 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