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