flagsconfig

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package flagsconfig owns feature flags: the flag definitions themselves and their per-environment values. Unlike appsettings and localization, a flag value is environment-wide rather than per-service, so every consumer in an environment watches every flag in it.

The package is the fullest worked example of the shape all three config domains share — model, sentinel errors, a Postgres repository and its SQLite mirror, a service that validates and publishes write-through to KV, and a handler that maps each sentinel to a status. CONTRIBUTING.md points here as the pattern to copy.

Flag keys are validated on the way in because they become part of a KV key ("{envID}.{flagKey}"): a key containing a dot would be ambiguous to a consumer parsing it, and would be republished by every sweep thereafter.

Index

Constants

This section is empty.

Variables

View Source
var ErrConflict = errors.New("flag value was modified by someone else")

ErrConflict is returned when an update carried an expected UPDATED_AT that no longer matches the stored row: somebody else edited it first. Distinct so a handler can map it to HTTP 409.

View Source
var ErrEnvironmentNotFound = errors.New("environment not found")

ErrEnvironmentNotFound guards a flag value against pointing at an environment that does not exist — the referential integrity check that keeps a foreign-key violation from surfacing as a 500.

View Source
var ErrFlagExists = errors.New("flag with this key already exists")

Creation collides with the natural unique keys: FLAG_KEY on CONFIG_FLAG, and (ENVIRONMENT_ID, FLAG_ID) on CONFIG_FLAG_VALUE. Distinct so a handler can map them to HTTP 409.

View Source
var ErrFlagNotFound = errors.New("flag not found")
View Source
var ErrFlagValueExists = errors.New("flag value for this environment already exists")
View Source
var ErrFlagValueTooLarge = errors.New("flag value exceeds the maximum length")

ErrFlagValueTooLarge rejects a value longer than the VALUE column. Left to the column it arrives as an opaque driver error, which writeErr can only map to a 500 — a server fault for what is ordinary caller input.

View Source
var ErrInvalidEnabled = errors.New("enabled must be 0 or 1")

ENABLED and IS_ACTIVE are SMALLINT columns carrying a boolean the API spells as 1/0 (migrations/004 explains why the column is not BOOLEAN). The int/bool split is the contract, not a range: only the KV payload is a real boolean, and it collapses everything non-zero to true, so a stored 7 is a row the API reads back as 7 while consumers see the same true a 1 would have given them. The bound is the meaning rather than the column — 2 fits a SMALLINT and says nothing — and it keeps an out-of-range SMALLINT from surfacing as a 500.

View Source
var ErrInvalidEnvironmentID = errors.New("invalid environment id")
View Source
var ErrInvalidFlagID = errors.New("invalid flag id")
View Source
var ErrInvalidFlagKey = errors.New("invalid flag key")

ErrInvalidFlagKey rejects a key that cannot be part of a KV key. The FLAGS bucket key is "{environmentID}.{flagKey}", so a key containing a dot or a space would either be ambiguous or refused by JetStream at publish time.

View Source
var ErrInvalidFlagValue = errors.New("flag value must not be empty")

ErrInvalidFlagValue rejects an empty value. VALUE is NOT NULL in migrations/005, but PostgreSQL keeps an empty string and NULL apart, so the constraint closes only half the hole and this check is the other half — the migration says so in as many words. It matters because the value is republished to every consumer in the environment and read as a rollout percentage, a variant name or a threshold: "" arrives at the far end indistinguishable from a parse bug, so a caller who means "no value" has to write a real placeholder.

View Source
var ErrInvalidIsActive = errors.New("isActive must be 0 or 1")

Functions

This section is empty.

Types

type DeletedFlagValue

type DeletedFlagValue struct {
	EnvironmentID int64
	FlagKey       string
}

DeletedFlagValue carries just enough of a removed row to purge its KV key. Deleting a flag removes many of them at once.

type Flag

type Flag struct {
	ID        int64     `json:"id"`
	FlagKey   string    `json:"flagKey"`
	IsActive  int64     `json:"isActive"`
	UpdatedAt time.Time `json:"updatedAt"`
}

type FlagFilter

type FlagFilter struct {
	Page
	FlagKey string
}

FlagFilter narrows the flag list. An empty FlagKey matches every flag.

type FlagValue

type FlagValue struct {
	ID            int64     `json:"id"`
	EnvironmentID int64     `json:"environmentId"`
	FlagId        int64     `json:"flagId"`
	Value         string    `json:"value"`
	Enabled       int64     `json:"enabled"`
	UpdatedAt     time.Time `json:"updatedAt"`

	// ExpectedUpdatedAt opts an update into optimistic concurrency: the write
	// only applies while the stored row still carries this timestamp, so two
	// admins editing the same flag no longer silently overwrite each other.
	// Absent (nil) keeps the original last-write-wins behaviour.
	ExpectedUpdatedAt *time.Time `json:"expectedUpdatedAt,omitempty"`
}

type FlagValueFilter

type FlagValueFilter struct {
	Page
	EnvironmentID int64
	FlagKey       string
}

FlagValueFilter narrows the flag-value list. Zero values match everything.

type FlagValueRow

type FlagValueRow struct {
	ID            int64     `json:"id"`
	EnvironmentID int64     `json:"environmentId"`
	FlagId        int64     `json:"flagId"`
	FlagKey       string    `json:"flagKey"`
	Value         string    `json:"value"`
	Enabled       int64     `json:"enabled"`
	UpdatedAt     time.Time `json:"updatedAt"`
}

FlagValueRow is a flag value with its flag's key already resolved — without it a list caller cannot tell which flag a row belongs to without a second request per row.

type Handler

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

func NewHandler

func NewHandler(service *Service) *Handler

func (*Handler) CreateFlag

func (h *Handler) CreateFlag(w http.ResponseWriter, r *http.Request)

POST /flags

func (*Handler) CreateFlagValue

func (h *Handler) CreateFlagValue(w http.ResponseWriter, r *http.Request)

POST /flags/values

func (*Handler) DeleteFlag

func (h *Handler) DeleteFlag(w http.ResponseWriter, r *http.Request)

DELETE /flags/{id}

func (*Handler) DeleteFlagValue

func (h *Handler) DeleteFlagValue(w http.ResponseWriter, r *http.Request)

DELETE /flags/values/{id}

func (*Handler) GetFlagsByID

func (h *Handler) GetFlagsByID(w http.ResponseWriter, r *http.Request)

GET /flags/{id}

func (*Handler) GetFlagsValueByID

func (h *Handler) GetFlagsValueByID(w http.ResponseWriter, r *http.Request)

GET /flags/values/{id}

func (*Handler) ListFlagValues

func (h *Handler) ListFlagValues(w http.ResponseWriter, r *http.Request)

GET /flags/values?environmentId=&flagKey=&limit=&offset=

func (*Handler) ListFlags

func (h *Handler) ListFlags(w http.ResponseWriter, r *http.Request)

GET /flags?flagKey=&limit=&offset=

func (*Handler) UpdateFlagValue

func (h *Handler) UpdateFlagValue(w http.ResponseWriter, r *http.Request)

PUT /flags/values

type Page

type Page struct {
	Limit  int
	Offset int
}

Page bounds a list query. The service normalises it, so a repository can bind both values as given.

type PostgresRepository

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

func NewPostgresRepository

func NewPostgresRepository(db *sql.DB) *PostgresRepository

func (*PostgresRepository) CreateFlag

func (r *PostgresRepository) CreateFlag(ctx context.Context, input Flag) (*Flag, error)

func (*PostgresRepository) CreateFlagValue

func (r *PostgresRepository) CreateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)

func (*PostgresRepository) DeleteFlag

func (r *PostgresRepository) DeleteFlag(ctx context.Context, id int64) ([]DeletedFlagValue, error)

DeleteFlag drops the flag and its values in one transaction. Doing it in two statements outside a transaction would leave orphaned value rows behind if the second one failed.

func (*PostgresRepository) DeleteFlagValue

func (r *PostgresRepository) DeleteFlagValue(ctx context.Context, id int64) (*DeletedFlagValue, error)

func (*PostgresRepository) GetFlagValueByID

func (r *PostgresRepository) GetFlagValueByID(ctx context.Context, id int64) (*FlagValue, error)

func (*PostgresRepository) GetFlagsByID

func (r *PostgresRepository) GetFlagsByID(ctx context.Context, id int64) (*Flag, error)

func (*PostgresRepository) ListAllForReconcile

func (r *PostgresRepository) ListAllForReconcile(ctx context.Context, since time.Time) ([]ReconcileRow, error)

ListAllForReconcile returns flag values joined with their flag key. A zero `since` sweeps every row; otherwise only rows changed at or after `since`, which keeps the periodic reconcile from re-reading the whole table.

func (*PostgresRepository) ListFlagValues

func (r *PostgresRepository) ListFlagValues(ctx context.Context, filter FlagValueFilter) ([]FlagValueRow, error)

func (*PostgresRepository) ListFlags

func (r *PostgresRepository) ListFlags(ctx context.Context, filter FlagFilter) ([]Flag, error)

func (*PostgresRepository) UpdateFlagValue

func (r *PostgresRepository) UpdateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)

type ReconcileRow

type ReconcileRow struct {
	ID            int64
	EnvironmentID int64
	FlagKey       string
	Enabled       int64
	Value         string
}

ReconcileRow is a flat flag-value + its human-stable key, used to republish every flag to KV during reconciliation. ID is the CONFIG_FLAG_VALUE id — the value an admin update targets.

type Repository

type Repository interface {
	GetFlagsByID(ctx context.Context, id int64) (*Flag, error)
	GetFlagValueByID(ctx context.Context, id int64) (*FlagValue, error)
	// UpdateFlagValue returns the stored row plus its flag's human-stable key,
	// so the caller can publish without a second lookup.
	UpdateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)

	ListFlags(ctx context.Context, filter FlagFilter) ([]Flag, error)
	CreateFlag(ctx context.Context, input Flag) (*Flag, error)
	// DeleteFlag removes the flag and every value row hanging off it, returning
	// what went away so the caller can purge the matching KV keys.
	DeleteFlag(ctx context.Context, id int64) ([]DeletedFlagValue, error)

	ListFlagValues(ctx context.Context, filter FlagValueFilter) ([]FlagValueRow, error)
	// CreateFlagValue returns the stored row plus its flag key, on the same
	// terms as UpdateFlagValue.
	CreateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)
	DeleteFlagValue(ctx context.Context, id int64) (*DeletedFlagValue, error)
}

type SQLiteRepository

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

SQLiteRepository backs the local test stack (see internal/database/sqlite.go). Same tables and columns as PostgreSQL; only the bind syntax differs (? vs $1).

func NewSQLiteRepository

func NewSQLiteRepository(db *sql.DB) *SQLiteRepository

func (*SQLiteRepository) CreateFlag

func (r *SQLiteRepository) CreateFlag(ctx context.Context, input Flag) (*Flag, error)

func (*SQLiteRepository) CreateFlagValue

func (r *SQLiteRepository) CreateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)

func (*SQLiteRepository) DeleteFlag

func (r *SQLiteRepository) DeleteFlag(ctx context.Context, id int64) ([]DeletedFlagValue, error)

func (*SQLiteRepository) DeleteFlagValue

func (r *SQLiteRepository) DeleteFlagValue(ctx context.Context, id int64) (*DeletedFlagValue, error)

func (*SQLiteRepository) GetFlagValueByID

func (r *SQLiteRepository) GetFlagValueByID(ctx context.Context, id int64) (*FlagValue, error)

func (*SQLiteRepository) GetFlagsByID

func (r *SQLiteRepository) GetFlagsByID(ctx context.Context, id int64) (*Flag, error)

func (*SQLiteRepository) ListAllForReconcile

func (r *SQLiteRepository) ListAllForReconcile(ctx context.Context, since time.Time) ([]ReconcileRow, error)

func (*SQLiteRepository) ListFlagValues

func (r *SQLiteRepository) ListFlagValues(ctx context.Context, filter FlagValueFilter) ([]FlagValueRow, error)

func (*SQLiteRepository) ListFlags

func (r *SQLiteRepository) ListFlags(ctx context.Context, filter FlagFilter) ([]Flag, error)

func (*SQLiteRepository) UpdateFlagValue

func (r *SQLiteRepository) UpdateFlagValue(ctx context.Context, input FlagValue) (*FlagValue, string, error)

type Service

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

func NewService

func NewService(repo Repository, pub messaging.ConfigPublisher) *Service

func (*Service) CreateFlag

func (s *Service) CreateFlag(ctx context.Context, req Flag) (*Flag, error)

func (*Service) CreateFlagValue

func (s *Service) CreateFlagValue(ctx context.Context, req FlagValue) (*FlagValue, error)

func (*Service) DeleteFlag

func (s *Service) DeleteFlag(ctx context.Context, id int64) error

DeleteFlag removes the flag, its values, and the KV keys those values fed. Waiting for the next full reconcile to prune them would leave consumers serving a deleted flag for up to a whole interval.

func (*Service) DeleteFlagValue

func (s *Service) DeleteFlagValue(ctx context.Context, id int64) error

func (*Service) GetFlagByID

func (s *Service) GetFlagByID(ctx context.Context, id int64) (*Flag, error)

func (*Service) GetFlagValueByID

func (s *Service) GetFlagValueByID(ctx context.Context, id int64) (*FlagValue, error)

func (*Service) ListFlagValues

func (s *Service) ListFlagValues(ctx context.Context, filter FlagValueFilter) ([]FlagValueRow, error)

func (*Service) ListFlags

func (s *Service) ListFlags(ctx context.Context, filter FlagFilter) ([]Flag, error)

func (*Service) UpdateFlagValue

func (s *Service) UpdateFlagValue(ctx context.Context, req FlagValue) (*FlagValue, error)

Jump to

Keyboard shortcuts

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