Cloudflare Worker for the modernized NEM dispatch SCADA API. Stage 1 (LAB-416): project scaffold, D1 schema, R2 archive bucket, and the generator reference data seeded from the repo registration CSV. Stage 2 (LAB-417): the 5-minute Cron ingest of CURRENT Dispatch SCADA. Stage 3 (LAB-418): the v2 query API; (LAB-419): the fuel-mix dashboard, served from this same Worker. Stage 4 (LAB-421): the weekly out-of-band generator-registration refresh from AEMO's live workbook.
src/api.ts — GET /api/v2/values, GET /api/v2/values/aggregate?group_by=fuel|tech|region,
GET /api/v2/dispatch, GET /api/v2/rooftop, GET /api/v2/generators, and
GET /api/v2/intensity, serving from D1. A greenfields public
contract (public until abuse is detected — ray, 2026-07-24; the legacy 2015
API is reference-only): columnar lib-agnostic payload, period-ending NEM-time
buckets (AEST, daily buckets end at AEST midnight), net-MW aggregate
convention, all user input bound (never interpolated), and allowlisted
sort/group_by/resolution identifiers. The pinned request/response contract
lives in API.md — it is owned jointly with the LAB-419
frontend; change both together.
Dev deployment: https://nem-api.raywalker.workers.dev (workers.dev is the dev
environment). Production domain will be nem.27b.io — attached at DNS
cutover (LAB-422) via a routes = [{ pattern = "nem.27b.io", custom_domain = true }]
entry in wrangler.toml; the 27b.io zone must live on the same Cloudflare
account as the Worker.
https://nem-api.raywalker.workers.dev is the staging endpoint and must pass
the source-parity checks before the production route is deployed. Deploying the
default branch attaches nem.27b.io through the routes entry in
wrangler.toml; Cloudflare provisions and renews TLS for the custom domain.
Immediately after deployment, verify — a 200 alone is not enough, since the
Worker can return 200 with {"status":"ok", "generators": 0} if the D1
table is empty or the count query silently returns zero rows (D1 failures
return HTTP 500 with {"status":"error"}, which curl --fail already
catches):
curl --fail --silent --show-error https://nem.27b.io/health | \
jq -e '.status == "ok" and .generators > 0'
curl --fail --silent --show-error "https://nem.27b.io/api/v2/values/aggregate?group_by=fuel&hours=1" | \
jq -e '.timestamps != null and .series != null and (.series | length) > 0'
curl --fail --silent --show-error "https://nem.27b.io/api/v2/rooftop?hours=6" | \
jq -e '[.series[].power[] | select(. != null)] | length > 0'The rooftop check is the canonical "not shipped dark" probe (LAB-1919), and its
field name matters: rooftop series carry power — not the values key the
values endpoints use — so a probe that reads the wrong field reports a false
"all null" against a healthy feed. hours=6 always spans published estimates
(the feed lags wall clock by at most ~1 h), so zero non-null values here means
ingest is genuinely dark, not just the live edge.
If production misbehaves, remove the routes entry, deploy that change from
the default branch, and confirm https://nem.27b.io/health no longer reaches
the Worker. The staging endpoint remains available throughout, so it is the
safe verification target while the DNS/route change is rolled back. Restoring
production is an explicit re-addition of the same route followed by a deploy;
do not point the domain at the retired 2015 service. Deleting the routes
entry does not remove the zone's Advanced Certificate for nem.27b.io —
Cloudflare leaves it provisioned; remove it from the dashboard/API if you need
the certificate gone, otherwise expect it to remain as harmless residual
state.
public/ is served via the Workers assets binding on the same Worker/zone
as the API (wrangler.toml [assets]) — no separate Pages project, no CORS.
Requests matching a file under public/ are served as static assets;
everything else falls through to the fetch handler. The hero view is the
OpenNEM-style fuel-mix stacked area over /api/v2/values/aggregate?group_by=fuel
with a region filter (five NEM regions, NEM-wide default), light/dark via
daisyUI data-theme.
- Chart: uPlot 1.6.32, pinned in
package.jsonand vendored intopublic/vendor/(no CDN at runtime). Stacking is diverging — values are net MW per the contract, so each fuel splits into positive/negative halves (batteries dip below zero while charging); the pure transform lives inpublic/stacking.jsand is unit-tested bytest/stacking.spec.ts. - Fuel palette: OpenNEM-style hue families re-stepped to pass CVD /
contrast / lightness gates in both themes (dataviz six-checks validator)
against the exact surfaces in
tailwind.css. Stack order is part of the accessibility mechanism — don't reorder or hand-edit hexes without re-validating. Battery has a reserved violet slot for when the LAB-421 registration refresh makes battery DUIDs resolvable; unknown fuel keys fold to gray, never dropped. - CSS: Tailwind v4 + daisyUI 5, one build step
(
npm run build:css, inputtailwind.css). The outputpublic/assets/styles.cssis generated and committed — same convention as the seed migration — so deploy stays a plainwrangler deploy. Rebuild and commit wheneverindex.html/app.jsclasses change; CI fails if the committed file is stale.tailwind.cssscans an explicit file list, not thepublic/directory: the stylesheet is shared with the station map, and a directory scan meant a class used only there still grew what this page downloads (it also scanned the vendored uPlot bundle, which was 10 kB of the output). Two silent failure modes fall out, so add a new chart-page file to that list or its classes go missing, and note that the map page may use only classes this list already emits —npm run check:classesfails CI if it ever uses one that is gone. The scanner also reads prose: the bare word "grow" in a comment emits.grow{flex-grow:1}. - Time: axis and "as at" stamps are NEM market time (AEST, UTC+10, no
DST) via
Australia/Brisbane— neverAustralia/Sydney. Buckets are period-ending.
public/map.html, served at /map (the assets layer drops the extension) — every dispatching station pinned, coloured by the fuel
carrying most of its registered capacity, sized by that capacity (√-scaled, so
area not radius tracks MW). Click a pin for the drill-down: registration
details, the last 24 h of the station's own output as a sparkline over
/api/v2/values?duid=…, its AEMO CDEII emission factor, and an estimated
emissions rate at current output. Deep-linkable: /map?region=SA1&station=TORRB.
Click a pin for its details. Pan by dragging; zoom with the on-map + / −
buttons, the scroll wheel (no modifier), or a double-click on the basemap
(shift+double-click zooms out); the region buttons jump straight to a region.
Double-clicking a pin opens the station and does not zoom — a marker is a
target, and hitting it twice must not move the map out from under the panel that
just opened. A resize or orientation change keeps the visitor's centre and zoom;
only the height follows the new aspect. The map box is capped at min(62vh, 34rem) and the
viewBox is fitted to it (boxAspect()), never the reverse — a viewBox whose
aspect differs from the box letterboxes under preserveAspectRatio and silently
offsets every pointer coordinate. The cap is also what makes an unmodified wheel
zoom acceptable: there is always page above and below the map to scroll past it.
Interaction here needs a real browser with real pointer events. Four bugs shipped past a DOM-assertion check that reported 213 markers with correct classes and no console errors:
svg.setPointerCapture()in the drag handler retargeted the subsequentpointerupandclickto the<svg>, so a marker's own click listener never fired and clicking a station did nothing. The drag now trackspointermove/pointeruponwindowinstead, which buys the same keep-panning-outside-the-element behaviour without touching event targeting. Never reintroduce pointer capture on the map root.- the drag threshold was 0.2% of the viewBox width — under two screen pixels at the whole-NEM view — so the hand tremor in an ordinary click registered as a drag and the click was suppressed. It is now 4 CSS pixels.
- double-clicking a pin zoomed the map out from under the panel it had just
opened, and the two clicks in the gesture each fired the 24-hour drill-down
fetch. Double-click now zooms the basemap only, and the capture-phase click
handler drops any click with
detail > 1. boxAspect()measured the box withclientWidth/clientHeight, which round to whole pixels, whileclientToUser()converts with the fractionalgetBoundingClientRect(). A 236.4 px box read as 236 fitted the viewBox to an aspect the element does not have — the same letterbox as above, at 0.17%, on any viewport where the height is not a whole number. Both must be the same measurement or they disagree by construction.
A synthetic click dispatched on a node (lightpanda, element.click()) does
not exercise pointerdown → capture → click retargeting, does not hit-test
through letterboxing, and never produces a detail > 1 — which is exactly why
all of them survived a check that reported 213 markers with the right classes and
no console errors.
So a real browser moving a real mouse is a CI gate here, not an optional
local ritual. test/pointer-probe.mjs drives /map with page.mouse.* through
playwright-core and fails the build on a pointer regression; each assertion
carries the bug it exists for, so read the file rather than a list here that
would go stale the first time one moves.
npm run migrate:local # seed D1 — no data, no markers, no probe
npx playwright-core install --with-deps chromium
npx wrangler dev --local # in one shell; the probe needs a server
npm run probe:map # in another — PROBE_URL to point elsewhere
PROBE_HEADED=1 npm run probe:map # watch it driveThe browser is playwright-core's own pinned chromium, not a system Chrome, so
CI and your laptop run the same revision. In CI it is cached and the probe shares
the wrangler dev the /health smoke check already boots, which is why the gate
adds about half a minute rather than two.
Two rules for anything added to it. No sleeps and no retries — wait on the
thing the gesture causes, and run the assertions exactly once, because a probe
allowed to pass on its second attempt reports "flaky" as "green". And make the
gesture the code actually reacts to: mouse.click() never moves while the
button is down (its only pointermove lands before pointerdown), so it passes
at any drag threshold including a negative one, and a drag released
where the marker no longer is proves nothing about click suppression. Both of
those assertions were green against a deliberately broken map before they were
rewritten to move the mouse the way a hand does; worker/README.md is not the
place that will remind you, so the probe says it in its own header. Screenshots
stay a manual diagnostic — an SVG geometry bug can paint a distorted map while
every assertion passes, and eyes are still the cheapest way to see that.
Shared page chrome ($, fetchJson, showError, REGIONS, TZ, the theme
toggle) lives in public/chrome.js and is imported by both pages; the theme
callback is the parameter because the chart repaints a canvas and the map
recolours SVG fills.
No map library, no tile server. The basemap is 2100 vertices of vendored
public-domain geometry rendered as one SVG path per jurisdiction, pan/zoomed by
moving the viewBox. That answers the tile-ToS question by not asking it,
themes with a CSS variable instead of a filter hack, and keeps the no-runtime-
CDN posture absolute — a vendored Leaflet still fetches somebody's tiles on
every page view. The five NEM regions are the basemap's subject; WA and NT are
drawn muted because they are separate grids.
Two committed, generated data files, both regenerated by hand:
| File | Source | Licence | Refresh |
|---|---|---|---|
public/basemap.json |
Natural Earth 50 m admin-1, pinned to tag v5.1.2 |
public domain | npm run basemap:generate — borders don't move; effectively never |
public/facilities.json |
Open Electricity v4 facility dump | CC BY-NC 4.0 — attribution rendered in the page footer | npm run facilities:generate when new stations appear (quarterly is generous) |
The facility dump is snapshotted, not fetched live: its URL is the backing file of OE's API rather than a documented contract (the v3 predecessor already moved domains once), so a page load must not depend on it, and a refresh should be a reviewed diff. Only what a pin needs is copied — OE's own capacity and emission-factor fields are deliberately left behind, because AEMO publishes both under attribution-only terms and we already ingest them. If the non-commercial clause ever becomes a problem, the documented fallback is Geoscience Australia's "Major Power Stations" (CC-BY 4.0, but no DUID, so name matching only).
units[].code in the dump is the AEMO DUID for ordinary generators. Batteries
are not ordinary: OE models a bidirectional unit as a synthesised charge/
discharge pair, so AEMO's BRDDBES1 appears as 0BRDDBESL1 and 0BRDDBESG1.
Joining on exact codes alone drops every grid battery in the NEM — 67 of
477 dispatching units when measured on 2026-08-09, which is most of the story
on a modern NEM day. unitAliases() in public/stations.js recovers the DUID
(0 + base + L/G/B + digit → base + digit).
The rule is checked, not trusted. The build aborts if an alias would claim a DUID another facility owns exactly, or if two facilities' aliases claim the same one; the runtime join additionally refuses to pin any unit whose facility contradicts AEMO about the NEM region. Measured on the 2026-08-09 dump: zero conflicts, zero contradictions.
$ npm run facilities:generate
Wrote public/facilities.json — 533 NEM facilities, 9 skipped for missing coordinates
Join coverage vs /api/v2/generators (690 DUIDs):
pinned 659 (95.5%) across 453 stations
unmatched 31 (4.5%)
region clash 0
of 477 DUIDs dispatching in the last 2 h: 459 pinned (96.2%)
The unmatched list is printed in full and the count is rendered on the page — a map that quietly drops units is a map you cannot reason about. The residual gap is mostly very new batteries the snapshot predates, plus Basslink (an interconnector, not a station, so correctly absent from a facilities dump) and a few landfill-gas sites.
Unit tests prove the join; they cannot notice a swapped lat/lng or a wrong projection. The check that can is point-in-polygon: every pinned station's coordinate against the polygon of the region AEMO assigns it. On the 2026-08-09 data, 446 of 453 stations (98.5%) fall inside their own region's outline. All seven exceptions were measured rather than waved away:
- five sit 0.01–0.19 km outside (Hume, Musselroe, ADP, Christies Beach, Yarrawonga) — border and coastline generalisation at 1:50 million, sub-pixel at any zoom this map offers;
- Starfish Hill is 5.2 km out, at the tip of the Fleurieu Peninsula, which 1:50 million truncates;
- Murray is 11.8 km out, and correctly so. A NEM region is an electrical
region: Snowy's Murray Power Station stands in New South Wales and dispatches
into
VIC1. The pin is geographically right and the region is electrically right; they simply disagree, which is why the page says so out loud.
Nothing here needs a finer basemap — 1:10 million is 40 MB of source for a correction smaller than a marker.
The join itself is pure and unit-tested (public/stations.js,
test/stations.spec.ts) and runs in two places on purpose: the build
script uses it to report coverage, the browser re-runs it on every page load
against live /api/v2/generators. Registration refreshes weekly and the
snapshot does not, so freezing the join at build time would go stale silently.
| Binding | Resource | Purpose |
|---|---|---|
DB |
D1 database nem-api-db |
Hot query window: generators, scada_values |
ARCHIVE |
R2 bucket nem-api-archive |
Raw NEMWEB zip archive / deep-history staging |
npm install
npm run migrate:local # apply migrations (schema + generator seed) to local D1
npm run dev # http://127.0.0.1:8787/health -> {"status":"ok","generators":350}npm run check typechecks (src and tests). npm test runs the vitest suite in
the real Workers runtime (@cloudflare/vitest-pool-workers) against a real D1
with all migrations applied. /health queries D1, so a 500 there means the
binding or migrations are broken — that is deliberate.
src/ingest.ts, triggered by the */5 * * * * cron in wrangler.toml. Each
run:
- Discovers files on the NEMWEB autoindex
(
https://nemweb.com.au/Reports/CURRENT/Dispatch_SCADA/, HTTPS — the legacy scraper's plainhttp.getbroke on the 307 redirect) withHTMLRewriter, no jsdom. - Diffs the listing against the
scrapeledger and processes only not-yet-recorded files, oldest first. - Per file: fetch zip → unzip in-Worker (
fflate; Workers have no native ZIP support) → parseD,DISPATCH,UNIT_SCADArows (per-row SETTLEMENTDATE, NEM market time = fixed UTC+10) → upsert intoscada_values→ archive the raw zip to R2 (current/<filename>) → record the filename inscrape.
To exercise it locally:
npx wrangler dev --test-scheduled
curl "http://127.0.0.1:8787/__scheduled?cron=*/5+*+*+*+*"Re-runs and overlapping intervals never double-insert: values upsert on the
(scrape_time, generator_id) primary key, the R2 key is deterministic (put
skipped when the object exists), and the ledger insert is OR IGNORE. The
ledger write happens last, so a file that fails part-way stays unrecorded
and is retried on the next run — every earlier step is idempotent, making
whole-file retry always safe.
If cron intervals are missed (deploy freeze, outage), the next run catches up
automatically: discovery diffs the whole CURRENT listing (~2 days of files)
against the ledger. Catch-up is bounded to 50 files per run
(MAX_FILES_PER_RUN) — a cold start over the full listing drains in about an
hour of 5-minute runs. Gaps longer than CURRENT's ~2-day window heal via the
ARCHIVE backfill cron (below) as soon as the affected daily zips are published.
Failure handling: one bad file logs and the run continues (per-file
isolation). NEMWEB's WAF rate-limits aggressive clients with 403s, so after 5
consecutive failures the run aborts early rather than extending the block —
the next run resumes where it stopped. DUIDs missing from generators are
logged per run and their values dropped; the registration refresh below keeps
that set small. DG_* (dummy generators) and RT_* (RERT reserve-trader
units) are AEMO virtual dispatch units that never appear in the registration
list — they are expected to stay in the unknown-DUID log.
The DUID→generator reference data self-updates from AEMO's live
NEM Registration and Exemption List
— deliberately outside the 5-minute hot path: units register roughly
monthly, so .github/workflows/refresh-generators.yml runs weekly
(Mon 19:43 UTC) and on manual dispatch. It calls
scripts/refresh-generators.mjs, which
- fetches the workbook (browser User-Agent — AEMO's WAF 403s plain clients;
the file is served as
.xlsbut is actually OOXML/xlsx, sofflate+ ~80 lines of XML extraction replace a spreadsheet library), - maps the "PU and Scheduled Loads" sheet to the
generatorsschema, resolving columns by header name (the sheet has already drifted once:Reg Cap (MW)→Reg Cap generation (MW)) and collapsing unit rows to one generator per (station name, DUID) exactly like the seed (scripts/registration.mjsis the shared logic), - writes
refresh-upsert.sql:INSERT … ON CONFLICT(duid, name) DO UPDATE— existing rows keep theirid(historicalscada_valuesrows point at it), new registrations insert, nothing is ever deleted. Generators that drop out of AEMO's list simply stay put and stop receiving values. (Renamed stations insert a new row — e.g. the seed's "Murray 1/2 Power Station" and today's "Murray Power Station" coexist on DUIDMURRAY; ingest keeps attributing MURRAY values to the lowest id.)
The workflow then applies the file with wrangler d1 execute --remote
(same CLOUDFLARE_API_TOKEN secret as deploy).
Fail-safe: any anomaly — fetch failure, renamed sheet, renamed/missing
column, a suspiciously small list (< 400 generators) — aborts via assert
before SQL is written, and a mapping self-check against a captured sample of
the real sheet (scripts/fixtures/nem-registration-sample.xlsx) runs before
every refresh and in CI (npm run refresh:check). A failed run leaves the
existing reference data fully intact; the apply is idempotent, so re-running
after a partial failure is always safe.
Manual refresh — GitHub: Actions → "Refresh generators" → Run workflow, or
gh workflow run refresh-generators.yml. Locally:
npm run refresh:generate # fetch + parse -> refresh-upsert.sql (gitignored)
npm run refresh:local # apply to local D1 (or refresh:remote for prod)Note: GitHub auto-disables scheduled workflows after ~60 days without repo activity; a manual dispatch re-enables the schedule.
src/backfill.ts (LAB-420), on its own cron schedule (11,26,41,56 * * * * —
the string in wrangler.toml must stay byte-identical to BACKFILL_CRON,
which src/index.ts dispatches on). NEMWEB's ARCHIVE
(https://nemweb.com.au/Reports/ARCHIVE/Dispatch_SCADA/) holds a rolling
~13 months of daily zips, each a zip-of-zips wrapping that day's 288
five-minute files. Per run the backfill diffs the ARCHIVE listing against the
scrape ledger (daily filenames — PUBLIC_DISPATCHSCADA_<YYYYMMDD>.zip —
share the ledger with the five-minute files; a GLOB keeps them apart) and
ingests up to 4 pending days, oldest first: nested unzip (fflate), the same
parse/map path as the CURRENT ingest, idempotent chunked upserts, raw daily
zip archived to R2 at archive/<filename>, ledger write last.
From an empty ledger the full archive (~375 days, ~61k values/day at current DUID coverage) drains in roughly a day of wall time, then each run idles for the cost of a listing fetch and one ledger query, picking up new daily zips as ARCHIVE publishes them — the backfill doubles as a standing gap healer for outages longer than CURRENT's ~2-day window. Days already covered by the CURRENT ingest overlap harmlessly (same upsert key). Retries read the raw zip back from R2 instead of re-downloading (the object is only written after a full successful parse, so it is always valid). Inner five-minute entries that fail to parse are logged and skipped — the archive is immutable, so a defect there would otherwise retry forever — while whole-day failures (fetch errors, zero parsed rows) leave the day unledgered for retry, with the same consecutive-failure abort as the CURRENT ingest to respect NEMWEB's WAF.
Each ingested day logs a sanity line (288 file(s), 288 interval(s), N value(s)) and a GAPS: warning on any deviation. To check progress or
completion against the deployed database:
# pending == 0 in the run logs means drained; per-day interval coverage:
npx wrangler d1 execute nem-api-db --remote --command "
SELECT date(scrape_time,'unixepoch','+10 hours') AS day,
count(DISTINCT scrape_time) AS intervals
FROM scada_values GROUP BY day HAVING intervals < 288 ORDER BY day"
npx wrangler d1 info nem-api-db # database size vs the 10GB capBoundary note for that query: a daily zip covers settlement times 00:05 through 24:00, so each calendar day's midnight interval comes from the previous day's zip — the oldest backfilled day reports 287 and the day after the newest reports 1. Those two rows are the settlement-date convention, not gaps. Sizing, measured locally at 19.4 bytes/row: the current window ingests ~23M rows ≈ ~450MB; once the LAB-421 registration refresh makes all ~510 DUIDs resolvable, a full window is ~55M rows ≈ ~1.1GB — inside D1's 10GB cap.
Re-ingesting after a registration refresh (LAB-421): values for DUIDs
missing from generators are dropped (logged per run), but the raw daily zips
survive in R2. Once the refresh lands, clear the daily ledger entries and the
cron re-ingests every day from R2 (no NEMWEB re-download), filling in the
previously unknown DUIDs — existing rows upsert to the same values:
npx wrangler d1 execute nem-api-db --remote --command \
"DELETE FROM scrape WHERE filename GLOB 'PUBLIC_DISPATCHSCADA_????????.zip'"/api/v2/values and /api/v2/values/aggregate at resolution 3600/86400
are served from pre-aggregated per-generator tables (scada_hourly,
scada_daily, plus the per-bucket interval counts in scada_intervals —
migrations/0004_rollups.sql) instead of GROUP-BYing raw 5-minute rows:
beyond ~90 days the raw grouping exhausts D1's SQLite memory budget
(D1_ERROR: out of memory: SQLITE_NOMEM, confirmed via wrangler tail
2026-08-08), and the full 13-month window scans ~50M raw rows vs ~200k daily
rollup rows. Resolutions 300/1800 and exact time= lookups keep the raw
path — and are capped to a maximum window span (3 days / 14 days) on both
endpoints, since a wide enough window there hits the same SQLITE_NOMEM shape
(LAB-1721; see worker/API.md).
Maintenance is automatic: both writers (5-minute CURRENT ingest and ARCHIVE
backfill) call refreshRollups (src/rollups.ts) after upserting values and
before their scrape-ledger write, recomputing every touched bucket whole
from scada_values in one transaction. Ledger-last means a failed refresh
retries with its file — rollups cannot go permanently stale behind a ledgered
file, and any drift heals by re-running the affected file/day (or the script
below).
One-off backfill / repair — populates (or reconverges) the rollup tables
from existing scada_values, one AEST-month chunk per statement (a single
full-history statement hits the same SQLITE_NOMEM ceiling; same lesson as the
LAB-733 verification queries). Idempotent, safe to re-run any time:
node scripts/backfill-rollups.mjs --remote # or --localThe script verifies its own invariant on completion (every distinct raw
interval counted exactly once across scada_intervals) and exits non-zero on
mismatch. Deploy-ordering note: apply the migration and run this backfill
before deploying Worker code that routes queries to the rollups, then
re-run it once after the deploy — it converges the buckets ingested in
the window between backfill and cutover (pre-deploy code did not maintain
rollups yet).
/api/v2/intensity computes Σ(MW × factor) / Σ(MW) per region and NEM-wide
from AEMO's published CDEII emission factors joined to dispatch SCADA on DUID.
The contract, the methodology and its disclosed biases live in
API.md — that is the document to read before changing the maths.
Two inputs, both from https://nemweb.com.au/Reports/Current/CDEII/ and both
upserted into emission_factors / cdeii_daily
(migrations/0005_emissions.sql) by the daily 37 20 * * * cron
(refreshCdeii, src/cdeii.ts). The files republish weekly, so daily is
generous; the refresh is pure upsert and never deletes, so a skipped or failed
run costs nothing but freshness.
Deploy ordering: the migration ships the tables empty on purpose (a
committed seed would be a stale second copy of data AEMO restates). Until the
first refresh runs, /api/v2/intensity honestly reports zero coverage and
null values rather than a wrong number, so trigger it once right after
applying the migration:
npx wrangler dev --remote --test-scheduled # real D1 bindings
curl "http://127.0.0.1:8787/__scheduled?cron=37+20+*+*+*"Reconciliation — the honesty gate. Compares our estimate against AEMO's own official daily index (published under NER 3.13.14, republished in the same payload) for every region-day in the window:
node scripts/reconcile-cdeii.mjs --days 7 # or --base http://127.0.0.1:8787Non-zero exit means a region-day drifted outside ±10% (or ±0.02 absolute, which is what matters on near-carbon-free days). Run it after any change to the factor join, the bucket math, or the refresh. The estimate reads a few percent high by construction — as-generated SCADA against sent-out factors — and is never silently corrected toward the official number.
Plain SQL files in migrations/, applied in filename order and tracked by
wrangler per-database:
npm run migrate:local # local sqlite under .wrangler/
npm run migrate:remote # the real nem-api-db0001_init.sql ports the legacy api/db/schema.sql: a flattened generators
table (== legacy flat_generators; the normalized participant/technology/fuel
tables were only ever read through the flat view, so they were dropped) and
scada_values with the legacy (scrape_time, generator_id) primary key so
ingest upserts stay idempotent.
migrations/0002_seed_generators.sql is generated — never edit it by hand.
It collapses the 389 physical-unit rows of
api/db/files/nem_registration_latest.csv into 350 generators, one per
(station name, DUID), summing unit capacities (matching the legacy
api/scrape/nem_registration.js behaviour, minus its reg/max swap bug).
To re-seed from an updated CSV, replace the CSV then:
npm run seed:generate # rewrites migrations/0002_seed_generators.sql
npm run migrate:local # or migrate:remoteIf 0002 was already applied, wrangler will not re-run it — bump the new seed
into a fresh migration file instead (cp the regenerated file to
migrations/000N_reseed_generators.sql).
DELETE + reinsert is only safe while scada_values is empty. The generated
seed starts with DELETE FROM generators, which reassigns ids — acceptable
for the one-time bootstrap, but once ingest has written value rows keyed by
generator_id, never re-run the seed against a live database. Ongoing updates
are the registration refresh's job (see above): it upserts on the
(duid, name) identity (generators_duid_name — DUID alone is not unique)
so ids are preserved and nothing is deleted.
CD: every push to the default branch (master, or main if ever renamed) runs
.github/workflows/deploy.yml — typecheck, apply D1 migrations, wrangler deploy,
then smoke-check /health. It needs two repo Actions secrets:
CLOUDFLARE_API_TOKEN— API token scoped to the account, with Workers Scripts: Edit and D1: Edit (Cloudflare dash → My Profile → API Tokens → "Edit Cloudflare Workers" template + add D1 Edit). The account id is pinned inwrangler.toml, so no account permission is needed beyond that.SMOKE_BYPASS_TOKEN— shared secret for the smoke check (LAB-1760). Cloudflare's edge bot/IP filtering 403s requests from GitHub-hosted runners before they reach the Worker, so the smoke step sendsX-Smoke-Bypass: <token>and a WAF custom rule on the 27b.io zone skips the filtering only when host isnem.27b.io, path is/health, and the header equals this secret. Scope the rule's Skip action narrowly: first identify the blocking service in zone Security → Events (thecf-rayfrom a failed run log looks it up directly), then tick only that product in the rule's "WAF components to skip" — do not select "All remaining custom rules" or products that aren't doing the blocking. Beware that the "All managed rules" component is not per-product: ticking it skips the entire WAF Managed Rules phase for matching requests. If the blocker is a managed rule, leave it unticked and instead add a WAF exception (zone Security → WAF → Managed rules → Add exception) that skips only the specific rule or ruleset, matching the same host/path/header expression. Place it above any custom rule that could match this request — a Skip does not suppress custom rules listed after it unless "All remaining custom rules" is ticked (which it must not be). All unrelated WAF, rate-limiting, and custom rules stay enabled, including for/health. Rotate by generating a new random value and updating the WAF rule and this secret together. If the smoke step starts 403ing again, check zone Security → Events for the blocking service — a block by (classic) Bot Fight Mode cannot be skipped by custom rules; only Super Bot Fight Mode / Bot Management rules can.
Manual deploy still works:
npm run deploy
curl https://nem-api.raywalker.workers.dev/health