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

settings

package module
v0.2.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: 17 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, settings.Ref{Type: "user", ID: "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. A value's JSON encoding may be at most MaxValueLen (64 KiB).

Scopes and resolution. A Scope is a type and an ID, both always set: settings.Global() (which is {Type: "global", ID: "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 with a fresh random 96-bit nonce per value 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. Each ciphertext is bound to its key name, scope and key ID through GCM's additional data, so a value copied to another row, scope or key fails to decrypt.

The package redacts secret values wherever it controls their output: All returns the "[redacted]" marker for secret values and defaults unless called with settings.RevealSecrets(), the OnChange hook and Change (printed or JSON-encoded) always carry the marker, error messages never quote a secret value, and fmt, slog and encoding/json print the marker for the default of a secret Key and the value of a secret Resolved. Only Get, Resolve (its Value field) and All with RevealSecrets return plaintext. What the package cannot reach: fmt prints an unexported struct field by reflection without calling its methods, so a secret Resolved or Key stored in an unexported field of your own struct shows in %v of that struct; keep only the values you need, where you need them.

To rotate keys, give every process a keyring whose current key is the new one and that still holds the old ones, then call client.Reencrypt(ctx) once. It scans the whole table, including keys only other services register, rewrites every encrypted value with the current key without overwriting concurrent writes, and keeps who last updated each value. Remove the old keys only when the result's Complete() reports true; otherwise Leftovers lists the values that still need them. This is how you retire a compromised key.

func rotate(ctx context.Context, store settings.Store, reg *settings.Registry, oldKey, newKey []byte) error {
	enc, err := settings.NewAESGCM("2026-11", map[string][]byte{
		"2026-10": oldKey, // still needed to read values written before the rotation
		"2026-11": newKey, // encrypts everything written from now on
	})
	if err != nil {
		return err
	}
	client, err := settings.New(store, reg, settings.Options{Encrypter: enc})
	if err != nil {
		return err
	}
	defer client.Close()
	res, err := client.Reencrypt(ctx)
	if err != nil {
		// Not complete: res.Leftovers says which values still need 2026-10.
		return err
	}
	log.Printf("re-encrypted %d secrets; key 2026-10 can now be removed", res.Rewritten)
	return nil
}

Security model. Encryption protects secret values from anyone who can read the settings table, its backups or its replicas without holding the keys. It does not protect against someone who can write to the table: they cannot forge or move a ciphertext to another row, scope or key, but they can put back an older ciphertext of the same row (from a backup, for example), and it decrypts to that row's older value for as long as the key that sealed it is in the keyring. Treat write access to the table as trusted, protect backups like the live table, and after a rotation remove retired keys as soon as Reencrypt reports completion, which makes every ciphertext sealed under them unreadable.

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. A write made through another client is seen no later than CacheTTL after the cached lookup started, and as soon as its notification arrives when Options.Invalidations is set to a pgxstore.Listener. The listener reconnects with jittered backoff after connection loss, tells the cache to drop everything whenever notifications may have been missed, and reports every failure to ListenerConfig.OnError; while it is disconnected the CacheTTL bound applies. 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, and the time. The actor is a settings.Ref (a type and an ID, such as {Type: "user", ID: "42"} or {Type: "job", ID: "nightly-sync"}), stored with the value as UpdatedBy. 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).

Actor wiring. By default the actor is whatever settings.WithActor put in the context. An application that already keeps its user in the context can supply the lookup once instead, through Options.ActorFrom, which has the same signature as settings.ActorFrom:

type userKey struct{}

type User struct{ ID string }

func newClient(store settings.Store, reg *settings.Registry) (*settings.Client, error) {
	return settings.New(store, reg, settings.Options{
		ActorFrom: func(ctx context.Context) (settings.Ref, bool) {
			u, ok := ctx.Value(userKey{}).(User)
			return settings.Ref{Type: "user", ID: u.ID}, ok
		},
	})
}

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 (scope_id <> '' AND length(scope_id) <= 255),
	value           jsonb       NOT NULL,
	updated_at      timestamptz NOT NULL,
	updated_by_type text        NOT NULL DEFAULT '' CHECK (length(updated_by_type) <= 64),
	updated_by_id   text        NOT NULL DEFAULT '' CHECK (length(updated_by_id) <= 255),
	PRIMARY KEY (key, scope_type, scope_id),
	CHECK (scope_type <> 'global' OR scope_id = 'global'),
	CHECK ((updated_by_type = '') = (updated_by_id = ''))
);
CREATE INDEX IF NOT EXISTS "settings_scope_idx" ON "settings" (scope_type, scope_id);

Upgrading a 0.1.0 table: see the SQL in CHANGELOG.md.

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. Swap, used by Reencrypt, is a single conditional UPDATE: it re-checks the expected value after waiting for any concurrent writer, so it never overwrites a value it did not see.

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{}

func newStore(pool *pgxpool.Pool) (*pgxstore.Store, error) {
	return 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
	}))
}

func extendTrial(ctx context.Context, pool *pgxpool.Pool, client *settings.Client, trialDays settings.Key[int]) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback(ctx) }() // no-op after Commit
	txCtx := context.WithValue(ctx, txKey{}, tx)
	// OnChange receives txCtx too and can insert the audit row with the same tx.
	if err := settings.Set(txCtx, client, trialDays, settings.Tenant("acme"), 30); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

Notifications are sent only on commit, so other instances never see a rolled-back write. Reads made with a context for which WithConn returns a connection run inside your transaction and bypass the client's cache in both directions (pgxstore.Store implements settings.CallerConnReporter), so a value read there, uncommitted or from an older REPEATABLE READ snapshot, is never served to other requests. The writing client invalidates its own cache when Set returns; if another request reads the key before the commit, it caches the old committed value until the commit notification arrives (when the client runs a Listener) or 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, DefaultCacheTTL, DefaultCacheSize Client construction
Get, Resolve, Resolved[T] Typed reads
Set, Client.SetJSON, Client.Unset, MaxValueLen Writes
Client.All, Entry, RevealSecrets Admin listing
Encrypter, KeyIdentifier, AESGCM, NewAESGCM Encryption at rest
Client.Reencrypt, ReencryptResult, Leftover, MaxLeftovers Key rotation
Change, Ref, WithActor, ActorFrom, Options.ActorFrom, Redacted Auditing
Store, Record, CallerConnReporter, 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 checkable with errors.Is. Every package error wraps one of the family sentinels ErrNotFound (ErrUnknownKey), ErrConflict (ErrDuplicateKey, pgxstore.ErrContention) or ErrInvalid (ErrInvalidKey, ErrTypeMismatch, ErrInvalidScope, ErrInvalidValue, ErrInvalidRecord, ErrNoEncrypter and configuration errors), so an application can map them to HTTP statuses once. ErrDecrypt (stored data that cannot be decrypted), ErrReencryptIncomplete and pgxstore.ErrListenerRunning wrap none.

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

  • 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).

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

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. A Scope is a type and an ID, both always set; Global returns the one global scope. 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. Scope types outside the order fail with ErrInvalidScope. 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 a random nonce per value and the key ID embedded in each ciphertext, so keys can be rotated while old values stay readable; Client.Reencrypt then moves every encrypted value in the table to the current key, and reports whether old keys can be retired. Each ciphertext is bound to its key name, scope and key ID, so a ciphertext copied to another row fails to decrypt; an older ciphertext of the same row, put back by someone with write access to the table, still decrypts while its key is in the keyring.

The package redacts secret values wherever it controls their output: All (unless RevealSecrets is passed), Change, error messages, and fmt, slog and encoding/json output of Key and Resolved. It cannot redact a Key or Resolved held in an unexported field of another struct, which fmt prints without calling their methods.

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 at most Options.CacheTTL after the cached lookup started, or as soon as their invalidation arrives when Options.Invalidations is set, for example to the pgxstore Listener, which relays PostgreSQL notifications. While such a source is disconnected, the TTL bound applies. Reads on a caller-supplied connection, such as a transaction joined through pgxstore.WithConn, bypass the cache (see CallerConnReporter).

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, a Ref such as {Type: "user", ID: "42"}. The actor comes from Options.ActorFrom, which defaults to reading the value stored by WithActor, so an application can plug in its own context lookup once.

Errors

Every error wraps one of the family sentinels ErrNotFound, ErrInvalid or ErrConflict where one applies, through a package-specific error such as ErrUnknownKey or ErrInvalidScope.

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 (
	MaxRefTypeLen = 64
	MaxRefIDLen   = 255
)

Limits on the parts of a Ref, shared by every Store. Lengths count Unicode code points, as PostgreSQL's length() does.

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 MaxLeftovers = 100

MaxLeftovers caps ReencryptResult.Leftovers, so a table full of values that cannot be re-encrypted does not produce an unbounded report; Remaining still counts all of them.

View Source
const MaxValueLen = 64 << 10

MaxValueLen caps the length in bytes of the JSON encoding of a value a client writes (before encryption), so a request body passed to SetJSON cannot make the client decode, encrypt or store an unbounded document.

View Source
const Redacted = "[redacted]"

Redacted replaces secret values wherever this package controls how they are exposed: listings, KeyInfo defaults and Change events carry the JSON string "[redacted]" instead of the value, and fmt, slog and encoding/json print it in place of the Default of a secret Key and the Value of a secret Resolved.

The package cannot reach a Key or Resolved held in an unexported field of another struct: fmt prints such fields by reflection without calling their methods, so %v of that outer struct shows the secret. Read Value only where it is needed and do not store a secret Resolved in a struct that may be printed.

Variables

View Source
var (
	// ErrNotFound reports that the record or key does not exist.
	ErrNotFound = errors.New("settings: not found")

	// ErrInvalid reports input that failed validation. The message names the
	// field that failed and why.
	ErrInvalid = errors.New("settings: invalid input")

	// ErrConflict reports a uniqueness violation, or a record whose state no
	// longer allows the operation.
	ErrConflict = errors.New("settings: conflict")
)

Family sentinels, shared in name and meaning by every Byte Minus package so an application can map all of them to HTTP statuses the same way. Every package-specific error below wraps one of them where one applies.

View Source
var (
	// ErrUnknownKey reports an operation on a key name that is not
	// registered. It wraps ErrNotFound.
	ErrUnknownKey = fmt.Errorf("settings: unknown key: %w", ErrNotFound)

	// ErrDuplicateKey reports an attempt to register a key name twice. It
	// wraps ErrConflict.
	ErrDuplicateKey = fmt.Errorf("settings: duplicate key: %w", ErrConflict)

	// ErrInvalidKey reports a malformed key name or a key definition whose
	// default value does not encode, decode or validate. It wraps ErrInvalid.
	ErrInvalidKey = fmt.Errorf("settings: invalid key: %w", ErrInvalid)

	// ErrTypeMismatch reports a Key[T] whose T differs from the type the key
	// name was registered with. It wraps ErrInvalid.
	ErrTypeMismatch = fmt.Errorf("settings: type mismatch: %w", ErrInvalid)

	// 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.
	// It wraps ErrInvalid.
	ErrInvalidScope = fmt.Errorf("settings: invalid scope: %w", ErrInvalid)

	// ErrInvalidValue reports a value that fails its key's Validate function
	// or exceeds MaxValueLen, or a stored value that does not decode into the
	// key's type. It wraps ErrInvalid.
	ErrInvalidValue = fmt.Errorf("settings: invalid value: %w", ErrInvalid)

	// ErrInvalidRecord reports a record a Store refuses to persist because it
	// violates the storage constraints (see Record.Validate). It wraps
	// ErrInvalid.
	ErrInvalidRecord = fmt.Errorf("settings: invalid record: %w", ErrInvalid)

	// ErrNoEncrypter reports a secret key used with a client that has no
	// Encrypter. It wraps ErrInvalid.
	ErrNoEncrypter = fmt.Errorf("settings: secret key requires an encrypter: %w", ErrInvalid)

	// ErrDecrypt reports a secret value that cannot be decrypted: it is
	// malformed, was encrypted with an unknown key, or fails authentication,
	// for example because it was copied from another row. It wraps none of
	// the family sentinels: the stored data, not the caller's input, is at
	// fault.
	ErrDecrypt = errors.New("settings: decrypt failed")
)

Package-specific 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.

View Source
var ErrReencryptIncomplete = errors.New("settings: re-encryption incomplete")

ErrReencryptIncomplete reports a Reencrypt run after which some stored value may still need an old key; see ReencryptResult.

Functions

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, ErrTypeMismatch when T differs from the registered type, ErrInvalidScope for malformed scopes, scopes outside the resolution order and a scope type given twice, ErrInvalidValue for a stored value that no longer decodes or validates, and ErrDecrypt for a secret that cannot be decrypted.

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 from Options.ActorFrom is recorded as the author. It fails with ErrUnknownKey, ErrTypeMismatch, ErrInvalidScope (including a scope type outside the resolution order), ErrInvalidValue (Validate failed or the encoding exceeds MaxValueLen), ErrInvalid for a half-empty actor, ErrNoEncrypter, or the store's or the OnChange hook's error.

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 Ref) context.Context

WithActor returns a context that records actor (a user, an API client, a job) as the author of writes made with it. The actor is stored with each written value (Record.UpdatedBy) and passed to the OnChange hook. A zero actor records no author; a half-empty one makes writes fail with ErrInvalid.

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; see Client.Reencrypt for moving old values to the current key so a retired key can be dropped.

Ciphertext layout: version (1 byte), key ID length (1 byte), key ID, 12-byte nonce, sealed data. The header is authenticated together with the caller's associated data, so the key ID cannot be swapped. Every encryption draws a fresh 96-bit nonce from crypto/rand; random nonces stay safe for about 2^32 encryptions under one key, far beyond what settings writes reach, and rotating the key resets the count.

What the binding does not stop: someone who can write to the settings table can put back an older ciphertext of the same row, for example from a backup, and it decrypts to that row's older value for as long as the key that sealed it is in the keyring. Treat write access to the table as trusted, keep backups as protected as the live table, and after rotating away from a key, remove it once Client.Reencrypt reports completion, which makes every ciphertext sealed under it unreadable.

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. Configuration errors wrap ErrInvalid and never include key material.

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.

func (*AESGCM) KeyID added in v0.2.0

func (a *AESGCM) KeyID(ciphertext []byte) (string, bool)

KeyID implements KeyIdentifier: it reports the key ID in the header of a ciphertext that is long enough to hold a nonce and an authentication tag, whether or not the key is in this keyring.

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 CallerConnReporter added in v0.2.0

type CallerConnReporter interface {
	UsesCallerConn(ctx context.Context) bool
}

CallerConnReporter is an optional interface for a Store that can run a call on a connection the caller supplies, such as a transaction carried in the context (see pgxstore.WithConn). pgxstore.Store implements it.

When UsesCallerConn reports true for a context, the client neither reads its cache nor fills it for that call: a read inside a caller's transaction can see an uncommitted write, or under REPEATABLE READ a snapshot older than the cache, and caching either would serve it to every other request after a rollback or a later commit. Reads on such a context always go to the store, inside the caller's transaction.

type Change

type Change struct {
	Key    string
	Scope  Scope
	Old    json.RawMessage
	New    json.RawMessage
	Secret bool
	// Actor is the actor from Options.ActorFrom, or the zero Ref.
	Actor Ref
	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, so a Change can be printed, logged or JSON-encoded safely.

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. It fails with an error wrapping ErrInvalid when store or registry is nil or an option is malformed, and with ErrNoEncrypter when a registered key is secret and there is no Encrypter.

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. It fails with ErrInvalidScope, the store's error, or, with RevealSecrets, ErrNoEncrypter or ErrDecrypt.

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(), settings.Ref{Type: "user", ID: "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=user:admin@example.com
ui.theme overridden=true value="dark" by=user: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 for up to its TTL, so Close also disables the cache; the client stays usable but reads go to the store. Close is idempotent.

func (*Client) Reencrypt added in v0.2.0

func (c *Client) Reencrypt(ctx context.Context) (ReencryptResult, error)

Reencrypt rewrites every encrypted value in the store with the Encrypter's current key. Run it after a rotation, once every process uses the new keyring. Only when it returns a result whose Complete method reports true (and so no error) may the old keys be removed, which is what makes retiring a compromised key possible.

It scans the whole table, not only the keys in this client's registry, so secrets written by other services sharing the table are covered:

  • Values of registered secret keys are always rewritten (with a KeyIdentifier Encrypter such as AESGCM, only those not already sealed with the current key).
  • Values of registered non-secret keys are left alone.
  • Values of unregistered keys are recognized as encrypted from their stored envelope, which needs a KeyIdentifier Encrypter; otherwise they are counted in Unchecked. A non-secret value that happens to look exactly like an envelope is reported in Remaining rather than ignored.

Values are rewritten one at a time with Store.Swap, so a concurrent Set or Unset is never overwritten; a value that changed under the rewrite is re-read and retried. Rewriting keeps the record's UpdatedAt and UpdatedBy, since the setting did not change, and does not call OnChange. An interrupted run can simply be repeated.

A value that cannot be rewritten does not stop the run: it is counted and described in the result, and Reencrypt then returns the result with an error wrapping ErrReencryptIncomplete and the first leftover's cause (such as ErrDecrypt). It fails with ErrNoEncrypter when the client has no Encrypter, and stops with the store's error, returning the result so far.

Example
package main

import (
	"bytes"
	"context"
	"fmt"

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

func main() {
	ctx := context.Background()
	token := settings.Key[string]{Name: "mail.api_token", Secret: true}
	reg, _ := settings.NewRegistry(token)
	store := memstore.New()
	oldKey, newKey := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)

	before, _ := settings.NewAESGCM("2026-10", map[string][]byte{"2026-10": oldKey})
	c1, _ := settings.New(store, reg, settings.Options{Encrypter: before})
	_ = settings.Set(ctx, c1, token, settings.Tenant("acme"), "sk-123")

	// Rotation: the new key is current, the old one still decrypts.
	rotating, _ := settings.NewAESGCM("2026-11", map[string][]byte{"2026-10": oldKey, "2026-11": newKey})
	c2, _ := settings.New(store, reg, settings.Options{Encrypter: rotating})
	res, err := c2.Reencrypt(ctx)
	fmt.Println("re-encrypted:", res.Rewritten, "complete:", res.Complete(), err)

	// The old key is gone and every value still reads.
	after, _ := settings.NewAESGCM("2026-11", map[string][]byte{"2026-11": newKey})
	c3, _ := settings.New(store, reg, settings.Options{Encrypter: after})
	v, err := settings.Get(ctx, c3, token, settings.Tenant("acme"))
	fmt.Println(v, err)
}
Output:
re-encrypted: 1 complete: true <nil>
sk-123 <nil>

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; it fails with ErrInvalidValue when value is longer than MaxValueLen or does not decode, and otherwise with the errors of Set.

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. It fails with ErrUnknownKey, ErrInvalidScope, ErrInvalid for a half-empty actor, or the store's or the OnChange hook's error.

type Encrypter

type Encrypter interface {
	// Encrypt seals plaintext, bound to associatedData, with the current key.
	Encrypt(ctx context.Context, plaintext, associatedData []byte) ([]byte, error)
	// Decrypt opens a ciphertext produced by Encrypt with the same
	// associatedData, using whichever key sealed it. Failures should wrap
	// ErrDecrypt; the client wraps those that do not.
	Decrypt(ctx context.Context, ciphertext, associatedData []byte) ([]byte, error)
}

Encrypter encrypts secret values at rest. AESGCM is the built-in implementation; applications implement Encrypter to keep keys in a KMS.

Implementations must be safe for concurrent use, must authenticate both the ciphertext and associatedData (an AEAD), and must not include plaintext in their errors. The client passes associatedData identifying the key name and scope a value belongs to, so a ciphertext copied to another row fails to decrypt. The context lets implementations make network calls; they should honour its deadline.

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 is the actor that wrote the value, or the zero Ref.
	UpdatedBy Ref
}

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. Applications implement it to relay changes over their own bus.

Subscribe registers fn and returns a function that removes it. fn may be called from another goroutine and must return quickly. An implementation that may have missed changes (for example after losing its connection) must deliver an Invalidation with All set before relying on incremental ones again; until then, clients fall back to their cache TTL.

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. Its error is
	// wrapped in the ErrInvalidValue error returned to the caller, except for
	// secret keys, where it is dropped because it may quote the value.
	Validate func(T) error
	// Secret values are encrypted at rest and redacted in listings, hooks,
	// errors, and fmt, slog and encoding/json output (see Redacted for the
	// one case the package cannot cover).
	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.

func (Key[T]) Format added in v0.2.0

func (k Key[T]) Format(f fmt.State, verb rune)

Format implements fmt.Formatter. It prints the key's fields, with Default replaced by Redacted when the key is secret, so a key logged by mistake cannot leak a secret default. Validate is not printed.

func (Key[T]) LogValue added in v0.2.0

func (k Key[T]) LogValue() slog.Value

LogValue implements slog.LogValuer, redacting the default of a secret key.

func (Key[T]) MarshalJSON added in v0.2.0

func (k Key[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler, encoding Name, Default, Description and Secret, with Default replaced by Redacted for a secret key. Validate is not encoded.

type KeyIdentifier added in v0.2.0

type KeyIdentifier interface {
	// CurrentKeyID returns the ID of the key Encrypt uses.
	CurrentKeyID() string
	// KeyID returns the ID of the key that sealed ciphertext, and false when
	// ciphertext is not in this Encrypter's format. It must not decrypt.
	KeyID(ciphertext []byte) (string, bool)
}

KeyIdentifier is an optional interface for Encrypters that can read, from a ciphertext alone, which key sealed it. AESGCM implements it. Client.Reencrypt uses it to skip values already sealed with the current key and to find secrets of keys missing from its registry; with an Encrypter that does not implement it, values of unregistered keys cannot be classified (see ReencryptResult.Unchecked).

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 Leftover added in v0.2.0

type Leftover struct {
	Key   string
	Scope Scope
	// Err says why, wrapping ErrDecrypt when the value does not decrypt. It
	// never quotes the value.
	Err error
}

Leftover is a value Reencrypt could not move to the current key.

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, counted from when the lookup started. Zero means
	// DefaultCacheTTL; negative disables the cache. Writes through this
	// client invalidate it immediately. Writes through other clients are
	// seen within CacheTTL; with Invalidations set they are usually seen as
	// soon as the invalidation arrives, and within CacheTTL while the source
	// is disconnected.
	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

	// ActorFrom returns the party performing a write, recorded as
	// Record.UpdatedBy and Change.Actor. Nil means the package-level
	// ActorFrom, which reads the value stored by WithActor. A half-empty
	// Ref makes the write fail with ErrInvalid.
	ActorFrom func(ctx context.Context) (Ref, bool)

	// 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 (ActorFrom)
package main

import (
	"context"
	"fmt"

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

func main() {
	// The application's own context value, set by its auth middleware.
	type userKey struct{}
	theme := settings.Key[string]{Name: "ui.theme", Default: "light"}
	reg, _ := settings.NewRegistry(theme)
	client, _ := settings.New(memstore.New(), reg, settings.Options{
		ActorFrom: func(ctx context.Context) (settings.Ref, bool) {
			id, ok := ctx.Value(userKey{}).(string)
			return settings.Ref{Type: "user", ID: id}, ok
		},
	})
	defer client.Close()

	ctx := context.WithValue(context.Background(), userKey{}, "42")
	_ = settings.Set(ctx, client, theme, settings.Tenant("acme"), "dark")
	r, _ := settings.Resolve(ctx, client, theme, settings.Tenant("acme"))
	fmt.Println(r.Value, "set by", r.UpdatedBy)
}
Output:
dark set by user:42
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(), settings.Ref{Type: "user", ID: "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:
user:alice changed api.rate_limit at tenant:acme: none -> 120
user:alice changed api.rate_limit at tenant:acme: 120 -> 240
user: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 is the actor that wrote the value, or the zero Ref.
	UpdatedBy Ref
}

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 that PostgreSQL jsonb can store (no \u0000 escapes, no unpaired UTF-16 surrogate escapes, no numbers beyond numeric's 131072 integer and 16383 fractional digits), and an UpdatedBy that is zero or has both parts set, each valid UTF-8 without NUL bytes and within MaxRefTypeLen and MaxRefIDLen. The error wraps ErrInvalidRecord.

type ReencryptResult added in v0.2.0

type ReencryptResult struct {
	// Rewritten counts the values re-sealed with the current key.
	Rewritten int
	// Remaining counts the encrypted values that are still not known to be
	// sealed with the current key: values that do not decrypt with this
	// keyring, and values that kept changing under the rewrite.
	Remaining int
	// Unchecked counts values of keys missing from the client's registry
	// that the client could not classify as encrypted or not, because its
	// Encrypter does not implement KeyIdentifier.
	Unchecked int
	// Leftovers describes the first MaxLeftovers values counted in
	// Remaining.
	Leftovers []Leftover
}

ReencryptResult reports what Client.Reencrypt did and, above all, whether any stored value may still need a key other than the current one.

func (ReencryptResult) Complete added in v0.2.0

func (r ReencryptResult) Complete() bool

Complete reports that no stored value needs a key other than the current one, so the old keys can be removed from the keyring.

type Ref added in v0.2.0

type Ref struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

Ref identifies a record or party by type and ID, such as the user, API client or job that wrote a setting. Typed references keep "user 42" and "api_token 42" apart. A stored Ref is either zero (no actor) or has both fields set.

func ActorFrom

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

ActorFrom returns the actor recorded by WithActor. The boolean is false when ctx carries none.

func (Ref) IsZero added in v0.2.0

func (r Ref) IsZero() bool

IsZero reports whether both fields are empty.

func (Ref) String added in v0.2.0

func (r Ref) String() string

String returns "type:id", for logs and display only. It is not unique when Type contains ":", so never use it as a map, cache or database key.

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 is the actor that wrote the value, or the zero Ref.
	UpdatedBy Ref
	// contains filtered or unexported fields
}

Resolved is a value together with where it came from. For a secret key, fmt, slog and encoding/json print Redacted in place of Value; a Resolved held in an unexported field of another struct is printed by fmt without that redaction (see Redacted).

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. It returns the same errors as Get.

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

func (Resolved[T]) Format added in v0.2.0

func (r Resolved[T]) Format(f fmt.State, verb rune)

Format implements fmt.Formatter. It prints the resolved fields, with Value replaced by Redacted when the key is secret. Read r.Value directly to use the value.

func (Resolved[T]) LogValue added in v0.2.0

func (r Resolved[T]) LogValue() slog.Value

LogValue implements slog.LogValuer, redacting the value of a secret key.

func (Resolved[T]) MarshalJSON added in v0.2.0

func (r Resolved[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler, encoding every field, with Value replaced by Redacted for a secret key. slog's JSON handler uses it too, for example for a slice of Resolved values.

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. Both fields are always set; the global scope is {Type: "global", ID: "global"}, as returned by Global.

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, for logs and error messages only: it is not unique, so never use it as a key.

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 and a non-empty valid UTF-8 ID without NUL bytes of at most MaxScopeIDLen characters, which for the global type must be the ID Global uses. The zero Scope is invalid. 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; a scope listed
	// twice yields one record. No scopes yields no records.
	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)

	// Scan returns up to limit records, of any key, that sort after the
	// position (afterKey, afterScope), in ascending order of (key, scope
	// type, scope ID) under a total order the store defines (byte order, or
	// a database collation) and keeps stable across calls. The empty key
	// and zero Scope sort first and start from the beginning; passing the
	// key and scope of the last record returned continues from there, so
	// every record present for the whole scan is returned exactly once.
	// limit is at least 1.
	Scan(ctx context.Context, afterKey string, afterScope Scope, limit int) ([]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)

	// Swap replaces the value of the record for (key, scope) with value,
	// keeping its UpdatedAt and UpdatedBy, only if the stored value still
	// equals old, a Value this store returned earlier. It reports whether it
	// replaced the value; a missing record or a different value is not an
	// error. The check and the write are one atomic step, so a concurrent
	// Put is never overwritten. A value failing Record.Validate is rejected
	// with an error wrapping ErrInvalidRecord.
	Swap(ctx context.Context, key string, scope Scope, old, value json.RawMessage) (bool, 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. Applications implement it to keep settings in a database this module does not support; the storetest package holds a conformance suite every implementation must pass.

Implementations must be safe for concurrent use, return UpdatedAt in UTC with at least microsecond precision, never alias the Value slices they are given or return, and honour context cancellation.

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