registry

package
v0.0.6 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: Apache-2.0 Imports: 9 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 AnalyticsSpec added in v0.0.6

type AnalyticsSpec struct {
	// Include is the allow-list of payload field names co-located in the
	// analytics store. Ignored when IncludeAll is true.
	Include []string
	// IncludeAll ships the whole (unredacted) payload. Use with care — it
	// widens the trust boundary to every column.
	IncludeAll bool
	// Hash names payload fields whose VALUE is replaced with a stable salted
	// hash before it crosses the trust boundary — pseudonymization. The raw
	// value never lands, but equal values hash equally, so operators can still
	// count-distinct / group-by / join on the field across the fleet without
	// co-locating the sensitive value. A hashed field is implicitly included
	// (it need not also appear in Include). Requires Config.Analytics.HashSalt.
	Hash []string
}

AnalyticsSpec opts an aggregate into the cross-tenant analytics sink and declares exactly what data may cross the co-location trust boundary. Deny-by-default: an unmarked entity ships nothing. Field minimization: Include is an allow-list of payload keys; everything else is stripped before the record leaves the projection.

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

	// Analytics opts the entity into the cross-tenant analytics sink. Nil
	// (the zero value) means the entity is NEVER co-located in the shared
	// analytics store — deny-by-default. When set, only the declared fields
	// (or the whole payload if IncludeAll) cross the trust boundary.
	Analytics *AnalyticsSpec

	// Insights opts the entity into the per-tenant customer-facing analytics
	// store (projected facts). Nil = not projected. Distinct from Analytics
	// (the cross-tenant operator sink) — Insights stays inside the tenant DB.
	Insights *InsightsSpec

	// Metrics declares named, typed cube queries for this entity's Source.
	Metrics []MetricSpec
}

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 InsightsSpec added in v0.0.6

type InsightsSpec struct {
	// Measures are numeric columns aggregatable via Sum/Avg/Min/Max in a cube
	// Query. Each must be a column of the entity.
	Measures []string
	// Dimensions are columns usable as group-by keys. Each must be a column.
	Dimensions []string
}

InsightsSpec opts a domain entity into the PER-TENANT customer-facing analytics store: on each change the entity is projected (version-gated) into the tenant's own fabriq_insights_facts table for on-demand aggregation. Unlike AnalyticsSpec (the cross-tenant operator sink) there is NO redaction — the data never leaves the tenant's own database. Nil = not projected.

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 MetricMeasure added in v0.0.6

type MetricMeasure struct {
	Kind  string
	Field string
	As    string
	// Percentile is the fraction (0,1) for a "percentile" Kind measure, e.g.
	// 0.95 for p95 — mirrors query.Measure.Percentile. Required when Kind is
	// "percentile"; ignored otherwise. Threaded through to query.Measure by
	// core/insights/resolve.go's toQueryMeasures.
	Percentile float64
}

MetricMeasure mirrors query.Measure at the registry layer (registry must not import core/query). Kind is one of "count"/"sum"/"avg"/"min"/"max"/ "count_distinct"/"percentile".

type MetricSpec added in v0.0.6

type MetricSpec struct {
	Name          string
	Source        string          // event name or entity name
	Measures      []MetricMeasure // at least one
	Dimensions    []string
	DefaultBucket time.Duration // optional default TimeBucket

	// Rollup opts this metric into materialization (phase 2b). Nil (the zero
	// value) means the metric stays live-only, computed on demand from raw
	// events/facts on every query. Rollups are event-sourced only (Source must
	// NOT name a registered entity). As of phase 2b-2, a Rollup metric may
	// also carry a non-additive "sketch" measure (count_distinct, percentile):
	// these are stored as toolkit-typed columns (hyperloglog/tdigest — see
	// adapters/postgres's rollupTableDDL) rather than plain NUMERIC, and
	// require timescaledb_toolkit to be available in the target database
	// (adapters/postgres's toolkitAvailable boot-check fails loudly if not).
	// Rollup-served count_distinct/percentile are approximate; the pure-live
	// path stays exact.
	Rollup *RollupSpec
}

MetricSpec is an optional named, typed cube query a tenant can invoke by name (query.AnalyticsQuery{Source: metric.Name}). Declaring a metric makes it rollup-ready in phase 2. Schemaless events remain queryable without any MetricSpec.

type Option added in v0.0.6

type Option func(*Registry)

Option configures a Registry at construction.

func WithTablePrefix added in v0.0.6

func WithTablePrefix(prefix string) Option

WithTablePrefix namespaces every static entity table registered with this registry (e.g. "acme_" turns table "widgets" into "acme_widgets"). fabriq_* infra tables and ds_* dynamic tables keep their existing namespaces. An invalid prefix panics: this is wiring-time misconfiguration, not runtime input.

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(opts ...Option) *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) EntityHasInsights added in v0.0.6

func (r *Registry) EntityHasInsights(name string) bool

EntityHasInsights reports whether name is a registered entity carrying a non-nil InsightsSpec (i.e. one whose projected facts are queryable).

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) MaterializedMetrics added in v0.0.6

func (r *Registry) MaterializedMetrics() []*MetricSpec

MaterializedMetrics returns every declared metric that opts into materialization (MetricSpec.Rollup != nil), sorted by Name for determinism. Like Metric, it reads the index built by Validate; calling it before Validate returns an empty slice.

func (*Registry) Metric added in v0.0.6

func (r *Registry) Metric(name string) (*MetricSpec, bool)

Metric returns the declared MetricSpec named name. The index is populated by Validate; calling Metric before Validate returns (nil, false).

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, plus the metric-name index (global uniqueness, no entity-name collision, and every entity source declares InsightsSpec). Call once after all Register calls.

Validate takes the WRITE lock, not RLock: it assigns r.metrics as its last step, and Validate is expected to run once at startup (no read contention to protect), so upgrading to Lock for the whole method is simplest and avoids any read/write race on that assignment.

type RollupSpec added in v0.0.6

type RollupSpec struct {
	// Bucket is the rollup grain (e.g. time.Hour). Required, must be > 0,
	// and must evenly divide 24h (Validate rejects anything else) — the
	// stitched-query boundary math truncates on Go's zero-time origin and
	// only lines up with Postgres time_bucket's day-aligned grid when the
	// grain divides a day evenly.
	Bucket time.Duration

	// SealGrace delays sealing a bucket past its end time, so events that
	// arrive slightly late still land in the correct bucket. Zero means the
	// maintainer applies its own sane default.
	SealGrace time.Duration

	// RerollWindow is how far back (in buckets) the maintainer recomputes on
	// every pass, to absorb events that arrived after a bucket was already
	// sealed. Zero means the maintainer applies its own sane default.
	RerollWindow time.Duration
}

RollupSpec opts a MetricSpec into materialization (phase 2b). Nil (on MetricSpec.Rollup) means live-only. The rollup grain is Bucket; a bucket is sealed (rolled up, no longer mutable by the maintainer) once it can no longer receive in-grace late events — SealGrace after the bucket's end time. RerollWindow trailing buckets are recomputed on each maintainer pass to absorb events that arrive within grace but after an earlier pass already sealed the bucket.

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