Types & Handles
| Type | Description |
|---|---|
KVStore | Opaque handle to an open database. |
KVColumnFamily | Opaque handle to a column family. |
KVIterator | Opaque handle to a key-value iterator. |
KVStoreStats | Statistics counters (see Diagnostics). |
KVStoreConfig | Configuration struct for kvstore_open_v2. |
Error Codes
| Code | Value | Description |
|---|---|---|
KVSTORE_OK | 0 | Operation succeeded. |
KVSTORE_ERROR | 1 | Generic error. |
KVSTORE_BUSY | 5 | Database locked by another connection. |
KVSTORE_LOCKED | 6 | Database locked within the same connection. |
KVSTORE_NOMEM | 7 | Memory allocation failed. |
KVSTORE_READONLY | 8 | Database is read-only. |
KVSTORE_CORRUPT | 11 | Database file is corrupted. |
KVSTORE_NOTFOUND | 12 | Key or column family not found. |
KVSTORE_AUTH_FAILED | 13 | Wrong password or encrypted store opened without a key. |
KVSTORE_PROTOCOL | 15 | Database lock protocol error. |
All codes are aliases for the corresponding SQLITE_* values.
Constants
Journal Modes
| Constant | Value | Description |
|---|---|---|
KVSTORE_JOURNAL_DELETE | 0 | Rollback journal mode. |
KVSTORE_JOURNAL_WAL | 1 | Write-Ahead Logging — recommended. |
Sync Levels
| Constant | Value | Description |
|---|---|---|
KVSTORE_SYNC_OFF | 0 | No fsync — fastest, not crash-safe. |
KVSTORE_SYNC_NORMAL | 1 | WAL-safe — survives process crash (default). |
KVSTORE_SYNC_FULL | 2 | Power-safe — fsync on every commit. |
Checkpoint Modes
| Constant | Value | Description |
|---|---|---|
KVSTORE_CHECKPOINT_PASSIVE | 0 | Non-blocking copy; may not flush all frames. |
KVSTORE_CHECKPOINT_FULL | 1 | Wait for writers, then copy all frames. |
KVSTORE_CHECKPOINT_RESTART | 2 | Like FULL, then reset WAL write position. |
KVSTORE_CHECKPOINT_TRUNCATE | 3 | Like RESTART, then truncate WAL file to zero. |
Other
| Constant | Value | Description |
|---|---|---|
KVSTORE_MAX_COLUMN_FAMILIES | 64 | Maximum column families per database. |
KVSTORE_NO_TTL | -1 | Sentinel: key exists but has no expiry. |
Configuration — KVStoreConfig
typedef struct KVStoreConfig KVStoreConfig; struct KVStoreConfig { int journalMode; /* KVSTORE_JOURNAL_WAL (default) or KVSTORE_JOURNAL_DELETE */ int syncLevel; /* KVSTORE_SYNC_NORMAL (default), _OFF, or _FULL */ int cacheSize; /* page cache in pages (0 = default = 2000 pages ≈ 8 MB); < 0 = budget in KiB */ int pageSize; /* page size in bytes (0 = 4096; new databases only) */ int readOnly; /* 1 = open read-only; default 0 */ int busyTimeout; /* ms to retry on SQLITE_BUSY (0 = fail immediately) */ int allowEncrypted; /* internal — leave 0 (use kvstore_open_encrypted) */ int walAutoCheckpoint; /* checkpoint when WAL ≥ N pages (0 = default 1000, < 0 = off) */ };
| Field | Default | Notes |
|---|---|---|
journalMode | KVSTORE_JOURNAL_WAL | WAL strongly recommended. |
syncLevel | KVSTORE_SYNC_NORMAL | Safe against process crash. |
cacheSize | 2000 (~8 MB) | Larger cache improves read-heavy loads. A negative value is a memory budget in KiB (e.g. -256 ≈ 256 KB of cache, whatever the page size) — the usual choice on small devices. The default can also be changed at build time with -DKVSTORE_DEFAULT_CACHE_SIZE=N. |
pageSize | 4096 bytes | Ignored for existing databases. |
readOnly | 0 | |
busyTimeout | 0 (fail immediately) | Set > 0 for multi-process workloads. |
allowEncrypted | 0 | Internal flag used by kvstore_open_encrypted. Leave it 0; zero-initialising the struct does this. |
walAutoCheckpoint | 0 → 1000 pages | After a commit, run a PASSIVE (non-blocking) checkpoint once the WAL holds at least this many pages — the same rule SQLite uses. Keeps the WAL around 4 MB, so page lookups in it stay cheap (an unbounded WAL slowed reads by 8–16% on 1M rows). N > 0 = custom threshold; N < 0 = off (WAL grows until kvstore_checkpoint or close). Build-time default: -DKVSTORE_DEFAULT_WAL_AUTOCHECKPOINT=N. |
Database Lifecycle
kvstore_open_v2
Open or create a database with full configuration control. Pass NULL for pConfig to use all defaults (WAL mode, NORMAL sync, 8 MB cache). Returns KVSTORE_AUTH_FAILED if the file is an encrypted store — use kvstore_open_encrypted instead.
/* default open */ KVStore *kv; kvstore_open_v2("mydb.db", &kv, NULL); /* fully configured */ KVStoreConfig cfg = {0}; cfg.journalMode = KVSTORE_JOURNAL_WAL; cfg.syncLevel = KVSTORE_SYNC_FULL; cfg.cacheSize = 4000; cfg.busyTimeout = 5000; kvstore_open_v2("mydb.db", &kv, &cfg); /* read-only */ KVStoreConfig ro = {0}; ro.readOnly = 1; kvstore_open_v2("mydb.db", &kv, &ro);
KVSTORE_OK on success, error code otherwise.
kvstore_open
Simplified open. Equivalent to kvstore_open_v2 with only journalMode set; all other fields use defaults. Returns KVSTORE_AUTH_FAILED if the file is an encrypted store.
KVStore *kv = NULL; kvstore_open("mydata.db", &kv, KVSTORE_JOURNAL_WAL);
kvstore_close
Close the database and free all resources. Any uncommitted transaction is rolled back. Runs a WAL checkpoint before closing.
Key-Value Operations — Default Column Family
kvstore_put
Insert or update a key-value pair. If the key already exists, its value is replaced. Keys and values are binary-safe. Without an explicit transaction, each call auto-commits.
kvstore_get
Retrieve a value by key. The returned buffer is heap-allocated by SNKV. Caller must free with snkv_free().
void *value = NULL; int vlen = 0; int rc = kvstore_get(kv, "user:1", 6, &value, &vlen); if (rc == KVSTORE_OK) { printf("%.*s\n", vlen, (char*)value); snkv_free(value); }
KVSTORE_OK if found · KVSTORE_NOTFOUND if key does not exist.
kvstore_delete
Delete a key. Returns KVSTORE_NOTFOUND if the key did not exist.
kvstore_exists
Check if a key exists without reading its value. More efficient than kvstore_get for existence checks. Sets *pExists to 1 if found, 0 otherwise.
Column Family Management
Column families provide multiple logical key-value namespaces within a single file. Each is stored in its own B-tree. Maximum KVSTORE_MAX_COLUMN_FAMILIES (64) per database, counting the default CF — i.e. up to 63 named CFs. Internal CFs (TTL indexes, encryption metadata, vector store) do not count towards the limit. Names starting with __ or _snkv_ are reserved for internal use.
kvstore_cf_create
Create a new column family. zName max 255 characters, must be unique and must not start with __ or _snkv_. *ppCF receives the handle. Passing "default" returns the default CF.
KVSTORE_OK · KVSTORE_ERROR if the name is invalid or reserved, the CF already exists, or 63 named CFs already exist · KVSTORE_NOMEM if the handle cannot be allocated (the CF may already be created — open it with kvstore_cf_open).
kvstore_cf_open
Open an existing column family. Returns KVSTORE_NOTFOUND if it does not exist, KVSTORE_ERROR for a reserved name, KVSTORE_NOMEM if the handle cannot be allocated. Each call returns a new, independent handle; close every handle with kvstore_cf_close.
kvstore_cf_get_default
Get a handle to the default column family. Always exists; created automatically on open.
kvstore_cf_drop
Delete a column family and all its data permanently. The default column family cannot be dropped. Also removes any hidden TTL index CFs. Other open CF handles stay valid (the file may move another table's root page; open handles follow it). The drop is all-or-nothing: on any error nothing is changed (inside your own transaction, roll it back).
KVSTORE_OK · KVSTORE_NOTFOUND if it does not exist · KVSTORE_ERROR for "default" or a reserved name · KVSTORE_BUSY if a handle to this CF is still open, or if any iterator is open on the store — close them first.
kvstore_cf_list
List the named column families (the default CF is not included). Internal CFs (names starting with __ or _snkv_) are not listed. Caller must free each name and the array with snkv_free().
char **names = NULL; int count = 0; kvstore_cf_list(kv, &names, &count); for (int i = 0; i < count; i++) { printf("%s\n", names[i]); snkv_free(names[i]); } snkv_free(names);
kvstore_cf_close
Release the column family handle. Does not delete data.
CF Key-Value Operations
Identical to the default-CF variants but take a KVColumnFamily* handle.
kvstore_cf_get with snkv_free().Iterators
Iterate all key-value pairs in ascending lexicographic key order.
kvstore_iterator_create / kvstore_cf_iterator_create
Create a forward iterator for the default or a specific column family.
kvstore_iterator_first / next / eof
Position at first entry, advance to next, or test for end-of-sequence.
kvstore_iterator_key / value
Read the current key or value. Pointers are owned by the iterator — do not free them. Valid until the next next() or close() call.
KVIterator *it = NULL; kvstore_iterator_create(kv, &it); kvstore_iterator_first(it); while (!kvstore_iterator_eof(it)) { void *key, *val; int klen, vlen; kvstore_iterator_key(it, &key, &klen); kvstore_iterator_value(it, &val, &vlen); printf("%.*s\n", klen, (char*)key); kvstore_iterator_next(it); } kvstore_iterator_close(it);
kvstore_iterator_close
Release the iterator and its cursor. Always call this when done.
Prefix Iterators
Pre-positioned at the first key matching the prefix. Stop automatically when keys no longer match. Do not call kvstore_iterator_first() — read directly, then call next().
KVIterator *it = NULL; kvstore_prefix_iterator_create(kv, "user:", 5, &it); /* already at first match — do NOT call first() */ while (!kvstore_iterator_eof(it)) { void *key; int klen; kvstore_iterator_key(it, &key, &klen); kvstore_iterator_next(it); } kvstore_iterator_close(it);
Reverse Iterators
Traverse keys in descending order. Use kvstore_iterator_last() and kvstore_iterator_prev() instead of first() and next(). All key/value/eof/close accessors work identically.
/* Full reverse scan */ KVIterator *it = NULL; kvstore_reverse_iterator_create(kv, &it); kvstore_iterator_last(it); while (!kvstore_iterator_eof(it)) { void *key; int klen; kvstore_iterator_key(it, &key, &klen); kvstore_iterator_prev(it); } kvstore_iterator_close(it); /* Reverse prefix — do NOT call last(), already positioned */ kvstore_reverse_prefix_iterator_create(kv, "user:", 5, &it); while (!kvstore_iterator_eof(it)) { kvstore_iterator_prev(it); } kvstore_iterator_close(it);
Iterator Seek
Jump an open iterator to an arbitrary key in O(log N) time. Works on forward and reverse iterators, including prefix iterators.
kvstore_iterator_seek
Reposition the iterator without closing and reopening it.
- Forward iterator — seek to the first key
>= (pKey, nKey). - Reverse iterator — seek to the last key
<= (pKey, nKey).
If the seek lands outside a prefix iterator’s prefix, the iterator immediately enters the eof state. Call kvstore_iterator_key / kvstore_iterator_next as usual after a successful seek.
| Return | Meaning |
|---|---|
KVSTORE_OK | Seek succeeded (check eof to know if a key was found). |
KVSTORE_ERROR | Invalid arguments. |
/* forward: scan from "m" onwards */ KVIterator *it = NULL; kvstore_iterator_create(kv, &it); kvstore_iterator_seek(it, "m", 1); while (!kvstore_iterator_eof(it)) { void *key; int klen; kvstore_iterator_key(it, &key, &klen); kvstore_iterator_next(it); } kvstore_iterator_close(it); /* reverse: scan from "m" backwards */ kvstore_reverse_iterator_create(kv, &it); kvstore_iterator_seek(it, "m", 1); while (!kvstore_iterator_eof(it)) { kvstore_iterator_prev(it); } kvstore_iterator_close(it);
Transactions
Explicit ACID transactions for batching operations atomically. Without an explicit transaction, each put/delete auto-commits. Wrapping bulk writes in a single transaction is significantly faster.
kvstore_begin(kv, 1); kvstore_put(kv, "key1", 4, "val1", 4); kvstore_put(kv, "key2", 4, "val2", 4); kvstore_commit(kv); /* both writes are atomic */
cfg.busyTimeout > 0 to auto-retry on lock contention, or handle the return code and retry manually.kvstore_commit fails (e.g. KVSTORE_BUSY or an I/O error) the transaction stays open: retry the commit, or call kvstore_rollback. Auto-commits inside other calls roll back automatically when they fail, so a failed call never leaves half its changes behind.kvstore_sync, kvstore_reencrypt and kvstore_remove_encryption return KVSTORE_ERROR and kvstore_checkpoint returns KVSTORE_BUSY — commit or roll back first. kvstore_purge_expired and kvstore_cf_drop work inside it and become part of your transaction.TTL / Key Expiry
Per-key expiry with zero overhead for stores that never use TTL. Hidden index CFs are created lazily on first use and are invisible in kvstore_cf_list.
kvstore_put_ttl / kvstore_get_ttl
/* write key that expires in 30 minutes */ int64_t exp = kvstore_now_ms() + 30 * 60 * 1000; kvstore_put_ttl(kv, "sess:abc", 8, token, tlen, exp); /* read with lazy expiry */ void *val = NULL; int nVal = 0; int64_t rem = 0; int rc = kvstore_get_ttl(kv, "sess:abc", 8, &val, &nVal, &rem); if (rc == KVSTORE_OK) { /* rem = ms remaining */ snkv_free(val); }
kvstore_ttl_remaining
| Return | *pnRemaining | Meaning |
|---|---|---|
KVSTORE_OK | KVSTORE_NO_TTL (-1) | Key exists, no expiry. |
KVSTORE_OK | 0 | Key just expired (lazy delete performed). |
KVSTORE_OK | N > 0 | N ms remain. |
KVSTORE_NOTFOUND | — | Key does not exist. |
kvstore_purge_expired
Delete all expired keys, oldest expiry first. O(expired keys) — the expiry index is sorted by time, so it stops at the first key that has not expired. Works in batches of up to 256 keys per write transaction (build-time setting KVSTORE_PURGE_BATCH), releasing the lock between batches; memory use is one key at a time. If you have a write transaction open, it works inside it instead: the deletes commit or roll back with your transaction.
KVColumnFamily* handle. Each user CF maintains independent TTL indexes; purging CF A never touches CF B.
kvstore_cf_put_ttl / kvstore_cf_get_ttl
CF-level equivalents of kvstore_put_ttl / kvstore_get_ttl. Semantics are identical; the TTL indexes are created lazily per CF and are fully independent.
kvstore_cf_ttl_remaining
Return remaining TTL for a key in this column family without fetching the value. Return codes and *pnRemaining semantics are identical to kvstore_ttl_remaining.
kvstore_cf_purge_expired
Delete all expired keys in this column family only. O(expired keys). Same batching and transaction behaviour as kvstore_purge_expired. Purging CF A never affects other column families.
Conditional Insert (Put-If-Absent)
Atomically insert a key-value pair only when the key does not already exist. Expired keys (lazy TTL) are treated as absent. Supports an optional TTL on the new entry.
kvstore_put_if_absent / kvstore_cf_put_if_absent
expire_ms | Behaviour |
|---|---|
0 | No TTL — new entry is permanent. |
> 0 | Absolute expiry in ms since Unix epoch (kvstore_now_ms() + delta_ms). |
The existence check and the write are performed inside a single write transaction, so no other writer can insert the same key concurrently.
int inserted = 0; kvstore_put_if_absent(kv, "lock", 4, "owner", 5, 0, &inserted); if (inserted) { puts("acquired lock"); } else { puts("lock already held"); } /* with TTL: session token that expires in 1 hour */ int64_t exp = kvstore_now_ms() + 3600 * 1000; kvstore_put_if_absent(kv, session_id, sid_len, token, tok_len, exp, NULL);
Bulk Clear
Remove every key-value pair from a column family in a single atomic write transaction. TTL index entries are also cleared. Much faster than iterating and deleting individually.
kvstore_clear / kvstore_cf_clear
Truncates the column family’s B-tree in O(pages) time using SQLite’s BtreeClearTable — the B-tree structure (root page) is preserved so future inserts work immediately without re-opening.
clear is called. BtreeClearTable invalidates all cursors on the cleared table; accessing a stale cursor is undefined behaviour./* flush a rate-limit namespace at midnight */ kvstore_cf_clear(rate_limit_cf); /* wipe everything in the default CF */ kvstore_clear(kv);
Key Count
Count keys in a column family without iterating over them. Uses SQLite’s sqlite3BtreeCount which reads only page headers — O(pages) ≈ O(N/100).
kvstore_count / kvstore_cf_count
Counts only the main data B-tree of the column family. TTL index CFs are separate B-trees and are not included. Expired-but-not-yet-purged keys are included in the count — call kvstore_purge_expired first for an exact live count.
int64_t n; kvstore_count(kv, &n); printf("default CF has %lld entries\n", (long long)n); kvstore_purge_expired(kv, NULL); kvstore_count(kv, &n); printf("live entries after purge: %lld\n", (long long)n);
Diagnostics & Maintenance
kvstore_errmsg
Return the last error message string. Do not free. Valid until the next operation on the same handle. When several threads share one handle, use kvstore_errmsg_copy instead.
kvstore_libversion / kvstore_libversion_number
Version of the library actually linked. The header defines the same values as macros for compile-time checks: KVSTORE_VERSION ("0.9.0"), KVSTORE_VERSION_MAJOR / _MINOR / _PATCH, and KVSTORE_VERSION_NUMBER = MAJOR×1000000 + MINOR×1000 + PATCH. Comparing kvstore_libversion_number() with KVSTORE_VERSION_NUMBER detects a header/library mismatch when SNKV is used as a shared library.
kvstore_errmsg_copy
Copy the last error message into zBuf (always NUL-terminated, truncated to fit). The copy is taken under the handle mutex, so it is safe when threads share a handle. Returns KVSTORE_ERROR if zBuf is NULL or nBuf <= 0.
kvstore_stats / kvstore_stats_reset
kvstore_stats fills the KVStoreStats struct. All counters are cumulative from open (or last reset). kvstore_stats_reset zeros them; nDbPages is a live gauge and is never zeroed.
| Field | Type | Description |
|---|---|---|
nPuts | uint64_t | Total put operations (includes put_ttl, put_if_absent). |
nGets | uint64_t | Total get operations (includes get_ttl). |
nDeletes | uint64_t | Total delete operations. |
nIterations | uint64_t | Total iterators created. |
nErrors | uint64_t | Total errors encountered. |
nBytesRead | uint64_t | Total value bytes returned by get operations. |
nBytesWritten | uint64_t | Total (key + value) bytes written by put operations. |
nWalCommits | uint64_t | Write transactions successfully committed. |
nCheckpoints | uint64_t | WAL checkpoints performed (auto + explicit). |
nTtlExpired | uint64_t | Keys lazily expired on get / exists calls. |
nTtlPurged | uint64_t | Keys removed by kvstore_purge_expired. |
nDbPages | uint64_t | Current total pages in the database file (gauge, not zeroed by reset). |
kvstore_integrity_check
Walk the entire B-tree and verify structural integrity. Returns KVSTORE_OK or KVSTORE_CORRUPT with details in *pzErrMsg. Caller must free *pzErrMsg with snkv_free().
kvstore_sync
Make every committed transaction durable: fsyncs the WAL file (or rollback journal) and the database file (fsync on Linux/macOS, FlushFileBuffers on Windows). Useful with KVSTORE_SYNC_OFF or NORMAL, where commits do not fsync: commit cheaply, then call kvstore_sync at points that must survive a power loss. It does not checkpoint — use kvstore_checkpoint for that.
KVSTORE_OK · KVSTORE_ERROR while an explicit write transaction is open (commit or roll back first) · an I/O error code if the disk sync fails.
kvstore_incremental_vacuum
Reclaim unused pages and shrink the database file. Pass 0 to free all unused pages, or a positive nPage to limit work per call (good for latency-sensitive apps). Safe while iterators are open: they continue from their current key. Runs in its own write transaction, or inside yours if one is open.
kvstore_incremental_vacuum(kv, 0); /* free all */ kvstore_incremental_vacuum(kv, 50); /* incremental: 50 pages */
kvstore_checkpoint
Copy WAL frames back into the main database file. pnLog and pnCkpt may be NULL. Requires no active write transaction. Non-WAL databases: no-op returning KVSTORE_OK.
int nLog = 0, nCkpt = 0; kvstore_checkpoint(kv, KVSTORE_CHECKPOINT_PASSIVE, &nLog, &nCkpt); printf("%d frames total, %d checkpointed\n", nLog, nCkpt); /* reclaim WAL disk space */ kvstore_checkpoint(kv, KVSTORE_CHECKPOINT_TRUNCATE, NULL, NULL);
Memory Management
| Macro | Description |
|---|---|
sqliteMalloc(n) | Legacy alias of snkv_malloc, kept for compatibility. |
sqliteFree(p) | Legacy alias of snkv_free, kept for compatibility. |
sqliteRealloc(p, n) | Legacy: resize an SNKV allocation. Not needed by the public API. |
sqliteStrDup(s) | Legacy: duplicate a C string into SNKV memory. Not needed by the public API. |
snkv_malloc(n) | Use this to allocate n zero-initialised bytes. Call it only after the first kvstore_open. |
snkv_free(p) | Use this to free any memory returned by SNKV. |
kvstore_get, kvstore_cf_get, kvstore_cf_list, or kvstore_integrity_check must be freed with snkv_free(). Iterator key/value pointers are iterator-owned — do NOT free them.Thread Safety
- One lock per handle. Every
KVStorehandle has one lock, so it may be shared across threads: calls on it run one at a time. Column families have no lock of their own. - Transactions belong to one thread.
kvstore_begin(kv, 1)keeps the handle locked untilkvstore_commit/kvstore_rollback, which must be called by the same thread. Other threads' calls on that handle wait until then — they never join or roll back another thread's transaction. - Different handles run in parallel. For maximum throughput, give each thread its own
KVStoreon the same file: in WAL mode readers run concurrently with one writer; a second writer getsKVSTORE_BUSY(or waits up tobusyTimeout). - Use
kvstore_errmsg_copywhen threads share a handle — the pointer fromkvstore_errmsgcan be overwritten by another thread's error. KVIteratorhandles are not thread-safe — use each iterator from one thread.- Do not call
kvstore_closewhile other threads may still use the handle. (Close waits for another thread's open transaction to finish.) - A
KVVecStoreis safe to share between threads: every vector call takes the store's own mutex.
Encryption
Per-value AES-grade encryption using XChaCha20-Poly1305 (via Monocypher). Passwords are stretched with Argon2id. All existing APIs — put, get, delete, iterate, TTL, column families — work transparently on encrypted stores. Keys are stored in plaintext; only values are encrypted.
kvstore_open_encrypted
Open or create an encrypted store. On a fresh file a new Argon2id-derived key is generated from the password. On an existing encrypted file the password is verified before access is granted. If the file is a plain (non-encrypted) store with existing data, all values are encrypted in-place using the supplied password — the store becomes fully encrypted transparently. Returns KVSTORE_AUTH_FAILED if the password is wrong on an already-encrypted file, KVSTORE_CORRUPT if the key-derivation parameters stored in the file are out of range (corrupt or crafted file — rejected before anything is allocated), and KVSTORE_NOMEM if the key-derivation memory cannot be allocated. *ppKV is set to NULL on any error — no resource leak.
/* Create / open encrypted */ KVStore *kv = NULL; int rc = kvstore_open_encrypted("mydb.db", "hunter2", 7, &kv, NULL); if (rc == KVSTORE_AUTH_FAILED) { puts("wrong password"); } /* All standard operations work transparently */ kvstore_put(kv, "key", 3, "secret", 6); kvstore_close(kv);
kvstore_is_encrypted
Returns 1 if the store was opened with kvstore_open_encrypted, 0 otherwise.
kvstore_reencrypt
Change the encryption password in-place. Derives a new key from the new password, re-encrypts every value in every column family, and updates the authentication metadata — all in a single atomic transaction. On success the old password is invalid immediately. On any failure (including a failed commit) nothing is changed and the old password stays valid. Returns KVSTORE_ERROR if the store is not encrypted or an explicit transaction is open (commit or roll back first).
kvstore_reencrypt(kv, "new-pass", 8); /* kv is still open and usable with the new password */
kvstore_remove_encryption
Decrypt all values in-place and remove the authentication metadata. After this call the store is a plain (non-encrypted) SNKV database and can be opened with kvstore_open or kvstore_open_v2. Runs in one transaction: on any failure nothing is changed and the store stays encrypted. Returns KVSTORE_ERROR if the store is not encrypted or an explicit transaction is open.
kvstore_remove_encryption(kv); kvstore_close(kv); /* Now opens without a password */ KVStore *plain = NULL; kvstore_open("mydb.db", &plain, KVSTORE_JOURNAL_WAL);
kvstore_now_ms
Current time in milliseconds since the Unix epoch. Useful for computing absolute TTL expiry timestamps: kvstore_now_ms() + delta_ms.
- KDF: Argon2id (default m=64 MB, t=3 iterations) — password + 16-byte random salt → 256-bit key. The cost is stored in each file; on small devices build with e.g.
-DSNKV_ENC_M_COST=256u(256 KB) for new keys - Cipher: XChaCha20-Poly1305 — 24-byte random nonce + 16-byte MAC per value
- Overhead: 40 bytes per stored value (nonce + MAC)
- Key scope: wiped from memory on
kvstore_close - Column families: all CFs share one key; re-encryption covers all CFs atomically
Vector Store
An HNSW approximate nearest-neighbour index built on top of KVStore, using the
usearch C SDK.
Vectors and KV data share a single .db file. The index is rebuilt from
the database on open and saved to a .usearch sidecar on close for fast
reload. All writes are atomic KVStore transactions; the usearch index is updated after
each successful commit.
kvstore_vec.h and link against libsnkv_vec.a
(built with make vector). The core store (libsnkv.a / snkv.h)
does not include the vector layer — it requires g++ to compile the usearch C++ core.Types & Constants
Opaque handle
KVVecStore | Opaque handle returned by kvstore_vec_open. |
Result structs
KVVecSearchResult | pKey/nKey, pValue/nValue, distance, pMetadata/nMetadata. Free with kvstore_vec_free_results. |
KVVecKeyResult | pKey/nKey, distance. Free with kvstore_vec_free_key_results. |
KVVecStats | Filled by kvstore_vec_stats. See Stats section. |
KVVecItem | One item for kvstore_vec_put_batch: pKey/nKey, pVal/nVal, pVec, pMeta/nMeta. |
Distance spaces
KVVEC_SPACE_L2 | 0 | Squared Euclidean (‖a−b‖²) — distances are comparable but not sqrt L2; default |
KVVEC_SPACE_COSINE | 1 | Cosine distance (1 − dot(a,b) / (‖a‖·‖b‖)) |
KVVEC_SPACE_IP | 2 | Inner product (negative dot product) |
KVVEC_SPACE_L2 returns squared Euclidean distance — squaring is omitted for performance. Relative ordering is identical to true L2, so nearest-neighbour results are unaffected. To recover metric distance, take sqrtf(result.distance).Index precision (dtype)
KVVEC_DTYPE_F32 | 0 | float32 — full precision (default) |
KVVEC_DTYPE_F16 | 1 | float16 — half RAM, negligible recall loss |
KVVEC_DTYPE_I8 | 2 | int8 — quarter RAM, cosine-like metrics only |
Extra error codes
KVVEC_DIM_MISMATCH | 20 | Opened with wrong dim for existing store |
KVVEC_DTYPE_MISMATCH | 21 | Opened with wrong dtype for existing store |
KVVEC_SPACE_MISMATCH | 22 | Opened with wrong space for existing store |
KVVEC_INDEX_DROPPED | 23 | Index has been dropped; search unavailable |
KVVEC_INDEX_EMPTY | 24 | Index is empty; put a vector first |
KVVEC_BAD_VECTOR | 25 | Vector shape or value is invalid |
Open / Close
kvstore_vec_open
Open or create a vector store. dim, space, and dtype are immutable after the first open — passing different values on a subsequent open returns the corresponding mismatch error. Pass pPassword/nPassword for an encrypted store; sidecar persistence is disabled for encrypted stores. *ppVS is NULL on any error.
KVVecStore *vs = NULL; int rc = kvstore_vec_open( "store.db", 128, KVVEC_SPACE_COSINE, 0, 0, 0, /* defaults */ KVVEC_DTYPE_F32, NULL, 0, /* plain (unencrypted) */ &vs ); if (rc == KVVEC_DIM_MISMATCH) puts("wrong dim for existing store");
kvstore_vec_close
Close the store and free all resources. For unencrypted file-backed stores, saves the HNSW graph to {path}.usearch and the next-id stamp to {path}.usearch.nid so the next open can skip the O(n·dim) rebuild. Safe to call with NULL.
Writes
kvstore_vec_put
Insert or update a key/value pair and add its vector to the HNSW index. The KVStore write (all 5 internal CFs) is one atomic transaction; the usearch index update happens after commit. If the key already exists, the old usearch label is removed before the new one is added (count stays constant on overwrite).
float vec[128] = { /* ... */ }; kvstore_vec_put(vs, "doc:1", 5, "hello", 5, vec, 0, /* no TTL */ "{\"tag\":\"ai\"}", 12); /* metadata */
kvstore_vec_put_batch
Insert or update nItems records in one atomic transaction. Last-write-wins for duplicate keys within the batch — only the last vector for a repeated key enters the index. All usearch insertions happen after the commit.
KVVecItem items[3] = {
{ "k1", 2, "val1", 4, vec1, NULL, 0 },
{ "k2", 2, "val2", 4, vec2, NULL, 0 },
{ "k3", 2, "val3", 4, vec3, meta, nMeta },
};
kvstore_vec_put_batch(vs, items, 3, 0);
kvstore_vec_kv_put
Plain KV write — does not update the vector index. Useful for storing non-vector data (config, counters) alongside vector keys in the same file.
Reads
kvstore_vec_get
Fetch value bytes. TTL-aware: returns KVSTORE_NOTFOUND and lazily deletes the key if it has expired. Caller must snkv_free(*ppVal).
kvstore_vec_get_vector
Fetch the stored float32 vector. *ppVec is a flat array of dim floats. Caller must snkv_free(*ppVec). TTL-aware: returns KVSTORE_NOTFOUND if the key is missing or expired. Returns KVVEC_INDEX_DROPPED after kvstore_vec_drop_index.
kvstore_vec_get_metadata
Fetch JSON metadata bytes. TTL-aware: returns KVSTORE_NOTFOUND with *ppMeta=NULL if the key is missing or expired. For a live key: returns KVSTORE_OK with *ppMeta=NULL when no metadata has ever been written to this store (tags CF does not yet exist), and KVSTORE_NOTFOUND with *ppMeta=NULL when this key has no metadata. Neither is an error. Metadata stays readable after the store is reopened. Caller must snkv_free(*ppMeta) on KVSTORE_OK with non-NULL result.
kvstore_vec_contains
Returns 1 if the key exists and is not expired, 0 otherwise.
kvstore_vec_count
Returns the number of active (non-deleted) vectors in the HNSW index (usearch_size).
Search
kvstore_vec_search
Approximate nearest-neighbour search. Expired keys are filtered out transparently (without replacement, so fewer than top_k results can come back; rerank's oversampling makes this less likely). Values are read only for the results returned. Results are sorted ascending by distance. Caller must pass the array to kvstore_vec_free_results. Returns KVVEC_INDEX_EMPTY if no vectors have been inserted, KVVEC_INDEX_DROPPED after kvstore_vec_drop_index.
float q[128] = { /* ... */ }; KVVecSearchResult *res = NULL; int n = 0; /* Basic ANN search */ kvstore_vec_search(vs, q, 5, 0, 0, 0.0f, &res, &n); /* With exact rerank (oversample×top_k candidates → exact distance sort) */ kvstore_vec_search(vs, q, 5, 1, 3, 0.0f, &res, &n); for (int i = 0; i < n; i++) printf("%.*s dist=%.4f val=%.*s\n", res[i].nKey, (char*)res[i].pKey, res[i].distance, res[i].nValue, (char*)res[i].pValue); kvstore_vec_free_results(res, n);
kvstore_vec_search_keys
ANN search returning (key, distance) pairs only — no value fetch. Cheaper than kvstore_vec_search when values are not needed. Caller must pass the array to kvstore_vec_free_key_results.
kvstore_vec_free_results / kvstore_vec_free_key_results
Free the result arrays returned by the search functions. Safe to call with NULL.
Delete
kvstore_vec_delete
Delete the key's value and its vector, label and metadata rows in one atomic transaction, then remove it from the HNSW index after the commit. Returns KVSTORE_NOTFOUND if the key does not exist.
TTL
Pass a non-zero expire_ms to kvstore_vec_put (absolute milliseconds since the Unix epoch — use kvstore_now_ms() + delta). Expired vectors are filtered out transparently during search and lazily deleted on get. Use kvstore_vec_purge_expired for bulk cleanup.
/* Expire in 60 seconds */ int64_t exp = kvstore_now_ms() + 60000; kvstore_vec_put(vs, "sess:abc", 8, token, nToken, vec, exp, NULL, 0); /* Bulk-delete all expired vectors */ int nPurged = 0; kvstore_vec_purge_expired(vs, &nPurged);
Stats
kvstore_vec_stats
Fill *pStats with current index configuration and runtime state. Always returns KVSTORE_OK.
dim | Vector dimension |
space | KVVEC_SPACE_* |
dtype | KVVEC_DTYPE_* |
connectivity | HNSW M parameter |
expansion_add | ef_construction |
expansion_search | ef at query time |
count | Active vectors in HNSW index (usearch_size) |
capacity | Allocated HNSW capacity |
fill_ratio | count / capacity — 0–1 ratio (multiply by 100 for %). Auto-doubles at 0.9. |
vec_cf_count | Entries in the __snkv_vec__ CF (may include expired) |
has_metadata | 1 if the __snkv_vec_tags__ CF exists |
sidecar_enabled | 1 for unencrypted file-backed stores |
Maintenance
kvstore_vec_purge_expired
Scan the __snkv_vec__ CF and delete all expired (or orphaned) vectors from every vector CF and the HNSW index, in batches of up to 64 per transaction (build-time setting KVVEC_PURGE_BATCH), so memory stays bounded however many keys expired. If a later batch fails, earlier batches stay deleted and the error is returned. *pnDeleted (may be NULL) is set to the number removed. Use this in preference to kvstore_purge_expired to keep vector CFs in sync. Returns KVSTORE_OK (0 deleted) when there are no TTL entries.
kvstore_vec_drop_index
Drop all five __snkv_vec*__ column families in one transaction and free the in-memory HNSW index. KV data in the default CF is preserved and readable via kvstore_vec_get. After this call, kvstore_vec_search returns KVVEC_INDEX_DROPPED; the drop persists across reopen. Sidecar files are removed. Returns KVSTORE_BUSY if an iterator is open on the store; on any error the index is kept and the error is returned.
kvstore_vec_drop_index(vs); /* index gone, KV intact */ void *v = NULL; int n = 0; kvstore_vec_get(vs, "doc:1", 5, &v, &n); /* still works */ snkv_free(v);
Build
The vector layer requires g++ (C++17) to compile the usearch HNSW core. The default make / make test targets use pure gcc and are unaffected.
# Build libsnkv_vec.a (core + usearch objects) make vector # Build and run the comprehensive example make vector-examples make run-vector-examples # Build and run the test suite make test-vector
Internal storage (5 Column Families)
__snkv_vec__ | key → float32[dim] — stored vector |
__snkv_vec_idk__ | key → uint64 label (8B big-endian) — maps key to HNSW label |
__snkv_vec_idi__ | uint64 label → key — reverse lookup used during search |
__snkv_vec_meta__ | config keys: ndim, metric, connectivity, expansion_add, expansion_search, dtype, next_id |
__snkv_vec_tags__ | key → JSON bytes — created lazily on first metadata write |
Stores created by older versions used single-underscore names (_snkv_vec_ …); they are renamed automatically on open.
Sidecar files
{path}.usearch | usearch HNSW binary graph — saved on close, loaded on open |
{path}.usearch.nid | 8-byte big-endian next-id stamp — used to detect stale sidecars |
zPath=NULL). The sidecar is used only if the stamp in .usearch.nid matches the stored next_id and it holds exactly as many vectors as the database; otherwise it is discarded and the index is rebuilt from the CFs. Both files are deleted once loaded, so they exist only after a clean kvstore_vec_close — after a crash the next open rebuilds instead of loading a stale index. Opening a store with a password deletes any sidecar left by an earlier unencrypted session.