GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 231 lines 7.0 kB view raw
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}