Pure Nim client for MongrelDB - embedded+server database with SQL, vector search, full-text search, and AI-native retrieval.
No C ABI bindings and no external dependencies - built on the standard library std/httpclient and std/json. The API mirrors the MongrelDB PHP, Go, Java, and D clients.
| Surface | Package | Install |
|---|---|---|
| Nim client | mongreldb |
nimble install mongreldb |
- Nim 2.0 or newer (built and tested with Nim 2.2.10)
- A running
mongreldb-serverdaemon
- Typed CRUD over the Kit transaction endpoint:
put(insert), anddeleteByPk(delete by primary key), with optional idempotency keys for safe retries. - Fluent query builder that pushes conditions down to the engine's specialized indexes for sub-millisecond lookups: bitmap equality/IN, learned-range, null checks, FM-index full-text search, HNSW vector similarity (
ann), and sparse vector match. Friendly aliases (column→column_id,min/max→lo/hi) are translated to the server's on-wire keys. - Idempotent batch transactions - operations staged locally and committed atomically, with the engine enforcing unique, foreign-key, and check constraints at commit time. Idempotency keys return the original response on duplicate commits, even after a crash.
- Full SQL access through the DataFusion-backed
/sqlendpoint: recursive CTEs, window functions,CREATE TABLE AS SELECT, materialized views, and multi-statement execution. - Schema management: typed table creation with
enumVariants(constrained value sets) anddefaultValue(server-side defaults), full schema catalog, and per-table descriptors. - Typed exceptions:
AuthError(401/403),NotFoundError(404),ConflictError(409, with error code + op index), andQueryError(everything else), all subclasses ofMongrelDBErrorcarrying the HTTP status and decoded server envelope. - Pluggable auth: Bearer token (
--auth-tokenmode) and HTTP Basic (--auth-usersmode); the token takes precedence. - User/role/credentials management via SQL: Argon2id-hashed catalog users, roles, and
GRANT/REVOKEtable-level permissions, all executed throughsql.
Runnable, end-to-end programs and deep dives for every feature live in
docs/:
- Quickstart - install, start the daemon, write and run a complete program.
- Batch transactions - atomic multi-op commits, idempotency, and retry.
- Native query builder - every condition type and the alias translation rules.
- SQL - recursive CTEs, window functions,
CREATE TABLE AS SELECT. - Authentication - bearer token, basic auth, and user/role management via SQL.
- Error handling - the exception hierarchy and recovery patterns.
import std/[json, options]
import mongreldb
# Connect to a running mongreldb-server daemon.
let db = newMongrelDB("http://127.0.0.1:8453")
# Create a table. Column ids are stable on-wire identifiers. `enumVariants`
# constrains the allowed values for `status`; the engine rejects writes that
# don't match the list. `defaultValue` (Option[string]) sets a server-side
# default that fills in when a put omits the column. Both fields are emitted
# on the wire only when populated.
discard db.createTable("orders", [
Column(id: 1'i64, name: "id", ty: "int64", primaryKey: true, nullable: false),
Column(id: 2'i64, name: "customer", ty: "varchar", primaryKey: false, nullable: false),
Column(id: 3'i64, name: "amount", ty: "float64", primaryKey: false, nullable: false),
Column(id: 4'i64, name: "status", ty: "enum", primaryKey: false, nullable: false,
enumVariants: @["pending", "paid", "shipped"]),
Column(id: 5'i64, name: "created_at", ty: "timestamp_nanos",
defaultValue: some("now")),
], %*{
"checks": [{"id": 1, "name": "id_present", "expr": {"IsNotNull": 1}}],
})
# Insert rows (cells pair column id -> value). `status` must be one of the
# enum variants; `created_at` is omitted and the engine fills the current time.
discard db.put("orders", {1'i64: %1'i64, 2'i64: %"Alice", 3'i64: %99.50, 4'i64: %"pending"})
discard db.put("orders", {1'i64: %2'i64, 2'i64: %"Bob", 3'i64: %150.00, 4'i64: %"paid"})
# Query with a native index condition (learned-range index on a float64 column).
let rows = db.query("orders")
.where("range_f64", parseJson("""{"column": 3, "min": 100.0, "max": 200.0, "min_inclusive": true, "max_inclusive": true}"""))
.projection([1'i64, 2'i64])
.limit(100)
.execute()
echo "rows: ", rows.len
echo "count: ", db.count("orders") # 2
# Run SQL.
discard db.sql("UPDATE orders SET amount = 200.0 WHERE customer = 'Bob'")# Bearer token (--auth-token mode)
let db = newMongrelDB("http://127.0.0.1:8453", token = "my-secret-token")
# HTTP Basic (--auth-users mode)
let db2 = newMongrelDB("http://127.0.0.1:8453", username = "admin", password = "s3cret")
# Custom per-request timeout (milliseconds, default 30000)
var db3 = newMongrelDB("http://127.0.0.1:8453")
discard db3.setTimeout(60_000)Operations are staged locally and committed atomically. The engine enforces unique, foreign-key, and check constraints at commit time.
let txn = db.begin()
discard txn.put("orders", {1'i64: %10'i64, 2'i64: %"Dave", 3'i64: %50.00})
discard txn.put("orders", {1'i64: %11'i64, 2'i64: %"Eve", 3'i64: %75.00})
discard txn.deleteByPk("orders", %2'i64)
try:
let results = txn.commit() # atomic - all or nothing
except ConflictError as e:
# A constraint violation rolls back every op.
echo "duplicate: ", e.msg, " code=", e.code, " op=", e.opIndex
# Idempotent commit - safe to retry; the daemon returns the original response.
let txn2 = db.begin()
discard txn2.put("orders", {1'i64: %20'i64, 2'i64: %"Frank", 3'i64: %100.00})
discard txn2.commit("order-20-create")Conditions push down to the engine's specialized indexes. The builder accepts
friendly aliases that are translated to the server's on-wire keys: column
(→ column_id), min/max (→ lo/hi), min_inclusive/max_inclusive
(→ lo_inclusive/hi_inclusive). The canonical keys are also accepted directly.
Use range for integer columns and range_f64 for float64 columns.
# Bitmap equality (low-cardinality columns).
discard db.query("orders")
.where("bitmap_eq", parseJson("""{"column": 2, "value": "Alice"}"""))
.execute()
# Range query on a float64 column (use `range` for integer columns).
discard db.query("orders")
.where("range_f64", parseJson("""{"column": 3, "min": 50.0, "max": 150.0, "min_inclusive": true, "max_inclusive": true}"""))
.limit(100)
.execute()
# Range query on an integer column.
discard db.query("orders")
.where("range", parseJson("""{"column": 1, "min": 1, "max": 100}"""))
.limit(100)
.execute()
# Full-text search (FM-index).
discard db.query("documents")
.where("fm_contains", parseJson("""{"column": 2, "value": "database performance"}"""))
.limit(10)
.execute()
# Check whether a result was capped by the limit.
let q = db.query("orders")
.where("range_f64", parseJson("""{"column": 3, "min": 0.0, "max": 9999.0, "min_inclusive": true, "max_inclusive": true}"""))
.limit(100)
let rows = q.execute()
if q.truncated:
# result set hit the limit; more matches exist on the server
discarddiscard db.sql("INSERT INTO orders (id, customer, amount) VALUES (99, 'Zoe', 999.0)")
discard db.sql("CREATE TABLE archive AS SELECT * FROM orders WHERE amount > 500")
# Recursive CTEs and window functions
discard db.sql("WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM r WHERE n<10) SELECT n FROM r")
discard db.sql("SELECT id, ROW_NUMBER() OVER (PARTITION BY customer ORDER BY amount DESC) FROM orders")Note: the
/sqlendpoint streams Arrow IPC bytes forSELECTs. The client decodes JSON bodies when present and returns an empty seq otherwise (e.g. for DDL/DML or binary result sets).
Control how far back time-travel queries can read. The window is measured in epochs (monotonically increasing commit numbers).
# Keep at least 1000 epochs of history readable.
let result = db.setHistoryRetentionEpochs(1000)
echo result.historyRetentionEpochs # 1000
echo result.earliestRetainedEpoch # oldest epoch still available
echo db.historyRetentionEpochs() # 1000
echo db.earliestRetainedEpoch() # oldest readable epoch
# Read a table as it existed at a specific epoch.
let historical = db.sql("SELECT label FROM orders AS OF EPOCH 42 WHERE id = 1")Raising retention prevents history from being garbage collected, but it cannot restore epochs that have already been pruned. These endpoints require admin privileges when the daemon runs with auth enabled.
When the daemon runs in --auth-users mode, users and roles live in the
catalog and are managed with SQL through sql.
# Create an Argon2id-hashed user.
discard db.sql("CREATE USER alice WITH PASSWORD 'hunter2'")
# Promote to administrator.
discard db.sql("ALTER USER alice ADMIN")
# Roles and table-level grants.
discard db.sql("CREATE ROLE analyst")
discard db.sql("GRANT SELECT ON orders TO analyst")
discard db.sql("GRANT analyst TO alice")
discard db.sql("REVOKE SELECT ON orders FROM analyst")
discard db.sql("DROP ROLE analyst")
discard db.sql("DROP USER alice")See docs/auth.md for the full auth mode reference and user/role recipes.
Every non-2xx response is mapped to a typed exception. Catch
MongrelDBError for any failure, or one of the specific subclasses.
try:
discard db.schemaFor("missing_table")
except NotFoundError as e:
echo "not found: ", e.msg, " (status ", e.status, ")"
except ConflictError as e:
echo "constraint ", e.code, " at op ", e.opIndex
except AuthError:
echo "not authorized"
except QueryError as e:
echo "query/server error: ", e.msg| Method | Description |
|---|---|
newMongrelDB(url = defaultBaseURL; token = ""; username = ""; password = "") |
Construct a client |
setTimeout(ms) |
Set per-request timeout (ms); returns var MongrelDB |
health() |
Check daemon health |
tableNames() |
List table names |
createTable(name, columns) |
Create a table; returns the table id |
dropTable(name) |
Drop a table |
count(table) |
Row count |
put(table, cells, idempotencyKey = "") |
Insert a row |
deleteByPk(table, pk) |
Delete by primary key |
query(table) |
Start a native query |
sql(sql) |
Execute SQL |
schema() |
Full schema catalog (OrderedTable[string, JsonNode]) |
schemaFor(table) |
Single-table descriptor |
setHistoryRetentionEpochs(epochs) |
Set the history retention window |
historyRetentionEpochs() |
Get the current retention window |
earliestRetainedEpoch() |
Get the oldest readable epoch |
begin() |
Start a batch |
Column describes one column in a createTable call. id is the stable
on-wire identifier referenced everywhere else.
| Field | Type | Description |
|---|---|---|
id |
int64 |
Stable on-wire column identifier |
name |
string |
Human-readable name |
ty |
string |
Engine type, e.g. int64, varchar, float64 |
primaryKey |
bool |
Marks the column as the primary key |
nullable |
bool |
Allows NULL values |
enumVariants |
seq[string] |
Allowed values for an enum column; emitted as enum_variants only when non-empty |
defaultValue |
Option[string] |
Server-side default; emitted as default_value only when set |
defaultValueJson |
Option[JsonNode] |
Static JSON scalar; takes precedence over defaultValue |
defaultExpr |
Option[string] |
Dynamic default: now or uuid; takes precedence server-side |
import std/options
Column(id: 4'i64, name: "status", ty: "varchar",
enumVariants: @["pending", "paid", "shipped"])
Column(id: 5'i64, name: "note", ty: "varchar", nullable: true,
defaultValue: some(""))| Method | Description |
|---|---|
where(type, params) |
Add a native condition (AND-ed) |
projection(columnIDs) |
Set column projection |
limit(n) |
Set row limit |
offset(n) |
Skip matching rows before the limit |
build() |
Build the request payload |
execute() |
Run the query |
truncated |
Whether the last execute result hit the limit |
| Method | Description |
|---|---|
put(table, cells, returning) |
Stage an insert |
delete(table, rowId) |
Stage a delete by row id |
deleteByPk(table, pk) |
Stage a delete by primary key |
count |
Number of staged operations |
commit(idempotencyKey = "") |
Commit atomically |
rollback() |
Discard all operations |
| Type | HTTP status | Meaning |
|---|---|---|
MongrelDBError |
- | Base type for every client failure |
AuthError |
401, 403 | Bad or missing credentials |
NotFoundError |
404 | Missing table, schema, or resource |
ConflictError |
409 | Unique/FK/check/trigger violation |
QueryError |
400, 5xx, transport | Catch-all for malformed queries and server errors |
All carry .status (int), .code (string, e.g. UNIQUE_VIOLATION), and
.opIndex (int, the offending op in a failed transaction, or -1).
The live test suite is a standalone executable that boots a real
mongreldb-server daemon and exercises the full client surface. It resolves
the binary in this order: the MONGRELDB_SERVER env var, ./bin/mongreldb-server,
then mongreldb-server on PATH. If none is available and MONGRELDB_URL is
unset, it self-skips. Set MONGRELDB_URL to point at an already-running daemon.
# Validate the package manifest and compile the library module
nimble check
nim c --path:src --noLinking:on src/mongreldb.nim
# Run the offline unit tests (no daemon needed)
nim c --path:src --run tests/test_query.nim
# Build and run the live suite
nimble liveTest
# or, explicitly:
# nim c --path:src --mm:orc -o:build/live_test src/mongreldb/tests/live_test.nim
# ./build/live_test # set MONGRELDB_URL=http://127.0.0.1:8453 to use a running daemonFetch a prebuilt server binary from the MongrelDB releases:
mkdir -p bin
curl -fsSL -o bin/mongreldb-server \
https://github.com/visorcraft/MongrelDB/releases/download/v0.52.3/mongreldb-server-linux-x64
chmod +x bin/mongreldb-serverContributions are welcome. Please:
- Open an issue first for non-trivial changes.
- Add focused tests near your change - the suite must stay green.
- Keep the client dependency-free (Nim standard library only) and free of engine C ABI bindings.
Dual-licensed under the MIT License or the Apache License, Version 2.0, at your option. See MIT OR Apache-2.0 for the full text.
SPDX-License-Identifier: MIT OR Apache-2.0
