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

apply struct serialization pattern across codebase

- add docs/zig-patterns.md and docs/turso-hrana.md
- mod.zig: add SearchResultJson, TagJson, PopularJson types
- mod.zig: add Doc.toJson() and Pub.toJson() methods
- mod.zig: simplify search, getTags, getPopular functions
- dashboard.zig: add TagJson, TimelineJson, PubJson types
- dashboard.zig: simplify format functions

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

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

+236 -108
+8 -35
backend/src/dashboard.zig
··· 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, ··· 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 } ··· 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 } ··· 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 }
··· 3 const Allocator = std.mem.Allocator; 4 const db = @import("db/mod.zig"); 5 6 + // JSON output types 7 + const TagJson = struct { tag: []const u8, count: i64 }; 8 + const TimelineJson = struct { date: []const u8, count: i64 }; 9 + const PubJson = struct { name: []const u8, basePath: []const u8, count: i64 }; 10 + 11 /// All data needed to render the dashboard 12 pub const Data = struct { 13 started_at: i64, ··· 102 fn formatTagsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 103 var output: std.Io.Writer.Allocating = .init(alloc); 104 errdefer output.deinit(); 105 var jw: json.Stringify = .{ .writer = &output.writer }; 106 try jw.beginArray(); 107 + for (rows) |row| try jw.write(TagJson{ .tag = row.text(0), .count = row.int(1) }); 108 try jw.endArray(); 109 return try output.toOwnedSlice(); 110 } ··· 112 fn formatTimelineJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 113 var output: std.Io.Writer.Allocating = .init(alloc); 114 errdefer output.deinit(); 115 var jw: json.Stringify = .{ .writer = &output.writer }; 116 try jw.beginArray(); 117 + for (rows) |row| try jw.write(TimelineJson{ .date = row.text(0), .count = row.int(1) }); 118 try jw.endArray(); 119 return try output.toOwnedSlice(); 120 } ··· 122 fn formatPubsJson(alloc: Allocator, rows: []const db.Row) ![]const u8 { 123 var output: std.Io.Writer.Allocating = .init(alloc); 124 errdefer output.deinit(); 125 var jw: json.Stringify = .{ .writer = &output.writer }; 126 try jw.beginArray(); 127 + for (rows) |row| try jw.write(PubJson{ .name = row.text(0), .basePath = row.text(1), .count = row.int(2) }); 128 try jw.endArray(); 129 return try output.toOwnedSlice(); 130 }
+46 -73
backend/src/db/mod.zig
··· 94 c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 95 } 96 97 - /// Document search result. 98 - /// Type derivation: has_publication=true → "article", false → "looseleaf" 99 const Doc = struct { 100 uri: []const u8, 101 did: []const u8, ··· 118 .hasPublication = row.int(7) != 0, 119 }; 120 } 121 }; 122 123 const DocsByTag = zql.Query( ··· 156 \\ORDER BY rank LIMIT 40 157 ); 158 159 - /// Publication search result. Type is always "publication". 160 const Pub = struct { 161 uri: []const u8, 162 did: []const u8, ··· 173 .snippet = row.text(3), 174 .rkey = row.text(4), 175 .basePath = row.text(5), 176 }; 177 } 178 }; ··· 187 \\ORDER BY rank LIMIT 10 188 ); 189 190 - const TagCount = struct { 191 - tag: []const u8, 192 - count: i64, 193 - 194 - fn fromRow(row: Row) TagCount { 195 - return .{ .tag = row.text(0), .count = row.int(1) }; 196 - } 197 - }; 198 - 199 const TagsQuery = zql.Query( 200 \\SELECT tag, COUNT(*) as count 201 \\FROM document_tags ··· 225 226 if (doc_result) |*res| { 227 defer res.deinit(); 228 - for (res.rows) |row| { 229 - const doc = Doc.fromRow(row); 230 - try jw.beginObject(); 231 - try jw.objectField("type"); 232 - try jw.write(if (doc.hasPublication) "article" else "looseleaf"); 233 - try jw.objectField("uri"); 234 - try jw.write(doc.uri); 235 - try jw.objectField("did"); 236 - try jw.write(doc.did); 237 - try jw.objectField("title"); 238 - try jw.write(doc.title); 239 - try jw.objectField("snippet"); 240 - try jw.write(doc.snippet); 241 - try jw.objectField("createdAt"); 242 - try jw.write(doc.createdAt); 243 - try jw.objectField("rkey"); 244 - try jw.write(doc.rkey); 245 - try jw.objectField("basePath"); 246 - try jw.write(doc.basePath); 247 - try jw.endObject(); 248 - } 249 } 250 251 // publications are excluded when filtering by tag (tags only apply to documents) ··· 257 258 if (pub_result) |*res| { 259 defer res.deinit(); 260 - for (res.rows) |row| { 261 - const p = Pub.fromRow(row); 262 - try jw.beginObject(); 263 - try jw.objectField("type"); 264 - try jw.write("publication"); 265 - try jw.objectField("uri"); 266 - try jw.write(p.uri); 267 - try jw.objectField("did"); 268 - try jw.write(p.did); 269 - try jw.objectField("title"); 270 - try jw.write(p.name); 271 - try jw.objectField("snippet"); 272 - try jw.write(p.snippet); 273 - try jw.objectField("rkey"); 274 - try jw.write(p.rkey); 275 - try jw.objectField("basePath"); 276 - try jw.write(p.basePath); 277 - try jw.endObject(); 278 - } 279 } 280 } 281 ··· 297 298 var jw: json.Stringify = .{ .writer = &output.writer }; 299 try jw.beginArray(); 300 - 301 - for (res.rows) |row| { 302 - const tag = TagCount.fromRow(row); 303 - try jw.beginObject(); 304 - try jw.objectField("tag"); 305 - try jw.write(tag.tag); 306 - try jw.objectField("count"); 307 - try jw.write(tag.count); 308 - try jw.endObject(); 309 - } 310 - 311 try jw.endArray(); 312 return try output.toOwnedSlice(); 313 } ··· 367 368 var jw: json.Stringify = .{ .writer = &output.writer }; 369 try jw.beginArray(); 370 - 371 - for (res.rows) |row| { 372 - try jw.beginObject(); 373 - try jw.objectField("query"); 374 - try jw.write(row.text(0)); 375 - try jw.objectField("count"); 376 - try jw.write(row.int(1)); 377 - try jw.endObject(); 378 - } 379 - 380 try jw.endArray(); 381 return try output.toOwnedSlice(); 382 }
··· 94 c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 95 } 96 97 + // JSON output types for search results 98 + const SearchResultJson = struct { 99 + type: []const u8, 100 + uri: []const u8, 101 + did: []const u8, 102 + title: []const u8, 103 + snippet: []const u8, 104 + createdAt: []const u8 = "", 105 + rkey: []const u8, 106 + basePath: []const u8, 107 + }; 108 + 109 + const TagJson = struct { tag: []const u8, count: i64 }; 110 + const PopularJson = struct { query: []const u8, count: i64 }; 111 + 112 + /// Document search result (internal) 113 const Doc = struct { 114 uri: []const u8, 115 did: []const u8, ··· 132 .hasPublication = row.int(7) != 0, 133 }; 134 } 135 + 136 + fn toJson(self: Doc) SearchResultJson { 137 + return .{ 138 + .type = if (self.hasPublication) "article" else "looseleaf", 139 + .uri = self.uri, 140 + .did = self.did, 141 + .title = self.title, 142 + .snippet = self.snippet, 143 + .createdAt = self.createdAt, 144 + .rkey = self.rkey, 145 + .basePath = self.basePath, 146 + }; 147 + } 148 }; 149 150 const DocsByTag = zql.Query( ··· 183 \\ORDER BY rank LIMIT 40 184 ); 185 186 + /// Publication search result (internal) 187 const Pub = struct { 188 uri: []const u8, 189 did: []const u8, ··· 200 .snippet = row.text(3), 201 .rkey = row.text(4), 202 .basePath = row.text(5), 203 + }; 204 + } 205 + 206 + fn toJson(self: Pub) SearchResultJson { 207 + return .{ 208 + .type = "publication", 209 + .uri = self.uri, 210 + .did = self.did, 211 + .title = self.name, 212 + .snippet = self.snippet, 213 + .rkey = self.rkey, 214 + .basePath = self.basePath, 215 }; 216 } 217 }; ··· 226 \\ORDER BY rank LIMIT 10 227 ); 228 229 const TagsQuery = zql.Query( 230 \\SELECT tag, COUNT(*) as count 231 \\FROM document_tags ··· 255 256 if (doc_result) |*res| { 257 defer res.deinit(); 258 + for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 259 } 260 261 // publications are excluded when filtering by tag (tags only apply to documents) ··· 267 268 if (pub_result) |*res| { 269 defer res.deinit(); 270 + for (res.rows) |row| try jw.write(Pub.fromRow(row).toJson()); 271 } 272 } 273 ··· 289 290 var jw: json.Stringify = .{ .writer = &output.writer }; 291 try jw.beginArray(); 292 + for (res.rows) |row| try jw.write(TagJson{ .tag = row.text(0), .count = row.int(1) }); 293 try jw.endArray(); 294 return try output.toOwnedSlice(); 295 } ··· 349 350 var jw: json.Stringify = .{ .writer = &output.writer }; 351 try jw.beginArray(); 352 + for (res.rows) |row| try jw.write(PopularJson{ .query = row.text(0), .count = row.int(1) }); 353 try jw.endArray(); 354 return try output.toOwnedSlice(); 355 }
+84
docs/turso-hrana.md
···
··· 1 + # turso and hrana 2 + 3 + leaflet-search uses [Turso](https://turso.tech) (hosted libsql) via the HTTP API. 4 + 5 + ## what is hrana? 6 + 7 + hrana (czech for "edge") is the protocol for connecting to libsql/sqlite over the network. designed for edge functions where low latency matters. 8 + 9 + the HTTP API (`/v2/pipeline`) is "hrana over HTTP" - stateless version of the websocket protocol. 10 + 11 + ## request format 12 + 13 + ```json 14 + { 15 + "requests": [ 16 + { 17 + "type": "execute", 18 + "stmt": { 19 + "sql": "SELECT * FROM users WHERE id = ?", 20 + "args": [ 21 + { "type": "text", "value": "123" } 22 + ] 23 + } 24 + }, 25 + { "type": "close" } 26 + ] 27 + } 28 + ``` 29 + 30 + ### stmt fields 31 + 32 + | field | type | required | notes | 33 + |-------|------|----------|-------| 34 + | sql | string | yes | single SQL statement | 35 + | args | array | no | positional parameters, **omit if empty** | 36 + | named_args | array | no | named parameters (`:name`, `@name`, `$name`) | 37 + | want_rows | bool | no | default true, set false to skip row data | 38 + 39 + ### value types 40 + 41 + ```typescript 42 + type Value = 43 + | { "type": "null" } 44 + | { "type": "integer", "value": string } // string to avoid precision loss 45 + | { "type": "float", "value": number } 46 + | { "type": "text", "value": string } 47 + | { "type": "blob", "base64": string } 48 + ``` 49 + 50 + ## response format 51 + 52 + ```json 53 + { 54 + "baton": null, 55 + "base_url": null, 56 + "results": [ 57 + { 58 + "type": "ok", 59 + "response": { 60 + "type": "execute", 61 + "result": { 62 + "cols": [{"name": "id", "decltype": "TEXT"}], 63 + "rows": [["123"]], 64 + "affected_row_count": 0, 65 + "last_insert_rowid": null 66 + } 67 + } 68 + }, 69 + { "type": "ok", "response": { "type": "close" } } 70 + ] 71 + } 72 + ``` 73 + 74 + ## gotchas 75 + 76 + 1. **args must be omitted, not null** - `"args": null` is invalid, omit the field entirely when no args 77 + 2. **integers as strings** - large integers are strings in JSON to preserve precision 78 + 3. **always close** - include `{"type": "close"}` at the end of requests array 79 + 80 + ## references 81 + 82 + - [Turso HTTP API docs](https://docs.turso.tech/sdk/http/reference) 83 + - [Hrana 3 spec](https://github.com/tursodatabase/libsql/blob/main/docs/HRANA_3_SPEC.md) 84 + - [HTTP v2 spec](https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md)
+98
docs/zig-patterns.md
···
··· 1 + # zig patterns 2 + 3 + notes on zig idioms learned while building leaflet-search. 4 + 5 + ## json serialization 6 + 7 + ### struct serialization vs manual building 8 + 9 + zig's `std.json.Stringify` can serialize structs directly with `jw.write(struct)`: 10 + 11 + ```zig 12 + // define types that mirror the JSON structure 13 + const Value = struct { type: []const u8 = "text", value: []const u8 }; 14 + const Stmt = struct { sql: []const u8, args: ?[]const Value = null }; 15 + const ExecuteReq = struct { type: []const u8 = "execute", stmt: Stmt }; 16 + 17 + // serialize with one call 18 + try jw.write(ExecuteReq{ .stmt = .{ .sql = sql, .args = values } }); 19 + ``` 20 + 21 + this is cleaner than manual field-by-field building: 22 + 23 + ```zig 24 + // verbose alternative 25 + try jw.beginObject(); 26 + try jw.objectField("type"); 27 + try jw.write("execute"); 28 + try jw.objectField("stmt"); 29 + try jw.beginObject(); 30 + try jw.objectField("sql"); 31 + try jw.write(sql); 32 + // ... many more lines 33 + try jw.endObject(); 34 + try jw.endObject(); 35 + ``` 36 + 37 + ### optional fields 38 + 39 + use `emit_null_optional_fields = false` to omit null optional fields instead of serializing them as `"field": null`: 40 + 41 + ```zig 42 + const Stmt = struct { 43 + sql: []const u8, 44 + args: ?[]const Value = null, // optional field 45 + }; 46 + 47 + var jw: json.Stringify = .{ 48 + .writer = &body.writer, 49 + .options = .{ .emit_null_optional_fields = false }, 50 + }; 51 + 52 + // if args is null, the field is omitted entirely 53 + try jw.write(Stmt{ .sql = "SELECT 1", .args = null }); 54 + // produces: {"sql":"SELECT 1"} 55 + // NOT: {"sql":"SELECT 1","args":null} 56 + ``` 57 + 58 + this matters when APIs reject `null` values for optional fields (like Turso/Hrana). 59 + 60 + ## file organization 61 + 62 + ### file-as-type pattern 63 + 64 + when a file IS a type (single primary struct), use `@This()`: 65 + 66 + ```zig 67 + // Client.zig 68 + const Client = @This(); 69 + 70 + allocator: Allocator, 71 + url: []const u8, 72 + // ... fields at top level 73 + 74 + pub fn init(allocator: Allocator) !Client { ... } 75 + pub fn query(self: *Client, ...) !Result { ... } 76 + ``` 77 + 78 + consumers import as: `const Client = @import("Client.zig");` 79 + 80 + ### namespace modules 81 + 82 + when a file is a namespace with multiple types, use regular exports: 83 + 84 + ```zig 85 + // result.zig 86 + pub const Result = struct { ... }; 87 + pub const Row = struct { ... }; 88 + pub const BatchResult = struct { ... }; 89 + ``` 90 + 91 + naming convention: 92 + - `TitleCase.zig` → file-as-type (the file IS the struct) 93 + - `snake_case.zig` → namespace module (exports multiple things) 94 + 95 + ## references 96 + 97 + - [zig std.json.Stringify source](https://github.com/ziglang/zig/blob/master/lib/std/json/Stringify.zig) 98 + - [zig style guide](https://ziglang.org/documentation/master/#Style-Guide)