Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.db.kv

import std.db.kv provides persistent, ordered byte-key storage without SQL in application code. The small public API is implemented in Plenty over the existing std.db.sqlite engine. It adds no native dependency. See the engine decision for the SQLite/redb comparison.

import std.bytes as buffers
import std.db.kv as kv
from std.bytes import BytesError

def main() -> Result[(), IoError | AllocError | BytesError | kv.KvError]:
    mut store = kv.connect("settings.db")?
    store.put("config", &buffers.from_text("theme")?, &buffers.from_text("dark")?)?
    store.close()?

    mut reopened = kv.connect("settings.db", mode=kv.Mode.ReadOnly)?
    match reopened.get("config", &buffers.from_text("theme")?)?:
        case Some(value):
            print(value.to_text()?)?
        case Nothing:
            print("unset")?
    print(reopened.len("config")?)?
    reopened.close()?
    Ok(())
dark
1

Stores and tables

connect(path, mode=Mode.Create, max_length=1048576) returns Result[Store, KvError]. Modes are Create, ReadWrite (existing file) and ReadOnly. memory(max_length=1048576) creates a private in-memory store. Paths are UTF-8 strings with the same validation as SQLite paths.

Logical table names are nonempty strings, bound as data rather than interpolated into SQL. Each table is an independent namespace. It appears on first put; an empty table and a nonexistent table behave identically. Keys and values are byte strings, including empty ones, NULs and non-UTF-8 bytes: inputs are &[u8] views and results are owned std.bytes.Bytes. Keys sort lexicographically by unsigned bytes, shorter equal prefixes first. Integer encodings must be chosen deliberately if numerical ordering matters.

Store methodResultBehavior
put(table, key, value)Result[(), KvError]Insert or replace one key
get(table, key)Result[Option[Bytes], KvError]Copy out the value, or Nothing
contains(table, key)Result[bool, KvError]Test presence without copying the value
remove(table, key)Result[bool, KvError]True if an entry was removed
clear(table)Result[i64, KvError]Remove all entries; return their count
len(table)Result[i64, KvError]Count entries (a table scan, not a cached counter)
set_busy_timeout(budget: &Duration)Result[(), KvError]Retry lock conflicts for this duration (rounded up to milliseconds); default zero
close()Result[(), KvError]Close, or Database(InUse) while transactions/scans still own handles

All methods take &mut Store. Key/value arguments are borrowed and copied into SQLite; returned values are owned and survive the next operation. Ordinary methods prepare a statement per call. Use transactions to amortize commit I/O.

Transactions

store.begin(immediate=True) returns a Transaction. Its commit() and rollback() return Result[(), KvError]. Dropping an unfinished transaction rolls back, including when ? exits a function. Every operation on that Store participates until completion. Outside a transaction each write auto-commits.

Only one transaction may be active per Store. Immediate mode takes the write lock at the beginning; immediate=False defers locks until first use and is appropriate for a read transaction. Deferred does not prohibit writes; use Mode.ReadOnly for a read-only connection. A busy commit can be retried or rolled back according to SQLite’s transaction contract.

Different Store connections follow SQLite isolation and locking. This module uses SQLite’s default rollback journal rather than enabling WAL automatically. Readers may delay a writer’s commit. No background thread, automatic retry loop or connection pool is introduced.

Bounded scans

scan(table, start, stop, max_rows: NonNegative[i64], after=False, reverse=False) returns a Scan over [start, stop); after=True excludes start too, allowing an ascending scan to resume after its last returned key. reverse=True changes the order without changing the bounds. Reversed/equal bounds yield no rows. scan_all(table, max_rows, reverse=False) has no key bounds, so it can include every byte key, including keys composed entirely of 0xff bytes.

The row limit is required and is a NonNegative[i64]; zero yields nothing. A limit truncates the scan, not an error; it does not promise that no more matching rows exist. Scan.next() returns Result[Option[Entry], KvError]; each Entry owns public key and value buffers. Only one row is copied per call. A copy allocation failure preserves the pending row for retry; a SQLite step failure terminates that scan, avoiding silent replay of earlier rows. Repeated calls after exhaustion return Nothing.

The snapshot starts at the first next, not construction. Finish/drop scans before writing through the same Store or ending its transaction: SQLite does not promise a stable scan when its own connection changes the rows being read. Separate Store connections supply normal SQLite snapshot isolation. A Scan keeps its underlying connection alive even if the Store is dropped. Drop the Scan to release its statement and any read lock promptly.

Limits, errors and allocation

max_length is SQLite’s per-text/blob/encoded-row limit, between 1024 and 1,000,000,000 bytes. It includes row encoding overhead; the maximum value payload is therefore smaller. It is not a total database size, page-cache memory cap or execution deadline. Callers choose scan row limits independently.

KvError includes InvalidName, ForeignDatabase, UnsupportedVersion(version), Allocation(AllocError) and Database(std.db.sqlite.DbError). SQLite allocation errors are flattened to KvError.Allocation; other database failures retain their structured cause, including busy, read-only, I/O, closed handles and overlarge values.

Plenty-owned handle/row allocations participate in allocation accounting and failure injection. SQLite’s C allocations are outside those hooks, but its SQLITE_NOMEM failures are returned through the same allocation error.

File compatibility

Files use SQLite’s documented format and a versioned Plenty schema:

PRAGMA application_id = 1347177302; -- ASCII PLKV
PRAGMA user_version = 1;
CREATE TABLE plenty_kv(
    table_name TEXT NOT NULL,
    key BLOB NOT NULL,
    value BLOB NOT NULL,
    PRIMARY KEY(table_name,key)
) STRICT, WITHOUT ROWID;

Mode.Create initializes only an empty, untagged SQLite database, in one transaction. It does not repurpose an unrelated database. Opening a known application ID with an unsupported version fails without migrating it. Version 1 remains readable across Plenty releases; future incompatible changes require an explicit migration/export path rather than silently rewriting old files. SQLite tools can inspect a store directly; use SQLite 3.37+ for STRICT tables. Manual schema/tag changes are unsupported, and the application ID is a format tag, not authentication of an untrusted database.

Durability, recovery and journals remain SQLite’s responsibility. Do not copy an active database by copying just its main file: its journal may be needed for recovery. This API does not yet supply online backup or compaction methods. See SQLite file format and atomic commit.

The tutorial shows a batch transaction and bounded scan. Direct SQL, alternate engines, prefix helpers, zero-copy values and automatic typed serialization are outside this API.