Case study
A webshop and its warehouse stock tracker
The MAE shop sells martial arts gear. Its stock is counted with a barcode scanner (or a phone camera) on a handful of web pages that keep everything in private WREN collections, and the counts are copied into the shop's own database by a script. This is how it was built, what we had to work around, and how we'd build it on WREN 0.10.
The project
There are two systems, and only one of them runs on WREN.
- The shop is a storefront in German, English and French, with club stores for B2B customers. Its code is a Cloudflare Worker with its own PostgreSQL database. Work on it started in March 2026. Its catalog has a few hundred products and over a thousand variants from two main suppliers. An older hosted store also still exists.
- The stock tracker is a set of plain HTML pages. A git repository holds the Python scripts that generate them, a script that publishes them into a private WREN tree, and the scripts that seed, ship and repair data. All data lives in private WREN collections. The tracker was started on 2026-09-08, when the first deliveries were counted, and was built and operated with an AI coding agent working in its own session.
| At a glance | |
|---|---|
| Pages | tree mae: /stock, /order, /receive, /receipts, /events, /shipments, the supplier's Excel order form, and since October a German phone app (/lager) with its service worker, manifest and icons; the files are documents in the binary collection mae-pages |
| Code | a repository of build_*_page.py generators, publish.py, push_to_wren.py (seed), ship.py, upload_invoice.py, one-off repair scripts, and Node and Playwright tests against fake WREN servers |
| Stock | mae-stock: a few hundred item codes, 960 versions (state on 2026-10-05) |
| Other data | mae-order, mae-receipts, mae-events, mae-shipments, mae-drafts, mae-invoices; binary collections for the pages, invoice PDFs and 20 product illustrations |
| Access | nothing public; the pages use the owner's WREN browser session; the scripts share one API key, read from a git-ignored file |
| Link to the shop | a CSV snapshot and two Node scripts, apply-stock.js and apply-eans.js, run by hand |
Part A
As built
The tracker grew in about three weeks of September 2026, one delivery and one tournament at a time. The shop already existed. The first counts reached it on 2026-09-17. The tracker's code went into git on 2026-10-05, together with camera scanning and the phone app.
Data model
Every JSON collection has a schema and a natural key from the first push, so all writes are upserts by key (PUT …/by-key/{key}). The stock document is deliberately small:
{
"naturalKey": "item_code",
"displayName": "{item_code} — {description}",
"schema": {
"required": ["item_code", "quantity", "count_date"],
"properties": {
"item_code": { "type": "string" },
"quantity": { "type": "integer", "minimum": 0 },
"count_date": { "type": "string" },
"counted_by": { "enum": ["scanned", "stand-in scan", "hand-written line", "size tally",
"reported outside the count", "not counted, assumed zero",
"received", "unknown"] },
"ean": {}, "supplier": {}, "category": {}, "count_sheet": {}, "last_received": {}
}
}
}counted_by records how a figure came about, not just what it is. On 2026-10-05 a little over half of the items had been scanned, about one in eight came from a size tally or a hand-written count sheet, about a fifth had only been received, and about one in ten were "not counted, assumed zero". A zero that nobody checked is not the same as a zero on the shelf, and the schema says so.
| Collection | Natural key | Holds |
|---|---|---|
mae-stock | item_code | quantity on hand per item, with how and when it was counted |
mae-order | line_key | supplier order lines: quantity, received so far, price |
mae-receipts | receipt_key | what came out of the boxes, and what became of each line |
mae-events | event_key | stock taken to a tournament booth, what came back, what sold |
mae-shipments | shipment_key | customer orders packed and shipped from the shelf |
mae-drafts | draft_key | the scanner's running tally, saved while you scan |
mae-invoices | invoice_key | supplier invoices, with the PDFs in mae-invoice-files |
The code
None of the pages is written by hand. Each one is generated by a Python script, and the generated HTML is not committed:
- Builders.
build_receive_page.py(about 2,000 lines) writes the scanner page with the supplier catalog and its barcodes embedded as JavaScript, so the page works with no signal.build_lager_page.pywrites the German phone app from the same catalog. The stock, order, receipts, shipments and events pages each have their own builder, andbuild_pwa.pywrites the service worker, manifest and icons that make the phone app installable. - Publishing.
publish.pyreads the tree, compares each built file's SHA-256 with the stored one, and uploads changed files as a new version of the same document (multipartPUTby id), orPOSTs a new document and assigns its tree path. Every change is live at once. - Seed.
push_to_wren.pypushed the 2026-09-08 stocktake and the first orders. It sets each collection's schema on every run, leaves existing stock documents alone unless told otherwise, and upserts order lines and invoices in full. - Movements from the command line.
ship.pyrecords a shipment from a JSON file, moves stock off the shelf and uploads the invoice PDF. Two one-off scripts settled the tournament booth's held and returned lines, with the live document ids written into them. - Tests. A Node harness runs each page's real script against an in-memory fake of WREN. A parity test checks that the phone app writes exactly the documents the desktop scanner page writes. A Playwright test feeds a generated barcode video to Chrome's fake camera and checks that every appearance is counted once, with Chrome's own
BarcodeDetectorand with the zxing fallback that iPhones get.
A Python client of 25 lines (wren.py) wraps the API. Its comments record two things the agent learned the hard way: the API lives under /api/v1 (at the time the docs showed unprefixed paths), and Cloudflare in front of WREN answers Python's default User-Agent with 403 error code: 1010.
How counting works
The scanner page (/receive) treats a USB or Bluetooth barcode scanner as what it is: a burst of keystrokes ending in Enter. It listens for that at document level, beeps once for a known barcode and twice, lower, for an unknown one. An unknown barcode opens a dialog to attach it to an existing product or create a new one, so the next scan just works. Scans of 18 or 19 digits are read as an EAN-13 plus a batch suffix. The check digit only ranks the possible readings, because one supplier publishes at least one barcode with a wrong check digit. Since October the page can also read barcodes from the phone's camera, and the phone app (/lager) does one job at a time in plain German for someone with a box in one hand.
It has four modes:
- Delivery scans the boxes against a supplier order and writes a receipt. It does not touch stock. Each line is settled later on the receipts page: onto the shelf, back to the supplier, or held.
- Inventory counts a shelf and sets the quantity of the scanned products, and only those.
- Event packs stock for a tournament booth and scans it back afterward. The one event so far, a tournament in mid-September, was still open at the time of the backup, with most of what went out scanned back.
- Ship packs a customer order. It beeps when one too many is in the box and flags products that aren't on the order.
Nothing is written to stock until you press the button. Until then the tally lives in two places: in the browser at once, and in WREN within a second, as a mae-drafts document. A dead phone, a cleared browser or a second device loses nothing; on load, the newer copy wins.
// Write a quantity, reading the document first so we never clobber a change.
async function writeQuantity(code, {delta, set, seed, countedBy}) {
const key = encodeURIComponent(code);
let doc = null;
try { doc = await api('GET', `/mae-stock/by-key/${key}`); } catch (e) { if (e.status !== 404) throw e; }
const data = doc ? doc.data : seed;
const wanted = set !== undefined ? set : (Number(data.quantity) || 0) + (delta || 0);
const next = {...data, quantity: Math.max(0, wanted), /* counted_by, dates … */};
return api('PUT', `/mae-stock/by-key/${key}`, next); // new version, same document
}The comment promises more than WREN can give: reading first doesn't stop a second device from writing between the read and the write. More on that under warts.
Two labels mark versions worth coming back to. In the page's own words, "Wren already versions every document, so a label is not an audit trail — it is a name for one version worth coming back to." counted on a stock document marks the version that was a real physical count, as opposed to arithmetic from deliveries. as-recorded on a receipt keeps what came out of the box before anyone settled lines.
One item's history
Every movement is a new version of the stock document, and event, shipment and receipt lines record which stock version they produced (stock_version_out, stock_version_back). Here is a white dobok in one size, from its first count to the second delivery:
mae-stock document. Nobody designed a movement log; the version history is one.From tracker to shop
The shop never reads WREN. On 2026-09-17 the agent working on the tracker sent the agent working on the shop a stock snapshot as text, and the shop side saved it as wren-stock-20260917.csv, with columns item_code, on_shelf, returns_not_yet_shelved, still_out_at_event, supplier, description. A new script, apply-stock.js, takes the first two columns:
node packages/db/scripts/apply-stock.js packages/db/data/wren-stock-YYYYMMDD.csv [--dry-run] [--zero-missing]
- Matching codes. One supplier's item codes are the shop's variant SKUs. The other supplier's codes are article references, mapped with the same transform the catalog import uses (a prefix, lower case, spaces removed).
- Through the shop's own function. Every change goes through
shop.admin_update_stock, so product totals and the revision log stay as consistent as with the admin UI. - First run. The dry run matched all but a handful of codes. One shop SKU had been renamed, and a few codes weren't in the shop yet.
- Placeholders exposed. About 40 shop variants with stock were not in the snapshot. Every one of them still had its value from the shop's March 2026 seed, with no stock change since: placeholders, not counts. The tracker added them as "not counted, assumed zero", with the shop's figure in a note, and the shop zeroed them with
--zero-missing. - Barcodes the same way. The shop had barcodes for only a few percent of its variants. The tracker sent the barcodes from
mae-stockplus several hundred more from one supplier's catalog, andapply-eans.jsloaded them, cutting 18 and 19 digit scanner reads to EAN-13 and skipping placeholder codes.
That was the only sync. On 2026-10-05 the shop still showed the counts of September 17, while the tracker had moved on after a second delivery.
Timeline
| Date (2026) | What happened |
|---|---|
| Mar 29 – Apr 4 | Shop repository started: storefront, MCP server, club stores, an EAN column on products and variants |
| Sep 8 | WREN org created; first stock count, the first supplier orders, and the stock, order and receive pages |
| Sep 11 | First deliveries scanned; packing for a tournament booth; the scanner page changed 22 times that day |
| Sep 14 – 16 | Ship mode and the shipments page; event returns scanned |
| Sep 17 | First snapshot applied to the shop; apply-stock.js and apply-eans.js committed; uncounted lines added to the tracker |
| Sep 25 | Shelf recount with the new counted label; 20 product illustrations uploaded |
| Sep 30 | Second delivery received and settled |
| Oct 1 | Last writes before the backup used for this page |
| Oct 5 | Tracker code committed to git; camera scanning, installable phone app, German warehouse app; a code review of how it uses WREN |
What worked well
- Upsert by key from day one. Every collection declared its natural key on the first day, so the pages could write
PUT /mae-stock/by-key/{item_code}without looking up ids. - Versions as the movement log. 960 versions of the stock documents, each one a count, a delivery, an event or a shipment. Event and shipment lines point at the stock version they produced, so any number on the stock page can be traced back.
- Schemas that say what a number means.
counted_byseparated real counts from assumptions. That's what showed the shop's seed values for what they were. - Drafts that survive. A tally saved to WREN within a second meant a long event return (one draft has 217 versions) never depended on one phone.
- No server of its own. Static pages and a spreadsheet in a tree, same-origin with the API, using the browser session. There is no backend to deploy, and the pages run on any phone or laptop.
- Files next to data. The supplier's Excel order form lives in the same tree. The order page fills its quantity cells in the browser and hands back the workbook, with formatting and formulas intact.
- Tests that run the real page code. The parity test is what lets two front ends (desktop and phone) write the same data safely.
Warts and workarounds
An honest list, from the data and from a review of the code. The status column refers to WREN 0.10.0, the release that publishes this page.
The workarounds from all case studies, with the better way for each, are collected in I did this. Is there a better way?
| What happened | Workaround | Root cause | Status |
|---|---|---|---|
| Stock reached the shop once, as text passed between two agent sessions, and was 18 days old by October 5 | CSV plus apply-stock.js, run by hand | The shop side had no WREN credentials. When the shop's agent looked for a key in the server's configuration, its session blocked that, correctly. | Design: a narrowed read key or a webhook (Part B) |
| About 40 shop variants showed stock nobody had counted | --zero-missing; "not counted, assumed zero" in the tracker | The shop's seed values looked like counts | Data modeling: record how a figure was obtained |
| Two devices moving the same item within a second or two: one movement is lost, silently | None; "reading first" doesn't prevent it | Every write is read, compute, PUT the whole document; WREN has no conditional write | Still open; detect it after writing (Part B) |
| An apply that failed halfway, retried, moves the lines that already went through a second time | None; the phone app asks the warehouse to call the owner | The tally is cleared only when every line succeeded; ship.py writes the record last | Code fix: settle each line as it's written (Part B) |
| Re-running the seed would overwrite live stock and order edits | Treated as a one-off | It reads existing stock with ?limit=1000, but lists return at most 200, newest first, so the oldest documents look missing; order lines are upserted in full | Code fix: create only, read with a cursor (Part B) |
| A failed tree assignment, or an invoice uploaded twice, leaves orphaned documents | None | POST isn't idempotent; files had no name to upsert by | Fixed: files by name (0.9.0) |
| Each script run resets schema settings changed in the Admin UI | None | Scripts send PUT …/_schema on every run, which replaces the whole record, including the natural key | Fixed in 0.10: PATCH …/_schema changes only the fields sent |
| The phone app's service worker can answer a read-before-write from its offline cache | The page sees an offline header but uses the copy | Offline support and arithmetic on stale data | Code fix: never compute a write from a cached read |
The tally autosave made 477 versions in mae-drafts, 217 of them on one document | None | Every write keeps the previous version | Fixed: retention policies (0.8.0) |
| Pages were changed while in use: the scanner page has 34 versions, 22 from the first delivery day; the pages' history takes 7.3 MB | None; each upload was live at once | No preview step, and every version kept in full | Preview and promote; retention (stored-once only helps with identical bytes: 70 of 71 page versions differ) |
| All 2,290 versions in the org are recorded under the same user, whether a scan or the agent wrote them | A device id in drafts and via on each scan | One owner account; the scripts' key acts as its creator | Groups, invites, MCP sign-in (Part B) |
| The scripts' key can do anything the owner can, and never expires; one script put it on curl's command line | None | No key: rules on it | key: rules narrow any key, including the owner's (0.7.0) |
| Product illustrations needed a key, so the shop copied them from the older public store instead | Download from the public site | Everything stored privately, including images meant to be public | Public collection rule with a label filter (Part B) |
Only about a fifth of the items carry the counted label | None | The label arrived halfway through the recount of September 25, and failures to set it are ignored on purpose | Lesson: decide labels before the first write; wrong labels can now be removed (0.9.0) |
| A second device only sees new scans after a reload | A Refresh button | Pages read on load | Live events (0.7.0) |
| One supplier's whole catalog is embedded in the scanner page and the phone app | Rebuild the pages when the catalog changes | Reference data kept in code, partly so the app works offline | Trade-off: a catalog collection, cached by the service worker |
Part B
The same system, built today
Same pages, same scanner, same shop database. Stock stays private: it's operational data, not something to publish. What changes is how the counts reach the shop, how the scripts survive failures, who is recorded as writing, and how pages and history are managed. Everything below is checked against WREN 0.10.0.
1 · Stock to the shop, without a snapshot
Give the shop its own key and narrow it to reading stock. A key with key: rules uses only those rules:
POST /api/v1/keys {"name": "shop-stock-read"} → id + key (shown once)
POST /api/v1/permissions {"principal": "key:<id>", "resource": "collection:mae-stock", "access": "read"}Hand the key over through a secret store, never a chat. With it, apply-stock.js reads mae-stock directly instead of a CSV: POST /api/v1/mae-stock/_query with select: ["item_code", "quantity", "ean", "counted_by"], up to 1,000 per page, following the cursor. The matching and admin_update_stock stay as they are. Because counted_by comes along, the shop can treat "not counted, assumed zero" differently from a scanned zero instead of losing the difference in a CSV.
To stay current without anyone running a script, subscribe a webhook. Webhooks filter by event type, so the receiver skips other collections. It reads each changed item by key and applies it:
// POST /api/v1/webhooks {"url": "https://<shop>/hooks/wren", "events": ["document.created", "document.updated"]}
async function onWrenWebhook(req) {
const body = await req.text();
if (!validSignature(body, req.headers.get("X-Wren-Signature"), WEBHOOK_SECRET)) return new Response(null, { status: 401 });
for (const { payload } of JSON.parse(body).events) {
if (payload.collection !== "mae-stock" || !payload.key) continue;
const r = await fetch(`${WREN}/api/v1/mae-stock/by-key/${encodeURIComponent(payload.key)}`,
{ headers: { Authorization: `Bearer ${SHOP_STOCK_KEY}` } });
const { data } = await r.json();
await applyStock(data.item_code, data.quantity); // same SKU matching + shop.admin_update_stock
}
return new Response(null, { status: 204 });
}Events within a few seconds arrive as one delivery, failed deliveries are retried, and POST /api/v1/webhooks/{id}/replay re-sends the last seven days, so a shop outage only delays the update. Since 0.9, a write whose data equals the current version creates no version (the response says "unchanged": true), so a recount to the same figure doesn't wake the receiver for nothing.
2 · Writes that survive retries and a second device
Most of the code review's findings are about what happens when a write fails halfway or a second writer acts at the same moment. Three changes cover them:
- Settle each line as it's written. Remove a row from the tally (and the draft) as soon as its stock write succeeds, and save the shipment, event or receipt after each line. A retry then replays only what failed.
ship.pyskips lines that already carry astock_version_out. Create a receipt's key once, when the tally starts, and keep it in the draft, so a retried save upserts the same receipt. - A seed that can't overwrite. The seed only creates documents that don't exist (
GET …/by-key/{key}answers 404) and never touches a live one. Reading "everything" goes through_querywith a cursor, never?limit=1000on the list endpoint, which returns at most 200. - Notice a lost update. WREN can't refuse a stale write yet, but version numbers go up by exactly one per write, so the writer can tell afterwards that someone else wrote in between, and re-apply its change on top:
def move(code, delta):
"""GET → PUT on one stock document, and notice when someone else wrote in between."""
path = f"/mae-stock/by-key/{quote(code)}"
c, doc = call("GET", path)
data, v0 = doc["data"], doc["version"]
c, saved = call("PUT", path, {**data, "quantity": max(0, int(data["quantity"]) + delta)})
expected = v0 if saved.get("unchanged") else v0 + 1 # unchanged: no new version (0.9)
if saved["version"] != expected:
# someone wrote between our GET and PUT, and our PUT replaced it: re-apply on top of theirs
c, theirs = call("GET", f"/mae-stock/{saved['id']}/versions/{saved['version'] - 1}")
fixed = max(0, int(theirs["data"]["quantity"]) + delta)
c, saved = call("PUT", path, {**theirs["data"], "quantity": fixed})
return saved["version"]The pages get the same check after their PUT, or at least a warning ("someone else changed this item at the same time"). And a read that comes right before a write must not be answered from the service worker's offline cache.
3 · Snapshots before risky runs, and a way back
The booth repairs ran against live data with hard-coded ids and no way back except rolling documents back one by one. Label the collection first; if the run goes wrong, one call puts it back:
# before a recount, a seed with overwrite, or a repair script: label every stock document
POST /api/v1/mae-stock/{id}/labels {"label": "before-recount-2026-10"} ← per document
# if it went wrong: one transaction for the whole collection (0.9)
POST /api/v1/mae-stock/_restore {"label": "before-recount-2026-10"}
→ { "label": "before-recount-2026-10", "restored": …, "undeleted": …, "deleted": 0, "unchanged": …, "collections": ["mae-stock"] }Restore writes the labeled content as new versions, brings back deleted documents that carry the label, and leaves documents already at the label alone. History moves forward, so the restore itself can be undone. To see what a recount changed on one item, diff the label against the current version: GET /api/v1/mae-stock/{id}/diff?v1=before-recount-2026-10&v2={version}; on receipts, &deep=true names the changed line (/lines/3/quantity) instead of reporting the whole lines array as replaced. A counted label set on the wrong version can be removed: DELETE /api/v1/mae-stock/{id}/labels/counted. And make label failures visible in the page instead of swallowing them.
4 · Publishing pages: by file name, preview, then promote
publish.py is the deploy path this project actually uses, so keep it and let WREN do the bookkeeping. Since 0.9, a file collection whose natural key is filename is addressed by name. Setting the key registers the names of the existing files; if two live documents share a name (the orphans from failed runs), WREN refuses with 409 and lists them, so delete those first:
call("PUT", "/mae-pages/_schema", {"collectionType": "binary", "naturalKey": "filename",
"displayName": "{filename}"}) # 200, or 409 listing duplicates
for path, src in files(pages):
code, doc = upload("PUT", f"/mae-pages/by-key/{quote(src.name)}", src) # 201 created / 200 replaced / 200 unchanged
if code == 201:
call("PUT", f"/tree/mae{path}", {"documentId": doc["id"]}) # new file: mount it once
if not doc.get("unchanged"):
call("POST", f"/mae-pages/{doc['id']}/labels", {"label": "preview"})No tree listing, no hash comparison in the script, no orphans: an unchanged file returns "unchanged": true and creates no version. Do the same for invoice PDFs (PUT /api/v1/mae-invoice-files/by-key/{invoice_key}.pdf), so uploading an invoice twice is a no-op. And stop the scripts from sending PUT …/_schema on every run, or the next run clears the natural key again.
The scanner page was changed 22 times on the first delivery day, each change live on the phone doing the scanning. Once helpers use the app, try a change at /api/v1/tree/mae/lager?label=preview, then release all pages at once with POST /api/v1/tree/mae/_promote {"label": "published", "from": "preview"}, one transaction. That protects people whose rule on tree:mae has labelFilter: "published"; the owner always sees the current version. If a release was bad, POST /api/v1/tree/mae/_restore {"label": "release-…"} puts every page back to a release label in one transaction.
5 · People and agents under their own names
Today every version says the owner wrote it. Invite the people who scan into a group with write access to the tracker's collections, so each version names who made it:
POST /api/v1/groups {"name": "Warehouse", "description": "scan and count"}
POST /api/v1/permissions {"principal": "group:<gid>", "resource": "collection:mae-stock", "access": "write"}
…same for mae-order, mae-receipts, mae-events, mae-shipments, mae-drafts
POST /api/v1/permissions {"principal": "group:<gid>", "resource": "tree:mae", "access": "read", "labelFilter": "published"}
POST /api/v1/invites {"email": "…", "role": "member", "groupIds": ["<gid>"]}Label filters on the tree, not on the data. Under a label-filtered rule, version history and diffs return 403. The receipts page rolls stock back by version number, so the helpers' rules on the data collections must not have a label filter.
Narrow the scripts' key with key: rules to the mae-* collections it writes and tree:mae. Agents can connect to the built-in MCP endpoint instead of holding a long-lived key: signing in through /mcp/login shows a consent page, acts as that user in that org, and issues one-hour tokens that can be revoked under Settings → Connected apps. MCP writes are labeled preview by default, which suits proposals such as a reorder list a person then accepts. For stock writes, pass label: "".
6 · Live pages
Two people scanning at once should see each other's work without a reload, which also makes lost updates rarer. The pages are on the same origin as the API, so the session cookie is enough:
const es = new EventSource("/api/v1/_events?collections=mae-stock,mae-drafts");
es.onmessage = e => {
const ev = JSON.parse(e.data);
if (ev.type === "reset") return reloadAll(); // missed events: re-read
if (ev.collection === "mae-stock" && ev.key) refreshItem(ev.key); // events carry keys, never data
};First let the stream past the phone app's service worker, which would otherwise try to cache an endless response. Range filters compare ISO dates since 0.9, so the shipments, receipts and events pages can ask only for recent records (where=shipped_on>=2026-09-01) instead of reading everything. Server-side totals (_query with aggregate) help less than they seem here: the stock page shows every row anyway.
7 · Retention: keep the counts, drop the noise
Stock history is the audit trail and must stay complete: receipts, events and shipments store stock version numbers for rollback. Draft autosaves and superseded page builds can go. Preview first; current and labeled versions are always kept:
POST /api/v1/retention/mae-drafts/_preview {"maxVersions": 5}
PUT /api/v1/retention/mae-drafts {"maxVersions": 5}
PUT /api/v1/retention/mae-pages {"maxVersions": 10} # later {"afterLabel": "published"}
PUT /api/v1/retention/mae-stock {} # exempt from any org default
…{} also for the receipts, events, shipments, orders and invoicesDrafts always change, because each save writes a fresh timestamp, so unchanged writes don't slow their growth; retention does. Label the versions you want to keep forever before you set a policy: counted on every real count, as-recorded on every receipt, and the before-… snapshots. Labeled versions survive every rule.
8 · Public images, private everything else
The 20 product illustrations are meant for the storefront. A collection rule with a label filter publishes exactly those, and only once they're labeled:
POST /api/v1/permissions {"principal": "*", "resource": "collection:mae-product-images",
"access": "read", "labelFilter": "published"}
# the shop links: /api/v1/orgs/{slug}/mae-product-images/{id}/raw/{name}.pngUse a collection rule, not a tree rule. The images are also mounted in the mae tree, next to the stock pages. A tree:mae rule would publish every page in it. Public access is granted per whole tree or collection, so anything public needs its own tree or collection.
Side by side
| Concern | As built | Built today |
|---|---|---|
| Writes | PUT /by-key/{key} | same, plus a version check after each write |
| Failed applies | retry replays every line | each line settled as it's written |
| Stock to the shop | CSV passed between agent sessions, script run by hand | webhook plus by-key read with a narrowed key |
| Barcodes to the shop | one-off CSV, apply-eans.js | the same webhook, or _query on ean |
| Page and PDF uploads | tree lookup, hash check, PUT by id or POST (orphans on failure) | PUT …/by-key/{filename} |
| Page changes | live on upload | preview label, atomic promote, tree _restore to a release |
| Undo a bad run | roll back document by document | label first, collection _restore |
| Who wrote it | the owner, always | each helper and each agent by name (groups, invites, MCP sign-in) |
| Agent and script access | owner-equivalent key, no expiry | MCP sign-in or a key narrowed with key: rules |
| Other devices | reload | live events |
| History size | everything kept, drafts included | retention per collection; stock kept in full |
| Product images | private; copied from another site | public collection rule, label-filtered |
Still open
- Conditional writes need adopting. Since 0.10 a write can say "only if still at version n": the scanner sends the version it read as
If-Matchand gets412with the current version if someone wrote in between, then re-reads and re-applies the count. Imports use?ifVersion=0so a re-run can't overwrite live stock. The pages and scripts as built don't send it yet; that is part of the shop's own to-do list. - Schema changes: use PATCH in scripts.
PUT …/_schemastill replaces display name, columns, indexes and natural key together. Since 0.10,PATCH …/_schemachanges only the fields sent, so a script that sets its schema on every run no longer drops settings made in the Admin UI. - Webhooks can't be filtered by collection. They filter by event type, so a receiver sees every write in the org and has to discard most of them.
- Public files by name. Since 0.10, a public file collection keyed by filename is also served by name:
/orgs/{slug}/{collection}/by-key/{name}/raw. Stock photos stay private here, so this matters only for files the shop chooses to publish. - Two systems of record. WREN knows what's on the shelf, and the shop knows what sold. Nothing flows back: goods leave the tracker's stock only when they're scanned in Ship mode. And mapping supplier codes to shop SKUs lives in the shop's scripts, not in either data model.
Lessons
- Record how a number was obtained, not just the number.
counted_bycaught about 40 placeholder quantities that would otherwise have been sold as stock. - Declare natural keys before the first write, for files too. Documents without a name are what failed runs leave behind.
- Design every apply for the retry. Settle line by line, so running it again only does what's left.
- Label before you repair. A label set before a risky run makes undoing it one call.
- Let systems talk to each other, not through agents. A snapshot relayed between two sessions worked once and then went stale. A narrowed key and a webhook keep working when nobody is looking.
- Give every writer an identity. One shared account makes a perfect history that can't say who did what.
For a public, read-heavy project on the same ideas, see the tournament tracker case study. The agent sessions that built this tracker logged their work in the task tracker, and the label-and-restore pattern is worked out for test data in repeatable test data. New to these ideas? Start with Concepts.