maniflex

package module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 45 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.12 or newer.

License

MIT

Documentation

Index

Examples

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 CanonicalTimeLayout = "2006-01-02T15:04:05.000000000Z07:00"

CanonicalTimeLayout is the string form maniflex writes time.Time values in for the SQL adapters, and the form it canonicalises timestamp filter values into.

SQLite has no native timestamp type: the adapters store timestamps as TEXT and compare them with the default BINARY collation, i.e. byte-by-byte. For a range filter (created_at >= ?, a scheduled due-check, a cursor) to order rows the way the instants order, the string form must sort lexicographically the same way it sorts in time. time.RFC3339Nano does not: it drops trailing fractional zeros, so its width varies, and within one second a whole-second stamp ("…56Z") sorts AFTER a fractional one ("…56.5Z") because the byte after the seconds is 'Z' (0x5A) in one and '.' (0x2E) in the other. This layout pads the fraction to a fixed nine digits so every stamp is the same width and byte order tracks time order. (jobs/sql fixes the identical bug behind its own ts() — audit JB-7.)

Postgres stores TIMESTAMPTZ and compares natively, so it is unaffected either way; feeding it this form is harmless because it is valid RFC3339.

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 DefaultMaxFileCount = 100

DefaultMaxFileCount is the ceiling on how many keys a FileKeys field accepts when it carries no mfx:"max_count:N".

Every key in the array is Stat'd against storage to enforce the field's rules, so an uncapped array is one client request costing N storage round-trips. That is the same shape as an unbounded fan-out, and the framework's other unbounded-input surfaces are capped by default rather than trusted (FilesConfig.MaxUploadBytes, Config.MaxConcurrentExports). Raise it per field with mfx:"max_count:N".

View Source
const DefaultRecursiveMaxDepth = 100

DefaultRecursiveMaxDepth bounds a RecursiveQuery whose MaxDepth is left at the zero value. Before v0.2.5 the zero value meant unlimited, which made an unbounded traversal the default rather than a deliberate choice.

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 MaxIncludeDepth = 2

MaxIncludeDepth is the number of dot-separated segments ?include= accepts: "author" is depth 1, "author.company" is depth 2, and "author.company.owner" is refused.

Capped because the tree is client-supplied and each level multiplies work: the includes are batched per level, so a request costs one query per node, and an uncapped tree would let a caller pick that number. Two levels covers what nesting is actually for — reaching the thing your thing belongs to — without making the cost of a request a matter of opinion.

View Source
const SingletonID = "singleton"

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

It does not apply to a singleton scoped by a forced filter (db.Tenancy, db.ForceFilter): there is one row per scope there, so one fixed id could not name them, and each keeps an ordinary generated primary key.

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 ErrAlreadyStarted = errors.New("maniflex: server already started")

ErrAlreadyStarted is returned when Start, StartWithContext, or StartServices is called while the same Server already has an active startup owner.

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 ErrPresignUnsupported = errors.New("storage backend cannot presign uploads")

ErrPresignUnsupported is returned by FileStorage.PresignUpload from a backend that cannot mint a presigned upload. The upload-url route answers 501 with it.

Returning this is the correct, safe answer — the wrong one is an unsigned URL, which would be an unauthenticated write endpoint wearing a presigned URL's clothes. LocalStorage.URL already sets that precedent on the read side (it returns /files/<key> whatever the ttl), and a read served openly is a leak where a write served openly is an upload endpoint for the internet.

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).

View Source
var ErrRegistrationClosed = errors.New("maniflex: registration is closed")

ErrRegistrationClosed is returned when a route or specification contributor is registered after Start or Handler has sealed the server.

View Source
var ErrStopped = errors.New("maniflex: server has stopped")

ErrStopped is returned when a startup method is called after the Server has stopped, failed to start, or had Shutdown called. A Server is not restartable; construct a new one.

Functions

func AddBatchComputedField added in v0.2.3

func AddBatchComputedField[T any](s *Server, modelName, name string, fn func(ctx *ServerContext, records []*T) ([]any, error), opts ...ComputedOption) error

AddBatchComputedField registers a typed batch-resolved derived field: the callback receives the whole page as []*T and returns one value per record, positionally aligned.

maniflex.AddBatchComputedField(srv, "StoreSite", "item_count",
    func(ctx *maniflex.ServerContext, sites []*StoreSite) ([]any, error) {
        counts, err := itemCountsBySite(ctx, ids(sites)) // ONE query
        ...
    })

Must be called before Start() or Handler().

func AddComputedField

func AddComputedField[T any](s *Server, modelName, name string, fn func(ctx *ServerContext, record *T) (any, error), opts ...ComputedOption) 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 *maniflex.ServerContext, p *Product) (any, error) {
        return stockService.CurrentLevel(ctx.Ctx, p.ID)
    })

Must be called before Start() or Handler().

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 CanonicalTime added in v0.3.2

func CanonicalTime(t time.Time) string

CanonicalTime formats an instant in CanonicalTimeLayout, in UTC.

func Create

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

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

Under an ActionScope the scope's columns are stamped onto the record first, overwriting whatever the caller set — a row created outside the scope would be invisible to the caller that created it, and letting a caller choose the value is exactly the placement the scope exists to prevent.

func DecodeCursor

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

DecodeCursor reverses EncodeCursor. It accepts legacy unversioned tokens so clients can finish an in-progress walk across an upgrade.

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. Under an ActionScope a record outside the scope is ErrNotFound and nothing is deleted.

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 FoldLocaleScalar added in v0.2.5

func FoldLocaleScalar(raw []byte) (map[string]string, bool)

FoldLocaleScalar reads a locale column that holds a bare JSON scalar — "Cardiology" rather than {"en":"Cardiology"} — and returns it as a one-key map so the value stays readable.

Such a column is corrupt: nothing writes this shape any more (see localeWriteValue), but rows written before v0.2.5 carry it, and a hard scan error made them unrecoverable through the API — a 500 on the record and, since one bad row fails the list scan, on the whole collection endpoint. Degrading keeps the endpoint up and the row editable, which is what lets an operator fix it by PATCHing a proper map.

The key is "en", matching effectiveLocaleChain's own hard fallback. Which key it is barely matters for display: resolveLocaleString falls back to any non-empty value when no chain key matches, so the value surfaces under every requested locale either way.

Reported by the caller as a warning, not swallowed — see the "locale field held a bare scalar" log in toJSONMap/marshalRecord.

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 RedactRecord added in v0.3.1

func RedactRecord(model *ModelMeta, v any) any

RedactRecord strips the columns that must never leave the response path from a raw DB result: hidden, write-only and encrypted fields, plus the `_hmac` companion of an encrypted+unique column (a searchable digest of the plaintext, which has no business on the wire either).

It exists because three subsystems serialize `ctx.DBResult` directly rather than through the response serializer, and `ctx.DBResult` is the map the DB step has already *decrypted*: versioning snapshots (audit MS-3), event payloads (EV-1), and the realtime hub, which forwards the event payload (RT-6). Each was leaking the plaintext of every encrypted column and the contents of every hidden and write-only field. One exclusion set closes all three, and — more to the point — keeps them from drifting apart again.

The keys are left as **DB column names**, matching what `ctx.DBResult` already carries. This is deliberately not the response projection: locale resolution and `ctx.RedactResponseField` masking are decisions made for one requesting caller, and an event is durable, replayable, and read by subscribers who never made that request.

v is accepted as `any` because `ctx.DBResult` is: a create or update leaves a `map[string]any`, a typed read leaves a `*T` (whose `json` tags would serialize a hidden field just as readily), and a list leaves a `*ListResult`. All three are handled; anything else is returned unchanged, since a value the framework did not produce has no model to redact against.

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) (int, error)

RotateEncryptionKey is the compatibility wrapper around RotateEncryptionKeyWithOptions. Use the detailed function when a resumable cursor or row-addressable audit report is required.

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, opts ...WriteOption) (*T, error)

Update writes record (a *T) over the row identified by id. It is a full-record update: every writable column is written, so a field left at its zero value overwrites the stored one. For partial/PATCH writes use the pipeline (HTTP PATCH) or ctx.GetModel(name).Update with a present map.

Not written: id, and any column the model marks mfx:"readonly" or mfx:"immutable" — which includes the framework-managed created_at. See updatablePresent. updated_at is still stamped by the adapter.

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. Server-error responses always use a generic public message and omit details and AsIs data, regardless of how the APIResponse was constructed.

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

	// AccessControlled declares that Middleware or Handler makes an explicit
	// authorization decision for this action. AllowPublic instead declares the
	// route intentionally public. Server.ValidateProduction requires one of
	// these declarations when no matching Pipeline.Auth or global HTTP access
	// policy applies. These flags are assertions; they do not install a policy.
	AccessControlled bool
	AllowPublic      bool

	// 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 ActionScope added in v0.2.3

type ActionScope struct {
	// Filters constrain every read, and every write is refused unless the target
	// record matches them. They are AND-ed with whatever the caller asked for.
	Filters []*FilterExpr

	// Name identifies the scope in the error a refused path returns, so the
	// message says what is being enforced rather than only that something is.
	// db.TenancyAction sets "tenancy"; empty reads as "an access scope".
	Name string
}

ActionScope is the row-level scope in force for a custom Action. It is set by db.TenancyAction / db.ForceFilterAction (or by hand, via ServerContext.SetActionScope) and read by every DB path reachable from an action handler.

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. The document is mounted at {PathPrefix}/asyncapi.json only when Config.Documentation also explicitly publishes or protects generated documentation. 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 BatchComputedFunc added in v0.2.3

type BatchComputedFunc func(ctx *ServerContext, rows []map[string]any) ([]any, error)

BatchComputedFunc derives a virtual field for a whole page in one call. It receives every row being returned and must return exactly one value per row, positionally aligned to `rows` — a length mismatch is logged and the field is omitted rather than misaligned onto the wrong records.

This is the answer to the N+1 that ComputedFunc invites: one query resolves a column for the whole page.

srv.AddBatchComputedField("StoreSite", "item_count",
    func(ctx *maniflex.ServerContext, rows []map[string]any) ([]any, error) {
        ids := make([]any, len(rows))
        for i, r := range rows { ids[i] = r["id"] }
        counts, err := itemCountsBySite(ctx, ids) // ONE query
        if err != nil { return nil, err }
        out := make([]any, len(rows))
        for i, r := range rows { out[i] = counts[r["id"].(string)] }
        return out, nil
    })

A single read and the create/update echo call it with a one-row slice, so one registration serves every read path.

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 BlindIndexKeyProvider added in v0.4.0

type BlindIndexKeyProvider interface {
	BlindIndexKeyID() string
}

BlindIndexKeyProvider is an optional KeyProvider capability that keeps encrypted-field uniqueness digests under a key independent of field encryption keys. RotateEncryptionKey requires a non-empty blind-index key for models with encrypted+unique fields.

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
	// Schema is the OpenAPI schema emitted for this field in the model's
	// response schema. Nil means "any type" — the framework cannot infer it,
	// since the callbacks return `any`. Set it with ComputedSchema.
	Schema *OASSchema
	// 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 *ServerContext, row map[string]any) (any, error)

ComputedFunc derives a virtual field value from one 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 that row — a computed-field failure must not poison the whole record. A panic is contained the same way: logged with its stack and the field omitted, never propagated (audit MS-6). Rows of a list resolve in worker goroutines, where an escaping panic would take the process down rather than the request.

The callback receives the *ServerContext, so it can reach ctx.Tx, ctx.GetModel and ctx.Auth. Note that rows of a list resolve concurrently (bounded by maxComputedConcurrency): work through ctx.Tx is serialised by the transaction's single connection, so a per-row resolver that queries is an N+1 regardless. Prefer BatchComputedFunc for anything that touches a database.

type ComputedOption added in v0.2.3

type ComputedOption func(*ComputedField)

ComputedOption configures a computed field at registration.

func ComputedSchema added in v0.2.3

func ComputedSchema(s *OASSchema) ComputedOption

ComputedSchema declares the OpenAPI schema for a computed field's value. Without it the field is still emitted in the spec (read-only), but with no type — the framework has no way to know one, since the callbacks return `any`, and guessing would put a claim in the spec that nothing enforces.

srv.AddBatchComputedField("StoreSite", "item_count", fn,
    maniflex.ComputedSchema(&maniflex.OASSchema{Type: "integer"}))

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

	// Documentation controls the generated OpenAPI and AsyncAPI endpoints.
	// The zero value keeps them unmounted. See DocumentationConfig.
	Documentation DocumentationConfig

	// QueryLimits bounds client-controlled URL query and aggregate complexity.
	// Zero fields receive production-safe defaults; see QueryLimits.
	QueryLimits QueryLimits

	// HTTPMiddlewares wrap the generated router in registration order. They run
	// after panic recovery, request-ID assignment, and optional trusted-proxy IP
	// resolution, but before route dispatch and every pipeline step.
	//
	// CORS belongs here so browser preflight requests can be answered before
	// authentication. For example:
	//
	//   cfg.HTTPMiddlewares = []maniflex.HTTPMiddleware{
	//       response.CORSHeaders("https://app.example.com"),
	//   }
	HTTPMiddlewares []HTTPMiddleware

	// HTTPAccessControlled declares that HTTPMiddlewares contains an access
	// policy which protects every route before dispatch. ValidateProduction
	// treats this as an explicit protected-access decision for generated model,
	// action, search, documentation, and standalone file routes.
	//
	// This is an assertion only: setting it does not install authentication.
	// ValidateProduction rejects it when HTTPMiddlewares is empty.
	HTTPAccessControlled bool

	// 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

	// Strict promotes startup warnings that describe a defensible-but-suspect
	// configuration into hard startup failures.
	//
	// It does NOT gate configuration that is unambiguously a mistake: a
	// misplaced ModelConfig, an invalid mfx:"scheduled" tag, a relation on a
	// field with no "ID" suffix, and a middleware registered on a step its
	// operations never reach all fail without it. Those silently dropped what
	// the author asked for, and a fix nobody opts into is not a fix.
	//
	// What it does gate is the handful of warnings that describe something a
	// reasonable application might mean:
	//
	//   - a mfx:"relation" whose target model is not registered (it may simply
	//     be a plain foreign id that wants no relation tag),
	//   - the standalone /files endpoints mounted with neither auth middleware
	//     nor an explicit FilesConfig.AllowPublic declaration,
	//   - a Config.StaticDir that does not exist (a missing asset directory
	//     should not take down a working API, and by default it does not).
	//
	// Turn it on in CI and staging, where a boot failure costs a re-run rather
	// than an outage. Every problem found is reported in one message, so one
	// pass finds all of them.
	//
	// Default: false.
	Strict bool

	// 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 Start or
	// StartServices. In the normal Start path it runs 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. 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.

Implementations must be comparable, and compare by identity

The framework compares DBAdapter values with == and != to decide whether two models live on the same database. ModelConfig.Adapter makes that a real question: with per-model adapters in play, "same adapter" is what decides whether one transaction may span two models.

Two consequences for an implementation:

  • **Use a pointer receiver.** Return *MyAdapter, not MyAdapter. Two separately constructed value-type adapters holding equal fields compare equal, so the framework would treat two distinct databases as one and let a transaction span them — a silent correctness bug, not an error.
  • **Stay comparable.** Comparing interface values whose dynamic type is not comparable — a struct containing a map, slice, or func field, used as a value rather than a pointer — panics at run time. A pointer receiver satisfies this too, since pointers are always comparable.

Both are what every built-in adapter already does; this is written down because nothing in the type system enforces it, and getting it wrong fails at run time or not at all (audit 11D.8).

The comparison sites, should the contract need revisiting: Batcher.For (batch.go) refuses a model that routes elsewhere, and ServerContext.getModel (context.go) drops ctx.Tx when the target model resolves to a different adapter than the request's.

type DocumentationConfig added in v0.4.0

type DocumentationConfig struct {
	// Public explicitly publishes generated documentation without requiring a
	// documentation-specific access policy. Global HTTPMiddlewares still apply.
	Public bool

	// Middleware wraps the generated OpenAPI and AsyncAPI endpoints in order.
	// It is also an explicit opt-in to mounting them. Use AdaptAuth to reuse
	// pipeline auth middleware such as auth.JWTAuth and auth.RequireRole.
	Middleware []HTTPMiddleware
}

DocumentationConfig controls the generated OpenAPI and AsyncAPI endpoints. Its zero value mounts neither endpoint, keeping internal schemas private by default. Set Public only when the documents are intentionally public, or provide Middleware to mount both endpoints behind a shared access policy.

type DriverType

type DriverType int

DriverType distinguishes SQL dialects used by DBAdapter implementations.

const (
	Postgres DriverType = iota
	SQLite
)

type EncryptionRotationError added in v0.4.0

type EncryptionRotationError struct {
	Failures []EncryptionRotationFailure
}

EncryptionRotationError reports every corrupt or inconsistent row found by a completed scan. Use errors.As to inspect Failures programmatically.

func (*EncryptionRotationError) Error added in v0.4.0

func (e *EncryptionRotationError) Error() string

func (*EncryptionRotationError) Unwrap added in v0.4.0

func (e *EncryptionRotationError) Unwrap() error

type EncryptionRotationFailure added in v0.4.0

type EncryptionRotationFailure struct {
	RowID string
	Field string
	Stage string
	Err   error
}

EncryptionRotationFailure identifies a row which could not be safely inspected or transformed. Rotation never silently skips one of these.

func (EncryptionRotationFailure) Error added in v0.4.0

type EncryptionRotationOptions added in v0.4.0

type EncryptionRotationOptions struct {
	// AfterID resumes strictly after this record ID. Use LastID after an
	// interruption or adapter error. Reported row failures must instead be
	// repaired and preflighted again from the beginning.
	AfterID string

	// PageSize controls keyset page size. Zero uses 100.
	PageSize int
}

EncryptionRotationOptions controls a resumable encryption-key rotation.

type EncryptionRotationReport added in v0.4.0

type EncryptionRotationReport struct {
	Model          string
	OldKeyID       string
	NewKeyID       string
	StartedAfterID string
	LastID         string
	Scanned        int
	Rotated        int
	Failures       []EncryptionRotationFailure
	Complete       bool
}

EncryptionRotationReport is the auditable result of a rotation attempt.

func RotateEncryptionKeyWithOptions added in v0.4.0

func RotateEncryptionKeyWithOptions(
	ctx context.Context,
	s *Server,
	modelName, oldKeyID, newKeyID string,
	opts EncryptionRotationOptions,
) (report EncryptionRotationReport, err error)

RotateEncryptionKeyWithOptions re-encrypts a model's old-key envelopes using keyset pagination. Both encryption keys must remain available until Complete is true.

Before writing anything, the function preflights the full requested range. Encrypted+unique fields require a dedicated blind-index key and their stored digest must already match it. The digest is not changed during rotation: equal plaintext therefore keeps the same UNIQUE value across old-key rows, new-key rows, and concurrent writes.

If interrupted, old-key and new-key rows can coexist safely. Resume with AfterID set to the returned LastID. Rows already using newKeyID are idempotently ignored.

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.
	//
	// For a composite constraint this is the first column only. Prefer Columns,
	// which names all of them; Column is kept because it predates it.
	Column string

	// Columns names every column the violated constraint covers, in the order
	// the driver reported them. A composite UNIQUE(phone_number, owner_id)
	// yields both, because neither column is in violation on its own and
	// reporting one of them blames the wrong field (audit 11D.1).
	//
	// Single-column constraints yield one entry, equal to Column. Empty for
	// drivers or violation kinds that expose no column detail.
	Columns []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 ExecuteError added in v0.2.3

type ExecuteError struct {
	// StatusCode is the HTTP status the pipeline produced.
	StatusCode int

	// Code and Message are the API error's code and message, when it carried one.
	Code    string
	Message string

	// Response is the whole envelope, including any Data.
	Response *APIResponse
}

ExecuteError is returned by Server.Execute when the pipeline answered with a status outside 2xx. The full response is on Response.

A non-2xx is an error rather than a value on purpose. The batch this exists to serve loops N invocations inside one transaction, and the failure it must survive is item 3 of 5 answering 422 — so the natural Go loop, `if err != nil { return err }`, has to be the one that rolls back. Handing a 422 back as (res, nil) would make the naive loop commit the prefix, which is the exact bug the caller came here to fix. Use errors.As to inspect the status.

func (*ExecuteError) Error added in v0.2.3

func (e *ExecuteError) Error() string

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.

func (FieldMeta) IsFileList added in v0.2.3

func (f FieldMeta) IsFileList() bool

IsFileList reports whether this field is an mfx:"file" column holding many storage keys (maniflex.FileKeys) rather than one.

The single-key shape is what mounts an attachment route (GET /{model}/{id}/ {field} streams one object) and what multipart can populate (one file per part). A list does neither, so the paths that assume one key ask this first.

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; implies Readonly unless WriteOnly
	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. On a FileKeys field
	// it bounds each key's object, not their total.
	MaxSize int64
	// MaxCount bounds how many keys a maniflex.FileKeys field accepts. Parsed
	// from mfx:"max_count:20". Zero means DefaultMaxFileCount — every key is
	// Stat'd against storage, so an uncapped array is one request costing N
	// round-trips. Meaningless on a single-key (string) file field, which is a
	// registration error rather than a silently ignored option.
	MaxCount int
	// 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
	// PresignedUpload mounts POST /{model}/{field}/upload-url for this field, so a
	// client uploads its bytes straight to storage and then sends back only the
	// key. Parsed from mfx:"upload:presigned". Requires File.
	//
	// The default (multipart through the app) materialises the whole body in the
	// server process before the handler runs, so a 60 MB video costs 60 MB of
	// server memory and two hops. This is the way out of both, and the field's
	// max_size and accept rules still bind: they are pinned into the signature
	// where the backend can, and re-checked against the stored object when the
	// record references the key.
	PresignedUpload bool
	// StreamUpload pipes a multipart upload straight to storage as it arrives off
	// the socket, instead of buffering the whole body to the app server's disk
	// first. Parsed from mfx:"upload:stream". Requires File, and is mutually
	// exclusive with PresignedUpload (a field has one upload strategy).
	//
	// The default multipart path materialises the whole request before the
	// handler runs — a 5 GB upload lands on the app server's disk in full, then is
	// copied to storage. Streaming removes that landing: bytes are written to the
	// backend as they are read. The trade-off is that the object is stored before
	// the record's own validation runs (accept is still checked from the sniffed
	// head first, and max_size mid-stream), so a request that later fails leaves
	// an object that the same non-2xx cleanup as every other stored file removes.
	// For the very largest uploads prefer PresignedUpload, which never routes the
	// bytes through the app at all.
	StreamUpload 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

	// UnknownOpts holds every mfx: comma-part the parser did not recognise, in
	// declaration order. ScanModel rejects a field that has any: an unknown
	// option used to be discarded in silence, so a directive typo'd as
	// `read_only` left Readonly false and the field client-writable — the tag
	// failing open in exactly the case it exists to protect.
	UnknownOpts []string

	// MalformedOpts holds mfx: parts whose *key* is recognised but whose value
	// could not be used — mfx:"min:abc", mfx:"max:", mfx:"enum:a||b". These used
	// to be dropped silently, which reads at a glance as a constraint that is
	// enforced and is not. ScanModel turns a non-empty list into a registration
	// error (audit MS-L11).
	MalformedOpts []string

	// 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 FileKeys added in v0.2.3

type FileKeys []string

FileKeys is a list of storage keys, usable as an mfx:"file" field so a model can hold many files in one column — a gallery, an attachment set.

type Post struct {
    maniflex.BaseModel
    Images maniflex.FileKeys `json:"images" mfx:"file,accept:image/*,max_size:5MB,auto_delete"`
}

It stores as a JSON array (JSONB on Postgres, TEXT on SQLite), so ordering is preserved exactly as written — a gallery keeps its sequence. Every rule a single-key file field enforces applies per key: existence, max_size, accept, file_acl signing on read, auto_delete of superseded objects, and cleanup on hard delete.

Writes are by key reference: upload via POST /files or a presigned upload (mfx:"upload:presigned"), then send the keys. Multipart carries one file per field, so it cannot populate an array — and routing many large files through the app process is what presigned uploads exist to avoid.

A PATCH replaces the whole array, as it does any other column. With auto_delete, keys present before the write and absent after it are deleted from storage once the write commits.

func (FileKeys) SQLType added in v0.2.3

func (FileKeys) SQLType(driver DriverType) string

SQLType implements SQLTyper: JSONB on Postgres (indexable), TEXT elsewhere. Mirrors LocaleString, the existing JSON-backed column type.

func (*FileKeys) Scan added in v0.2.3

func (fk *FileKeys) Scan(src any) error

Scan implements sql.Scanner.

func (FileKeys) Schema added in v0.2.3

func (FileKeys) Schema() *OASSchema

Schema implements ObjectWithSchema so the generated OpenAPI spec describes the column as an array of storage keys.

Without it the field would be absent from the spec entirely: goTypeToSchema maps no slice kind and returns nil, which buildModelSchemas treats as "skip this field" — so a client generated from the spec would not know the column exists. ObjectWithSchema is the framework's own answer for exactly this.

func (FileKeys) Value added in v0.2.3

func (fk FileKeys) Value() (driver.Value, error)

Value implements driver.Valuer, storing the list as a JSON array.

Implementing Valuer/Scanner rather than adding a case to the adapter's write normaliser keeps this type out of db/sqlcore: database/sql honours the interfaces natively, so the column works on any driver without the adapter knowing the type exists.

A nil or empty list stores as "[]" rather than NULL, so a scanned column is always a valid JSON array and reads need no NULL branch.

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.
	//
	// meta.Size is the object's length in bytes when known, and 0 when it is not:
	// a streamed upload (mfx:"file,upload:stream" or POST /files) is piped through
	// before its length is known, so the backend must store an unsized reader —
	// read r to EOF rather than trusting meta.Size. On S3 this means a multipart
	// upload; the AWS SDK's manager.Uploader does it transparently.
	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)

	// Stat returns the metadata of the object at key without fetching its body.
	// Returns ErrFileNotFound when the key does not exist.
	//
	// Size and ContentType must reflect what is actually stored, because they are
	// what the framework checks an mfx:"file" field's max_size and accept rules
	// against when a record references a key uploaded out of band — a client that
	// uploaded 5 GB to a 60 MB field is caught here or nowhere.
	//
	// It exists because Exists answers only yes/no and Retrieve would download the
	// object to read its size, which for the large files this path is for is the
	// cost the path was created to avoid.
	Stat(ctx context.Context, key string) (FileMeta, error)

	// PresignUpload returns a one-shot authorisation for a client to write one
	// object directly to storage at key, bypassing the app process entirely.
	//
	// Return ErrPresignUnsupported when the backend cannot mint one. Do NOT return
	// an unauthenticated URL instead: a presigned upload that degrades to an open
	// one is a hole, not a fallback, and the caller answers 501 rather than
	// handing a client something that only looks signed.
	//
	// A backend that can pin opts.MaxSize into the signature must do so (S3's
	// POST-policy content-length-range does; a presigned PUT cannot). The
	// framework re-checks the stored object on completion regardless, so a backend
	// that cannot pin it is not unsafe — but the bytes have to be paid for and
	// stored before it can be caught, so pin it where you can.
	PresignUpload(ctx context.Context, key string, opts PresignUploadOptions) (*PresignedUpload, 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

	// AllowPublic explicitly declares that the standalone /files endpoints may
	// be reached without BeforeMiddlewares. It is consulted only by
	// Server.ValidateProduction; it does not mount routes or change runtime
	// authorization.
	AllowPublic 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

	// KeyScope binds a minted storage key to the principal that minted it, so a
	// later create/update that references the key by name can be refused when the
	// referencing principal is not the one it was minted for. Without this, any
	// caller who learns a key (they leak through signed URLs and file_acl:private
	// responses) can point their own record at another record's — or another
	// tenant's — object, and an auto_delete field can then delete a blob it does
	// not own.
	//
	// KeyScope returns an opaque token identifying the owner of keys minted in
	// this request. Every minting path (POST /files, a presigned upload, and a
	// multipart upload through a model) prefixes the key with a hash of the token;
	// handleFileKeyReference and the FileKeys list verifier recompute the token
	// from the referencing request and refuse a key whose prefix does not match.
	//
	// When nil, the framework uses ctx.Auth.TenantID when set (so a tenant's
	// members share one scope), else ctx.Auth.UserID. An empty token — the return
	// for an anonymous request, and what you return to opt a request out — leaves
	// the key unscoped and referenceable by anyone, so the guarantee holds only
	// for keys minted while a principal was present. A key minted before this
	// release carries no scope prefix and is likewise left to the existence check,
	// so upgrading does not break references to already-stored keys.
	//
	// The token must resolve consistently across the mint request and the later
	// reference request; if your /files auth and your model auth populate ctx.Auth
	// differently, override KeyScope to read whatever both share.
	KeyScope func(ctx *ServerContext) 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

	// Forced marks a filter the server imposed rather than one the client asked
	// for — a tenant scope, an ownership scope, a soft-delete guard. It changes
	// nothing about how the filter reads; it decides whether the filter also
	// constrains an update or a delete.
	//
	// The distinction is necessary because both kinds share Query.Filters. A
	// client's ?filter= must keep being ignored on a write (it always was, and a
	// stray query parameter turning a PATCH into a 404 would be a surprise), while
	// a server-imposed scope must be honoured there or a caller can update and
	// delete rows the same filter hides from their reads.
	//
	// db.Tenancy and db.ForceFilter set it. Set it on a hand-built FilterExpr when
	// the filter expresses who may touch the row rather than which rows were asked
	// for; the DB step then refuses a write to a record the filter excludes.
	Forced bool
}

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)
)

func (FilterOperator) Valid added in v0.2.3

func (o FilterOperator) Valid() bool

Valid reports whether o is an operator the query builder implements.

ParseFilterParam checks this for every filter arriving over HTTP, so a client cannot get an unknown operator past it. A FilterExpr built in Go bypasses that parse entirely, and Operator is a bare string type — which is what makes a typo (Operator: "equals", where the constant OpEq is "eq") compile, register, and reach the adapter as a predicate nothing recognises. Valid is exported so a custom adapter can hold the same line the shipped one does.

type ForeignKeySpec added in v0.2.4

type ForeignKeySpec struct {
	Name      string         // deterministic constraint name
	Column    string         // FK column on this (child) table
	RefTable  string         // referenced (parent) table
	RefColumn string         // referenced (parent) column — its primary key
	OnDelete  OnDeleteAction // cascade / setNull / restrict
}

ForeignKeySpec describes a foreign-key constraint an adapter should emit for a model. It is derived from an mfx:"relation:Parent;onDelete:ACTION" tag whose action the database can enforce — one where neither side soft-deletes. The FK lives on the child table (Column); RefTable/RefColumn name the parent.

func ForeignKeysFor added in v0.2.4

func ForeignKeysFor(reg RegistryAccessor, m *ModelMeta) []ForeignKeySpec

ForeignKeysFor returns the FK constraints an adapter should emit for model m: one per BelongsTo relation carrying an onDelete action the database can enforce (dbEnforcedDelete). Edges that touch soft-delete are enforced in the maniflex delete path instead and are not returned here — so the FK constraints an adapter emits and the edges enforceCascadeDelete skips are the same set, drawn by the same line.

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
	// AllowPublic explicitly declares the search endpoint intentionally public.
	// ValidateProduction otherwise requires a matching Pipeline.Auth policy or
	// Config.HTTPAccessControlled.
	AllowPublic bool
}

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

type HTTPMiddleware added in v0.4.0

type HTTPMiddleware func(http.Handler) http.Handler

HTTPMiddleware wraps the server's HTTP router. Unlike pipeline middleware, it runs before route dispatch and therefore before Auth. Use this layer for protocol concerns such as CORS preflight, request shaping, and edge logging.

func AdaptAuth added in v0.4.0

func AdaptAuth(middleware ...MiddlewareFunc) HTTPMiddleware

AdaptAuth adapts request-level pipeline authentication middleware for use as HTTP middleware. It is intended for router-level endpoints such as generated documentation, which have no model or CRUD operation but still need the same JWT, API-key, role, or scope checks as model routes.

Middleware runs in the supplied order and shares one ServerContext, so an authenticator can populate Auth for a following authorizer:

cfg.Documentation.Middleware = []maniflex.HTTPMiddleware{
    maniflex.AdaptAuth(
        auth.JWTAuth(secret),
        auth.RequireRole("internal"),
    ),
}

Do not adapt middleware that requires Model, DB, or CRUD operation state.

Example
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/xaleel/maniflex"
	"github.com/xaleel/maniflex/middleware/auth"
)

func main() {
	const jwtSecret = "replace-with-a-strong-32-byte-secret"
	server := maniflex.New(maniflex.Config{
		Documentation: maniflex.DocumentationConfig{
			Middleware: []maniflex.HTTPMiddleware{
				maniflex.AdaptAuth(
					auth.JWTAuth(jwtSecret),
					auth.RequireRole("internal"),
				),
			},
		},
	})

	req := httptest.NewRequest(http.MethodGet, "/api/openapi.json", nil)
	rec := httptest.NewRecorder()
	server.Handler().ServeHTTP(rec, req)
	fmt.Println(rec.Code)

}
Output:
401

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 Invocation added in v0.2.3

type Invocation struct {
	// Model is the registered model's struct name, e.g. "Order". Required.
	Model string

	// Operation is the operation to run: OpCreate, OpRead, OpUpdate, OpDelete or
	// OpList. Required. The streaming operations (OpExport, OpReadAttachment) and
	// the trimmed-pipeline ones (OpAction, OpSearch) are refused — see Execute.
	Operation Operation

	// ID is the record's primary key, for OpRead, OpUpdate and OpDelete.
	ID string

	// Body is the request body for OpCreate and OpUpdate. A []byte or
	// json.RawMessage is used verbatim; anything else is marshalled. For an
	// OpUpdate this is what decides presence: a field absent from the body is
	// left alone, exactly as it is over HTTP, so send a map when you mean to
	// patch some columns and a full struct when you mean to send them all.
	Body any

	// Query carries what a URL's query string would: filters, sort, include,
	// pagination. Build it with url.Values, or leave it nil.
	//
	//	Query: url.Values{"filter": {"status:eq:open"}, "limit": {"50"}},
	Query url.Values

	// Auth is the principal this invocation runs as. It is a typed value rather
	// than a header, which is the point: a caller cannot forge it and a client
	// cannot reach it. Leave it nil to run unauthenticated.
	Auth *AuthInfo

	// Tx, when set, is the transaction this invocation joins. Several Executes
	// sharing one Tx commit or roll back together, which is what makes a
	// multi-item staged write atomic. The caller owns it: Execute neither commits
	// nor rolls back.
	Tx Tx

	// Header carries request headers for middleware that reads them (If-Match,
	// Accept-Language, Idempotency-Key). Authorization does not belong here — use
	// Auth. Optional.
	Header http.Header
}

Invocation describes one pipeline run raised from Go. See Server.Execute.

type JunctionModel added in v0.2.6

type JunctionModel struct{}

JunctionModel marks a model as a many-to-many join table. Embed it alongside BaseModel, not instead of it — a junction is an ordinary model with an id, and everything that reads or writes one depends on that:

type ProductTag struct {
    maniflex.BaseModel
    maniflex.JunctionModel `mfx:"unique"`
    ProductID string `json:"product_id" mfx:"relation"`
    TagID     string `json:"tag_id"     mfx:"relation"`
    Position  int    `json:"position"`
}

The model must have exactly two BelongsTo relations to distinct models; anything else is a registration error, since there is no pair to join.

mfx:"unique"

Off by default, and deliberately so. Uniqueness on the key pair is the norm for a pure link table — Django, Rails and EF Core all default to it — but it is wrong for a junction that carries its own attributes, where the same pair may legitimately repeat:

Enrollment{student_id, course_id, term}       // same pair, different terms
Attendance{user_id, event_id, checked_in_at}  // many check-ins

Nothing in the model's shape says which kind it is, which is the same reason detection had to be narrowed. Declaring it emits a UNIQUE index over the two key columns, and also lets an include collapse duplicate links: with the declaration a repeat is corruption, without it a repeat is data.

Adding the tag to a table that already holds duplicate pairs fails the migration until they are cleaned up. That is why it is separate from the marker itself — embedding JunctionModel changes nothing about the schema, so declaring what a model *is* never risks a migration.

Deletes

A junction's foreign keys default to ON DELETE CASCADE, so deleting either endpoint takes its link rows with it. A link to a row that no longer exists says nothing, and leaving it behind was how junction tables accumulated orphans (audit MS-L10). An explicit mfx:"on_delete:..." on the column wins.

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

	// RequiredFields names the model fields this middleware acts on, declared
	// with RequiresField. Checked at startup; empty means "declares nothing".
	RequiredFields []string

	// ProvidesScope marks this middleware as one that establishes the request's
	// row scope, declared with ProvidesScope. Such middleware is hoisted out of
	// its own step and run immediately after Deserialize — see that option.
	ProvidesScope bool
}

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 ProvidesScope added in v0.3.0

func ProvidesScope() MiddlewareOption

ProvidesScope declares that this middleware establishes the request's row scope — it appends forced filters, as db.Tenancy, db.ForceFilter and db.ForceFilterVia do. Declaring it hoists the middleware out of the step it is registered on and runs it immediately after Deserialize, ahead of Validate.

server.Pipeline.DB.Register(
    db.Tenancy("org_id", tenantFromAuth),
    maniflex.ProvidesScope(),
)

Scope is conventionally registered on the DB step, which is late: a scope is an input to almost everything that runs before the write, not just to the query. Anything earlier in the chain that needs to know which rows the caller can see therefore could not ask. The sharpest case is a scoped Singleton, whose row id is not known until its scope is — so validate.UniqueField excluded the record under edit by a placeholder that matched nothing, and the row collided with itself (audit 13.12). auth.ABAC, the auth ownership checks and the workflow transition guard read the record by the same placeholder.

The middleware itself does not change. Hoisting only moves when it runs, and forced-filter middleware is written to be movable: it needs ctx.Query, which Deserialize creates, and nothing the later steps produce.

It is opt-in rather than inferred because the framework cannot tell a scope provider from any other middleware that happens to touch filters, and running the wrong one early is a behaviour change nobody asked for. An operation that skips the step the middleware is registered on still skips it when hoisted.

func RequiresField added in v0.2.4

func RequiresField(names ...string) MiddlewareOption

RequiresField declares that this middleware acts on the named model field(s), so a startup check can refuse a registration that names a field no model has.

It exists because a field-gating middleware that misspells its field is a silent hole, not a visible failure: the gate watches for a body key nothing sends, the real field keeps its name, and nothing gates it. Nothing at runtime can distinguish that from a gate deliberately registered across models where only some carry the field — which is why the middleware cannot detect it for itself, and why the declaration has to come from the registration, where the model scope is known.

server.Pipeline.Validate.Register(
    validate.RestrictField("document_quota_bytes", isSuperuser),
    maniflex.ForModel("User"),
    maniflex.RequiresField("document_quota_bytes"),
)

The field name is checked against the model, not against the middleware's own argument, so writing it twice is not the tautology it looks like: a misspelling is caught either way.

Names are JSON field names. The check is scoped by ForModel:

  • With ForModel, every named model must have every declared field — the gate was aimed at those models specifically.
  • Without it, at least one registered model must have the field, since a gate no model can trigger cannot be doing anything.

Use it for any middleware that reads or gates a field by name, not only validate.RestrictField — response.RedactField and hand-written gates have the same failure mode.

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.

When the accessor carries an ActionScope every method honours it — see action_scope.go. A nil scope is the ordinary case and costs nothing.

func (*ModelAccessor) Create

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

Create inserts a new record and returns the stored representation. Returns *maniflex.ErrConstraint on unique/check violations. A scope is stamped onto the record rather than checked: a create has no existing row to test, and a row created outside the scope would be invisible to the caller that made it. The stamp overwrites whatever the caller supplied for those columns, which is what stops a caller placing a row in someone else's scope — the same thing db.Tenancy does on the CRUD path.

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, and likewise when a scope is in force and the record falls outside it.

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, opts ...WriteOption) (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. A scoped update of a record outside the scope returns ErrNotFound without writing.

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

	// QueryLimits overrides non-zero fields from Config.QueryLimits for this
	// model. Use it to tighten expensive models independently.
	QueryLimits QueryLimits

	// 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
	// VersionedRequired makes a failed history write fail the request.
	//
	// By default a history write that fails is logged and the primary write
	// still succeeds, so a data change can end up with no history row and
	// nothing tells the caller (audit MS-L7). That is the right default for a
	// model where history is a convenience, and the wrong one where it is an
	// audit record: the gap is silent and only shows up when someone asks what
	// changed. With this set, the failure is returned and — when the write is
	// running in a transaction — the primary write rolls back with it, so the
	// row and its history entry stand or fall together.
	//
	// Note that without a transaction there is nothing to roll back: the
	// primary write has already committed, and the error only tells the caller
	// that history is missing.
	VersionedRequired bool

	// DisableAutoJunction opts this model out of many-to-many auto-detection.
	//
	// A model with exactly two BelongsTo relations to distinct models is treated
	// as a join table and registers a many-to-many between its two endpoints.
	// That is right for a real join table and wrong for an entity that happens
	// to have two foreign keys — Order{customer_id, shipping_address_id} — which
	// this turns off (audit MS-L9). Explicit mfx:"through:" relations are
	// unaffected.
	DisableAutoJunction bool

	// Junction marks this model as a many-to-many join table. Set by embedding
	// maniflex.JunctionModel; settable here for a model whose struct you do not
	// control. See JunctionModel for what it implies.
	Junction bool
	// JunctionUnique adds a UNIQUE index over the junction's two key columns and
	// lets includes collapse duplicate links. Set by mfx:"unique" on the
	// JunctionModel embed. Off by default — a junction carrying its own columns
	// may legitimately repeat a pair.
	JunctionUnique 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.
	//
	// "Same adapter" means the same value under ==, so two models sharing a
	// database must be given the *same* adapter, not two equivalent ones opened
	// against the same DSN — those are separate connection pools and separate
	// transactions, and the framework has no way to tell they point at one
	// database. See the DBAdapter godoc for the comparability contract this puts
	// on an implementation.
	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

	// RestoreEnabled mounts POST /:model/{id}/restore when true, clearing the
	// delete marker so the row is live again. It requires the model to soft-delete;
	// on a model that hard-deletes there is nothing to restore and the route is
	// not mounted.
	//
	// It is off by default: un-deleting is a privileged operation, and an endpoint
	// that appeared merely because a version was upgraded would not be covered by
	// the authorisation an app already wrote.
	//
	// It dispatches as the update operation, so middleware registered for
	// OpUpdate — auth, tenancy, force filters, audit — applies unchanged and an
	// app's existing "who may modify this row" rule governs restoring it too.
	// Use ctx.IsRestore() where the two must be told apart.
	//
	// The request carries no body. Restoring a row that is not deleted is a 404,
	// mirroring the re-delete guard. Only the delete marker is written:
	// updated_at is left alone, so a restore does not masquerade as an edit.
	//
	// Requires a database adapter implementing Restorer; one that does not
	// answers 501. The bundled SQLite and Postgres adapters do.
	//
	// Cascade is not undone. A restore brings back the row it names and nothing
	// else, because nothing records which children an onDelete:cascade removed —
	// restore each explicitly, or model the relationship so the children survive.
	RestoreEnabled 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 on first access, so GET returns column
	// defaults before anything is written and PATCH always targets an existing
	// row. Singleton models must not declare mfx:"required" fields — the
	// auto-provisioned row has no values to satisfy them.
	//
	// Which row is "the one row" depends on whether the request is scoped.
	//
	// Unscoped, it is a single global row under the well-known SingletonID: the
	// "admin edits one config record, clients read it at launch" shape (GET
	// /config).
	//
	// Scoped — a db.Tenancy or db.ForceFilter registered on the DB step for this
	// model — it is one row per scope, resolved and provisioned per caller:
	//
	//	server.MustRegister(StoreSite{}, maniflex.ModelConfig{Singleton: true})
	//	server.Pipeline.DB.Register(
	//	    db.Tenancy("owner_id", ownerOf),
	//	    maniflex.ForModel("StoreSite"),
	//	)
	//	// GET /store_sites → this owner's storefront, created on first access
	//
	// That covers the per-tenant settings/profile/storefront record, which is
	// otherwise Headless plus a hand-written Action — and an Action skips the
	// Validate step, so that workaround silently loses every mfx tag rule
	// (required, enum, min/max, immutable) along with the generated schema.
	//
	// A scoped row keeps an ordinary generated primary key; SingletonID names the
	// global row only. Give the scope column a unique index (mfx:"unique") so two
	// concurrent first accesses cannot both provision a row — the loser then
	// collides and re-reads the winner's.
	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
	// contains filtered or unexported fields
}

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)

Must be called before Start() or Handler(); it panics after the pipeline is frozen so a live specification cannot observe a partially changed chain.

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 match attachment
	// requests: an attachment is a read of one record, so whatever decides who
	// may read it decides this too (audit MS-8). The implication is one-way —
	// ForOperation(OpReadAttachment) stays attachment-only.
	OpReadAttachment Operation = "read_attachment"

	// OpReadHistory identifies a request for GET /:model/:id/history — the
	// per-record version history of a ModelConfig.Versioned model (audit MS-4).
	//
	// It dispatches against the **parent** model, not the synthesized history
	// model, and that is the whole point. The history table has none of the
	// parent's columns — no tenant_id, no owner_id, nothing a scope could filter
	// on — so it cannot be secured on its own terms. Running the parent's read
	// pipeline first means the caller must be allowed to read the record before
	// its history is fetched, and every auth, tenancy and force-filter middleware
	// already registered ForModel(parent) governs it unchanged.
	//
	// Middleware filtered with ForOperation(OpRead) DOES match history requests
	// (audit MS-8), and that is load-bearing rather than a convenience: the gate
	// above reads the request's *forced* filters, so a tenancy middleware scoped
	// to OpRead that never ran would leave the gate with nothing to scope by and
	// hand every tenant's history to every caller. The implication is one-way —
	// ForOperation(OpReadHistory) stays history-only.
	OpReadHistory Operation = "read_history"

	// 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 match exports: an
	// export is a list in another format, so list-scoped auth and tenancy
	// cover it (audit MS-8). The implication is one-way —
	// ForOperation(OpExport) stays export-only.
	OpExport Operation = "export"

	// Minting a presigned upload for an mfx:"file,upload:presigned" field —
	// POST /{model}/{field}/upload-url. It runs a trimmed pipeline of
	// Auth → handler → Response: there is no body to validate, no record to read,
	// and no row to write, so Deserialize, Validate, Service and DB are skipped.
	//
	// Auth runs, which is the point: minting a URL is granting the right to write
	// an object, so it must be gated by whatever gates the model. Middleware
	// filtered with ForOperation(OpCreate) does NOT match it — the mint is not the
	// create, and can precede one by minutes.
	OpPresignUpload Operation = "presign_upload"
)

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 for the three standard flags — Steps, Timings and
	// Aborts — and turns them on unless one of those three is already set, in
	// which case the caller is choosing precisely and the shorthand stays out of
	// the way.
	//
	// Bodies and Skips are NOT among them, and setting one does not suppress the
	// expansion: they are additive opt-ins, high-volume or capable of exposing
	// request data, so asking for one cannot be read as declining the rest.
	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 PresignUploadOptions added in v0.2.3

type PresignUploadOptions struct {
	// TTL is how long the presigned request stays valid.
	TTL time.Duration

	// MaxSize caps the object in bytes, from mfx:"max_size". Zero means no cap.
	// Pin it into the signature if the backend can (S3 POST-policy's
	// content-length-range); the framework re-checks on completion either way.
	MaxSize int64

	// ContentType is the media type the client declared at mint time. It has
	// already been checked against the field's mfx:"accept" list, so a backend
	// that can bind the signature to it should: that is what stops a client
	// declaring video/mp4 to get the URL and then uploading something else.
	ContentType string

	// Filename is the client's original filename, for a Content-Disposition the
	// backend may wish to store. Already sanitised into the key.
	Filename string
}

PresignUploadOptions describes the upload a presigned request must permit. The framework fills it from the target mfx:"file" field's tags.

type PresignedUpload added in v0.2.3

type PresignedUpload struct {
	// URL is where the client sends the upload.
	URL string `json:"url"`

	// Method is the HTTP method to use — "POST" for an S3 POST-policy form,
	// "PUT" for a presigned PUT.
	Method string `json:"method"`

	// Fields are form values the client must send alongside the file, in this
	// order, as multipart/form-data with the file last. POST-policy only.
	Fields map[string]string `json:"fields,omitempty"`

	// Headers are headers the client must set verbatim. Presigned PUT only.
	Headers map[string]string `json:"headers,omitempty"`

	// Key is the storage key the object will land at — the value to send back in
	// the record's file field to complete the upload. The framework mints it; a
	// client never chooses it, or it could aim an upload at another record's
	// object.
	Key string `json:"key"`

	// ExpiresAt is when the authorisation stops working.
	ExpiresAt time.Time `json:"expires_at"`

	// MaxSize echoes the cap the signature pins, so a client can fail a too-large
	// file before spending the upload rather than after.
	MaxSize int64 `json:"max_size,omitempty"`
}

PresignedUpload is a one-shot authorisation for a client to write one object directly to storage. It is what the upload-url route returns.

type QueryLimits added in v0.4.0

type QueryLimits struct {
	// MaxURLBytes bounds the complete request URI. Default: 8 KiB.
	MaxURLBytes int
	// MaxFilterClauses bounds all client-supplied filter expressions. Default: 32.
	MaxFilterClauses int
	// MaxFilterGroups bounds distinct bracketed OR groups. Default: 8.
	MaxFilterGroups int
	// MaxFiltersPerGroup bounds clauses within one bracketed OR group. Default: 8.
	MaxFiltersPerGroup int
	// MaxSortFields bounds comma-separated sort terms. Default: 8.
	MaxSortFields int
	// MaxSelectFields bounds projected fields. Default: 64.
	MaxSelectFields int
	// MaxIncludes bounds requested relation include paths. Default: 8.
	MaxIncludes int

	// MaxAggregateSelectFields bounds aggregate expressions. Default: 16.
	MaxAggregateSelectFields int
	// MaxAggregateGroupFields bounds GROUP BY columns. Default: 8.
	MaxAggregateGroupFields int
	// MaxAggregateFilters bounds aggregate WHERE conditions. Default: 32.
	MaxAggregateFilters int
	// MaxAggregateHaving bounds aggregate HAVING conditions. Default: 16.
	MaxAggregateHaving int
	// MaxAggregateSortFields bounds aggregate ORDER BY terms. Default: 8.
	MaxAggregateSortFields int
	// DefaultAggregateRows is used when the HTTP aggregate request omits limit.
	// Default: 100.
	DefaultAggregateRows int
	// MaxAggregateRows clamps an HTTP aggregate request's positive limit.
	// Default: 200.
	MaxAggregateRows int
}

QueryLimits bounds client-controlled URL query and aggregate complexity. Zero fields inherit the global/default value; negative fields explicitly disable that individual limit. ModelConfig.QueryLimits can tighten or override the global values for one model, except MaxURLBytes remains subject to the global hard ceiling applied before route dispatch.

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.*

	// NestedIncludes maps a relation key in Includes to relation keys on *that*
	// model, loaded one level further down — what ?include=author.company asks
	// for. Nil for the flat case, which is why Includes keeps its meaning and
	// existing readers of it are unaffected.
	//
	// One level only. Depth is capped at parse time (MaxIncludeDepth) because
	// each level multiplies the number of queries and the tree is client-supplied.
	// A key here is always present in Includes too: the parent is what the child
	// hangs off, so asking for a.b implies a.
	NestedIncludes map[string][]string

	// 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. Hand-built QueryParams outside the HTTP parser saturate at MaxInt instead of wrapping negative.

type RangeRetriever added in v0.2.4

type RangeRetriever interface {
	// RetrieveRange returns a reader over exactly the bytes
	// [offset, offset+length) of the object at key, along with its metadata.
	//
	// The framework resolves the window against the size reported by Stat
	// before calling, so offset and length are always absolute, in range, and
	// length is always positive — implementations do not need to clamp or to
	// interpret the relative forms of a Range header. The returned FileMeta may
	// describe the window rather than the object (its Size in particular); the
	// framework takes the object's content type and filename from Stat when the
	// window does not carry them.
	//
	// Returns ErrFileNotFound when the key does not exist.
	RetrieveRange(ctx context.Context, key string, offset, length int64) (io.ReadCloser, FileMeta, error)
}

RangeRetriever is an optional extension a FileStorage backend may implement to serve a byte window of an object without transferring the whole thing.

When a backend implements it, GET /files/* and the per-model attachment route answer a Range request with 206 Partial Content, fetching only the requested bytes from storage — on a remote backend that is the difference between paying egress for a 2 GB video and paying it for the 1 MB the client seeked to. A backend that does not implement it still works: the framework falls back to net/http's ServeContent when the reader is seekable, and otherwise ignores the Range header and serves the whole object with 200.

It is deliberately separate from FileStorage so that adding it is not a breaking change for third-party backends. LocalStorage and S3Storage both implement it.

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. MaxDepth=1 returns the root
	// plus its immediate children/parent only.
	//
	// 0 (the zero value) applies DefaultRecursiveMaxDepth. A negative value
	// means genuinely unlimited — say so explicitly if you want it, because an
	// unbounded traversal of a large hierarchy is a request that never ends.
	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 Restorer added in v0.2.4

type Restorer interface {
	// Restore clears the soft-delete marker on the row with the given id and
	// returns the restored record.
	//
	// q carries the request's forced filters — the server-imposed scope from
	// db.Tenancy or db.ForceFilter — which must be applied to the statement, so
	// a caller cannot restore a row outside their scope by knowing its id. It is
	// nil when nothing is scoped. A client's own ?filter= is not included, in
	// keeping with how writes are scoped elsewhere.
	//
	// Return ErrNotFound when no row matches, including when the row exists but
	// is not deleted: restoring a live row is a no-op the caller should hear
	// about, mirroring the re-delete guard.
	//
	// The returned record is a typed *T, as FindByID returns.
	Restore(ctx context.Context, model *ModelMeta, id string, q *QueryParams) (any, error)
}

Restorer is an optional extension a DBAdapter (and its Tx) may implement to clear a soft-delete marker, powering Config.RestoreEnabled's POST /:model/{id}/restore.

It is separate from DBAdapter so that adding it is not a breaking change for third-party adapters: one that does not implement it simply answers 501 on the restore route, and everything else is unaffected. The bundled SQLite and Postgres adapters implement it via db/sqlcore.

It cannot be expressed through Update. Every read and update path applies the adapter's soft-delete condition unconditionally, so a soft-deleted row is invisible to FindByID and unreachable by Update — the restore has to be its own statement, exactly as the delete already is.

type Rollup added in v0.2.3

type Rollup struct {
	// Parent is the model carrying the denormalised column.
	Parent string
	// ParentField is the JSON name of that column.
	ParentField string
	// Op is the aggregate: AggSum, AggCount, AggAvg, AggMin or AggMax.
	Op AggregateOp
	// Child is the model whose rows are aggregated.
	Child string
	// ChildField is the JSON name of the aggregated column. Required for every
	// op except AggCount, which counts rows.
	ChildField string
	// On is the JSON name of the foreign key on Child that points to Parent's id.
	On string
}

Rollup declares a denormalised aggregate column on a parent model that the framework keeps in step with its children. It is the maintained form of the column an app would otherwise recompute by hand — `Order.PaidAmount` as `SUM(OrderPayment.amount)`, `StoreSite.ReviewsCount` as `COUNT(Review)` — and which drifts the moment one write path forgets to update it.

srv.MustRegisterRollup(maniflex.Rollup{
    Parent: "Order", ParentField: "paid_amount", Op: maniflex.AggSum,
    Child:  "OrderPayment", ChildField: "amount", On: "order_id",
})

On every create, update or delete of a Child, the parent named by the child's `On` foreign key is recomputed from scratch — `Op(ChildField)` over that parent's live children — and written to `ParentField`, inside the request's transaction. Recomputing rather than applying a delta is what makes it correct by construction: a delete, a re-parenting update (the FK moved, so both the old and new parent are recomputed), and a soft-deleted child are all handled without a special case, and the total can never drift from the rows it summarises. Soft-deleted children are excluded, matching what a fresh aggregate would return.

A rollup requires an active transaction on the child write — otherwise a child insert could commit while the parent update fails, leaving exactly the drift it exists to prevent. Register maniflex.WithTransaction on the Service step for the child's create/update/delete; without one the write is refused with 500 ROLLUP_NO_TX, following the mfx:"lock_scope" precedent.

Field names are JSON names, resolved to columns and validated at registration, so a typo is a startup error naming the field — not a tag mini-language that fails silently at runtime.

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 ScopeChecker added in v0.2.4

type ScopeChecker interface {
	// ExistsInScope reports whether a row with this id exists and satisfies
	// filters, including when the row is soft-deleted.
	//
	// filters carries the request's forced filters — the server-imposed scope
	// from db.Tenancy or db.ForceFilter — and is nil when nothing is scoped. A
	// client's own ?filter= is not included, as with every other scope check.
	ExistsInScope(ctx context.Context, model *ModelMeta, id string, filters []*FilterExpr) (bool, error)
}

ScopeChecker is an optional DBAdapter/Tx capability: it answers whether a row exists and satisfies a scope, counting soft-deleted rows as present.

It exists for the history endpoint (audit MS-4), which authorises a request by the parent record. Every ordinary read applies the adapter's soft-delete condition unconditionally, so once a record is soft-deleted its history would become unreachable — including the delete entry, which is usually the one an audit is looking for. A soft-deleted row still exists and still carries its tenant and owner columns, so gating on it is exactly as sound as gating on a live one.

It deliberately returns only a bool, never the row. A method that returned soft-deleted records would be a general bypass of the soft-delete condition, and would eventually be used as one; this one can authorise a request and nothing else.

An adapter that does not implement it still works — the history endpoint falls back to a normal scoped read, and a deleted record's history 404s.

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) AddBatchComputedField added in v0.2.3

func (s *Server) AddBatchComputedField(modelName, name string, fn BatchComputedFunc, opts ...ComputedOption) error

AddBatchComputedField registers a derived field resolved for a whole page in one call. The callback receives every row being returned and must return one value per row, positionally aligned.

server.AddBatchComputedField("StoreSite", "item_count", fn,
    maniflex.ComputedSchema(&maniflex.OASSchema{Type: "integer"}))

A single read and the create/update echo call it with a one-row slice; an export calls it once per chunk of rows.

Must be called before Start() or Handler().

func (*Server) AddComputedField

func (s *Server) AddComputedField(modelName, name string, fn ComputedFunc, opts ...ComputedOption) 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 *maniflex.ServerContext, row map[string]any) (any, error) {
        return stockService.CurrentLevel(ctx.Ctx, row["id"].(string))
    })

For anything that queries a database, prefer AddBatchComputedField — this callback runs once per row.

Must be called before Start() or Handler().

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, or StartServices.

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

func (*Server) AllowPublic added in v0.4.0

func (c *Server) AllowPublic(opts ...MiddlewareOption)

AllowPublic declares generated model operations intentionally public for ValidateProduction. It does not change runtime behavior: generated routes are already passthrough unless Pipeline.Auth middleware protects them.

Call it before Start or Handler, using the same scopes as middleware:

server.AllowPublic(
    maniflex.ForModel("User"),
    maniflex.ForOperation(maniflex.OpCreate),
)

func (*Server) BackfillRollups added in v0.2.3

func (s *Server) BackfillRollups(ctx context.Context) error

BackfillRollups recomputes every registered rollup for every parent, from the current child rows. Run it once after adding a rollup to an existing dataset, or to reconcile a column edited out of band; the live middleware keeps it exact thereafter.

It reconciles rather than locks: each parent is recomputed independently against the child rows as they stand, so a concurrent write during the backfill is simply picked up by that write's own rollup. Prefer a quiet window for large tables all the same.

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) Execute added in v0.2.3

func (c *Server) Execute(ctx context.Context, inv Invocation) (*APIResponse, error)

Execute runs a registered model's operation through the full pipeline, from Go, and returns the response the same request over HTTP would have produced.

res, err := srv.Execute(ctx.Ctx, maniflex.Invocation{
    Model:     "Item",
    Operation: maniflex.OpUpdate,
    ID:        item.ResourceID,
    Body:      map[string]any{"status": "approved"},
    Auth:      &approver,  // a typed principal, not a header
    Tx:        ctx.Tx,     // joins the caller's transaction
})

Every step runs — Auth, Deserialize, Validate, Service, DB, Response — in the order and with the middleware a client's request would get. There is no way to skip one; see the file comment for why.

A non-2xx answer is returned as an *ExecuteError, so a loop of invocations inside one transaction rolls back on the first failure rather than committing the prefix. The *APIResponse is returned either way.

Refused: OpExport and OpReadAttachment write bytes to a response writer rather than producing a value, and OpAction and OpSearch run trimmed pipelines of their own — call an action's handler directly.

Execute builds the router on first use, exactly as Handler() does, so the registration window closes: Pipeline.Register and Server.Action must precede it.

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.

Handler also does not start registered services or OnStart. Embeddings that use them must call StartServices before serving and call Shutdown after their owning http.Server has drained.

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())

MigrateOnly first validates and seals the complete router and middleware configuration, exactly as Start does before migration. Register all models, actions, and middleware before calling it. A validation error is returned before any adapter's AutoMigrate method can alter schema.

func (*Server) MustAddBatchComputedField added in v0.2.3

func (s *Server) MustAddBatchComputedField(modelName, name string, fn BatchComputedFunc, opts ...ComputedOption)

MustAddBatchComputedField is the panic-on-error variant of AddBatchComputedField. It must be called before Start() or Handler().

func (*Server) MustAddComputedField

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

MustAddComputedField is the panic-on-error variant, intended for use in `main` or package initialisation. It must be called before Start() or Handler().

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) MustRegisterRollup added in v0.2.3

func (s *Server) MustRegisterRollup(r Rollup)

MustRegisterRollup is the panic-on-error variant of RegisterRollup, for use in main() or package initialisation.

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 configures an AsyncAPI 2.6 document describing the realtime event channels clients can subscribe to over the realtime hub (see the realtime package). Config.Documentation must also explicitly publish or protect the generated documentation endpoints. 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().

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) RegisterRollup added in v0.2.3

func (s *Server) RegisterRollup(r Rollup) error

RegisterRollup installs a maintained aggregate column. It validates the configuration against the registry and wires a DB-step middleware on the child model for create, update and delete. Must be called before Start()/Handler().

Returns an error rather than panicking so the configuration can be validated in a test; MustRegisterRollup is the panic-on-error variant for main().

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. It must be called before Handler, Start, StartServices, or MigrateOnly seals the server; a late call panics.

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. It must be called before Handler, Start, StartServices, or MigrateOnly seals the server; a late call panics.

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). It must be called before Handler, Start, StartServices, or MigrateOnly seals the server; a late call panics rather than leaving the fixed routes inconsistent with the active storage backend.

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 bounded by the provided context. When Maniflex owns the listener it waits for in-flight requests and tracked background goroutines (audit writes, cache invalidations). An embedding must first drain its own http.Server; Shutdown then drains framework goroutines without running services or hooks that were never started. Call StartServices to opt an embedded server into those lifecycle phases.

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) StartServices added in v0.4.1

func (c *Server) StartServices() error

StartServices starts the application lifecycle without opening an HTTP listener. It is the embedded-server counterpart to Start: it validates and seals the router, runs OnStart, then starts registered services in order.

Handler does not migrate. An embedding application that uses framework migration should call MigrateOnly before StartServices, then mount Handler in its own http.Server. During shutdown, stop the owner http.Server first so no request can add background work, then call Server.Shutdown to stop services and drain Server.Go and ctx.GoBackground work.

Only one startup mode may own a Server. Calls while Start, StartWithContext, or StartServices is active return ErrAlreadyStarted; calls after shutdown or a failed startup return ErrStopped.

func (*Server) StartWithContext

func (c *Server) StartWithContext(ctx context.Context) (returnErr 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

func (*Server) ValidateProduction added in v0.4.0

func (c *Server) ValidateProduction() error

ValidateProduction audits the fully configured server for production-dangerous defaults. Call it after registering models, middleware, actions, and optional routes, and before Start or Handler.

It is intentionally opt-in so development remains convenient. A successful result means strict startup validation is enabled, database work and client query shapes are bounded, automatic migration is disabled when models exist, and every mounted data route has an explicit protected or public access decision.

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. For a 5xx response, message is logged as a private diagnostic and replaced with generic status text on the wire. The current step must then return nil without calling next() for the abort to take effect.

func (*ServerContext) ActionScope added in v0.2.3

func (c *ServerContext) ActionScope() *ActionScope

ActionScope returns the scope in force, or nil when the request is unscoped.

func (*ServerContext) AfterCommit added in v0.3.1

func (c *ServerContext) AfterCommit(fn func()) bool

AfterCommit registers fn to run once the request's transaction has committed successfully. If the transaction rolls back, fn is never called.

Use it for side effects that must not become visible before the write they describe is durable — publishing to a broker, enqueuing a job, calling a webhook. Without it, a side effect fired from inside the transaction is announcing a write that may still be rolled back, and a subscriber that reads the record straight back races the commit and finds nothing (audit EV-3).

fn runs synchronously, after the commit and before the request returns; keep it short, or have it start its own goroutine.

AfterCommit reports whether fn was deferred. It returns false — having already called fn inline — when there is no transaction to wait for, or when the transaction was opened by something that does not run commit hooks. Deferring into a queue nobody drains would lose the side effect entirely, which is worse than performing it early, so the fallback is to run it. Callers that only need the side effect to happen can ignore the result:

ctx.AfterCommit(func() { bus.Publish(bgCtx, e) })

WithTransaction is the middleware that drains the queue.

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)

BeginTx opens a transaction on the request's adapter.

When an ActionScope is in force the returned Tx is scoped by it: reads are filtered, a create is stamped, and an update or delete of a record outside the scope returns ErrNotFound. ctx.Tx is a public field, so anything downstream that picks the transaction up is scoped too. ctx.Unscoped().BeginTx returns an unscoped transaction for work that genuinely must reach across the scope.

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) EnsureRawBody added in v0.2.3

func (c *ServerContext) EnsureRawBody() ([]byte, error)

EnsureRawBody returns the raw request body, reading and buffering it if no earlier step already has. The Deserialize step sets ctx.RawBody for create and update requests, but the trimmed action/search/presign pipelines skip Deserialize entirely and Deserialize never reads a body for GET/DELETE — so a middleware that must see the raw bytes before the handler binds them (an idempotency body hash, a signature check) calls this instead of reading ctx.RawBody directly, which would otherwise be nil in those contexts.

The body is read once, under the same size limit as Deserialize (respecting SetMaxBodySize), cached on ctx.RawBody, and the request body is restored so a later ctx.BindJSON in the handler still sees it. Subsequent calls return the cached bytes without re-reading. A request with no body yields an empty, non-nil slice. On a size-limit or read failure it calls ctx.Abort and returns a non-nil error, so the caller can return nil immediately.

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.

When an ActionScope is in force the accessor is scoped by it: reads see only matching records, a create is stamped with the scope's values, and an update or delete of a record outside it returns ErrNotFound — the same answer the scoped read gives. ctx.Unscoped().GetModel returns an unscoped accessor.

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) InProcess added in v0.2.3

func (c *ServerContext) InProcess() bool

InProcess reports whether this request was raised by Server.Execute rather than sent by a client.

It exists because a synthesised request is, to a middleware, indistinguishable from a real one — which is what makes Execute cheap and what makes it worth asking about. Middleware whose job is transport-shaped can consult it and step aside: there is no browser to protect from CSRF, no remote address to rate-limit per, and no Authorization header to read when the principal arrived as a typed value. The shipped auth.JWTAuth/JWKSAuth do exactly the last of these.

It is derived from an unexported field, so only Execute can make it true. That is what separates it from the header it replaces: a client can send any header it likes, and cannot reach this at all.

func (*ServerContext) IsFieldRedacted added in v0.2.5

func (c *ServerContext) IsFieldRedacted(jsonName string) bool

IsFieldRedacted reports whether RedactResponseField named this field.

func (*ServerContext) IsRestore added in v0.2.4

func (c *ServerContext) IsRestore() bool

IsRestore reports whether this request is a soft-delete restore (POST /:model/:id/restore), which dispatches as OpUpdate so that existing update middleware applies to it.

Middleware registered with ForOperation(OpUpdate) runs for restores too, and usually should. Read this when the two need telling apart — an audit sink recording "restored" rather than "updated", an event emitter choosing a different type, or a validation rule that only makes sense against a body, since a restore carries none (ctx.ParsedBody is empty).

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))

When an ActionScope is in force, a record outside it is ErrNotFound and no lock is taken: FindByIDForUpdate is keyed by id alone and carries no filter, so the scope is applied by looking the record up through it first. The lookup runs inside the same transaction as the lock, so the row cannot leave the scope between the two.

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.

It refuses while an ActionScope is in force, for the same reason RawQuery does — and a write is the case where it matters most. Use ctx.Unscoped().RawExec to step outside deliberately.

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.

It refuses while an ActionScope is in force: the statement is opaque to the framework, so the scope cannot be applied to it and running it anyway would return every tenant's rows under a guarantee that says otherwise. Use the scoped paths, or ctx.Unscoped().RawQuery to step outside deliberately.

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) RedactResponseField added in v0.2.5

func (c *ServerContext) RedactResponseField(jsonNames ...string)

RedactResponseField declares that this response must not disclose the named field, by its JSON name. It is additive and idempotent.

Use it from a Response-step middleware that masks a field, and call it **before** next(). Mutating ctx.Response after next() is not enough on its own: an export streams its bytes during next() and never builds a Response, so a middleware that only mutates one masks the JSON and silently leaves the CSV in full (audit MS-11). Declaring the field lets the export honour the same decision while still writing a row at a time.

server.Pipeline.Response.Register(
    response.RedactField("salary", isNotAdmin),
    maniflex.ForModel("Employee"),
)

response.RedactField already does this. Reach for it directly when you write your own masking middleware — a middleware that only edits ctx.Response applies to JSON responses alone.

func (*ServerContext) ResolveResourceID added in v0.3.0

func (c *ServerContext) ResolveResourceID() string

ResolveResourceID returns the id of the record this request addresses.

Almost always that is ctx.ResourceID, read straight from the URL. The exception is a Singleton, which has no {id} to read: the handler pins ResourceID to the SingletonID placeholder, and for a *scoped* singleton the DB step later swaps in the caller's real row id, since one fixed value cannot name one row per scope. Middleware at the DB pipeline's Before position runs ahead of that swap and therefore sees the placeholder — which addresses no row, so a read through it silently returns nothing (audit 13.9).

Returns "" when a scoped singleton's row does not exist yet. That is the honest answer — there is no record to address — and callers should treat it the same way they treat a create, which is what the request is about to become. Never provisions the row: resolving must stay read-only.

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) ServerSetField added in v0.3.0

func (c *ServerContext) ServerSetField(jsonName string) bool

ServerSetField reports whether jsonName was written by SetField — that is, by the server rather than parsed from the request body.

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) SetActionScope added in v0.2.3

func (c *ServerContext) SetActionScope(s *ActionScope)

SetActionScope pins a row-level scope to this request. Every DB path an action handler can reach then either applies it or refuses to run — see ActionScope.

Middleware registered in an ActionConfig.Middleware list calls this; db.TenancyAction and db.ForceFilterAction are the shipped wrappers. Calling it twice replaces the scope rather than merging, so two scoping middlewares on one action is a mistake this makes visible rather than silently AND-ing.

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")

func (*ServerContext) Unscoped added in v0.2.3

func (c *ServerContext) Unscoped() *UnscopedContext

Unscoped returns a handle that bypasses any ActionScope in force. On an unscoped request it is simply the same calls, so code need not branch on whether a scope happens to be set.

func (*ServerContext) ViaFilter added in v0.2.3

func (c *ServerContext) ViaFilter(relationKey, parentField string, value any) (*FilterExpr, error)

ViaFilter builds a forced filter that scopes this request's model through one of its BelongsTo parents — the model has no column to scope by, so the scope is applied to the column its parent carries.

relationKey names the relation, in the same vocabulary a nested ?filter= uses (?filter=author.status:neq:banned → "author"). parentField names the column on the parent model, by JSON or DB name. The result is AND-ed into the query like any other filter, and marked Forced, so it constrains updates and deletes as well as reads.

db.ForceFilterVia is the shipped wrapper and is what most callers want; this is exported for middleware that needs the FilterExpr itself.

It reports an error rather than returning a filter that would not do what it says: an unregistered relation, a HasMany (there is no foreign key on this row to join by), or an encrypted parent column (the stored value is ciphertext, so an equality against the plaintext scope value matches nothing). The caller must fail the request on an error — a scope that cannot be built is not a scope, and skipping it would leave the request unscoped rather than refused.

type Service

type Service interface {
	// Start is called once after migration and DB-ready in the normal Start
	// path, or when an embedding calls StartServices after its own readiness
	// step. 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 or StartServices.

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 UnscopedContext added in v0.2.3

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

UnscopedContext is the deliberate bypass of an ActionScope. Obtain one from ServerContext.Unscoped. It exposes the paths that refuse while a scope is in force, and running them through it is a statement that the caller has considered the scope and is stepping outside it on purpose — an audit query across tenants, a migration, a health probe.

It is a distinct type rather than a flag so the bypass appears at the call site, in the diff, and in a grep for "Unscoped".

func (*UnscopedContext) BeginTx added in v0.2.3

func (u *UnscopedContext) BeginTx(ctx context.Context, opts *TxOptions) (Tx, error)

BeginTx opens a transaction that is not scoped. ctx.BeginTx returns one that is; this is the deliberate bypass, for work that must reach across the scope.

func (*UnscopedContext) GetModel added in v0.2.3

func (u *UnscopedContext) GetModel(modelName string) *ModelAccessor

GetModel returns an accessor with no scope applied.

func (*UnscopedContext) RawExec added in v0.2.3

func (u *UnscopedContext) RawExec(query string, args ...any) (int64, error)

RawExec runs ctx.RawExec without the scope check.

func (*UnscopedContext) RawQuery added in v0.2.3

func (u *UnscopedContext) RawQuery(query string, args ...any) ([]Row, error)

RawQuery runs ctx.RawQuery without the scope check. The rows are whatever the SQL selects, across every tenant.

func (*UnscopedContext) Search added in v0.2.3

func (u *UnscopedContext) Search(opts SearchOptions) ([]SearchResult, error)

Search runs ctx.Search across every model's rows, unscoped.

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

type WriteOption added in v0.2.5

type WriteOption func(*writeOptions)

WriteOption adjusts a programmatic write. See SkipValidation.

func SkipValidation added in v0.2.5

func SkipValidation() WriteOption

SkipValidation turns off the model's value constraints for one write:

_, err := maniflex.Create(ctx, &acct, maniflex.SkipValidation())

Use it where a violation is deliberate — backfilling rows that predate an enum, or importing legacy data an app intends to clean up later. It is a per-call argument rather than a mode so the exception is visible at the site that takes it, not somewhere up the stack.

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 router-level CORS handling plus Response-step middleware for caching, field transforms, custom envelopes, and observability.
Package response provides router-level CORS handling plus Response-step middleware for 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