package model import ( "fmt" "sort" "strings" ) // SyncScope narrows one full-sync run to an explicit channel whitelist. // // A nil scope, or one whose channel list normalizes to empty, means "full // sync" and must behave exactly like a run started before this field existed. // TriggerSource is descriptive only: it is recorded on the run for operators // and never changes scheduling or retry behaviour. type SyncScope struct { Channels []string `json:"channels,omitempty"` TriggerSource string `json:"triggerSource,omitempty"` } const ( // MaxScopeChannels bounds one partial-sync request. It is a guard against // accidental "select everything" payloads, not a capacity limit. MaxScopeChannels = 200 // MaxChanKeyLength matches t_sim_channels.chan_key varchar(32). MaxChanKeyLength = 32 ) // Trigger source values recorded on the run. const ( TriggerSourceScheduled = "scheduled" TriggerSourceManual = "manual" TriggerSourceAPI = "api" TriggerSourceReplay = "replay" ) // NormalizeScope validates a requested scope and returns its canonical form. // // Canonical form is: nil for a full sync, otherwise a scope whose channel list // is deduplicated and sorted. Sorting makes ScopeDigest stable across requests // that select the same channels in a different order. func NormalizeScope(scope *SyncScope) (*SyncScope, error) { if scope == nil { return nil, nil } triggerSource := strings.TrimSpace(scope.TriggerSource) switch triggerSource { case "", TriggerSourceScheduled, TriggerSourceManual, TriggerSourceAPI, TriggerSourceReplay: default: return nil, fmt.Errorf("invalid triggerSource %q", scope.TriggerSource) } if len(scope.Channels) == 0 { return nil, nil } if len(scope.Channels) > MaxScopeChannels { return nil, fmt.Errorf("scope has %d channels, maximum is %d", len(scope.Channels), MaxScopeChannels) } seen := make(map[string]struct{}, len(scope.Channels)) channels := make([]string, 0, len(scope.Channels)) for _, raw := range scope.Channels { chanKey := strings.TrimSpace(raw) if err := ValidateChanKey(chanKey); err != nil { return nil, err } if _, exists := seen[chanKey]; exists { continue } seen[chanKey] = struct{}{} channels = append(channels, chanKey) } if len(channels) == 0 { return nil, nil } sort.Strings(channels) if triggerSource == "" { triggerSource = TriggerSourceManual } return &SyncScope{Channels: channels, TriggerSource: triggerSource}, nil } // ValidateChanKey rejects values that cannot exist in t_sim_channels.chan_key. // The whitelist character set keeps the value safe to log and to embed in a // Redis key without escaping. func ValidateChanKey(chanKey string) error { if chanKey == "" { return fmt.Errorf("chanKey is empty") } if len(chanKey) > MaxChanKeyLength { return fmt.Errorf("chanKey %q exceeds %d characters", chanKey, MaxChanKeyLength) } for _, r := range chanKey { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r >= '0' && r <= '9': case r == '-' || r == '_': default: return fmt.Errorf("chanKey %q contains invalid character %q", chanKey, r) } } return nil } // ChannelSet returns the scope channels as a lookup set, or nil for a full // sync. Callers treat nil as "no filtering". func (s *SyncScope) ChannelSet() map[string]struct{} { if s == nil || len(s.Channels) == 0 { return nil } set := make(map[string]struct{}, len(s.Channels)) for _, chanKey := range s.Channels { set[chanKey] = struct{}{} } return set } // Allows reports whether a card on chanKey belongs to this scope. A nil scope // allows every channel. func (s *SyncScope) Allows(chanKey string) bool { if s == nil || len(s.Channels) == 0 { return true } for _, allowed := range s.Channels { if allowed == chanKey { return true } } return false } // ChannelCount is the number of selected channels; zero means full sync. func (s *SyncScope) ChannelCount() int { if s == nil { return 0 } return len(s.Channels) } // ChannelSample returns at most limit channels for logging and run metadata. func (s *SyncScope) ChannelSample(limit int) []string { if s == nil || limit <= 0 { return nil } if len(s.Channels) <= limit { return s.Channels } return s.Channels[:limit] }