Case study
A live tournament tracker on WREN
TournamentDay publishes live brackets and results for taekwondo tournaments, on tournament day, from a handful of Python scripts and a static site. This is how it's built on WREN today, what we had to work around, and how we'd build it now.
The project
tournaments.tkd-scores.com shows, for each tournament day, the brackets, who fights when and where, results and scores as they come in, and links to match video. It also offers filtered views per country and per club. A companion site, data.tkd-scores.com, lists the entries for World Taekwondo events and keeps a history of how registrations grew.
| At a glance | |
|---|---|
| Writers | Python parsers and live pollers for several draw and scoring systems, run during tournament days; a manual correction script; deploy scripts for the static site |
| WREN data | private JSON collections draws, results, standings, streams, days; a binary collection for site files |
| Site | tree tkd-scores with 126 paths: 28 event folders and 45 day pages; 1,852 public documents in the org |
| Front door | a Cloudflare Worker on the custom domain, in front of the public tree and a small private API |
| Companion data site | public events collection (94 documents; the events index is at version 131) and a media collection (937 documents) |
Part A
As built
The tracker grew over the summer of 2026, one tournament at a time. Each design choice below solved a real problem on a real tournament day.
Writing data
Each poller writes one document per thing it knows about: a day's draw, a ring's results, a category's standings. Every record carries its own key, joined with __: slug__day, slug__day__box, slug__day__cat__name. The client's comment explains the separator: keys containing / broke the by-key URL route.
WREN can upsert by natural key in one call, but the collection must first declare which field is the key, and that's an admin operation. The deploy key wasn't an admin key, so the client emulates upsert with two calls:
# find the document by its key, then update it or create it
hits = post(f"/api/v1/{col}/_query", {"where": f"key = '{key}'", "limit": 1})["items"]
if hits:
put(f"/api/v1/{col}/{hits[0]['id']}", doc) # new version, same document
else:
post(f"/api/v1/{col}", doc) # first time: new documentThe days document ties a tournament day together. Instead of copying data, it holds query references that WREN resolves on read with ?depth=2:
"draws": {"$ref": "query:draws", "$q": {"where": f"key = '{daykey}'"}, "$limit": 1},
"streams": {"$ref": "query:streams", "$q": {"where": f"key = '{daykey}'"}, "$limit": 1},
"results": {"$ref": "query:results", "$q": {"where": f"dayKey = '{daykey}'"}, "$limit": 2000},Deploying the site
- From CLI to REST (August 2026). The first deploy script used
wren deploy. The CLI only knew session cookies, and a cookie that expired mid-tournament stopped deploys, so a REST script with an API key replaced it. - Change detection by md5. The CLI compared file sizes, and "size comparison misses same-length edits". The REST script keeps a local md5 manifest and uploads only what changed: a multipart
PUTto the existing document, orPOSTplus a treePUTfor new files. - Targeted deploys only (September 2026). Two machines published into the same live tree, so full-tree deploys were banned. A stale copy on one machine must never overwrite the other's work.
- Cache-busting stamps.
--stamprewritesapp.js?v=<md5>into every day page, and--verifypolls the live site until the new version shows up.
Reading data
Pages are static. Each day page loads a bootstrap data.js with the draw, then overlays live results from /api/day without a reload. The custom domain is a Cloudflare Worker that:
- serves pages from the public tree, mapping country and club prefixes and directory URLs to
index.html; - answers
/api/day,/api/resultsand/api/placementsby querying the private collections with a key stored as a Worker secret; - proxies the public
eventsfeed, because the public API sent no CORS headers; - adjusts caching: a build id in the cache key and a short
max-ageon JS and JSON, because the edge had been keeping files for hours.
const found = await q("days", { where: `key = ${esc(key)}`, limit: 1 });
// query results carry no id in .data, so look the id up, then read with $refs resolved
const idr = await fetch(`${API}/days/_query`, { method: "POST", headers: H,
body: JSON.stringify({ where: `key = ${esc(key)}`, select: ["key"], limit: 1 }) });
const id = (await idr.json()).items?.[0]?.id;
const r = await fetch(`${API}/days/${id}?depth=2`, { headers: H });What worked well
- Versions as a free history. The data site rebuilds registration-over-time charts from the
/versionsof each event document. The events index has been rewritten 131 times, and every snapshot is still there. Nobody designed a history table. - One document, two paths. "Today's"
/data.jsand/portugal-open-2026/day1/data.jspoint at the same document, so switching the active day is a tree assignment, not a copy. $refcomposition. One read returns a whole tournament day assembled from four collections.- Hosting and data in one place. Static files, JSON and binaries share one API, one key and one permission model, behind an ordinary custom domain.
- Live data without deploys. Results reach visitors seconds after a poller writes them. Pages only change when the site itself changes.
Warts and workarounds
An honest list. The status column refers to the WREN release that publishes this page.
| What happened | Workaround | Root cause | Status |
|---|---|---|---|
Agents and scripts guessed routes like /api/me and got 404s | Probing, reading /openapi.json | llms.txt documented unversioned routes; everything authenticated is under /api/v1 | Fixed: docs rewritten around a URL map |
| Upsert took two calls (query, then PUT or POST) | Emulated in the Python client | Declaring a natural key is admin-only; the writer's key wasn't admin | By design: an admin declares it once (Part B) |
Keys with / broke by-key URLs | __ separators | Unencoded slashes split the path | Encode the key, or avoid / |
| A 404 stayed cached at the edge for hours after a file was deployed (September 2026) | ?v= stamps, build id in the cache key | Error responses were cacheable | Fixed: errors are no-store |
| Same-length edits weren't deployed | md5 manifest in a REST script | CLI compared sizes | Fixed: SHA-256 stored on upload, compared by the CLI |
| Deploys stopped when the CLI's cookie expired | REST deploy script with an API key | CLI supported cookies only | Fixed: WREN_API_KEY or wren auth key |
| The browser couldn't call the public API from the custom domain | Worker proxy for /api/events | No CORS on public routes | Fixed: public routes send Access-Control-Allow-Origin: * |
Python's default client got 403 error code: 1010 | Browser User-Agent on every script and Worker request | Cloudflare browser-integrity check in front of the server | Infrastructure setting, not WREN (documented) |
| Live data needed a secret-holding Worker | /api/* routes with a key | Everything stored privately, including data meant to be public | Design choice, see Part B |
| Big days were cut off at 1,000 rows | Requesting 2,000 (silently capped) | _query caps a page at 1,000 | Paginate with cursor, or use a materialized query |
| Date filters didn't work | Filtering in the browser | Dates stored as MM/DD/YYYY strings | Data modelling: store ISO dates |
Windows line endings broke ids in shell glue (slug\r), and two scheduled runs failed silently | Stripping \r | Shell pipelines between scripts | Gone once writes are single upserts |
Part B
Idealized: the same tracker, built today
Same pollers, same pages, same custom domain. What changes is that each problem above is handled by a WREN feature instead of code around it. A sibling project, a video archive of fights, already uses most of these patterns in production, and the snippets follow its code.
1 · Data model: one admin step, once
An admin declares each collection's schema and natural key once. After that, any key with write access can upsert. Use separators that are safe in a URL path, and ISO dates so where filters work.
PUT /api/v1/results/_schema
{
"naturalKey": "key",
"displayName": "{event} · day {day} · ring {ring}",
"schema": {
"type": "object",
"required": ["key", "event", "day", "ring", "date"],
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9-]+:[0-9]+:[0-9]+$" },
"date": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }
}
}
}2 · Writes: one idempotent call
import os, requests
from urllib.parse import quote
S = requests.Session() # requests sends its own User-Agent, so no 1010 block
S.headers["Authorization"] = f"Bearer {os.environ['WREN_API_KEY']}"
BASE = "https://wren.aemwip.com"
def upsert(collection: str, doc: dict) -> dict:
"""Create on first call, new version afterwards. Safe to re-run."""
r = S.put(f"{BASE}/api/v1/{collection}/by-key/{quote(doc['key'], safe='')}", json=doc)
r.raise_for_status()
return r.json()
upsert("results", {"key": "swiss-open-2026:1:3", "event": "swiss-open-2026",
"day": 1, "ring": 3, "date": "2026-09-26", "bouts": bouts})This replaces the query-then-write client and the separator workarounds, and it makes the shell glue (and its line-ending bugs) unnecessary. With a natural key in place, $refs can point at {"$ref": "results", "$key": "…"} directly, so the Worker's second lookup goes away too.
3 · Aggregates: let WREN keep them current
Placements and medal tables were computed in Python. A materialized query recomputes after every write and is itself a versioned document:
PUT /api/v1/standings/_materialized/medals
{
"query": { "aggregate": { "groupBy": ["event", "country", "place"],
"metrics": { "athletes": { "count": "name" } } } },
"refreshOn": "write"
}For long lists, page through _query with its cursor instead of asking for more than the 1,000-row page limit.
4 · Public data without a secret-holding proxy
Brackets, results and standings are published to anyone who opens the site anyway, so make those collections public and keep only the truly private ones (notes, contact data) behind the API. Live data should be visible the moment it's written, so these rules have no label filter:
POST /api/v1/permissions {"principal":"*", "resource":"collection:results", "access":"read"}
POST /api/v1/permissions {"principal":"*", "resource":"collection:standings", "access":"read"}Public routes now send Access-Control-Allow-Origin: *, so pages on the custom domain can read them directly, with plain fetch or with wren.js:
<script src="https://wren.aemwip.com/wren.js"
data-base="https://wren.aemwip.com/api/v1/orgs/tkd-tracker"></script>
<wren-materialized collection="standings" name="medals">
<template><tr><td>{{country}}</td><td>{{place}}</td><td>{{athletes}}</td></tr></template>
</wren-materialized>The Worker shrinks to what only a Worker can do: map the custom domain and its country and club prefixes onto the tree.
Public means public. Everything a * rule allows is listed at /api/v1/projects and in the org's llms.txt. Personal data such as birth dates belongs in a separate collection without a public rule. Mixing it into a public collection and hiding it in the page is not access control.
5 · Releasing pages: preview, then promote
Site changes are different from live data: you want to check them first. Give the tree a public rule with labelFilter: "published" and release with the CLI:
export WREN_URL=https://wren.aemwip.com WREN_API_KEY=wren_… # keys don't expire mid-tournament wren deploy ./web --tree tkd-scores --label preview --public # first time creates the published-only rule curl -H "Authorization: Bearer $WREN_API_KEY" \ https://wren.aemwip.com/api/v1/tree/tkd-scores/index.html?label=preview # check it wren promote tkd-scores --from preview # every page goes live in one transaction
Deploy compares SHA-256 hashes, so same-length edits are uploaded, and two machines deploying different folders don't clobber each other's files. Promote is a single transaction, so visitors never get a new page with an old app.js. Error responses aren't cached anymore, so the ?v= stamps become an optimization rather than a necessity.
To react to new data (rebuild a static summary, purge a cache, notify someone), subscribe a webhook to document.updated on results instead of polling.
Side by side
| Concern | As built | Idealized |
|---|---|---|
| Upsert | query, then PUT or POST | PUT /by-key/{key} |
| Keys | slug__day__box | slug:day:box, URL-encoded, validated by schema |
| Aggregates | computed in Python | materialized query, refreshed on write |
| Public live data | private, proxied by a Worker with a secret | public read rule, read directly |
| Front end | hand-written fetch overlay | wren.js or plain fetch to public URLs |
| Site deploy | REST script, md5 manifest | wren deploy (SHA-256 diff) |
| Release | live on upload | preview label, atomic promote |
| Caching | stamps, cache-key and max-age overrides | errors not cached; stamps optional |
| Change triggers | polling and manual verify | webhooks |
Still open
Not everything above is possible yet. These are the gaps the project still hits:
- By-key for files. Binary uploads can't be addressed by natural key; the tree path is the de-facto key for site files.
- Natural keys need an admin. That's intentional, since schemas are a contract, but it means a one-time admin step before writers can upsert.
- Edge settings. A Cloudflare browser-integrity check in front of a server blocks some HTTP clients (
403 error code: 1010). That's a setting on the deployment, not in WREN.
Lessons
- Decide what is public on purpose. Most of the proxy code existed because public data was stored as private.
- Give every record a natural key from day one, and have an admin declare it before the first import.
- Treat live data and site releases differently. Data: public, no label filter. Pages:
labelFilter: "published"and promote. - Lean on versions. History, audit and charts over time come free. Don't build them yourself.
- When you write a workaround, write down why. Every "why" comment in this project's code turned into a fix or a line on this page.
New to these ideas? Start with Concepts.