Dynamic Entities
Runtime-defined entities — register a schema descriptor instead of a Go struct, and fabriq runs the whole data plane (managed DDL, writes, reads, projections) over real typed Postgres columns.
A normal fabriq entity is a compile-time Go struct, bound by reflection, with its table owned by a migration. A dynamic entity is defined at runtime from a schema descriptor instead — fabriq generates and manages its table (CREATE/additive ALTER), and the entire data plane (validated writes → versioned events → outbox, map-native reads, and every projection) flows over real typed columns, exactly like a static entity.
This is the capability a control plane needs: a low-code platform, headless CMS, multi-tenant app builder, or dataset product authors schemas at runtime and lets fabriq run the storage, eventing, and derived features. The division of responsibility:
- Your control plane owns authoring, schema versioning, and the catalog.
- fabriq owns the data plane — writes, reads, graph/search/vector projections, reconcile, rebuild, CRDT, live queries, and structural tenancy/RLS.
Dynamic entities are additive and fenced. Static entities are untouched and keep the rule "migrations are the authority." Dynamic entities are a separate, opt-in lane with their own rule: the descriptor is the authority, and fabriq manages their DDL.
The descriptor
A dynamic entity is described by a DynamicSchema (in core/registry). You declare only domain columns — fabriq injects the structural columns (id, tenant_id, version).
type DynamicSchema struct {
Table string // physical table name; identifier-validated
Columns []DynamicColumn // domain columns only
Indexes []DynamicIndex // optional secondary indexes
}
type DynamicColumn struct {
Name string
Type ColumnType // neutral type, mapped to SQL by the adapter
NotNull bool
Default string // optional SQL default EXPRESSION (e.g. "now()", "'pending'", "0")
}
type DynamicIndex struct {
Name string
Columns []string
Unique bool
}ColumnType is a neutral set the Postgres adapter maps to SQL:
ColumnType | Postgres type |
|---|---|
ColText | TEXT |
ColInt | BIGINT |
ColFloat | DOUBLE PRECISION |
ColBool | BOOLEAN |
ColTime | TIMESTAMPTZ |
ColJSON | JSONB |
DynamicColumn.Default is interpolated verbatim into DDL — it is an SQL expression, not an identifier, so it is not identifier-validated. It must be a trusted, control-plane value, never a user-supplied string. Same trust level as hand-written migration SQL. (Table, column, and index names are identifier-validated against ^[A-Za-z_][A-Za-z0-9_]{0,63}$ — the injection guard.)
Declaring a dynamic entity
Set EntitySpec.Schema instead of Model (the two are mutually exclusive — exactly one must be set):
reg.MustRegister(registry.EntitySpec{
Name: "orders",
Kind: registry.KindAggregate,
Schema: ®istry.DynamicSchema{
Table: "ds_orders",
Columns: []registry.DynamicColumn{
{Name: "sku", Type: registry.ColText, NotNull: true},
{Name: "qty", Type: registry.ColInt},
{Name: "meta", Type: registry.ColJSON},
},
Indexes: []registry.DynamicIndex{
{Name: "ds_orders_sku_idx", Columns: []string{"sku"}},
},
},
})Registration validates the descriptor: the table and column names must be valid identifiers, a domain column may not redeclare a structural column (id/tenant_id/version), duplicate columns are rejected, and every index column must exist. A dynamic entity carries the same GraphNode / GraphEdge / Search / Subscribe / Validate / Live fields as a static one — they reference column names, which the descriptor provides.
Managed DDL: EnsureDynamic
fabriq owns the physical table for a dynamic entity. (*postgres.Adapter).EnsureDynamic is a create-or-evolve operation, run as the schema owner (not the RLS-constrained app role):
func (a *Adapter) EnsureDynamic(ctx context.Context, ent *registry.Entity) errorIt emits, idempotently (IF NOT EXISTS throughout):
CREATE TABLEwith the structural columns (id TEXT PRIMARY KEY,tenant_id TEXT NOT NULL,version BIGINT NOT NULL) plus the descriptor's domain columns,- a mandatory
tenant_idindex and any descriptor-declared secondary indexes, - the
tenant_isolationRLS policy (ENABLE+FORCE ROW LEVEL SECURITY), identical to the static-table policy — so tenancy holds the same way.
ent, _ := reg.Get("orders")
if err := owner.EnsureDynamic(ctx, ent); err != nil { /* ... */ }The setup order matters: migrate → EnsureDynamic → provision the app role, so the role's DEFAULT PRIVILEGES grant the new table to fabriq_app. Register the entity before opening the adapter, so the pool-path tenant backstop includes the dynamic table in its guarded set.
This is the fenced managed-DDL lane: fabriq manages DDL only for dynamic entities. Calling EnsureDynamic for a static entity (Schema == nil) is an error, and the static model↔DDL conformance test does not apply to dynamic tables — their descriptor is the schema authority, so drift is impossible by construction.
Writing rows
Writes go through the ordinary command plane — the only difference is that Payload is a map[string]any keyed by column name:
res, err := f.Exec(ctx, command.Command{
Entity: "orders",
Op: command.OpUpsert, // OpCreate / OpUpdate / OpUpsert / OpDelete all supported
AggID: "order-123",
Payload: map[string]any{"sku": "A1", "qty": 3},
})Everything downstream is identical to a static entity: optimistic concurrency, the version bump, exactly one column-keyed outbox event (orders.created / orders.updated / orders.deleted), and the transactional outbox. Keys not in the descriptor are dropped; the structural columns are stamped by the executor; a payload tenant_id that differs from the context tenant is rejected (the forgery check). The adapter builds a parameterized map-native INSERT/UPDATE — every interpolated identifier is re-validated at the SQL boundary.
Reading rows
The relational port returns dynamic-entity rows as maps instead of structs. Get fills a *map[string]any; GetMany and List fill a *[]map[string]any:
// One row.
var order map[string]any
err := f.Relational().Get(ctx, "orders", "order-123", &order)
// Filtered + ordered page — the SAME query.Where filter AST as static List.
var rows []map[string]any
err = f.Relational().List(ctx, "orders", query.ListQuery{
Where: query.Where{query.Eq("sku", "A1")},
OrderBy: "qty DESC",
Limit: 50,
}, &rows)
// Batch hydration in ids order.
var many []map[string]any
err = f.Relational().GetMany(ctx, "orders", []string{"r3", "r1"}, &many)List filters and orders over descriptor columns with the full query.Where vocabulary (Eq/In/Gt/Like/Or/…); unknown columns are rejected (the injection guard). Every read runs inside a tenant-stamped transaction, so RLS scopes results to the context tenant — a cross-tenant read returns nothing.
The rest of the plane comes free
Because fabriq's event payloads, projection appliers, and filters are already column-keyed, a dynamic entity gets the entire feature set with no special code. Declare the same capability fields you would on a static entity and they just work:
registry.EntitySpec{
Name: "widget",
Schema: ®istry.DynamicSchema{Table: "ds_widgets", Columns: /* … */},
GraphNode: "Widget", // → graph projection
Search: registry.SearchSpec{Index: "widgets", Fields: []string{"name", "kind"}}, // → search
Subscribe: []registry.Scope{registry.ByID, registry.ByTenant}, // → delta subscriptions
Live: ®istry.LiveSpec{Sortable: []string{"name"}}, // → live queries
}An end-to-end test verifies a dynamic widget projects to FalkorDB (a MATCH (n:Widget {id}) returns its scalar columns as node properties) and to Elasticsearch (full-text Search) through the ordinary relay/projection pipeline — using the same built-in appliers as static entities, with no applier changes or projection mutations. Reconcile, blue-green rebuild, CRDT, and the delta/live-query planes apply equally.
Schema evolution
EnsureDynamic is also the evolution path. Called again with a changed descriptor on an existing table, it reconciles additively:
- new domain columns →
ALTER TABLE … ADD COLUMN IF NOT EXISTS … - new indexes →
CREATE INDEX IF NOT EXISTS … - existing columns/indexes → left untouched.
| Operation | Behavior |
|---|---|
| Add nullable column | applied (existing rows get NULL) |
Add column with Default | applied (default backfills) |
Add index (incl. Unique) | applied |
| Re-run with same descriptor | no-op (idempotent) |
| Drop / rename / type-change | not auto-applied — write an explicit migration |
Add NOT NULL column without Default to a populated table | fails (Postgres error, by design) |
Evolution is strictly additive: destructive changes are never applied automatically, and the unsafe NOT NULL-without-default-on-populated-table case is surfaced as an error rather than worked around. A consumer that needs a destructive change supplies a Default (for new required columns) or authors a migration.
Invariants
- The static lane is untouched — compile-time entities keep migrations-as-authority and the model↔DDL conformance test.
- The descriptor is the dynamic authority — fabriq creates/alters only dynamic tables, only from their descriptors, additively.
- Identifiers are guarded — table/column/index names are validated against the identifier regex at both registration and the SQL boundary; only
Defaultis trusted-verbatim. - Tenancy is structural —
tenant_id+ thetenant_isolationRLS policy are injected on every dynamic table; reads/writes run tenant-stamped, identical to static entities.
Where to go next
Registry
How an entity is declared once as an EntitySpec and the projection mappings, channel names, and tenant-scoped store names are all derived from it.
Tenancy
The three enforcement layers — stamped transactions plus RLS, structural stamping, and the grove hook backstop — that make every access tenant-scoped.