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

Configure Feed

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

refactor backend module structure following zig conventions

- delete dead stats.zig (never imported)
- merge dashboard_data.zig into dashboard.zig
- rename http.zig → server.zig
- restructure turso.zig → Client.zig (file-as-type pattern)
- keep result.zig as namespace (multiple related types)

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

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

zzstoatzz 6481db35 291cca93

+515 -569
+156 -3
backend/src/dashboard.zig
··· 1 1 const std = @import("std"); 2 - const dashboard_data = @import("dashboard_data.zig"); 2 + const json = std.json; 3 + const Allocator = std.mem.Allocator; 4 + const db = @import("db/mod.zig"); 5 + 6 + /// All data needed to render the dashboard 7 + pub const Data = struct { 8 + started_at: i64, 9 + searches: i64, 10 + publications: i64, 11 + articles: i64, 12 + looseleafs: i64, 13 + tags_json: []const u8, 14 + timeline_json: []const u8, 15 + top_pubs_json: []const u8, 16 + }; 17 + 18 + // all dashboard queries batched into one request 19 + const STATS_SQL = 20 + \\SELECT 21 + \\ (SELECT COUNT(*) FROM documents) as docs, 22 + \\ (SELECT COUNT(*) FROM publications) as pubs, 23 + \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 24 + \\ (SELECT total_errors FROM stats WHERE id = 1) as errors, 25 + \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at 26 + ; 27 + 28 + const DOC_TYPES_SQL = 29 + \\SELECT 30 + \\ SUM(CASE WHEN publication_uri != '' THEN 1 ELSE 0 END) as articles, 31 + \\ SUM(CASE WHEN publication_uri = '' OR publication_uri IS NULL THEN 1 ELSE 0 END) as looseleafs 32 + \\FROM documents 33 + ; 34 + 35 + const TAGS_SQL = 36 + \\SELECT tag, COUNT(*) as count 37 + \\FROM document_tags 38 + \\GROUP BY tag 39 + \\ORDER BY count DESC 40 + \\LIMIT 100 41 + ; 42 + 43 + const TIMELINE_SQL = 44 + \\SELECT DATE(created_at) as date, COUNT(*) as count 45 + \\FROM documents 46 + \\WHERE created_at IS NOT NULL AND created_at != '' 47 + \\GROUP BY DATE(created_at) 48 + \\ORDER BY date DESC 49 + \\LIMIT 30 50 + ; 51 + 52 + const TOP_PUBS_SQL = 53 + \\SELECT p.name, p.base_path, COUNT(d.uri) as doc_count 54 + \\FROM publications p 55 + \\JOIN documents d ON d.publication_uri = p.uri 56 + \\GROUP BY p.uri 57 + \\ORDER BY doc_count DESC 58 + \\LIMIT 8 59 + ; 60 + 61 + pub fn fetch(alloc: Allocator) !Data { 62 + const client = db.getClient() orelse return error.NotInitialized; 63 + 64 + // batch all 5 queries into one HTTP request 65 + var batch = client.queryBatch(&.{ 66 + .{ .sql = STATS_SQL }, 67 + .{ .sql = DOC_TYPES_SQL }, 68 + .{ .sql = TAGS_SQL }, 69 + .{ .sql = TIMELINE_SQL }, 70 + .{ .sql = TOP_PUBS_SQL }, 71 + }) catch return error.QueryFailed; 72 + defer batch.deinit(); 73 + 74 + // extract stats (query 0) 75 + const stats_row = batch.getFirst(0); 76 + const started_at = if (stats_row) |r| r.int(4) else 0; 77 + const searches = if (stats_row) |r| r.int(2) else 0; 78 + const publications = if (stats_row) |r| r.int(1) else 0; 79 + 80 + // extract doc types (query 1) 81 + const doc_row = batch.getFirst(1); 82 + const articles = if (doc_row) |r| r.int(0) else 0; 83 + const looseleafs = if (doc_row) |r| r.int(1) else 0; 84 + 85 + return .{ 86 + .started_at = started_at, 87 + .searches = searches, 88 + .publications = publications, 89 + .articles = articles, 90 + .looseleafs = looseleafs, 91 + .tags_json = try formatTagsJson(alloc, batch.get(2)), 92 + .timeline_json = try formatTimelineJson(alloc, batch.get(3)), 93 + .top_pubs_json = try formatPubsJson(alloc, batch.get(4)), 94 + }; 95 + } 96 + 97 + fn formatTagsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 98 + var output: std.Io.Writer.Allocating = .init(alloc); 99 + errdefer output.deinit(); 100 + 101 + var jw: json.Stringify = .{ .writer = &output.writer }; 102 + try jw.beginArray(); 103 + 104 + for (rows) |row| { 105 + try jw.beginObject(); 106 + try jw.objectField("tag"); 107 + try jw.write(row.text(0)); 108 + try jw.objectField("count"); 109 + try jw.write(row.int(1)); 110 + try jw.endObject(); 111 + } 112 + 113 + try jw.endArray(); 114 + return try output.toOwnedSlice(); 115 + } 116 + 117 + fn formatTimelineJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 118 + var output: std.Io.Writer.Allocating = .init(alloc); 119 + errdefer output.deinit(); 120 + 121 + var jw: json.Stringify = .{ .writer = &output.writer }; 122 + try jw.beginArray(); 123 + 124 + for (rows) |row| { 125 + try jw.beginObject(); 126 + try jw.objectField("date"); 127 + try jw.write(row.text(0)); 128 + try jw.objectField("count"); 129 + try jw.write(row.int(1)); 130 + try jw.endObject(); 131 + } 132 + 133 + try jw.endArray(); 134 + return try output.toOwnedSlice(); 135 + } 136 + 137 + fn formatPubsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 138 + var output: std.Io.Writer.Allocating = .init(alloc); 139 + errdefer output.deinit(); 140 + 141 + var jw: json.Stringify = .{ .writer = &output.writer }; 142 + try jw.beginArray(); 143 + 144 + for (rows) |row| { 145 + try jw.beginObject(); 146 + try jw.objectField("name"); 147 + try jw.write(row.text(0)); 148 + try jw.objectField("basePath"); 149 + try jw.write(row.text(1)); 150 + try jw.objectField("count"); 151 + try jw.write(row.int(2)); 152 + try jw.endObject(); 153 + } 3 154 4 - pub const DashboardData = dashboard_data.DashboardData; 155 + try jw.endArray(); 156 + return try output.toOwnedSlice(); 157 + } 5 158 6 159 /// Generate dashboard HTML with stats and charts 7 - pub fn render(alloc: std.mem.Allocator, data: DashboardData) ![]const u8 { 160 + pub fn render(alloc: Allocator, data: Data) ![]const u8 { 8 161 var buf: std.ArrayList(u8) = .{}; 9 162 const w = buf.writer(alloc); 10 163
-157
backend/src/dashboard_data.zig
··· 1 - const std = @import("std"); 2 - const json = std.json; 3 - const Allocator = std.mem.Allocator; 4 - const db = @import("db/mod.zig"); 5 - 6 - /// All data needed to render the dashboard 7 - pub const DashboardData = struct { 8 - started_at: i64, 9 - searches: i64, 10 - publications: i64, 11 - articles: i64, 12 - looseleafs: i64, 13 - tags_json: []const u8, 14 - timeline_json: []const u8, 15 - top_pubs_json: []const u8, 16 - }; 17 - 18 - // all dashboard queries batched into one request 19 - const STATS_SQL = 20 - \\SELECT 21 - \\ (SELECT COUNT(*) FROM documents) as docs, 22 - \\ (SELECT COUNT(*) FROM publications) as pubs, 23 - \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 24 - \\ (SELECT total_errors FROM stats WHERE id = 1) as errors, 25 - \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at 26 - ; 27 - 28 - const DOC_TYPES_SQL = 29 - \\SELECT 30 - \\ SUM(CASE WHEN publication_uri != '' THEN 1 ELSE 0 END) as articles, 31 - \\ SUM(CASE WHEN publication_uri = '' OR publication_uri IS NULL THEN 1 ELSE 0 END) as looseleafs 32 - \\FROM documents 33 - ; 34 - 35 - const TAGS_SQL = 36 - \\SELECT tag, COUNT(*) as count 37 - \\FROM document_tags 38 - \\GROUP BY tag 39 - \\ORDER BY count DESC 40 - \\LIMIT 100 41 - ; 42 - 43 - const TIMELINE_SQL = 44 - \\SELECT DATE(created_at) as date, COUNT(*) as count 45 - \\FROM documents 46 - \\WHERE created_at IS NOT NULL AND created_at != '' 47 - \\GROUP BY DATE(created_at) 48 - \\ORDER BY date DESC 49 - \\LIMIT 30 50 - ; 51 - 52 - const TOP_PUBS_SQL = 53 - \\SELECT p.name, p.base_path, COUNT(d.uri) as doc_count 54 - \\FROM publications p 55 - \\JOIN documents d ON d.publication_uri = p.uri 56 - \\GROUP BY p.uri 57 - \\ORDER BY doc_count DESC 58 - \\LIMIT 8 59 - ; 60 - 61 - pub fn fetch(alloc: Allocator) !DashboardData { 62 - const client = db.getClient() orelse return error.NotInitialized; 63 - 64 - // batch all 5 queries into one HTTP request 65 - var batch = client.queryBatch(&.{ 66 - .{ .sql = STATS_SQL }, 67 - .{ .sql = DOC_TYPES_SQL }, 68 - .{ .sql = TAGS_SQL }, 69 - .{ .sql = TIMELINE_SQL }, 70 - .{ .sql = TOP_PUBS_SQL }, 71 - }) catch return error.QueryFailed; 72 - defer batch.deinit(); 73 - 74 - // extract stats (query 0) 75 - const stats_row = batch.getFirst(0); 76 - const started_at = if (stats_row) |r| r.int(4) else 0; 77 - const searches = if (stats_row) |r| r.int(2) else 0; 78 - const publications = if (stats_row) |r| r.int(1) else 0; 79 - 80 - // extract doc types (query 1) 81 - const doc_row = batch.getFirst(1); 82 - const articles = if (doc_row) |r| r.int(0) else 0; 83 - const looseleafs = if (doc_row) |r| r.int(1) else 0; 84 - 85 - return .{ 86 - .started_at = started_at, 87 - .searches = searches, 88 - .publications = publications, 89 - .articles = articles, 90 - .looseleafs = looseleafs, 91 - .tags_json = try formatTagsJson(alloc, batch.get(2)), 92 - .timeline_json = try formatTimelineJson(alloc, batch.get(3)), 93 - .top_pubs_json = try formatPubsJson(alloc, batch.get(4)), 94 - }; 95 - } 96 - 97 - fn formatTagsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 98 - var output: std.Io.Writer.Allocating = .init(alloc); 99 - errdefer output.deinit(); 100 - 101 - var jw: json.Stringify = .{ .writer = &output.writer }; 102 - try jw.beginArray(); 103 - 104 - for (rows) |row| { 105 - try jw.beginObject(); 106 - try jw.objectField("tag"); 107 - try jw.write(row.text(0)); 108 - try jw.objectField("count"); 109 - try jw.write(row.int(1)); 110 - try jw.endObject(); 111 - } 112 - 113 - try jw.endArray(); 114 - return try output.toOwnedSlice(); 115 - } 116 - 117 - fn formatTimelineJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 118 - var output: std.Io.Writer.Allocating = .init(alloc); 119 - errdefer output.deinit(); 120 - 121 - var jw: json.Stringify = .{ .writer = &output.writer }; 122 - try jw.beginArray(); 123 - 124 - for (rows) |row| { 125 - try jw.beginObject(); 126 - try jw.objectField("date"); 127 - try jw.write(row.text(0)); 128 - try jw.objectField("count"); 129 - try jw.write(row.int(1)); 130 - try jw.endObject(); 131 - } 132 - 133 - try jw.endArray(); 134 - return try output.toOwnedSlice(); 135 - } 136 - 137 - fn formatPubsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 138 - var output: std.Io.Writer.Allocating = .init(alloc); 139 - errdefer output.deinit(); 140 - 141 - var jw: json.Stringify = .{ .writer = &output.writer }; 142 - try jw.beginArray(); 143 - 144 - for (rows) |row| { 145 - try jw.beginObject(); 146 - try jw.objectField("name"); 147 - try jw.write(row.text(0)); 148 - try jw.objectField("basePath"); 149 - try jw.write(row.text(1)); 150 - try jw.objectField("count"); 151 - try jw.write(row.int(2)); 152 - try jw.endObject(); 153 - } 154 - 155 - try jw.endArray(); 156 - return try output.toOwnedSlice(); 157 - }
+342
backend/src/db/Client.zig
··· 1 + const std = @import("std"); 2 + const http = std.http; 3 + const json = std.json; 4 + const mem = std.mem; 5 + const Allocator = mem.Allocator; 6 + 7 + const result = @import("result.zig"); 8 + pub const Result = result.Result; 9 + pub const Row = result.Row; 10 + pub const BatchResult = result.BatchResult; 11 + 12 + const Client = @This(); 13 + 14 + const URL_BUF_SIZE = 512; 15 + const AUTH_BUF_SIZE = 512; 16 + 17 + // fields 18 + allocator: Allocator, 19 + url: []const u8, 20 + token: []const u8, 21 + mutex: std.Thread.Mutex = .{}, 22 + http_client: http.Client, 23 + 24 + pub fn init(allocator: Allocator) !Client { 25 + const url = std.posix.getenv("TURSO_URL") orelse { 26 + std.debug.print("TURSO_URL not set\n", .{}); 27 + return error.MissingEnv; 28 + }; 29 + const token = std.posix.getenv("TURSO_TOKEN") orelse { 30 + std.debug.print("TURSO_TOKEN not set\n", .{}); 31 + return error.MissingEnv; 32 + }; 33 + 34 + // strip libsql:// prefix if present 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 + std.debug.print("turso client initialized: {s}\n", .{host}); 42 + 43 + return .{ 44 + .allocator = allocator, 45 + .url = host, 46 + .token = token, 47 + .http_client = .{ .allocator = allocator }, 48 + }; 49 + } 50 + 51 + pub fn deinit(self: *Client) void { 52 + self.http_client.deinit(); 53 + } 54 + 55 + /// Execute a query and return parsed results. 56 + /// Validates parameter count at compile time. 57 + pub fn query(self: *Client, comptime sql: []const u8, args: anytype) !Result { 58 + const expected = comptime countPlaceholders(sql); 59 + const provided = comptime countArgsType(@TypeOf(args)); 60 + if (expected != provided) { 61 + @compileError(std.fmt.comptimePrint( 62 + "SQL has {} placeholders but {} args provided", 63 + .{ expected, provided }, 64 + )); 65 + } 66 + const args_slice = try self.argsToSlice(args); 67 + defer self.allocator.free(args_slice); 68 + const response = try self.executeRaw(sql, args_slice); 69 + defer self.allocator.free(response); 70 + return Result.parse(self.allocator, response); 71 + } 72 + 73 + /// Execute a statement, ignoring results. 74 + /// Validates parameter count at compile time. 75 + pub fn exec(self: *Client, comptime sql: []const u8, args: anytype) !void { 76 + const expected = comptime countPlaceholders(sql); 77 + const provided = comptime countArgsType(@TypeOf(args)); 78 + if (expected != provided) { 79 + @compileError(std.fmt.comptimePrint( 80 + "SQL has {} placeholders but {} args provided", 81 + .{ expected, provided }, 82 + )); 83 + } 84 + const args_slice = try self.argsToSlice(args); 85 + defer self.allocator.free(args_slice); 86 + const response = try self.executeRaw(sql, args_slice); 87 + self.allocator.free(response); 88 + } 89 + 90 + /// Statement for batch queries 91 + pub const Statement = struct { 92 + sql: []const u8, 93 + args: []const []const u8 = &.{}, 94 + }; 95 + 96 + /// Execute multiple queries in a single HTTP request 97 + pub fn queryBatch(self: *Client, statements: []const Statement) !BatchResult { 98 + const response = try self.executeBatchRaw(statements); 99 + defer self.allocator.free(response); 100 + return BatchResult.parse(self.allocator, response, statements.len); 101 + } 102 + 103 + /// Convert tuple/struct args to slice, with comptime validation 104 + fn argsToSlice(self: *Client, args: anytype) ![]const []const u8 { 105 + const ArgsType = @TypeOf(args); 106 + const args_type_info = @typeInfo(ArgsType); 107 + 108 + // handle pointer to tuple (e.g., &.{a, b, c}) 109 + if (args_type_info == .pointer) { 110 + const child_info = @typeInfo(args_type_info.pointer.child); 111 + if (child_info == .@"struct") { 112 + const fields = child_info.@"struct".fields; 113 + const slice = try self.allocator.alloc([]const u8, fields.len); 114 + inline for (fields, 0..) |field, i| { 115 + slice[i] = @field(args.*, field.name); 116 + } 117 + return slice; 118 + } 119 + } 120 + 121 + // handle direct struct/tuple 122 + if (args_type_info == .@"struct") { 123 + const fields = args_type_info.@"struct".fields; 124 + const slice = try self.allocator.alloc([]const u8, fields.len); 125 + inline for (fields, 0..) |field, i| { 126 + slice[i] = @field(args, field.name); 127 + } 128 + return slice; 129 + } 130 + 131 + @compileError("args must be a tuple or pointer to tuple"); 132 + } 133 + 134 + /// Execute and return raw JSON response (caller owns memory) 135 + fn executeRaw(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 { 136 + self.mutex.lock(); 137 + defer self.mutex.unlock(); 138 + 139 + var url_buf: [URL_BUF_SIZE]u8 = undefined; 140 + const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch 141 + return error.UrlTooLong; 142 + 143 + // build request body 144 + const body = try self.buildRequestBody(sql, args); 145 + defer self.allocator.free(body); 146 + 147 + var auth_buf: [AUTH_BUF_SIZE]u8 = undefined; 148 + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch 149 + return error.AuthTooLong; 150 + 151 + var response_body: std.Io.Writer.Allocating = .init(self.allocator); 152 + errdefer response_body.deinit(); 153 + 154 + const res = self.http_client.fetch(.{ 155 + .location = .{ .url = url }, 156 + .method = .POST, 157 + .headers = .{ 158 + .content_type = .{ .override = "application/json" }, 159 + .authorization = .{ .override = auth }, 160 + }, 161 + .payload = body, 162 + .response_writer = &response_body.writer, 163 + }) catch |err| { 164 + std.debug.print("turso request failed: {}\n", .{err}); 165 + return error.HttpError; 166 + }; 167 + 168 + if (res.status != .ok) { 169 + std.debug.print("turso error: {}\n", .{res.status}); 170 + return error.TursoError; 171 + } 172 + 173 + return try response_body.toOwnedSlice(); 174 + } 175 + 176 + /// Execute batch and return raw JSON response 177 + fn executeBatchRaw(self: *Client, statements: []const Statement) ![]const u8 { 178 + self.mutex.lock(); 179 + defer self.mutex.unlock(); 180 + 181 + var url_buf: [URL_BUF_SIZE]u8 = undefined; 182 + const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch 183 + return error.UrlTooLong; 184 + 185 + const body = try self.buildBatchRequestBody(statements); 186 + defer self.allocator.free(body); 187 + 188 + var auth_buf: [AUTH_BUF_SIZE]u8 = undefined; 189 + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch 190 + return error.AuthTooLong; 191 + 192 + var response_body: std.Io.Writer.Allocating = .init(self.allocator); 193 + errdefer response_body.deinit(); 194 + 195 + const res = self.http_client.fetch(.{ 196 + .location = .{ .url = url }, 197 + .method = .POST, 198 + .headers = .{ 199 + .content_type = .{ .override = "application/json" }, 200 + .authorization = .{ .override = auth }, 201 + }, 202 + .payload = body, 203 + .response_writer = &response_body.writer, 204 + }) catch |err| { 205 + std.debug.print("turso batch request failed: {}\n", .{err}); 206 + return error.HttpError; 207 + }; 208 + 209 + if (res.status != .ok) { 210 + std.debug.print("turso batch error: {}\n", .{res.status}); 211 + return error.TursoError; 212 + } 213 + 214 + return try response_body.toOwnedSlice(); 215 + } 216 + 217 + fn buildBatchRequestBody(self: *Client, statements: []const Statement) ![]const u8 { 218 + var body: std.Io.Writer.Allocating = .init(self.allocator); 219 + errdefer body.deinit(); 220 + 221 + var jw: json.Stringify = .{ .writer = &body.writer }; 222 + 223 + try jw.beginObject(); 224 + try jw.objectField("requests"); 225 + try jw.beginArray(); 226 + 227 + for (statements) |stmt| { 228 + // execute statement 229 + try jw.beginObject(); 230 + try jw.objectField("type"); 231 + try jw.write("execute"); 232 + try jw.objectField("stmt"); 233 + try jw.beginObject(); 234 + try jw.objectField("sql"); 235 + try jw.write(stmt.sql); 236 + 237 + if (stmt.args.len > 0) { 238 + try jw.objectField("args"); 239 + try jw.beginArray(); 240 + for (stmt.args) |arg| { 241 + try jw.beginObject(); 242 + try jw.objectField("type"); 243 + try jw.write("text"); 244 + try jw.objectField("value"); 245 + try jw.write(arg); 246 + try jw.endObject(); 247 + } 248 + try jw.endArray(); 249 + } 250 + 251 + try jw.endObject(); // stmt 252 + try jw.endObject(); // execute request 253 + } 254 + 255 + // single close at the end 256 + try jw.beginObject(); 257 + try jw.objectField("type"); 258 + try jw.write("close"); 259 + try jw.endObject(); 260 + 261 + try jw.endArray(); // requests 262 + try jw.endObject(); // root 263 + 264 + return try body.toOwnedSlice(); 265 + } 266 + 267 + fn buildRequestBody(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 { 268 + var body: std.Io.Writer.Allocating = .init(self.allocator); 269 + errdefer body.deinit(); 270 + 271 + var jw: json.Stringify = .{ .writer = &body.writer }; 272 + 273 + try jw.beginObject(); 274 + try jw.objectField("requests"); 275 + try jw.beginArray(); 276 + 277 + // execute statement 278 + try jw.beginObject(); 279 + try jw.objectField("type"); 280 + try jw.write("execute"); 281 + try jw.objectField("stmt"); 282 + try jw.beginObject(); 283 + try jw.objectField("sql"); 284 + try jw.write(sql); 285 + 286 + if (args.len > 0) { 287 + try jw.objectField("args"); 288 + try jw.beginArray(); 289 + for (args) |arg| { 290 + try jw.beginObject(); 291 + try jw.objectField("type"); 292 + try jw.write("text"); 293 + try jw.objectField("value"); 294 + try jw.write(arg); 295 + try jw.endObject(); 296 + } 297 + try jw.endArray(); 298 + } 299 + 300 + try jw.endObject(); // stmt 301 + try jw.endObject(); // execute request 302 + 303 + // close statement 304 + try jw.beginObject(); 305 + try jw.objectField("type"); 306 + try jw.write("close"); 307 + try jw.endObject(); 308 + 309 + try jw.endArray(); // requests 310 + try jw.endObject(); // root 311 + 312 + return try body.toOwnedSlice(); 313 + } 314 + 315 + // module-level helpers (don't need self) 316 + 317 + /// Count `?` placeholders in SQL at comptime 318 + fn countPlaceholders(comptime sql: []const u8) usize { 319 + var count: usize = 0; 320 + for (sql) |c| { 321 + if (c == '?') count += 1; 322 + } 323 + return count; 324 + } 325 + 326 + /// Count args in a tuple type (handles both direct tuples and pointers to tuples) 327 + fn countArgsType(comptime ArgsType: type) usize { 328 + const args_type_info = @typeInfo(ArgsType); 329 + 330 + if (args_type_info == .pointer) { 331 + const child_info = @typeInfo(args_type_info.pointer.child); 332 + if (child_info == .@"struct") { 333 + return child_info.@"struct".fields.len; 334 + } 335 + } 336 + 337 + if (args_type_info == .@"struct") { 338 + return args_type_info.@"struct".fields.len; 339 + } 340 + 341 + return 0; 342 + }
+7 -9
backend/src/db/mod.zig
··· 3 3 const Allocator = std.mem.Allocator; 4 4 5 5 const zql = @import("zql"); 6 - const turso = @import("turso.zig"); 6 + const Client = @import("Client.zig"); 7 7 const schema = @import("schema.zig"); 8 8 const result = @import("result.zig"); 9 9 10 - pub const Client = turso.Client; 11 - pub const Result = turso.Result; 12 - pub const Row = turso.Row; 13 - pub const BatchResult = turso.BatchResult; 14 - pub const Statement = turso.Client.Statement; 10 + pub const Row = result.Row; 11 + pub const BatchResult = result.BatchResult; 12 + pub const Statement = Client.Statement; 15 13 16 14 var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; 17 - var client: ?turso.Client = null; 15 + var client: ?Client = null; 18 16 19 17 pub fn init() !void { 20 - client = try turso.Client.init(gpa.allocator()); 18 + client = try Client.init(gpa.allocator()); 21 19 try schema.init(&client.?); 22 20 } 23 21 24 - pub fn getClient() ?*turso.Client { 22 + pub fn getClient() ?*Client { 25 23 if (client) |*c| return c; 26 24 return null; 27 25 }
+4 -4
backend/src/db/schema.zig
··· 1 1 const std = @import("std"); 2 - const turso = @import("turso.zig"); 2 + const Client = @import("Client.zig"); 3 3 4 4 /// Initialize database schema and run migrations 5 - pub fn init(client: *turso.Client) !void { 5 + pub fn init(client: *Client) !void { 6 6 try createTables(client); 7 7 try runMigrations(client); 8 8 std.debug.print("schema initialized\n", .{}); 9 9 } 10 10 11 - fn createTables(client: *turso.Client) !void { 11 + fn createTables(client: *Client) !void { 12 12 try client.exec( 13 13 \\CREATE TABLE IF NOT EXISTS documents ( 14 14 \\ uri TEXT PRIMARY KEY, ··· 91 91 , &.{}); 92 92 } 93 93 94 - fn runMigrations(client: *turso.Client) !void { 94 + fn runMigrations(client: *Client) !void { 95 95 // these may fail if columns already exist - that's fine 96 96 client.exec("ALTER TABLE documents ADD COLUMN publication_uri TEXT", &.{}) catch {}; 97 97 client.exec("ALTER TABLE publications ADD COLUMN base_path TEXT", &.{}) catch {};
-339
backend/src/db/turso.zig
··· 1 - const std = @import("std"); 2 - const http = std.http; 3 - const json = std.json; 4 - const mem = std.mem; 5 - const Allocator = mem.Allocator; 6 - 7 - const result = @import("result.zig"); 8 - pub const Result = result.Result; 9 - pub const Row = result.Row; 10 - pub const BatchResult = result.BatchResult; 11 - 12 - const URL_BUF_SIZE = 512; 13 - const AUTH_BUF_SIZE = 512; 14 - 15 - /// Count `?` placeholders in SQL at comptime 16 - fn countPlaceholders(comptime sql: []const u8) usize { 17 - var count: usize = 0; 18 - for (sql) |c| { 19 - if (c == '?') count += 1; 20 - } 21 - return count; 22 - } 23 - 24 - /// Count args in a tuple type (handles both direct tuples and pointers to tuples) 25 - fn countArgsType(comptime ArgsType: type) usize { 26 - const args_type_info = @typeInfo(ArgsType); 27 - 28 - if (args_type_info == .pointer) { 29 - const child_info = @typeInfo(args_type_info.pointer.child); 30 - if (child_info == .@"struct") { 31 - return child_info.@"struct".fields.len; 32 - } 33 - } 34 - 35 - if (args_type_info == .@"struct") { 36 - return args_type_info.@"struct".fields.len; 37 - } 38 - 39 - return 0; 40 - } 41 - 42 - pub const Client = struct { 43 - allocator: Allocator, 44 - url: []const u8, 45 - token: []const u8, 46 - mutex: std.Thread.Mutex = .{}, 47 - http_client: http.Client, 48 - 49 - pub fn init(allocator: Allocator) !Client { 50 - const url = std.posix.getenv("TURSO_URL") orelse { 51 - std.debug.print("TURSO_URL not set\n", .{}); 52 - return error.MissingEnv; 53 - }; 54 - const token = std.posix.getenv("TURSO_TOKEN") orelse { 55 - std.debug.print("TURSO_TOKEN not set\n", .{}); 56 - return error.MissingEnv; 57 - }; 58 - 59 - // strip libsql:// prefix if present 60 - const libsql_prefix = "libsql://"; 61 - const host = if (mem.startsWith(u8, url, libsql_prefix)) 62 - url[libsql_prefix.len..] 63 - else 64 - url; 65 - 66 - std.debug.print("turso client initialized: {s}\n", .{host}); 67 - 68 - return .{ 69 - .allocator = allocator, 70 - .url = host, 71 - .token = token, 72 - .http_client = .{ .allocator = allocator }, 73 - }; 74 - } 75 - 76 - pub fn deinit(self: *Client) void { 77 - self.http_client.deinit(); 78 - } 79 - 80 - /// Execute a query and return parsed results. 81 - /// Validates parameter count at compile time. 82 - pub fn query(self: *Client, comptime sql: []const u8, args: anytype) !Result { 83 - const expected = comptime countPlaceholders(sql); 84 - const provided = comptime countArgsType(@TypeOf(args)); 85 - if (expected != provided) { 86 - @compileError(std.fmt.comptimePrint( 87 - "SQL has {} placeholders but {} args provided", 88 - .{ expected, provided }, 89 - )); 90 - } 91 - const args_slice = try self.argsToSlice(args); 92 - defer self.allocator.free(args_slice); 93 - const response = try self.executeRaw(sql, args_slice); 94 - defer self.allocator.free(response); 95 - return Result.parse(self.allocator, response); 96 - } 97 - 98 - /// Execute a statement, ignoring results. 99 - /// Validates parameter count at compile time. 100 - pub fn exec(self: *Client, comptime sql: []const u8, args: anytype) !void { 101 - const expected = comptime countPlaceholders(sql); 102 - const provided = comptime countArgsType(@TypeOf(args)); 103 - if (expected != provided) { 104 - @compileError(std.fmt.comptimePrint( 105 - "SQL has {} placeholders but {} args provided", 106 - .{ expected, provided }, 107 - )); 108 - } 109 - const args_slice = try self.argsToSlice(args); 110 - defer self.allocator.free(args_slice); 111 - const response = try self.executeRaw(sql, args_slice); 112 - self.allocator.free(response); 113 - } 114 - 115 - /// Convert tuple/struct args to slice, with comptime validation 116 - fn argsToSlice(self: *Client, args: anytype) ![]const []const u8 { 117 - const ArgsType = @TypeOf(args); 118 - const args_type_info = @typeInfo(ArgsType); 119 - 120 - // handle pointer to tuple (e.g., &.{a, b, c}) 121 - if (args_type_info == .pointer) { 122 - const child_info = @typeInfo(args_type_info.pointer.child); 123 - if (child_info == .@"struct") { 124 - const fields = child_info.@"struct".fields; 125 - const slice = try self.allocator.alloc([]const u8, fields.len); 126 - inline for (fields, 0..) |field, i| { 127 - slice[i] = @field(args.*, field.name); 128 - } 129 - return slice; 130 - } 131 - } 132 - 133 - // handle direct struct/tuple 134 - if (args_type_info == .@"struct") { 135 - const fields = args_type_info.@"struct".fields; 136 - const slice = try self.allocator.alloc([]const u8, fields.len); 137 - inline for (fields, 0..) |field, i| { 138 - slice[i] = @field(args, field.name); 139 - } 140 - return slice; 141 - } 142 - 143 - @compileError("args must be a tuple or pointer to tuple"); 144 - } 145 - 146 - /// Execute and return raw JSON response (caller owns memory) 147 - fn executeRaw(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 { 148 - self.mutex.lock(); 149 - defer self.mutex.unlock(); 150 - 151 - var url_buf: [URL_BUF_SIZE]u8 = undefined; 152 - const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch 153 - return error.UrlTooLong; 154 - 155 - // build request body 156 - const body = try self.buildRequestBody(sql, args); 157 - defer self.allocator.free(body); 158 - 159 - var auth_buf: [AUTH_BUF_SIZE]u8 = undefined; 160 - const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch 161 - return error.AuthTooLong; 162 - 163 - var response_body: std.Io.Writer.Allocating = .init(self.allocator); 164 - errdefer response_body.deinit(); 165 - 166 - const res = self.http_client.fetch(.{ 167 - .location = .{ .url = url }, 168 - .method = .POST, 169 - .headers = .{ 170 - .content_type = .{ .override = "application/json" }, 171 - .authorization = .{ .override = auth }, 172 - }, 173 - .payload = body, 174 - .response_writer = &response_body.writer, 175 - }) catch |err| { 176 - std.debug.print("turso request failed: {}\n", .{err}); 177 - return error.HttpError; 178 - }; 179 - 180 - if (res.status != .ok) { 181 - std.debug.print("turso error: {}\n", .{res.status}); 182 - return error.TursoError; 183 - } 184 - 185 - return try response_body.toOwnedSlice(); 186 - } 187 - 188 - /// Statement for batch queries 189 - pub const Statement = struct { 190 - sql: []const u8, 191 - args: []const []const u8 = &.{}, 192 - }; 193 - 194 - /// Execute multiple queries in a single HTTP request 195 - pub fn queryBatch(self: *Client, statements: []const Statement) !BatchResult { 196 - const response = try self.executeBatchRaw(statements); 197 - defer self.allocator.free(response); 198 - return BatchResult.parse(self.allocator, response, statements.len); 199 - } 200 - 201 - /// Execute batch and return raw JSON response 202 - fn executeBatchRaw(self: *Client, statements: []const Statement) ![]const u8 { 203 - self.mutex.lock(); 204 - defer self.mutex.unlock(); 205 - 206 - var url_buf: [URL_BUF_SIZE]u8 = undefined; 207 - const url = std.fmt.bufPrint(&url_buf, "https://{s}/v2/pipeline", .{self.url}) catch 208 - return error.UrlTooLong; 209 - 210 - const body = try self.buildBatchRequestBody(statements); 211 - defer self.allocator.free(body); 212 - 213 - var auth_buf: [AUTH_BUF_SIZE]u8 = undefined; 214 - const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.token}) catch 215 - return error.AuthTooLong; 216 - 217 - var response_body: std.Io.Writer.Allocating = .init(self.allocator); 218 - errdefer response_body.deinit(); 219 - 220 - const res = self.http_client.fetch(.{ 221 - .location = .{ .url = url }, 222 - .method = .POST, 223 - .headers = .{ 224 - .content_type = .{ .override = "application/json" }, 225 - .authorization = .{ .override = auth }, 226 - }, 227 - .payload = body, 228 - .response_writer = &response_body.writer, 229 - }) catch |err| { 230 - std.debug.print("turso batch request failed: {}\n", .{err}); 231 - return error.HttpError; 232 - }; 233 - 234 - if (res.status != .ok) { 235 - std.debug.print("turso batch error: {}\n", .{res.status}); 236 - return error.TursoError; 237 - } 238 - 239 - return try response_body.toOwnedSlice(); 240 - } 241 - 242 - fn buildBatchRequestBody(self: *Client, statements: []const Statement) ![]const u8 { 243 - var body: std.Io.Writer.Allocating = .init(self.allocator); 244 - errdefer body.deinit(); 245 - 246 - var jw: json.Stringify = .{ .writer = &body.writer }; 247 - 248 - try jw.beginObject(); 249 - try jw.objectField("requests"); 250 - try jw.beginArray(); 251 - 252 - for (statements) |stmt| { 253 - // execute statement 254 - try jw.beginObject(); 255 - try jw.objectField("type"); 256 - try jw.write("execute"); 257 - try jw.objectField("stmt"); 258 - try jw.beginObject(); 259 - try jw.objectField("sql"); 260 - try jw.write(stmt.sql); 261 - 262 - if (stmt.args.len > 0) { 263 - try jw.objectField("args"); 264 - try jw.beginArray(); 265 - for (stmt.args) |arg| { 266 - try jw.beginObject(); 267 - try jw.objectField("type"); 268 - try jw.write("text"); 269 - try jw.objectField("value"); 270 - try jw.write(arg); 271 - try jw.endObject(); 272 - } 273 - try jw.endArray(); 274 - } 275 - 276 - try jw.endObject(); // stmt 277 - try jw.endObject(); // execute request 278 - } 279 - 280 - // single close at the end 281 - try jw.beginObject(); 282 - try jw.objectField("type"); 283 - try jw.write("close"); 284 - try jw.endObject(); 285 - 286 - try jw.endArray(); // requests 287 - try jw.endObject(); // root 288 - 289 - return try body.toOwnedSlice(); 290 - } 291 - 292 - fn buildRequestBody(self: *Client, sql: []const u8, args: []const []const u8) ![]const u8 { 293 - var body: std.Io.Writer.Allocating = .init(self.allocator); 294 - errdefer body.deinit(); 295 - 296 - var jw: json.Stringify = .{ .writer = &body.writer }; 297 - 298 - try jw.beginObject(); 299 - try jw.objectField("requests"); 300 - try jw.beginArray(); 301 - 302 - // execute statement 303 - try jw.beginObject(); 304 - try jw.objectField("type"); 305 - try jw.write("execute"); 306 - try jw.objectField("stmt"); 307 - try jw.beginObject(); 308 - try jw.objectField("sql"); 309 - try jw.write(sql); 310 - 311 - if (args.len > 0) { 312 - try jw.objectField("args"); 313 - try jw.beginArray(); 314 - for (args) |arg| { 315 - try jw.beginObject(); 316 - try jw.objectField("type"); 317 - try jw.write("text"); 318 - try jw.objectField("value"); 319 - try jw.write(arg); 320 - try jw.endObject(); 321 - } 322 - try jw.endArray(); 323 - } 324 - 325 - try jw.endObject(); // stmt 326 - try jw.endObject(); // execute request 327 - 328 - // close statement 329 - try jw.beginObject(); 330 - try jw.objectField("type"); 331 - try jw.write("close"); 332 - try jw.endObject(); 333 - 334 - try jw.endArray(); // requests 335 - try jw.endObject(); // root 336 - 337 - return try body.toOwnedSlice(); 338 - } 339 - };
+1 -2
backend/src/http.zig backend/src/server.zig
··· 4 4 const mem = std.mem; 5 5 const db = @import("db/mod.zig"); 6 6 const dashboard = @import("dashboard.zig"); 7 - const dashboard_data = @import("dashboard_data.zig"); 8 7 9 8 const HTTP_BUF_SIZE = 8192; 10 9 const QUERY_PARAM_BUF_SIZE = 64; ··· 173 172 defer arena.deinit(); 174 173 const alloc = arena.allocator(); 175 174 176 - const data = dashboard_data.fetch(alloc) catch { 175 + const data = dashboard.fetch(alloc) catch { 177 176 try sendNotFound(request); 178 177 return; 179 178 };
+5 -5
backend/src/main.zig
··· 3 3 const posix = std.posix; 4 4 const Thread = std.Thread; 5 5 const db = @import("db/mod.zig"); 6 - const http_server = @import("http.zig"); 6 + const server = @import("server.zig"); 7 7 const tap = @import("tap.zig"); 8 8 9 9 const MAX_HTTP_WORKERS = 16; ··· 36 36 }; 37 37 38 38 const address = try net.Address.parseIp("0.0.0.0", port); 39 - var server = try address.listen(.{ .reuse_address = true }); 40 - defer server.deinit(); 39 + var listener = try address.listen(.{ .reuse_address = true }); 40 + defer listener.deinit(); 41 41 42 42 std.debug.print("leaflet-search listening on http://0.0.0.0:{d} (max {} workers)\n", .{ port, MAX_HTTP_WORKERS }); 43 43 44 44 while (true) { 45 - const conn = server.accept() catch |err| { 45 + const conn = listener.accept() catch |err| { 46 46 std.debug.print("accept error: {}\n", .{err}); 47 47 continue; 48 48 }; ··· 51 51 std.debug.print("failed to set socket timeout: {}\n", .{err}); 52 52 }; 53 53 54 - pool.spawn(http_server.handleConnection, .{conn}) catch |err| { 54 + pool.spawn(server.handleConnection, .{conn}) catch |err| { 55 55 std.debug.print("pool spawn error: {}\n", .{err}); 56 56 conn.stream.close(); 57 57 };
-50
backend/src/stats.zig
··· 1 - const std = @import("std"); 2 - const Atomic = std.atomic.Value; 3 - 4 - /// Service stats - in-memory counters, Turso-backed totals 5 - pub const Stats = struct { 6 - started_at: i64, 7 - searches: Atomic(u64), 8 - errors: Atomic(u64), 9 - 10 - pub fn init() Stats { 11 - return .{ 12 - .started_at = std.time.timestamp(), 13 - .searches = Atomic(u64).init(0), 14 - .errors = Atomic(u64).init(0), 15 - }; 16 - } 17 - 18 - pub fn recordSearch(self: *Stats) void { 19 - _ = self.searches.fetchAdd(1, .monotonic); 20 - } 21 - 22 - pub fn recordError(self: *Stats) void { 23 - _ = self.errors.fetchAdd(1, .monotonic); 24 - } 25 - 26 - pub fn getUptime(self: *const Stats) i64 { 27 - return std.time.timestamp() - self.started_at; 28 - } 29 - 30 - pub fn getSearches(self: *const Stats) u64 { 31 - return self.searches.load(.monotonic); 32 - } 33 - 34 - pub fn getErrors(self: *const Stats) u64 { 35 - return self.errors.load(.monotonic); 36 - } 37 - }; 38 - 39 - var global_stats: Stats = undefined; 40 - var initialized: bool = false; 41 - 42 - pub fn init() void { 43 - global_stats = Stats.init(); 44 - initialized = true; 45 - } 46 - 47 - pub fn get() *Stats { 48 - if (!initialized) init(); 49 - return &global_stats; 50 - }