Project Spec — Ticker
Single source of truth for the Ticker course (
src/content/projects/ticker.mdx). The course teaches toward exactly this runnable project. If a step and this spec disagree, fix both — prefer fixing the spec first. Spotlight: Redis Streams (realtime change feed + fan-out + reconnect-replay). Backends: Go (default) and TypeScript (Hono on Bun) — both implement the identical contract below.
1. Overview & definition of done
Ticker is the realtime backbone of an operations control room: a small set of products whose price and stock many people watch at once. When an operator changes a price or sells a unit, the change is:
- committed once to Postgres (the durable truth, with an optimistic-concurrency
versionand an atomic oversell guard), then - appended to a Redis Stream as an ordered, durable event with a stable id (
<ms>-<seq>), then - fanned out over Server-Sent Events (SSE) to every connected dashboard within ~100 ms.
Each client persists the last Stream id it applied, so on reconnect it replays exactly the events it
missed via XREAD from that id — no gaps, no full refetch — and if its id was trimmed away it falls back
to a fresh snapshot.
Definition of done (what the learner ends with, runnable for $0):
docker compose up -dbrings uppostgres:16+redis:7; onepsqlload creates theproductstable and three seed rows.- One server binary/process (
PORT=8080) serves the bundled dashboard at/, a JSON snapshot atGET /products, two write routes, and an SSE feed atGET /stream. - The learner opens
http://localhost:8080/in two browser windows: dragging the price slider in window A makes window B’s number tick within ~100 ms; clicking Sell 1 drops stock in both; selling the last unit flips both to SOLD OUT and the loser gets an out of stock toast. - The offline demo: take window B offline in DevTools, make changes in window A, bring B back online — the status pill reads Reconnecting… → Replaying N events, the backlog fast-forwards, and B ends identical to A.
- A second server instance on
PORT=8081against the same Redis proves N instances act as one: a write on A appears on a dashboard connected to B. - An integration test proves both guarantees: live delivery within ~100 ms and exact reconnect-replay.
Everything runs locally with free, open-source images and free tools. No account, quota, or card.
2. Architecture (prose diagram)
┌────────────── operator (browser) ──────────────┐
│ public/index.html (fetch + EventSource) │
└───┬───────────────────────────┬─────────────────┘
writes (PUT/POST) live feed (GET /stream, SSE)
│ ▲
▼ │ id:/data: frames + heartbeats
┌───────────────────────────────────────────────┐
│ Ticker server (Go default | Hono/Bun) │
│ • GET / -> serves public/index.html │
│ • GET /products -> snapshot (Postgres) │
│ • PUT /products/{id}/price -> write path │
│ • POST /products/{id}/sell -> write path │
│ • GET /stream -> tail Stream, SSE fan-out │
└───────┬───────────────────────────┬────────────┘
commit (durable truth) XADD / XREAD (ordered change feed)
▼ ▼
┌───────────────┐ ┌────────────────────────┐
│ Postgres 16 │ │ Redis 7 Stream │
│ products row │ │ key: warehouse:1 │
│ + version │ │ XADD MAXLEN ~ 10000 │
└───────────────┘ └────────────────────────┘
- Write path (the spine). A write
UPDATEs the row in Postgres (bumpingversion, returning the post-write state), then — only on a committed row —XADDs a self-contained change event to the Stream. Commit before publish so the Stream never advertises a change that did not happen. - Fan-out.
GET /streamis a long-livedtext/event-streamresponse. The handler tails the Stream with a bounded blockingXREADin a loop and writes one SSE frame per event. Because every instance tails the same Stream with plainXREAD(no consumer group), all instances see every event — the Stream, not the process, is the fan-out point, so horizontal scaling is free. - Reconnect-replay. The SSE
id:field carries the Stream id. The browser’sEventSourceresends it asLast-Event-IDon auto-reconnect; the server startsXREADjust after that id, so the first batch is the missed events. If the id is older than the Stream’s first retained entry, the server sends aresyncevent and the client reloads the snapshot. - The single invariant (state once, reference everywhere): Snapshot = baseline truth (Postgres),
Stream = recent deltas (Redis); apply both through one idempotent reducer keyed by
(id, version).
Fixed warehouse for this build
The Stream is keyed per warehouse as a design (warehouse:{id} keeps each feed small and lets a
dashboard subscribe to just the warehouse it watches). This build commits to a single warehouse,
warehouse:1, as the fixed key everywhere — the write routes XADD to it, GET /stream tails it, and
the dashboard reads it. (Extending to a request-supplied warehouse — GET /stream?warehouse=N, write routes
keyed by the product’s warehouse — is left as an exercise; if you do it, every step must use the same id.)
The course must not teach a dynamic key it does not wire.
3. Runnable structure (the repo the learner ends with)
ticker/
├── docker-compose.yml # postgres:16 + redis:7
├── db/
│ └── schema.sql # products table + CHECK constraints + 3 seed rows
├── public/
│ └── index.html # the zero-build dashboard (served at /)
│
├── (Go default)
│ ├── go.mod # module github.com/you/ticker
│ ├── cmd/server/main.go # ENTRYPOINT: composes everything (below)
│ └── internal/... # optional: split store/handlers if desired
│
└── (TypeScript alternative)
├── package.json # bun; deps: hono, pg, redis@^5
└── src/server.ts # ENTRYPOINT: the Hono app + Bun export
The app entrypoint (composition root)
Both backends have one entrypoint that composes the whole app — it must, in order:
- Read config from env:
DATABASE_URL,REDIS_URL,PORT(default8080). - Open the Postgres pool and the Redis client once at startup; close them on shutdown.
- Register all routes on one mux/app:
GET /,GET /products,PUT /products/{id}/price,POST /products/{id}/sell,GET /stream. - Serve static
public/index.htmlat/. - Listen on
:${PORT}.
Go — cmd/server/main.go: pgxpool.New → redis.NewClient(redis.ParseURL(...)) → http.NewServeMux
with Go 1.22 method patterns → port := os.Getenv("PORT"); if port == "" { port = "8080" } →
http.ListenAndServe(":"+port, mux). http.FileServer(http.Dir("public")) serves index.html at /
automatically.
TypeScript — src/server.ts: new Pool({ connectionString }) → createClient({ url }); await redis.connect() → new Hono() with routes → app.get("*", serveStatic({ path: "./public/index.html" }))
fallback for the SPA root → export default { port: Number(process.env.PORT) || 8080, fetch: app.fetch }.
Key interfaces / contracts (named explicitly)
Both backends implement the same logical operations. Names are illustrative; the behaviour is the contract.
- Store (durable truth, Postgres):
listProducts(ctx) -> []Product—SELECT ... ORDER BY id.setPrice(ctx, id, newPrice, expectedVersion) -> (Product, conflict bool, found bool)— conditionalUPDATE ... WHERE id=$id AND version=$expected RETURNING price, stock, version. On a one-row result:found=true, conflict=false, return the post-write row. On zero rows, disambiguate by re-SELECTing the row by id alone: if the row exists the version did not match ⇒found=true, conflict=trueand the returnedProductis the current row (so the caller can 409 with live values); if the re-SELECTfinds nothing the id is unknown ⇒found=false(so the caller can 404). A conditionalUPDATEcannot by itself tell “stale version” from “no such id” — the re-SELECTis what makes the 404 in §5 reachable.sell(ctx, id, qty) -> (Product, outOfStock bool, found bool)— atomicUPDATE ... SET stock = stock - $qty ... WHERE id=$id AND stock >= $qty RETURNING price, stock, version. One row ⇒found=true, outOfStock=false. Zero rows re-SELECTs by id: row present ⇒found=true, outOfStock=truewith the current row (409out_of_stock); row absent ⇒found=false(404). Same disambiguation assetPrice: the WHERE clause merges “insufficient stock” and “no such id”, and the re-SELECTseparates them.
- Feed (change feed, Redis):
publish(ctx, ev ChangeEvent) -> streamID—XADD warehouse:1 MAXLEN ~ 10000 * <fields>.tail(ctx, fromID, block) -> ([]Entry, timedOut bool)— boundedXREAD BLOCK <ms> COUNT N STREAMS warehouse:1 <fromID>; on timeout returnstimedOut=true(no entries) so the handler can heartbeat and re-loop.firstEntryID(ctx) -> streamID—XINFO STREAM warehouse:1→first-entryid (for the trimmed-id check).
- Handler: parses/validates the request, calls Store then Feed, maps results to the HTTP contract in §5.
4. Data model
Table products (Postgres)
| column | type | constraints | notes |
|---|---|---|---|
id | BIGINT | GENERATED ALWAYS AS IDENTITY PRIMARY KEY | product id (also the PK index) |
name | TEXT | NOT NULL | |
price | BIGINT | NOT NULL CHECK (price >= 0) | cents, never float |
stock | INTEGER | NOT NULL CHECK (stock >= 0) | the oversell guard floor |
version | INTEGER | NOT NULL DEFAULT 0 | optimistic concurrency + idempotency |
updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | wall-clock for the UI |
The PK index on id is the only index the path needs (all reads/writes are by id or full-table snapshot
of 3 rows). No FK in the base build — products is self-contained.
Prerequisite/seed rows (required before GET /products is expected to return 3):
INSERT INTO products (name, price, stock) VALUES
('Aurora Mug', 1499, 50),
('Aurora Tee', 2999, 12),
('Sticker Pack', 499, 200);
Loading the schema (the step that must exist before the snapshot is queried): the compose file mounts no
init script, so load it explicitly after docker compose up:
psql "$DATABASE_URL" -f db/schema.sql
# or, without a local psql client, through the db container:
docker compose exec -T db psql -U postgres -d ticker < db/schema.sql
DATABASE_URL uses user postgres (the image’s default superuser, since compose sets only
POSTGRES_PASSWORD/POSTGRES_DB). If you set POSTGRES_USER, change the URL to match.
Redis Stream warehouse:1
- Created lazily by the first
XADD; capped withMAXLEN ~ 10000(approximate trim — a little over is fine and much cheaper than exact). No consumer group in the base build (see §8). - Stream field values are strings. Numbers are encoded as decimal text on
XADDand parsed on read.
Migrations
db/schema.sql is the single migration for the base build. The course frames it as the v1 migration; any
later table (e.g. a feature’s price_history) is an additive migration applied the same way.
5. API & event contract (canonical — every step, client, and test shares this)
All bodies are JSON. Money is integer cents. The product object is the single shared shape:
// Product (snapshot rows AND the body of 200/409 write responses)
{ "id": 1, "name": "Aurora Mug", "price": 1499, "stock": 50, "version": 3 }
JSON type note (one canonical rule).
GET /productsand the write-route bodies returnprice,stock,versionas JSON numbers. SSE stream frames carry the same fields but as strings (Redis Stream values are strings; the server forwardsmsg.Values/msg.messageverbatim). Every client MUST coerce withNumber(...)(web) /.toLong()/.toInt()(Kotlin). This split is deliberate and documented; do not “fix” one side.
Endpoints
| Method | Path | Request body | Success | Error |
|---|---|---|---|---|
GET | / | — | 200 text/html (the dashboard) | — |
GET | /products | — | 200 JSON array of Product (ordered by id) | — |
PUT | /products/{id}/price | { "price": <int cents>, "version": <int> } | 200 JSON Product (post-write) | 409 JSON Product (current row) on version conflict; 400 on bad body; 404 if id unknown |
POST | /products/{id}/sell | { "qty": <int ≥ 1> } | 200 JSON Product (post-sale) | 409 { "error": "out_of_stock", ...currentRow } when stock < qty; 400 on bad body; 404 if id unknown |
GET | /stream | — (optional Last-Event-ID header) | 200 text/event-stream (frames below) | — |
On a successful PUT/POST, and only after the Postgres commit, the server XADDs one change event and
returns the post-write Product. A 409 (conflict or out-of-stock) and a 404 (unknown id) emit no
event. The write path’s conditional UPDATE returns zero rows for both a stale/insufficient write and an
unknown id; the store op re-SELECTs the row by id to tell them apart (row present ⇒ 409 + current row,
row absent ⇒ 404), so the 404 in the table above is actually reachable from a §3-built backend.
SSE wire format (GET /stream)
-
Data frame (one per change event):
id: 1718900000000-0 data: {"product_id":"1","price":"1399","stock":"50","version":"4"}datais the JSON-encoded Stream fields (strings — see the type note). Theid:line is the Stream id. -
Replaying frame (sent once at the start of a reconnect, only when ≥1 event is being replayed):
event: replaying data: 5datais the count of replayed events. Guarded bycount > 0, so a clean reconnect never flashes “Replaying 0”. -
Resync frame (the client’s
Last-Event-IDis older than the first retained entry):event: resync data: 1Tells the client to re-
GET /products, then continue live. -
Heartbeat (comment line, on each bounded-block timeout with no new events):
:keepaliveA comment (
:prefix) is ignored byEventSourcebut keeps idle proxies/load balancers from killing the connection. It is the production-honesty half of the boundedXREADloop.
ChangeEvent (the Redis Stream entry)
XADD warehouse:1 MAXLEN ~ 10000 * product_id <id> price <cents> stock <n> version <v> — fields are the same
four the data frame carries. The Stream id <ms>-<seq> is Redis-assigned, monotonic, and opaque.
6. Build order (dependency-ordered; each step’s prerequisites already exist)
Common (always shown) unless tagged:
- Stand up Postgres + Redis (
docker-compose.yml, exportDATABASE_URL/REDIS_URL). (common) - Concept: pub/sub vs Streams — why a change feed + fan-out + replay, and why Streams not pub/sub. (common)
- Feel fan-out + replay in raw
redis-cli— two terminals:XREAD BLOCKparks and wakes; reconnect from an explicit id replays a gap. (De-risks the later server steps.) (common) - Schema (
db/schema.sql:products+ CHECKs + 3 seed rows). (common) - Load the schema into Postgres (
psql -f/docker compose exec). NowGET /productscan return 3. (common) - Model the change event (fields,
warehouse:1as the fixed key, string values). (common) - Scaffold the server — pool + client +
GET /productssnapshot + servepublic/index.html+ readPORT. (backend: go | typescript) - ★ The write path —
setPrice/sellstore ops: commit to Postgres, thenXADD. (common — SQL + language-agnostic order; the concept/SQL is identical, code lands in step 9.) - Wire the write HTTP routes —
PUT /products/{id}/priceandPOST /products/{id}/sell: parse body, call the write path, return200+row /409+current-row /404on unknown id (re-SELECTdisambiguates the zero-row case — see §3),XADDon success. (backend: go | typescript) - Optimistic concurrency — the conditional
UPDATE→409+ current value (SQL, concept). (common) - Oversell guard in one atomic line —
WHERE stock >= qty; contrast Aurora. (common) - Client offset protocol — every SSE frame carries its Stream id; the id is the whole protocol. (common)
- ★ Tail the Stream + SSE fan-out — bounded
XREAD BLOCKloop + heartbeat on timeout;id:/data:frames; default start$. (backend: go (id=fanout-go) | typescript (id=fanout-ts)) - Reconnect-and-replay —
Last-Event-ID→XREADfrom that id;replaying N(guarded>0), trimmed-id →resync. (backend: go | typescript) - Idempotent apply by
(id, version)— the client reducer; the single invariant box. (common) - Build the dashboard (
public/index.html: snapshot +EventSource+ slider/Sell/Sold-Out). (frontend: web-client) - Status pill + offline demo (Live/Reconnecting/Replaying; DevTools-offline reconnect). (frontend: web-client)
- Multi-instance fan-out — two instances (
PORT=8080/8081), one Stream. (common) - Bound the Stream —
XADD MAXLEN ~; snapshot-first recovery. (common) - Consumer groups: the other half of Streams — name
XREADGROUP/XACK/XPENDING/XAUTOCLAIM, and why this fan-out deliberately uses plainXREAD(every instance sees every event), not a group. (common — advanced concept) - Integration test: live + exact replay. (backend: go | typescript)
- Optional deploy — Cloud Run + self-hosted/managed Redis (blocking
XREADcaveat). (common) - Feature: AI anomaly note (feature: ai-anomaly) — see §8.
- Feature: native Compose client (feature: compose-client) — see §8.
Each backend-tagged step has both a go and a typescript variant (parity). The write path (8) is common
SQL/order; its HTTP wiring (9), fan-out (13), replay (14), and tests (21) fork by backend.
7. Backends — same contract, parity points
Go (default) and TypeScript (Hono/Bun) implement §5 identically. Parity checklist:
| Concern | Go | TypeScript |
|---|---|---|
| Config | os.Getenv("PORT"), default 8080; redis.ParseURL | Number(process.env.PORT)||8080; createClient({url}) |
Static / | http.FileServer(http.Dir("public")) (serves index.html) | serveStatic({ path: "./public/index.html" }) fallback |
| DB driver | github.com/jackc/pgx/v5 (pgxpool) | pg (node-postgres Pool) |
| Redis | github.com/redis/go-redis/v9 | redis (node-redis, pin redis@^5) |
| Write routes | mux.HandleFunc("PUT /products/{id}/price", ...) (1.22 path values) | app.put("/products/:id/price", ...) |
| Fan-out read | XRead with Block: 15*time.Second; timeout ⇒ redis.Nil ⇒ heartbeat + re-loop | xRead({key,id},{BLOCK:15000,COUNT:N}) on redis.duplicate(); timeout ⇒ null ⇒ heartbeat + re-loop |
| Blocking conn | the request’s own goroutine/connection is fine | dedicated redis.duplicate() per stream (never the shared client) |
first-entry | rdb.XInfoStream(ctx, "warehouse:1").Result().FirstEntry.ID | info["first-entry"].id (node-redis v5 returns the hyphenated key, not firstEntry) |
Critical parity nuance (verified):
- Bounded BLOCK, not
BLOCK 0. go-redisBlock: 0blocks forever; node-redisxReadwithBLOCK: 0has a documented foot-gun where the promise can fail to resolve (redis/node-redis #2258). Using a bounded block (15s) in a re-loop on a dedicated connection is reliable on both clients, makes heartbeats natural, and keeps the two servers behaviourally identical. This is the canonical design — the course must not presentBLOCK 0as straightforwardly working on node-redis. - Replay = same loop from a different id on both; the first batch (when
count > 0) is the missed set. - Both reset
products+ the Stream between integration-test runs andt.Skip/skip when env is unset.
8. Optional feature modules (off by default)
ai-anomaly—GET /insights: read the last ~200 Stream events (XRANGE warehouse:1 - + COUNT 200), ask Gemini for structured JSON (responseMimeType: "application/json"+responseSchema) →{ headline, restock[], price_anomalies[] }. Read-only: it surfaces advice for a human, never changes a price or stock. KeepGEMINI_API_KEYserver-side; link the official structured-output docs rather than pinning a model id. Backend-agnostic (the prompt + schema are the lesson; wiring described in the AgentPrompt). $0: a free Google AI Studio key.compose-client— a native Jetpack Compose client beside the web dashboard, against the unchanged backend (http://10.0.2.2:8080from the emulator). Consumes the same/products+/stream; the lesson is that the realtime protocol is client-agnostic, and that — unlike the browser’sEventSource— OkHttp’sokhttp-sseEventSourcedoes not auto-reconnect or auto-sendLast-Event-ID, so Android owns reconnect-and-replay itself (idempotent apply byversion). UI-only; backend unchanged. $0: emulator ships free with Android Studio.
Why the base build deliberately uses plain XREAD, not a consumer group: SSE fan-out wants every
instance to see every event (a dashboard mirror), which is exactly plain XREAD. Work distribution
(process each event once across a pool) is the consumer-group job: XGROUP CREATE, XREADGROUP >, XACK,
the Pending Entries List (XPENDING), and XAUTOCLAIM to reclaim stuck messages. The course names these
and explains the judgement (fan-out-read vs group-read) so the choice of plain XREAD here is load-bearing,
not accidental. (A consumer-group-worker feature is a natural future add-on; not in scope here.)
9. Free-to-complete ($0)
| Piece | Free setup |
|---|---|
| Postgres | postgres:16 via Docker Compose (open source) |
| Redis | redis:7 via Docker Compose (open source); XADD/XREAD/Streams are core, not a paid tier |
| Server | Go toolchain or Bun — both free; one local process |
| Dashboard | one static public/index.html, no build, no framework |
| Gemini (feature) | free Google AI Studio key |
| Compose client (feature) | Android Studio emulator — free, no device/account |
| Deploy (optional) | not required to see it work; Cloud Run has a free monthly allotment; self-host redis:7 so blocking XREAD works (some serverless Redis free tiers lack blocking XREAD — fall back to short non-blocking polling there) |
docker compose up + one psql load + run the server is the entire demo. Nothing in the project requires a
paid service, and the platform never runs anything for the learner.