Zig Client
The official Zig client for Planck. Same API across two transports:
- Native (
PlanckClient), opens a TCP connection (optional TLS 1.3), authenticates, and talks the BSON-native protocol directly. Used from shell apps, BFFs, dev servers, integration tests. - WASM (
WasmClient), for WASM services running inside Planck. The host process already owns the connection; the client is a thin transport adapter that funnels query packets through ahost_requestextern. No TCP, no TLS, no auth from the WASM side.
You import the target-aware alias Client and the same query builder works in either runtime. The compiler picks the right transport via builtin.target.cpu.arch == .wasm32.
Features
- BSON-native protocol, no HTTP overhead
- Fluent query builder with filters, sorting, aggregation, projections
- Type-safe struct serialization via Zig's comptime reflection
- Manual memory management with idiomatic
defercleanup - TLS 1.3 (native only)
- Built-in retry policy, circuit breaker, and timeout config (native only)
- Connection string–based configuration, same format used by every Planck client
Installation
build.zig.zon:
.dependencies = .{
.planck_zig_client = .{
.url = "https://github.com/planckapps/planck-zig-client/-/archive/v0.1.0/planck-zig-client-v0.1.0.tar.gz",
.hash = "...",
},
}build.zig, wire it into your module graph the same way you wire zeish (see zeish dependency wiring). The package is published as planck_zig_client; downstream code typically imports it as planck:
const planck_dep = b.dependency("schnelldb_zig_client", .{});
const planck = b.createModule(.{
.root_source_file = planck_dep.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
planck.addImport("bson", bson);
planck.addImport("utils", utils);
planck.addImport("tls", tls);
planck.addImport("proto", proto);
// Consumer-facing import name
exe_mod.addImport("planck", planck);Connection String
Native clients connect using the standard Planck connection string:
host:port;uid=<username>;key=<base64-key>;tls=true|false| Parameter | Required | Description |
|---|---|---|
host | Yes | Server IP or hostname |
port | Yes | Server port (default: 23469) |
uid | Yes* | Username for authentication |
key | Yes* | Base64-encoded auth key |
tls | No | Enable TLS 1.3 (true/false, default: false) |
*Required when server has security enabled (default). For full reference and resilience defaults shared by all Planck clients see the Connection String Reference.
WASM services do not use a connection string, the Planck host has already connected and authenticated; the WASM module just calls WasmClient.init(allocator, buf_size) and starts issuing queries.
Quick Start, Native
const std = @import("std");
const planck = @import("planck");
const Client = planck.Client;
const Query = planck.Query;
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var threaded: std.Io.Threaded = .init(allocator, .{ .async_limit = .unlimited });
defer threaded.deinit();
const io = threaded.io();
// Init + connect using a connection string
var client = try Client.init(allocator, io);
defer client.deinit();
var auth = try client.connect("127.0.0.1:23469;uid=admin;key=NH8ohl2LHDT8xSJbHGPAsCluCh5pe8Ldn+hckcJovXk=");
defer auth.deinit();
// First-time admin login: server may have auto-regenerated the key.
// Save auth.new_key, it is shown ONCE.
if (auth.new_key) |new_key| {
std.log.warn("New admin key (save this!): {s}", .{new_key});
}
// Insert
const User = struct { id: u64, name: []const u8, age: u32 };
const user = User{ .id = 1, .name = "Alice", .age = 30 };
var query = Query.init(client);
defer query.deinit();
_ = try query.store("users").create(user);
var response = try query.run();
defer response.deinit();
}Quick Start, WASM
A WASM service running inside Planck skips connect/auth entirely; the host already has the database open. WasmClient.init only needs an allocator and a packet buffer size.
const std = @import("std");
const zeish = @import("zeish");
const planck = @import("planck");
const Client = planck.Client;
const Query = planck.Query;
var app: zeish.WasmApp = undefined;
var client: Client = undefined;
export fn init() i32 {
const allocator = std.heap.wasm_allocator;
// 8 KB packet buffer, sized to the largest single query/response you expect
client = Client.init(allocator, 8 * 1024) catch return -1;
app = zeishex.WasmApp.init(allocator, .{}) catch return -1;
var handler = ListHandler{ .client = &client };
app.route(ListHandler, null, null, .get, "/users", &handler, null) catch return -1;
app.onResponse(struct {
fn hook(_: *const zeish.WasmRequest, res: *zeish.WasmResponse, buf: []u8) void {
const b = res.toBytes(buf) catch return;
host_respond(b.ptr, @intCast(b.len));
}
}.hook);
return 0;
}
export fn process(p: [*]const u8, l: u32) i32 {
app.process(p, l) catch return -1;
return 0;
}
extern fn host_respond(ptr: [*]const u8, len: u32) void;
const ListHandler = struct {
client: *Client,
pub fn handle(self: *ListHandler, allocator: std.mem.Allocator, _: *anyopaque) ![]const u8 {
var query = Query.init(self.client);
defer query.deinit();
_ = query.store("users").orderBy("id", .asc).limit(50);
var response = try query.run();
defer response.deinit();
return try allocator.dupe(u8, response.data orelse "[]");
}
};The Query API in this handler is identical to the native example, same .store(...).where(...).run() chain. Only the client construction differs.
Connection, Native
Plain TCP
var client = try Client.init(allocator, io);
defer client.deinit();
var auth = try client.connect("127.0.0.1:23469;uid=admin;key=NH8ohl2LHDT8xSJbHGPAsCluCh5pe8Ldn+hckcJovXk=");
defer auth.deinit();
defer client.disconnect();TLS 1.3
Append ;tls=true. The handshake happens transparently inside connect():
var auth = try client.connect(
"db.example.com:23469;uid=myapp;key=abc123base64key==;tls=true",
);
defer auth.deinit();The connection string is stored on the client; client.reconnect() re-uses it (re-establishes TCP, re-runs the TLS handshake if tls=true, re-authenticates).
try client.reconnect();AuthResult
pub const AuthResult = struct {
token: [32]u8, // session token, used implicitly on subsequent ops
new_key: ?[]const u8, // set on first admin login when server regenerates key
allocator: ?Allocator,
pub fn deinit(self: *AuthResult) void;
};If new_key is non-null, the server auto-regenerated the default admin key and is handing it back once. Persist it before the next connection attempt, there is no second chance.
Resilience knobs
// Retry policy
client.setRetryPolicy(.{
.max_attempts = 3,
.initial_backoff_ms = 100,
.max_backoff_ms = 10_000,
.multiplier = 2,
});
// Timeout config
client.setTimeoutConfig(.{
.connect_timeout_ms = 5_000,
.read_timeout_ms = 30_000,
.write_timeout_ms = 10_000,
.operation_timeout_ms = 60_000,
});
// Circuit breaker
client.setCircuitBreaker(planck.CircuitBreaker.init(io, 5, 2, 30_000));Defaults match the cross-client defaults documented in the Connection String Reference.
WasmClient
A WASM service is loaded by Planck into the same OS process as the database. Queries go through a single host extern:
extern fn host_request(query_ptr: [*]const u8, query_len: u32,
dest_ptr: [*]u8, dest_cap: u32) i32;The client serializes a Packet, hands it to host_request, and parses the reply. Zero socket, zero serialization-over-the-wire, zero auth handshake, the host already owns the session.
Surface area is intentionally tiny, the runtime concerns (connection pooling, TLS, reconnection, retry, circuit breaker) all live on the host side and don't apply.
pub const WasmClient = struct {
pub fn init(allocator: Allocator, buf_size: usize) !WasmClient;
pub fn deinit(self: *WasmClient) void;
// Low-level, most code goes through Query instead
pub fn doOperation(self: *WasmClient, op: Operation) !Packet;
// Auto-incrementing sequence (creates the named sequence on first call)
pub fn nextSequence(self: *WasmClient, name: []const u8) !i64;
};Sizing buf_size: pick the largest single query/response you expect. 8 KB is fine for typical CRUD; bump to 64 KB+ for scans or aggregations returning many rows.
Query Builder
Query is identical in native and WASM. Every chain follows the same shape:
Query.init(&client)
.store("store_name")
.operation(...)
.run();Always pair the builder with defer query.deinit() and the response with defer response.deinit().
CRUD Operations
Create
const User = struct {
id: u64,
name: []const u8,
email: []const u8,
age: u32,
};
const user = User{
.id = 1,
.name = "Alice",
.email = "alice@example.com",
.age = 28,
};
var query = Query.init(&client);
defer query.deinit();
_ = try query.store("users").create(user);
var response = try query.run();
defer response.deinit();
if (response.data) |data| {
std.debug.print("Created: {s}\n", .{data});
}Read by ID
var query = Query.init(&client);
defer query.deinit();
_ = query.store("users").readById(12345678901234567890);
var response = try query.run();
defer response.deinit();
if (response.data) |data| {
std.debug.print("User: {s}\n", .{data});
}Update
const updated = User{
.id = 1,
.name = "Alice Smith",
.email = "alice.smith@example.com",
.age = 29,
};
var query = Query.init(&client);
defer query.deinit();
_ = try query.store("users").readById(doc_key).update(updated);
var response = try query.run();
defer response.deinit();Delete
var query = Query.init(&client);
defer query.deinit();
_ = query.store("users").readById(doc_key).delete();
try query.run();Querying
Filter
var query = Query.init(&client);
defer query.deinit();
_ = query.store("users")
.where("age", .gt, .{ .int = 21 })
.limit(10);
var response = try query.run();
defer response.deinit();Filter operators
| Operator | Enum | Description | Example |
|---|---|---|---|
= | .eq | Equal | .where("status", .eq, .{.string = "active"}) |
!= | .ne | Not equal | .where("status", .ne, .{.string = "deleted"}) |
> | .gt | Greater than | .where("age", .gt, .{.int = 21}) |
>= | .gte | Greater or equal | .where("age", .gte, .{.int = 18}) |
< | .lt | Less than | .where("price", .lt, .{.float = 100.0}) |
<= | .lte | Less or equal | .where("price", .lte, .{.float = 50.0}) |
~ | .regex | Regex match | .where("name", .regex, .{.string = "^John"}) |
in | .in | Value in list | .where("status", .in, .{.array = &statuses}) |
contains | .contains | Contains value | .where("tags", .contains, .{.string = "featured"}) |
exists | .exists | Field exists | .where("email", .exists, .{.bool = true}) |
Value types
const Value = union(enum) {
string: []const u8,
int: i64,
float: f64,
bool: bool,
null_value,
};
// Examples
.{ .string = "hello" }
.{ .int = 42 }
.{ .float = 3.14 }
.{ .bool = true }
.{ .null_value = {} }AND filters
_ = query.store("orders")
.where("status", .eq, .{.string = "completed"})
.where("total", .gt, .{.float = 100.0})
.where("user_id", .lt, .{.int = 10000});Sorting
_ = query.store("users")
.orderBy("name", .asc)
.limit(50);
_ = query.store("orders")
.orderBy("created_at", .desc)
.limit(20);Pagination
// Page 1
_ = query.store("users")
.orderBy("id", .asc)
.limit(10);
// Page 2
_ = query.store("users")
.orderBy("id", .asc)
.skip(10)
.limit(10);Scan (range reads)
var query = Query.init(&client);
defer query.deinit();
const start_key: ?u128 = null; // start from beginning
const record_count: u32 = 100;
_ = query.store("users").scan(record_count, start_key);
var response = try query.run();
defer response.deinit();Aggregations
// Count
_ = query.store("orders").count("total_orders");
// Multiple aggregations
_ = query.store("orders")
.count("order_count")
.sum("total_revenue", "amount")
.avg("avg_order_value", "amount")
.min("min_order", "amount")
.max("max_order", "amount");
// Group by single field
_ = query.store("orders")
.groupBy("status")
.count("count")
.sum("revenue", "total");
// Group by multiple fields
_ = query.store("sales")
.groupBy("region")
.groupBy("year")
.count("order_count")
.sum("revenue", "amount");
// Filter + group + aggregate
_ = query.store("orders")
.where("created_at", .gte, .{.int = start_timestamp})
.where("created_at", .lt, .{.int = end_timestamp})
.groupBy("customer_id")
.count("order_count")
.sum("total_spent", "amount")
.avg("avg_order_value", "amount");Index Management
Native only, WASM services rely on indexes the host already provisioned.
try client.create(planck.Index{
.id = 0,
.store_id = 0,
.ns = "users.email_idx",
.field = "email",
.field_type = .String,
.unique = true,
.description = "Email lookup index",
.created_at = 0,
});Error Handling
Common errors
error.ConnectionFailed
error.ConnectionRefused
error.ConnectionLost
error.WriteTimeout
error.ReadTimeout
error.Timeout
error.DocumentNotFound
error.InvalidQuery
error.InvalidResponse
error.InvalidDocument
error.SerializationFailed
error.DeserializationFailed
error.PermissionDenied
error.UnauthenticatedPattern
var response = query.run() catch |err| switch (err) {
error.ConnectionRefused, error.ConnectionLost => {
std.log.err("Database unavailable", .{});
try client.reconnect();
return err;
},
error.DocumentNotFound => {
std.log.warn("Document not found, using defaults", .{});
return default_value;
},
else => return err,
};
defer response.deinit();Best Practices
- Always clean up,
defer query.deinit(),defer response.deinit(),defer auth.deinit(). - Save
auth.new_key, first admin login regenerates the default key and shows it once. - Use type-safe structs, Zig's comptime reflection serializes them to BSON automatically.
- Limit result sets, always cap unbounded reads with
.limit(...). - Create indexes, for fields used in filters, before they hit the hot path.
- Handle errors explicitly, pair
catchwithswitchon the error set you care about. - One client per logical connection, the native client serializes operations on a single TCP connection via an internal mutex; spawn one per worker pool, not one per request.
- Pick the right
WasmClientbuffer size, 8 KB is fine for CRUD; 64 KB+ for scans/aggregations returning many rows.
API Reference
Client (target-aware alias)
pub const Client = if (is_wasm) WasmClient else PlanckClient;PlanckClient (native)
pub fn init(allocator: Allocator, io: Io) !*Self
pub fn initWithFlushThreshold(allocator: Allocator, io: Io, flush_threshold: u32) !*Self
pub fn deinit(self: *Self) void
// Connection
pub fn connect(self: *Self, conn_str: []const u8) !AuthResult
pub fn disconnect(self: *Self) void
pub fn reconnect(self: *Self) !void
pub fn isConnected(self: *Self) bool
// Auth
pub fn authenticate(self: *Self, uid: []const u8, key: []const u8) !AuthResult
pub fn logout(self: *Self) !void
// Resilience
pub fn setRetryPolicy(self: *Self, policy: RetryPolicy) void
pub fn setTimeoutConfig(self: *Self, config: TimeoutConfig) void
pub fn setCircuitBreaker(self: *Self, breaker: CircuitBreaker) void
pub fn getCircuitBreakerState(self: *const Self) CircuitBreaker.State
pub fn resetCircuitBreaker(self: *Self) voidWasmClient (WASM)
pub fn init(allocator: Allocator, buf_size: usize) !WasmClient
pub fn deinit(self: *WasmClient) void
pub fn doOperation(self: *WasmClient, op: Operation) !Packet
pub fn nextSequence(self: *WasmClient, name: []const u8) !i64Query
pub fn init(client: *Client) Query
pub fn initWithAllocator(client: *Client, allocator: Allocator) Query
pub fn deinit(self: *Query) void
// Store
pub fn store(self: *Query, name: []const u8) *Query
pub fn index(self: *Query, name: []const u8) *Query
// CRUD
pub fn create(self: *Query, value: anytype) !*Query
pub fn readById(self: *Query, id: u128) *Query
pub fn update(self: *Query, value: anytype) !*Query
pub fn delete(self: *Query) *Query
// Filtering & modifiers
pub fn where(self: *Query, field: []const u8, op: FilterOp, value: Value) *Query
pub fn orderBy(self: *Query, field: []const u8, direction: OrderDir) *Query
pub fn limit(self: *Query, n: u32) *Query
pub fn skip(self: *Query, n: u32) *Query
pub fn scan(self: *Query, max_records: u32, start_key: ?u128) *Query
// Aggregations
pub fn groupBy(self: *Query, field: []const u8) *Query
pub fn count(self: *Query, name: []const u8) *Query
pub fn sum(self: *Query, name: []const u8, field: []const u8) *Query
pub fn avg(self: *Query, name: []const u8, field: []const u8) *Query
pub fn min(self: *Query, name: []const u8, field: []const u8) *Query
pub fn max(self: *Query, name: []const u8, field: []const u8) *Query
// Execution
pub fn run(self: *Query) !QueryResponseQueryResponse
pub const QueryResponse = struct {
success: bool,
data: ?[]const u8,
count: usize,
allocator: ?Allocator,
pub fn deinit(self: *QueryResponse) void;
};AuthResult
pub const AuthResult = struct {
token: [32]u8,
new_key: ?[]const u8 = null,
allocator: ?Allocator = null,
pub fn deinit(self: *AuthResult) void;
};