schema

package
v0.1.14 Latest Latest
Warning

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

Go to latest
Published: Aug 29, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Index

Constants

View Source
const (
	RelationHasMany    = "has_many"
	RelationBelongsTo  = "belongs_to"
	RelationManyToMany = "many_to_many"

	// DefaultEmbedLimit bounds children per parent in an embed when a relation
	// declares no Limit — a paginated embed that caps fan-out (DoS guard).
	DefaultEmbedLimit = 50
	// DefaultMaxIncludeDepth is the maximum nesting depth of ?include= (e.g.
	// "lines.product" is depth 2). Requests beyond it are rejected with 400.
	DefaultMaxIncludeDepth = 2
)

Relation type constants and embed defaults (ADR-019 §4).

View Source
const (
	OnDeleteRestrict = "restrict"
	OnDeleteCascade  = "cascade"
	OnDeleteSetNull  = "set_null"
)

On-delete referential actions for a field-level foreign key (MIG-F1-S1). An unset action defaults to RESTRICT — the safe choice (reject a delete that would orphan children, rather than silently orphaning them, the integrity bug closed here). set_null requires the FK column to be nullable.

View Source
const (
	OnUpdateRestrict = "restrict"
	OnUpdateCascade  = "cascade"
	OnUpdateSetNull  = "set_null"
)

On-update referential actions (MIG-F1-S5). The action vocabulary mirrors on_delete, but the UNSET default is NO ACTION (Postgres' own default, which the pre-S5 FKs already carry) — not RESTRICT — so introducing on_update generates no drift on existing tenants (see FieldDef.OnUpdate). set_null requires the FK column to be nullable.

View Source
const (
	IndexMethodBtree = "btree"
	IndexMethodGIN   = "gin"
)

Index access methods (LIBRARY-GAPS-S1). Empty defaults to btree — the historical behavior — so an existing schema is byte-identical.

View Source
const JSONValueMessage = "" /* 159-byte string literal not displayed */

JSONValueMessage is the one 422 message every door uses for a string that is not valid JSON on a json/jsonb field (rule "type").

View Source
const MaxPatternLength = 200

MaxPatternLength bounds a field's regex source. Go's regexp is RE2 (linear time, no catastrophic backtracking), so the cap only limits compile cost and schema noise, not worst-case matching behaviour.

View Source
const PublicRoleName = "$public"

validateConditionOp rejects an RBAC condition whose operator the engine does not enforce. prefix is the condition's path (e.g. "rbac.roles.x.conditions"). A nil condition or an eq/empty op produces no error. PublicRoleName is the reserved role the rbac.public block compiles into (ADR-026). It starts with a character no schema role may use, and declaring it literally in rbac.roles is rejected at load — the anonymous surface can only ever come from the public block.

Variables

View Source
var ValidHookEvents = map[string]bool{
	"before_create": true,
	"after_create":  true,
	"before_update": true,
	"after_update":  true,
}

ValidHookEvents is the set of lifecycle events the engine actually consults (codegen.BuildRouter + the GraphQL create resolver). A hook on any other event name would never fire — so any other name is a validation error.

Functions

func AsFloat64 added in v0.1.7

func AsFloat64(v any) (float64, bool)

AsFloat64 converts any Go numeric value to float64 for rule evaluation. It is the ONE place that decides what counts as a number on a write, shared by the declarative rules here and by codegen's type check, so the two cannot drift.

WHY IT IS NOT JUST float64. encoding/json decodes every JSON number into float64, and for years that was the only shape a write could arrive in — the HTTP path is the only caller. The library path (Ctx.Insert / Ctx.Update, the custom-handler surface) broke that assumption: a Go handler passes what Go computes, and what the engine RETURNS from a read is int64/int32 from the pgx driver. Rejecting int64 on a write while handing int64 back on a read makes the round trip — read a row, change a field, write it back — impossible without a manual cast, which is what a third-party consumer hit and had to diagnose ("surprising in a Go API; it cost a full rebuild cycle").

The conversion is for VALIDATION only; the caller's original value is what reaches the database, so an int64 beyond 2^53 is still stored exactly.

func CanonicalJSONText added in v0.1.10

func CanonicalJSONText(v any) (string, error)

CanonicalJSONText renders one write value of a `json` field as the compact JSON text the TEXT column stores. A string is JSON TEXT: validated and compacted verbatim (numeric text and key order preserved); a []byte / json.RawMessage likewise; anything else is encoded. The error is the caller's 422 — never a driver error.

func EmitTopic

func EmitTopic(resource, action string) string

EmitTopic returns the outbox topic for (resource, action), e.g. EmitTopic("tasks","create") == "tasks.created". Empty if action is unknown.

func Explain added in v0.1.2

func Explain(s *APISchema, lang string) string

Explain renders the schema as plain-language prose. lang: "en" | "es" (anything else falls back to "en").

func GraphQLPascal added in v0.1.9

func GraphQLPascal(s string) string

GraphQLPascal upper-cases each `_`/`-`-separated segment and joins them.

func GraphQLSingular added in v0.1.9

func GraphQLSingular(name string) string

GraphQLSingular strips a trailing plural so a resource `guides` yields the type stem `guide`. It is intentionally simple (the engine's naming, not a linguistics library); its ONLY contract is that pkg/graphql and the validator compute the same value.

func GraphQLTypeName added in v0.1.9

func GraphQLTypeName(resourceName string) string

GraphQLTypeName is the type stem a resource contributes to the GraphQL schema (e.g. `categorias` and `categoria` both → `Categoria`, which is why two such resources collide). Every generated GraphQL type name is built from this.

func IsIntegral added in v0.1.7

func IsIntegral(v any) bool

IsIntegral reports whether v is a whole number, for the int/int64 type check. Any Go integer type is integral by construction; a float is only integral when it has no fractional part (the 1.9-into-an-int-column case, which Postgres would silently truncate).

func MetaSchemaJSON

func MetaSchemaJSON() []byte

MetaSchemaJSON returns the raw bytes of the embedded meta-schema (for the `appximo meta-schema` command, IDE integration, and external tooling).

func PromoteJSONText added in v0.1.10

func PromoteJSONText(row map[string]any, cols []string)

PromoteJSONText turns, IN PLACE, the stored text of each listed `json` column of a row into a json.RawMessage, so the encoder emits the VALUE (`"data": {"nit":"900"}`) instead of an escaped string. A text that is not valid JSON — a row written by an engine before ADR-028, when the column took any string — is left as the string it is: readable, never a 500. The one read-side rule REST, GraphQL, SSE, the batch results and the admin browse share; `?include=` embeds get the same effect from a `::json` cast in SQL because Postgres builds those rows.

func PromoteJSONTextRows added in v0.1.10

func PromoteJSONTextRows(rows []map[string]any, cols []string)

PromoteJSONTextRows applies PromoteJSONText to every row.

func RelationSubroute added in v0.1.9

func RelationSubroute(fieldName string) string

RelationSubroute is the URL segment a relation field's subroute serves under: the field name minus a trailing "_id" (`customer_id` → `customer`; a name without the suffix is used as-is).

Types

type APISchema

type APISchema struct {
	Schema    string                    `json:"$schema"`
	Version   string                    `json:"version"`
	Name      string                    `json:"name"`
	Resources map[string]ResourceSchema `json:"resources"`
	RBAC      RBACPolicy                `json:"rbac"`
	// Workflows is reserved for the Phase 2 multi-step orchestration engine
	// (ADR-012). The struct is parsed for forward compatibility, but no executor
	// runs it yet — present so existing schemas remain valid once it ships.
	Workflows map[string]WorkflowSchema `json:"workflows,omitempty"`
}

APISchema is the top-level contract for an Appximo project.

func LoadFromBytes

func LoadFromBytes(data []byte) (*APISchema, error)

LoadFromBytes parses and structurally checks a schema from raw JSON — the exact checks LoadFromFile performs (strict keys, required $schema/version), for callers that receive the schema over the wire (e.g. the engine self-restart persist, UI-F4-S2) instead of from a file.

func LoadFromFile

func LoadFromFile(path string) (*APISchema, error)

type AutoValue added in v0.1.9

type AutoValue string

AutoValue is the declared engine-management role of a timestamp field — the value of the `auto` key (SILENT-CORRUPTION-S1).

JSON forms accepted:

"auto": true      → AutoLegacy — the historical contract, byte-compatible:
                    the column is provisioned TIMESTAMPTZ DEFAULT now()
                    (set once at insert) and, ONLY when the field is
                    literally named `updated_at`, additionally refreshed to
                    now() on every update. Any other name behaves as a
                    creation timestamp.
"auto": "create"  → AutoCreate — an explicit creation timestamp: set once
                    at insert, never refreshed, regardless of the field's
                    name. `creado_en`, `placed_at`, `fecha` all work.
"auto": "update"  → AutoUpdate — an explicit modification timestamp: set at
                    insert AND refreshed to now() on every engine update
                    (REST PUT/PATCH, GraphQL update, batch transaction,
                    Ctx.Update), regardless of the field's name.
                    `modificado_en` finally means what it says.
"auto": false     → off (same as omitting the key).

The string forms exist because binding the refresh to the literal English name `updated_at` silently froze every non-English modification timestamp at its creation value forever — a validator-clean schema whose data was wrong (ENG-45). The declared role, not the field's name, is now the source of truth; the legacy boolean keeps its name-magic for backward compatibility and raises a load warning when its name suggests update intent.

All three enabled forms share the auto contract: exempt from `required`, no `default`, excluded from create/update inputs (writes answer 422 read_only on update), type must be `time` (validated at load — the column is TIMESTAMPTZ regardless of the declaration, so any other declared type would silently diverge from the database).

const (
	// AutoOff — the field is not engine-managed (the zero value).
	AutoOff AutoValue = ""
	// AutoLegacy — JSON `true`: creation-timestamp semantics plus the
	// documented literal-`updated_at` refresh magic.
	AutoLegacy AutoValue = "legacy"
	// AutoCreate — JSON `"create"`: explicit creation timestamp, any name.
	AutoCreate AutoValue = "create"
	// AutoUpdate — JSON `"update"`: explicit modification timestamp, any name.
	AutoUpdate AutoValue = "update"
)

func (AutoValue) Enabled added in v0.1.9

func (a AutoValue) Enabled() bool

Enabled reports whether the field is engine-managed at all (any of the three enabled forms). Every consumer that used the old boolean semantics (`fd.Auto` as bool) asks this.

func (AutoValue) MarshalJSON added in v0.1.9

func (a AutoValue) MarshalJSON() ([]byte, error)

MarshalJSON round-trips faithfully: the legacy form marshals back to `true` (the editor's round-trip contract — a schema authored with booleans is re-exported with booleans), the explicit roles as their strings.

func (AutoValue) RefreshesOnUpdate added in v0.1.9

func (a AutoValue) RefreshesOnUpdate(fieldName string) bool

RefreshesOnUpdate reports whether a field declared with this AutoValue and this name is refreshed to now() by every engine update. The single decision point: an explicit "update" role always refreshes; the legacy boolean refreshes only the literal `updated_at` (its documented magic); "create" never refreshes, even on a field named updated_at (the author explicitly opted out).

func (*AutoValue) UnmarshalJSON added in v0.1.9

func (a *AutoValue) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts the boolean legacy form and the explicit string roles. An unrecognized string is preserved verbatim so the validator can reject it with a named, actionable error (a raw json error here would lose the path).

type Condition

type Condition struct {
	Field string `json:"field"`
	Op    string `json:"op"`  // "eq", "neq", "in", etc.
	Val   string `json:"val"` // may reference session vars like "$user_id"
}

Condition is a simple predicate evaluated at request time for row-level filtering.

type FieldDef

type FieldDef struct {
	// Type is one of: string, text, int, int64, float64, bool, uuid, time, json,
	// file. A `file` field (FILES-LINK-S1) stores the id of an uploaded file
	// (the file_id `POST /api/files` returns) as a UUID column carrying a REAL
	// foreign key to the tenant's own files table — the first-class file↔record
	// link. Its on_delete (restrict default | set_null; cascade rejected) governs
	// what happens to the record when the referenced FILE is deleted; deleting
	// the record never deletes the file.
	Type     string `json:"type"`
	Required bool   `json:"required,omitempty"`
	Unique   bool   `json:"unique,omitempty"`
	// Auto declares an engine-managed timestamp. Accepts `true` (legacy:
	// creation timestamp + refresh-on-update ONLY for the literal name
	// `updated_at`), or the explicit, name-independent roles `"create"` /
	// `"update"` (SILENT-CORRUPTION-S1) — see AutoValue in auto.go.
	Auto     AutoValue `json:"auto,omitempty"`
	Enum     []string  `json:"enum,omitempty"`
	Relation string    `json:"relation,omitempty"` // name of the related resource
	// OnDelete declares the referential action of THIS field's foreign key when the
	// referenced (parent) row is deleted (MIG-F1-S1): "restrict" | "cascade" |
	// "set_null". Only meaningful on a field that also declares `relation` (the FK
	// lives on the column, like `unique`/`required`). EMPTY DEFAULTS TO RESTRICT —
	// the safe choice: a delete of a still-referenced row is rejected (409) rather
	// than silently orphaning its children (the integrity bug this closes). set_null
	// requires the column to be nullable (not `required`); validated at load.
	OnDelete string `json:"on_delete,omitempty"`
	// OnUpdate declares the referential action of THIS field's foreign key when the
	// referenced (parent) row's KEY changes (MIG-F1-S5): "restrict" | "cascade" |
	// "set_null". Only meaningful with `relation`. EMPTY DEFAULTS TO NO ACTION — which
	// is what Postgres already records for an FK created without ON UPDATE, so adding
	// this key to an existing schema generates NO churn (a re-provision stays a no-op;
	// see refActionForOnUpdate). This is the deliberate asymmetry with on_delete (whose
	// default is RESTRICT): on_delete shipped RESTRICT from the start, so the live FKs
	// already carry it; on_update is introduced over FKs that already exist with NO
	// ACTION, so its default must match them. set_null requires a nullable column.
	OnUpdate string `json:"on_update,omitempty"`
	// References declares the target COLUMN this field's foreign key points at
	// (MIG-F1-S5). EMPTY DEFAULTS TO "id" (the target's implicit primary key — the only
	// behavior before this key, so retrocompat is total). A non-id value must name a
	// column that is UNIQUE on the target (Postgres requires an FK destination to be a
	// PK or unique column/index); validated at load. Only meaningful with `relation`.
	References string `json:"references,omitempty"`
	// RenamedFrom declares the PREVIOUS name of this field's column (MIG-F1-S2): the
	// migration engine emits `ALTER TABLE … RENAME COLUMN <old> TO <new>`
	// (metadata-only, data preserved, indexes/FK/unique follow the column) instead
	// of the converger's drop+add that stranded the data in the old column. The old
	// name must NOT still be a current field (you cannot rename from a name that
	// still exists); validated at load. Once applied the intent is INERT — a
	// re-provision with it present is a no-op (the old column no longer exists).
	RenamedFrom string `json:"renamed_from,omitempty"`
	Default     any    `json:"default,omitempty"`

	// Declarative validation rules (S44). Pointer types distinguish "absent"
	// from a legitimate zero (min: 0, minLength: 0).
	Min       *float64 `json:"min,omitempty"`       // numeric types: value >= Min
	Max       *float64 `json:"max,omitempty"`       // numeric types: value <= Max
	MinLength *int     `json:"minLength,omitempty"` // string/text: rune count >= MinLength
	MaxLength *int     `json:"maxLength,omitempty"` // string/text: rune count <= MaxLength
	Pattern   string   `json:"pattern,omitempty"`   // string/text: RE2 regex, len <= MaxPatternLength
	Format    string   `json:"format,omitempty"`    // string/text: email | uuid | url | date

	// Accept (FILES-1) — file fields only — is the per-FIELD upload policy: the
	// content types this field will attach. Entries are matched against the
	// file's STORED (magic-byte-sniffed at upload, never client-declared)
	// content type: a bare family name ("image", "audio", "video", "text")
	// matches the whole family (image/*…), "pdf" is a convenience alias for
	// application/pdf, and an entry containing "/" matches that exact type
	// ("application/zip"). JSON accepts a single string or an array. Enforced
	// at ATTACH time (create/update on REST, GraphQL and batch — the upload
	// endpoint is field-agnostic, so the field's policy applies when a record
	// references the file): a violating file_id is a 422 `file_policy` naming
	// what the field accepts. The instance-wide env knobs
	// (APPXIMO_FILES_MAX_BYTES / _ALLOWED_EXT) remain the outer bound at
	// upload.
	Accept StringList `json:"accept,omitempty"`
	// MaxBytes (FILES-1) — file fields only — caps the size of a file this
	// field will attach, in bytes (checked against the stored size at attach
	// time, same 422 file_policy). Must be > 0 when declared.
	MaxBytes int64 `json:"max_bytes,omitempty"`

	// StateMachine (G5) declares the allowed lifecycle transitions of a string
	// status field: which states a row may be CREATED in (Initial) and which moves
	// are permitted between states (Transitions). The engine forces it on create
	// (the initial state must be valid) and on update (a state may only move along a
	// declared transition; a state with no outgoing transitions is terminal /
	// immutable). A field without StateMachine is a free string (unchanged). Only
	// string/text fields; coherent with `enum` if both are declared.
	StateMachine *StateMachine `json:"state_machine,omitempty"`
}

FieldDef describes one field within a resource.

The declarative validation keys (min/max, minLength/maxLength, pattern, format) are ALL optional — a schema that declares none of them behaves exactly as before. They are compiled into a ResourceValidator at schema load (see rules.go); the request path never compiles anything.

func (FieldDef) EffectiveAutoRole added in v0.1.9

func (fd FieldDef) EffectiveAutoRole(fieldName string) string

EffectiveAutoRole names the role an enabled auto field actually plays — "update" when the engine refreshes it, "create" otherwise, "" when not auto. Consumed by the OpenAPI generator (`x-appximo-auto`) so generic tools read the truth instead of guessing from English field names.

func (*FieldDef) FileAcceptMatches

func (fd *FieldDef) FileAcceptMatches(contentType string) bool

FileAcceptMatches reports whether a stored (sniffed) content type satisfies this field's accept list. An empty list accepts everything. An EMPTY stored content type fails a non-empty list closed: a file whose type could not be determined is not evidence it is an image.

func (*FieldDef) HasFilePolicy

func (fd *FieldDef) HasFilePolicy() bool

HasFilePolicy reports whether this file field declares a per-field attach policy (FILES-1) — the gate the write paths check before paying any lookup.

func (FieldDef) ReferencedColumn added in v0.1.5

func (f FieldDef) ReferencedColumn() string

ReferencedColumn resolves the target column this field's foreign key points at: `references` when declared (MIG-F1-S5), else the implicit "id". It is the SINGLE source of that rule — the relation subroute, the ?include= embed compiler (REST and GraphQL share it) and anything else that follows an FK must call this instead of re-deriving the default, so the resolutions can never diverge (PUBLIC-SURFACE-S1: the embed compiler had its own hardcoded "id" and returned null for every FK declaring a non-id `references`, while the subroute followed the FK correctly).

type FieldRuleError

type FieldRuleError struct {
	Field   string `json:"field"`
	Rule    string `json:"rule"`
	Message string `json:"message"`
}

FieldRuleError is one declarative-validation violation, in the exact shape the 422 response body carries inside "fields".

func CoerceJSONFields added in v0.1.10

func CoerceJSONFields(res *ResourceSchema, body map[string]any) []FieldRuleError

CoerceJSONFields normalizes, IN PLACE, every json/jsonb field present in a write body to the ONE representation the column takes, and reports the values that are not JSON:

  • `json` (TEXT): the value becomes canonical compact JSON text — an object/array/number/boolean is encoded (Go's encoding: keys sorted, numbers through float64, the HTTP path's documented limit); a string is read as JSON TEXT (the document's source, the convention Postgres and pgx use for jsonb) and compacted, keeping its numeric text and key order.
  • `jsonb`: the decoded value is left for pgx (it encodes a map/slice as jsonb natively); a string is validated as JSON text (pgx passes it through, and Postgres would answer an anonymous 22P02 for a bad one).

A string that is not valid JSON is a FieldRuleError{Rule: "type"} naming the field — on both types, the same message. `null` is left alone (it is SQL NULL, governed by `required`). Fields the resource does not declare, or declares as any other type, are untouched. Idempotent: a canonical string coerced again stays byte-identical, so a body that goes through two cores (e.g. after a before-hook) is safe. Errors are sorted by field.

func GovernedFieldViolations added in v0.1.9

func GovernedFieldViolations(r *ResourceSchema, body map[string]any, op GovernedOp, role string) []FieldRuleError

GovernedFieldViolations is the ONE implementation of the governed-field write rule. It returns a 422-shaped read_only violation for every governed field present in the body that the operation does not permit, sorted by field name (deterministic responses, the ENG-16 class). An empty result means the body carries no forbidden governed field.

The update-side messages are byte-compatible with what CollectUpdate answered before this file existed (a pinned public contract); the create-side messages additionally say how to make the write legal, because there IS a legal way (ADR-024: name the field, say what to do).

type ForeignKeyDef

type ForeignKeyDef struct {
	Columns    []string `json:"columns"`             // local columns (this resource)
	Target     string   `json:"target"`              // referenced resource
	RefColumns []string `json:"ref_columns"`         // referenced columns on Target (same count as Columns)
	OnDelete   string   `json:"on_delete,omitempty"` // restrict (default) | cascade | set_null
	OnUpdate   string   `json:"on_update,omitempty"` // restrict | cascade | set_null; unset → NO ACTION
}

ForeignKeyDef is one resource-level COMPOSITE foreign key (MIG-F1-S5): a set of local Columns referencing the same number of RefColumns on the Target resource, which must together form the target's PRIMARY KEY or a UNIQUE constraint/index (Postgres requires it; validated at load). OnDelete defaults to RESTRICT (safe — like the field-level relation) and OnUpdate defaults to NO ACTION (no-churn). A single-column FK is the field-level `relation`, not this block.

type GovernedOp added in v0.1.9

type GovernedOp int

GovernedOp selects which write door's contract GovernedFieldViolations enforces.

const (
	// GovernedCreate — a create body: governed fields are rejected unless the
	// resource's `import` declaration grants them to the caller's role.
	GovernedCreate GovernedOp = iota
	// GovernedUpdate — an update body: governed fields are ALWAYS rejected
	// (import is a create-time concept; an existing row's engine-managed
	// values are immutable through every door).
	GovernedUpdate
)

type HookConfig

type HookConfig struct {
	Type          string `json:"type"`                      // "js" | "webhook" | "wasm"
	Script        string `json:"script,omitempty"`          // JS source for type=js
	URL           string `json:"url,omitempty"`             // endpoint for type=webhook
	HMACSecretEnv string `json:"hmac_secret_env,omitempty"` // env var holding the HMAC secret
	WasmModule    string `json:"wasm_module,omitempty"`     // for type=wasm: name of the pre-loaded module
	WasmFn        string `json:"wasm_fn,omitempty"`         // for type=wasm: function to call (default "transform")
	Timeout       string `json:"timeout,omitempty"`         // execution budget, e.g. "500ms" (default "500ms")
}

HookConfig defines a lifecycle hook on a resource (before_create, after_create, etc.).

type ImportConfig added in v0.1.9

type ImportConfig struct {
	// Roles is the closed list of RBAC roles granted import. Required,
	// non-empty; every entry must be a role the schema's rbac block declares
	// (a typo is a load error, never a silently dead grant — the ENG-27 mold).
	// There is no wildcard: an auditor reads exactly who may import.
	Roles []string `json:"roles"`
	// Fields optionally narrows WHICH governed fields the grant covers, e.g.
	// ["id"] for client-generated ids without opening timestamp forgery.
	// Each entry must be "id" or an auto field of the resource. Absent = the
	// full governed set.
	Fields []string `json:"fields,omitempty"`
}

ImportConfig is the resource-level `import` declaration: which roles may supply the engine-governed fields (`id` + every `auto` field) when CREATING rows, and optionally which subset of those fields.

type IndexDef

type IndexDef struct {
	Fields []string `json:"fields"`
	Unique bool     `json:"unique,omitempty"`

	// Method is the Postgres access method (LIBRARY-GAPS-S1): "btree" (the
	// default, and what every pre-S1 index used) or "gin". EMPTY MEANS BTREE, so
	// adding this key to an existing schema generates zero churn — the introspector
	// reads pg_am.amname back, and an unchanged index diffs identical.
	//
	// "gin" is the index that makes jsonb containment (`@>`) an index lookup
	// instead of a sequential scan; it is accepted ONLY over `jsonb` columns
	// (validated at load, never a runtime surprise) and never with unique (GIN
	// cannot enforce uniqueness).
	Method string `json:"method,omitempty"`

	// Opclass is the operator class applied to EVERY listed column, e.g.
	// "jsonb_path_ops" for a GIN index that only ever answers `@>` (smaller and
	// faster than the default jsonb_ops, at the cost of key-existence `?`
	// queries). Only valid with an explicit method, and only a value from the
	// method's closed allowlist (validIndexOpclasses) — it is rendered into DDL,
	// so it is never free-form text. Invisible to the diff (the introspector
	// cannot read an opclass back from the index key list), which is exactly why
	// declaring one causes no migration churn.
	Opclass string `json:"opclass,omitempty"`
}

IndexDef specifies an index on one or more fields (a composite index when more than one). Applied at tenant migration as CREATE [UNIQUE] INDEX IF NOT EXISTS over the listed columns (BUGS-V1 — previously parsed but not applied).

type RBACPolicy

type RBACPolicy struct {
	Roles map[string]RolePolicy `json:"roles"`

	// Public declares the ANONYMOUS surface (PUBLIC-SURFACE-S1, ADR-026): the
	// resources an UNAUTHENTICATED request may READ, each with its own row
	// condition and field allowlist — a blog's published articles, a catalogue,
	// a landing — with no Go and no token. It reuses the per-resource
	// permission shape (`{ "articulos": { "actions": ["read"], "conditions":
	// {…}, "fields": […] } }`) and compiles into the ONE existing evaluator as
	// the reserved role rbac.PublicRoleName, so every enforcement surface
	// (REST, GraphQL, aggregates, embeds, SSE, files) is the same code path an
	// authenticated role uses — never a second implementation.
	//
	// Constraints, validated at load (deny-by-default stays intact):
	//   - actions must be exactly ["read"] — the anonymous surface is
	//     read-only in v1 (an anonymous write is a spam/abuse surface that
	//     needs its own design; ADR-026 records the reasoning).
	//   - conditions.val must be a LITERAL ("published") — $user_id /
	//     $external_client_id name an identity an anonymous request does not
	//     have, so they are load errors here.
	//   - the built-in "files" store is grantable (actions-only, like any
	//     role) — the public-image pattern.
	//   - absent block ⇒ NOTHING anonymous, exactly today's behavior. The key
	//     is NEW, and unknown keys have always rejected the schema, so no
	//     existing schema can change meaning by upgrading the engine.
	//
	// Anonymous requests are additionally throttled by the public-route rate
	// limiter (APPXIMO_PUBLIC_ROUTE_RPS) and NEVER served from or stored in
	// the response cache.
	Public map[string]ResourcePermission `json:"public,omitempty"`
}

RBACPolicy holds all role definitions for a resource set.

type RelationDef

type RelationDef struct {
	Type     string `json:"type"`                // has_many | belongs_to | many_to_many
	Target   string `json:"target"`              // related resource name
	FK       string `json:"fk"`                  // foreign-key column (see Type for which table)
	Through  string `json:"through,omitempty"`   // junction table (many_to_many only)
	TargetFK string `json:"target_fk,omitempty"` // target's FK column in Through (many_to_many only)
	Limit    int    `json:"limit,omitempty"`     // top-N children per parent (0 → DefaultEmbedLimit)
}

RelationDef declares one relation between resources (RELATIONS-V1, ADR-019), served nested in a single round-trip via json_agg + LATERAL when a client opts in with ?include=. Declaration is EXPLICIT (no FK-catalog inference): the relation compiles once at boot from the shared schema, never per request.

has_many     — parent → many children: FK lives on the TARGET (child) table,
               matched against the parent's id (child.<FK> = parent.id).
belongs_to   — child → its parent: FK lives on THIS (source) table, matched
               against the target's id (target.id = source.<FK>).
many_to_many — both sides via a junction table: Through holds FK (this side's
               id) and TargetFK (the target's id).

type ResourcePermission

type ResourcePermission struct {
	Actions          []string   `json:"actions"`
	Conditions       *Condition `json:"conditions,omitempty"`
	ConditionActions []string   `json:"condition_actions,omitempty"` // actions the condition gates (empty = all)
	Fields           []string   `json:"fields,omitempty"`            // per-resource field allowlist
}

ResourcePermission is one role's grant on one resource (G2). Mirrors rbac.ResourcePermission so the schema→rbac.Policy JSON round-trip is lossless.

type ResourceSchema

type ResourceSchema struct {
	Fields  map[string]FieldDef   `json:"fields"`
	Hooks   map[string]HookConfig `json:"hooks,omitempty"`
	Indexes []IndexDef            `json:"indexes,omitempty"`

	// ForeignKeys declares COMPOSITE (multi-column) foreign keys at the resource
	// level (MIG-F1-S5). A single-column FK is the field-level `relation` (which is
	// inherently 1 column = 1 column → target.id, optionally → a unique non-id column
	// via `references`); a composite FK references MULTIPLE columns of the target's
	// composite PK/unique, which does not fit the field-level model, so it gets an
	// explicit block. Each entry is one constraint over (columns) → target(ref_columns)
	// with its own on_delete/on_update. A resource that omits this key behaves exactly
	// as before. See ForeignKeyDef.
	ForeignKeys []ForeignKeyDef `json:"foreign_keys,omitempty"`

	// RenamedFrom declares the PREVIOUS name of this resource's table (MIG-F1-S2):
	// the migration engine emits `ALTER TABLE <old> RENAME TO <new>` (metadata-only,
	// data + indexes + constraints preserved) instead of the converger's drop+add
	// that stranded the data. The old name must NOT still be a current resource
	// (you cannot rename from a name that still exists); validated at load. Once the
	// rename is applied the intent is INERT — re-provisioning with it still present
	// is a no-op (the old table no longer exists), so it is safe to leave in place.
	RenamedFrom string `json:"renamed_from,omitempty"`

	// Relations is the opt-in set of declarative relations (RELATIONS-V1,
	// ADR-019) keyed by the embed name exposed to clients (the key used in
	// ?include=<name> and the GraphQL nested field). A resource that omits this
	// key serves exactly as before and pays zero overhead on the read path —
	// relations are served ONLY when explicitly requested via ?include=.
	Relations map[string]RelationDef `json:"relations,omitempty"`

	// Events is the opt-in list of write actions that emit a transactional
	// outbox event (CRUD-EMIT-V1). Valid values: "create", "update", "delete"
	// (present-tense, matching the RBAC action vocabulary). For a declared
	// action the engine writes a row to public.outbox IN THE SAME TRANSACTION as
	// the CRUD write, with topic "{resource}.{created|updated|deleted}". A
	// resource that omits this key emits nothing and pays zero overhead. See
	// EmitActions / EmitTopic.
	Events []string `json:"events,omitempty"`

	// Import declares which roles may supply the engine-governed fields (the
	// implicit `id` + every `auto` timestamp) when CREATING rows — the data-
	// import / fixture-restore contract (WRITE-ASYMMETRY-S1). Absent, the
	// governed fields are rejected on create at every door, exactly as they
	// always were on update. See ImportConfig / GovernedFieldViolations.
	Import *ImportConfig `json:"import,omitempty"`
}

ResourceSchema defines a single entity (table) with its fields, hooks, and indexes.

func (*ResourceSchema) AutoRefreshColumns added in v0.1.9

func (r *ResourceSchema) AutoRefreshColumns() []string

AutoRefreshColumns returns, sorted, the columns of this resource the engine forces to now() on every update — the SINGLE source the REST update core, the batch transaction, and Ctx.Update all consume, so no update path can diverge on which timestamps refresh (SILENT-CORRUPTION-S1: two of the three used to hardcode the literal `updated_at` and the third refreshed nothing).

func (ResourceSchema) EmitsOn

func (r ResourceSchema) EmitsOn(action string) bool

EmitsOn reports whether the resource opted into emitting an event for action ("create" | "update" | "delete"). Computed from ResourceSchema.Events.

func (*ResourceSchema) GovernedWriteFields added in v0.1.9

func (r *ResourceSchema) GovernedWriteFields() []string

GovernedWriteFields returns, sorted, the fields of this resource whose values the engine owns on the write path: the implicit "id" plus every field with an enabled `auto` role.

func (*ResourceSchema) HasJSONFields added in v0.1.10

func (r *ResourceSchema) HasJSONFields() bool

HasJSONFields reports whether the resource declares any json or jsonb field — the precomputed gate for the write-side coercion (one map scan at boot, nothing per request for a resource without them).

func (*ResourceSchema) ImportDeclaredFields added in v0.1.9

func (r *ResourceSchema) ImportDeclaredFields() []string

ImportDeclaredFields returns, sorted, the governed fields this resource's `import` declaration actually covers — the declared subset when one is given, else the full governed set. Empty when the resource declares no import. Consumed by the GraphQL input-type builder (which fields exist on the create input at all) and the OpenAPI generator (`x-appximo-import`), so both surfaces publish exactly what the one predicate enforces.

func (*ResourceSchema) ImportableOnCreate added in v0.1.9

func (r *ResourceSchema) ImportableOnCreate(role, field string) bool

ImportableOnCreate reports whether `role` may supply governed field `field` on create under this resource's `import` declaration. False when there is no declaration, when the role is not granted, or when a declared fields subset excludes the field.

func (*ResourceSchema) IsGovernedWriteField added in v0.1.9

func (r *ResourceSchema) IsGovernedWriteField(name string) bool

IsGovernedWriteField reports whether a write-body key is engine-governed — the implicit primary key or an auto timestamp. The predicate every door's own key loop uses to skip keys the single source already judged.

func (*ResourceSchema) JSONTextColumns added in v0.1.10

func (r *ResourceSchema) JSONTextColumns() []string

JSONTextColumns returns, sorted, the `json` (TEXT-backed) fields of the resource — the columns whose stored text must be promoted to a native JSON value on the way out. Empty for the vast majority of resources, which is what keeps the read path free for them: callers precompute it once per resource at boot and skip everything when it is empty.

type ResourceValidator

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

ResourceValidator holds the precompiled validation for one resource: the regexes are compiled, the enum sets built, and the required list resolved ONCE at schema load. The request path only executes these closures.

func CompileRules

func CompileRules(res *ResourceSchema) *ResourceValidator

CompileRules builds the ResourceValidator for one resource. It never panics: an invalid pattern (only reachable when schema.Validate was skipped, since Validate rejects it at load) compiles to a fail-closed rule that rejects every value for that field — never a silently dropped rule.

func (*ResourceValidator) ApplyDefaults

func (rv *ResourceValidator) ApplyDefaults(body map[string]any)

ApplyDefaults fills, in place, any field that declares a default and is ABSENT from body (a present key — even an explicit null — is left as the caller set it, matching SQL DEFAULT, which applies only when a column is omitted). Called on CREATE only, BEFORE required-field validation, so a required field with a default is satisfied by the default while a required field without one still 422s. A resource with no defaults pays a single length check (the create gate).

func (*ResourceValidator) ValidateInitialStates

func (rv *ResourceValidator) ValidateInitialStates(body map[string]any) []FieldRuleError

ValidateInitialStates checks, for a CREATE body, that every present state-machine field holds one of its INITIAL states — a row may not be created already advanced in its lifecycle. Call it on create only, after ValidateWrite (which already rejected unknown states). No-op for a resource with no state machines.

func (*ResourceValidator) ValidateWrite

func (rv *ResourceValidator) ValidateWrite(body map[string]any, requireAll bool) []FieldRuleError

ValidateWrite checks a decoded JSON body against the precompiled rules and returns ALL violations (never just the first). requireAll is true for POST (create) and PUT (full replace): every required non-auto field must be present and non-null. PATCH passes false — only the fields present in the body are validated. A null value is skipped by the per-field rules (null semantics belong to required / the update path).

type RolePolicy

type RolePolicy struct {
	Resources  json.RawMessage `json:"resources,omitempty"`
	Actions    []string        `json:"actions,omitempty"`
	Conditions *Condition      `json:"conditions,omitempty"`
	Fields     []string        `json:"fields,omitempty"` // field-level allowlist (read-only roles)

	// Permissions is the per-resource form (G2): resource name → its grant. Empty for
	// a legacy role; omitempty keeps the marshalled legacy policy byte-identical so the
	// schema→rbac.Policy round-trip is unchanged for every existing schema.
	Permissions map[string]ResourcePermission `json:"permissions,omitempty"`

	// Routes grants access to CUSTOM ROUTES — endpoints a Go backend registers with
	// (*App).Register, whose first /api/ segment the engine authorizes as a VIRTUAL
	// resource (LIBRARY-GAPS-S1). It is keyed by that segment, with its own actions:
	//
	//	"routes": { "checkout": { "actions": ["create"] } }   → POST /api/checkout
	//
	// It is ORTHOGONAL to resources/permissions — a role may declare both, because
	// they govern different namespaces (real tables vs registered endpoints). That
	// is the whole point: before this key, a role using per-resource `permissions`
	// could not reach ANY custom route (every permissions key is checked against a
	// real resource), so "owner-scoped end users + a custom action endpoint" — a
	// customer with their own orders AND a checkout — was inexpressible.
	//
	// SEMANTICS, deliberately narrow:
	//   - AUTHORITATIVE: for a segment listed here, this entry decides. It can only
	//     narrow a wildcard role, never widen one — a segment NOT listed falls
	//     through to the role's normal resources/permissions evaluation, so
	//     deny-by-default is untouched.
	//   - NO conditions, NO field allowlist. A virtual segment has no rows and no
	//     columns; a row filter would be injected into SQL for a table that does not
	//     exist. Declaring either is a LOAD error, never a silent no-op.
	//   - Validated at BOOT against the REGISTERED routes: a grant for a segment no
	//     route serves (or an action no registered method provides) fails the boot
	//     with a clear message, instead of a confusing 403 at request time.
	// See docs/adr/ADR-021-custom-route-authorization.md.
	Routes map[string]RouteGrant `json:"routes,omitempty"`
}

RolePolicy defines what a role can do. Resources is json.RawMessage because it can be the string "*" or an array of resource names.

A role is expressed in ONE of two mutually-exclusive forms (G2):

  • Role-global (legacy): Resources + Actions + (optional) Conditions/Fields — the single condition/allowlist applies to EVERY listed resource (unchanged).
  • Per-resource: a Permissions map where each resource carries its OWN actions, condition and field allowlist (workspace/participation/owner scoping). When present it is the sole source of truth (deny-by-default for absent resources).

Declaring both forms on one role is rejected at validation.

type RouteGrant

type RouteGrant struct {
	Actions []string `json:"actions"`
}

RouteGrant is one role's grant on ONE custom-route segment (LIBRARY-GAPS-S1). Actions are the same vocabulary as everywhere else (read/create/update/delete/*), mapped from the HTTP method the middleware already derives: GET→read, POST→create, PUT|PATCH→update, DELETE→delete. There is deliberately no conditions/fields key — see RolePolicy.Routes.

type StateMachine

type StateMachine struct {
	Initial     []string            `json:"initial"`
	Transitions map[string][]string `json:"transitions"`
}

StateMachine is the declarative lifecycle of a status field (G5). Initial is the set of states a row may be created in; Transitions maps each state to the states it may move to (an absent key or empty list ⇒ terminal). `initial` accepts a single string or an array in JSON; it is always a slice after parsing.

func (*StateMachine) IsInitial

func (sm *StateMachine) IsInitial(s string) bool

IsInitial reports whether s is a state a row may be created in.

func (*StateMachine) KnownStates

func (sm *StateMachine) KnownStates() map[string]bool

KnownStates returns the set of every state the machine references (initial states, transition sources, and transition targets) — the universe of valid state values.

func (*StateMachine) OriginsOf

func (sm *StateMachine) OriginsOf(target string) []string

OriginsOf returns the states from which a transition to target is declared (the reverse of Transitions), sorted for deterministic SQL. Empty ⇒ no state may transition INTO target (it is only reachable as an initial state, if at all).

func (*StateMachine) UnmarshalJSON

func (sm *StateMachine) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts `initial` as either a string ("pending") or an array (["pending","draft"]), normalizing to a slice — the rest is plain.

type StringList

type StringList []string

StringList is a []string that additionally accepts a single JSON string ("image" ⇒ ["image"]) — the same author-friendly flexibility as state_machine's `initial`.

func (*StringList) UnmarshalJSON

func (sl *StringList) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts a string or an array of strings.

type StructuredError

type StructuredError struct {
	Path     string   `json:"path"`               // dotted location, e.g. resources.posts.fields.author_id.references
	Rule     string   `json:"rule"`               // machine-readable category, e.g. invalid_type
	Message  string   `json:"message"`            // human-readable explanation
	Expected []string `json:"expected,omitempty"` // allowed values, when a closed set
	Got      string   `json:"got,omitempty"`      // the offending value, when applicable
	Fix      string   `json:"fix,omitempty"`      // how to correct it
	Source   string   `json:"source"`             // "metaschema" (structural) | "semantic" (cross-reference)
}

StructuredError is one validation failure in a form an LLM can correct from.

type ValidationError

type ValidationError struct {
	Field   string
	Message string

	// Structured metadata for the LLM-friendly report (AI-F0-S2). All optional —
	// an error that predates the enrichment leaves them empty and the report derives
	// a fallback Rule from the Field path. They are NOT part of Error(), so the human
	// (interactive) output is byte-unchanged.
	Rule     string   `json:"rule,omitempty"`     // machine-readable category, e.g. "invalid_type"
	Fix      string   `json:"fix,omitempty"`      // how to correct it (an LLM can apply this)
	Expected []string `json:"expected,omitempty"` // the allowed values, when a closed set
	Got      string   `json:"got,omitempty"`      // the offending value, when applicable
}

func CheckUnknownKeys

func CheckUnknownKeys(raw json.RawMessage) []ValidationError

CheckUnknownKeys validates the RAW schema JSON against the known key set of every level of the schema contract. Go's json.Unmarshal silently drops keys the structs don't declare, which turns typos into silent no-ops — fatal for a product whose entire contract IS the schema (a user writing "webhooks" instead of "hooks" must get an error, not a quietly dead feature).

It must be called with the raw bytes BEFORE/alongside unmarshalling (after parsing, unknown keys are already gone). Returns one error per unknown key, each listing the valid keys for that level. WorkflowStep.Config is the one deliberately free-form map (step-specific configuration).

func Validate

func Validate(s *APISchema) []ValidationError

func ValidateAgainstMetaSchema

func ValidateAgainstMetaSchema(raw []byte) []ValidationError

ValidateAgainstMetaSchema validates raw schema bytes against the embedded JSON Schema meta-schema and returns one ValidationError per STRUCTURAL violation (Field is the JSON-pointer instance location, Message the reason). It is ADDITIVE to Validate, never a replacement: it catches the structural errors JSON Schema can express (early, precise, engine-free), while Validate stays the semantic authority. Returns nil when the document is structurally valid.

func Warnings

func Warnings(s *APISchema) []ValidationError

Warnings returns the non-blocking findings for a schema. A nil/empty result means nothing suspicious was found — never that the schema is correct.

func (ValidationError) Error

func (e ValidationError) Error() string

type ValidationReport

type ValidationReport struct {
	Valid  bool              `json:"valid"`
	Errors []StructuredError `json:"errors"`
	// Warnings are findings that do NOT make the schema invalid but almost always
	// mean it will not do what its author intended (SCHEMA-5). They are reported in
	// the same shape as errors so the AI correction loop can act on them with the
	// same code path, and `valid` stays true — a warning never blocks a deploy.
	//
	// ALWAYS EMITTED, even when empty — deliberately not omitempty. With the key
	// absent, "this schema has no warnings" and "this binary has no warnings
	// feature" are the same JSON, so a caller cannot tell a clean bill of health
	// from an old engine. A third-party agent reported exactly that: it could not
	// confirm zero warnings as a POSITIVE signal and pre-empted the known rules by
	// hand instead. `errors` has always been emitted unconditionally; this makes
	// the two halves of the report symmetric.
	Warnings []StructuredError `json:"warnings"`
}

ValidationReport is the top-level result for a candidate schema.

func ValidateReport

func ValidateReport(raw []byte) ValidationReport

ValidateReport runs BOTH validators over raw schema bytes and returns a unified, LLM-friendly report. The meta-schema provides the structural errors (types, enums, patterns, unknown/missing keys, RBAC form); the Go validator provides the semantic cross-reference errors (existence, uniqueness, type compatibility, coherence). A path that already has a structural error suppresses the semantic error at the SAME path (they are the same problem reported by two layers), so the report is deduplicated without losing either layer's unique findings.

type WorkflowSchema

type WorkflowSchema struct {
	Trigger WorkflowTrigger `json:"trigger"`
	Steps   []WorkflowStep  `json:"steps,omitempty"`
}

WorkflowSchema is one named workflow: a trigger plus an ordered list of steps.

type WorkflowStep

type WorkflowStep struct {
	Name   string         `json:"name"`
	Type   string         `json:"type"`             // "hook" | "webhook" | "wasm" | "branch"
	Ref    string         `json:"ref,omitempty"`    // hook/module/url reference
	Config map[string]any `json:"config,omitempty"` // step-specific configuration
	Next   string         `json:"next,omitempty"`   // name of the next step (or branch target)
}

WorkflowStep is a single node in a workflow pipeline.

type WorkflowTrigger

type WorkflowTrigger struct {
	Type     string `json:"type"`               // "event" | "cron" | "http"
	Event    string `json:"event,omitempty"`    // e.g. "after_create" (with Resource)
	Resource string `json:"resource,omitempty"` // resource the event applies to
	Cron     string `json:"cron,omitempty"`     // cron expression for type=cron
	Path     string `json:"path,omitempty"`     // route for type=http
}

WorkflowTrigger describes what starts a workflow.

Jump to

Keyboard shortcuts

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