GET /xrpc/app.bsky.actor.searchActorsTypeahead
typeahead.waow.tech
1//! Read-only Turso HTTP API client for sync
2//! Uses hrana v2 pipeline protocol
3
4const std = @import("std");
5const io = std.Options.debug_io;
6const http = std.http;
7const json = std.json;
8const mem = std.mem;
9const Allocator = mem.Allocator;
10
11const log = std.log.scoped(.turso);
12
13fn cGetenv(name: [*:0]const u8) ?[]const u8 {
14 if (std.c.getenv(name)) |p| return mem.span(p);
15 return null;
16}
17
18const TursoClient = @This();
19
20const Value = struct { type: []const u8 = "text", value: []const u8 };
21const Stmt = struct { sql: []const u8, args: ?[]const Value = null };
22const ExecuteReq = struct { type: []const u8 = "execute", stmt: Stmt };
23const CloseReq = struct { type: []const u8 = "close" };
24
25allocator: Allocator,
26url: []const u8, // host only (no protocol prefix)
27token: []const u8,
28http_client: http.Client,
29mutex: std.Io.Mutex = std.Io.Mutex.init,
30
31pub fn init(allocator: Allocator) !TursoClient {
32 const url = cGetenv("TURSO_URL") orelse {
33 log.err("TURSO_URL not set", .{});
34 return error.MissingEnv;
35 };
36 const token = cGetenv("TURSO_AUTH_TOKEN") orelse {
37 log.err("TURSO_AUTH_TOKEN not set", .{});
38 return error.MissingEnv;
39 };
40
41 const libsql_prefix = "libsql://";
42 const host = if (mem.startsWith(u8, url, libsql_prefix))
43 url[libsql_prefix.len..]
44 else
45 url;
46
47 log.info("turso client → {s}", .{host});
48
49 return .{
50 .allocator = allocator,
51 .url = host,
52 .token = token,
53 .http_client = .{ .allocator = allocator, .io = io },
54 };
55}
56
57pub fn deinit(self: *TursoClient) void {
58 self.http_client.deinit();
59}
60
61pub const Row = struct {
62 columns: []const json.Value,
63
64 pub fn text(self: Row, index: usize) []const u8 {
65 if (index >= self.columns.len) return "";
66 return extractText(self.columns[index]);
67 }
68
69 pub fn int(self: Row, index: usize) i64 {
70 if (index >= self.columns.len) return 0;
71 return extractInt(self.columns[index]);
72 }
73};
74
75pub const Result = struct {
76 allocator: Allocator,
77 parsed: ?json.Parsed(json.Value),
78 rows: []const Row,
79
80 pub fn deinit(self: *Result) void {
81 self.allocator.free(self.rows);
82 if (self.parsed) |*p| p.deinit();
83 }
84};
85
86pub fn query(self: *TursoClient, sql: []const u8, args: []const []const u8) !Result {
87 const response = try self.executeRaw(sql, args);
88 defer self.allocator.free(response);
89 return parseResult(self.allocator, response);
90}
91
92fn executeRaw(self: *TursoClient, sql: []const u8, args: []const []const u8) ![]const u8 {
93 self.mutex.lockUncancelable(io);
94 defer self.mutex.unlock(io);
95
96 var url_buf: [512]u8 = undefined;
97 const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch
98 return error.UrlTooLong;
99
100 const body = try self.buildRequestBody(sql, args);
101 defer self.allocator.free(body);
102
103 var auth_buf: [512]u8 = undefined;
104 const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch
105 return error.AuthTooLong;
106
107 var response_body: std.Io.Writer.Allocating = .init(self.allocator);
108 errdefer response_body.deinit();
109
110 const res = self.http_client.fetch(.{
111 .location = .{ .url = url },
112 .method = .POST,
113 .headers = .{
114 .content_type = .{ .override = "application/json" },
115 .authorization = .{ .override = auth },
116 },
117 .payload = body,
118 .response_writer = &response_body.writer,
119 .keep_alive = false,
120 }) catch |err| {
121 log.err("http failed: {s}", .{@errorName(err)});
122 return error.HttpError;
123 };
124
125 if (res.status != .ok) {
126 const resp_text = response_body.toOwnedSlice() catch "";
127 defer if (resp_text.len > 0) self.allocator.free(resp_text);
128 const preview = if (resp_text.len > 200) resp_text[0..200] else resp_text;
129 log.err("turso error: {} | {s}", .{ res.status, preview });
130 return error.TursoError;
131 }
132
133 return try response_body.toOwnedSlice();
134}
135
136fn buildRequestBody(self: *TursoClient, sql: []const u8, args: []const []const u8) ![]const u8 {
137 var body: std.Io.Writer.Allocating = .init(self.allocator);
138 errdefer body.deinit();
139 var jw: json.Stringify = .{ .writer = &body.writer, .options = .{ .emit_null_optional_fields = false } };
140
141 var values: []const Value = &.{};
142 defer if (values.len > 0) self.allocator.free(values);
143
144 if (args.len > 0) {
145 const v = try self.allocator.alloc(Value, args.len);
146 for (args, 0..) |arg, i| {
147 v[i] = .{ .value = arg };
148 }
149 values = v;
150 }
151
152 try jw.beginObject();
153 try jw.objectField("requests");
154 try jw.beginArray();
155 try jw.write(ExecuteReq{
156 .stmt = .{ .sql = sql, .args = if (values.len > 0) values else null },
157 });
158 try jw.write(CloseReq{});
159 try jw.endArray();
160 try jw.endObject();
161
162 return try body.toOwnedSlice();
163}
164
165fn parseResult(allocator: Allocator, response: []const u8) !Result {
166 const parsed = json.parseFromSlice(json.Value, allocator, response, .{}) catch {
167 return .{ .allocator = allocator, .parsed = null, .rows = &.{} };
168 };
169
170 const json_rows = getRowsFromParsed(parsed.value) orelse {
171 return .{ .allocator = allocator, .parsed = parsed, .rows = &.{} };
172 };
173
174 var rows: std.ArrayList(Row) = .empty;
175 errdefer rows.deinit(allocator);
176
177 for (json_rows.items) |item| {
178 if (item == .array) {
179 try rows.append(allocator, .{ .columns = item.array.items });
180 }
181 }
182
183 return .{
184 .allocator = allocator,
185 .parsed = parsed,
186 .rows = try rows.toOwnedSlice(allocator),
187 };
188}
189
190fn getRowsFromResult(item: json.Value) ?json.Array {
191 if (item != .object) return null;
192 const resp = item.object.get("response") orelse return null;
193 if (resp != .object) return null;
194 const res = resp.object.get("result") orelse return null;
195 if (res != .object) return null;
196 const rows = res.object.get("rows") orelse return null;
197 if (rows != .array) return null;
198 return rows.array;
199}
200
201fn getRowsFromParsed(value: json.Value) ?json.Array {
202 const results = value.object.get("results") orelse return null;
203 if (results != .array or results.array.items.len == 0) return null;
204 return getRowsFromResult(results.array.items[0]);
205}
206
207fn extractText(val: json.Value) []const u8 {
208 return switch (val) {
209 .string => |s| s,
210 .object => |obj| {
211 const v = obj.get("value") orelse return "";
212 return if (v == .string) v.string else "";
213 },
214 else => "",
215 };
216}
217
218fn extractInt(val: json.Value) i64 {
219 return switch (val) {
220 .integer => |i| i,
221 .object => |obj| {
222 const v = obj.get("value") orelse return 0;
223 return switch (v) {
224 .integer => |i| i,
225 .string => |s| std.fmt.parseInt(i64, s, 10) catch 0,
226 else => 0,
227 };
228 },
229 else => 0,
230 };
231}