Most of the crawls are empty. That's the useful part.
19,245 crawls of OpenRouter. Three quarters of them change nothing that matters. The change feed is hiding in the other quarter, if you skip the clocks.
I have 19,245 crawls of OpenRouter in a pile. About three quarters of them contain no selected core change. The interesting number is not 19,245. It's the 22,947 field-level events hiding in the other quarter.
This is the bit of ORCA that looks like overengineering until you live with a catalog that twitches every hour.
Three steps, on purpose
The pipeline is deliberately boring:
- Crawl — a Convex cron, hourly, fetches the catalog, gzips the raw payload, stamps a
crawl_id. Preview deploys do not run this. I learned that the expensive way. - Materialize — project the blob into models / endpoints / providers. The UI never reads the archive. The archive is evidence; the views are disposable.
- Diff — consecutive snapshots through
json-diff-ts, atomized into a change history. Monitor and Discord both drink from that.
Note
The snapshot code is marked legacy in its own AGENTS.md. That's not a confession, it's a plan. The raw bundles stay. The projections get to be rebuilt.
Retries on the fetch are quadratic, because linear retries just hammer a flapping origin:
Three attempts. Then we record the failure against that model and exclude it from both sides of the next diff. A failed fetch is not a mass deletion. I will die on this hill.
The skip list is the product
If you diff everything, you get a change feed of clocks. This is the actual design work:
| Field | Why we skip it |
|---|---|
updated_at, unavailable_at |
A clock is not news. |
stats, status |
Latency jitter every hour. Nobody wants that pinged to Discord. |
icon_url |
CDN churn. The whale does not need a new favicon event. |
| denormalised names on the endpoint | The model didn't change. We copied the label again. |
Arrays are the other trap. Naive index comparison on supported_parameters produces enormous groups of fake updates the moment someone shuffles the list. The fix is to tell the differ that membership is the identity:
const DIFF_OPTIONS = {
keysToSkip: [
"updated_at",
"unavailable_at",
"icon_url",
"stats",
"status",
],
embeddedObjKeys: {
input_modalities: "$value",
output_modalities: "$value",
supported_parameters: "$value",
datacenters: "$value",
},
treatTypeChangeAsReplace: false,
}
That's it. That's the whole trick. A reordering is not a change. An add or a remove is.
A high-frequency price period is often three to twelve field updates per crawl — mostly endpoint prices — not a new universe. Adjacent crawls are frequently identical. Sparsity is the point of storing diffs at all.1
What a change looks like
Monitor doesn't show a blob. It shows this:
{
"path": "pricing.text_input",
"from": "0.90",
"to": "0.42"
}
Green down, red up, a percentage if we can make one without lying. Discord gets the same event squeezed into an embed, which is why the keys are cache_read and not text_cache_read. I have opinions about pixels.

GLM 5.2, thirty days, too many providers. The chart is behind a flag. The skip list is not.
The raw bundles remain the source of truth. A changeset is a convenience on top of evidence we already kept. If the differ is wrong, we still have the gzip. That's the contract: capture what we use as raw artifacts, then build projections we are allowed to throw away.
- Reversible changesets — `diff` / `applyChangeset` / `revertChangeset` — are sitting in a notes folder, not in production. The archive does not depend on them. I am allowed to faff. I am not allowed to make the evidence unrecoverable. ↩