Loading documentation...
fire_dataCanonical contract reference for the HeatWaves ETL adapters — the canonical pipeline for BRIS, BRASK, and SSB. The adapters
implement a registry-driven bridge between three native fire-statistics
APIs (BRIS, BRASK, SSB) and the HeatWaves fire_data schema, writing to
fact_yearly with two-tier idempotency.
Read vs write grain: Adapters today persist fact_yearly rows for yearly histogram/API buckets and may persist fact_daily where upstream emits DATE-grain cells (see §10). Chart reads use fire_data.get_fire_data, which branches on p_granularity: year selects fact_yearly only; sub-year selects fact_daily plus sources.supports_subyear — never a single SQL UNION across both tables in the shipped RPC (docs/fire-data-schema/postgres-contract-rls-and-keys.md, docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md).
For filters.standard_code tokens such as RESIDENTIAL, get_fire_data resolves to every matching registry row for SQL aggregates. Ingest POST bodies use per-axis native ids (filter-axes-native-to-filter-id.md §§2–4); rollup tokens expand to one or more HTTP calls per adapter.
Live-contract authority: docs/fire-data-schema/postgres-contract-rls-and-keys.md
plus generated types/fire_data.database.types.ts.
Schema history narratives (v2-001-initial-migration-narrative.md, incremental-patches-and-rpc-changelog.md) are design context; derive executable types from the postgres contract and generated types only.
fact_yearly columns the adapters write into:
| Column | Type | Resolution |
|---|---|---|
source_id | INTEGER FK → sources.source_id | Caller supplies — UI populates from the sources table |
dim_code | INTEGER FK → dimensions.dim_code | 1=Årsak, 2=Tennkilde, 3=Objekt, 4=Arnested, 5=Bygningstype (SSB only) — see category-native-to-cat-code.md §1 |
cat_code | INTEGER, source-scoped | Resolved from native API label via the categories registry |
fire_type_code | INTEGER NULL FK → filters.id (axis=fire_type) | Resolved from native upstream value via the filters registry |
building_type_code | INTEGER NULL FK → filters.id (axis=building_type) | Resolved via the filters registry |
building_age_code | INTEGER NULL FK → filters.id (axis=building_age) | BRASK only; BRIS/SSB write NULL |
region_code | INTEGER NULL FK → filters.id (axis=region) | Resolved via the filters registry; canonical mapping in regions-and-cresta.md |
value | NUMERIC | Counts (antall) for BRIS/BRASK/SSB |
value_kr | NUMERIC NULL | BRASK damage only: stored unit is tusen kroner (integer n ⇒ 1000n NOK). BRIS/SSB leave NULL. |
year | SMALLINT | 2016..present |
fact_yearly and fact_daily each have a BEFORE INSERT OR UPDATE
trigger — fact_yearly_enforce_filter_axes and
fact_daily_enforce_filter_axes — that executes fire_data.enforce_fact_filter_code_axes().
The function rejects rows whose non-null filters.id references the wrong
filters.axis, or violates filters.applicable_dim_codes for the row’s
dim_code (see postgres-contract-rls-and-keys.md for full behavior).
Foreign keys to filters(id) reject unknown ids; axis / applicability checks
are trigger-only. The trigger does not verify filters.source_id = fact.source_id
— adapters must keep registry lookups scoped to the slice’s source_id.
fact_buildings_yearly (SSB building stock) keeps its legacy TEXT key shape
and is out of scope for this pipeline. Its building_type_code is
constrained to the four mutually-exclusive leaf buckets
RESIDENTIAL, COMMERCIAL, INDUSTRY, OTHER (the partition of the
building stock used as a denominator for fire-rate calculations).
ALL_BUILDINGS and NON_RESIDENTIAL are deliberately excluded there because
they are roll-ups, not partition members.
flowchart LR bris["BRIS<br/>(Police, DSB, Brigade)"] --> brisAdapter["bris.ts"] brask["BRASK<br/>(HTML form)"] --> braskAdapter["brask.ts"] ssb["SSB<br/>(json-stat2)"] --> ssbAdapter["ssb.ts"] registry["loadSourceRegistry()<br/>categories + filters"] --> brisAdapter registry --> braskAdapter registry --> ssbAdapter brisAdapter --> totalCheck{"api_total ==<br/>db_total?"} braskAdapter --> totalCheck ssbAdapter --> totalCheck totalCheck -->|"yes"| skip["Skip<br/>(unchanged)"] totalCheck -->|"no"| transform["Transform<br/>+ assert"] transform --> upsert["Chunked upsert<br/>(8-col natural key)"] upsert --> factYearly["fact_yearly"]
Scope: Figures in §2 sketch default yearly admin ingest (grain omitted or 'yearly') into fact_yearly. fact_daily is written by brask.daily.ts (source_id = 1, grain: 'daily') and bris.daily.ts (source_id = 2, Police only, grain: 'daily') — §10, §10.3, §10.4. bris.ts / brask.ts remain the yearly materialization paths for their sources.
Three layers per adapter:
fetchXxx(slice, registry) → raw API response transformXxx(raw, ctx) → FactYearlyInsert[] (pure, registry-driven) ingestXxxSlice(supabase, registry, slice) → SliceResult ingestXxx(supabase, slices) → IngestResult (loads registry once)
The pipeline has one input language (HeatWaves integer ids) and three
output languages (BRIS numeric ids, BRASK ASP.NET form keys, SSB
PxWebApi codes). The adapters translate twice per slice, both times against
the same SourceRegistry:
slice into the
shape the API expects (Norwegian labels, Matrikkel groups, ContentsCode
strings, BRASK ASP.NET form fields).cat_code and filters.id integers, plus the within-slice time axis
(year for yearly adapters, date for daily adapters).flowchart TD ui["UI / cron client<br/>POST /api/admin/ingest<br/>{ source_id, slices?, mode? }"] ui --> dispatch["_dispatch.ts<br/>route by source_id"] dispatch --> adapter["Adapter<br/>(bris.ts | brask.ts | ssb.ts)"] registry[("SourceRegistry<br/>loaded once per run<br/>(categories + filters)")] registry -. "filters.id → native_code<br/>cat_code → native_name" .-> reqBuild adapter --> reqBuild["Build native request<br/>(Norwegian labels,<br/>Matrikkel ids,<br/>ContentsCode strings,<br/>BRASK form fields)"] reqBuild --> nativeAPI[("Native HTTP<br/>BRIS / BRASK / SSB")] nativeAPI --> rawResp["Raw response<br/>(JSON buckets,<br/>HTML cells,<br/>json-stat array)"] rawResp --> transform["transformXxx<br/>(pure, registry-driven)"] registry -. "native_name → cat_code<br/>native_code → filters.id" .-> transform transform --> idempot{"api_total ==<br/>db_total?"} idempot -->|"yes"| skip["Skip<br/>(no upsert)"] idempot -->|"no"| factRows["FactYearlyInsert[]<br/>(only integer ids:<br/>source_id, dim_code, cat_code,<br/>year, fire_type_code,<br/>building_type_code,<br/>region_code, building_age_code)"] factRows --> upsert["Chunked upsert<br/>ON CONFLICT (8 cols)<br/>NULLS NOT DISTINCT"] upsert --> factYearly[("fact_yearly")] trigger["enforce_fact_filter_code_axes()<br/>(axis + applicable_dim_codes)"] -. guards .-> factYearly
Key invariants the diagram encodes:
sources / dimensions / categories / filters.categories or filters is a no-code
change to the adapter.onConflict
must match fact_yearly_natural_key exactly (see §5.1).Trace one Police slice (Årsak / Developed / dwellings, all years) through the full pipeline. The slice is described in pure integer-id space; every arrow that crosses a layer is a translation moment.
UI surface (what code constructs) ────────────────────────────────── slice = { source_id: 2, // Police dim_code: 1, // Årsak fire_type_code: 17, // filters.id, axis=fire_type, native='10086' (Developed) building_type_code: 41, // filters.id, axis=building_type, native='0' (Dwellings) region_code: null, // national rollup building_age_code: null, // not applicable to BRIS years: [2016..2025], } ↓ outbound translation (registry.filterByIdToNativeCode) Native API request body ─────────────────────── { questionId: 407, // Police's Årsak question id (cat_code source-of-truth) revisedMissionTypes: ['10086'], buildingTypes: ['0'], dateRange: { start: 2016-01-01, end: 2025-12-31 }, ... } ↓ POST /api/v1/missionreports/aggregate Native response (truncated) ─────────────────────────── { "result": [{ "buckets": { "Påsatt brann": { "count": 4708, "subAggregationBuckets": { "1451606400000": { "count": 557 }, "1483228800000": { "count": 612 }, ... }}, "Røyking": { "count": 3120, "subAggregationBuckets": {...} }, "-1": { "count": 2104, "subAggregationBuckets": {...} }, ... }, "total": 32320 }] } ↓ inbound translation (registry.catByNativeName + UTC year derivation) FactYearlyInsert[] (what reaches Postgres — integer-only) ───────────────────────────────────────────────────────── [ { source_id: 2, dim_code: 1, cat_code: 102, year: 2016, fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 557 }, { source_id: 2, dim_code: 1, cat_code: 102, year: 2017, fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 612 }, { source_id: 2, dim_code: 1, cat_code: 199, year: 2016, // -1 → "Ikke besvart" fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 412 }, ... ] ↓ idempotency check (sum(value) == db sum for these 6 slice keys?) ↓ if changed: chunked upsert ON CONFLICT (8-col natural key) Postgres state ────────────── fact_yearly rows keyed only by integers; cross-source rollup joins via standard_code in categories / filters.
Cross-source rollups happen at read time, not at ingest time:
fact_yearly carries source-scoped cat_code and source-scoped
filters.id; analytical queries join the categories / filters lookup
tables on standard_code to merge BRIS / BRASK / SSB rows that mean the
same thing.
Explore (/explore) URL prefers comma-separated filters.id tokens (native registry rows) where practical so UI picks map 1:1 to filters.id on facts; standard_code tokens still expand to every matching filter row on that axis — this expands cleanly for get_fire_data aggregates regardless of fact_yearly vs fact_daily branch. Cold-start materialization uses fire_data.ingest_runs + POST /api/admin/ingest (docs/application-architecture/explore-fire-data-slice-probe-and-ingest.md), not live Explore fetches.
Adapters bridge native API vocabulary (Norwegian labels, ASP.NET ids,
BRIS numeric ids, SSB content codes) and HeatWaves integer ids
(source_id, dim_code, cat_code, filters.id).
The bridge is dynamic — every translation looks up against
sources / dimensions / categories / filters rows, never against
hardcoded constants in adapter code. Add a row to categories and the next
run picks it up; no code edit required.
lib/data-model/adapters/_registry.tsexport interface FilterByNativeCode { fire_type: Map<string, number> building_type: Map<string, number> building_age: Map<string, number> region: Map<string, number> } export interface SourceRegistry { source_id: number /** Inbound: `catKey(dim_code, native_name)` → `cat_code`. */ catByNativeName: Map<string, number> /** Inbound: `native_code` → `filters.id`, by axis. */ filterByNativeCode: FilterByNativeCode /** Outbound: `filters.id` → `native_code` (for native-API request building). */ filterByIdToNativeCode: Map<number, string> /** Resolve a `filters.id` to its native code (alias for outbound bridge). */ filterNativeCode: (filters_id: number) => string | undefined /** `filters.standard_code` when present (e.g. BRIS building Matrikkel rollups). */ filterStandardCode: (filters_id: number) => string | null | undefined } export async function loadSourceRegistry( supabase: FireDataClient, source_id: number, ): Promise<SourceRegistry>
Loaded once per ingest run and passed by reference into every transform
call. No DB queries inside the per-row hot loop. Cost: two SELECT … WHERE source_id = X calls per run; ~100 filter rows + ~200 category rows total.
Before any adapter writes, filters must contain rows for every native code
the adapters will encounter. Unknown filters.id values fail the FK;
wrong axis or dim_code vs applicable_dim_codes fails
enforce_fact_filter_code_axes() — adapters can't seed-as-they-go.
| Axis | BRASK | BRIS | SSB |
|---|---|---|---|
fire_type | lbType=1 Varm, lbType=2 Kald | revisedMissionTypes: 10026, 10086, 10212, 10175 | — (not supported) |
building_type | lbNæring codes | Matrikkel groups | KOSboligbranner0000, KOSbygningsbrann0000 |
building_age | 13 BRASK native buckets → 6 standard buckets per filter-axes-native-to-filter-id.md §3 | — | — |
region | Cresta-sone 1–22, 99 | Counties (current admin) | Municipality codes (4-digit, 358 current) + canonical county aggregates (EKA{nn}, 15 rows). standard_code is always the 2-digit canonical county. |
Region rows must carry standard_code matching the canonical 15-county set
in regions-and-cresta.md when deterministic; NULL otherwise
(Svalbard, Jan Mayen, BRIS merged historic counties without municipality
detail).
flowchart LR sources --> categories sources --> filters dimensions --> categories categories --> factYearly["fact_yearly"] categories --> factDaily["fact_daily"] filters -.id FK.-> factYearly filters -.id FK.-> factDaily trigger["enforce_fact_filter_code_axes()<br/>(axis + applicable_dim_codes)"] -.guards.-> factYearly trigger -.guards.-> factDaily
/aggregate and /restrictedaggregation)Live response shape (datasets 1–11 in
adapter-mappings/.live_responses/ — identical for both endpoint variants; see source-api-request-examples.md for capture policy):
{ "result": [{ "buckets": { "Kjøkken": { "count": 4708, "calculatedSubAggregationValues": [], "value": null, "subAggregationBuckets": { "1451606400000": { "count": 557, "subAggregationBuckets": {} } } }, "-1": { "count": 6, "subAggregationBuckets": { "...": { "count": 6 } } } }, "total": 32320 }] }
Transform contract:
cat_code via registry.catByNativeName.'-1' (missing-answer bucket) → native_name = 'Ikke besvart' then catByNativeName (resolveCatCodeFromBrisBucket in _registry.ts).year via new Date(ms).getUTCFullYear().calculatedSubAggregationValues and value: null are ignored by the transform./aggregate (full body); Brigade and DSB use
/restrictedaggregation (slim body, no buildingTypes).Question-id mapping (per category-native-to-cat-code.md
"BRIS Source-Of-Truth Question IDs") is source-scoped — Police uses 407
for Årsak while the Fire Brigade uses 269 for the same dim_code=1.
Ingestion-time validation: sum(rows.value) must equal the sum of bucket time-series counts (reconcilableBrisTotal — sum of subAggregationBuckets, not necessarily raw.result[0].total when the API declares a wider mission total). Mismatch indicates a bucket label without a matching categories.native_name row — hard-fail the slice.
docs/fire-data-schema/schema-history/brask-ingest-loop-and-invariants.md owns BRASK loop invariants (§4–§7).
Adapter behavior:
/ for ASP.NET hidden fields; POST / with form body.cheerio scoped to #ctl00_Innhold_lblResultat table; raw HTML is not persisted.Accept-Encoding: gzip.rblVerdi=1 → value, rblVerdi=2 → value_kr. One
call returns one metric; the slice descriptor carries metric: 'antall' | 'kr'.value or value_kr) so the two passes can fill
the same natural-key row without the absent column overwriting the other
with NULL. validateFactRows (lib/data-model/adapters/_validate.ts)
requires at least one finite, non-negative metric per row.value_kr scale. BRASK reports erstatningsbeløp in tusen kroner
(rblVerdi=2). Persist that scale unchanged in value_kr (stored n =
1000n NOK); multiply by 1000 only when comparing or labeling full NOK.building_age_code is BRASK-only — populated from lbAldersgruppe.SUM × SUM cell of the
result table. Marginal SUM rows/columns are skipped (derivable on read).brask-ingest-loop-and-invariants.md §7 (Zero-cell skipping).Supported analytical dims: only 1 (Årsak) and 2 (Tennkilde). Objekt/
Arnested are not represented in BRASK's grid.
PxWebApi query shape, variable inventory, and cross-table notes:
adapter-mappings/ssb-pxwebapi-tables.md.
Verified upstream request: source-api-request-examples.md Dataset 10.
{ "class": "dataset", "id": ["KOKkommuneregion0000", "ContentsCode", "Tid"], "size": [357, 1, 10], "dimension": { "KOKkommuneregion0000": { "category": { "index": { "0": 0, "0301": 1, ... }, "label": { ... } } }, "ContentsCode": { "category": { "index": { "KOSboligbranner0000": 0 }, "label": { ... } } }, "Tid": { "category": { "index": { "2016": 0, "2017": 1, ... }, "label": { ... } } } }, "value": [265, 214, 247, ...] }
value[] is a flat array in row-major order across the dimension cross-
product. For dim order [a, b, c] with sizes [A, B, C], the index for
(ai, bi, ci) is ai*B*C + bi*C + ci. The transform inverts that.
Filter spine: SSB has no causal/object/area-of-origin breakdown — ingestible
rows use dim_code = 5 (Bygningstype) and either cat_code = 501
(Boligbranner) or 502 (Bygningsbranner). 501 stores
building_type_code = 64 (RESIDENTIAL filter id). 502 stores
building_type_code = NULL (NULL-as-ALL — no filter row for «alle bygg»;
see adapter-mappings/filter-axes-native-to-filter-id.md
§2). ContentsCode is resolved from cat_code, not from the building_type
filter axis.
Dim 7 (Nøkkeltall) holds the remaining table-12058 ContentsCode
variables as is_native_only categories — live read only
(lib/data-model/native/), never written to fact_yearly. On Explore,
NativeBranch requests only the ContentsCode values for the selected
cat_standard topic group on SSB dim 7 (see explore doc); implementation:
lib/data-model/ssb-nokkelgrupper.ts.
Region mapping (table 12058): Each decoded cell carries a KOKkommuneregion0000
native code. EAK (Hele landet) is stored as region_code NULL. Municipality
codes are stored as region_code = the matching filters.id for
source_id = 5, axis = 'region', looked up by filters.native_code
(region catalog: seed_ssb_region_filters_12058.sql,
regenerate from PxWeb metadata when SSB adds codes). Non-null region_code
must reference fire_data.filters (postgres-contract-rls-and-keys.md);
cells without a catalog row are not written. The adapter logs skipped and
continues the slice (lib/data-model/adapters/ssb.ts).
Explore kommune slices request only codes already in the catalog; national EAK
ingests return a single region dimension value.
Region axis (table 12058):
| Explore / ingest intent | PxWeb KOKkommuneregion0000 | fact_yearly.region_code |
|---|---|---|
| Alle regioner (no URL region filter) | { "filter": "item", "values": ["EAK"] } | NULL (national landstall — same NULL-as-ALL rule as BRASK/BRIS totals) |
| One or more region filter ids | { "filter": "item", "values": [<native codes>] } | Matching filters.id per municipality |
Selection logic: ssbTable12058RegionSelection in lib/data-model/upstream/pxweb-table-12058.ts. National reads use the same NULL-as-ALL convention as other sources (omitted p_region → region_code IS NULL in get_fire_data).
Sums + totals — store atoms, compute on read: adapters persist atomic category rows only; chart and RPC layers aggregate on read (no synthetic x00 rollups in facts).
Most scheduled runs find historical slices unchanged. Compare upstream api_total (BRIS result[0].total, BRASK SUM × SUM cell, SSB sum of value[]) to SUM(value) on the same slice keys; equal totals skip the upsert.
flowchart TD fetchAPI["Fetch native API for slice"] --> apiTotal["api_total"] apiTotal --> dbTotal["dbTotal = SUM(value)<br/>FROM fact_yearly<br/>WHERE slice keys"] dbTotal --> compareGrand{"api_total == dbTotal?"} compareGrand -->|"yes & dbTotal > 0"| skipAll["Skip upsert<br/>(unchanged)"] compareGrand -->|"dbTotal == 0"| fullPath["Full upsert<br/>(first run / backfill)"] compareGrand -->|"differs"| perYear["Compute per-year sums<br/>from API response"] perYear --> dbPerYear["SELECT year, SUM(value)<br/>FROM fact_yearly<br/>GROUP BY year"] dbPerYear --> diffYears["Diff: changed years only"] diffYears --> partialUpsert["Upsert rows for<br/>changed years only"]
For 26 datasets daily:
| Scenario | Without check | With two-tier check |
|---|---|---|
| Stable history (typical day) | 26 full upserts (~7,000 rows) | 26 SUM queries + 0 upserts |
| Today's data updated | 26 full upserts | 26 SUM queries + 1–2 partial upserts (~50–200 rows) |
| Backfill / first run | 26 full upserts | 26 full upserts (dbTotal=0 triggers full path) |
Per postgres-contract-rls-and-keys.md:
CREATE UNIQUE INDEX fact_yearly_natural_key ON fire_data.fact_yearly ( source_id, dim_code, cat_code, year, fire_type_code, building_type_code, region_code, building_age_code ) NULLS NOT DISTINCT;
onConflict lists all 8 columns:
const ON_CONFLICT = 'source_id,dim_code,cat_code,year,fire_type_code,building_type_code,region_code,building_age_code'
NULLS NOT DISTINCT is critical — without it, every NULL filter creates a
duplicate row on every run.
cat_code and year are within-slice axes — the slice key fixes the
other six dimensions and the SUM aggregates over cat_code and year.
The full ingest is ~7,000 rows; SUM in JS or SQL is microseconds. Stored
derived totals drift the moment one fact row is upserted without a matching
total update. No fact row should ever carry cat_code = x00 — x00 is
the synthetic "Alle kategorier" label for the lookup table only.
| Compute location | Use when |
|---|---|
| Postgres view / RPC | Multiple pages need the same totals. Extend get_fire_data with a p_with_totals flag, or sibling get_fire_data_with_totals. |
| Server Component | Page-specific totals — fetch atoms via getFireData, run a reduce once, pass { rows, categoryTotals, yearTotals } as props. Recommended for the new chart page. |
Client useMemo | Totals must recompute as the user toggles filters/series without refetching. |
Shared helper: lib/utils/totals.ts —
buildTotals(rows) is used by both the live BRIS chart path and the
Supabase chart page.
Facts do not carry an is_aggregate (or similar) flag. Precision comes
from the natural key: each row stores at most one FK per filter axis
(fire_type_code, building_type_code, building_age_code, region_code),
each pointing at filters.id for the correct axis, guarded by
fire_data.enforce_fact_filter_code_axes. Cross-source roll-ups such as CONFINED
or RESIDENTIAL are represented only in filters.standard_code and
chart/RPC resolution, not as separate “total rows” in fact_*. The per-axis
«Alle» / total is the NULL-FK slice (omitted get_fire_data axis param), never
a roll-up filter row.
Ingest path (adapters):
NULL
per axis). Every transformed row from that fetch inherits that combination.revisedMissionTypes.ids entry per slice.
The adapter maps slice.fire_type_code → exactly one native mission-type id
(lib/data-model/adapters/bris.ts, toMissionTypeIds). Documentation examples
that show multiple ids in one POST body describe upstream capability;
for HeatWaves storage, run separate slices for each leaf mission type and
tag rows with the matching fire_type_code so atoms stay 1:1.filter-axes-native-to-filter-id.md);
a non-empty selection implies one resolved native code per slice pass where
the adapter wires it that way — treat “all vs leaf” as different slice keys
(different natural keys / totals checks).Read path (fire_data.get_fire_data):
NULL) — target contract: keep only facts where that axis FK IS NULL.p_fire_type / p_building_type / etc. resolves to filters.id values; facts must match any resolved id (OR within axis, AND across axes).standard_code is the cross-source bridge: e.g. RESIDENTIAL resolves to all matching filters.id rows (BRASK Beboelse catalog row, BRIS bolig leaves, SSB Boligbranner); the RPC then matches those integers against fact FK columns.Why this matters: chart queries that pass standard_code expand to all
matching ids on that axis (union of atoms). Ingest must still write those atoms
with native-accurate FKs; otherwise totals checks and cross-source joins drift.
This subsection does not enumerate blocking prerequisites before the first fact
insert. Postgres already rejects invalid filters.id FKs, enforce_fact_filter_code_axes()
violations (axis / applicable_dim_codes), and BRIS adapters abort slices when
transformed sums diverge from upstream totals (postgres-contract-rls-and-keys.md).
Residual exposure is process-level: silent omission of buckets when categories
rows are absent, incorrect physical grain (fact_yearly vs fact_daily),
and semantics outside DB validation (e.g. multi-id BRIS requests with a single slice
fire_type_code). The table maps each class to existing guards versus operational
ownership; narrative orchestration diagrams live in catalog-governance-and-reconciliation.md.
| Risk class | Database / adapter guard | Operational ownership |
|---|---|---|
Registry gaps (filters, categories) | FK + trigger failures for filters; BRIS total assertion | Maintain categories so bucket keys resolve; reconcile IngestResult after seed edits |
| Grain mismatch | get_fire_data branching + supports_subyear | Route ingest adapters per source cadence (§10) |
| Duplicate semantic slices | Natural-key upsert convergence | Single canonical slice definition per catalog tuple |
BRIS multi-id POST + one fire_type_code | None | §5.3 invariant |
Asymmetric standard_code by source | RPC resolves ids — semantics catalog-dependent | Source-specific filters documentation |
filters.source_id vs fact.source_id | Not enforced | SourceRegistry scoped to ingest source_id |
Tier-1 (grand total) and tier-2 (per-year) behaviour above is unchanged: when the slice is classified unchanged, adapters skip transform-time classification and write no rows.
When the adapter proceeds to evaluate-and-write, it loads a before
snapshot of existing facts for the relevant key set (beforeByKey: 8-column
natural key → { value, value_kr }), classifies each transformed row, and
builds toWrite = rows that are new or whose metrics differ on the
columns that path actually sets (BRASK may send value-only or
value_kr-only partial rows — omitted columns must not be treated as
changes).
Counter (per slice, rolled up on ingest_runs) | Meaning |
|---|---|
rows_added | Composite natural key not in beforeByKey — first insert. |
rows_updated | Key exists; value and/or value_kr differ from DB for the compared columns. |
rows_skipped_identical | Key exists; metrics match — omitted from toWrite (no PostgREST row). |
rows_upserted on fire_data.ingest_runs is the cumulative count of rows
sent through the writer (i.e. rows_added + rows_updated for the
tracked run, slice by slice). It does not include identical keys skipped in
toWrite. ledger_events (when used) align with written keys only.
SQL writer: fire_data.upsert_fact_yearly_batch(p_rows jsonb) performs
INSERT … ON CONFLICT DO UPDATE … WHERE row IS DISTINCT FROM excluded so
heap/WAL work tracks real changes. Prefetch: fire_data.fact_yearly_slice_ingest_prefetch
returns grand_total, by_year, and row_count without shipping
all fact rows — adapters use it for tier-1/tier-2 gates before a heavier
beforeByKey load when needed. Migration: 20260510140000_ingest_runs_metrics_fact_prefetch_upsert.sql
under supabase/migrations/; patch note: docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md.
fire_data.ingest_runs analytics columns (operator / Explore)These columns are durable run metadata for dashboards and the Explore poll
endpoint. They do not replace fact_yearly as the mathematical source of
truth for chart aggregates (facts + triggers remain canonical; see
catalog-governance-and-reconciliation.md §6).
| Column | Use |
|---|---|
rows_added, rows_updated, rows_skipped_identical | Cumulative across slices in the run; see §5.5. |
rows_upserted | Cumulative written row count (matches batch upserts + §5.5). |
api_total | Upstream grand total for the request (incident-style sum) when slices_total === 1 (Utforsk / single-slice). NULL when slices_total > 1 to avoid a misleading single scalar. |
transformed_row_count | Cardinality of transformed atoms for that run when slices_total === 1; NULL for multi-slice batch runs. |
per_slice_metrics | When slices_total > 1: JSON array of objects (one per completed slice) with at least slice_index, slice_key, api_total, transformed_row_count, optional write_stats. Version the object shape here when adding fields. |
Route rollup: app/api/admin/ingest/route.ts updates these on each
onSliceComplete; app/api/ingest/status/[runId]/route.ts exposes them to the
polling client. Explore flow (probe, branching, polling): explore-fire-data-slice-probe-and-ingest.md. Table DDL: postgres-contract-rls-and-keys.md and migrations under supabase/migrations/.
phase_detail lifecycle on completion: The route handler emits a synthetic
route_lifecycle payload around the adapter loop — queued (5%) before
the registry load and finalize_metrics_cache (95%) only on success,
between the adapter loop and revalidateTag. On status === 'error' the
ceiling is not emitted, so the adapter's last
brask_daily_fanout / bris_police_daily_fanout payload (with its
failing (year, month) cursor and in-progress canonical_progress) survives
as the terminal phase_detail. This lets operators read where the run died
without grepping server logs, and pairs with the
BRASK POST 500 (YYYY-MM) form of error_message (§10.3) for the what.
The Explore detail sheet (IngestRunDetailSheet.tsx)
suppresses the PhaseStrip when status === 'complete' so the green Status
row is the single completion signal — no redundant "Ferdig" line.
sequenceDiagram actor cron as Cron / curl participant route as POST /api/admin/ingest participant after as next/server after() participant dispatch as ingestBySourceId participant registry as loadSourceRegistry participant adapter as Adapter participant supabase as Supabase cron->>route: POST { source_id, slices? } route->>route: Verify x-ingest-key route-->>cron: 202 Accepted (runId) route->>after: schedule runIngest after->>dispatch: ingestBySourceId(supabase, source_id, slices) dispatch->>registry: loadSourceRegistry(source_id) registry->>supabase: SELECT categories, filters supabase-->>registry: rows registry-->>adapter: SourceRegistry loop per slice adapter->>adapter: fetch native API adapter->>supabase: SELECT SUM (slice keys) alt totals match adapter->>adapter: skip (unchanged) else totals differ adapter->>adapter: transform via registry adapter->>supabase: upsert chunks end end
'server-only' import causes a build error if a module is imported
client-side. after() keeps the function alive on Vercel via
waitUntil(). For very long runs (full 26-dataset refresh), a Supabase Edge
Function or pg_cron is more reliable than a Route Handler.
| Error class | Cause | Behavior |
|---|---|---|
| Filter resolution failure | Native code missing in filters for active (source_id, axis) | Hard-fail the slice, surface error in IngestResult.slices[].error, continue to next slice |
| Category resolution failure | Bucket label missing in categories | Skip the bucket + soft warn (per-row) |
| Total mismatch (BRIS) | sum(rows.value) !== reconcilableBrisTotal(raw) (sum of bucket time-series counts) | Hard-fail the slice — missing categories.native_name row (often -1 / Ikke besvart) |
enforce_fact_filter_code_axes violation | Non-null filters.id with wrong axis, missing filters row, or dim_code outside applicable_dim_codes | Upsert fails (**postgres-contract-rls-and-keys.md) |
| Postgrest chunk error | Schema constraint, network, FK violation | Surface PostgrestError.message in error, abort slice |
| Unmapped SSB municipality cell | PxWeb native_code has no filters row (source_id = 5, axis = region) | Cell omitted from fact_yearly; transform skipped count + log; slice continues (§4.3) |
Every adapter wraps its slice runner in try/catch and returns a
SliceResult with status: 'error' rather than throwing — this lets the
full IngestResult complete and surface every slice's status.
fire_data.sources (allocate the next source_id).dimensions rows if the source brings a new analytical axis.categories rows per (source_id, dim_code) — including the
Ikke besvart row (x99, native_name = 'Ikke besvart', standard_code = 'UNKNOWN') if the
source can return missing-answer (-1) buckets.filters rows for every native code the adapter will encounter.
Unknown ids fail the FK to filters; wrong axis or dim_code
vs applicable_dim_codes fails enforce_fact_filter_code_axes (§1).lib/data-model/adapters/<source>.ts exporting
ingest<Source>(supabase, slices) → IngestResult.source_id case in
lib/data-model/adapters/_dispatch.ts.curl -X POST /api/admin/ingest -H 'x-ingest-key: …' -d '{"source_id": N}'.Before changing ingestion code or schema:
npx supabase gen types typescript) when schema changes.SourceRegistry (no static lookup Maps in adapters).onConflict lists the 8 natural-key columns in fact_yearly_natural_key order.filters.applicable_dim_codes vs row dim_code.sum(rows.value) === reconcilableBrisTotal(raw).'-1' buckets resolve to the Ikke besvart row via native_name (catalog row must exist per dim).Accept-Encoding: gzip; persists parsed tuples only.try/catch returns SliceResult instead of throwing.INGEST_SECRET check and after() fire-and-forget pattern.Adapters write fact_yearly and/or fact_daily depending on upstream cadence (see §10.1). get_fire_data never derives yearly totals by scanning fact_daily — yearly charts require rows ingested into fact_yearly. Both fact tables share the same 8-column natural-key shape aside from the time axis (year SMALLINT vs date DATE):
fact_yearly natural key: source_id, dim_code, cat_code, year, fire_type_code, building_type_code, region_code, building_age_code fact_daily natural key: source_id, dim_code, cat_code, date, fire_type_code, building_type_code, region_code, building_age_code
Yearly extracts are intentionally unbounded at fetch time. BRASK yearly
keeps lbÅr='' in its POST body — one request returns the full 1985-current
archive for the slice — and the BRIS Police /aggregate /
/restrictedaggregation (DSB and Brigade) endpoints similarly return every
year the upstream knows about. We do not narrow the fetch to the
Explore-requested window because:
row_count > 0) over the requested window: probe_slice_coverage when
granularity = year, else probe_slice_coverage_daily (must match the
get_fire_data branch — see Explore doc).Daily is the only path where the Explore time-range bounds the ETL sequence (BRASK monthly fan-out, §10.3) — and even there, the bounds gate which months get a POST, not which years exist.
The route handler captures the actual stored year span (per slice
year_window_written, aggregated to a run-level ingested_year_window) onto
ingest_runs.slice_config, so the Explore Last imported list can show
Requested 2024–2025 (stored 1985–2026) when intent and reality differ —
see ../application-architecture/explore-fire-data-slice-probe-and-ingest.md
(ingest_runs and status JSON).
Display granularity vs write grain. The Explore time-range slider walks
five display granularities (year → quarter → month → week → day — see
components/fire-data/console/rpc-time-range.ts),
but the ingest dispatcher in
app/(main)/explore/actions.ts
collapses every sub-year choice to a single physical grain:
const grain: IngestGrain = slice.granularity === 'year' ? 'yearly' : 'daily'
Consequence: fire_data.ingest_runs.slice_config.granularity can be
'quarter' (or 'month', 'week', 'day') on a row whose facts live in
fact_daily. The Explore monitor surfaces only the write grain
(Dag / År) — the original display label stays in
slice_config.granularity_label for JSON diagnostics. The collapse helper is
writeGrainLabelFromSliceConfig
and it powers both the "Sist importert" column and the "Lagringsgrain" row in
ingest-detaljer.
| Source | Endpoint | fact_yearly | fact_daily | How the daily grain is reached |
|---|---|---|---|---|
BRIS — Police (source_id=2) | POST /missionreports/aggregate | yes | yes | One request, nextLevelAggregationDefinition set to MISSION_CALL_TIME_DAY (type: DATE_HISTOGRAM, interval: DAY). Live probe (Apr 2026): bucket count 3,772 for 2016-01-01..2026-04-22. |
BRIS — Brigade (source_id=3) | POST /missionreports/restrictedaggregation | yes | no — server-enforced | Live probe (Apr 2026): same body with interval: MONTH or DAY returns HTTP 403. Only interval: YEAR is accepted on this endpoint (year-only time sub-aggregation on restricted datasets). |
BRIS — DSB (source_id=4) | POST /missionreports/restrictedaggregation | yes | no — server-enforced | Same restriction as Brigade — both share the restricted endpoint. |
BRASK (source_id=1) | POST https://brask.finansnorge.no/ | yes | yes (per-month fan-out) | The BRASK form does have a dag selector (BRASK_FILTER_FIELD_MAP), but the table grid only fits a single (year, month) pair when dag is the column. Daily ingestion is a fan-out: one POST per (year, month) pair with column = dag, parsing 28–31 day cells per response. ~12 requests per year per slice. |
SSB (source_id=5) | POST data.ssb.no/api/v0/no/table/12058 | yes | no — API design | All KOSTRA tables are annual; PxWebApi exposes no sub-annual time dimension. |
BRIS — Police, ingest vs upstream: lib/data-model/adapters/bris.ts still always uses subGroupBy: MISSION_CALL_TIME_YEAR and writes only fact_yearly (one POST returns the full upstream history per slice — see §10 on unbounded yearly fetches). Day-grain materialisation lives in the sibling module lib/data-model/adapters/bris.daily.ts, dispatched when the admin route is called with grain: 'daily' and source_id = 2 (§10.4). The two paths share registry + helpers; bris.ts exports BRIS_QUESTION_ID, endpointForBris, sanitizeBrisSliceForEndpoint, and the parametrized fetchBrisData(slice, registry, { subGroupBy?, periodStart?, periodEnd? }) so bris.daily.ts reuses one HTTP body builder. Brigade/DSB still have no daily path — /restrictedaggregation rejects sub-yearly intervals server-side.
Adding fact_daily adapters is a parallel module set, not an
architectural change:
SourceRegistry is grain-agnostic — same categories / filters
loaded once per run; BRASK yearly (ingestBrask) and daily
(ingestBraskDaily) both reuse it.brask.ts →
fact_yearly; brask.daily.ts → fact_daily when ingest
passes grain: 'daily' (§10.3). BRIS mirrors that split for
Police only: bris.ts materialises fact_yearly (always
MISSION_CALL_TIME_YEAR); bris.daily.ts materialises
fact_daily when ingest passes grain: 'daily' and
source_id = 2 (§10.4). Brigade/DSB stay yearly-only because
/restrictedaggregation rejects sub-yearly intervals server-side
(§10.1). Sub-year chart reads are gated by sources.supports_subyear
/ dimension read-grain (see
postgres-contract-rls-and-keys.md
on get_fire_data + taxonomy
taxonomy-and-eu-firestat-alignment.md
§3.1).(years[], dim_code, filters…) slice and explodes it into years.length × 12 BRASK form
POSTs (one per (year, month) pair, column = dag). Each response
contributes 28–31 fact-daily rows per category. Two-tier idempotency
compares per-month sums to detect changed months; unchanged months skip
their POST entirely./restrictedaggregation rejects sub-yearly intervals (§10.1).SUM(value) GROUP BY (source_id, …), per-bucket diff is per-day instead
of per-year._dispatch.ts carries the grain: 'yearly' | 'daily' parameter and
maps each source-grain combination to its adapter module:
(BRASK, daily) → ingestBraskDaily, (BRIS-Police, daily) → ingestBrisDaily,
(BRIS-Brigade|BRIS-DSB|SSB, daily) → typed error.fact_daily_enforce_filter_axes (same function as yearly) is installed on
fact_daily (postgres-contract-rls-and-keys.md), so the same checks apply.The existing fact_buildings_yearly table (annual building stock for
denominator calculations) stays separate — building-stock data has no daily
grain.
brask.daily.ts)The daily BRASK adapter never re-fetches an unchanged window. Each call goes through four sequential gates; on a stable history every gate downstream of Tier-1 short-circuits.
flowchart TD start["Slice = (dim_code, metric, fire_type, naering, age, region, date_start..date_end)"] start --> t0["Tier-0 (DB only) — fact_daily_slice_ingest_prefetch<br/>daily_row_count > 0 ?"] t0 --> t1pre{"fact_yearly populated<br/>for this slice?"} t1pre -->|"no"| chainYearly["Auto-chain: run ingestBraskSlice (yearly) first.<br/>Same runId, same registry, same supabase client.<br/>Re-run Tier-0 prefetch."] chainYearly --> coldBranch t1pre -->|"yes"| coldBranch coldBranch{"daily_row_count == 0<br/>(cold)?"} coldBranch -->|"yes"| coldFan["Cold fan-out:<br/>monthsInWindow restricted to<br/>years present in yearlyByYear"] coldBranch -->|"no"| t1 coldFan --> emptyCheck{"any years left?"} emptyCheck -->|"no"| unchanged["Slice unchanged.<br/>No BRASK POST. No write."] emptyCheck -->|"yes"| t3 t1["Tier-1 (DB only) — yearly drift detector<br/>driftedYears = { y | yearlyByYear[y] - dailyByYear[y] != 0 }"] t1 -->|"empty"| unchanged t1 -->|"non-empty"| t2 t2["Tier-2 (BRASK) — 1 POST per drifted year<br/>row=arsak|kilde, column=maned, filters.ar=[year]<br/>dbByMonth = prefetch.daily_by_year_month restricted to year<br/>changedMonths = { m | apiByMonth[m] - dbByMonth[m] != 0 }"] t2 -->|"empty for year"| nextYear["go to next driftedYear"] t2 -->|"non-empty"| t3 nextYear --> t2 t3["Tier-3 (BRASK) — 1 POST per changed month<br/>row=arsak|kilde, column=dag, filters ar+maned<br/>28..31 day cells"] t3 --> filterDays["Drop impossible days:<br/>new Date(Date.UTC(y, m-1, d)).getUTCDate() !== d"] filterDays --> classify["classifyWritesForDailySlice -> toWrite + write_stats"] classify --> upsert["upsert_fact_daily_batch<br/>WHERE row IS DISTINCT FROM EXCLUDED"] upsert --> phaseUpdate["onPhaseChange -> ingest_runs.phase_detail"] phaseUpdate --> nextMonth["next month"]
Tier-0 vs the outer coverage probe. The diagram's Tier-0 is the
adapter's internal cold check. It asks one yes/no question — is
fact_daily.daily_row_count > 0 for this slice + window? — using the same
fact_daily_slice_ingest_prefetch round-trip that supplies the Tier-1 / Tier-2
aggregates. The outer Explore coverage probe
(fire_data.probe_slice_coverage_daily when the URL requests sub-year
granularity — see Explore doc) is a
different thing: it answers "should we trigger the adapter at all?" and adds
last_run_completed so Explore can distinguish "empty and never ingested"
from "empty but an ingest just finished" before opening a polling channel.
The two checks live at different layers; Tier-0 is the adapter's
short-circuit, the coverage probe is the page's gating predicate.
Why Tier-1 reads fact_yearly (not BRASK). In Explore the granularity selector
flow yearly → daily guarantees fact_yearly was just refreshed for the
slice. That makes fact_yearly the authoritative yearly truth, and re-asking
BRASK for the same numbers is pure redundancy. Tier-1 compares two Postgres
aggregates from the same database — exact integer math, no HTTP round-trip,
no rate-limit exposure. The fact_yearly_slice_ingest_prefetch call ships
both yearly_by_year (from fact_yearly) and daily_by_year /
daily_by_year_month (from fact_daily) in one round-trip.
Auto-chain fallback (t1pre branch). If fact_yearly is empty for the
slice (cron / operator path, or first-ever run), the daily adapter invokes
the existing ingestBraskSlice (yearly) before the cold / drift split.
Same runId, same supabase client, same registry. This mirrors the
natural Explore flow without imposing a precondition error on non-UI
callers, and — critically for cold mode — gives the fan-out an authoritative
yearlyByYear snapshot to filter against (see next paragraph).
Cold fan-out is filtered by yearlyByYear. In cold mode (daily_row_count == 0), after the optional yearly auto-chain, driftedYears is monthsInWindow.years ∩ yearlyByYear.keys(). Years absent from yearlyByYear are skipped (BRASK returns HTTP 500 for years with no rows). An empty intersection short-circuits to status: 'unchanged' with zero BRASK POSTs. Drift mode uses diffNumberMaps over the same year keys.
Failing-cursor error message. postBraskForm accepts an optional
{ year, month } context and surfaces it in the thrown message
(e.g. BRASK POST 500 (2026-01)). The route handler stores that message on
fire_data.ingest_runs.error_message, and the in-progress
brask_daily_fanout phase_detail (with its own cursor: { year, month })
is preserved on error — see §5.6 below for the lifecycle-on-error rule.
Subtraction diff vs row-equality. diffNumberMaps is strict numeric
subtraction over a Map<key, number> — keys missing on one side count as
zero. BRASK skips zero cells on the API side, and the BRASK adapter likewise
skips zero rows on the DB side (see §4.2); the diff stays correct because
both sides are missing the same zeros.
Impossible-day filter. BRASK always returns 31 day columns. transformBraskDailyTable
drops days that fail the JS UTC round-trip — new Date(Date.UTC(year, month-1, day))
auto-rolls Feb 30 to March 2, so the check is exactly:
const d = new Date(Date.UTC(year, month - 1, day)) if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) continue if (numeric === 0) continue // matches yearly zero-cell skip
No daysInMonth table, no leap-year branching.
RPC contracts (supabase/migrations/20260512010000_fact_daily_helpers_and_phase_detail.sql):
fire_data.fact_daily_slice_ingest_prefetch(p_source_id, p_dim_code, p_*_code, p_metric, p_date_start, p_date_end) →
(daily_grand_total, daily_by_year, daily_by_year_month, daily_row_count, yearly_by_year).
One round-trip yields every aggregate the four-tier probe needs.fire_data.probe_slice_coverage_daily(p_source_id, p_dim_code, p_*_codes integer[], p_slice_hash, p_date_start, p_date_end, p_metric) →
(min_date, max_date, last_ingested, row_count, last_run_completed). Sibling of the yearly probe.fire_data.upsert_fact_daily_batch(p_rows jsonb) — same WHERE row IS DISTINCT FROM EXCLUDED gate as the yearly batch
upsert; date replaces year in the natural key.phase_detail progress contract. Some daily adapters finish one logical slice (slices_total = 1) across many internal steps (extra HTTP round-trips or months). They emit a versioned jsonb payload on fire_data.ingest_runs.phase_detail so the UI can show monotonic canonical_progress (0..1) instead of inferring progress from slices_done / slices_total.
Two kind values share the same field names today so Explore polling and GET /api/ingest/status/[runId] stay parser-stable; the discriminated union is typed as PhaseDetail in lib/data-model/types.ts:
kind | Module | Notes |
|---|---|---|
brask_daily_fanout | brask.daily.ts | Many (year, month) BRASK POSTs per slice; done / total count months in the run window. |
bris_police_daily_fanout | bris.daily.ts | Same tier labels (tier0_cold_check … tier3_daily_fetch); done / total also count months in the window so the bar matches BRASK semantics. |
BRASK example (brask_daily_fanout):
{ "v": 1, "kind": "brask_daily_fanout", "phase": "tier3_daily_fetch", // tier0_cold_check | tier1_yearly_probe | tier2_monthly_probe | tier3_daily_fetch | write | done "done": 37, "total": 120, "canonical_progress": 0.308, // monotonic 0..1; UI reads this for the bar "cursor": { "year": 2019, "month": 3 }, "started_at": "2026-05-11T19:30:00Z", "stats": { "rows_added": 412, "rows_updated": 5, "rows_skipped_identical": 1893 } }
BRIS Police example (bris_police_daily_fanout): same keys; phase uses the same tier string union; stats matches SliceWriteStats.
{ "v": 1, "kind": "bris_police_daily_fanout", "phase": "tier2_monthly_probe", "done": 14, "total": 120, "canonical_progress": 0.117, "cursor": { "year": 2020, "month": 2 }, "started_at": "2026-05-12T10:00:00Z", "stats": { "rows_added": 0, "rows_updated": 0, "rows_skipped_identical": 0 } }
The polling status route (GET /api/ingest/status/[runId]) selects
phase_detail and passes it through. Consumers in the Explore workspace
treat brask_daily_fanout and bris_police_daily_fanout the same way:
prefer canonical_progress over slices_done / slices_total when either kind is present.
slice_hash is a Postgres-generated column. fire_data.ingest_runs.slice_hash
is GENERATED ALWAYS AS fire_data.compute_slice_hash(...) STORED from the
canonical slice_config JSON
(migration 20260512000000).
Adapters and Explore never compute the hash in TypeScript; pre-insert
lookups call the compute_slice_hash RPC, which uses the same SQL function
behind the partial unique index ingest_runs_active_slice.
Cost on a 10-year slice, fully stable history (Explore flow).
| Tier | DB RPCs | BRASK POSTs | Writes |
|---|---|---|---|
| 0 | 1 | 0 | 0 |
| 1 | 0 (data folded into Tier-0 prefetch) | 0 | 0 |
| 2 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 |
| Total | 1 | 0 | 0 |
When only the last 2 months changed: Tier-1 surfaces 1 drifted year, Tier-2
sends 1 BRASK POST, Tier-3 sends ≤ 2 BRASK POSTs, and the upsert RPC writes
~50–60 rows after IS DISTINCT FROM filters out unchanged keys.
bris.daily.ts)Police is the only BRIS sub-source the upstream lets us hit at sub-yearly
intervals (§10.1). The adapter mirrors the BRASK four-tier vocabulary but
each tier issues at most one POST instead of per-(year, month)
fan-out: BRIS /aggregate accepts nextLevelAggregationDefinition.interval
of MONTH or DAY together with optional periodStart / periodEnd,
so one HTTP call covers a whole year (Tier-2) or a whole month (Tier-3).
flowchart TD start["Slice = (sub=POLICE, dim_code, fire_type, building_type, region, date_start..date_end)"] start --> t0["Tier-0 (DB only) — fact_daily_slice_ingest_prefetch<br/>daily_row_count > 0 ?"] t0 --> t1pre{"fact_yearly populated<br/>for this slice?"} t1pre -->|"no"| chainYearly["Auto-chain: ingestBrisSlice (yearly).<br/>Same runId, same registry, same supabase client.<br/>Re-run Tier-0 prefetch."] chainYearly --> coldBranch t1pre -->|"yes"| coldBranch coldBranch{"daily_row_count == 0<br/>(cold)?"} coldBranch -->|"yes"| coldFan["Cold fan-out:<br/>monthsInWindow restricted to<br/>years present in yearlyByYear"] coldBranch -->|"no"| t1 coldFan --> emptyCheck{"any years left?"} emptyCheck -->|"no"| unchanged["Slice unchanged.<br/>No BRIS POST. No write."] emptyCheck -->|"yes"| t3 t1["Tier-1 (DB only) — yearly drift detector<br/>driftedYears = { y | yearlyByYear[y] - dailyByYear[y] != 0 }"] t1 -->|"empty"| unchanged t1 -->|"non-empty"| t2 t2["Tier-2 (BRIS) — 1 MONTH POST per drifted year<br/>subGroupBy = MISSION_CALL_TIME_MONTH<br/>periodStart=y-01-01, periodEnd=y-12-31<br/>changedMonths = diff vs daily_by_year_month for year"] t2 -->|"empty for year"| nextYear["go to next driftedYear"] t2 -->|"non-empty"| t3 nextYear --> t2 t3["Tier-3 (BRIS) — 1 DAY POST per changed month<br/>subGroupBy = MISSION_CALL_TIME_DAY<br/>periodStart=y-MM-01, periodEnd=last day of month"] t3 --> filterDays["Skip zero-count day cells:<br/>BRIS zero-pads every day, mirrors BRASK skip"] filterDays --> classify["classifyWritesForDailySlice -> toWrite + write_stats"] classify --> upsert["upsert_fact_daily_batch<br/>WHERE row IS DISTINCT FROM EXCLUDED"] upsert --> phaseUpdate["onPhaseChange -> ingest_runs.phase_detail (kind: bris_police_daily_fanout)"] phaseUpdate --> nextMonth["next month"]
Tier-0 / Tier-1 are shared with BRASK daily. Both adapters call the
same fact_daily_slice_ingest_prefetch RPC and read the same
yearly_by_year / daily_by_year aggregates. The only behavioural
difference is that Tier-1's auto-chain falls back to ingestBrisSlice
(not ingestBraskSlice) when fact_yearly is empty for the slice — same
contract, different source. Cold-mode fan-out is filtered by
yearlyByYear the same way as BRASK daily (§10.3): symmetric behaviour
keeps the two adapters drift-free even though BRIS /aggregate is more
forgiving than the BRASK form for empty future periods.
Tier-2 is one BRIS POST per drifted year. parseBrisMonthlySums(raw)
sums count across all category buckets (including the -1 "Ikke
besvart" bucket, because Tier-3 writes those rows under the same
native_name lookup). The result is compared month-for-month against
prefetch.dailyByYearMonth filtered to the year via
filterDailyByYearMonthForYear (same shape used by BRASK daily). On a
stable history Tier-2 returns no drifted months and Tier-3 issues zero
POSTs.
Tier-3 is one BRIS POST per drifted month. The DAY response zero-pads
every calendar day in the period (verified by
docs/ingestion/adapter-mappings/.live_responses/dataset_12_police_arnested_DAY_2024.json —
366 day buckets per category for 2024, most with count: 0).
transformBrisDailyResponse skips count === 0 entries the same way
transformBraskDailyTable skips zero cells, so fact_daily only
records actual incident days.
No impossible-day filter. BRIS responds with proper UTC-midnight
timestamps for real calendar days only — Feb 30 etc. simply don't appear,
so the JS Date.UTC round-trip BRASK uses isn't needed here.
phase_detail.kind = 'bris_police_daily_fanout'. Same phase set as
brask_daily_fanout (tier0_cold_check, tier1_yearly_probe,
tier2_monthly_probe, tier3_daily_fetch, write, done) and the same
monotonic canonical_progress curve. The Explore polling UI handles both
discriminators identically (app/(main)/explore/_lib/ingest-progress.ts,
app/(main)/explore/_components/IngestRunDetailSheet.tsx).
Cost on a 10-year slice, fully stable history.
| Tier | DB RPCs | BRIS POSTs | Writes |
|---|---|---|---|
| 0 | 1 | 0 | 0 |
| 1 | 0 (folded into Tier-0 prefetch) | 0 | 0 |
| 2 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 |
| Total | 1 | 0 | 0 |
When the latest month changed: Tier-1 surfaces 1 drifted year → Tier-2
sends 1 BRIS POST → Tier-3 sends ≤ 1 BRIS POST per drifted month → the
upsert RPC writes only the days whose count actually moved.
docs/ingestion/catalog-governance-and-reconciliation.md —
global ingest policy (catalog, adapters, monitoring) + Mermaid overviews.
docs/fire-data-schema/schema-history/README.md —
database docs index and migration contract references.
docs/fire-data-schema/postgres-contract-rls-and-keys.md — live schema
contract authority.
docs/ingestion/adapter-mappings/category-native-to-cat-code.md —
dim_code / cat_code source-of-truth + Ikke besvart §5.
docs/ingestion/adapter-mappings/filter-axes-native-to-filter-id.md —
per-axis native ↔ standard filter mappings.
docs/ingestion/adapter-mappings/regions-and-cresta.md —
canonical region model + source-to-canonical mapping.
docs/ingestion/adapter-mappings/source-api-request-examples.md —
the 11 verified BRIS slice templates plus BRASK/SSB request shapes.
docs/ingestion/adapter-mappings/ssb-pxwebapi-tables.md — SSB PxWebApi tables and
region inventory.
docs/fire-data-schema/schema-history/brask-ingest-loop-and-invariants.md —
BRASK ingestion-loop invariants.
docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md —
get_fire_data argument summary (including multi-token filter OR).