Skip to content

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 a host_request extern. 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 defer cleanup
  • 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:

zig
.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:

zig
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
ParameterRequiredDescription
hostYesServer IP or hostname
portYesServer port (default: 23469)
uidYes*Username for authentication
keyYes*Base64-encoded auth key
tlsNoEnable 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

zig
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.

zig
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

zig
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():

zig
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).

zig
try client.reconnect();

AuthResult

zig
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

zig
// 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:

zig
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.

zig
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:

zig
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

zig
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

zig
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

zig
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

zig
var query = Query.init(&client);
defer query.deinit();

_ = query.store("users").readById(doc_key).delete();

try query.run();

Querying

Filter

zig
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

OperatorEnumDescriptionExample
=.eqEqual.where("status", .eq, .{.string = "active"})
!=.neNot equal.where("status", .ne, .{.string = "deleted"})
>.gtGreater than.where("age", .gt, .{.int = 21})
>=.gteGreater or equal.where("age", .gte, .{.int = 18})
<.ltLess than.where("price", .lt, .{.float = 100.0})
<=.lteLess or equal.where("price", .lte, .{.float = 50.0})
~.regexRegex match.where("name", .regex, .{.string = "^John"})
in.inValue in list.where("status", .in, .{.array = &statuses})
contains.containsContains value.where("tags", .contains, .{.string = "featured"})
exists.existsField exists.where("email", .exists, .{.bool = true})

Value types

zig
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

zig
_ = query.store("orders")
    .where("status", .eq, .{.string = "completed"})
    .where("total", .gt, .{.float = 100.0})
    .where("user_id", .lt, .{.int = 10000});

Sorting

zig
_ = query.store("users")
    .orderBy("name", .asc)
    .limit(50);

_ = query.store("orders")
    .orderBy("created_at", .desc)
    .limit(20);

Pagination

zig
// 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)

zig
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

zig
// 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.

zig
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

zig
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.Unauthenticated

Pattern

zig
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

  1. Always clean up, defer query.deinit(), defer response.deinit(), defer auth.deinit().
  2. Save auth.new_key, first admin login regenerates the default key and shows it once.
  3. Use type-safe structs, Zig's comptime reflection serializes them to BSON automatically.
  4. Limit result sets, always cap unbounded reads with .limit(...).
  5. Create indexes, for fields used in filters, before they hit the hot path.
  6. Handle errors explicitly, pair catch with switch on the error set you care about.
  7. 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.
  8. Pick the right WasmClient buffer size, 8 KB is fine for CRUD; 64 KB+ for scans/aggregations returning many rows.

API Reference

Client (target-aware alias)

zig
pub const Client = if (is_wasm) WasmClient else PlanckClient;

PlanckClient (native)

zig
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) void

WasmClient (WASM)

zig
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) !i64

Query

zig
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) !QueryResponse

QueryResponse

zig
pub const QueryResponse = struct {
    success: bool,
    data: ?[]const u8,
    count: usize,
    allocator: ?Allocator,

    pub fn deinit(self: *QueryResponse) void;
};

AuthResult

zig
pub const AuthResult = struct {
    token: [32]u8,
    new_key: ?[]const u8 = null,
    allocator: ?Allocator = null,

    pub fn deinit(self: *AuthResult) void;
};