storage

package module
v0.12.0 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 35 Imported by: 0

README

kelindar/roaring
Go Version PkgGoDev License Coverage

Typed Object Storage for Go

This is a small, typed resource store for Go. It keeps application objects as JSON documents in SQLite or PostgreSQL and covers the pieces that tend to get rebuilt in every service: queries, links, optimistic updates, locks, change feeds, sequences, lifecycle state, validation, and blobs.

The idea is simple: define a struct, embed Meta, give it a kind, and register it. After that, the same API is used to create, query, link, expire, and delete resources.

Storage is not an ORM or an object database. The structs remain ordinary Go values. The drivers store their JSON documents alongside the fields needed for identity, state, timestamps, and indexes. In other words, objects in Go, documents on disk, and relational metadata around them.

There are no generated models, sessions, or Save methods. Create, fetch, update, and search are explicit operations.

  • Typed. Resources are regular Go structs with a storage.Meta field.
  • Multi-tenant. Every resource belongs to a tenant, namespace, kind, and generated ID.
  • Durable. SQLite and PostgreSQL drivers provide transactions, migrations, locks, changes, and sequences.
  • Queryable. Filter, sort, page, search, and count resources without writing SQL.
  • Composable. Links, state machines, validation, conversion helpers, and blobs are optional.

Installation

Requires Go 1.25 or newer. Install the core package and one of the drivers:

go get github.com/kelindar/storage
go get github.com/kelindar/storage/driver/sqlite
# or:
go get github.com/kelindar/storage/driver/pgsql

The SQLite and PostgreSQL drivers are separate Go modules. Each one depends directly on the core package, so it can be used without a replace directive.

Quick start

This is the whole flow: define a resource, register it, open a driver, and use the generic helpers.

package main

import (
	"context"
	"fmt"

	"github.com/kelindar/storage"
	"github.com/kelindar/storage/driver/sqlite"
)

type Document struct {
	storage.Meta `kind:"document" json:",inline"`
	Title        string `json:"title"`
}

func main() {
	registry := storage.NewRegistry()
	storage.MustRegister[*Document](registry)

	db, err := sqlite.Open("storage.db", registry)
	if err != nil {
		panic(err)
	}
	defer db.Close()

	ctx := context.Background()
	document, err := storage.Create[*Document](ctx, db, func(document *Document) error {
		document.Title = "Hello, storage"
		return nil
	}, "acme", "default")
	if err != nil {
		panic(err)
	}

	rows, err := storage.Search[*Document](ctx, db, storage.Query{
		Tenant: "acme",
		Limit:  20,
	})
	if err != nil {
		panic(err)
	}
	for document := range rows {
		fmt.Println(document.ID, document.Title)
	}
}

Resources

Resources are plain Go values. Embedding storage.Meta gives them the storage.Object interface:

type Object interface {
	URN() storage.URN
	Status() string
	Created() (string, time.Time)
	Updated() (string, time.Time)
}

Meta contains the fields shared by every resource:

  • ID, Kind, Tenant, and Namespace identify it.
  • State holds its current lifecycle state.
  • CreatedBy, CreatedAt, UpdatedBy, and UpdatedAt record mutations.
  • ExpiresAt is an optional Unix-nanosecond deadline.

storage.New[T] creates a new object with a generated ID. NewByType, KindOf, and KindOfT are useful when the concrete type is only known at runtime. IDs and kinds are also available through the object's URN.

Registry and metadata

Register every resource type before opening a driver. A driver creates one table per registered kind during migration. storage.Blob must be registered too when the application stores blobs.

The registry can enumerate types, resolve a kind, and look up fields through Type.Field.

storage.Options holds application metadata for a resource type:

  • Icon, Title, Plural, and Sort describe how it is presented.
  • Search enables a materialized full-text index where the driver supports it; it is disabled by default.
  • States defines an optional lifecycle state machine.
  • Actions contains permission names.
  • Workflows contains application workflow names.

Actions and workflows are only metadata. Storage does not authorize an action or execute a workflow. When Actions is empty, DefaultActions is used.

Actors

Put the actor responsible for a mutation in the context:

ctx := storage.WithActor(context.Background(), "user:123")
created, err := storage.Insert(ctx, db, document)

storage.Actor(ctx) returns the value later. If no actor is set, it returns storage.UnknownActor; storage.SystemActor is available for background work.

Drivers

Both drivers implement the same storage.Storage interface and auto-migrate their shared tables and the tables for registered resources.

SQLite

The SQLite driver lives in github.com/kelindar/storage/driver/sqlite:

db, err := sqlite.Open("app.db", registry)
if err != nil {
	return err
}
defer db.Close()

testDB := sqlite.OpenEphemeral(registry)
defer testDB.Close()

sqlite.Open uses the cgo-free ncruces/go-sqlite3 driver. OpenEphemeral opens an in-memory database and is handy in tests.

Set storage.Options{Search: true} when registering a resource to enable SQLite's materialized FTS5 index. If FTS5 is unavailable or the option is disabled, matching falls back to a case-insensitive substring search.

PostgreSQL

The PostgreSQL driver lives in github.com/kelindar/storage/driver/pgsql:

db, err := pgsql.Open("postgres://user:password@localhost/app?sslmode=disable", registry)
if err != nil {
	return err
}
defer db.Close()

pgsql.Open uses the pgx database/sql driver and owns the database handle. If the application already has a *sql.DB, use pgsql.New instead:

sqlDB, err := sql.Open("pgx", dsn)
if err != nil {
	return err
}

db, err := pgsql.New(sqlDB, registry)
if err != nil {
	return err
}
defer db.Close() // does not close sqlDB

pgsql.New does not close the supplied *sql.DB. PostgreSQL Query.Match performs a case-insensitive substring search over the stored JSON; it does not provide SQLite-style FTS ranking, so the Search option has no effect there.

The raw Upload method on either driver rejects blob content because a database backend does not include a file store. Wrap a driver with storage.NewStore when blobs are needed.

To write another backend, implement storage.Storage. It covers resource operations as well as links, search, changes, locks, uploads, and sequences. storage.Store adds a storage.Files implementation on top of it.

Working with resources

Create and update

Most applications only need a handful of operations:

  • storage.Create constructs and inserts a resource.
  • storage.Insert assigns a missing ID, applies the default state, and inserts it.
  • storage.Update writes an existing resource with optimistic concurrency.
  • storage.Patch fetches, changes, and updates a resource.
  • storage.Upsert inserts a resource or patches the existing one after a conflict.
  • storage.Overwrite refreshes the stored version before updating, so it can overwrite changes made by another writer.
  • storage.Fetch, storage.Delete, storage.Search, and storage.Count are the direct read, delete, search, and count operations.

For example, create a document, then update it with a patch:

document, err := storage.New[*Document]("acme", "default")
if err != nil {
	return err
}
document.Title = "First version"

document, err = storage.Insert(ctx, db, document)
if err != nil {
	return err
}

document, err = storage.Patch(ctx, db, document.URN(), func(document *Document) error {
	document.Title = "Updated version"
	return nil
})

Updates compare the UpdatedAt value read from the database. A stale object returns storage.ErrConflict.

Patch retries a conflict up to ten times. Its callback may run more than once, so it should only mutate the supplied object. Do not send mail, publish an event, or perform another external side effect from it. The same rule applies to the callback passed to Upsert.

Use the error helpers rather than matching error strings:

if storage.IsNotFound(err) {
	// ...
}
if storage.IsConflict(err) {
	// refetch or retry
}
if storage.IsInvalidTransition(err) {
	// reject the requested state change
}
if storage.IsLockLost(err) {
	// stop work performed under the lease
}

ErrInvalid, ErrDeleting, and ErrKindNotFound are also available for errors.Is checks.

Identity and targets

Every resource has a storage.URN in this form:

urn:tenant:namespace:kind:id

Use NewURN for a generated ID, MakeURN for a known ID, and ParseURN for an encoded value:

urn, err := storage.NewURN("acme", "default", "document")
if err != nil {
	return err
}

parsed, err := storage.ParseURN(urn.String())
if err != nil {
	return err
}
_ = parsed.IsValid()

Tenants, namespaces, and kinds are normalized to lowercase and must use the package's slug format. IDs are 20-character xid-compatible values. URNs marshal to and from JSON strings.

storage.Target adds a selector to a URN. It supports @draft, @latest, and exact positive resource versions such as @v3:

target, err := storage.ParseTarget(urn.String() + "@latest")
if err != nil {
	return err
}
fmt.Println(target.URN(), target.Ref(), target.IsLatest())

The target is only a selector; it does not create or store versions by itself.

Queries

storage.Query filters and sorts a resource kind without exposing SQL to the caller:

query := storage.Query{
	Tenant:     "acme",
	IDs:        []string{"id1", "id2"},
	Namespaces: []string{"default"},
	States:     []string{"active"},
	Indexes:    []string{"sample"},
	Filters: map[string][]string{
		"title":       {"release"},
		"description": {""}, // existence check
	},
	Match:        "deploy production",
	SortBy:       []string{"-updatedAt", "title"},
	Offset:       0,
	Limit:        50,
	UpdatedAfter: time.Now().Add(-time.Hour),
}

The fields cover the common cases:

  • Tenant, IDs, and Namespaces scope the result set.
  • States filters lifecycle states.
  • Indexes filters the value returned by an optional Index() string method.
  • Filters compare JSON paths.
  • Match performs full-text or substring matching, depending on the driver. Fields tagged with search:"-" are excluded from the search content; resources without search tags use their serialized data.
  • SortBy accepts +field for ascending and -field for descending order.
  • Offset and Limit page the results.
  • CreatedBefore, UpdatedBefore, and UpdatedAfter apply time bounds.

Selection unions whole namespaces and exact namespace/ID pairs, then intersects them with every other filter. Both drivers apply it within the same SQL query:

query.Selection = map[string][]string{
	"team":   nil,                         // All records in team.
	"shared": {"d92il9hhq4uhlo6a5ucg"},   // Only this record in shared.
}

A nil map adds no restriction; an empty map matches nothing. Within the map, nil IDs select the whole namespace and empty IDs select nothing. Names and IDs are literal. Keep the map and slices unchanged during Search or Count.

A filter with a value is an equality check. An empty value means “present and non-zero”: the field must exist and must not be empty, zero, or false. Nested fields can be addressed with paths such as profile.email.

Search returns an iterator. Collect drains it into a slice, while Select drains it and projects another value:

rows, err := storage.Search[*Document](ctx, db, query)
if err != nil {
	return err
}

for document := range rows {
	fmt.Println(document.Title)
}

count, err := storage.Count[*Document](ctx, db, query)
if err != nil {
	return err
}

rows, _ = storage.Search[*Document](ctx, db, query)
titles := storage.Select(rows, func(document *Document) (string, bool) {
	return document.Title, document.Title != ""
})

Count does not accept sorting, offset, or limit.

String queries

ParseQuery accepts semicolon-separated key/value pairs. This is useful when a query arrives from a URL or another text-based transport:

query, err := storage.ParseQuery(
	"tenant=acme;namespace=default;state=active;"+
		"filter=title:release,description;"+
		"match={Title};sort=-updatedAt;limit=20;offset=0",
	document,
	storage.Query{},
)

The supported components are tenant, id, namespace, selection, state, index, filter, match, sort, limit, offset, and updatedAfter. Multiple IDs, namespaces, states, and indexes are comma-separated. namespace=* removes the namespace restriction. selection is URL-escaped JSON, emitted by Query.String(), which preserves nil and empty ID lists.

match can substitute fields from the supplied object with {FieldName}. Substitutions support strings, integers, floats, and booleans. Filters use field:value for equality or just field for existence. Query.String() produces a compact representation for logging and transport.

JSON and YAML

The JSON helpers use the registry to find the concrete type from the kind field. The reader variants take an io.Reader:

data, err := storage.ToJSON(document)
decoded, err := storage.FromJSON(registry, data)

decoded, err = storage.ReadJSON(registry, reader)
decoded, err = storage.FromYAML(registry, yamlData)
decoded, err = storage.ReadYAML(registry, reader)

UnmarshalYAML follows JSON struct tags. ReadFile reads from disk when its data argument is nil and chooses JSON, YAML, or YML from the file extension.

A store tag changes where a field is persisted without changing its public JSON shape:

type SecretDocument struct {
	storage.Meta `kind:"secret_document" json:",inline"`
	Token        string `json:"-" store:"credentials.token"`
}

ToJSON writes Token at credentials.token, and FromJSON reads it from there. store:"-" removes a field from the stored representation. Nested structs, slices, arrays, and maps are supported.

For polymorphic embedded resources, use storage.Embed. It uses the registry and the embedded object's kind field to decode the concrete type.

A field tagged with link:"kind" produces dependency links to URNs or URN strings of that kind:

type Conversation struct {
	storage.Meta `kind:"conversation" json:",inline"`
	Attachments  []storage.URN `json:"attachments" link:"blob"`
}

The link walker understands pointers, nested structs, slices, arrays, and maps. Paths use JSON field names and include indexes or map keys, such as attachments.0. A field with json:"-" or link:"-" is ignored.

Call storage.Links(obj) to extract the links declared by an object.

For links that are not represented by a tagged field, implement storage.Linker:

func (b *Bundle) Links() ([]storage.Link, error) {
	return []storage.Link{
		storage.Own(b.URN(), target, storage.Path("resources.0")),
	}, nil
}

Use storage.Use for a dependency and storage.Own for exclusive ownership. Drivers rebuild outgoing links during inserts and updates. Call db.Link(ctx, source) to rebuild them explicitly, and db.Links(ctx, target) to list incoming links.

Links must have valid URNs, a non-empty path, the matching source, and the same tenant on both sides. Ownership is unique per target. Blobs cannot own resources, and only bundles may own non-blob resources. Path.String, Path.Label, Path.Index, Path.ID, and Path.Walk expose the link path when it needs to be inspected.

Lifecycle state

An optional state.Machine describes the transitions allowed for a resource:

states := state.Machine{
	"create":  "* -> draft",
	"publish": "draft -> active",
	"archive": "active -> inactive",
}

storage.MustRegister[*Document](registry, storage.Options{States: states})

The wildcard source supplies the default state. Insert and Upsert assign it when no state is set. Update and Patch reject invalid transitions with ErrInvalidTransition.

The state package also provides shared state names—Creating, Active, Inactive, Deleting, and Failed—along with Machine.TryAction, Machine.CanTransition, Machine.Default, Machine.States, and Edge.Value.

Expiration

Set Meta.ExpiresAt to a Unix-nanosecond deadline:

document.ExpiresAt = time.Now().Add(time.Hour).UnixNano()
document, err = storage.Update(ctx, db, document)

The sweeper needs a callback because deleting a resource is application-specific:

store.Start(ctx, func(ctx context.Context, urn storage.URN) error {
	_, err := store.Delete(ctx, urn)
	return err
})

Expiration is eventual. The sweeper scans expired resources in pages at a randomized interval between one and two hours. Failed or blocked deletions are logged and tried again later. Store.Start also enables change-log retention cleanup, and Store.Close stops the sweeper before closing the wrapped storage.

Locks, changes, and sequences

Named locks

Storage.Lock acquires a renewable named lease:

lockCtx, release, err := db.Lock(ctx, "rebuild-index")
if err != nil {
	return err
}
defer release()

// Use lockCtx for work that must stop if the lease is lost.
if err := rebuild(lockCtx); err != nil {
	return err
}
if err := context.Cause(lockCtx); storage.IsLockLost(err) {
	return err
}

Only one live owner can hold a name. The returned context is canceled if renewal fails or ownership is lost. Always release the lease and use the returned context for work protected by it.

Durable changes

storage.Changes[T] starts a named, persistent consumer for one resource kind:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

worker := storage.Changes[*Document](
	ctx,
	db,
	"search-indexer",
	time.Time{},
	func(ctx context.Context, batch []storage.Change) error {
		for _, change := range batch {
			fmt.Println(change.Action, change.URN, change.At)
		}
		return nil
	},
)

if err := worker.Wait(); err != nil {
	return err
}

The cursor is durable and keyed by consumer name and kind. Delivery is at least once: if the callback returns an error, the batch is retried until it succeeds or the context is canceled. Make the callback idempotent. Batches are borrowed only for the callback, so do not retain or modify them.

The after time selects the starting point for a new consumer. The sweeper removes change history older than seven days; pruned history is not replayed.

Changes describe creates, updates, and deletes. They identify the resource and the time of the mutation; they do not contain a copy of the document.

Sequences

storage.Next[T] advances a sequence named by the resource kind. For an arbitrary name, call db.Next directly:

sequence, err := storage.Next[*Document](ctx, db)
named, err := db.Next(ctx, "invoice")

Sequences are durable, atomic, and return uint32 values.

Blobs

Blobs keep binary content in a storage.Files backend and their metadata in the resource database. Register the blob type before opening the driver:

storage.MustRegister[*storage.Blob](registry, storage.Options{
	States: state.Machine{
		"create": "* -> active",
		"delete": "active -> deleting",
	},
})

Wrap the driver with storage.NewStore:

backend, err := sqlite.Open("app.db", registry)
if err != nil {
	return err
}

files := &storage.Memory{} // zero-value in-memory filesystem
store := storage.NewStore(backend, files)
defer store.Close()

scope := storage.URN{Tenant: "acme", Namespace: "default"}
blob, err := store.Upload(ctx, scope, "text/plain", []byte("hello"))
if err != nil {
	return err
}

data, err := blob.Read(ctx)
if err != nil {
	return err
}
_, err = blob.WriteTo(ctx, destination)
_ = data

storage.Memory is intended for tests. A custom Files implementation must also implement fs.FS, Write(context.Context, string, []byte), and Delete(context.Context, string).

Blob content is immutable. On upload, storage limits the uncompressed payload to storage.MaxSize (64 MiB), detects and validates the MIME type, records both sizes and a SHA-256 digest, and compresses text, JSON, XML, YAML, TOML, and selected vendor formats with zstd. Every read checks the size, decompression, and digest.

The persisted Blob.Compression is either CompressionRaw or CompressionZstd. Updating a blob changes its metadata only; it does not replace the bytes.

Blob deletion is two-phase. A referenced blob returns ErrConflict. Otherwise it is marked as deleting first, then its file and metadata are removed. If file deletion fails, the blob remains in the deleting state and can be retried. Store.Recover(ctx) takes the blob recovery lock and retries all deleting blobs.

Supporting packages

Validation

github.com/kelindar/storage/validate validates nested structs, slices, arrays, maps, and pointers. Validation tags use is:

type Input struct {
	Email string `json:"email" is:"required,email"`
	Age   int    `json:"age" is:"min(18)"`
}

ok, err := validate.Struct(&Input{
	Email: "person@example.com",
	Age:   21,
})

Struct requires a non-nil pointer to a struct and returns (bool, error). A failure may be a validate.Errors collection. Each validate.Error includes the field name, validator, nested path, and message.

The built-in validators cover required values, strings, lengths, character classes, numbers, ranges, URLs, email, IP addresses, UUIDs, hashes, dates, encodings, and common identifiers. Register another one with validate.Register; its negated !name form is registered automatically.

Create and Update enforce field access declared with form tags:

type Document struct {
	Name    string `json:"name" form:"rw"`
	Type    string `json:"type" form:"create"`
	Version int    `json:"version" form:"ro"`
}

err := validate.Create(incoming)
err = validate.Update(current, incoming)

Create rejects populated ro fields. Update rejects changes to ro and create fields while allowing omitted or unchanged values. If a read-only field is empty in current, Update clears it from incoming; this supports round-tripping projections that are not stored.

Conversion helpers

github.com/kelindar/storage/convert contains the small helpers that are useful around resource metadata and query input:

  • TitleCase, Label, and SlugLabel create display labels.
  • Strings trims, removes empty values, deduplicates, and sorts.
  • Int and Float parse strings with defaults.
  • Int64, Uint64, and Float64 convert common Go and JSON values.
  • ScheduleLabel creates a readable label from common five-field cron expressions.
  • BuiltinID creates a stable tenant-specific 20-character ID.

Package layout

/                 core storage API, registry, objects, queries, links, blobs
/state             lifecycle state machines
/validate          struct validation
/convert           labels, conversions, schedules, stable IDs
/driver/sqlite     standalone cgo-free SQLite module
/driver/pgsql      standalone PostgreSQL module using pgx
/bench             standalone benchmark module
/internal/walk     private reflection walker

internal/walk is an implementation detail and is not part of the public API.

Benchmarks

The benchmark is a separate Go module under bench. It exercises resource creation, registry lookup, CRUD, search, count, changes, links, expiration scans, locks, sequences, blobs, and deletion.

Run it with:

(cd bench && go run .)

Development

Run the root tests and checks:

go test ./...
go test -race ./...
go vet ./...

The nested modules are tested separately:

(cd driver/sqlite && go test ./...)
(cd driver/pgsql && go test ./...)
(cd bench && go test ./...)

Contributing

Keep changes focused and run the relevant tests before sending a pull request.

License

Storage is licensed under the MIT License.

Documentation

Index

Constants

View Source
const (
	UnknownActor = "unknown"
	SystemActor  = "system"
)
View Source
const MaxSize = 64 << 20

MaxSize is the largest uncompressed Blob payload.

Variables

View Source
var (
	ErrNotFound          = errors.New("storage: document was not found")
	ErrConflict          = errors.New("storage: write conflict")
	ErrInvalidTransition = errors.New("storage: invalid state transition")
	ErrDeleting          = errors.New("storage: content is deleting")
	ErrInvalid           = errors.New("storage: invalid input")
	ErrLockLost          = errors.New("storage: lock ownership lost")
)
View Source
var DefaultActions = []string{
	"create", "read", "update", "delete", "search", "count", "run", "fail",
}

DefaultActions lists standard permission actions used when Options.Actions is empty.

View Source
var (
	ErrKindNotFound = errors.New("resource: kind not found")
)

Functions

func Actor added in v0.9.1

func Actor(ctx context.Context) string

Actor returns the audit identity carried by ctx, or UnknownActor.

func Changes added in v0.9.1

func Changes[T Object](ctx context.Context, db Storage, consumer string, after time.Time, handle func(context.Context, []Change) error) async.Awaiter

Changes starts bounded, durable changes for T with the named persistent consumer. Wait observes its terminal error. The callback is retried until it succeeds or ctx is canceled.

func Collect added in v0.9.1

func Collect[T Object](seq iter.Seq[T], where func(T) bool) []T

Collect drains a search iterator, keeping items for which where returns true. A nil where keeps every item. Use this before running nested queries on SQLite, which only allows one open cursor at a time.

func Count added in v0.9.1

func Count[T Object](ctx context.Context, db Storage, q Query) (int, error)

Count returns the number of records that match the specified query.

func Create added in v0.9.1

func Create[T Object](ctx context.Context, db Storage, constructor func(obj T) error, tenant, namespace string) (T, error)

Create creates a new resource and inserts it into the storage.

func Delete added in v0.9.1

func Delete[T Object](ctx context.Context, db Storage, urn URN) (T, error)

Delete deletes a resource from the storage and returns the deleted object.

func Fetch added in v0.9.1

func Fetch[T Object](ctx context.Context, db Storage, urn URN) (T, error)

Fetch attempts to find a specific document in the storage layer.

func Insert added in v0.9.1

func Insert[T Object](ctx context.Context, db Storage, v T) (T, error)

Insert inserts a new resource into the storage.

func IsConflict added in v0.9.1

func IsConflict(err error) bool

IsConflict returns true if the specified error is a conflict error.

func IsInvalidTransition added in v0.9.1

func IsInvalidTransition(err error) bool

IsInvalidTransition checks whether the error is a state transition error.

func IsLockLost added in v0.9.1

func IsLockLost(err error) bool

IsLockLost returns true if the specified error reports lost lock ownership.

func IsNotFound added in v0.9.1

func IsNotFound(err error) bool

IsNotFound returns true if the specified error is a not found error.

func New added in v0.9.1

func New[T Object](tenant, namespace string, funcs ...func(obj T) error) (T, error)

New creates a new instance of the specified resource kind.

func Next added in v0.9.1

func Next[T Object](ctx context.Context, db Storage) (uint32, error)

Next advances the sequence named by T's resource kind and returns its new value.

func Overwrite added in v0.9.1

func Overwrite[T Object](ctx context.Context, db Storage, v T) (T, error)

Overwrite updates using the latest stored version.

func Patch added in v0.9.1

func Patch[T Object](ctx context.Context, db Storage, urn URN, patch func(T) error) (T, error)

Patch fetches a resource, applies patch, and retries the update with a fresh version when a concurrent change wins, up to ten attempts. The patch callback may therefore run up to ten times and must only mutate the supplied object; it must be side-effect-free and safe to repeat.

func ReadFile added in v0.9.1

func ReadFile(path string, data []byte, v any) error

ReadFile decodes a file into v using its extension (.json, .yaml, .yml). When data is nil the file contents are read from path.

func Search[T Object](ctx context.Context, db Storage, q Query) (iter.Seq[T], error)

Search performs a query against the storage layer.

func Select added in v0.9.1

func Select[T Object, P any](seq iter.Seq[T], where func(T) (P, bool)) []P

Select drains a search iterator, projecting items for which where returns true.

func ToJSON added in v0.9.1

func ToJSON(v Object) ([]byte, error)

ToJSON encodes a resource for storage. Fields use their JSON name unless a store tag overrides it.

func UnmarshalYAML added in v0.9.1

func UnmarshalYAML(data []byte, v any) error

UnmarshalYAML decodes YAML into v using json struct tags.

func Update added in v0.9.1

func Update[T Object](ctx context.Context, db Storage, v T) (T, error)

Update updates an existing resource in the storage.

func Upsert added in v0.9.1

func Upsert[T Object](ctx context.Context, db Storage, v T, patch func(T) error) (T, error)

Upsert inserts a resource, or patches the existing resource with the same URN when insertion conflicts. The patch callback is not called when the insert succeeds and may run more than once when it patches an existing row.

func WithActor added in v0.9.1

func WithActor(ctx context.Context, actor string) context.Context

WithActor returns a context carrying the audit identity for storage mutations.

Types

type Blob added in v0.9.1

type Blob struct {
	Meta        `kind:"blob" json:",inline"`
	ContentType string      `json:"contentType" form:"ro"`
	Size        int64       `json:"size" form:"ro"`
	ObjectKey   string      `json:"-" store:"objectKey" form:"-"`
	SHA256      string      `json:"-" store:"sha256" form:"-"`
	StoredSize  int64       `json:"-" store:"storedSize" form:"-"`
	Compression Compression `json:"-" store:"compression" form:"-"`
	// contains filtered or unexported fields
}

Blob is immutable binary content stored outside the resource database. Storage details are retained only in the persisted representation.

func (*Blob) Read added in v0.9.1

func (b *Blob) Read(ctx context.Context) ([]byte, error)

Read returns verified original bytes.

func (*Blob) Subtitle added in v0.9.1

func (b *Blob) Subtitle() string

func (*Blob) Title added in v0.9.1

func (b *Blob) Title() string

func (*Blob) WriteTo added in v0.9.1

func (b *Blob) WriteTo(ctx context.Context, dst io.Writer) (int64, error)

WriteTo writes verified original bytes to dst.

type Change added in v0.9.1

type Change struct {
	URN    URN
	Action string
	At     time.Time
}

Change describes a durable create, update, or delete mutation. It is valid only for the duration of the Changes callback and must not be retained or modified by the consumer.

type Compression added in v0.9.1

type Compression string

Compression describes the persisted encoding of a Blob.

const (
	CompressionRaw  Compression = "raw"
	CompressionZstd Compression = "zstd"
)

type Embed added in v0.9.1

type Embed struct {
	Value    Object `json:",inline"`
	Registry Registry
}

Embedded represents a generic embedded document for unmarshaling

func (Embed) MarshalJSON added in v0.9.1

func (r Embed) MarshalJSON() ([]byte, error)

MarshalJSON marshals the JSON from the embedded document

func (*Embed) UnmarshalJSON added in v0.9.1

func (r *Embed) UnmarshalJSON(b []byte) error

UnmarshalJSON unmarshals the JSON into the embedded document

type Files added in v0.9.1

type Files interface {
	fs.FS
	Write(context.Context, string, []byte) (string, error)
	Delete(context.Context, string) error
}

Files is the object filesystem used for Blob content.

type Indexer added in v0.9.1

type Indexer interface {
	Index() string
}

Indexer represents a resource that provides an index.

type Kind added in v0.9.1

type Kind string

Kind represents a resource Kind (e.g. "Document", "Sprite")

const (
	// KindBlob identifies binary resources.
	KindBlob Kind = "blob"
)

func KindOf added in v0.9.1

func KindOf(typ reflect.Type) (Kind, error)

KindOf returns the Kind of the object.

func KindOfT added in v0.9.1

func KindOfT[T any]() (Kind, error)

KindOfT returns the Kind of the object.

func (Kind) String added in v0.9.1

func (k Kind) String() string

String returns the string representation of the resource kind.

type Link struct {
	Source URN      `json:"source"`
	Target URN      `json:"target"`
	Path   Path     `json:"path"`
	Kind   LinkKind `json:"kind"`
}

Link is a derived link index entry.

func Links(obj Object) ([]Link, error)

Links returns links declared by tags or by the object's Linker method.

func Own added in v0.9.1

func Own(source, target URN, path Path) Link

Own returns an ownership link.

func Use added in v0.9.1

func Use(source, target URN, path Path) Link

Use returns a dependency link.

type LinkKind added in v0.9.1

type LinkKind uint8

LinkKind distinguishes exclusive ownership from an ordinary dependency.

const (
	LinkOwnership LinkKind = iota + 1
	LinkDependency
)

type Linker added in v0.9.1

type Linker interface {
	Links() ([]Link, error)
}

Linker is implemented by documents that declare explicit links.

type Memory added in v0.9.1

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

Memory is a zero-value in-memory object filesystem for tests.

func (*Memory) Delete added in v0.9.1

func (m *Memory) Delete(ctx context.Context, name string) error

func (*Memory) Open added in v0.9.1

func (m *Memory) Open(name string) (fs.File, error)

func (*Memory) Write added in v0.9.1

func (m *Memory) Write(ctx context.Context, name string, data []byte) (string, error)

type Meta added in v0.9.1

type Meta struct {
	ID        string `json:"id" form:"-"`                  // Globally unique identifier (e.g. "9m4e2mr0ui3e8a215n4g")
	Kind      Kind   `json:"kind" form:"-"`                // Meta kind (e.g. "deployment")
	Tenant    string `json:"tenant" form:"-"`              // Tenant slug (e.g. "acme")
	Namespace string `json:"namespace" form:"-"`           // Namespace of the object (e.g. "default")
	State     string `json:"state,omitempty"  form:"-"`    // State is the current state of the resource
	CreatedBy string `json:"createdBy,omitempty" form:"-"` // CreatedBy is the user who created the resource
	CreatedAt int64  `json:"createdAt,omitempty" form:"-"` // CreatedAt is the time when the resource was created
	UpdatedBy string `json:"updatedBy,omitempty" form:"-"` // UpdatedBy is the user who last updated the resource
	UpdatedAt int64  `json:"updatedAt,omitempty" form:"-"` // UpdatedAt is the time when the resource was last updated
	ExpiresAt int64  `json:"expiresAt,omitempty" form:"-"` // ExpiresAt is when the resource becomes eligible for deletion
}

Meta represents a metadata of the object.

func (*Meta) Created added in v0.9.1

func (r *Meta) Created() (string, time.Time)

Created returns who created the resource and when.

func (*Meta) Status added in v0.9.1

func (r *Meta) Status() string

Status returns the current state of the resource.

func (*Meta) Title added in v0.9.1

func (r *Meta) Title() string

Title returns the title of the resource.

func (*Meta) URN added in v0.9.1

func (r *Meta) URN() URN

URN returns the URN of the object.

func (*Meta) Updated added in v0.9.1

func (r *Meta) Updated() (string, time.Time)

Updated returns who updated the resource and when.

type Object

type Object interface {
	URN() URN                     // URN returns the uniform identifier of the object
	Status() string               // Status returns the current state
	Created() (string, time.Time) // Created returns createdBy and createdAt information
	Updated() (string, time.Time) // Updated returns updatedBy and updatedAt information
}

Object represents an object in the system.

func FromJSON added in v0.9.1

func FromJSON(c Registry, data []byte) (Object, error)

FromJSON parses a JSON file and returns a resource

func FromYAML added in v0.9.1

func FromYAML(c Registry, data []byte) (Object, error)

FromYAML parses a YAML document and returns a resource.

func NewByType added in v0.9.1

func NewByType(typ reflect.Type, tenant, namespace string) (Object, error)

New creates a new instance of the specified resource kind.

func ReadJSON added in v0.9.1

func ReadJSON(c Registry, reader io.Reader) (Object, error)

ReadJSON reads a JSON file and returns a resource

func ReadYAML added in v0.9.1

func ReadYAML(c Registry, reader io.Reader) (Object, error)

ReadYAML reads a YAML document and returns a resource.

type Options added in v0.9.1

type Options struct {
	Icon      string        `json:"icon,omitempty"`      // Icon name from https://lucide.dev/icons
	Title     string        `json:"title,omitempty"`     // Title of the document (e.g. Person)
	Plural    string        `json:"plural,omitempty"`    // Plural name of the document (e.g. People)
	Sort      string        `json:"sort,omitempty"`      // Sort field
	Search    bool          `json:"search,omitempty"`    // Enable a materialized full-text index where supported
	States    state.Machine `json:"-"`                   // Optional lifecycle state machine
	Actions   []string      `json:"actions,omitempty"`   // Allowed permission actions for this kind
	Workflows []string      `json:"workflows,omitempty"` // Built-in workflows to run after saves
}

Options represents the options for a document

type Path added in v0.9.1

type Path string

Path represents a rendering path for a particular field.

func (Path) ID added in v0.9.1

func (p Path) ID(prefix string) string

ID generates a unique ID for the path, encoded in hex.

func (Path) Index added in v0.9.1

func (p Path) Index() int

Index retrieves the index of the path, if it's a slice. Otherwise, returns -1.

func (Path) Label added in v0.9.1

func (p Path) Label() string

Label returns the label of the path.

func (Path) String added in v0.9.1

func (p Path) String() string

String returns the string representation of the path.

func (Path) Walk added in v0.9.1

func (p Path) Walk() iter.Seq[Path]

Walk iterates over all sub-paths (e.g. "foo.bar.baz" -> "foo", "foo.bar", "foo.bar.baz")

type Query added in v0.9.1

type Query struct {
	Tenant        string              // Tenant limits results to one tenant.
	IDs           []string            // IDs limits results to the listed resource IDs.
	Namespaces    []string            // Namespaces limits results to the listed namespaces.
	States        []string            // States is a list of states to filter by
	Indexes       []string            // Indexes is a list of indexes to filter by
	Filters       map[string][]string // Filters is a map of filters to apply
	Match         string              // Match is the full-text search query
	SortBy        []string            // Sort is the set of fields to order by
	Offset        int                 // Offset is the number of records to skip
	Limit         int                 // Limit is the maximum number of records to return
	CreatedBefore time.Time           // CreatedBefore filters records created before this time
	UpdatedBefore time.Time           // UpdatedBefore filters records updated before this time
	UpdatedAfter  time.Time           // UpdatedAfter filters records updated after this time

	// Selection unions exact namespaces and their IDs, intersecting all other filters.
	// A nil map is unrestricted; an empty map matches nothing. A nil ID slice
	// selects the whole namespace; an empty slice selects nothing. No wildcards
	// are interpreted. The caller owns the map and slices and must not modify
	// them during Search or Count. Tenant and kind retain their existing scope.
	Selection map[string][]string
}

Query represents a query to filter records.

func ParseQuery added in v0.9.1

func ParseQuery(queryString string, object any, out Query) (Query, error)

ParseQuery parses a string query into a Query struct. The query format is structured as a semicolon-separated list of key-value pairs. Example query: "namespace=company;state=active;filter=age:30;match={Name}" - The query is limited to `company` namespace. - Only records with an `active` state will be considered. - A filter is applied to only include records where `age` is `30`. - It matches records containing the person's name from the placeholder `{Name}`.

  1. **namespace**: Specifies the namespaces to filter by. Multiple namespaces can be separated by commas. Example: `namespace=company,person`

  2. **state**: Indicates the states to filter by. Multiple states can be separated by commas. Example: `state=active,inactive`

  3. **filter**: Defines filters to apply. Each filter is specified as `field:value` for equality checks, or just `field` (without colon) for existence checks (non-nil and non-zero). Multiple filters can be separated by commas. Example: `filter=age:30,income:1000` (equality checks) Example: `filter=email` (existence check - matches records where email is set and non-empty)

  4. **match**: A full-text search query. This can include any search terms. Example: `match=software engineer`

func (*Query) String added in v0.9.1

func (q *Query) String() string

String returns the string representation of the query.

type Registry added in v0.9.1

type Registry interface {
	Types() iter.Seq[Type]
	Register(Type) error
	Resolve(Kind) (Type, error)
}

Registry represents a registry of various object kinds.

func NewRegistry added in v0.9.1

func NewRegistry() Registry

NewRegistry creates a new registry.

type Storage added in v0.9.1

type Storage interface {
	io.Closer
	Registry() Registry
	Lock(ctx context.Context, name string) (context.Context, context.CancelFunc, error)
	Insert(ctx context.Context, v Object) (Object, error)
	Update(ctx context.Context, v Object) (Object, error)
	Delete(ctx context.Context, urn URN) (Object, error)
	Fetch(ctx context.Context, urn URN) (Object, error)
	Link(ctx context.Context, source URN) error
	Links(ctx context.Context, target URN) ([]Link, error)
	Search(ctx context.Context, kind Kind, query Query) (iter.Seq[Object], error)
	Count(ctx context.Context, kind Kind, query Query) (int, error)
	Changes(ctx context.Context, consumer string, kind Kind, after time.Time, handle func(context.Context, []Change) error) error
	Upload(ctx context.Context, scope URN, contentType string, data []byte) (*Blob, error)
	Next(ctx context.Context, name string) (uint32, error)
}

Storage represents a storage layer for records.

type Store added in v0.9.1

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

Store joins resource storage with its file backend.

func NewStore added in v0.9.1

func NewStore(store Storage, files Files) *Store

NewStore joins resource storage with its file backend.

func (*Store) Close added in v0.9.1

func (s *Store) Close() error

Close stops background processes before closing storage.

func (*Store) Delete added in v0.9.1

func (s *Store) Delete(ctx context.Context, urn URN) (Object, error)

Delete removes ordinary resources directly. Deleting a Blob first makes it unreadable, then removes its file and metadata. File failures leave a retryable deleting resource.

func (*Store) Fetch added in v0.9.1

func (s *Store) Fetch(ctx context.Context, urn URN) (Object, error)

Fetch retrieves a resource and binds its file backend when needed.

func (*Store) Insert added in v0.9.1

func (s *Store) Insert(ctx context.Context, object Object) (Object, error)

Insert stores a resource and binds its file backend when needed.

func (*Store) Recover added in v0.9.1

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

Recover removes files and metadata for Blobs left in the deleting state.

func (*Store) Search added in v0.9.1

func (s *Store) Search(ctx context.Context, kind Kind, query Query) (iter.Seq[Object], error)

Search retrieves resources and binds their file backend when needed.

func (*Store) Start added in v0.9.1

func (s *Store) Start(ctx context.Context, deleteResource func(context.Context, URN) error)

Start starts storage background processes.

func (*Store) Update added in v0.9.1

func (s *Store) Update(ctx context.Context, object Object) (Object, error)

Update stores a resource and binds its file backend when needed.

func (*Store) Upload added in v0.9.1

func (s *Store) Upload(ctx context.Context, scope URN, contentType string, data []byte) (*Blob, error)

Upload stores immutable content.

type Target added in v0.9.1

type Target string

Target identifies a resource and an optional reference.

func ParseTarget added in v0.9.1

func ParseTarget(raw string) (Target, error)

ParseTarget parses a target in the form <URN> or <URN>@<ref>.

func (Target) IsDraft added in v0.9.1

func (t Target) IsDraft() bool

IsDraft reports whether the target selects the draft version.

func (Target) IsLatest added in v0.9.1

func (t Target) IsLatest() bool

IsLatest reports whether the target selects the latest version.

func (Target) MarshalJSON added in v0.9.1

func (t Target) MarshalJSON() ([]byte, error)

MarshalJSON marshals a valid target as a JSON string.

func (Target) Ref added in v0.9.1

func (t Target) Ref() string

Ref returns the optional reference.

func (Target) String added in v0.9.1

func (t Target) String() string

String returns the target string.

func (Target) URN added in v0.9.1

func (t Target) URN() URN

URN returns the resource portion of the target.

func (*Target) UnmarshalJSON added in v0.9.1

func (t *Target) UnmarshalJSON(data []byte) error

UnmarshalJSON validates and unmarshals a target from a JSON string.

func (Target) Version added in v0.9.1

func (t Target) Version() int

Version returns the exact positive version, or zero for an unversioned selector.

type Type added in v0.9.1

type Type struct {
	Kind        Kind         // Kind of the resource
	Type        reflect.Type // Type of the resource
	SearchPaths []string     // JSON paths excluded from full-text search by search:"-".
	Options                  // Options of the resource
	// contains filtered or unexported fields
}

Type represents a registration of a resource kind.

func MustRegister added in v0.9.1

func MustRegister[T Object](c Registry, opts ...Options) Type

MustRegister registers a resource kind into the specified registry, panicking on error.

func Register added in v0.9.1

func Register[T Object](c Registry, opts ...Options) (Type, error)

Register registers a resource kind into the specified registry.

func (*Type) Field added in v0.9.1

func (t *Type) Field(path Path) (reflect.StructField, bool)

Field retrieves a field information by the specified path.

type URN added in v0.9.1

type URN struct {
	Tenant    string `json:"-" uri:"tenant" binding:"required"`    // Tenant slug (e.g. "acme")
	Namespace string `json:"-" uri:"namespace" binding:"required"` // Namespace name (e.g. "default")
	Kind      Kind   `json:"-" uri:"kind" binding:"required"`      // Object kind (e.g. "namespace")
	ID        string `json:"-" uri:"id"`                           // Globally unique identifier
}

URN represents a uniform resource name for accessing resources. Format: urn:tenant:namespace:kind:id

func MakeURN added in v0.9.1

func MakeURN(tenant, namespace string, kind Kind, id string) (URN, error)

MakeURN assembles a URN from its parts.

func NewURN added in v0.9.1

func NewURN(tenant, namespace string, kind Kind) (URN, error)

NewURN creates a new URN with a generated id.

func ParseURN added in v0.9.1

func ParseURN(s string) (URN, error)

ParseURN parses a string into a URN.

func (URN) IsValid added in v0.9.1

func (u URN) IsValid() bool

IsValid returns true if the URN is valid.

func (URN) MarshalJSON added in v0.9.1

func (u URN) MarshalJSON() ([]byte, error)

MarshalJSON marshals the URN to JSON.

func (URN) String added in v0.9.1

func (u URN) String() string

String returns the string representation of the URN.

func (*URN) UnmarshalJSON added in v0.9.1

func (u *URN) UnmarshalJSON(b []byte) error

UnmarshalJSON unmarshals the JSON into a URN.

Directories

Path Synopsis
driver
sqlite module
internal

Jump to

Keyboard shortcuts

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