search for standard sites pub-search.waow.tech
search zig blog atproto
12
fork

Configure Feed

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

refactor db.zig to use std.json.Stringify

- replace manual JSON string building with json.Stringify API
- remove appendEscaped() - json.write() handles escaping
- merge execSqlNoArgs into execSql with default empty args
- extract extractRows() helper for turso response parsing
- reuse extractRows() in parseCount() to reduce duplication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+156 -184
+156 -184
backend/src/db.zig
··· 1 1 const std = @import("std"); 2 2 const mem = std.mem; 3 - const Thread = std.Thread; 4 - const Allocator = mem.Allocator; 5 3 const json = std.json; 6 4 const http = std.http; 7 - const Io = std.Io; 5 + const Allocator = mem.Allocator; 8 6 9 7 pub var turso_url: []const u8 = undefined; 10 8 pub var turso_token: []const u8 = undefined; 11 - pub var mutex: Thread.Mutex = .{}; 9 + pub var mutex: std.Thread.Mutex = .{}; 12 10 13 11 var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; 14 12 13 + // Turso API response types 14 + const TursoResponse = struct { 15 + results: []const TursoResult, 16 + }; 17 + 18 + const TursoResult = struct { 19 + type: []const u8, 20 + response: ?TursoResultResponse = null, 21 + }; 22 + 23 + const TursoResultResponse = struct { 24 + type: []const u8, 25 + result: ?TursoQueryResult = null, 26 + }; 27 + 28 + const TursoQueryResult = struct { 29 + rows: []const []const TursoValue, 30 + }; 31 + 32 + const TursoValue = struct { 33 + type: []const u8, 34 + value: ?json.Value = null, 35 + }; 36 + 37 + // Search result type 38 + pub const SearchResult = struct { 39 + uri: []const u8, 40 + did: []const u8, 41 + title: []const u8, 42 + snippet: []const u8, 43 + createdAt: []const u8, 44 + rkey: []const u8, 45 + basePath: []const u8, 46 + }; 47 + 15 48 pub fn init() !void { 16 49 turso_url = std.posix.getenv("TURSO_URL") orelse { 17 50 std.debug.print("TURSO_URL not set\n", .{}); ··· 29 62 pub fn close() void {} 30 63 31 64 fn initSchema() !void { 32 - _ = try execSqlNoArgs( 65 + _ = try execSql( 33 66 \\CREATE TABLE IF NOT EXISTS documents ( 34 67 \\ uri TEXT PRIMARY KEY, 35 68 \\ did TEXT NOT NULL, ··· 39 72 \\ created_at TEXT, 40 73 \\ publication_uri TEXT 41 74 \\) 42 - ); 75 + , &.{}); 43 76 44 - _ = try execSqlNoArgs( 77 + _ = try execSql( 45 78 \\CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( 46 79 \\ uri UNINDEXED, 47 80 \\ title, 48 81 \\ content 49 82 \\) 50 - ); 83 + , &.{}); 51 84 52 - _ = try execSqlNoArgs( 85 + _ = try execSql( 53 86 \\CREATE TABLE IF NOT EXISTS publications ( 54 87 \\ uri TEXT PRIMARY KEY, 55 88 \\ did TEXT NOT NULL, ··· 58 91 \\ description TEXT, 59 92 \\ base_path TEXT 60 93 \\) 61 - ); 94 + , &.{}); 62 95 63 96 // migrate: add columns if missing 64 - _ = execSqlNoArgs("ALTER TABLE documents ADD COLUMN publication_uri TEXT") catch {}; 65 - _ = execSqlNoArgs("ALTER TABLE publications ADD COLUMN base_path TEXT") catch {}; 97 + _ = execSql("ALTER TABLE documents ADD COLUMN publication_uri TEXT", &.{}) catch {}; 98 + _ = execSql("ALTER TABLE publications ADD COLUMN base_path TEXT", &.{}) catch {}; 66 99 67 100 std.debug.print("turso schema initialized with FTS5\n", .{}); 68 101 } 69 102 70 103 pub fn insertDocument(uri: []const u8, did: []const u8, rkey: []const u8, title: []const u8, content: []const u8, created_at: ?[]const u8, publication_uri: ?[]const u8) !void { 71 - _ = try execSqlWithArgs( 104 + _ = try execSql( 72 105 "INSERT OR REPLACE INTO documents (uri, did, rkey, title, content, created_at, publication_uri) VALUES (?, ?, ?, ?, ?, ?, ?)", 73 - &[_][]const u8{ uri, did, rkey, title, content, created_at orelse "", publication_uri orelse "" }, 106 + &.{ uri, did, rkey, title, content, created_at orelse "", publication_uri orelse "" }, 74 107 ); 75 108 76 - // delete from fts first (ignore errors) 77 - _ = execSqlWithArgs("DELETE FROM documents_fts WHERE uri = ?", &[_][]const u8{uri}) catch {}; 109 + _ = execSql("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 78 110 79 - _ = execSqlWithArgs( 111 + _ = execSql( 80 112 "INSERT INTO documents_fts (uri, title, content) VALUES (?, ?, ?)", 81 - &[_][]const u8{ uri, title, content }, 113 + &.{ uri, title, content }, 82 114 ) catch |err| { 83 115 std.debug.print("insert FTS error: {}\n", .{err}); 84 116 }; 85 117 } 86 118 87 119 pub fn insertPublication(uri: []const u8, did: []const u8, rkey: []const u8, name: []const u8, description: ?[]const u8, base_path: ?[]const u8) !void { 88 - _ = try execSqlWithArgs( 120 + _ = try execSql( 89 121 "INSERT OR REPLACE INTO publications (uri, did, rkey, name, description, base_path) VALUES (?, ?, ?, ?, ?, ?)", 90 - &[_][]const u8{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 122 + &.{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 91 123 ); 92 124 } 93 125 94 126 pub fn deleteDocument(uri: []const u8) void { 95 - _ = execSqlWithArgs("DELETE FROM documents WHERE uri = ?", &[_][]const u8{uri}) catch {}; 96 - _ = execSqlWithArgs("DELETE FROM documents_fts WHERE uri = ?", &[_][]const u8{uri}) catch {}; 127 + _ = execSql("DELETE FROM documents WHERE uri = ?", &.{uri}) catch {}; 128 + _ = execSql("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 97 129 } 98 130 99 131 pub fn deletePublication(uri: []const u8) void { 100 - _ = execSqlWithArgs("DELETE FROM publications WHERE uri = ?", &[_][]const u8{uri}) catch {}; 132 + _ = execSql("DELETE FROM publications WHERE uri = ?", &.{uri}) catch {}; 101 133 } 102 134 103 135 pub fn searchDocuments(alloc: Allocator, query: []const u8) !std.ArrayList(u8) { 104 - var response: std.ArrayList(u8) = .{}; 105 - try response.appendSlice(alloc, "["); 136 + var output: std.ArrayList(u8) = .{}; 137 + const writer = output.writer(alloc); 106 138 107 139 const temp_alloc = gpa.allocator(); 108 140 109 - const result = execSqlWithArgs( 141 + const result = execSql( 110 142 "SELECT f.uri, d.did, d.title, snippet(documents_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, d.created_at, d.rkey, p.base_path FROM documents_fts f JOIN documents d ON f.uri = d.uri LEFT JOIN publications p ON d.publication_uri = p.uri WHERE documents_fts MATCH ? ORDER BY rank LIMIT 50", 111 - &[_][]const u8{query}, 143 + &.{query}, 112 144 ) catch { 113 - try response.appendSlice(alloc, "]"); 114 - return response; 145 + try writer.writeAll("[]"); 146 + return output; 115 147 }; 116 148 defer temp_alloc.free(result); 117 149 118 - const parsed = json.parseFromSlice(json.Value, temp_alloc, result, .{}) catch { 119 - try response.appendSlice(alloc, "]"); 120 - return response; 150 + const rows = extractRows(temp_alloc, result) catch { 151 + try writer.writeAll("[]"); 152 + return output; 121 153 }; 122 - defer parsed.deinit(); 123 154 124 - const results = parsed.value.object.get("results") orelse { 125 - try response.appendSlice(alloc, "]"); 126 - return response; 127 - }; 155 + var jw: json.Stringify = .{ .writer = &writer.any() }; 156 + try jw.beginArray(); 128 157 129 - if (results != .array or results.array.items.len == 0) { 130 - try response.appendSlice(alloc, "]"); 131 - return response; 132 - } 158 + for (rows) |row| { 159 + if (row.len < 7) continue; 133 160 134 - const first_result = results.array.items[0]; 135 - if (first_result != .object) { 136 - try response.appendSlice(alloc, "]"); 137 - return response; 161 + try jw.beginObject(); 162 + try jw.objectField("uri"); 163 + try jw.write(extractText(row[0])); 164 + try jw.objectField("did"); 165 + try jw.write(extractText(row[1])); 166 + try jw.objectField("title"); 167 + try jw.write(extractText(row[2])); 168 + try jw.objectField("snippet"); 169 + try jw.write(extractText(row[3])); 170 + try jw.objectField("createdAt"); 171 + try jw.write(extractText(row[4])); 172 + try jw.objectField("rkey"); 173 + try jw.write(extractText(row[5])); 174 + try jw.objectField("basePath"); 175 + try jw.write(extractText(row[6])); 176 + try jw.endObject(); 138 177 } 139 178 140 - const resp_obj = first_result.object.get("response") orelse { 141 - try response.appendSlice(alloc, "]"); 142 - return response; 143 - }; 179 + try jw.endArray(); 180 + return output; 181 + } 144 182 145 - if (resp_obj != .object) { 146 - try response.appendSlice(alloc, "]"); 147 - return response; 148 - } 149 - 150 - const result_obj = resp_obj.object.get("result") orelse { 151 - try response.appendSlice(alloc, "]"); 152 - return response; 153 - }; 154 - 155 - if (result_obj != .object) { 156 - try response.appendSlice(alloc, "]"); 157 - return response; 158 - } 183 + fn extractRows(alloc: Allocator, result: []const u8) ![]const []const json.Value { 184 + const parsed = try json.parseFromSlice(json.Value, alloc, result, .{}); 185 + defer parsed.deinit(); 159 186 160 - const rows = result_obj.object.get("rows") orelse { 161 - try response.appendSlice(alloc, "]"); 162 - return response; 163 - }; 187 + const results = parsed.value.object.get("results") orelse return &.{}; 188 + if (results != .array or results.array.items.len == 0) return &.{}; 164 189 165 - if (rows != .array) { 166 - try response.appendSlice(alloc, "]"); 167 - return response; 168 - } 190 + const first = results.array.items[0]; 191 + if (first != .object) return &.{}; 169 192 170 - var first = true; 171 - for (rows.array.items) |row| { 172 - if (row != .array) continue; 173 - const cols = row.array.items; 174 - if (cols.len < 7) continue; 193 + const resp = first.object.get("response") orelse return &.{}; 194 + if (resp != .object) return &.{}; 175 195 176 - if (!first) try response.appendSlice(alloc, ","); 177 - first = false; 196 + const res = resp.object.get("result") orelse return &.{}; 197 + if (res != .object) return &.{}; 178 198 179 - const uri = getTextValue(cols[0]); 180 - const did = getTextValue(cols[1]); 181 - const title = getTextValue(cols[2]); 182 - const snippet = getTextValue(cols[3]); 183 - const created_at = getTextValue(cols[4]); 184 - const rkey = getTextValue(cols[5]); 185 - const base_path = getTextValue(cols[6]); 199 + const rows = res.object.get("rows") orelse return &.{}; 200 + if (rows != .array) return &.{}; 186 201 187 - try response.appendSlice(alloc, "{\"uri\":\""); 188 - try appendEscaped(alloc, &response, uri); 189 - try response.appendSlice(alloc, "\",\"did\":\""); 190 - try appendEscaped(alloc, &response, did); 191 - try response.appendSlice(alloc, "\",\"title\":\""); 192 - try appendEscaped(alloc, &response, title); 193 - try response.appendSlice(alloc, "\",\"snippet\":\""); 194 - try appendEscaped(alloc, &response, snippet); 195 - try response.appendSlice(alloc, "\",\"createdAt\":\""); 196 - try appendEscaped(alloc, &response, created_at); 197 - try response.appendSlice(alloc, "\",\"rkey\":\""); 198 - try appendEscaped(alloc, &response, rkey); 199 - try response.appendSlice(alloc, "\",\"basePath\":\""); 200 - try appendEscaped(alloc, &response, base_path); 201 - try response.appendSlice(alloc, "\"}"); 202 + var result_rows = std.ArrayList([]const json.Value).init(alloc); 203 + for (rows.array.items) |row| { 204 + if (row == .array) { 205 + try result_rows.append(row.array.items); 206 + } 202 207 } 203 - 204 - try response.appendSlice(alloc, "]"); 205 - return response; 208 + return result_rows.toOwnedSlice(); 206 209 } 207 210 208 - fn getTextValue(val: json.Value) []const u8 { 211 + fn extractText(val: json.Value) []const u8 { 209 212 return switch (val) { 210 213 .string => |s| s, 211 214 .object => |obj| if (obj.get("value")) |v| (if (v == .string) v.string else "") else "", ··· 213 216 }; 214 217 } 215 218 216 - fn appendEscaped(alloc: Allocator, list: *std.ArrayList(u8), s: []const u8) !void { 217 - for (s) |c| { 218 - switch (c) { 219 - '"' => try list.appendSlice(alloc, "\\\""), 220 - '\\' => try list.appendSlice(alloc, "\\\\"), 221 - '\n' => try list.appendSlice(alloc, "\\n"), 222 - '\r' => try list.appendSlice(alloc, "\\r"), 223 - '\t' => try list.appendSlice(alloc, "\\t"), 224 - else => try list.append(alloc, c), 225 - } 226 - } 227 - } 228 - 229 - fn execSqlNoArgs(sql: []const u8) ![]const u8 { 230 - return execSqlWithArgs(sql, &[_][]const u8{}); 231 - } 232 - 233 - fn execSqlWithArgs(sql: []const u8, args: []const []const u8) ![]const u8 { 219 + fn execSql(sql: []const u8, args: []const []const u8) ![]const u8 { 234 220 mutex.lock(); 235 221 defer mutex.unlock(); 236 222 ··· 245 231 turso_url, 246 232 }) catch return error.UrlTooLong; 247 233 248 - // build request body with parameterized args 234 + // build request body 249 235 var body: std.ArrayList(u8) = .{}; 250 236 defer body.deinit(alloc); 237 + const writer = body.writer(alloc); 251 238 252 - try body.appendSlice(alloc, "{\"requests\":[{\"type\":\"execute\",\"stmt\":{\"sql\":\""); 253 - for (sql) |c| { 254 - switch (c) { 255 - '"' => try body.appendSlice(alloc, "\\\""), 256 - '\\' => try body.appendSlice(alloc, "\\\\"), 257 - '\n' => try body.appendSlice(alloc, "\\n"), 258 - '\r' => try body.appendSlice(alloc, "\\r"), 259 - '\t' => try body.appendSlice(alloc, "\\t"), 260 - else => try body.append(alloc, c), 261 - } 262 - } 263 - try body.appendSlice(alloc, "\""); 239 + var jw: json.Stringify = .{ .writer = &writer.any() }; 240 + try jw.beginObject(); 241 + try jw.objectField("requests"); 242 + try jw.beginArray(); 264 243 265 - // add args array if we have any 244 + // execute statement 245 + try jw.beginObject(); 246 + try jw.objectField("type"); 247 + try jw.write("execute"); 248 + try jw.objectField("stmt"); 249 + try jw.beginObject(); 250 + try jw.objectField("sql"); 251 + try jw.write(sql); 266 252 if (args.len > 0) { 267 - try body.appendSlice(alloc, ",\"args\":["); 268 - for (args, 0..) |arg, i| { 269 - if (i > 0) try body.appendSlice(alloc, ","); 270 - try body.appendSlice(alloc, "{\"type\":\"text\",\"value\":\""); 271 - for (arg) |c| { 272 - switch (c) { 273 - '"' => try body.appendSlice(alloc, "\\\""), 274 - '\\' => try body.appendSlice(alloc, "\\\\"), 275 - '\n' => try body.appendSlice(alloc, "\\n"), 276 - '\r' => try body.appendSlice(alloc, "\\r"), 277 - '\t' => try body.appendSlice(alloc, "\\t"), 278 - else => try body.append(alloc, c), 279 - } 280 - } 281 - try body.appendSlice(alloc, "\"}"); 253 + try jw.objectField("args"); 254 + try jw.beginArray(); 255 + for (args) |arg| { 256 + try jw.beginObject(); 257 + try jw.objectField("type"); 258 + try jw.write("text"); 259 + try jw.objectField("value"); 260 + try jw.write(arg); 261 + try jw.endObject(); 282 262 } 283 - try body.appendSlice(alloc, "]"); 263 + try jw.endArray(); 284 264 } 265 + try jw.endObject(); 266 + try jw.endObject(); 285 267 286 - try body.appendSlice(alloc, "}},{\"type\":\"close\"}]}"); 268 + // close statement 269 + try jw.beginObject(); 270 + try jw.objectField("type"); 271 + try jw.write("close"); 272 + try jw.endObject(); 273 + 274 + try jw.endArray(); 275 + try jw.endObject(); 287 276 288 277 var auth_buf: [512]u8 = undefined; 289 278 const auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{turso_token}) catch return error.AuthTooLong; ··· 291 280 var client: http.Client = .{ .allocator = alloc }; 292 281 defer client.deinit(); 293 282 294 - var aw: Io.Writer.Allocating = .init(alloc); 295 - defer aw.deinit(); 283 + var response_body: std.Io.Writer.Allocating = .init(alloc); 284 + defer response_body.deinit(); 296 285 297 286 const result = client.fetch(.{ 298 287 .location = .{ .url = url }, ··· 302 291 .authorization = .{ .override = auth_header }, 303 292 }, 304 293 .payload = body.items, 305 - .response_writer = &aw.writer, 294 + .response_writer = &response_body.writer, 306 295 }) catch |err| { 307 296 std.debug.print("turso request failed: {}\n", .{err}); 308 297 return error.HttpError; ··· 313 302 return error.TursoError; 314 303 } 315 304 316 - return try aw.toOwnedSlice(); 305 + return try response_body.toOwnedSlice(); 317 306 } 318 307 319 308 pub fn getStats() struct { documents: i64, publications: i64 } { 320 - const doc_result = execSqlNoArgs("SELECT COUNT(*) FROM documents") catch return .{ .documents = 0, .publications = 0 }; 309 + const doc_result = execSql("SELECT COUNT(*) FROM documents", &.{}) catch return .{ .documents = 0, .publications = 0 }; 321 310 defer gpa.allocator().free(doc_result); 322 311 323 - const pub_result = execSqlNoArgs("SELECT COUNT(*) FROM publications") catch return .{ .documents = 0, .publications = 0 }; 312 + const pub_result = execSql("SELECT COUNT(*) FROM publications", &.{}) catch return .{ .documents = 0, .publications = 0 }; 324 313 defer gpa.allocator().free(pub_result); 325 314 326 - const doc_count = parseCount(doc_result); 327 - const pub_count = parseCount(pub_result); 328 - 329 - return .{ .documents = doc_count, .publications = pub_count }; 315 + return .{ 316 + .documents = parseCount(doc_result), 317 + .publications = parseCount(pub_result), 318 + }; 330 319 } 331 320 332 321 fn parseCount(result: []const u8) i64 { 333 322 const alloc = gpa.allocator(); 334 - const parsed = json.parseFromSlice(json.Value, alloc, result, .{}) catch return 0; 335 - defer parsed.deinit(); 336 - 337 - const results = parsed.value.object.get("results") orelse return 0; 338 - if (results != .array or results.array.items.len == 0) return 0; 323 + const rows = extractRows(alloc, result) catch return 0; 324 + if (rows.len == 0) return 0; 325 + if (rows[0].len == 0) return 0; 339 326 340 - const first = results.array.items[0]; 341 - if (first != .object) return 0; 342 - 343 - const resp = first.object.get("response") orelse return 0; 344 - if (resp != .object) return 0; 345 - 346 - const res = resp.object.get("result") orelse return 0; 347 - if (res != .object) return 0; 348 - 349 - const rows = res.object.get("rows") orelse return 0; 350 - if (rows != .array or rows.array.items.len == 0) return 0; 351 - 352 - const row = rows.array.items[0]; 353 - if (row != .array or row.array.items.len == 0) return 0; 354 - 355 - const val = row.array.items[0]; 327 + const val = rows[0][0]; 356 328 return switch (val) { 357 329 .integer => |i| i, 358 330 .object => |obj| blk: {