Document event and Postgres integration contracts

This commit is contained in:
2026-06-10 20:12:27 +00:00
parent 4ac4e401ed
commit abb8f218ec
6 changed files with 658 additions and 407 deletions

238
docs/integrations/events.md Normal file
View File

@@ -0,0 +1,238 @@
# Event Wire Contract
This document is the canonical JSON contract for events emitted by
`weatherfeeder` JSON sinks, including stdout and NATS. Postgres stores the same
event envelope fields in parent table columns; see
[Postgres integration](postgres.md).
Downstream consumers should read the envelope, switch on `schema`, and decode
`payload` according to that schema.
## Envelope
Every emitted event is a JSON object with these fields:
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `id` | string | yes | Stable event identifier. Treat as opaque. |
| `kind` | string | yes | Routing kind, such as `observation` or `alert`. |
| `source` | string | yes | Configured source name. |
| `emitted_at` | timestamp | yes | When the daemon emitted the event. |
| `effective_at` | timestamp | no | Timestamp the payload is about, when known. |
| `schema` | string | no | Schema identifier. Weatherfeeder sources and normalizers set this. |
| `payload` | object, array, string, or scalar | yes | Schema-specific payload. |
Timestamps are JSON strings using Go `time.Time` JSON encoding, which is
RFC3339Nano-compatible. Weatherfeeder normalizers use UTC timestamps for
canonical payloads.
## Kinds And Schemas
Canonical schemas emitted after normalization:
| Kind | Schema | Payload |
|---|---|---|
| `observation` | `weather.observation.v1` | `WeatherObservation` |
| `forecast` | `weather.forecast.v1` | `WeatherForecastRun` |
| `forecast_discussion` | `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
| `weather_story` | `weather.weather_story.v1` | `WeatherStoryRun` |
| `alert` | `weather.alert.v1` | `WeatherAlertRun` |
Raw upstream schemas emitted by current sources:
| Kind | Schema | Payload |
|---|---|---|
| `observation` | `raw.nws.observation.v1` | NWS observation JSON |
| `observation` | `raw.openmeteo.current.v1` | Open-Meteo current JSON |
| `observation` | `raw.openweather.current.v1` | OpenWeather current JSON |
| `forecast` | `raw.nws.hourly.forecast.v1` | NWS hourly forecast JSON |
| `forecast` | `raw.nws.narrative.forecast.v1` | NWS narrative forecast JSON |
| `forecast_discussion` | `raw.nws.forecast_discussion.v1` | NWS forecast discussion HTML string |
| `weather_story` | `raw.nws.weatherstories.v1` | NWS weather stories JSON |
| `forecast` | `raw.openmeteo.hourly.forecast.v1` | Open-Meteo hourly forecast JSON |
| `alert` | `raw.nws.alerts.v1` | NWS alerts JSON |
`standards.SchemaRawOpenWeatherHourlyForecastV1` exists in code, but no current
registered source emits it.
## Shared Conventions
- Canonical numeric measurements use metric units.
- Floating-point values in canonical payloads are rounded to 4 digits after the
decimal point during normalization.
- Optional fields use JSON `omitempty`; absent fields should be treated as
unknown.
- `conditionCode` is a WMO weather interpretation code. Unknown observation
conditions use `-1`. Forecast period `conditionCode` is optional.
- Additive fields are compatible within a schema version. Removing, renaming, or
changing the meaning of a field requires a new schema identifier.
## `weather.observation.v1`
Payload type: `WeatherObservation`.
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `stationId` | string | no | Provider station/location identifier. |
| `stationName` | string | no | Human station name. |
| `timestamp` | timestamp | yes | Observation timestamp. |
| `conditionCode` | integer | yes | WMO code; `-1` means unknown. |
| `isDay` | boolean | no | Day/night hint. |
| `textDescription` | string | no | Short human description. |
| `temperatureC` | number | no | Celsius. |
| `dewpointC` | number | no | Celsius. |
| `windDirectionDegrees` | number | no | Degrees. |
| `windSpeedKmh` | number | no | Kilometers per hour. |
| `windGustKmh` | number | no | Kilometers per hour. |
| `barometricPressurePa` | number | no | Pascals. |
| `visibilityMeters` | number | no | Meters. |
| `relativeHumidityPercent` | number | no | Percent from 0 to 100. |
| `apparentTemperatureC` | number | no | Celsius. |
| `presentWeather` | array | no | Provider-specific present weather fragments. |
`presentWeather[]` entries contain optional `raw` objects.
## `weather.forecast.v1`
Payload type: `WeatherForecastRun`.
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `locationId` | string | no | Provider location identifier. |
| `locationName` | string | no | Human location name. |
| `issuedAt` | timestamp | yes | When the forecast run was generated or issued. |
| `updatedAt` | timestamp | no | Later provider update time. |
| `product` | string | yes | `hourly`, `narrative`, or `daily`. |
| `latitude` | number | no | Degrees. |
| `longitude` | number | no | Degrees. |
| `elevationMeters` | number | no | Meters. |
| `periods` | array | yes | Ordered forecast periods. |
`periods[]` entries:
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `startTime` | timestamp | yes | Period start. |
| `endTime` | timestamp | yes | Period end. |
| `name` | string | no | Human label. |
| `isDay` | boolean | no | Day/night hint. |
| `conditionCode` | integer | no | WMO code when applicable. |
| `textDescription` | string | no | Human summary. |
| `temperatureC` | number | no | Celsius. |
| `temperatureCMin` | number | no | Celsius. |
| `temperatureCMax` | number | no | Celsius. |
| `dewpointC` | number | no | Celsius. |
| `relativeHumidityPercent` | number | no | Percent from 0 to 100. |
| `windDirectionDegrees` | number | no | Degrees. |
| `windSpeedKmh` | number | no | Kilometers per hour. |
| `windGustKmh` | number | no | Kilometers per hour. |
| `barometricPressurePa` | number | no | Pascals. |
| `visibilityMeters` | number | no | Meters. |
| `apparentTemperatureC` | number | no | Celsius. |
| `cloudCoverPercent` | number | no | Percent from 0 to 100. |
| `probabilityOfPrecipitationPercent` | number | no | Percent from 0 to 100. |
| `precipitationAmountMm` | number | no | Liquid-equivalent millimeters. |
| `snowfallDepthMm` | number | no | Millimeters. |
| `uvIndex` | number | no | Unitless index. |
## `weather.forecast_discussion.v1`
Payload type: `WeatherForecastDiscussion`.
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `officeId` | string | no | NWS office identifier. |
| `officeName` | string | no | Office name. |
| `product` | string | yes | Current value is `afd`. |
| `issuedAt` | timestamp | yes | Bulletin issue time. |
| `updatedAt` | timestamp | no | Later update time. |
| `keyMessages` | array of strings | no | Extracted key messages. |
| `shortTerm` | object | no | Short-term section. |
| `longTerm` | object | no | Long-term section. |
`shortTerm` and `longTerm` sections contain optional `qualifier`, `issuedAt`,
and `text` fields.
## `weather.weather_story.v1`
Payload type: `WeatherStoryRun`.
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `officeId` | string | no | NWS office identifier. |
| `asOf` | timestamp | yes | Snapshot time. |
| `stories` | array | yes | Ordered story cards. |
`stories[]` entries:
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `officeId` | string | no | Office identifier. |
| `startTime` | timestamp | yes | Story start. |
| `endTime` | timestamp | yes | Story end. |
| `updatedAt` | timestamp | yes | Story update time. |
| `title` | string | no | Story title. |
| `description` | string | no | Story description. |
| `altText` | string | no | Image alternate text. |
| `priority` | boolean | yes | Provider priority flag. |
| `order` | integer | yes | Provider display order. |
| `downloadUrl` | string | no | Story image URL. |
## `weather.alert.v1`
Payload type: `WeatherAlertRun`.
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `locationId` | string | no | Provider location identifier. |
| `locationName` | string | no | Human location name. |
| `asOf` | timestamp | yes | Snapshot time. |
| `latitude` | number | no | Degrees. |
| `longitude` | number | no | Degrees. |
| `alerts` | array | yes | Active alerts. |
`alerts[]` entries:
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `id` | string | yes | Provider-stable alert identifier. |
| `event` | string | no | Alert event label. |
| `headline` | string | no | Alert headline. |
| `severity` | string | no | Provider severity. |
| `urgency` | string | no | Provider urgency. |
| `certainty` | string | no | Provider certainty. |
| `status` | string | no | Alert status. |
| `messageType` | string | no | Alert message type. |
| `category` | string | no | Alert category. |
| `response` | string | no | Recommended response. |
| `description` | string | no | Alert description. |
| `instruction` | string | no | Alert instruction. |
| `sent` | timestamp | no | Provider sent time. |
| `effective` | timestamp | no | Effective time. |
| `onset` | timestamp | no | Onset time. |
| `expires` | timestamp | no | Expiration time. |
| `areaDescription` | string | no | Affected area description. |
| `senderName` | string | no | Provider sender name. |
| `references` | array | no | Related alerts. |
`references[]` entries contain optional `id`, `identifier`, `sender`, and
`sent` fields.
## Compact Example
```json
{
"id": "NWSObservationKSTL:2026-06-10T12:00:00Z",
"kind": "observation",
"source": "NWSObservationKSTL",
"emitted_at": "2026-06-10T12:00:05Z",
"effective_at": "2026-06-10T12:00:00Z",
"schema": "weather.observation.v1",
"payload": {
"stationId": "KSTL",
"timestamp": "2026-06-10T12:00:00Z",
"conditionCode": 0,
"temperatureC": 22.5
}
}
```

View File

@@ -0,0 +1,403 @@
# Postgres Integration
This document is the canonical table contract for the optional `postgres` sink.
It describes the schema created and written by weatherfeeder through feedkit's
Postgres sink.
Configure the sink as described in [configuration](../config.md#postgres).
## Initialization And Writes
At startup, each configured Postgres sink opens the database and runs
`CREATE TABLE IF NOT EXISTS` for every weatherfeeder table, followed by
`CREATE INDEX IF NOT EXISTS` for every configured index.
This initialization creates missing tables and indexes only. It does not alter
existing tables, migrate column definitions, drop old objects, or backfill data.
Schema changes require operator-managed database migration.
Events are mapped only for canonical weather schemas:
- `weather.observation.v1`
- `weather.forecast.v1`
- `weather.forecast_discussion.v1`
- `weather.weather_story.v1`
- `weather.alert.v1`
Unsupported schemas produce no writes for this sink. Mapped events are inserted
transactionally. Inserts use ordinary `INSERT`; duplicate primary keys fail the
write.
## Shared Envelope Columns
Parent tables store the feed event envelope:
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
## Table Overview
| Table | Primary key | Prune column |
|---|---|---|
| `observations` | `event_id` | `observed_at` |
| `observation_present_weather` | `event_id`, `weather_index` | `observed_at` |
| `forecasts` | `event_id` | `issued_at` |
| `forecast_periods` | `run_event_id`, `period_index` | `issued_at` |
| `forecast_discussions` | `event_id` | `issued_at` |
| `forecast_discussion_key_messages` | `run_event_id`, `message_index` | `issued_at` |
| `weather_story_runs` | `event_id` | `as_of` |
| `weather_stories` | `run_event_id`, `story_index` | `as_of` |
| `alert_runs` | `event_id` | `as_of` |
| `alerts` | `run_event_id`, `alert_index` | `as_of` |
| `alert_references` | `run_event_id`, `alert_index`, `reference_index` | `as_of` |
## Table Contract
### `observations`
Primary key: `event_id`
Prune column: `observed_at`
Indexes:
- `idx_wf_obs_station_observed_at` on `station_id`, `observed_at`
- `idx_wf_obs_observed_at` on `observed_at`
- `idx_wf_obs_condition_code` on `condition_code`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
| `station_id` | `TEXT` | yes | `payload.stationId` |
| `station_name` | `TEXT` | yes | `payload.stationName` |
| `observed_at` | `TIMESTAMPTZ` | no | `payload.timestamp` |
| `condition_code` | `INTEGER` | no | `payload.conditionCode` |
| `is_day` | `BOOLEAN` | yes | `payload.isDay` |
| `text_description` | `TEXT` | yes | `payload.textDescription` |
| `temperature_c` | `DOUBLE PRECISION` | yes | `payload.temperatureC` |
| `dewpoint_c` | `DOUBLE PRECISION` | yes | `payload.dewpointC` |
| `wind_direction_degrees` | `DOUBLE PRECISION` | yes | `payload.windDirectionDegrees` |
| `wind_speed_kmh` | `DOUBLE PRECISION` | yes | `payload.windSpeedKmh` |
| `wind_gust_kmh` | `DOUBLE PRECISION` | yes | `payload.windGustKmh` |
| `barometric_pressure_pa` | `DOUBLE PRECISION` | yes | `payload.barometricPressurePa` |
| `visibility_meters` | `DOUBLE PRECISION` | yes | `payload.visibilityMeters` |
| `relative_humidity_percent` | `DOUBLE PRECISION` | yes | `payload.relativeHumidityPercent` |
| `apparent_temperature_c` | `DOUBLE PRECISION` | yes | `payload.apparentTemperatureC` |
### `observation_present_weather`
Primary key: `event_id`, `weather_index`
Prune column: `observed_at`
Foreign key: `event_id` references `observations(event_id)` with cascade delete.
Index: `idx_wf_obs_present_observed_at` on `observed_at`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT REFERENCES observations(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `weather_index` | `INTEGER` | no | `payload.presentWeather[]` index. |
| `observed_at` | `TIMESTAMPTZ` | no | `payload.timestamp` |
| `raw_text` | `TEXT` | yes | Compact JSON text from `payload.presentWeather[].raw` |
### `forecasts`
Primary key: `event_id`
Prune column: `issued_at`
Indexes:
- `idx_wf_fc_location_product_issued_at` on `location_id`, `product`, `issued_at`
- `idx_wf_fc_issued_at` on `issued_at`
- `idx_wf_fc_product_issued_at` on `product`, `issued_at`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
| `location_id` | `TEXT` | yes | `payload.locationId` |
| `location_name` | `TEXT` | yes | `payload.locationName` |
| `issued_at` | `TIMESTAMPTZ` | no | `payload.issuedAt` |
| `updated_at` | `TIMESTAMPTZ` | yes | `payload.updatedAt` |
| `product` | `TEXT` | no | `payload.product` |
| `latitude` | `DOUBLE PRECISION` | yes | `payload.latitude` |
| `longitude` | `DOUBLE PRECISION` | yes | `payload.longitude` |
| `elevation_meters` | `DOUBLE PRECISION` | yes | `payload.elevationMeters` |
| `period_count` | `INTEGER` | no | `len(payload.periods)` |
### `forecast_periods`
Primary key: `run_event_id`, `period_index`
Prune column: `issued_at`
Foreign key: `run_event_id` references `forecasts(event_id)` with cascade delete.
Indexes:
- `idx_wf_fc_period_start_time` on `start_time`
- `idx_wf_fc_period_end_time` on `end_time`
- `idx_wf_fc_period_run_start` on `run_event_id`, `start_time`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES forecasts(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `period_index` | `INTEGER` | no | `payload.periods[]` index. |
| `issued_at` | `TIMESTAMPTZ` | no | Parent `payload.issuedAt` |
| `start_time` | `TIMESTAMPTZ` | no | `payload.periods[].startTime` |
| `end_time` | `TIMESTAMPTZ` | no | `payload.periods[].endTime` |
| `name` | `TEXT` | yes | `payload.periods[].name` |
| `is_day` | `BOOLEAN` | yes | `payload.periods[].isDay` |
| `condition_code` | `INTEGER` | yes | `payload.periods[].conditionCode` |
| `text_description` | `TEXT` | yes | `payload.periods[].textDescription` |
| `temperature_c` | `DOUBLE PRECISION` | yes | `payload.periods[].temperatureC` |
| `temperature_c_min` | `DOUBLE PRECISION` | yes | `payload.periods[].temperatureCMin` |
| `temperature_c_max` | `DOUBLE PRECISION` | yes | `payload.periods[].temperatureCMax` |
| `dewpoint_c` | `DOUBLE PRECISION` | yes | `payload.periods[].dewpointC` |
| `relative_humidity_percent` | `DOUBLE PRECISION` | yes | `payload.periods[].relativeHumidityPercent` |
| `wind_direction_degrees` | `DOUBLE PRECISION` | yes | `payload.periods[].windDirectionDegrees` |
| `wind_speed_kmh` | `DOUBLE PRECISION` | yes | `payload.periods[].windSpeedKmh` |
| `wind_gust_kmh` | `DOUBLE PRECISION` | yes | `payload.periods[].windGustKmh` |
| `barometric_pressure_pa` | `DOUBLE PRECISION` | yes | `payload.periods[].barometricPressurePa` |
| `visibility_meters` | `DOUBLE PRECISION` | yes | `payload.periods[].visibilityMeters` |
| `apparent_temperature_c` | `DOUBLE PRECISION` | yes | `payload.periods[].apparentTemperatureC` |
| `cloud_cover_percent` | `DOUBLE PRECISION` | yes | `payload.periods[].cloudCoverPercent` |
| `probability_of_precipitation_percent` | `DOUBLE PRECISION` | yes | `payload.periods[].probabilityOfPrecipitationPercent` |
| `precipitation_amount_mm` | `DOUBLE PRECISION` | yes | `payload.periods[].precipitationAmountMm` |
| `snowfall_depth_mm` | `DOUBLE PRECISION` | yes | `payload.periods[].snowfallDepthMm` |
| `uv_index` | `DOUBLE PRECISION` | yes | `payload.periods[].uvIndex` |
### `forecast_discussions`
Primary key: `event_id`
Prune column: `issued_at`
Indexes:
- `idx_wf_discussion_office_product_issued_at` on `office_id`, `product`, `issued_at`
- `idx_wf_discussion_issued_at` on `issued_at`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
| `office_id` | `TEXT` | yes | `payload.officeId` |
| `office_name` | `TEXT` | yes | `payload.officeName` |
| `issued_at` | `TIMESTAMPTZ` | no | `payload.issuedAt` |
| `updated_at` | `TIMESTAMPTZ` | yes | `payload.updatedAt` |
| `product` | `TEXT` | no | `payload.product` |
| `short_term_qualifier` | `TEXT` | yes | `payload.shortTerm.qualifier` |
| `short_term_issued_at` | `TIMESTAMPTZ` | yes | `payload.shortTerm.issuedAt` |
| `short_term_text` | `TEXT` | yes | `payload.shortTerm.text` |
| `long_term_qualifier` | `TEXT` | yes | `payload.longTerm.qualifier` |
| `long_term_issued_at` | `TIMESTAMPTZ` | yes | `payload.longTerm.issuedAt` |
| `long_term_text` | `TEXT` | yes | `payload.longTerm.text` |
| `key_message_count` | `INTEGER` | no | `len(payload.keyMessages)` |
### `forecast_discussion_key_messages`
Primary key: `run_event_id`, `message_index`
Prune column: `issued_at`
Foreign key: `run_event_id` references `forecast_discussions(event_id)` with
cascade delete.
Index: `idx_wf_discussion_message_issued_at` on `issued_at`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES forecast_discussions(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `message_index` | `INTEGER` | no | `payload.keyMessages[]` index. |
| `issued_at` | `TIMESTAMPTZ` | no | Parent `payload.issuedAt` |
| `message_text` | `TEXT` | yes | `payload.keyMessages[]` value |
### `weather_story_runs`
Primary key: `event_id`
Prune column: `as_of`
Indexes:
- `idx_wf_story_run_office_as_of` on `office_id`, `as_of`
- `idx_wf_story_run_as_of` on `as_of`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
| `office_id` | `TEXT` | yes | `payload.officeId` |
| `as_of` | `TIMESTAMPTZ` | no | `payload.asOf` |
| `story_count` | `INTEGER` | no | `len(payload.stories)` |
### `weather_stories`
Primary key: `run_event_id`, `story_index`
Prune column: `as_of`
Foreign key: `run_event_id` references `weather_story_runs(event_id)` with
cascade delete.
Indexes:
- `idx_wf_stories_start_time` on `start_time`
- `idx_wf_stories_end_time` on `end_time`
- `idx_wf_stories_updated_at` on `updated_at`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES weather_story_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `story_index` | `INTEGER` | no | `payload.stories[]` index. |
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
| `office_id` | `TEXT` | yes | `payload.stories[].officeId` |
| `start_time` | `TIMESTAMPTZ` | no | `payload.stories[].startTime` |
| `end_time` | `TIMESTAMPTZ` | no | `payload.stories[].endTime` |
| `updated_at` | `TIMESTAMPTZ` | no | `payload.stories[].updatedAt` |
| `title` | `TEXT` | yes | `payload.stories[].title` |
| `description` | `TEXT` | yes | `payload.stories[].description` |
| `alt_text` | `TEXT` | yes | `payload.stories[].altText` |
| `priority` | `BOOLEAN` | no | `payload.stories[].priority` |
| `story_order` | `INTEGER` | no | `payload.stories[].order` |
| `download_url` | `TEXT` | yes | `payload.stories[].downloadUrl` |
### `alert_runs`
Primary key: `event_id`
Prune column: `as_of`
Indexes:
- `idx_wf_alert_run_location_as_of` on `location_id`, `as_of`
- `idx_wf_alert_run_as_of` on `as_of`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `event_id` | `TEXT` | no | `event.id` |
| `event_kind` | `TEXT` | no | `event.kind` |
| `event_source` | `TEXT` | no | `event.source` |
| `event_schema` | `TEXT` | no | `event.schema` |
| `event_emitted_at` | `TIMESTAMPTZ` | no | `event.emitted_at` |
| `event_effective_at` | `TIMESTAMPTZ` | yes | `event.effective_at` |
| `location_id` | `TEXT` | yes | `payload.locationId` |
| `location_name` | `TEXT` | yes | `payload.locationName` |
| `as_of` | `TIMESTAMPTZ` | no | `payload.asOf` |
| `latitude` | `DOUBLE PRECISION` | yes | `payload.latitude` |
| `longitude` | `DOUBLE PRECISION` | yes | `payload.longitude` |
| `alert_count` | `INTEGER` | no | `len(payload.alerts)` |
### `alerts`
Primary key: `run_event_id`, `alert_index`
Prune column: `as_of`
Foreign key: `run_event_id` references `alert_runs(event_id)` with cascade
delete.
Indexes:
- `idx_wf_alerts_alert_id` on `alert_id`
- `idx_wf_alerts_severity_expires` on `severity`, `expires`
- `idx_wf_alerts_as_of` on `as_of`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES alert_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `alert_index` | `INTEGER` | no | `payload.alerts[]` index. |
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
| `alert_id` | `TEXT` | no | `payload.alerts[].id` |
| `event` | `TEXT` | yes | `payload.alerts[].event` |
| `headline` | `TEXT` | yes | `payload.alerts[].headline` |
| `severity` | `TEXT` | yes | `payload.alerts[].severity` |
| `urgency` | `TEXT` | yes | `payload.alerts[].urgency` |
| `certainty` | `TEXT` | yes | `payload.alerts[].certainty` |
| `status` | `TEXT` | yes | `payload.alerts[].status` |
| `message_type` | `TEXT` | yes | `payload.alerts[].messageType` |
| `category` | `TEXT` | yes | `payload.alerts[].category` |
| `response` | `TEXT` | yes | `payload.alerts[].response` |
| `description` | `TEXT` | yes | `payload.alerts[].description` |
| `instruction` | `TEXT` | yes | `payload.alerts[].instruction` |
| `sent` | `TIMESTAMPTZ` | yes | `payload.alerts[].sent` |
| `effective` | `TIMESTAMPTZ` | yes | `payload.alerts[].effective` |
| `onset` | `TIMESTAMPTZ` | yes | `payload.alerts[].onset` |
| `expires` | `TIMESTAMPTZ` | yes | `payload.alerts[].expires` |
| `area_description` | `TEXT` | yes | `payload.alerts[].areaDescription` |
| `sender_name` | `TEXT` | yes | `payload.alerts[].senderName` |
| `reference_count` | `INTEGER` | no | `len(payload.alerts[].references)` |
### `alert_references`
Primary key: `run_event_id`, `alert_index`, `reference_index`
Prune column: `as_of`
Foreign key: `run_event_id` references `alert_runs(event_id)` with cascade
delete.
Indexes:
- `idx_wf_alert_refs_as_of` on `as_of`
- `idx_wf_alert_refs_sent` on `sent`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES alert_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `alert_index` | `INTEGER` | no | Parent alert index. |
| `reference_index` | `INTEGER` | no | `payload.alerts[].references[]` index. |
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
| `id` | `TEXT` | yes | `payload.alerts[].references[].id` |
| `identifier` | `TEXT` | yes | `payload.alerts[].references[].identifier` |
| `sender` | `TEXT` | yes | `payload.alerts[].references[].sender` |
| `sent` | `TIMESTAMPTZ` | yes | `payload.alerts[].references[].sent` |
## Retention
When sink param `prune` is set, every successful write transaction deletes rows
older than `now - prune` from every table using that table's prune column.
The sink also exposes manual prune helpers in code, but the `weatherfeeder`
binary does not provide CLI commands for them.
## Reconstructing Canonical Payloads
- `WeatherObservation`: read `observations`, then join
`observation_present_weather` by `event_id` ordered by `weather_index`.
- `WeatherForecastRun`: read `forecasts`, then join `forecast_periods` by
`run_event_id` ordered by `period_index`.
- `WeatherForecastDiscussion`: read `forecast_discussions`, then join
`forecast_discussion_key_messages` by `run_event_id` ordered by
`message_index`.
- `WeatherStoryRun`: read `weather_story_runs`, then join `weather_stories` by
`run_event_id` ordered by `story_index`.
- `WeatherAlertRun`: read `alert_runs`, join `alerts` by `run_event_id` ordered
by `alert_index`, then join `alert_references` by `run_event_id` and
`alert_index` ordered by `reference_index`.