| name | core |
| description | Load when writing or reviewing code that uses @pgxsinkit/* — the offline-first sync toolkit for the Postgres -> Circuits engine -> durable-streams -> PGlite read path and the client -> write API -> Postgres write path. Teaches the model the source does not make obvious: the two sync paths are separate and asymmetric, exactly one write path (an in-database apply function, not per-table CRUD), a non-sync Event lane carries append-only facts, a client asks the control plane for its streams (controlPlaneUrl + streamBaseUrl, both or neither) and reads fail closed twice — 401 control plane, 403 edge — local PGlite schema is not full DDL parity, writable tables must declare a conflict policy plus managed fields, ordinary writes self-activate their lazy group, and authenticated groups must not activate before claims exist. Load before wiring sync, defining a registry, adding an Event stream, or debugging "writes don't appear" / "an acked write stays pending" / "no rows stream" / "a removed member still sees rows". |
| metadata | {"type":"core","library":"@pgxsinkit/client","library_version":"0.3.0","source":"https://pgxsinkit.github.io/llms-full.txt"} |
Using pgxsinkit correctly
pgxsinkit is a toolkit, not a database, a framework, or the demo app. The @pgxsinkit/* packages
are the product: contracts (the registry + shared types), server (the write API, the read path's
control plane, and the stream edge), client (local PGlite store + mutation runtime +
convergence), and react (hooks). A consumer installs these and wires them; they do not "run pgxsinkit".
The one idea everything else follows from: two separate, asymmetric sync paths
- Read path: Postgres → the Circuits engine (ingests logical replication and maintains the shapes
the control plane creates) → durable-streams → the edge (token-gated) → local PGlite. The
client does not
construct a stream URL: it subscribes through the control plane, which decides which streams that
subject may read and hands back their paths. Reads are served from PGlite.
- Write path: client stages an optimistic local write → flushes a batch to the write API → one
in-database apply function (
pgxsinkit_apply_mutations) applies it under RLS → Postgres.
Those two are the sync rail, and almost everything below is about it. Beside them sits one more,
deliberately non-sync lane — the Event lane — for data that was never sync state (see below). So the
accurate count is two sync paths plus one event lane, not three paths.
Writes do not travel back to the writer on the write channel. The loop closes through Postgres: your
write lands in Postgres, then streams back to every subscriber (including you) as an ordinary read-path
change, which clears the optimistic overlay. Do not look for a write to "come back" on the write channel,
and do not try to write down the read path — it is read transport only.
There is exactly one write path
There is no per-table CRUD API and no selectable backend. Every mutation — create, update, delete —
goes through POST /api/mutations and is applied by the single in-database function. You provision that
function once from your registry with the pgxsinkit-generate CLI (a drizzle-kit migration). Do not
invent REST endpoints per table; do not write to Postgres tables directly from the client.
Blind update — writing a row your read shape excludes. A pessimistic write unit
(client.transaction({ mode: "pessimistic" }, (tx) => …)) flush-routes to the authoritative endpoint and
resolves with each member's acked / conflicted / rejected outcome. Alongside create/update/delete
its handles carry updateBlind(entityKey, patch): an update-by-key for a target your own read shape
excludes (a write-only flow, or an anonymity-scoped moderation write whose row streams only to a different
projection). Ordinary update requires the entity in the local read model (to seed the overlay + capture the
base version); updateBlind skips that — it plans a journal row only, writes no overlay, and the
acked row retires without a synced echo (nothing local ever converges for it). It is pessimistic-only
and throws at enqueue on an optimistic route. Never seed a phantom base row to satisfy update for an
invisible target — that row + its overlay would linger forever behind the echo barrier; use updateBlind.
The third lane: Event streams are NOT the write path
High-volume, append-only client facts — "viewed this", interaction logs, review grades — are never edited,
never conflict, and are never read back down. They do not belong on a synced table (every client would
re-download its own log, and the conflict/overlay machinery would tax rows that cannot conflict). They go on
the Event lane, registered on the SAME registry under streams (the record key IS the stream name):
appendEvent() → Outbox (durable, local-only) → flush → POST /api/events → queue → your consumer callback
- It is not a mutation. No overlay, no echo, no conflict policy, no convergence — nothing comes back
down, and
appendEvent resolves on durable local enqueue, not on delivery (it is async; its four
refusals — no streams registered, unknown stream, schema-invalid payload, oversized payload — REJECT the
promise, so await/catch it). Delivery is at-least-once: the server-side callback must be
idempotent, and deduping on the library-stamped eventId composes that to effectively-exactly-once.
- Choose it over a writable table when the data is queue-shaped: append-only, no client ever reads it
back, no per-row conflict, and volume high enough that syncing it would be self-inflicted. Choose a
readwrite table the moment anything needs to edit the row, resolve a conflict, show it optimistically,
or read it on another client. "Write-only table" (a readwrite entry that never streams) is the write
path's answer, not this lane's — that one still applies under RLS and acks per mutation.
- Identity is server-stamped from verified claims (
identity: { viewerId: { claimPath: ["sub"] } });
the client's envelope carries none, so never put an actor id in the payload. Object payloads must be
strict (z.strictObject), and a payload schema may evolve only backward-compatibly.
- The client-side surfaces are
onOutboxStatus (drain signal) and onEventLaneReport (per-pass verdicts:
acked / refused / rejected / deferred, of which only deferred is non-terminal). Flush cadence
and batch caps are client config (events), never registry.
Reading the local store: base table vs overlay view
Reads run against local PGlite through the client, not hand-written SQL. For a pure-Drizzle read, pass
the builder callback directly to client.query((c) => …) (the guarded read): pgxsinkit scans the compiled
SQL and activates + awaits every lazy relation the query touches (FROM, JOIN, subquery, WHERE) before it
runs — nothing to declare. client.query resolves to the rows array directly (not { rows }). Inside
the callback, reach relations through a directly-imported synced table/view object, c.drizzle, or
c.views. Only when the builder embeds a raw sql`…` fragment (which the scan can miss) use
client.queryRaw({ use, build }) and name those relations in use. queryRow / queryRawRow are the
first-row-or-null variants. (Reactive equivalents: useLiveDrizzleRows for pure reads,
useLiveQueryRaw({ use, build }) for raw fragments.)
Auth-gated lazy groups. A query that references one lazy relation activates its whole consistency
group, subscribing with the claims available at that moment. If the row filter returns
DENY_ALL_PREDICATE without a user claim, do not let an authenticated-only query run while auth is
unresolved: gate it (ready: false in the React hooks) until the session exists. Making the group eager
is not an equivalent fix — it can open the same anonymous subscription during boot. The control plane
declines a denied shape outright, so the group settles "ready" with nothing granted and stays empty;
activate it once with the correct claims instead. Activating a claims-denied group
with no auth token now emits a console.warn naming the group, so an accidental anonymous activation is
visible rather than silent.
Writes self-activate. An ordinary optimistic create/update/delete on a lazy readwrite entry activates
its group automatically at enqueue (a write is a reference, and a reference activates), so the Postgres
commit can echo down the read path and retire the acknowledged journal row — no manual activator query is
needed. Because that activation uses the claims available when the write runs, an authenticated-only group
still should not be written before the session exists (gate the write on auth, same as a read).
updateBlind is the deliberate exception: it has no overlay, does not activate its group, and retires on
the authoritative ack without an echo.
Lint the split. @pgxsinkit/client ships an oxlint rule via its ./oxlint subpath export. Enable it
in the consuming repo's .oxlintrc.jsonc — "jsPlugins": ["@pgxsinkit/client/oxlint"] +
"pgxsinkit/guarded-query-purity": "error" — to catch a raw sql`…` fragment on the pure path and a
redundant use (autofixable), the two things the types can't. (oxlint jsPlugins is alpha.)
The non-obvious rule — which relation to select FROM:
- A readonly entry syncs only its base table. Read it from the entry's
.table
(registry.<name>.table). There is no overlay.
- A readwrite entry also generates a
_read_model overlay view that merges your own optimistic
(not-yet-synced) writes on top of the synced base rows. Read it from the entry's .view
(registry.<name>.view) — not its .table. Selecting the base .table of a readwrite entry
silently omits the writer's own pending writes, so a just-issued create/edit/delete will not appear
locally until it round-trips through Postgres and streams back down the read path.
client.query((c) => c.drizzle.select({ id: catalogResource.table.id }).from(catalogResource.table));
const reportView = registry.report.view!;
client.query((c) => c.drizzle.select({ id: reportView.id, status: reportView.status }).from(reportView));
This is the read-side twin of "writes return down the read path": your optimistic write is visible
immediately only because you read the overlay view; the base table catches up when Postgres streams the
committed row back. (c.views.<name> is the client's accessor for the same overlay views; the entry's
.view object is the direct handle. Type note: .view is typed as optional on a SyncTableEntry, so a
non-null assertion — registry.<name>.view! — is expected at the read site.)
Reaching the generated relations directly (factories). For diagnostics, tests, and tooling — not
app code — @pgxsinkit/client exports a typed factory per generated relation so you author Drizzle instead
of hand-written SQL against <t>_overlay / <t>_mutations / <t>_sync_state / <t>_read_model:
getSyncedLocalTable, getOverlayTable, getJournalTable, getSyncStateView, getReadModelView,
getLocalMetaTable (all (registry, tableKey); overlay/journal/sync-state/read-model are writable-only).
import { getOverlayTable, getSyncStateView } from "@pgxsinkit/client";
const overlay = getOverlayTable(registry, "report");
db.select({ pending: getSyncStateView(registry, "report").pendingCount }).from(getSyncStateView(registry, "report"));
Why the factories (vs the entry handles): entry.table / entry.localTable are already schema-qualified
(built with the registry's schema, enforced to match it), so for the synced cache the factory only tracks
a clientProjection.syncedTable rename. But entry.view (the _read_model view) is built unqualified
— a non-public local schema must read it via getReadModelView — and _overlay / _mutations /
_sync_state have no entry handle at all. All factory objects carry the registry's local schema and
memoize per (registry, tableKey) (getLocalMetaTable per local schema). Typing follows the registry — a
concretely-typed registry gives real per-column types (overlay.col, $inferInsert, .values() all
typecheck), a bare SyncTableRegistry degrades to bracket access (overlay["col"]). getJournalTable /
getSyncStateView are always conservatively indexed for entity/PK columns, but key them differently:
the journal by DB column name (journal["author_id"]), the sync-state view by the entry's property
key (syncState["authorId"]); the fixed runtime/state columns stay typed on both.
In app code prefer the guarded client.query — the factories are for reading the generated relations
directly (a test, a perf harness, a diagnostic), not a replacement for it.
Non-negotiables (each fails closed or throws)
- The read path is two URLs, both or neither.
createSyncClient / defineSyncWorker take
controlPlaneUrl (subscribe, stream-token re-mint, the convergence barrier) and streamBaseUrl
(the edge that serves durable-streams reads). They are separate deployments — the edge belongs on its
own origin, because the cache key is the URL — so supplying one without the other throws at boot.
- Writable tables have two hard requirements.
defineSyncRegistry throws unless every readwrite
table declares both a server-version managed field (a nowMicroseconds-on-update column,
conventionally updated_at_us, that optimistic convergence keys on) and a conflictPolicy
(reject-if-stale | last-write-wins). There is no silent default — a silent last-write-wins is the
exact data loss the choice exists to surface.
- Managed fields are server-assigned. Fields stamped by
authClaim (a verified claim at a JSON path —
["sub"] is the old auth.uid() owner idiom) / nowMicroseconds are set by the apply function; the
write API rejects a client payload that includes them. Never send them.
- Read authorization fails closed in two places, and they mean different things. The control plane
answers 401 when the deployment's
resolveAuthClaims yields no sub (the client reports
auth-needed and keeps retrying for a fresh token — an expired JWT is retryable, not a refusal); the
edge answers 403 for a stream token that expired or names a scope the subject no longer holds — the
client re-mints once, and a second rejection is read as revocation, so it truncates that scope and
unsubscribes. A rowFilter that restricts nothing —
no customPredicate and no columns allow-list — is refused at definition time rather than
quietly compiled into a shape with no subject test.
Authorization runs in two engines — derive both from one predicate
A row must never be readable-but-unwritable (or the reverse). The two paths enforce auth in different
engines, so the subject is referenced two ways:
- Write path — RLS in Postgres: policies use
auth.uid() / current_setting('request.jwt.claims');
the applier sets the claims before applying a batch.
- Read path — the shape
rowFilter: the control plane compiles the filter once, at shape creation,
and posts it to the engine — so customPredicate returns a predicate AST, not SQL text. Author it
with the p.* builders over real Drizzle columns (p.eq, p.and/p.or/p.not, p.in +
p.subquery); null bypasses filtering (every row) and DENY_ALL_PREDICATE denies. There is no string
to escape and no grammar to satisfy, and comparisons are checked against the column's own TypeScript
type — a mistyped enum label or a jsonb/Date column with no scalar wire form is a compile error.
Use buildSupabaseOwnerOrAdminNativePolicies / buildSupabaseMembershipNativePolicies for the common
owner/membership shapes — they take Drizzle columns, so call them in defineSyncTable's extras
callback; compose your own from pgPolicy + Drizzle operators for anything beyond them (e.g. collaborative
any-member writes). Each ships its read-path mirror (buildOwnerOrAdminShapePredicate,
buildMembershipShapePredicate, buildGrantScopeAccessShapePredicate), so build the read filter and the
RLS policy from the same Drizzle columns and they cannot drift.
Membership changes converge the local store — both ways, even offline
A subquery (membership) read filter — p.in(col, p.subquery(…)) — is reactive in both directions,
against a running client with no re-subscribe: granting a membership materialises the container's rows
in the member's local PGlite; revoking one evicts them (a row reachable through a second membership
survives until its last grant is gone). The engine states each side rather than implying it: the wire
carries upsert | delete, so a row leaving your shape arrives as an explicit delete envelope. This holds
live and across an offline gap — a client disconnected when the membership changed converges on
reconnect (it resumes from its per-stream offset and replays what it missed), so a revoked member's read
access never lingers offline. Observe it on the live subscription or a normal resume, not by re-reading a
stream from the start.
Local PGlite schema is not full DDL parity
The local store generates enums, tables, the overlay, the journal, and convergence triggers — not
RLS, arbitrary triggers/functions, or managed-field defaults, and it does not enforce CHECK / FK /
UNIQUE the way Postgres does. Treat Postgres as the source of truth for integrity; do not assume a
constraint that holds server-side also holds in PGlite.
Common mistakes
- Expecting an optimistic write to echo back on the write channel — it returns down the read path.
- Configuring
controlPlaneUrl without streamBaseUrl (or the reverse) — it throws; they are two
deployments, and the edge belongs on its own origin.
- Omitting
conflictPolicy (throws) or sending a managed field in a write payload (rejected).
- Declaring a
rowFilter that restricts nothing (no customPredicate, no columns) — refused at
definition time, because the compiled shape would carry no subject test.
- Writing directly to Postgres tables / building per-table CRUD instead of using the one write path.
- Reading a readwrite entry from its base
.table instead of its .view overlay, so your own
optimistic writes do not appear locally until they round-trip through Postgres.
- Activating an authenticated lazy group before auth resolves — reading OR writing while claims are
unresolved subscribes against anonymous claims (now flagged by a console warning), and the control plane
grants nothing, so the group stays empty until it is desynced and referenced again.
- Assuming PGlite enforces every Postgres constraint.
- Assuming a revoked member keeps their synced rows offline — membership changes converge both ways,
live and on resume.
- Passing a PGlite storage URL (
idb://…, memory://…) as storePath — the contract is a plain
name and schemes throw; the backend is derived (ADR-0036).
Naming the local store
createSyncClient/createClientPGlite take a plain storePath (e.g. "my-app-store"), never a
storage URL — the backend is derived, not named: a capability-proven browser engine home uses the
constant-handle OPFS-repacked backend, fixed worker mode and browser fallbacks use IndexedDB, and bun/Node
uses the filesystem; anything containing :// throws InvalidStorePathError. Memory-backed stores are
deliberately not expressible in the production API (pgxsinkit's retention + journal durability assume a
persisted store): in TESTS, spread memoryStoreForTests("name") from @pgxsinkit/client/testing into the
options; a caller-owned pgliteInstance that is provably non-persistent (a bare new PGlite(), or
memory://) is refused with NonPersistentStoreError unless a testing acknowledgment is spread alongside.
Separately from persistence, the store's flush timing is durability, declared once on the registry
(storage.durability, default "relaxed") — never a per-open, per-tab, or minting-surface option. Its
physical behavior is backend-specific: idb detaches its whole-snapshot flush, while OPFS-repacked keeps the
host awaited but omits routine physical flushes. "strict" restores each backend's strict boundary. The
SharedWorker factory, capability-driven Safari/Chromium/Firefox engine placement, relocation outcomes,
backend permanence, destruction, write-latency rationale, and recovery windows live in the operating
skill.
Backups, the SQL exports, and restoreFrom are there too.
Where to look
- Concepts: the two paths, read path, write path, local-schema DDL parity, and timestamps (microsecond
BIGINT, decimal strings across the boundary).
- For the Event lane in full — the Outbox, verdicts, backpressure, the delivery contract and the limits —
read https://pgxsinkit.github.io/concepts/event-lane/, then load the skill for the half you are
writing:
operating (@pgxsinkit/client) for the Outbox surfaces and flush tuning, deploying
(@pgxsinkit/server) for the ingest route, the pgmq/queue DDL and the consumer runner, and
registry-authoring (@pgxsinkit/contracts) for declaring a stream.
- For deployment and runtime/operational behavior (cold starts, convergence cadence, the HTTP/2
connection budget, the
globalThis.__pgxsinkitDebug latency instrumentation), load the operating
skill.
- Full prose: https://pgxsinkit.github.io/llms-full.txt.