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:847

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.electricTable 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 Electric 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 Electric target is taken from the owner — there is no consumer-facing source field to get wrong (see ShapeSpec.electricTable).
  • Readonly. A projection has no write path; the engine resolves an incoming shape request by its unique shapeKey (= as) and consults the derived physical target only on egress.

The rowFilter callback receives the OWNER’s full columns — customWhere runs in Electric 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 customWhere 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 on the proxy egress path for this shape only. The order on egress is transform first, then omission: the transform runs against the fetched row, then column omission strips this projection’s omitted columns (the client keep-set is columns ∪ primaryKey) before the row reaches the client wire. This lets a “secure window” over a keyed table stream the body while stripping the keys per row.

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 Electric fetch allow-list — so it is fetched from Electric, 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 PGlite table name AND its shapeKey.

TColumns

Column keys (of the owner) to sync locally + fetch from Electric. 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 Electric fetch 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