Skip to content

defineReadProjection

defineReadProjection<TOwnerTable, TOwnerLocal, TAs, TColumns>(owner, opts): SyncTableEntry<TOwnerTable, ProjectionLocalTable<TOwnerTable, TAs, TColumns[number]>> & object

Defined in: packages/contracts/src/registry.ts:919

Define a read projection: a second client shape over a table an owner entry already owns. The projection reads the SAME physical rows under a DISTINCT local identity (as) and its own narrower shape — a typed column subset and/or an admin/role-scoped rowFilter — without owning, migrating, or RLS-guarding any new table. The first use is a light admin view of a heavy authoring table (titles, not the jsonb), while the learner keeps reading the full table through the owner’s shape.

It is the obvious, DRY way to express “another shape over this table”, versus the bare shape.physicalTable string it replaces (a footgun — config that silently un-asserts table ownership):

  • Owns nothing. The returned entry’s table IS owner.table (the same object), so there is no new pgTable to migrate or to leak into a drizzle-kit schema barrel. Only localTable (named as) and shape are its own. readProjection is set so generators skip it.
  • DRY columns. columns is a typed subset of the owner’s column keys; the local table is built by filtering the owner’s own column definitions (never restated), and the same subset becomes the shape’s columns allow-list so an omitted (e.g. heavy jsonb) column never crosses the wire. The primary key is always kept. Omit columns to sync every column.
  • Source is derived, never named. The physical source table is taken from the owner — there is no consumer-facing source field to get wrong (see ShapeSpec.physicalTable).
  • Readonly. A projection has no write path; the control plane resolves an incoming subscription by its unique shapeKey (= as) and consults the derived physical target when it creates the shape.

The rowFilter callback receives the OWNER’s full columns — the predicate is evaluated against the physical table, so it may reference a column the local subset omits. RLS for the projection’s reads lives on the OWNER’s table (a projection adds no DDL to a table it does not own); its customPredicate must be a subset of what that RLS allows.

The owner may be a defineSyncTable entry OR an asReadonly of one — an asReadonly projection preserves the full read contract (physical table, columns, primary key) and only drops the write path, so projecting off it is equivalent to projecting off its writable source. A CHAINED read projection (an owner that is itself a defineReadProjection) is rejected, because it composes wrongly — see below.

Server-side egress redaction (serverProjection + serverOnlyColumns)

Section titled “Server-side egress redaction (serverProjection + serverOnlyColumns)”

A projection may carry its own serverProjection (a ServerProjectionSpec, typically a rowTransform) — resolved by the projection’s shapeKey and run at the STREAM EDGE, per request, for this shape only (ADR-0055 decision 5). The order on egress is transform first, then omission: the transform runs against the fetched row, then the edge strips the row to the client keep-set (columns ∪ primaryKey) before it reaches the client wire. This lets a “secure window” over a keyed table stream the body while stripping the keys per row. Such a shape is private-tier by construction (its bytes depend on the reader) and is answered cache-control: private, no-store.

serverOnlyColumns are owner column keys the transform must READ but that are NOT in the client shape (e.g. a keysWithheld control flag). Such a key stays omitted from the client keep-set, yet is ADDED to the shape’s columns allow-list — so it is fetched, visible to the transform, and then stripped on egress by the same omission pass, never reaching the client. It requires a serverProjection.rowTransform (a fetch no transform reads is dead weight) AND columns (with columns omitted every column is already kept, so “server-only” is a contradiction), and must be disjoint from columns and the primary key.

No inheritance — ENFORCED. A projection does NOT inherit its owner’s serverProjection. That is deliberate: an inherited transform whose input column is absent from the projection’s fetch list would read undefined and silently fail OPEN (serving the un-redacted body) — half-protection worse than none. Because a bare projection over a redacting owner would therefore egress RAW owner rows, the registry no longer merely warns: when owner.serverProjection?.rowTransform exists, this function THROWS at definition time unless the projection declares a posture. You must either declare your own serverProjection on the projection (typically the same transform fn, plus serverOnlyColumns for its control-flag inputs), or — only after confirming the projection’s kept columns leak nothing — opt out explicitly with the literal serverProjection: "unredacted", which attaches no transform (egress raw) but records that as a visible, reviewed decision at the definition site. The opt-out is meaningful only where it applies: "unredacted" over an owner with NO egress rowTransform is itself rejected, so a stale opt-out cannot silently pre-authorize a leak the day the owner grows one.

TOwnerTable extends AnyPgTable

TOwnerLocal extends AnyPgTable

TAs extends string

TColumns extends readonly Extract<keyof TableColumnsShape<TOwnerTable>, string>[] = readonly Extract<keyof TableColumnsShape<TOwnerTable>, string>[]

SyncTableEntry<TOwnerTable, TOwnerLocal>

TAs

The projection’s distinct local identity — its pgwasm table name AND its shapeKey.

TColumns

Column keys (of the owner) to sync locally + fetch on the shape. The PK is always kept. Omit → all.

string

Retention

string

Row classification (ADR-0052) for THIS projection. Defaults to the OWNER’s rowClass — a projection reads the owner’s rows, so it carries the owner’s classification (and the invariants bound to it) unless you say otherwise. Override when the narrower shape genuinely changes the KIND of row the client receives (e.g. a redacting window over private rows that egresses only public fields).

(columns) => RowFilterSpec

Row filter for this shape; the callback form receives the owner’s full (physical) columns.

readonly Extract<keyof TableColumnsShape<TOwnerTable>, string>[]

Owner column keys the serverProjection.rowTransform must READ but which are NOT part of the client shape (e.g. a keysWithheld control flag). They are added to the shape’s columns allow-list so the transform can see them, then stripped on egress before the client wire. Requires serverProjection.rowTransform and columns; must be disjoint from columns and the primary key.

ServerProjectionSpec | "unredacted"

Server-side egress projection (ADR-0004) for THIS shape — typically a rowTransform that redacts a sub-document of a kept column conditionally on row data. A projection does NOT inherit its owner’s serverProjection (see the docblock’s no-inheritance caution): an inherited transform whose input column is unfetched would fail OPEN, so inheritance is refused, not silent. When the OWNER declares an egress rowTransform, this is therefore required — the registry throws at definition time unless you either declare your own spec here (typically the same transform fn, plus serverOnlyColumns for its control-flag inputs) OR opt out with the literal "unredacted". Use "unredacted" only after confirming this projection’s kept columns leak nothing; it attaches NO egress transform (the shape streams raw owner rows), but records that as a visible, reviewed decision at the definition site. "unredacted" over a transform-less owner is itself rejected — a stale opt-out would silently pre-authorize a leak the day the owner grows a transform.

SubscriptionTiming

SyncTableEntry<TOwnerTable, ProjectionLocalTable<TOwnerTable, TAs, TColumns[number]>> & object