Skip to content

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.

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.


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.

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.

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 constraint

Default. ["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.

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 UPDATE

Default. "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.

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_model view, 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_model view. Requires a conflictPolicy and a nowMicroseconds-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.


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.

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.


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 Drizzle SQL fragment (preferred; values become bound params), a raw string (the escape hatch — you must escape interpolated values), or null to bypass filtering (e.g. admin). Reference columns through c() so the where uses the bare identifiers Electric’s grammar requires; cast enums to text (${c(col)}::text = 'x'); keep subqueries self-contained. Return DENY_ALL to make no rows visible.
  • rowFilter.columns — restrict the synced columns at the shape URL.
  • rowFilter.revision — an opaque version tag for the customWhere body (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 to tableName; 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.


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.warn per (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 a managedField — never from a client payload.

  • 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.


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) plus serverOnlyColumns for 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.


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 with discardConflict. (A structurally-rejected write is instead quarantined, with the symmetric discardQuarantined rollback — 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).

What it achieves. Columns the database stamps on apply, overriding any client-sent value (the client write payload omits them). Two strategies:

  • nowMicrosecondsclock_timestamp() microseconds, stamped via the canonical public.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 for created_at_us and updated_at_us. The updated_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 JSON claimPath. ["sub"] is the auth subject (the old auth.uid() owner); ["app_metadata","person_id"] an app-minted identity. An optional cast overrides 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).

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.


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.

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_SCOPE

Default. 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:

  1. 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.
  2. 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.
  3. 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).

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. With persistent, first use is a one-time ignition that promotes it to eager for later sessions; with ephemeral it 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.

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 as TEMP/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.

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.


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.

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).

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.


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.

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.