add zig lessons documentation

notes from building this feed generator - memory management,
json handling, concurrency patterns, sdk adoption

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

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

Changed files
+278
docs
+278
docs/zig-lessons.md
···
··· 1 + # zig lessons from building a bluesky feed generator 2 + 3 + notes from building a production feed generator in zig 0.15. things we learned, patterns that worked, mistakes made. 4 + 5 + ## memory management 6 + 7 + ### arena allocators for request handling 8 + 9 + http requests are perfect for arena allocators - allocate freely during the request, free everything at once when done: 10 + 11 + ```zig 12 + fn handleRequest(request: *http.Server.Request) !void { 13 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 14 + defer arena.deinit(); 15 + const alloc = arena.allocator(); 16 + 17 + // allocate freely - no individual frees needed 18 + const result = try processStuff(alloc); 19 + try sendResponse(request, result); 20 + } 21 + // arena.deinit() frees everything 22 + ``` 23 + 24 + ### errdefer for cleanup on error paths 25 + 26 + when building up state that needs cleanup on error: 27 + 28 + ```zig 29 + var posts: std.ArrayList(AuthorPost) = .empty; 30 + errdefer { 31 + for (posts.items) |p| { 32 + allocator.free(p.uri); 33 + allocator.free(p.cid); 34 + } 35 + posts.deinit(allocator); 36 + } 37 + // if anything below errors, cleanup runs automatically 38 + ``` 39 + 40 + ### allocator passed to methods (0.15 style) 41 + 42 + zig 0.15 changed ArrayList - allocator is passed to methods, not stored: 43 + 44 + ```zig 45 + // old (pre-0.15) 46 + var list = std.ArrayList(u8).init(allocator); 47 + try list.append('x'); 48 + 49 + // new (0.15+) 50 + var list: std.ArrayList(u8) = .empty; 51 + try list.append(allocator, 'x'); 52 + defer list.deinit(allocator); 53 + ``` 54 + 55 + ## json handling 56 + 57 + ### runtime path navigation 58 + 59 + for one-off extractions, path-based navigation is clean: 60 + 61 + ```zig 62 + const did = zat.json.getString(item, "post.author.did") orelse continue; 63 + const text = zat.json.getString(item, "post.record.text") orelse ""; 64 + ``` 65 + 66 + ### comptime struct extraction 67 + 68 + for repeated patterns, define a struct and extract at compile time: 69 + 70 + ```zig 71 + const FeedPost = struct { 72 + uri: []const u8, 73 + cid: []const u8, 74 + record: struct { 75 + text: []const u8 = "", 76 + embed: ?struct { 77 + external: ?struct { uri: []const u8 } = null, 78 + } = null, 79 + }, 80 + }; 81 + 82 + // extracts nested structure, handles missing optionals 83 + const post = zat.json.extractAt(FeedPost, allocator, item, .{"post"}) catch continue; 84 + ``` 85 + 86 + the struct mirrors the json shape. optional fields (`?struct`) handle missing keys gracefully. default values (`= ""`) provide fallbacks. 87 + 88 + ### std.json for raw parsing 89 + 90 + when you need the full json tree: 91 + 92 + ```zig 93 + var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return error.ParseError; 94 + defer parsed.deinit(); 95 + 96 + if (parsed.value != .object) return error.InvalidFormat; 97 + const obj = parsed.value.object; 98 + const kind = obj.get("kind") orelse return error.MissingField; 99 + ``` 100 + 101 + ## concurrency 102 + 103 + ### mutex for shared state 104 + 105 + simple mutex pattern for shared database access: 106 + 107 + ```zig 108 + var db: ?zqlite.Conn = null; 109 + var mutex: std.Thread.Mutex = .{}; 110 + 111 + pub fn addPost(uri: []const u8, cid: []const u8) !void { 112 + mutex.lock(); 113 + defer mutex.unlock(); 114 + 115 + const conn = db orelse return error.NotInitialized; 116 + conn.exec("INSERT ...", .{uri, cid}) catch |err| { 117 + return err; 118 + }; 119 + } 120 + ``` 121 + 122 + ### spawning threads 123 + 124 + ```zig 125 + const thread = std.Thread.spawn(.{}, jetstream.consumer, .{allocator}) catch |err| { 126 + std.debug.print("failed to spawn: {}\n", .{err}); 127 + return; 128 + }; 129 + thread.detach(); 130 + ``` 131 + 132 + ## http patterns 133 + 134 + ### building responses with ArrayList 135 + 136 + ```zig 137 + var buf: std.ArrayList(u8) = .empty; 138 + defer buf.deinit(alloc); 139 + const w = buf.writer(alloc); 140 + 141 + try w.print("{{\"status\":\"{s}\",\"count\":{d}}}", .{status, count}); 142 + 143 + try sendJson(request, .ok, buf.items); 144 + ``` 145 + 146 + ### multiline strings for sql 147 + 148 + zig's multiline strings work well for embedded sql: 149 + 150 + ```zig 151 + db.exec( 152 + \\CREATE TABLE IF NOT EXISTS posts ( 153 + \\ uri TEXT PRIMARY KEY, 154 + \\ cid TEXT NOT NULL, 155 + \\ indexed_at INTEGER NOT NULL 156 + \\) 157 + , .{}) catch |err| { 158 + return err; 159 + }; 160 + ``` 161 + 162 + ## error handling 163 + 164 + ### error unions with catch 165 + 166 + ```zig 167 + // return error on failure 168 + const result = try riskyOperation(); 169 + 170 + // handle specific errors 171 + const value = operation() catch |err| { 172 + std.debug.print("failed: {}\n", .{err}); 173 + return error.OperationFailed; 174 + }; 175 + 176 + // fallback on error 177 + const maybe = operation() catch null; 178 + ``` 179 + 180 + ### sentinel errors for filtering 181 + 182 + use specific errors to distinguish "not found" from "actual failure": 183 + 184 + ```zig 185 + fn processRecord(payload: []const u8) !void { 186 + // these are expected, not failures 187 + if (!isPost(record)) return error.NotAPost; 188 + if (!filter.matches(record)) return error.NoMatch; 189 + 190 + // this is an actual failure 191 + db.addPost(uri, cid) catch |err| { 192 + std.debug.print("db error: {}\n", .{err}); 193 + return err; 194 + }; 195 + } 196 + 197 + // caller can ignore expected "errors" 198 + self.processRecord(data) catch |err| { 199 + if (err != error.NotAPost and err != error.NoMatch) { 200 + std.debug.print("processing error: {}\n", .{err}); 201 + } 202 + }; 203 + ``` 204 + 205 + ## project structure 206 + 207 + adopted domain-based grouping after the codebase grew: 208 + 209 + ``` 210 + src/ 211 + main.zig # entry point, spawns threads 212 + feed/ 213 + config.zig # environment variables, feed URIs 214 + filter.zig # post matching logic 215 + server/ 216 + http.zig # request handling 217 + dashboard.zig # html rendering 218 + stats.zig # metrics 219 + stream/ 220 + jetstream.zig # websocket consumer 221 + db.zig # sqlite operations 222 + atproto.zig # AT Protocol utilities 223 + ``` 224 + 225 + relative imports: `@import("../feed/config.zig")` 226 + 227 + ## tooling 228 + 229 + ### zig fmt 230 + 231 + built-in formatter. run before commits: 232 + 233 + ```sh 234 + zig fmt src/ build.zig 235 + ``` 236 + 237 + check mode for CI/hooks: 238 + 239 + ```sh 240 + zig fmt --check src/ build.zig 241 + ``` 242 + 243 + ### build.zig dependencies 244 + 245 + fetch external packages: 246 + 247 + ```bash 248 + zig fetch --save https://example.com/package/archive/main 249 + ``` 250 + 251 + creates entry in `build.zig.zon`, imports via `@import("package")`. 252 + 253 + ## things that surprised us 254 + 255 + 1. **no hidden allocations** - you always know when memory is allocated because you pass the allocator 256 + 257 + 2. **comptime is powerful** - struct extraction, string formatting, type introspection all happen at compile time 258 + 259 + 3. **error handling is ergonomic** - `try`, `catch`, `errdefer` compose well 260 + 261 + 4. **multiline strings preserve indentation** - `\\` prefix strips leading whitespace consistently 262 + 263 + 5. **optionals chain nicely** - `if (a) |b| if (b.c) |d| d.value else null else null` 264 + 265 + ## sdk adoption (zat) 266 + 267 + started with hand-rolled http/did resolution (~450 lines). adopted zat sdk: 268 + 269 + - `zat.Did.parse()` - DID parsing 270 + - `zat.DidResolver` - DID document resolution 271 + - `zat.XrpcClient` - XRPC API calls with automatic JSON handling 272 + - `zat.json.getString()` - path-based JSON navigation 273 + - `zat.json.extractAt()` - comptime struct extraction 274 + - `zat.Tid`, `zat.AtUri`, `zat.Nsid` - AT Protocol primitives 275 + 276 + result: atproto.zig went from 898 lines to 454 lines (50% reduction). 277 + 278 + lesson: **find or build good abstractions** - the raw http/tls/json code worked but was noisy. moving it to a library cleaned up the application code significantly.