How to install, configure, and integrate smartbotic-database into your C++ project.
Add the Smartbotics repository to your system:
# Download GPG key
curl -fsSL https://repository.smartbotics.ai/smartbotics-repo.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/smartbotics-repo.gpg
# Configure authentication
sudo tee /etc/apt/auth.conf.d/smartbotics.conf << EOF
machine repository.smartbotics.ai
login callerai
password <repo-password>
EOF
sudo chmod 600 /etc/apt/auth.conf.d/smartbotics.conf
# Add repository
echo "deb [signed-by=/usr/share/keyrings/smartbotics-repo.gpg] https://repository.smartbotics.ai trixie main" | \
sudo tee /etc/apt/sources.list.d/smartbotics.list
# Install
sudo apt update
sudo apt install smartbotic-database
This installs:
/usr/bin/smartbotic-database — the server binary/etc/smartbotic-database/config.json — configuration filesmartbotic-database.service — systemd service (starts automatically)The service listens on localhost:9004 by default.
# Check service status
sudo systemctl status smartbotic-database
# Check health via CLI (if installed)
smartbotic-db-cli health
| Package | What it provides | Install when... |
|---|---|---|
smartbotic-database |
Server + systemd service | You need to run the database on this machine |
libsmartbotic-db-client |
Shared library (.so) |
Your application links the client at runtime |
libsmartbotic-db-client-dev |
Headers + cmake config | You're building a C++ project against the client API |
smartbotic-db-cli |
CLI admin tool | You want to administer the database from the command line |
sudo apt install libsmartbotic-db-client-dev
This pulls in libsmartbotic-db-client (the runtime .so) as a dependency, plus all development headers needed to compile.
sudo apt install libsmartbotic-db-client
Only the shared library — no headers, no cmake config, no dev tooling.
If libsmartbotic-db-client-dev is installed (e.g., in your Docker build image):
# CMakeLists.txt
find_package(smartbotic-db-client REQUIRED)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE smartbotic::db-client)
The smartbotic::db-client target automatically sets up include paths and links the shared library.
git submodule add ssh://git@git.smartbotics.ai:10022/fszontagh/smartbotic-database.git \
external/smartbotic-database
# CMakeLists.txt
add_subdirectory(external/smartbotic-database)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE smartbotic-db-client)
When built as a submodule, the library is static (.a) and linked directly into your binary.
Use the system package when available, fall back to submodule:
# Try system-installed first (Docker builds, CI)
find_package(smartbotic-db-client QUIET)
if(smartbotic-db-client_FOUND)
message(STATUS "Using system smartbotic-db-client")
set(DB_CLIENT_TARGET smartbotic::db-client)
else()
# Fall back to submodule (local development)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/smartbotic-database/CMakeLists.txt")
add_subdirectory(external/smartbotic-database)
set(DB_CLIENT_TARGET smartbotic-db-client)
else()
message(FATAL_ERROR
"smartbotic-database not found. Either:\n"
" apt install libsmartbotic-db-client-dev\n"
" git submodule update --init external/smartbotic-database")
endif()
endif()
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE ${DB_CLIENT_TARGET})
#include <smartbotic/database/client.hpp>
smartbotic::database::Client::Config config;
config.address = "localhost:9004"; // gRPC endpoint
config.timeoutMs = 5000; // per-RPC timeout
config.maxRetries = 3; // auto-retry on transient failures
smartbotic::database::Client db(config);
if (!db.connect()) {
// handle connection failure
}
// Insert
nlohmann::json doc = {{"name", "Alice"}, {"email", "alice@example.com"}};
std::string id = db.insert("users", doc);
// Or with explicit ID:
std::string id = db.insert("users", doc, "user-123");
// Get
auto result = db.get("users", "user-123");
if (result) {
std::cout << (*result)["name"] << std::endl;
}
// Update (full document replacement, with automatic optimistic locking)
db.update("users", "user-123", {{"name", "Alice Smith"}, {"email", "alice@example.com"}});
// Update with explicit optimistic locking (fails if version changed)
db.updateIfVersion("users", "user-123", {{"name", "Alice"}}, /*expectedVersion=*/2);
// Patch — atomic partial update (only specified fields are modified)
uint64_t newVer = db.patch("users", "user-123", {{"name", "Alice Smith"}});
// Upsert (insert or update)
auto [uid, isNew] = db.upsert("users", doc, "user-123");
// Delete
db.remove("users", "user-123");
// Check existence
bool found = db.exists("users", "user-123");
When multiple clients modify the same document concurrently, choose the right method:
patch() — use for most updates (recommended). Merges only the specified fields on the server, atomically. Two clients can safely modify different fields simultaneously:
// Client A: // Client B:
db.patch("users", "user-123", db.patch("users", "user-123",
{{"name", "Alice Smith"}}); {{"email", "alice@new.com"}});
// Result: both fields updated, nothing lost
update() — full document replacement with automatic retry. Uses optimistic locking internally (reads current version, writes with version check, retries on conflict). Use when you need to replace the entire document:
auto doc = db.get("users", "user-123");
(*doc)["name"] = "Alice Smith";
(*doc)["role"] = "admin";
db.update("users", "user-123", *doc); // replaces entire document
Note: update() is "last writer wins" for complete replacement — if two clients modify different fields via update(), the last one overwrites the other's changes. Use patch() to avoid this.
updateIfVersion() — explicit optimistic locking. Use when you need to detect conflicts and handle them yourself:
auto doc = db.get("users", "user-123");
uint64_t version = (*doc)["_version"];
(*doc)["balance"] = (*doc)["balance"].get<int>() + 100;
if (!db.updateIfVersion("users", "user-123", *doc, version)) {
// Version conflict — someone else modified the document
// Re-read and retry, or report error to user
}
Views are named, read-only projections over collections. They filter which fields are returned and optionally enforce baked-in filters and default sort order. Views are ideal for:
using Op = smartbotic::database::Client::FilterOp;
// Simple: just project fields
db.createView("users_public",
/*collection=*/"users",
/*include=*/{"id", "name", "avatar_url", "profile.public_bio"});
// Full-featured: fields + filters + default sort
db.createView("active_admins",
/*collection=*/"users",
/*include=*/{"id", "name", "role"},
/*exclude=*/{},
/*where=*/{
{"status", Op::EQ, "active"},
{"role", Op::EQ, "admin"}
},
/*defaultSort=*/smartbotic::database::Client::Sort{"name", false});
Views work like collections — use the view name in get(), find(), count():
auto user = db.get("users_public", "u1"); // projection applied
auto results = db.find("active_admins", {
.filters = {{"department", Op::EQ, "eng"}}, // AND-merged with view's where
.limit = 50
});
auto total = db.count("active_admins", {{"department", Op::EQ, "eng"}});
auto views = db.listViews();
auto info = db.getViewInfo("users_public");
db.dropView("users_public");
| Dimension | Behavior |
|---|---|
| Filters | View AND caller — view's where is always applied. Caller's filters are merged with AND. Consumers cannot escape the view's constraints. |
| Sort | Caller overrides view — if the caller specifies a sort, it wins. Otherwise the view's defaultSort is used. |
| Include | Whitelist — when non-empty, only those paths are returned. |
| Exclude | Blacklist — ignored if include is non-empty; otherwise removes listed paths. |
| Metadata | _id, _version, _created_at, _updated_at, _created_by, _updated_by are always returned regardless of include/exclude. |
| Nested paths | Dot-notation supported: user.profile.name. Stops at arrays. |
| Writes | Rejected — insert(), update(), patch(), upsert(), remove() on a view name return an error. |
| View-of-view | Not allowed — views must target real collections. |
| Project scope (2.4.2+) | Views belong to a project, exactly like collections. A view created by a client with project = "acme" is addressable only from that project, and two projects may each own a view of the same name. listViews() returns only the calling project's views. |
| Unknown name (2.4.2+) | A name matching neither a view nor a collection makes find() throw std::runtime_error (gRPC NOT_FOUND). Before 2.4.2 it returned an empty result, which was indistinguishable from an empty collection. |
View names are namespaced with the client's Config::project, so you keep
using the bare name and the client qualifies it:
Client::Config cfg;
cfg.project = "acme";
Client db(cfg);
db.createView("adults", "users", {"name", "age"}); // -> acme:adults over acme:users
auto rows = db.find("adults", {}); // resolves within acme only
A different workspace may define its own adults over its own users
without colliding, and neither can read through the other's view.
Upgrading from <= 2.4.1. Views were unreachable through the client on 2.3.0-2.4.1:
createViewregistered the bare name while every read path sent the project-qualified one, so lookups always missed and returned an empty result with no error. Existing view definitions are re-keyed into thedefaultproject automatically on first boot of 2.4.2 (idempotent, logged asViewManager: re-keyed N legacy view(s)). No operator action required. Callers that treated the empty result as "view has no rows" will start seeing real rows.
{
"version": "020",
"name": "create_active_admins_view",
"operations": [
{
"type": "create_view",
"name": "active_admins",
"collection": "users",
"include": ["id", "name", "role"],
"where": [
{"field": "status", "op": "EQ", "value": "active"},
{"field": "role", "op": "EQ", "value": "admin"}
],
"default_sort": {"field": "name", "descending": false}
}
]
}
The op field in where filters accepts either a string name (EQ, NE, GT, GTE, LT, LTE, IN, CONTAINS, EXISTS, REGEX, SEARCH) or the matching integer. Migrations are idempotent — re-running a migration that creates an existing view is a no-op.
A view's filters are applied server-side, before projection, and a consumer
filter is ANDed on top rather than replacing them — so a caller querying
active_admins cannot widen the result set beyond what the view defines. That
makes a view a reliable schema and shape contract: what comes back through
this name is always a subset of what the view allows.
A view is NOT a security boundary. Earlier revisions of this guide described view filters as "a security enforcement boundary" and compared them to row-level security. That was wrong, and should not be used as the basis of a threat model. Three reasons:
- There is no caller identity. Authentication (2.4+) compares a bearer token against a flat list of shared keys per listener. It attaches no principal to the call, so the server cannot know who is asking — and every key holder has identical, complete access.
- The underlying collection stays readable. Views only reject writes on a view name.
find("active_admins")narrows;find("users")does not. Nothing stops a client bypassing the view entirely.- Projects are not a boundary either.
Config::projectis chosen by the client, and a fully-qualifiedother_project:collectionname is accepted from any client, so namespaces isolate naming, not access.Views constrain what a cooperating client sees. Treat them as ergonomics and API hygiene. Real row- and column-level security requires a per-principal identity and a policy evaluated at every read handler; that is planned but not present today.
Related: field-level encryption (
sensitiveFields) is encryption at rest, not access control. Sensitive fields are decrypted for every authenticated reader; the protection is against someone reading data files, snapshots, or backups, not against a client.
Per-collection runtime settings. Currently supports timestamp precision; extensible for future knobs.
Every document carries _created_at and _updated_at — int64 timestamps since epoch.
Since 2.2.0 the default is nanoseconds (ns). It was milliseconds up to
2.1.x. The flip was made because collections that receive rapid-fire writes
(LLM streaming chunks, agent tool loops) saw ties at ms resolution that made
ORDER BY _created_at ambiguous; ns gives unique, strictly ordered timestamps
out of the box. Collections that already had a stored config keep their value —
only collections created after the upgrade without an explicit
configureCollection() inherit the new default.
using Cfg = smartbotic::database::Client::CollectionConfig;
// Default since 2.2.0: ns (no configuration needed)
auto id = db.insert("llm_tokens", {{"token", "hello"}});
// doc["_created_at"] is ~19 digits, nanoseconds since epoch
// Opt a collection back to milliseconds
db.configureCollection("logs", Cfg{"ms"});
// Future inserts into logs get ms-precision timestamps
auto id2 = db.insert("logs", {{"msg", "..."}});
// doc["_created_at"] is ~13 digits, milliseconds since epoch
Note:
Client::CollectionConfig::timestampPrecisionstill defaults to"ms"in the header, soCfg{}sends an explicit"ms"rather than meaning "server default". Pass the precision you want explicitly, or leave the string empty to mean "leave unchanged" (2.4.5+, see partial updates below).
auto cfg = db.getCollectionConfig("logs");
std::cout << cfg.timestampPrecision << std::endl; // "ms"
bool explicit_set = db.hasCollectionConfig("logs"); // true
bool default_only = db.hasCollectionConfig("llm_tokens"); // false (still ns default)
Version history can be switched per collection, on a collection that already exists and already holds data:
Cfg off;
off.timestampPrecision = ""; // empty = leave precision unchanged
off.versioningEnabled = false; // stop recording new versions
db.configureCollection("metrics_events", off);
// ... later, turn it back on
Cfg on;
on.timestampPrecision = "";
on.versioningEnabled = true;
db.configureCollection("metrics_events", on);
Semantics:
getVersionHistory() / getDocumentVersion(), so
re-enabling simply resumes appending.maxVersions = 0, which means "keep unlimited history".
versioningEnabled = false means "keep none"._collection_meta system collection, so
it survives restarts through the normal WAL + snapshot path.configureCollection() is a partial update. Fields you leave unset are
preserved, not reset:
timestampPrecision → precision unchangedunset versioningEnabled (std::nullopt) → versioning unchanged
Cfg justPrecision;
justPrecision.timestampPrecision = "ms"; // versioningEnabled stays nullopt
db.configureCollection("logs", justPrecision);
// -> precision becomes ms; versioning is left exactly as it was
On getCollectionConfig(), versioningEnabled is always populated with the
collection's effective value rather than the "leave unchanged" sentinel.
Before 2.4.5 this RPC replaced the whole config. That was harmless while precision was the only field, but it is why
versioning_enabledis declaredoptionalin the proto: a plain proto3 bool defaults tofalse, so any precision-only call would otherwise have silently switched versioning off.
It does not convert existing timestamps. Flipping a collection from ms to ns only affects future inserts. Pre-existing docs keep whatever precision they had. This keeps the config flip atomic and reversible.
Consequences of mixed precision within one collection:
int64 ordering within the collection is still consistent (ms values are always < 10^15, ns values are always > 10^15, so ns docs sort after ms docs). Queries don't break.When you're ready to convert legacy data, run the migration helper during a planned maintenance window:
auto result = db.migrateCollectionTimestamps("llm_tokens",
/*fromPrecision=*/"ms",
/*toPrecision=*/"ns");
if (result.success) {
std::cout << "migrated " << result.rowsMigrated
<< " rows, skipped " << result.rowsSkipped << std::endl;
} else {
std::cerr << "failed: " << result.error << std::endl;
}
Semantics:
ms → ns: multiply _created_at / _updated_at by 10^6ns → ms: divide by 10^6 (truncation — loses sub-ms precision)rows_skipped.from == to is a no-op success.configureCollection(name, {ns}) — atomic config flip; future writes immediately use ns.migrateCollectionTimestamps(name, "ms", "ns") — converts existing docs._created_at values are now in the ns range.Between steps 2 and 3, new writes are ns and old docs are ms — this is temporary but normal. Ordering still works because the 10^15 threshold cleanly separates the two ranges.
To set timestamp precision declaratively, call db.configureCollection() from your consumer project's startup code. The configure_collection migration operation will be available in a future version.
{
"version": "021",
"name": "llm_tokens_ns_precision",
"operations": [
{
"type": "configure_collection",
"collection": "llm_tokens",
"config": { "timestamp_precision": "ns" }
}
]
}
smartbotic-database uses WAL + snapshots for durability. When starting up, the server tries to recover state from the newest snapshot and replay WAL entries since that snapshot. If something goes wrong, it uses a tiered recovery system modeled on MySQL's innodb_force_recovery.
| Level | Name | Behavior |
|---|---|---|
| 0 | normal |
Load latest snapshot + replay WAL. Default. If auto_escalate=true (default), falls back to older snapshot automatically when latest is corrupt. |
| 1 | snapshot_fallback |
Try snapshots newest→oldest, first that loads wins. |
| 2 | wal_only |
Ignore snapshots, replay WAL from sequence 0. Slow on large DBs. |
| 3 | best_effort |
Try snapshot_fallback, then fall through to wal_only. |
| 4 | force_empty |
Start empty. Snapshots + WAL preserved on disk for forensics. |
Operator escalation path when recovery fails:
# After a refuse-to-start, try older snapshots
smartbotic-database --recovery-mode=snapshot_fallback
# If that still fails, replay WAL from scratch (slow)
smartbotic-database --recovery-mode=wal_only
# Last-ditch: try everything
smartbotic-database --recovery-mode=best_effort
# Absolute last resort: start empty (data preserved on disk)
smartbotic-database --recovery-mode=force_empty
Or set in config.json:
"persistence": {
"recovery": {
"mode": "normal",
"auto_escalate": true,
"allow_empty_on_fresh_install": true
}
}
The CLI flag overrides the config for one-shot recoveries.
If recovery used anything other than "latest snapshot loaded cleanly," the DB boots in read-only mode. This is a safety feature — writing onto a possibly-stale state can corrupt your data further. The operator must explicitly acknowledge the state before writes resume.
On startup you'll see (at ERROR level):
[ERROR] Database booted in READ-ONLY mode after non-trivial recovery
Reason: fell back to snapshot snapshot-20260419-090719.dat because
snapshot-20260419-100747.dat failed: body truncated
Writes will be REJECTED until you acknowledge this state:
smartbotic-db-cli unlock # live, no restart
smartbotic-database --force-readwrite # on next restart
/var/lib/smartbotic-database/ before anything else.normal). It will either recover cleanly or refuse with a clear banner. Use smartbotic-db-cli status to see what happened.--recovery-mode=snapshot_fallback (and upwards as needed).smartbotic-db-cli unlock to resume writes./var/lib/smartbotic-database/ and start clean.// Acknowledge auto-readonly state and resume writes
db.unlock();
// Force read-only for maintenance (regardless of recovery outcome)
db.lock();
// Inspect state + recovery outcome
auto s = db.getReadOnlyStatus();
std::cout << "read-only: " << s.readOnly << "\n"
<< "reason: " << s.reason << "\n"
<< "recovery outcome: " << s.recoveryOutcome << "\n"
<< "expected snap: " << s.expectedSnapshot << "\n"
<< "snapshot used: " << s.snapshotUsed << "\n"
<< "failure reason: " << s.failureReason << "\n"
<< "WAL replayed: " << s.walEntriesReplayed << "\n"
<< "snapshots tried: " << s.snapshotsAttempted << "\n";
# Inspect read-only + recovery state
smartbotic-db-cli status
# Acknowledge non-trivial recovery and accept writes
smartbotic-db-cli unlock
# Manually lock (e.g. before a maintenance window)
smartbotic-db-cli lock
Under persistence.snapshots:
"snapshots": {
"validate_after_write": true, // verify every snapshot immediately after creation
"cleanup_only_if_verified": true // never evict old snapshots if the new one failed validation
}
Both default to true — turn off only if you really know what you're doing (disables protections against silent writer corruption).
| Code | Meaning |
|---|---|
0 |
Normal shutdown |
1 |
Generic error |
10 |
Recovery refused. Snapshot or WAL failure + mode=normal + data exists on disk. Operator must escalate the recovery mode or pass --force-readwrite. |
11 |
Config invalid (e.g. bad --recovery-mode value) |
--force-readwriteBypasses the auto-readonly guard on non-trivial recovery. Use after you've reviewed the recovery state via smartbotic-db-cli status and are certain writes onto that state are safe.
# After confirming recovery state looks good
smartbotic-database --force-readwrite
// Find with filters (equality is default)
smartbotic::database::Client::QueryOptions opts;
opts.filters = {
{"status", "active"}, // EQ (default) — 2-arg Filter constructor
{"role", "admin"}, // EQ
};
opts.sortField = "name";
opts.sortDescending = false;
opts.limit = 50;
opts.offset = 0;
auto docs = db.find("users", opts);
for (const auto& doc : docs) {
std::cout << doc["name"] << std::endl;
}
// Find all documents in a collection
auto allDocs = db.find("users");
// Count
uint64_t total = db.count("users");
uint64_t admins = db.count("users", {{"role", "admin"}});
Filters support a range of operators via Client::FilterOp. The 2-argument
Filter(field, value) constructor defaults to EQ — existing brace-init code
continues to compile and behave the same. To use other operators, pass the
operator explicitly with the 3-argument constructor:
using Op = smartbotic::database::Client::FilterOp;
smartbotic::database::Client::QueryOptions opts;
opts.filters = {
{"age", Op::GTE, 18}, // age >= 18
{"status", Op::NE, "deleted"}, // status != "deleted"
{"tags", Op::CONTAINS, "admin"}, // tags array contains "admin"
{"role", Op::IN, nlohmann::json::array({"admin", "moderator"})}, // role IN [...]
{"email", Op::REGEX, ".*@example\\.com$"}, // regex match
{"name", Op::SEARCH, "alice"}, // full-text search
{"deleted_at", Op::EXISTS, false}, // field does NOT exist
};
auto docs = db.find("users", opts);
| Operator | Semantics | Value type |
|---|---|---|
FilterOp::EQ |
field equals value | any JSON |
FilterOp::NE |
field does not equal value | any JSON |
FilterOp::GT |
field > value | number or string |
FilterOp::GTE |
field >= value | number or string |
FilterOp::LT |
field < value | number or string |
FilterOp::LTE |
field <= value | number or string |
FilterOp::IN |
field value appears in the supplied array | JSON array |
FilterOp::CONTAINS |
field (array) contains the supplied value | any JSON |
FilterOp::EXISTS |
field presence check (true = must exist, false = must not) |
bool |
FilterOp::REGEX |
field matches the supplied regex pattern | string |
FilterOp::SEARCH |
full-text search across document ID and string fields | string |
Note: the old _search magic field name that auto-selected SEARCH is no
longer special — use FilterOp::SEARCH explicitly instead.
// Create a basic collection
db.createCollection("users");
// Create with options
db.createCollection("sessions",
/*defaultTtlSeconds=*/3600, // auto-expire after 1 hour
/*encrypted=*/false,
/*maxVersions=*/5, // keep last 5 versions
/*vectorDimension=*/0); // no vector support
// Create vector-enabled collection
db.createCollection("memories",
/*defaultTtlSeconds=*/0,
/*encrypted=*/false,
/*maxVersions=*/0,
/*vectorDimension=*/384); // 384-dim embeddings
// List and inspect
auto collections = db.listCollections();
auto info = db.getCollectionInfo("users");
if (info) {
std::cout << "Documents: " << info->documentCount << std::endl;
}
// Drop
db.dropCollection("old_data");
Collection names are namespaced with the client's Config::project, exactly
like documents. You keep passing bare names; the client qualifies them on the
wire and un-qualifies them coming back, so listCollections() round-trips:
Client::Config cfg;
cfg.project = "acme";
Client db(cfg);
db.createCollection("users"); // -> acme:users
db.insert("users", {{"name", "Ada"}}); // -> acme:users
auto info = db.getCollectionInfo("users"); // -> acme:users
db.listCollections(); // -> { "users", ... }
Fixed in 2.4.5 — upgrade if you call
createCollection. On 2.3.0-2.4.4createCollection,dropCollectionandgetCollectionInfosent the bare name whileinsert/get/findsent the project-qualified one. Because the client qualifies againstConfig::projectunconditionally — includingdefault— every project was affected. OnecreateCollection("things")plus oneinsert("things")created two collections: the bare one received the options and stayed empty, while<project>:thingswas created implicitly by the insert with defaults. No error was raised.Concretely, on affected versions:
vectorDimensionnever reached the collection holding the data, sosimilaritySearch()returned no hits.encrypted = truesilently did not apply.dropCollection()returnedtruewhile every document remained readable — it dropped the empty phantom.After upgrading: leftover empty phantom collections are harmless litter, but options that were swallowed do not retroactively apply.
createCollectionon a now-correctly-resolved existing collection returnsfalseand only updatesmaxVersions. SincevectorDimensionis immutable after creation, a collection that needs vector search must be recreated: create a new collection with the rightvectorDimension, copy the documents across, then drop the old one.
Note that listCollections() currently returns every project's collections —
names outside your own project stay visibly qualified rather than being
flattened into yours. Unlike ListViewsRequest, ListCollectionsRequest has no
project filter yet.
Requires a collection created with vectorDimension > 0.
// Insert documents with embeddings
nlohmann::json doc = {
{"id", "mem-1"},
{"content", "The user prefers dark mode"},
{"_vector", embedding} // std::vector<float>, size must match vectorDimension
};
db.insert("memories", doc, "mem-1");
// Search
std::vector<float> queryVec = getEmbedding("user preferences");
auto results = db.similaritySearch("memories", queryVec, /*topK=*/5, /*minScore=*/0.7f);
for (const auto& r : results) {
std::cout << r.id << " (score=" << r.score << "): "
<< r.data["content"] << std::endl;
}
Notes:
_vector is extracted and stored separately — not returned in get() or find() responses// Get history
auto history = db.getVersionHistory("users", "user-123", /*limit=*/10);
for (const auto& v : history.versions) {
std::cout << "v" << v.version << " by " << v.updatedBy
<< " at " << v.timestamp << std::endl;
}
// Get a specific version
auto oldVersion = db.getDocumentVersion("users", "user-123", /*version=*/2);
// Restore to a previous version
uint64_t newVer = db.restoreVersion("users", "user-123", /*version=*/2);
// Restore to a point in time
auto [fromVer, newVersion] = db.restoreToDate("users", "user-123", timestamp);
Two-level storage with content-addressed blob deduplication. Identical files are stored once.
Files are namespaced by project, like collections. The client fills the project
from Config::project on every file call, so you keep passing bare ids:
Client::Config cfg;
cfg.project = "acme";
Client db(cfg);
auto up = db.uploadFile(data, meta); // owned by project "acme"
db.getFileInfo(up.id); // resolves
db.listFiles("document"); // only acme's files
An id belonging to another project is reported as NOT_FOUND, not as
"forbidden" — getFileInfo returns nullopt, downloadFile throws,
deleteFile returns false, and listFiles omits it. The indistinguishability
is deliberate: a distinct "exists but not yours" answer would confirm that a peer
project holds that file.
Before 2.6.0, files had no project at all — one shared blob tree, one flat record tree, and no
projectfield on any file RPC. Any client could read, list, or delete any file by id. On upgrade, existing records are stamped withproject: "default"automatically (idempotent, logged at INFO with a count). If your consumer used a non-default project and uploaded files, those files are now owned bydefaultand will not be visible to that project — move them by re-uploading under the intended project.
FileRecord gained a project member, which changes the struct size, so
consumers must rebuild against 2.6.0 headers.
result.deduplicated means this project already referenced those bytes. Blob
storage still dedups globally, so the disk saving is unchanged, but the flag is
scoped deliberately: reporting the global hit would let you upload a candidate
file and learn from the flag whether some other project holds it.
// Upload
std::vector<uint8_t> data = readFile("photo.jpg");
smartbotic::database::Client::FileUploadMeta meta;
meta.name = "photo.jpg";
meta.mime_type = "image/jpeg";
meta.file_type = "document"; // "plugin", "document", "generated"
meta.related_id = "conversation-1";
meta.is_public = false;
auto result = db.uploadFile(data, meta);
std::cout << "File ID: " << result.id
<< " Deduplicated: " << result.deduplicated << std::endl;
// Download
auto fileData = db.downloadFile(result.id);
// Get file info
auto fileInfo = db.getFileInfo(result.id);
// List files
auto files = db.listFiles(
/*file_type=*/"document",
/*related_id=*/"conversation-1",
/*limit=*/100, /*offset=*/0,
/*checksum=*/"",
/*name=*/"photo.jpg");
// Delete
db.deleteFile(result.id);
auto subscription = db.subscribe({"users", "sessions"}, [](
const std::string& collection,
const std::string& id,
const std::string& eventType,
const std::optional<nlohmann::json>& data) {
std::cout << eventType << " on " << collection << "/" << id << std::endl;
});
// Unsubscribe by releasing the handle
subscription.reset();
Redis-compatible set operations on top of documents.
db.setAdd("tags", "user-123", "admin");
db.setAdd("tags", "user-123", "active");
auto members = db.setMembers("tags", "user-123");
bool isAdmin = db.setIsMember("tags", "user-123", "admin");
db.setRemove("tags", "user-123", "active");
// Quick health check
if (db.healthCheck()) {
std::cout << "Database is healthy" << std::endl;
}
// Detailed health
auto health = db.getHealthInfo();
if (health) {
std::cout << "Uptime: " << health->uptimeMs << "ms"
<< " Docs: " << health->documentCount << std::endl;
}
// Full statistics
auto stats = db.getStats();
if (stats) {
std::cout << "Memory: " << stats->memoryUsedBytes << " bytes"
<< " Insert avg: " << stats->insertAvgMicros() << " us" << std::endl;
}
The server configuration file is at /etc/smartbotic-database/config.json.
{
"log_level": "info",
"storage": {
"bind_address": "localhost",
"rpc_port": 9004,
"node_id": "smartbotic-db",
"data_directory": "/var/lib/smartbotic-database",
"memory": {
"max_memory_mb": 512,
"eviction_threshold_percent": 80,
"eviction_target_percent": 60,
"eviction_check_interval_ms": 5000
},
"persistence": {
"wal_sync_interval_ms": 100,
"snapshot_interval_sec": 3600,
"compression": "lz4"
},
"encryption": {
"enabled": true,
"key_file": "/var/lib/smartbotic-database/storage.key",
"auto_generate_key": true
},
"migrations": {
"enabled": false,
"directory": "/etc/smartbotic-database/migrations",
"auto_apply": false
},
"files": {
"max_file_size_mb": 500,
"cleanup_orphans_interval_sec": 3600
},
"replication": {
"enabled": false
}
}
}
| Setting | Default | Description |
|---|---|---|
bind_address |
localhost |
Change to 0.0.0.0 to accept remote connections |
rpc_port |
9004 |
gRPC listen port |
data_directory |
/var/lib/smartbotic-database |
WAL, snapshots, files stored here |
memory.max_memory_mb |
512 |
Maximum memory for document cache. Evicts LRU when exceeded |
memory.eviction_max_episode_percent |
50 |
(2.4.3+) Ceiling on the share of the resident set one pressure episode may evict. 0 disables. See the drain-cap note below |
persistence.snapshot_interval_sec |
3600 |
Full snapshot every N seconds (WAL truncated after) |
encryption.enabled |
true |
Field-level AES-256-GCM encryption. Key auto-generated on first start |
migrations.enabled |
false |
Enable to auto-apply JSON migrations on startup |
migrations.directory |
/etc/smartbotic-database/migrations |
Path to migration JSON files |
Under storage.grpc (added in v1.6.2):
| Setting | Default | Purpose |
|---|---|---|
max_receive_message_size_mb |
100 |
Max inbound gRPC message size. Raise for larger file uploads. |
max_send_message_size_mb |
100 |
Max outbound gRPC message size. |
resource_quota_memory_mb |
256 |
Global memory budget for gRPC inbound buffers across all RPCs combined. |
max_concurrent_subscribe_streams |
50 |
Max simultaneous Subscribe event-stream clients. Excess gets RESOURCE_EXHAUSTED. |
max_concurrent_file_streams |
10 |
Max simultaneous UploadFile + DownloadFile streams (shared pool). |
When a concurrency limit is hit, the server returns RESOURCE_EXHAUSTED immediately and logs WARN with the current / max counts. Clients should retry with backoff.
In addition to /etc/smartbotic-database/config.json, the server loads any *.json files from /etc/smartbotic-database/conf.d/ and deep-merges them into the running config in lexicographic order.
This lets consumer projects (shadowman-cpp, callerai, your own apps) ship their own tuning without touching the upstream config.json that dpkg --configure guards as a conffile.
Example:
// /etc/smartbotic-database/config.json
{
"log_level": "info",
"storage": {
"rpc_port": 9004,
"memory": { "max_memory_mb": 512 }
}
}
// /etc/smartbotic-database/conf.d/50-shadowman.json (shipped by shadowman-cpp's deb)
{
"storage": {
"memory": { "max_memory_mb": 2048 }
}
}
// Effective merged config — sibling rpc_port preserved, max_memory_mb overridden
{
"log_level": "info",
"storage": {
"rpc_port": 9004,
"memory": { "max_memory_mb": 2048 }
}
}
Use numeric prefixes for deterministic merge order. Last writer wins.
| File | Owner | Purpose |
|---|---|---|
00-defaults.json |
smartbotic-database (optional) | Baseline conservative tunings |
50-<project>.json |
consumer project's deb postinst | Workload-tuned values |
99-local.json |
operator | Site-specific overrides |
Files are merged in filename-sort order, so 00- applies before 50- applies before 99-.
config.json — OK, treated as {}conf.d/ directory — OK11, log identifies the file + parser locationWARN but don't block startup. This lets v1.x consumers drop a conf.d/ file that mentions a key only v1.(x+1) knows aboutAt startup, the server prints each file as it merges it:
[INFO] Loading base config: /etc/smartbotic-database/config.json
[INFO] Merging drop-in: /etc/smartbotic-database/conf.d/50-shadowman.json
[INFO] Merging drop-in: /etc/smartbotic-database/conf.d/99-local.json
If a consumer wonders which tuning values are actually in effect, this is where to look.
smartbotic-database holds documents in memory for fast reads. When usage approaches the configured budget (storage.memory.max_memory_mb), eviction removes the least-useful docs to free space. Eviction is chunked, throttled, and hot-write-aware so it doesn't block foreground writes — a design requirement after the 2026-04-22 Zoe incident where one-shot eviction stalled LLM streaming writes long enough to miss their gRPC deadline.
The server tracks four pressure levels based on current usage vs. max_memory_mb:
| Level | Default % | Behavior |
|---|---|---|
normal |
< 70% | No eviction |
soft |
70–85% | Trickle eviction: one chunk per tick |
hard |
85–95% | Aggressive eviction: up to max_eviction_passes_per_trigger / 2 chunks per tick. Fires MEMORY_PRESSURE_HIGH event (edge-triggered) |
emergency |
≥ 95% | All writes rejected with RESOURCE_EXHAUSTED until pressure drops below hard. Eviction runs at full rate |
Query current state via smartbotic-db-cli status or the GetMemoryStats RPC.
Eviction assumes the memory estimate falls as documents leave. When memory is held by something eviction cannot free, that assumption breaks: the target is never reachable, and eviction keeps trimming until the cache is empty. On 2026-08-03 a production instance did exactly this — 5996 of ~5990 documents evicted over 30 minutes while the estimate rose (347 → 358 MB). Every collection then reported 0 docs, which looks indistinguishable from data loss. No data was actually lost; it was all still in LMDB.
Two guards now bound this:
memory.eviction_max_episode_percent (default 50%) of the resident set it
started with, enforced per chunk. An episode re-arms only after pressure has
been normal for 3 consecutive ticks.Both log at ERROR. Hitting either means the memory estimate is wrong, not
that the store is oversized — treat it as an alert, raise
storage.memory.max_memory_mb, and check what is holding memory. Set
eviction_max_episode_percent: 0 to disable the cap (not recommended).
Note for v2.0+. MemoryStore is a cache in front of LMDB; the durable copy lives in
<dataDir>/projects/<name>/env/. Eviction draining the cache only becomes visible if the LMDB mirror is also unhealthy, which is what made the above incident look total. Watch forv2.0 mirror failedin the log.
Eviction never stops the world. Each tick (default every eviction_check_interval_ms = 5000ms, but reactive to pressure):
updatedAt is older than hot_write_floor_ms (30 s default) ago, in non-pinned collections(memory_priority, lastAccessedAt) — low-priority + cold goes first(share × 1.5) of a single chunkeviction_chunk_size docs (default 1000) in one passeviction_chunk_pause_ms (default 50 ms)max_eviction_passes_per_trigger times, stopping early if target reachedDocs being actively written (quiesce) are skipped via an in-flight-write counter — eviction can't land mid-upsert.
Use CollectionOptions.memory_priority to bias eviction:
| Value | Meaning |
|---|---|
low |
Evicted preferentially — cold archive collections |
normal (default) |
Standard LRU weight |
high |
Evicted last — hot operational collections |
| (pinned=true on CollectionOptions) | Never evicted |
Set via createCollection or migration:
{
"type": "create_collection",
"collection": "messages",
"options": {
"memory_priority": "high"
}
}
storage.memory)| Setting | Default | Meaning |
|---|---|---|
max_memory_mb |
512 | Memory budget before eviction kicks in |
eviction_chunk_size |
1000 | Docs evicted per chunk |
eviction_chunk_pause_ms |
50 | Sleep between chunks within one tick |
max_eviction_passes_per_trigger |
20 | Safety cap on chunks per tick |
hot_write_floor_ms |
30000 | Docs updated within this window are unevictable |
memory_soft_percent |
70 | Enter soft pressure |
memory_hard_percent |
85 | Enter hard pressure — fires PRESSURE_HIGH event |
memory_emergency_percent |
95 | Enter emergency — admission control rejects writes |
eviction_burst_threshold |
10000 | Fire MEMORY_EVICTION_BURST event if chunk ≥ this |
eviction_target_percent |
60 | Stop evicting once usage drops below this |
eviction_check_interval_ms |
5000 | Wake eviction loop this often |
Subscribe to receive memory-related notifications:
| Event type | When | Payload |
|---|---|---|
MEMORY_PRESSURE_HIGH |
Edge-triggered: pressure enters hard or emergency |
{pressure_level, pressure_percent, estimated_bytes, max_bytes} |
MEMORY_EVICTION_BURST |
An eviction pass touched ≥ eviction_burst_threshold docs |
{evicted_docs, bytes_freed, pressure_level} |
Both are system-level events (empty collection / id). Subscribers filter by event type.
The client library (v1.7.0+) automatically retries write RPCs that return transient errors:
| Status | Retry? |
|---|---|
DEADLINE_EXCEEDED |
✅ |
RESOURCE_EXHAUSTED (admission control / concurrency cap) |
✅ |
UNAVAILABLE |
✅ |
| All others (INVALID_ARGUMENT, FAILED_PRECONDITION, etc.) | ❌ — final |
Config under Client::Config:
| Field | Default | Meaning |
|---|---|---|
writeRetries |
3 | Max retry attempts |
writeRetryBackoffMs |
100 | Initial backoff (exponential: 100 → 200 → 400 …) |
writeRetryMaxBackoffMs |
5000 | Cap on any single backoff |
writeRetryJitter |
0.25 | ± 25% jitter |
Writes wrapped: insert, updateIfVersion, upsert, remove, patch. Reads are NOT auto-retried — callers retry at their discretion.
GetMemoryStats RPC (and Client::getMemoryStats()) returns:
document_count, estimated_bytes, evicted_stub_count, priorityLast eviction: timestamp, docs evicted, bytes freed
auto s = db.getMemoryStats();
std::cout << "Memory: " << s.totalMemoryBytes << "/" << s.maxMemoryBytes
<< " (" << s.pressurePercent << "% — " << s.pressureLevel << ")" << std::endl;
for (const auto& c : s.collections) {
std::cout << " " << c.collection << ": " << c.documentCount << " docs, "
<< c.estimatedBytes << " bytes, priority=" << c.priority << std::endl;
}
Also shown in smartbotic-db-cli status output (if the CLI exposes it; otherwise subscribers can consume the events).
2 GB — production shadowman-cpp profile:
// /etc/smartbotic-database/conf.d/50-shadowman.json
{
"storage": {
"memory": {
"max_memory_mb": 2048,
"memory_soft_percent": 65,
"memory_hard_percent": 80,
"eviction_chunk_size": 500,
"hot_write_floor_ms": 60000
}
}
}
8+ GB large — consider raising eviction_burst_threshold to 50000 so you don't get event spam, and consider hot_write_floor_ms: 120000 if your write paths are slow (e.g. STT-heavy callerai)
"Client::insert failed: RESOURCE_EXHAUSTED (memory pressure emergency)" — server is in emergency pressure, client is retrying. If this persists, raise max_memory_mb in a conf.d drop-in or lower memory_emergency_percent.
"Eviction: nothing evictable this pass" — every candidate is either pinned, hot-written, or in-flight. Usually benign; server will re-try next tick. If persistent, consider reducing hot_write_floor_ms or flagging some collections as unpinned/low-priority.
Pressure stays at hard despite quiet writes — check eviction_target_percent; eviction stops when usage drops below this. If you want eviction to drain further during idle, raise max_memory_mb or lower the target.
Consumer projects typically enable migrations by configuring the database via their own postinst script:
# Example: in your project's postinst
DB_CONFIG="/etc/smartbotic-database/config.json"
python3 -c "
import json
with open('$DB_CONFIG') as f:
cfg = json.load(f)
s = cfg['storage']
s['migrations']['enabled'] = True
s['migrations']['directory'] = '/opt/myproject/migrations'
s['migrations']['auto_apply'] = True
with open('$DB_CONFIG', 'w') as f:
json.dump(cfg, f, indent=2)
"
systemctl restart smartbotic-database
Declarative JSON-based schema migrations. Create numbered JSON files in the migrations directory.
{
"version": "001",
"name": "create_users",
"description": "Create users collection with encryption",
"operations": [
{
"type": "create_collection",
"collection": "users",
"options": {
"encrypted": true,
"max_versions": 10
}
}
]
}
| Operation | Fields | Description |
|---|---|---|
create_collection |
collection, options |
Create a new collection. Options: encrypted, max_versions, default_ttl_seconds, vector_dimension |
put |
collection, document |
Insert/upsert a document |
delete |
collection, id |
Delete a document |
delete_where_id_prefix |
collection, prefix |
Bulk-delete documents whose ID starts with prefix |
{
"version": "002",
"name": "create_memories",
"description": "Create vector-enabled memories collection",
"operations": [
{
"type": "create_collection",
"collection": "memories",
"options": {
"vector_dimension": 384
}
}
]
}
Note: vector_dimension is immutable after collection creation.
When building your project in Docker for production, install the dev package in your base image instead of compiling the submodule from source.
FROM debian:trixie
# Add Smartbotics APT repository
COPY smartbotics-repo.gpg /usr/share/keyrings/
RUN echo "deb [signed-by=/usr/share/keyrings/smartbotics-repo.gpg] \
https://repository.smartbotics.ai trixie main" \
> /etc/apt/sources.list.d/smartbotics.list
# Configure authentication
ARG REPO_USER=callerai
ARG REPO_PASS
RUN printf "machine repository.smartbotics.ai\nlogin %s\npassword %s\n" \
"$REPO_USER" "$REPO_PASS" > /etc/apt/auth.conf.d/smartbotics.conf && \
chmod 600 /etc/apt/auth.conf.d/smartbotics.conf
# Install client dev package (pulls shared lib as dependency)
RUN apt-get update && apt-get install -y \
libsmartbotic-db-client-dev \
&& rm -rf /var/lib/apt/lists/*
Then your project's CMake uses find_package(smartbotic-db-client REQUIRED) and links against the pre-built shared library.
Your project's production .deb should declare:
Depends: smartbotic-database (>= 1.2.0), libsmartbotic-db-client (>= 1.2.0), ...
This ensures both the server and runtime library are installed on the target machine.
Install the CLI tool:
sudo apt install smartbotic-db-cli
The CLI connects to the database via gRPC. It does not require the server installed on the same machine — it can administer a remote instance.
# Connect to default (localhost:9004)
smartbotic-db-cli health
# Connect to a remote instance
smartbotic-db-cli --address 192.168.1.50:9004 health
If your machine previously had shadowman-database or callerai-storage installed, the new smartbotic-database package handles the transition automatically:
sudo apt install smartbotic-database
What happens:
dpkg sees Conflicts: shadowman-database / callerai-storageprerm stops the old service)smartbotic-database is installedpostinst script detects old data/config and migrates:
/var/lib/shadowman/data/database/ → /var/lib/smartbotic-database//etc/shadowman/database.json → /etc/smartbotic-database/config.json (paths updated)The old data directory is preserved (copied, not moved) so rollback is safe.
If something goes wrong:
sudo apt remove smartbotic-database
sudo apt install shadowman-database=1.2.0-14 # or callerai-storage=0.5.18-1
Old packages remain in the repository.
The raw .proto file is available at:
/usr/share/smartbotic-database/proto/database.protoproto/database.protoThis can be used to generate client stubs in other languages (Python, Go, etc.) if needed.