Skip to content

The two paths

pgxsinkit moves data in two directions over two different mechanisms. They are not symmetric and they are not one channel — Electric carries the read direction only, never writes.

PostgreSQL → ElectricSQL → PGlite

Postgres is the source of truth. ElectricSQL streams shapes (filtered row sets, including membership fan-out) to the client, where they land in local PGlite. The app reads from PGlite. This path is live and continuous. See The read path.

client → write route → PostgreSQL

Local edits do not travel back through Electric. They are staged locally, flushed as a batch to a typed write route on the pgxsinkit server, and applied to Postgres by a single in-database function. See The write path.

  • You cannot “write to Electric.” Electric is read transport only. A mutation that isn’t sent to the write route never reaches Postgres, and therefore never comes back down the read path.
  • The loop closes through Postgres. A local write becomes durable only once the server applies it; it becomes visible to other clients only once Electric streams it back down. The client holds the optimistic value in an overlay until that echo returns (see The write path and Timestamps).
  • Synced tables are replication targets. Application code must never mutate a synced table directly — those rows are owned by the read path. All writes go through the mutation runtime.

The two paths above are the sync rail, and they are what this page is about. Beside them sits a third, non-sync lane for data that was never sync state: high-volume, append-only client facts (view logs, interaction events) that are never edited, never conflict, and are never read back down. Those go on the event lane — an append into a local Outbox, flushed to an ingestion endpoint and handed to a queue and your own consumer callback. It has no overlay, no echo and no conflict resolution, so the asymmetry above simply does not arise for it. Everything that is sync state still obeys the one rule.

Read from PGlite. Write through the write route. Never write to a synced table directly, and never expect Electric to carry a write.

The registry keeps one table’s read filter and write policy in agreement — that is its job, and it does it from a single declaration. What it cannot see is a rule that spans tables and rails.

An example: an invite table’s RLS legitimately lets an offering-scoped teacher create an invite, and an acceptance worker later mints a membership row from it. Both policies are correct alone; together they can break “this offering only ever has one member”, because the worker’s semantics appear in no per-table declaration.

So when something writes rows as a consequence of other rows, the invariants of the output table are the ones at stake — re-check them against current state when the worker runs, rather than trusting that the input row’s authorization already settled it. And test at the composition seam, driving the worker or route end to end: per-table policy tests structurally cannot fail on a composition hole.