Documentation
¶
Overview ¶
Package settings provides database-backed, typed application settings for SaaS applications, with values that can be overridden per tenant, per user or per any other scope.
Keys ¶
A setting is declared as a Key[T]: a dotted name, a default, an optional validation function and a secret flag. Keys are registered in a Registry, which rejects malformed and duplicate names and checks that the default survives a JSON round trip and passes validation:
var TrialDays = settings.Key[int]{
Name: "billing.trial_days",
Default: 14,
Validate: func(v int) error { ... },
}
reg, err := settings.NewRegistry(TrialDays)
Resolution ¶
A Client resolves a key against a chain of scopes. Options.Order lists the scope types from highest to lowest precedence (by default user, tenant, global); Get consults the scopes it is given in that order, then the global scope, then the key's default. Resolve also reports which scope supplied the value. Set and SetJSON write an override at one scope, Unset deletes it, and All lists every registered key with its override at one scope for admin screens. Operations on unregistered keys fail with ErrUnknownKey.
Secrets ¶
Values of keys with Secret set are encrypted before they reach the store, through an Encrypter. AESGCM is the built-in implementation: AES-256-GCM with the key ID embedded in each ciphertext, so keys can be rotated while old values stay readable. Each ciphertext is bound to its key name and scope. All and the OnChange hook redact secret values unless RevealSecrets is passed to All.
Caching ¶
Reads go through an in-process TTL cache, which also remembers that a scope has no override. Writes through a Client invalidate its own cache at once. Writes made by other processes are seen when the entry expires, or immediately when Options.Invalidations is set, for example to the pgxstore Listener, which relays PostgreSQL notifications.
Storage ¶
Store is the persistence interface. The memstore package holds an in-memory implementation for tests, pgxstore a PostgreSQL implementation, and storetest a conformance suite for third-party stores.
Auditing ¶
Options.OnChange receives a Change for every write that altered the store, carrying the old and new JSON values and the actor recorded in the context with WithActor.
Example ¶
package main
import (
"context"
"errors"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := context.Background()
trialDays := settings.Key[int]{
Name: "billing.trial_days",
Default: 14,
Description: "Length of the free trial",
Validate: func(v int) error {
if v < 1 || v > 90 {
return errors.New("must be between 1 and 90")
}
return nil
},
}
reg, err := settings.NewRegistry(trialDays)
if err != nil {
panic(err)
}
client, err := settings.New(memstore.New(), reg, settings.Options{})
if err != nil {
panic(err)
}
defer client.Close()
acme := settings.Tenant("acme")
days, _ := settings.Get(ctx, client, trialDays, acme)
fmt.Println("default:", days)
_ = settings.Set(ctx, client, trialDays, settings.Global(), 21)
_ = settings.Set(ctx, client, trialDays, acme, 30)
days, _ = settings.Get(ctx, client, trialDays, acme)
fmt.Println("acme:", days)
days, _ = settings.Get(ctx, client, trialDays, settings.Tenant("globex"))
fmt.Println("globex:", days)
err = settings.Set(ctx, client, trialDays, acme, 365)
fmt.Println(errors.Is(err, settings.ErrInvalidValue))
}
Output: default: 14 acme: 30 globex: 21 true
Index ¶
- Constants
- Variables
- func ActorFrom(ctx context.Context) (string, bool)
- func DefaultOrder() []string
- func Get[T any](ctx context.Context, c *Client, k Key[T], scopes ...Scope) (T, error)
- func Set[T any](ctx context.Context, c *Client, k Key[T], scope Scope, v T) error
- func ValidateKeyName(name string) error
- func WithActor(ctx context.Context, actor string) context.Context
- type AESGCM
- type AllOption
- type AnyKey
- type Change
- type Client
- type Encrypter
- type Entry
- type Invalidation
- type InvalidationSource
- type Key
- type KeyInfo
- type Options
- type Record
- type Registry
- type Resolved
- type Scope
- type Store
Examples ¶
Constants ¶
const ( DefaultCacheTTL = 30 * time.Second DefaultCacheSize = 10000 )
Defaults applied by New for zero-valued Options fields.
const ( ScopeGlobal = "global" ScopeTenant = "tenant" ScopeUser = "user" )
Well-known scope types. Any identifier matching [a-z][a-z0-9_]* can be used as a scope type as long as it appears in the client's resolution order; these are the ones the default order knows about.
const ( MaxKeyNameLen = 200 MaxScopeTypeLen = 64 MaxScopeIDLen = 255 )
Storage limits, shared by every Store so that the in-memory and PostgreSQL stores accept exactly the same records. Lengths count Unicode code points, as PostgreSQL's length() does.
const Redacted = "[redacted]"
Redacted replaces secret values wherever they would otherwise be exposed: listings, KeyInfo defaults and Change events carry the JSON string "[redacted]" instead of the value.
Variables ¶
var ( // ErrUnknownKey reports an operation on a key name that is not registered. ErrUnknownKey = errors.New("settings: unknown key") // ErrDuplicateKey reports an attempt to register a key name twice. ErrDuplicateKey = errors.New("settings: duplicate key") // ErrInvalidKey reports a malformed key name or a key definition whose // default value does not encode, decode or validate. ErrInvalidKey = errors.New("settings: invalid key") // ErrTypeMismatch reports a Key[T] whose T differs from the type the key // name was registered with. ErrTypeMismatch = errors.New("settings: type mismatch") // ErrInvalidScope reports a malformed scope, a scope type that is not part // of the client's resolution order, or the same scope type given twice. ErrInvalidScope = errors.New("settings: invalid scope") // ErrInvalidValue reports a value that fails its key's Validate function // or a stored value that does not decode into the key's type. ErrInvalidValue = errors.New("settings: invalid value") // ErrInvalidRecord reports a record a Store refuses to persist because it // violates the storage constraints (see Record.Validate). ErrInvalidRecord = errors.New("settings: invalid record") // ErrNoEncrypter reports a secret key used with a client that has no // Encrypter. ErrNoEncrypter = errors.New("settings: secret key requires an encrypter") // ErrDecrypt reports a secret value that cannot be decrypted: it is // malformed, was encrypted with an unknown key, or fails authentication. ErrDecrypt = errors.New("settings: decrypt failed") )
Sentinel errors. Errors returned by this package wrap one of these, so callers can test for them with errors.Is; the wrapped chain also carries the underlying cause (a Validate error, a store error) where there is one.
Functions ¶
func DefaultOrder ¶
func DefaultOrder() []string
DefaultOrder is the resolution order used when Options.Order is empty: the most specific scope wins.
func Get ¶
Get returns the value of k for the first of scopes (ordered by the client's resolution order, with the global scope added when the order includes it) that has one, or k's default. It fails with ErrUnknownKey for unregistered keys and ErrInvalidScope for scopes outside the resolution order.
func Set ¶
Set stores v as the value of k at scope, after validating it. Secret values are encrypted. The actor in ctx (see WithActor) is recorded as the author.
func ValidateKeyName ¶
ValidateKeyName reports whether name is a well-formed key name: one or more dot-separated segments, each matching [a-z][a-z0-9_]*, at most MaxKeyNameLen characters in total, such as "billing.trial_days". The error wraps ErrInvalidKey.
Types ¶
type AESGCM ¶
type AESGCM struct {
// contains filtered or unexported fields
}
AESGCM is the built-in Encrypter: AES-256-GCM with key rotation. Each ciphertext embeds the ID of the key that produced it, so values written with retired keys still decrypt while new values use the current key.
Ciphertext layout: version (1 byte), key ID length (1 byte), key ID, 12-byte random nonce, sealed data. The header is authenticated together with the caller's associated data.
AESGCM is safe for concurrent use.
func NewAESGCM ¶
NewAESGCM returns an AESGCM that encrypts with keys[currentKeyID] and decrypts with any key in keys. Every key must be 32 bytes; key IDs must be 1 to 255 bytes. The keys are copied.
func (*AESGCM) CurrentKeyID ¶
CurrentKeyID returns the ID of the key new values are encrypted with.
type AllOption ¶
type AllOption func(*allOptions)
AllOption configures All.
func RevealSecrets ¶
func RevealSecrets() AllOption
RevealSecrets makes All decrypt and include secret values and defaults instead of redacting them. Use it only where the caller is entitled to see secrets.
type AnyKey ¶
type AnyKey interface {
// contains filtered or unexported methods
}
AnyKey is implemented by every Key[T]; it lets a Registry hold keys of different types. It cannot be implemented outside this package.
type Change ¶
type Change struct {
Key string
Scope Scope
Old json.RawMessage
New json.RawMessage
Secret bool
// Actor is the actor recorded in the context with WithActor, or empty.
Actor string
At time.Time
}
Change describes a write, for the OnChange hook. Old is nil when the key had no value at Scope before the write and New is nil after an Unset. For secret keys, present values are replaced by the JSON string Redacted.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client reads and writes settings: it resolves values across scopes, validates and encrypts writes, caches reads and reports changes. A Client is safe for concurrent use.
func New ¶
New returns a Client over store for the keys in registry. Keys registered later are picked up automatically.
func (*Client) All ¶
All lists every registered key, ordered by name, with the override stored at exactly scope (if any). It reads the store directly, bypassing the cache, so admin views are never stale. Stored values of keys that are no longer registered are skipped.
Example ¶
package main
import (
"bytes"
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := settings.WithActor(context.Background(), "admin@example.com")
theme := settings.Key[string]{Name: "ui.theme", Default: "light", Description: "Colour scheme"}
token := settings.Key[string]{Name: "mail.api_token", Secret: true}
reg, _ := settings.NewRegistry(theme, token)
enc, _ := settings.NewAESGCM("2026-10", map[string][]byte{"2026-10": bytes.Repeat([]byte{7}, 32)})
client, _ := settings.New(memstore.New(), reg, settings.Options{Encrypter: enc})
defer client.Close()
acme := settings.Tenant("acme")
_ = settings.Set(ctx, client, theme, acme, "dark")
_ = settings.Set(ctx, client, token, acme, "sk-123")
entries, _ := client.All(ctx, acme)
for _, e := range entries {
fmt.Printf("%s overridden=%t value=%s by=%s\n", e.Name, e.Overridden, e.Value, e.UpdatedBy)
}
}
Output: mail.api_token overridden=true value="[redacted]" by=admin@example.com ui.theme overridden=true value="dark" by=admin@example.com
func (*Client) Close ¶
func (c *Client) Close()
Close detaches the client from its invalidation source. Without remote invalidations a cache could serve stale values indefinitely within its TTL, so Close also disables the cache; the client stays usable but reads go to the store. Close is idempotent.
func (*Client) SetJSON ¶
func (c *Client) SetJSON(ctx context.Context, name string, scope Scope, value json.RawMessage) error
SetJSON stores a JSON-encoded value for the key registered under name, for admin UIs that do not have the typed Key at hand. The value is decoded into the key's type and validated exactly as Set would.
type Encrypter ¶
type Encrypter interface {
Encrypt(ctx context.Context, plaintext, associatedData []byte) ([]byte, error)
Decrypt(ctx context.Context, ciphertext, associatedData []byte) ([]byte, error)
}
Encrypter encrypts secret values at rest. associatedData binds a ciphertext to the key name and scope it was written for, so a ciphertext copied to another row fails to decrypt. Implementations must be safe for concurrent use; the context lets implementations backed by a KMS make network calls.
type Entry ¶
type Entry struct {
KeyInfo
// Overridden reports that the key has a value stored at the listed scope.
Overridden bool
// Value is the stored value when Overridden: JSON, or the Redacted marker
// for secret keys unless RevealSecrets was given.
Value json.RawMessage
UpdatedAt time.Time
UpdatedBy string
}
Entry is one registered key as seen at one scope, for admin listings.
type Invalidation ¶
Invalidation tells a cache that values may have changed elsewhere. All set means anything may have changed (for example after a lost connection); otherwise only the value of Key at Scope.
type InvalidationSource ¶
type InvalidationSource interface {
Subscribe(fn func(Invalidation)) (unsubscribe func())
}
InvalidationSource delivers invalidations published by other processes, such as the pgxstore Listener. Subscribe registers fn and returns a function that removes it. fn may be called from another goroutine and must return quickly.
type Key ¶
type Key[T any] struct { // Name is the dotted key name, such as "billing.trial_days". Name string // Default is returned when no scope in the resolution chain has a value. Default T // Description is shown in admin listings. Description string // Validate, if set, is applied to Default at registration, to every value // written, and to every value read back from the store. Validate func(T) error // Secret values are encrypted at rest and redacted in listings and hooks. Secret bool }
Key declares a typed setting. Values are stored as the JSON encoding of T and decoded strictly: unknown object fields and trailing data are errors.
A Key is a plain value; declare it once (typically as a package-level variable) and register it with a Registry. The registered definition is authoritative: operations only use the Key's Name and T.
type KeyInfo ¶
type KeyInfo struct {
Name string
Description string
Secret bool
// Type is the Go type of the key's values, such as "int" or "[]string".
Type string
Default json.RawMessage
}
KeyInfo describes a registered key for admin UIs. Default is the JSON encoding of the key's default, or the Redacted marker for secret keys.
type Options ¶
type Options struct {
// Order lists scope types from highest to lowest precedence. Get consults
// the scopes it is given in this order, then the key's default. When
// Order contains ScopeGlobal, the global scope is consulted even if the
// caller does not pass it. Scope types not listed cannot be read or
// written. Empty means DefaultOrder().
Order []string
// Encrypter encrypts secret keys at rest. It is required when any
// registered key is secret.
Encrypter Encrypter
// CacheTTL bounds how long a looked-up value is served from the
// in-process cache. Zero means DefaultCacheTTL; negative disables the
// cache. Writes through this client invalidate it immediately; writes
// through other processes are seen after CacheTTL unless Invalidations
// is set.
CacheTTL time.Duration
// CacheSize caps the number of cached (key, scope) entries. Zero means
// DefaultCacheSize.
CacheSize int
// Invalidations, if set, delivers invalidations from other processes, so
// their writes are seen without waiting for CacheTTL.
Invalidations InvalidationSource
// OnChange, if set, is called after every successful Set, SetJSON and
// Unset that changed the store, for audit trails. Secret values are
// redacted. If it returns an error, the write has already happened; the
// error is returned to the caller, so a caller that writes inside a
// database transaction (see pgxstore.WithConn) can roll back both.
OnChange func(ctx context.Context, c Change) error
// Now returns the current time; it stamps updated_at and drives cache
// expiry. Nil means time.Now.
Now func() time.Time
}
Options configures a Client. The zero value is ready to use.
Example (OnChange) ¶
package main
import (
"context"
"encoding/json"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := settings.WithActor(context.Background(), "alice")
limit := settings.Key[int]{Name: "api.rate_limit", Default: 60}
reg, _ := settings.NewRegistry(limit)
client, _ := settings.New(memstore.New(), reg, settings.Options{
OnChange: func(_ context.Context, c settings.Change) error {
fmt.Printf("%s changed %s at %s: %s -> %s\n", c.Actor, c.Key, c.Scope, orNone(c.Old), orNone(c.New))
return nil
},
})
defer client.Close()
_ = settings.Set(ctx, client, limit, settings.Tenant("acme"), 120)
_ = client.SetJSON(ctx, limit.Name, settings.Tenant("acme"), json.RawMessage(`240`))
_ = client.Unset(ctx, limit.Name, settings.Tenant("acme"))
}
func orNone(v json.RawMessage) string {
if v == nil {
return "none"
}
return string(v)
}
Output: alice changed api.rate_limit at tenant:acme: none -> 120 alice changed api.rate_limit at tenant:acme: 120 -> 240 alice changed api.rate_limit at tenant:acme: 240 -> none
type Record ¶
type Record struct {
Key string
Scope Scope
Value json.RawMessage
UpdatedAt time.Time
UpdatedBy string
}
Record is one stored override: the value of a key at a scope. Value holds JSON; for secret keys it holds the encrypted envelope, never the plaintext. Stores may normalize the JSON text (PostgreSQL jsonb does), so compare values by decoding them rather than byte for byte.
func (Record) Validate ¶
Validate reports whether r satisfies the storage constraints every Store enforces: a valid key name, a valid scope, a non-empty valid JSON value whose strings contain no NUL characters (PostgreSQL jsonb rejects them), and an UpdatedBy that is valid UTF-8 without NUL bytes. The error wraps ErrInvalidRecord.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds the set of known keys. It is safe for concurrent use; keys can be registered at any time, but are typically registered at startup. The zero value is an empty registry ready to use.
func NewRegistry ¶
NewRegistry returns a registry holding keys, or an error if any of them is malformed or duplicated.
func (*Registry) MustRegister ¶
MustRegister is like Register but panics on error. It is meant for package-level key declarations, where an error is a programming mistake.
type Resolved ¶
type Resolved[T any] struct { Value T // Scope is the scope the value was stored at; zero when Default is set. Scope Scope // Default reports that no scope had a value and Value is the key's // default. Default bool UpdatedAt time.Time UpdatedBy string }
Resolved is a value together with where it came from.
func Resolve ¶
Resolve is Get, also reporting which scope the value came from, for admin UIs that show where an effective value is set.
Example ¶
package main
import (
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := context.Background()
theme := settings.Key[string]{Name: "ui.theme", Default: "light"}
reg, _ := settings.NewRegistry(theme)
client, _ := settings.New(memstore.New(), reg, settings.Options{})
defer client.Close()
_ = settings.Set(ctx, client, theme, settings.Tenant("acme"), "dark")
r, _ := settings.Resolve(ctx, client, theme, settings.User("u1"), settings.Tenant("acme"))
fmt.Println(r.Value, "from", r.Scope)
}
Output: dark from tenant:acme
type Scope ¶
Scope identifies where a setting value applies: the global scope, or a typed owner such as a tenant or a user. The global scope has an empty ID; every other scope has a non-empty one.
func (Scope) Validate ¶
Validate reports whether s is well formed: a type matching [a-z][a-z0-9_]* of at most MaxScopeTypeLen characters, an empty ID for the global scope, and a non-empty valid UTF-8 ID without NUL bytes of at most MaxScopeIDLen characters for every other scope. The error wraps ErrInvalidScope.
type Store ¶
type Store interface {
// Get returns the records stored for key at any of scopes, in no
// particular order. Scopes without a record are omitted.
Get(ctx context.Context, key string, scopes []Scope) ([]Record, error)
// List returns every record stored at scope, ordered by key.
List(ctx context.Context, scope Scope) ([]Record, error)
// Put inserts or replaces the record for (rec.Key, rec.Scope) atomically
// and returns the record it replaced, or nil if there was none. Concurrent
// Puts on the same key and scope must serialize: each one observes the
// value written by the one before it. Records failing Record.Validate are
// rejected with an error wrapping ErrInvalidRecord.
Put(ctx context.Context, rec Record) (*Record, error)
// Delete removes the record for (key, scope) and returns it, or returns
// nil without error if there was none.
Delete(ctx context.Context, key string, scope Scope) (*Record, error)
}
Store persists overrides. Implementations must be safe for concurrent use. The storetest package holds a conformance suite every implementation should pass.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore is an in-memory settings.Store for tests and examples.
|
Package memstore is an in-memory settings.Store for tests and examples. |
|
Package pgxstore is a PostgreSQL settings.Store built on pgx v5.
|
Package pgxstore is a PostgreSQL settings.Store built on pgx v5. |
|
Package storetest is a conformance suite for settings.Store implementations.
|
Package storetest is a conformance suite for settings.Store implementations. |