Run contextdb in 2 minutes.
Download the latest release for your platform from GitHub Releases:
# Linux x86_64
curl -fsSL https://github.com/context-graph-ai/contextdb/releases/latest/download/x86_64-unknown-linux-gnu.tar.gz | tar xz
# Linux ARM64
curl -fsSL https://github.com/context-graph-ai/contextdb/releases/latest/download/aarch64-unknown-linux-gnu.tar.gz | tar xz
# macOS Intel
curl -fsSL https://github.com/context-graph-ai/contextdb/releases/latest/download/x86_64-apple-darwin.tar.gz | tar xz
# macOS Apple Silicon
curl -fsSL https://github.com/context-graph-ai/contextdb/releases/latest/download/aarch64-apple-darwin.tar.gz | tar xzcargo install contextdb-cliRequires Rust stable (1.75+) and Git.
git clone https://github.com/context-graph-ai/contextdb.git
cd contextdb
cargo build --release -p contextdb-clicontextdb ./my.db --write--write creates the store if it is missing and authorizes mutation. :memory: is always
writable with no flag; a file-backed store is different — see
docs/cli.md. Example tables such as decisions are yours to
define; see the README.
Try the state machine — the feature that makes contextdb different from plain SQL:
CREATE TABLE decisions (
id UUID PRIMARY KEY,
status TEXT NOT NULL,
reasoning TEXT
) STATE MACHINE (status: draft -> [active, rejected], active -> [superseded]);
INSERT INTO decisions (id, status, reasoning)
VALUES ('550e8400-e29b-41d4-a716-446655440000', 'draft', 'initial assessment');
-- Valid transition: draft -> active
UPDATE decisions SET status = 'active'
WHERE id = '550e8400-e29b-41d4-a716-446655440000';
-- Invalid transition: active -> draft (rejected by the database)
UPDATE decisions SET status = 'draft'
WHERE id = '550e8400-e29b-41d4-a716-446655440000';
-- Error: invalid state transition: active -> draftThe database enforces the state machine. No application code needed.
Audit-frozen columns — declare IMMUTABLE on any column whose value must never be silently rewritten after the initial INSERT:
CREATE TABLE audit_decisions (
id UUID PRIMARY KEY,
decision_type TEXT NOT NULL IMMUTABLE,
description TEXT NOT NULL IMMUTABLE,
status TEXT NOT NULL DEFAULT 'active'
);
INSERT INTO audit_decisions (id, decision_type, description)
VALUES ('550e8400-e29b-41d4-a716-446655440001', 'sql-migration', 'adopt contextdb');
-- Mutable column: succeeds
UPDATE audit_decisions SET status = 'superseded'
WHERE id = '550e8400-e29b-41d4-a716-446655440001';
-- Flagged column: rejected with Error::ImmutableColumn
UPDATE audit_decisions SET decision_type = 'other'
WHERE id = '550e8400-e29b-41d4-a716-446655440001';
-- Error: column `decision_type` on table `audit_decisions` is immutableThe row stays at its original decision_type; the session continues. To record a correction, INSERT a new row and mark the original superseded.
The first session already wrote ./my.db. One database file plus its .lock companion
(my.db and my.db.lock), and a third file — my.db.fabric-identity.key — once the store has
synced. Crash-safe via redb. Reopen and your data is there. Without --write the same path opens
a read-only session instead, which is the subject of the next chapter. See
docs/cli.md.
You have a contextdb store on disk — maybe your own, maybe one a running service owns — and you want to look inside without any risk of changing it. That is the default command:
contextdb ./my.dbThis opens a read-only session. Mutation needs --write; the refusals and routing are in
docs/cli.md.
contextdb(ro)> .tables
decisions
has_more: false
contextdb(ro)> .schema decisions
.schema reprints the declaration from the first session, with from-states sorted rather than
in the order you declared them.
contextdb(ro)> SELECT id, status FROM decisions;
+--------------------------------------+--------+
| id | status |
+--------------------------------------+--------+
| 550e8400-e29b-41d4-a716-446655440000 | active |
+--------------------------------------+--------+
(1 rows)
Three shapes to expect. .tables is a bounded page, so it always closes with a has_more: line.
.schema prints the from-states sorted. And an ordinary SELECT renders as a bordered table with
exactly one (N rows) footer.
The first reading command prints one route notice on stderr telling you how you are reading.
In human mode that is one sentence — reading the committed snapshot taken at 2026-08-29T06:41:47Z when nobody owns the store (you are reading a point-in-time snapshot), or
reading through the live owner's local channel when a live process owns it and is serving you
its committed state over an authenticated local channel (same commands, same shapes). Under
--json the same notice carries the machine form in detail.route ("file" or "owner") and
detail.snapshot_at (the committed moment on the file route, null on the owner route) — the
shapes are in docs/cli.md.
The read surface has exactly one reference — docs/cli.md. Rather than restate it here
and let the two copies drift, three sentences and where each one is spelled out in full:
- A result that crosses a ceiling is refused whole — never truncated, no partial rows — and
the refusal names both escapes and carries a copy-ready
.cursor open <statement>you can page it with: Ordinary results: complete or refused and Large results: the session cursor. - Which escape is honest depends on your route, so check the route notice or
.owner statusfirst: raising--read-result-rows/--read-result-bytesfor a one-shot export works on the file route only — on the owner route the ceilings are the owner's, a reader cannot raise them, and the cursor is the in-session escape. If a live process owns the store,contextdb <path>reads through that owner automatically and--writeis refused withheld_by_writer: Reading routes and Declared limits. - Scripts pass
--json: stdout is one JSON document per statement, every notice and error is a JSON document on stderr, and the process exit code is one of the four in Exit Codes.
contextdb :memory: is a disposable, always-writable playground — no file, no --write
needed, gone on exit.
contextdb is designed for agents holding tens of thousands of entities with
sub-100ms filtered retrieval. Indexes accelerate filtered scans so a
10,000-row table answers WHERE tag = 'x' in microseconds instead of
milliseconds.
-- `observations` is an example table you define - see the README.
CREATE TABLE observations (
id UUID PRIMARY KEY,
tag TEXT,
value INTEGER
);
CREATE INDEX idx_tag ON observations (tag);
INSERT INTO observations (id, tag, value)
VALUES ('650e8400-e29b-41d4-a716-446655440010', 'pay', 1);
SELECT value FROM observations WHERE tag = 'pay';Composite indexes push a matched leading prefix, not just the first column:
CREATE INDEX idx_observation_route ON observations (tag, value, id);
.explain SELECT id FROM observations WHERE tag = 'pay' AND value = 1;The explanation reports IndexScan { index: idx_observation_route } with
predicates_pushed: [tag, value]. A query whose WHERE clause does not match
the first column of any declared or auto-index reports Scan.
Vector search does not have to rank by cosine similarity alone. Declare a RANK_POLICY on the
vector column and every caller asking for the same SORT_KEY gets the same outcome-weighted
ordering — resolved at DDL time, stored with the schema, and replicated through sync — so the row
that is closest but failed sorts below the row that was less similar and worked. No
application copies formula text into its queries.
- Grammar —
RANK_POLICY,JOIN, theFORMULAplaceholders,SORT_KEY,USE RANK:docs/query-language.md. - Runnable walkthrough with the expected ordering, the same query without
USE RANKfor contrast, the join-index requirement, the HNSW/ANN caveat, and how to change a policy:skills/vector-search/SKILL.md.
contextdb is an embedded database — the CLI is for exploration. The primary interface is the Rust API:
use contextdb_engine::Database;
use contextdb_core::Value;
use std::collections::HashMap;
let db = Database::open(std::path::Path::new("./my.db"))?;
let params = HashMap::new();
db.execute(
"CREATE TABLE observations (
id UUID PRIMARY KEY,
data JSON,
embedding VECTOR(384)
) IMMUTABLE",
¶ms,
)?;
// Insert with parameters
let mut params = HashMap::new();
params.insert("id".into(), Value::Uuid(uuid::Uuid::new_v4()));
params.insert("data".into(), Value::Json(serde_json::json!({"type": "sensor"})));
params.insert("embedding".into(), Value::Vector(vec![0.1; 384]));
db.execute(
"INSERT INTO observations (id, data, embedding) VALUES ($id, $data, $embedding)",
¶ms,
)?;Add to your Cargo.toml:
[dependencies]
contextdb-engine = "1.0.0"
contextdb-core = "1.0.0"Database::open takes ownership of a store file, so one process holds it at a time. Reading is a
separate door. ReadSession::open (or ReadSession::open_with_options, which carries the
caller's own limits and deadlines) reads a store whether or not somebody owns it: it routes
through the live owner's local channel when a process owns the store, and reads the committed
file directly when none does.
ReadSession::open_owner_only is the third door — the one for questions only a running owner
can answer (what the process is doing, whether it is serving yet). It takes the owner route when a
process owns the store, and when none does it says owner_not_running rather than falling through
to the file. Four exported names, and that is the whole surface:
use contextdb_engine::{ReadSession, ReadSessionOptions};
// The live owner if one is running, the committed file if not.
let session = ReadSession::open("./my.db")?;
// The same, with the caller's own ceilings and deadlines.
let bounded = ReadSession::open_with_options("./my.db", ReadSessionOptions::default())?;
// Administrative asking and readiness probing: an owner, or nothing.
let owner = ReadSession::open_owner_only("./my.db", ReadSessionOptions::default())?;Why open_owner_only never falls through to the file, what a readiness probe would otherwise do
to the writer it is waiting for, and how a refused read splits into a stable kind() and a
machine-readable detail():
docs/architecture.md.
- Why contextdb? — the problems it solves and how it compares to alternatives
- Usage Scenarios
- Query Language — full SQL, graph, and vector reference
- Sync Across Two Machines — stand up a hub, enroll two edges, converge both ways
- CLI Reference — REPL commands, sync, scripting