polls on atproto pollz.waow.tech
atproto zig

switch to tap for firehose sync, fix vote race condition

- replace jetstream.zig/backfill.zig with tap integration
- use putRecord to update votes instead of delete+create
(avoids race condition with out-of-order firehose events)
- add upsert logic in db.insertVote with timestamp comparison
- handle "update" action in tap consumer
- reorganize docs into docs/ directory
- simplify frontend voting flow

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

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

+4
backend/fly.toml
··· 3 3 4 4 [build] 5 5 6 + [env] 7 + TAP_HOST = 'pollz-tap.internal' 8 + TAP_PORT = '2480' 9 + 6 10 [http_service] 7 11 internal_port = 3000 8 12 force_https = true
-97
backend/src/backfill.zig
··· 1 - const std = @import("std"); 2 - const mem = std.mem; 3 - const json = std.json; 4 - const Allocator = mem.Allocator; 5 - const db = @import("db.zig"); 6 - const jetstream = @import("jetstream.zig"); 7 - 8 - pub fn run(allocator: Allocator) void { 9 - std.debug.print("starting backfill from ufos-api.microcosm.blue...\n", .{}); 10 - 11 - backfillCollection(allocator, "tech.waow.poll"); 12 - backfillCollection(allocator, "tech.waow.vote"); 13 - 14 - std.debug.print("backfill complete\n", .{}); 15 - } 16 - 17 - fn backfillCollection(allocator: Allocator, collection: []const u8) void { 18 - var url_buf: [256]u8 = undefined; 19 - const url = std.fmt.bufPrint(&url_buf, "https://ufos-api.microcosm.blue/records?collection={s}", .{collection}) catch return; 20 - 21 - std.debug.print("backfill: fetching {s} from ufos\n", .{collection}); 22 - 23 - // make https request 24 - var client = std.http.Client{ .allocator = allocator }; 25 - defer client.deinit(); 26 - 27 - const uri = std.Uri.parse(url) catch return; 28 - 29 - var req = client.request(.GET, uri, .{ 30 - .headers = .{ .accept_encoding = .{ .override = "identity" } }, 31 - }) catch |err| { 32 - std.debug.print("backfill: http request error: {}\n", .{err}); 33 - return; 34 - }; 35 - defer req.deinit(); 36 - 37 - req.sendBodiless() catch |err| { 38 - std.debug.print("backfill: http send error: {}\n", .{err}); 39 - return; 40 - }; 41 - 42 - var redirect_buf: [8192]u8 = undefined; 43 - var response = req.receiveHead(&redirect_buf) catch |err| { 44 - std.debug.print("backfill: http receive error: {}\n", .{err}); 45 - return; 46 - }; 47 - 48 - if (response.head.status != .ok) { 49 - std.debug.print("backfill: http status {}\n", .{response.head.status}); 50 - return; 51 - } 52 - 53 - // read response body 54 - var reader = response.reader(&.{}); 55 - const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch |err| { 56 - std.debug.print("backfill: http read error: {}\n", .{err}); 57 - return; 58 - }; 59 - defer allocator.free(body); 60 - 61 - // parse json response - UFOs returns array of {did, collection, rkey, record} 62 - const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { 63 - std.debug.print("backfill: json parse error: {}\n", .{err}); 64 - return; 65 - }; 66 - defer parsed.deinit(); 67 - 68 - if (parsed.value != .array) return; 69 - 70 - var count: usize = 0; 71 - for (parsed.value.array.items) |item| { 72 - if (item != .object) continue; 73 - 74 - const did = item.object.get("did") orelse continue; 75 - if (did != .string) continue; 76 - 77 - const rkey = item.object.get("rkey") orelse continue; 78 - if (rkey != .string) continue; 79 - 80 - const record = item.object.get("record") orelse continue; 81 - if (record != .object) continue; 82 - 83 - // construct record uri 84 - const record_uri = std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection, rkey.string }) catch continue; 85 - defer allocator.free(record_uri); 86 - 87 - if (mem.eql(u8, collection, "tech.waow.poll")) { 88 - jetstream.processPoll(allocator, record_uri, did.string, rkey.string, record.object) catch continue; 89 - count += 1; 90 - } else if (mem.eql(u8, collection, "tech.waow.vote")) { 91 - jetstream.processVote(record_uri, did.string, record.object) catch continue; 92 - count += 1; 93 - } 94 - } 95 - 96 - std.debug.print("backfill: indexed {d} {s} records\n", .{ count, collection }); 97 - }
+12 -36
backend/src/db.zig
··· 70 70 return err; 71 71 }; 72 72 73 - conn.execNoArgs( 74 - \\CREATE TABLE IF NOT EXISTS cursor ( 75 - \\ id INTEGER PRIMARY KEY CHECK (id = 1), 76 - \\ time_us INTEGER NOT NULL 77 - \\) 78 - ) catch |err| { 79 - std.debug.print("failed to create cursor table: {}\n", .{err}); 80 - return err; 81 - }; 82 - 83 73 std.debug.print("database schema initialized\n", .{}); 84 74 } 85 75 86 - pub fn getCursor() ?i64 { 87 - mutex.lock(); 88 - defer mutex.unlock(); 89 - 90 - const row = conn.row("SELECT time_us FROM cursor WHERE id = 1", .{}) catch return null; 91 - if (row == null) return null; 92 - defer row.?.deinit(); 93 - return row.?.int(0); 94 - } 95 - 96 - pub fn saveCursor(time_us: i64) void { 97 - mutex.lock(); 98 - defer mutex.unlock(); 99 - 100 - conn.exec("INSERT OR REPLACE INTO cursor (id, time_us) VALUES (1, ?)", .{time_us}) catch |err| { 101 - std.debug.print("failed to save cursor: {}\n", .{err}); 102 - }; 103 - } 104 - 105 76 pub fn insertPoll(uri: []const u8, did: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8) !void { 106 77 mutex.lock(); 107 78 defer mutex.unlock(); ··· 119 90 mutex.lock(); 120 91 defer mutex.unlock(); 121 92 122 - // delete any existing vote by this user on this poll, then insert new one 123 - // this enforces one vote per user per poll 124 - conn.exec("DELETE FROM votes WHERE subject = ? AND voter = ?", .{ subject, voter }) catch {}; 125 - 93 + // upsert: update if exists and new vote is newer, otherwise insert 94 + // this handles out-of-order events from tap 126 95 conn.exec( 127 - "INSERT INTO votes (uri, subject, option, voter, created_at) VALUES (?, ?, ?, ?, ?)", 128 - .{ uri, subject, option, voter, created_at }, 129 - ) catch |err| { 96 + \\INSERT INTO votes (uri, subject, option, voter, created_at) 97 + \\VALUES (?, ?, ?, ?, ?) 98 + \\ON CONFLICT(subject, voter) DO UPDATE SET 99 + \\ uri = excluded.uri, 100 + \\ option = excluded.option, 101 + \\ created_at = excluded.created_at 102 + \\WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL 103 + , .{ uri, subject, option, voter, created_at }) catch |err| { 130 104 std.debug.print("db insert vote error: {}\n", .{err}); 131 105 return err; 132 106 }; ··· 149 123 mutex.lock(); 150 124 defer mutex.unlock(); 151 125 126 + // only delete if the URI matches - if a newer vote replaced this one, 127 + // the URI won't match and we should not delete 152 128 conn.exec("DELETE FROM votes WHERE uri = ?", .{uri}) catch |err| { 153 129 std.debug.print("db delete vote error: {}\n", .{err}); 154 130 };
+51 -41
backend/src/jetstream.zig backend/src/tap.zig
··· 9 9 const POLL_COLLECTION = "tech.waow.poll"; 10 10 const VOTE_COLLECTION = "tech.waow.vote"; 11 11 12 + // tap url from env or default to fly.io internal network 13 + fn getTapHost() []const u8 { 14 + return std.posix.getenv("TAP_HOST") orelse "pollz-tap.fly.dev"; 15 + } 16 + 17 + fn getTapPort() u16 { 18 + const port_str = std.posix.getenv("TAP_PORT") orelse "443"; 19 + return std.fmt.parseInt(u16, port_str, 10) catch 443; 20 + } 21 + 22 + fn useTls() bool { 23 + const port = getTapPort(); 24 + return port == 443; 25 + } 26 + 12 27 pub fn consumer(allocator: Allocator) void { 28 + // exponential backoff: 1s -> 2s -> 4s -> ... -> 60s cap 29 + var backoff: u64 = 1; 30 + const max_backoff: u64 = 60; 31 + 13 32 while (true) { 14 33 connect(allocator) catch |err| { 15 - std.debug.print("jetstream error: {}, reconnecting in 3s...\n", .{err}); 34 + std.debug.print("tap error: {}, reconnecting in {}s...\n", .{ err, backoff }); 16 35 }; 17 - posix.nanosleep(3, 0); 36 + posix.nanosleep(backoff, 0); 37 + backoff = @min(backoff * 2, max_backoff); 18 38 } 19 39 } 20 40 ··· 25 45 pub fn serverMessage(self: *Handler, data: []const u8) !void { 26 46 self.msg_count += 1; 27 47 if (self.msg_count % 100 == 1) { 28 - std.debug.print("jetstream: received {} messages\n", .{self.msg_count}); 48 + std.debug.print("tap: received {} messages\n", .{self.msg_count}); 29 49 } 30 50 processMessage(self.allocator, data) catch |err| { 31 51 std.debug.print("message processing error: {}\n", .{err}); ··· 33 53 } 34 54 35 55 pub fn close(_: *Handler) void { 36 - std.debug.print("jetstream connection closed\n", .{}); 56 + std.debug.print("tap connection closed\n", .{}); 37 57 } 38 58 }; 39 59 40 60 fn connect(allocator: Allocator) !void { 41 - const host = "jetstream1.us-east.bsky.network"; 61 + const host = getTapHost(); 62 + const port = getTapPort(); 63 + const tls = useTls(); 42 64 43 - var path_buf: [512]u8 = undefined; 65 + const path = "/channel"; 44 66 45 - // only use saved cursor if we have one (for resuming after disconnect) 46 - // otherwise start from NOW - UFOs handles backfill, Jetstream is for live events only 47 - const path = if (db.getCursor()) |cursor| 48 - std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}&cursor={d}", .{ POLL_COLLECTION, VOTE_COLLECTION, cursor }) catch "/subscribe" 49 - else 50 - std.fmt.bufPrint(&path_buf, "/subscribe?wantedCollections={s}&wantedCollections={s}", .{ POLL_COLLECTION, VOTE_COLLECTION }) catch "/subscribe"; 51 - 52 - std.debug.print("connecting to wss://{s}{s}\n", .{ host, path }); 67 + std.debug.print("connecting to {s}://{s}:{d}{s}\n", .{ if (tls) "wss" else "ws", host, port, path }); 53 68 54 69 var client = websocket.Client.init(allocator, .{ 55 70 .host = host, 56 - .port = 443, 57 - .tls = true, 71 + .port = port, 72 + .tls = tls, 58 73 }) catch |err| { 59 74 std.debug.print("websocket client init failed: {}\n", .{err}); 60 75 return err; 61 76 }; 62 77 defer client.deinit(); 63 78 64 - std.debug.print("tcp+tls connected, starting handshake...\n", .{}); 79 + std.debug.print("tcp connected, starting handshake...\n", .{}); 65 80 66 - // add Host header which is required for websocket handshake 67 - var host_header_buf: [128]u8 = undefined; 68 - const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: jetstream1.us-east.bsky.network\r\n"; 81 + var host_header_buf: [256]u8 = undefined; 82 + const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: pollz-tap.fly.dev\r\n"; 69 83 70 84 client.handshake(path, .{ .headers = host_header }) catch |err| { 71 85 std.debug.print("websocket handshake failed: {}\n", .{err}); 72 86 return err; 73 87 }; 74 88 75 - std.debug.print("jetstream connected!\n", .{}); 89 + std.debug.print("tap connected!\n", .{}); 76 90 77 91 var handler = Handler{ .allocator = allocator }; 78 92 client.readLoop(&handler) catch |err| { ··· 82 96 } 83 97 84 98 fn processMessage(allocator: Allocator, payload: []const u8) !void { 85 - // parse jetstream event 99 + // parse tap event 86 100 const parsed = json.parseFromSlice(json.Value, allocator, payload, .{}) catch return; 87 101 defer parsed.deinit(); 88 102 89 103 const root = parsed.value.object; 90 104 91 - // save cursor from event timestamp 92 - if (root.get("time_us")) |time_us_val| { 93 - if (time_us_val == .integer) { 94 - db.saveCursor(time_us_val.integer); 95 - } 96 - } 105 + // tap format: { "id": 123, "type": "record", "record": { ... } } 106 + const msg_type = root.get("type") orelse return; 107 + if (msg_type != .string) return; 97 108 98 - const kind = root.get("kind") orelse return; 99 - if (kind != .string) return; 109 + if (!mem.eql(u8, msg_type.string, "record")) return; 100 110 101 - if (!mem.eql(u8, kind.string, "commit")) return; 111 + const record_wrapper = root.get("record") orelse return; 112 + if (record_wrapper != .object) return; 102 113 103 - const commit = root.get("commit") orelse return; 104 - if (commit != .object) return; 114 + const rec = record_wrapper.object; 105 115 106 - const collection = commit.object.get("collection") orelse return; 116 + const collection = rec.get("collection") orelse return; 107 117 if (collection != .string) return; 108 118 109 - const operation = commit.object.get("operation") orelse return; 110 - if (operation != .string) return; 119 + const action = rec.get("action") orelse return; 120 + if (action != .string) return; 111 121 112 - const did = root.get("did") orelse return; 122 + const did = rec.get("did") orelse return; 113 123 if (did != .string) return; 114 124 115 - const rkey = commit.object.get("rkey") orelse return; 125 + const rkey = rec.get("rkey") orelse return; 116 126 if (rkey != .string) return; 117 127 118 128 const uri_str = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection.string, rkey.string }); 119 129 defer allocator.free(uri_str); 120 130 121 - if (mem.eql(u8, operation.string, "create")) { 122 - const record = commit.object.get("record") orelse return; 131 + if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) { 132 + const record = rec.get("record") orelse return; 123 133 if (record != .object) return; 124 134 125 135 if (mem.eql(u8, collection.string, POLL_COLLECTION)) { ··· 131 141 std.debug.print("vote processing error: {}\n", .{err}); 132 142 }; 133 143 } 134 - } else if (mem.eql(u8, operation.string, "delete")) { 144 + } else if (mem.eql(u8, action.string, "delete")) { 135 145 if (mem.eql(u8, collection.string, POLL_COLLECTION)) { 136 146 db.deletePoll(uri_str); 137 147 std.debug.print("deleted poll: {s}\n", .{uri_str});
+5 -7
backend/src/main.zig
··· 3 3 const Thread = std.Thread; 4 4 const db = @import("db.zig"); 5 5 const http_server = @import("http.zig"); 6 - const jetstream = @import("jetstream.zig"); 7 - const backfill = @import("backfill.zig"); 6 + const tap = @import("tap.zig"); 8 7 9 8 pub fn main() !void { 10 9 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; ··· 16 15 try db.init(db_path); 17 16 defer db.close(); 18 17 19 - // backfill existing records from known repos at startup 20 - backfill.run(allocator); 18 + // tap handles backfill automatically - no need to call backfill.run() 21 19 22 - // start jetstream consumer in background 23 - const jetstream_thread = try Thread.spawn(.{}, jetstream.consumer, .{allocator}); 24 - defer jetstream_thread.join(); 20 + // start tap consumer in background 21 + const tap_thread = try Thread.spawn(.{}, tap.consumer, .{allocator}); 22 + defer tap_thread.join(); 25 23 26 24 // start http server (bind to 0.0.0.0 for containerized deployments) 27 25 const address = try net.Address.parseIp("0.0.0.0", 3000);
-137
backend/zignotes.md
··· 1 - # zig 0.15 notes 2 - 3 - ## breaking changes from 0.14 4 - 5 - ### `json.stringify` → `json.fmt` 6 - ```zig 7 - // old: json.stringify(value, .{}, writer); 8 - // new: use json.fmt formatter 9 - try buffer.print(allocator, "{f}", .{json.fmt(value, .{})}); 10 - ``` 11 - 12 - ### `std.builtin.Mode` → `std.builtin.OptimizeMode` 13 - the optimization mode enum was renamed 14 - 15 - ### `std.time.sleep` removed 16 - use `std.posix.nanosleep(seconds, nanoseconds)` instead 17 - 18 - ### `std.ArrayList` is now unmanaged by default 19 - the old `ArrayList` with embedded allocator is now `array_list.AlignedManaged`. 20 - the new `std.ArrayList(T)` returns an unmanaged struct that: 21 - - doesn't store the allocator internally 22 - - requires passing allocator to methods like `appendSlice(alloc, slice)` 23 - - initializes with `.{}` or `.empty` instead of `.init(allocator)` 24 - - `deinit(alloc)` takes allocator as argument 25 - 26 - ```zig 27 - // old 0.14 style: 28 - var list = std.ArrayList(u8).init(allocator); 29 - try list.appendSlice("hello"); 30 - list.deinit(); 31 - 32 - // new 0.15 style: 33 - var list: std.ArrayList(u8) = .{}; 34 - try list.appendSlice(allocator, "hello"); 35 - list.deinit(allocator); 36 - ``` 37 - 38 - ### build.zig changes 39 - - `root_source_file` → `root_module` with `b.createModule(...)` 40 - - `strip` field removed from `ExecutableOptions` 41 - - fingerprint field required in `build.zig.zon` 42 - 43 - ### `std.http.Server` API 44 - requires `*std.Io.Reader` and `*std.Io.Writer`, not raw `net.Stream` 45 - 46 - ## net.Stream → http.Server 47 - 48 - ```zig 49 - var read_buffer: [8192]u8 = undefined; 50 - var write_buffer: [8192]u8 = undefined; 51 - 52 - var reader = conn.stream.reader(&read_buffer); 53 - var writer = conn.stream.writer(&write_buffer); 54 - 55 - // reader has .interface() method that returns *Io.Reader 56 - // writer has .interface field that is Io.Writer 57 - var server = http.Server.init(reader.interface(), &writer.interface); 58 - ``` 59 - 60 - ### http.Server.Request.respond 61 - the `respond` method is on `Request`, not `Server`: 62 - ```zig 63 - try request.respond(body, .{ 64 - .status = .ok, 65 - .extra_headers = &.{ 66 - .{ .name = "content-type", .value = "application/json" }, 67 - }, 68 - }); 69 - ``` 70 - 71 - ### `std.Uri.percentDecode` → `std.Uri.percentDecodeInPlace` 72 - there's no allocating `percentDecode` anymore. use in-place decoding: 73 - ```zig 74 - // copy to mutable buffer first, then decode in place 75 - const uri_buf = try alloc.dupe(u8, uri_encoded); 76 - const uri = std.Uri.percentDecodeInPlace(uri_buf); 77 - ``` 78 - 79 - ### `std.http.Client` for outgoing requests 80 - ```zig 81 - var client = std.http.Client{ .allocator = allocator }; 82 - defer client.deinit(); 83 - 84 - const uri = std.Uri.parse("https://example.com/api") catch return; 85 - 86 - // use .headers to control accept-encoding (default is gzip/deflate) 87 - var req = client.request(.GET, uri, .{ 88 - .headers = .{ .accept_encoding = .{ .override = "identity" } }, 89 - }) catch return; 90 - defer req.deinit(); 91 - 92 - req.sendBodiless() catch return; 93 - 94 - var redirect_buf: [8192]u8 = undefined; 95 - var response = req.receiveHead(&redirect_buf) catch return; 96 - 97 - if (response.head.status != .ok) return; 98 - 99 - // read response body - use allocRemaining with Limit 100 - var reader = response.reader(&.{}); 101 - const body = reader.allocRemaining(allocator, std.Io.Limit.limited(65536)) catch return; 102 - defer allocator.free(body); 103 - ``` 104 - 105 - ## external libraries 106 - 107 - ### websocket.zig (karlseguin/websocket.zig) 108 - use for websocket client/server. add to `build.zig.zon`: 109 - ```zig 110 - .dependencies = .{ 111 - .websocket = .{ 112 - .url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz", 113 - .hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ", 114 - }, 115 - }, 116 - ``` 117 - 118 - client usage: 119 - ```zig 120 - const websocket = @import("websocket"); 121 - 122 - var client = try websocket.Client.init(allocator, .{ 123 - .host = "example.com", 124 - .port = 443, 125 - .tls = true, 126 - }); 127 - defer client.deinit(); 128 - 129 - // Host header is NOT automatically added - must provide it 130 - client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| { 131 - // handle error 132 - }; 133 - 134 - // handler must have serverMessage(self, data) function 135 - var handler = MyHandler{}; 136 - try client.readLoop(&handler); 137 - ```
+126
docs/architecture.md
··· 1 + # pollz architecture 2 + 3 + ## overview 4 + 5 + pollz is a polling app built on atproto. users create polls and vote using their bluesky accounts. 6 + 7 + ``` 8 + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 9 + │ frontend │────▶│ backend │◀────│ tap │ 10 + │ (vite/ts) │ │ (zig) │ │ (go) │ 11 + │ cloudflare │ │ fly.io │ │ fly.io │ 12 + └─────────────┘ └─────────────┘ └─────────────┘ 13 + │ │ │ 14 + │ ▼ │ 15 + │ ┌─────────────┐ │ 16 + │ │ sqlite │ │ 17 + │ │ (fly vol) │ │ 18 + │ └─────────────┘ │ 19 + │ │ 20 + ▼ ▼ 21 + ┌─────────────┐ ┌─────────────┐ 22 + │ user PDS │ │ firehose │ 23 + │ (bsky.social) │ (relay) │ 24 + └─────────────┘ └─────────────┘ 25 + ``` 26 + 27 + ## components 28 + 29 + ### frontend (src/) 30 + - vanilla typescript with vite 31 + - oauth via @atcute/oauth-browser-client 32 + - writes polls/votes directly to user's PDS 33 + - fetches poll data from backend API 34 + 35 + ### backend (backend/) 36 + - zig http server 37 + - sqlite for persistence 38 + - consumes events from tap via websocket 39 + - serves REST API for frontend 40 + 41 + ### tap (tap/) 42 + - bluesky's official atproto sync utility 43 + - handles firehose connection, backfill, cursor management 44 + - filters for `tech.waow.poll` and `tech.waow.vote` collections 45 + - delivers events to backend via websocket 46 + 47 + ## data flow 48 + 49 + ### creating a poll 50 + 1. user logs in via oauth 51 + 2. frontend calls `com.atproto.repo.createRecord` on user's PDS 52 + 3. PDS broadcasts to relay/firehose 53 + 4. tap receives event, forwards to backend 54 + 5. backend inserts poll into sqlite 55 + 56 + ### voting 57 + 1. frontend checks if user has existing vote on this poll 58 + 2. if exists: `com.atproto.repo.putRecord` (update) 59 + 3. if not: `com.atproto.repo.createRecord` (create) 60 + 4. tap receives event, forwards to backend 61 + 5. backend upserts vote (one vote per user per poll) 62 + 63 + ### reading polls 64 + 1. frontend fetches `/api/polls` from backend 65 + 2. backend queries sqlite, returns polls with vote counts 66 + 3. frontend renders poll list 67 + 68 + ## lexicons 69 + 70 + ### tech.waow.poll 71 + ```json 72 + { 73 + "$type": "tech.waow.poll", 74 + "text": "what's the best language?", 75 + "options": ["rust", "zig", "go"], 76 + "createdAt": "2024-01-01T00:00:00.000Z" 77 + } 78 + ``` 79 + 80 + ### tech.waow.vote 81 + ```json 82 + { 83 + "$type": "tech.waow.vote", 84 + "subject": "at://did:plc:.../tech.waow.poll/...", 85 + "option": 0, 86 + "createdAt": "2024-01-01T00:00:00.000Z" 87 + } 88 + ``` 89 + 90 + ## key lessons learned 91 + 92 + ### vote updates, not delete+create 93 + when changing a vote, use `putRecord` to update the existing record rather than deleting and creating. this avoids race conditions where tap receives events out of order (create then delete) causing the vote to disappear. 94 + 95 + ### tap event ordering 96 + tap delivers events in the order they're received from the firehose, but the firehose itself can deliver events out of order. the backend must handle this gracefully: 97 + - `insertVote` uses upsert with timestamp comparison 98 + - only updates if the incoming vote is newer than existing 99 + 100 + ### one vote per user per poll 101 + enforced at multiple levels: 102 + - frontend: checks for existing vote before creating 103 + - backend: `UNIQUE(subject, voter)` constraint 104 + - backend: upsert logic in `insertVote` 105 + 106 + ## deployment 107 + 108 + ### fly.io apps 109 + - `pollz-backend` - zig backend with sqlite volume 110 + - `pollz-tap` - tap instance with sqlite volume 111 + 112 + ### cloudflare pages 113 + - frontend static files 114 + - oauth client metadata at `/oauth-client-metadata.json` 115 + 116 + ### environment variables 117 + 118 + backend: 119 + - `TAP_HOST` - tap hostname (default: pollz-tap.internal) 120 + - `TAP_PORT` - tap port (default: 2480) 121 + - `DATA_PATH` - sqlite db path (default: /data/pollz.db) 122 + 123 + tap: 124 + - `TAP_DATABASE_URL` - sqlite path 125 + - `TAP_COLLECTION_FILTERS` - collections to track 126 + - `TAP_SIGNAL_COLLECTION` - collection for auto-discovery
+122
docs/tap.md
··· 1 + # tap integration 2 + 3 + tap is bluesky's official atproto sync utility. pollz uses it to receive real-time events from the firehose. 4 + 5 + ## what tap provides 6 + 7 + - firehose connection with automatic reconnection 8 + - signature verification of repo structure and identity 9 + - automatic backfill when adding new repos 10 + - filtered output by collection 11 + - ordering guarantees - backfill completes before live events 12 + - cursor management - persists automatically, resumes on restart 13 + 14 + ## pollz tap configuration 15 + 16 + ```toml 17 + # tap/fly.toml 18 + [env] 19 + TAP_COLLECTION_FILTERS = "tech.waow.poll,tech.waow.vote" 20 + TAP_SIGNAL_COLLECTION = "tech.waow.poll" 21 + TAP_DATABASE_URL = "sqlite:///data/tap.db" 22 + TAP_DISABLE_ACKS = "true" 23 + ``` 24 + 25 + `TAP_SIGNAL_COLLECTION` makes tap automatically discover and track all repos that have ever created a poll. 26 + 27 + ## event format 28 + 29 + tap delivers events via websocket at `/channel`: 30 + 31 + ```json 32 + { 33 + "id": 12345, 34 + "type": "record", 35 + "record": { 36 + "live": true, 37 + "did": "did:plc:abc123", 38 + "collection": "tech.waow.poll", 39 + "rkey": "3kb3fge5lm32x", 40 + "action": "create", 41 + "record": { 42 + "text": "what's your favorite color?", 43 + "options": ["red", "blue", "green"], 44 + "$type": "tech.waow.poll", 45 + "createdAt": "2024-10-07T12:00:00.000Z" 46 + } 47 + } 48 + } 49 + ``` 50 + 51 + ### action types 52 + - `create` - new record created 53 + - `update` - existing record updated (same rkey) 54 + - `delete` - record deleted 55 + 56 + ## backend tap consumer 57 + 58 + the backend connects to tap via websocket and processes events: 59 + 60 + ```zig 61 + // tap.zig 62 + if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) { 63 + // process poll or vote 64 + } else if (mem.eql(u8, action.string, "delete")) { 65 + // delete poll or vote 66 + } 67 + ``` 68 + 69 + ## handling out-of-order events 70 + 71 + tap delivers events in firehose order, but the firehose itself can deliver events out of order. example: 72 + 73 + 1. user deletes old vote, creates new vote 74 + 2. firehose delivers: create (new), delete (old) 75 + 3. if backend processes delete after create, the new vote disappears 76 + 77 + ### solution: use putRecord instead of delete+create 78 + 79 + when changing a vote, the frontend uses `putRecord` to update the existing record: 80 + 81 + ```typescript 82 + // api.ts 83 + if (existingRkey) { 84 + // update existing vote - single "update" event 85 + await rpc.post("com.atproto.repo.putRecord", { ... }); 86 + } else { 87 + // create new vote 88 + await rpc.post("com.atproto.repo.createRecord", { ... }); 89 + } 90 + ``` 91 + 92 + this results in a single "update" event instead of separate "delete" and "create" events, eliminating the race condition. 93 + 94 + ### backend upsert logic 95 + 96 + as additional protection, `insertVote` uses upsert with timestamp comparison: 97 + 98 + ```sql 99 + INSERT INTO votes (uri, subject, option, voter, created_at) 100 + VALUES (?, ?, ?, ?, ?) 101 + ON CONFLICT(subject, voter) DO UPDATE SET 102 + uri = excluded.uri, 103 + option = excluded.option, 104 + created_at = excluded.created_at 105 + WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL 106 + ``` 107 + 108 + this ensures that if out-of-order events do occur, older events don't overwrite newer ones. 109 + 110 + ## deployment 111 + 112 + tap runs as a separate fly.io app (`pollz-tap`) and communicates with the backend over fly's internal network: 113 + 114 + ``` 115 + pollz-tap.internal:2480 → pollz-backend 116 + ``` 117 + 118 + ## further reading 119 + 120 + - [tap README](https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md) 121 + - [indigo repo](https://github.com/bluesky-social/indigo) 122 + - [bailey's tap guide](https://marvins-guide.leaflet.pub/3m7ttuppfzc23)
+126
docs/zig.md
··· 1 + # zig 0.15 notes 2 + 3 + reference for zig 0.15 patterns used in the backend. 4 + 5 + ## breaking changes from 0.14 6 + 7 + ### json.stringify → json.fmt 8 + ```zig 9 + // old: json.stringify(value, .{}, writer); 10 + // new: use json.fmt formatter 11 + try buffer.print(allocator, "{f}", .{json.fmt(value, .{})}); 12 + ``` 13 + 14 + ### std.ArrayList is unmanaged by default 15 + ```zig 16 + // old 0.14 style: 17 + var list = std.ArrayList(u8).init(allocator); 18 + try list.appendSlice("hello"); 19 + list.deinit(); 20 + 21 + // new 0.15 style: 22 + var list: std.ArrayList(u8) = .{}; 23 + try list.appendSlice(allocator, "hello"); 24 + list.deinit(allocator); 25 + ``` 26 + 27 + ### std.time.sleep removed 28 + ```zig 29 + // use posix.nanosleep instead 30 + std.posix.nanosleep(seconds, nanoseconds); 31 + ``` 32 + 33 + ### std.Uri.percentDecode → percentDecodeInPlace 34 + ```zig 35 + // copy to mutable buffer first, then decode in place 36 + const uri_buf = try alloc.dupe(u8, uri_encoded); 37 + const uri = std.Uri.percentDecodeInPlace(uri_buf); 38 + ``` 39 + 40 + ## http server patterns 41 + 42 + ### net.Stream → http.Server 43 + ```zig 44 + var read_buffer: [8192]u8 = undefined; 45 + var write_buffer: [8192]u8 = undefined; 46 + 47 + var reader = conn.stream.reader(&read_buffer); 48 + var writer = conn.stream.writer(&write_buffer); 49 + 50 + var server = http.Server.init(reader.interface(), &writer.interface); 51 + ``` 52 + 53 + ### responding to requests 54 + ```zig 55 + try request.respond(body, .{ 56 + .status = .ok, 57 + .extra_headers = &.{ 58 + .{ .name = "content-type", .value = "application/json" }, 59 + }, 60 + }); 61 + ``` 62 + 63 + ## websocket client (karlseguin/websocket.zig) 64 + 65 + ```zig 66 + const websocket = @import("websocket"); 67 + 68 + var client = try websocket.Client.init(allocator, .{ 69 + .host = "example.com", 70 + .port = 443, 71 + .tls = true, 72 + }); 73 + defer client.deinit(); 74 + 75 + // Host header must be provided manually 76 + client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| { 77 + // handle error 78 + }; 79 + 80 + // handler must have serverMessage(self, data) function 81 + var handler = MyHandler{}; 82 + try client.readLoop(&handler); 83 + ``` 84 + 85 + ## sqlite patterns (zqlite) 86 + 87 + ### prepared statements with bind 88 + ```zig 89 + var stmt = conn.prepare("SELECT * FROM votes WHERE uri = ?") catch return; 90 + defer stmt.deinit(); 91 + 92 + const row = stmt.bind(.{uri}).step() catch return; 93 + if (row) |r| { 94 + const subject = r[0].?.text; 95 + // ... 96 + } 97 + ``` 98 + 99 + ### upsert with ON CONFLICT 100 + ```zig 101 + conn.exec( 102 + \\INSERT INTO votes (uri, subject, option, voter, created_at) 103 + \\VALUES (?, ?, ?, ?, ?) 104 + \\ON CONFLICT(subject, voter) DO UPDATE SET 105 + \\ uri = excluded.uri, 106 + \\ option = excluded.option, 107 + \\ created_at = excluded.created_at 108 + \\WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL 109 + , .{ uri, subject, option, voter, created_at }) catch |err| { 110 + // handle error 111 + }; 112 + ``` 113 + 114 + ## build.zig.zon 115 + 116 + ```zig 117 + .{ 118 + .name = .pollz, 119 + .version = "0.0.0", 120 + .fingerprint = 0x..., // required in 0.15 121 + .dependencies = .{ 122 + .zqlite = .{ ... }, 123 + .websocket = .{ ... }, 124 + }, 125 + } 126 + ```
+6 -1
justfile
··· 1 1 # pollz 2 2 mod backend 3 + mod tap 3 4 4 5 # show available commands 5 6 default: ··· 18 19 deploy-backend: 19 20 just backend::deploy 20 21 22 + # deploy tap to fly.io 23 + deploy-tap: 24 + just tap::deploy 25 + 21 26 # deploy everything 22 - deploy: deploy-backend deploy-frontend 27 + deploy: deploy-tap deploy-backend deploy-frontend
+312
src/lib/api.ts
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { 3 + CompositeDidDocumentResolver, 4 + CompositeHandleResolver, 5 + DohJsonHandleResolver, 6 + PlcDidDocumentResolver, 7 + AtprotoWebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + } from "@atcute/identity-resolver"; 10 + import { 11 + configureOAuth, 12 + createAuthorizationUrl, 13 + defaultIdentityResolver, 14 + finalizeAuthorization, 15 + getSession, 16 + OAuthUserAgent, 17 + deleteStoredSession, 18 + } from "@atcute/oauth-browser-client"; 19 + 20 + export const POLL = "tech.waow.poll"; 21 + export const VOTE = "tech.waow.vote"; 22 + 23 + export const didDocumentResolver = new CompositeDidDocumentResolver({ 24 + methods: { 25 + plc: new PlcDidDocumentResolver(), 26 + web: new AtprotoWebDidDocumentResolver(), 27 + }, 28 + }); 29 + 30 + export const handleResolver = new CompositeHandleResolver({ 31 + strategy: "dns-first", 32 + methods: { 33 + dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }), 34 + http: new WellKnownHandleResolver(), 35 + }, 36 + }); 37 + 38 + const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech"; 39 + export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev"; 40 + 41 + configureOAuth({ 42 + metadata: { 43 + client_id: `${BASE_URL}/oauth-client-metadata.json`, 44 + redirect_uri: `${BASE_URL}/`, 45 + }, 46 + identityResolver: defaultIdentityResolver({ 47 + handleResolver, 48 + didDocumentResolver, 49 + }), 50 + }); 51 + 52 + // state 53 + export let agent: OAuthUserAgent | null = null; 54 + export let currentDid: string | null = null; 55 + 56 + export const setAgent = (a: OAuthUserAgent | null) => { agent = a; }; 57 + export const setCurrentDid = (did: string | null) => { currentDid = did; }; 58 + 59 + export type Poll = { 60 + uri: string; 61 + repo: string; 62 + rkey: string; 63 + text: string; 64 + options: string[]; 65 + createdAt: string; 66 + votes: Map<string, number>; 67 + voteCount?: number; 68 + }; 69 + 70 + export const polls = new Map<string, Poll>(); 71 + 72 + // oauth 73 + export const login = async (handle: string): Promise<void> => { 74 + const url = await createAuthorizationUrl({ 75 + scope: `atproto repo:${POLL} repo:${VOTE}`, 76 + target: { type: "account", identifier: handle }, 77 + }); 78 + location.assign(url); 79 + }; 80 + 81 + export const logout = async (): Promise<void> => { 82 + if (currentDid) { 83 + await deleteStoredSession(currentDid as `did:${string}:${string}`); 84 + localStorage.removeItem("lastDid"); 85 + } 86 + agent = null; 87 + currentDid = null; 88 + }; 89 + 90 + export const handleCallback = async (): Promise<boolean> => { 91 + const params = new URLSearchParams(location.hash.slice(1)); 92 + if (!params.has("state")) return false; 93 + 94 + history.replaceState(null, "", "/"); 95 + const { session } = await finalizeAuthorization(params); 96 + agent = new OAuthUserAgent(session); 97 + currentDid = session.info.sub; 98 + localStorage.setItem("lastDid", currentDid); 99 + return true; 100 + }; 101 + 102 + export const restoreSession = async (): Promise<void> => { 103 + const lastDid = localStorage.getItem("lastDid"); 104 + if (!lastDid) return; 105 + 106 + try { 107 + const session = await getSession(lastDid as `did:${string}:${string}`); 108 + agent = new OAuthUserAgent(session); 109 + currentDid = session.info.sub; 110 + } catch { 111 + localStorage.removeItem("lastDid"); 112 + } 113 + }; 114 + 115 + // backend api 116 + export const fetchPolls = async (): Promise<void> => { 117 + const res = await fetch(`${BACKEND_URL}/api/polls`); 118 + if (!res.ok) throw new Error("failed to fetch polls"); 119 + 120 + const backendPolls = await res.json() as Array<{ 121 + uri: string; 122 + repo: string; 123 + rkey: string; 124 + text: string; 125 + options: string[]; 126 + createdAt: string; 127 + voteCount: number; 128 + }>; 129 + 130 + for (const p of backendPolls) { 131 + const existing = polls.get(p.uri); 132 + if (existing) { 133 + existing.voteCount = p.voteCount; 134 + } else { 135 + polls.set(p.uri, { 136 + uri: p.uri, 137 + repo: p.repo, 138 + rkey: p.rkey, 139 + text: p.text, 140 + options: p.options, 141 + createdAt: p.createdAt, 142 + votes: new Map(), 143 + voteCount: p.voteCount, 144 + }); 145 + } 146 + } 147 + }; 148 + 149 + export const fetchPoll = async (uri: string) => { 150 + const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`); 151 + if (!res.ok) return null; 152 + return res.json() as Promise<{ 153 + uri: string; 154 + repo: string; 155 + rkey: string; 156 + text: string; 157 + options: Array<{ text: string; count: number }>; 158 + createdAt: string; 159 + }>; 160 + }; 161 + 162 + export const fetchVoters = async (pollUri: string) => { 163 + const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`); 164 + if (!res.ok) return []; 165 + return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>; 166 + }; 167 + 168 + // user votes 169 + export const loadUserVotes = async (): Promise<void> => { 170 + if (!agent || !currentDid) return; 171 + 172 + try { 173 + const rpc = new Client({ handler: agent }); 174 + const res = await rpc.get("com.atproto.repo.listRecords", { 175 + params: { repo: currentDid, collection: VOTE, limit: 100 }, 176 + }); 177 + 178 + if (res.ok) { 179 + for (const record of res.data.records) { 180 + const val = record.value as { subject?: string; option?: number }; 181 + if (val.subject && typeof val.option === "number") { 182 + const poll = polls.get(val.subject); 183 + if (poll) { 184 + poll.votes.set(record.uri, val.option); 185 + } 186 + } 187 + } 188 + } 189 + } catch (e) { 190 + console.error("failed to load user votes:", e); 191 + } 192 + }; 193 + 194 + // create poll 195 + export const createPoll = async (text: string, options: string[]): Promise<string | null> => { 196 + if (!agent || !currentDid) return null; 197 + 198 + const rpc = new Client({ handler: agent }); 199 + const res = await rpc.post("com.atproto.repo.createRecord", { 200 + input: { 201 + repo: currentDid, 202 + collection: POLL, 203 + record: { $type: POLL, text, options, createdAt: new Date().toISOString() }, 204 + }, 205 + }); 206 + 207 + if (!res.ok) throw new Error(res.data.error || "failed to create poll"); 208 + 209 + const rkey = res.data.uri.split("/").pop()!; 210 + polls.set(res.data.uri, { 211 + uri: res.data.uri, 212 + repo: currentDid, 213 + rkey, 214 + text, 215 + options, 216 + createdAt: new Date().toISOString(), 217 + votes: new Map(), 218 + }); 219 + 220 + return res.data.uri; 221 + }; 222 + 223 + // vote - creates or updates vote record on user's PDS 224 + export const vote = async (pollUri: string, option: number): Promise<void> => { 225 + if (!agent || !currentDid) throw new Error("not logged in"); 226 + 227 + const rpc = new Client({ handler: agent }); 228 + 229 + // check if we already have a vote on this poll 230 + const existing = await rpc.get("com.atproto.repo.listRecords", { 231 + params: { repo: currentDid, collection: VOTE, limit: 100 }, 232 + }); 233 + 234 + let existingRkey: string | null = null; 235 + if (existing.ok) { 236 + for (const record of existing.data.records) { 237 + const val = record.value as { subject?: string }; 238 + if (val.subject === pollUri) { 239 + existingRkey = record.uri.split("/").pop()!; 240 + break; 241 + } 242 + } 243 + } 244 + 245 + if (existingRkey) { 246 + // update existing vote 247 + const res = await rpc.post("com.atproto.repo.putRecord", { 248 + input: { 249 + repo: currentDid, 250 + collection: VOTE, 251 + rkey: existingRkey, 252 + record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 253 + }, 254 + }); 255 + if (!res.ok) throw new Error(res.data.error || res.data.message || "vote update failed"); 256 + } else { 257 + // create new vote 258 + const res = await rpc.post("com.atproto.repo.createRecord", { 259 + input: { 260 + repo: currentDid, 261 + collection: VOTE, 262 + record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 263 + }, 264 + }); 265 + if (!res.ok) throw new Error(res.data.error || res.data.message || "vote failed"); 266 + } 267 + }; 268 + 269 + // resolve handle from DID 270 + const handleCache = new Map<string, string>(); 271 + 272 + export const resolveHandle = async (did: string): Promise<string> => { 273 + if (handleCache.has(did)) return handleCache.get(did)!; 274 + try { 275 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 276 + if (res.ok) { 277 + const data = await res.json(); 278 + if (data.handle) { 279 + handleCache.set(did, data.handle); 280 + return data.handle; 281 + } 282 + } 283 + } catch {} 284 + return did; 285 + }; 286 + 287 + // fetch poll directly from PDS (fallback) 288 + export const fetchPollFromPDS = async (repo: string, rkey: string) => { 289 + const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`); 290 + const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined; 291 + const pdsUrl = pds?.serviceEndpoint || "https://bsky.social"; 292 + 293 + const pdsClient = new Client({ 294 + handler: simpleFetchHandler({ service: pdsUrl }), 295 + }); 296 + 297 + const res = await pdsClient.get("com.atproto.repo.getRecord", { 298 + params: { repo, collection: POLL, rkey }, 299 + }); 300 + 301 + if (!res.ok) return null; 302 + 303 + const rec = res.data.value as { text: string; options: string[]; createdAt: string }; 304 + return { 305 + uri: res.data.uri, 306 + repo, 307 + rkey, 308 + text: rec.text, 309 + options: rec.options, 310 + createdAt: rec.createdAt, 311 + }; 312 + };
+16
src/lib/utils.ts
··· 1 + // html escaping 2 + export const esc = (s: string) => 3 + s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 4 + 5 + // relative time 6 + export const ago = (date: string) => { 7 + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); 8 + if (seconds < 60) return "just now"; 9 + const minutes = Math.floor(seconds / 60); 10 + if (minutes < 60) return `${minutes}m ago`; 11 + const hours = Math.floor(minutes / 60); 12 + if (hours < 24) return `${hours}h ago`; 13 + const days = Math.floor(hours / 24); 14 + if (days < 30) return `${days}d ago`; 15 + return new Date(date).toLocaleDateString(); 16 + };
+159 -362
src/main.ts
··· 1 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 1 import { 3 - CompositeDidDocumentResolver, 4 - CompositeHandleResolver, 5 - DohJsonHandleResolver, 6 - PlcDidDocumentResolver, 7 - AtprotoWebDidDocumentResolver, 8 - WellKnownHandleResolver, 9 - } from "@atcute/identity-resolver"; 10 - import { 11 - configureOAuth, 12 - createAuthorizationUrl, 13 - defaultIdentityResolver, 14 - finalizeAuthorization, 15 - getSession, 16 - OAuthUserAgent, 17 - deleteStoredSession, 18 - } from "@atcute/oauth-browser-client"; 19 - 20 - const POLL = "tech.waow.poll"; 21 - const VOTE = "tech.waow.vote"; 22 - 23 - const didDocumentResolver = new CompositeDidDocumentResolver({ 24 - methods: { 25 - plc: new PlcDidDocumentResolver(), 26 - web: new AtprotoWebDidDocumentResolver(), 27 - }, 28 - }); 29 - 30 - const handleResolver = new CompositeHandleResolver({ 31 - strategy: "dns-first", 32 - methods: { 33 - dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }), 34 - http: new WellKnownHandleResolver(), 35 - }, 36 - }); 37 - 38 - const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech"; 39 - 40 - configureOAuth({ 41 - metadata: { 42 - client_id: `${BASE_URL}/oauth-client-metadata.json`, 43 - redirect_uri: `${BASE_URL}/`, 44 - }, 45 - identityResolver: defaultIdentityResolver({ 46 - handleResolver, 47 - didDocumentResolver, 48 - }), 49 - }); 2 + POLL, 3 + VOTE, 4 + agent, 5 + currentDid, 6 + setAgent, 7 + setCurrentDid, 8 + polls, 9 + login, 10 + logout, 11 + handleCallback, 12 + restoreSession, 13 + fetchPolls, 14 + fetchPoll, 15 + fetchVoters, 16 + loadUserVotes, 17 + createPoll, 18 + vote, 19 + resolveHandle, 20 + fetchPollFromPDS, 21 + type Poll, 22 + } from "./lib/api"; 23 + import { esc, ago } from "./lib/utils"; 50 24 51 25 const app = document.getElementById("app")!; 52 26 const nav = document.getElementById("nav")!; 53 27 const status = document.getElementById("status")!; 54 28 55 - let agent: OAuthUserAgent | null = null; 56 - let currentDid: string | null = null; 57 - let jetstream: WebSocket | null = null; 58 - 59 29 const setStatus = (msg: string) => (status.textContent = msg); 60 30 61 - type Poll = { 62 - uri: string; 63 - repo: string; 64 - rkey: string; 65 - text: string; 66 - options: string[]; 67 - createdAt: string; 68 - votes: Map<string, number>; 69 - voteCount?: number; // from backend, used when votes map is empty 70 - }; 31 + // track if a vote is in progress to prevent double-clicks 32 + let votingInProgress = false; 71 33 72 - const polls = new Map<string, Poll>(); 34 + // jetstream - replay last 24h on connect, then live updates 35 + let jetstream: WebSocket | null = null; 73 36 74 - // jetstream - replay last 24h on connect, then live updates 75 37 const connectJetstream = () => { 76 38 if (jetstream?.readyState === WebSocket.OPEN) return; 77 39 78 - // cursor is microseconds since epoch - go back 24 hours 79 40 const cursor = (Date.now() - 24 * 60 * 60 * 1000) * 1000; 80 41 const url = `wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=${POLL}&wantedCollections=${VOTE}&cursor=${cursor}`; 81 42 jetstream = new WebSocket(url); ··· 113 74 render(); 114 75 } 115 76 } else if (commit.operation === "delete") { 116 - // find and remove vote from its poll 117 77 for (const poll of polls.values()) { 118 78 if (poll.votes.has(uri)) { 119 79 poll.votes.delete(uri); ··· 136 96 const match = path.match(/^\/poll\/([^/]+)\/([^/]+)$/); 137 97 138 98 if (match) { 139 - renderPoll(match[1], match[2]); 99 + renderPollPage(match[1], match[2]); 140 100 } else if (path === "/new") { 141 101 renderCreate(); 102 + } else if (path === "/mine") { 103 + renderHome(true); 142 104 } else { 143 - renderHome(); 105 + renderHome(false); 144 106 } 145 107 }; 146 108 147 109 const renderNav = () => { 148 110 if (agent) { 149 - nav.innerHTML = `<a href="/">my polls</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`; 111 + nav.innerHTML = `<a href="/">all</a> · <a href="/mine">mine</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`; 150 112 document.getElementById("logout")!.onclick = async (e) => { 151 113 e.preventDefault(); 152 - if (currentDid) { 153 - await deleteStoredSession(currentDid as `did:${string}:${string}`); 154 - localStorage.removeItem("lastDid"); 155 - } 156 - agent = null; 157 - currentDid = null; 114 + await logout(); 115 + setAgent(null); 116 + setCurrentDid(null); 158 117 render(); 159 118 }; 160 119 } else { ··· 164 123 if (!handle) return; 165 124 setStatus("redirecting..."); 166 125 try { 167 - const url = await createAuthorizationUrl({ 168 - scope: `atproto repo:${POLL} repo:${VOTE}`, 169 - target: { type: "account", identifier: handle }, 170 - }); 171 - location.assign(url); 126 + await login(handle); 172 127 } catch (e) { 173 128 setStatus(`error: ${e}`); 174 129 } ··· 176 131 } 177 132 }; 178 133 179 - const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev"; 180 - 181 - // fetch user's existing votes from their PDS 182 - const loadUserVotes = async () => { 183 - if (!agent || !currentDid) return; 184 - 185 - try { 186 - const rpc = new Client({ handler: agent }); 187 - const res = await rpc.get("com.atproto.repo.listRecords", { 188 - params: { repo: currentDid, collection: VOTE, limit: 100 }, 189 - }); 190 - 191 - if (res.ok) { 192 - for (const record of res.data.records) { 193 - const val = record.value as { subject?: string; option?: number }; 194 - if (val.subject && typeof val.option === "number") { 195 - const poll = polls.get(val.subject); 196 - if (poll) { 197 - poll.votes.set(record.uri, val.option); 198 - } 199 - } 200 - } 201 - } 202 - } catch (e) { 203 - console.error("failed to load user votes:", e); 204 - } 205 - }; 206 - 207 - const renderHome = async () => { 134 + const renderHome = async (mineOnly: boolean = false) => { 208 135 app.innerHTML = "<p>loading polls...</p>"; 209 136 210 137 try { 211 - // fetch all polls from backend 212 - const res = await fetch(`${BACKEND_URL}/api/polls`); 213 - if (!res.ok) throw new Error("failed to fetch polls"); 138 + await fetchPolls(); 139 + await loadUserVotes(); 214 140 215 - const backendPolls = await res.json() as Array<{ 216 - uri: string; 217 - repo: string; 218 - rkey: string; 219 - text: string; 220 - options: string[]; 221 - createdAt: string; 222 - voteCount: number; 223 - }>; 224 - 225 - // merge into local state 226 - for (const p of backendPolls) { 227 - const existing = polls.get(p.uri); 228 - if (existing) { 229 - // update vote count from backend 230 - existing.voteCount = p.voteCount; 231 - } else { 232 - polls.set(p.uri, { 233 - uri: p.uri, 234 - repo: p.repo, 235 - rkey: p.rkey, 236 - text: p.text, 237 - options: p.options, 238 - createdAt: p.createdAt, 239 - votes: new Map(), 240 - voteCount: p.voteCount, 241 - }); 242 - } 141 + let filteredPolls = Array.from(polls.values()); 142 + if (mineOnly && currentDid) { 143 + filteredPolls = filteredPolls.filter((p) => p.repo === currentDid); 243 144 } 244 - 245 - // load user's votes now that polls are in memory 246 - await loadUserVotes(); 247 - 248 - const allPolls = Array.from(polls.values()) 249 - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 145 + filteredPolls.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 250 146 251 147 const newLink = agent ? `<p><a href="/new">+ new poll</a></p>` : `<p>login to create polls</p>`; 148 + const heading = mineOnly ? `<p><strong>my polls</strong></p>` : ""; 252 149 253 - if (allPolls.length === 0) { 254 - app.innerHTML = newLink + "<p>no polls yet</p>"; 150 + if (filteredPolls.length === 0) { 151 + const msg = mineOnly ? "you haven't created any polls yet" : "no polls yet"; 152 + app.innerHTML = newLink + heading + `<p>${msg}</p>`; 255 153 } else { 256 - app.innerHTML = newLink + allPolls.map(renderPollCard).join(""); 154 + app.innerHTML = newLink + heading + filteredPolls.map(renderPollCard).join(""); 257 155 attachVoteHandlers(); 258 156 } 259 157 } catch (e) { ··· 263 161 }; 264 162 265 163 const renderPollCard = (p: Poll) => { 266 - // always use backend voteCount for total 267 164 const total = p.voteCount ?? 0; 165 + const disabled = votingInProgress ? " disabled" : ""; 268 166 269 167 const opts = p.options 270 - .map((opt, i) => { 271 - return ` 272 - <div class="option" data-vote="${i}" data-poll="${p.uri}"> 273 - <span class="option-text">${esc(opt)}</span> 274 - </div> 275 - `; 276 - }) 168 + .map((opt, i) => ` 169 + <div class="option${disabled}" data-vote="${i}" data-poll="${p.uri}"> 170 + <span class="option-text">${esc(opt)}</span> 171 + </div> 172 + `) 277 173 .join(""); 278 174 279 175 return ` ··· 285 181 `; 286 182 }; 287 183 288 - const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 289 - 290 - const ago = (date: string) => { 291 - const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); 292 - if (seconds < 60) return "just now"; 293 - const minutes = Math.floor(seconds / 60); 294 - if (minutes < 60) return `${minutes}m ago`; 295 - const hours = Math.floor(minutes / 60); 296 - if (hours < 24) return `${hours}h ago`; 297 - const days = Math.floor(hours / 24); 298 - if (days < 30) return `${days}d ago`; 299 - return new Date(date).toLocaleDateString(); 300 - }; 301 - 302 - const attachVoteHandlers = () => { 303 - app.querySelectorAll("[data-vote]").forEach((el) => { 304 - el.addEventListener("click", async (e) => { 305 - e.preventDefault(); 306 - const t = e.currentTarget as HTMLElement; 307 - await vote(t.dataset.poll!, parseInt(t.dataset.vote!, 10)); 308 - }); 309 - }); 310 - 311 - // attach hover handlers for vote counts 312 - app.querySelectorAll(".vote-count").forEach((el) => { 313 - el.addEventListener("mouseenter", showVotersTooltip); 314 - el.addEventListener("mouseleave", hideVotersTooltip); 315 - }); 316 - }; 317 - 318 - type Vote = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 319 - const votersCache = new Map<string, Vote[]>(); 320 - const handleCache = new Map<string, string>(); 184 + // voters tooltip 185 + type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 186 + const votersCache = new Map<string, VoteInfo[]>(); 321 187 let activeTooltip: HTMLElement | null = null; 322 188 let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 323 189 324 - // resolve DID to handle 325 - const resolveHandle = async (did: string): Promise<string> => { 326 - if (handleCache.has(did)) return handleCache.get(did)!; 327 - try { 328 - const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 329 - if (res.ok) { 330 - const data = await res.json(); 331 - if (data.handle) { 332 - handleCache.set(did, data.handle); 333 - return data.handle; 334 - } 335 - } 336 - } catch {} 337 - return did; // fallback to DID 338 - }; 339 - 340 190 const showVotersTooltip = async (e: Event) => { 341 191 const el = e.target as HTMLElement; 342 192 const pollUri = el.dataset.pollUri; 343 193 if (!pollUri) return; 344 194 345 - // clear any pending hide 346 195 if (tooltipTimeout) { 347 196 clearTimeout(tooltipTimeout); 348 197 tooltipTimeout = null; 349 198 } 350 199 351 - // fetch voters if not cached 352 200 if (!votersCache.has(pollUri)) { 353 201 try { 354 - const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`); 355 - if (res.ok) { 356 - votersCache.set(pollUri, await res.json()); 357 - } 202 + const voters = await fetchVoters(pollUri); 203 + votersCache.set(pollUri, voters); 358 204 } catch (err) { 359 205 console.error("failed to fetch voters:", err); 360 206 return; ··· 364 210 const voters = votersCache.get(pollUri); 365 211 if (!voters || voters.length === 0) return; 366 212 367 - // resolve handles for all voters 368 213 await Promise.all(voters.map(async (v) => { 369 214 if (!v.handle) { 370 215 v.handle = await resolveHandle(v.voter); 371 216 } 372 217 })); 373 218 374 - // get poll options for display 375 219 const poll = polls.get(pollUri); 376 220 const options = poll?.options || []; 377 221 378 - // remove existing tooltip if any 379 222 if (activeTooltip) activeTooltip.remove(); 380 223 381 - // create tooltip 382 224 const tooltip = document.createElement("div"); 383 225 tooltip.className = "voters-tooltip"; 384 226 tooltip.innerHTML = voters ··· 391 233 }) 392 234 .join(""); 393 235 394 - // keep tooltip visible when hovering over it 395 236 tooltip.addEventListener("mouseenter", () => { 396 237 if (tooltipTimeout) { 397 238 clearTimeout(tooltipTimeout); ··· 400 241 }); 401 242 tooltip.addEventListener("mouseleave", hideVotersTooltip); 402 243 403 - // position tooltip 404 244 const rect = el.getBoundingClientRect(); 405 245 tooltip.style.position = "fixed"; 406 246 tooltip.style.left = `${rect.left}px`; ··· 411 251 }; 412 252 413 253 const hideVotersTooltip = () => { 414 - // delay hiding so user can move to tooltip 415 254 tooltipTimeout = setTimeout(() => { 416 255 if (activeTooltip) { 417 256 activeTooltip.remove(); ··· 420 259 }, 150); 421 260 }; 422 261 262 + const attachVoteHandlers = () => { 263 + app.querySelectorAll("[data-vote]").forEach((el) => { 264 + el.addEventListener("click", async (e) => { 265 + e.preventDefault(); 266 + const t = e.currentTarget as HTMLElement; 267 + await handleVote(t.dataset.poll!, parseInt(t.dataset.vote!, 10)); 268 + }); 269 + }); 270 + 271 + app.querySelectorAll(".vote-count").forEach((el) => { 272 + el.addEventListener("mouseenter", showVotersTooltip); 273 + el.addEventListener("mouseleave", hideVotersTooltip); 274 + }); 275 + }; 276 + 277 + const handleVote = async (pollUri: string, option: number) => { 278 + console.log("[handleVote] called", { pollUri, option, votingInProgress, agent: !!agent, currentDid }); 279 + 280 + if (!agent || !currentDid) { 281 + setStatus("login to vote"); 282 + console.log("[handleVote] not logged in, returning"); 283 + return; 284 + } 285 + 286 + if (votingInProgress) { 287 + console.log("[handleVote] vote already in progress, returning"); 288 + return; 289 + } 290 + 291 + votingInProgress = true; 292 + setStatus("voting..."); 293 + console.log("[handleVote] set votingInProgress=true, calling vote()"); 294 + 295 + // disable all vote options visually 296 + app.querySelectorAll("[data-vote]").forEach((el) => { 297 + el.classList.add("disabled"); 298 + }); 299 + 300 + try { 301 + await vote(pollUri, option); 302 + console.log("[handleVote] vote() completed successfully"); 303 + setStatus("confirming..."); 304 + 305 + // poll backend until vote is confirmed (tap needs time to process) 306 + const maxWait = 10000; 307 + const pollInterval = 500; 308 + const start = Date.now(); 309 + 310 + while (Date.now() - start < maxWait) { 311 + const voters = await fetchVoters(pollUri); 312 + const myVote = voters.find(v => v.voter === currentDid); 313 + console.log("[handleVote] polling backend", { myVote, elapsed: Date.now() - start }); 314 + if (myVote && myVote.option === option) { 315 + console.log("[handleVote] vote confirmed in backend"); 316 + break; 317 + } 318 + await new Promise(r => setTimeout(r, pollInterval)); 319 + } 320 + 321 + setStatus(""); 322 + console.log("[handleVote] calling render()"); 323 + render(); 324 + } catch (e) { 325 + console.error("[handleVote] error:", e); 326 + setStatus(`error: ${e}`); 327 + setTimeout(() => { 328 + setStatus(""); 329 + render(); 330 + }, 2000); 331 + } finally { 332 + votingInProgress = false; 333 + console.log("[handleVote] finally, votingInProgress=false"); 334 + } 335 + }; 336 + 423 337 const attachShareHandler = () => { 424 338 const btn = app.querySelector(".share-btn") as HTMLButtonElement; 425 339 if (!btn) return; ··· 435 349 btn.classList.remove("copied"); 436 350 }, 2000); 437 351 } catch { 438 - // fallback for older browsers 439 352 const input = document.createElement("input"); 440 353 input.value = url; 441 354 document.body.appendChild(input); ··· 452 365 }); 453 366 }; 454 367 455 - const renderPoll = async (repo: string, rkey: string) => { 368 + const renderPollPage = async (repo: string, rkey: string) => { 456 369 const uri = `at://${repo}/${POLL}/${rkey}`; 457 370 app.innerHTML = "<p>loading...</p>"; 458 371 459 372 try { 460 - // fetch poll with vote counts from backend 461 - const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`); 373 + const data = await fetchPoll(uri); 462 374 463 - if (res.ok) { 464 - const data = await res.json() as { 465 - uri: string; 466 - repo: string; 467 - rkey: string; 468 - text: string; 469 - options: Array<{ text: string; count: number }>; 470 - createdAt: string; 471 - }; 472 - 473 - // render poll with vote counts from backend 375 + if (data) { 474 376 const total = data.options.reduce((sum, o) => sum + o.count, 0); 377 + const disabled = votingInProgress ? " disabled" : ""; 378 + 475 379 const opts = data.options 476 380 .map((opt, i) => { 477 381 const pct = total > 0 ? Math.round((opt.count / total) * 100) : 0; 478 382 return ` 479 - <div class="option" data-vote="${i}" data-poll="${uri}"> 480 - <div class="option-bar" style="width: ${pct}%"></div> 481 - <span class="option-text">${esc(opt.text)}</span> 482 - <span class="option-count">${opt.count} (${pct}%)</span> 483 - </div>`; 383 + <div class="option${disabled}" data-vote="${i}" data-poll="${uri}"> 384 + <div class="option-bar" style="width: ${pct}%"></div> 385 + <span class="option-text">${esc(opt.text)}</span> 386 + <span class="option-count">${opt.count} (${pct}%)</span> 387 + </div>`; 484 388 }) 485 389 .join(""); 486 390 ··· 501 405 } 502 406 503 407 // fallback to direct PDS fetch if backend doesn't have it 504 - const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`); 505 - const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined; 506 - const pdsUrl = pds?.serviceEndpoint || "https://bsky.social"; 507 - 508 - const pdsClient = new Client({ 509 - handler: simpleFetchHandler({ service: pdsUrl }), 510 - }); 511 - 512 - const pdsRes = await pdsClient.get("com.atproto.repo.getRecord", { 513 - params: { repo, collection: POLL, rkey }, 514 - }); 515 - if (!pdsRes.ok) { 408 + const pdsData = await fetchPollFromPDS(repo, rkey); 409 + if (!pdsData) { 516 410 app.innerHTML = "<p>not found</p>"; 517 411 return; 518 412 } 519 - const rec = pdsRes.data.value as { text: string; options: string[]; createdAt: string }; 520 - const poll = { uri: pdsRes.data.uri, repo, rkey, text: rec.text, options: rec.options, createdAt: rec.createdAt, votes: new Map() }; 413 + 414 + const poll: Poll = { ...pdsData, votes: new Map() }; 521 415 polls.set(uri, poll); 522 416 523 417 app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; ··· 540 434 <button id="create">create</button> 541 435 </div> 542 436 `; 543 - document.getElementById("create")!.onclick = create; 437 + document.getElementById("create")!.onclick = handleCreate; 544 438 }; 545 439 546 - const create = async () => { 440 + const handleCreate = async () => { 547 441 if (!agent || !currentDid) return; 548 442 549 443 const text = (document.getElementById("question") as HTMLInputElement).value.trim(); ··· 558 452 } 559 453 560 454 setStatus("creating..."); 561 - const rpc = new Client({ handler: agent }); 562 - const res = await rpc.post("com.atproto.repo.createRecord", { 563 - input: { 564 - repo: currentDid, 565 - collection: POLL, 566 - record: { $type: POLL, text, options, createdAt: new Date().toISOString() }, 567 - }, 568 - }); 569 - 570 - if (!res.ok) { 571 - setStatus(`error: ${res.data.error}`); 572 - return; 573 - } 574 - 575 - const rkey = res.data.uri.split("/").pop()!; 576 - polls.set(res.data.uri, { 577 - uri: res.data.uri, 578 - repo: currentDid, 579 - rkey, 580 - text, 581 - options, 582 - createdAt: new Date().toISOString(), 583 - votes: new Map(), 584 - }); 585 - 586 - setStatus(""); 587 - history.pushState(null, "", "/"); 588 - render(); 589 - }; 590 - 591 - const vote = async (pollUri: string, option: number) => { 592 - if (!agent || !currentDid) { 593 - setStatus("login to vote"); 594 - return; 595 - } 596 - 597 - setStatus("voting..."); 598 - const rpc = new Client({ handler: agent }); 599 - 600 - // first, find and delete any existing votes on this poll 601 455 try { 602 - const existing = await rpc.get("com.atproto.repo.listRecords", { 603 - params: { repo: currentDid, collection: VOTE, limit: 100 }, 604 - }); 605 - if (existing.ok) { 606 - for (const record of existing.data.records) { 607 - const val = record.value as { subject?: string }; 608 - if (val.subject === pollUri) { 609 - const rkey = record.uri.split("/").pop()!; 610 - await rpc.post("com.atproto.repo.deleteRecord", { 611 - input: { repo: currentDid, collection: VOTE, rkey }, 612 - }); 613 - } 614 - } 615 - } 456 + await createPoll(text, options); 457 + setStatus(""); 458 + history.pushState(null, "", "/"); 459 + render(); 616 460 } catch (e) { 617 - console.error("error checking existing votes:", e); 461 + setStatus(`error: ${e}`); 618 462 } 619 - 620 - const res = await rpc.post("com.atproto.repo.createRecord", { 621 - input: { 622 - repo: currentDid, 623 - collection: VOTE, 624 - record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 625 - }, 626 - }); 627 - 628 - if (!res.ok) { 629 - console.error("vote error:", res.status, res.data); 630 - setStatus(`error: ${res.data.error || res.data.message || "unknown"}`); 631 - setTimeout(() => setStatus(""), 3000); 632 - return; 633 - } 634 - 635 - // update local state 636 - const poll = polls.get(pollUri); 637 - if (poll) { 638 - // remove any existing vote from this user 639 - for (const [uri, _] of poll.votes) { 640 - if (uri.startsWith(`at://${currentDid}/${VOTE}/`)) { 641 - poll.votes.delete(uri); 642 - } 643 - } 644 - poll.votes.set(res.data.uri, option); 645 - } 646 - 647 - setStatus(""); 648 - render(); 649 463 }; 650 464 651 - // oauth 652 - const handleCallback = async () => { 465 + // oauth callback handler 466 + const handleOAuthCallback = async () => { 653 467 const params = new URLSearchParams(location.hash.slice(1)); 654 468 if (!params.has("state")) return false; 655 469 656 - history.replaceState(null, "", "/"); 657 470 setStatus("logging in..."); 658 471 659 472 try { 660 - const { session } = await finalizeAuthorization(params); 661 - agent = new OAuthUserAgent(session); 662 - currentDid = session.info.sub; 663 - localStorage.setItem("lastDid", currentDid); 473 + const success = await handleCallback(); 664 474 setStatus(""); 665 - return true; 475 + return success; 666 476 } catch (e) { 667 477 setStatus(`login failed: ${e}`); 668 478 return false; 669 479 } 670 480 }; 671 481 672 - const restoreSession = async () => { 673 - const lastDid = localStorage.getItem("lastDid"); 674 - if (!lastDid) return; 675 - 676 - try { 677 - const session = await getSession(lastDid as `did:${string}:${string}`); 678 - agent = new OAuthUserAgent(session); 679 - currentDid = session.info.sub; 680 - } catch { 681 - localStorage.removeItem("lastDid"); 682 - } 683 - }; 684 - 685 482 // routing 686 483 window.addEventListener("popstate", render); 687 484 document.addEventListener("click", (e) => { ··· 695 492 696 493 // init 697 494 (async () => { 698 - await handleCallback(); 495 + await handleOAuthCallback(); 699 496 await restoreSession(); 700 497 connectJetstream(); 701 498 render();
+30
tap/fly.toml
··· 1 + app = 'pollz-tap' 2 + primary_region = 'iad' 3 + 4 + [build] 5 + image = 'ghcr.io/bluesky-social/indigo/tap:latest' 6 + 7 + [env] 8 + TAP_DATABASE_URL = 'sqlite:///data/tap.db' 9 + TAP_BIND = ':2480' 10 + TAP_SIGNAL_COLLECTION = 'tech.waow.poll' 11 + TAP_COLLECTION_FILTERS = 'tech.waow.poll,tech.waow.vote' 12 + TAP_DISABLE_ACKS = 'true' 13 + TAP_LOG_LEVEL = 'info' 14 + TAP_CURSOR_SAVE_INTERVAL = '5s' 15 + 16 + [http_service] 17 + internal_port = 2480 18 + force_https = false 19 + auto_stop_machines = 'off' 20 + auto_start_machines = true 21 + min_machines_running = 1 22 + 23 + [[vm]] 24 + memory = '512mb' 25 + cpu_kind = 'shared' 26 + cpus = 1 27 + 28 + [mounts] 29 + source = 'tap_data' 30 + destination = '/data'
+17
tap/justfile
··· 1 + # tap instance for pollz 2 + 3 + # deploy tap to fly.io 4 + deploy: 5 + fly deploy --app pollz-tap 6 + 7 + # check tap status 8 + status: 9 + fly status --app pollz-tap 10 + 11 + # view tap logs 12 + logs: 13 + fly logs --app pollz-tap 14 + 15 + # ssh into tap instance 16 + ssh: 17 + fly ssh console --app pollz-tap