spi package - github.com/cyoda-platform/cyoda-go-spi - Go Packages

spi

package module
v0.8.4 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

cyoda-go-spi

Storage-plugin contract for cyoda-go.

This module defines the interfaces and value types that any storage backend must implement. Plugin authors depend only on this module.

Packages

  • spi — core interfaces, value types, sentinel errors, UUIDGenerator, ClusterBroadcaster, and Plugin registration machinery.
  • spi/predicate — search-predicate AST types and JSON parse/marshal.

Dependencies

Standard library only.

Versioning & Compatibility

License

Apache 2.0.

For Plugin Authors

Every spi.StoreFactory implementation should run the spitest conformance harness to verify it meets the SPI contract.

Wiring the Harness
package myplugin_test

import (
    "testing"
    "time"

    spitest "github.com/cyoda-platform/cyoda-go-spi/spitest"
    "github.com/your-org/myplugin"
)

func TestConformance(t *testing.T) {
    factory := myplugin.NewStoreFactory(/* ... */)
    spitest.StoreFactoryConformance(t, spitest.Harness{
        Factory:      factory,
        AdvanceClock: func(d time.Duration) { time.Sleep(d) },
    })
}
AdvanceClock Contract

The harness calls AdvanceClock(d time.Duration) between writes that need distinct timestamps. After AdvanceClock returns, every subsequent timestamp the plugin assigns must strictly dominate every timestamp assigned before the call. d > 0.

Plugins wire this to whatever clock mechanism they use:

  • In-memory / app-side clock: inject a TestClock with an Advance(d) method via a factory option; AdvanceClock calls that.
  • DB-side clock (e.g., PostgreSQL): use time.Sleep(d). The DB's monotonic wall clock satisfies the contract; ~1–5ms gaps are sufficient.
  • Logical clock (e.g., Cassandra HLC): advance the physical component via a test-only hook.
Harness.Now (optional)

If your plugin uses an injected TestClock, also set Harness.Now to the clock's Now() method so the harness's temporal assertions use the same clock as the plugin. Defaults to time.Now which matches wall-clock-based plugins (postgres).

Error-Assertion Contract

The harness uses errors.Is() against SPI sentinels (spi.ErrNotFound, spi.ErrConflict). Plugins MUST wrap backend-native errors at the SPI boundary:

// WRONG — harness will fail
return pgx.ErrNoRows

// RIGHT — harness passes
return fmt.Errorf("entity %q: %w", id, spi.ErrNotFound)
State Isolation

Every subtest runs under a fresh tenant. The harness never calls Reset, Truncate, or any teardown hook. A single factory handles all subtests across different tenants. Factory.Close() is called once when the suite finishes.

Cross-tenant leakage is caught by the explicit TenantIsolation/* subtests, not by infrastructure.

Known Limitations / Harness.Skip

Backends with structural incompatibilities can register documented skips via Harness.Skip. The key is the subtest path below the root test name (the part after the first /). Mistyped keys cause the suite to fail with an "unused skip key" error, preventing stale entries from silently accumulating.

spitest.StoreFactoryConformance(t, spitest.Harness{
    Factory:      factory,
    AdvanceClock: testClock.Advance,
    Skip: map[string]string{
        "Transaction/Join":                        "pending #42: Join does not share write-set",
        "AsyncSearch/SaveAndGetResults/Pagination": "pending #43: SaveResults not yet implemented",
    },
})

Documentation

Overview

Package spi defines the storage-plugin contract for cyoda-go.

Plugin Authoring — Minimal Example

A storage plugin is a Go module that implements the Plugin interface and registers itself at package init() time:

package myplugin

import (
	"context"
	spi "github.com/cyoda-platform/cyoda-go-spi"
)

func init() { spi.Register(&plugin{}) }

type plugin struct{}

func (p *plugin) Name() string { return "myplugin" }

func (p *plugin) NewFactory(
	ctx context.Context,
	getenv func(string) string,
	opts ...spi.FactoryOption,
) (spi.StoreFactory, error) {
	// Parse config via getenv (not os.Getenv — the core injects a
	// closure so tests can supply a fake environment).
	dsn := getenv("MYPLUGIN_DSN")

	// Resolve options (e.g., a ClusterBroadcaster for cluster-wide
	// notifications). Plugins that don't need any option can skip.
	cfg := spi.ApplyFactoryOptions(opts)
	_ = cfg.ClusterBroadcaster() // nil if unset

	// Connect, run migrations, return a ready factory. Use ctx for
	// all blocking setup work so unreachable infra fails fast.
	return newStoreFactory(ctx, dsn), nil
}

The binary is built with a blank import of the plugin:

import _ "example.com/myplugin"

which causes init() to run and Register to install the plugin in the process-global registry. The core resolves the active backend at startup by calling spi.GetPlugin(name).

The getenv injection

Plugins read their configuration through the injected getenv function, not os.Getenv directly. In production the core passes os.Getenv; in tests the core passes a closure over a map[string]string so that test fixtures can provide exactly the variables the test needs without leaking into the process environment.

Plugin-owned TransactionManager

Each plugin provides its own TransactionManager via StoreFactory's TransactionManager(ctx) method. The plugin's TM implementation is free to couple tightly to the plugin's stores (memory does) or be a lightweight lifecycle tracker (postgres does — the pgx.Tx is tracked in a plugin-internal registry and stores look it up by txID when called inside an active transaction). The pattern for bridging a logical txID to a physical transaction handle:

// Begin registers the handle in the plugin's internal registry.
func (tm *TM) Begin(ctx context.Context) (string, context.Context, error) {
	phys := openPhysical(ctx)
	txID := uuid.UUID(tm.uuids.NewTimeUUID()).String()
	tm.registry.Register(txID, phys)
	state := &spi.TransactionState{ID: txID}
	return txID, spi.WithTransaction(ctx, state), nil
}

// Stores resolve the handle from context.
func (f *StoreFactory) queryExecutor(ctx context.Context) Querier {
	if state := spi.GetTransaction(ctx); state != nil {
		if phys, ok := f.tm.Lookup(state.ID); ok {
			return phys
		}
	}
	return f.defaultExecutor  // e.g., a connection pool
}

Startable and Close — symmetry

Plugins with background goroutines implement the optional Startable interface. The core calls Start(ctx) immediately after NewFactory and before any store-facing call (including TransactionManager), so plugins whose TransactionManager depends on Start's side effects (e.g. cassandra's shard-rebalance wait) can rely on the ordering. Plugins must tear down those goroutines in StoreFactory.Close(): each goroutine observes either ctx.Done() or a shutdown channel closed by Close(); Close() waits (bounded) for them to exit with a sync.WaitGroup. Leaked goroutines compound under test-driven create/destroy cycles.

Dependencies

The spi package depends on the Go standard library plus google/uuid and tidwall/gjson. Plugin authors depend only on this module; they do not depend on cyoda-go itself.

Predicate AST, and translating it

Submodule spi/predicate holds the search-predicate AST and JSON parse/marshal helpers. Plugins with no search semantics may ignore it.

A plugin that receives a predicate.Condition should translate it with ConditionToFilter rather than interpreting the AST itself. The resulting Filter is what the leaf-comparison kernel (Prepare, EvalLeaf) evaluates, and building one any other way means answering the same query differently from every other backend — the comparison rules are type-directed and subtle enough that an independent implementation drifts rather than merely lagging. Declared types come from FieldsMapFromSchema over ModelDescriptor.Schema; supplying a nil fields map is not a safe degraded mode, and ConditionToFilter documents why.

Deciding which parts of a Filter to narrow on in the plugin's own query dialect (SQL, CQL) is the separate and expected step: a planner may push down what it can and leave the rest residual, because the kernel re-checks every candidate.

Index

Constants

View Source
const MaxConditionDepth = 256

MaxConditionDepth caps recursion in ValidateConditionOperators and ValidateConditionPatterns to defend against stack exhaustion from a deeply nested predicate tree. Client-facing parsers cap incoming requests at a smaller depth, but a programmatically constructed tree bypasses that and can otherwise nest arbitrarily. 256 is well above any realistic query and well below the stack-blow threshold.

Variables

View Source
var ErrAggregationNotPushdownable = errors.New("aggregation request shape not pushdownable")

ErrAggregationNotPushdownable signals that a GroupedAggregator implementation cannot safely push down a specific request shape; the caller (typically the service layer) should fall through to the streaming-tally path via EntityStore.Iterate.

View Source
var ErrAlreadyTerminal = errors.New("job is in a terminal status")

ErrAlreadyTerminal is returned by AsyncSearchStore write methods (UpdateJobStatus, Heartbeat, SaveResults) called against a job already in a terminal status (SUCCESSFUL/FAILED/CANCELLED). Cancel is the sole idempotent-nil exception.

View Source
var ErrConflict = errors.New("conflict: entity has been modified")

ErrConflict indicates the write conflicts with a concurrent modification.

View Source
var ErrEpochMismatch = errors.New("shard epoch mismatch")

ErrEpochMismatch indicates the caller's shard epoch is stale relative to the cluster view. Retry after refreshing.

View Source
var ErrGroupCardinalityExceeded = errors.New("group cardinality exceeded ceiling")

ErrGroupCardinalityExceeded is returned by GroupedAggregator implementations (or surfaced by the service-layer streaming tally) when the result group count would exceed the configured ceiling.

View Source
var ErrInvalidFilterPath = errors.New("invalid filter path")

ErrInvalidFilterPath is returned for a Filter.Path — or an OrderSpec.Path — that falls outside the documented path grammar. See the "Grammar" and "Rejection is mandatory" sections of Filter's Path field: a non-empty path is a dotted run of ASCII identifier segments, and a backend MUST refuse anything else with an error rather than answering with an empty result set.

ConditionToFilter also returns it one step earlier, for the WIRE form: a condition jsonPath that is not JSON Path nomenclature — no "$." leader, an empty or trailing segment, bracket-quoted access, a bracket spelling outside the two supported subscript forms (the wildcard "[*]" and a non-negative index that fits an int32), or any other disallowed character. Note what it does NOT cover there: a WELL-FORMED array-subscripted path ("$.tags[*]", "$.arr[0]") is not invalid input at all — it translates like any other well-formed path, because the kernel resolves a subscripted path directly (see ResolvePath) rather than falling back to in-memory evaluation.

Like ErrUnknownOperator this means the INPUT is invalid, so a caller should surface it as a client error rather than a storage failure. Backends declare their own package-level sentinel of the same name for their local callers; each one wraps this, so

errors.Is(err, spi.ErrInvalidFilterPath)

is the backend-agnostic way to classify a malformed path.

View Source
var ErrInvalidPattern = errors.New("invalid pattern")

ErrInvalidPattern is returned by ValidateLeafPattern and ValidateConditionPatterns for an operand that cannot be used as a pattern: a LIKE operand ending in an unpaired escape, or a MATCHES_PATTERN operand that does not compile.

The wrapped message names the operator and the failure, and deliberately carries NEITHER the operand NOR the anchored form the kernel compiles — a caller puts this error into a client-facing 400, and both are internals.

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

ErrNotFound indicates the requested resource does not exist.

View Source
var ErrPartialUniqueKey = errors.New("invalid composite unique key value")

ErrPartialUniqueKey is the umbrella for every ComputeClaims VALUE-invalid error — a partially-filled key, an over-bound numeric literal, or a non-scalar value at a key path. All map to 422 INVALID_UNIQUE_KEY.

View Source
var ErrRetryExhausted = errors.New("retry budget exhausted")

ErrRetryExhausted indicates the plugin's retry budget for a transparently-retried operation was consumed without success. Returned by ExtendSchema when CYODA_SCHEMA_EXTEND_MAX_RETRIES attempts have completed without success AND the context was not cancelled. Callers may choose to retry at a higher level (with backoff) or surface the condition to the end user.

Distinct from ErrConflict: ErrConflict means a single attempt hit a conflict; ErrRetryExhausted means the plugin exhausted its configured retry budget.

View Source
var ErrSavepointNotFound = &sentinelErr{msg: "savepoint not found", parent: ErrNotFound}

ErrSavepointNotFound indicates that a savepoint identifier does not refer to a known savepoint on the given transaction. Returned by RollbackToSavepoint or ReleaseSavepoint when the named savepoint is unknown (either never created, already released, or rolled past). Wraps ErrNotFound.

View Source
var ErrSearchResultLimitExceeded = errors.New("search result limit exceeded")

ErrSearchResultLimitExceeded is returned by an EntityStore whose direct search (EntityStore.Search) matched more entities than the configured result-limit cap (bounded-or-fail contract). The engine maps it to a client-facing 400.

View Source
var ErrStaleClaim = errors.New("write fenced: stale claim epoch")

ErrStaleClaim is returned by AsyncSearchStore write methods (UpdateJobStatus, SaveResults, Heartbeat) when the caller's epoch does not match the job's current Epoch — another claimant has since taken over.

View Source
var ErrTxAlreadyCommitted = &sentinelErr{msg: "transaction already committed", parent: ErrTxTerminated}

ErrTxAlreadyCommitted indicates an attempt to Join, Commit, or otherwise operate on a transaction whose terminal state is Commit. Wraps ErrTxTerminated.

View Source
var ErrTxCommitInProgress = errors.New("transaction commit in progress")

ErrTxCommitInProgress indicates that Commit was called on a transaction another goroutine is already committing. Distinct from ErrTxTerminated because the transaction is not yet terminal — the loser of the race may still observe the committed result.

Note: this sentinel has no portable conformance subtest in spitest because reliably racing two Commit goroutines from a black-box harness is brittle. Backends that own their own in-process commit registry exercise this in their internal concurrency suites.

View Source
var ErrTxNotFound = &sentinelErr{msg: "transaction not found", parent: ErrNotFound}

ErrTxNotFound indicates that a transaction handle does not refer to a known transaction — either the txID never existed, or its state has been fully purged. Wraps ErrNotFound so existing

errors.Is(err, spi.ErrNotFound)

checks on tx-lifecycle paths continue to match.

View Source
var ErrTxRolledBack = &sentinelErr{msg: "transaction rolled back", parent: ErrTxTerminated}

ErrTxRolledBack indicates that an in-flight operation observed the transaction marked rolled-back, or that an op was attempted on a transaction whose terminal state is Rollback. Surfaced by plugins that own their own in-process tx-state buffer; see ErrTxTerminated godoc for the alternate-surface caveat on plugins that delegate transaction state to an external engine.

View Source
var ErrTxTenantMismatch = errors.New("transaction tenant mismatch")

ErrTxTenantMismatch indicates a transaction-lifecycle operation (Join, Commit, Rollback, Savepoint, etc.) was attempted with a UserContext whose tenant does not match the transaction's tenant. Tenant-isolation invariant — distinct from data-op tenant checks.

View Source
var ErrTxTerminated = errors.New("transaction in terminal state")

ErrTxTerminated is the umbrella sentinel for any operation on a transaction that has reached a terminal state (committed or rolled back). Callers that do not need to distinguish rollback from commit can match this directly.

NOTE: Backends that delegate transaction state to an external engine may surface mid-op rollback as ErrConflict (e.g. via a SQLSTATE 25P02 from a SQL engine) instead of ErrTxRolledBack, where the engine's abort code is already semantically meaningful. The ErrTxTerminated sentinel is required only on plugins that own their own in-process tx-state buffer. Consumers writing backend-agnostic code should match both ErrTxTerminated and ErrConflict on data-op paths.

View Source
var ErrUnevaluableLeaf = errors.New("unevaluable leaf")

ErrUnevaluableLeaf is returned by Prepare for a Filter leaf it cannot evaluate: an operand that parses into none of the leaf's declared types (including an empty/nil declared set), a SourceData leaf whose Path is empty or falls outside the documented path grammar, a SourceMeta leaf whose Path is empty or is not one of the names extractFilterMetaValue recognizes (the plugin-facing meta keyset — broader than the client-facing MetaFieldNames vocabulary, since it also carries the storage-key aliases e.g. "entity_id", "created_at"), a pattern operand (LIKE / MATCHES_PATTERN) that will not compile, or an unsupported operator.

It also covers a malformed FilterNot node: one whose Children is not exactly length 1. That is not a leaf defect, but the same umbrella applies for the same reason — it is a property of the request, decided once at prepare time — and FilterNot's single child is itself prepared through this same recursion, so a zero-Op or otherwise-unevaluable child surfaces this sentinel too, one level down.

Every cause is decided at prepare time, from the condition alone, before any entity is read — it is a property of the REQUEST, not an artifact of a particular row. Prepare therefore rejects the whole filter rather than silently building a leaf that never matches: a leaf that never matches is safe only in the absence of negation, because a NOT would invert it into matches-everything.

The "no declared type" wrapped message names the operand but caps it at [maxEchoedOperandBytes] (see truncateOperand): this is the ONLY documented-normal case here — a field with no declared type on a search request — so it is also the only one reachable with a caller-sized (megabyte-scale) operand; echoing it verbatim would let an ordinary 400 blow up to request size, and it is logged again as "cause" by callers.

View Source
var ErrUniqueViolation = errors.New("composite unique key violation")

ErrUniqueViolation: a write would duplicate a declared composite unique key. Deterministic, NON-retryable (distinct from ErrConflict).

View Source
var ErrUnknownOperator = errors.New("unknown condition operator")

ErrUnknownOperator is returned by ConditionToFilter for a condition leaf whose operatorType is not in the closed set OperatorNames reports.

It is a distinct sentinel because it means the INPUT is invalid, which a caller should surface as a client error (400 INVALID_CONDITION). Other translation failures mean the predicate is well-formed but not expressible as a pushdown Filter, which is a different answer entirely.

Functions

func AdmitsNumeric added in v0.8.4

func AdmitsNumeric(t DataType, v Decimal) bool

AdmitsNumeric reports whether a field declaring t can hold the value v.

This is the single definition of numeric admission. Ingestion asks it to decide whether a write changes the model; the search kernel asks it to decide whether a stored value belongs to the declared type. Two copies would be two things that can disagree, which is the defect this predicate exists to remove.

It is deliberately NOT "is v inside t's range". Admission must agree with what search can find, and for DOUBLE the operand bucket drops the EQUALS branch entirely for a value that exceeds 15 significant digits or a scale of 292 (see produceDecimalInRange / isDoubleBucketPrecise) — so a value admitted on range alone would be stored where EQUALS could never find it and NOT_EQUAL would wrongly match it. The integer family needs wholeness on top of its bounds for the same reason: foldToInt drops the whole family for a fractional operand, and UNBOUND_INTEGER has no bound to hide behind.

The precision bound is also the mantissa argument stated as a value test rather than a label test: a decimal of at most 15 significant digits round-trips uniquely through a binary64 double, which is exactly what the DOUBLE bucket's findability and the lossless float8 pushdown need. 2147483648 (10 digits) is inside that bound; 9007199254740993 (16) is not — without condemning the 10-digit value by association with its LONG label. This is not "precision <= 15 excludes exactly the values above 2^53": the predicate works on stripped precision, so a value like 1e16 strips to precision 1 and is admitted despite being past 2^53 — the bound is on significant digits, not on magnitude.

A non-numeric t is never admitted; callers route by JSON kind first.

func CompareTemporal added in v0.8.3

func CompareTemporal(op FilterOp, storedMs int64, storedOK bool, cmpMs int64, cmpOK bool) bool

CompareTemporal is the single per-operator temporal decision for the single-sided comparison ops, shared by both Go evaluators. storedOK=false (stored value not a valid instant) → excluded for positive ops, vacuously true for NE. cmpMs is the operand instant; cmpOK is false only if the operand failed to parse (validation makes this unreachable for validated callers; evaluators still degrade safely). Range ops (BETWEEN / BETWEEN_INCLUSIVE) do not route through this helper — eval_leaf.go's precise-range path handles them.

func DefaultSaveAll

func DefaultSaveAll(store EntityStore, ctx context.Context, entities iter.Seq[*Entity]) ([]int64, error)

DefaultSaveAll is the sequential fallback for EntityStore.SaveAll. It calls store.Save for each entity in order and stops on the first error. Backends that don't need concurrent saves delegate their SaveAll to this.

func DesugarCondition added in v0.8.4

func DesugarCondition(c predicate.Condition) predicate.Condition

DesugarCondition rewrites every predicate.ArrayCondition in cond's tree into a predicate.GroupCondition (operator "AND") of predicate.SimpleCondition EQUALS leaves, one per non-null value, each addressing its element by a bracket index rather than the container's wildcard. It recurses into predicate.GroupCondition children so a nested array clause is rewritten too, and returns every other clause type unchanged. ConditionToFilter calls it first, so no evaluator ever sees an ArrayCondition — this is the ONLY place the clause's positional semantics are defined; nothing downstream has a second opinion on what it means.

A single non-null value collapses to the bare SimpleCondition rather than a one-child group — an ordinary AND-of-one is a needless wrapper, and every caller here just wants "this leaf's Filter", not "this group's Filter with one child". An all-null Values collapses to an empty AND (Conditions has length zero), which [groupToFilter] renders as FilterAnd with no children — the empty-AND identity ("matches everything") already established for a caller-written empty group, not a special case reinvented here.

This function is TOTAL: it never errors and never inspects jsonPath for well-formedness. Per the path grammar, an array clause's jsonPath must carry a trailing wildcard subscript ("$.tags[*]"), which desugars to "$.tags[i]" by replacing that trailing "[*]" with "[i]"; a path with no trailing wildcard has "[i]" appended instead (a bare "$.tags" yields "$.tags[0]"). Rejecting the bare spelling is the engine's job at its validation boundary — not this function's — so a caller-supplied jsonPath that is not well-formed at all reaches the resulting SimpleCondition unrejected here and is caught downstream by [stripDollarDot], the same as it always was for any other malformed path.

func EvalLeaf added in v0.8.3

func EvalLeaf(exp Expansion, stored gjson.Result) bool

EvalLeaf reports whether stored satisfies the pre-built leaf Expansion. exp was built by ExpandLeaf, so it inherits that function's declared contract: a multi-numeric declared set was outside the kernel's guarantees when exp was built, and nothing here re-checks that.

func FieldsMapFromSchema added in v0.8.4

func FieldsMapFromSchema(schema []byte) (map[string]FieldDescriptor, error)

FieldsMapFromSchema derives the flattened JSONPath -> FieldDescriptor view from a model's stored schema (ModelDescriptor.Schema).

Nil or empty schema bytes yield (nil, nil). That is not leniency: a model with no schema bound has no types to declare, and the engine's own schema-loading path makes the same distinction, so a plugin that reported an error here would reject searches the engine accepts. Callers must handle the nil map — with no fields map there is no declared type set, and validation or coercion that depends on one must degrade in whatever direction the caller has decided is safe, not silently treat "unknown" as "no match".

Non-empty but unparseable bytes are a wrapped error. Those bytes were written by the engine, so failing to read them means this executor disagrees with the writer about the model; continuing would search against a schema nobody defined. Fail closed.

The returned map is freshly built on every call and owned by the caller; mutating it affects nothing else.

func HasRole

func HasRole(roles []string, role string) bool

HasRole checks whether the target role is present in the roles slice.

func IsArrayIndex added in v0.8.4

func IsArrayIndex(s string) bool

IsArrayIndex reports whether s is a non-empty run of ASCII digits — the digit-class half of "is this a well-formed array index" for a filter-path subscript body (the text between "[" and "]", once the wildcard "*" case has been ruled out). It says nothing about magnitude: [parsePathSub] is the full predicate, checking this and then that the run fits an int32, and [scanPathHops] — the one scan loop both ParseFilterPath and scanWirePathBody (condition_filter.go) build on — calls parsePathSub, not this function directly, so the wire boundary and the parser agree on the complete rule, digit class and magnitude both. Every other place in this module that needs the digit-class check alone delegates here instead of scanning its own copy.

func IsAssignableTo added in v0.8.3

func IsAssignableTo(dataT, schemaT DataType) bool

IsAssignableTo reports whether a value classified as dataT can losslessly assign into schemaT per the widening lattice. NULL assigns to any type (absence is universally acceptable).

func IsNumeric added in v0.8.3

func IsNumeric(dt DataType) bool

IsNumeric reports whether dt is in either numeric family.

func IsTemporalMetaField added in v0.8.4

func IsTemporalMetaField(field string) bool

IsTemporalMetaField reports whether the given already-canonicalized meta field name is classified as temporal. Note "already-canonicalized": the "previousTransition" alias must be resolved to "transitionForLatestSave" before calling — ConditionToFilter does that for lifecycle conditions.

func LessByOrder added in v0.8.3

func LessByOrder(a, b *Entity, specs []OrderSpec) bool

LessByOrder is this engine's strict-less-than comparator for OrderSpec sequences: each spec in precedence order, missing/null last (both directions), with a final entity_id tiebreaker.

For data paths and non-id meta fields, Kind fixes the comparison (byte order text, IEEE-754 numeric, bool false<true, chronological instant) and every backend (memory, sqlite, postgres, commercial) applies the same comparison for a given Kind, so ordering on those fields matches across backends. It mirrors the SQL ORDER BY built by plugins/sqlite/searcher.go and plugins/postgres/searcher.go's orderByFieldExpr for that shared-Kind subset.

For Source=SourceMeta, Path="id" — including the terminal tiebreaker — Kind is IGNORED: the comparator is this engine's BYTE-WISE canonical entity-ID order, a per-engine choice documented per backend and NOT required to be identical across backends (see OrderSpec's doc comment). Ported from internal/domain/search/ordersort.go (sortEntities/lessByKey).

func MarshalModelNode added in v0.8.4

func MarshalModelNode(n *ModelNode) ([]byte, error)

MarshalModelNode encodes a ModelNode tree into the persisted schema bytes.

func MergeOrdered added in v0.8.4

func MergeOrdered(
	next func() (*Entity, bool, error),
	adds []*Entity,
	isDeleted func(entityID string) bool,
	cmp func(a, b *Entity) int,
) func() (*Entity, bool, error)

MergeOrdered merges an already-ordered committed pull-stream with a sorted buffered overlay, excluding deleted ids, yielding the merged order. cmp is the total order both inputs are sorted by (final key: canonical ID), so cmp(a, b) == 0 implies a and b share an entity ID.

On an equal-ID collision the overlay entity wins — it is yielded in place of the committed row, which is silently consumed (exactly one yield for that ID), matching read-your-own-writes overlay semantics elsewhere in this module (transaction write-set shadows the committed value).

The returned function is a pull-stream: it does nothing, and calls next nothing, until first invoked. An error from next is propagated once all entities already fetched from the committed stream (and any adds that sort no later than them) have been yielded, and is sticky thereafter — no further entity is yielded once the committed stream has failed, since the true merge order past that point cannot be known.

func MetaFieldNames added in v0.8.4

func MetaFieldNames() []string

MetaFieldNames returns the sorted canonical meta-field names, as a fresh slice the caller may retain or mutate.

Enumeration is published alongside the ResolveMetaField point lookup because the vocabulary is a CLOSED set whose membership other code must agree with — a runtime matcher deciding which names address metadata, a validator rejecting the rest, a diagnostic listing the valid ones. Each of those needs the set, not a single lookup, and without this they keep their own copy: a silent drift surface, since nothing would compare the copies.

Note "previousTransition" is absent. It is a client-facing alias for "transitionForLatestSave", not a vocabulary member, and ConditionToFilter canonicalizes it before any lookup. A caller validating raw client input must admit the alias itself.

func NormalisePath added in v0.8.4

func NormalisePath(raw string) string

NormalisePath returns raw in the "$."-prefixed convention, idempotently.

It is exported because the "$."-prefixed form is the fields-map key convention: FieldsMapFromSchema emits keys in it, and a lookup that misses returns a zero FieldDescriptor with no declared types, which annihilates comparison leaves rather than erroring. A caller assembling fields-map keys must produce the same form this function does, so it is published rather than reimplemented per plugin.

It is a CANONICALISER, not a validator: it says nothing about whether raw is a legal path, and adding a leader to a bare identifier here does not make that identifier an acceptable wire jsonPath — ConditionToFilter requires the leader on input and rejects a bare path outright.

func NumericFamily added in v0.8.3

func NumericFamily(dt DataType) int

NumericFamily returns 1 for integer types, 2 for decimal types, 0 for non-numeric.

func NumericFloat added in v0.8.3

func NumericFloat(v any) (float64, bool)

NumericFloat coerces genuine numeric Go types to float64. It deliberately does NOT parse strings — this is the canonical numeric-leaf coercion both evaluators use. Mirrors the sqlite plugin's toFloat64.

func NumericRank added in v0.8.3

func NumericRank(dt DataType) int

NumericRank returns the position in the widening hierarchy within a family.

func OperandString added in v0.8.3

func OperandString(v any) string

OperandString normalizes a filter operand to its Cloud .asText() string form, the shape ExpandLeaf parses. A json.Number keeps its exact lexical form; a nil operand becomes the empty string (a genuinely-null binary operand was rejected at the search boundary).

func OperatorNames added in v0.8.4

func OperatorNames() []string

OperatorNames returns the sorted set of operator names MapOperator recognises, as a fresh slice the caller may retain or mutate.

It exists so a caller can render a "valid operators are…" diagnostic, or validate membership, without maintaining a second copy of the table — a copy that would drift silently, since nothing would compare the two.

func ParseStringOrNull added in v0.8.3

func ParseStringOrNull(operand string, t DataType) (any, bool)

ParseStringOrNull parses operand as the target DataType t, porting Cloud's DataType.parseStringOrNull (DataType.kt:125-166). ok=false means operand does not parse as t — this is never an error; callers drop that type-branch from a polymorphic evaluation and try the next candidate type.

Temporal types (LocalDate, LocalDateTime, LocalTime, ZonedDateTime, Year, YearMonth) are NOT handled here: ParseStringOrNull always returns (nil, false) for them. The temporal engine (a later task) parses those separately.

Successful numeric parses (whole or decimal) return a spi.Decimal — it losslessly represents both integral and fractional values via unscaled/scale, so a single return type covers every numeric DataType without an integer/decimal split in the result shape.

func ParseTemporalMillis added in v0.8.3

func ParseTemporalMillis(s string) (int64, bool)

ParseTemporalMillis parses an offset-bearing RFC3339 timestamp to floored epoch-milliseconds. Returns ok=false for any input that is not full RFC3339 with an explicit offset (Z or ±hh:mm). The mandatory offset makes the value an absolute instant — which is what lets the SQL cyoda_epoch_millis be IMMUTABLE. Shared kernel: called by internal/match, spi.PreparedFilter.Match, and the SQL planners (to precompute operands). Do not duplicate this logic — it is the single home for the temporal-scalar rule.

func Register

func Register(p Plugin)

Register adds p to the plugin registry. Register panics if another plugin has already been registered under the same Name — a naming collision at init time is always a programmer error. Matches the database/sql.Register convention.

func RegisteredPlugins

func RegisteredPlugins() []string

RegisteredPlugins returns the names of all currently registered plugins, sorted by name for deterministic ordering.

func ResolvePath added in v0.8.4

func ResolvePath(data []byte, hops []PathHop) []gjson.Result

ResolvePath resolves a parsed filter path against a JSON document and returns the values the path addresses, in document order.

This is the whole of the spec's addressing rule (see docs/cloud-parity/path-grammar.md, sections 3 and 5): the path says what it addresses, and the shape of the stored value never decides what the path meant.

  • A bare hop contributes exactly one result: the value at that key, whatever its shape (scalar, object, or array), never unwrapped.
  • A "[N]" subscript contributes exactly one result: the element at that index, non-existent when the value is not an array or the index is past the end.
  • A "[*]" subscript contributes one result per element, and none at all when the value is not an array — it never wraps a scalar into a one-element sequence.
  • A missing key contributes one non-existent result rather than being dropped, so a presence test (e.g. IS_NULL) can see it.

Data whose root is a JSON array resolves every hop to a non-existent result: entity data is always a JSON document (a sample-data array body is a collection of documents, not one document), so a root array is not a shape entity data ever takes, and failing every hop closed is the correct answer regardless.

Each hop is resolved with gjson.Result.Get, never a joined path string: gjson resolves a numeric path segment against an array as an index, which is exactly the data-driven behaviour this function must not exhibit. A hop named "0" is always an object-key lookup — see [fieldResult], which guards Get so this holds even when the current result is itself an array (gjson's Get applies the same array-index rule per call, not just when a full path string is joined).

func RunScheduledTaskStoreConformance added in v0.8.3

func RunScheduledTaskStoreConformance(t *testing.T, newFactory func() StoreFactory)

RunScheduledTaskStoreConformance exercises the ScheduledTaskStore contract against any StoreFactory. Each plugin calls this from its test package.

func ValidateConditionOperators added in v0.8.4

func ValidateConditionOperators(cond predicate.Condition) error

ValidateConditionOperators walks a condition tree and returns an error naming the first unrecognised operator it finds, wrapping ErrUnknownOperator. The error text lists the canonical set so a caller can self-correct.

It is a convenience, not a safety requirement: ConditionToFilter rejects an unrecognised operator on its own. Use this to reject the whole request up front, before any partial work, and to report the problem at the request boundary rather than mid-translation. It exists so that a backend wanting that does not write the recursion over the condition types itself — a second implementation surface of the kind relocating ConditionToFilter here was meant to remove.

It covers ONLY operator names — but that now means BOTH a leaf's OperatorType (EQUALS, NOT_EQUAL, …) AND a GroupCondition's own Operator (AND, OR, NOT): the group's operator is checked at the same node as its children are recursed into, so a bad one nested arbitrarily deep is reported the same way a bad leaf operator is. The three operand obligations documented on ConditionToFilter are deliberately not folded in: the object-operand and BETWEEN-arity checks are cheap local checks a caller can apply while walking its own input; the pattern-compilability check was blocked on reaching the kernel's own pattern derivation, which ValidateLeafPattern and ValidateConditionPatterns now expose. Passing this function is not the same as having validated the condition.

Pattern operands are now covered by ValidateConditionPatterns. A pattern-cost bound (rejecting a syntactically valid but expensive pattern) remains a separate, unsettled concern, out of scope for both functions.

func ValidateConditionPatterns added in v0.8.4

func ValidateConditionPatterns(cond predicate.Condition) error

ValidateConditionPatterns walks cond and validates every pattern operand against the kernel's own derivation, so a caller can reject the whole request at the boundary rather than mid-translation or, worse, discover it as an empty result page.

It mirrors ValidateConditionOperators's recursion and shares its depth cap. The two are complements and neither implies the other: this one checks pattern OPERANDS and passes a misspelled operator (MapOperator returns the zero FilterOp, which compiles no pattern); that one checks operator NAMES and ignores operands. Call both.

Errors wrap ErrInvalidPattern and name the offending leaf by jsonPath (or, for a lifecycle leaf, by field) and the operator string the caller wrote (e.g. "MATCHES_PATTERN") — never the operand, and never the internal FilterOp spelling (ValidateLeafPattern uses that vocabulary; this one speaks the caller's).

func ValidateFilterPath added in v0.8.4

func ValidateFilterPath(p string) error

ValidateFilterPath reports whether p is a well-formed filter path, discarding the parsed hops. The error, when non-nil, wraps ErrInvalidFilterPath.

func ValidateLeafPattern added in v0.8.4

func ValidateLeafPattern(op FilterOp, value any) error

ValidateLeafPattern reports whether value is usable as op's pattern operand, using the SAME derivation the kernel evaluates with. A validator calling this cannot accept an operand the kernel will refuse, or refuse one it accepts.

Returns nil for every operator that carries no pattern, so a caller can pass any leaf without switching on the operator first.

It covers pattern VALIDITY only. Passing it is not the same as having validated the condition — see ValidateConditionOperators for operator names. Errors wrap ErrInvalidPattern, name op (the exact FilterOp the caller passed in — accurate here, since the caller supplied it), and carry neither the operand nor the anchored form.

func WithAmbientOrigin added in v0.8.3

func WithAmbientOrigin(ctx context.Context, p Principal) context.Context

WithAmbientOrigin seeds the origin for a causal-chain root that has no transaction yet. Single legitimate seed site: the scheduled fire, from the durable task row. A zero Principal is never seeded.

func WithTransaction

func WithTransaction(ctx context.Context, tx *TransactionState) context.Context

WithTransaction returns a new context carrying the given transaction state.

func WithUniqueKeys added in v0.8.2

func WithUniqueKeys(ctx context.Context, keys []UniqueKey) context.Context

WithUniqueKeys attaches the declared []UniqueKey for the current store to ctx, making them available to CompositeUniqueKeyCapable implementations via UniqueKeysFromContext.

func WithUserContext

func WithUserContext(ctx context.Context, uc *UserContext) context.Context

Types

type AggregateExpr added in v0.8.1

type AggregateExpr struct {
	Op    AggregateOp
	Field string // scalar JSONPath
	// Alias is the response key. If blank, the server synthesizes
	// <op>_<field>.
	Alias string
}

AggregateExpr is one requested aggregation.

type AggregateOp added in v0.8.1

type AggregateOp string

AggregateOp enumerates the supported per-bucket aggregations.

const (
	AggSum AggregateOp = "sum"
	AggAvg AggregateOp = "avg"
	AggMin AggregateOp = "min"
	AggMax AggregateOp = "max"
	// AggStdev is sample standard deviation (n-1 denominator).
	AggStdev AggregateOp = "stdev"
)

type ArrayBranch added in v0.8.4

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

ArrayBranch records that a path was observed holding an array, together with the descriptor shared by every element.

func (*ArrayBranch) Element added in v0.8.4

func (b *ArrayBranch) Element() *ModelNode

Element returns the descriptor shared by every position, or nil when the array was observed but never with any content.

A nil element is meaningful and must not be substituted with an empty leaf: that would declare a field with an empty type set — a leaf that matches nothing — where the truth is that nothing was declared at all.

func (*ArrayBranch) Kind added in v0.8.4

func (b *ArrayBranch) Kind() NodeKind

Kind implements Branch.

type AsyncSearchStore

type AsyncSearchStore interface {
	// CreateJob persists a new job row. Epoch is always persisted as 1,
	// regardless of the value set on job.Epoch by the caller.
	CreateJob(ctx context.Context, job *SearchJob) error

	GetJob(ctx context.Context, jobID string) (*SearchJob, error)

	// UpdateJobStatus fences on epoch (ErrStaleClaim on mismatch) and refuses
	// a terminal job (ErrAlreadyTerminal). Against a missing job it returns
	// ErrNotFound. A zero finishTime is stored as absent (NULL/nil).
	UpdateJobStatus(ctx context.Context, jobID string, epoch int64, status string, resultCount int, errMsg string, finishTime time.Time, calcTimeMs int64) error

	// SaveResults streams entityIDs into the job's persisted result set.
	// Exactly one call is made per claim epoch; yield order is preserved as
	// GetResultIDs order. Implementations batch internally as they see fit,
	// but the result sequence position must increase strictly across chunks.
	// The store observes ctx cancellation. A nil return means everything
	// yielded was durably stored — it is NOT a statement about job success,
	// which is recorded separately via UpdateJobStatus.
	//
	// Fences on epoch (ErrStaleClaim) and terminal status (ErrAlreadyTerminal,
	// checked at least at chunk boundaries), matching UpdateJobStatus. A
	// missing job returns ErrNotFound.
	//
	// The fence is a property of the CALL, not of the rows it carries: an
	// entityIDs sequence that yields nothing MUST still be fenced, and MUST
	// still report ErrStaleClaim / ErrAlreadyTerminal / ErrNotFound where a
	// non-empty sequence would have. A search matching zero entities is an
	// ordinary outcome, so short-circuiting on "nothing to write" is exactly
	// the case where a reclaimed executor is most likely to learn — or fail to
	// learn — that it has been fenced off.
	SaveResults(ctx context.Context, jobID string, epoch int64, entityIDs iter.Seq[string]) error

	// GetResultIDs requires offset >= 0 && limit >= 1; a violation returns an
	// error, never a panic. Reading a non-terminal job answers with the
	// results saved so far.
	GetResultIDs(ctx context.Context, jobID string, offset, limit int) (entityIDs []string, total int, err error)

	DeleteJob(ctx context.Context, jobID string) error

	// ReapExpired deletes eligible expired jobs. Cross-tenant: obtain with a
	// background/tenant-less context, as with ScheduledTaskStore.ScanDue
	// (persistence.go:19-24).
	ReapExpired(ctx context.Context, ttl time.Duration) (int, error)

	// Cancel marks the job CANCELLED and stamps the given finishTime on the
	// job it transitions. Idempotent: cancelling a job already in a
	// terminal state returns nil AND does not overwrite the existing finish
	// time. Cancelling a non-existent job returns ErrNotFound. The finish
	// time is caller-supplied so all backends record the same instant — the
	// engine is the single clock.
	Cancel(ctx context.Context, jobID string, finishTime time.Time) error

	// Heartbeat stamps HeartbeatTime, fenced by epoch. Returns ErrStaleClaim
	// if epoch does not match the job's current Epoch, ErrAlreadyTerminal if
	// the job is already terminal, and ErrNotFound if the job does not exist.
	Heartbeat(ctx context.Context, jobID string, epoch int64) error

	// ClaimStale atomically claims up to limit RUNNING jobs whose heartbeat
	// (HeartbeatTime, or CreateTime as the baseline when HeartbeatTime is
	// nil) is older than staleAfter. It never claims a terminal job. Claiming
	// bumps Epoch and stamps HeartbeatTime from the store's current clock,
	// strictly later than the stamp it found stale; concurrent claimers
	// obtain disjoint sets of jobs. The staleness stamp and the staleness
	// comparison use the same clock domain (store-side, where the store has
	// one).
	// A released job (see Release) is eligible regardless of staleAfter;
	// claiming it clears the released mark and does NOT increment
	// StaleClaims. A job claimed because its heartbeat went stale has
	// StaleClaims incremented, atomically with the claim.
	// Cross-tenant, like ReapExpired: obtain with a background/tenant-less
	// context, as with ScheduledTaskStore.ScanDue (persistence.go:19-24).
	ClaimStale(ctx context.Context, staleAfter time.Duration, limit int) ([]*SearchJob, error)

	// ClearResults deletes the job's persisted result IDs. Idempotent.
	ClearResults(ctx context.Context, jobID string) error

	// Release relinquishes the caller's claim on a RUNNING job without
	// finishing it: the job stays RUNNING and becomes eligible for
	// ClaimStale immediately, regardless of staleAfter, so a live node can
	// take it over without waiting for the heartbeat to age out. Fenced by
	// epoch like every executor-side write: ErrStaleClaim if epoch is not
	// the job's current Epoch, ErrAlreadyTerminal if the job is terminal,
	// ErrNotFound if it does not exist. Idempotent at the same epoch.
	// Release does NOT bump Epoch and does NOT increment StaleClaims — a
	// release is a graceful handoff, not an attempt. The released mark
	// survives a later Heartbeat at the same epoch (a stray stamp from a
	// node that is going away cannot resurrect the job) and is cleared by
	// the ClaimStale that takes the job. Tenant-scoped, like Heartbeat.
	Release(ctx context.Context, jobID string, epoch int64) error
}

AsyncSearchStore provides persistence for async search jobs and their results.

Terminal statuses (SUCCESSFUL/FAILED/CANCELLED) are write-once: once a job reaches one, UpdateJobStatus, Heartbeat, and SaveResults against it return ErrAlreadyTerminal (SaveResults checks this at least at chunk boundaries). Cancel is the sole idempotent-nil exception — cancelling an already-terminal job returns nil and leaves it unchanged. ClaimStale never claims a terminal job.

Epoch fencing: UpdateJobStatus, SaveResults, Heartbeat, and Release each take the epoch the caller was claimed under and MUST refuse a call whose epoch does not match the job's current Epoch with ErrStaleClaim — this is how a reclaimed job fences off writes from the executor it was taken from.

type Branch added in v0.8.4

type Branch interface{ Kind() NodeKind }

Branch is one kind a path has been observed as, together with what that observation recorded.

type ChangeLevel

type ChangeLevel string

ChangeLevel controls which structural changes are permitted during data ingestion.

const (
	ChangeLevelArrayLength   ChangeLevel = "ARRAY_LENGTH"
	ChangeLevelArrayElements ChangeLevel = "ARRAY_ELEMENTS"
	ChangeLevelType          ChangeLevel = "TYPE"
	ChangeLevelStructural    ChangeLevel = "STRUCTURAL"
)

func ValidateChangeLevel

func ValidateChangeLevel(s string) (ChangeLevel, error)

ValidateChangeLevel returns an error if the given string is not a known ChangeLevel.

type ClusterBroadcaster

type ClusterBroadcaster interface {
	Broadcast(topic string, payload []byte)
	Subscribe(topic string, handler func(payload []byte))
}

ClusterBroadcaster delivers opaque payloads to peer nodes on a named topic. Semantics are fire-and-forget, best-effort: no ordering, no anti-entropy, no persistence. Payloads that need ordering or delivery guarantees should use a backend-internal transport (e.g. a message broker) rather than this interface.

Broadcast is non-blocking; it enqueues the payload and returns. Subscribe registers a handler called for every message received on the topic. Handlers run on the broadcaster's goroutines; they must not block indefinitely.

Typical use: a plugin needs eventually-consistent cluster-wide notifications (cache invalidation, clock gossip, topology hints).

type CompositeUniqueKeyCapable added in v0.8.2

type CompositeUniqueKeyCapable interface {
	SupportsCompositeUniqueKeys() bool
}

CompositeUniqueKeyCapable is OPTIONAL on a StoreFactory: advertises composite unique-key support. Absence (or false) = unsupported. Additive; NOT part of the StoreFactory interface.

type ConfigVar

type ConfigVar struct {
	Name        string
	Description string
	Default     string
	Required    bool
}

ConfigVar documents a single environment variable a plugin reads.

type DataType added in v0.8.3

type DataType int

DataType represents a primitive data type in the entity model.

const (

	// Integer is a 32-bit signed integer.
	Integer DataType = iota
	// Long is a 64-bit signed integer.
	Long
	// BigInteger is an arbitrary-precision integer fitting Int128.
	BigInteger
	// UnboundInteger is an integer of arbitrary magnitude.
	UnboundInteger
	// Double is a decimal within the precision-15, scale-292 envelope.
	Double
	// BigDecimal is a decimal fitting Trino's fixed-scale Int128 encoding.
	BigDecimal
	// UnboundDecimal is a decimal of arbitrary precision and scale.
	UnboundDecimal

	// String is a variable-length character sequence.
	String
	// Character is a single Unicode character.
	Character

	// LocalDate is a date without time-zone (yyyy-MM-dd).
	LocalDate
	// LocalDateTime is a date-time without time-zone.
	LocalDateTime
	// LocalTime is a time without time-zone.
	LocalTime
	// ZonedDateTime is a date-time with a time-zone.
	ZonedDateTime
	// Year is a year value (e.g. 2026).
	Year
	// YearMonth is a year-month value (e.g. 2026-03).
	YearMonth

	// UUIDType is a universally unique identifier.
	UUIDType
	// TimeUUIDType is a time-based UUID.
	TimeUUIDType

	// ByteArray is a variable-length byte sequence.
	ByteArray

	// Boolean is a true/false value.
	Boolean
	// Null represents the absence of a value.
	Null
)

func ClassifyDecimal added in v0.8.3

func ClassifyDecimal(d Decimal) DataType

ClassifyDecimal classifies a non-whole-number decimal value into DOUBLE, BIG_DECIMAL, or UNBOUND_DECIMAL. Input MUST be the result of StripTrailingZeros. Per spec §4.2:

  • DOUBLE if precision ≤ 15 AND |scale| ≤ 292.
  • BIG_DECIMAL definite if precision ≤ 38 AND (precision - scale) ≤ 20 AND scale ≤ 18.
  • BIG_DECIMAL loose if precision ≤ 39 AND (precision - scale) ≤ 21 AND scale ≤ 18 AND SetScale(18).Unscaled().IsInt128().
  • Otherwise UNBOUND_DECIMAL.

func ClassifyInteger added in v0.8.3

func ClassifyInteger(v *big.Int) DataType

ClassifyInteger classifies a whole-number value into INTEGER, LONG, BIG_INTEGER, or UNBOUND_INTEGER by magnitude. Matches the Cyoda Cloud integer-family logic at ParserFunctions.kt:133-155, minus BYTE/SHORT (dropped in cyoda-go — spec §2.3).

  • [-2^31, 2^31 - 1] → INTEGER
  • [-2^63, 2^63 - 1] outside → LONG
  • [-2^127, 2^127 - 1] outside → BIG_INTEGER (fits signed Int128)
  • beyond → UNBOUND_INTEGER

func ClassifyTemporalString added in v0.8.3

func ClassifyTemporalString(s string) (DataType, bool)

ClassifyTemporalString reports the most specific temporal DataType the given string parses as (per the natural-subtype precedence), or false if it is not a temporal value. Model discovery uses this to classify a data field's ISO-8601 string values as a temporal subtype, matching exactly how the leaf kernel classifies stored temporal values — so a field discovered as a temporal type evaluates with the same subtype the stored value carries.

func CollapseNumeric added in v0.8.3

func CollapseNumeric(types []DataType) DataType

CollapseNumeric reduces a numeric-only set to a single DataType that every input widens to, using the Cyoda-compatible widening lattice (see wideningLattice above, matching DataType.kt:240-287).

Preconditions: input is non-empty; every element satisfies IsNumeric. Panics on either violation.

Invariant: for every input t, IsAssignableTo(t, result) || t == result. This guarantees that validation of a pre-collapse value against the post-collapse schema succeeds — see A.2 §I3 monotonicity.

Algorithm (equivalent to Cyoda's findCommonDataType at DataType.kt:293, restricted to numeric inputs):

  • Integer-only inputs → widest integer in input (same-family widen).
  • Otherwise candidate = widest decimal in input. If some input does not widen to the candidate, escalate to UnboundDecimal (which every numeric type reaches).

Divergence from Cyoda: Cyoda's findCommonDataType returns STRING as a universal fallback for non-widening pairs. That is Cyoda-internal (every leaf is also stored as a string for search). CollapseNumeric is scoped to numerics where UnboundDecimal is the universal sink — STRING fallback is never needed.

func ParseDataType added in v0.8.3

func ParseDataType(name string) (DataType, bool)

ParseDataType returns the DataType for a given name, or false if unknown.

func (DataType) String added in v0.8.3

func (d DataType) String() string

String returns the canonical name of the DataType.

type Decimal added in v0.8.3

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

Decimal is a fixed-scale arbitrary-precision decimal. Value = unscaled × 10^(-scale). Scale may be negative (e.g. 1e2 has unscaled=1, scale=-2). No arithmetic — cyoda-go delegates arithmetic to Trino.

func ParseDecimal added in v0.8.3

func ParseDecimal(s string) (Decimal, error)

ParseDecimal parses a decimal string. Accepts integer literals, fractional literals, and scientific notation with optional sign. Rejects NaN, Infinity, empty strings, and malformed forms.

func (Decimal) Canonical added in v0.8.3

func (d Decimal) Canonical() string

Canonical returns a plain-decimal string representation (no scientific notation). Round-trippable through ParseDecimal.

func (Decimal) Cmp added in v0.8.3

func (d Decimal) Cmp(other Decimal) int

Cmp returns -1 if d < other, 0 if equal, 1 if d > other. Exact — no rounding modes.

Magnitude first. The sign decides when the signs differ; the adjusted exponent — precision − scale, the position of the most significant digit — decides when they do not. Only a tie on both needs the coefficients aligned to a common scale, and in a tie the scale difference equals the precision difference, so the alignment cost is bounded by the operands' own digit counts.

Aligning unconditionally, as this once did, multiplied the smaller-scale coefficient by 10^diff — and diff is bounded only by int32, so comparing 1e10000000 against any ordinary value materialised a ten-million-digit integer. That was reachable from a 13-byte search operand through toRange before any other guard ran.

func (Decimal) IsInt128 added in v0.8.3

func (d Decimal) IsInt128() bool

IsInt128 reports whether the unscaled value fits the signed Int128 range [-2^127, 2^127-1]. Scale is not considered.

Implementation note: relies on pre-computed boundaries rather than big.Int.BitLen() comparisons, because BitLen ignores sign and BitLen(-2^127) == 128 — incorrectly excluding the valid minimum.

func (Decimal) IsZero added in v0.8.3

func (d Decimal) IsZero() bool

IsZero reports whether d is numerically zero.

func (Decimal) MarshalJSON added in v0.8.3

func (d Decimal) MarshalJSON() ([]byte, error)

MarshalJSON encodes the Decimal as a JSON number (not string) using Canonical form.

func (Decimal) Precision added in v0.8.3

func (d Decimal) Precision() int

Precision returns the number of significant digits in the unscaled value. Matches Java BigDecimal.precision() — returns 1 for zero.

func (Decimal) Scale added in v0.8.3

func (d Decimal) Scale() int32

Scale returns the scale: number of digits after the decimal point. Negative scale corresponds to scientific notation like 1e2.

func (Decimal) SetScale added in v0.8.3

func (d Decimal) SetScale(newScale int32) (Decimal, error)

SetScale returns a Decimal at the requested scale. Upward scale (adding fractional digits) multiplies the unscaled value by 10^(n-scale) and always succeeds. Downward scale (removing fractional digits) succeeds only if the unscaled value is divisible by 10^(scale-n); otherwise returns a precision-loss error.

func (Decimal) Sign added in v0.8.3

func (d Decimal) Sign() int

Sign returns -1 for negative, 0 for zero, 1 for positive.

func (Decimal) StripTrailingZeros added in v0.8.3

func (d Decimal) StripTrailingZeros() Decimal

StripTrailingZeros returns a Decimal with trailing zeros removed from the unscaled value. Matches Java BigDecimal.stripTrailingZeros semantics: a non-zero unscaled value with trailing zero digits has those digits removed and the scale decremented accordingly. A zero value collapses to unscaled=0, scale=0.

The whole zero run is removed in one division. Peeling one digit per full-width QuoRem, as this once did, costs O(zeros × digits): a 1,000,002-byte operand — a "1" and a million zeros, comfortably inside the request body cap — took minutes. Both the search-operand path (expandCompare strips the operand before bucketing it) and the write path (inferDataType) are request-boundary, so the cost has to be the value's own size and nothing else.

func (*Decimal) UnmarshalJSON added in v0.8.3

func (d *Decimal) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a JSON number or string into the Decimal.

func (Decimal) Unscaled added in v0.8.3

func (d Decimal) Unscaled() *big.Int

Unscaled returns a defensive copy of the unscaled big.Int.

type DescribablePlugin

type DescribablePlugin interface {
	Plugin
	ConfigVars() []ConfigVar
}

DescribablePlugin is an optional Plugin capability: it exposes the configuration variables the plugin reads, so --help can render them.

type Entity

type Entity struct {
	Meta EntityMeta
	Data []byte
}

func MergeBounded added in v0.8.3

func MergeBounded(next func() (*Entity, bool, error), adds []*Entity, deleted func(id string) bool, specs []OrderSpec, limit int) ([]*Entity, error)

MergeBounded performs a bounded k-way merge of a sorted committed source (next, lazy pull) with a pre-sorted adds slice, skipping committed rows for which deleted(id) is true, ordered by LessByOrder(specs).

limit >= 1 is REQUIRED — a bounded-or-fail cap on the merged result, not a page size, matching EntityStore.Search's contract: if the number of survivors exceeds limit, MergeBounded returns ErrSearchResultLimitExceeded rather than a truncated prefix. The bound gates on TOTAL survivors, so the adds slice alone can trip it. Memory is bounded to ~limit+1+len(adds): the committed source is pulled lazily and the merge stops the moment the bound is exceeded.

limit <= 0 is a contract violation: MergeBounded returns an error rather than treating it as "unbounded" or substituting a default. There is no unbounded mode — a caller that wants every surviving entity uses the EntityStore.Iterate streaming surface (see MergeOrdered) instead of asking for a materialized slice with no bound.

type EntityMeta

type EntityMeta struct {
	ID                      string
	TenantID                TenantID
	ModelRef                ModelRef
	State                   string
	Version                 int64
	CreationDate            time.Time
	LastModifiedDate        time.Time
	TransactionID           string
	ChangeType              string        // "CREATED", "UPDATED", "DELETED"
	ChangeUser              string        // user ID who performed the change
	ChangeUserKind          PrincipalKind // kind of the attributed ChangeUser; empty on legacy rows
	ChangeExecutor          Principal     // the actual caller that performed the change, independent of attribution
	TransitionForLatestSave string
}

type EntityStore

type EntityStore interface {
	Save(ctx context.Context, entity *Entity) (int64, error)
	// CompareAndSave saves the entity only if expectedTxID equals the
	// entity's current transaction ID as the caller's own transaction sees
	// it: a same-transaction Delete or Save IS the current state, not the
	// pre-transaction one, so comparing against a stale ID — including the
	// transaction's own prior write — conflicts and returns ErrConflict.
	//
	// expectedTxID MUST be non-empty. An empty expectedTxID is a contract
	// violation and the implementation MUST return an error rather than
	// comparing it — it is a caller bug, not a domain outcome, so it carries
	// no sentinel (the same treatment Search gives Limit <= 0). The empty
	// string is not a usable expectation: a missing entity, a deleted one,
	// and an entity written outside any transaction all carry an empty
	// EntityMeta.TransactionID — a non-transactional write may stamp none,
	// and EntityVersionMeta.TransactionID shows the same empty value on the
	// audit row — so "" cannot tell the three apart, and an "expect no
	// entity" reading of it would silently overwrite an entity that exists.
	//
	// For a non-empty expectedTxID the rule is a literal string comparison
	// against the entity's current EntityMeta.TransactionID. There are no
	// synonyms and no existence test; every other case follows from the
	// literal rule:
	//
	//   - A missing or deleted entity carries the empty transaction ID and
	//     so matches no non-empty expectedTxID. CompareAndSave therefore
	//     can never create, and never resurrect a deleted entity; Save is
	//     the way to create or to re-create.
	//   - Once a Delete is staged in the caller's own transaction, the
	//     entity's current ID is empty in that transaction's view, so no
	//     CompareAndSave can succeed against it for the rest of the
	//     transaction. Save unstages the delete.
	//
	// This agrees with GetVersionByTransaction below: neither treats an
	// empty transaction ID as a matchable value. There it is a lookup key
	// that matches nothing and returns ErrNotFound; here it is rejected
	// outright as a caller error.
	CompareAndSave(ctx context.Context, entity *Entity, expectedTxID string) (int64, error)
	// SaveAll saves multiple entities, returning versions in iteration order.
	// Backends may execute saves concurrently. On error, returns the first
	// error encountered; partially-saved entities within an uncommitted
	// transaction are invisible to readers.
	SaveAll(ctx context.Context, entities iter.Seq[*Entity]) ([]int64, error)
	Get(ctx context.Context, entityID string) (*Entity, error)
	// GetAsAt returns entityID as of asAt. Like every point-in-time read in
	// this SPI it is COMMITTED-ONLY: it ignores any ambient transaction and
	// never surfaces that transaction's own uncommitted writes — an entity
	// the transaction created is ErrNotFound, and one it updated comes back
	// at its committed payload. A backend whose ordinary reads join the
	// caller's transaction must route this read off it; bounding the query on
	// a timestamp is not sufficient, because a transaction-stable clock makes
	// the transaction's own writes fall inside every window it can compute.
	GetAsAt(ctx context.Context, entityID string, asAt time.Time) (*Entity, error)
	Delete(ctx context.Context, entityID string) error
	DeleteAll(ctx context.Context, modelRef ModelRef) error
	Exists(ctx context.Context, entityID string) (bool, error)
	Count(ctx context.Context, modelRef ModelRef) (int64, error)
	// CountByState returns the count of non-deleted entities grouped by state
	// for the given model. If states is non-nil, only the listed states are
	// included in the result. If states is nil, all states are returned.
	// An empty (non-nil) states slice returns an empty map without querying
	// the storage layer.
	//
	// Unknown model: returns an empty map with no error, matching Count's
	// behavior (no model-registry check at this layer).
	//
	// The returned map is always non-nil on success. Every zero-count
	// result — unknown model, empty model, an empty (non-nil) states slice,
	// or a transaction whose own view holds no entities (after DeleteAll,
	// say) — is an empty map, never nil.
	//
	// Implementations MUST push the state filter down to the storage layer
	// when feasible. Callers may invoke this from inside a transaction; the
	// returned counts MUST reflect the transactional view (uncommitted writes
	// from the current tx are visible, writes from other in-flight txs are not),
	// matching the semantics of Count.
	CountByState(ctx context.Context, modelRef ModelRef, states []string) (map[string]int64, error)

	// GetPage returns a page of modelRef's entities in the engine's
	// canonical per-engine entity-ID order (see OrderSpec's doc comment —
	// this is the same order Search/Iterate use for an empty/id-only
	// OrderBy, not guaranteed identical across backends).
	//
	// limit >= 1 && offset >= 0 is REQUIRED; either violation is a contract
	// violation and the implementation MUST return an error rather than
	// substituting a default. Implementations fail fast on any row-level
	// error rather than returning a partial page.
	//
	// A page with no rows — an empty model, or an offset past the end — MUST
	// be a non-nil, empty slice with a nil error, never a nil slice. Callers
	// distinguish "no rows" from "no page" without a nil check, so the
	// idiomatic `return nil, nil` is a contract violation here.
	//
	// asAt == nil reads the live, in-transaction overlay: with an ambient
	// transaction, the committed page is merged with the transaction's own
	// write-set, and — unconditionally, unlike Search's and Iterate's opt-in
	// TrackingRead — every entity on the returned page is recorded in the
	// transaction's read-set. asAt != nil ignores any ambient transaction
	// and reads committed-only state as of that instant.
	GetPage(ctx context.Context, modelRef ModelRef, limit, offset int, asAt *time.Time) ([]*Entity, error)

	// GetVersionByTransaction returns the earliest version of entityID
	// written by transaction txID. A transaction that saved the same
	// entity more than once before committing (e.g. two Save calls inside
	// one commit) may produce more than one matching version; the
	// earliest (lowest Version) is returned.
	//
	// Versions with no entity payload — DELETED tombstones — never match,
	// even when txID is the deleting transaction's own ID: this method
	// surfaces entity content, and a tombstone has none. Use
	// GetVersionMetadata to read a tombstone's metadata instead.
	//
	// An empty txID never matches a stored-empty TransactionID
	// (non-transactional writes carry one); it always returns ErrNotFound.
	// CompareAndSave above takes the same line on the empty transaction ID
	// — never a matchable value — and rejects it as a caller error.
	GetVersionByTransaction(ctx context.Context, entityID, txID string) (*EntityVersion, error)

	// GetVersionMetadata returns entityID's version metadata — no entity
	// payload, just the audit trail — newest first, ties broken by
	// Version DESC. opts.From/opts.Until bound the window inclusively; a
	// nil side is unbounded. opts.Limit caps the returned row count; 0
	// means all, bounded only by this one entity's own version history —
	// a deliberate divergence from GetPage's limit>=1 requirement, since
	// a single entity's history can never be an unbounded model-wide scan.
	//
	// Returns ErrNotFound ONLY when entityID has no version history at all;
	// an existing entity whose versions all fall outside opts.From/opts.Until
	// yields an empty slice and a nil error, never ErrNotFound.
	//
	// Deleted is true only on the DELETED tombstone row, and Version is
	// populated on every returned row, including the tombstone.
	GetVersionMetadata(ctx context.Context, entityID string, opts VersionMetadataOptions) ([]EntityVersionMeta, error)

	// Search is the bounded-or-fail predicate read: SearchOptions.Limit >= 1
	// is REQUIRED and caps the matched set; more matches than Limit MUST be
	// ErrSearchResultLimitExceeded, never a truncated prefix; exactly at the
	// limit succeeds; Limit <= 0 is a contract violation and MUST error.
	// Search honours an active transaction (read-your-own-writes) unless
	// PointInTime is set, in which case it is committed-only. With a
	// transaction active the implementation overlays the transaction's
	// write-set, so the result is identical to a committed-plus-buffer merge
	// for the same transaction state — the merge MergeBounded computes.
	// Returned entities enter the read-set only when
	// SearchOptions.TrackingRead is set. See SearchOptions.
	Search(ctx context.Context, filter Filter, opts SearchOptions) ([]*Entity, error)

	// Iterate is the streamed predicate read: entities matching filter, one
	// at a time, in bounded memory. See IterateOptions and Iterator.
	//
	// Semantics:
	//   - Plugins push pushable parts of the filter into storage (SQL WHERE,
	//     CQL index lookup); residual is applied inside Next() before
	//     yielding.
	//   - A zero-value Filter means "yield all entities for the model"
	//     (subject to opts).
	//   - IterateOptions.OrderBy: empty means order is unspecified. A backend
	//     whose async search is engine-executed MUST honour a non-empty
	//     OrderBy. A backend whose async search is self-executing MAY reject a
	//     non-empty OrderBy with a plain error — there is no refusal sentinel,
	//     callers see whatever error the plugin returns. A non-empty OrderBy
	//     with an ambient transaction is unsupported; Iterate MUST return an
	//     error rather than silently ignoring the order.
	//   - Overlay semantics: with an ambient transaction, the merged
	//     (committed ∪ transaction write-set) view is snapshotted at Iterate()
	//     call time. Mutating the transaction while its iterator is open is
	//     forbidden — the visibility of entities such a mutation would add,
	//     remove, or change is unspecified for that already-open iterator.
	//   - Implementations MUST NOT hold a global write-blocking lock for the
	//     lifetime of the iterator (e.g. by holding only short-lived row
	//     locks, or by paging through a cursor).
	//   - The iterator MUST observe ctx cancellation: the underlying driver
	//     surfaces an error; the iterator reports it via Err() and Next()
	//     returns false.
	//   - No retry on transient driver errors — the plugin surfaces the first
	//     error and ends iteration.
	//   - Err() returns that error stickily; subsequent Next() calls return
	//     false.
	//   - Close() is idempotent.
	//
	// ModelRef is a first-class argument because iteration is always scoped
	// to exactly one model; IterateOptions carries only knobs that vary
	// across calls against the same model.
	//
	// Every engine path that reads more than one entity — direct search on
	// a store, async search, delete-all, conditional delete, grouped stats —
	// consumes Search or Iterate. There is no whole-model read on this
	// interface and no in-process fallback in the engine.
	Iterate(ctx context.Context, model ModelRef, filter Filter, opts IterateOptions) (Iterator, error)
}

type EntityVersion

type EntityVersion struct {
	Entity     *Entity
	ChangeType string
	User       string
	Timestamp  time.Time
	Version    int64
	Deleted    bool
	// AttributedKind is the kind of the attributed User above; empty on
	// legacy rows. Populated independently of Entity — GetVersionByTransaction,
	// EntityVersion's sole producer, contractually never returns a DELETED
	// tombstone (see its doc comment), so Entity is populated on every
	// EntityVersion in circulation today; AttributedKind is nonetheless
	// stamped as an independent field rather than derived from Entity, for
	// symmetry with EntityVersionMeta.
	AttributedKind PrincipalKind
	// Executor is the actual caller that performed the change, independent
	// of attribution. Populated independently of Entity for the same reason
	// as AttributedKind.
	Executor Principal
}

EntityVersion represents a single version entry in an entity's change history.

type EntityVersionMeta added in v0.8.4

type EntityVersionMeta struct {
	Version    int64
	ChangeType string
	Timestamp  time.Time
	// User is the attributed user ID for this version's change.
	User string
	// AttributedKind is the kind of User above; empty on legacy rows.
	AttributedKind PrincipalKind
	// Executor is the actual caller that performed the change, independent
	// of attribution.
	Executor Principal
	// TransactionID may be empty for non-transactional writes.
	TransactionID string
	// Deleted is canonical: derived from ChangeType == "DELETED", not a
	// separately-stamped flag. True only on the tombstone row.
	Deleted bool
}

EntityVersionMeta is one version's metadata — the audit-trail sibling of EntityVersion, without the entity payload. Returned by EntityStore.GetVersionMetadata, newest first, ties broken by Version DESC.

type ExecutionResult

type ExecutionResult struct {
	State      string
	Success    bool
	StopReason string
	Error      error
}

ExecutionResult holds the outcome of a workflow engine execution.

type Expansion added in v0.8.3

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

Expansion is the once-per-query parse+bucket result of a single leaf. It is opaque to callers — build it with ExpandLeaf and pass it to EvalLeaf.

A kindCompare expansion can have every numeric/temporal/other bucket empty (>=1 declared type accepted the operand, but every sub-condition it produced was then dropped — e.g. EQUALS against an imprecise value). That is not a distinct "void" case: at eval time the stored value's own type family simply has no candidate sub-condition, exactly like a declared type that never accepted the operand in the first place. EvalLeaf answers such an unsatisfiable comparison by operator polarity (isNegativeOp) — false for a positive operator, true for a negative one — not with an unconditional non-match; see evalCompare's hadCandidate contract.

func ExpandLeaf added in v0.8.3

func ExpandLeaf(op FilterOp, operand string, values []string, declared []DataType) (Expansion, error)

ExpandLeaf parses operand (or, for range ops, the two bounds in values) against the field's declared type set and returns the typed Expansion.

Input contract:

  • Unary ops (IS_NULL / NOT_NULL): operand and values are ignored.
  • Binary ops (the six comparables, all string ops): operand is the single value; values is ignored.
  • Range ops (BETWEEN / BETWEEN_INCLUSIVE): values must hold exactly the two bounds; operand is ignored.

Errors (the caller maps to INVALID_CONDITION / CONDITION_TYPE_MISMATCH):

  • a range op whose values is not exactly length 2 → arity error;
  • a compare op whose operand parses into NO declared type → type-mismatch.

Note on shape errors the string inputs cannot express — a JSON-null operand on a binary/range op, or an object/array operand where a scalar is required — are detected by the caller, which holds the raw JSON value, before it reaches this string-typed boundary (entity-search.md §8).

Kernel contract on declared: the caller is expected to pass AT MOST ONE numeric DataType in declared — cyoda-go's TypeSet collapses the numeric family down to its single narrowest bucket via CollapseNumeric before it ever reaches this function, and every production caller goes through that collapse. A declared set carrying more than one numeric type is outside what this kernel guarantees: a stored value can then satisfy several of those buckets rather than the single narrowest one an uncollapsed caller might expect, because admission (AdmitsNumeric) judges each declared type independently rather than picking one canonical bucket for the value.

type FactoryConfig

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

FactoryConfig is the read-only view plugins see after resolution.

func ApplyFactoryOptions

func ApplyFactoryOptions(opts []FactoryOption) FactoryConfig

ApplyFactoryOptions resolves the variadic options into a read-only FactoryConfig. Plugins call this inside NewFactory.

func (FactoryConfig) ClusterBroadcaster

func (c FactoryConfig) ClusterBroadcaster() ClusterBroadcaster

ClusterBroadcaster returns the cluster broadcaster supplied via WithClusterBroadcaster, or nil if none was supplied.

type FactoryOption

type FactoryOption func(*factoryConfig)

FactoryOption configures a storage factory during NewFactory. Plugins receive options via the variadic parameter and resolve them with ApplyFactoryOptions.

func WithClusterBroadcaster

func WithClusterBroadcaster(b ClusterBroadcaster) FactoryOption

WithClusterBroadcaster injects the cluster broadcaster for plugins that use ClusterBroadcaster for cluster-wide notifications.

type FieldDescriptor added in v0.8.4

type FieldDescriptor struct {
	// Path is the leaf's key in the flattened field view, in the model
	// tree's JSONPath convention: a "$." prefix, and array hops rendered as
	// "[*]" (e.g. "$.name", "$.items[*].price"). The convention is
	// load-bearing, not cosmetic — a lookup that misses yields empty Types,
	// which the kernel treats as described on [ConditionToFilter].
	Path string

	// Types is the declared type set for the leaf — what [Filter.Declared]
	// carries and what the kernel dispatches on. Empty is meaningful and
	// dangerous: see [ConditionToFilter].
	Types []DataType

	// IsArray marks a leaf reached directly as an array's element type.
	IsArray bool
}

FieldDescriptor is a flat representation of a single leaf field in a model's schema tree — one entry of the flattened "fields map" keyed by JSONPath.

It lives here, rather than in the engine's model package, for the same reason DataType does (see datatype.go): the search leaf-comparison kernel and ConditionToFilter both need it, and a storage plugin that self-executes a search must be able to build one.

The engine currently declares its own structurally identical schema.FieldDescriptor. The intent is for that to become a type alias for this one so there is a single definition, but until the engine change lands the two are independent and must be kept in sync.

type FieldSource added in v0.4.0

type FieldSource string

FieldSource indicates whether a filter path refers to entity data or metadata.

const (
	SourceData FieldSource = "data"
	SourceMeta FieldSource = "meta"
)

type Filter added in v0.4.0

type Filter struct {
	Op FilterOp

	// Path addresses the leaf field this predicate applies to. It is BARE:
	// there is no "$." prefix and no JSONPath syntax. [ConditionToFilter]
	// strips the "$." at the wire boundary (see stripDollarDot, where the
	// leader is mandatory on the way in) and [lifecycleToFilter] emits
	// canonical meta names directly, so by the time a Filter reaches a storage
	// plugin the prefix is already gone. A "$."-prefixed path is therefore
	// malformed, not a tolerated alias.
	//
	// The two forms are opposites and must not be conflated: the wire jsonPath
	// REQUIRES the leader, this plugin-facing Path FORBIDS it.
	//
	// # Grammar
	//
	//	path      = segment ( "." segment )*
	//	segment   = name subscript*
	//	name      = 1*( ALPHA / DIGIT / "_" / "-" )   ; ASCII only
	//	subscript = "[" ( "*" / 1*DIGIT ) "]"
	//
	// This is the wire jsonPath grammar with the "$." leader removed. A
	// bracket is an array subscript; a dotted numeric segment is a field
	// whose name is that digit string. The two address different values and
	// a backend MUST NOT collapse them — see docs/cloud-parity/path-grammar.md.
	//
	// The grammar is deliberately narrower than any backend's native JSON
	// path syntax. It is the intersection every backend can serve, and on
	// SQL backends it is also the injection guard: every character that could
	// terminate a quoted JSON-path literal is outside it. A backend needing a
	// wider form must widen this grammar, not bypass its own validator.
	//
	// An EMPTY Path is legal ONLY for a tree operator (FilterAnd, FilterOr):
	// those carry no leaf condition of their own, Children hold the real
	// leaves. It is NOT a way for a LEAF to say "addresses no field" — every
	// leaf addresses exactly one field, on either FieldSource, and an empty
	// Path on one is rejected: spi.Prepare fails it with ErrUnevaluableLeaf
	// rather than treating it as a match against the whole document or as an
	// unconditional non-match. See prepared_filter.go.
	//
	// Parse it with ParseFilterPath and validate it with ValidateFilterPath.
	// A second, independent spelling of the grammar is how a backend admits a
	// form no resolver serves. This grammar and these two parse helpers are
	// for Source=SourceData; a Source=SourceMeta Path is not a data path at
	// all — it names one of a closed set of canonical meta field names
	// directly (a superset of [MetaFieldNames] that also carries storage-key
	// aliases such as "entity_id"), and spi.Prepare rejects a Path that is
	// empty or outside that set the same way (ErrUnevaluableLeaf).
	//
	// # Rejection is mandatory
	//
	// Both FieldSource values are held to the same grammar, and the check is
	// on the whole tree — a malformed path nested under an and/or branch is
	// still malformed.
	//
	// A backend MUST reject a malformed non-empty Path with an error. It MUST
	// NOT answer with an empty result set: a path the caller mistyped and a
	// predicate that genuinely matched nothing are different answers, and a
	// backend that conflates them makes a client error indistinguishable from
	// a legitimate empty page on that backend alone. Backends name this
	// sentinel ErrInvalidFilterPath. The in-process kernel (spi.Prepare)
	// enforces the same rejection — of an empty or malformed SourceData path,
	// and of an empty or non-vocabulary SourceMeta path — via
	// ErrUnevaluableLeaf, ahead of any backend-specific validation.
	Path string

	Source   FieldSource
	Value    any
	Values   []any
	Children []Filter
	Coercion FilterCoercion // temporal comparison routing (zero = CoerceNone)
	// Declared holds the leaf field's declared model DataTypes, stamped by the
	// domain layer from the model schema; the kernel uses them for
	// type-directed comparison. Empty for as-yet-unstamped or non-typed leaves.
	Declared []DataType
}

Filter is a generic predicate tree for search pushdown. Leaf nodes carry Op, Path, Source, and Value/Values. Branch nodes (FilterAnd, FilterOr) carry Children of any length (zero is the identity: empty AND matches everything, empty OR matches nothing). FilterNot is also a branch node but is arity-exactly-one: Children of length 0, length >= 2, or whose single element has a zero Op, all fail Prepare rather than being guessed at — there is no well-defined way to invert an empty or many-child set, and Filter is a public struct any backend can build, so Prepare cannot trust one to already be well-formed.

func ConditionToFilter added in v0.8.4

func ConditionToFilter(cond predicate.Condition, fields map[string]FieldDescriptor) (Filter, error)

ConditionToFilter translates a predicate.Condition into a Filter. It is the anti-corruption layer between the domain's predicate syntax and the stable filter contract storage plugins use for pushdown, and it is the only supported way to produce a Filter that the leaf-comparison kernel (Prepare / EvalLeaf) will evaluate correctly.

It lives in the SPI, not in the engine, because a backend that self-executes a search — one that receives a serialized condition rather than a ready-made Filter, e.g. an async search job it runs itself — has no other way to reach the kernel. Without it such a backend must ship a second evaluator, which then drifts from this one and answers the same query differently.

jsonPath must be JSON Path nomenclature

A condition's jsonPath is the WIRE form and is JSON Path syntax, so the "$." leader is REQUIRED:

jsonPath  = "$." segment ( "." segment )*
segment   = name subscript*
name      = 1*( ALPHA / DIGIT / "_" / "-" )   ; ASCII only
subscript = "[" ( "*" / 1*DIGIT ) "]"

"$.amount" and "$.address.city" are paths. A bare "amount" is not one and is REJECTED with an error wrapping ErrInvalidFilterPath — it is not a tolerated alias. So are an empty path, an empty or trailing segment ("$..a", "$.a."), bracket-quoted property access ("$['x']", "$.['x']", `$.a["b"]`), a bracket spelling outside the two supported subscript forms ("$.a[", "$.a]", "$.a[-1]", "$.a[0:2]", "$.a[0,1]", "$.a[?(@.x)]"), and any character outside the segment set — including one that FOLLOWS a well-formed subscript ("$.a[0];DROP"). Callers should surface all of these as a client error (400), not as a reason to fall back.

Distinguish this from the PLUGIN-FACING form: Filter.Path is what this function emits, and it is BARE ("amount"), with the leader already stripped. A "$."-prefixed Filter.Path is malformed.

Metadata is not addressed through jsonPath at all: a predicate.LifecycleCondition names a member of the closed meta vocabulary (MetaFieldNames) directly and is not subject to this grammar. A data path that happens to spell "$._meta.state" is an ordinary dotted path and is accepted as one.

A WELL-FORMED array-subscripted path ("$.tags[*].name", "$.arr[0]", "$.matrix[*][*]") TRANSLATES: the kernel resolves a subscripted path directly (see ResolvePath), so it is pushdownable like any other path, not a reason to fall back. A malformed one ("$.a[", "$.a[-1]", "$.a[0:2]", "$.a[0];DROP") is invalid input, per the list above, and is rejected the same way. The one remaining "valid but not expressible as a pushdown filter" case is a predicate.FunctionCondition: it fails with a plain error that does NOT wrap ErrInvalidFilterPath, which is the signal to fall back to in-memory evaluation rather than to reject the request.

fields, and why nil is not a safe default

fields is the model's flattened field view (JSONPath → FieldDescriptor), normally obtained via FieldsMapFromSchema over ModelDescriptor.Schema. It supplies each data leaf's declared types, which the kernel dispatches on.

A nil or incomplete fields map does not error, and the result is worse than either "correct" or "empty": it is INTERNALLY INCONSISTENT. An empty declared set does not degrade every leaf the same way, because the kernel only consults declared types for the leaves that need a type slot to compare in:

  • The EIGHT COMPARISON AND ORDERING leaves ANNIHILATE to false: EQUALS, NOT_EQUAL, GREATER_THAN, GREATER_OR_EQUAL, LESS_THAN, LESS_OR_EQUAL, BETWEEN, BETWEEN_INCLUSIVE. ExpandLeaf engages no type bucket, errors, and evalLeafFilter swallows that into a non-match.
  • The OTHER EIGHTEEN evaluate NORMALLY, because they never needed a declared type. Presence: IS_NULL, NOT_NULL — decided purely from whether the stored value is present and non-null (see ExpandLeaf's kindUnary arm, which returns before declared is read), so they are NOT comparisons despite the null operand. String and pattern: CONTAINS, NOT_CONTAINS, STARTS_WITH, NOT_STARTS_WITH, ENDS_WITH, NOT_ENDS_WITH, LIKE, MATCHES_PATTERN, and the case-insensitive family IEQUALS, INOT_EQUAL, ICONTAINS, INOT_CONTAINS, ISTARTS_WITH, INOT_STARTS_WITH, IENDS_WITH, INOT_ENDS_WITH — all handled by ExpandLeaf's kindStringOp arm, which compares stringified forms and never reads declared.

The negated and case-insensitive string operators are easy to overlook here: ICONTAINS resembles a comparison but is not one, and it keeps evaluating against a nil declared set exactly as CONTAINS does.

So a condition mixing the two kinds yields wrong answers in a structure-dependent direction, not merely fewer: under AND a dropped comparison conjunct removes rows that should have matched, while under OR a surviving string disjunct admits rows the failed comparison was supposed to exclude. Both are silent.

This is strictly more dangerous than uniformly returning nothing, which at least looks like an anomaly. Callers that cannot supply declared types should treat that as an error and refuse the query, rather than proceeding with a filter that is part-evaluated and part-annihilated.

Meta leaves are unaffected: their types come from the static meta vocabulary, not from fields, so a nil map does not degrade them at all.

An unrecognised operator is an error, not a fallback

A leaf whose OperatorType is outside the closed set OperatorNames reports fails with ErrUnknownOperator. Callers should map that to a client error (400 INVALID_CONDITION); it means the input was invalid, unlike the other failures here, which mean a well-formed predicate is not expressible as a pushdown filter.

This is worth stating because the obvious alternative is actively harmful. Mapping an unrecognised name onto a real operator does not make it unevaluable — the kernel evaluates whatever it is given — so it silently answers a DIFFERENT question. Routing to a pattern match is the worst choice available: "NOT_EQUALS", the obvious misspelling of NOT_EQUAL, becomes an anchored regex that behaves as EQUALS and returns exactly the rows the caller meant to exclude.

Three obligations that remain the caller's

ConditionToFilter validates operator names and path shape. It does not validate operands, and each of these fails SILENTLY — an under- or wrongly-populated result set, never an error:

  • OBJECT OPERANDS. A leaf value that is an object denotes no scalar any operator could compare against. Left unchecked it reaches the kernel, which stringifies it via fmt.Sprint and compares the literal text "map[a:1]". Reject a map-typed operand outright.
  • BETWEEN ARITY. BETWEEN / BETWEEN_INCLUSIVE require exactly a two-element [lo, hi] operand. Anything else leaves Filter.Values nil, ExpandLeaf errors, and the leaf silently no-matches. Check the arity before translating rather than diagnosing an empty result set afterwards.
  • PATTERN COMPILABILITY. An uncompilable MATCHES_PATTERN or LIKE operand (e.g. LIKE with a trailing unpaired escape) leaves the compiled program nil and the leaf silently returns false. Note the kernel compiles the ANCHORED form of MATCHES_PATTERN while a naive caller-side check would compile the raw operand, so the two accept sets are not identical — use ValidateLeafPattern (per leaf) or ValidateConditionPatterns (whole condition) rather than hand-rolling the check; they route through the same derivation the kernel does.

type FilterCoercion added in v0.8.3

type FilterCoercion int

FilterCoercion selects the comparison semantics for a leaf, mirroring OrderSpec.Kind for sort. CoerceNone (zero value) preserves the existing numeric/text/bool evaluation; CoerceTemporal compares as floored epoch-ms instants. The domain layer stamps this from the model schema / meta type; backends consume it without inspecting the value. Polymorphic-temporal body typing reuses this marker unchanged — it adds no new coercion value.

const (
	CoerceNone FilterCoercion = iota
	CoerceTemporal
)

type FilterOp added in v0.4.0

type FilterOp string

FilterOp defines a filter operation for search predicate pushdown.

const (
	FilterAnd FilterOp = "and"
	FilterOr  FilterOp = "or"

	// FilterNot negates its single child (see Filter.Children). It is NOT
	// De Morgan sugar for "invert the operator and distribute": over a
	// wildcard path it is a universal quantifier ("no element satisfies the
	// child"), a different question from applying the child's negative
	// counterpart element-wise ("some element differs" — see
	// docs/cloud-parity/path-grammar.md section 5 and prepared_filter.go's
	// match doc). NOT of a leaf that is false for every reason — including a
	// vacuous one: an empty array, an explicit null, or an absent field —
	// is true.
	FilterNot FilterOp = "not"

	FilterEq  FilterOp = "eq"
	FilterNe  FilterOp = "ne"
	FilterGt  FilterOp = "gt"
	FilterLt  FilterOp = "lt"
	FilterGte FilterOp = "gte"
	FilterLte FilterOp = "lte"

	FilterContains   FilterOp = "contains"
	FilterStartsWith FilterOp = "starts_with"
	FilterEndsWith   FilterOp = "ends_with"

	// FilterLike is a glob, not a regex. The grammar, which is PostgreSQL's
	// and SQLite's `LIKE ... ESCAPE '\'`:
	//
	//   - '%'  any sequence of characters, including empty, INCLUDING newlines
	//   - '_'  exactly one character (one rune), INCLUDING a newline
	//   - '\X' the literal character X, for ANY X — so \%, \_ and \\ are literal
	//     '%', '_' and '\', and \d is a literal 'd'
	//   - anything else, itself
	//
	// The match is whole-string and case-sensitive. A trailing unpaired '\' is
	// the only malformed pattern; every other operand matches something.
	//
	// This is deliberately NOT Cloud's Like.prepareSpecialCharacters, which
	// translates to a regex and leaks RE2 escapes. cyoda-go leads this
	// contract.
	FilterLike FilterOp = "like"

	FilterIsNull  FilterOp = "is_null"
	FilterNotNull FilterOp = "not_null"

	FilterBetween          FilterOp = "between"
	FilterBetweenInclusive FilterOp = "between_inclusive"
	FilterMatchesRegex     FilterOp = "matches_regex"

	FilterIEq            FilterOp = "ieq"
	FilterINe            FilterOp = "ine"
	FilterIContains      FilterOp = "icontains"
	FilterINotContains   FilterOp = "inot_contains"
	FilterNotContains    FilterOp = "not_contains"
	FilterIStartsWith    FilterOp = "istarts_with"
	FilterINotStartsWith FilterOp = "inot_starts_with"
	FilterNotStartsWith  FilterOp = "not_starts_with"
	FilterIEndsWith      FilterOp = "iends_with"
	FilterINotEndsWith   FilterOp = "inot_ends_with"
	FilterNotEndsWith    FilterOp = "not_ends_with"
)

func LookupOperator added in v0.8.4

func LookupOperator(op string) (FilterOp, bool)

LookupOperator translates a domain operator string to a FilterOp, reporting whether the name was recognised.

Use it to validate an operator name ahead of translation when you want to reject the whole request with your own diagnostic. It is not a safety requirement: ConditionToFilter rejects an unrecognised operator on its own.

func MapOperator added in v0.8.4

func MapOperator(op string) FilterOp

MapOperator translates a domain operator string to a FilterOp, returning the zero FilterOp for a name outside the closed set OperatorNames reports.

It is exported because a caller may need to map operators independently of translation — the engine's condition type-soundness validator does — and should not keep a second copy of this table. Use LookupOperator where the recognised/unrecognised distinction matters.

The zero FilterOp is not a valid leaf operator: ExpandLeaf rejects it and ConditionToFilter refuses to build a filter around it. An unrecognised name therefore cannot become an evaluable predicate by accident.

type GroupExpr added in v0.8.1

type GroupExpr struct {
	Kind GroupExprKind
	// Path is the JSONPath; only meaningful when Kind == GroupExprDataPath.
	Path string
}

GroupExpr is one dimension of the group-by.

type GroupExprKind added in v0.8.1

type GroupExprKind int

GroupExprKind selects between the lifecycle state and a scalar data path.

const (
	// GroupExprState groups by the entity's lifecycle state.
	GroupExprState GroupExprKind = iota
	// GroupExprDataPath groups by a scalar JSONPath into entity data.
	GroupExprDataPath
)

type GroupKeyEntry added in v0.8.1

type GroupKeyEntry struct {
	Path string
	// Value is the JSON-typed value: string for scalar/state values, nil
	// for missing/literal-null/non-scalar extracted values. Carried as any
	// rather than *string to leave room for non-string scalar key types in
	// future GroupExpr variants without breaking the SPI surface.
	Value any
}

GroupKeyEntry is one (path, value) pair in a bucket's key.

type GroupedAggregateBucket added in v0.8.1

type GroupedAggregateBucket struct {
	// GroupKey is ordered, matching the request groupBy order.
	GroupKey []GroupKeyEntry
	Count    int64
	// Aggregations maps alias to float64 or nil. nil means the bucket had
	// zero numeric samples for that field. Carried as any rather than
	// *float64 to leave room for future non-numeric aggregations (mode,
	// percentile_bucket, etc.) without breaking the SPI surface.
	Aggregations map[string]any
}

GroupedAggregateBucket is one row of the grouped-stats result.

type GroupedAggregationsOptions added in v0.8.1

type GroupedAggregationsOptions struct {
	PointInTime *time.Time
	// MaxBuckets is the result cardinality ceiling. MUST be > 0; the SPI
	// does not define semantics for zero or negative values, and the
	// caller (typically the service layer) is responsible for applying a
	// default when the user omits the value. Implementations MUST return
	// ErrGroupCardinalityExceeded if the result would exceed this count.
	MaxBuckets   int
	Aggregations []AggregateExpr
}

GroupedAggregationsOptions parameterizes the GroupedAggregate call.

type GroupedAggregator added in v0.8.1

type GroupedAggregator interface {
	GroupedAggregate(
		ctx context.Context,
		model ModelRef,
		groupBy []GroupExpr,
		filter Filter,
		opts GroupedAggregationsOptions,
	) ([]GroupedAggregateBucket, error)
}

GroupedAggregator is an optional capability on a storage backend that answers a grouped-stats query natively (e.g. via SQL GROUP BY).

May decline a specific request shape via ErrAggregationNotPushdownable; the caller (typically the service layer) should then fall through to the streaming-tally path via EntityStore.Iterate.

type IterateOptions added in v0.8.1

type IterateOptions struct {
	// PointInTime, when non-nil, requests a historical snapshot at the
	// given instant. Semantics match the rest of the SPI (read-committed
	// snapshot): the read is COMMITTED-ONLY and ignores any ambient
	// transaction, so it never yields that transaction's own uncommitted
	// writes — see EntityStore.GetAsAt for the full statement, and note that
	// bounding the query on a timestamp is not sufficient to achieve it.
	PointInTime *time.Time

	// OrderBy specifies the sort keys applied to yielded entities. Empty
	// means order is unspecified — this differs from EntityStore.Search,
	// where an empty OrderBy still yields the engine's canonical entity-ID
	// order. See EntityStore.Iterate for which backends must honour a
	// non-empty OrderBy, and why a non-empty OrderBy with an ambient
	// transaction is an error.
	OrderBy []OrderSpec

	// TrackingRead, when true and a transaction is active, records the
	// entities this iteration yields into the transaction's read-set, so
	// commit-time first-committer-wins validates them. Default false: a
	// plain snapshot read that records nothing. No-op when no transaction
	// is active. Same rule as SearchOptions.TrackingRead.
	//
	// Yielded, not scanned: a row the filter excludes is never handed to the
	// caller and MUST NOT be recorded, whichever layer excluded it — a
	// storage predicate or the residual re-check inside Next(). See
	// SearchOptions.TrackingRead, which states the same rule for the matched
	// set it returns.
	TrackingRead bool
}

IterateOptions narrows, orders, and scopes the iteration window.

type Iterator added in v0.8.1

type Iterator interface {
	// Next advances the iterator. Returns false on end or sticky error.
	Next() bool
	// Entity returns the current row. Valid only after Next() == true.
	Entity() *Entity
	// Err returns the first error encountered. Sticky.
	Err() error
	// Close releases server resources. Idempotent.
	Close() error
}

Iterator yields entities one at a time. Standard Go iterator shape modeled after database/sql.Rows.

type KeyValueStore

type KeyValueStore interface {
	Put(ctx context.Context, namespace string, key string, value []byte) error
	Get(ctx context.Context, namespace string, key string) ([]byte, error)
	Delete(ctx context.Context, namespace string, key string) error
	List(ctx context.Context, namespace string) (map[string][]byte, error)
}

type MessageHeader

type MessageHeader struct {
	Subject         string
	ContentType     string
	ContentLength   int64
	ContentEncoding string
	MessageID       string // custom message ID from X-Message-ID header
	UserID          string
	Recipient       string
	ReplyTo         string
	CorrelationID   string
}

MessageHeader holds the fixed AMQP-aligned headers for an edge message.

type MessageMetaData

type MessageMetaData struct {
	Values        map[string]any
	IndexedValues map[string]any
}

MessageMetaData holds arbitrary key-value metadata for an edge message. Values preserve their original JSON types (string, number, bool, etc.).

type MessageStore

type MessageStore interface {
	Save(ctx context.Context, id string, header MessageHeader, metaData MessageMetaData, payload io.Reader) error
	Get(ctx context.Context, id string) (MessageHeader, MessageMetaData, io.ReadCloser, error)
	Delete(ctx context.Context, id string) error
	DeleteBatch(ctx context.Context, ids []string) error
}

type MetaField added in v0.8.4

type MetaField struct {
	Source FieldSource
	Path   string
	Kind   OrderKind
}

MetaField describes one entry of the closed meta-field vocabulary — the canonical client-facing names that address entity metadata rather than document data.

func ResolveMetaField added in v0.8.4

func ResolveMetaField(name string) (MetaField, bool)

ResolveMetaField looks up name in the meta vocabulary. The map-key lookup is what enforces "no nested meta paths": a dotted name (e.g. "a.b") is simply not a key and returns ok=false.

type ModelDescriptor

type ModelDescriptor struct {
	Ref         ModelRef
	State       ModelState
	ChangeLevel ChangeLevel
	UpdateDate  time.Time
	Schema      []byte
	// UniqueKeys are the model's composite unique-key definitions. Additive;
	// persisted inside the descriptor by each model store. Empty = none.
	UniqueKeys []UniqueKey
}

ModelDescriptor holds the full metadata and schema for an entity model.

type ModelNode added in v0.8.4

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

ModelNode is a node in a model's schema tree — the decoded form of ModelDescriptor.Schema.

A node holds the SET of kinds the path has been observed as, and whether it has been observed as null. A field observed in more than one kind carries more than one branch, and every branch it carries is a kind the field declares: a single label could only name one of three independent observations, so every reader that dispatched on one lost the others.

Nodes are not safe for concurrent mutation. Build (or decode) a tree fully, then treat it as read-only; the flattening in ModelNode.Fields is cached and safe to call concurrently once the tree has stopped changing.

func NewArrayNode added in v0.8.4

func NewArrayNode(element *ModelNode) *ModelNode

NewArrayNode returns a node carrying an array branch whose elements are described by element. A nil element is legal — see ArrayBranch.Element.

func NewEmptyNode added in v0.8.4

func NewEmptyNode() *ModelNode

NewEmptyNode returns a node that declares nothing: no branch, and not nullable. Every value is a change against it, which is what makes it the model a fresh derivation walks against — deriving a field's description is the same traversal as admitting a value, run against a model that admits nothing.

This is NOT NewLeafNode(Null), which records that a path HAS been observed holding null and therefore already admits it.

func NewLeafNode added in v0.8.4

func NewLeafNode(dt DataType) *ModelNode

NewLeafNode returns a node carrying a scalar branch seeded with dt.

NewLeafNode(Null) is the exception, and the reason nullability is a flag: a path observed only as null has been observed as no kind at all, so the node carries no branch and is merely nullable.

func NewObjectNode added in v0.8.4

func NewObjectNode() *ModelNode

NewObjectNode returns a node carrying an empty object branch.

func UnmarshalModelNode added in v0.8.4

func UnmarshalModelNode(data []byte) (*ModelNode, error)

UnmarshalModelNode decodes the persisted schema bytes of a model (ModelDescriptor.Schema) into a ModelNode tree.

Empty input is NOT special-cased here: it is a JSON syntax error like any other malformed input. Callers that treat "no schema bound" as "nothing to declare" must make that decision explicitly; FieldsMapFromSchema is the entry point that does.

func (*ModelNode) AddScalarTypes added in v0.8.4

func (n *ModelNode) AddScalarTypes(dts ...DataType)

AddScalarTypes records primitive observations at this path, establishing the scalar branch if it does not exist. NULL among them is the nullable marker and is routed to ModelNode.SetNullable; a concrete type clears the marker, so the two orders agree.

func (*ModelNode) Array added in v0.8.4

func (n *ModelNode) Array() *ArrayBranch

Array returns the node's array branch, or nil when the path was never observed holding an array.

func (*ModelNode) Branch added in v0.8.4

func (n *ModelNode) Branch(k NodeKind) Branch

Branch returns the branch for k, or nil when the node does not carry it.

func (*ModelNode) DeclareKind added in v0.8.4

func (n *ModelNode) DeclareKind(k NodeKind)

DeclareKind records that the path was observed as kind k, establishing an empty branch when the node does not already carry one, and leaving an existing branch untouched.

A branch can be present and empty — an object with no children, an array whose element was never observed, a scalar branch with no type yet. Nothing in the payload distinguishes such a node from one never observed as that kind at all; only the kind record does, which is why the set is stored rather than inferred from whichever payload keys happen to be populated.

func (*ModelNode) DeclaredTypes added in v0.8.4

func (n *ModelNode) DeclaredTypes() []DataType

DeclaredTypes returns the node's DataTypes in the spelling the field walk, the exporters and the persisted form use: the scalar branch's types, or the lone NULL marker when the node is nullable and carries no scalar branch, or nil. The slice is a copy.

func (*ModelNode) Fields added in v0.8.4

func (n *ModelNode) Fields() []FieldDescriptor

Fields returns the flat list of leaf descriptors for this tree, sorted by path and cached after the first call.

Flattening rules — these are the contract, and every executor must agree on them, because a path spelled differently here simply misses at lookup time and silently narrows results:

  • Paths are JSONPath-like and rooted at "$": "$.name", "$.address.city".
  • An array hop renders as "[*]" on the array's own path segment, never as an index: "$.tags[*]", "$.items[*].price".
  • IsArray is set only for a leaf reached directly as an array's element type (an array branch whose element carries only a scalar branch). It is deliberately narrower than "anything under an array": neither "$.items[*].price" nor the self-descriptor of an object-or-scalar array element carries it. This matches the engine's flattening exactly — do not "generalise" it, because consumers key off the narrow meaning.
  • A node that carries a scalar branch ALONGSIDE a container branch emits a descriptor for its OWN path IN ADDITION to the container's contents. This is the object-or-scalar shape, and dropping the self-descriptor turns every scalar comparison against such a path into a non-match. The scalar branch must declare a type for that: an empty one beside a container emits nothing, for the same reason an unobserved array element does — a descriptor with no types matches nothing while looking like a declared field. A node declaring ONLY an empty scalar branch still emits it, which is what a bare {"kind":"LEAF"} has always meant.
  • A node observed only as null declares NULL at its own path. A container that is merely nullable emits no self-descriptor: null is the marker, not a scalar observation, so the path stays a pure container.

The returned slice ALIASES the cache and is shared by every caller holding this node, process-wide. Do not sort, append to, or otherwise mutate it; copy first if you need to.

func (*ModelNode) FieldsMap added in v0.8.4

func (n *ModelNode) FieldsMap() map[string]FieldDescriptor

FieldsMap returns the same flattening as ModelNode.Fields, keyed by path.

The returned map ALIASES the cache and is shared process-wide; treat it as read-only. Note that a FieldDescriptor's Types slice is likewise shared.

func (*ModelNode) IsPolymorphic added in v0.8.4

func (n *ModelNode) IsPolymorphic() bool

IsPolymorphic reports whether the path was observed as more than one kind. A monomorphic field is a set of one; the answer is derived, never stored.

func (*ModelNode) Kinds added in v0.8.4

func (n *ModelNode) Kinds() []NodeKind

Kinds returns the kinds this node declares, in ascending NodeKind order. A node observed only as null declares none.

func (*ModelNode) Nullable added in v0.8.4

func (n *ModelNode) Nullable() bool

Nullable reports whether the path has been observed holding null.

It is recorded only while the node carries no scalar branch: a scalar declaration admits null anyway, which is the same collapse TypeSet.Add applies when it drops NULL in the presence of a concrete type.

func (*ModelNode) Object added in v0.8.4

func (n *ModelNode) Object() *ObjectBranch

Object returns the node's object branch, or nil when the path was never observed holding an object.

func (*ModelNode) Scalar added in v0.8.4

func (n *ModelNode) Scalar() *ScalarBranch

Scalar returns the node's scalar branch, or nil when the path was never observed holding a primitive value.

func (*ModelNode) SetChild added in v0.8.4

func (n *ModelNode) SetChild(name string, child *ModelNode)

SetChild adds or replaces a named child, establishing the object branch if it does not exist, and drops the cached flattening so a later ModelNode.Fields reflects the new shape. Dropping the cache does not make concurrent build-and-read safe; it only makes build-then-read correct.

func (*ModelNode) SetElement added in v0.8.4

func (n *ModelNode) SetElement(element *ModelNode)

SetElement sets the descriptor shared by every array position, establishing the array branch if it does not exist.

func (*ModelNode) SetNullable added in v0.8.4

func (n *ModelNode) SetNullable()

SetNullable records that this path has been observed holding null. It is a no-op when the node carries a scalar branch, which already admits null.

type ModelRef

type ModelRef struct {
	EntityName   string
	ModelVersion string
}

func (ModelRef) String

func (r ModelRef) String() string

type ModelState

type ModelState string

ModelState represents the lifecycle state of an entity model.

const (
	ModelLocked   ModelState = "LOCKED"
	ModelUnlocked ModelState = "UNLOCKED"
)

type ModelStore

type ModelStore interface {
	Save(ctx context.Context, desc *ModelDescriptor) error
	Get(ctx context.Context, modelRef ModelRef) (*ModelDescriptor, error)
	GetAll(ctx context.Context) ([]ModelRef, error)
	Delete(ctx context.Context, modelRef ModelRef) error
	Lock(ctx context.Context, modelRef ModelRef) error
	Unlock(ctx context.Context, modelRef ModelRef) error
	IsLocked(ctx context.Context, modelRef ModelRef) (bool, error)
	SetChangeLevel(ctx context.Context, modelRef ModelRef, level ChangeLevel) error
	// ExtendSchema appends a schema delta for the model at ref. The
	// delta is an opaque, plugin-agnostic blob that the plugin stores
	// verbatim in its extension log; folding the log into the current
	// schema is done on read via a plugin-injected ApplyFunc.
	//
	// Contract:
	//   - Success (nil return) means the extension is durably committed
	//     and visible to subsequent reads on this node.
	//   - A non-nil error means no persisted effect — no log entry,
	//     no savepoint, no partial state.
	//   - Plugins with a native conflict surface (sqlite SQLITE_BUSY,
	//     cassandra LWT applied:false) retry transparently up to a
	//     configurable budget. On exhaustion without ctx cancellation,
	//     return ErrRetryExhausted.
	//   - Context cancellation between retry attempts returns ctx.Err()
	//     (wrapped with attempt count), not ErrRetryExhausted. Mid-attempt
	//     cancellation follows backend-native behavior.
	//   - Plugins without a conflict surface (memory, postgres) commit
	//     immediately or fail with the backend's native error.
	//
	// Empty or nil deltas are a no-op and return nil.
	ExtendSchema(ctx context.Context, ref ModelRef, delta SchemaDelta) error
}

type NodeKind added in v0.8.4

type NodeKind int

NodeKind names one branch a ModelNode can carry.

const (
	// KindLeaf is the scalar branch: primitive DataTypes observed at the path.
	KindLeaf NodeKind = iota
	// KindObject is the object branch: named children.
	KindObject
	// KindArray is the array branch: one element descriptor shared by every
	// position.
	KindArray
)

func (NodeKind) String added in v0.8.4

func (k NodeKind) String() string

String returns the canonical wire name of the NodeKind ("LEAF", "OBJECT", "ARRAY"). These names are the on-the-wire encoding written by the engine, so they are part of the persisted format and must not be re-spelled.

type NumericSubCondition added in v0.8.3

type NumericSubCondition struct {
	Type    DataType
	Value   Decimal
	Op      FilterOp
	NotNull bool
}

NumericSubCondition is one expanded numeric branch produced from a single numeric operand against a polymorphic (multi-type) field. It mirrors one Cloud ParsedCondition (PolymorphicNumberConversions.kt).

  • Value is the (possibly rounded / folded) operand for this type bucket. For int-family branches it is the folded integer as a scale-0 Decimal; for decimal-family branches it is the decimal operand, rounded only when the bucket requires it.
  • When NotNull is true the branch is a bare existence test (Op is FilterNotNull) and Value is meaningless — the operand fell outside the type's magnitude range in a direction where "is present" is the correct residual predicate.

func ExpandNumericOperand added in v0.8.3

func ExpandNumericOperand(value Decimal, declaredNumeric []DataType, op FilterOp) []NumericSubCondition

ExpandNumericOperand expands a single numeric operand into per-type sub-conditions for a polymorphic field, faithfully porting Cloud's PolymorphicNumberConversions.parseNumberConditionToPolyType (PolymorphicNumberConversions.kt:25-36).

declaredNumeric is the field's declared numeric type set. The result is the union of the decimal-family and integer-family expansions. An empty result means every numeric branch was dropped (the operand contributes nothing — "void"): e.g. a fractional value under EQUALS against an integer-only field.

Non-numeric entries in declaredNumeric are ignored (the caller is expected to pass numeric types; this guard keeps a stray entry from panicking).

type ObjectBranch added in v0.8.4

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

ObjectBranch records the named children a path was observed holding.

func (*ObjectBranch) Child added in v0.8.4

func (b *ObjectBranch) Child(name string) *ModelNode

Child returns the named child, or nil if there is none.

func (*ObjectBranch) Children added in v0.8.4

func (b *ObjectBranch) Children() map[string]*ModelNode

Children returns a shallow copy of the children map (the map is copied, the child nodes are not).

func (*ObjectBranch) Kind added in v0.8.4

func (b *ObjectBranch) Kind() NodeKind

Kind implements Branch.

func (*ObjectBranch) Len added in v0.8.4

func (b *ObjectBranch) Len() int

Len reports how many children the branch carries, without copying the map.

type OrderKind added in v0.8.2

type OrderKind int

OrderKind selects the canonical comparison applied to a sort key. For data paths and non-id meta fields, every backend (memory, sqlite, postgres, commercial) applies the same comparison for a given Kind (byte-order text, IEEE-754 numeric, bool false<true, chronological instant), so ordering on those fields matches across backends. It does NOT apply to entity-ID ordering: see OrderSpec for the Path="id" case, where Kind is ignored and the comparator is the engine's own canonical ID order instead. The zero value is OrderText (byte-order string comparison).

const (
	OrderText     OrderKind = iota // byte order: BINARY / COLLATE "C" / bytes.Compare
	OrderNumeric                   // IEEE-754 double
	OrderBool                      // false < true
	OrderTemporal                  // chronological instant (engine meta dates only)
)

func ClassifyType added in v0.8.4

func ClassifyType(types []DataType) (OrderKind, error)

ClassifyType returns the single canonical ordering class for a leaf's declared types, used by the FILTER path (ConditionToFilter) to route coercion.

Null members are ignored — a nullable field is still orderable. The remaining members must all map to the same class, otherwise there is no deterministic order and the field is unsortable, which is an error rather than an arbitrary choice.

Temporal data subtypes classify as OrderTemporal here so data-temporal comparisons route to the temporal-aware pushdown. A SORT path may want the opposite (ISO-8601 lexical order is already chronological and is byte-identical across backends); such callers use ClassifyTypesFold with a fold that maps OrderTemporal onto OrderText rather than changing this function.

func ClassifyTypesFold added in v0.8.4

func ClassifyTypesFold(types []DataType, fold func(OrderKind) OrderKind) (OrderKind, error)

ClassifyTypesFold is the shared unification core behind ClassifyType and any caller needing a different per-class fold (notably the engine's ORDER BY classification, which folds temporal onto text). Each non-null member is classified, then optionally folded; all folded classes must agree.

A nil fold classifies without folding, making ClassifyTypesFold(t, nil) identical to ClassifyType(t).

type OrderSpec added in v0.4.0

type OrderSpec struct {
	Path   string
	Source FieldSource
	Desc   bool
	Kind   OrderKind
}

OrderSpec is one sort key. Path is a scalar leaf: a dotted data path (Source=SourceData) or a canonical meta field name (Source=SourceMeta) — one of: state, creationDate, lastUpdateTime, transitionForLatestSave, transactionId, id. Kind fixes the cross-backend comparison for every path except one: for Source=SourceMeta, Path="id" the comparator is the engine's canonical entity-ID order — one total, stable, deterministic order per engine, documented per backend, and NOT required to be identical across backends — and Kind is ignored for that path. Absent/null values sort last. When OrderBy is empty the default order is the engine's canonical entity-ID order (engine-specific, documented per backend).

type PathHop added in v0.8.4

type PathHop struct {
	Name string
	Subs []PathSub
}

PathHop is one segment of a parsed filter path: a field name, optionally followed by one or more array subscripts when the field addresses a JSON array.

func ParseFilterPath added in v0.8.4

func ParseFilterPath(p string) ([]PathHop, error)

ParseFilterPath parses a plugin-facing filter path — the spelling a Filter.Path or OrderSpec.Path carries, with no "$." leader — into its hops.

Grammar:

path      = "" / hop ( "." hop )*
hop       = name subscript*
name      = 1*( ALPHA / DIGIT / "_" / "-" )        ; ASCII only
subscript = "[" ( "*" / 1*DIGIT ) "]"

An empty path parses to a nil hop slice and is valid: the tree operators (AND/OR/NOT) carry one instead of a leaf condition, and their Path is always empty.

This is the same grammar scanWirePathBody enforces for the wire jsonPath form, minus the "$." leader a wire path carries and this one does not — callers that hold a wire path strip the leader (or call ConditionToFilter, which does that translation) before reaching this grammar.

type PathSub added in v0.8.4

type PathSub struct {
	Wildcard bool
	Index    int
}

PathSub is one bracketed subscript following a PathHop's name: either the wildcard "[*]" (Wildcard true, Index unused) or a non-negative decimal index "[N]" (Wildcard false, Index the parsed value).

type Plugin

type Plugin interface {
	Name() string
	NewFactory(ctx context.Context, getenv func(string) string, opts ...FactoryOption) (StoreFactory, error)
}

Plugin is the storage-backend contract. Implementations register themselves at init time by calling Register.

func GetPlugin

func GetPlugin(name string) (Plugin, bool)

GetPlugin returns the registered plugin with the given name.

type PreparedFilter added in v0.8.4

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

PreparedFilter is a Filter compiled for repeated evaluation. Build it once per query with Prepare, then call Match once per candidate row.

The zero PreparedFilter matches everything, mirroring Prepare(Filter{}) and the "no filter" convention every backend already relies on.

func Prepare added in v0.8.4

func Prepare(f Filter) (PreparedFilter, error)

Prepare compiles f for repeated evaluation, or reports why it cannot: a leaf whose operand cannot be expanded, whose SourceData Path is empty or outside the documented path grammar, whose SourceMeta Path is empty or outside the closed meta vocabulary, or whose pattern operand will not compile, makes the whole filter unevaluable — and so does a FilterNot node whose Children is not exactly one well-formed filter. That is decided once, from the condition alone, before any entity is read — it is a property of the request, and Prepare rejects the request rather than silently building a leaf that never matches. A never-match leaf would be indistinguishable from a genuine non-match at Match time, and — the reason this is not merely cosmetic — a NOT would invert it into matches-everything.

Prepare copies everything it needs out of f. It does not retain a reference to it, so mutating f afterwards does not affect the returned value.

func (PreparedFilter) Match added in v0.8.4

func (p PreparedFilter) Match(data []byte, meta EntityMeta) bool

Match reports whether the entity satisfies the prepared filter. It performs no parsing, bucketing or regex compilation — all of that happened in Prepare.

type Principal added in v0.8.3

type Principal struct {
	ID   string        `json:"id"`
	Kind PrincipalKind `json:"kind"`
}

Principal identifies an actor and its explicit kind. The zero value means "absent".

func AttributionFor added in v0.8.3

func AttributionFor(ctx context.Context) (attributed, executor Principal)

AttributionFor returns (attributed, executor) for a durable write staged under ctx. Origin inheritance engages only for service/system executors inside a transaction; a user-kind (or legacy unset-kind) executor records itself. Never elevates a non-joined write to a claimed user.

func GetAmbientOrigin added in v0.8.3

func GetAmbientOrigin(ctx context.Context) Principal

GetAmbientOrigin returns the ambient origin seeded via WithAmbientOrigin, or the zero Principal if none was seeded.

func ResolveOrigin added in v0.8.3

func ResolveOrigin(ctx context.Context) Principal

ResolveOrigin is the single shared origin-precedence implementation: parent-tx > ambient > UserContext. All backends MUST use it at Begin — divergence here is an attribution bug.

type PrincipalKind added in v0.8.3

type PrincipalKind string

PrincipalKind classifies the actor identified by a Principal.

const (
	PrincipalUser    PrincipalKind = "user"
	PrincipalService PrincipalKind = "service"
	PrincipalSystem  PrincipalKind = "system"
)

type ProcessorConfig

type ProcessorConfig struct {
	AttachEntity         bool   `json:"attachEntity,omitempty"`
	CalculationNodesTags string `json:"calculationNodesTags,omitempty"`
	ResponseTimeoutMs    int64  `json:"responseTimeoutMs,omitempty"`
	RetryPolicy          string `json:"retryPolicy,omitempty"`
	Context              string `json:"context,omitempty"`
	// StartNewTxOnDispatch, when true and ExecutionMode is COMMIT_BEFORE_DISPATCH,
	// causes the cascade engine to open a fresh transaction before dispatching
	// the processor (so the processor may perform transactional work via that
	// tx's token). When false (default) the processor runs with no transaction
	// context and the connection is released entirely during dispatch.
	// Ignored for any other ExecutionMode.
	StartNewTxOnDispatch *bool `json:"startNewTxOnDispatch,omitempty"`

	// AsyncResult, when true, requests that the cascade engine suspend
	// the transaction at processor dispatch and resume only when the
	// processor's result eventually arrives via the async-result
	// delivery slot. The runtime that implements this — durable
	// suspend state, work-stealing recovery, distributed timer
	// coordination — is gated on storage-engine primitives not
	// available in every backend. Consuming engines that do not
	// implement async-result semantics MUST reject this field at
	// import (or the equivalent configuration-boundary) rather than
	// silently degrade to synchronous dispatch.
	//
	// Pointer so that nil (absent) and &false (explicit no-async) are
	// distinguishable on the wire and round-trip byte-equivalent.
	AsyncResult *bool `json:"asyncResult,omitempty"`

	// CrossoverToAsyncMs is the timer, in milliseconds, after which
	// the engine crosses over from sync-wait to async-result delivery
	// for an AsyncResult=true processor. Effective only when
	// AsyncResult is true. Consuming engines that do not implement
	// async-result semantics MUST reject any non-nil value at import.
	CrossoverToAsyncMs *int64 `json:"crossoverToAsyncMs,omitempty"`
}

ProcessorConfig holds configuration for a processor.

type ProcessorDefinition

type ProcessorDefinition struct {
	// Type is the execution-location axis. Recognised values are defined
	// by the cyoda-go engine package; canonical values are "externalized"
	// (dispatched via gRPC to a calculation node selected by
	// Config.CalculationNodesTags) and "internalized" (reserved for an
	// in-process execution location, currently rejected at engine
	// dispatch as not yet implemented). Empty is treated as "externalized".
	// Any value other than "internalized" falls through to the
	// ExecutionMode dispatch path; import-time validation does not
	// constrain this field.
	Type          string          `json:"type"`
	Name          string          `json:"name"`
	ExecutionMode string          `json:"executionMode,omitempty"`
	Config        ProcessorConfig `json:"config,omitempty"`
	// Annotations is arbitrary client-owned metadata, stored and
	// round-tripped verbatim and never interpreted by the engine.
	// Well-known renderer keys (displayName, description) are a documented
	// convention only — the engine validates object-shape and size, not
	// the value types.
	Annotations json.RawMessage `json:"annotations,omitempty"`
}

ProcessorDefinition represents a processor attached to a transition.

type ReconcileRequest added in v0.8.3

type ReconcileRequest struct {
	TenantID     TenantID
	EntityID     string
	CurrentState string
	Arm          []ScheduledTask // tasks to Upsert (current state's schedules)
	// Cancel lists task IDs to delete for this transaction regardless of
	// SourceState, e.g. born-expired scheduled transitions computed by a
	// ScheduleFunction whose result already lies in the past. Audited
	// separately from the SourceState-mismatch cancels.
	Cancel []string
}

ReconcileForEntity input: arm the CurrentState's scheduled transitions, cancel (delete) any pending task for this entity whose SourceState != CurrentState, and additionally delete the tasks explicitly listed in Cancel. Returns the cancelled tasks (for audit); the Cancel-driven deletions are reported distinctly from the SourceState-mismatch cancels.

type ScalarBranch added in v0.8.4

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

ScalarBranch records the primitive DataTypes a path was observed holding. It NEVER holds NULL: null is not a scalar observation, it is the nullable marker, and ModelNode.Nullable carries it.

func (*ScalarBranch) Kind added in v0.8.4

func (b *ScalarBranch) Kind() NodeKind

Kind implements Branch.

func (*ScalarBranch) Types added in v0.8.4

func (b *ScalarBranch) Types() []DataType

Types returns a sorted copy of the observed DataTypes.

type ScheduleFunction added in v0.8.3

type ScheduleFunction struct {
	Name                 string `json:"name"`
	ResultKind           string `json:"resultKind"` // must be "Schedule"
	CalculationNodesTags string `json:"calculationNodesTags"`
	AttachEntity         bool   `json:"attachEntity"`
	Context              string `json:"context,omitempty"`
	ResponseTimeoutMs    int64  `json:"responseTimeoutMs,omitempty"`
}

ScheduleFunction configures a Function callout that computes a scheduled transition's firing time (and optional expiry) per entity. Mutually exclusive with TransitionSchedule.DelayMs (enforced by the engine at import).

type ScheduledTask added in v0.8.3

type ScheduledTask struct {
	// ID is deterministic and engine-defined: the same
	// (tenant, entity, source state, transition) always derives the same
	// ID, so re-arming a still-scheduled transition upserts the existing
	// row in place instead of creating a duplicate. Tenant and entity are
	// incorporated so IDs can never collide across tenants or entities.
	// The exact derivation (hash inputs, encoding) is an engine-internal
	// detail, not part of this SPI's contract — stores must treat ID as
	// an opaque, stable key.
	ID       string            `json:"id"`
	TenantID TenantID          `json:"tenantId"`
	Type     ScheduledTaskType `json:"type"`
	// ScheduledTime is unix-millis; due when <= now.
	ScheduledTime int64 `json:"scheduledTime"`
	// TimeoutMs is the lateness tolerance in ms; nil = never expires.
	TimeoutMs *int64 `json:"timeoutMs,omitempty"`
	// RedispatchAfter is a unix-millis best-effort throttle; the scan
	// excludes rows still inside it. Not a lease, not conditional.
	RedispatchAfter *int64 `json:"redispatchAfter,omitempty"`

	// --- fire-transition payload ---
	EntityID     string `json:"entityId,omitempty"`
	ModelName    string `json:"modelName,omitempty"`
	ModelVersion int    `json:"modelVersion,omitempty"`
	Transition   string `json:"transition,omitempty"`
	SourceState  string `json:"sourceState,omitempty"`

	ArmedAt      int64 `json:"armedAt,omitempty"`
	AttemptCount int   `json:"attemptCount,omitempty"`

	// ArmedBy is the arming principal (chain origin at arm time, per the
	// follow-on-action attribution design); zero on legacy rows — fire
	// treats zero as the system principal. omitempty does not omit a zero
	// struct; readers rely on the zero-value check, never field absence.
	ArmedBy Principal `json:"armedBy,omitempty"`
}

ScheduledTask is a durable "do something at ScheduledTime, with TimeoutMs lateness tolerance" record. For fire-transition, the payload fields identify the entity+transition to fire. See the cyoda-go scheduled-transition-runtime design for semantics.

type ScheduledTaskStore added in v0.8.3

type ScheduledTaskStore interface {
	Upsert(ctx context.Context, task ScheduledTask) error
	Get(ctx context.Context, id string) (task *ScheduledTask, found bool, err error)
	// ScanDue returns up to limit tasks with ScheduledTime <= nowMs AND
	// (RedispatchAfter is null OR <= nowMs), ordered by ScheduledTime, across tenants.
	ScanDue(ctx context.Context, nowMs int64, limit int) ([]ScheduledTask, error)
	// MarkRedispatch sets RedispatchAfter = redispatchAfterMs (plain write) and bumps AttemptCount.
	MarkRedispatch(ctx context.Context, id string, redispatchAfterMs int64) error
	// Delete removes the task, returning whether a row was actually removed
	// (delete-gated terminal audit relies on this).
	Delete(ctx context.Context, id string) (removed bool, err error)
	// ReconcileForEntity upserts req.Arm, deletes the entity's other-state
	// pending tasks, and additionally deletes the tasks listed in req.Cancel
	// (audited distinctly from the SourceState-mismatch cancels); returns
	// the deleted (cancelled) tasks.
	ReconcileForEntity(ctx context.Context, req ReconcileRequest) (cancelled []ScheduledTask, err error)
}

ScheduledTaskStore persists ScheduledTasks. Arm/Delete/Reconcile MUST participate in the caller's transaction (atomic with the entity write). ScanDue is a read across all tenants and is called outside any tenant tx.

type ScheduledTaskType added in v0.8.3

type ScheduledTaskType string

ScheduledTaskType discriminates ScheduledTask variants. Only fire-transition is implemented today; the runtime is generic so future variants (delayed export, async-result crossover) reuse it.

const ScheduledTaskFireTransition ScheduledTaskType = "fire-transition"

type SchemaDelta added in v0.6.0

type SchemaDelta []byte

SchemaDelta is an opaque, plugin-agnostic serialization of an additive schema change. Bytes are produced by the consuming application's schema diff logic (e.g. cyoda-go's internal/domain/model/schema) and replayed by an injected apply function in the plugin. Plugins persist bytes verbatim; they MUST NOT interpret them.

type SearchJob

type SearchJob struct {
	ID       string
	TenantID TenantID
	Status   string // RUNNING, SUCCESSFUL, FAILED, CANCELLED
	ModelRef ModelRef

	// Condition is the client's predicate in the DOMAIN wire syntax
	// ([predicate.Condition] as JSON), deliberately NOT translated to a
	// [Filter]. It is the one plugin-facing field that carries domain syntax;
	// every other predicate surface here (EntityStore.Search,
	// EntityStore.Iterate, GroupedAggregate) takes a Filter.
	//
	// For a store the engine executes, this field is OPAQUE: persist it and
	// return it unchanged. The engine reads it back and translates it itself.
	//
	// For a [SelfExecutingSearchStore] it is the input to execution, and the
	// obligations on that interface apply — translate it with
	// [ConditionToFilter], do not parse or evaluate it independently.
	//
	// The shape is settled and permanent. Carrying a translated Filter here
	// instead was considered and rejected: [ConditionToFilter] and
	// [FieldsMapFromSchema] already live in this module, so a self-executing
	// store can translate with the kernel's own code, which is what actually
	// prevents divergence. Moving the translation to submission time would
	// also have to define what happens when a condition does not translate,
	// at the one point the engine has already stepped out.
	Condition json.RawMessage

	PointInTime time.Time
	SearchOpts  json.RawMessage
	ResultCount int
	Error       string
	CreateTime  time.Time
	FinishTime  *time.Time
	CalcTimeMs  int64

	// HeartbeatTime is the last liveness stamp from the owning executor.
	// nil means never stamped, in which case staleness is measured from
	// CreateTime (the baseline).
	HeartbeatTime *time.Time

	// Epoch is the claim/attempt counter. CreateJob persists 1 regardless
	// of the value set on the input job; ClaimStale increments it on each
	// successful claim. Callers fence writes (UpdateJobStatus, SaveResults,
	// Heartbeat) against the Epoch they were claimed with.
	Epoch int64

	// StaleClaims counts how many times ClaimStale took this job because
	// its heartbeat went stale — an executor lost without releasing. A
	// claim of a released job (see Release) does NOT count. CreateJob
	// persists 0 regardless of the value on the input job; ClaimStale
	// increments it, atomically with the claim, only for a staleness claim.
	// The engine's attempt cap bounds this counter, not Epoch, so a graceful
	// handoff (Release then claim) never advances a job toward being failed.
	StaleClaims int64
}

SearchJob represents the persistent state of an async search operation.

type SearchOptions added in v0.4.0

type SearchOptions struct {
	ModelName    string
	ModelVersion string
	PointInTime  *time.Time

	// Limit is a bounded-or-fail cap on the matched set. Limit >= 1 is
	// REQUIRED; Limit <= 0 is a contract violation and the implementation
	// MUST return an error. See EntityStore.Search's doc comment — the full
	// contract is load-bearing.
	Limit   int
	OrderBy []OrderSpec

	// TrackingRead, when true and a transaction is active, records the
	// entities this search returns into the transaction's read-set, so
	// commit-time first-committer-wins validates them (a FOR-SHARE / locking
	// read, implemented optimistically). Default false: a plain snapshot
	// predicate read that records nothing. No-op when no transaction is
	// active. In-transaction search never prevents phantoms regardless of
	// this flag (see cyoda-go's docs/CONSISTENCY.md).
	//
	// Returned, not scanned: a row the filter excludes is never handed to the
	// caller and MUST NOT be recorded, whichever layer excluded it — a
	// storage predicate or an in-process re-check. Recording a
	// scanned-but-excluded row aborts the transaction on a concurrent commit
	// it never had a reason to conflict with. IterateOptions.TrackingRead
	// carries the identical rule, per yielded row.
	TrackingRead bool
}

SearchOptions configures EntityStore.Search: bounding, ordering and scoping. There is no Offset: direct search does not paginate (async search does, over its persisted result-ID list).

type SelfExecutingSearchStore

type SelfExecutingSearchStore interface {
	AsyncSearchStore
	SelfExecuting()
}

SelfExecutingSearchStore is implemented by AsyncSearchStore variants whose CreateJob method also kicks off per-shard execution and result persistence. The domain SearchService detects this via a type assertion after CreateJob and skips its own background-execution goroutine for these stores.

Memory and Postgres do NOT implement this — their CreateJob only persists the job row, and the SearchService spawns a background goroutine to perform the actual search. A backend with native distributed execution can opt in by implementing this interface; its CreateJob is expected to dispatch work and persist results itself.

Self-executing stores may reject SaveResults (they persist results as a side effect of CreateJob's own dispatch, not via a caller-driven stream) and no-op Heartbeat, ClaimStale, ClearResults, and Release — liveness and reclaim are meaningless for a store that owns execution outright.

Predicate obligation

This is the ONLY interface for which SearchJob.Condition is load-bearing: an engine-executed store persists that field and never reads it, while a self-executing store must act on it with no engine present.

Such a store MUST derive its predicate through this module — FieldsMapFromSchema over the model schema, then ConditionToFilter, then Prepare / PreparedFilter.Match — and MUST NOT ship its own condition parser or leaf comparator. A second implementation of either is not a local choice: it silently answers the same query differently from every other backend, and it has already happened once, diverging on numeric precision, BETWEEN inclusivity, absent-field handling for negative operators, pattern anchoring and array comparison.

Passing a nil or partial fields map does not satisfy this. An empty declared-type set does not degrade uniformly — comparison leaves annihilate while string and presence leaves evaluate normally — so the result is internally inconsistent rather than empty. See ConditionToFilter.

type Startable

type Startable interface {
	Start(ctx context.Context) error
}

Startable is an optional StoreFactory capability: the core calls Start immediately after NewFactory and before any store-facing call (including TransactionManager). Plugins that need background goroutines — shard managers, consumer groups, rebalance waits, long-lived cluster connections — implement this. Start must complete (successfully) before the factory is expected to serve transactions; plugins whose TransactionManager depends on Start's side effects (rebalance-assigned shards, consumer group membership) can rely on this ordering.

Start is bounded by the caller's context (typically a startup timeout). Plugins must honor ctx.Done() for cancellation.

type StateDefinition

type StateDefinition struct {
	Transitions []TransitionDefinition `json:"transitions,omitempty"`
	// Annotations is arbitrary client-owned metadata, stored and
	// round-tripped verbatim and never interpreted by the engine.
	Annotations json.RawMessage `json:"annotations,omitempty"`
}

StateDefinition represents a state with its transitions.

type StateMachineAuditStore

type StateMachineAuditStore interface {
	Record(ctx context.Context, entityID string, event StateMachineEvent) error
	GetEvents(ctx context.Context, entityID string) ([]StateMachineEvent, error)
	GetEventsByTransaction(ctx context.Context, entityID string, transactionID string) ([]StateMachineEvent, error)
}

type StateMachineEvent

type StateMachineEvent struct {
	EventType     StateMachineEventType `json:"eventType"`
	EntityID      string                `json:"entityId"`
	TimeUUID      string                `json:"timeUuid"`
	State         string                `json:"state,omitempty"`
	TransactionID string                `json:"transactionId,omitempty"`
	Details       string                `json:"details"`
	Data          map[string]any        `json:"data,omitempty"`
	Timestamp     time.Time             `json:"timestamp"`
}

StateMachineEvent represents a single event in a state machine execution.

type StateMachineEventType

type StateMachineEventType string

StateMachineEventType represents the type of state machine event.

const (
	SMEventStarted                    StateMachineEventType = "STATE_MACHINE_START"
	SMEventFinished                   StateMachineEventType = "STATE_MACHINE_FINISH"
	SMEventCancelled                  StateMachineEventType = "CANCEL"
	SMEventForcedSuccess              StateMachineEventType = "FORCE_SUCCESS"
	SMEventWorkflowFound              StateMachineEventType = "WORKFLOW_FOUND"
	SMEventWorkflowNotFound           StateMachineEventType = "WORKFLOW_NOT_FOUND"
	SMEventWorkflowSkipped            StateMachineEventType = "WORKFLOW_SKIP"
	SMEventTransitionMade             StateMachineEventType = "TRANSITION_MAKE"
	SMEventTransitionNotFound         StateMachineEventType = "TRANSITION_NOT_FOUND"
	SMEventTransitionCriterionNoMatch StateMachineEventType = "TRANSITION_NOT_MATCH_CRITERION"
	SMEventProcessCriterionNoMatch    StateMachineEventType = "PROCESS_NOT_MATCH_CRITERION"
	SMEventProcessingPaused           StateMachineEventType = "PAUSE_FOR_PROCESSING"
	SMEventStateProcessResult         StateMachineEventType = "STATE_PROCESS_RESULT"

	SMEventScheduledTransitionArmed     StateMachineEventType = "SCHEDULED_TRANSITION_ARM"
	SMEventScheduledTransitionFired     StateMachineEventType = "SCHEDULED_TRANSITION_FIRE"
	SMEventScheduledTransitionExpired   StateMachineEventType = "SCHEDULED_TRANSITION_EXPIRE"
	SMEventScheduledTransitionCancelled StateMachineEventType = "SCHEDULED_TRANSITION_CANCEL"
)

type StoreFactory

type StoreFactory interface {
	EntityStore(ctx context.Context) (EntityStore, error)
	ModelStore(ctx context.Context) (ModelStore, error)
	KeyValueStore(ctx context.Context) (KeyValueStore, error)
	MessageStore(ctx context.Context) (MessageStore, error)
	WorkflowStore(ctx context.Context) (WorkflowStore, error)
	StateMachineAuditStore(ctx context.Context) (StateMachineAuditStore, error)
	AsyncSearchStore(ctx context.Context) (AsyncSearchStore, error)
	// ScheduledTaskStore accesses durable scheduled tasks. Unlike the
	// per-tenant stores, its ScanDue is cross-tenant (obtain with a
	// background/tenant-less context); Upsert/Delete/Reconcile carry the
	// tenant on the task/request. Participates in the entity write's
	// transaction so arm/cancel are atomic with the state change.
	ScheduledTaskStore(ctx context.Context) (ScheduledTaskStore, error)
	TransactionManager(ctx context.Context) (TransactionManager, error)
	Close() error
}

type TemporalSubCondition added in v0.8.3

type TemporalSubCondition struct {
	Type   DataType
	Millis int64
	Op     FilterOp
}

TemporalSubCondition is one resolved per-type branch: the target subtype, the operand as floored epoch-millis, and the (possibly mutated) operation. It bridges to the instant kernel — Millis feeds CompareTemporal.

func ExpandTemporalOperand added in v0.8.3

func ExpandTemporalOperand(operand string, declaredTemporal []DataType, op FilterOp) []TemporalSubCondition

ExpandTemporalOperand parses a single temporal operand and resolves it into one condition per declared temporal subtype for a polymorphic (or meta) field, faithfully porting PolymorphicTemporalConversions.parseTemporalConditionToPolyType (kt:8-9) plus convert() (kt:49-76).

The operand is classified once to its natural subtype. For each declared type: the matching subtype yields an identity condition (value and op unchanged); every other declared type is resolved through the downscale/upscale graph. Branches with no path, and imprecise-EQUALS downscales, are dropped. An empty result means every temporal branch was dropped.

Meta-vs-data ZonedDateTime: a coarse operand ("2024") is classified as Year and upscaled to the instant, which is the meta-field relaxation of the offset-mandatory rule. The stricter data-field rule — a ZonedDateTime operand must carry an offset — lives in ParseTemporalSubtype(operand, ZonedDateTime), which rejects offset-less input; a data-field caller enforces it by parsing directly against its declared type rather than relying on coarse upscale.

type TemporalValue added in v0.8.3

type TemporalValue struct {
	// Type is the subtype this value currently represents.
	Type DataType
	// contains filtered or unexported fields
}

TemporalValue is a parsed temporal operand together with the subtype (granularity) it currently represents. It is produced by ParseTemporalSubtype and threaded through the resolution graph, which floors and re-tags it.

func ParseTemporalSubtype added in v0.8.3

func ParseTemporalSubtype(operand string, t DataType) (TemporalValue, bool)

ParseTemporalSubtype parses operand as exactly the temporal subtype t, returning ok=false if the string is not a valid representation of that subtype. ZonedDateTime requires an explicit offset (Z or +/-hh:mm); an offset-less string is rejected (the data-field offset-mandatory rule). t must be one of the six temporal subtypes; any other DataType yields ok=false.

func (TemporalValue) Millis added in v0.8.3

func (tv TemporalValue) Millis() int64

Millis returns the value as floored epoch-milliseconds, ready to feed CompareTemporal. Zone-less subtypes are read at UTC; a live ZonedDateTime honours its offset to yield the true instant.

type Tenant

type Tenant struct {
	ID   TenantID
	Name string
}

Tenant is a first-class domain entity representing a tenant.

type TenantID

type TenantID string

TenantID is a named type for tenant identifiers, preventing accidental use of bare strings.

const SystemTenantID TenantID = "SYSTEM"

SystemTenantID is the well-known tenant for system-level data.

type TransactionManager

type TransactionManager interface {
	// Begin starts a new transaction in the caller's tenant. Returns the
	// txID and a child context carrying the new TransactionState. After
	// Begin returns, the TransactionState's immutable fields (ID,
	// TenantID, SnapshotTime) are safe to read without locks.
	Begin(ctx context.Context) (txID string, txCtx context.Context, err error)

	// Commit closes the transaction and applies its buffered writes to
	// the underlying store. Commit acquires tx.OpMu.Lock for its
	// duration, so it waits for any in-flight tx-path operation on the
	// same tx (any SPI method invocation that holds OpMu.RLock) to drain
	// before mutating or closing tx state. Implementations must verify
	// that the caller's tenant matches tx.TenantID and reject
	// mismatched-tenant calls.
	Commit(ctx context.Context, txID string) error

	// Rollback closes the transaction and discards its buffered writes.
	// Acquires tx.OpMu.Lock; same tenant verification as Commit.
	Rollback(ctx context.Context, txID string) error

	// Join returns a context carrying the TransactionState for an existing
	// active transaction. Multiple goroutines may participate in the same
	// tx, but only one operation at a time per transaction:
	// application-side serialisation is required, per the Application
	// contract below and [TransactionState]'s concurrency contract
	// (cyoda-go serialises through its per-transaction gate).
	//
	// Two distinct contracts apply to a joined tx:
	//
	//   - Plugin contract (enforced by [TransactionState.OpMu]): the
	//     plugin's tx-path SPI methods hold OpMu.RLock; the plugin's
	//     closure SPI methods (Commit, Rollback, RollbackToSavepoint)
	//     hold OpMu.Lock. So closure waits for any in-flight SPI-method
	//     invocation to return before mutating or closing tx state. This
	//     contract covers SPI-method invocations only — application code
	//     that mutates tx state directly (e.g. through a [GetTransaction]
	//     handle) is outside the OpMu protection.
	//
	//   - Application contract (NOT enforced by the plugin): the
	//     application must serialise its own concurrent in-flight ops on
	//     the same tx. OpMu.RLock allows multiple readers concurrently;
	//     two RLock-holding ops (e.g. two Save calls from different
	//     goroutines) will trigger Go's "concurrent map writes" runtime
	//     fatal because both write to tx.Buffer / tx.WriteSet / tx.Deletes
	//     without mutual exclusion. RLock does not protect map writes from
	//     each other regardless of key overlap. The plugin does not detect
	//     or recover from this contract violation.
	//
	// Implementations must verify that the caller's tenant matches
	// tx.TenantID and reject mismatched-tenant joins. Implementations must
	// read tx.RolledBack and tx.Closed under tx.OpMu.RLock (not under the
	// manager mutex) — Commit's deferred Closed-write runs outside the
	// manager-mutex region.
	Join(ctx context.Context, txID string) (txCtx context.Context, err error)

	GetSubmitTime(ctx context.Context, txID string) (time.Time, error)

	// Savepoint creates a named savepoint within the given transaction by
	// snapshotting tx.Buffer / tx.ReadSet / tx.WriteSet / tx.Deletes, with
	// tx.DeleteAttribution snapshotted paired with tx.Deletes.
	//
	// Locking discipline: read-only on tx state. Implementations must
	// acquire tx.OpMu.RLock for the snapshot read so the operation is
	// serialised against Commit/Rollback (which take tx.OpMu.Lock)
	// without blocking other in-flight readers.
	//
	// Tenant isolation: implementations must reject calls whose
	// UserContext tenant does not match tx.TenantID.
	Savepoint(ctx context.Context, txID string) (savepointID string, err error)

	// RollbackToSavepoint rolls back all work done since the savepoint was
	// created by replacing tx.Buffer / tx.ReadSet / tx.WriteSet /
	// tx.Deletes with the snapshot taken at Savepoint time, restoring
	// tx.DeleteAttribution paired with tx.Deletes.
	//
	// Locking discipline: write on tx state — exclusive against every
	// other tx-path op. Implementations must acquire tx.OpMu.Lock (write
	// lock, not RLock) for the duration of the field replacement.
	//
	// Tenant isolation: implementations must reject mismatched-tenant
	// callers — RollbackToSavepoint is destructive on tx-state.
	RollbackToSavepoint(ctx context.Context, txID string, savepointID string) error

	// ReleaseSavepoint releases a savepoint, merging its work into the
	// parent transaction. The work done since the savepoint already lives
	// in tx.Buffer / tx.ReadSet / tx.WriteSet / tx.Deletes / tx.DeleteAttribution
	// — Release only removes the snapshot record from manager-side state.
	//
	// Locking discipline: does not touch any field of TransactionState
	// (only manager-side savepoint records). Implementations need only
	// the manager mutex; tx.OpMu is not required.
	//
	// Tenant isolation: implementations must reject mismatched-tenant
	// callers — manager-side savepoint state is tenant-scoped.
	ReleaseSavepoint(ctx context.Context, txID string, savepointID string) error
}

TransactionManager is the plugin-side surface for the snapshot-isolation transaction model. See TransactionState for the full concurrency contract that implementations must honour.

type TransactionState

type TransactionState struct {
	ID           string
	TenantID     TenantID
	SnapshotTime time.Time
	Origin       Principal          // attribution root for the tx; immutable after Begin (see godoc above)
	ReadSet      map[string]bool    // entity IDs read; access under OpMu (see godoc)
	WriteSet     map[string]bool    // entity IDs written; access under OpMu
	Buffer       map[string]*Entity // staged writes; access under OpMu
	// Deletes and DeleteAttribution are the buffering mechanism: backends
	// that stage deletes in TransactionState (e.g. memory, sqlite) use
	// these maps to carry staging + attribution through to the commit-time
	// flush, and populate them at delete-stage time; access under OpMu.
	// Backends whose delete staging/visibility is instead governed by the
	// underlying store (e.g. postgres, via immediate DML under a SQL
	// SAVEPOINT) stamp attribution directly at delete time and MAY leave
	// these maps unpopulated — that is not a bug. The conformance contract
	// is the committed outcome (GetVersionMetadata's tombstone row), never
	// these maps' contents; do not assert on them from backend-agnostic tests.
	Deletes           map[string]bool             // staged deletes; access under OpMu
	DeleteAttribution map[string]WriteAttribution // entityID → actors for staged deletes; same OpMu posture as Deletes
	RolledBack        bool                        // closure flag; written under OpMu.Lock, read under OpMu.RLock
	OpMu              sync.RWMutex                // see TransactionState godoc above for full contract
	Closed            bool                        // closure flag; written under OpMu.Lock, read under OpMu.RLock
}

TransactionState holds the state of an active SSI transaction. All processor execution is sequential (no goroutines) — see docs/superpowers/specs/2026-04-01-workflow-processor-execution-design.md. SAVEPOINTs snapshot/restore these maps for ASYNC_NEW_TX rollback isolation, with DeleteAttribution snapshotted/restored paired with Deletes — the two maps always cover the same key set, for backends that populate them at all (see the Deletes/DeleteAttribution field comments below — the conformance contract is the committed outcome, never these maps' contents).

Concurrency contract

Plugin implementations of TransactionManager must coordinate concurrent access to TransactionState's mutable fields using OpMu. Two distinct concerns:

  1. Cross-class serialisation — plugin's responsibility, enforced via OpMu. In-flight tx-path operations (Save, Get, Delete, Savepoint, etc., regardless of which plugin type — TransactionManager, EntityStore, or any other surface — defines them) hold OpMu.RLock; closure operations (Commit, Rollback, RollbackToSavepoint) hold OpMu.Lock. This guarantees Commit/Rollback wait for any in-flight SPI-method invocation on the same tx to drain before mutating or closing tx state. Every plugin method that reads or writes ReadSet, WriteSet, Buffer, Deletes, RolledBack, or Closed must acquire OpMu in the appropriate posture. DeleteAttribution carries the same posture as Deletes — every method that reads or writes Deletes must apply the identical OpMu discipline to DeleteAttribution, since the two maps are always mutated together.

  2. Within-class serialisation — application's responsibility, NOT enforced by OpMu. OpMu.RLock allows multiple readers concurrently; it does not mutually exclude RLock-holders from each other. If the application fires two RLock-holding ops on the same tx concurrently (e.g. two `Save` calls from different goroutines), the underlying Go map writes to tx.Buffer / tx.WriteSet / tx.Deletes will trigger the runtime's "concurrent map writes" fatal — RLock does not protect map writes from each other regardless of key overlap. The application must serialise its own ops on a given tx; the plugin does not detect or recover from this contract violation.

Lock posture per field

  • ReadSet, WriteSet, Buffer, Deletes, DeleteAttribution: read or written under OpMu.RLock by in-flight ops; iterated or replaced under OpMu.Lock by Commit / Rollback / RollbackToSavepoint. DeleteAttribution shares Deletes' posture exactly — the two are always read, written, and savepoint-snapshotted together.
  • Closed: written under OpMu.Lock by Commit/Rollback in their defer (so all return paths are covered); read under OpMu.RLock by every in-flight op so the op fails fast on a closed tx.
  • RolledBack: written under OpMu.Lock by Rollback eagerly inside the OpMu region (not in defer); read under OpMu.RLock by every in-flight op.
  • ID, TenantID, SnapshotTime, Origin: immutable after TransactionManager.Begin returns; safe to read without locks.

Lock order

Plugin implementations acquire locks in this overall order to avoid deadlock:

tx.OpMu  →  factory's per-store mutex  →  manager's per-tx-table mutex

The manager's per-tx-table mutex is also acquired BEFORE tx.OpMu for the brief active-tx-table lookup at the top of every method, then released BEFORE tx.OpMu is taken. So in practice the manager mutex appears at two distinct points in the timeline:

  1. Brief lookup of the tx pointer in the manager's active-tx table. Released immediately. Never held across slow operations.
  2. Optional re-acquisition INSIDE the OpMu region for committedLog / savepoint-table maintenance, while still holding OpMu.

Holding the manager mutex across the tx.OpMu acquisition is a deadlock-bug — Commit holds tx.OpMu while waiting on the manager mutex for log maintenance, so any path that holds the manager mutex while waiting on tx.OpMu inverts the order.

Required reading for plugin authors

New methods that touch *TransactionState — on TransactionManager, EntityStore, or any other plugin surface — must declare their OpMu posture in a code comment ("Locking discipline: ..."). See `.claude/rules/tx-state-locking.md` in the cyoda-go-spi repository for the review checklist enforced at code review.

func GetTransaction

func GetTransaction(ctx context.Context) *TransactionState

GetTransaction returns the transaction state from the context, or nil if none.

type TransitionDefinition

type TransitionDefinition struct {
	Name       string                `json:"name"`
	Next       string                `json:"next"`
	Manual     bool                  `json:"manual"`
	Disabled   bool                  `json:"disabled,omitempty"`
	Criterion  json.RawMessage       `json:"criterion,omitempty"`
	Processors []ProcessorDefinition `json:"processors,omitempty"`
	Schedule   *TransitionSchedule   `json:"schedule,omitempty"`
	// Annotations is arbitrary client-owned metadata, stored and
	// round-tripped verbatim and never interpreted by the engine.
	Annotations json.RawMessage `json:"annotations,omitempty"`
	// CriterionAnnotations is client-owned metadata describing this
	// transition's guard Criterion as a whole. Sibling to Criterion;
	// engine-ignored. See WorkflowDefinition.CriterionAnnotations.
	CriterionAnnotations json.RawMessage `json:"criterionAnnotations,omitempty"`
}

TransitionDefinition represents a single transition from a state.

type TransitionSchedule added in v0.8.1

type TransitionSchedule struct {
	// DelayMs is the delay between source-state entry and the
	// scheduled execution time, in milliseconds. Must be > 0.
	DelayMs int64 `json:"delayMs"`

	// TimeoutMs is the late-tolerance window past the scheduled
	// execution time, in milliseconds. Nil means no timeout — the
	// task fires whenever the scheduler eventually picks it up.
	// Non-nil zero is the strictest setting — drop on any lateness.
	// Non-nil positive N drops the task if it picks up more than N
	// milliseconds after scheduledTime. Independent of DelayMs; the
	// two measure different quantities.
	TimeoutMs *int64 `json:"timeoutMs,omitempty"`

	// Function configures a Function callout that computes the firing
	// time (and optional expiry) per entity instead of a static DelayMs.
	// Mutually exclusive with DelayMs (enforced by the engine at import).
	Function *ScheduleFunction `json:"function,omitempty"`
}

TransitionSchedule configures automatic firing of a future state transition. Presence of this struct on a TransitionDefinition marks the transition as scheduled.

Semantics. The scheduled execution time of the transition is scheduledTime = stateEntryTime + DelayMs. When the scheduler picks the task up at executionTime, it computes lateness = executionTime - scheduledTime.

  • If TimeoutMs is nil, the task is always attempted (no timeout).
  • If TimeoutMs is non-nil and lateness > *TimeoutMs, the task is dropped and the transition is NOT attempted.
  • If TimeoutMs is non-nil and lateness <= *TimeoutMs (including *TimeoutMs == 0 when lateness is 0), the transition fires.

TimeoutMs gives operators control over how the system handles backlog and intermittent-offline conditions. Short positive values prefer freshness — stale tasks are discarded rather than fired against a possibly-changed entity. Nil prefers eventual execution.

Scheduled transitions are mutually exclusive with Manual=true.

Scheduled transitions are a special case of a generic ScheduledTask abstraction. The lateness-tolerance concept (TimeoutMs) applies uniformly across all ScheduledTask variants. The generic abstraction and the runtime that implements it ship in a later release; until then, consuming engines silently skip scheduled transitions during automated cascade selection and reject explicit fires by name with a transition-not-found error.

type TypeSet added in v0.8.3

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

TypeSet is a sorted, deduplicated set of DataTypes.

func NewTypeSet added in v0.8.3

func NewTypeSet() *TypeSet

NewTypeSet returns an empty TypeSet.

func Union added in v0.8.3

func Union(a, b *TypeSet) *TypeSet

Union returns a new TypeSet containing all types from both sets.

func (*TypeSet) Add added in v0.8.3

func (ts *TypeSet) Add(dt DataType)

Add inserts a DataType into the set and applies the cyoda-go collapse rule:

  • NULL is dropped when any concrete type is present.
  • Numeric members collapse to a single DataType per CollapseNumeric.
  • Non-numeric members (other than NULL) are preserved as-is.

func (*TypeSet) Equal added in v0.8.3

func (ts *TypeSet) Equal(other *TypeSet) bool

Equal returns true if other contains exactly the same DataTypes.

func (*TypeSet) IsEmpty added in v0.8.3

func (ts *TypeSet) IsEmpty() bool

IsEmpty returns true if the set contains no types.

func (*TypeSet) IsPolymorphic added in v0.8.3

func (ts *TypeSet) IsPolymorphic() bool

IsPolymorphic returns true if the set contains more than one type.

func (*TypeSet) Types added in v0.8.3

func (ts *TypeSet) Types() []DataType

Types returns a sorted copy of the DataTypes in this set.

type UUIDGenerator

type UUIDGenerator interface {
	NewTimeUUID() [16]byte
}

UUIDGenerator produces identifiers for stored records.

The return type is [16]byte so this package remains stdlib-only. Callers that want the github.com/google/uuid type perform a zero-cost type conversion: uuid.UUID(gen.NewTimeUUID()).

Implementations should produce monotonic, time-ordered IDs (v1 UUIDs or equivalent) so that sorted IDs correspond to insertion order.

type UniqueClaim added in v0.8.2

type UniqueClaim struct {
	KeyID     string
	Signature string
}

UniqueClaim is a computed assertion: the store must guarantee no OTHER live entity in the same (tenant, model name, model version) holds the same (KeyID, Signature). Signature is an opaque, type-tagged canonical encoding.

func ComputeClaims added in v0.8.2

func ComputeClaims(keys []UniqueKey, doc []byte) ([]UniqueClaim, error)

ComputeClaims derives a UniqueClaim for each UniqueKey where every declared field is present and non-null in the document. Rules:

  • All fields absent/null → no claim (the key is not applicable to this doc).
  • Some-but-not-all present/non-null → error wrapping ErrPartialUniqueKey.
  • Non-scalar value at a declared path → error wrapping ErrPartialUniqueKey.
  • Over-bound numeric literal → error wrapping ErrPartialUniqueKey.

Signatures are opaque type-tagged canonical strings; numeric values are canonicalized so 42, 42.0, and 4.2e1 produce identical signatures.

type UniqueKey added in v0.8.2

type UniqueKey struct {
	ID     string
	Fields []string
}

UniqueKey is a model-level composite unique key over scalar leaf fields. Fields are ordered dotted JSONPath leaves (same form as the schema's field paths).

func UniqueKeysFromContext added in v0.8.2

func UniqueKeysFromContext(ctx context.Context) []UniqueKey

UniqueKeysFromContext retrieves the []UniqueKey stored by WithUniqueKeys. Returns nil when no keys were attached to ctx.

type UserContext

type UserContext struct {
	UserID   string
	UserName string
	Kind     PrincipalKind
	Tenant   Tenant
	Roles    []string
}

UserContext carries the authenticated user's identity through the request lifecycle.

func GetUserContext

func GetUserContext(ctx context.Context) *UserContext

func MustGetUserContext

func MustGetUserContext(ctx context.Context) *UserContext

type VersionMetadataOptions added in v0.8.4

type VersionMetadataOptions struct {
	// From, Until bound the returned window inclusively; a nil side is
	// unbounded.
	From, Until *time.Time
	// Limit caps the number of returned rows. 0 means all — unlike
	// GetPage, 0 is a valid "unbounded" value here, deliberately: the
	// result is bounded by one entity's own version history, not an
	// unbounded model-wide scan. See GetVersionMetadata's doc comment.
	Limit int
}

VersionMetadataOptions bounds a GetVersionMetadata query.

type WorkflowDefinition

type WorkflowDefinition struct {
	Version      string                     `json:"version"`
	Name         string                     `json:"name"`
	Description  string                     `json:"desc,omitempty"`
	InitialState string                     `json:"initialState"`
	Active       bool                       `json:"active"`
	Criterion    json.RawMessage            `json:"criterion,omitempty"`
	States       map[string]StateDefinition `json:"states"`
	// Annotations is arbitrary client-owned metadata, stored and
	// round-tripped verbatim and never interpreted by the engine.
	Annotations json.RawMessage `json:"annotations,omitempty"`
	// CriterionAnnotations is client-owned metadata describing the
	// workflow-selection Criterion as a whole (a sibling to Criterion,
	// because the criterion value is opaque and round-trips verbatim).
	// Same bag shape as Annotations; engine-ignored.
	CriterionAnnotations json.RawMessage `json:"criterionAnnotations,omitempty"`
}

WorkflowDefinition represents a complete workflow configuration.

type WorkflowStore

type WorkflowStore interface {
	Save(ctx context.Context, modelRef ModelRef, workflows []WorkflowDefinition) error
	Get(ctx context.Context, modelRef ModelRef) ([]WorkflowDefinition, error)
	Delete(ctx context.Context, modelRef ModelRef) error
}

type WriteAttribution added in v0.8.3

type WriteAttribution struct {
	Attributed Principal
	Executor   Principal
}

WriteAttribution records the (attributed, executor) principal pair for a single staged write, per the AttributionFor stamp rule.

Directories

Path Synopsis
Package spitest provides a conformance test harness for spi.StoreFactory implementations.
Package spitest provides a conformance test harness for spi.StoreFactory implementations.

Jump to

Keyboard shortcuts

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