storage

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

nexssp/storage

A universal adapter contract for pluggable storage backends, with a Flow bundle that makes every store available as compile-time-checked actions and middleware.

What this is

One Go module (github.com/nexssp/storage) with three layers:

  1. Adapter contract (backend.go, blob.go, errors.go) — Kind, Backend, Instance, Registry, Declaration.
  2. Reference memory backends (memory/) — 8 backends, one per Kind.
  3. Flow bundle (nexssflow/) — the @storage directive, two action forms (generic storage.* + aliased <alias>.*), the storage.events hook, the storage.probe action, and three middleware-wiring modifiers.

Database schema and persistent-data versioning follow the Nexss storage migration standard. The proposed SQL action surface and application-schema workflow are outlined in the database plan.

One @require loads everything:

@require github.com/nexssp/storage v0.1.0

The 8 Kinds

Kind Kernel interface Use case
kv action.CacheLayer[V] Typed values with TTL
blob storage.BlobStore Streamed file/document payloads addressed by typed xfile.Ref
lock action.Mutex Best-effort distributed lock
fenced_lock action.FencedMutex Monotonic-fence lock
idem action.IdempotencyStore (+Coordinator) Idempotent replay + claims
journal action.Journal Step memoization for action.Durable
tx action.TxRunner Relational / document transactions
rate_limit action.RateLimiter Distributed token-bucket

Four ways to use a declared store

1. Aliased actions (compile-time-checked, no store: argument)
@storage kv:memory as cache { ttl: "5m" }

# The alias "cache" becomes the action namespace.
# If you typo "cache" or forget the @storage line, nflow lint fails.
cache.kv.set @{ key: "user:1", value: "alice" }
cache.kv.get @{ key: "user:1" }
cache.kv.del @{ key: "user:1" }

Each @storage declaration materializes as a set of actions named <alias>.<kind>.<op>. The store name is baked into a closure — no runtime store: argument, no string lookup.

2. Generic actions (dynamic store selection)
@storage kv:memory as cache { }

# The generic form takes a store: argument. Useful when the store
# name is dynamic (e.g. selected by a routing expression).
storage.kv.get @{ store: "cache", key: "k" }
3. Middleware wiring (the practical path — bare identifiers)
@storage kv:memory as cache { ttl: "5m" }
@storage idem:memory as idem { ttl: "1h" }
@storage lock:memory as locks { }

# No quotes, no strings — bare identifiers, compile-time-checked.
# :cache_store=cache resolves "cache" to the @storage declaration at
# advisor time. If the store doesn't exist, compilation fails.
http.request:cache_store=cache:cache_ttl=30s @{ url: "..." }
payment.charge:idempotent_store=idem @{ amount: 100 }
api.call:dedup_store=locks @{ ... }

Three modifiers, all unquoted:

Modifier Wires Value
:cache_store=<name> + :cache_ttl=<duration> CacheMiddleware store name + TTL
:idempotent_store=<name> IdempotencyMiddleware store name
:dedup_store=<name> Dedup store name
4. Hook observation
@storage kv:memory as cache { }
@hook:storage.events

# Every cache.kv.get records cache_hit / cache_miss / error into the
# request context. storage.probe reads the accumulator.
cache.kv.get @{ key: "missing" }
-> storage.probe
# Result: { attached: true, hits: 0, misses: 1, errors: 0, events: [...] }

The storage.events hook is registered by nexssflow's init(). Attach it with @hook:storage.events. The storage.probe action returns a summary { hits, misses, errors, events } — assert on it in fixtures:

@assert: result.misses == 1
@assert: result.hits == 0

Architecture

Blob writes and opens use streams; lookups return compact typed references rather than bytes. References carry expected integrity metadata, while confirmed metadata is returned separately by Open. See Blob references and streams for API signatures, caller/tenant scope rules, and current adapter buffering limits.

.nflow source
    │
    │ @require github.com/nexssp/storage v0.1.0
    │ @storage kv:redis as cache { addr: "..." }
    │ cache.kv.set @{ key: "k", value: "v" }
    │ http.request:cache_store=cache:cache_ttl=30s @{ url: "..." }
    │ @hook:storage.events
    │
    ▼
nexssp/storage/nexssflow/         ← the Flow bundle
    ├── library.go                Bundle() registers everything
    ├── directive.go              @storage parser
    ├── materialize.go            mounts <alias>.<kind>.<op> actions
    ├── aliased_actions.go        the aliased action builders
    ├── actions.go                generic storage.* actions
    ├── hooks.go                  storage.events hook + accumulator
    ├── probe.go                  storage.probe action
    ├── advise.go                 :cache_store= / :idempotent_store= / :dedup_store=
    └── nflows/                   conformance + showcase fixtures
    │
    ▼
nexssp/storage                    ← the adapter contract
    ├── backend.go                Kind, Backend, Instance, Registry
    ├── blob.go                   streaming BlobStore + xfile references
    ├── errors.go
    └── memory/memory.go          8 reference backends
    │
    ▼
nexssp/kernel                     ← unchanged

Persistent adapters

The module includes these persistent backends alongside the reference memory implementations:

Package Backends Key options
sqlite kv, blob, tx path, optional namespace, KV ttl
postgres kv, blob, tx dsn, optional namespace, KV ttl
redis kv, blob, lock addr, optional username, password, db, namespace, KV ttl

The Flow bundle registers these backend names automatically. For pure-Go consumers, each package exposes Register(reg *storage.Registry). For example:

reg := storage.NewRegistry()
if err := sqlite.Register(reg); err != nil {
    panic(err)
}

SQLite/PostgreSQL SQL tables are not created by Open. Apply the versioned Goose migrations as an explicit deployment step first; see the migration standard. Each SQL package exposes its own embedded migration filesystem through Migrations(). Redis uses the default keyspace prefix nexss:storage:v1; choose a separate namespace when isolating applications.

For a copy-paste PostgreSQL example, see examples/postgres/: it includes a loopback-only Compose database with development defaults, the explicit Goose command, a runnable Go KV example, and a Flow sample. The demo password and sslmode=disable are local-only; production DSNs must come from trusted runtime configuration.

Documentation

Overview

Package storage defines the universal adapter contract for pluggable storage backends used by Nexss Flow and any other consumer of nexssp/kernel.

The contract is intentionally tiny: a Backend advertises a Name and a Kind, and produces an Instance from an options map. Every Instance satisfies one or more concrete Kernel interfaces (CacheLayer[V], Mutex, FencedMutex, IdempotencyStore, Journal, TxRunner, RateLimiter, or the streaming BlobStore defined in blob.go).

Typical use:

reg := storage.NewRegistry()
reg.MustRegister(memory.NewKV())
reg.MustRegister(memory.NewBlob())

decl := storage.Declaration{
    Name:    "kv:memory",
    Backend: "memory",
    Kind:    storage.KindKV,
    Options: map[string]string{"ttl": "5m"},
}
inst, err := decl.Open(reg)
// inst implements action.CacheLayer[any]

A Flow extension wires this Registry into a Bundle; see ../flow-storage-extension/library.go.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssertFencedMutex

func AssertFencedMutex(inst Instance) (action.FencedMutex, error)

AssertFencedMutex asserts that inst satisfies action.FencedMutex.

func AssertIdempotencyStore

func AssertIdempotencyStore(inst Instance) (action.IdempotencyStore, error)

AssertIdempotencyStore asserts that inst satisfies action.IdempotencyStore. Callers may additionally probe for IdempotencyCoordinator and IdempotencyLeaseRenewer via type assertion; see action.IdempotencyCoordinator.

func AssertJournal

func AssertJournal(inst Instance) (action.Journal, error)

AssertJournal asserts that inst satisfies action.Journal.

func AssertKV

func AssertKV(inst Instance) (action.CacheLayer[any], error)

AssertKV asserts that inst satisfies action.CacheLayer[any] and returns the typed handle. The Kind check is defensive: a well-formed Registry never hands back an Instance of the wrong Kind, but a consumer may have an Instance from another source.

func AssertMutex

func AssertMutex(inst Instance) (action.Mutex, error)

AssertMutex asserts that inst satisfies action.Mutex.

func AssertRateLimiter

func AssertRateLimiter(inst Instance) (action.RateLimiter, error)

AssertRateLimiter asserts that inst satisfies action.RateLimiter.

func AssertTxRunner

func AssertTxRunner(inst Instance) (action.TxRunner, error)

AssertTxRunner asserts that inst satisfies action.TxRunner.

func BadRequest

func BadRequest(msg string) error

BadRequest wraps xerr.BadRequest. Use for invalid user-supplied options (unknown key, malformed value, missing required field). msg is treated as a final message; pre-format with fmt.Sprintf if interpolation is needed.

func BadRequestf

func BadRequestf(format string, args ...any) error

BadRequestf is the printf-style variant.

func Conflict

func Conflict(msg string) error

Conflict wraps xerr.Conflict. Use for duplicate registrations, fence-token mismatches, idempotency-key payload conflicts.

func Conflictf

func Conflictf(format string, args ...any) error

Conflictf is the printf-style variant.

func Internal

func Internal(msg string) error

Internal wraps xerr.Internal. Use for programmer bugs that are not the user's fault: a Backend that does not implement its declared Kind's interface, a corrupted in-memory cache entry.

func Internalf

func Internalf(format string, args ...any) error

Internalf is the printf-style variant.

func NotFound

func NotFound(msg string) error

NotFound wraps xerr.NotFound. Use for missing declarations, missing backends, missing keys (when "missing" is not the expected hit path).

func NotFoundf

func NotFoundf(format string, args ...any) error

NotFoundf is the printf-style variant.

func ParseDuration

func ParseDuration(s string, def time.Duration) (time.Duration, error)

ParseDuration is a tiny helper that mirrors time.ParseDuration but returns a classified BadRequest on parse failure. Backends may use it to validate duration options consistently.

func TooManyRequests

func TooManyRequests(msg string) error

TooManyRequests wraps xerr.TooManyRequests. Use from RateLimiter backends when the underlying store returns its own rate-limit signal distinct from the token-bucket decision.

func Unavailable

func Unavailable(msg string, cause error) error

Unavailable wraps xerr.Unavailable. Use for infrastructure failures: connection refused, timeout, network partition, disk full. Always wrap the underlying cause.

func Unavailablef

func Unavailablef(cause error, format string, args ...any) error

Unavailablef is the printf-style variant.

Types

type Backend

type Backend interface {
	// Name is the adapter identifier, e.g. "memory", "redis", "s3".
	// Two backends with the same Name cannot coexist in one Registry.
	Name() string

	// Kind is the primary storage contract this Backend provides.
	// A Backend declares exactly one Kind; adapters that can serve
	// multiple Kinds (e.g., Redis can do both KV and Lock) register
	// separate Backend instances for each Kind.
	Kind() Kind

	// Open returns a fresh Instance bound to opts. opts keys are
	// backend-specific; common keys include "addr", "ttl",
	// "namespace", "bucket", "region". Unrecognized keys MUST be
	// rejected with xerr.BadRequest.
	Open(opts map[string]string) (Instance, error)
}

Backend is the contract every adapter implements. Open returns an Instance whose concrete type satisfies the Kernel interface for the declared Kind.

Open MUST be safe to call more than once with the same opts map. Callers may open fresh Instances per declaration, or share one Instance across declarations; the Registry caches by Declaration name regardless.

Open MUST validate opts and return a classified error (xerr.BadRequest for invalid user input, xerr.Unavailable for infrastructure failures) rather than panicking.

type BlobStore

type BlobStore interface {
	// Get looks up key and returns a small reference. A missing or inaccessible
	// key returns (zero, false, nil). The reference carries expected metadata
	// when the backend has it; it never contains the payload.
	Get(ctx context.Context, key string) (ref xfile.Ref, hit bool, err error)

	// Put streams src under key, replacing any existing object. It returns a
	// typed reference with integrity expectations for the bytes consumed.
	Put(ctx context.Context, key string, src io.Reader) (xfile.Ref, error)

	// Open resolves ref and returns a reader owned by the caller plus confirmed
	// metadata for the opened object. Expected metadata in ref is verified.
	Open(ctx context.Context, ref xfile.Ref) (io.ReadCloser, xfile.Info, error)

	// Delete removes key for the authenticated caller and tenant. A missing or
	// inaccessible key is not an error.
	Delete(ctx context.Context, key string) error
}

BlobStore stores opaque payloads without TTL semantics. Unlike a KV cache, BlobStore is intended for files, documents, snapshots, and other large data.

Put consumes src and returns an opaque, provider-scoped reference. Get is a metadata-only lookup: it returns a compact xfile.Ref, never the object bytes. Open streams an object identified by a reference and returns confirmed metadata separately from the caller-supplied expectations in xfile.Ref. Implementations scope operations to the authenticated caller and tenant in ctx; references are untrusted selectors, not authorization credentials.

func AssertBlob

func AssertBlob(inst Instance) (BlobStore, error)

AssertBlob asserts that inst satisfies BlobStore.

type Declaration

type Declaration struct {
	// Name is the unique identifier callers use to reference this
	// declaration. Convention: "<kind>:<backend>" (e.g. "kv:redis"),
	// but any unique non-empty string is allowed. The Flow @storage
	// directive uses "as <alias>" to override.
	Name string

	// Backend names the adapter to open. Must match a Backend in the
	// target Registry. Example: "memory", "redis", "s3".
	Backend string

	// Kind is the storage contract. Must match Backend.Kind() or
	// Declaration.Open returns a classified mismatch error.
	Kind Kind

	// Options are backend-specific connection details. Validated by
	// the Backend at Open time.
	Options map[string]string

	// Position carries source location for error attribution. Pure-Go
	// consumers leave it zero. Flow fills it from the directive.
	Position Position
}

Declaration is the parsed form of an @storage block. It binds a human-readable Name to a Backend and its connection options.

Flow parses declarations from source; pure-Go consumers construct them directly. Either way, Declaration.Open is the only way to obtain a live Instance from a Registry.

func (Declaration) Open

func (d Declaration) Open(reg *Registry) (Instance, error)

Open looks up the Backend in reg, validates the Kind match, and returns a fresh Instance.

type Instance

type Instance interface {
	// Kind echoes the Kind the Instance was opened under. Must match
	// the Backend that produced it.
	Kind() Kind

	// Close releases the Instance's resources. It is called exactly
	// once by the Registry when the host is shutting down. After
	// Close returns, the Instance must not be used.
	Close() error
}

Instance is a type-erased handle to an opened store. Callers assert to the specific Kernel interface matching the declared Kind at use time. This is the only reflection-like step in the system, and it happens once per declaration, never per call.

An Instance MAY satisfy multiple Kernel interfaces (e.g., a relational database backend might implement both TxRunner and Journal). The Kind only declares the primary contract; consumers may probe for additional capabilities with the standard Go type-assertion idiom:

if rl, ok := inst.(action.RateLimiter); ok { ... }

type Kind

type Kind string

Kind enumerates the storage contracts an adapter can implement. Each Kind maps 1:1 to a Kernel interface, so the type system guarantees the right method set is available after Instance assertion.

Add a new Kind only when a new Kernel storage interface appears. Do not add Kinds for non-storage contracts (logging, metrics, etc.).

const (
	// KindKV maps to action.CacheLayer[V]. A KV store serves typed
	// values keyed by string with per-entry TTL. Use this for caches,
	// session stores, and small structured records.
	KindKV Kind = "kv"

	// KindBlob maps to BlobStore (defined in blob.go). A Blob store
	// serves streamed objects addressed by typed xfile.Ref handles,
	// without TTL semantics. Use it for files, documents, snapshots,
	// and large payloads.
	KindBlob Kind = "blob"

	// KindLock maps to action.Mutex. A basic distributed lock with
	// TryLock/Unlock semantics. Use this for short critical sections
	// where stale writers are tolerable.
	KindLock Kind = "lock"

	// KindFencedLock maps to action.FencedMutex. A fenced lock returns
	// a monotonic LockLease that downstream systems can use to reject
	// stale writers. Use this for any storage backend that supports
	// fencing tokens (Redis ACL, etcd revision, Postgres advisory
	// locks with version columns).
	KindFencedLock Kind = "fenced_lock"

	// KindIdem maps to action.IdempotencyStore. The advanced
	// IdempotencyCoordinator and IdempotencyLeaseRenewer contracts are
	// optional capabilities an Instance may additionally satisfy; the
	// Flow extension probes for them at use time.
	KindIdem Kind = "idem"

	// KindJournal maps to action.Journal. Used by action.Durable for
	// step-level memoization so resumed executions skip already-run
	// steps. Use this for workflow checkpoint storage.
	KindJournal Kind = "journal"

	// KindTx maps to action.TxRunner. A TxRunner wraps a function in a
	// transaction boundary. Use this for relational databases and
	// document stores that expose atomic multi-op transactions.
	KindTx Kind = "tx"

	// KindRateLimit maps to action.RateLimiter. A distributed
	// token-bucket admission control. Use this when rate-limit state
	// must be shared across processes (Redis, etcd).
	KindRateLimit Kind = "rate_limit"
)

func AllKinds

func AllKinds() []Kind

AllKinds returns every defined Kind, sorted. Useful for diagnostics and CLI help output.

func (Kind) KernelInterface

func (k Kind) KernelInterface() string

KernelInterface returns the Kernel interface name an Instance of this Kind is expected to satisfy. Used in error messages and diagnostics; not used for runtime assertion.

type Position

type Position struct {
	File string
	Line int
}

Position is a minimal source-location record. It mirrors core.Position from flow/compiler but lives here so the storage package does not depend on flow.

func (Position) String

func (p Position) String() string

type Registry

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

Registry holds the available backends and tracks opened Instances for lifecycle management. It is a struct, not a package global, so two flows in the same process can have independent storage configurations. This addresses the anti-pattern flagged in flow/AGENTS.md §7 (currently exhibited by extensions/decide).

Backends are keyed by (Name, Kind) — a single adapter like Redis can serve both KV and Lock contracts by registering two Backend instances with Name="redis" but different Kind values. This keeps the user's mental model simple ("the redis backend" is one thing) while letting the type system distinguish which contract is in use.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry. Callers Register backends before any Declaration.Open call.

func (*Registry) Close

func (r *Registry) Close(ctx context.Context) error

Close releases every opened Instance. Errors are joined but never abort the loop; one failing Close does not leak the rest.

Close honors ctx for backends whose Close blocks on network I/O. Callers should pass a context with a deadline so a hung backend cannot stall host shutdown indefinitely.

func (*Registry) Lookup

func (r *Registry) Lookup(name string, kind Kind) (Backend, bool)

Lookup returns the Backend with the given (name, kind) and a found flag. Use LookupKind to enumerate Backends regardless of Kind.

func (*Registry) LookupKind

func (r *Registry) LookupKind(name string) []Backend

LookupKind returns every Backend registered under name, regardless of Kind. Useful for diagnostics: "what kinds does 'redis' serve?".

func (*Registry) MustRegister

func (r *Registry) MustRegister(b Backend)

MustRegister panics on registration error. Intended for built-in backends whose absence is a programmer bug, not a user error.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns every registered Backend name, de-duplicated and sorted. A name appears once even if the same adapter serves multiple Kinds.

func (*Registry) Register

func (r *Registry) Register(b Backend) error

Directories

Path Synopsis
examples
postgres command
Command postgres demonstrates the PostgreSQL KV adapter.
Command postgres demonstrates the PostgreSQL KV adapter.
internal
blobref
Package blobref contains shared helpers for storage BlobStore adapters.
Package blobref contains shared helpers for storage BlobStore adapters.
sqladapter
Package sqladapter contains the shared SQL implementation used by the SQLite and PostgreSQL adapters.
Package sqladapter contains the shared SQL implementation used by the SQLite and PostgreSQL adapters.
Package memory provides reference in-process implementations of every storage.Kind.
Package memory provides reference in-process implementations of every storage.Kind.
Package postgres provides PostgreSQL implementations of storage KV, Blob, and Tx backends.
Package postgres provides PostgreSQL implementations of storage KV, Blob, and Tx backends.
Package redis provides Redis-backed KV, Blob, and distributed lock implementations.
Package redis provides Redis-backed KV, Blob, and distributed lock implementations.
Package sqlite provides SQLite implementations of storage KV, Blob, and Tx backends.
Package sqlite provides SQLite implementations of storage KV, Blob, and Tx backends.

Jump to

Keyboard shortcuts

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