This repository has no description
README.md

tpuf #

a turbopuffer client for zig 0.16, over zttp. request bodies are plain zig values serialized with std.json, so a query reads the way the api docs write it; responses keep their attributes dynamic, because a namespace's schema belongs to whoever wrote it.

install #

requires zig 0.16+.

zig fetch --save https://tangled.org/zzstoatzz.io/tpuf/archive/main

then in build.zig:

const tpuf = b.dependency("tpuf", .{ .target = target, .optimize = optimize }).module("tpuf");
exe.root_module.addImport("tpuf", tpuf);

use #

const tpuf = @import("tpuf");
const zttp = @import("zttp");

var transport = zttp.Client.initWithUserAgent(io, allocator, "myapp/1.0 (+https://example.com)");
defer transport.deinit();
const client: tpuf.Client = .init(&transport, .{ .api_key = key, .host = .{ .region = "gcp-us-central1" } });
const ns = try client.namespace("posts");

_ = try ns.write(allocator, .{
    .distance_metric = .cosine_distance,
    .upsert_rows = &docs, // any struct with `id`, `vector`, and attribute fields
}, .{});

const result = try ns.query(allocator, .{
    .rank_by = tpuf.rank.ann("vector", embedding),
    .top_k = 10,
    .filters = tpuf.filter.@"and"(.{
        tpuf.filter.eq("kind", "post"),
        tpuf.filter.gte("ts", since),
    }),
    .include_attributes = tpuf.Attributes.all,
}, .{});
defer result.deinit();

var rows = result.rows();
while (rows.next()) |row| {
    const uri = row.string("uri") orelse continue;
    const dist = row.dist().?;
    ...
}

Successful queries expose the provider's billing and performance fields:

const billing = result.billing();
const performance = result.performance();
std.log.info("tpuf request_bytes={d} response_bytes={d} queried={?d} cache={?s}", .{
    result.request_bytes,
    result.response_bytes,
    billing.billable_logical_bytes_queried,
    performance.cache_temperature,
});
hybrid search
const result = try ns.query(allocator, .{
    .queries = .{
        .{ .rank_by = tpuf.rank.ann("vector", embedding), .top_k = 20 },
        .{ .rank_by = tpuf.rank.max(.{
            tpuf.rank.bm25("name", text),
            tpuf.rank.bm25("caption", text),
        }), .top_k = 20 },
    },
}, .{});
defer result.deinit();
var results = result.results(); // one view per query, in order

one round trip for both legs; add .rerank_by = .{"RRF"} to have the api fuse them.

typed rows
const Hit = struct { id: tpuf.Id, @"$dist": f64, uri: ?[]const u8 = null };
const hits = try result.rowsAs(Hit, arena.allocator());

unknown attributes are ignored, so a namespace that predates a field still deserializes.

patch, delete, export
_ = try ns.write(allocator, .{ .patch_rows = .{.{ .id = old_id, .status = "superseded" }} }, .{});
_ = try ns.write(allocator, .{ .deletes = &ids }, .{});

var pages = ns.exporter(allocator, .{ .attributes = .only(&.{"uri"}) });
while (try pages.next()) |page| {
    defer page.deinit();
    ...
}

export pages in id order with an id > last cursor, the way the api guide describes.

errors
var diag: tpuf.Diagnostics = .{};
ns.query(allocator, req, .{ .diagnostics = &diag }) catch |err| switch (err) {
    error.BadRequest => log.err("{s}", .{diag.message()}), // the api's own text
    else => return err,
};

a non-2xx status maps to a named error and the api's message lands in Diagnostics; transport failures come through as zttp's named errors.

namespaces, warmth, ids
const page = try client.listNamespaces(allocator, .{ .prefix = "user-" });
const meta = try ns.metadata(allocator, .{});
try ns.warmCache(.{});

var keepalive: tpuf.Keepalive = .init(ns, allocator, .{});
try keepalive.start();

const id = tpuf.id.hash(at_uri); // 32 hex chars, under the 64-byte id cap

design #

  • requests are values, not builders — std.json turns tuples into arrays, so rank and filter return tuples that nest into any body. the api's grammar stays visible and nothing here has to be updated when it grows.
  • responses stay dynamic — a row is a json object with typed getters, or rowsAs(T) when a struct is wanted. the namespace decides its schema; the client does not pretend to know it.
  • one transport, bounded — a borrowed zttp.Client pools connections across namespaces and threads and puts a deadline on every phase of a request. tpuf adds only what the api needs on top: status retries with Retry-After, and named errors for each status.
  • nothing global — no module-level state; a Client is a value, and a Namespace is a client plus a name. several namespaces per process is the normal case, not the exception.

develop #

zig build test    # unit tests, no network
zig build smoke   # against the real api: needs TURBOPUFFER_API_KEY, optional TURBOPUFFER_REGION

docs #

docs/adopters.md the services that carry a hand-rolled copy of this today, and what each needs from it
turbopuffer in production operational lessons that shaped the api: id caps, schema evolution, cold namespaces

license #

MIT