Nucleus Client

View as Markdown

Neutron for Zig ships a typed client for all 14 Nucleus data models. It connects over the PostgreSQL wire protocol, so it works against Nucleus or plain PostgreSQL, with feature detection to light up the non-SQL models when connected to Nucleus.

Status: not covered by the live conformance suite. The sdk-live CI matrix runs python, go, typescript and elixir against a running engine; zig is not in it. The signatures below are taken from the SDK source and its own unit tests, not from executed calls against Nucleus. When that suite was first pointed at an SDK it found breakage in eight methods on day one, so treat this surface as likely to hold surprises. Checked against source 2026-08-15.

Connecting

const NucleusClient = @import("nucleus/client.zig").NucleusClient;

var client = try NucleusClient.fromUrl(allocator, "postgres://localhost:5432/mydb");
try client.connect();
defer client.close();

if (client.isNucleus()) {
    // Non-SQL models are available.
}

connect() authenticates, fills the connection pool, and runs feature detection. client.features carries is_nucleus and the server version string (features.versionStr()) — not a per-model capability flag, unlike the Python, Go and TypeScript clients. isNucleus() is the shorthand for the check you actually want.

Model accessors are functions, not fields

Each model is reached through a method call on the client, which returns a typed model struct:

const kv = client.kv();
_ = try kv.set("session:abc", "user_42", 3600); // ttl in seconds, 0 for none
const val = try kv.get("session:abc");          // ?[]const u8 — null when absent

Written as a field access — client.kv.set(...) — this does not compile. The accessors are sql(), kv(), vector(), timeseries(), document(), fts(), graph(), geo(), blob(), streams(), pubsub(), columnar(), datalog() and cdc(), one per model, each in its own file under src/nucleus/.

Reading results

Runtime model methods return !?[]const u8 — an error union wrapping an optional slice. null means the query returned no row, which is distinct from an empty string:

if (try kv.get("missing")) |value| {
    std.debug.print("got {s}\n", .{value});
} else {
    // key absent
}

Raw SQL goes through the client directly:

const rows = try client.query("SELECT id, email FROM users WHERE active = true");
const tag = try client.exec("UPDATE users SET active = false WHERE id = 1");
const vec = client.vector();
const hits = try vec.search("embeddings", "[0.1,0.2,0.3]", 10, "cosine");

The query vector is passed as its text representation, and the metric as a string ("cosine", "l2", "inner_product"). Each model also exposes *Sql builder functions (Vector.searchSql, KV.setSql, …) that write the statement into a caller-supplied buffer and perform no I/O — that is the zero-allocation path, and it is what the runtime methods are built on.

Two API layers

This is the distinction worth understanding before reading the source:

| Layer | Shape | Allocates | Use when | |---|---|---|---| | *Sql builders | fn setSql(key, value, ttl, buf) ![]const u8 | no — writes into buf | you own the connection, or you are on a fixed budget | | Runtime methods | fn set(self, key, value, ttl) !?[]const u8 | via the client | you want the call to execute |

The builders are why the SDK fits in a 12KB binary with no heap: on an embedded target you can format the statement into a stack buffer and send it over your own transport, never linking the pool at all.

Connection pool

client.poolSize();              // configured slots
client.availableConnections();  // idle
client.inUseConnections();      // checked out

Pool size comes from the connection URL or Config. close() returns every connection and is safe to call twice.

Errors

lastErrorCode() returns the SQLSTATE of the most recent server error, captured before the connection is recycled:

_ = client.exec("INSERT INTO users (id) VALUES (1)") catch {
    if (std.mem.eql(u8, client.lastErrorCode(), "23505")) {
        // unique violation
    }
};