weatherfeeder: split the former maximumdirect.net/weatherd project in two.

feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
This commit is contained in:
2026-01-13 18:14:21 -06:00
parent 1e05b38347
commit aa4774e0dd
21 changed files with 2432 additions and 1 deletions

212
internal/model/event.go Normal file
View File

@@ -0,0 +1,212 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
)
// ErrInvalidEvent is a sentinel error used for errors.Is checks.
var ErrInvalidEvent = errors.New("invalid event")
// EventValidationError reports one or more problems with an Event.
//
// We keep this structured because it makes debugging faster than a single
// "invalid event" string; you get all issues in one pass.
type EventValidationError struct {
Problems []string
}
func (e *EventValidationError) Error() string {
if e == nil || len(e.Problems) == 0 {
return "invalid event"
}
var b strings.Builder
b.WriteString("invalid event:\n")
for _, p := range e.Problems {
b.WriteString(" - ")
b.WriteString(p)
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
// Is lets errors.Is(err, ErrInvalidEvent) work.
func (e *EventValidationError) Is(target error) bool {
return target == ErrInvalidEvent
}
// Event is the normalized unit your pipeline moves around.
// It wraps exactly one of Observation/Forecast/Alert plus metadata.
type Event struct {
ID string // stable dedupe/storage key (source-defined or computed)
Kind Kind
Source string // configured source name (e.g. "NWSObservationKSTL")
EmittedAt time.Time // when *your* system emitted this event
EffectiveAt *time.Time // optional: “time the event applies”
// Union payload: EXACTLY ONE must be non-nil.
Observation *WeatherObservation
Forecast *WeatherForecast
Alert *WeatherAlert
}
// Validate enforces Event invariants.
//
// This is intentionally strict. If an event is invalid, we want to find out
// immediately rather than letting it drift into sinks or storage.
//
// Invariants enforced:
// - ID is non-empty
// - Kind is known
// - Source is non-empty
// - EmittedAt is non-zero
// - Exactly one payload pointer is non-nil
// - Kind matches the non-nil payload
func (e Event) Validate() error {
var problems []string
if strings.TrimSpace(e.ID) == "" {
problems = append(problems, "ID is required")
}
if !e.Kind.IsKnown() {
problems = append(problems, fmt.Sprintf("Kind %q is not recognized", string(e.Kind)))
}
if strings.TrimSpace(e.Source) == "" {
problems = append(problems, "Source is required")
}
if e.EmittedAt.IsZero() {
problems = append(problems, "EmittedAt must be set (non-zero)")
}
// Count payloads and ensure Kind matches.
payloadCount := 0
if e.Observation != nil {
payloadCount++
if e.Kind != KindObservation {
problems = append(problems, fmt.Sprintf("Observation payload present but Kind=%q", string(e.Kind)))
}
}
if e.Forecast != nil {
payloadCount++
if e.Kind != KindForecast {
problems = append(problems, fmt.Sprintf("Forecast payload present but Kind=%q", string(e.Kind)))
}
}
if e.Alert != nil {
payloadCount++
if e.Kind != KindAlert {
problems = append(problems, fmt.Sprintf("Alert payload present but Kind=%q", string(e.Kind)))
}
}
if payloadCount == 0 {
problems = append(problems, "exactly one payload must be set; all payloads are nil")
} else if payloadCount > 1 {
problems = append(problems, "exactly one payload must be set; multiple payloads are non-nil")
}
if len(problems) > 0 {
return &EventValidationError{Problems: problems}
}
return nil
}
// NewObservationEvent constructs a valid observation Event.
//
// If emittedAt is zero, it defaults to time.Now().UTC().
// effectiveAt is optional (nil allowed).
//
// The returned Event is guaranteed valid (or you get an error).
func NewObservationEvent(
id string,
source string,
emittedAt time.Time,
effectiveAt *time.Time,
obs *WeatherObservation,
) (Event, error) {
if obs == nil {
return Event{}, fmt.Errorf("%w: observation payload is nil", ErrInvalidEvent)
}
if emittedAt.IsZero() {
emittedAt = time.Now().UTC()
}
e := Event{
ID: strings.TrimSpace(id),
Kind: KindObservation,
Source: strings.TrimSpace(source),
EmittedAt: emittedAt,
EffectiveAt: effectiveAt,
Observation: obs,
}
if err := e.Validate(); err != nil {
return Event{}, err
}
return e, nil
}
// NewForecastEvent constructs a valid forecast Event.
func NewForecastEvent(
id string,
source string,
emittedAt time.Time,
effectiveAt *time.Time,
fc *WeatherForecast,
) (Event, error) {
if fc == nil {
return Event{}, fmt.Errorf("%w: forecast payload is nil", ErrInvalidEvent)
}
if emittedAt.IsZero() {
emittedAt = time.Now().UTC()
}
e := Event{
ID: strings.TrimSpace(id),
Kind: KindForecast,
Source: strings.TrimSpace(source),
EmittedAt: emittedAt,
EffectiveAt: effectiveAt,
Forecast: fc,
}
if err := e.Validate(); err != nil {
return Event{}, err
}
return e, nil
}
// NewAlertEvent constructs a valid alert Event.
func NewAlertEvent(
id string,
source string,
emittedAt time.Time,
effectiveAt *time.Time,
a *WeatherAlert,
) (Event, error) {
if a == nil {
return Event{}, fmt.Errorf("%w: alert payload is nil", ErrInvalidEvent)
}
if emittedAt.IsZero() {
emittedAt = time.Now().UTC()
}
e := Event{
ID: strings.TrimSpace(id),
Kind: KindAlert,
Source: strings.TrimSpace(source),
EmittedAt: emittedAt,
EffectiveAt: effectiveAt,
Alert: a,
}
if err := e.Validate(); err != nil {
return Event{}, err
}
return e, nil
}