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 }