Zig Client
planck-zig-client is the Zig client for Planck. One package, one set of imports, and two transports:
PlanckClient, the native client. It opens a TCP connection (optionally TLS 1.3), authenticates, and talks the BSON-native wire protocol. This is whatplanctl, workbench, integration tests, and ordinary Zig programs use.WasmClient, for WASM modules hosted by Planck. Here there is no socket, no TLS, no handshake. Every operation goes through a singlehost_requestextern and rides on the host's session.
You import the alias planck.Client and the same builder works in either runtime. The compiler picks the right one for you through builtin.target.cpu.arch == .wasm32, so handler code stays identical across native and WASM.
const planck = @import("planck");
// Native
var client = try planck.Client.init(allocator, io);
// WASM
var client = try planck.Client.init(allocator, 4 * 1024 * 1024);Minimum Zig: 0.16.0.
Installation
build.zig.zon:
.dependencies = .{
.planck_zig_client = .{
.url = "https://github.com/planckapps/planck-zig-client/archive/refs/tags/v0.1.0.tar.gz",
.hash = "...",
},
},build.zig:
const planck_dep = b.dependency("planck_zig_client", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("planck", planck_dep.module("planck_zig_client"));Transitive dependencies (bson, tls, proto, utils) are pulled in automatically. You only add them to your own module graph in case your code uses them directly.
Connection String
Native clients connect using the cross-language Planck connection string. One parser, one format:
host:port;uid=<username>;key=<base64-key>;tls=true|false| Parameter | Required | Description |
|---|---|---|
host | yes | Server IP or hostname. |
port | yes | Server port (Planck's default is 23469). |
uid | yes | Username for authentication. |
key | yes | Base64-encoded auth key. |
tls | no | true enables TLS 1.3 on the wire. Absent or anything else is off. |
The parser splits on ;, and then on = for each pair. Unknown keys are simply ignored, which keeps things forward-compatible. For the full reference, see Connection String.
WASM modules do not use a connection string. The host is already connected and authenticated.
TLS
Planck speaks TLS 1.3, nothing older. Append ;tls=true and the handshake happens inside connect() itself.
In a mono app, the default posture is TLS 1.3 with a self-signed certificate. The client connects, completes the handshake, and does not verify the server certificate chain. This matches the deployment posture where the server, the data, and the workload all sit behind one trust boundary. So ;tls=true works out of the box. In case you front Planck with a TLS-terminating proxy, leave ;tls=true off and let the proxy handle the same. For a real certificate, configure it server-side in config.yaml; the client still just needs ;tls=true.
There is no ca, verify, or cert parameter on the client. Mono covers the common case. For anything beyond that, the right knob is on the server.
Quick Start (native)
const std = @import("std");
const planck = @import("planck");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var threaded: std.Io.Threaded = .init(allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const client = try planck.Client.init(allocator, io);
defer client.deinit();
var auth = try client.connect(
"127.0.0.1:23469;uid=admin;key=UGxhbmNrX0RlZmF1bHRfQWRtaW5fS2V5XzAwMTA=;tls=false",
);
defer auth.deinit();
// First admin login may auto-regenerate the default key. It's shown ONCE.
if (auth.new_key) |k| std.log.warn("save this key: {s}", .{k});
var resp = try client.executePql(
allocator,
"products.filter(CategoryID = 3).orderBy(Name, asc).limit(50)",
);
defer resp.deinit();
}The connection string is stored on the client. client.reconnect() reuses the same: it re-opens TCP, re-runs the TLS 1.3 handshake if tls=true, and re-authenticates. client.isConnected() and client.disconnect() round out the lifecycle.
Quick Start (WASM)
A WASM module loaded by Planck shares the host process. There is no connect and no auth, you just init the client with a scratch buffer:
const std = @import("std");
const planck = @import("planck");
var client: planck.Client = undefined;
export fn init() i32 {
const allocator = std.heap.wasm_allocator;
client = planck.Client.init(allocator, 4 * 1024 * 1024) catch return -1;
return 0;
}The buffer is per-instance linear memory, used to marshal request bytes before each extern call. Size it to the largest single request or response you expect. 8 KB is fine for CRUD. Do bump it to 64 KB+ for scans, aggregations, or anything that returns many rows.
Two Ways to Query
The same QueryResponse comes back either way. Pick the path that fits the call site.
client.executePql (the easy path)
Hand the client a PQL string and an allocator. You get back a response which you then decode into your own struct.
const Product = struct {
ProductID: i32 = 0,
Name: []const u8 = "",
CategoryID: i32 = 0,
BasePrice: f64 = 0,
};
var resp = try client.executePql(
allocator,
"products.filter(CategoryID = 3).orderBy(Name, asc).limit(50)",
);
defer resp.deinit();
if (!resp.success) return error.QueryFailed;
const items = try resp.decode(allocator, Product);
defer allocator.free(items);Same signature on PlanckClient and WasmClient. Mutations work too:
_ = try client.executePql(allocator,
"products.insert({\"ProductID\": 9001, \"Name\": \"Widget\", \"CategoryID\": 1, \"BasePrice\": 9.99})");
_ = try client.executePql(allocator,
"products.filter(ProductID = 9001).set({\"BasePrice\": 12.99})");
_ = try client.executePql(allocator,
"products.filter(ProductID = 9001).delete()");Insert and update JSON payloads are converted to BSON in the allocator you pass, so a per-request arena frees everything once the response is sent.
planck.Query (the typed path)
When filter values or field names come from typed variables and you want the compiler to keep you honest, use the builder. Construct it with Query.initWithAllocator(client, allocator) so that the builder scratch (filter values, sort keys, etc.) lives in the allocator you control.
fn listProducts(client: *planck.Client, allocator: std.mem.Allocator) ![]const Product {
var q = planck.Query.initWithAllocator(client, allocator);
defer q.deinit();
var resp = try q.store("products")
.where("CategoryID", .eq, .{ .int = 3 })
.orderBy("Name", .asc)
.limit(50)
.run();
defer resp.deinit();
return try resp.decode(allocator, Product);
}Query.init(client) is the shorter form when you are fine using the client's allocator.
Builder methods
.store(name) .index(name)
.create(value) .batchInsert(values)
.readByKey(u128) .range(start, end, ?max_count)
.where(field, op, value) .between(field, lower, upper)
.@"and"(field, op, value) .@"or"(field, op, value)
.orderBy(field, .asc|.desc) .limit(n) .skip(n)
.after(cursor) .select(fields)
.scan(max, ?start_key)
.groupBy(field)
.count(name) .sum(name, field) .avg(name, field)
.min(name, field) .max(name, field) .countOnly()
.update(value) .delete() .deleteByKey(u128)
.run()FilterOp is .eq, .ne, .gt, .gte, .lt, .lte, .regex, .in, .contains, .starts_with, .exists, .between. OrderDir is .asc or .desc.
Value is a tagged union:
pub const Value = union(enum) {
string: []const u8,
int: i64,
float: f64,
bool: bool,
null,
array: []const Value,
u128: u128,
};Build them inline at the call site: .{ .int = 3 }, .{ .string = "abc" }, .{ .bool = true }, .{ .null = {} }.
CRUD with the typed builder
const User = struct {
id: u64,
name: []const u8,
email: []const u8,
age: u32,
};
// Create
var q = planck.Query.initWithAllocator(client, allocator);
defer q.deinit();
_ = try q.store("users").create(User{
.id = 1,
.name = "Alice",
.email = "alice@example.com",
.age = 28,
});
var r1 = try q.run();
defer r1.deinit();
// Read by primary key
var q2 = planck.Query.initWithAllocator(client, allocator);
defer q2.deinit();
var r2 = try q2.store("users").readByKey(doc_key).run();
defer r2.deinit();
const one = try r2.decodeOne(allocator, User);
// Update
var q3 = planck.Query.initWithAllocator(client, allocator);
defer q3.deinit();
_ = try q3.store("users").readByKey(doc_key).update(User{
.id = 1,
.name = "Alice Smith",
.email = "alice.smith@example.com",
.age = 29,
});
var r3 = try q3.run();
defer r3.deinit();
// Delete
var q4 = planck.Query.initWithAllocator(client, allocator);
defer q4.deinit();
_ = q4.store("users").deleteByKey(doc_key);
var r4 = try q4.run();
defer r4.deinit();batchInsert(values) takes a slice of structs and ships them all in one round trip. range(start, end, ?max_count) is the typed equivalent of a key-bounded scan; scan(max, ?start_key) walks from an optional cursor.
Filters, sorting, pagination
// AND across multiple filters (logical AND is implicit between .where calls)
_ = q.store("orders")
.where("status", .eq, .{ .string = "completed" })
.where("total", .gt, .{ .float = 100.0 })
.where("user_id", .lt, .{ .int = 10_000 });
// Explicit OR uses the keyword form
_ = q.store("users")
.where("role", .eq, .{ .string = "admin" })
.@"or"("role", .eq, .{ .string = "owner" });
// Between (inclusive)
_ = q.store("events").between("ts", .{ .int = start_ms }, .{ .int = end_ms });
// Sort + paginate
_ = q.store("users").orderBy("name", .asc).limit(50);
_ = q.store("users").orderBy("id", .asc).skip(50).limit(50);
// Projection
_ = q.store("users").select(&.{ "id", "name", "email" });Aggregations
// Single counter
_ = q.store("orders").count("total_orders");
// Multiple aggregates in one pass
_ = q.store("orders")
.count("order_count")
.sum("total_revenue", "amount")
.avg("avg_order_value", "amount")
.min("min_order", "amount")
.max("max_order", "amount");
// Group by, filter, aggregate
_ = q.store("orders")
.where("created_at", .gte, .{ .int = start_ts })
.where("created_at", .lt, .{ .int = end_ts })
.groupBy("customer_id")
.count("order_count")
.sum("total_spent", "amount")
.avg("avg_order_value", "amount");
// Cheap count over a filter (server-side, no rows back)
_ = q.store("users").where("active", .eq, .{ .bool = true }).countOnly();Validating a PQL string before it runs
If you need to inspect or reject a parsed query before it hits the wire (refusing mutations on systemdb, say), then skip executePql and stitch the primitives together yourself. Query.applyAst is the same translation step that executePql calls under the hood, so validation slots in cleanly between parse and applyAst.
var ast = try planck.pql.parse(allocator, user_input);
defer ast.deinit();
if (std.mem.eql(u8, ast.store orelse "", "systemdb") and ast.mutation != null) {
return error.SystemDbReadOnly;
}
var q = planck.Query.initWithAllocator(client, allocator);
defer q.deinit();
try q.applyAst(&ast);
var resp = try q.run();
defer resp.deinit();The workbench query editor uses this exact pattern. The reference handler is planck/wb/src/api/query.zig.
Decoding Responses
var resp = try q.store("products").limit(50).run();
defer resp.deinit();
if (!resp.success) {
std.log.err("query failed: {s}", .{resp.error_message orelse "?"});
return error.QueryFailed;
}
const items = try resp.decode(allocator, Product);
defer allocator.free(items);decode(T) returns []const T. For a single-row response (for example readByKey), decodeOne(T) returns one T. Both go through the BSON decoder, so do note that your struct field names must match the document keys exactly.
Schema Validation
Schemas describe the structure and constraints of a document. They run in your handler before insert and return a structured error which you can serialize back to the client.
pub const ProductSchema = planck.Schema(&.{
.{ "SKU", .{ .field_type = .string, .required = true, .min_length = 1, .max_length = 50 } },
.{ "Name", .{ .field_type = .string, .required = true, .min_length = 1, .max_length = 200 } },
.{ "CategoryID", .{ .field_type = .int, .required = true, .min = 1 } },
.{ "BasePrice", .{ .field_type = .double, .required = true, .min = 0 } },
});
if (try ProductSchema.validate(allocator, &incoming)) |verr| {
var err = verr;
defer err.deinit();
const msg = try err.format(allocator);
defer allocator.free(msg);
res.status = .bad_request;
return res.json(msg);
}FieldType covers .string, .int, .int32, .double, .float, .boolean, .date, .object_id, .array, .object, .binary, .decimal128, .uuid, .timestamp, .null_type. FieldRule carries the per-field constraints (required, min, max, min_length, max_length, enum_values). Schema(...).validateAndEncode(allocator, value) is the shortcut when the next step is BSON anyway: it validates first, BSON-encodes on success, and returns error.ValidationFailed on failure.
Models
planck.Model(T, opts) is a thin layer over Query that binds a struct type to a store and gives you the usual find, findOne, findById, create, updateOne, deleteOne, count, exists, plus per-field aggregates and a typed QueryBuilder / Aggregator. It is optional sugar, useful when the same handful of fields gets queried from many call sites and you would rather not retype the store name and field strings each time.
const Product = struct {
ProductID: i32 = 0,
Name: []const u8 = "",
CategoryID: i32 = 0,
BasePrice: f64 = 0,
CreatedAt: i64 = 0,
UpdatedAt: i64 = 0,
};
pub const Products = planck.Model(Product, .{
.store = "products",
.primary_key = "ProductID",
.sequence_name = "product_id",
.timestamps = true,
.schema = &.{
.{ "Name", .{ .field_type = .string, .required = true, .min_length = 1 } },
.{ "BasePrice", .{ .field_type = .double, .min = 0 } },
},
});
const list = try Products.find(client, allocator, .{ .CategoryID = 3 });
defer allocator.free(list);The timestamps flag fills CreatedAt / UpdatedAt automatically. The field names are configurable via created_at_field and updated_at_field on ModelOpts. Models are a convenience layer only; everything they do is reachable from the bare Query API as well.
Change Streams (Watch)
The native client tails change events from one or more stores. It is long-poll style: the server holds the call open until new events arrive or max_wait_ms elapses.
var since: u64 = 0;
while (true) {
var result = try client.watch(allocator, &.{ "orders", "payments" }, since, 30_000);
defer result.deinit(allocator);
for (result.records) |rec| {
switch (rec.kind) {
.insert, .update => try process(rec.store_ns, rec.value),
.delete => try evict(rec.store_ns, rec.key),
}
since = rec.lsn;
}
}watch fans in across a slice of store names on one socket. watchOne(allocator, store, since_lsn, max_wait_ms) is the convenience wrapper for a single store. WatchResult.rebootstrap_required flags the rare case where the server cannot serve from your cursor (typically after a long disconnect or a WAL truncate); when it is set, reseed from a full read.
Watch is not available on WasmClient. In-WASM modules do not own the socket, so they cannot long-poll. The convention here is to run a separate sse/ service in your app, built on the native client. The WatchClient in ssehub wraps watch with auto-reconnect and cursor preservation across Planck restarts.
Sequences
nextSequence returns a monotonically-increasing 64-bit integer for the given name. The counter lives in Planck and is durable across restarts. It is available on both clients.
const order_id = try client.nextSequence("order_id");Use this when you want compact integer ids without rolling your own monotonic source.
Fault Handling (native)
PlanckClient ships with retry, timeout, and circuit breaker policies. They are off by default and opt-in per client.
client.setRetryPolicy(.{
.max_attempts = 3,
.initial_backoff_ms = 100,
.max_backoff_ms = 10_000,
.backoff_multiplier = 2.0,
});
client.setTimeoutConfig(.{
.connect_timeout_ms = 5_000,
.read_timeout_ms = 30_000,
.write_timeout_ms = 10_000,
.operation_timeout_ms = 60_000,
});
client.setCircuitBreaker(planck.CircuitBreaker.init(io, 5, 2, 30_000));
if (client.getCircuitBreakerState() == .open) return error.UpstreamDown;TimeoutConfig ships three presets: .default, .fast, .no_timeout. The circuit breaker is positional: init(io, failure_threshold, success_threshold, timeout_ms). resetCircuitBreaker() forces it back to closed.
For a one-off override on a single call, doOperationWithTimeout(op, timeout_ms) and sendAsyncWithTimeout(op, timeout_ms) bypass the configured timeout. The cross-client resilience defaults are documented in the Connection String reference.
Errors
Errors are ClientErrors plus the standard Zig allocator and IO errors. The common cases:
ConnectionFailed,ConnectionRefused,ConnectionReset: TCP is gone. Reconnect.AuthFailed: wrong uid or key, or the admin key has changed.Timeout,ReadTimeout,WriteTimeout: the operation exceeded its configured timeout.CircuitOpen: breaker has tripped, requests are being short-circuited.ProtocolError,InvalidResponse: the server sent something the client could not parse.NotFound,PermissionDenied: the server returned an error status.
Pair catch with switch on the cases you care about:
var resp = client.executePql(allocator, q) catch |err| switch (err) {
error.ConnectionReset, error.ConnectionRefused => {
try client.reconnect();
return err;
},
error.NotFound => return defaults,
else => return err,
};
defer resp.deinit();disconnect() and reconnect() are both safe to call from a recovery path; the client tracks state with isConnected().
Low-Level Escape Hatches
Most code should use Query or executePql. But in case you need to talk the wire protocol directly:
const reply = try client.doOperation(operation); // sync round trip
const tag = try client.sendAsync(operation); // returns request id
const reply = try client.receiveAsync(); // matches the tagOperation is exported from the proto crate. Anything above doOperation is public API. There are also sendRaw / receiveRaw / sendOp for the integration tests; real code should not need the same.
API Cheatsheet
// Alias resolves at compile time based on target.
pub const Client = if (is_wasm) WasmClient else PlanckClient;
// PlanckClient (native)
pub fn init(allocator, io) !*PlanckClient
pub fn connect(self, conn_str) !AuthResult
pub fn reconnect(self) !void
pub fn disconnect(self) void
pub fn isConnected(self) bool
pub fn executePql(self, allocator, pql_text) !QueryResponse
pub fn watch(self, allocator, stores, since_lsn, max_wait_ms) !WatchResult
pub fn watchOne(self, allocator, store, since_lsn, max_wait_ms) !WatchResult
pub fn nextSequence(self, name) !i64
pub fn setRetryPolicy(self, policy) void
pub fn setTimeoutConfig(self, config) void
pub fn setCircuitBreaker(self, breaker) void
pub fn getCircuitBreakerState(self) CircuitBreaker.State
pub fn resetCircuitBreaker(self) void
// WasmClient
pub fn init(allocator, buf_size) !WasmClient
pub fn executePql(self, allocator, pql_text) !QueryResponse
pub fn doOperation(self, op) !Packet
pub fn nextSequence(self, name) !i64
// Query (target-agnostic)
pub fn init(client) Query
pub fn initWithAllocator(client, allocator) Query
pub fn run(self) !QueryResponse
// QueryResponse
success: bool
data: ?[]const u8
error_message: ?[]const u8
count: usize
pub fn decode(self, allocator, comptime T) ![]const T
pub fn decodeOne(self, allocator, comptime T) !T
pub fn deinit(self) void
// AuthResult
token: [32]u8
new_key: ?[]const u8 // set once, on first admin login when the server regenerates the default key
pub fn deinit(self) voidHandling Tips
A few things worth knowing before you reach for them:
- Always pair the builder and the response with
defer:defer query.deinit(),defer response.deinit(),defer auth.deinit(). - Save
auth.new_keythe first time it appears. The server shows it only once. - Cap unbounded reads with
.limit(...). The builder will happily ask for everything otherwise. - Create indexes before the field hits the hot path. Filters without indexes will scan.
- One client per logical connection. The native client serializes operations on a single TCP socket via an internal mutex. So spawn one per worker pool, not one per request.
- Size the WASM scratch buffer to your largest single payload. 8 KB for CRUD, 64 KB+ for scans and aggregations.