declarative relay deployment on hetzner relay-eval.waow.tech
atproto relay
14
fork

Configure Feed

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

relay-eval: historical coverage trend with stained-glass visualization

- add /api/trend endpoint returning coverage data across recent runs
- store.zig: getTrendData() joins runs + relay_stats for last 48 runs
- full-width canvas at top draws multi-relay coverage lines with glow
- additive blending on fills creates stained-glass color layering
- glass-morphism panel (backdrop-filter, semi-transparent bg) floats
over the trend, letting the glow bleed through
- canvas auto-scales Y axis to data range, handles missing data points
- responsive: redraws on window resize with cached data

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

+405 -21
+186
relay-eval/src/server.zig
··· 1 + const std = @import("std"); 2 + const Store = @import("store.zig").Store; 3 + 4 + const log = std.log.scoped(.server); 5 + 6 + const index_html = @embedFile("static/index.html"); 7 + 8 + pub fn run(allocator: std.mem.Allocator, db_path: [:0]const u8, port: u16) !void { 9 + var store = try Store.open(db_path); 10 + defer store.close(); 11 + 12 + const addr = std.net.Address.parseIp4("0.0.0.0", port) catch unreachable; 13 + var listener = try addr.listen(.{ .reuse_address = true }); 14 + defer listener.deinit(); 15 + 16 + log.info("listening on :{d}", .{port}); 17 + 18 + while (true) { 19 + const conn = listener.accept() catch |err| { 20 + log.err("accept: {s}", .{@errorName(err)}); 21 + continue; 22 + }; 23 + handleConnection(allocator, conn.stream, &store) catch |err| { 24 + log.debug("request error: {s}", .{@errorName(err)}); 25 + }; 26 + conn.stream.close(); 27 + } 28 + } 29 + 30 + fn handleConnection(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 31 + var buf: [4096]u8 = undefined; 32 + const n = try stream.read(&buf); 33 + if (n == 0) return; 34 + 35 + const request = buf[0..n]; 36 + 37 + // parse method + path from first line 38 + const first_line_end = std.mem.indexOf(u8, request, "\r\n") orelse return; 39 + const first_line = request[0..first_line_end]; 40 + 41 + var parts = std.mem.splitScalar(u8, first_line, ' '); 42 + const method = parts.next() orelse return; 43 + const path = parts.next() orelse return; 44 + 45 + if (!std.mem.eql(u8, method, "GET")) { 46 + try sendResponse(stream, "405 Method Not Allowed", "text/plain", "method not allowed"); 47 + return; 48 + } 49 + 50 + if (std.mem.eql(u8, path, "/")) { 51 + try sendResponse(stream, "200 OK", "text/html", index_html); 52 + } else if (std.mem.eql(u8, path, "/api/runs")) { 53 + try serveRuns(allocator, stream, store); 54 + } else if (std.mem.eql(u8, path, "/api/latest")) { 55 + try serveLatest(allocator, stream, store); 56 + } else if (std.mem.startsWith(u8, path, "/api/runs/")) { 57 + const id_str = path["/api/runs/".len..]; 58 + const id = std.fmt.parseInt(i64, id_str, 10) catch { 59 + try sendResponse(stream, "400 Bad Request", "text/plain", "invalid run id"); 60 + return; 61 + }; 62 + try serveRunDetail(allocator, stream, store, id); 63 + } else if (std.mem.eql(u8, path, "/api/trend")) { 64 + try serveTrend(allocator, stream, store); 65 + } else { 66 + try sendResponse(stream, "404 Not Found", "text/plain", "not found"); 67 + } 68 + } 69 + 70 + fn serveRuns(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 71 + const runs = try store.getRecentRuns(allocator, 50); 72 + defer { 73 + for (runs) |r| allocator.free(r.timestamp); 74 + allocator.free(runs); 75 + } 76 + 77 + var json: std.ArrayList(u8) = .empty; 78 + defer json.deinit(allocator); 79 + 80 + try json.appendSlice(allocator, "["); 81 + for (runs, 0..) |r, i| { 82 + if (i > 0) try json.appendSlice(allocator, ","); 83 + try json.print(allocator, "{{\"id\":{d},\"timestamp\":\"{s}\",\"window_seconds\":{d},\"union_dids\":{d}}}", .{ 84 + r.id, r.timestamp, r.window_seconds, r.union_dids, 85 + }); 86 + } 87 + try json.appendSlice(allocator, "]"); 88 + 89 + try sendResponse(stream, "200 OK", "application/json", json.items); 90 + } 91 + 92 + fn serveLatest(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 93 + const run_id = try store.getLatestRunId() orelse { 94 + try sendResponse(stream, "200 OK", "application/json", "null"); 95 + return; 96 + }; 97 + try serveRunDetail(allocator, stream, store, run_id); 98 + } 99 + 100 + fn serveRunDetail(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store, run_id: i64) !void { 101 + const run_meta = try store.getRun(allocator, run_id) orelse { 102 + try sendResponse(stream, "404 Not Found", "text/plain", "run not found"); 103 + return; 104 + }; 105 + defer allocator.free(run_meta.timestamp); 106 + 107 + const stats = try store.getRunStats(allocator, run_id); 108 + defer { 109 + for (stats) |s| allocator.free(s.host); 110 + allocator.free(stats); 111 + } 112 + 113 + const diffs = try store.getRunDiffs(allocator, run_id); 114 + defer { 115 + for (diffs) |d| { 116 + allocator.free(d.relay); 117 + allocator.free(d.did); 118 + allocator.free(d.classification); 119 + } 120 + allocator.free(diffs); 121 + } 122 + 123 + var json: std.ArrayList(u8) = .empty; 124 + defer json.deinit(allocator); 125 + 126 + try json.print(allocator, "{{\"id\":{d},\"timestamp\":\"{s}\",\"window_seconds\":{d},\"union_dids\":{d},\"stats\":[", .{ 127 + run_id, run_meta.timestamp, run_meta.window_seconds, run_meta.union_dids, 128 + }); 129 + for (stats, 0..) |s, i| { 130 + if (i > 0) try json.appendSlice(allocator, ","); 131 + try json.print(allocator, "{{\"host\":\"{s}\",\"events\":{d},\"unique_dids\":{d},\"connected\":{}}}", .{ 132 + s.host, s.events, s.unique_dids, s.connected, 133 + }); 134 + } 135 + try json.appendSlice(allocator, "],\"diffs\":["); 136 + for (diffs, 0..) |d, i| { 137 + if (i > 0) try json.appendSlice(allocator, ","); 138 + try json.print(allocator, "{{\"relay\":\"{s}\",\"did\":\"{s}\",\"classification\":\"{s}\"}}", .{ 139 + d.relay, d.did, d.classification, 140 + }); 141 + } 142 + try json.appendSlice(allocator, "]}"); 143 + 144 + try sendResponse(stream, "200 OK", "application/json", json.items); 145 + } 146 + 147 + fn serveTrend(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 148 + const points = try store.getTrendData(allocator, 48); 149 + defer { 150 + for (points) |p| { 151 + allocator.free(p.timestamp); 152 + allocator.free(p.host); 153 + } 154 + allocator.free(points); 155 + } 156 + 157 + var json: std.ArrayList(u8) = .empty; 158 + defer json.deinit(allocator); 159 + 160 + try json.appendSlice(allocator, "["); 161 + for (points, 0..) |p, i| { 162 + if (i > 0) try json.appendSlice(allocator, ","); 163 + try json.print(allocator, "{{\"ts\":\"{s}\",\"union\":{d},\"host\":\"{s}\",\"dids\":{d}}}", .{ 164 + p.timestamp, p.union_dids, p.host, p.unique_dids, 165 + }); 166 + } 167 + try json.appendSlice(allocator, "]"); 168 + 169 + try sendResponse(stream, "200 OK", "application/json", json.items); 170 + } 171 + 172 + fn sendResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void { 173 + var header_buf: [512]u8 = undefined; 174 + const header = std.fmt.bufPrint(&header_buf, 175 + "HTTP/1.1 {s}\r\n" ++ 176 + "Content-Type: {s}\r\n" ++ 177 + "Content-Length: {d}\r\n" ++ 178 + "Access-Control-Allow-Origin: *\r\n" ++ 179 + "Connection: close\r\n" ++ 180 + "\r\n", 181 + .{ status, content_type, body.len }, 182 + ) catch return error.HeaderTooLong; 183 + 184 + try stream.writeAll(header); 185 + try stream.writeAll(body); 186 + }
+181 -21
relay-eval/src/static/index.html
··· 16 16 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 17 17 font-size: 13px; 18 18 background: var(--bg); color: var(--fg); 19 - padding: 2.5rem 2rem; max-width: 960px; margin: 0 auto; 19 + margin: 0; padding: 0; 20 20 line-height: 1.55; 21 21 -webkit-font-smoothing: antialiased; 22 22 } 23 + 24 + /* trend background */ 25 + .trend-wrap { 26 + position: relative; 27 + width: 100%; height: 260px; 28 + overflow: hidden; 29 + } 30 + #trend { display: block; width: 100%; height: 100%; } 31 + 32 + /* glass panel */ 33 + .glass { 34 + position: relative; z-index: 1; 35 + max-width: 960px; margin: -2.5rem auto 0; 36 + padding: 2rem 2rem 2.5rem; 37 + background: rgba(13, 17, 23, 0.91); 38 + backdrop-filter: blur(20px); 39 + -webkit-backdrop-filter: blur(20px); 40 + border: 1px solid rgba(255, 255, 255, 0.04); 41 + border-top: 1px solid rgba(255, 255, 255, 0.08); 42 + border-radius: 16px 16px 0 0; 43 + min-height: calc(100vh - 230px); 44 + box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.5); 45 + } 46 + 23 47 h1 { font-size: 1.25rem; font-weight: 600; color: var(--fg); letter-spacing: -0.02em; } 24 48 .subtitle { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; } 25 49 ··· 129 153 </head> 130 154 <body> 131 155 132 - <h1>relay-eval</h1> 133 - <p class="subtitle">comparing what each relay sees on the atproto network</p> 156 + <div class="trend-wrap"> 157 + <canvas id="trend"></canvas> 158 + </div> 134 159 135 - <div id="content"><p class="loading">loading...</p></div> 136 - <div id="runs-nav"></div> 160 + <div class="glass"> 161 + <h1>relay-eval</h1> 162 + <p class="subtitle">comparing what each relay sees on the atproto network</p> 163 + <div id="content"><p class="loading">loading...</p></div> 164 + <div id="runs-nav"></div> 165 + </div> 137 166 138 167 <script> 139 168 const ops = { 140 - 'bsky.network': { name: 'Bluesky PBC', sym: '◆', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 141 - 'relay1.us-east.bsky.network': { name: 'Bluesky PBC', sym: '◆', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 142 - 'relay1.us-west.bsky.network': { name: 'Bluesky PBC', sym: '◆', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 143 - 'zlay.waow.tech': { name: '@zzstoatzz.io', sym: '▲', color: '#3fb950', url: 'https://bsky.app/profile/zzstoatzz.io' }, 144 - 'relay.waow.tech': { name: '@zzstoatzz.io', sym: '▲', color: '#3fb950', url: 'https://bsky.app/profile/zzstoatzz.io' }, 145 - 'relay.bas.sh': { name: '@bas.sh', sym: '■', color: '#f0883e', url: 'https://bsky.app/profile/bas.sh' }, 146 - 'northamerica.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 147 - 'asia.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 148 - 'europe.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 149 - 'relay.fire.hose.cam': { name: '@bad-example.com', sym: '◇', color: '#f85149', url: 'https://bsky.app/profile/bad-example.com' }, 150 - 'relay3.fr.hose.cam': { name: '@bad-example.com', sym: '◇', color: '#f85149', url: 'https://bsky.app/profile/bad-example.com' }, 151 - 'relay.xero.systems': { name: '@besaid.zone', sym: '★', color: '#d29922', url: 'https://bsky.app/profile/besaid.zone' }, 152 - 'atproto.africa': { name: 'blacksky', sym: '◎', color: '#da7dae', url: 'https://bsky.app/profile/blackskyweb.xyz' }, 153 - 'relay.upcloud.world': { name: 'upcloud', sym: '▽', color: '#39d2c0' }, 154 - 'relay.feeds.blue': { name: '@mackuba.eu', sym: '⬡', color: '#d2a8ff', url: 'https://bsky.app/profile/mackuba.eu' }, 169 + 'bsky.network': { name: 'Bluesky PBC', sym: '\u25c6', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 170 + 'relay1.us-east.bsky.network': { name: 'Bluesky PBC', sym: '\u25c6', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 171 + 'relay1.us-west.bsky.network': { name: 'Bluesky PBC', sym: '\u25c6', color: '#58a6ff', url: 'https://bsky.app/profile/bsky.app' }, 172 + 'zlay.waow.tech': { name: '@zzstoatzz.io', sym: '\u25b2', color: '#3fb950', url: 'https://bsky.app/profile/zzstoatzz.io' }, 173 + 'relay.waow.tech': { name: '@zzstoatzz.io', sym: '\u25b2', color: '#3fb950', url: 'https://bsky.app/profile/zzstoatzz.io' }, 174 + 'relay.bas.sh': { name: '@bas.sh', sym: '\u25a0', color: '#f0883e', url: 'https://bsky.app/profile/bas.sh' }, 175 + 'northamerica.firehose.network': { name: '@sri.xyz', sym: '\u25cf', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 176 + 'asia.firehose.network': { name: '@sri.xyz', sym: '\u25cf', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 177 + 'europe.firehose.network': { name: '@sri.xyz', sym: '\u25cf', color: '#bc8cff', url: 'https://bsky.app/profile/sri.xyz' }, 178 + 'relay.fire.hose.cam': { name: '@bad-example.com', sym: '\u25c7', color: '#f85149', url: 'https://bsky.app/profile/bad-example.com' }, 179 + 'relay3.fr.hose.cam': { name: '@bad-example.com', sym: '\u25c7', color: '#f85149', url: 'https://bsky.app/profile/bad-example.com' }, 180 + 'relay.xero.systems': { name: '@besaid.zone', sym: '\u2605', color: '#d29922', url: 'https://bsky.app/profile/besaid.zone' }, 181 + 'atproto.africa': { name: 'blacksky', sym: '\u25ce', color: '#da7dae', url: 'https://bsky.app/profile/blackskyweb.xyz' }, 182 + 'relay.upcloud.world': { name: 'upcloud', sym: '\u25bd', color: '#39d2c0' }, 183 + 'relay.feeds.blue': { name: '@mackuba.eu', sym: '\u2b21', color: '#d2a8ff', url: 'https://bsky.app/profile/mackuba.eu' }, 155 184 }; 156 185 157 - function op(h) { return ops[h] || { name: h.split('.').slice(-2).join('.'), sym: '·', color: '#8b949e' }; } 186 + function op(h) { return ops[h] || { name: h.split('.').slice(-2).join('.'), sym: '\u00b7', color: '#8b949e' }; } 158 187 159 188 function ago(iso) { 160 189 const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); ··· 202 231 + `</div>`; 203 232 } 204 233 234 + // --- trend visualization --- 235 + 236 + function drawTrend(raw) { 237 + const canvas = document.getElementById('trend'); 238 + if (!canvas) return; 239 + const wrap = canvas.parentElement; 240 + const ctx = canvas.getContext('2d'); 241 + const dpr = window.devicePixelRatio || 1; 242 + const W = wrap.clientWidth, H = wrap.clientHeight; 243 + canvas.width = W * dpr; canvas.height = H * dpr; 244 + canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; 245 + ctx.scale(dpr, dpr); 246 + 247 + // group flat array into runs 248 + const runs = [], rmap = {}; 249 + for (const p of raw) { 250 + if (!rmap[p.ts]) { rmap[p.ts] = { ts: p.ts, union: p.union, relays: {} }; runs.push(rmap[p.ts]); } 251 + rmap[p.ts].relays[p.host] = p.dids; 252 + } 253 + if (runs.length < 2) { 254 + // single run or empty: just draw dark background 255 + ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, W, H); 256 + return; 257 + } 258 + 259 + const hosts = [...new Set(raw.map(p => p.host))]; 260 + const pad = { t: 24, r: 16, b: 28, l: 16 }; 261 + const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 262 + 263 + // compute coverage series and y range 264 + const series = {}; 265 + let lo = 100, hi = 0; 266 + for (const host of hosts) { 267 + series[host] = runs.map(run => { 268 + const dids = run.relays[host]; 269 + if (dids == null || run.union === 0) return null; 270 + const v = (dids / run.union) * 100; 271 + if (v < lo) lo = v; 272 + if (v > hi) hi = v; 273 + return v; 274 + }); 275 + } 276 + lo = Math.max(0, Math.floor(lo - 1)); 277 + hi = Math.min(100, Math.ceil(hi + 0.5)); 278 + const yr = hi - lo || 1; 279 + 280 + const toX = i => pad.l + (i / (runs.length - 1)) * cw; 281 + const toY = v => pad.t + ch - ((v - lo) / yr) * ch; 282 + 283 + // background 284 + const bg = ctx.createLinearGradient(0, 0, 0, H); 285 + bg.addColorStop(0, '#10151c'); bg.addColorStop(1, '#0d1117'); 286 + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); 287 + 288 + // faint grid 289 + ctx.strokeStyle = 'rgba(255,255,255,0.025)'; ctx.lineWidth = 1; 290 + for (let v = Math.ceil(lo); v <= Math.floor(hi); v++) { 291 + const yy = toY(v); 292 + ctx.beginPath(); ctx.moveTo(pad.l, yy); ctx.lineTo(W - pad.r, yy); ctx.stroke(); 293 + } 294 + 295 + // sort by avg coverage (worst first = painted underneath) 296 + const avgOf = s => { const v = s.filter(x => x !== null); return v.length ? v.reduce((a,b) => a+b, 0) / v.length : 0; }; 297 + const sorted = [...hosts].sort((a, b) => avgOf(series[a]) - avgOf(series[b])); 298 + 299 + // helper: get valid (non-null) points for a host 300 + const validPts = host => series[host].map((v, i) => [i, v]).filter(p => p[1] !== null); 301 + 302 + // stained glass fills (additive blending) 303 + ctx.save(); 304 + ctx.globalCompositeOperation = 'lighter'; 305 + for (const host of sorted) { 306 + const vp = validPts(host); 307 + if (vp.length < 2) continue; 308 + ctx.beginPath(); 309 + ctx.moveTo(toX(vp[0][0]), pad.t + ch); 310 + for (const [i, v] of vp) ctx.lineTo(toX(i), toY(v)); 311 + ctx.lineTo(toX(vp[vp.length - 1][0]), pad.t + ch); 312 + ctx.closePath(); 313 + ctx.fillStyle = op(host).color + '06'; 314 + ctx.fill(); 315 + } 316 + ctx.restore(); 317 + 318 + // electric lines 319 + for (const host of sorted) { 320 + const vp = validPts(host); 321 + if (vp.length < 2) continue; 322 + const color = op(host).color; 323 + const trace = () => { 324 + ctx.beginPath(); 325 + ctx.moveTo(toX(vp[0][0]), toY(vp[0][1])); 326 + for (let j = 1; j < vp.length; j++) ctx.lineTo(toX(vp[j][0]), toY(vp[j][1])); 327 + }; 328 + 329 + // glow 330 + ctx.save(); 331 + trace(); 332 + ctx.shadowColor = color; ctx.shadowBlur = 14; 333 + ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.globalAlpha = 0.5; 334 + ctx.stroke(); 335 + ctx.restore(); 336 + 337 + // bright core 338 + trace(); 339 + ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.globalAlpha = 0.85; 340 + ctx.stroke(); 341 + ctx.globalAlpha = 1; 342 + } 343 + 344 + // bottom fade into page bg 345 + const fade = ctx.createLinearGradient(0, H - 36, 0, H); 346 + fade.addColorStop(0, 'rgba(13,17,23,0)'); fade.addColorStop(1, 'rgba(13,17,23,1)'); 347 + ctx.fillStyle = fade; ctx.fillRect(0, H - 36, W, 36); 348 + 349 + // labels 350 + ctx.fillStyle = 'rgba(139,148,158,0.3)'; ctx.font = '10px monospace'; 351 + ctx.textAlign = 'left'; ctx.fillText(ago(runs[0].ts), pad.l, H - 6); 352 + ctx.textAlign = 'right'; ctx.fillText('now', W - pad.r, H - 6); 353 + ctx.textAlign = 'left'; ctx.fillText('coverage %', pad.l, 14); 354 + } 355 + 356 + // --- dashboard render --- 357 + 205 358 function render(data) { 206 359 if (!data) return '<p class="empty">no runs yet</p>'; 207 360 ··· 353 506 } 354 507 355 508 async function init() { 509 + // load trend in parallel with run data 510 + fetch('/api/trend').then(r => r.json()).then(d => { _trendData = d; drawTrend(d); }).catch(() => {}); 511 + 356 512 const runs = await fetch('/api/runs').then(r => r.json()); 357 513 const nav = document.getElementById('runs-nav'); 358 514 ··· 372 528 } 373 529 374 530 init(); 531 + 532 + // cache trend data for resize redraws 533 + let _trendData = null; 534 + window.addEventListener('resize', () => { if (_trendData) drawTrend(_trendData); }); 375 535 </script> 376 536 </body> 377 537 </html>
+38
relay-eval/src/store.zig
··· 247 247 return null; 248 248 } 249 249 250 + pub const TrendPoint = struct { 251 + run_id: i64, 252 + timestamp: []const u8, 253 + union_dids: i32, 254 + host: []const u8, 255 + unique_dids: i32, 256 + }; 257 + 258 + /// get coverage data across recent runs for trend visualization 259 + pub fn getTrendData(self: *Store, allocator: std.mem.Allocator, limit: u32) ![]TrendPoint { 260 + const sql = 261 + \\SELECT r.id, r.timestamp, r.union_dids, rs.host, rs.unique_dids 262 + \\FROM runs r 263 + \\JOIN relay_stats rs ON rs.run_id = r.id 264 + \\WHERE r.id IN (SELECT id FROM runs ORDER BY id DESC LIMIT ?) 265 + \\ORDER BY r.id ASC, rs.host 266 + ; 267 + var stmt: ?*c.sqlite3_stmt = null; 268 + if (c.sqlite3_prepare_v2(self.db, sql, -1, &stmt, null) != c.SQLITE_OK) { 269 + return self.sqlError(); 270 + } 271 + defer _ = c.sqlite3_finalize(stmt); 272 + 273 + _ = c.sqlite3_bind_int(stmt, 1, @intCast(limit)); 274 + 275 + var points: std.ArrayList(TrendPoint) = .empty; 276 + while (c.sqlite3_step(stmt) == c.SQLITE_ROW) { 277 + try points.append(allocator, .{ 278 + .run_id = c.sqlite3_column_int64(stmt, 0), 279 + .timestamp = try self.colText(allocator, stmt, 1), 280 + .union_dids = c.sqlite3_column_int(stmt, 2), 281 + .host = try self.colText(allocator, stmt, 3), 282 + .unique_dids = c.sqlite3_column_int(stmt, 4), 283 + }); 284 + } 285 + return points.toOwnedSlice(allocator); 286 + } 287 + 250 288 fn colText(self: *Store, allocator: std.mem.Allocator, stmt: ?*c.sqlite3_stmt, col: c_int) ![]const u8 { 251 289 _ = self; 252 290 const ptr = c.sqlite3_column_text(stmt, col);