Help us shape the future of Go! Take the 2026 Go Developer Survey

settings

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 16 Imported by: 0

README

go-settings

tests Go Reference

Database-backed, typed application settings for SaaS applications. Declare a setting once as a Key[T] with a default and a validation rule, then let operators override it globally, per tenant or per user, with the most specific value winning. Values are checked on the way in and on the way out, secrets are encrypted at rest with rotating AES-256-GCM keys, reads are served from an in-process cache that PostgreSQL LISTEN/NOTIFY keeps coherent across instances, and every change can feed your audit log.

Install

go get github.com/byteminus/go-settings

Requires Go 1.26 or later. CI runs the PostgreSQL store against PostgreSQL 16, 17 and 18.

Quick start

package main

import (
	"context"
	"errors"
	"log"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	settings "github.com/byteminus/go-settings"
	"github.com/byteminus/go-settings/pgxstore"
)

var TrialDays = settings.Key[int]{
	Name:        "billing.trial_days",
	Default:     14,
	Description: "Length of the free trial in days",
	Validate: func(v int) error {
		if v < 1 || v > 90 {
			return errors.New("must be between 1 and 90")
		}
		return nil
	},
}

func main() {
	ctx := context.Background()
	url := os.Getenv("DATABASE_URL")
	pool, err := pgxpool.New(ctx, url)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}

	// The listener keeps this process's cache coherent with writes made by
	// other instances.
	listener, err := pgxstore.NewListener(pgxstore.ListenerConfig{
		Connect: func(ctx context.Context) (*pgx.Conn, error) { return pgx.Connect(ctx, url) },
		OnError: func(err error) { log.Printf("settings listener: %v", err) },
	})
	if err != nil {
		log.Fatal(err)
	}
	go func() { _ = listener.Run(ctx) }()

	reg, err := settings.NewRegistry(TrialDays)
	if err != nil {
		log.Fatal(err)
	}
	client, err := settings.New(store, reg, settings.Options{Invalidations: listener})
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	acme := settings.Tenant("acme")
	admin := settings.WithActor(ctx, "admin@example.com")
	if err := settings.Set(admin, client, TrialDays, acme, 30); err != nil {
		log.Fatal(err)
	}

	days, err := settings.Get(ctx, client, TrialDays, settings.User("u-42"), acme)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("trial: %d days", days) // 30, from the tenant override
}

Concepts

Keys. A Key[T] has a dotted Name (segments matching [a-z][a-z0-9_]*, such as billing.trial_days), a Default, a Description, an optional Validate func(T) error and a Secret flag. Keys are registered in a Registry, which rejects malformed and duplicate names and checks that the default encodes to JSON, decodes back and passes Validate. Registration is all or nothing. Every operation on an unregistered name fails with ErrUnknownKey; using a key name with a different T fails with ErrTypeMismatch.

Values. Values are stored as JSON and decoded strictly into T: unknown object fields and trailing data are errors. Validate runs on every write and on every read, so a stored value that no longer satisfies the rules surfaces as ErrInvalidValue instead of reaching your code.

Scopes and resolution. A Scope is a type and an ID: settings.Global(), settings.Tenant(id), settings.User(id) or any custom type such as {Type: "workspace", ID: id}. Options.Order lists scope types from highest to lowest precedence; the default is user, tenant, global. Get(ctx, client, key, scopes...) consults the given scopes in that order (argument order does not matter), then the global scope (added automatically), then the default. Resolve returns the same value plus the scope it came from, its UpdatedAt and UpdatedBy, for admin screens that show where an effective value is set. Put the global scope first in Order to make operator values override tenant choices.

Writes. Set writes a typed value at one scope; SetJSON writes by name from JSON for admin forms; Unset deletes an override so resolution falls through. All(ctx, scope) lists every registered key with its metadata and the override stored at exactly that scope.

Secrets. Values of Secret keys are encrypted before they reach the store through an Encrypter. The built-in AESGCM uses AES-256-GCM and embeds the key ID in each ciphertext: construct it with the current key ID and every key still in use, and old values keep decrypting while new ones use the current key. Ciphertexts are bound to their key name and scope, so a value copied to another row fails to decrypt. All returns the "[redacted]" marker for secret values and defaults unless called with settings.RevealSecrets(), and the OnChange hook always receives redacted values.

Cache. Reads go through an in-process cache (default TTL 30 seconds, 10,000 entries) that also remembers which scopes have no override, so the common "no user override" lookup costs nothing. Writes through a client invalidate its own cache immediately. Writes from other processes are seen when entries expire, or immediately when Options.Invalidations is set to a pgxstore.Listener. The listener reconnects with jittered backoff after connection loss and tells the cache to drop everything whenever notifications may have been missed. A negative CacheTTL disables the cache.

Auditing. Options.OnChange is called after every write that changed the store with a Change: key, scope, old and new JSON (nil when absent), the actor from settings.WithActor, and the time. If the hook returns an error, Set returns it; the write has already happened, so if you need the audit record and the change to be atomic, run both in one transaction (below).

PostgreSQL

pgxstore.New(db, opts...) accepts anything with pgx's Exec, Query and QueryRow (*pgxpool.Pool, *pgx.Conn, pgx.Tx). Options:

  • WithTable("app.settings"): table name, optionally schema-qualified.
  • WithChannel("settings_changed"): notification channel; "" disables notifications.
  • WithConn(fn): resolve the connection per call (see below).

CreateSchema(ctx) runs this idempotent DDL (Schema() returns it for migration tools):

CREATE TABLE IF NOT EXISTS "settings" (
	key        text        NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$' AND length(key) <= 200),
	scope_type text        NOT NULL CHECK (scope_type ~ '^[a-z][a-z0-9_]*$' AND length(scope_type) <= 64),
	scope_id   text        NOT NULL CHECK (length(scope_id) <= 255),
	value      jsonb       NOT NULL,
	updated_at timestamptz NOT NULL,
	updated_by text        NOT NULL DEFAULT '',
	PRIMARY KEY (key, scope_type, scope_id),
	CHECK ((scope_type = 'global') = (scope_id = ''))
);
CREATE INDEX IF NOT EXISTS "settings_scope_idx" ON "settings" (scope_type, scope_id);

Put is an upsert that is safe under concurrency and returns the value it replaced (for the OnChange hook): a locking UPDATE that returns the old row, falling back to INSERT ... ON CONFLICT DO NOTHING, retried if another writer wins the race in between. Each write publishes its notification in the same statement, so notifications are sent only for writes that commit.

Joining transactions

WithConn lets settings writes join a transaction your code carries in its context, so a setting change, your own audit row and anything else commit or roll back together:

type txKey struct{}

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil // use the pool
}))

tx, err := pool.Begin(ctx)
// ...
txCtx := context.WithValue(ctx, txKey{}, tx)
err = settings.Set(txCtx, client, TrialDays, settings.Tenant("acme"), 30)
// OnChange receives txCtx too and can insert the audit row with the same tx.
err = tx.Commit(ctx)

Notifications are sent only on commit, so other instances never see a rolled-back write. The writing client invalidates its own cache when Set returns; if it reads the key again before the commit, it caches the old value until the commit notification arrives (when it runs a Listener) or the TTL expires. Avoid reading settings with a transaction context that may roll back: such a read sees the uncommitted value and may cache it until the TTL expires.

API overview

Identifier Purpose
Key[T], AnyKey Typed setting declaration
Registry, NewRegistry, Register, MustRegister, Lookup, Keys, KeyInfo Known keys and their metadata
Scope, Global, Tenant, User, ValidateKeyName Scopes and name rules
New, Options, Client, Close, DefaultOrder Client construction
Get, Resolve, Resolved[T] Typed reads
Set, Client.SetJSON, Client.Unset Writes
Client.All, Entry, RevealSecrets Admin listing
Encrypter, AESGCM, NewAESGCM Encryption at rest
Change, WithActor, ActorFrom, Redacted Auditing
Store, Record, Invalidation, InvalidationSource Storage and cache extension points
memstore.New In-memory store for tests
pgxstore.New, WithTable, WithChannel, WithConn, Schema, CreateSchema PostgreSQL store
pgxstore.NewListener, ListenerConfig, Listener.Run, Listener.Subscribe Cross-instance invalidation
storetest.Run Store conformance suite

Errors are sentinels checkable with errors.Is: ErrUnknownKey, ErrDuplicateKey, ErrInvalidKey, ErrTypeMismatch, ErrInvalidScope, ErrInvalidValue, ErrInvalidRecord, ErrNoEncrypter, ErrDecrypt, and in pgxstore, ErrContention and ErrListenerRunning.

Testing

Use memstore.New() in unit tests; it enforces the same constraints as PostgreSQL. To verify your own Store, run the conformance suite:

func TestMyStore(t *testing.T) {
	storetest.Run(t, func(t *testing.T) settings.Store { return newMyStore(t) })
}

This repository's own tests run with go test -race ./...; set TEST_DATABASE_URL to include the PostgreSQL integration tests (see CONTRIBUTING.md).

Roadmap

  • Re-encrypting existing secret values after a key rotation (today old values stay readable but are only re-encrypted when next written).
  • Change history table and point-in-time reads.
  • Per-key cache TTLs and bulk reads of many keys in one query.
  • An embeddable admin HTTP handler built on All, SetJSON and Unset.
  • Scope resolution from the request context (tenant and user middleware).

License

MIT; see LICENSE.

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

Examples

Constants

View Source
const (
	DefaultCacheTTL  = 30 * time.Second
	DefaultCacheSize = 10000
)

Defaults applied by New for zero-valued Options fields.

View Source
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.

View Source
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.

View Source
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

View Source
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 ActorFrom

func ActorFrom(ctx context.Context) (string, bool)

ActorFrom returns the actor recorded by WithActor, if any.

func DefaultOrder

func DefaultOrder() []string

DefaultOrder is the resolution order used when Options.Order is empty: the most specific scope wins.

func Get

func Get[T any](ctx context.Context, c *Client, k Key[T], scopes ...Scope) (T, error)

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

func Set[T any](ctx context.Context, c *Client, k Key[T], scope Scope, v T) error

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

func ValidateKeyName(name string) error

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.

func WithActor

func WithActor(ctx context.Context, actor string) context.Context

WithActor returns a context that records actor (a user ID, a service name) as the author of writes made with it. The actor is stored in the updated_by column and passed to the OnChange hook.

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

func NewAESGCM(currentKeyID string, keys map[string][]byte) (*AESGCM, error)

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

func (a *AESGCM) CurrentKeyID() string

CurrentKeyID returns the ID of the key new values are encrypted with.

func (*AESGCM) Decrypt

func (a *AESGCM) Decrypt(_ context.Context, ciphertext, associatedData []byte) ([]byte, error)

Decrypt opens a ciphertext produced by Encrypt with any known key. Failures wrap ErrDecrypt.

func (*AESGCM) Encrypt

func (a *AESGCM) Encrypt(_ context.Context, plaintext, associatedData []byte) ([]byte, error)

Encrypt seals plaintext with the current key.

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

func New(store Store, registry *Registry, opts Options) (*Client, error)

New returns a Client over store for the keys in registry. Keys registered later are picked up automatically.

func (*Client) All

func (c *Client) All(ctx context.Context, scope Scope, opts ...AllOption) ([]Entry, error)

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.

func (*Client) Unset

func (c *Client) Unset(ctx context.Context, name string, scope Scope) error

Unset deletes the override of the key registered under name at scope, so resolution falls through to the next scope. Unsetting a value that is not set is not an error and does not call OnChange.

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

type Invalidation struct {
	Key   string
	Scope Scope
	All   bool
}

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

func (r Record) Validate() error

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

func NewRegistry(keys ...AnyKey) (*Registry, error)

NewRegistry returns a registry holding keys, or an error if any of them is malformed or duplicated.

func (*Registry) Keys

func (r *Registry) Keys() []KeyInfo

Keys returns every registered key, ordered by name.

func (*Registry) Lookup

func (r *Registry) Lookup(name string) (KeyInfo, bool)

Lookup returns the description of the key registered under name.

func (*Registry) MustRegister

func (r *Registry) MustRegister(keys ...AnyKey)

MustRegister is like Register but panics on error. It is meant for package-level key declarations, where an error is a programming mistake.

func (*Registry) Register

func (r *Registry) Register(keys ...AnyKey) error

Register adds keys. It is all or nothing: if any key is malformed (wrapping ErrInvalidKey) or its name is already taken (wrapping ErrDuplicateKey), none of them is added.

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

func Resolve[T any](ctx context.Context, c *Client, k Key[T], scopes ...Scope) (Resolved[T], error)

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

type Scope struct {
	Type string
	ID   string
}

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 Global

func Global() Scope

Global returns the global scope.

func Tenant

func Tenant(id string) Scope

Tenant returns the scope of the tenant with the given ID.

func User

func User(id string) Scope

User returns the scope of the user with the given ID.

func (Scope) IsGlobal

func (s Scope) IsGlobal() bool

IsGlobal reports whether s is the global scope type.

func (Scope) String

func (s Scope) String() string

String returns "global" for the global scope and "type:id" otherwise.

func (Scope) Validate

func (s Scope) Validate() error

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.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL