161 lines
6.2 KiB
Go
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,
|
|
})
|
|
}
|