source.rdb is the single relational-database source subtype. The pre-v2 split
of source.dbTable / source.dbView is retired; the form is now
source.rdb with a @kind attribute that determines read-only-ness and codegen
dispatch.
@kind |
Read-only | Default? | Typical use |
|---|---|---|---|
table |
No | Yes (when @kind omitted) |
Persisted entity |
view |
Yes | – | Projection — read-only Zod, read-only routes, read-only finders |
materializedView |
Yes | – | Same as view, refresh discipline is host-app's concern |
storedProc |
Yes | – | Read-only projection backed by a stored procedure |
tableFunction |
Yes | – | Read-only projection backed by a parameterized table-valued function |
Read-only-ness drives the codegen dispatch: a view-kind projection emits read-only
Zod, no mutation routes, and read-only finders. A table emits the full CRUD surface.
A read-only @kind (view / materializedView / storedProc / tableFunction) may
only be an object's primary source on an object.projection — a derived, read-only
representation (see ADR-0028).
An object.entity's primary source must be a writable @kind (table); declaring an
entity over a read-only primary source is a load error
(ERR_ENTITY_PRIMARY_SOURCE_READONLY). A read-only source may still appear on an entity
in a non-primary read @role.
{
"object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true } },
{ "identity.primary": { "@fields": "id" } }
]
}
}@kind is omitted — it defaults to "table". @table is the physical table name
(NOT @name).
The single most common legacy view is an entity's own columns plus a joined extra:
CREATE VIEW order_with_customer AS
SELECT o.*, c.name AS customer_name
FROM orders o JOIN customers c ON o.customer_id = c.id;This is not a projection — it is the Order entity with a read route. Reach for an
entity read-view first: an object.entity that keeps its writable table source and
adds a non-primary read-only view source, declaring only the extra as a derived
(origin.*) field. The entity's own field set is the o.*, so you re-state nothing
but the extra. The FR-024 §7 contract: writes route to the table (derived fields don't exist
there and are excluded from the write codecs); reads route to the view; the CREATE VIEW DDL
is emitted by meta migrate from the same assembly logic as a projection view — one
emitter, two hosts.
Status — shipped in all five ports. Both halves have landed: the schema/write half (#213) keeps derived fields OFF the write table and emits the replica view (verified by a real-Postgres round-trip), and the codegen READ half (#214) routes generated reads to the replica view and carries the derived fields on the entity read type — a create/update re-reads the row through the view by primary key, so the returned value carries the derived columns (read-your-writes). Writes still target the table and exclude derived fields. (A flattened
field.objecton a write-through entity is a tracked sub-limitation.)
{
"object.entity": {
"name": "Order",
"children": [
{ "source.rdb": { "@role": "primary", "@table": "orders" } },
{ "source.rdb": { "@role": "replica", "@kind": "view", "@table": "v_order_with_customer" } },
{ "field.long": { "name": "id" } },
{ "field.long": { "name": "customerId", "@required": true } },
{ "field.string": { "name": "customerName",
"children": [ { "origin.passthrough": { "@from": "Customer.name" } } ] } },
{ "relationship.association": { "name": "customer", "@objectRef": "Customer", "@cardinality": "one" } },
{ "identity.primary": { "name": "pk", "@fields": "id" } },
{ "identity.reference": { "name": "ref_customer", "@fields": ["customerId"], "@references": "Customer" } }
]
}
}A derived field must be providable — a read-capable source must be able to carry it. A
derived field on a table-only entity (no read source) is a load error
(ERR_DERIVED_FIELD_NO_READ_SOURCE).
| Use an entity read-view when | Use a projection when |
|---|---|
| the extras sit over the entity's own table | it is an independent exposure contract |
| same read trust-domain — a new entity field appearing in the view is correct, not a leak | a subset / renamed columns / versioned / external consumers |
| you can still INSERT the base and it is the record of truth | keyless, multi-base, or proc-backed; all-derived; borrowed / no identity |
Two boundaries push you to a projection instead of an entity read-view:
- Renamed base columns. A field carries one
@columnper paradigm, so renaming a base column in the view is the projection's whole point — a projection field maps anextends-bound source column to a new@column. - Row-filtered views (
… WHERE status = 'active', soft-delete). An entity read-view exposes the whole entity; a projection can carry a row-scope@filter(#207) that lowers to the view's outerWHERE— the metadata-managed alternative to a hand-written soft-delete/status view.
When the read shape is an independent exposure contract (a subset, renamed columns, a
versioned or externally-consumed shape — see the decision table above) rather than the
owning entity's own columns, declare it as an object.projection, not an object.entity
(an entity's primary source must be writable). A projection's identity is optional and, when
present, MUST extend an entity identity; the example below omits it (a keyless read
model).
The view's SQL is generated — never hand-write it. The CREATE VIEW body is
derived from the projection's origin.* children (passthrough columns, aggregates,
collections) and emitted by the Node meta migrate (schema migrations are Node-owned
for every port — ADR-0015). Hand-authoring the view SQL for a shape the origins can
express is a second source of truth that drifts silently — and because an unmodeled DB
view is unmanaged, meta verify --db can't even see it. Reach for a hand-written
view only when a construct origins can't express (recursive CTE, window function,
set operation) blocks projection authoring — then carry that body in the source.rdb
@sql escape (#208, ADR-0043),
which keeps it tool-managed (registered, fingerprinted, drift-checked) instead of
accidentally-unmanaged in a hand-edited migration file.
{
"object.projection": {
"name": "AuthorView",
"children": [
{ "source.rdb": { "@kind": "view", "@table": "v_author" } },
{ "field.long": { "name": "id", "@required": true,
"children": [ { "origin.passthrough": { "@from": "Author.id" } } ] } },
{ "field.string": { "name": "name", "@required": true,
"children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } },
{ "field.long": { "name": "postCount",
"children": [ { "origin.aggregate": {
"@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }
]
}
}origin.* children declare where each field's value comes from — see
templates-and-payloads.md for the full origin
vocabulary (passthrough, aggregate, collection, computed, first).
A count/sum/avg/min/max can be row-scoped with an optional @filter — a
structured predicate (the same shape as a preset filter) that the view emitter renders
as SQL FILTER (WHERE …) (Postgres) or a portable CASE WHEN (SQLite). For example, a
max over only the active rows — the filtered-scalar shape a plain aggregate can't express:
{ "field.int": { "name": "activeMax", "children": [
{ "origin.aggregate": { "@agg": "max", "@of": "Week.ordinal",
"@via": "Program.weeks", "@filter": { "status": "active" } } }
]}}More origin.aggregate reductions (#195). Beyond the scalar reduces, @agg also takes
any / all (predicate quantifiers over a @filter — @of forbidden; empty set →
any=false, all=true) and collect (an array rollup of @of into an isArray field,
with optional @distinct / @orderBy; [] on the empty set). Two sibling origin subtypes
round out the read-model vocabulary: origin.computed carries a closed @expr
expression grammar (a derived scalar computed from other fields), and origin.first picks
one related row's column along a @via path (an argmax-style projection; nullable).
Projection row-scope @filter (#207). Distinct from the per-aggregate @filter above,
an object-level @filter on an object.projection scopes the whole view's rows — it
lowers to the view's outer WHERE. This is the metadata-managed way to express a
soft-delete / status / type view without hand-writing SQL:
{ "object.projection": { "name": "ActivePrograms",
"@filter": { "status": { "eq": "active" } },
"children": [
{ "source.rdb": { "@kind": "view", "@view": "v_active_programs" } },
{ "field.long": { "name": "id", "extends": "Program.id" } },
{ "field.string": { "name": "name", "children": [ { "origin.passthrough": { "@from": "Program.name" } } ] } },
{ "identity.primary": { "name": "id", "extends": "Program.id" } }
]}}For a genuinely-irreducible view body (recursive CTE, window function) that no origin.*
can express, carry it in the source.rdb @sql escape rather than hand-writing it
outside the tool — see migrations-and-drift.md and
ADR-0043.
{
"object.projection": {
"name": "AuthorStats",
"children": [
{ "source.rdb": { "@kind": "storedProc", "@table": "sp_author_stats" } },
{ "field.long": { "name": "authorId" } },
{ "field.long": { "name": "publishedCount" } }
]
}
}{ "source.rdb": { "@table": "authors", "@schema": "blog" } }Postgres default is public; SQLite rejects non-default @schema values at load
time.
An entity may have multiple source.rdb children, one per role. Exactly one must
carry @role: "primary"; the others identify additional sources (typically
read-replicas or split-domain projections). The TS persistence layer and the Java
OMDB engine route writes to primary and reads to the role you select.
{
"object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@role": "primary", "@table": "authors" } },
{ "source.rdb": { "@role": "replica", "@table": "authors_ro" } }
]
}
}A table-kind source generates a pgTable(...) + writable Zod + the full CRUD
route surface. A view-kind source generates a pgView(...) + read-only Zod + a
read-only finder, and meta migrate emits the CREATE VIEW DDL inferred from the
origin.* children.
// generated/acme/blog/AuthorView.ts
import { pgView, bigserial, varchar } from "drizzle-orm/pg-core";
export const authorView = pgView("v_author").as((qb) => qb.selectFrom("authors")...);
export const AuthorViewSchema = z.object({
id: z.number(),
name: z.string(),
postCount: z.number().nullable(),
});
export type AuthorView = z.infer<typeof AuthorViewSchema>;
// no createAuthorView / updateAuthorView / deleteAuthorView — read-only.Read-schema nullability mirrors the view column: a field that is not @required
(here postCount, a derived aggregate) is nullable in the view's SELECT type, so
its Zod read field is emitted as .nullable() — matching what
db.select().from(view) actually returns. A @required field (id, name) stays
non-null.
OMDB resolves the physical name via @table; a @kind: "view" source on an
object.projection is read-only at the ObjectManager layer (mutating ops throw). The CREATE VIEW body
is emitted by the TS toolchain (meta migrate) from the origin.* aggregate /
passthrough metadata; the meta:migrate Maven goal was removed.
// OMDB ObjectManager — exact API:
List<Author> authors = om.getObjectsBy(Author.class, /* filter */);
List<AuthorView> views = om.getObjectsBy(AuthorView.class, /* filter */);
// om.persist(view) → throws — AuthorView is read-only.KotlinExposedTableGenerator generates an Exposed Table object for @kind: "table". For @kind: "view" it generates a read-only Table wrapper with the
same column mapping; the CREATE VIEW body is emitted by the TS toolchain
(meta migrate) — the meta:migrate Maven goal was removed.
// generated/acme/blog/AuthorViewTable.kt — read-only
object AuthorViewTable : Table("v_author") {
val id = long("id")
val name = varchar("name", 200)
val postCount = long("post_count")
override val primaryKey = PrimaryKey(id)
}MetaObjects.Codegen emits OwnsOne / DbSet wiring as appropriate; for @kind: "view" the generated AppDbContext calls entity.ToView("v_author"). The CREATE VIEW body is emitted by the Node meta migrate (schema is Node-owned — ADR-0015;
the C# migrate surface was removed), not by dotnet meta.
// generated/AppDbContext.cs (excerpt) — view registration
modelBuilder.Entity<AuthorView>().ToView("v_author").HasKey(v => v.Id);Other @kind values (storedProc, tableFunction, materializedView) — partial
coverage; see ports/csharp.md for current support.
The Python loader recognizes source.rdb and all @kind values; the codegen
emits a @dataclass either way (read-only-ness is a runtime concern). Persistence
- migration codegen for non-
tablekinds are in progress.
# generated/acme/blog/author_view.py — emitted same as a table-kind entity
@dataclass
class AuthorView:
id: int
name: str
post_count: intThe following conformance fixtures gate this feature's behavior across ports:
source.rdb paradigm
fixtures/conformance/source-rdb-column/—@column(renamed from@dbColumn) physical-name attrfixtures/conformance/source-rdb-referential-actions/—@onDelete/@onUpdateon relationships
@kind: "table" (pre-v2 source.dbTable)
fixtures/conformance/source-db-table-explicit/— explicit table sourcefixtures/conformance/source-db-table-with-schema/—@schemahonoredfixtures/conformance/source-db-table-default-schema-omitted/— default schema omitted from output
@kind: "view" projections (pre-v2 source.dbView)
fixtures/conformance/source-db-view-projection/— view with originsfixtures/conformance/source-db-view-with-schema/—@schemaon view
Entity read-view (derived field must be providable)
fixtures/conformance/error-derived-field-no-read-source/— a derived (origin.*) field on a table-only entity (no read-capable source) isERR_DERIVED_FIELD_NO_READ_SOURCE
Multi-source via @role
fixtures/conformance/source-multi-source-roles/— primary + secondary sources on one objectfixtures/conformance/error-source-multiple-primary/— exactly one primary requiredfixtures/conformance/error-source-no-primary/— at least one primary required
Query semantics against source.rdb (cross-port runtime contract)
fixtures/persistence-conformance/queries/—get-by-id,list-*,count,filter-*,projection-aggregateagainst the sharedcanonical/metadata. Every port that ships a runtime tier asserts identical normalized result rows.
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See docs/CONFORMANCE.md
for the per-port pass/skip ledger.
- entities.md — the host node
object.entity - templates-and-payloads.md —
origin.*subtypes used by views - migrations-and-drift.md — how
meta migrateemits view + table DDL - ADR-0007 — design rationale