// 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, }) }