maniflex

package module
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 39 Imported by: 0

README

maniflex

manifold + flexible - many shapes from one flexible core.

A Go framework that turns annotated structs into a complete REST API: filtering, pagination, relations, soft-delete, and a composable middleware pipeline, all derived at runtime by reflection. No code generation.

Define a struct, register it, point it at a database. You get CRUD routes, query parsing, validation, an OpenAPI spec, and relation loading for free.

Features

  • Full CRUD with keyset pagination, filtering, sorting, and field projection
  • Relations by convention (BelongsTo, HasMany) populated via ?include=
  • Soft-delete, history versioning, and field-level encryption
  • A six-step pipeline (Auth, Deserialize, Validate, Service, DB, Response) with Before/After/Replace middleware
  • OpenAPI 3.1 and AsyncAPI 2.6 spec generation
  • Event bus, background jobs, WebSocket/SSE, and an admin panel
  • Pluggable backends: PostgreSQL and pure-Go SQLite (no CGo)
  • Minimal core: only chi and uuid; heavy integrations live in opt-in satellite modules

Install

go get github.com/xaleel/maniflex
go get github.com/xaleel/maniflex/db/sqlite

Quickstart

package main

import (
	"log"

	"github.com/xaleel/maniflex"
	"github.com/xaleel/maniflex/db/sqlite"
)

type Post struct {
	maniflex.BaseModel
	Title  string `json:"title"  mfx:"required,filterable,sortable"`
	Body   string `json:"body"   mfx:"required"`
	Status string `json:"status" mfx:"required,filterable,enum:draft|published|archived"`
}

func main() {
	server := maniflex.New(maniflex.Config{
		Port:        8080,
		PathPrefix:  "/api",
		AutoMigrate: true,
	})

	// Register models before opening the DB - the adapter needs the registry
	// to run migrations and resolve relations.
	server.MustRegister(Post{})

	db, err := sqlite.Open("./blog.db", server.Registry())
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	server.SetDB(db)

	log.Fatal(server.Start())
}

Post{} now has a full set of routes under /api:

curl -X POST localhost:8080/api/posts -d '{"title":"Hello","body":"...","status":"draft"}'
curl 'localhost:8080/api/posts?filter=status:eq:published&sort=created_at:desc&page=1&limit=10'

Documentation

Full documentation, guides, and the tutorial series are at maniflex.dev.

Modules

maniflex is a multi-module monorepo. The core carries only chi and uuid; each satellite module isolates one heavy dependency so you pull only what you import - db/postgres, db/sqlite, events/{kafka,nats,rabbitmq,redis}, jobs/redis, middleware/service/bcrypt, storage/s3, pkg/otel, and more.

Requirements

Go 1.25 or newer.

License

MIT

Documentation

Index

Constants

View Source
const (
	// FileACLPrivate is the default: the storage key is returned as-is.
	// Downloads go through the per-model attachment route (3B.3a) which
	// enforces the same auth as the parent record read.
	FileACLPrivate FileACLMode = "private"

	// FileACLSigned causes the Response step to replace the storage key with
	// a pre-signed URL valid for Config.FileSignedURLTTL (default 1 hour).
	// Requires FileStorage.URL() support on the configured backend.
	FileACLSigned FileACLMode = "signed"

	// FileACLPublic causes the Response step to replace the storage key with
	// a permanent public URL. LocalStorage returns a /files/<key> path;
	// S3Storage returns the object's public URL (requires public-read ACL on
	// the bucket or object).
	FileACLPublic FileACLMode = "public"

	// DefaultFileSignedURLTTL is the default TTL for signed URLs when
	// Config.FileSignedURLTTL is zero.
	DefaultFileSignedURLTTL = time.Hour

	// DefaultMaxUploadBytes caps the total size of a multipart/form-data request
	// when FilesConfig.MaxUploadBytes is zero. Without a ceiling the body is
	// unbounded: everything past the in-memory buffer spools to temp files, so a
	// single long stream can fill the disk (BUG-5).
	DefaultMaxUploadBytes int64 = 32 << 20 // 32 MB

	// DefaultMaxUploadMemory is how much of a multipart request is buffered in
	// memory before the remainder spools to temp files, when
	// FilesConfig.MaxUploadMemory is zero. It matches net/http's own default.
	DefaultMaxUploadMemory int64 = 32 << 20 // 32 MB
)
View Source
const ALPHANUM = UPPER + LOWER + DIGITS
View Source
const DIGITS = "0123456789"
View Source
const DefaultMaxConcurrentExports = 4

DefaultMaxConcurrentExports is the number of exports allowed to run at once when Config.MaxConcurrentExports is unset.

View Source
const DefaultMaxExportRows = 100_000

DefaultMaxExportRows is the row cap used when ModelConfig.MaxExportRows is unset.

View Source
const LOWER = "abcdefghijklmnopqrstuvwxyz"
View Source
const LOWER_D = LOWER + DIGITS
View Source
const LikeEscapeChar = `\`

LikeEscapeChar is the escape character in the patterns LikePattern builds. Every LIKE/ILIKE that consumes such a pattern must spell out ESCAPE '\': SQLite has no escape character by default and Postgres has a backslash, so saying it out loud is what makes the two agree.

View Source
const SingletonID = "singleton"

SingletonID is the fixed primary key of the single row backing a ModelConfig.Singleton model. The row is created with this id on first access and every GET/PATCH on the model addresses it.

View Source
const StatusClientClosedRequest = 499

StatusClientClosedRequest is the status recorded when the caller hangs up before the response is written — nginx's non-standard 499. Nothing reaches the client (the connection is already gone), but this is what access logs, metrics and any middleware reading the response status will see, so a disconnect is told apart from a genuine server-side timeout (504) rather than being counted as one.

View Source
const TABLE_NAME_PREFIX = ""
View Source
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
View Source
const UPPER_D = UPPER + DIGITS

Variables

View Source
var ErrFileNotFound = errors.New("file not found")

ErrFileNotFound is returned by FileStorage.Retrieve when the requested key does not exist.

View Source
var ErrNoAdapter = errNoAdapter("maniflex: no database adapter configured")

ErrNoAdapter is returned by BeginTx when no database adapter has been configured on the Server instance.

View Source
var ErrNotFound = errors.New("record not found")

ErrNotFound is returned by adapter methods when a requested record does not exist.

View Source
var ErrRawNotSupportedInTx = errors.New(
	"maniflex: the active transaction does not support raw SQL; " +
		"RawQuery/RawExec would run outside it")

ErrRawNotSupportedInTx is returned by RawQuery and RawExec when a transaction is active but its Tx implementation cannot run raw SQL. The statement is refused rather than run on the bare adapter: that would put it on a different connection, outside the transaction, where it would commit on its own and survive the rollback the caller believes covers it (BUG-12).

Functions

func AddComputedField

func AddComputedField[T any](s *Server, modelName, name string, fn func(ctx context.Context, record *T) (any, error)) error

AddComputedField registers a typed derived field: the callback receives the loaded record as a *T instead of a JSON map. It's the typed counterpart of (*Server).AddComputedField (typed-models migration, T4.5 / locked assumption §5). On read and list the record is the scanned *T; on create/update echo it is bridged best-effort from the stored row.

maniflex.AddComputedField(srv, "Product", "stock_level",
    func(ctx context.Context, p *Product) (any, error) {
        return stockService.CurrentLevel(ctx, p.ID)
    })

func Batch

func Batch(ctx *ServerContext, fn func(*Batcher) error) error

Batch executes fn inside a single database transaction shared by all Batcher operations. On success the transaction commits; on any error it rolls back.

Transaction ownership:

  • If ctx.Tx is nil, Batch opens a new transaction, commits on success, rolls back on fn error or ctx.Abort, and restores ctx.Tx to nil on exit.
  • If ctx.Tx is already set (e.g. from WithTransaction or a manual BeginTx), Batch joins it. It does not commit or roll back — the outer owner is responsible for lifecycle. fn returning an error still propagates up so the outer owner can react.

ctx.Tx and ctx.Ctx are updated to point at the batch transaction for the duration of fn, so any code inside fn that calls ctx.RawQuery, ctx.LockForUpdate, or ctx.GetModel also participates in the same transaction. Both fields are restored when Batch returns.

err := maniflex.Batch(ctx, func(b *maniflex.Batcher) error {
    inv, err := b.Create("Invoice", invoiceData)
    if err != nil { return err }
    for _, line := range lines {
        line["invoice_id"] = inv["id"]
        if _, err := b.Create("InvoiceLine", line); err != nil { return err }
    }
    return nil
})

func Bind

func Bind[T any](ctx *ServerContext) (*T, error)

Bind is For with an error instead of a bool, for handlers that require a typed body and want to return early on its absence.

func Create

func Create[T any](ctx *ServerContext, record *T) (*T, error)

Create inserts record (a *T) and returns the stored representation, scanned back into a field-populated *T.

func DecodeCursor

func DecodeCursor(token string) (value any, id string, err error)

DecodeCursor reverses EncodeCursor. JSON numbers are decoded as int64 when integral (else float64) so an integer cursor column compares as a number on both Postgres and SQLite rather than arriving as a lossy float.

func DefaultKeyGen added in v0.1.3

func DefaultKeyGen(_ *ServerContext, header *multipart.FileHeader) string

func Delete

func Delete[T any](ctx *ServerContext, id string) error

Delete removes (or soft-deletes) the T identified by id.

func EncodeCursor

func EncodeCursor(value any, id string) string

EncodeCursor builds the opaque token that points just past (value, id) in the keyset ordering. It is base64url(JSON) so it survives in a query string and carries no separator-collision risk.

func ExtraColumns

func ExtraColumns(record any) map[string]any

ExtraColumns returns the framework-internal extra columns staged on a record carrier (NULLs, driver-shaped scalars, *_hmac, populated includes), or nil. DB adapters call it from their write builders to append these columns. Transitional Phase-3 bridge; removed in Phase 7.

func For

func For[T any](ctx *ServerContext) (*T, bool)

For returns the request's typed record (*T) and true when one is bound to ctx.Record, or (nil, false) otherwise — e.g. a read with no body, a model whose body failed to decode, or a T that doesn't match the bound record.

func FormatDuration

func FormatDuration(d time.Duration) string

func LikePattern added in v0.1.5

func LikePattern(op FilterOperator, value any) string

LikePattern turns a user-supplied value into the LIKE pattern for one of the substring operators (contains, starts_with, ends_with). The LIKE metacharacters in the value — % and _, and the escape character itself — are escaped so they match literally, and the wildcards the operator implies are added around the result. Filtering for "50%" therefore finds the literal "50%" rather than everything beginning with 50.

Returns "" for any other operator. Exported so DB adapters in sub-packages can build the same pattern the core does.

func List

func List[T any](ctx *ServerContext, q *QueryParams) ([]*T, error)

List returns a page of T records. q may be nil (page 1, default limit).

func MapToRecord

func MapToRecord(model *ModelMeta, m map[string]any) (any, error)

MapToRecord is the exported transition bridge used by DB adapters to wrap a DB-column-keyed map in a *T carrier. Transitional Phase-3 bridge; removed in Phase 7.

func Migrate

func Migrate(s *Server, migrations []Migration) error

Migrate runs the supplied migrations against the server's DB adapter, recording each applied version in a schema_migrations table.

Migrations are sorted by Version and executed in order. A migration is skipped if its version already appears in schema_migrations, so Migrate is safe to call on every startup. Each migration runs inside its own transaction (unless NoTransaction is set); the version row is inserted in the same transaction so a crash mid-migration leaves the table in either the pre- or post-state, never partially applied.

Migrate is independent of AutoMigrate: AutoMigrate handles struct-driven CREATE/ALTER for green-field development, while Migrate is the path for data backfills, concurrent index builds, and coordinated multi-step deployments. Most production setups call AutoMigrate first (to ensure tables exist) and then Migrate (for the explicit, ordered changes).

if err := maniflex.Migrate(server, []maniflex.Migration{
    {Version: "0001_seed_roles", Up: seedRoles},
    {Version: "0002_backfill_slugs", Up: backfillSlugs},
}); err != nil {
    log.Fatal(err)
}

func Mount

func Mount(r chi.Router, services ...*Server)

Mount registers each Server instance on r at its configured PathPrefix. It lets multiple services share a single chi router so that shared middleware (logging, tracing, CORS) is applied once:

r := chi.NewRouter()
r.Use(myLoggingMiddleware, myTracingMiddleware)
maniflex.Mount(r,
    ordersService,    // PathPrefix: /api/orders
    inventoryService, // PathPrefix: /api/inventory
    ledgerService,    // PathPrefix: /api/ledger
)
http.ListenAndServe(":8080", r)

func PanicRecoverer

func PanicRecoverer(logger *slog.Logger) func(http.Handler) http.Handler

PanicRecoverer returns an HTTP middleware that catches panics, emits a structured slog log record, and writes a JSON error response consistent with the rest of the maniflex API — instead of the plain-text HTML page that chi's built-in Recoverer returns.

Every panic produces:

  1. A slog record at ERROR level with the fields: - method, path (request context) - request_id (from chi's RequestID middleware, when present) - panic (the recovered value as a string) - stack (full goroutine stack trace as a single string)

  2. An HTTP 500 response with body: {"error": {"code": "PANIC", "message": "internal server error"}}

The stack trace is intentionally omitted from the HTTP response — it is available in the log and must not be leaked to API clients.

One panic value is deliberately not recovered: http.ErrAbortHandler, which a handler panics with to abandon a response on purpose (httputil.ReverseProxy does this when an upstream dies mid-stream). It is re-panicked so net/http can close the connection silently, as the stdlib contract expects.

logger may be nil; when nil slog.Default() is used.

func PresentColumns

func PresentColumns(record any) map[string]struct{}

PresentColumns returns the set of DB column names the bridge recorded as present on a record carrier, or nil when none were recorded. Transitional Phase-3 bridge; removed in Phase 7.

func RandomString

func RandomString(length int, charset string) string

RandomString returns a cryptographically-secure random string of the given length, each character drawn uniformly (without modulo bias) from charset. It is safe for tokens, session IDs, and other secrets. charset is indexed by byte, so pass an ASCII charset such as ALPHANUM.

A non-positive length or an empty charset returns "". It panics only if the operating system's secure random source fails, which does not occur in normal operation.

func Read

func Read[T any](ctx *ServerContext, id string) (*T, error)

Read returns the single T identified by id, or maniflex.ErrNotFound.

func RecordToMap

func RecordToMap(model *ModelMeta, record any) map[string]any

RecordToMap is the exported transition bridge used by DB adapters to convert a returned *T back into the map the still-map pipeline consumes. Transitional Phase-3 bridge; removed in Phase 7.

func ResolveManyToManyForTest

func ResolveManyToManyForTest(reg *Registry) error

ResolveManyToManyForTest runs the M2M resolution pass. Exported for use in tests.

func RotateEncryptionKey

func RotateEncryptionKey(ctx context.Context, s *Server, modelName, oldKeyID, newKeyID string) (rotated int, err error)

RotateEncryptionKey re-encrypts every value of a model's encrypted fields from oldKeyID to newKeyID, one page of records at a time. Both keys must be active in the KeyProvider until rotation is confirmed complete.

The operation is not atomic across all rows: if interrupted, rows encrypted with oldKeyID and rows encrypted with newKeyID will coexist. Both keys must remain available until the function returns without error.

For large tables (millions of rows), run this as a background job (3C.3) rather than inline at startup.

Pagination uses keyset (cursor) traversal — `WHERE id > $last ORDER BY id` — so each page covers fresh rows even as Update mutates the table and the underlying adapter's natural order is unstable (e.g. Postgres without an explicit ORDER BY). Offset pagination over a mutating set silently skipped or revisited rows.

func SetExtra

func SetExtra(record any, m map[string]any)

SetExtra merges columns into a record carrier's extra map (populated includes, computed fields, _through). No-op for records without a BaseModel carrier.

func SetPresentColumns

func SetPresentColumns(record any, cols []string)

SetPresentColumns records, on a record carrier, the set of DB columns that were actually populated (e.g. the columns scanStruct read for a ?select= projection). The serializer emits only present columns. No-op for records without a BaseModel carrier.

func SplitCSV

func SplitCSV(s string) []string

SplitCSV splits a comma-separated string, trimming whitespace from each part. Exported so DB adapters in sub-packages can use it.

func Update

func Update[T any](ctx *ServerContext, id string, record *T) (*T, error)

Update writes record (a *T) over the row identified by id. It is a full-record update: every column (except id) is written. For partial/PATCH writes use the pipeline (HTTP PATCH) or ctx.GetModel(name).Update with a present map.

Types

type APIError

type APIError struct {
	Code    string `json:"code"`
	Message string `json:"message"`
	Details any    `json:"details,omitempty"`
}

APIError is the error body for non-2xx responses.

type APIResponse

type APIResponse struct {
	StatusCode int
	Data       any
	Error      *APIError
	Meta       *ResponseMeta
	AsIs       bool
	// Dir, when non-empty, adds {"_dir": Dir} to the response meta. For list
	// responses Meta already carries Dir via ResponseMeta.Dir; this field is
	// used for single-record responses (read/create/update) that have no
	// pagination meta — it produces {"data": ..., "meta": {"_dir": "rtl"}}.
	Dir string
}

APIResponse is the structured response written to the wire by the Response pipeline step.

func (*APIResponse) Write

func (r *APIResponse) Write(w http.ResponseWriter)

Write serialises the APIResponse and sends it to the HTTP ResponseWriter.

type ActionConfig

type ActionConfig struct {
	// Routing — path must use chi v5 {param} syntax, not :param.
	Method string // HTTP method: "GET", "POST", "PATCH", "PUT", "DELETE"
	Path   string // e.g. "/appointments/{id}/cancel"

	// Behaviour
	Handler    ActionHandlerFunc // required
	Middleware []MiddlewareFunc  // run between global Auth and Handler; nil is fine

	// ResponseMiddleware runs after Handler but before the Response step writes
	// the response — the per-action counterpart to Pipeline.Response. Use it to
	// post-process ctx.Response (headers, envelope shaping) for one action
	// without registering against the synthetic model name. nil is fine.
	ResponseMiddleware []MiddlewareFunc

	// OpenAPI metadata (all optional)
	Tags       []string // defaults to ["Actions"] when empty
	Summary    string
	Deprecated bool

	// OpenAPI schema hints (all optional)
	//
	// RequestBody describes the expected request body for POST/PUT/PATCH actions.
	// Use maniflex.JSONRequestBody(schema) to build one from an *OASSchema.
	//
	// Responses maps HTTP status codes to response body schemas.
	// A nil schema means no response body (suitable for 204 No Content).
	// When set, these replace the default generated responses entirely.
	//
	// Example:
	//   Responses: map[int]*maniflex.OASSchema{
	//     201: {Type: "object", Properties: map[string]*maniflex.OASSchema{"id": {Type: "string"}}},
	//     404: nil,
	//   },
	RequestBody *OASRequestBody
	Responses   map[int]*OASSchema

	// OpenAPI carries richer, optional OpenAPI metadata for this action,
	// most notably request/response schemas inferred from Go structs. It
	// complements the inline RequestBody / Responses fields above, which take
	// precedence when both are set. See ActionOpenAPI.
	OpenAPI ActionOpenAPI
}

ActionConfig configures one custom action endpoint.

type ActionHandlerFunc

type ActionHandlerFunc func(ctx *ServerContext) error

ActionHandlerFunc is the signature for custom action handler functions. The handler must either set ctx.Response or call ctx.Abort before returning nil. If neither is done, the Response step defaults to 200 OK with no body.

type ActionOpenAPI

type ActionOpenAPI struct {
	// RequestSchema is a Go struct value, pointer, or reflect.Type whose
	// exported fields (read via json + mfx tags) become an application/json
	// request body schema. Ignored when ActionConfig.RequestBody is set.
	RequestSchema any

	// ResponseSchema is a Go struct value, pointer, or reflect.Type reflected
	// into the success-response body schema. Ignored when ActionConfig.Responses
	// is set.
	ResponseSchema any

	// ResponseStatus is the HTTP status the ResponseSchema documents.
	// Defaults to 200 when zero.
	ResponseStatus int

	// Description is a long-form operation description (Markdown). Summary on
	// ActionConfig remains the short one-liner.
	Description string

	// QueryParams declares query-string parameters in addition to the path
	// parameters extracted automatically from the route.
	QueryParams []OASParameter

	// Security lists security requirement objects for the operation, e.g.
	// []map[string][]string{{"bearerAuth": {}}}. The referenced schemes must
	// be registered separately (see middleware/openapi.AddSecurityScheme).
	Security []map[string][]string
}

ActionOpenAPI holds optional OpenAPI documentation for a custom action that goes beyond the inline ActionConfig.RequestBody / Responses fields. Every field is optional and the zero value adds nothing to the spec.

The headline feature is schema inference: set RequestSchema / ResponseSchema to a Go struct value (or pointer) annotated with the same json + mfx tags as models, and the OpenAPI generator reflects it into an application/json schema — no hand-written *OASSchema required.

server.Action(maniflex.ActionConfig{
    Method: "POST",
    Path:   "/appointments/{id}/reschedule",
    OpenAPI: maniflex.ActionOpenAPI{
        RequestSchema:  RescheduleRequest{},  // {new_time string `mfx:"required"`}
        ResponseSchema: Appointment{},
        ResponseStatus: 200,
        QueryParams: []maniflex.OASParameter{{
            Name: "notify", In: "query",
            Schema: &maniflex.OASSchema{Type: "boolean"},
        }},
        Security: []map[string][]string{{"bearerAuth": {}}},
    },
    Handler: rescheduleHandler,
})

type AggregateField

type AggregateField struct {
	Op    AggregateOp
	Field string
	As    string
}

AggregateField selects one aggregate to compute. Field is the DB column to aggregate over; leave it empty for AggCount to mean COUNT(*). As overrides the result column alias; defaults to "<op>_<field>" (or "count" for COUNT(*)).

type AggregateOp

type AggregateOp string

AggregateOp is the SQL aggregate function applied to a column.

const (
	AggCount         AggregateOp = "count"
	AggCountDistinct AggregateOp = "count_distinct"
	AggSum           AggregateOp = "sum"
	AggAvg           AggregateOp = "avg"
	AggMin           AggregateOp = "min"
	AggMax           AggregateOp = "max"
)

type AggregateQuery

type AggregateQuery struct {
	Select  []AggregateField
	GroupBy []string
	Where   []*FilterExpr
	Having  []HavingClause
	OrderBy []SortExpr
	Limit   int
}

AggregateQuery is the input to ctx.Aggregate. All slices are optional except Select. GroupBy referenced columns must be DB column names on the model; HAVING aliases must match an entry in Select; OrderBy may reference either an aggregate alias or a GroupBy column.

type AsyncAPIChannel

type AsyncAPIChannel struct {
	Description string             `json:"description,omitempty"`
	Subscribe   *AsyncAPIOperation `json:"subscribe,omitempty"`
	Publish     *AsyncAPIOperation `json:"publish,omitempty"`
}

AsyncAPIChannel is a Channel Item Object. Each channel is one event type. The application sends, so events the client receives carry a `subscribe` operation (AsyncAPI 2.x perspective is the application's).

type AsyncAPIComponents

type AsyncAPIComponents struct {
	Messages map[string]*AsyncAPIMessage `json:"messages,omitempty"`
	Schemas  map[string]*OASSchema       `json:"schemas,omitempty"`
}

AsyncAPIComponents holds reusable messages and schemas.

type AsyncAPIConfig

type AsyncAPIConfig struct {
	Title   string
	Version string
	Servers []AsyncAPIServerConfig

	// Events declares custom event types and their payloads.
	Events []EventDoc

	// AutoModelEvents derives <model>.created|updated|deleted channels from the
	// model registry, using each model's struct as the message payload. This
	// mirrors the default events.Emit type naming; apps that customise
	// EmitConfig.Type should declare their events explicitly via Events instead.
	AutoModelEvents bool
}

AsyncAPIConfig is registered once via Server.RealtimeDoc to enable the {PathPrefix}/asyncapi.json endpoint. Title/Version default to the API name.

type AsyncAPIInfo

type AsyncAPIInfo struct {
	Title       string `json:"title"`
	Version     string `json:"version"`
	Description string `json:"description,omitempty"`
}

AsyncAPIInfo is the Info Object.

type AsyncAPIMessage

type AsyncAPIMessage struct {
	Name        string     `json:"name,omitempty"`
	Title       string     `json:"title,omitempty"`
	Summary     string     `json:"summary,omitempty"`
	Description string     `json:"description,omitempty"`
	ContentType string     `json:"contentType,omitempty"`
	Payload     *OASSchema `json:"payload,omitempty"`
}

AsyncAPIMessage is a Message Object. The payload reuses OASSchema because AsyncAPI 2.6 message payloads are JSON-Schema-shaped (the default schema format is a JSON Schema superset), so the same reflection that builds OpenAPI schemas serialises unchanged here.

type AsyncAPIMessageRef

type AsyncAPIMessageRef struct {
	Ref string `json:"$ref,omitempty"`
}

AsyncAPIMessageRef references a reusable message in components.messages.

type AsyncAPIOperation

type AsyncAPIOperation struct {
	OperationID string              `json:"operationId,omitempty"`
	Summary     string              `json:"summary,omitempty"`
	Description string              `json:"description,omitempty"`
	Message     *AsyncAPIMessageRef `json:"message,omitempty"`
}

AsyncAPIOperation is an Operation Object.

type AsyncAPIServer

type AsyncAPIServer struct {
	URL         string `json:"url"`
	Protocol    string `json:"protocol"`
	Description string `json:"description,omitempty"`
}

AsyncAPIServer is a Server Object (AsyncAPI 2.6 shape: url + protocol).

type AsyncAPIServerConfig

type AsyncAPIServerConfig struct {
	Name        string
	URL         string // e.g. "ws://localhost:8080/ws" or "http://localhost:8080/sse"
	Protocol    string // "ws", "wss", "sse", "http"
	Description string
}

AsyncAPIServerConfig describes one endpoint the realtime hub is mounted at. Name keys the server in the document (defaults to Protocol when empty).

type AsyncAPISpec

type AsyncAPISpec struct {
	AsyncAPI   string                     `json:"asyncapi"`
	Info       AsyncAPIInfo               `json:"info"`
	Servers    map[string]AsyncAPIServer  `json:"servers,omitempty"`
	Channels   map[string]AsyncAPIChannel `json:"channels"`
	Components AsyncAPIComponents         `json:"components,omitempty"`
}

AsyncAPISpec is the root AsyncAPI 2.6.0 document describing the realtime event channels a client can subscribe to over the WebSocket / SSE hub. It is the event-stream analogue of OpenAPISpec: clients codegen typed event payloads from it the same way they codegen REST types from /openapi.json.

func GenerateAsyncAPI

func GenerateAsyncAPI(reg RegistryAccessor, cfg *Config, asyncCfg AsyncAPIConfig) *AsyncAPISpec

GenerateAsyncAPI builds an AsyncAPI 2.6 document from the model registry and the supplied AsyncAPIConfig. It is called by the asyncapi.json handler and can be called directly to obtain the base document for customisation.

type AuthIdentityType

type AuthIdentityType string

AuthIdentityType classifies the principal behind an authenticated request.

const (
	// IdentityAnonymous is the zero value — the request is unauthenticated or
	// the identity type was not set by the auth middleware.
	IdentityAnonymous AuthIdentityType = ""

	// IdentityHuman represents an interactive end-user (employee, patient, admin).
	IdentityHuman AuthIdentityType = "human"

	// IdentityServiceAccount represents a machine caller (background job,
	// service-to-service, API integration).
	IdentityServiceAccount AuthIdentityType = "service_account"
)

type AuthInfo

type AuthInfo struct {
	// Core identity — present in every auth scheme.
	UserID string
	Roles  []string
	Claims map[string]any // raw token claims or equivalent; always non-nil when Auth is set

	// Multi-tenancy — the partition key for the authenticated principal.
	// Set by JWTAuth when JWTOptions.TenantClaim is configured, or directly
	// by any auth middleware (subdomain tenancy, X-Tenant-ID header, etc.).
	TenantID string

	// IdentityType classifies the caller. Zero value (IdentityAnonymous) means
	// the type was not determined; treat it as a human for backward compatibility.
	IdentityType AuthIdentityType

	// Scopes holds the OAuth2 scopes granted to the token.
	// Populated by JWTAuth from the configured ScopesClaim (default "scope").
	Scopes []string

	// SessionID is the JWT "jti" claim or session token ID. Empty for API-key
	// callers. Useful for read audit trails that need to record which session
	// accessed a record.
	SessionID string

	// AuthMethod describes how the caller authenticated.
	// JWTAuth sets this to "jwt"; APIKeyAuth sets it to "api_key".
	// Conventional values: "jwt", "api_key", "session", "oauth2".
	AuthMethod string
}

AuthInfo carries identity information set by the Auth pipeline step. Populate it inside your auth middleware via ctx.Auth = &maniflex.AuthInfo{...}

type BaseModel

type BaseModel struct {
	ID        string    `json:"id"         db:"id"`
	CreatedAt time.Time `json:"created_at" db:"created_at" mfx:"readonly,sortable"`
	UpdatedAt time.Time `json:"updated_at" db:"updated_at" mfx:"readonly,sortable"`
	// contains filtered or unexported fields
}

BaseModel provides the standard id / created_at / updated_at columns. Embed it in every model struct you register, else registering model fails `CreatedAt` and `UpdatedAt` are auto-injected. If edited here, make sure the names are edited in the `injectTimestamp` function

type Batcher

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

Batcher provides transactional CRUD access to registered models inside a maniflex.Batch call. Do not construct directly — obtain via maniflex.Batch.

func (*Batcher) Create

func (b *Batcher) Create(model string, data map[string]any) (map[string]any, error)

Create inserts a new record for model and returns the stored representation.

func (*Batcher) Delete

func (b *Batcher) Delete(model string, id string) error

Delete removes (or soft-deletes) the record identified by id from model. Returns maniflex.ErrNotFound when absent.

func (*Batcher) List

func (b *Batcher) List(model string, q *QueryParams) ([]map[string]any, error)

List returns a page of records from model matching q. q may be nil (defaults to page 1, limit 20, no filters or sorts).

func (*Batcher) Model

func (b *Batcher) Model(name string) *ModelAccessor

Model returns a *ModelAccessor for modelName bound to the batch transaction. All five CRUD operations on the accessor participate in the same transaction. The accessor records an error on first use when modelName is not registered, or when modelName routes to a different adapter than the batch transaction (a single transaction cannot span databases).

func (*Batcher) Read

func (b *Batcher) Read(model string, id string) (map[string]any, error)

Read returns the record identified by id from model. Returns maniflex.ErrNotFound when the record does not exist.

func (*Batcher) Update

func (b *Batcher) Update(model string, id string, data map[string]any) (map[string]any, error)

Update applies a partial patch to the record identified by id in model and returns the updated representation. Returns maniflex.ErrNotFound when absent.

type CacheStore

type CacheStore interface {
	Get(ctx context.Context, key string) (any, bool)
	Set(ctx context.Context, key string, value any, ttl time.Duration)
	Delete(ctx context.Context, key string)
}

CacheStore is a generic, TTL-aware key/value cache shared by middleware that needs cross-request memoisation (idempotency keys, rate-limit windows, per-user response caches, etc.). Implementations must be safe for concurrent use.

Get returns ok=false when no entry exists for the key or it has expired. Set persists value under key for at most ttl. Delete removes the entry under key; it is a no-op when the key is absent.

type ComputedField

type ComputedField struct {
	Name string
	Fn   ComputedFunc
	// contains filtered or unexported fields
}

ComputedField is one registered virtual field on a model. Name is the JSON key it appears under in responses; collisions with real model fields are rejected at registration. Computed fields cannot be filtered or sorted — they're materialised only on the way out.

type ComputedFunc

type ComputedFunc func(ctx context.Context, row map[string]any) (any, error)

ComputedFunc derives a virtual field value from the loaded row. It runs in the Response step after the DB row has been converted to JSON keys, so the `row` map keys are JSON field names.

A non-nil error is logged and the field is omitted from the response — a computed-field failure must not poison the whole record.

type Config

type Config struct {
	// Port the HTTP server listens on. Default: 8080.
	Port int

	// PathPrefix is prepended to every generated route. Default: "/api".
	PathPrefix string

	// StaticDir is the filesystem directory served as static files, or "" to
	// serve none. Static serving is opt-in: an empty StaticDir mounts nothing.
	// (It used to fall back to "<cwd>/static", which silently published any
	// static/ directory that happened to be in the working tree.) A relative
	// path is resolved against the working directory; a named directory that
	// does not exist is skipped with a warning, not an error. Ignored when
	// StaticDisabled is true.
	//
	// Files are served verbatim under StaticPrefix: with StaticDir "public" and
	// the default prefix, public/app.js is reachable at /static/app.js, and a
	// single-page app under public/admin/ (with its own index.html) is served in
	// full at /static/admin/, nested assets included.
	StaticDir string

	// StaticPrefix is the URL path prefix under which StaticDir is served.
	// Default: "/static". Unlike model routes it is mounted at the router root,
	// NOT under PathPrefix.
	StaticPrefix string

	// StaticDisabled turns off static file serving even when StaticDir is set.
	// It exists so an app that configures StaticDir unconditionally can still
	// flip serving off from an env var or flag without clearing the field.
	StaticDisabled bool

	// MaxConcurrentExports caps how many export requests may run at once across
	// the whole server. 0 means DefaultMaxConcurrentExports (4); a negative value
	// removes the limit.
	//
	// An export holds its entire result set in memory — up to the model's
	// MaxExportRows (default 100,000) records — for as long as it takes to write
	// the body to the client. MaxExportRows bounds the row count of one export
	// but not how wide a row is, nor how many exports run together, so a handful
	// of concurrent exports of a wide model is enough to exhaust the heap. This
	// bounds the product: peak export memory is at most this many result sets.
	//
	// A request arriving when every slot is taken is rejected immediately with
	// 503 EXPORT_BUSY and a Retry-After header rather than queued, so it fails
	// fast instead of adding to the pile. The limit is server-wide, not
	// per-model: the heap it protects is shared.
	//
	// The slot is taken before the pipeline runs, so it is held across the DB
	// read and the write — the whole window the rows are live — and released
	// when the request returns.
	MaxConcurrentExports int

	// TrustProxyHeaders controls whether the client IP is derived from the
	// X-Forwarded-For / X-Real-IP request headers (via chi's RealIP middleware).
	// It is OFF by default: RemoteAddr stays the real TCP peer, so a client
	// cannot forge its own address.
	//
	// Every IP-keyed feature reads the resolved RemoteAddr — per-IP rate limiting
	// (db.RateLimit / db.RateLimitAction), idempotency scoping, and read-audit
	// records. With this flag off they key on the direct peer; with it on they
	// key on the forwarded client IP.
	//
	// Enable it ONLY when the server sits behind a trusted reverse proxy or load
	// balancer that (a) sets X-Forwarded-For to the real client and (b) strips any
	// inbound XFF sent by the client. Turning it on while directly internet-facing
	// lets an attacker spoof its address with an X-Forwarded-For header, defeating
	// per-IP rate limits and poisoning audit logs (SEC-5).
	TrustProxyHeaders bool

	// ServiceName identifies this service in logs, audit records, and outgoing
	// requests. When set:
	//   - every framework log line gains a "service" attribute,
	//   - every audit record's ServiceName field is populated,
	//   - the X-Service-Name response header is set on every response.
	// When empty (the default) none of the above happens — zero behavioural
	// change for callers that don't configure it.
	ServiceName string

	// DB is the database adapter to use. Required before calling Start().
	DB DBAdapter

	// DisableAutoMigrate turns off schema creation/migration on Start() and
	// MigrateOnly(). Migration runs by default; set this to true to skip it (e.g.
	// when migrations are managed out of band). Replaces the old AutoMigrate bool,
	// whose zero value (false) could not honour the documented "default on".
	DisableAutoMigrate bool

	// ShutdownTimeout is the maximum duration Start() waits for in-flight
	// requests to complete after a shutdown signal is received before forcing
	// connections closed. Default: 30s.
	//
	// Set a shorter value for fast-cycling environments (e.g. tests, lambdas),
	// and a longer value when requests may take several seconds (e.g. bulk
	// imports, large file uploads).
	ShutdownTimeout time.Duration

	// ReadHeaderTimeout bounds how long a connection may take to send its request
	// headers. Default: 10s. This is the slowloris defence: without it a client
	// can hold a connection open indefinitely by dribbling one header byte at a
	// time, and enough such connections exhaust the server's file descriptors
	// without a single request ever reaching the pipeline.
	//
	// Set a negative value to disable (net/http's unbounded default). Only do that
	// behind a proxy that already bounds header reads.
	ReadHeaderTimeout time.Duration

	// IdleTimeout bounds how long a keep-alive connection may sit idle between
	// requests before the server closes it. Default: 120s. Set a negative value to
	// disable, in which case idle connections are bounded by ReadTimeout instead
	// (and if that is unset too, they are never closed).
	IdleTimeout time.Duration

	// ReadTimeout bounds the time taken to read an entire request, headers and
	// body. Zero (the default) means unbounded: a deadline here caps how long a
	// client may take to upload, so a large file over a slow link is cut off
	// mid-transfer. Set it when you know your request sizes; the header phase is
	// covered by ReadHeaderTimeout regardless.
	ReadTimeout time.Duration

	// WriteTimeout bounds the time taken to write a response. Zero (the default)
	// means unbounded, deliberately: the deadline covers the whole response, so
	// any value at all would sever a long-lived stream — realtime.SSEHandler,
	// a large download — at that mark. Set it only on a server with no streaming
	// endpoints.
	WriteTimeout time.Duration

	// Logger is the slog.Logger used for all framework-level log output:
	// server lifecycle messages (startup, shutdown, migration), per-request
	// logs emitted via ServerContext.Logger(), and DB adapter messages such as
	// AutoMigrate column-drift warnings.
	//
	// When nil, slog.Default() is used, which writes plain-text lines to
	// stderr. Set an explicit logger to route output to a JSON handler, a
	// remote aggregator, or a test capture handler.
	Logger *slog.Logger

	// PanicLogger is the slog.Logger used by the panic recovery middleware.
	// Every unhandled panic is logged at ERROR level with structured fields:
	// method, path, request_id, panic value, and full stack trace.
	//
	// When nil, Logger is used (falling back to slog.Default() if Logger is
	// also nil). Set PanicLogger explicitly only when panics must be routed
	// to a different sink than the rest of the framework logs.
	PanicLogger *slog.Logger

	// Trace configures pipeline tracing for debug purposes.
	// Set Trace.Enabled to true to activate all standard trace output (step
	// enter/exit, timings, and abort call sites). Individual sub-flags allow
	// finer control — see PipelineTrace for details.
	//
	// All trace output is emitted at DEBUG level through Config.Logger, so it
	// is invisible unless the logger's handler accepts DEBUG records.
	Trace PipelineTrace

	// QueryTimeout is the maximum duration allowed for a single request's
	// database operations. When non-zero a context.WithTimeout deadline is
	// attached to ServerContext.Ctx before the pipeline runs, so every DB adapter
	// call made during the request — including calls from middleware — inherits
	// the same deadline.
	//
	// If a query exceeds this deadline the DB step returns HTTP 504 with error
	// code "TIMEOUT" instead of the usual 500.
	//
	// Zero (the default) means no per-request timeout is applied; ctx.Ctx
	// carries the HTTP request context as-is, which has no deadline unless the
	// client disconnects.
	//
	// Typical values:
	//   5s  — tight APIs where every response must be fast
	//   30s — general OLTP; catches runaway queries without affecting normal use
	//   0   — no timeout (legacy / migration path)
	QueryTimeout time.Duration

	// HealthCheckDB controls whether GET /health pings the database.
	//
	// When false (the default) the endpoint always returns {"status":"ok"} with
	// HTTP 200 — matching the previous behaviour and suitable for liveness
	// probes that only need to know the process is alive.
	//
	// When true the handler calls db.Ping() with a HealthTimeout deadline:
	//   - On success: HTTP 200  {"status":"ok",      "db":"ok"}
	//   - On failure: HTTP 503  {"status":"degraded","db":"error","error":"..."}
	//
	// Enable this for Kubernetes readiness probes so the pod is only marked
	// ready once it can actually reach the database.
	HealthCheckDB bool

	// DBWriteURL is the DSN/connection-string for the primary (write) database.
	// Not used by the framework directly — set Config.DB with the adapter you
	// open from this URL. Populated by ConfigFromEnv.
	DBWriteURL string

	// DBReadURL is the DSN/connection-string for the read replica. Pass an empty
	// string to route reads to the write primary. Populated by ConfigFromEnv.
	DBReadURL string

	FilesConfig FilesConfig

	// KeyProvider is the encryption key provider for mfx:"encrypted" fields.
	// When nil, any attempt to create or update a record with encrypted fields
	// will be rejected with HTTP 500 ENCRYPTION_NOT_CONFIGURED.
	// Reads of already-encrypted values return the raw stored ciphertext string.
	//
	// Use pkg/encryption.EnvKeyProvider for keys in environment variables, or
	// pkg/encryption.VaultKeyProvider for HashiCorp Vault Transit.
	KeyProvider KeyProvider

	// HealthTimeout is the maximum time the health handler waits for the
	// database ping to complete. Only used when HealthCheckDB is true.
	// Default: 3s.
	//
	// Choose a value smaller than your probe's timeoutSeconds so the handler
	// can return a clean 503 before the probe itself times out:
	//
	//   readinessProbe:
	//     httpGet:
	//       path: /api/health
	//     timeoutSeconds: 5        # probe timeout
	//   # → set HealthTimeout to 3s so 503 arrives before 5s probe timeout
	HealthTimeout time.Duration

	// OnStart is a lightweight lifecycle hook run once during boot, after
	// migration and DB-ready but before the HTTP listener opens — the same slot
	// as Service.Start, and ahead of any registered services. A non-nil error
	// aborts boot exactly like a failed migration. The ctx is cancelled when
	// shutdown begins.
	//
	// Use it for callers that want a start hook without defining a Service type;
	// for components that also need an ordered Stop, register a Service instead.
	OnStart func(ctx context.Context) error

	// OnShutdown is the symmetric hook run once during graceful shutdown, after
	// the HTTP listener has drained and all services have stopped. The ctx is a
	// fresh deadline context bounded by ShutdownTimeout. A returned error is
	// logged but does not change the shutdown outcome.
	OnShutdown func(ctx context.Context) error
}

Config is the top-level configuration passed to New().

func ConfigFromEnv

func ConfigFromEnv(prefix string) (Config, error)

ConfigFromEnv builds a Config by reading standard environment variables.

If prefix is non-empty (e.g. "ORDERS"), each variable is prefixed with an underscore separator: ORDERS_PORT, ORDERS_DB_WRITE_URL, etc. If prefix is empty the variables are read without a prefix: PORT, DB_WRITE_URL, etc.

Variables read:

PREFIX_PORT               → Config.Port             (integer, 1-65535)
PREFIX_DB_WRITE_URL       → Config.DBWriteURL       (string)
PREFIX_DB_READ_URL        → Config.DBReadURL        (string)
PREFIX_QUERY_TIMEOUT_MS   → Config.QueryTimeout     (positive integer, milliseconds)
PREFIX_SHUTDOWN_TIMEOUT_S → Config.ShutdownTimeout  (positive integer, seconds)
PREFIX_SERVICE_NAME       → Config.ServiceName      (string)
PREFIX_HEALTH_CHECK_DB    → Config.HealthCheckDB    (bool: true/false, 1/0, yes/no, on/off)

A variable that is unset or empty is left at its zero value, for Config.ApplyDefaults to fill in later. A variable that is *set but unreadable* is an error: it names the variable and the value it could not parse, and every such variable is reported at once, so two typos take one deploy to find rather than two. Nothing is silently ignored — a mistyped PORT should stop the process, not leave it listening on 8080 and looking healthy.

ApplyDefaults is NOT called here; callers may customise further before passing the Config to maniflex.New, which applies them.

cfg, err := maniflex.ConfigFromEnv("ORDERS")
if err != nil {
    log.Fatal(err) // e.g. maniflex: ORDERS_PORT: invalid integer "808O"
}

func (*Config) ApplyDefaults

func (c *Config) ApplyDefaults()

type ConstraintKind added in v0.1.2

type ConstraintKind string

ConstraintKind classifies a database constraint violation so the DB step can map it to the right HTTP status: unique / foreign-key → 409 Conflict, and not-null → 422 Validation. The zero value ("") is treated as a generic conflict, preserving the original behaviour for normalisers that don't set it.

const (
	ConstraintUnique     ConstraintKind = "unique"
	ConstraintForeignKey ConstraintKind = "foreign_key"
	ConstraintNotNull    ConstraintKind = "not_null"
)

type CursorParams

type CursorParams struct {
	// Field is the DB column the cursor walks (resolved from the model).
	Field string
	// Direction is the keyset walk direction; the id tiebreaker uses the same
	// direction so the ORDER BY and the boundary predicate stay consistent.
	Direction SortDir

	// HasToken is false on the first page (?cursor= with an empty value) and
	// true once a token from a previous page is supplied. When false the bound
	// predicate is omitted and the walk starts from the first row.
	HasToken   bool
	AfterValue any    // decoded cursor field value of the last row of the previous page
	AfterID    string // decoded id of the last row of the previous page (tiebreaker)

	// NextCursor is the token to fetch the next page, set by the adapter from the
	// last returned row. Empty when the page was the last one (HasMore == false).
	NextCursor string
	// HasMore reports whether at least one more row exists after this page.
	HasMore bool
}

CursorParams carries the state for one keyset (cursor) paginated list request. It is set on QueryParams.Cursor by ParseQueryParams when the request opts into cursor mode via ?cursor= on a model that declares a cursor field (mfx:"cursor_field:..." or ModelConfig.CursorField).

Keyset pagination orders by (cursor field, id) so the boundary is total even when the cursor field has ties, then walks the dataset with a WHERE bound instead of OFFSET — so it never skips or duplicates rows when rows are inserted or deleted between page fetches.

The Field/Direction/After* members are inputs filled from the request; the DB adapter writes NextCursor/HasMore back as outputs after fetching the page.

type DBAdapter

type DBAdapter interface {
	// AutoMigrate creates or updates tables/collections for the given models.
	// Called once on Server.Start() unless Config.DisableAutoMigrate is set.
	AutoMigrate(ctx context.Context, reg RegistryAccessor) error

	// BeginTx starts a database transaction with the given options.
	// Pass nil opts for the default isolation level.
	// The returned Tx must be committed or rolled back by the caller.
	BeginTx(ctx context.Context, opts *TxOptions) (Tx, error)

	// Run arbitrary SQL
	Raw(ctx context.Context, query string, args ...any) RawResult

	// FindByID returns the record (*T) matching id.
	// Includes listed in q.Includes are populated as nested objects.
	// Returns ErrNotFound when the record does not exist.
	FindByID(ctx context.Context, model *ModelMeta, id string, q *QueryParams) (any, error)

	// FindByIDForUpdate fetches the record (*T) and acquires a pessimistic
	// row-level lock. Postgres: appends FOR UPDATE; use inside a transaction so
	// the lock is held until commit/rollback. SQLite: behaves like FindByID —
	// the lock is acquired at the transaction level, at BEGIN IMMEDIATE.
	// Returns ErrNotFound when the record does not exist.
	FindByIDForUpdate(ctx context.Context, model *ModelMeta, id string) (any, error)

	// FindMany returns a page of records ([]any of *T) and the total count
	// before pagination.
	FindMany(ctx context.Context, model *ModelMeta, q *QueryParams) ([]any, int64, error)

	// Create inserts the record (*T). The adapter generates the ID if absent.
	// Returns the stored representation (*T) including generated fields.
	// Returns *ErrConstraint on unique/check violations.
	Create(ctx context.Context, model *ModelMeta, record any) (any, error)

	// Update applies a patch to the record identified by id. record is a *T
	// carrying the new values; present names the DB columns to write (PATCH
	// semantics) — only those (plus updated_at) are updated. Returns the updated
	// representation (*T). Returns ErrNotFound when absent, *ErrConstraint on
	// unique/check violations.
	Update(ctx context.Context, model *ModelMeta, id string, record any, present map[string]struct{}) (any, error)

	// Delete removes or soft-deletes the record identified by id.
	// Returns ErrNotFound when absent.
	Delete(ctx context.Context, model *ModelMeta, id string) error

	// Close releases any resources held by the adapter.
	Close() error
}

DBAdapter is the interface all database backends must implement. Methods receive a *ModelMeta describing the target model and a *QueryParams carrying pagination, filters, sorts, and relation includes.

type DriverType

type DriverType int

DriverType distinguishes SQL dialects used by DBAdapter implementations.

const (
	Postgres DriverType = iota
	SQLite
)

type ErrConstraint

type ErrConstraint struct {
	// Kind classifies the violation (unique, foreign_key, not_null). Empty for
	// normalisers that predate the field; treated as a generic conflict.
	Kind ConstraintKind
	// Table is the DB table name where the violation occurred.
	Table string
	// Column is the column name that was violated, if the driver exposes it.
	// May be empty for drivers that do not provide column-level detail.
	Column string
	// Detail is the raw driver error message, for logging context.
	Detail string
}

ErrConstraint is returned by adapter methods when a database constraint is violated. It is driver-neutral: SQLite "UNIQUE constraint failed: table.column" / "NOT NULL constraint failed: table.column" and Postgres error codes (23505, 23502, 23503) are normalised into this type before being returned to the DB step, which converts it to a 409 Conflict (unique/FK) or a 422 Validation error (not-null).

func (*ErrConstraint) Error

func (e *ErrConstraint) Error() string

type EventDoc

type EventDoc struct {
	Type        string // CloudEvents type / topic, e.g. "invoice.created"
	Title       string
	Description string
	Payload     any
}

EventDoc documents one realtime event type's payload for AsyncAPI generation. Payload accepts a struct value, pointer, or reflect.Type annotated with the same json + mfx tags as models; it is reflected into a JSON schema exactly like an action's RequestSchema/ResponseSchema (10.8).

type ExportFormat

type ExportFormat string

ExportFormat selects the wire format the export endpoint produces.

const (
	ExportFormatCSV  ExportFormat = "csv"
	ExportFormatXLSX ExportFormat = "xlsx"
)

type FieldMeta

type FieldMeta struct {
	Name  string       // Go struct field name
	Type  reflect.Type // Go type
	Tags  FieldTags    // parsed mfx/json/db tags
	Index []int        // reflect index path (supports embedded structs)
}

FieldMeta describes one scalar (non-relation) DB column field.

type FieldTags

type FieldTags struct {
	// mfx tag directives
	Required   bool     // must be present in create requests
	Readonly   bool     // stripped from all write operations
	Immutable  bool     // settable on create, rejected on update
	Filterable bool     // may be used in ?filter= queries
	Sortable   bool     // may be used in ?sort= queries
	Hidden     bool     // excluded from all API responses
	WriteOnly  bool     // accepted on write, excluded from responses (e.g. password)
	Unique     bool     // hint to DB adapter to add UNIQUE constraint
	Index      bool     // mfx:"index" → CREATE INDEX on the column in AutoMigrate
	Searchable bool     // included in full-text search (if supported)
	Enum       []string // allowed values, e.g. mfx:"enum:draft|published"
	Min        *float64 // numeric minimum
	Max        *float64 // numeric maximum
	Default    string   // automatically cast to corresponding type (if possible)

	// Relation options — set via mfx:"relation:RelationName;onDelete:cascade"
	// Relation names the companion struct field that carries the target model type.
	// When set this FK is an explicit relation; when empty the legacy ID-suffix
	// convention is used instead.
	Relation string
	OnDelete OnDeleteAction

	// RelationInfer is set by a bare mfx:"relation" tag. It marks a scalar FK field
	// as a BelongsTo whose target is inferred from the field name (the "ID" suffix
	// is stripped, e.g. AuthorID → Author). Use mfx:"relation:Target" instead when
	// the field name doesn't match the target model.
	RelationInfer bool

	// Deprecated: NoRelation (mfx:"norelation") is a no-op. Relations are no longer
	// inferred from a field's "ID" suffix — tag the field mfx:"relation" (or
	// mfx:"relation:Target") to opt IN instead. The tag is still parsed so existing
	// models compile.
	NoRelation bool

	// Through is set on []SliceFields to declare a many-to-many relation via a
	// junction model. Value is the junction model struct name, e.g. "ProductTag".
	// mfx:"through:ProductTag"
	Through string

	// Encryption options — set via mfx:"encrypted" or mfx:"encrypted,key:patient-pii"
	// Encrypted marks the field for transparent AES-256-GCM encryption at rest.
	// Values are stored as "enc:<base64(envelope)>" and decrypted on read.
	// Filtering and sorting on encrypted fields is not supported.
	// If Unique is also set, a companion {field}_hmac column enforces uniqueness
	// without exposing the plaintext.
	Encrypted     bool
	EncryptionKey string // key name passed to KeyProvider; defaults to "default"

	// File upload options — set via mfx:"file,max_size:10MB,accept:image/*"
	// File marks this field as a file upload field. The DB column stores the
	// storage key (string). When true, multipart form-data is automatically
	// accepted for create/update operations on models containing this field.
	File bool
	// MaxSize is the maximum allowed file size in bytes. Parsed from
	// mfx:"max_size:10MB". Zero means no per-field limit.
	MaxSize int64
	// Accept is a list of allowed MIME type patterns, e.g. ["image/*", "application/pdf"].
	// Parsed from mfx:"accept:image/*|application/pdf".
	Accept []string
	// AutoDelete controls whether the file is automatically deleted from storage
	// when the record is hard-deleted or the field value is replaced by an update.
	// Default: true when File is set. Set to false with mfx:"auto_delete:false".
	AutoDelete bool
	// FileACL controls how the field value is presented in API responses.
	// Parsed from mfx:"file_acl:private|signed|public". Default: FileACLPrivate.
	// FileACLSigned replaces the storage key with a pre-signed URL.
	// FileACLPublic replaces the storage key with a permanent public URL.
	FileACL FileACLMode

	// Locale marks this field as a bilingual storage field. The Go type must
	// be maniflex.LocaleString. Stored as TEXT (SQLite) or JSONB (Postgres).
	// Response representation is controlled by LocaleMode (default: split).
	Locale bool

	// LocaleMode overrides the response representation for this specific field.
	// When empty the mode is inherited from ModelConfig.DefaultLocaleMode, then
	// LocaleOptions.DefaultLocaleMode, then split (framework default).
	// Valid values: "split", "resolve", "dynamic".
	LocaleMode LocaleMode

	// LocaleModeConflict is set when more than one of split/resolve/dynamic
	// appears in the same mfx tag. ScanModel rejects such fields at registration.
	LocaleModeConflict bool

	// LocaleDefault is the per-field fallback locale used when the client did
	// not request a specific locale. Only meaningful in resolve/split mode.
	// e.g. mfx:"locale,default_locale:ar" makes Arabic the field-level default.
	LocaleDefault string

	// LockWhen carries conditions parsed from mfx:"lock_when:field=value"
	// directives. Multiple occurrences on the same field accumulate. At
	// registration these per-field lists are flattened into ModelMeta.LockWhen
	// — the conditions reference the record state, not the field they're
	// declared on, so the declaration site is incidental.
	LockWhen []LockCondition

	// LockScope names a registered model whose row must be locked FOR UPDATE
	// before a create. Parsed from mfx:"lock_scope:ModelName".
	// The field value is used as the referenced row ID.
	// Requires an active transaction (maniflex.WithTransaction).
	LockScope string

	// CursorField opts the model into keyset (cursor) pagination and names the
	// column the cursor walks. Parsed from mfx:"cursor_field:created_at". The
	// value is the JSON or DB name of the cursor field; ScanModel resolves it to
	// a DB column and stores it on ModelMeta.CursorField. Declaring it on any one
	// field (typically the embedded BaseModel) enables the model — the value, not
	// the declaration site, picks the cursor column.
	CursorField string

	// Scheduled-operation directive (8.6). Scheduled is the on/off switch; the
	// rest is meaningful only when Scheduled is true. The action flags are NOT
	// mutually validated here — parseScheduledTag records exactly what was
	// written; ScanModel resolves and rejects (see "No implicit action").
	Scheduled    bool   // mfx:"scheduled" present
	SchedSoft    bool   // ;soft-delete
	SchedHard    bool   // ;hard-delete
	SchedField   string // ;field=
	SchedFrom    string // ;from=
	SchedTo      string // ;to=
	SchedHasFrom bool   // distinguishes ;from= absent vs. from="" given
	SchedHasTo   bool   // distinguishes ;to=   absent vs. to=""   given
	SchedBadOpt  string // first unrecognised option, "" if none — ScanModel errors on it

	// Derived names
	JSONName  string
	DBName    string
	OmitEmpty bool
	Ignore    bool // db:"-" or mfx:"-" (excludes the field from persistence — no column)
}

FieldTags holds every directive that can appear in a `mfx:"..."` struct tag, plus the derived JSON and DB column names.

type FileACLMode

type FileACLMode string

FileACLMode controls how file field values are presented in API responses.

type FileMeta

type FileMeta struct {
	Key         string `json:"key"`
	ContentType string `json:"content_type"`
	Size        int64  `json:"size"`
	Filename    string `json:"filename"`
}

FileMeta describes an uploaded file's metadata.

type FileStorage

type FileStorage interface {
	// Store writes the contents of r to the given key with the supplied metadata.
	// The key is framework-generated; implementations must create any intermediate
	// directories or object prefixes as needed.
	Store(ctx context.Context, key string, r io.Reader, meta FileMeta) error

	// Retrieve returns a ReadCloser for the file at key, along with its metadata.
	// Returns ErrFileNotFound when the key does not exist.
	Retrieve(ctx context.Context, key string) (io.ReadCloser, FileMeta, error)

	// Delete removes the file at key from storage. Implementations should
	// return ErrFileNotFound when the key does not exist so the standalone
	// DELETE /files/* handler can translate that into a 404. Returning nil
	// for a missing key is also acceptable for backends that cannot detect
	// it atomically (notably some S3-compatible APIs); callers must treat
	// both as "delete succeeded" except where a 404 is specifically wanted.
	Delete(ctx context.Context, key string) error

	// Exists reports whether the file at key exists in storage.
	Exists(ctx context.Context, key string) (bool, error)

	// URL returns a URL suitable for the given access mode.
	// For FileACLSigned, ttl is how long the URL remains valid; the backend
	// must return an error if it cannot produce time-limited URLs.
	// For FileACLPublic, ttl is 0 and a permanent (or very long-lived) URL
	// is returned.
	// LocalStorage returns a server-relative /files/<key> path for both modes.
	// S3Storage returns a presigned GET URL using the AWS SDK presigner.
	URL(ctx context.Context, key string, ttl time.Duration) (string, error)
}

FileStorage is the interface all file storage backends must implement. It is analogous to DBAdapter for database operations.

Implementations:

  • storage.LocalStorage — disk-based, ships with maniflex
  • Bring your own: S3, R2, GCS, etc.

type FilesConfig added in v0.1.3

type FilesConfig struct {
	// Storage is the storage backend for file uploads. When nil, models with
	// mfx:"file" fields reject multipart uploads with 501, and the standalone
	// /files endpoints (if mounted) return 501. Set before calling Start(), or
	// use SetStorage() for two-step init.
	//
	// Use storage.NewLocalStorage(path) for disk-based storage, or implement
	// the FileStorage interface for S3, R2, GCS, etc.
	Storage FileStorage

	// MountEndpoints controls whether the standalone /files routes (POST /files,
	// GET /files/*, DELETE /files/*) are mounted. It defaults to false and is
	// NOT implied by setting Storage — you must opt in explicitly.
	//
	// Footgun: setting only Storage enables per-model attachment routes and
	// multipart handling on mfx:"file" fields, but leaves the standalone /files
	// endpoints unmounted (requests 404). If you relied on the pre-FilesConfig
	// behaviour where a non-nil FileStorage auto-mounted /files, set this true.
	//
	// Mounting with Storage still nil is allowed: the routes exist but every
	// request returns 501 NO_STORAGE until a backend is configured.
	MountEndpoints bool

	// SignedURLTTL is the default time-to-live for pre-signed URLs generated
	// for mfx:"file_acl:signed" fields. Default: DefaultFileSignedURLTTL (1 hour).
	SignedURLTTL time.Duration

	// MaxUploadBytes caps the total size of a multipart/form-data request — every
	// part summed, not per file. A request over the ceiling is rejected with
	// 413 BODY_TOO_LARGE as it streams, before anything is written to disk.
	// Default: DefaultMaxUploadBytes (32 MB). It applies to model create/update
	// uploads and to POST /files alike.
	//
	// The per-field mfx:"max_size" tag still applies on top of this: it bounds an
	// individual attachment, this bounds the request that carries it. Raise this
	// for large media, and note that a Deserialize-step body.MaxBodySize
	// (ctx.SetMaxBodySize) overrides it for the models it is registered on.
	MaxUploadBytes int64

	// MaxUploadMemory is how much of a multipart request is buffered in memory
	// before the remainder spools to temp files. Default: DefaultMaxUploadMemory
	// (32 MB). Lower it to trade memory for temp-file I/O; the total is still
	// bounded by MaxUploadBytes either way.
	MaxUploadMemory int64

	// KeyGen derives the storage key for a standalone POST /files upload from the
	// request context and the multipart header. When nil, DefaultKeyGen is used,
	// producing "uploads/<uuid>/<sanitised-filename>". Override it to route
	// uploads into a custom layout (e.g. per-tenant prefixes read from ctx.Auth).
	//
	// The returned string is used verbatim as the storage key; implementations
	// are responsible for sanitising any user-supplied component (see
	// sanitizeFilename / DefaultKeyGen for the framework's default).
	KeyGen func(ctx *ServerContext, header *multipart.FileHeader) string

	// BeforeMiddlewares run before the standalone /files endpoints (POST /files,
	// GET /files/*, DELETE /files/*) with the supplied pipeline middleware
	// chain. Middlewares run in slice order; any that sets ctx.Response
	// short-circuits the request before the file handler runs.
	//
	// Empty (the default) leaves /files unauthenticated — backward-compatible
	// with pre-fix behaviour but unsafe for production: anyone who guesses a
	// key can delete arbitrary files. Recommended production setup:
	//
	//	maniflex.Config{
	//	    FilesConfig: maniflex.FilesConfig{
	//	        Storage:        storage.NewLocalStorage("./uploads"),
	//	        MountEndpoints: true,
	//	        BeforeMiddlewares: []maniflex.MiddlewareFunc{
	//	            auth.JWTAuth("...", auth.JWTOptions{}),
	//	            auth.RequireRole("admin"),
	//	        },
	//	    },
	//	}
	//
	// Per-model attachment routes (mfx:"file" fields on a registered model)
	// already run through the full Auth / DB pipeline and are unaffected.
	BeforeMiddlewares []MiddlewareFunc

	// AfterMiddlewares run after the standalone /files handler has served the
	// request. They are for observation and side effects only — audit logging,
	// metrics, cleanup — NOT for altering the response.
	//
	// The handler streams its response (status, headers, body) directly to the
	// client, so by the time an AfterMiddleware runs the response is already
	// committed to the wire and cannot be rewritten. Read the outcome via
	// ctx.Writer.(interface{ Status() int }).Status(); a middleware that sets
	// ctx.Response here is ignored (and logged) rather than corrupting the
	// already-sent body. To alter or replace the response, or to block the
	// request, use BeforeMiddlewares instead.
	//
	// Like BeforeMiddlewares, each runs in slice order and receives a next()
	// callback it must invoke to run the remaining chain.
	AfterMiddlewares []MiddlewareFunc
}

FilesConfig groups all file-upload/storage settings under Config.FilesConfig. It supersedes the flat Config.FileStorage / FileSignedURLTTL / FileMiddleware fields removed in this release.

Minimal setup — enable model file fields and the standalone /files endpoints:

maniflex.New(maniflex.Config{
    FilesConfig: maniflex.FilesConfig{
        Storage:        storage.NewLocalStorage("./uploads"),
        MountEndpoints: true, // required: see the field doc below
    },
})

type FilterExpr

type FilterExpr struct {
	// Flat filter (not nested)
	Field    string // DB column name on the primary table
	Operator FilterOperator
	Value    any // raw string from URL; adapters cast as needed

	// Nested filter (Field contains a "." and references a BelongsTo relation)
	IsNested      bool
	RelationKey   string // e.g. "author"
	RelationModel string // e.g. "Author"
	RelationTable string // DB table of related model, e.g. "users"
	RelationFK    string // FK column on THIS table, e.g. "author_id"
	NestedField   string // DB column on the related table, e.g. "status"

	// Locale filter (Field contains a "." and the left side is a locale field)
	// e.g. ?filter=name.ar:ilike:قلب
	// SQL: name->>'ar' ILIKE ? (Postgres) / json_extract(name,'$.ar') LIKE ? (SQLite)
	IsLocale  bool   // true when filtering on a locale sub-key
	LocaleKey string // the locale key portion, e.g. "ar"

	// Group is the OR-group index.
	//
	// Group <= 0 (which includes the struct's zero value) means ungrouped: the
	// filter forms its own AND clause. Filters sharing the same Group >= 1 are
	// OR-ed together. The zero value is ungrouped on purpose so a hand-built
	// FilterExpr AND-s by default — building one with the bare Field/Operator/
	// Value set is the common case and must not silently OR.
	//
	// The URL bracket syntax ?filter[N]=... (N >= 0) is mapped onto Group N+1
	// during parsing, so e.g. ?filter[0]=a&filter[0]=b is one OR group; the
	// external contract is unchanged.
	Group int
}

FilterExpr is a single parsed and validated filter condition.

func ParseFilterParam

func ParseFilterParam(raw string, model *ModelMeta, reg RegistryAccessor) (*FilterExpr, error)

ParseFilterParam parses one filter query parameter value into a FilterExpr.

Format: field:operator[:value]

Examples:

status:eq:published
created_at:gte:2024-01-01
author.status:neq:banned      (nested — author must be a registered relation)
deleted_at:is_null            (no value)
role:in:admin,editor

type FilterOperator

type FilterOperator string

FilterOperator is a comparison operator used in filter expressions.

const (
	OpEq      FilterOperator = "eq"       // field = value
	OpNeq     FilterOperator = "neq"      // field != value
	OpGt      FilterOperator = "gt"       // field > value
	OpGte     FilterOperator = "gte"      // field >= value
	OpLt      FilterOperator = "lt"       // field < value
	OpLte     FilterOperator = "lte"      // field <= value
	OpLike    FilterOperator = "like"     // field LIKE value — value is a raw pattern (case-sensitive)
	OpILike   FilterOperator = "ilike"    // field ILIKE value — value is a raw pattern (case-insensitive)
	OpIn      FilterOperator = "in"       // field IN (v1,v2,...)
	OpNotIn   FilterOperator = "not_in"   // field NOT IN (v1,v2,...)
	OpIsNull  FilterOperator = "is_null"  // field IS NULL  (no value)
	OpNotNull FilterOperator = "not_null" // field IS NOT NULL (no value)
	OpBetween FilterOperator = "between"  // field BETWEEN lo AND hi (value "lo,hi")

	// The substring operators below take a literal value, not a pattern: % and _
	// in it are escaped and match themselves. Use them for user-typed text (a
	// search box, a filename); reach for like/ilike only when the caller really
	// is writing a pattern. All three are case-insensitive.
	OpContains   FilterOperator = "contains"    // field contains value  (%value%)
	OpStartsWith FilterOperator = "starts_with" // field starts with value (value%)
	OpEndsWith   FilterOperator = "ends_with"   // field ends with value   (%value)
)

type GlobalSearchConfig

type GlobalSearchConfig struct {
	// Path is the route mounted under PathPrefix. Defaults to "/search".
	Path string
	// DefaultLimit is the merged-result cap when the request omits ?limit=.
	// Defaults to 20.
	DefaultLimit int
	// MaxLimit clamps ?limit= so a client cannot request an unbounded scan.
	// Defaults to 100; set negative to disable the clamp.
	MaxLimit int
}

GlobalSearchConfig configures the built-in cross-model search endpoint. The zero value is valid — every field falls back to a default.

type HavingClause

type HavingClause struct {
	Alias    string
	Operator FilterOperator
	Value    any
}

HavingClause filters aggregated results by an aggregate alias. Operator is the same set used by FilterExpr (eq, gt, gte, lt, lte, neq); set ops, like, and null checks are rejected since they make no sense on a numeric aggregate result.

type IndexSpec

type IndexSpec struct {
	// Name is the unique index identifier, e.g. "idx_invoice_history_record_id".
	Name string
	// Columns are the column expressions to index, e.g. ["record_id", "version DESC"].
	// Each entry is emitted verbatim inside the ON table(...) clause.
	Columns []string
	// Unique adds the UNIQUE keyword.
	Unique bool
}

IndexSpec describes a database index to create during AutoMigrate.

type KeyProvider

type KeyProvider interface {
	// Encrypt encrypts plaintext under keyID and returns a self-describing
	// binary envelope that embeds the keyID. The keyID is only needed at write
	// time; Decrypt reads it from the envelope automatically.
	Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error)

	// Decrypt decrypts an envelope produced by Encrypt. The keyID is extracted
	// from the envelope — callers do not need to supply it.
	Decrypt(ctx context.Context, envelope []byte) ([]byte, error)

	// KeyIDOf extracts the keyID from an envelope without decrypting. Useful
	// for audit logging and key rotation checks.
	KeyIDOf(envelope []byte) (string, error)

	// HMAC returns a deterministic keyed digest of data under keyID. Used to
	// enforce UNIQUE constraints on encrypted fields: a companion
	// {field}_hmac TEXT UNIQUE column stores this digest so uniqueness can be
	// checked without exposing or comparing ciphertexts.
	HMAC(ctx context.Context, keyID string, data []byte) ([]byte, error)
}

KeyProvider manages encryption keys for mfx:"encrypted" struct fields.

Values are stored in the database as the string "enc:<base64(envelope)>". The "enc:" prefix lets the framework distinguish already-encrypted values from unencrypted legacy data, so tables can be migrated incrementally.

Use pkg/encryption.EnvKeyProvider for environment-variable-backed keys, or pkg/encryption.VaultKeyProvider for HashiCorp Vault Transit.

type ListResult

type ListResult struct {
	Items []any
	Total int64
	Query *QueryParams
}

ListResult is returned by DBAdapter.FindMany.

As of the typed-models migration (Phase 3) Items holds the type-erased record carriers: each element is a *T for the queried model's GoType. The pipeline bridges them to maps during the transition; Phase 4 consumes them directly.

type LocaleMode

type LocaleMode string

LocaleMode controls how a LocaleString field is represented in responses.

const (
	// LocaleModeSplit is the default: the field name holds the resolved string
	// and an auto-generated companion field (e.g. "name_i18n") always holds the
	// full locale map. Clients get a stable string type for display while still
	// having access to all translations.
	LocaleModeSplit LocaleMode = "split"

	// LocaleModeResolve always returns the field as a string. The locale is
	// determined by: ?locale= param → Accept-Language header → field
	// default_locale → model DefaultLocale → app Default → "en".
	LocaleModeResolve LocaleMode = "resolve"

	// LocaleModeDynamic replicates the legacy behavior: the field is a string
	// when ?locale= is present, the full map otherwise. Opt-in only; not
	// recommended for new models because the field type is non-deterministic.
	LocaleModeDynamic LocaleMode = "dynamic"
)

type LocaleOptions

type LocaleOptions struct {
	// Supported is the list of locale codes the application accepts.
	// Requests with a locale outside this list fall through to Default.
	// Empty means all locales are accepted as-is.
	Supported []string

	// Default is the locale used when the request carries no recognisable
	// locale preference. Defaults to "en" when empty.
	Default string

	// FromHeader enables Accept-Language header parsing. When true the
	// resolver picks the first value in Supported that matches a language
	// tag in the header (ignoring quality values for simplicity).
	FromHeader bool

	// RTL is the list of locale codes that use right-to-left script.
	// When the resolved locale is in this list the response meta gets
	// "_dir": "rtl".
	RTL []string

	// DefaultLocaleMode sets the app-wide default mode for LocaleString fields
	// that don't have an explicit mode in their struct tag and whose model
	// has no ModelConfig.DefaultLocaleMode. When empty, split is used.
	DefaultLocaleMode LocaleMode

	// SplitSuffix is the suffix appended to a field name for the i18n companion
	// in split mode. Defaults to "_i18n" when empty.
	SplitSuffix string
}

LocaleOptions configures the LocaleResolver middleware.

type LocaleString

type LocaleString map[string]string

LocaleString is a map of locale keys to translated strings. Stored as TEXT (SQLite) or JSONB (Postgres) in the database. Use with mfx:"locale" on struct fields:

type Department struct {
    maniflex.BaseModel
    Name maniflex.LocaleString `mfx:"locale"`
    Code string                `mfx:"required,unique"`
}

The response representation depends on the field's LocaleMode (default: split):

  • split: "name" is the resolved string, "name_i18n" is always the full map
  • resolve: "name" is always the resolved string
  • dynamic: "name" is a string when ?locale= is set, the full map otherwise

func (LocaleString) MarshalJSON

func (ls LocaleString) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler so LocaleString round-trips cleanly.

func (LocaleString) SQLType

func (LocaleString) SQLType(driver DriverType) string

SQLType implements SQLTyper so the DB adapter maps LocaleString to the correct column type: JSONB in Postgres (enables GIN index on locale keys), TEXT in SQLite.

func (*LocaleString) UnmarshalJSON

func (ls *LocaleString) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type LockCondition

type LockCondition struct {
	// JSONName is the JSON field name the condition compares against (the
	// directive is declared as `lock_when:status=posted`; JSONName="status").
	// At registration this is resolved against the model's JSON names so a
	// typo is caught early rather than silently never matching.
	JSONName string

	// Value is the right-hand side of the equality check. Stored as a string
	// because the directive itself is textual; numeric/boolean comparisons go
	// through fmt.Sprintf("%v", ...) on the loaded record value to flatten
	// type differences.
	Value string
}

LockCondition expresses a single mfx:"lock_when:<field>=<value>" directive. When a record's current state matches every Field/Value pair across all conditions attached to the model, the record is "locked" — updates and deletes return 422 RECORD_LOCKED. Each LockCondition contributes one independent rule; if any rule matches, the record is locked.

type LockScopeSpec

type LockScopeSpec struct {
	// DBName is the DB column carrying the referenced row's ID.
	DBName string
	// Model is the registered model name to lock.
	Model string
}

LockScopeSpec records one mfx:"lock_scope:ModelName" directive resolved at registration time. The DB step acquires a FOR UPDATE lock on the referenced row before executing a create, preventing write-skew races on shared resources (pharmacy stock, seat inventory, etc.).

Requires an active transaction on the request — register maniflex.WithTransaction(nil) on the Service step for the model.

type MemoryCache

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

MemoryCache is a process-local CacheStore implementation. Suitable for single-instance deployments and tests. For multi-replica deployments back the cache with Redis or another shared store by implementing CacheStore against it.

MemoryCache evicts expired entries lazily on Get and, to bound memory for write-mostly keys (rate-limit windows, idempotency replays) that are never queried again, sweeps the map every memoryCachePruneEvery inserts. The pattern mirrors the rate-limiter (see middleware/db query.go §11B.5): no background goroutine is started, so a test that constructs many caches doesn't leak janitors that the testing harness would have to wait on.

func NewMemoryCache

func NewMemoryCache() *MemoryCache

NewMemoryCache returns a ready-to-use in-process CacheStore.

func (*MemoryCache) Delete

func (m *MemoryCache) Delete(_ context.Context, key string)

Delete implements CacheStore.

func (*MemoryCache) Get

func (m *MemoryCache) Get(_ context.Context, key string) (any, bool)

Get implements CacheStore.

func (*MemoryCache) Set

func (m *MemoryCache) Set(_ context.Context, key string, value any, ttl time.Duration)

Set implements CacheStore.

type MiddlewareConfig

type MiddlewareConfig struct {
	Models     []string    // restrict to these model names (empty = all)
	Operations []Operation // restrict to these operations (empty = all)
	Position   Position
	Name       string
}

MiddlewareConfig holds the filter criteria for a registered middleware. An empty Models slice means "all models"; an empty Operations slice means "all operations".

type MiddlewareFunc

type MiddlewareFunc func(ctx *ServerContext, next func() error) error

MiddlewareFunc is the signature every pipeline middleware must satisfy. Call next() to proceed to the next handler in the chain. Return without calling next() to short-circuit the pipeline (e.g. on auth failure).

func Handle

func Handle[T any](fn func(ctx *ServerContext, record *T) error) MiddlewareFunc

Handle adapts a typed handler into a MiddlewareFunc. When a *T record is bound to the request, fn runs with it before next(); when none is bound (e.g. a read operation), fn is skipped and the chain continues. A non-nil error from fn short-circuits the pipeline exactly like any middleware error.

func LocaleResolver

func LocaleResolver(opts LocaleOptions) MiddlewareFunc

LocaleResolver returns a Deserialize-step middleware that determines the active locale for the request and stores it on ctx.Locale.

Resolution order:

  1. ?locale= query parameter
  2. Accept-Language header (first match in opts.Supported), when opts.FromHeader is true
  3. opts.Default (default: "en")

Usage:

server.Pipeline.Deserialize.Register(maniflex.LocaleResolver(maniflex.LocaleOptions{
    Supported:  []string{"en", "ar"},
    Default:    "en",
    FromHeader: true,
    RTL:        []string{"ar", "he", "fa", "ur"},
}))

func WithTransaction

func WithTransaction(opts *TxOptions) MiddlewareFunc

WithTransaction wraps the pipeline's DB step in a database transaction. It begins a transaction before the DB step runs and commits it after all After-DB middleware complete. If any step returns an error or sets an error response, the transaction is rolled back instead.

Register it on the Service step (Before position, the default) so it fires just before the DB step:

server.Pipeline.Service.Register(
    maniflex.WithTransaction(nil), // nil opts = default isolation
    maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)

Or on the DB step itself at Replace position to fully replace the default:

server.Pipeline.DB.Register(
    maniflex.WithTransaction(nil),
    maniflex.AtPosition(maniflex.Replace),
)

Once registered, middleware running in the same request can read ctx.Tx to join the same transaction, or call ctx.BeginTx themselves for nested work.

WithTransaction is safe to use with both Postgres and SQLite. SQLite does not support nested transactions; registering WithTransaction twice for the same request will return an error from the second BeginTx call.

type MiddlewareOption

type MiddlewareOption func(*MiddlewareConfig)

MiddlewareOption is a functional option applied to a MiddlewareConfig.

func AtPosition

func AtPosition(p Position) MiddlewareOption

AtPosition sets where the middleware sits relative to the default handler.

pipeline.Response.Register(addHeaders, maniflex.AtPosition(maniflex.After))

func ForModel

func ForModel(names ...string) MiddlewareOption

ForModel restricts the middleware to the named model(s).

pipeline.Auth.Register(requireLogin, maniflex.ForModel("User", "Post"))

func ForOperation

func ForOperation(ops ...Operation) MiddlewareOption

ForOperation restricts the middleware to the given operation(s).

pipeline.DB.Register(auditLog, maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate))

func WithName

func WithName(name string) MiddlewareOption

WithName sets name for middleware for debug purposes

pipeline.Auth.Register(rateLimit, maniflex.WithName("rate-limiter"))

type Migration

type Migration struct {
	// Version uniquely identifies the migration. Required, must be non-empty.
	Version string

	// Up applies the migration. Required.
	Up func(ctx context.Context, tx *sql.Tx) error

	// NoTransaction disables the BEGIN/COMMIT wrapper for this migration.
	// Use only for DDL that the database refuses to run inside a transaction
	// (e.g. Postgres CREATE INDEX CONCURRENTLY). When true, the *sql.Tx
	// passed to Up is a thin shim that executes against a dedicated
	// connection with no rollback semantics.
	NoTransaction bool
}

Migration is a single versioned schema change applied exactly once.

Version is a unique identifier ordered lexicographically — typically a zero-padded sequence ("0001_init", "0002_add_index") or a timestamp ("20260429_120000_backfill"). Migrate sorts the slice by Version before running, so registration order does not matter.

Up runs inside a transaction. Returning an error rolls the transaction back and aborts the migration run; the version is not recorded as applied. Use the supplied *sql.Tx for both DDL and any data backfill so the entire step is atomic.

Note: some DDL statements are not transactional on every backend (e.g. Postgres `CREATE INDEX CONCURRENTLY` cannot run inside a transaction). For those, set NoTransaction: true and run the statement directly against the supplied *sql.Tx — it will be a degenerate "transaction" that simply runs the statement on a dedicated connection without BEGIN/COMMIT wrapping.

type ModelAccessor

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

ModelAccessor exposes the five standard CRUD operations for a single registered model. Obtain one via ServerContext.GetModel. All methods route through the active transaction (ctx.Tx) when one is set.

func (*ModelAccessor) Create

func (a *ModelAccessor) Create(data map[string]any) (map[string]any, error)

Create inserts a new record and returns the stored representation. Returns *maniflex.ErrConstraint on unique/check violations.

func (*ModelAccessor) Delete

func (a *ModelAccessor) Delete(id string) error

Delete removes (or soft-deletes) the record identified by id. Returns maniflex.ErrNotFound when absent.

func (*ModelAccessor) List

func (a *ModelAccessor) List(q *QueryParams) ([]map[string]any, error)

List returns a page of records matching q. q may be nil (defaults to page 1, limit 20, no filters or sorts).

func (*ModelAccessor) Read

func (a *ModelAccessor) Read(id string) (map[string]any, error)

Read returns the single record identified by id. Returns maniflex.ErrNotFound when the record does not exist.

func (*ModelAccessor) Update

func (a *ModelAccessor) Update(id string, data map[string]any) (map[string]any, error)

Update applies a partial patch to the record identified by id and returns the updated representation. Returns maniflex.ErrNotFound when absent. Returns *maniflex.ErrConstraint on unique/check violations.

type ModelConfig

type ModelConfig struct {
	// TableName overrides the auto-generated snake_case plural table name.
	TableName string
	// SoftDelete opts the model into soft deletion.
	SoftDelete SoftDeleteConfig
	// Middleware holds per-model pipeline middleware installed at registration
	// time. nil means no per-model middleware.
	Middleware *ModelMiddleware

	// Versioned enables field-change history for this model. AutoMigrate
	// creates a sibling {model}_history table. Every write emits a history row
	// with a per-field diff. Equivalent to mfx:"versioned" on BaseModel.
	Versioned bool
	// VersionedDiffOnly skips the snapshot column. Only changed fields are
	// stored. Equivalent to mfx:"versioned:diff_only" on BaseModel.
	VersionedDiffOnly bool

	// Indices declares extra DB indexes to create during AutoMigrate. Use this
	// to pre-declare indexes that the framework would otherwise auto-generate
	// (e.g. for mfx:"scheduled" timestamp columns) so duplicates are skipped.
	Indices []IndexSpec

	// Adapter overrides Config.DB for this model only. When non-nil, all CRUD
	// reads/writes, AutoMigrate, and transactions for this model route through
	// it instead of the global Config.DB.
	//
	// Leave nil to use Config.DB. Used to spread aggregates across separate
	// databases (e.g. orders DB vs. inventory DB) without running multiple
	// service binaries.
	//
	// maniflex.Batch and pkg/saga cannot span adapters in a single transaction —
	// Batch construction rejects mixed-adapter model sets; sagas are the
	// supported cross-adapter pattern.
	Adapter DBAdapter

	// ExportEnabled mounts GET /:model/export when true. The route accepts the
	// same filter and sort query parameters as the standard list endpoint and
	// streams the result as CSV (default) or XLSX (?format=xlsx). Hidden and
	// writeonly fields are excluded from the output.
	ExportEnabled bool

	// MaxExportRows caps the number of rows the export endpoint will return.
	// 0 means use DefaultMaxExportRows (100,000). Exports that would exceed
	// the cap return 413 Request Entity Too Large.
	MaxExportRows int

	// CursorField opts the model into keyset (cursor) pagination and names the
	// column the ?cursor= walk orders by. Value is the JSON or DB name of the
	// field (resolved to a DB column at registration); empty leaves the model on
	// offset (?page=/?limit=) pagination only. Equivalent to declaring
	// mfx:"cursor_field:<name>" on a field. The cursor field should be indexed
	// and effectively monotonic (e.g. created_at, a sequence) for best results;
	// id is always appended as the tiebreaker so the keyset boundary is total.
	CursorField string

	// AggregateEnabled mounts GET /:model/aggregate when true. The route takes the
	// aggregation (select/group_by/where/having/order_by/limit) as URL-encoded
	// JSON in the ?aggregate= query parameter, validates every referenced field
	// against the model's filterable/sortable allow-list, and runs it through
	// ctx.Aggregate. It dispatches as the list operation, so auth and row-isolation
	// middleware registered for OpList apply unchanged and any ?filter= query
	// parameters (including tenancy force-filters) are AND-ed into the aggregate
	// WHERE.
	//
	// The spec is a query parameter and not a request body because this is a GET:
	// a GET body is dropped by many proxies and CDNs and cannot be sent by fetch()
	// at all. The body is not read; sending one gets a 400 naming ?aggregate=.
	//
	// Field and operator names use the same convention as ?filter=/?sort=:
	// the JSON field name (DB column name also accepted). Only count, sum, avg,
	// min, max, and count_distinct are exposed. The result is returned as a
	// JSON array of group rows under the usual {"data": ...} envelope.
	AggregateEnabled bool

	// SearchLanguage names the text-search configuration used for full-text
	// search (?q=) on mfx:"searchable" fields. On Postgres it is the
	// to_tsvector / websearch_to_tsquery configuration name (default "english");
	// on SQLite it is ignored (the FTS5 porter tokenizer is language-agnostic).
	// The value is embedded into SQL as a config identifier — it must be a plain
	// identifier ([A-Za-z_]+) and is rejected at registration otherwise. Empty
	// means the framework default ("english").
	SearchLanguage string

	// GlobalSearchable opts the model into the built-in cross-model search
	// endpoint (GET /search, enabled via Server.EnableGlobalSearch). Only models
	// with this flag are searched by that endpoint and may be named in its
	// ?models= filter. It requires the model to declare at least one
	// mfx:"searchable" field — registration fails otherwise. It has no bearing on
	// per-model ?q= search (that needs only mfx:"searchable"), nor on ctx.Search
	// called with an explicit model list (the Action-scoped path, which the app
	// authorises itself).
	GlobalSearchable bool

	// DefaultLocale is the model-level fallback locale for LocaleString fields
	// in resolve/split mode when the client did not request a specific locale
	// and the field has no default_locale tag. Falls back to
	// LocaleOptions.Default when empty.
	DefaultLocale string

	// DefaultLocaleMode sets the default response representation for all
	// LocaleString fields on this model that do not carry an explicit mode tag.
	// When empty the app-level LocaleOptions.DefaultLocaleMode applies, then
	// the framework default (split).
	DefaultLocaleMode LocaleMode

	// OptimisticLock enables If-Match / ETag concurrency control for PATCH and
	// DELETE operations. When set, the DB step fetches the current record before
	// executing the write, computes its ETag (MD5 of the JSON response body),
	// and returns 412 Precondition Failed if the If-Match header does not match.
	//
	// The ETag format is identical to the one emitted by response.Cache, so
	// clients can obtain it via a preceding GET and use it on the mutating
	// request without special handling.
	//
	// If-Match: * is the RFC 9110 wildcard — it holds for any existing record,
	// so it means "overwrite whatever is there, but do not create it" rather
	// than pinning a particular version.
	//
	// Requests that omit the If-Match header bypass the check — the field
	// opts in to enforcement, not to mandatory locking.
	OptimisticLock bool

	// Singleton turns the model into a single-row config / feature-flag
	// resource. Instead of the usual collection + item routes, the model mounts
	// only GET and PATCH on its bare table path (no id, no POST/DELETE/list):
	//
	//	GET   /{table}   → read the one row
	//	PATCH /{table}   → update the one row
	//
	// The row is provisioned lazily under the well-known SingletonID on first
	// access, so GET returns column defaults before anything is written and
	// PATCH always targets an existing row. This is the blessed shape for the
	// "admin edits one config record, clients read it at launch" pattern (e.g.
	// GET /config). Singleton models must not declare mfx:"required" fields —
	// the auto-provisioned row has no values to satisfy them.
	Singleton bool

	// Headless registers the model fully — migration, registry, typed access,
	// relations — but mounts NO REST routes for it. Use it to back a path with a
	// custom server.Action instead of the auto-generated CRUD: a model and an
	// action cannot both own the same method+path (chi panics at boot), so set
	// Headless on the model to free its table path (e.g. GET /threads) for the
	// action. The model is still reachable via ctx.GetModel / typed CRUD and via
	// relations from other models. Takes precedence over Singleton.
	Headless bool
}

ModelConfig holds user-supplied options for a single registered model. All fields are optional; sensible defaults are derived from the struct name.

type ModelMeta

type ModelMeta struct {
	Name       string
	GoType     reflect.Type
	TableName  string
	Fields     []FieldMeta    // scalar DB column fields
	Relations  []RelationMeta // FK and slice-based relations
	SoftDelete SoftDeleteConfig
	Config     ModelConfig
	Indices    []IndexSpec // extra DB indexes created during AutoMigrate

	// LockWhen aggregates all `lock_when:field=value` directives across the
	// model's fields. Populated at registration via collectLockWhen. The
	// default validate step checks these on Update/Delete: if any condition
	// matches the currently-stored record, the request is rejected with 422
	// RECORD_LOCKED. Empty slice means the model is never locked.
	LockWhen []LockCondition

	// LockScopes aggregates all `lock_scope:ModelName` directives across the
	// model's fields. Populated at registration via collectLockScopes. The DB
	// step acquires a FOR UPDATE lock on each referenced row before executing a
	// create. Requires an active transaction. Empty slice means no auto-locking.
	LockScopes []LockScopeSpec

	// Computed holds per-model virtual fields registered via
	// Server.AddComputedField. They are materialised in the Response step
	// after toJSONMap and cannot be filtered or sorted — read-only output
	// only. Mutated under mu; readers should snapshot the slice header under
	// the read lock before iterating to avoid racing with AddComputedField.
	Computed []ComputedField

	// Adapter overrides the global Config.DB for this model. nil means use
	// the global. Copied from ModelConfig.Adapter at registration.
	Adapter DBAdapter

	// CursorField is the resolved DB column for keyset (cursor) pagination, or
	// "" when the model only supports offset pagination. Resolved at registration
	// from ModelConfig.CursorField or an mfx:"cursor_field:..." field tag.
	CursorField string

	// SearchFields are the DB column names of every mfx:"searchable" field, in
	// declaration order. Non-empty enables full-text search (?q=) on the model:
	// AutoMigrate provisions the driver's native FTS index (Postgres tsvector
	// column + GIN, SQLite FTS5 shadow table) over these columns. Resolved at
	// registration by collectSearchFields, which also rejects non-string fields.
	SearchFields []string
	// contains filtered or unexported fields
}

ModelMeta holds all reflection-derived and user-supplied metadata for a registered model. Built once at registration; treated as read-only afterwards.

Fields and Relations are indexed by name to back the accessors below, so the one mutation they do not tolerate is renaming an entry in place after it has been looked up once — the index keys off DBName/JSONName/RelationKey/ RelatedModel and would keep resolving the old name. Appending is fine (that is how resolveManyToMany adds its relations when the router is built).

func ScanModel

func ScanModel(v any, cfg ModelConfig) (*ModelMeta, error)

ScanModel inspects a struct value (or pointer) and returns a populated ModelMeta.

func (*ModelMeta) EncryptedFields

func (m *ModelMeta) EncryptedFields() []FieldMeta

EncryptedFields returns all fields marked mfx:"encrypted".

func (*ModelMeta) FieldByDBName

func (m *ModelMeta) FieldByDBName(name string) *FieldMeta

func (*ModelMeta) FieldByJSONName

func (m *ModelMeta) FieldByJSONName(name string) *FieldMeta

func (*ModelMeta) FileFields

func (m *ModelMeta) FileFields() []FieldMeta

FileFields returns all fields with the File tag set.

func (*ModelMeta) FilterableDBNames

func (m *ModelMeta) FilterableDBNames() map[string]bool

func (*ModelMeta) HasEncryptedFields

func (m *ModelMeta) HasEncryptedFields() bool

HasEncryptedFields reports whether the model has any mfx:"encrypted" fields.

func (*ModelMeta) HasFileFields

func (m *ModelMeta) HasFileFields() bool

HasFileFields reports whether the model has any mfx:"file" fields.

func (*ModelMeta) HasScheduled

func (m *ModelMeta) HasScheduled() bool

HasScheduled reports whether the model declares any valid scheduled field.

func (*ModelMeta) RelationByKey

func (m *ModelMeta) RelationByKey(key string) *RelationMeta

func (*ModelMeta) RelationByModel

func (m *ModelMeta) RelationByModel(name string) *RelationMeta

func (*ModelMeta) ResolveAdapter

func (m *ModelMeta) ResolveAdapter(global DBAdapter) DBAdapter

ResolveAdapter returns the adapter to use for this model: the per-model override if set, otherwise the supplied global. Both may be nil; callers must check.

func (*ModelMeta) Scheduled

func (m *ModelMeta) Scheduled() []ScheduledSpec

Scheduled returns the resolved scheduled-transition specs for the model. A nil/empty slice means there is nothing for a scheduled.Runner to sweep.

type ModelMiddleware

type ModelMiddleware struct {
	Auth        []MiddlewareFunc
	Deserialize []MiddlewareFunc
	Validate    []MiddlewareFunc
	Service     []MiddlewareFunc
	DB          []MiddlewareFunc
	Response    []MiddlewareFunc
}

ModelMiddleware holds per-step middleware registered alongside a model at registration time. Every middleware is implicitly scoped to the model it is registered with; adding a ForModel option is not required and would be redundant.

Used by jobs/maniflex.Mount to install write-blockers and force-filters without requiring a separate server.Pipeline.X.Register call after registration.

type OASEncoding

type OASEncoding struct {
	ContentType string `json:"contentType,omitempty"`
}

OASEncoding is an Encoding Object, used inside multipart/form-data media types to declare per-part content type and other transfer attributes.

type OASMediaType

type OASMediaType struct {
	Schema   *OASSchema             `json:"schema,omitempty"`
	Encoding map[string]OASEncoding `json:"encoding,omitempty"`
}

OASMediaType is a Media Type Object.

type OASOperation

type OASOperation struct {
	OperationID string                 `json:"operationId"`
	Summary     string                 `json:"summary"`
	Description string                 `json:"description,omitempty"`
	Tags        []string               `json:"tags,omitempty"`
	Deprecated  bool                   `json:"deprecated,omitempty"`
	Parameters  []OASParameter         `json:"parameters,omitempty"`
	RequestBody *OASRequestBody        `json:"requestBody,omitempty"`
	Responses   map[string]OASResponse `json:"responses"`

	// Security lists the security requirement objects for this operation.
	// Each map names a security scheme (declared in components.securitySchemes)
	// mapped to the scopes it requires, e.g. {"bearerAuth": {}}.
	Security []map[string][]string `json:"security,omitempty"`
}

OASOperation is an Operation Object.

type OASParameter

type OASParameter struct {
	Name        string     `json:"name"`
	In          string     `json:"in"` // "path" | "query"
	Required    bool       `json:"required,omitempty"`
	Description string     `json:"description,omitempty"`
	Schema      *OASSchema `json:"schema,omitempty"`
	Explode     *bool      `json:"explode,omitempty"`
	Style       string     `json:"style,omitempty"`
}

OASParameter is a Parameter Object (path or query).

type OASRequestBody

type OASRequestBody struct {
	Description string                  `json:"description,omitempty"`
	Required    bool                    `json:"required"`
	Content     map[string]OASMediaType `json:"content"`
}

OASRequestBody is a Request Body Object.

func JSONRequestBody

func JSONRequestBody(schema *OASSchema) *OASRequestBody

JSONRequestBody is a convenience constructor that wraps an *OASSchema in a required application/json request body — the common case for action endpoints.

type OASResponse

type OASResponse struct {
	Description string                  `json:"description"`
	Content     map[string]OASMediaType `json:"content,omitempty"`
}

OASResponse is a Response Object.

type OASSchema

type OASSchema struct {
	// Core
	Ref         string `json:"$ref,omitempty"`
	Type        any    `json:"type,omitempty"` // string | []string
	Format      string `json:"format,omitempty"`
	Description string `json:"description,omitempty"`

	// Object
	Properties           map[string]*OASSchema `json:"properties,omitempty"`
	Required             []string              `json:"required,omitempty"`
	AdditionalProperties *bool                 `json:"additionalProperties,omitempty"`

	// Array
	Items *OASSchema `json:"items,omitempty"`

	// Validation
	Enum      []any    `json:"enum,omitempty"`
	Minimum   *float64 `json:"minimum,omitempty"`
	Maximum   *float64 `json:"maximum,omitempty"`
	MinLength *int     `json:"minLength,omitempty"`
	MaxLength *int     `json:"maxLength,omitempty"`

	// Annotations
	ReadOnly  bool `json:"readOnly,omitempty"`
	WriteOnly bool `json:"writeOnly,omitempty"`

	// Composition
	AllOf []*OASSchema `json:"allOf,omitempty"`
	AnyOf []*OASSchema `json:"anyOf,omitempty"`
	OneOf []*OASSchema `json:"oneOf,omitempty"`
}

OASSchema is a JSON Schema 2020-12 / OAS 3.1 schema object.

The Type field is `any` because OAS 3.1 allows both a single string ("string") and an array of strings (["string", "null"]) for nullable types.

type OASSecurityScheme

type OASSecurityScheme struct {
	Type         string `json:"type"`
	Scheme       string `json:"scheme,omitempty"`
	BearerFormat string `json:"bearerFormat,omitempty"`
	In           string `json:"in,omitempty"`
	Name         string `json:"name,omitempty"`
	Description  string `json:"description,omitempty"`
}

OASSecurityScheme is a Security Scheme Object.

type ObjectWithSchema added in v0.1.4

type ObjectWithSchema interface {
	Schema() *OASSchema
}

type OnDeleteAction

type OnDeleteAction string

OnDeleteAction is a referential action applied to FK columns when the referenced row is deleted.

const (
	OnDeleteNoAction OnDeleteAction = ""         // default — no constraint clause emitted
	OnDeleteCascade  OnDeleteAction = "cascade"  // DELETE parent → DELETE children
	OnDeleteSetNull  OnDeleteAction = "setNull"  // DELETE parent → SET fk = NULL
	OnDeleteRestrict OnDeleteAction = "restrict" // DELETE parent → ERROR if children exist
)

type OpenAPIComponents

type OpenAPIComponents struct {
	Schemas         map[string]*OASSchema        `json:"schemas,omitempty"`
	SecuritySchemes map[string]OASSecurityScheme `json:"securitySchemes,omitempty"`
}

OpenAPIComponents holds reusable component objects.

type OpenAPIContext

type OpenAPIContext struct {
	Request *http.Request
	Writer  http.ResponseWriter
	Ctx     context.Context

	// Auth is populated by the Auth step (same pattern as ServerContext).
	Auth *AuthInfo

	// Spec is set by the Generate step. Middleware registered After Generate
	// can read and mutate it to add custom paths, extensions, or security schemes.
	Spec *OpenAPISpec

	// Response overrides the default JSON serialisation when set by middleware.
	// If nil after the Response step runs, the default handler writes Spec as JSON.
	Response *APIResponse
	// contains filtered or unexported fields
}

OpenAPIContext is the shared object threaded through the OpenAPI pipeline. It is separate from ServerContext because the OpenAPI endpoint is not tied to any specific model or CRUD operation.

func (*OpenAPIContext) Abort

func (c *OpenAPIContext) Abort(statusCode int, code, message string)

Abort sets an error response, short-circuiting the pipeline.

func (*OpenAPIContext) Get

func (c *OpenAPIContext) Get(key string) (any, bool)

Get retrieves a value stored by an earlier middleware.

func (*OpenAPIContext) Set

func (c *OpenAPIContext) Set(key string, val any)

Set stores a custom value for downstream middleware.

type OpenAPIInfo

type OpenAPIInfo struct {
	Title       string `json:"title"`
	Version     string `json:"version"`
	Description string `json:"description,omitempty"`
}

OpenAPIInfo is the Info Object.

type OpenAPIMiddlewareFunc

type OpenAPIMiddlewareFunc func(ctx *OpenAPIContext, next func() error) error

OpenAPIMiddlewareFunc is the middleware signature for the OpenAPI pipeline. Call next() to proceed; return without calling next() to short-circuit.

type OpenAPIPipeline

type OpenAPIPipeline struct {
	Auth     *OpenAPIStepRegistry
	Generate *OpenAPIStepRegistry
	Response *OpenAPIStepRegistry
}

OpenAPIPipeline is the middleware injection surface for the spec endpoint.

The three steps run in order:

Auth     → guard access (passthrough by default)
Generate → build *OpenAPISpec from the registry (default) or replace it
Response → serialise Spec to JSON (default)

Usage:

// Require a Bearer token to see the spec
server.Pipeline.OpenAPI.Auth.Register(myAuthMiddleware)

// Inject a custom extension after generation
server.Pipeline.OpenAPI.Generate.Register(func(ctx *maniflex.OpenAPIContext, next func() error) error {
    if err := next(); err != nil { return err }
    ctx.Spec.Info.Description = "My custom API"
    ctx.Spec.Components.SecuritySchemes = map[string]maniflex.OASSecurityScheme{
        "bearerAuth": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"},
    }
    return nil
}, maniflex.After)

// Serve a hand-crafted spec file instead
server.Pipeline.OpenAPI.Generate.Register(serveStaticSpec, maniflex.Replace)

type OpenAPIServer

type OpenAPIServer struct {
	URL         string `json:"url"`
	Description string `json:"description,omitempty"`
}

OpenAPIServer is a Server Object.

type OpenAPISpec

type OpenAPISpec struct {
	OpenAPI    string              `json:"openapi"`
	Info       OpenAPIInfo         `json:"info"`
	Servers    []OpenAPIServer     `json:"servers,omitempty"`
	Tags       []OpenAPITag        `json:"tags,omitempty"`
	Paths      map[string]PathItem `json:"paths"`
	Components OpenAPIComponents   `json:"components"`
}

OpenAPISpec is the root OpenAPI 3.1.0 document. It is populated by the Generate pipeline step and serialised to JSON by the Response step. Middleware registered on Pipeline.OpenAPI.Generate can read and mutate it freely.

func GenerateSpec

func GenerateSpec(reg RegistryAccessor, cfg *Config, actions []ActionConfig, search ...*GlobalSearchConfig) *OpenAPISpec

GenerateSpec builds a complete OpenAPI 3.1.0 document from all registered models and custom actions. It is called by the default Generate pipeline step and can be called directly by middleware to get the base spec before customisation.

The optional trailing search argument documents the built-in /search endpoint when the app enabled it via Server.EnableGlobalSearch; it is variadic only so existing direct callers keep compiling.

type OpenAPIStepRegistry

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

OpenAPIStepRegistry holds middlewares for one OpenAPI pipeline step. Unlike StepRegistry it has no ForModel/ForOperation filtering because the OpenAPI endpoint is a single, model-agnostic route.

func (*OpenAPIStepRegistry) Register

func (s *OpenAPIStepRegistry) Register(fn OpenAPIMiddlewareFunc, pos ...Position)

Register adds a middleware to this step.

pos controls where relative to the default handler the middleware runs:

Before  (default) — runs before the default handler
After             — runs after the default handler
Replace           — replaces the default handler entirely

Examples:

// Require admin role to read the spec
pipeline.OpenAPI.Auth.Register(requireAdmin)

// Add a custom extension after the spec is generated
pipeline.OpenAPI.Generate.Register(addMyExtension, maniflex.After)

// Serve the spec from a static file instead of generating it
pipeline.OpenAPI.Generate.Register(serveFromFile, maniflex.Replace)

type OpenAPITag

type OpenAPITag struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
}

OpenAPITag is a Tag Object used for grouping operations in Swagger UI.

type Operation

type Operation string

Operation identifies which CRUD action a request performs.

const (
	OpCreate  Operation = "create"
	OpRead    Operation = "read"
	OpUpdate  Operation = "update"
	OpDelete  Operation = "delete"
	OpList    Operation = "list"
	OpOptions Operation = "options"

	// OpHead is retained for compatibility but is never set on a request. HEAD is
	// GET with the body suppressed, so a HEAD request dispatches as the read it
	// mirrors — OpRead for an item, OpList for a collection — and every middleware
	// scoped to those operations applies to it unchanged. Scope HEAD-aware
	// middleware with ForOperation(OpRead)/ForOperation(OpList), not with this.
	OpHead Operation = "head"

	// OpReadAttachment identifies a request for GET /:model/:id/:file_field —
	// the per-model attachment route. Runs Auth → Deserialize → Validate → DB
	// (FindByID) like a regular Read, then the Response step streams the
	// referenced file instead of writing a JSON envelope.
	//
	// Middleware filtered with ForOperation(OpRead) does NOT match attachment
	// requests; use ForOperation(OpRead, OpReadAttachment) to apply to both.
	OpReadAttachment Operation = "read_attachment"

	// OpAction identifies a request handled by a custom action registered via
	// server.Action(). Deserialize, Validate, Service, and DB steps are skipped;
	// Auth and Response run normally.
	// Note: registering middleware on Pipeline.Deserialize/Validate/Service/DB
	// with ForOperation(OpAction) is valid syntax but has no effect — those steps
	// do not execute for actions. The server logs a warning at startup for such a
	// registration (see warnIneffectiveMiddleware in pipeline.go).
	OpAction Operation = "action"

	// OpSearch identifies a request to the built-in cross-model search endpoint
	// (GET /search, enabled via Server.EnableGlobalSearch). Like OpAction it runs
	// only Auth → handler → Response; Deserialize, Validate, Service, and DB are
	// skipped, so the handler performs the search via ctx.Search.
	//
	// The synthetic model name is "__search", so ForModel never matches it; gate
	// middleware on this endpoint with ForOperation(OpSearch) or register it
	// globally on Pipeline.Auth. Registering on Deserialize/Validate/Service/DB
	// with ForOperation(OpSearch) has no effect and is warned about at startup
	// (see warnIneffectiveMiddleware in pipeline.go).
	OpSearch Operation = "search"

	// OpExport identifies a request for GET /:model/export — the auto-generated
	// CSV/XLSX export endpoint. The DB step reads with the same filter/sort as
	// OpList (no pagination; capped at ModelConfig.MaxExportRows). The Response
	// step streams CSV (default) or XLSX (?format=xlsx) directly instead of
	// emitting a JSON envelope.
	//
	// Middleware filtered with ForOperation(OpList) does NOT match exports;
	// use ForOperation(OpList, OpExport) to cover both.
	OpExport Operation = "export"
)

type PathItem

type PathItem struct {
	Get    *OASOperation `json:"get,omitempty"`
	Post   *OASOperation `json:"post,omitempty"`
	Put    *OASOperation `json:"put,omitempty"`
	Patch  *OASOperation `json:"patch,omitempty"`
	Delete *OASOperation `json:"delete,omitempty"`
}

PathItem holds all operations for one URL path.

type Pinger

type Pinger interface {
	Ping(ctx context.Context) error
}

Pinger is satisfied by any DB adapter that exposes a Ping method. It is deliberately narrow — only the health handler uses it, so we avoid adding Ping to the full DBAdapter interface (which would break all custom adapters written against the previous interface).

*sqlcore.Adapter satisfies Pinger automatically because it wraps *sql.DB, which has PingContext. Custom adapters that do not embed *sql.DB can add:

func (a *MyAdapter) Ping(ctx context.Context) error { return a.db.PingContext(ctx) }

type Pipeline

type Pipeline struct {
	// Auth — identity and access control (passthrough by default).
	Auth *StepRegistry

	// Deserialize — parse JSON body into ParsedBody; parse URL query params into Query.
	Deserialize *StepRegistry

	// Validate — enforce mfx struct-tag rules (required, readonly, immutable, enum, min/max).
	Validate *StepRegistry

	// Service — user-injectable business logic (noop by default).
	Service *StepRegistry

	// DB — dispatch to the configured DBAdapter.
	DB *StepRegistry

	// Response — build the JSON response envelope from ctx.DBResult.
	Response *StepRegistry

	// OpenAPI — three-step pipeline for the GET /openapi.json endpoint.
	// Steps: Auth → Generate → Response
	OpenAPI *OpenAPIPipeline
	// contains filtered or unexported fields
}

Pipeline is the public middleware injection surface. It holds one StepRegistry per pipeline step, in execution order:

Auth → Deserialize → Validate → Service → DB → Response

It also holds an OpenAPIPipeline for the schema endpoint:

OpenAPI.Auth → OpenAPI.Generate → OpenAPI.Response

Inject middleware into any step via its Register() method:

server.Pipeline.Auth.Register(myJWTMiddleware)
server.Pipeline.Service.Register(myBusinessLogic, maniflex.ForModel("Order"))
server.Pipeline.DB.Register(myAuditLogger, maniflex.AtPosition(maniflex.After))
server.Pipeline.OpenAPI.Auth.Register(requireAdmin)
server.Pipeline.OpenAPI.Generate.Register(addExtensions, maniflex.After)

type PipelineTrace

type PipelineTrace struct {
	// Enabled is a shorthand that activates Steps, Timings, and Aborts when set
	// to true without any sub-flags being explicitly configured.
	// Bodies and Skips are NOT turned on by Enabled — they are opt-in because
	// they are high-volume or may expose sensitive request data.
	Enabled bool

	// Steps logs an "enter" record before each named middleware runs and an
	// "exit" record after it returns, with step name and middleware name.
	Steps bool

	// Timings adds an elapsed duration to each "exit" record. Requires Steps.
	Timings bool

	// Aborts logs when ctx.Abort() is called: the HTTP status, error code, and
	// the source file:line of the Abort call site inside the middleware.
	Aborts bool

	// Bodies logs the field names present in ctx.ParsedBody after the Deserialize
	// step. WARNING: may expose sensitive fields (passwords, tokens). Disabled by
	// Enabled; must be set explicitly.
	Bodies bool

	// Skips logs when a registered middleware is skipped because its ForModel or
	// ForOperation filter did not match the current request.
	Skips bool
}

PipelineTrace controls per-request debug tracing through the middleware pipeline. All output is at DEBUG level; set Config.Logger to a handler that accepts DEBUG records to see it.

type Position

type Position int

Position controls where in a step's execution chain a middleware is inserted.

const (
	// Before inserts the middleware before the default step handler (default).
	Before Position = iota
	// After inserts the middleware after the default step handler.
	After
	// Replace swaps out the default step handler entirely with this middleware.
	Replace
)

type QueryParams

type QueryParams struct {
	Page     int
	Limit    int
	Filters  []*FilterExpr
	Sorts    []SortExpr
	Includes []string // relation keys to load inline
	Fields   []string // DB column names to SELECT; empty = SELECT table.*

	// Search holds the trimmed ?q= full-text search query, "" when absent. When
	// non-empty the DB step adds the driver's native FTS predicate over the
	// model's mfx:"searchable" columns and orders results by relevance. Only set
	// on models that declare searchable fields; ParseQueryParams rejects ?q= on
	// any other model with a 400.
	Search string

	// Cursor, when non-nil, switches the list query to keyset (cursor)
	// pagination: the DB step walks the dataset ordered by (cursor field, id)
	// with a WHERE bound instead of LIMIT/OFFSET, and skips the COUNT. Set by
	// ParseQueryParams when ?cursor= is present on a cursor-enabled model.
	Cursor *CursorParams
}

QueryParams holds all URL query parameters parsed for a list request.

func ParseQueryParams

func ParseQueryParams(r *http.Request, model *ModelMeta, reg RegistryAccessor) (*QueryParams, error)

ParseQueryParams parses page, limit, filter, sort, and include parameters.

?page=2&limit=25
?filter=status:eq:active&filter=author.role:neq:banned
?sort=created_at:desc,title:asc
?include=author,category

func (*QueryParams) HasInclude

func (q *QueryParams) HasInclude(key string) bool

HasInclude reports whether key is in the include list.

func (*QueryParams) Offset

func (q *QueryParams) Offset() int

Offset returns the DB offset for the current page.

type RawResult

type RawResult interface {
	// When `Raw` called without select (as Exec), otherwise returns (nil, nil)
	LastInsertId() (*int64, error)
	// When `Raw` called without select (as Exec), otherwise returns (nil, nil)
	RowsAffected() (*int64, error)
	// Result of query if `Raw` called with select, otherwise returns (nil, nil)
	Rows() (*sql.Rows, error)
}

type RecursiveDirection

type RecursiveDirection string

RecursiveDirection controls which direction a recursive CTE traversal walks.

const (
	// RecursiveDescendants walks the tree downward, collecting all children of
	// the root node. This is the default when Direction is unset.
	RecursiveDescendants RecursiveDirection = "descendants"
	// RecursiveAncestors walks the tree upward, collecting all parents of the
	// starting node up to the root.
	RecursiveAncestors RecursiveDirection = "ancestors"
)

type RecursiveQuery

type RecursiveQuery struct {
	// RootID is the id of the starting node. Required.
	RootID string
	// ParentField is the DB column that holds the parent's id.
	// e.g. "parent_id", "manager_id". Required; must be a registered column.
	ParentField string
	// MaxDepth limits how many levels to traverse. 0 means unlimited.
	// MaxDepth=1 returns the root plus its immediate children/parent only.
	MaxDepth int
	// Direction controls descent (Descendants, default) or ascent (Ancestors).
	Direction RecursiveDirection
	// Where are additional filters applied in both the anchor and recursive
	// members. Nested-relation filters are not supported and return an error.
	Where []*FilterExpr
}

RecursiveQuery is the input to ctx.RecursiveQuery.

type Registry

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

Registry is a thread-safe, ordered store of registered ModelMeta values.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates a new empty Registry. Exported for use in tests.

func (*Registry) AddForTest

func (r *Registry) AddForTest(meta *ModelMeta) error

AddForTest adds a ModelMeta to the registry. Exported for use in tests.

func (*Registry) All

func (r *Registry) All() []*ModelMeta

All returns all registered models in registration order.

func (*Registry) Get

func (r *Registry) Get(name string) (*ModelMeta, bool)

Get returns the ModelMeta registered under name.

type RegistryAccessor

type RegistryAccessor interface {
	Get(name string) (*ModelMeta, bool)
	All() []*ModelMeta
}

RegistryAccessor is the read-only interface DB adapters receive.

type RelationKind

type RelationKind int

RelationKind describes the direction of a relationship.

const (
	// BelongsTo: this model holds the FK. e.g. Post.UserID → User
	BelongsTo RelationKind = iota
	// HasMany: the related model holds the FK. e.g. User → []Post
	HasMany
	// ManyToMany: a junction table connects two models. e.g. Product ↔ Tag via ProductTag.
	ManyToMany
)

type RelationMeta

type RelationMeta struct {
	// FieldName is the Go FK field name for BelongsTo (e.g. "ManagerID")
	// or the slice field name for HasMany (e.g. "Posts").
	FieldName string

	// DBName is the snake_case version of FieldName.
	DBName string

	// FKColumn is the DB column carrying the foreign key.
	//   BelongsTo → column on THIS table (e.g. "manager_id")
	//   HasMany   → column on the RELATED table (e.g. "team_id")
	FKColumn string

	// RelationKey is the short key used in ?include= and nested ?filter=.
	//   Explicit: snake_case of the Relation tag value, e.g. "manager"
	//   Convention: snake_case of trimmed field name, e.g. "user"
	RelationKey string

	// CompanionField is the Go struct field name of the companion placeholder
	// (e.g. "Manager" for a ManagerID FK). Empty for convention-style or HasMany.
	CompanionField string

	// RelatedModel is the target model's struct name, e.g. "User".
	RelatedModel string

	// Convention is true when this BelongsTo was inferred from a "<Name>ID" field
	// name rather than an explicit mfx:"relation:X" tag. Used to warn (not error)
	// when such an inferred relation targets a model that was never registered —
	// the common microservice case of storing a foreign id (e.g. UserID) by
	// design, which mfx:"norelation" silences.
	Convention bool

	Kind     RelationKind
	OnDelete OnDeleteAction

	// ManyToMany-only fields. All three are populated by resolveManyToMany.
	ThroughTable    string // junction table, e.g. "product_tags"
	ThroughLocalFK  string // FK on junction pointing to THIS model, e.g. "product_id"
	ThroughRemoteFK string // FK on junction pointing to the related model, e.g. "tag_id"
	ThroughModel    string // junction model name, e.g. "ProductTag"
}

RelationMeta describes one relationship on a model.

type RequestBody

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

RequestBody holds the parsed, JSON-keyed request body. It is read-only from outside the maniflex package: the only way to mutate it is ctx.SetField / ctx.DeleteField, which also keep the typed record (ctx.Record) in sync.

This is deliberate. When ParsedBody was a bare map[string]any, middleware could write ctx.ParsedBody["k"] = v directly — bypassing the record sync — and the typed write path would then silently drop the change (it sources columns from the in-sync record when the present-key sets match). Wrapping the map makes that bypass a compile error: there is no exported way to set a key except through ctx.SetField.

All methods are nil-safe, so ctx.ParsedBody can be compared to nil (no body on the request) and still be read without a guard.

func NewRequestBody

func NewRequestBody(m map[string]any) *RequestBody

NewRequestBody wraps a JSON-keyed map as a RequestBody. The pipeline builds one during Deserialize; this constructor is for callers (mainly tests) that build a ServerContext directly.

func (*RequestBody) Get

func (b *RequestBody) Get(key string) (any, bool)

Get returns the value for a JSON key and whether the key was present (an explicit null is present with a nil value).

func (*RequestBody) Has

func (b *RequestBody) Has(key string) bool

Has reports whether a JSON key is present, regardless of its value.

func (*RequestBody) Keys

func (b *RequestBody) Keys() []string

Keys returns the top-level JSON keys in unspecified order.

func (*RequestBody) Len

func (b *RequestBody) Len() int

Len returns the number of top-level keys in the body.

func (*RequestBody) Map

func (b *RequestBody) Map() map[string]any

Map returns a shallow copy of the body as a plain map, for read-only consumers such as validation callbacks and ABAC policies. Mutating the returned map does NOT change the body — use ctx.SetField / ctx.DeleteField for that.

type ResponseMeta

type ResponseMeta struct {
	Total int64  `json:"total"`
	Page  int    `json:"page"`
	Limit int    `json:"limit"`
	Pages int64  `json:"pages"`
	Dir   string `json:"_dir,omitempty"` // "rtl" when the active locale uses right-to-left script

	// Cursor-mode fields. Cursor is set to render the keyset shape; NextCursor is
	// the token for the following page ("" on the last page).
	Cursor     bool   `json:"-"`
	NextCursor string `json:"-"`
	HasMore    bool   `json:"-"`
}

ResponseMeta carries pagination metadata for list responses. It serialises in one of two shapes depending on the pagination mode (see MarshalJSON): offset mode emits {total, page, limit, pages}; cursor (keyset) mode emits {limit, next_cursor, has_more}.

func (ResponseMeta) MarshalJSON

func (m ResponseMeta) MarshalJSON() ([]byte, error)

MarshalJSON renders the offset shape ({total, page, limit, pages}) by default, and the cursor shape ({limit, next_cursor, has_more}) when Cursor is set, so a keyset response never carries meaningless total/page/pages fields.

type Row

type Row = map[string]any

Row is the sanctioned shape for schemaless query results — raw SQL, aggregates, and recursive queries, whose columns aren't a registered model. It is an alias for map[string]any, so existing code keeps compiling; the named type documents intent and gives the dynamic escape hatch a single home as the typed-models migration removes ad-hoc maps elsewhere.

type SQLTyper

type SQLTyper interface {
	SQLType(driver DriverType) string
}

SQLTyper is implemented by Go types that need a driver-specific SQL column type instead of the default mapping in goTypeToSQL. The adapter calls SQLType before its built-in type switch, so any type implementing this interface controls its own schema column definition.

type Amount struct { ... }
func (Amount) SQLType(driver maniflex.DriverType) string {
    if driver == maniflex.Postgres { return "NUMERIC(19,4)" }
    return "TEXT"
}

type SchedAction

type SchedAction uint8

SchedAction is the action a scheduled time-driven transition applies to a row once its timestamp column falls in the past (8.6).

const (
	// SchedSoftDelete soft-deletes the row (model must be soft-deletable).
	SchedSoftDelete SchedAction = iota
	// SchedHardDelete physically deletes the row.
	SchedHardDelete
	// SchedSetField writes a fixed value into a sibling column.
	SchedSetField
)

type ScheduledSpec

type ScheduledSpec struct {
	Column  string      // DB column of the *time.Time field that drives it
	Action  SchedAction // resolved action
	Field   string      // target DB column          (SchedSetField only)
	From    string      // guard value                (SchedSetField only)
	HasFrom bool        // whether From was specified  (SchedSetField only)
	To      string      // value to write             (SchedSetField only)
}

ScheduledSpec is one resolved scheduled field of a model (8.6).

type SearchOptions

type SearchOptions struct {
	// Query is the free-form search text (the ?q= value). Blank (after trimming)
	// is a no-op: ctx.Search returns (nil, nil).
	Query string

	// Models names the registered models to search. Each must be registered and
	// declare at least one mfx:"searchable" field, else ctx.Search errors. When
	// empty, ctx.Search searches every model whose ModelConfig.GlobalSearchable
	// is set (the model set the built-in /search endpoint uses).
	Models []string

	// Limit caps the number of merged results returned. <= 0 defaults to 20.
	Limit int

	// PerModelLimit is a fairness "fair chance" cap, NOT a hard ceiling. When > 0,
	// the merge first takes at most this many of each model's top-scoring hits,
	// then backfills any slots still free up to Limit from the remaining
	// candidates by score — regardless of model — so a single model MAY exceed the
	// cap during backfill rather than leave the result short. <= 0 merges purely
	// by score.
	PerModelLimit int
}

SearchOptions configures a cross-model full-text search run by ctx.Search.

type SearchResult

type SearchResult struct {
	Model   string  `json:"model"`   // registered model name the hit came from
	ID      string  `json:"id"`      // primary key of the matched row
	Snippet string  `json:"snippet"` // highlighted excerpt of the matched text
	Score   float64 `json:"score"`   // relevance, higher = more relevant
}

SearchResult is one hit in the merged cross-model search result.

type Server

type Server struct {
	Pipeline *Pipeline
	// contains filtered or unexported fields
}

Server is the top-level server.

Typical usage — signals handled automatically:

server := maniflex.New(maniflex.Config{DB: myAdapter})
server.MustRegister(User{}, Post{}, Comment{})
server.Pipeline.Auth.Register(jwtMiddleware)
if err := server.Start(); err != nil {
    log.Fatal(err)
}

Advanced usage — caller controls the shutdown context:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := server.StartWithContext(ctx); err != nil {
    log.Fatal(err)
}

func New

func New(cfg Config) *Server

New creates a Server with the given configuration. Sensible defaults are applied for any zero-value fields.

func (*Server) Action

func (c *Server) Action(cfg ActionConfig)

Action registers a custom HTTP endpoint that participates in the Auth and Response pipeline steps. Deserialize, Validate, Service, and DB steps are skipped; the handler is responsible for body parsing (ctx.BindJSON) and setting ctx.Response or calling ctx.Abort.

Must be called before Start() or Handler(). Panics if the server has already started or if the method+path conflicts with a registered model route.

func (*Server) AddComputedField

func (s *Server) AddComputedField(modelName, name string, fn ComputedFunc) error

AddComputedField registers a derived field on the named model. The field appears in every read response (single read, create/update echo, list rows) for that model. Returns an error when the model is not registered or the field name collides with an existing real field or another computed field.

server.AddComputedField("Product", "stock_level",
    func(ctx context.Context, row map[string]any) (any, error) {
        return stockService.CurrentLevel(ctx, row["id"].(string))
    })

func (*Server) AddService

func (c *Server) AddService(s Service)

AddService registers a long-lived background Service supervised by the server. Services start after migration and DB-ready, in registration order, before the HTTP listener opens, and stop in reverse order during graceful shutdown (before the background-goroutine drain). A Start error aborts boot.

Must be called before Start/StartWithContext.

server.AddService(pool)                          // a custom Service
server.AddService(maniflex.ServiceFunc(startFn)) // adapter for a bare func

func (*Server) DB

func (c *Server) DB() DBAdapter

DB returns the configured database adapter. It is used by packages such as jobs/maniflex that need to write directly to the database outside the HTTP pipeline (e.g. StatusSink.Transition called from a background worker).

func (*Server) EnableGlobalSearch

func (c *Server) EnableGlobalSearch(cfg ...GlobalSearchConfig)

EnableGlobalSearch mounts the built-in cross-model search endpoint at {PathPrefix}{Path} (default GET /search). It fans the native full-text search out over every model with ModelConfig.GlobalSearchable set and merges the hits into one relevance-ranked {"data": [{model, id, snippet, score}, ...]} list.

The endpoint runs only the global Auth pipeline step (and Response) — it does NOT apply per-model auth/tenancy middleware — so only opt models into GlobalSearchable that are safe to search this way, and gate the endpoint with Pipeline.Auth middleware (globally or ForOperation(OpSearch)). For a scoped search with the app's own authorisation, build a custom Action that calls ctx.Search with an explicit model list instead.

Must be called before Start() or Handler(). Apps that never call it gain no new endpoint.

func (*Server) Go

func (c *Server) Go(fn func(context.Context))

Go runs fn on an application-scoped goroutine that the server drains during graceful shutdown — the same lifecycle as ctx.GoBackground, but tied to the server rather than a single request. The ctx passed to fn is cancelled when shutdown begins, so a long-running loop (e.g. a periodic reconciler) returns on ctx.Done() and is then waited on, bounded by Config.ShutdownTimeout.

fn starts immediately, not at Start, and is drained however the server ends — including a boot that fails (a migration error, a service that would not start, a port already in use), so it is never abandoned mid-write.

server.Go(func(ctx context.Context) {
    t := time.NewTicker(time.Minute)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            reconcile(ctx)
        }
    }
})

func (*Server) Handler

func (c *Server) Handler() http.Handler

Handler returns the underlying http.Handler without starting the server. Useful for testing or embedding into an existing HTTP mux.

Unlike Start, Handler does NOT run auto-migration — call Start, MigrateOnly, or the adapter's AutoMigrate first, or requests will fail against missing tables.

The router is built on the first call and reused thereafter. Concurrent callers block until it is built rather than each building one of their own: the build resolves many-to-many relations by writing back to the registry, so two of them at once raced over shared model metadata.

func (*Server) KeyProvider added in v0.1.3

func (c *Server) KeyProvider() KeyProvider

KeyProvider returns the configured KeyProvider (nil if none). Use it to wire a background ServerContext for typed access to encrypted models:

bg := maniflex.NewBackground(ctx, srv.DB(), srv.Registry())
bg.SetKeyProvider(srv.KeyProvider())

func (*Server) MigrateOnly

func (c *Server) MigrateOnly(ctx context.Context) error

MigrateOnly runs auto-migration (if enabled in Config) and returns without starting the HTTP server. Use this for the Kubernetes init-container pattern where migrations run as a separate, single-shot process before the main server replicas start serving traffic:

if os.Getenv("MIGRATE_ONLY") == "1" {
    log.Fatal(server.MigrateOnly(ctx))
}
log.Fatal(server.Start())

func (*Server) MustAddComputedField

func (s *Server) MustAddComputedField(modelName, name string, fn ComputedFunc)

MustAddComputedField is the panic-on-error variant, intended for use in `main` or package initialisation.

func (*Server) MustRegister

func (c *Server) MustRegister(args ...any)

MustRegister calls Register and panics on error. Intended for use in package-level init or main().

func (*Server) PathPrefix

func (c *Server) PathPrefix() string

PathPrefix returns the configured route prefix (e.g. "/api"). Satellite packages such as maniflex/admin use it to address the generated API in-process.

func (*Server) RealtimeDoc

func (c *Server) RealtimeDoc(cfg AsyncAPIConfig)

RealtimeDoc enables the {PathPrefix}/asyncapi.json endpoint, which serves an AsyncAPI 2.6 document describing the realtime event channels clients can subscribe to over the realtime hub (see the realtime package). Declare custom event payloads via cfg.Events and/or set cfg.AutoModelEvents to derive <model>.created|updated|deleted channels from the registry.

Must be called before Start() or Handler(). Apps that never call it gain no new endpoint.

func (*Server) Register

func (c *Server) Register(args ...any) error

Register adds one or more models to the Server. It accepts any of:

server.Register(User{})
server.Register(User{}, ModelConfig{TableName: "members"})
server.Register(User{}, Post{}, Comment{})
server.Register([]any{User{}, Post{}})

func (*Server) Registry

func (c *Server) Registry() RegistryAccessor

Registry returns the read-only model registry.

func (*Server) SetDB

func (c *Server) SetDB(db DBAdapter)

SetDB injects or replaces the database adapter after construction. This allows the two-step init pattern:

server := maniflex.New(maniflex.Config{...})
server.MustRegister(User{}, Post{})
db, _ := sqlite.Open(":memory:", server.Registry())
server.SetDB(db)
server.Start()

func (*Server) SetKeyProvider

func (c *Server) SetKeyProvider(kp KeyProvider)

SetKeyProvider injects or replaces the KeyProvider after construction. This allows the two-step init pattern:

server := maniflex.New(maniflex.Config{...})
server.SetKeyProvider(&encryption.EnvKeyProvider{Prefix: "MYAPP_KEY"})

func (*Server) SetStorage

func (c *Server) SetStorage(fs FileStorage)

SetStorage injects or replaces the file storage backend after construction. This allows the two-step init pattern (analogous to SetDB):

server := maniflex.New(maniflex.Config{...})
fs, _ := storage.NewLocalStorage("./uploads")
server.SetStorage(fs)

func (*Server) Shutdown

func (c *Server) Shutdown(ctx context.Context) error

Shutdown initiates a graceful shutdown of the running server and waits for in-flight requests AND tracked background goroutines (audit writes, cache invalidations) to complete, bounded by the provided context. If the server was never started, Shutdown is a no-op.

It is safe to call from another goroutine at any point in the server's life, including while it is still booting — the usual shape in a test:

go func() { _ = server.Start() }()
...
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)

A Shutdown that arrives during boot (migration, or a service still starting) countermands it: the listener is never opened, Start unwinds what it had brought up and returns nil, and Shutdown waits for that to finish before returning.

It is terminal, not a pause: once called, a Start that has not yet opened the listener will not open one, whether it is already booting or has yet to be called. A Server is not restartable.

func (*Server) Start

func (c *Server) Start() error

Start performs auto-migration (if enabled), starts the HTTP server, and listens for SIGINT or SIGTERM. When a signal arrives it initiates a graceful shutdown: in-flight requests are given up to Config.ShutdownTimeout to complete before the server closes. Start returns nil if shutdown completes cleanly, or an error if the shutdown context expires or startup fails.

Start is the correct entry point for production. It is equivalent to:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
server.StartWithContext(ctx)

func (*Server) StartWithContext

func (c *Server) StartWithContext(ctx context.Context) error

StartWithContext is like Start but uses the provided context to drive shutdown instead of OS signals. The server runs until ctx is cancelled, then performs a graceful shutdown with a fresh timeout context derived from Config.ShutdownTimeout.

This variant is useful when:

  • The caller manages its own signal handling.
  • The server must shut down in response to application-level events.
  • Tests need to stop the server without sending a real OS signal.

Example:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
server.StartWithContext(ctx) // server stops after 5 minutes

type ServerContext

type ServerContext struct {
	// HTTP primitives
	Request *http.Request
	Writer  http.ResponseWriter
	Ctx     context.Context

	// Routing context — set by the handler before pipeline.execute()
	Model      *ModelMeta
	Operation  Operation
	ResourceID string // primary key from the URL, present for read/update/delete

	// AttachmentField is set when Operation == OpReadAttachment. It points at
	// the file field whose contents the request is asking to stream. The
	// Response step consults it to dereference the storage key from
	// DBResult and serve the file. nil for all other operations.
	AttachmentField *FieldMeta

	// RequestID is the value of the X-Request-Id header set by chi's RequestID
	// middleware. It is extracted once in handler.dispatch and stored here so
	// every middleware can include it in structured log records without importing
	// chi or reading the HTTP header directly.
	//
	// The same value is echoed back in the X-Request-Id response header so
	// clients can correlate requests to log lines.
	RequestID string

	// TraceID is the value of the W3C traceparent header on the incoming
	// request, or empty when the header is absent or malformed. The full
	// header value is preserved verbatim so it can be forwarded to downstream
	// services (e.g. service.Webhook) without re-encoding.
	TraceID string

	// Step outputs — each step populates these in order
	RawBody []byte // raw request body bytes (set by Deserialize)
	// ParsedBody is the deserialized JSON body, JSON-keyed (set by Deserialize).
	// It is read-only: read via ctx.Field or ParsedBody's methods (Get/Has/Len/
	// Keys/Map), and mutate ONLY via ctx.SetField / ctx.DeleteField, which keep
	// the typed Record in sync. It is a *RequestBody (not a bare map) precisely so
	// a raw ctx.ParsedBody["k"]=v — which bypassed that sync and could drop the
	// write — is a compile error. nil when the request carries no body.
	//
	// Prefer Record (the typed *T carrier) where possible.
	ParsedBody *RequestBody
	// Record holds the typed record carrier (*T for ctx.Model.GoType) for write
	// operations (set by Deserialize) and, increasingly, read results. The
	// pipeline reads/writes record fields through it instead of ParsedBody.
	Record   any
	Query    *QueryParams // parsed URL query params (set by Deserialize)
	DBResult any          // *T, *ListResult, or map[string]any (set by DB step)
	Response *APIResponse // final response envelope (set by Response step)

	// Files holds parsed file uploads from multipart/form-data requests.
	// Keyed by the form field name (which matches the JSON field name).
	// Populated by the Deserialize step when Content-Type is multipart/form-data.
	Files map[string]*UploadedFile

	// Locale is the explicit locale requested by the client, resolved by
	// LocaleResolver middleware from ?locale= or Accept-Language. Empty when
	// the client did not request a specific locale. Used as the first entry in
	// the locale resolution chain for LocaleString fields.
	Locale string

	// DefaultLocale is the app-configured fallback locale from LocaleOptions.Default.
	// Set by LocaleResolver; empty when the middleware is not registered (falls
	// back to "en" in the resolution chain).
	DefaultLocale string

	// SplitSuffix is the suffix used for the i18n companion field in split mode
	// (e.g. "name_i18n"). Set by LocaleResolver from LocaleOptions.SplitSuffix;
	// defaults to "_i18n" when the middleware is not registered.
	SplitSuffix string

	// DefaultLocaleMode is the app-wide fallback LocaleMode from
	// LocaleOptions.DefaultLocaleMode. Set by LocaleResolver; empty when the
	// middleware is not registered (falls back to LocaleModeSplit).
	DefaultLocaleMode LocaleMode

	// Auth — set by the Auth step (your middleware fills this)
	Auth *AuthInfo

	// Tx is an optional active transaction. When non-nil the default DB step
	// routes all Create / Update / Delete / FindByID / FindMany calls through
	// it instead of the bare adapter, so every DB operation in the request
	// participates in the same transaction.
	//
	// Set this field from a Service or DB middleware using BeginTx:
	//
	//	tx, err := ctx.BeginTx(ctx.Ctx, nil)
	//	if err != nil { ... }
	//	ctx.Tx = tx
	//	defer tx.Rollback() // no-op after Commit
	Tx Tx
	// contains filtered or unexported fields
}

ServerContext is the single shared object threaded through every pipeline step for one HTTP request. Steps read from it, write to it, and call next() to proceed to the following step.

func NewBackground

func NewBackground(ctx context.Context, adapter DBAdapter, reg RegistryAccessor) *ServerContext

NewBackground constructs a minimal ServerContext for use outside the HTTP pipeline — background workers, tests, and CLI commands. The returned context has no HTTP request wired up; operations that require ctx.Request will panic.

Example (tests):

bgCtx := maniflex.NewBackground(context.Background(), srv.DB(), srv.Registry())
entry, err := l.Post(bgCtx, ...)

func (*ServerContext) Abort

func (c *ServerContext) Abort(statusCode int, code, message string)

Abort populates ctx.Response with an error and should be used by middleware to short-circuit the pipeline. The current step must then return nil without calling next() for the abort to take effect.

func (*ServerContext) Aggregate

func (c *ServerContext) Aggregate(modelName string, agg AggregateQuery) ([]Row, error)

Aggregate runs a structured aggregation query against the named model. It validates field names against the model's registered DB columns so an invalid input fails fast with a clear error rather than producing a SQL syntax error from the driver.

The query participates in ctx.Tx when one is active; otherwise it uses the adapter's read pool. The returned rows are one map per group; aggregate values are accessed by alias.

rows, err := ctx.Aggregate("Order", maniflex.AggregateQuery{
    Select: []maniflex.AggregateField{
        {Op: maniflex.AggCount, As: "n"},
        {Op: maniflex.AggSum, Field: "total", As: "revenue"},
    },
    GroupBy: []string{"status"},
    Where:   []*maniflex.FilterExpr{{Field: "created_at", Operator: maniflex.OpGte, Value: "2026-01-01"}},
    OrderBy: []maniflex.SortExpr{{DBName: "revenue", Direction: maniflex.SortDesc}},
    Limit:   100,
})

func (*ServerContext) BeginTx

func (c *ServerContext) BeginTx(ctx context.Context, opts *TxOptions) (Tx, error)

func (*ServerContext) BindJSON

func (c *ServerContext) BindJSON(v any) error

BindJSON decodes the request body as JSON into v. It enforces the same 4 MB body size limit as the Deserialize step and sets ctx.RawBody. An absent body is rejected with 400 EMPTY_BODY — use TryBindJSON for GET or optional-body actions. On error it calls ctx.Abort and returns a non-nil error so the caller can return nil immediately:

var req MyRequest
if err := ctx.BindJSON(&req); err != nil {
    return nil // ctx.Abort already called
}

func (*ServerContext) DeleteField

func (c *ServerContext) DeleteField(jsonName string)

DeleteField removes a request-body field by its JSON name from ParsedBody and clears it from the typed record's present set, so the write path skips it. Use it from middleware that strip a field before the DB step.

func (*ServerContext) DriverType

func (c *ServerContext) DriverType() DriverType

DriverType returns the SQL dialect used by the adapter that will serve this request. Useful for packages that build raw SQL and need to choose between ? (SQLite) and $N (Postgres) placeholders.

If the underlying adapter does not implement the optional DriverTyper interface, SQLite is returned as a safe default.

func (*ServerContext) Field

func (c *ServerContext) Field(jsonName string) (any, bool)

Field reads a request-body field by its JSON name. ParsedBody is the authoritative body during the transition, so it is read from there.

func (*ServerContext) Get

func (c *ServerContext) Get(key string) (any, bool)

Get retrieves a value stored by a previous pipeline step or middleware.

func (*ServerContext) GetModel

func (c *ServerContext) GetModel(modelName string) *ModelAccessor

GetModel returns a ModelAccessor for the named registered model. All five CRUD operations on the accessor route through ctx.Tx when one is active. Returns an error accessor that surfaces the lookup failure on first use when the model name is not registered or the registry is unavailable.

func (*ServerContext) GoBackground

func (c *ServerContext) GoBackground(fn func(context.Context))

GoBackground schedules fn on a goroutine tracked by the Server. Server.Shutdown waits for tracked goroutines to complete (bounded by Config.ShutdownTimeout) so audit writes, cache invalidations, and other fire-and-forget work are not truncated by a process exit. The ctx passed to fn is independent of the HTTP request context (which is already returning when GoBackground is called) but IS cancelled when Shutdown's deadline elapses, so well-behaved writers honour that signal and exit promptly.

When ctx.bg is nil (a synthesised ServerContext without server wiring), fn runs on a plain goroutine with context.Background() — no shutdown coupling.

func (*ServerContext) HasRole

func (c *ServerContext) HasRole(role string) bool

HasRole reports whether the authenticated principal holds the given role.

func (*ServerContext) LockForUpdate

func (c *ServerContext) LockForUpdate(modelName, id string) (map[string]any, error)

LockForUpdate acquires a pessimistic row-level lock on the named model's record identified by id and returns the current row data.

For Postgres the underlying query appends FOR UPDATE; the lock is held until the enclosing transaction commits or rolls back. For SQLite the lock is the transaction's own write lock, taken at BEGIN — the sqlite adapter opens its write connections with _txlock=immediate, so a second read-then-write transaction waits at BEGIN instead of reading the row you are about to change.

LockForUpdate requires an active transaction on ctx.Tx. Call ctx.BeginTx or register maniflex.WithTransaction before invoking it.

server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
    stockID, _ := ctx.Field("stock_id")
    stock, err := ctx.LockForUpdate("StockBalance", stockID.(string))
    if err != nil { return err }
    if stock["quantity"].(int64) < 1 {
        ctx.Abort(409, "OUT_OF_STOCK", "insufficient stock")
        return nil
    }
    return next()
}, maniflex.ForModel("Dispense"), maniflex.ForOperation(maniflex.OpCreate))

func (*ServerContext) Logger

func (c *ServerContext) Logger() *slog.Logger

Logger returns a *slog.Logger pre-seeded with the request_id attribute so every log line emitted from middleware is automatically correlated to the originating HTTP request without any extra boilerplate.

ctx.Logger().Info("record processed", slog.String("id", record["id"].(string)))

The returned logger is memoised across calls within the same request — the underlying attributes (service / request_id / trace_id) don't change once the request enters the pipeline, so per-step middleware can call Logger() in a hot path without paying a fresh base.With(...) on every emission.

It is safe to call from any goroutine, including a ctx.GoBackground closure running after the request goroutine has moved on: the memoised logger is built exactly once, so every caller gets the same one.

func (*ServerContext) QueryModel deprecated

func (c *ServerContext) QueryModel(modelName string, q *QueryParams) ([]map[string]any, error)

QueryModel reads records from any registered model using the standard FindMany path. When ctx.Tx is active the query participates in it. q may be nil — defaults to page 1, limit 20 with no filters or sorts.

Deprecated: prefer ctx.GetModel(name).List(q) which also exposes Read, Create, Update, and Delete on the same accessor (3D.5).

func (*ServerContext) QueryParam

func (c *ServerContext) QueryParam(name string) string

QueryParam returns the first value of a URL query parameter, or "" if absent. Convenience wrapper around ctx.Request.URL.Query().Get(name).

date := ctx.QueryParam("date")   // ?date=2026-05-04

The query string is parsed on the first call and reused for the rest of the request — url.Values is rebuilt from the raw string by every Query() call, so asking for three parameters used to parse it three times (PERF-4). A request that never asks for one parses nothing. Safe to call from any goroutine.

func (*ServerContext) RawExec

func (c *ServerContext) RawExec(query string, args ...any) (int64, error)

RawExec executes a parameterised non-SELECT statement and returns the number of rows affected. When ctx.Tx is active the statement participates in it; otherwise it uses the adapter's write pool.

If a transaction is active but its Tx cannot run raw SQL, the call fails with ErrRawNotSupportedInTx rather than quietly running outside the transaction — where the write would commit on its own and survive the rollback.

func (*ServerContext) RawQuery

func (c *ServerContext) RawQuery(query string, args ...any) ([]Row, error)

RawQuery executes a parameterised statement that returns rows and returns each row as a column-name → value map. This is a SELECT, a CTE-SELECT, or a data-modifying statement with a RETURNING clause (e.g. UPDATE … RETURNING id — a "claim-and-fetch" pattern). When ctx.Tx is active the query participates in the transaction; otherwise a plain SELECT uses the read pool and a RETURNING write uses the write pool.

If a transaction is active but its Tx cannot run raw SQL, the call fails with ErrRawNotSupportedInTx rather than quietly running outside the transaction.

Placeholders are rebound to the adapter's dialect, so `?` works on both SQLite and Postgres ($N). Never interpolate values directly into the query string.

func (*ServerContext) RecursiveQuery

func (c *ServerContext) RecursiveQuery(modelName string, q RecursiveQuery) ([]Row, error)

RecursiveQuery executes a WITH RECURSIVE CTE against a self-referential model and returns all nodes in the subtree (or ancestor chain) starting at q.RootID. Every returned row contains an integer _depth column (0 = root, 1 = immediate children/parent, …). Rows are ordered by _depth ascending.

Both Postgres (WITH RECURSIVE … $N) and SQLite (WITH RECURSIVE … ?, supported since 3.8.3) are handled transparently.

The query participates in ctx.Tx when one is active; otherwise it uses the adapter's read pool.

rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
    RootID:      "some-uuid",
    ParentField: "parent_id",
    MaxDepth:    5,
})
// rows[0]["_depth"] == int64(0) is the root; rows[1..n] are descendants.

func (*ServerContext) Search

func (c *ServerContext) Search(opts SearchOptions) ([]SearchResult, error)

Search runs a cross-model full-text search and returns the merged, relevance- ranked hits. It participates in ctx.Tx when one is active (via ctx.RawQuery); otherwise it uses the adapter's read pool.

hits, err := ctx.Search(maniflex.SearchOptions{
    Query:  "wireless headphones",
    Models: []string{"Post", "Comment"}, // explicit, app-authorised set
    Limit:  20,
})

With Models empty it searches every ModelConfig.GlobalSearchable model — the behaviour the built-in GET /search endpoint relies on.

func (*ServerContext) ServiceName

func (c *ServerContext) ServiceName() string

ServiceName returns the Config.ServiceName configured on the Server instance, or "" when none was set. Middleware uses this to enrich audit records, outgoing requests, and observability payloads without holding a reference to the framework Config.

func (*ServerContext) Set

func (c *ServerContext) Set(key string, val any)

Set stores a value that later pipeline steps or middleware can retrieve.

func (*ServerContext) SetField

func (c *ServerContext) SetField(jsonName string, value any)

SetField sets a request-body field by its JSON name, writing through to ctx.ParsedBody and (when a typed record is bound) the matching struct field on ctx.Record, marking it present. Use it from create/update middleware.

func (*ServerContext) SetKeyProvider added in v0.1.3

func (c *ServerContext) SetKeyProvider(kp KeyProvider)

SetKeyProvider wires the KeyProvider used to encrypt/decrypt mfx:"encrypted" fields on the typed (maniflex.Create/Read) and ctx.GetModel paths. The HTTP pipeline sets this automatically; call it on a NewBackground context (workers, CLIs) that reads or writes encrypted models, e.g.

bg := maniflex.NewBackground(ctx, srv.DB(), srv.Registry())
bg.SetKeyProvider(srv.KeyProvider())

func (*ServerContext) SetMaxBodySize added in v0.1.5

func (c *ServerContext) SetMaxBodySize(limit int64)

SetMaxBodySize overrides the JSON body size limit for this request (default 4 MB). Call it from a Deserialize-step middleware, before the body is read — body.MaxBodySize does exactly this. A non-positive value is ignored.

func (*ServerContext) TrackStoredFile

func (c *ServerContext) TrackStoredFile(key string, storage FileStorage)

TrackStoredFile records an object written to FileStorage during this request so the framework can delete it (roll back the write) if the request ultimately fails. The default file-upload step calls this after every successful Store; custom Service middleware that writes to storage directly should call it too, passing the backend it used, to get the same orphan-cleanup guarantee (3B.2b).

A request that ends 2xx keeps every tracked file. A pipeline error or a non-2xx final response deletes them all (see cleanupOrphanedFiles).

func (*ServerContext) TryBindJSON

func (c *ServerContext) TryBindJSON(v any) (ok bool, err error)

TryBindJSON behaves like BindJSON but treats an absent body as a non-error, so GET or optional-body actions can share the same size-limited, abort-on- failure parsing path. It returns (false, nil) when the body is empty (v left untouched, ctx not aborted), (false, err) after calling ctx.Abort on an I/O, size, or parse failure, and (true, nil) on success with v populated and ctx.RawBody set:

ok, err := ctx.TryBindJSON(&req)
if err != nil {
    return nil // ctx.Abort already called
}
if ok {
    // req populated from a present body
}

func (*ServerContext) URLParam

func (c *ServerContext) URLParam(name string) string

URLParam returns the value of a named URL parameter from the current request. Uses chi's URLParam under the hood; action handlers use this instead of importing chi directly.

// Route: /patients/{patientId}/notes/{noteId}
patientID := ctx.URLParam("patientId")
noteID    := ctx.URLParam("noteId")

type Service

type Service interface {
	// Start is called once, after migration and DB-ready, before the HTTP
	// listener opens. It must return promptly — spawn any long-running loop on
	// its own goroutine (or via Server.Go) rather than blocking here. The ctx
	// is cancelled when shutdown begins, so a loop that selects on ctx.Done()
	// winds itself down without waiting for Stop.
	//
	// A non-nil error aborts boot exactly like a failed migration; services that
	// already started are stopped in reverse order first.
	Start(ctx context.Context) error

	// Stop is called once during graceful shutdown, after the HTTP listener has
	// drained and after the Start ctx has been cancelled. Use the ctx to flush
	// buffers, close connections, or wait for in-flight work to settle.
	//
	// The ctx carries what remains of the one shutdown budget — Config.Shutdown-
	// Timeout, or the deadline passed to Server.Shutdown — shared with the HTTP
	// drain that ran before it and the OnShutdown hook and goroutine drain that
	// run after. A slow drain leaves Stop less time, so honour the ctx: it is
	// what keeps total shutdown inside the window an orchestrator gives you
	// before it sends SIGKILL.
	Stop(ctx context.Context) error
}

Service is an application-owned, long-lived background component supervised by the Server: a poller, cache warmer, queue consumer, or an in-memory pool manager. Register one with Server.AddService and the framework wires it into the boot and shutdown lifecycle instead of the caller hand-supervising it around Start.

Boot order: migrate → OnStart → Service.Start (registration order) → listen. Shutdown order: http.Shutdown → Service.Stop (reverse order) → OnShutdown →

drain Server.Go + ctx.GoBackground goroutines.

All of shutdown is bounded by Config.ShutdownTimeout.

type ServiceFunc

type ServiceFunc func(ctx context.Context) error

ServiceFunc adapts a bare start function into a Service. Stop is a no-op, so the function is expected to honour cancellation of the ctx passed to Start (e.g. a loop that returns on ctx.Done()).

server.AddService(maniflex.ServiceFunc(func(ctx context.Context) error {
    go poller.Run(ctx) // exits when ctx is cancelled at shutdown
    return nil
}))

func (ServiceFunc) Start

func (f ServiceFunc) Start(ctx context.Context) error

Start runs the wrapped function.

func (ServiceFunc) Stop

Stop is a no-op; the function honours ctx cancellation from Start instead.

type SoftDeletable

type SoftDeletable interface {
	SoftDeleteConfig() SoftDeleteConfig
}

SoftDeletable is satisfied by models that declare their own soft-delete config.

type SoftDeleteConfig

type SoftDeleteConfig struct {
	Enabled   bool
	Field     string          // DB column name, e.g. "deleted_at"
	FieldType SoftDeleteStyle // how to test "is deleted"
}

SoftDeleteConfig describes how a model handles soft deletion. When Enabled is false (default) hard-deletes are performed.

type SoftDeleteStyle

type SoftDeleteStyle int

SoftDeleteStyle indicates how soft deletion is stored in the database.

const (
	// SoftDeleteTimestamp stores a nullable timestamp; NULL means not deleted.
	SoftDeleteTimestamp SoftDeleteStyle = iota
	// SoftDeleteBool stores a boolean flag; false/0 means not deleted.
	SoftDeleteBool
)

type SortDir

type SortDir string

SortDir is a sort direction.

const (
	SortAsc  SortDir = "asc"
	SortDesc SortDir = "desc"
)

type SortExpr

type SortExpr struct {
	DBName    string  // DB column name
	Direction SortDir // asc or desc

	// IsLocale is true when the sort column is a LocaleString field and the
	// sort should use a JSON path expression targeting LocaleKey.
	IsLocale  bool
	LocaleKey string // e.g. "ar" — set by locale enrichment in the Deserialize step

	// Nested sort — the sort name contained a "." referencing a BelongsTo
	// relation, e.g. ?sort=vendor.name:asc. Mirrors the nested fields on
	// FilterExpr; the query builder adds a LEFT JOIN and orders on the
	// related table's column.
	IsNested      bool
	RelationKey   string // relation key / JOIN alias, e.g. "vendor"
	RelationModel string // related model struct name, e.g. "Vendor"
	RelationTable string // DB table of related model, e.g. "vendors"
	RelationFK    string // FK column on THIS table, e.g. "vendor_id"
	NestedField   string // DB column on the related table, e.g. "name"
}

SortExpr is a parsed sort instruction.

type StepRegistry

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

StepRegistry holds all middlewares registered for one pipeline step. Obtain one from the Pipeline struct and call Register() on it.

func (*StepRegistry) Register

func (s *StepRegistry) Register(fn MiddlewareFunc, opts ...MiddlewareOption)

Register adds a middleware to this pipeline step.

Without options the middleware applies to every model and operation. Combine options to narrow the scope:

// Run before the DB step for all POST /users requests
pipeline.DB.Register(hashPassword,
    maniflex.ForModel("User"),
    maniflex.ForOperation(maniflex.OpCreate),
)

// Run after the Response step for every request
pipeline.Response.Register(addCorpHeaders, maniflex.AtPosition(maniflex.After))

// Replace the default Service step for Post creates
pipeline.Service.Register(publishPost,
    maniflex.ForModel("Post"),
    maniflex.ForOperation(maniflex.OpCreate),
    maniflex.AtPosition(maniflex.Replace),
)

Must be called before Start() or Handler(): the composed chains are cached when the router is built, so a middleware registered afterwards could not be applied consistently — Register panics rather than take effect for some requests only.

type Tx

type Tx interface {
	// The same CRUD methods as DBAdapter, used when routing through a transaction.
	// Typed-models contract (Phase 3): the returned any is always a *T for
	// model.GoType; record passed to Create/Update is likewise a *T.
	FindByID(ctx context.Context, model *ModelMeta, id string, q *QueryParams) (any, error)
	FindMany(ctx context.Context, model *ModelMeta, q *QueryParams) ([]any, int64, error)
	Create(ctx context.Context, model *ModelMeta, record any) (any, error)
	Update(ctx context.Context, model *ModelMeta, id string, record any, present map[string]struct{}) (any, error)
	Delete(ctx context.Context, model *ModelMeta, id string) error

	// FindByIDForUpdate fetches the record and acquires a pessimistic row-level
	// lock held until the transaction commits or rolls back.
	// Postgres: appends FOR UPDATE to the SELECT.
	// SQLite: behaves identically to FindByID — the lock is at the transaction
	// level, taken at BEGIN (the sqlite adapter opens write connections with
	// _txlock=immediate).
	FindByIDForUpdate(ctx context.Context, model *ModelMeta, id string) (any, error)

	// Commit finalises the transaction. Returns an error if the commit fails.
	Commit() error

	// Rollback aborts the transaction. Returns nil if the transaction has
	// already been committed (safe to defer unconditionally).
	Rollback() error
}

Tx is a transaction handle. It exposes the same write operations as DBAdapter so middleware can use it identically to the adapter itself.

Obtain one via ctx.BeginTx() and store it on ctx.Tx:

tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil { ... }
ctx.Tx = tx
defer tx.Rollback() // no-op after Commit

The default DB step routes through ctx.Tx when it is non-nil, so all Create / Update / Delete calls within the same request will use the transaction.

func TxFromContext

func TxFromContext(ctx context.Context) Tx

TxFromContext returns the active database transaction stored in ctx by WithTransaction, or nil when no transaction is active. Use this from packages (e.g. jobs/sql) that need to join the caller's transaction without holding a *ServerContext reference.

It returns nil once WithTransaction has committed or rolled back, so a caller that resumes after the transaction closed gets the bare adapter rather than a finished transaction.

type TxOptions

type TxOptions = sql.TxOptions

TxOptions mirrors sql.TxOptions so callers do not need to import database/sql.

type UploadedFile

type UploadedFile struct {
	Filename    string
	ContentType string
	Size        int64
	Reader      io.ReadCloser
}

UploadedFile holds a parsed file from a multipart request, ready for processing by the Service step.

type WithDeletedAt

type WithDeletedAt struct {
	DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at" mfx:"readonly,filterable"`
}

WithDeletedAt enables deleted_at-style soft deletion.

func (WithDeletedAt) SoftDeleteConfig

func (WithDeletedAt) SoftDeleteConfig() SoftDeleteConfig

type WithIsDeleted

type WithIsDeleted struct {
	IsDeleted bool `json:"is_deleted" db:"is_deleted" mfx:"readonly,filterable,default:false"`
}

WithIsDeleted enables is_deleted boolean-style soft deletion.

func (WithIsDeleted) SoftDeleteConfig

func (WithIsDeleted) SoftDeleteConfig() SoftDeleteConfig

Directories

Path Synopsis
admin module
db
sqlcore
Package sqlcore provides a database/sql-backed DBAdapter that works with both PostgreSQL ($N placeholders) and SQLite (? placeholders).
Package sqlcore provides a database/sql-backed DBAdapter that works with both PostgreSQL ($N placeholders) and SQLite (? placeholders).
postgres module
sqlite module
Package events provides a CloudEvents 1.0-aligned event bus for maniflex.
Package events provides a CloudEvents 1.0-aligned event bus for maniflex.
cloudevents
Package cloudevents provides encoding and decoding helpers for CloudEvents 1.0.
Package cloudevents provides encoding and decoding helpers for CloudEvents 1.0.
inproc
Package inproc provides an in-process event bus for maniflex.
Package inproc provides an in-process event bus for maniflex.
outbox
Package outbox provides a transactional outbox publisher for maniflex events.
Package outbox provides a transactional outbox publisher for maniflex events.
kafka module
nats module
rabbitmq module
redis module
Package jobs provides a durable, adapter-pluggable job queue for maniflex.
Package jobs provides a durable, adapter-pluggable job queue for maniflex.
cron
Package cron provides a best-effort scheduled trigger for maniflex jobs.
Package cron provides a best-effort scheduled trigger for maniflex jobs.
inproc
Package inproc provides an in-process job queue backed by a goroutine pool.
Package inproc provides an in-process job queue backed by a goroutine pool.
maniflex
Package jobsmaniflex (imported as jobs/maniflex) integrates the jobs package with the maniflex model layer (3C.4).
Package jobsmaniflex (imported as jobs/maniflex) integrates the jobs package with the maniflex model layer (3C.4).
sql
maniflex_glue.go wires maniflex.TxFromContext into the jobs/sql outbox path.
maniflex_glue.go wires maniflex.TxFromContext into the jobs/sql outbox path.
redis module
middleware
auth
Package auth provides authentication and authorisation middleware for maniflex.
Package auth provides authentication and authorisation middleware for maniflex.
body
Package body provides Deserialize-step middleware for request body control.
Package body provides Deserialize-step middleware for request body control.
db
Package db provides DB-step middleware for query control, tenancy, rate limiting, auditing, and cache invalidation.
Package db provides DB-step middleware for query control, tenancy, rate limiting, auditing, and cache invalidation.
idempotency
Package idempotency provides a Deserialize-step middleware that makes POST requests safe to retry over flaky networks.
Package idempotency provides a Deserialize-step middleware that makes POST requests safe to retry over flaky networks.
openapi
Package openapi provides OpenAPI pipeline middleware for enriching the auto-generated spec with security schemes, servers, examples, and extensions.
Package openapi provides OpenAPI pipeline middleware for enriching the auto-generated spec with security schemes, servers, examples, and extensions.
response
Package response provides Response-step middleware for CORS, caching, field transforms, custom envelopes, and observability.
Package response provides Response-step middleware for CORS, caching, field transforms, custom envelopes, and observability.
service
Package service provides Service-step middleware for common field transforms and computed-value injection that runs before the DB write.
Package service provides Service-step middleware for common field transforms and computed-value injection that runs before the DB write.
validate
Package validate provides Validate-step middleware for advanced field rules that cannot be expressed with static mfx struct tags.
Package validate provides Validate-step middleware for advanced field rules that cannot be expressed with static mfx struct tags.
workflow
Package workflow provides a Validate-step middleware that enforces permitted state transitions on a model.
Package workflow provides a Validate-step middleware that enforces permitted state transitions on a model.
auth/redis module
db/redis module
pkg
encryption
Package encryption provides KeyProvider implementations for mfx:"encrypted" struct fields.
Package encryption provides KeyProvider implementations for mfx:"encrypted" struct fields.
integration
Package integration provides building blocks for outbound integrations: HTTP callers with retry/backoff, periodic pollers, and signed-webhook receivers.
Package integration provides building blocks for outbound integrations: HTTP callers with retry/backoff, periodic pollers, and signed-webhook receivers.
ledger
Package ledger provides double-entry accounting primitives for Maniflex applications.
Package ledger provides double-entry accounting primitives for Maniflex applications.
money
Package money provides a fixed-precision monetary amount type for use as a model field.
Package money provides a fixed-precision monetary amount type for use as a model field.
saga
Package saga provides a lightweight in-process saga coordinator for cross-domain atomic operations.
Package saga provides a lightweight in-process saga coordinator for cross-domain atomic operations.
otel module
Package realtime provides a WebSocket and SSE hub backed by events.Bus.
Package realtime provides a WebSocket and SSE hub backed by events.Bus.
Package scheduled applies time-driven state transitions to registered models.
Package scheduled applies time-driven state transitions to registered models.
jobsx
Package jobsx bridges a scheduled.Runner to the jobs system, enabling the durable/distributed sweep path without making the core scheduled package depend on jobs.
Package jobsx bridges a scheduled.Runner to the jobs system, enabling the durable/distributed sweep path without making the core scheduled package depend on jobs.
Package storage provides file storage backends for maniflex.
Package storage provides file storage backends for maniflex.
s3 module

Jump to

Keyboard shortcuts

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