Files
member-console/internal/instance/settings.go
T

161 lines
6.2 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package instance
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
)
// This package is the seed of the deployment-wide "instance settings" store
// (design D28): runtime state the console writes at request time (an
// operator dismissing something, flipping a switch), distinct from
// core.integration_config_overrides, which is boot-applied configuration
// that only takes effect on restart. Where a config override changes what
// the process reads at startup, an instance setting changes what a request
// handler reads right now.
//
// Nothing writes an ad hoc key: every key this package will ever read or
// write is declared in the registry below, so a typo in a key string fails
// GetBool/SetBool loudly instead of silently reading/writing a key nobody
// declared.
// Key names one instance setting. A typed alias rather than a bare string so
// a caller cannot pass an undeclared key without an explicit conversion.
type Key string
// SetupBannerDismissed is the first instance setting: whether the operator
// overview's setup banner has been dismissed, deployment-wide (design D12).
const SetupBannerDismissed Key = "setup_banner_dismissed"
// StripeEnvironmentCheck records which API key the last Stripe environment
// check ran under and what it found (stripe-environment-stamp D3). The
// value is a JSON object; its fields belong to the Stripe package, which
// marshals and unmarshals them, so this package stores the bytes and does
// not name the shape.
const StripeEnvironmentCheck Key = "stripe.environment_check"
// settingKind names the JSON shape a key's value holds, so the accessors
// can refuse a key that was never declared as their shape rather than
// silently coercing whatever JSON happens to be stored under it.
type settingKind string
const (
kindBool settingKind = "bool"
// kindJSON is a JSON object whose fields the caller owns. This package
// carries the bytes and the registry entry; the shape lives with the
// code that has a reason to know it.
kindJSON settingKind = "json"
)
// registry is the allowed-keys list: every key this package will read or
// write, and the value shape it holds. A key absent from this map is not a
// real instance setting, whether or not a row happens to exist for it.
var registry = map[Key]settingKind{
SetupBannerDismissed: kindBool,
StripeEnvironmentCheck: kindJSON,
}
// Store is the typed accessor over core.instance_settings. Built from a
// Querier so a caller can inject a *Queries backed by a *sql.DB, a *sql.Tx,
// or a test fake.
type Store struct {
q Querier
}
// NewStore builds a Store backed by db (typically the application's
// *sql.DB; DBTX also accepts a *sql.Tx for transaction-scoped tests).
func NewStore(db DBTX) *Store {
return &Store{q: New(db)}
}
// GetBool reads a boolean instance setting. A key with no stored row reads
// as false with no error — an instance setting that has never been written
// is indistinguishable from one explicitly set to its zero value, and every
// key declared here defaults to false until an operator acts. An undeclared
// key, or one declared with a different kind, is an error: this package
// never reads a key it did not itself register.
func (s *Store) GetBool(ctx context.Context, key Key) (bool, error) {
if kind, ok := registry[key]; !ok || kind != kindBool {
return false, fmt.Errorf("instance: %q is not a declared boolean setting", key)
}
row, err := s.q.GetInstanceSetting(ctx, string(key))
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
var value bool
if err := json.Unmarshal(row.Value, &value); err != nil {
return false, fmt.Errorf("instance: decode %q: %w", key, err)
}
return value, nil
}
// SetBool writes a boolean instance setting, recording who set it. updatedBy
// is stored as-is (typically the operator's signed-in email/subject); an
// empty string stores NULL, matching the config-override write path's
// convention for an unknown actor.
func (s *Store) SetBool(ctx context.Context, key Key, value bool, updatedBy string) error {
if kind, ok := registry[key]; !ok || kind != kindBool {
return fmt.Errorf("instance: %q is not a declared boolean setting", key)
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("instance: encode %q: %w", key, err)
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertInstanceSetting(ctx, UpsertInstanceSettingParams{
Key: string(key), Value: raw, UpdatedBy: by,
})
}
// GetJSON reads a JSON-object instance setting as the stored bytes. The
// second return is false when no row exists, which GetBool can fold into
// its zero value but a JSON object cannot: "never written" and "written as
// {}" are different answers, and the caller needs to tell them apart. The
// caller unmarshals the bytes into its own shape, so this package never
// names one.
func (s *Store) GetJSON(ctx context.Context, key Key) (json.RawMessage, bool, error) {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return nil, false, fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
row, err := s.q.GetInstanceSetting(ctx, string(key))
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return json.RawMessage(row.Value), true, nil
}
// SetJSON writes a JSON-object instance setting, recording who set it.
// value is marshalled here, so a caller passes its own struct. updatedBy
// follows SetBool: stored as-is, empty stores NULL. The upsert stamps
// updated_at, which is how a reader dates the record.
func (s *Store) SetJSON(ctx context.Context, key Key, value any, updatedBy string) error {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("instance: encode %q: %w", key, err)
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertInstanceSetting(ctx, UpsertInstanceSettingParams{
Key: string(key), Value: raw, UpdatedBy: by,
})
}