Tom Gould

Writing

One source of truth behind a static app

How the second version of Where Should I Live keeps its data in a headless CMS, ships it as static JSON, and refuses to build if the data has drifted.

Where Should I Live ranks cities by tax, costs and visas. The first version was a static JavaScript app with its data in JavaScript files, and adding a city meant editing six or seven of them by hand.

For the second version I wanted a real backend, where data is validated before it’s written and every change can be reviewed. I also wanted to keep a static front end, so that ranking cities still needs no server. This is how the two fit together.

Keep the old shapes

The tax, scoring and visa calculators were already written and tested, and they expect the first version’s JSON shapes. So the first rule was that the backend must produce exactly those shapes, quirks included.

I wrote the shapes down in a data contract before building anything. It lists every field, every string key the code depends on, and a section of quirks to preserve rather than fix. One country code has four letters. London has no exchange rate, because its prices are already in pounds. A city’s headline tax rate is for display only and has nothing to do with the tax tables. Fixing any of those in the backend would quietly break a calculator.

The content model

The backend is Drupal 11, used headless. Cities, countries, visa types, passport groups and visa matrices are content types. Tax regimes and their brackets are paragraphs on the country. A few richer blocks, such as a city’s family costs or a country’s joint filing rules, are JSON fields, validated on the way in. An install script builds every content type and field in code, and the result is exported to config, so a fresh site and the live one match.

A single serialiser turns that content back into the contract’s shapes, and two outputs use it:

  • a JSON API (/api/cities, /api/taxdata, /api/visa, /api/meta), cached until the content behind it changes, which the app reads in development
  • drush wsil:snapshot, which writes the same data to static JSON files that are committed and shipped with the production build

The live app reads the static files, and calls the backend only for accounts.

Writes go through validated commands

Data changes go through console commands, one for each kind of change: add a city, refresh a country’s tax, refresh costs, set an exchange rate, replace a visa matrix. Every command checks its payload against the contract first, and writes nothing if there is a single violation. If the payload is clean, it finds each record by its natural key, such as a country code or a city slug, and updates it in place or creates it.

drush wsil:add-city --json=scripts/data/my-city.json --snapshot
drush wsil:refresh-fx --currency=EUR --rate=0.84 --snapshot

The gate

The main check is drush wsil:snapshot --check. It builds the snapshot from the backend and compares it, field by field, with a reviewed baseline. It’s stricter than a text diff. A missing key and a null are different things. A list that changes length is reported. For a few objects the key order matters to the code that reads them. In a visa matrix, for example, a passport gets the rules of the first group that lists it. For those paths the gate compares key order as well. Numbers are compared with a relative tolerance, so a float that went through the database comes back equal:

if ($ta === 'number') {
  $scale = max(1, abs($a), abs($b));
  if ($a != $b && abs($a - $b) > 1e-9 * $scale) {
    $out[] = sprintf('%s: number mismatch (orig=%s built=%s)', $path, $a, $b);
  }
  return;
}

When I first imported the old data, this gate proved the migration lost nothing: every city and every tax table matched exactly. Since then it does a different job. Any intended change, such as new tax bands or a new city, shows up as a diff. I review the diff, then promote the new snapshot to be the baseline. Anything I didn’t intend shows up too. Cities and tax data must match exactly or the gate fails, and visa differences are reported for review.

The release script

release.sh puts it in order. It rebuilds the snapshot from the backend, runs the gate, and copies the files into the client. Then it runs the real calculators over every tax country and every city, with golden tax figures, and runs the household tests. Only then does Vite build the client. If any step fails, nothing is built.

The household tests include one strict rule: a single person’s figures must be exactly the same with the household model as without it.

Production is the source of truth

The production database holds the real content, and code and config come from git. I make each data change locally first, read the gate’s diff, then run the same command with the same payload on production.

What it bought me

Adding a city is now one command, checked before it writes. On one day this month I added 13 cities, four of them in countries that needed new tax regimes and visa matrices. Every change came with a reviewable diff, and a fresh re-import reproduces the snapshot files byte for byte.

The app the visitor sees is still a set of static files. Everything behind it is there so the data in those files can be checked before it ships.