storage

package
v1.0.0-rc.1 Latest Latest
Warning

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

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

Documentation

Overview

Package storage is TableX's own metadata database: the small amount of state the application itself needs to outlive a process, kept in a database the operator configures. It is deliberately separate from the databases a user administers — everywhere else TableX acts only as the logged-in user, whereas here it writes on its own behalf.

It is entirely optional. With no storage block configured, TableX behaves exactly as it always has: sessions live in process memory and are gone on restart. Nothing here is on the path of a request until an operator turns it on, so the zero-configuration single-binary story is unchanged.

What is kept here, and what never is

Kept: the session envelope — its id, its CSRF token and its two timestamps (see internal/session).

Never kept: a database credential, or anything derived from one. The password a user types at login lives only in that session's in-memory payload and is dropped as soon as the session ends (docs/security.md §2). Persisting it — even encrypted, since the key would have to be readable by the same process — would turn one read of this database into a compromise of every server TableX can reach. There is therefore no column for it, and the durable session store keeps authenticated sessions node-local as a direct consequence.

That said, a session id IS a bearer credential: anything that can read this database can impersonate every live session in it, exactly as with any other server-side session store. Treat the storage database as being as sensitive as the cookies it stands in for.

Portability

The same schema has to work on every engine implementing driver.StorageHost, so everything here stays inside the portable intersection: CREATE TABLE IF NOT EXISTS, positional placeholders, and the three column types a dialect spells for us (driver.StorageDDL). Instants are stored as Unix microseconds rather than a date type, so no engine's time-zone handling can reach the data.

Index

Constants

View Source
const DefaultTablePrefix = "tablex_"

DefaultTablePrefix is used when the operator sets none.

View Source
const (
	SessionsTable = "sessions"
)

Table names, without the operator's prefix.

Variables

This section is empty.

Functions

func FromMicros

func FromMicros(us int64) time.Time

FromMicros is the inverse of Micros.

func Micros

func Micros(t time.Time) int64

Micros converts an instant to the integer representation the schema uses: Unix microseconds, UTC. Microseconds rather than nanoseconds because MySQL's own temporal precision stops there, and a value that survives a round trip through a DATETIME(6) column keeps this comparable if the schema ever changes.

func ValidTablePrefix

func ValidTablePrefix(prefix string) (string, error)

ValidTablePrefix checks a configured table prefix and returns the value to use (DefaultTablePrefix when empty).

The prefix is concatenated into DDL, so it is validated rather than escaped: quoting would let a prefix of `"; DROP …` through as a legal — if absurd — identifier, and there is no reason to accept one. The accepted shape is the portable intersection of an unquoted identifier on every supported engine.

Types

type Config

type Config struct {
	Engine   string
	Host     string
	Port     int
	Socket   string
	User     string
	Password string
	Database string
	FilePath string
	SSLMode  string
	Params   map[string]string

	// TablePrefix is prepended to every table name so the metadata tables can
	// share a database with something else without colliding. It goes into DDL,
	// so it is validated as an identifier prefix before it is ever used.
	TablePrefix string
}

Config is the operator's storage configuration. Engine empty means "no metadata database" — the default.

Unlike a user's login, these credentials are admin-defined and read from the operator's own config file or environment, which is the same exception predefined servers already carry (docs/security.md §2).

func (Config) Enabled

func (c Config) Enabled() bool

Enabled reports whether a metadata database is configured.

type SessionStore

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

SessionStore is a session.Store that keeps each session's ENVELOPE in the metadata database, so a session outlives the process that created it and is visible to every replica behind a load balancer.

What becomes durable, and what cannot

The envelope is the id, the CSRF token and the two timestamps. The PAYLOAD — live connection pools and the password the user typed — is not stored and cannot be; this package's comment says why. What follows from that:

  • A PRE-AUTH session works completely across replicas, which is the case that actually mattered. Without a shared store, a login form rendered by replica A carries a CSRF token replica B has never issued, so behind a round-robin load balancer every login fails. This fixes that.
  • An AUTHENTICATED session is node-bound. Another replica finds the row, accepts the id and the token, finds no payload behind it, and shows the login page. The user logs in again and is bound to that replica. Sticky sessions avoid the extra login; nothing here depends on them for CORRECTNESS, only for convenience.
  • A restart is a logout in the same limited sense: the rows survive, the pools do not.

The honest summary is that this makes session identity durable and shared, not the connections. Making those shared would require persisting the credential, which is the one thing TableX does not do.

Errors degrade; answers are obeyed

A metadata database that has gone away must not log out every user, so a storage ERROR falls back to this node's own view — which is precisely TableX's default configuration, the in-memory store, and never anything weaker. A storage ANSWER is authoritative: "no such row" means the session is over (logged out, reaped, or expired somewhere else) and is honoured as such.

While degraded, a logout performed on another replica is not observed until storage returns. Both timeouts keep applying throughout, because they are evaluated against the envelope this node already holds.

func NewSessionStore

func NewSessionStore(st *Store, cfg SessionStoreConfig) *SessionStore

NewSessionStore builds a durable session store over an open metadata database.

func (*SessionStore) Degradations

func (s *SessionStore) Degradations() int64

Degradations reports how many operations have fallen back to this process's own view since startup — the number that says session durability is not currently being delivered. A nil receiver reads as zero, so a caller holding a session.Store it did not build can ask without knowing which kind it has.

func (*SessionStore) Delete

func (s *SessionStore) Delete(id string)

Delete ends a session everywhere.

func (*SessionStore) Detach

func (s *SessionStore) Detach() []*session.Session

Detach releases every session this process holds without ending any of them for other replicas (session.Detacher). It is what Manager.Shutdown wants: the rows stay, so a rolling restart does not sign the cluster out.

func (*SessionStore) Get

func (s *SessionStore) Get(id string) (*session.Session, bool)

Get resolves a session id. The metadata row is authoritative for EXISTENCE; the payload, when there is one, only ever lives in the local view.

func (*SessionStore) Len

func (s *SessionStore) Len() int

Len reports how many sessions THIS PROCESS holds — the same meaning the in-memory store's Len has, which is what makes Manager.ActiveSessions a reliable "every pool is closed" signal in tests. It is not a cluster total.

func (*SessionStore) Reap

func (s *SessionStore) Reap(expired func(*session.Session) bool) []*session.Session

Reap removes finished sessions and returns the ones this process was holding, so their pools can be closed. It sweeps in two halves.

LOCAL: a session held here is finished when the policy says so, or when its row has gone — which is how a logout or an expiry performed on ANOTHER replica releases the pools this node is still holding. That second condition is the whole reason this method reads the table rather than just filtering the local map.

SHARED: rows that no local session stands behind belong to another replica, or to a process that died without cleaning up. Nobody else will necessarily collect the second kind, so every node applies the same policy to them.

Note that a wholesale predicate here would delete every row in the cluster. That is exactly why Manager.Shutdown goes through Detach instead, and why session.Detacher exists at all.

func (*SessionStore) Replace

func (s *SessionStore) Replace(old, new *session.Session) bool

Replace implements the atomic login swap. The row DELETE is the membership check: it either removes the one row and reports 1, or reports 0 because something else got there first. That is the same guarantee the in-memory store's exact-pointer check provides, extended across every replica — two simultaneous logins on one pre-auth session resolve to exactly one winner wherever they land.

func (*SessionStore) Save

func (s *SessionStore) Save(sess *session.Session)

Save records a new session.

type SessionStoreConfig

type SessionStoreConfig struct {
	// IdleTimeout is the manager's idle policy. It is used for one thing: how
	// often a live session's stored last_seen is refreshed — often enough that
	// another replica's reaper does not mistake an active session for an idle
	// one, rarely enough that a page view is not an extra write.
	IdleTimeout time.Duration
	// Log receives the degradation warnings. A nil Log discards them.
	Log *slog.Logger
	// Now replaces the clock (tests). It must be safe for concurrent use.
	Now func() time.Time
}

SessionStoreConfig tunes the durable store.

type Store

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

Store is an open metadata database.

func Open

func Open(ctx context.Context, cfg Config) (*Store, error)

Open dials the configured metadata database, creates or verifies the schema, and returns a ready Store. It fails rather than degrading: every error here is a misconfiguration the operator has to see at startup.

func (*Store) Close

func (s *Store) Close() error

Close shuts the metadata pool down.

func (*Store) Col

func (s *Store) Col(name string) string

Col quotes a metadata column name. Every name in this schema is safe unquoted on every engine — which is why they are spelled "k"/"v" rather than "key"/"value" — but the project's rule is that an identifier reaching SQL goes through QuoteIdent, and a rule with exceptions is a rule nobody checks.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the pool. Callers build their own statements with Table and Placeholder; there is no query builder here because there are only a handful of statements and they are all in this package's callers.

func (*Store) Describe

func (s *Store) Describe() string

Describe renders a one-line, credential-free summary for the startup log.

func (*Store) Engine

func (s *Store) Engine() string

Engine reports the configured engine name ("sqlite", "postgres", …).

func (*Store) Ping

func (s *Store) Ping(ctx context.Context) error

Ping reports whether the metadata database is reachable right now.

It exists for /metrics, and only there. It is deliberately NOT wired into /healthz: that endpoint is unauthenticated so a container probe never needs a credential, and answering it from the metadata database would both leak internal state to anonymous callers and make a degraded store — which this package is built to survive — read as a dead process.

func (*Store) Placeholder

func (s *Store) Placeholder(n int) string

Placeholder renders the nth bind placeholder (1-based) for this engine.

func (*Store) SchemaVersion

func (s *Store) SchemaVersion() int

SchemaVersion reports the applied schema version.

func (*Store) Table

func (s *Store) Table(name string) string

Table returns the quoted, prefixed name of one metadata table. Metadata tables are unqualified: they live in whatever schema the configured connection defaults to, which is the one thing the operator controls directly and the only choice that is portable across engines with and without schemas.

Jump to

Keyboard shortcuts

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