Educational demo of a single node, sqlite, Datastar, CQRS app with low latency UX.
Go 78%
HTML 13%
CSS 8%
Shell <1%
Makefile <1%

README.md

dbugs #

A reimagining of Rocicorp's zbugs — the demo app for their local-first sync engine, Zero — built as its architectural antithesis: Go + SQLite + Datastar, one binary, one box, no client database.

🚀 Try it live: dbugs.yagni.club #

1,000,000 issues, full-text search, realtime multiplayer — on a $24/mo droplet.

screenshot

The premise #

A local-first sync engine buys UI latency by moving the data to the client: sync the rows into the browser, run queries locally, and interactions are instant because nothing crosses the network. The price is everything that follows from the client holding the dataset — megabytes on first load, a client query engine and sync protocol, auth questions a synced schema structurally can't answer, and search that gets disabled once the dataset is too big to ship (the real zbugs turns search off on its 240k-issue project).

dbugs is the opposite bet: the browser holds nothing. Each tab's filters, sort, and open issue live on the server as a search session; the server renders the visible window of that session to HTML and pushes it down one SSE stream; Datastar morphs it into the DOM. Every interaction is a ~12-byte command POST that mutates the session and lets the projection refresh.

The claim being tested: with a single node, SQLite, and the right architecture, the server round trip is cheap enough that you don't need a sync engine to get a performant UX —

  • Initial load is 2.3 KB (brotli) instead of megabytes; data never looked at is never sent.
  • Filtered full-text search over 1M rows answers in ~10–20 ms — facets are tokenized into the FTS index, so the feature the sync engine had to disable is one of the fastest paths here.
  • Writes commit in under a millisecond through a single batching writer, and every other open tab sees them live over its stream — multiplayer for free, confirmed-not-optimistic.
  • 1,000 concurrent streams sustain mixed writes at 44 ms push p50 (p99 119 ms, zero errors) on one node.
  • Read auth is "what the template renders" — row-level is a WHERE clause, cell-level is omitting a field from the HTML. No reader-dependent row types.

The honest trade: no offline, and the latency floor is a network round trip. The answer is architectural (everything above) plus honest UX — optimistic cursor state where it's safe, loading indicators that only surface when a round trip is actually slow, and writes that confirm rather than pretend.

Run #

go run .            # seeds 10k fake issues into a throwaway temp-file SQLite, serves :8080
go run . -seed 1000000                    # the full-size demo (~614 MB on disk)
go run . -fresh=false -db file:dbugs.db   # persist to a named file instead

Tracing is built in and off by default; flip it on with standard OTel env vars (spans become JSON lines on stdout — see the observability essay):

OTEL_TRACES_EXPORTER=console go run .

There's also a sanitized live trace view at GET /traces — public and on by default, for education and transparency: the same spans, fat-morphed into a page in realtime over the read stream as a Sentry-style waterfall (bars positioned by time, color-coded by span category), with a live "N watching" presence count. What makes it safe to expose is the sanitizer, not a gate: every span passes a deny-by-default allow-list (no IPs, User-Agents, or issue ids reach the browser) and trace ids are hashed with a per-process random salt into anonymous handles. The view is rendered once per frame and fanned out to all viewers, and is ephemeral — a bounded in-memory ring of the last 512 spans, nothing persisted. No collector needed; it turns tracing on by itself. Disable with -trace-view=false.

go run .                        # then open http://localhost:8080/traces

Load-test it with the purpose-built SSE harness (start the server with -writelimit=false):

go run ./cmd/zload -readers 500 -writers 50 -rate 1 -dur 15s

Architecture #

CQRS over one SSE stream — commands mutate a server-side session, the read model is rendered HTML:

┌──────────┐   command POST (~12 bytes)          ┌─────────────────────────┐
│ Browser  │  ───────────────────────────────►   │ mutate SessionState     │
│          │   /cmd/search /cmd/toggle-label     │ (the write model)       │
│ Datastar │   /cmd/select  /cmd/sort  /cmd/more │                         │
│          │                                     │ notify() ───┐           │
│          │   ◄─────────────────────────────    │             ▼           │
│  morph   │   ONE long-lived SSE stream         │ recompute projection    │
│  the DOM │   (the read model, brotli'd)        │ from SQLite, push morph │
└──────────┘                                     └─────────────────────────┘

State lives where it can be queried, authorized, and cached:

        browser (no data)                    server (all state)
┌───────────────────────────┐    ┌────────────────────────────────────────┐
│ rendered HTML              │    │ SessionState (per tab: query, filters, │
│ a handful of signals:      │    │   sort, window, selected issue)        │
│  · cursor position         │    │ SQLite (WAL):                          │
│  · form drafts             │    │   issues / labels / comments           │
│  · transient busy flags    │    │   FTS5(title, description, facets)     │
└───────────────────────────┘    └────────────────────────────────────────┘

The write path serializes through one connection and fans out by marking sessions dirty — never by doing work inline:

POST /cmd/create ─┐
POST /cmd/comment ┼─► queue ─► ONE writer goroutine ─► BEGIN … SAVEPOINT/op … COMMIT
POST /cmd/edit ───┘            (a comment storm = one fsync)        │
                                                                    ▼
                                       dataVersion++ (only if the issue set changed
                                                      → invalidates count/list caches)
                                                                    │
                  sessions marked dirty (comments: only watchers) ◄─┘
                                  │
                     frame ticker (16 ms) wakes dirty sessions
                                  │
        per stream: render regions ─ count/list caches are SHARED across
        sessions (singleflight, 99% hit) ─► skip-unchanged dedup ─► brotli SSE

Reads scale because renders are shared; writes scale because they batch; idle connections cost nothing because nothing happens until something is dirty.

file role
main.go routing, the long-lived /stream (brotli SSE), the /cmd/* commands, security middleware, and the projection/diff loop
db.go schema, deterministic fake seed, migrations, read-side queries, the faceted FTS index
writer.go single-goroutine serialized write path (batched savepoints, version bump)
session.go SessionState (write model) + per-session pub/sub hub + write rate-limit
render.go view models + html/template fragments → strings for morphing
countcache.go / listcache.go version-invalidated, singleflight-backed count and rendered-list caches
auth.go GitHub OAuth + HMAC-signed stateless auth cookie
otel.go OpenTelemetry setup: console (journald) or OTLP exporter, env-var gated
templates/ index.html shell + fragments.html (facets / list / detail)
static/ app.css + self-hosted datastar.js
cmd/zload/ load/QA harness (readers + writers + write→visible latency probe)
deploy/ systemd unit + setup/deploy scripts for a single-box deployment (runbook)

The long version #

Everything measured and learned along the way — the wire-cost comparison against the real Zero demo, scaling SQLite to 1M rows, the fan-out ceilings and how each one moved, brotli memory dials, the deleted query planner, tracing with no collector — lives in slop-essays/.