settings

package
v13.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: AGPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package settings stores the runtime settings a user or an account sets about themselves: administrator-defined definitions with a kind, a default and an enumeration, and per-subject values stored against them.

definitions, err := store.CreateDefinition(ctx, tenancy.Global(), &settings.Definition{
	Name:        "notifications.digest",
	Kind:        settings.KindString,
	Default:     pointer.To("weekly"),
	Enumeration: []string{"daily", "never", "weekly"},
})

// On the request path.
if _, err = store.SetValue(ctx, tenancy.Global(), me, "notifications.digest", "daily"); err != nil { ... }

resolved, err := store.Resolve(ctx, tenancy.Global(), me, "notifications.digest")
digest, err := resolved.String() // "daily", and "weekly" for anyone who has not chosen

This is not featureflags, and it is not config

Three things in this module answer the question "what value should this code use here", and they are not interchangeable. Getting them confused is the failure mode this package has, so it is worth being explicit about which is which.

config is boot-time environment. It is read once, by the process, out of the environment or a file; nothing writes it back, and every request in the process sees the same answer. If the value changes when somebody redeploys, it is config.

featureflags is a vendor's evaluation. A flag is somebody's rollout decision — percentage rollouts, targeting rules, a kill switch — evaluated per request against an external service, owned by whoever is shipping the feature. It is not storage: this module holds no flag values, and a flag that does not exist is a distinct answer rather than a missing row. If the value changes when somebody moves a slider in LaunchDarkly, it is a flag.

This package is neither. A setting is a fact a user or an account chose about themselves — their notification digest, their time zone, whether they want the compact layout — stored in the consumer's own database, readable and writable by the person it is about, and durable across deployments and vendors alike. If the value changes when somebody clicks "save" on their own preferences page, it is a setting.

The temptation this package has to resist is growing into a second featureflags: targeting rules, percentage rollouts, an evaluation order across subject types. Every one of those is a decision somebody makes about a population, which is what a flag is for, and none of them is a fact a subject chose about themselves. What this package will grow is storage-shaped: more kinds, better reads, a bulk write. A rule engine belongs on the other side of that line.

The two halves, and the rules between them

A Definition is what a setting is: the name application code asks for, the Kind its values parse as, the Definition.Default a subject who has not chosen falls back to, and the Definition.Enumeration of values it admits. A Value is one Subject's answer.

Split like that, three integrity rules exist between the two halves, and they are exactly the rules that drift when the pair is hand-rolled in an application:

  • A value with no definition. Every write here reads the definition first, in the same transaction, and the schema's foreign key holds the same line for a writer that did not come through this package.
  • A value outside its definition's enumeration. Checked at every write, against the definition read inside that write's transaction.
  • A definition change that strands stored values. Narrowing an enumeration or changing a kind decides how every value already written is read, so SQLStore.UpdateDefinition walks the live values first and refuses the edit at the first one the new definition would not admit — ErrStrandedValues, naming the subject and the value. The alternative is not a smaller problem: it is rows that exist, resolve, and fail to parse, for the subjects who chose a value somebody has just made illegal.

Resolution has three answers, not two

(value, SourceSubject)  the subject chose it
(value, SourceDefault)  they have not, and the definition has a default
("",    SourceUnset)    they have not, and it does not

The third is why Resolution is a value rather than four getters on the store. A typed getter taking a fallback cannot express it — it answers "nobody has said" with whatever the caller guessed, and gives the caller no way to tell that is what happened. So the tri-state is a Source on the resolution and an ErrSettingUnset from the typed accessors, which a caller matches:

switch retention, err := resolved.Int(); {
case errors.Is(err, settings.ErrSettingUnset):
	// Nobody has decided. The caller's own policy applies.
case err != nil:
	// A kind mismatch, or an unparseable row.
default:
	// retention is somebody's decision.
}

Definition.Default is a *string for the same reason. A text setting defaulting to "" answers every subject who has not chosen; a text setting with no default answers none of them, and a plain string column has nowhere to put the difference.

Scope

Every method takes a tenancy.Scope, and there is no unscoped read of anything here. A deployment with one catalog of settings passes tenancy.Global() everywhere and behaves exactly as it would have without the column.

A definition and the values stored against it share a scope. That is a real restriction and it is deliberate: a global catalog with per-tenant values would put two scopes into every resolution, and a read whose whole guarantee is that it names one scope cannot name two without the guarantee becoming a convention. A deployment whose tenants share a catalog and differ in their answers gives both the tenant's scope and seeds the definitions per tenant, which is an administrative write and a cheap one.

Where the SQL comes from

Nothing in this package composes SQL. The statements are rendered from the column lists in settings/internal/queries through database/querygen, committed as one .sql per dialect, checked against the schema by sqlc, and executed through the querier sqlc-gen-unison generates into settings/internal/settingsdb. A column renamed in settings/migrations is a failed generate rather than a runtime scan error, on every table, in all three dialects.

The tables are settings/migrations' to create, at whatever prefix a consumer chooses. The platform ships no numbered migration file — see that package.

Where this package stops

At the store. A settings API's routes, its request and response types, and who is allowed to write a definition as opposed to read one are an application's, and this package ships none of them. The module README's "Stores and Transports" section is where that line is drawn for the module as a whole.

Example

Example shows the flow this package exists for: an administrator defines a setting, a person answers it, and the request path reads the answer back with the default standing in for everyone who has not.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/primandproper/platform-go/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/sqlite"
	"github.com/primandproper/platform-go/v13/pointer"
	"github.com/primandproper/platform-go/v13/settings"
	"github.com/primandproper/platform-go/v13/settings/migrations"
	"github.com/primandproper/platform-go/v13/tenancy"
)

func main() {
	ctx := context.Background()
	store := exampleWiring()

	// One catalog, so the scope is global — the shape a single-tenant
	// application has, behaving exactly as it would without the column.
	scope := tenancy.Global()

	// The administrative half. Written once, by a migration or an admin console,
	// rather than on a request path.
	definition, err := store.CreateDefinition(ctx, scope, &settings.Definition{
		Name:        "notifications.digest",
		Description: "how often a digest email is sent",
		Kind:        settings.KindString,
		Default:     pointer.To("weekly"),
		Enumeration: []string{"daily", "weekly", "never"},
	})
	if err != nil {
		panic(err)
	}

	ada := settings.Subject{Type: settings.SubjectUser, ID: "user-ada"}
	grace := settings.Subject{Type: settings.SubjectUser, ID: "user-grace"}

	// The request path. The value is checked against the definition inside the
	// write, so a setting can only hold what it admits.
	if _, err = store.SetValue(ctx, scope, ada, definition.Name, "daily"); err != nil {
		panic(err)
	}

	if _, err = store.SetValue(ctx, scope, ada, definition.Name, "hourly"); err != nil {
		fmt.Println("refused:", errors.Is(err, settings.ErrNotEnumerated))
	}

	// Reading it back. Ada chose; Grace did not and gets the default.
	subjects := []settings.Subject{ada, grace}
	for i := range subjects {
		subject := subjects[i]

		resolved, resolveErr := store.Resolve(ctx, scope, subject, definition.Name)
		if resolveErr != nil {
			panic(resolveErr)
		}

		digest, digestErr := resolved.String()
		if digestErr != nil {
			panic(digestErr)
		}

		fmt.Printf("%s: %s (%s)\n", subject.ID, digest, resolved.Source)
	}

}

// exampleWiring builds a throwaway SQLite-backed store. A real application hands
// migrations.SQL to its own migration run and builds the store over the database
// it already has.
func exampleWiring() settings.Store {
	ctx := context.Background()

	dir, err := os.MkdirTemp("", "settings-example")
	if err != nil {
		panic(err)
	}

	client, err := sqlite.NewDatabaseClient(ctx, &exampleClientConfig{
		connectionString: filepath.Join(dir, "settings.db"),
	})
	if err != nil {
		panic(err)
	}

	stmts, err := migrations.Statements(dialect.SQLite, settings.DefaultTablePrefix)
	if err != nil {
		panic(err)
	}

	for _, stmt := range stmts {
		if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
			panic(err)
		}
	}

	store, err := settings.NewSQLStore(client)
	if err != nil {
		panic(err)
	}

	return store
}

// exampleClientConfig is the minimum database.ClientConfig a SQLite client
// needs.
type exampleClientConfig struct {
	connectionString string
}

func (c *exampleClientConfig) GetReadConnectionString() string   { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string  { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64        { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration  { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int              { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int              { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output:
refused: true
user-ada: daily (subject)
user-grace: weekly (default)
Example (Unset)

Example_unset shows the third answer a resolution can give, and why it is a sentinel rather than a fallback parameter.

A setting with no value and no default has not been decided by anybody, and a getter taking a default would answer it with whatever the caller guessed — leaving the caller unable to tell "nobody has said" from "somebody said this".

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/primandproper/platform-go/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/sqlite"
	"github.com/primandproper/platform-go/v13/settings"
	"github.com/primandproper/platform-go/v13/settings/migrations"
	"github.com/primandproper/platform-go/v13/tenancy"
)

func main() {
	ctx := context.Background()
	store := exampleWiring()
	scope := tenancy.Global()

	if _, err := store.CreateDefinition(ctx, scope, &settings.Definition{
		Name: "retention.days",
		Kind: settings.KindInt,
	}); err != nil {
		panic(err)
	}

	resolved, err := store.Resolve(ctx, scope,
		settings.Subject{Type: settings.SubjectAccount, ID: "account-1"}, "retention.days")
	if err != nil {
		panic(err)
	}

	switch days, readErr := resolved.Int(); {
	case errors.Is(readErr, settings.ErrSettingUnset):
		fmt.Println("nobody has decided; the caller's own policy applies")
	case readErr != nil:
		panic(readErr)
	default:
		fmt.Println("retain for", days, "days")
	}

}

// exampleWiring builds a throwaway SQLite-backed store. A real application hands
// migrations.SQL to its own migration run and builds the store over the database
// it already has.
func exampleWiring() settings.Store {
	ctx := context.Background()

	dir, err := os.MkdirTemp("", "settings-example")
	if err != nil {
		panic(err)
	}

	client, err := sqlite.NewDatabaseClient(ctx, &exampleClientConfig{
		connectionString: filepath.Join(dir, "settings.db"),
	})
	if err != nil {
		panic(err)
	}

	stmts, err := migrations.Statements(dialect.SQLite, settings.DefaultTablePrefix)
	if err != nil {
		panic(err)
	}

	for _, stmt := range stmts {
		if _, err = client.Writer().ExecContext(ctx, stmt); err != nil {
			panic(err)
		}
	}

	store, err := settings.NewSQLStore(client)
	if err != nil {
		panic(err)
	}

	return store
}

// exampleClientConfig is the minimum database.ClientConfig a SQLite client
// needs.
type exampleClientConfig struct {
	connectionString string
}

func (c *exampleClientConfig) GetReadConnectionString() string   { return c.connectionString }
func (c *exampleClientConfig) GetWriteConnectionString() string  { return c.connectionString }
func (c *exampleClientConfig) GetMaxPingAttempts() uint64        { return 1 }
func (c *exampleClientConfig) GetPingWaitPeriod() time.Duration  { return time.Millisecond }
func (c *exampleClientConfig) GetMaxIdleConns() int              { return 2 }
func (c *exampleClientConfig) GetMaxOpenConns() int              { return 1 }
func (c *exampleClientConfig) GetConnMaxLifetime() time.Duration { return time.Minute }
Output:
nobody has decided; the caller's own policy applies

Index

Examples

Constants

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the settings tables carry when none is configured, which is none — rendering settings_definitions and its two siblings.

The settings_ segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_settings_definitions, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.

Variables

View Source
var (
	// ErrNilDatabaseClient indicates a nil database.Client. It wraps
	// errors.ErrNilInputParameter, so a caller may check either.
	ErrNilDatabaseClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil database client")

	// ErrNilDefinition indicates a nil *Definition where one was required.
	ErrNilDefinition = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil setting definition")

	// ErrEmptyDefinitionName indicates a definition with no name. The name is
	// the only handle a value-side call takes, so a definition without one is
	// unreachable rather than merely unlabeled.
	ErrEmptyDefinitionName = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty setting name")

	// ErrEmptySubjectType indicates a Subject with no type.
	ErrEmptySubjectType = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty subject type")

	// ErrEmptySubjectID indicates a Subject with no id.
	ErrEmptySubjectID = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty subject id")

	// ErrEmptyEnumerationValue indicates an enumeration carrying the empty
	// string. It is refused rather than stored because an enumerated setting
	// whose legal values include "" cannot be told apart from one whose caller
	// left a slot blank, and the enumeration is the thing every write is checked
	// against.
	ErrEmptyEnumerationValue = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty enumeration value")

	// ErrDuplicateEnumerationValue indicates an enumeration naming one value
	// twice. The schema stores an enumeration as a set keyed on the value, so a
	// duplicate is a write that would silently collapse rather than a harmless
	// repetition.
	ErrDuplicateEnumerationValue = platformerrors.New("enumeration names a value twice")

	// ErrDefinitionNotFound indicates no live definition by that name or id in
	// this scope. Every value-side call can return it, because a value is only
	// meaningful against a definition.
	ErrDefinitionNotFound = platformerrors.New("setting definition not found")

	// ErrValueNotFound indicates the subject has not set this setting. It is
	// what GetValue and ClearValue report; Resolve does not, because a subject
	// that has not answered is a resolution rather than an absence — see
	// [SourceUnset].
	ErrValueNotFound = platformerrors.New("setting value not found")

	// ErrDefinitionNameTaken indicates a setting name already defined in this
	// scope.
	//
	// It is a distinct error rather than a raw constraint violation because the
	// difference between "your input collides" and "the database is unwell"
	// decides whether the caller reports to a person or retries. The uniqueness
	// covers archived definitions, so a name freed by archiving is a name that
	// stays taken — see settings/migrations.
	ErrDefinitionNameTaken = platformerrors.New("setting name is already defined")

	// ErrUnknownKind indicates a Kind this package cannot parse.
	ErrUnknownKind = platformerrors.Wrap(platformerrors.ErrUnrecognizedInputValue, "unknown setting kind")

	// ErrMalformedValue indicates a value that is not of its definition's kind:
	// "yes" for a boolean, "1.5" for an integer.
	ErrMalformedValue = platformerrors.Wrap(platformerrors.ErrUnrecognizedInputValue, "value is not of the setting's kind")

	// ErrNotEnumerated indicates a value outside the definition's enumeration.
	ErrNotEnumerated = platformerrors.Wrap(platformerrors.ErrUnrecognizedInputValue, "value is not one the setting admits")

	// ErrKindMismatch indicates a typed read of the wrong kind: Resolution.Bool
	// on a setting whose kind is integer.
	//
	// It is an error rather than a coerced answer because it is a mistake in the
	// calling code, and every coercion available is worse: false is a decision
	// the caller did not make, and reporting nothing is how a mistyped read gets
	// deployed.
	ErrKindMismatch = platformerrors.New("setting is not of the kind it was read as")

	// ErrSettingUnset indicates a resolution with neither a value nor a default.
	//
	// It is the third state of a resolved setting, and it is a sentinel rather
	// than a bool parameter on the accessors for the reason [Resolution]
	// describes: a getter taking a fallback answers "unset" with whatever the
	// caller guessed and gives them no way to tell that is what happened.
	ErrSettingUnset = platformerrors.New("setting has no value and no default")

	// ErrCursorStalled indicates a paged read that answered with the cursor it
	// was handed, which would leave a walk over the collection repeating one
	// page forever.
	//
	// It surfaces from the two reads here that walk a collection rather than
	// answer a page — resolving every setting for a subject, and checking every
	// stored value against a definition being edited — and it is an error rather
	// than a stop, for the reason dataprivacy's namesake is: the rows past the
	// stall are the ones the caller asked about, and a check that skipped them
	// would approve an edit that strands values while reporting success.
	ErrCursorStalled = platformerrors.New("settings paged read did not advance")

	// ErrStrandedValues indicates an edit to a definition that some stored value
	// no longer satisfies: a kind that value does not parse as, or an
	// enumeration it is not in.
	//
	// The write is refused rather than applied, which is the whole of what this
	// store owns that a hand-rolled pair does not. Applied, the stored value
	// would still be there and every read of it would fail — a setting that
	// works for most subjects and is broken for the ones who chose the value
	// somebody just made illegal. The wrapped message names the subject and the
	// value, because clearing or migrating them is what the administrator has to
	// do before the edit can succeed.
	ErrStrandedValues = platformerrors.New("edit would strand stored setting values")
)

The sentinels this package returns. They live together because a caller deciding what to do next is choosing between them, and a set spread across the files that happen to return each one cannot be read as the set it is.

Functions

This section is empty.

Types

type Definition

type Definition struct {

	// CreatedAt is when the definition was added. It is the database's clock
	// rather than the application's, read back by the write — see
	// settings/migrations.
	CreatedAt time.Time `json:"createdAt"`

	// LastUpdatedAt is when the definition last changed, or nil for one that
	// has not been edited.
	LastUpdatedAt *time.Time `json:"lastUpdatedAt"`

	// ArchivedAt is when the definition was retired. An archived definition is
	// excluded from every read that does not ask for archived rows; the values
	// stored against it are left alone, because archiving is not erasure and
	// the name stays claimed.
	ArchivedAt *time.Time `json:"archivedAt"`

	// Default is what [SQLStore.Resolve] answers with for a subject that has
	// not set this setting, or nil for a definition with no default at all.
	//
	// A pointer rather than a string, and that is the whole of what "absence is
	// distinguishable from zero" means here. A text setting defaulting to ""
	// answers every subject that has not chosen; a text setting with no default
	// answers none of them, and the caller is told so — see [SourceUnset].
	Default *string `json:"default"`

	// ID identifies the definition. Minted on write when empty.
	ID string `json:"id"`

	// Name is what application code asks for, unique within Scope. It is the
	// only handle a value-side call takes: an application holds the name of the
	// setting it wants, not the id of a row it would have to look up first.
	Name string `json:"name"`

	// Description is prose for whoever administers the setting.
	Description string `json:"description"`

	// Kind is how a stored value is parsed.
	Kind Kind `json:"kind"`

	// Scope is whose catalog this definition is in. See the package
	// documentation on why a definition and the values against it share one.
	Scope tenancy.Scope `json:"scope"`

	// Enumeration is the values this setting admits, or empty for a setting that
	// admits any value of its kind.
	//
	// A set rather than a sequence: it comes back sorted, and what it decides is
	// whether a write is legal. Membership has no order, and a rendering order
	// is the caller's — see settings/migrations for why the schema does not
	// carry one.
	//
	// Every read that returns a Definition fills this in. A field populated on
	// some reads and not others would be indistinguishable from a setting that
	// enumerates nothing, and that reading fails open: every value would be
	// legal.
	Enumeration []string `json:"enumeration"`

	// AdminOnly marks a setting only an administrator may write. It is recorded
	// rather than enforced — this package has no notion of who is calling, and
	// a store that pretended to would be an authorization check in the wrong
	// layer. What it is for is the caller's own check, and the admin UI that
	// needs to know which settings to hide from a self-service page.
	AdminOnly bool `json:"adminOnly"`
	// contains filtered or unexported fields
}

Definition is what a setting is: the name application code asks for, the kind of value it holds, what it falls back to, and which values it admits.

Definitions are administrative rows. Nothing on a request path creates one — the catalog is a deployment's decision, in the same sense that a database column is — and what a request path does is read one and store an answer against it.

type DefinitionStore

type DefinitionStore interface {
	// CreateDefinition adds a setting to the catalog and returns it as
	// stored, with the id it was minted under and the creation time the
	// database assigned.
	//
	// It refuses a name already defined in this scope with
	// ErrDefinitionNameTaken, a default the setting would not admit, and an
	// enumeration holding an empty or repeated value.
	CreateDefinition(ctx context.Context, scope tenancy.Scope, definition *Definition) (*Definition, error)

	// GetDefinition reads one live definition by id.
	GetDefinition(ctx context.Context, scope tenancy.Scope, definitionID string) (*Definition, error)

	// GetDefinitionByName reads one live definition by the name application
	// code spells. It is the read every value-side call begins with.
	GetDefinitionByName(ctx context.Context, scope tenancy.Scope, name string) (*Definition, error)

	// ListDefinitions pages the scope's catalog.
	ListDefinitions(ctx context.Context, scope tenancy.Scope, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Definition], error)

	// UpdateDefinition rewrites a definition, enumeration included.
	//
	// It refuses an edit that some stored value no longer satisfies —
	// ErrStrandedValues, naming the subject and the value — which is the rule
	// this store exists to own. An administrator narrowing an enumeration or
	// changing a kind clears or migrates the offending values first, and the
	// refusal names them one at a time so that there is always something to do
	// next.
	UpdateDefinition(ctx context.Context, scope tenancy.Scope, definition *Definition) error

	// ArchiveDefinition retires a setting.
	//
	// The values stored against it are left alone and the name stays claimed:
	// archiving is not erasure, and freeing the name would let a second
	// definition inherit rows written for the first. A catalog that genuinely
	// wants the name back deletes the definition, which takes its values with
	// it through the schema's cascade.
	ArchiveDefinition(ctx context.Context, scope tenancy.Scope, definitionID string) error
}

DefinitionStore is the catalog: what settings exist, what kind of value each holds, and what each falls back to.

Every method takes a tenancy.Scope, and a deployment with one catalog passes tenancy.Global() to all of them. There is deliberately no unscoped variant of any of these — see the tenancy package, and the settings package documentation on why a definition and the values against it share a scope.

type Kind

type Kind string

Kind is what sort of value a setting holds.

It is a closed set, unlike SubjectType, and the two differ for a reason worth stating: a subject type names a kind of principal, which is the application's to invent, while a kind decides how a stored string is parsed — so a kind this package does not implement is a value nothing can read back. An application wanting a shape none of these express stores the encoding it chose as a KindString and parses it itself, which is honest about where the interpretation lives.

const (
	// KindString is any text. Every value is legal unless the definition
	// enumerates its own.
	KindString Kind = "string"
	// KindBool is a flag, stored as strconv.FormatBool writes it: "true" or
	// "false".
	KindBool Kind = "boolean"
	// KindInt is a signed 64-bit integer in base ten.
	KindInt Kind = "integer"
	// KindFloat is a 64-bit float, as strconv.ParseFloat reads one.
	KindFloat Kind = "float"
)

func (Kind) String

func (k Kind) String() string

String renders the kind as it is stored.

func (Kind) Valid

func (k Kind) Valid() bool

Valid reports whether k is one of the four kinds.

type Resolution

type Resolution struct {

	// Definition is the setting that was resolved. Never nil: resolution begins
	// by reading it, and a setting that does not exist is an error rather than
	// an unset resolution.
	Definition *Definition `json:"definition"`

	// Value is the row the subject set, or nil when the default answered or
	// nothing did.
	Value *Value `json:"value"`

	// Raw is the answer as stored, empty when Source is [SourceUnset].
	Raw string `json:"raw"`

	// Source says which of the three cases this is.
	Source Source `json:"source"`
	// contains filtered or unexported fields
}

Resolution is a setting resolved for a subject: the value, and where it came from.

It is what a typed read hands back, and the reason it is a struct rather than four accessors on the store is the tri-state. A resolved setting is answered by the subject, answered by the default, or not answered at all, and a getter taking a fallback value cannot express the third — it would answer "unset" with whatever the caller guessed, and a caller that wanted to know would have no way to ask. So the third state is a value here and a sentinel from the accessors, which a caller matches with errors.Is.

func (*Resolution) Bool

func (r *Resolution) Bool() (bool, error)

Bool returns the resolved value of a KindBool setting.

func (*Resolution) Float

func (r *Resolution) Float() (float64, error)

Float returns the resolved value of a KindFloat setting.

func (*Resolution) Int

func (r *Resolution) Int() (int64, error)

Int returns the resolved value of a KindInt setting.

func (*Resolution) Set

func (r *Resolution) Set() bool

Set reports whether the setting was answered at all, by the subject or by the definition's default.

func (*Resolution) String

func (r *Resolution) String() (string, error)

String returns the resolved value of a KindString setting.

It is deliberately not fmt.Stringer, despite the name: a resolution can fail to answer — the setting is of another kind, or nobody has set it — and a Stringer has nowhere to say so. The name is the one that pairs with Kind, which is what a caller is choosing between when they reach for it, and the two-value signature is what keeps `fmt.Sprintf("%s", resolution)` from silently calling it.

type SQLStore

type SQLStore struct {
	// contains filtered or unexported fields
}

SQLStore is the SQL-backed Store, against the schema settings/migrations renders.

It is exported, and returned by NewSQLStore, so a caller who has chosen SQL storage can depend on that choice rather than on the Store seam every backing shares.

func NewSQLStore

func NewSQLStore(client database.Client, opts ...SQLStoreOption) (*SQLStore, error)

NewSQLStore builds a Store over the given database.

The dialect comes from the client, so the two cannot disagree. The prefix must still match the one the migrations were rendered with — nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.

Observability is optional and defaults to nothing: an unconfigured store logs to a noop logger and traces to a noop provider.

func (*SQLStore) ArchiveDefinition

func (s *SQLStore) ArchiveDefinition(ctx context.Context, scope tenancy.Scope, definitionID string) error

ArchiveDefinition retires one of the scope's settings.

func (*SQLStore) ClearValue

func (s *SQLStore) ClearValue(ctx context.Context, scope tenancy.Scope, subject Subject, name string) error

ClearValue takes a subject's answer back, leaving them on the definition's default.

The row is archived rather than deleted. What a subject answered is worth keeping — it is what a later restore restores and what an audit of a preference change reads — and the row is the thing the unique key is about, so archiving leaves the key claimed and the next write converging on the same row.

func (*SQLStore) CreateDefinition

func (s *SQLStore) CreateDefinition(
	ctx context.Context,
	scope tenancy.Scope,
	definition *Definition,
) (*Definition, error)

CreateDefinition adds a setting to the scope's catalog.

The definition, its enumeration and the read-back of the creation time share one transaction. Without it a definition could exist with half its enumeration written, which is the state that makes every value legal or no value legal depending on which half landed — and an enumeration is what every subsequent write is checked against.

func (*SQLStore) GetDefinition

func (s *SQLStore) GetDefinition(
	ctx context.Context,
	scope tenancy.Scope,
	definitionID string,
) (*Definition, error)

GetDefinition reads one of the scope's live definitions by id.

func (*SQLStore) GetDefinitionByName

func (s *SQLStore) GetDefinitionByName(
	ctx context.Context,
	scope tenancy.Scope,
	name string,
) (*Definition, error)

GetDefinitionByName reads one of the scope's live definitions by the name application code spells.

func (*SQLStore) GetValue

func (s *SQLStore) GetValue(
	ctx context.Context,
	scope tenancy.Scope,
	subject Subject,
	name string,
) (*Value, error)

GetValue reads the answer a subject stored, without applying the definition's default. Resolve is what applies it.

func (*SQLStore) ListDefinitions

func (s *SQLStore) ListDefinitions(
	ctx context.Context,
	scope tenancy.Scope,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[Definition], error)

ListDefinitions pages the scope's catalog, in the direction the filter names.

The direction is a choice between two generated statements rather than an argument either of them binds — see sortedRows — so what this method does with filter.SortBy is pick the one whose ORDER BY and cursor comparison agree with it.

func (*SQLStore) ListValuesForDefinition

func (s *SQLStore) ListValuesForDefinition(
	ctx context.Context,
	scope tenancy.Scope,
	name string,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[Value], error)

ListValuesForDefinition pages everyone who has answered one setting.

func (*SQLStore) ListValuesForSubject

func (s *SQLStore) ListValuesForSubject(
	ctx context.Context,
	scope tenancy.Scope,
	subject Subject,
	filter *filtering.QueryFilter,
) (*filtering.QueryFilteredResult[Value], error)

ListValuesForSubject pages everything one subject has answered.

func (*SQLStore) Resolve

func (s *SQLStore) Resolve(
	ctx context.Context,
	scope tenancy.Scope,
	subject Subject,
	name string,
) (*Resolution, error)

Resolve answers one setting for one subject: their value, else the definition's default, else neither.

func (*SQLStore) ResolveAll

func (s *SQLStore) ResolveAll(ctx context.Context, scope tenancy.Scope, subject Subject) ([]*Resolution, error)

ResolveAll answers every live setting in the scope for one subject, sorted by name.

Two walks rather than one resolution per setting: the catalog, and the subject's own answers. Both are bounded by the size of the catalog — a subject can have answered at most one of each — and reading them separately is what lets a setting nobody has answered appear in the result at its default, which is exactly what a preferences page renders.

func (*SQLStore) SetValue

func (s *SQLStore) SetValue(
	ctx context.Context,
	scope tenancy.Scope,
	subject Subject,
	name, raw string,
) (*Value, error)

SetValue stores a subject's answer to one setting.

The definition read and the write share a transaction, and the read is what makes the write checkable: raw has to be of the definition's kind and in its enumeration, and both of those are facts about a row that another transaction could be editing. Read outside the write, a value could be validated against an enumeration that no longer holds by the time it lands — which is the same stranded row SQLStore.UpdateDefinition refuses to create, reached from the other side.

The write converges rather than inserts: the (scope, subject, definition) quadruple is unique across live and archived rows alike, so a subject setting a value they had cleared revives the row they cleared, keeping the creation time that records when they first answered.

func (*SQLStore) TablePrefix

func (s *SQLStore) TablePrefix() string

TablePrefix returns the namespace this store's tables carry, for a caller that needs the rendered names — a maintenance TRUNCATE, a schema audit. Pass it to migrations.Tables.

func (*SQLStore) UpdateDefinition

func (s *SQLStore) UpdateDefinition(ctx context.Context, scope tenancy.Scope, definition *Definition) error

UpdateDefinition rewrites a definition, enumeration included, refusing an edit that some stored value no longer satisfies.

The refusal is the rule this store owns. A narrowed enumeration or a changed kind decides how every value already written against the definition is read, and an edit applied over them leaves rows that exist, resolve, and fail to parse — a setting that works for most subjects and is broken for whoever chose the value somebody just made illegal. So the values are walked first, in the same transaction as the write, and the first one the new definition would not admit stops it.

Only live values are checked. A cleared value resolves to the default rather than to itself, and setting it again goes through the write path with the new definition in hand.

The walk is skipped where the edit cannot strand anything: renaming a setting, rewording it, changing its default or its admin flag leaves every stored value exactly as legal as it was.

type SQLStoreOption

type SQLStoreOption func(*SQLStore)

SQLStoreOption configures a SQLStore.

The observability dependencies are options rather than parameters because every one of them is genuinely optional: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing. A caller wanting none of the three names none of them.

func WithStoreLogger

func WithStoreLogger(logger logging.Logger) SQLStoreOption

WithStoreLogger attaches a logger. An absent logger logs nowhere.

func WithStoreMetricsProvider

func WithStoreMetricsProvider(metricsProvider metrics.Provider) SQLStoreOption

WithStoreMetricsProvider attaches a metrics provider. An absent provider records nothing.

func WithStorePillars

func WithStorePillars(p *observability.Pillars) SQLStoreOption

WithStorePillars attaches a logger, tracer provider, and metrics provider in one go. A nil Pillars attaches nothing.

Options apply in order, so a caller can hand over its pillars and then override one of them.

func WithStoreTracerProvider

func WithStoreTracerProvider(tracerProvider tracing.Provider) SQLStoreOption

WithStoreTracerProvider attaches a tracer provider, enabling spans on every read and write. An absent provider traces nowhere.

It takes a provider rather than a ready-made tracer so that the spans this package emits carry this package's instrumentation scope. A caller-supplied tracer would attribute them to whoever built it.

func WithTablePrefix

func WithTablePrefix(prefix string) SQLStoreOption

WithTablePrefix namespaces the three settings tables. It must match the prefix the migrations were rendered with; nothing here can check that, and a mismatch surfaces as a missing table on the first query rather than at construction.

type Source

type Source string

Source says where a resolved setting's value came from.

const (
	// SourceSubject is a value the subject set.
	SourceSubject Source = "subject"
	// SourceDefault is the definition's default, for a subject that has not set
	// the setting.
	SourceDefault Source = "default"
	// SourceUnset is neither: the setting exists, the subject has not answered
	// it, and it has no default. Reading it as a typed value reports
	// [ErrSettingUnset] rather than the kind's zero.
	SourceUnset Source = "unset"
)

func (Source) String

func (s Source) String() string

String renders the source as it is reported.

type Store

type Store interface {
	DefinitionStore
	ValueStore
}

Store is the whole of what this package persists: the catalog and the answers stored against it.

It is two interfaces because they have two callers. A DefinitionStore is reached by whatever administers a deployment — a migration, an admin console, a seeding job — and a ValueStore is reached on the request path, by the handler saving somebody's notification preference and by the code that reads it back. Splitting them is what lets a component depend on the half it uses; Store is here for the wiring that provides both.

type Subject

type Subject struct {
	// Type says what kind of principal this is. Required.
	Type SubjectType `json:"type"`
	// ID identifies the principal within that type. Required.
	ID string `json:"id"`
}

Subject is whose setting a value is.

It is two fields rather than one composite string for the reason the tenancy doctrine gives for the scope: a key spelling "user:abc123" carries two facts in a column that can only be indexed, filtered and enumerated as one.

func (Subject) Validate

func (s Subject) Validate() error

Validate reports whether the subject names anything.

type SubjectType

type SubjectType string

SubjectType distinguishes the kinds of thing a setting can be about.

Like dataprivacy.SubjectType and audit.ActorType this is a bare string with suggested constants rather than a closed set: an application whose settings hang off a third kind of principal — a device, a workspace, an API client — should say so rather than misfile it as one of these.

const (
	// SubjectUser is one person's own settings: their notification preferences,
	// their display choices.
	SubjectUser SubjectType = "user"
	// SubjectAccount is an account's, tenant's, or organization's settings —
	// the ones an administrator sets on everybody's behalf.
	SubjectAccount SubjectType = "account"
)

func (SubjectType) String

func (t SubjectType) String() string

String renders the subject type as it is stored.

type Value

type Value struct {

	// CreatedAt is when the subject first answered. A value that was cleared and
	// set again keeps it, because the write converges on the row rather than
	// adding a second one.
	CreatedAt time.Time `json:"createdAt"`

	// LastUpdatedAt is when the answer last changed, or nil for one set once.
	LastUpdatedAt *time.Time `json:"lastUpdatedAt"`

	// ArchivedAt is when the answer was cleared. A cleared value is excluded
	// from every read that does not ask for archived rows, and resolution falls
	// back to the definition's default as though it had never been set.
	ArchivedAt *time.Time `json:"archivedAt"`

	// Subject is whose answer it is.
	Subject Subject `json:"subject"`

	// ID identifies the row. It is not how the row is addressed — every
	// single-row statement keys on the scope, the subject and the definition —
	// and what it is for is the cursor a page walks.
	ID string `json:"id"`

	// DefinitionID is the definition this answers.
	DefinitionID string `json:"definitionID"`

	// Raw is the answer as it is stored. It is a string on purpose: one column
	// holds every kind, and [Resolution] is where it becomes a typed value.
	Raw string `json:"value"`

	// Scope is whose settings these are.
	Scope tenancy.Scope `json:"scope"`
	// contains filtered or unexported fields
}

Value is what one subject answered for one definition.

type ValueStore

type ValueStore interface {
	// SetValue stores a subject's answer, replacing whatever they answered
	// before and reviving an answer they had cleared.
	//
	// raw is checked against the definition: of its kind, and in its
	// enumeration where there is one. A value the setting does not admit is
	// ErrMalformedValue or ErrNotEnumerated rather than a row nothing can read
	// back.
	SetValue(ctx context.Context, scope tenancy.Scope, subject Subject, name, raw string) (*Value, error)

	// GetValue reads the answer a subject stored, or ErrValueNotFound when they
	// have not answered. It is the raw row; Resolve is what applies the
	// default.
	GetValue(ctx context.Context, scope tenancy.Scope, subject Subject, name string) (*Value, error)

	// ClearValue takes a subject's answer back, leaving them on the
	// definition's default.
	ClearValue(ctx context.Context, scope tenancy.Scope, subject Subject, name string) error

	// ListValuesForSubject pages everything one subject has answered.
	ListValuesForSubject(ctx context.Context, scope tenancy.Scope, subject Subject, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Value], error)

	// ListValuesForDefinition pages everyone who has answered one setting. It is
	// the administrative read behind "who has overridden this", and the walk
	// UpdateDefinition runs before it changes a kind or an enumeration.
	ListValuesForDefinition(ctx context.Context, scope tenancy.Scope, name string, filter *filtering.QueryFilter) (*filtering.QueryFilteredResult[Value], error)

	// Resolve answers one setting for one subject: their value, else the
	// definition's default, else neither.
	//
	// The third case is a resolution rather than an error — the setting exists
	// and has not been answered — and reading it as a typed value reports
	// ErrSettingUnset. A setting that does not exist at all is
	// ErrDefinitionNotFound.
	Resolve(ctx context.Context, scope tenancy.Scope, subject Subject, name string) (*Resolution, error)

	// ResolveAll answers every live setting in the scope for one subject, sorted
	// by name.
	//
	// It is the read a settings page makes, and it is one pass over the
	// catalog and one over the subject's answers rather than one resolution
	// per setting. Settings the subject has not answered are in the result, at
	// their default or as [SourceUnset]: a page rendering "your preferences"
	// wants the ones nobody has touched too.
	ResolveAll(ctx context.Context, scope tenancy.Scope, subject Subject) ([]*Resolution, error)
}

ValueStore is the request path: what one subject answered, and what a setting resolves to for them.

Every method takes the setting's name rather than a definition id, because the name is what application code holds — and the definition read that name costs is the read that validates the write anyway, so nothing is saved by making a caller do it first.

Directories

Path Synopsis
Package settingscfg assembles a settings Store from environment configuration.
Package settingscfg assembles a settings Store from environment configuration.
internal
queries
Package queries is the settings schema described as data: the canonical table names, each table's columns in the order every read projects them, and the two subsets a write may assign.
Package queries is the settings schema described as data: the canonical table names, each table's columns in the order every read projects them, and the two subsets a write may assign.
queriesgen command
Command queriesgen writes the canonical sqlc input for the settings schema, one file per dialect, from settings/internal/queries.
Command queriesgen writes the canonical sqlc input for the settings schema, one file per dialect, from settings/internal/queries.
Package migrations supplies the settings tables' DDL, rendered for a dialect and table prefix.
Package migrations supplies the settings tables' DDL, rendered for a dialect and table prefix.
Package settingsmock provides moq-generated mock implementations of interfaces in the settings package.
Package settingsmock provides moq-generated mock implementations of interfaces in the settings package.

Jump to

Keyboard shortcuts

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