Registry entry options
A sync table entry is the unit of configuration in pgxsinkit. You author one with
defineSyncTable, collect entries into a registry with
defineSyncRegistry, and that registry is the single
contract the server proxy, the write API, and every client consume.
This page is the field-by-field reference. The six core-concept pages are the mental
model; read those first. Here, each option has what it achieves, a short example, when to
use it, and its default / constraints. The autogenerated
@pgxsinkit/contracts API reference has the exact types;
SyncTableInput is the authoring input and
SyncTableEntry is the resolved result.
Anatomy of an entry
Section titled “Anatomy of an entry”import { bigint, uuid, varchar } from "drizzle-orm/pg-core";import { authenticatedRole } from "drizzle-orm/supabase";import { defineSyncTable } from "@pgxsinkit/contracts";
const messageSyncEntry = defineSyncTable({ tableName: "message", // identity (Postgres + local) makeColumns: () => ({ // Drizzle columns, built internally id: uuid("id").primaryKey(), channelId: uuid("channel_id").notNull(), authorId: uuid("author_id").notNull(), body: varchar("body", { length: 4000 }).notNull(), createdAtUs: bigint("created_at_us", { mode: "bigint" }).notNull(), updatedAtUs: bigint("updated_at_us", { mode: "bigint" }).notNull(), }), policies: buildMessagePolicies(authenticatedRole), // RLS on the Postgres table mode: "readwrite", // read + write capability conflictPolicy: "last-write-wins", // required for writable tables subscription: "lazy", // subscribe on first reference retention: "ephemeral", // no durable local trace governance: { managedFields: [ { column: "authorId", applyOn: ["create"], strategy: "authClaim", claimPath: ["sub"] }, { column: "createdAtUs", applyOn: ["create"], strategy: "nowMicroseconds" }, { column: "updatedAtUs", applyOn: ["create", "update"], strategy: "nowMicroseconds" }, ], },});You supply tableName + makeColumns; defineSyncTable builds the Drizzle pgTable, the projected
client localTable, and (for readwrite) the _read_model view for you. Access them on the returned
entry as .table, .localTable, and .view.
Identity & columns
Section titled “Identity & columns”tableName (required)
Section titled “tableName (required)”What it achieves. The Postgres table name, and the default for the synced/local table name, the shape key, and the Electric table. Most other names derive from it.
defineSyncTable({ tableName: "issue", makeColumns: () => ({/* … */}),});Constraints. Must be a valid Postgres identifier. Unique within a registry.
makeColumns (required)
Section titled “makeColumns (required)”What it achieves. A factory returning the Drizzle column map. It is a function (called more than once internally — for the server table, the projected local table, and the read-model view) so each consumer gets its own column instances.
makeColumns: () => ({ id: uuid("id").primaryKey(), title: varchar("title", { length: 200 }).notNull(), status: issueStatusEnum("status").notNull().default("todo"),}),When to use. Always. Define real Drizzle columns — types, notNull, .default(...), enums, and
references all carry through to the generated DDL and the apply ladder.
primaryKey
Section titled “primaryKey”What it achieves. THE primary key of the server table — the single source of truth for its physical
PRIMARY KEY constraint (used for upserts on the read path and to identify a row for writes).
defineSyncTable emits it as the constraint, named `${tableName}_pkey` — matching Postgres’s
default inline-PK constraint name (which drizzle’s own naming does not produce), so DDL declared through
pgxsinkit agrees with plain-Postgres inline-PK DDL and drizzle-kit sees no rename churn against a real
Postgres database. This closes the gap where the spec was runtime-only metadata: drizzle-kit, seeing no
PK in the generated DDL, could DROP a live composite key.
primaryKey: ["org_id", "person_id"], // composite key → org_person_pkey (spec order)primaryKey: { name: "org_person_pk", columns: ["org_id", "person_id"] }, // object form names the constraintDefault. ["id"]. When to use. For a composite key, a non-id primary key, or (object form) a
custom constraint name.
Column-level .primaryKey(). A single-column key MAY equivalently be declared idiomatically on the
column itself (id: uuid("id").primaryKey()); it must match this spec, and emission is skipped because
the column already carries the constraint (a single-id table’s DDL then agrees with plain-Postgres
inline-PK DDL). A
custom constraint name requires letting defineSyncTable emit the key, so it cannot be combined with a
column-level .primaryKey(). A table-level primaryKey(...) passed via extras/policies is
rejected — declare the key here instead; the spec is the single source of truth.
applyMode
Section titled “applyMode”What it achieves. Chooses how a server CDC insert for this table is applied on the client (ADR-0045). A synced cache table is server-authoritative, so the default deliberately keeps a genuine primary-key collision visible.
applyMode: "insert", // default — a CDC insert is a plain INSERT; a real PK collision surfaces (ADR-0014)applyMode: "upsert", // idempotent — server CDC inserts become INSERT … ON CONFLICT (pk) DO UPDATEDefault. "insert". A CDC insert is a plain INSERT with no conflict clause, so a duplicate insert
(a real bug in a server-authoritative table) surfaces instead of being silently swallowed — the ADR-0014
collision-surfacing invariant.
When to use "upsert". Only when this table legitimately receives locally-derived provisional
rows — e.g. a local trigger on another synced table inserts a provisional row here, and the server
independently creates the same row, so its CDC insert would otherwise collide (23505) and degrade the
engine. With "upsert", server CDC inserts (the initial bulk-snapshot path, the steady-state fold, and
the per-message path) are applied idempotently as INSERT … ON CONFLICT (pk) DO UPDATE (or a pk-targeted
DO NOTHING for a pk-only table); the authoritative server row overwrites the provisional local row.
Declare the exception here, where it lives — the strict invariant stays the default everywhere else.
schema
Section titled “schema”What it achieves. Places the Postgres table in a specific schema (the entry’s shape and DDL are qualified accordingly).
import { pgSchema } from "drizzle-orm/pg-core";const perfLab = pgSchema("perf_lab");defineSyncTable({ tableName: "event", schema: perfLab, makeColumns: () => ({/* … */}),});Default. public. When to use. Multi-schema servers, or to make a local relation
collision-proof for the lazy-relation guard. Set the registry-wide schema on
defineSyncRegistry({ schema, tables }) instead when every table shares one.
What it achieves. The table’s capability, and the machinery defineSyncTable derives from it:
readonly— synced down only. No overlay/journal, no_read_modelview, no write handle.readwrite— full local write cluster: an overlay (the optimistic value the UI reads), a durable mutation journal, and an overlay-merged_read_modelview. Requires aconflictPolicyand anowMicroseconds-on-update server version.writeonly— a write path with no local read cache (rare; for fire-and-forget writes the client never reads back locally).
mode: "readwrite",Default. readonly. When to use. readwrite for any table a client edits; readonly for
reference/streamed data. Because mode is a per-client capability (a teacher writes what a learner only
reads), don’t bake the read-only case into a second table — define the table once at its maximum
capability and project it with asReadonly.
The Postgres table (server side)
Section titled “The Postgres table (server side)”policies
Section titled “policies”What it achieves. Row-level security policies attached to the Postgres table. These govern the
write path (and, with Supabase, mirror the read filter). Use the
drizzle-orm/supabase role shims and the buildSupabase*NativePolicies helpers.
policies: buildMessagePolicies(authenticatedRole),When to use. Whenever a writable table must restrict who can write which rows. Read-side
visibility is the shape.rowFilter — keep the two mirror images so a row is never
visible-but-unwritable by accident.
extras
Section titled “extras”What it achieves. Extra constraints or indexes on the server table — anything pgTable’s third
argument accepts (unique constraints, indexes, composite checks). Receives the built column map.
extras: (t) => [unique().on(t.orgId, t.slug), index("idx_issue_team").on(t.teamId)],When to use. Server-side integrity and performance. Not applied to the local PGlite table.
Read shape
Section titled “Read shape”What it achieves. How rows reach the client: the Electric shape name, an optional column projection, and the row filter that scopes which rows each caller syncs. The filter runs in the proxy per request, against the caller’s verified JWT claims.
shape: { rowFilter: (columns) => ({ // bare, rename-safe column refs via `c()`; the subject is a bound param, never a literal customWhere: (claims) => claims.sub ? sql`${c(columns.teamId)} in (${memberTeams(claims.sub)})` : DENY_ALL, revision: "v1", }),},Sub-fields:
rowFilter.customWhere— returns a DrizzleSQLfragment (preferred; values become bound params), a raw string (the escape hatch — you must escape interpolated values), ornullto bypass filtering (e.g. admin). Reference columns throughc()so thewhereuses the bare identifiers Electric’s grammar requires; cast enums to text (${c(col)}::text = 'x'); keep subqueries self-contained. ReturnDENY_ALLto make no rows visible.rowFilter.columns— restrict the synced columns at the shape URL.rowFilter.revision— an opaque version tag for thecustomWherebody (a closure can’t be hashed). Bump it whenever you change the filter logic, or the fingerprint won’t shift and clients will serve the stale shape.tableName/shapeKey/electricTable— default totableName; override only to point at a differently-named Electric table or share a shape key.
When to use. Almost always — the row filter is your read-side authorization. The read path and Electric subqueries pages cover fan-out filters in depth.
Client projection
Section titled “Client projection”clientProjection
Section titled “clientProjection”What it achieves. Shapes the local (PGlite) table differently from the server table — without a migration, since the local schema is a runtime-derived projection.
clientProjection: { omitColumns: ["internal_flag"], // never lands on the client localPrimaryKey: { columns: ["id"] },// readonly-only: local PK override},Sub-fields:
-
omitColumns— columns present on the server but absent from the client table and its TypeScript shape. For a writable table you may only omit create-safe columns (nullable, defaulted, or managed) and never a primary-key column. They are invisible to the write path by design: the apply function reads only a table’s projected columns from a write payload. A non-column payload key splits into two cases:- a projected-away column (one in
omitColumns) sent explicitly is 400-rejected by the write route’s projected-field check — it is not silently dropped, and the write does not succeed; - a genuinely unknown non-column key (a typo) is silently ignored by the apply function — the
write collapses to a bare server-version bump and still acks. The write API surfaces only this case
with one
console.warnper (table, key) per process (a diagnostic, not a rejection) pointing back here; it does not warn for a projected-away column, which is rejected rather than dropped.
Write a server-only column outside the sync rail — a server-side
UPDATE, a trigger, or amanagedField— never from a client payload. - a projected-away column (one in
-
syncedTable/overlayTable/journalTable— override the derived local relation names (default${tableName},${tableName}_overlay,${tableName}_mutations). Rarely needed. -
localPrimaryKey— a different local primary key (e.g. when a readonly client keys rows by a natural key). Readonly tables only.
When to use. omitColumns to keep server-only control columns (or PII) off the client — pair with
serverProjection.rowTransform when the decision is per-row, not whole-column.
Server projection
Section titled “Server projection”serverProjection
Section titled “serverProjection”What it achieves. A per-row rewrite applied in the proxy response path — server authority, not
client shape. It can strip a sub-document of a jsonb column or rewrite a value conditionally on row
data, which whole-column omitColumns cannot. It runs before column omission, so it may read a column
that omitColumns then removes.
serverProjection: { rowTransform: (row, { claims }) => claims?.sub === row.author_id ? row : { ...row, draft_notes: null },},When to use. Conditional redaction: hide a field from everyone but its owner; trim a large blob for non-privileged callers. It never alters the local schema or the Electric shape URL, so it never pollutes Electric’s shared shape cache.
On a read projection: a secure “window” over a keyed table
Section titled “On a read projection: a secure “window” over a keyed table”A read projection (defineReadProjection) may carry its own
serverProjection too — resolved by the projection’s shapeKey and run on its egress path only. The
recipe: a table stores the item body (a jsonb payload with the answer key inside) plus a
keysWithheld control flag; a projection streams the body, strips the keys per row. Because the
transform must read the control flag — which is not part of the client shape — declare it in
serverOnlyColumns: those owner keys are added to the Electric fetch allow-list (so the transform can
see them) yet stay omitted from the client keep-set, so the same egress omission pass strips them before
the wire. Order on egress is transform first, then omission.
export const secureItemWindow = defineReadProjection(secureItem, { as: "secure_item_window", columns: ["payload", "metadata"], // the client keep-set (PK always kept) serverProjection: { rowTransform: (row) => (row.keys_withheld === true ? { ...row, payload: stripKey(row.payload) } : row), }, serverOnlyColumns: ["keysWithheld"], // fetched for the transform, never on the client wire});serverOnlyColumns requires both 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 — each is a loud error.
No inheritance — enforced at definition time. A projection does not inherit its owner’s
serverProjection. This 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
the raw owner row, the registry does not merely warn: when the owner declares an egress rowTransform,
defineReadProjection throws at definition time unless the projection declares its posture. You
satisfy the guard in one of two ways:
- declare your own
serverProjection(typically the same fn) plusserverOnlyColumnsfor its control-flag inputs — the redaction the owner does, done again for this shape; or - opt out explicitly with the literal
serverProjection: "unredacted"— only after confirming the projection’s kept columns leak nothing. It attaches no transform (the shape streams raw owner rows) but records that as a visible, reviewed decision at the definition site.
"unredacted" over an owner that declares no egress rowTransform is itself rejected — a stale
opt-out left in place would silently pre-authorize a leak the day the owner grows a transform, so the
opt-out is only accepted exactly where it applies.
Write contract
Section titled “Write contract”conflictPolicy
Section titled “conflictPolicy”What it achieves. What happens to a stale write — one whose base server version is behind the row’s current version at apply (someone else wrote in between).
last-write-wins— apply the stale write anyway (a conscious, named choice, not silent clobbering).reject-if-stale— don’t apply; surface the conflict (onConflict), keep the user’s optimistic overlay marked conflicted, and let them resolve it as a new write or roll it back withdiscardConflict. (A structurally-rejected write is insteadquarantined, with the symmetricdiscardQuarantinedrollback — see the write path.)
conflictPolicy: "reject-if-stale",Default. None — required for every writable table (defineSyncRegistry rejects a writable
table without one; there is no silent default). When to use. reject-if-stale for edited records
where a clobber is data loss (an issue’s title/description); last-write-wins for append-mostly data
where each row has its own key and collisions are benign (chat messages).
governance.managedFields
Section titled “governance.managedFields”What it achieves. Columns the database stamps on apply, overriding any client-sent value (the client write payload omits them). Two strategies:
nowMicroseconds—clock_timestamp()microseconds, stamped via the canonicalpublic.pgxsinkit_clock_us()database function (one home for the clock semantics), installed by the utilities migration — which must be the first folder in your migration chain. Use forcreated_at_usandupdated_at_us. Theupdated_at_us-on-update field is the strictly-monotonic server version optimistic convergence keys on — a writable table must declare one.authClaim— a value read from the verified JWT claims at a JSONclaimPath.["sub"]is the auth subject (the oldauth.uid()owner);["app_metadata","person_id"]an app-minted identity. An optionalcastoverrides the SQL cast (defaults to the target column’s own type).
governance: { managedFields: [ { column: "ownerId", applyOn: ["create"], strategy: "authClaim", claimPath: ["sub"] }, { column: "createdAtUs", applyOn: ["create"], strategy: "nowMicroseconds" }, { column: "updatedAtUs", applyOn: ["create", "update"], strategy: "nowMicroseconds" }, ],},When to use. Always, on a writable table — at minimum the updated_at_us server version. Managed
fields are omitted from SyncTableCreateInput, so
you never pass them (the write path explains the optimistic-overlay fill).
governance.deferrableConstraints
Section titled “governance.deferrableConstraints”What it achieves. Declares a constraint deferrable so intra-batch foreign keys resolve — the
apply function runs the whole batch under SET CONSTRAINTS ALL DEFERRED, letting a parent and child
inserted in the same flush land together regardless of order.
governance: { deferrableConstraints: [{ constraintName: "issue_team_fk", columns: ["team_id"], initiallyDeferred: true }],},When to use. When a single write batch creates rows that reference each other.
Lifecycle axes
Section titled “Lifecycle axes”These four axes are orthogonal to read/write mode and to authorization. They are properties of a
consistency group (see below): every table sharing a consistencyGroup must agree on
subscription, retention, and writeMode, or defineSyncRegistry rejects the registry.
consistencyGroup
Section titled “consistencyGroup”What it achieves. Binds tables onto one MultiShapeStream that commits atomically at a shared
LSN frontier — a reader never sees one grouped table advanced past another for the same server
transaction.
const TEAM_SCOPE = "team-scope";// team, channel, issue all set: consistencyGroup: TEAM_SCOPEDefault. None — each table is its own singleton group (independent frontier). When to use. When a join across the group must never flicker mid-update (a member added to a team should see the team, its channel, and its issues appear in one frame). Cost: a group advances only as fast as its slowest shape.
How to scope a group. Three rules:
- Group the transactionally-joined cluster — tables written together in one server transaction and rendered joined (FK parent + children). That is exactly what the atomic frontier protects.
- Quiet members are affordable. A rarely-written reference table used to be dangerous in a group (its parked long-poll could hold the whole group for a full ~41s hold cycle on CDN-fronted Electric); the live-tail sibling nudge (ADR-0033) caps that at roughly one catch-up round trip per gated commit. Don’t contort a schema to keep a lookup table out of its natural group.
- Don’t group “everything”. Every gated commit nudges each lagging member, so group scope should be the joined cluster, not the whole registry — unrelated clusters belong in separate groups (or stay singletons).
subscription
Section titled “subscription”What it achieves. When a shape subscribes.
eager— in the boot subscription set (today’s default).lazy— excluded from boot; subscribed on first query-reference. Withpersistent, first use is a one-time ignition that promotes it to eager for later sessions; withephemeralit is session-scoped.
subscription: "lazy",Default. eager. When to use. lazy for a rarely-opened view, to keep the boot connection
budget for the shapes the first screen needs. A lazy relation auto-activates on any query reference. The
live-query result envelope’s hydrating/ready state is not lazy-specific: it stays hydrating until
every consistency group the query reads — eager or lazy — has caught up and its rows have been delivered,
so a query over an eager relation still catching up on a cold boot is covered too (cached rows paint
immediately meanwhile). A readwrite entry declared lazy and never
activated is still fully provisioned locally, so it can serve as a
write-only table — authored through updateBlind without
ever streaming a row.
Gate authenticated lazy groups until auth is resolved. The first query reference starts the
relation’s whole consistency group using the claims available to that shape request. If the group’s
row filters return DENY_ALL without a user claim, a query mounted while auth is still loading starts an
anonymous, empty subscription. A later auth change can rotate the shape and rehydrate it, but that is a
refetch boundary rather than the intended first activation; with persistent retention, the premature
activation is also remembered for later sessions.
For a group that is meaningful only to signed-in users, defer every query that can first reference it
until the session exists. In React, pass { ready: session != null } to the live hook. Referencing one
member is enough to activate every member of the group. The same holds for writes: an ordinary optimistic
write self-activates its target’s group (see
Lazy read/write groups need an echo), so an
authenticated-only group should not be written before the session exists either — the write would activate
it against anonymous claims. Activating a claims-denied group with no auth token now emits a console.warn
naming the group, so an accidental anonymous activation (read- or write-triggered) is visible rather than
silent.
retention
Section titled “retention”What it achieves. Whether the local copy is durable.
persistent— the durable PGlite/OPFS backend with a resumable subscription-state.ephemeral— the table’s whole local cluster (read cache, overlay, journal, sequence, views, reconcile function) is emitted asTEMP/pg_temp, so reads and writes leave no durable trace and re-hydrate fresh each session.
An ephemeral table’s sync bookkeeping — its subscription cursor and its tagged-subquery reason sets — is
session-scoped too, held in pg_temp alongside the rows it tracks. The guarantee that follows: a returning
session always re-streams an ephemeral view from scratch. Close the tab, come back, and the view
rebuilds cleanly rather than resuming a stale cursor over an empty cluster — so nothing an earlier session
synced can linger or reappear stale.
retention: "ephemeral",Default. persistent. When to use. ephemeral for data that must not sit durably on the
client — proctored-exam answers, sensitive/PII under data-minimisation — or for cold per-user data not
worth persisting. Composition rule: an ephemeral table has no durable offline write queue (its
journal is TEMP), so pair a must-not-lose write with a pessimistic writeMode or a
prompt flush. To make this per-client (durable for one client, ephemeral for another), see
withRetention / asEphemeral.
writeMode
Section titled “writeMode”What it achieves. How a write reaches the server.
optimistic— staged locally with an overlay, the UI updates immediately, and the convergence loop flushes the journal as one batch; the canonical row returns via the sync echo.pessimistic— server-authoritative: the write flush-routes to an authoritative endpoint that applies it in an isolated, serialised transaction and returns an accepted/rejected result before the UI shows success.
writeMode: "pessimistic",Default. optimistic. When to use. pessimistic for an invariant the client can’t evaluate
locally — a capacity/quota/uniqueness gate enforced server-side — or to pair with ephemeral so a
must-not-lose write reaches the server before the tab closes. Not allowed on a readonly table. A
dynamic override is the imperative transaction({ mode }) block.
Row classification
Section titled “Row classification”A privacy or visibility rule is rarely about one entry — it is about a kind of row that several entries carry. Classification lets you name those kinds in your own words, make classifying mandatory, and then assert rules against every entry of a class at once. See ADR-0052.
rowClass
Section titled “rowClass”What it achieves. Records what kind of rows this entry carries. The vocabulary is entirely yours — pgxsinkit defines no values and attaches no behaviour to any of them. It is documentation-as-code at the definition site, and the key an invariant binds to.
rowClass: "team-scoped",Default. None. Constraints. When the registry declares rowClasses,
this becomes required and must be one of the declared values; otherwise it is unconstrained. It is
authoring metadata only: it never enters the registry fingerprint or the read-contract fingerprint, so
classifying a table never invalidates a local store’s cache. Every projection carries it through —
asReadonly, withRetention/asEphemeral, and defineReadProjection (which inherits the owner’s
class unless you pass its own rowClass).
rowClasses (on the registry)
Section titled “rowClasses (on the registry)”What it achieves. Declares the registry’s closed classification vocabulary — and by doing so makes
classification a fail-closed obligation: every entry must then carry a rowClass from that exact set,
checked when defineSyncRegistry runs (so, at module eval).
export const registry = defineSyncRegistry({ rowClasses: ["directory", "team-scoped", "channel-scoped"], tables: { profile, team, team_member, channel, issue, message },});When to use. As soon as any rule spans more than one table. The point is not the labels — it is that the next table someone adds cannot join the registry without its author deciding which kind of rows it holds, so it can never inherit an obligation invisibly. The error names every unclassified or wrongly-classified entry at once. Constraints. Only available on the definition-object form (the bare registry map has nowhere to declare it, and is unconstrained); an empty or duplicated declaration is rejected.
assertRegistryInvariant (the registry-wide invariant)
Section titled “assertRegistryInvariant (the registry-wide invariant)”What it achieves. Asserts a rule about the rendered authorization artifacts of every entry a
class binds, evaluated against named claims personas. Each cell hands your predicate the real read filter
for those claims (renderedWhere — the same buildRowFilterShape call the proxy makes per shape request)
and the entry table’s RLS policies rendered to SQL text (renderedPolicies), so one predicate can check
both enforcement surfaces at once.
import { assertRegistryInvariant } from "@pgxsinkit/contracts";
assertRegistryInvariant(registry, { name: "team-scoped rows are never visible to an anonymous caller", appliesTo: ["team-scoped", "channel-scoped"], // or a predicate over the entry claimsFixtures: { anonymous: {}, member: { sub: memberId }, admin: adminClaims }, holds: ({ fixtureName, renderedWhere }) => fixtureName !== "anonymous" || renderedWhere?.where === "false" || "anonymous read is not denied",});When to use. At module eval beside the registry (or in a test), the same way
assertReadContractPreserved is used. Unlike the
fingerprints, it can see the customWhere body — because it renders it — so it catches a filter-logic
change no hash can; the trade is that it sees exactly the personas you enumerate, and nothing else.
Complement it with rowFilter.revision (which forces the cache/subscription reset a logic change needs).
Fail-closed behaviour. Every failing cell is aggregated into one error (entry (fixture): reason),
never first-failure-only. A class in appliesTo that the registry’s declared vocabulary does not contain
throws immediately, and an invariant that binds zero entries throws — an invariant that checks nothing
would otherwise pass vacuously, which is the exact failure this mechanism exists to remove. It is a pure
audit: nothing about runtime behaviour changes.
What it cannot see. Per-entry declarations only. If a worker or route writes rows into one table as a consequence of rows in another, that composition is invisible here — test it at the composition seam. See The two paths.
Per-client projections
Section titled “Per-client projections”One authoritative registry defines each table at its maximum capability; each client consumes a projection of it. The projection helpers are pure transforms over an entry — readable at the call site, and guarded by an invariant so a projection can never silently diverge the data it syncs.
asReadonly (readonly projection)
Section titled “asReadonly (readonly projection)”What it achieves. Returns the entry defineSyncTable would have produced with mode: "readonly":
flips mode, drops the _read_model view and the overlay/journal client projection, and drops the
write-only metadata (conflictPolicy, governance, writeMode). The read/identity contract — table,
columns, primary key, synced-table name, column omission, shape/row filter — is preserved, and the
lifecycle axes carry through.
import { asReadonly, defineSyncRegistry } from "@pgxsinkit/contracts";
export const memberRegistry = defineSyncRegistry({ ...authoritativeRegistry, team: asReadonly(authoritativeRegistry.team), // members read teams… team_member: asReadonly(authoritativeRegistry.team_member), // …but only admins write them});When to use. When the same table is readwrite for one role and readonly for another. The
read-only client then provisions no write cluster and exposes no write handle, so an accidental write is
impossible — not merely quarantined. See ADR-0025.
withRetention / asEphemeral (lifecycle projection)
Section titled “withRetention / asEphemeral (lifecycle projection)”What it achieves. Returns a copy of an entry with retention overridden, everything
else preserved verbatim. withRetention(entry, retention) is the bidirectional primitive;
asEphemeral(entry) is the named convenience for the common direction. Because retention is a lifecycle
axis (not part of the read contract), a projection may legitimately differ on it — the override still
passes assertReadContractPreserved.
import { asEphemeral, asReadonly, withRetention } from "@pgxsinkit/contracts";
// One client keeps the exam durable; another wants no durable trace:const examForProctor = withRetention(authoritativeRegistry.exam, "ephemeral");
// Compose with asReadonly — read-only AND no durable trace:const examForViewer = asEphemeral(asReadonly(authoritativeRegistry.exam));When to use. When durability is a per-client decision: persist on a trusted device, go ephemeral on
a shared/exam machine, all from one authoritative registry. Constraints (carried over, not enforced by
the helper): every table in a consistencyGroup must agree on retention — override the whole group,
or defineSyncRegistry rejects the mixed group; and an ephemeral writable table has no durable offline
write queue (pair with pessimistic writeMode). A singleton-group table can be flipped alone.
assertReadContractPreserved (the projection invariant)
Section titled “assertReadContractPreserved (the projection invariant)”What it achieves. Asserts that every table a per-client projection declares preserves the
authoritative entry’s read contract — synced columns, primary key, column omission, row-filter
shape. A projection may differ only in write capability and lifecycle (mode, the overlay/journal
machinery, conflictPolicy, governance, writeMode, consistencyGroup, subscription, retention);
diverging the data throws. A table only in the authoritative registry is a permitted subset; a table
in the projection with no authoritative source is an error.
import { assertReadContractPreserved } from "@pgxsinkit/contracts";
assertReadContractPreserved(authoritativeRegistry, memberRegistry, { label: "member" });When to use. At module-eval (or in a test) where you assemble the client registries, so a drifted
projection fails closed instead of silently serving different rows to different clients. Like every
fingerprint here it can’t see the customWhere body — bump rowFilter.revision so a logic-only
divergence is caught.
See also
Section titled “See also”- The two paths, write path, read path — the mental model these options configure.
@pgxsinkit/contractsAPI reference — exact types for every field above.- Design decisions — ADR-0015 (conflict policy), ADR-0021 (lifecycle axes), ADR-0022 (write modes), ADR-0025 (per-client projection), ADR-0052 (row classification and registry invariants).