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.

Written October 2026 from the project's code, deploy scripts and history. Numbers are from the public /api/v1/projects and /orgs/{slug}/llms.txt on 2026-10-04.

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
WritersPython 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 dataprivate JSON collections draws, results, standings, streams, days; a binary collection for site files
Sitetree tkd-scores with 126 paths: 28 event folders and 45 day pages; 1,852 public documents in the org
Front doora Cloudflare Worker on the custom domain, in front of the public tree and a small private API
Companion data sitepublic 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.

Architecture as built Python pollers write to private JSON collections with an API key. Deploy scripts upload site files to a binary collection and assign tree paths. A Cloudflare Worker on the custom domain serves pages from the public tree, and answers /api routes by querying the private collections with a secret key. The browser loads a bootstrap data.js and overlays live results from the Worker's /api/day. Python pollers parse draws, poll scores Bearer key, browser UA Deploy scripts md5 manifest, ?v= stamps multipart PUT + tree PUT WREN · private collections draws results standings streams days "days" composes the rest via $ref WREN · public tree tkd-scores (126 paths) HTML, JS, data.js as binary Cloudflare Worker custom domain /api/* with secret key cache-key + max-age fixes Browser
Figure 1. As built: all live data stays private, and the Worker is the only way the browser reaches it.

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:

python · simplified
# 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 document

The days document ties a tournament day together. Instead of copying data, it holds query references that WREN resolves on read with ?depth=2:

python
"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 PUT to the existing document, or POST plus a tree PUT for 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. --stamp rewrites app.js?v=<md5> into every day page, and --verify polls 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/results and /api/placements by querying the private collections with a key stored as a Worker secret;
  • proxies the public events feed, because the public API sent no CORS headers;
  • adjusts caching: a build id in the cache key and a short max-age on JS and JSON, because the edge had been keeping files for hours.
js · Worker, /api/day
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 /versions of 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.js and /portugal-open-2026/day1/data.js point at the same document, so switching the active day is a tree assignment, not a copy.
  • $ref composition. 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 happenedWorkaroundRoot causeStatus
Agents and scripts guessed routes like /api/me and got 404sProbing, reading /openapi.jsonllms.txt documented unversioned routes; everything authenticated is under /api/v1Fixed: docs rewritten around a URL map
Upsert took two calls (query, then PUT or POST)Emulated in the Python clientDeclaring a natural key is admin-only; the writer's key wasn't adminBy design: an admin declares it once (Part B)
Keys with / broke by-key URLs__ separatorsUnencoded slashes split the pathEncode 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 keyError responses were cacheableFixed: errors are no-store
Same-length edits weren't deployedmd5 manifest in a REST scriptCLI compared sizesFixed: SHA-256 stored on upload, compared by the CLI
Deploys stopped when the CLI's cookie expiredREST deploy script with an API keyCLI supported cookies onlyFixed: WREN_API_KEY or wren auth key
The browser couldn't call the public API from the custom domainWorker proxy for /api/eventsNo CORS on public routesFixed: public routes send Access-Control-Allow-Origin: *
Python's default client got 403 error code: 1010Browser User-Agent on every script and Worker requestCloudflare browser-integrity check in front of the serverInfrastructure setting, not WREN (documented)
Live data needed a secret-holding Worker/api/* routes with a keyEverything stored privately, including data meant to be publicDesign choice, see Part B
Big days were cut off at 1,000 rowsRequesting 2,000 (silently capped)_query caps a page at 1,000Paginate with cursor, or use a materialized query
Date filters didn't workFiltering in the browserDates stored as MM/DD/YYYY stringsData modelling: store ISO dates
Windows line endings broke ids in shell glue (slug\r), and two scheduled runs failed silentlyStripping \rShell pipelines between scriptsGone 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.

Architecture, idealized Pollers upsert by natural key into collections. Public collections and a materialized medal table are readable directly by the browser via wren.js; only genuinely private data stays behind the API. The site is deployed with a preview label and promoted atomically. The Worker only maps the custom domain. Python pollers PUT …/by-key/{key} wren deploy / promote --label preview, atomic WREN · public, read-only results standings draws schema + naturalKey: "key" _materialized/medals live: no labelFilter, by design private notes stay private WREN · public tree rule: labelFilter "published" pages + bootstrap JSON Browser + wren.js reads public data directly (Worker: domain mapping only)
Figure 2. Idealized: data meant to be public is public, the browser reads it directly, and the site is released with preview and promote.

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.

http · admin, once per collection
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

python
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:

http · admin, once
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:

http
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:

html
<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:

bash
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

ConcernAs builtIdealized
Upsertquery, then PUT or POSTPUT /by-key/{key}
Keysslug__day__boxslug:day:box, URL-encoded, validated by schema
Aggregatescomputed in Pythonmaterialized query, refreshed on write
Public live dataprivate, proxied by a Worker with a secretpublic read rule, read directly
Front endhand-written fetch overlaywren.js or plain fetch to public URLs
Site deployREST script, md5 manifestwren deploy (SHA-256 diff)
Releaselive on uploadpreview label, atomic promote
Cachingstamps, cache-key and max-age overrideserrors not cached; stamps optional
Change triggerspolling and manual verifywebhooks

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

  1. Decide what is public on purpose. Most of the proxy code existed because public data was stored as private.
  2. Give every record a natural key from day one, and have an admin declare it before the first import.
  3. Treat live data and site releases differently. Data: public, no label filter. Pages: labelFilter: "published" and promote.
  4. Lean on versions. History, audit and charts over time come free. Don't build them yourself.
  5. 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.