registry

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Jul 3, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package registry is fabriq's declarative schema registry. Entities are described once as EntitySpecs — relational shape via a grove-tagged model, fabric-only concerns (graph mapping, search mapping, subscription scopes, CRDT plane) layered on top — and everything else is derived: projection mappings, channel names, tenant-scoped store names, conformance checks.

The registry never generates DDL; grove migrations remain the schema authority and the registry-conformance test is the bridge.

Index

Constants

View Source
const (
	VerbCreated = "created"
	VerbUpdated = "updated"
	VerbDeleted = "deleted"
)

Event verbs. EventType(entity, verb) is the only way event type strings are minted, so appliers and consumers can rely on the shape.

View Source
const (
	ColumnID      = "id"
	ColumnTenant  = "tenant_id"
	ColumnVersion = "version"
	// ColumnScope is the optional secondary-scope column. It is nullable: a NULL
	// scope_id means the row is "shared" and visible in all scoped and unscoped
	// reads within the tenant. A non-NULL value restricts the row to that scope
	// (plus unscoped reads). Consumers that want secondary scoping must declare
	// this column in their entity model (grove tag: `db:"scope_id"`) and apply
	// migrations.ScopeAwareTenantPolicy to the table.
	ColumnScope = "scope_id"
)

Structural column names used by fabriq adapters.

ColumnID, ColumnTenant, and ColumnVersion are REQUIRED on every fabriq-managed entity table: they make tenancy (RLS) and optimistic concurrency enforceable by construction.

ColumnScope is OPTIONAL. When a table carries it, consumers may partition rows within a tenant into a secondary scope (e.g. "project" within a "workspace") by adding the column and applying migrations.ScopeAwareTenantPolicy. Fabriq stamped-write paths detect presence of the column and fill it from the context scope; read paths enforce the soft filter automatically.

Variables

View Source
var ByID = Scope{Name: "id"}

ByID scopes deltas to a single aggregate: changes:{tenant}:id:{aggID}.

View Source
var ByTenant = Scope{Name: "tenant"}

ByTenant scopes deltas to everything in the tenant.

Functions

func ChannelName

func ChannelName(tenantID string, scope Scope, id string) string

ChannelName derives a subscription channel. Channels are tenant-prefixed and only ever constructed here: changes:{tenant}:{scope}:{id}.

func CoerceRow added in v0.0.4

func CoerceRow(ent *Entity, vals map[string]any) error

CoerceRow coerces and type-checks a dynamic entity's column-keyed values in place against their declared ColumnTypes, replacing each value with its canonical Go type. It is a no-op for Go-model entities (their struct fields are already typed) and skips the structural columns id/tenant_id/version, which are stamped by the executor / materializer rather than typed by the caller. A returned error names the offending entity and column.

func CoerceToColumn added in v0.0.4

func CoerceToColumn(t ColumnType, v any) (any, error)

CoerceToColumn validates v against the declared column type t and returns the canonical Go value for that type. A nil v passes through unchanged — column nullability is enforced separately by the required-field check. It returns an error when v cannot represent t.

Coercion is lenient where it is safe and lossless: JSON numbers decode to float64, so ColInt accepts any integral float and ColFloat accepts any integer; ColTime accepts an RFC3339 string. ColJSON accepts any value.

func DocChannelName

func DocChannelName(tenantID, docID string) string

DocChannelName derives the RAW document-sync channel for one document: doc:{tenant}:{docID}. Frames on it are never conflated.

func DocPresenceChannelName added in v0.0.5

func DocPresenceChannelName(tenantID, docID string) string

DocPresenceChannelName derives the RAW awareness channel for one document: docpresence:{tenant}:{docID}. Presence frames are ephemeral — capped stream, tailed from now, never persisted, no delivery guarantees.

func EventType

func EventType(entity, verb string) string

EventType derives the canonical event type for an entity and verb, e.g. "asset.updated".

func GraphName

func GraphName(tenantID string) string

GraphName derives the per-tenant graph name (FalkorDB key).

func GraphNameVersioned

func GraphNameVersioned(tenantID string, version int) string

GraphNameVersioned derives a blue-green build target for rebuilds; the live pointer is tracked in projection_state.

func IsReservedColumn added in v0.0.4

func IsReservedColumn(name string) bool

IsReservedColumn reports whether name is a structural column fabriq manages automatically (id, tenant_id, version). Entity specs must not declare a domain column with a reserved name — fabriq provides and stamps these. Note scope_id is NOT reserved: it is an opt-in column consumers declare themselves.

func SearchIndexAlias

func SearchIndexAlias(tenantID, base string) string

SearchIndexAlias derives the stable per-tenant alias for a search index; reads and writes go through the alias, rebuilds swap it atomically.

func SearchIndexVersioned

func SearchIndexVersioned(tenantID, base string, version int) string

SearchIndexVersioned derives the concrete versioned index behind the alias.

func StreamKey

func StreamKey() string

StreamKey is the single Redis stream all domain events are relayed to; projections consume it through their own consumer groups.

Types

type Binding

type Binding struct {
	Table         string
	Columns       []string
	PK            string
	TenantColumn  string
	VersionColumn string
	// contains filtered or unexported fields
}

Binding is the compiled relational shape of an entity, derived from its grove-tagged model at registration time, or from a DynamicSchema.

func (*Binding) DynColumn added in v0.0.4

func (b *Binding) DynColumn(col string) (DynamicColumn, bool)

DynColumn returns the declared dynamic column for col. ok is false when this binding is not dynamic or col is not one of its declared domain columns.

func (*Binding) HasColumn

func (b *Binding) HasColumn(col string) bool

HasColumn reports whether the bound table has the given column.

func (*Binding) IsDynamic

func (b *Binding) IsDynamic() bool

IsDynamic reports whether this binding describes a runtime-defined entity (declared via DynamicSchema rather than a Go Model).

func (*Binding) ModelType

func (b *Binding) ModelType() reflect.Type

ModelType returns the bound struct type (not pointer).

func (*Binding) NewModel

func (b *Binding) NewModel() any

NewModel returns a pointer to a fresh zero value of the bound model type.

func (*Binding) Populate

func (b *Binding) Populate(model any, vals map[string]any) error

Populate sets the model's fields from column-keyed values (the reverse of ValuesByColumn). Numeric JSON widening (float64 -> int64 etc.) is converted; incompatible types error.

func (*Binding) Required

func (b *Binding) Required() []string

Required returns the non-structural columns that must be provided on create/update: NOT NULL, no default, not auto-generated.

func (*Binding) ValuesByColumn

func (b *Binding) ValuesByColumn(model any) (map[string]any, error)

ValuesByColumn extracts the model's field values keyed by column name. This is the canonical payload shape: event payloads, graph node props and search documents are all column-keyed. For dynamic entities model must be a map[string]any; unknown keys are dropped.

type CRDTSpec

type CRDTSpec struct {
	Engine        string        // engine reference, e.g. "grove-crdt"
	SnapshotEvery int           // compact after this many updates
	QuietWindow   time.Duration // idle window before materialization

	// ArchiveHistory opts this entity's documents into offloading sealed
	// update history to the blob plane on Compact (latest state stays in the
	// DB). nil = inherit the global Config.Documents.ArchiveHistory default;
	// a non-nil pointer overrides it per entity.
	ArchiveHistory *bool
}

CRDTSpec configures the document plane for KindDocument entities. The merge engine comes from grove's crdt packages — referenced, not reimplemented.

type CacheSpec

type CacheSpec struct {
	TTL    time.Duration
	Scoped bool
}

CacheSpec opts an entity into the read-through row cache (P3). Nil (the zero value on EntitySpec) means caching is disabled for the entity. Scoped picks the cache partition: true => tenant+scope, false => tenant. TTL bounds each cached row (0 = no expiry; per-id eviction on write still applies).

type ColumnType

type ColumnType int

ColumnType is the neutral column type set for dynamic entities; adapters map it to engine SQL types.

const (
	ColText ColumnType = iota
	ColInt
	ColFloat
	ColBool
	ColTime
	ColJSON
)

func (ColumnType) String added in v0.0.4

func (t ColumnType) String() string

String renders the column type name used in validation error messages.

type DistillSpec

type DistillSpec struct {
	SourceFields []string
	Text         func(vals map[string]any) string
	Scopes       []string
	Budget       int
}

DistillSpec opts an entity into context distillation. Declarative metadata only — the distillation layer supplies the summarization model and the guard. SourceFields names the columns concatenated into the L0 source text; Text, when set, overrides SourceFields. Scopes names the declared scope names that form L1 backbone digest nodes. Budget is the L0 summary token budget (0 = config default).

type DynamicColumn

type DynamicColumn struct {
	Name    string
	Type    ColumnType
	NotNull bool
	// Default is an optional SQL default EXPRESSION (e.g. "now()", "'pending'",
	// "0"). It is interpolated verbatim into DDL and is intentionally NOT
	// identifier-validated (it is an expression, not an identifier), so it must
	// be a trusted, control-plane value — never a user-supplied string. Same
	// trust level as hand-written migration SQL.
	Default string
}

DynamicColumn is one domain column of a runtime-defined entity.

type DynamicIndex

type DynamicIndex struct {
	Name    string
	Columns []string
	Unique  bool
}

DynamicIndex is an optional secondary index on a dynamic entity.

type DynamicSchema

type DynamicSchema struct {
	Table   string
	Columns []DynamicColumn
	Indexes []DynamicIndex

	// NoTypeCheck opts the entire entity out of application-level payload type
	// validation on the command plane. Column values are then passed through
	// unchanged and only the database column types enforce shape. Zero value
	// (false) = validate.
	NoTypeCheck bool
}

DynamicSchema describes an entity defined at runtime instead of by a Go Model. Mutually exclusive with EntitySpec.Model. fabriq injects the structural columns (id, tenant_id, version); declare only domain columns.

type EdgeSpec

type EdgeSpec struct {
	Field  string // FK column on this entity's table
	Rel    string // relationship type, e.g. "LOCATED_AT"
	Target string // registry name of the target entity
}

EdgeSpec maps a foreign-key column to a graph relationship.

type EmbedSpec

type EmbedSpec struct {
	Fields []string
	Text   func(vals map[string]any) string
}

EmbedSpec opts an entity into vector embedding. It is declarative metadata only — the agent layer supplies the embedding model. Fields names the columns whose values are concatenated into the embed text; Text, when set, overrides Fields and builds the text from column values.

type Entity

type Entity struct {
	Spec    EntitySpec
	Binding *Binding
}

Entity is a registered, compiled spec: the declarative EntitySpec plus its relational Binding.

type EntitySpec

type EntitySpec struct {
	Name      string
	Kind      Kind
	Model     any
	GraphNode string         // graph label; empty = not projected to the graph
	GraphEdge *GraphEdgeSpec // when set, the entity projects as a relationship
	Edges     []EdgeSpec
	Search    SearchSpec
	Subscribe []Scope
	CRDT      *CRDTSpec

	// Schema declares a runtime-defined ("dynamic") entity instead of Model.
	// Exactly one of Model or Schema must be set.
	Schema *DynamicSchema

	// Validate, when set, runs after structural validation on every
	// create/update/upsert with the column-keyed payload. Fabriq attaches
	// no meaning to the values; consumers enforce their own invariants
	// (enum membership, checksums, cross-field rules).
	Validate func(vals map[string]any) error

	// Live opts the entity into the maintained-result-set live query engine.
	// Nil (the zero value) means live queries are disabled for this entity.
	Live *LiveSpec

	// Cache opts the entity into the read-through row cache. Nil = not cached.
	Cache *CacheSpec

	// Embed opts the entity into vector embedding (auto-indexing). Nil = not embedded.
	Embed *EmbedSpec

	// Distill opts the entity into context distillation: each row gets an
	// L0 digest summary; declared Scopes form L1 backbone nodes. Nil = not
	// distilled. The distillation layer supplies the Summarizer/Guard.
	Distill *DistillSpec
}

EntitySpec declares one entity. Model must be a grove-tagged struct pointer such as (*domain.Asset)(nil); its table and columns are bound at registration.

type GraphEdgeSpec

type GraphEdgeSpec struct {
	TypeField   string
	SourceField string
	TargetField string
	SourceLabel string
	TargetLabel string
	PropFields  []string
}

GraphEdgeSpec maps a reified-edge ENTITY (rows that ARE relationships) into the graph. Endpoints are matched by id under their identity labels; the rel type comes from a column value. General: reified relationships (membership, grant, subscription) are a common pattern, not specific to any domain.

type Kind

type Kind int

Kind classifies how an entity is written.

const (
	// KindAggregate entities are written exclusively through the command
	// plane: one transactional write, one versioned outbox event.
	KindAggregate Kind = iota

	// KindDocument entities are collaborative CRDT documents: updates land
	// in the append-only document plane and are periodically materialized
	// into an ordinary versioned domain event. The plane's implementation
	// is deferred; the seam exists from phase 1.
	KindDocument
)

func (Kind) String

func (k Kind) String() string

type LiveSpec

type LiveSpec struct {
	Filterable []string // columns allowed in Where (empty = all)
	Sortable   []string // columns allowed in Sort (empty = all)
	MaxWindow  int      // cap on Limit (0 = engine default)
}

LiveSpec opts an entity into the live query engine (nil = disabled). Filterable/Sortable default to all columns when empty; columns are validated against the model at registration.

type Registry

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

Registry holds all registered entities. Registration happens at startup; lookups are concurrent and read-only afterwards.

func New

func New() *Registry

New returns an empty registry.

func (*Registry) All

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

All returns every registered entity, sorted by name for determinism.

func (*Registry) Get

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

Get returns the entity registered under name.

func (*Registry) GetByModelType

func (r *Registry) GetByModelType(t reflect.Type) (*Entity, bool)

GetByModelType returns the entity bound to the given model struct type; it powers hydration-target inference in TraverseAndHydrate.

func (*Registry) MustRegister

func (r *Registry) MustRegister(spec EntitySpec)

MustRegister is Register that panics; for static wiring in domain packs.

func (*Registry) Register

func (r *Registry) Register(spec EntitySpec) error

Register compiles and validates a spec. Cross-entity references (edge targets) are checked in Validate once all entities are registered.

func (*Registry) Replace added in v0.0.4

func (r *Registry) Replace(spec EntitySpec) error

Replace re-registers an existing DYNAMIC entity with an updated spec, recomputing its Binding. It is the runtime-mutation counterpart to Register (which rejects duplicates). Replacing an unknown entity, or a modelled (static) entity, is an error — statics are compile-time and keep the "register before Open" contract.

func (*Registry) Unregister added in v0.0.4

func (r *Registry) Unregister(name string) error

Unregister removes a DYNAMIC entity from the registry. Removing an unknown or modelled entity is an error.

func (*Registry) Validate

func (r *Registry) Validate() error

Validate performs startup validation across entities: every edge target must itself be registered. Call once after all Register calls.

type Scope

type Scope struct {
	// Name appears in channel names: changes:{tenant}:{name}:{id}.
	Name string

	// Field is the model column whose value provides the channel id for
	// containing-scope channels (e.g. "site_id" for a by-site scope).
	// Empty for the ByID and ByTenant builtins.
	Field string
}

Scope names a subscription dimension. Channels are always resolved server-side from (tenant, scope, id) — clients never name channels.

func ByField

func ByField(name, field string) Scope

ByField declares a containing scope whose channel id comes from the named column, e.g. ByField("site", "site_id").

type SearchSpec

type SearchSpec struct {
	Index  string   // logical index base name; tenant routing is derived
	Fields []string // columns included in the indexed document
}

SearchSpec maps an entity into the search projection. The zero value (empty Index) means the entity is not indexed.

Jump to

Keyboard shortcuts

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