package flags import ( "context" "fmt" "sync" "time" ) // FeatureFlag represents a boolean on/off flag scoped to a tenant or global (tenant_id = ""). type FeatureFlag struct { ID string TenantID string // empty string = global flag Name string IsEnabled bool CreatedAt time.Time UpdatedAt time.Time } // FlagStore is the persistence interface for feature flags. type FlagStore interface { // IsEnabled returns true if the flag is enabled for the given tenant. // Lookup order: tenant-specific flag → global flag (tenant_id="") → false. IsEnabled(ctx context.Context, tenantID, name string) (bool, error) // Get returns a single flag by tenantID + name. Get(ctx context.Context, tenantID, name string) (FeatureFlag, error) // Set creates or updates a flag for the given tenant (or global if tenantID=""). Set(ctx context.Context, tenantID, name string, enabled bool) (FeatureFlag, error) // List returns all flags for a tenant plus global flags. List(ctx context.Context, tenantID string) ([]FeatureFlag, error) // Delete removes a flag. Returns ErrNotFound if absent. Delete(ctx context.Context, tenantID, name string) error } // ErrNotFound is returned when a flag does not exist. var ErrNotFound = fmt.Errorf("feature flag not found") // ─── MemFlagStore ───────────────────────────────────────────────────────────── // MemFlagStore is a thread-safe, in-memory FlagStore for tests and local dev. type MemFlagStore struct { mu sync.RWMutex flags map[string]FeatureFlag // key = tenantID + ":" + name (empty tenant = ":" + name) seq int } func NewMemFlagStore() *MemFlagStore { return &MemFlagStore{flags: make(map[string]FeatureFlag)} } func flagKey(tenantID, name string) string { return tenantID + ":" + name } func (m *MemFlagStore) IsEnabled(ctx context.Context, tenantID, name string) (bool, error) { m.mu.RLock() defer m.mu.RUnlock() // Tenant-specific flag takes precedence if f, ok := m.flags[flagKey(tenantID, name)]; ok { return f.IsEnabled, nil } // Global fallback if f, ok := m.flags[flagKey("", name)]; ok { return f.IsEnabled, nil } return false, nil } func (m *MemFlagStore) Get(ctx context.Context, tenantID, name string) (FeatureFlag, error) { m.mu.RLock() defer m.mu.RUnlock() f, ok := m.flags[flagKey(tenantID, name)] if !ok { return FeatureFlag{}, ErrNotFound } return f, nil } func (m *MemFlagStore) Set(ctx context.Context, tenantID, name string, enabled bool) (FeatureFlag, error) { m.mu.Lock() defer m.mu.Unlock() now := time.Now() key := flagKey(tenantID, name) existing, ok := m.flags[key] if ok { existing.IsEnabled = enabled existing.UpdatedAt = now m.flags[key] = existing return existing, nil } m.seq++ f := FeatureFlag{ ID: fmt.Sprintf("mem-%d", m.seq), TenantID: tenantID, Name: name, IsEnabled: enabled, CreatedAt: now, UpdatedAt: now, } m.flags[key] = f return f, nil } func (m *MemFlagStore) List(ctx context.Context, tenantID string) ([]FeatureFlag, error) { m.mu.RLock() defer m.mu.RUnlock() var out []FeatureFlag for _, f := range m.flags { if f.TenantID == tenantID || f.TenantID == "" { out = append(out, f) } } return out, nil } func (m *MemFlagStore) Delete(ctx context.Context, tenantID, name string) error { m.mu.Lock() defer m.mu.Unlock() key := flagKey(tenantID, name) if _, ok := m.flags[key]; !ok { return ErrNotFound } delete(m.flags, key) return nil }