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.

Written October 2026 from the emulator's code, README and git history (August 2026), its test script, the platform's docs, and the versions and labels the emulator left in WREN (backup of 2026-10-04). Part B is checked against WREN 0.9.0.

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, where on equality and regex joined by AND, select, cursor pages and two metrics, and throws unsupported where on 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 isa Cloudflare Worker that serves Webling's URL scheme (/api/1/…) on top of WREN, plus an admin UI
Built2026-08-02 to 2026-08-05, five commits
WREN dataone 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 dataa coherent club: 8 members in nested groups, a 2026 accounting period, invoices (paid and open), VAT, articles, documents, users, presence lists
Testsworker/test.mjs runs the Worker's request handler in Node against the real WREN backend
Usersthe 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 as built The club apps' connector and the test script call the emulator Worker with Webling URLs. The Worker translates them to WREN calls on one collection per Webling type, keyed by the Webling id, and adds an /api/_wren namespace for labels, restore, versions and diff. A Python seeder writes the test data to WREN with idempotent by-key upserts. Club apps via the connector WEBLING_BASE_URL test.mjs, curl, UI snapshot, run, restore ?apikey= like Webling seed_wren.py schemas + PUT by-key Emulator (Worker) /api/1/member?filter=… Webling filters, shapes, /changes /api/_wren/labels /api/_wren/restore versions, diff, rollback WREN · shared org member membergroup debitor account … 30 collections natural key = Webling id by-key/110 labels per document test-snapshot other projects' data in the same org
Figure 1. As built: the apps think they talk to Webling; the data, its history and its snapshots live in WREN.

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:

WeblingWREN
GET /api/1/member/110GET /api/v1/member/by-key/110
PUT /api/1/member/110PUT /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/configGET /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":

http · emulator routes
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 back

Every 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}/labels each.
  • 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."
js · emulator, simplified
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:

bash
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"    # restore

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

Member 110's last three versions Version 58 has town Bern and carries the test-snapshot label. Version 59, written by the test, has town Zürich. Version 60, written by the restore, is a copy of version 58 with town Bern. Member 118 was created and deleted again by the tests and exists as 64 deleted documents. member 110 (natural key "110") v58 Ort: Bern v59 Ort: Zürich v60 (current) Ort: Bern test-snapshot the test's edit restore: rollback to v58 writes a new version member 118: created and deleted again by the tests. 64 deleted copies by 2026-08-04.
Figure 2. Restore never rewinds. It writes the labeled data again, so the restore itself is in the history and can be undone.

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.mjs checked 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_URL pointed 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 happenedWorkaroundRoot causeStatus
A snapshot took "a few hundred wren requests", seconds rather than millisecondsAccepted: "Fine for testing, not a hot path"Labels are set one document at a timeBetter: 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 itOne retry after 250 ms on network errorsNo bulk rollback; each rollback is its own requestFixed in 0.9.0: POST /{collection}/_restore and POST /tree/{name}/_restore, one transaction each
Listing which snapshots exist walks every version of every documentDone in the WorkerNo call lists labelsBetter: GET /api/v1/{collection}?label=… returns the documents carrying a label, with a total
WREN's diff reported /properties as replaced when one field changedThe Worker computes its own field-level diffDiff compared top-level fields onlyFixed in 0.9.0: deep=true reports each changed nested field
Webling's sync API needs a revision counterFaked from the newest updatedAtNo change feedFixed: live events with ids, versions and Last-Event-ID (0.7.0)
Test data shared an org with other projectsThe raw-data browser is fenced to the emulator's own collections; 29 per-collection write rules for the seed keyOne org for everythingUse 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 118NoneEvery run writes an edit and a rollback; a deleted key re-created by PUT by-key is a new documentBetter: 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-04NoneThe test labels the current state at the start of each run, and setting a label again moves itBy 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 membersNo pagingPage with offset (200 per page) or _query with cursor
"Local" development depended on an internet-hosted, shared storeNoneOne WREN server for everythingRun a WREN container for CI (reusr1/wren)
Python's default User-Agent was blocked (Cloudflare error 1010), and the Worker's requests needed one tooA custom User-Agent on every requestBrowser-integrity check in front of the serverInfrastructure 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.

Fixture lifecycle Seed with by-key upserts and assign each document to a fixtures tree. Promote the tree to a dated fixture label in one transaction. Tests read at the label or write current data while a live event stream records what the system under test wrote. Afterwards, diff the label against current, then restore each collection to the label in one transaction, deleting documents the test created. A retention policy keeps labeled versions and removes scratch versions. 1 · Seed PUT by-key/{key} assign to tree fixtures 2 · Label tree _promote fixture-2026-10-01 3 · Run tests read ?label= or write current 4 · Inspect diff label vs current events of the run 5 · Reset _restore {label, deleteUnlabeled}, one transaction Retention: labeledOnly keeps fixtures and current, drops scratch hourly next run
Figure 3. A fixture is a label on a set of versions. Tests can move the current data anywhere; the label stays where it was put.

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:

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

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

Re-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.

typescript · @usewren/client
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:

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

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

typescript
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.

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

bash · admin
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:

bash
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

ConcernAs built (August 2026)Today (0.9.0)
Isolationshared org, collections fenced in codean org per suite, groups, narrowed keys
Stable idsWebling id as natural keysame: PUT …/by-key/{key}
Snapshotlabel every document, one request eachtree _promote, one transaction
Snapshot nametest-snapshot, re-set every rundated, never moved: fixture-2026-10-01
Read a past state?label= per document?label= on lists, by-key and _query
Restoreper-document rollback and delete_restore {label, deleteUnlabeled}, one transaction per collection
What changedown field-level diffdiff?v1={label}&v2={n}&deep=true
Re-seedinga new version per document per rununchanged writes create no version
Observe writesrevision faked from updatedAt/api/v1/_events
Version growthunboundedretention: labeledOnly

Still open

  • One transaction per collection, or per tree. A collection _restore is atomic for that collection; several collections take several calls. A tree _restore spans 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 undelete or 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

  1. 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.
  2. A label is a pointer, so name fixtures like releases. Use dated names and never set a fixture label from inside a test.
  3. Label a fixture set in one step. Put fixtures in a tree and promote it, instead of labeling documents in a loop.
  4. Reset forward, not backward. Restore writes new versions, so every reset is visible and reversible.
  5. Clean up old fixture names. Labels can be removed now; a label you no longer need otherwise keeps its versions safe from retention forever.
  6. 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.