Document current outlook schema behavior

This commit is contained in:
2026-06-12 04:38:44 +00:00
parent 97141c7a9b
commit dcea5261ab
12 changed files with 175 additions and 45 deletions

View File

@@ -63,7 +63,7 @@ func Decode(payload []byte) (any, error) {
case standards.SchemaWeatherAlertV1: case standards.SchemaWeatherAlertV1:
var out model.WeatherAlertRun var out model.WeatherAlertRun
return &out, json.Unmarshal(evt.Payload, &out) return &out, json.Unmarshal(evt.Payload, &out)
case standards.SchemaWeatherOutlookV1: case standards.SchemaWeatherOutlookV2:
var out model.WeatherOutlookRun var out model.WeatherOutlookRun
return &out, json.Unmarshal(evt.Payload, &out) return &out, json.Unmarshal(evt.Payload, &out)
default: default:

View File

@@ -9,8 +9,8 @@ import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
## Purpose ## Purpose
Package `model` defines `weatherfeeder`'s canonical weather payload structs. Package `model` defines `weatherfeeder`'s canonical weather payload structs.
These structs are emitted as the `payload` of canonical `weather.*.v1` events These structs are emitted as the `payload` of canonical `weather.*` events and
and are also the domain types consumed by downstream applications such as are also the domain types consumed by downstream applications such as
`weatherapi`. `weatherapi`.
The JSON field tags on these structs are part of the wire contract. For the full The JSON field tags on these structs are part of the wire contract. For the full
@@ -27,7 +27,7 @@ Current canonical schema families map to these public types:
| `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` | | `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
| `weather.weather_story.v1` | `WeatherStoryRun` | | `weather.weather_story.v1` | `WeatherStoryRun` |
| `weather.alert.v1` | `WeatherAlertRun` | | `weather.alert.v1` | `WeatherAlertRun` |
| `weather.outlook.v1` | `WeatherOutlookRun` | | `weather.outlook.v2` | `WeatherOutlookRun` |
Related child types include: Related child types include:
@@ -41,6 +41,9 @@ Related child types include:
- `WeatherOutlookDiscussion` - `WeatherOutlookDiscussion`
- `WMOCode` - `WMOCode`
`WeatherOutlookRun` includes `WeatherOutlookDiscussion` entries as run-level
SPC day discussions.
## Wire And Compatibility Rules ## Wire And Compatibility Rules
- JSON tags define canonical payload field names. - JSON tags define canonical payload field names.

View File

@@ -41,13 +41,13 @@ Canonical schemas emitted after normalization:
| `SchemaWeatherForecastDiscussionV1` | `weather.forecast_discussion.v1` | | `SchemaWeatherForecastDiscussionV1` | `weather.forecast_discussion.v1` |
| `SchemaWeatherStoryV1` | `weather.weather_story.v1` | | `SchemaWeatherStoryV1` | `weather.weather_story.v1` |
| `SchemaWeatherAlertV1` | `weather.alert.v1` | | `SchemaWeatherAlertV1` | `weather.alert.v1` |
| `SchemaWeatherOutlookV1` | `weather.outlook.v1` | | `SchemaWeatherOutlookV2` | `weather.outlook.v2` |
Additional canonical schema constant: Historical canonical schema constant:
| Constant | Value | | Constant | Value |
|---|---| |---|---|
| `SchemaWeatherOutlookV2` | `weather.outlook.v2` | | `SchemaWeatherOutlookV1` | `weather.outlook.v1` |
## Raw Schema Constants ## Raw Schema Constants

View File

@@ -37,7 +37,7 @@ Canonical schemas emitted after normalization:
| `forecast_discussion` | `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` | | `forecast_discussion` | `weather.forecast_discussion.v1` | `WeatherForecastDiscussion` |
| `weather_story` | `weather.weather_story.v1` | `WeatherStoryRun` | | `weather_story` | `weather.weather_story.v1` | `WeatherStoryRun` |
| `alert` | `weather.alert.v1` | `WeatherAlertRun` | | `alert` | `weather.alert.v1` | `WeatherAlertRun` |
| `outlook` | `weather.outlook.v1` | `WeatherOutlookRun` | | `outlook` | `weather.outlook.v2` | `WeatherOutlookRun` |
Raw upstream schemas emitted by current sources: Raw upstream schemas emitted by current sources:
@@ -220,13 +220,15 @@ Payload type: `WeatherAlertRun`.
`references[]` entries contain optional `id`, `identifier`, `sender`, and `references[]` entries contain optional `id`, `identifier`, `sender`, and
`sent` fields. `sent` fields.
## `weather.outlook.v1` ## `weather.outlook.v2`
Payload type: `WeatherOutlookRun`. Payload type: `WeatherOutlookRun`.
The current producer is the SPC convective outlook normalizer. It emits Day 1-3 The current producer is the SPC convective outlook normalizer. It emits Day 1-3
convective outlook polygons for categorical, tornado, hail, and wind products. convective outlook snapshots for categorical, tornado, hail, and wind products
All timestamps are UTC. that apply to the configured forecast point. Raw SPC bundles remain complete;
canonical outlook payloads are filtered to local polygons. All timestamps are
UTC.
| Field | Type | Required | Notes | | Field | Type | Required | Notes |
|---|---|:---:|---| |---|---|:---:|---|
@@ -236,7 +238,8 @@ All timestamps are UTC.
| `longitude` | number | no | Configured point longitude in decimal degrees. | | `longitude` | number | no | Configured point longitude in decimal degrees. |
| `asOf` | timestamp | yes | Snapshot time. For SPC, this is the latest outlook issue time when available. | | `asOf` | timestamp | yes | Snapshot time. For SPC, this is the latest outlook issue time when available. |
| `issuedAt` | timestamp | no | Latest issue time across outlook features when any feature exists. | | `issuedAt` | timestamp | no | Latest issue time across outlook features when any feature exists. |
| `outlooks` | array | yes | Ordered outlook polygons. | | `outlooks` | array | yes | Ordered outlook polygons containing the configured point. |
| `discussions` | array | yes | Run-level day discussions for retained outlook days. |
`outlooks[]` entries: `outlooks[]` entries:
@@ -255,19 +258,53 @@ All timestamps are UTC.
| `issuedAt` | timestamp | yes | Feature issue time. | | `issuedAt` | timestamp | yes | Feature issue time. |
| `expiresAt` | timestamp | yes | Expiration time; currently equal to `validTo`. | | `expiresAt` | timestamp | yes | Expiration time; currently equal to `validTo`. |
| `forecaster` | string | no | SPC forecaster text, when present. | | `forecaster` | string | no | SPC forecaster text, when present. |
| `headline` | string | no | Matching Day 1-3 print-page product title. |
| `summary` | string | no | Text from the print-page `...SUMMARY...` section. |
| `discussion` | string | no | Cleaned full print-page product text. |
| `sourceUrl` | string | no | GeoJSON product URL for this outlook feature. | | `sourceUrl` | string | no | GeoJSON product URL for this outlook feature. |
| `imageUrl` | string | no | Reserved for provider image URLs; currently empty. | | `imageUrl` | string | no | Reserved for provider image URLs; currently empty. |
| `containsLocation` | boolean | yes | Whether the configured point is inside or on the boundary of the polygon. | | `containsLocation` | boolean | yes | Always `true` for emitted v2 outlooks. |
| `geometry` | object | yes | Compact GeoJSON `Polygon` or `MultiPolygon` geometry. | | `geometry` | object | yes | Compact GeoJSON `Polygon` or `MultiPolygon` geometry. |
`geometry` preserves the SPC feature geometry as compact GeoJSON using `geometry` preserves the SPC feature geometry as compact GeoJSON using
`[longitude, latitude]` coordinate order. `containsLocation` is computed with `[longitude, latitude]` coordinate order. `containsLocation` is computed with
that geometry and the configured source `latitude`/`longitude`; boundary points that geometry and the configured source `latitude`/`longitude`; boundary points
count as contained. All outlook polygons are emitted, including polygons that do count as contained. Polygons that do not contain the configured point are not
not contain the configured point. included in canonical v2 payloads.
`discussions[]` entries:
| Field | Type | Required | Notes |
|---|---|:---:|---|
| `day` | integer | yes | SPC outlook day, currently `1`, `2`, or `3`. |
| `headline` | string | no | Matching Day 1-3 print-page product title. |
| `summary` | string | no | Text from the print-page `...SUMMARY...` section. |
| `discussion` | string | no | Cleaned full print-page product text. |
| `updatedAt` | timestamp | no | Print-page update time, when present. |
When no SPC polygons apply locally, the run is still emitted with `outlooks: []`
and `discussions: []`. Discussions are included only for days represented by at
least one retained outlook, and multiple retained outlook types for the same day
share one discussion entry.
### SPC Outlook Supersession
Consumers should prefer latest-run semantics for current conditions: read the
latest `WeatherOutlookRun` for the configured location and use its `outlooks`
and `discussions` arrays together.
Historical SQL consumers that collapse older rows should identify superseded
outlooks by `provider`, `product`, `outlookType`, `validFrom`, and `validTo`,
then keep rows with the greatest `issuedAt`. `day` and `label` are not identity
fields. When multiple retained polygons share that latest `issuedAt`, preserve
the full group.
## Legacy `weather.outlook.v1`
`weather.outlook.v1` is a historical canonical schema retained as a standards
constant for older data and consumers. Current SPC normalization emits
`weather.outlook.v2`.
The v1 payload used `WeatherOutlookRun` and placed `headline`, `summary`, and
`discussion` on each `outlooks[]` polygon. It also represented the pre-v2 SPC
canonical behavior, where national polygons were preserved in canonical output.
## Compact Example ## Compact Example

View File

@@ -23,7 +23,7 @@ Events are mapped only for canonical weather schemas:
- `weather.forecast_discussion.v1` - `weather.forecast_discussion.v1`
- `weather.weather_story.v1` - `weather.weather_story.v1`
- `weather.alert.v1` - `weather.alert.v1`
- `weather.outlook.v1` - `weather.outlook.v2`
Unsupported schemas produce no writes for this sink. Mapped events are inserted Unsupported schemas produce no writes for this sink. Mapped events are inserted
transactionally. Inserts use ordinary `INSERT`; duplicate primary keys fail the transactionally. Inserts use ordinary `INSERT`; duplicate primary keys fail the
@@ -59,6 +59,7 @@ Parent tables store the feed event envelope:
| `alert_references` | `run_event_id`, `alert_index`, `reference_index` | `as_of` | | `alert_references` | `run_event_id`, `alert_index`, `reference_index` | `as_of` |
| `outlook_runs` | `event_id` | `as_of` | | `outlook_runs` | `event_id` | `as_of` |
| `outlooks` | `run_event_id`, `outlook_index` | `as_of` | | `outlooks` | `run_event_id`, `outlook_index` | `as_of` |
| `outlook_discussions` | `run_event_id`, `discussion_index` | `as_of` |
## Table Contract ## Table Contract
@@ -408,6 +409,7 @@ Indexes:
| `as_of` | `TIMESTAMPTZ` | no | `payload.asOf` | | `as_of` | `TIMESTAMPTZ` | no | `payload.asOf` |
| `issued_at` | `TIMESTAMPTZ` | yes | `payload.issuedAt` | | `issued_at` | `TIMESTAMPTZ` | yes | `payload.issuedAt` |
| `outlook_count` | `INTEGER` | no | `len(payload.outlooks)` | | `outlook_count` | `INTEGER` | no | `len(payload.outlooks)` |
| `discussion_count` | `INTEGER` | no | `len(payload.discussions)` |
### `outlooks` ### `outlooks`
@@ -442,14 +444,36 @@ Indexes:
| `issued_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].issuedAt` | | `issued_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].issuedAt` |
| `expires_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].expiresAt` | | `expires_at` | `TIMESTAMPTZ` | no | `payload.outlooks[].expiresAt` |
| `forecaster` | `TEXT` | yes | `payload.outlooks[].forecaster` | | `forecaster` | `TEXT` | yes | `payload.outlooks[].forecaster` |
| `headline` | `TEXT` | yes | `payload.outlooks[].headline` |
| `summary` | `TEXT` | yes | `payload.outlooks[].summary` |
| `discussion` | `TEXT` | yes | `payload.outlooks[].discussion` |
| `source_url` | `TEXT` | yes | `payload.outlooks[].sourceUrl` | | `source_url` | `TEXT` | yes | `payload.outlooks[].sourceUrl` |
| `image_url` | `TEXT` | yes | `payload.outlooks[].imageUrl` | | `image_url` | `TEXT` | yes | `payload.outlooks[].imageUrl` |
| `contains_location` | `BOOLEAN` | no | `payload.outlooks[].containsLocation` | | `contains_location` | `BOOLEAN` | no | `payload.outlooks[].containsLocation` |
| `geometry_json` | `TEXT` | no | Compact JSON from `payload.outlooks[].geometry` | | `geometry_json` | `TEXT` | no | Compact JSON from `payload.outlooks[].geometry` |
### `outlook_discussions`
Primary key: `run_event_id`, `discussion_index`
Prune column: `as_of`
Foreign key: `run_event_id` references `outlook_runs(event_id)` with cascade
delete.
Indexes:
- `idx_wf_outlook_discussions_day_as_of` on `day`, `as_of`
- unique `idx_wf_outlook_discussions_run_day` on `run_event_id`, `day`
| Column | Type | Null | Source |
|---|---|:---:|---|
| `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. |
| `discussion_index` | `INTEGER` | no | `payload.discussions[]` index. |
| `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` |
| `day` | `INTEGER` | no | `payload.discussions[].day` |
| `headline` | `TEXT` | yes | `payload.discussions[].headline` |
| `summary` | `TEXT` | yes | `payload.discussions[].summary` |
| `discussion` | `TEXT` | yes | `payload.discussions[].discussion` |
| `updated_at` | `TIMESTAMPTZ` | yes | `payload.discussions[].updatedAt` |
## Retention ## Retention
When sink param `prune` is set, every successful write transaction deletes rows When sink param `prune` is set, every successful write transaction deletes rows
@@ -472,5 +496,6 @@ binary does not provide CLI commands for them.
- `WeatherAlertRun`: read `alert_runs`, join `alerts` by `run_event_id` ordered - `WeatherAlertRun`: read `alert_runs`, join `alerts` by `run_event_id` ordered
by `alert_index`, then join `alert_references` by `run_event_id` and by `alert_index`, then join `alert_references` by `run_event_id` and
`alert_index` ordered by `reference_index`. `alert_index` ordered by `reference_index`.
- `WeatherOutlookRun`: read `outlook_runs`, then join `outlooks` by - `WeatherOutlookRun`: read `outlook_runs`, join `outlooks` by `run_event_id`
`run_event_id` ordered by `outlook_index`. ordered by `outlook_index`, then join `outlook_discussions` by
`run_event_id` ordered by `discussion_index`.

View File

@@ -13,7 +13,7 @@ events are documented in [event wire contract](events.md).
| Driver | Kind | Raw schema | Canonical schema | | Driver | Kind | Raw schema | Canonical schema |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `spc_convective_outlook` | `outlook` | `raw.spc.convective_outlook.v1` | `weather.outlook.v1` | | `spc_convective_outlook` | `outlook` | `raw.spc.convective_outlook.v1` | `weather.outlook.v2` |
## Config Requirements ## Config Requirements
@@ -52,8 +52,8 @@ It also fetches three required print pages:
- Day 3 convective outlook print page - Day 3 convective outlook print page
GeoJSON products are authoritative for outlook polygons, valid windows, issue GeoJSON products are authoritative for outlook polygons, valid windows, issue
times, labels, and severity rank. Print pages are authoritative for headline, times, labels, and severity rank. Print pages are authoritative for run-level
summary, and discussion text. day discussion headline, summary, and discussion text.
## Accept Headers ## Accept Headers
@@ -99,23 +99,33 @@ Raw source `effective_at` prefers:
4. fetch time. 4. fetch time.
The normalizer sets canonical `asOf` and normalized event `effective_at` from The normalizer sets canonical `asOf` and normalized event `effective_at` from
the latest valid outlook feature `issuedAt`, with fallback to print-page update the latest valid GeoJSON issue time across the complete raw bundle, including
time and then the incoming event envelope. empty no-risk placeholders, with fallback to print-page update time and then the
incoming event envelope.
## Mapping Notes ## Mapping Notes
Each GeoJSON feature becomes one canonical outlook. Products are ordered by day, The raw source fetches and envelopes the complete SPC bundle. The normalizer
then categorical, tornado, hail, and wind. Feature order is preserved within decodes every configured GeoJSON product, skips empty no-risk
each product. `GeometryCollection` placeholders, and emits canonical outlooks only when the
configured point is inside or on the boundary of a real feature. Products are
ordered by day, then categorical, tornado, hail, and wind. Retained feature order
is preserved within each product.
The normalizer computes `containsLocation` with the configured latitude and The normalizer computes `containsLocation` with the configured latitude and
longitude against compact GeoJSON `Polygon` or `MultiPolygon` geometry. longitude against compact GeoJSON `Polygon` or `MultiPolygon` geometry.
Coordinates use GeoJSON order, `[longitude, latitude]`, and boundary points Coordinates use GeoJSON order, `[longitude, latitude]`, and boundary points
count as contained. count as contained.
All outlook polygons are preserved, including polygons that do not contain the Canonical outlook runs are emitted even when no polygons apply locally. In that
configured point. Matching print-page headline, summary, and discussion text is case the payload contains empty `outlooks` and `discussions` arrays.
attached to every outlook for the same day.
Print-page prose is represented as run-level day discussions. Discussions are
included only for days represented by at least one retained outlook. Multiple
retained outlook types for the same day share one discussion entry.
For downstream current-state and historical supersession guidance, see the
[event wire contract](events.md#spc-outlook-supersession).
## Failure Behavior ## Failure Behavior

View File

@@ -19,11 +19,16 @@ Inputs are canonical feed events. The mapper currently handles these schemas:
- `weather.forecast_discussion.v1` - `weather.forecast_discussion.v1`
- `weather.weather_story.v1` - `weather.weather_story.v1`
- `weather.alert.v1` - `weather.alert.v1`
- `weather.outlook.v1` - `weather.outlook.v2`
Outputs are feedkit `PostgresWrite` values for weatherfeeder-owned tables. Outputs are feedkit `PostgresWrite` values for weatherfeeder-owned tables.
Unsupported schemas produce no writes and no error. Unsupported schemas produce no writes and no error.
Outlook runs are written to `outlook_runs`, retained local polygons are written
to `outlooks`, and run-level day discussions are written to
`outlook_discussions`. The parent run row stores `outlook_count` and
`discussion_count`.
## Boundaries ## Boundaries
- Weatherfeeder owns table definitions in `schema.go`. - Weatherfeeder owns table definitions in `schema.go`.
@@ -82,6 +87,7 @@ Child rows use positional indexes to preserve canonical array order:
- `alert_index` - `alert_index`
- `reference_index` - `reference_index`
- `outlook_index` - `outlook_index`
- `discussion_index`
Required canonical fields are validated before writes are returned: Required canonical fields are validated before writes are returned:
@@ -94,7 +100,10 @@ Required canonical fields are validated before writes are returned:
- alert runs require `asOf`, and each alert requires `id`; - alert runs require `asOf`, and each alert requires `id`;
- outlook runs require `asOf`, and each outlook requires `id`, `provider`, - outlook runs require `asOf`, and each outlook requires `id`, `provider`,
`product`, `day`, `outlookType`, `label`, `validFrom`, `validTo`, `issuedAt`, `product`, `day`, `outlookType`, `label`, `validFrom`, `validTo`, `issuedAt`,
`expiresAt`, and `geometry`. `expiresAt`, `containsLocation: true`, and `geometry`;
- outlook discussions require day `1`, `2`, or `3`; at least one of
`headline`, `summary`, or `discussion`; and no duplicate discussion day in the
same run.
Nullable canonical values are converted to SQL nulls by mapper helpers. Nullable canonical values are converted to SQL nulls by mapper helpers.
Observation present-weather raw values and outlook geometry values are stored as Observation present-weather raw values and outlook geometry values are stored as

View File

@@ -82,7 +82,7 @@ document bodies as the previous successful poll.
Every event passes through normalization first and dedupe second. Every event passes through normalization first and dedupe second.
Normalizers match raw source schemas and produce canonical `weather.*.v1` Normalizers match raw source schemas and produce versioned canonical `weather.*`
payloads. If an event has no matching normalizer, the normalize processor passes payloads. If an event has no matching normalizer, the normalize processor passes
it through unchanged. it through unchanged.

View File

@@ -48,6 +48,7 @@ The implementation style is:
- Hexagonal boundaries: provider APIs, config loading, scheduling, dispatch, and sinks are external mechanisms around the weather domain model and normalization logic. - Hexagonal boundaries: provider APIs, config loading, scheduling, dispatch, and sinks are external mechanisms around the weather domain model and normalization logic.
- Raw-to-canonical flow: sources should fetch and envelope raw provider payloads; normalizers should own provider-to-canonical mapping. - Raw-to-canonical flow: sources should fetch and envelope raw provider payloads; normalizers should own provider-to-canonical mapping.
- Location-focused canonical data: canonical weather events represent data relevant to the configured forecast location or configured provider object; `weatherfeeder` is not a national provider-data archive.
- Schema-based routing: normalizers match on event schema, not source name or event kind. - Schema-based routing: normalizers match on event schema, not source name or event kind.
- Composable registries: source drivers, normalizers, processors, and sinks are assembled explicitly through registries. - Composable registries: source drivers, normalizers, processors, and sinks are assembled explicitly through registries.
- Bounded concurrency: scheduling and sink fanout are concurrent, but the application should keep queues, goroutine ownership, logging, and cancellation behavior visible. - Bounded concurrency: scheduling and sink fanout are concurrent, but the application should keep queues, goroutine ownership, logging, and cancellation behavior visible.

View File

@@ -11,7 +11,7 @@
// - weather.forecast_discussion.v1 -> model.WeatherForecastDiscussion // - weather.forecast_discussion.v1 -> model.WeatherForecastDiscussion
// - weather.weather_story.v1 -> model.WeatherStoryRun // - weather.weather_story.v1 -> model.WeatherStoryRun
// - weather.alert.v1 -> model.WeatherAlertRun // - weather.alert.v1 -> model.WeatherAlertRun
// - weather.outlook.v1 -> model.WeatherOutlookRun // - weather.outlook.v2 -> model.WeatherOutlookRun
// //
// Parent/child relationships: // Parent/child relationships:
// - observations.event_id -> observation_present_weather.event_id // - observations.event_id -> observation_present_weather.event_id
@@ -21,6 +21,7 @@
// - alert_runs.event_id -> alerts.run_event_id // - alert_runs.event_id -> alerts.run_event_id
// - alerts.(run_event_id, alert_index) -> alert_references.(run_event_id, alert_index) // - alerts.(run_event_id, alert_index) -> alert_references.(run_event_id, alert_index)
// - outlook_runs.event_id -> outlooks.run_event_id // - outlook_runs.event_id -> outlooks.run_event_id
// - outlook_runs.event_id -> outlook_discussions.run_event_id
// //
// Dedupe and retention behavior: // Dedupe and retention behavior:
// - Parent primary keys (event_id): observations, forecasts, alert_runs, outlook_runs. // - Parent primary keys (event_id): observations, forecasts, alert_runs, outlook_runs.
@@ -39,6 +40,7 @@
// - alert_references.as_of // - alert_references.as_of
// - outlook_runs.as_of // - outlook_runs.as_of
// - outlooks.as_of // - outlooks.as_of
// - outlook_discussions.as_of
// //
// Envelope field mapping (shared parent columns) // Envelope field mapping (shared parent columns)
// //
@@ -227,6 +229,7 @@
// - as_of TIMESTAMPTZ -> payload.asOf // - as_of TIMESTAMPTZ -> payload.asOf
// - issued_at TIMESTAMPTZ NULL -> payload.issuedAt // - issued_at TIMESTAMPTZ NULL -> payload.issuedAt
// - outlook_count INTEGER -> len(payload.outlooks) // - outlook_count INTEGER -> len(payload.outlooks)
// - discussion_count INTEGER -> len(payload.discussions)
// //
// 11. outlooks (PK: run_event_id, outlook_index) // 11. outlooks (PK: run_event_id, outlook_index)
// //
@@ -246,14 +249,22 @@
// - issued_at TIMESTAMPTZ -> payload.outlooks[i].issuedAt // - issued_at TIMESTAMPTZ -> payload.outlooks[i].issuedAt
// - expires_at TIMESTAMPTZ -> payload.outlooks[i].expiresAt // - expires_at TIMESTAMPTZ -> payload.outlooks[i].expiresAt
// - forecaster TEXT NULL -> payload.outlooks[i].forecaster // - forecaster TEXT NULL -> payload.outlooks[i].forecaster
// - headline TEXT NULL -> payload.outlooks[i].headline
// - summary TEXT NULL -> payload.outlooks[i].summary
// - discussion TEXT NULL -> payload.outlooks[i].discussion
// - source_url TEXT NULL -> payload.outlooks[i].sourceUrl // - source_url TEXT NULL -> payload.outlooks[i].sourceUrl
// - image_url TEXT NULL -> payload.outlooks[i].imageUrl // - image_url TEXT NULL -> payload.outlooks[i].imageUrl
// - contains_location BOOLEAN -> payload.outlooks[i].containsLocation // - contains_location BOOLEAN -> payload.outlooks[i].containsLocation
// - geometry_json TEXT -> compact JSON payload.outlooks[i].geometry // - geometry_json TEXT -> compact JSON payload.outlooks[i].geometry
// //
// 12. outlook_discussions (PK: run_event_id, discussion_index)
//
// - run_event_id TEXT -> outlook_runs.event_id / payload.discussions[i]
// - discussion_index INTEGER -> i (array position in payload.discussions)
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
// - day INTEGER -> payload.discussions[i].day
// - headline TEXT NULL -> payload.discussions[i].headline
// - summary TEXT NULL -> payload.discussions[i].summary
// - discussion TEXT NULL -> payload.discussions[i].discussion
// - updated_at TIMESTAMPTZ NULL -> payload.discussions[i].updatedAt
//
// Reconstructing canonical JSON payloads // Reconstructing canonical JSON payloads
// //
// - WeatherObservation: // - WeatherObservation:
@@ -274,6 +285,7 @@
// ordered by reference_index to rebuild references per alert. // ordered by reference_index to rebuild references per alert.
// //
// - WeatherOutlookRun: // - WeatherOutlookRun:
// read one row from outlook_runs, then join outlooks by run_event_id ordered // read one row from outlook_runs, join outlooks by run_event_id ordered by
// by outlook_index to rebuild outlooks. // outlook_index to rebuild outlooks, then join outlook_discussions by
// run_event_id ordered by discussion_index to rebuild discussions.
package postgres package postgres

View File

@@ -0,0 +1,33 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestDocumentedOutlookDiscussionStorage(t *testing.T) {
for _, path := range []string{
"../../../docs/integrations/postgres.md",
"../../../docs/internal/postgres-sink.md",
} {
t.Run(path, func(t *testing.T) {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", path, err)
}
doc := string(raw)
for _, want := range []string{
tableOutlookDiscussions,
"discussion_count",
"discussion_index",
"weather.outlook.v2",
} {
if !strings.Contains(doc, want) {
t.Fatalf("%s missing %q", path, want)
}
}
})
}
}

View File

@@ -98,7 +98,7 @@ func stringConstantsFromFile(t *testing.T, path string, prefix string, skip func
func schemaConstantNotInCurrentContract(name string) bool { func schemaConstantNotInCurrentContract(name string) bool {
switch name { switch name {
case "SchemaRawOpenWeatherHourlyForecastV1", "SchemaWeatherOutlookV2": case "SchemaRawOpenWeatherHourlyForecastV1":
return true return true
default: default:
return false return false