store

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 6, 2026 License: AGPL-3.0 Imports: 8 Imported by: 0

Documentation

Overview

Package store defines the persistence boundary of Wegweiser.

It is the only place that knows SQL exists (architecture invariant 3). Everything above it works in terms of zone.Zone, zone.Record and journal.Commit, and the boundary is enforced mechanically: depguard refuses an import of database/sql or of a driver anywhere outside this package tree.

Implementations live in subpackages: sqlite today, postgres later. Callers hold the interface, never the implementation, so a backend swap is a wiring change in cmd/weg and nothing else. Where backends genuinely differ, the difference is reported by Capabilities rather than discovered by a type assertion.

The store is the source of truth. The in-memory snapshot the query path serves from is a derived cache that can always be rebuilt from here, and never the other way around (architecture invariant 8).

Index

Constants

View Source
const (
	// DefaultLimit is the page size used when a filter names none.
	DefaultLimit = 100
	// MaxLimit is the largest page any listing returns.
	MaxLimit = 1000
)

Page size bounds. A caller that asks for nothing gets DefaultLimit; one that asks for more than MaxLimit gets MaxLimit, because a page size is client input and an unbounded one is a way to ask the server to allocate until it dies.

Variables

View Source
var (
	// ErrNotFound means the requested object does not exist. It is also what a
	// lookup of a revoked or expired token returns, so that a caller cannot
	// tell those apart from an unknown one.
	ErrNotFound = errors.New("store: not found")

	// ErrConflict means the write collided with something already stored: a
	// duplicate resource record within an RRset (RFC 2181 §5), a zone name
	// already in use, or a serial the journal already holds a commit for.
	ErrConflict = errors.New("store: conflict")

	// ErrSchemaTooNew means the database was written by a newer build than this
	// one. Continuing would let an older binary write rows the newer schema
	// expects to be shaped differently, so the store refuses to open instead.
	ErrSchemaTooNew = errors.New("store: schema newer than this build")

	// ErrClosed means the store has been closed.
	ErrClosed = errors.New("store: closed")
)

The errors every implementation reports, so that no caller ever has to recognise a driver error. An implementation wraps these with what was being looked for; a caller tests with errors.Is.

Functions

This section is empty.

Types

type Backend

type Backend string

Backend names a persistence implementation.

const (
	// BackendSQLite is the embedded default, one file, no server.
	BackendSQLite Backend = "sqlite"
	// BackendPostgres is the optional backend for large deployments.
	BackendPostgres Backend = "postgres"
)

type Capabilities

type Capabilities struct {
	Backend Backend

	// ConcurrentWriters is false for SQLite, which serializes writers itself.
	// It tells the applier whether its per-zone lock orders writes or merely
	// sits in front of the database's own ordering.
	ConcurrentWriters bool

	// ListenNotify is true when the backend can push change notifications
	// instead of being polled. False for SQLite.
	ListenNotify bool
}

Capabilities reports what a backend can do beyond the common interface, so a caller can adapt without asserting on the concrete type.

type CommitFilter

type CommitFilter struct {
	Paging

	ZoneID zone.ZoneID
	Kinds  []journal.Kind
	// Sources selects by what caused the change. It is what tells a change
	// somebody made from the reverse entries the server then kept in step
	// with it, which carry [journal.SourceSystem].
	Sources []journal.Source
	// Actor matches the recorded actor exactly.
	Actor string
	// Since and Until bound the commit time, Since inclusive and Until
	// exclusive.
	Since time.Time
	Until time.Time
}

CommitFilter selects journal commits. A zero field is not a constraint.

type Cursor

type Cursor string

Cursor marks a position in a listing. Its contents are the implementation's business: a caller passes back what it was given and nothing else.

type Page

type Page[T any] struct {
	Items []T
	// NextCursor is empty once the listing is exhausted.
	NextCursor Cursor
}

Page is one slice of a larger result set.

type Paging

type Paging struct {
	Cursor Cursor
	Limit  int
}

Paging is the cursor and page size shared by every listing.

func (Paging) EffectiveLimit

func (p Paging) EffectiveLimit() int

EffectiveLimit returns the page size to use: DefaultLimit for a filter that names none, and never more than MaxLimit.

type Reader

type Reader interface {
	// ZoneByID returns one zone.
	ZoneByID(ctx context.Context, id zone.ZoneID) (*zone.Zone, error)

	// ZoneByName returns the zone whose apex is exactly name. It does not walk
	// up looking for an enclosing zone: which zone answers a query is decided
	// against the snapshot, never the database (invariant 2).
	ZoneByName(ctx context.Context, name zone.Name) (*zone.Zone, error)

	// ListZones returns one page of zones in canonical name order.
	ListZones(ctx context.Context, f ZoneFilter) (Page[*zone.Zone], error)

	// IterZones streams every zone, disabled ones included, in canonical name
	// order. It exists for the snapshot rebuild, which wants each zone once and
	// has no reason to carry a cursor.
	IterZones(ctx context.Context) iter.Seq2[*zone.Zone, error]

	// ReverseZoneFor returns the most specific reverse zone covering addr,
	// which is where an address record's PTR belongs. Longest prefix wins, so a
	// classless RFC 2317 child beats the /24 delegating to it.
	ReverseZoneFor(ctx context.Context, addr netip.Addr) (*zone.Zone, error)

	// RecordByID returns one record.
	RecordByID(ctx context.Context, id zone.RecordID) (*zone.Record, error)

	// ListRecords returns one page of records in canonical order.
	ListRecords(ctx context.Context, f RecordFilter) (Page[*zone.Record], error)

	// IterZoneRecords streams every record of a zone, disabled ones included,
	// in canonical order. It must not materialize the zone: a large one holds
	// millions of records.
	IterZoneRecords(ctx context.Context, id zone.ZoneID) iter.Seq2[*zone.Record, error]

	// RecordsByAddress returns every A and AAAA record pointing at addr, across
	// all zones. Reverse automation asks before generating a second PTR.
	RecordsByAddress(ctx context.Context, addr netip.Addr) ([]*zone.Record, error)

	// ManagedBy returns the records generated from the given source record.
	ManagedBy(ctx context.Context, id zone.RecordID) ([]*zone.Record, error)

	// ManagedByZone streams the records generated from any record of a zone and
	// living somewhere else, in canonical order. Deleting a zone takes them
	// with it, written out rather than cascaded, because the removals belong to
	// the journal of the zone they are in.
	ManagedByZone(ctx context.Context, id zone.ZoneID) iter.Seq2[*zone.Record, error]

	// CommitByID returns one journal commit with its events. Not named Commit:
	// this interface is embedded in [Tx], where that would read as the method
	// ending the transaction.
	CommitByID(ctx context.Context, id journal.CommitID) (*journal.Commit, error)

	// ListCommits returns one page of commit metadata, newest first and without
	// events. The events of one commit are fetched by identifier.
	ListCommits(ctx context.Context, f CommitFilter) (Page[*journal.Commit], error)

	// TokenByHash resolves an API token by the SHA-256 of its secret. Unknown,
	// revoked and expired tokens all return [ErrNotFound], so a caller cannot
	// tell which of the three it hit.
	TokenByHash(ctx context.Context, hash []byte) (*Token, error)

	// TSIGKeyByName resolves the key a transfer request names. A revoked key
	// is not found: its secret is gone and nothing can be verified with it.
	TSIGKeyByName(ctx context.Context, name zone.Name) (*TSIGKey, error)

	// TSIGKeyByID returns one key, revoked or not, which is how a client asks
	// to see a secret it has already been shown once.
	TSIGKeyByID(ctx context.Context, id TSIGKeyID) (*TSIGKey, error)

	// ListTSIGKeys returns every key, revoked ones included, so the interface
	// can show what a secondary used to sign with. A revoked key carries no
	// secret.
	ListTSIGKeys(ctx context.Context) ([]*TSIGKey, error)

	// ListTokens returns every token, including revoked and expired ones.
	// Secrets are not stored and cannot be returned.
	ListTokens(ctx context.Context) ([]*Token, error)

	// Setting returns one JSON-encoded setting value.
	Setting(ctx context.Context, key string) ([]byte, error)

	// AppliedIndex returns how far into the replicated log this node has got,
	// and zero when nothing replicated has reached it. Zero is not a position a
	// log entry can occupy, so it needs no error of its own.
	AppliedIndex(ctx context.Context) (uint64, error)

	// MemberID returns the identifier this node is a member of a cluster by,
	// and the empty string when it has never been given one. Like the applied
	// index it is node-local: no log snapshot carries it and no restore
	// replaces it (docs/decisions/d42-membership-lives-in-the-log.md).
	MemberID(ctx context.Context) (string, error)

	// ExportReplicated streams everything a cluster replicates, which is what a
	// log snapshot is made of
	// (docs/decisions/d30-what-a-log-snapshot-contains.md). Call it inside
	// [Store.View] to have the stream describe one state.
	//
	// The items arrive grouped by kind, settings first and then tokens, keys,
	// zones, records and commits, so that a restore meets a zone before the
	// records in it. Within the records that is as far as the ordering goes: a
	// generated record points at the record it was derived from, which may
	// arrive after it, so a backend that enforces the reference has to hold the
	// check until the whole stream is in.
	ExportReplicated(ctx context.Context) iter.Seq2[*Replicated, error]
}

Reader is the read-only surface of the store, satisfied by both Store and Tx so a helper written against it works inside or outside a transaction.

A lookup that finds nothing returns ErrNotFound, never a nil result. The streaming methods yield each item with a nil error; on failure they yield a nil item with the error and stop, so the error must be tested on every step.

type RecordFilter

type RecordFilter struct {
	Paging

	ZoneID zone.ZoneID
	// Name matches one owner name exactly.
	Name zone.Name
	// Under matches an owner name at or below this name, which is how the GUI
	// expands one branch of the name tree.
	Under zone.Name
	Types []zone.RRType
	// Prefix selects the address records pointing into one network. It is how
	// a reverse zone finds the records it should be answering for.
	Prefix netip.Prefix
	// Search matches anywhere in the owner name or the record data,
	// case-insensitively.
	Search string
	// Managed selects only generated records, or only authored ones.
	Managed *bool
}

RecordFilter selects records. A zero field is not a constraint.

type Replicated added in v0.5.0

type Replicated struct {
	Kind ReplicatedKind

	Setting *Setting
	Token   *Token
	TSIGKey *TSIGKey
	Zone    *zone.Zone
	Record  *zone.Record
	Commit  *journal.Commit
}

Replicated is one item of the content a cluster replicates. Exactly one of the fields below is set, and Kind says which.

The list is closed, and closing it is the point: docs/decisions/d30-what-a-log-snapshot-contains.md says anything left out of a log snapshot is data a joining node silently does not have, and docs/decisions/d32-what-else-the-cluster-replicates.md widened the list to the tokens, keys and settings that reach other nodes in a batch.

What is deliberately absent is node-local: how far this node has got through the log, the identifier it is a member by, the browser sessions it is holding, and when a token was last used *here*. That last one is the only node-local field living on a replicated row, and it is zero on a token that travels.

func (*Replicated) Validate added in v0.5.0

func (r *Replicated) Validate() error

Validate reports whether the item carries exactly what its kind promises. It says nothing about whether the item itself is well formed, which is the business of whoever stores it.

type ReplicatedKind added in v0.5.0

type ReplicatedKind string

ReplicatedKind says which field of a Replicated carries the item.

const (
	// ReplicatedSetting is one server setting.
	ReplicatedSetting ReplicatedKind = "setting"
	// ReplicatedToken is one API token, without its secret, which this server
	// never held.
	ReplicatedToken ReplicatedKind = "token"
	// ReplicatedTSIGKey is one transfer key, with the secret it signs by.
	ReplicatedTSIGKey ReplicatedKind = "tsig_key"
	// ReplicatedZone is one zone, without the records in it.
	ReplicatedZone ReplicatedKind = "zone"
	// ReplicatedRecord is one record.
	ReplicatedRecord ReplicatedKind = "record"
	// ReplicatedCommit is one journal commit, with its events.
	ReplicatedCommit ReplicatedKind = "commit"
)

type Setting added in v0.5.0

type Setting struct {
	Key string
	// Value is JSON, which is what the column holds and what every reader of a
	// setting unmarshals.
	Value []byte
}

Setting is one server setting together with the key it is stored under. Reading one by name answers with the value alone, a caller who names a key already holding the key; streaming them all has to carry both.

type Store

type Store interface {
	Reader

	// Update runs fn inside one write transaction, committing if fn returns nil
	// and rolling back otherwise. Writers are serialized: SQLite by
	// construction, Postgres by asking.
	//
	// The Tx must not outlive fn or be used from another goroutine.
	Update(ctx context.Context, fn func(tx Tx) error) error

	// View runs fn inside one read transaction, for a caller that needs several
	// reads to see the same state.
	View(ctx context.Context, fn func(r Reader) error) error

	// ReplaceReplicated throws away every replicated row this store holds and
	// rebuilds it from in, in one transaction, together with the log index the
	// content is current as of.
	//
	// It is the only operation here that destroys data it was not asked about,
	// and it is meant to: restoring a log snapshot replaces what a node holds
	// rather than merging into it. What survives is what is node-local, minus
	// the one node-local field that lives on a replicated row, a token's last
	// use, which is rebuilt as never used.
	//
	// The index is written in the same transaction as the content, for the
	// reason a batch's is (D24): the two disagreeing after a power cut is a
	// node that quietly holds a state it does not know the age of.
	ReplaceReplicated(ctx context.Context, index uint64, in iter.Seq2[*Replicated, error]) error

	// Migrate brings the schema up to what this build expects. It returns
	// [ErrSchemaTooNew] if the database was written by a newer build.
	Migrate(ctx context.Context) error

	// Ping checks that the backend is reachable. Nothing on the serving path
	// asks it: /healthz reports whether there is a snapshot to answer queries
	// from, which is a question about the data plane rather than the database.
	Ping(ctx context.Context) error

	// Capabilities reports optional backend features.
	Capabilities() Capabilities

	// Close releases the backend. Transactions still running are rolled back.
	Close() error
}

Store is a persistence backend.

type TSIGKey added in v0.2.0

type TSIGKey struct {
	ID TSIGKeyID

	// Name is the key name both ends agree on, in domain name syntax
	// (RFC 8945 §4.2). It is compared case-insensitively (§9), which is what
	// [zone.Name] does anyway.
	Name zone.Name

	Algorithm zone.TSIGAlgorithm

	// Secret is the shared secret, held as it is used rather than as a hash of
	// itself. Verifying a signature means recomputing the MAC, so there is no
	// arrangement in which this database holds a usable key and cannot hand it
	// over; docs/decisions/d28-tsig.md says what follows for the file.
	//
	// It is empty for a revoked key, whose secret is cleared rather than kept.
	Secret []byte

	CreatedAt time.Time
	// RevokedAt is zero for a key that still signs.
	RevokedAt time.Time
}

TSIGKey is a secret shared with a secondary, which signs its transfer requests with it and verifies what comes back (RFC 8945).

It lives here for the same reason Token does: what interprets it is spread across packages that may not import one another, and a package holding one struct buys nothing.

func (TSIGKey) Active added in v0.2.0

func (k TSIGKey) Active() bool

Active reports whether the key may still sign or verify.

type TSIGKeyID added in v0.2.0

type TSIGKeyID string

TSIGKeyID identifies a TSIG key.

type Token

type Token struct {
	ID   TokenID
	Name string

	// Prefix is the leading characters of the secret, kept so the UI and the
	// audit log can tell two tokens apart without holding either.
	Prefix string
	// Hash is the SHA-256 of the secret. Not a slow password hash: a token is
	// 256 bits from crypto/rand and is not brute-forceable whatever the hash
	// costs, so a slow one would only rate-limit our own request path. See
	// data model §4.7.
	Hash []byte

	// Scopes are the permissions granted. The vocabulary belongs to the API,
	// which is the only thing that interprets them.
	Scopes []string

	CreatedAt time.Time
	// LastUsedAt is zero for a token that has never authenticated a request.
	// It is written advisorily and may lag.
	LastUsedAt time.Time
	// ExpiresAt is zero for a token that does not expire.
	ExpiresAt time.Time
	// RevokedAt is zero for a token that is still valid.
	RevokedAt time.Time
}

Token is an API credential.

It lives in this package rather than beside the authentication code because the authentication code lives in internal/api, and nothing may import that (architecture invariant 1). The alternative, a package holding one struct, buys nothing.

func (Token) Active

func (t Token) Active(at time.Time) bool

Active reports whether the token may authenticate a request at the given time. A caller still has to check the scopes.

type TokenID

type TokenID string

TokenID identifies an API token.

type Tx

type Tx interface {
	Reader
	Writer
}

Tx is a read-write transaction. Reads inside it see its own uncommitted writes.

type Writer

type Writer interface {
	// CreateZone stores a new zone. It returns [ErrConflict] if the apex is
	// already taken.
	CreateZone(ctx context.Context, z *zone.Zone) error

	// UpdateZone replaces a zone's settings. It does not touch its records.
	UpdateZone(ctx context.Context, z *zone.Zone) error

	// DeleteZone removes a zone and its records. Its journal stays, which the
	// schema explains where journal_commits is defined: a commit outlives the
	// zone it describes, or deleting one would erase the record of who did it.
	DeleteZone(ctx context.Context, id zone.ZoneID) error

	// SetZoneSerial advances a zone serial. Separate from UpdateZone because
	// the serial belongs to the journal, not to whoever is editing the zone.
	SetZoneSerial(ctx context.Context, id zone.ZoneID, serial zone.Serial) error

	// InsertRecord stores a new record. It returns [ErrConflict] if an
	// identical resource record is already in the same RRset (RFC 2181 §5).
	InsertRecord(ctx context.Context, r *zone.Record) error

	// UpdateRecord replaces a record in place, keeping its identity so its
	// comment, history and generated PTR stay attached.
	UpdateRecord(ctx context.Context, r *zone.Record) error

	// DeleteRecord removes one record, and with it anything generated from it.
	DeleteRecord(ctx context.Context, id zone.RecordID) error

	// DeleteRRset removes every record of one owner name, class and type. The
	// RRset is the unit DNS operates on (RFC 2181 §5), and enumerating members
	// first would race with whoever adds one in between.
	DeleteRRset(ctx context.Context, id zone.ZoneID, key zone.RRsetKey) error

	// AppendCommit stores a commit and its events. It returns [ErrConflict] if
	// the zone already has a commit producing that serial, which is the last
	// line of defence for one commit per serial step.
	AppendCommit(ctx context.Context, c *journal.Commit) error

	// CreateToken stores a new API token.
	CreateToken(ctx context.Context, t *Token) error

	// CreateTSIGKey stores a new key.
	CreateTSIGKey(ctx context.Context, k *TSIGKey) error

	// RevokeTSIGKey marks a key unusable and clears its secret. The row stays
	// so the name and the dates can still be read; the material does not,
	// because nothing would read it again.
	RevokeTSIGKey(ctx context.Context, id TSIGKeyID, at time.Time) error

	// RevokeToken marks a token unusable. Tokens are not deleted, so the audit
	// log keeps naming the token behind each change.
	RevokeToken(ctx context.Context, id TokenID, at time.Time) error

	// TouchToken records that a token authenticated a request. It is advisory:
	// an implementation may batch or drop these writes, and no caller may
	// depend on the value it reads back.
	TouchToken(ctx context.Context, id TokenID, at time.Time) error

	// PutSetting stores a JSON-encoded setting value, replacing any previous
	// one.
	PutSetting(ctx context.Context, key string, value []byte) error

	// SetAppliedIndex records how far into the replicated log this node has
	// got. It belongs in the same transaction as the batch that log entry
	// carried, and means nothing written on its own.
	//
	// The index only moves forward, and whoever applies the log is what keeps
	// it that way: the store records what it is told.
	SetAppliedIndex(ctx context.Context, index uint64) error

	// SetMemberID records the identifier this node is a member of a cluster
	// by. It is written once: setting the one already held again changes
	// nothing, and setting another returns [ErrConflict], because a member
	// whose identifier changed would graft one member's history onto another's.
	SetMemberID(ctx context.Context, id string) error
}

Writer is the mutating surface, reachable only through Store.Update, so nothing writes outside a transaction. Methods taking a pointer write back the timestamps the store owns.

type ZoneFilter

type ZoneFilter struct {
	Paging

	// Kind restricts the listing to forward or to reverse zones.
	Kind zone.Kind
	// Name matches one apex exactly. It is what a client resolves a name a
	// person typed into the zone it belongs to, which every command taking a
	// zone name has to do before it can do anything else. Search cannot serve
	// that: "example.com" also matches "notexample.com" and "example.com.au".
	Name zone.Name
	// Search matches anywhere in the zone name, case-insensitively. It backs
	// the instant filter above the zone list.
	Search string
	// Disabled selects only disabled or only enabled zones.
	Disabled *bool
}

ZoneFilter selects zones. A zero field is not a constraint.

Directories

Path Synopsis
Package sqlite implements the Wegweiser store on SQLite.
Package sqlite implements the Wegweiser store on SQLite.
Package storetest is the conformance suite every store.Store implementation has to pass.
Package storetest is the conformance suite every store.Store implementation has to pass.

Jump to

Keyboard shortcuts

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