Files
2026-06-28 17:52:49 +02:00

145 lines
4.2 KiB
Go

// Registry is the per-tenant Schema lookup. A producer processes tenant X:
// it builds an event.Event with Source="catalog:<tenant>" and asks the
// Registry for X's Schema; the Builder applies that Schema to the source
// JSON. Unknown tenants get DefaultSchema — that makes the platform
// "just work" for new tenants while the operator rolls out the proper
// override file.
package catalog
import (
"encoding/json"
"fmt"
"os"
"sort"
"sync"
)
// Registry is concurrency-safe: Register / SchemaFor can be called from any
// goroutine. SchemaFor is the hot path; it takes a read lock.
type Registry struct {
mu sync.RWMutex
schemas map[string]Schema
def Schema // the "default" / DefaultSchema() fallback
}
// NewRegistry returns an empty Registry whose DefaultSchema fallback is
// DefaultSchema(). Use Register to add tenant-specific overrides.
func NewRegistry() *Registry {
return &Registry{
schemas: map[string]Schema{},
def: DefaultSchema(),
}
}
// WithDefault swaps the DefaultSchema fallback (used when SchemaFor is
// called with an unknown tenant name, or with the literal "default").
// Returns the receiver so it composes with NewRegistry().
func (r *Registry) WithDefault(s Schema) *Registry {
if s.Tenant == "" {
s.Tenant = "default"
}
r.mu.Lock()
defer r.mu.Unlock()
r.def = s
return r
}
// Register stores s under s.Tenant. A Schema with empty Tenant is stored
// under "default" and ALSO replaces the DefaultSchema fallback.
func (r *Registry) Register(s Schema) error {
if s.SKUField == "" || s.PriceField == "" {
return fmt.Errorf("catalog: schema for %q must declare sku_field and price_field", s.Tenant)
}
if s.PriceScale < 0 {
return fmt.Errorf("catalog: schema %q has negative price_scale (%d)", s.Tenant, s.PriceScale)
}
// Foot-gun 3, registry-side guard: UseSourceField without a field is
// the silent stamp-now path. Refuse at load time so a JSON typo
// surfaces immediately rather than invalidating caches per-rebuild.
if s.UpdatedAtSource == UseSourceField && s.UpdatedAtField == "" {
return fmt.Errorf("catalog: schema %q has updated_at_source=UseSourceField but no updated_at_field (would silently stamp-now)", s.Tenant)
}
if s.TrueValues == nil {
s.TrueValues = DefaultTrueValues()
}
key := s.Tenant
if key == "" {
key = "default"
s.Tenant = key
r.mu.Lock()
defer r.mu.Unlock()
r.def = s
r.schemas[key] = s
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.schemas[key] = s
return nil
}
// SchemaFor returns the Schema registered for tenant, or the DefaultSchema
// fallback when tenant is "" or not registered.
func (r *Registry) SchemaFor(tenant string) Schema {
r.mu.RLock()
defer r.mu.RUnlock()
if tenant == "" {
return r.def
}
if s, ok := r.schemas[tenant]; ok {
return s
}
return r.def
}
// Tenants returns the sorted list of registered tenant names (excluding the
// default fallback). Useful for ops dashboards and "what schemas are loaded".
func (r *Registry) Tenants() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.schemas))
for k := range r.schemas {
out = append(out, k)
}
sort.Strings(out)
return out
}
// LoadFile reads a JSON file of { "tenant": Schema, ... } and registers each
// entry into r. The JSON shape mirrors the Schema struct exactly (json tags
// in schema.go). Missing file is OK (file is optional); unreadable file
// returns the error so a misconfigured deployment fails loudly on startup.
func (r *Registry) LoadFile(path string) error {
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("catalog: read schema file %q: %w", path, err)
}
var raw map[string]Schema
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("catalog: parse schema file %q: %w", path, err)
}
// Walk in sorted order so operator-visible loading is deterministic
// (helps when something fails partway through).
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
s := raw[k]
if s.Tenant == "" {
s.Tenant = k
}
if err := r.Register(s); err != nil {
return fmt.Errorf("catalog: schema %q in %q: %w", k, path, err)
}
}
return nil
}