Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251//! one turbopuffer namespace: the v2 write and query endpoints plus the v1//! metadata, cache-warm, recall and delete endpoints.//!//! request bodies are `anytype` and serialized with `std.json`, so a body is//! written the way the api docs show it. `rank` and `filter` in `query.zig`//! build the nested arrays; anything they don't cover is a tuple literal.
const Namespace = @This();
const std = @import("std");const json = std.json;const Allocator = std.mem.Allocator;const Client = @import("Client.zig");const query_mod = @import("query.zig");const write_mod = @import("write.zig");const Export = @import("Export.zig");const zttp = @import("zttp");
client: *const Client,name: []const u8,
pub const max_name_len = 128;
pub const CallOptions = struct { diagnostics: ?*Client.Diagnostics = null,};
const stringify_options: json.Stringify.Options = .{ .emit_null_optional_fields = false };
pub fn init(client: *const Client, name: []const u8) error{InvalidNamespace}!Namespace { if (!isValidName(name)) return error.InvalidNamespace; return .{ .client = client, .name = name };}
/// `[A-Za-z0-9-_.]{1,128}`pub fn isValidName(name: []const u8) bool { if (name.len == 0 or name.len > max_name_len) return false; for (name) |c| switch (c) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.' => {}, else => return false, }; return true;}
/// POST /v2/namespaces/{name}. `request` carries `upsert_rows`,/// `patch_rows`, `deletes`, `delete_by_filter`, `distance_metric`,/// `schema`, ... exactly as documented.pub fn write(self: Namespace, alloc: Allocator, request: anytype, options: CallOptions) !write_mod.Response { const body = try json.Stringify.valueAlloc(alloc, request, stringify_options); defer alloc.free(body);
var buf: [256]u8 = undefined; const response = try self.client.request(.POST, self.path(&buf, ""), body, .{ .diagnostics = options.diagnostics, .retry = !isConditional(@TypeOf(request)), }); defer self.client.free(response);
const parsed = json.parseFromSlice(write_mod.Response, alloc, response, .{ .ignore_unknown_fields = true }) catch return error.UnexpectedResponse; defer parsed.deinit(); return parsed.value;}
/// POST /v2/namespaces/{name}/query. a request with `queries` (multi-query)/// comes back with `result.results()` populated instead of `result.rows()`.pub fn query(self: Namespace, alloc: Allocator, request: anytype, options: CallOptions) !query_mod.Result { const body = try json.Stringify.valueAlloc(alloc, request, stringify_options); defer alloc.free(body);
var buf: [256]u8 = undefined; const response = try self.client.request(.POST, self.path(&buf, "/query"), body, .{ .diagnostics = options.diagnostics }); defer self.client.free(response);
var result = try query_mod.Result.parse(alloc, response); result.request_bytes = body.len; return result;}
/// page through every row in id order. see `Export`.pub fn exporter(self: Namespace, alloc: Allocator, options: Export.Options) Export { return Export.init(self, alloc, options);}
pub const Metadata = struct { parsed: json.Parsed(json.Value),
pub fn deinit(self: Metadata) void { self.parsed.deinit(); }
pub fn get(self: Metadata, key: []const u8) ?json.Value { return switch (self.parsed.value) { .object => |o| o.get(key), else => null, }; }
pub fn approxRowCount(self: Metadata) ?u64 { return query_mod.uintOf(self.get("approx_row_count") orelse return null); }
pub fn approxLogicalBytes(self: Metadata) ?u64 { return query_mod.uintOf(self.get("approx_logical_bytes") orelse return null); }
pub fn createdAt(self: Metadata) ?[]const u8 { return query_mod.stringOf(self.get("created_at") orelse return null); }
pub fn lastWriteAt(self: Metadata) ?[]const u8 { return query_mod.stringOf(self.get("last_write_at") orelse return null); }
/// the per-attribute schema object, or null on a namespace with no rows. pub fn schema(self: Metadata) ?json.ObjectMap { return switch (self.get("schema") orelse return null) { .object => |o| o, else => null, }; }};
/// GET /v1/namespaces/{name}/metadatapub fn metadata(self: Namespace, alloc: Allocator, options: CallOptions) !Metadata { var buf: [256]u8 = undefined; const response = try self.client.request(.GET, self.v1path(&buf, "/metadata"), null, .{ .diagnostics = options.diagnostics }); defer self.client.free(response); const parsed = json.parseFromSlice(json.Value, alloc, response, .{ .allocate = .alloc_always }) catch return error.UnexpectedResponse; return .{ .parsed = parsed };}
/// GET /v1/namespaces/{name}/hint_cache_warm — free when already warm.pub fn warmCache(self: Namespace, options: CallOptions) !void { var buf: [256]u8 = undefined; const response = try self.client.request(.GET, self.v1path(&buf, "/hint_cache_warm"), null, .{ .diagnostics = options.diagnostics }); self.client.free(response);}
pub const Recall = struct { avg_recall: f64, avg_exhaustive_count: f64, avg_ann_count: f64,};
/// POST /v1/namespaces/{name}/_debug/recall. `request` is e.g./// `.{ .num = 25, .top_k = 10 }`, optionally with `filters`.pub fn recall(self: Namespace, alloc: Allocator, request: anytype, options: CallOptions) !Recall { const body = try json.Stringify.valueAlloc(alloc, request, stringify_options); defer alloc.free(body);
var buf: [256]u8 = undefined; const response = try self.client.request(.POST, self.v1path(&buf, "/_debug/recall"), body, .{ .diagnostics = options.diagnostics }); defer self.client.free(response);
const parsed = json.parseFromSlice(Recall, alloc, response, .{ .ignore_unknown_fields = true }) catch return error.UnexpectedResponse; defer parsed.deinit(); return parsed.value;}
/// DELETE /v2/namespaces/{name}. unrecoverable.pub fn delete(self: Namespace, options: CallOptions) !void { var buf: [256]u8 = undefined; const response = try self.client.request(.DELETE, self.path(&buf, ""), null, .{ .diagnostics = options.diagnostics }); self.client.free(response);}
/// a write with an `upsert_condition`, `patch_condition` or `delete_condition`/// is billed as query + write and may apply on a retry that the first send/// already applied, so it is sent exactly once.pub fn isConditional(comptime Request: type) bool { return @hasField(Request, "upsert_condition") or @hasField(Request, "patch_condition") or @hasField(Request, "delete_condition");}
fn path(self: Namespace, buf: *[256]u8, comptime suffix: []const u8) []const u8 { return std.fmt.bufPrint(buf, "/v2/namespaces/{s}" ++ suffix, .{self.name}) catch unreachable;}
fn v1path(self: Namespace, buf: *[256]u8, comptime suffix: []const u8) []const u8 { return std.fmt.bufPrint(buf, "/v1/namespaces/{s}" ++ suffix, .{self.name}) catch unreachable;}
test "namespace name validation" { try std.testing.expect(isValidName("coral")); try std.testing.expect(isValidName("phi-user_did.plc.abc")); try std.testing.expect(!isValidName("")); try std.testing.expect(!isValidName("has space")); try std.testing.expect(!isValidName("at://uri")); try std.testing.expect(!isValidName("x" ** 129)); try std.testing.expect(isValidName("x" ** 128));}
test "paths" { var transport: zttp.Client = .init(std.testing.io, std.testing.allocator); defer transport.deinit(); const client: Client = .init(&transport, .{ .api_key = "k" }); const ns = try client.namespace("bufos-v3"); var buf: [256]u8 = undefined; try std.testing.expectEqualStrings("/v2/namespaces/bufos-v3/query", ns.path(&buf, "/query")); try std.testing.expectEqualStrings("/v1/namespaces/bufos-v3/metadata", ns.v1path(&buf, "/metadata")); try std.testing.expectError(error.InvalidNamespace, client.namespace("no/slashes"));}
test "conditional writes are detected at compile time" { try std.testing.expect(!isConditional(@TypeOf(.{ .upsert_rows = .{} }))); try std.testing.expect(isConditional(@TypeOf(.{ .upsert_rows = .{}, .upsert_condition = .{ "id", "Eq", 1 } }))); try std.testing.expect(isConditional(@TypeOf(.{ .patch_rows = .{}, .patch_condition = .{} }))); try std.testing.expect(isConditional(@TypeOf(.{ .deletes = .{}, .delete_condition = .{} })));}
test "write request bodies stringify like the docs" { const Doc = struct { id: []const u8, vector: []const f32, title: []const u8, has_publication: bool, }; const docs = [_]Doc{ .{ .id = "a1", .vector = &.{ 0.5, 0.25 }, .title = "hello", .has_publication = true }, }; const body = try json.Stringify.valueAlloc(std.testing.allocator, .{ .distance_metric = .cosine_distance, .upsert_rows = &docs, }, stringify_options); defer std.testing.allocator.free(body); try std.testing.expectEqualStrings( "{\"distance_metric\":\"cosine_distance\",\"upsert_rows\":[{\"id\":\"a1\",\"vector\":[0.5,0.25],\"title\":\"hello\",\"has_publication\":true}]}", body, );
const patch = try json.Stringify.valueAlloc(std.testing.allocator, .{ .patch_rows = .{.{ .id = "a1", .status = "superseded" }}, .deletes = .{ "b2", "c3" }, }, stringify_options); defer std.testing.allocator.free(patch); try std.testing.expectEqualStrings( "{\"patch_rows\":[{\"id\":\"a1\",\"status\":\"superseded\"}],\"deletes\":[\"b2\",\"c3\"]}", patch, );}
test "null optionals are omitted from request bodies" { const Req = struct { rank_by: []const []const u8, top_k: u32, filters: ?[]const u8 = null }; const req: Req = .{ .rank_by = &.{ "id", "asc" }, .top_k = 5 }; const body = try json.Stringify.valueAlloc(std.testing.allocator, req, stringify_options); defer std.testing.allocator.free(body); try std.testing.expectEqualStrings("{\"rank_by\":[\"id\",\"asc\"],\"top_k\":5}", body);}