Case study
Repeatable test data with versions
A club platform needed to test against its member database without touching the real one. It built an emulator of that database's API on WREN, and used labels and rollback to put the whole dataset back after every test run. This is how it worked, what it cost, and how we'd build it on WREN today.
The problem
Tests need data that looks like production and is the same every time they run. Both halves are hard:
- Realistic means related records, real field names, real shapes of responses. A hand-written stub rarely has them.
- Repeatable means a test that writes must not change what the next test sees. A shared test database drifts with every run.
The usual answer is a mock: an in-memory stand-in for the service. Mocks are fast, but they only know what their author taught them. Two projects on this platform show the trade-off in their own code:
- The athlete app's unit tests use a 63-line in-memory fake of WREN. It supports natural keys,
whereon equality and regex joined byAND,select, cursor pages and two metrics, and throwsunsupported whereon anything else. It keeps no versions. - bootOS, a small operating-system project, unit-tests against an in-memory store and keeps one separate live check against the real WREN, which its comment calls "the one that proves the store on the other end really behaves the way the rest of the code assumes".
A mock drifts from the real service the day the service changes. The alternative this page is about: run the tests against a real store that keeps every version, and use those versions to put the data back.
Part A
As built: a Webling emulator on WREN
The platform is a set of web apps for a taekwondo club. Member data lives in Webling, the club's member management system (a CRM). Every app reaches Webling through one connector, which reads its target from WEBLING_BASE_URL. To test "without touching the real Webling instance", an emulator was built in August 2026.
| At a glance | |
|---|---|
| What it is | a Cloudflare Worker that serves Webling's URL scheme (/api/1/…) on top of WREN, plus an admin UI |
| Built | 2026-08-02 to 2026-08-05, five commits |
| WREN data | one collection per Webling resource type (24 object types, 5 singletons) and a presence extension, each with a JSON Schema; in a WREN org shared with other projects |
| Test data | a coherent club: 8 members in nested groups, a 2026 accounting period, invoices (paid and open), VAT, articles, documents, users, presence lists |
| Tests | worker/test.mjs runs the Worker's request handler in Node against the real WREN backend |
| Users | the platform's local development setup: "Everything runs against the webling-emulator with seeded test data. No real Webling, no Gmail, no Cloudflare login." |
The emulator
Each Webling object is one WREN document, and the Webling numeric id is the collection's natural key. That made the mapping almost one to one:
| Webling | WREN |
|---|---|
GET /api/1/member/110 | GET /api/v1/member/by-key/110 |
PUT /api/1/member/110 | PUT /api/v1/member/by-key/110 |
GET /api/1/member?filter=… | POST /api/v1/member/_query, then Webling's filter language evaluated in the Worker |
GET /api/1/config | GET /api/v1/config/by-key/1 |
The seeder declares a schema with naturalKey: "id" on every collection and writes each record with PUT …/by-key/{id}. Its docstring: "Idempotent: uses PUT by-key upserts, safe to re-run." Running it again is also the documented way to go back to the canonical seed.
Snapshot and restore
The emulator adds a namespace that is not part of Webling, "so a test run can be snapshotted and rolled back":
GET /api/_wren/labels known labels + document counts
POST /api/_wren/labels {label} snapshot: label EVERY document
POST /api/_wren/restore {label} restore all documents to a label
GET /api/_wren/{type}/{id}/versions version history of one object
GET /api/_wren/{type}/{id}/diff?v1=&v2= field-level diff
POST /api/_wren/{type}/{id}/rollback/{n} roll one object backEvery Webling read also accepts ?label=, so GET /api/1/member?label=vor-test&format=full returns the members as they were when the label was set. Under the hood, each of these is a loop over WREN's per-document API:
- Snapshot lists every document of every type and sets the label on its current version, one
POST …/{id}/labelseach. - Restore reads each document at the label. If it has no labeled version, it was created after the snapshot and is deleted. If its version differs, it is rolled back to the labeled one. The response counts
{restored, deleted, unchanged}. The README is explicit: "Restore is a true snapshot restore, not a merge."
async function restoreAll(wren, label) {
for (const type of OBJECT_TYPES) {
for (const d of await wren.listDocs(type)) {
const at = await wren.docMeta(type, d.data.id, label); // GET by-key/{id}?label=
if (!at) { await wren.deleteDoc(type, d.data.id); continue; } // created after the snapshot
const now = await wren.docMeta(type, d.data.id);
if (now.version !== at.version) await wren.rollback(type, now.id, at.version);
}
}
}The documented test loop, from the platform's local development guide:
curl -X POST -H "Content-Type: application/json" -d '{"label":"vor-test"}' \
"http://webling.localhost:8000/api/_wren/labels?apikey=test" # snapshot
# … run the tests, mutate freely …
curl -X POST -H "Content-Type: application/json" -d '{"label":"vor-test"}' \
"http://webling.localhost:8000/api/_wren/restore?apikey=test" # restoreThe admin UI exposes the same thing to people: create a named snapshot, list labels with document counts, restore everything to a label, and preview any label's data read-only.
A test run, in versions
The test script exercises the loop itself: it labels every document test-snapshot, changes member 110's town from Bern to Zürich, creates a new member (it gets Webling id 118), checks that a ?label= read still shows Bern and leaves out 118, restores, and checks that 110 is back in Bern and 118 is gone. The data in WREN shows the result of the last run on 2026-08-04:
A second, smaller example
bootOS keeps its whole file system as one WREN document whose versions are file-system snapshots. Its live check, fscheck.js, runs only on demand because "it needs the network and a credential, and it writes". It publishes a new root, labels it fscheck and reads it back by that label, rolls back one version and asserts that the history grew ("Forward, not rewound"), then removes what it wrote. The labels fscheck and known-good are still on that document in WREN.
What worked well
- Time travel without building it.
?label=on every Webling read was a few lines in the Worker, because WREN already resolves a document at a label. - Natural keys kept the foreign ids. Webling's numeric ids became WREN natural keys, so links between records (
parents,children,links) resolved across collections unchanged, and the seeder could be re-run at any time. - Rollback is a write. Restoring never destroyed anything. A bad restore could itself be rolled back, and the history shows every run.
- Tests against the real backend.
test.mjschecked the emulator against WREN itself, including the snapshot and restore round trip, so the emulator's assumptions about WREN were tested on every run. - The whole platform, locally. With
WEBLING_BASE_URLpointed at the emulator, every app ran against the seeded club instead of the real member data.
Pain points
From the code, its comments and the data it left. The status column refers to WREN 0.9.0; in August 2026 none of the "fixed" features existed yet.
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 |
|---|---|---|---|
| A snapshot took "a few hundred wren requests", seconds rather than milliseconds | Accepted: "Fine for testing, not a hot path" | Labels are set one document at a time | Better: tree _promote labels every document in a tree in one transaction (0.5.0) |
| Restore fans out hundreds of calls; one flaky connection could abort it | One retry after 250 ms on network errors | No bulk rollback; each rollback is its own request | Fixed in 0.9.0: POST /{collection}/_restore and POST /tree/{name}/_restore, one transaction each |
| Listing which snapshots exist walks every version of every document | Done in the Worker | No call lists labels | Better: GET /api/v1/{collection}?label=… returns the documents carrying a label, with a total |
WREN's diff reported /properties as replaced when one field changed | The Worker computes its own field-level diff | Diff compared top-level fields only | Fixed in 0.9.0: deep=true reports each changed nested field |
| Webling's sync API needs a revision counter | Faked from the newest updatedAt | No change feed | Fixed: live events with ids, versions and Last-Event-ID (0.7.0) |
| Test data shared an org with other projects | The raw-data browser is fenced to the emulator's own collections; 29 per-collection write rules for the seed key | One org for everything | Use an org per test suite, with groups and narrowed keys (0.6.0) |
| Member 110 reached version 60 between 2026-08-02 and 2026-08-04; 64 deleted copies of member 118 | None | Every run writes an edit and a rollback; a deleted key re-created by PUT by-key is a new document | Better: retention removes unlabeled versions (0.8.0); deleted documents remain as tombstones |
The test-snapshot label was set on 2026-08-02 and moved again on 2026-08-04 | None | The test labels the current state at the start of each run, and setting a label again moves it | By design: use dated fixture labels and never re-set them in a test |
The Worker lists each type with one request (?limit=1000) | Fine for 8 members | No paging | Page with offset (200 per page) or _query with cursor |
| "Local" development depended on an internet-hosted, shared store | None | One WREN server for everything | Run a WREN container for CI (reusr1/wren) |
| Python's default User-Agent was blocked (Cloudflare error 1010), and the Worker's requests needed one too | A custom User-Agent on every request | Browser-integrity check in front of the server | Infrastructure setting, not WREN |
Part B
The pattern today
Same idea: tests run against a real WREN, and versions put the data back. What changes is that a fixture set is labeled in one transaction, kept by retention, compared with diff and observed with live events. The examples use a members collection; any set of collections works the same way.
1 · An org per test suite
Each WREN org is its own Postgres schema. Give each test suite its own org instead of a corner of a shared one: nothing to fence off, and a restore can't touch anyone else's data. Inside it, people get access through groups (every org starts with Editors and Viewers), and the CI key is narrowed with key: rules. An admin sets this up once:
POST /api/v1/keys {"name": "ci members-suite"} ← full key shown once
POST /api/v1/permissions {"principal": "key:<keyId>", "resource": "collection:members", "access": "write"}
POST /api/v1/permissions {"principal": "key:<keyId>", "resource": "tree:fixtures", "access": "write"}A key acts in the org it was created in, so the suite's key can't reach production data in another org. For CI that should not depend on a shared server at all, run WREN itself in a container (reusr1/wren).
2 · Stable ids: natural keys
Fixtures refer to each other, and tests refer to fixtures. UUIDs change every time you seed a fresh org; natural keys don't. Declare the key once, then seed with upserts that are safe to re-run:
PUT /api/v1/members/_schema
{ "naturalKey": "key",
"schema": { "type": "object", "required": ["key", "city"] } }
PUT /api/v1/members/by-key/m-110
{ "key": "m-110", "city": "Bern", "groups": ["g-101"] } ← 201 created; 200 + new version if it changedRe-running the seeder is cheap since 0.9: a write whose data equals the current version (compared by meaning, so key order and whitespace don't matter) returns 200 with "unchanged": true and creates no version. A seed of 8 members run 50 times leaves 8 versions, not 400. Add ?force=true if a test really needs a new version with the same data.
3 · Label a known-good state, in one transaction
Assign every fixture document to a tree, then promote the tree to a dated label. Promote points the label at the current version of every document in the tree in a single transaction, across collections, so there is never a half-labeled fixture. This replaces the emulator's per-document snapshot loop.
import { WrenClient } from "@usewren/client";
const wren = new WrenClient({ baseUrl: process.env.WREN_URL!, apiKey: process.env.WREN_API_KEY! });
const FIXTURE = "fixture-2026-10-01";
for (const m of seed.members) { // idempotent
const doc = await wren.documents.upsertByKey("members", m.key, m);
await wren.trees.assign("fixtures", `/members/${m.key}`, doc.id);
}
// The client has no promote helper; request() calls any /api/v1 route.
const { promoted } = await wren.request<{ promoted: { documentId: string; version: number }[] }>(
"POST", "/tree/fixtures/_promote", { label: FIXTURE });To make an older fixture the one tests use, move a second label in one step: POST /api/v1/tree/fixtures/_promote {"label": "fixture-current", "from": "fixture-2026-09-01"}. Documents without the from label are left alone.
4 · Read the fixture, whatever happened since
Tests that only read can read at the label. They see exactly the labeled versions, no matter what was written after:
GET /api/v1/members/by-key/m-110?label=fixture-2026-10-01
GET /api/v1/members?label=fixture-2026-10-01&limit=200 ← only documents carrying the label
POST /api/v1/members/_query {"where": "city:Bern", "label": "fixture-2026-10-01"}A system under test usually reads current data, not a label. For those tests, reset the current data to the label before (or after) each run.
5 · Reset after a run
The emulator's restore was a loop of hundreds of requests. Since 0.9 it is one call per collection, in one transaction. Every document carrying the label gets the labeled content as a new version (as in Figure 2), deleted fixtures come back, and with deleteUnlabeled the documents the test created are deleted:
type Restored = { label: string; restored: number; undeleted: number; deleted: number;
unchanged: number; collections: string[] };
export async function resetTo(label: string, collections: string[]) {
for (const c of collections) {
const r = await wren.request<Restored>("POST", `/${c}/_restore`, { label, deleteUnlabeled: true });
// e.g. { restored: 1, undeleted: 0, deleted: 1, unchanged: 7, collections: ["members"] }
}
}
await resetTo(FIXTURE, ["members", "invoices"]);Documents already at the labeled content count as unchanged and get no new version, so a reset after a read-only test writes nothing. History moves forward: the restore is itself versioned and can be undone. If a test deleted a fixture and then created a new document with the same key, the restore would give two live documents one key; it refuses with 409 and changes nothing, so delete the newcomer first.
To restore several collections in one transaction, restore the fixtures tree instead: POST /api/v1/tree/fixtures/_restore {"label": "fixture-2026-10-01"} covers every document mounted in it, across collections. It can't see documents a test created outside the tree, so follow it with the per-collection call, or have the system under test mount what it creates.
Deleted fixtures come back. A restore undeletes documents that carry the label, and POST /api/v1/members/{id}/undelete brings back a single one with its whole history. Tests may delete fixtures now; a test that wants to check "removal" no longer needs an "active": false workaround.
6 · What did the test change?
Diff the label against the current version. Since 0.9, v1 and v2 accept label names as well as version numbers, and deep=true walks nested objects and arrays:
const before = await wren.documents.getByKey("members", "m-110", { label: FIXTURE });
const now = await wren.documents.getByKey("members", "m-110");
if (now.version !== before.version) { // a version diffed with itself is a 404
const { diff } = await wren.request<{ diff: { op: string; path: string }[] }>(
"GET", `/members/${now.id}/diff?v1=${FIXTURE}&v2=${now.version}&deep=true`);
// [{ op: "replace", path: "/city", value: "Zürich", oldValue: "Bern" },
// { op: "add", path: "/groups/1", value: "g-102" }]
}Without deep=true, paths are top-level fields, and a change inside a nested record (the Webling objects kept everything under properties) shows up as the whole sub-object replaced. That is why the emulator computed its own field-level diff; with deep=true it no longer has to.
7 · Watch what the system under test wrote
Open a live event stream before the run. Every committed change arrives as one event with the collection, id, version and natural key, from every write path (REST, upserts, uploads, promote, MCP). Events never carry data; read what changed through the API.
curl -N -H "Authorization: Bearer $WREN_API_KEY" \
"$WREN_URL/api/v1/_events?collections=members,invoices"
data: {"type":"ready","at":"…"}
id: …-42
data: {"type":"document.updated","collection":"members","id":"…","version":7,"key":"m-110","at":"…"}In a test, collect these lines while the system runs and assert on them: "exactly one update to m-110, no new invoices". Wait for ready before starting. A reset event means the server couldn't replay what you missed. Webhooks carry the same events in batches, but they only deliver to public addresses unless the server allows a host with WREN_WEBHOOK_ALLOW_HOSTS, so for a local test runner the stream is simpler.
8 · Keep fixtures, drop scratch versions
Every test run adds versions: the system's writes and the reset's rollbacks. In the emulator's org, member 110 reached version 60 between 2026-08-02 and 2026-08-04. A retention policy cleans that up. A document's current version and every labeled version are always kept, so fixture labels are safe by construction:
curl -X POST "$WREN_URL/api/v1/retention/members/_preview" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -d '{"labeledOnly": true}' # what would go; changes nothing
curl -X PUT "$WREN_URL/api/v1/retention/members" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" -d '{"labeledOnly": true}'Policies run hourly, or on demand with POST /api/v1/retention/_apply. Removal is permanent. Only org owners and admins can set them. In a test org, PUT /api/v1/retention/* sets the same rule as the org default. A fixture you retire keeps its versions until its label goes: remove it per document with DELETE /api/v1/members/{id}/labels/fixture-2026-09-01, and the next run trims them.
9 · Agents writing tests
A coding agent writing these tests needs the API, not guesses. Point it at /llms.txt or /llms-full.txt on the server, or connect it over MCP with a key for the test org:
claude mcp add --transport http wren-tests "https://wren.example.com/mcp?readonly=1" \ --header "Authorization: Bearer $WREN_API_KEY"
The read tools cover what test authoring needs: query_documents and get_document with a label, list_versions and diff_versions. ?readonly=1 keeps an agent that only inspects fixtures from writing. If an agent does write, note that write_document labels the new version preview unless told otherwise (label: ""), and promote_tree moves labels on a whole tree: confirm before running it against a fixture tree.
Side by side
| Concern | As built (August 2026) | Today (0.9.0) |
|---|---|---|
| Isolation | shared org, collections fenced in code | an org per suite, groups, narrowed keys |
| Stable ids | Webling id as natural key | same: PUT …/by-key/{key} |
| Snapshot | label every document, one request each | tree _promote, one transaction |
| Snapshot name | test-snapshot, re-set every run | dated, never moved: fixture-2026-10-01 |
| Read a past state | ?label= per document | ?label= on lists, by-key and _query |
| Restore | per-document rollback and delete | _restore {label, deleteUnlabeled}, one transaction per collection |
| What changed | own field-level diff | diff?v1={label}&v2={n}&deep=true |
| Re-seeding | a new version per document per run | unchanged writes create no version |
| Observe writes | revision faked from updatedAt | /api/v1/_events |
| Version growth | unbounded | retention: labeledOnly |
Still open
- One transaction per collection, or per tree. A collection
_restoreis atomic for that collection; several collections take several calls. A tree_restorespans collections in one transaction, but only covers documents mounted in the tree, so it can't delete documents a test created elsewhere. A reset that fails between two calls leaves a mix; running it again converges, because documents already at the label count as unchanged. - Old versions of deleted documents. A deleted document comes back whole with
undeleteor a restore, but while it is deleted its versions and diffs aren't readable. - No org or collection copy. The platform's architecture plan asks for "cloning a seed workspace" so each end-to-end run gets a fresh club. WREN has no clone call; seeding by key into a fresh org is the substitute.
Lessons
- Test against the real store when you can. The emulator tested its WREN assumptions on every run; the in-memory fakes only test what their authors remembered.
- A label is a pointer, so name fixtures like releases. Use dated names and never set a fixture label from inside a test.
- Label a fixture set in one step. Put fixtures in a tree and promote it, instead of labeling documents in a loop.
- Reset forward, not backward. Restore writes new versions, so every reset is visible and reversible.
- Clean up old fixture names. Labels can be removed now; a label you no longer need otherwise keeps its versions safe from retention forever.
- Give tests their own org, and set a retention policy in it on day one.
The same label-then-restore move protects production data too: the MAE stock tracker labels its stock before a recount, and AI-maintained sites restore a tree to a release. New to these ideas? Start with Concepts.