Gonvex

Query Invalidation

How Gonvex decides which live queries should refresh after writes.

Query Invalidation

Query invalidation decides whether a committed write can affect a live subscription.

The target behavior:

affected subscription -> refresh
unaffected subscription -> skip
unknown impact -> refresh

Why It Matters

Realtime apps become expensive if every write refreshes every query. They become incorrect if a write changes visible data but the frontend never gets a fresh result.

Gonvex uses a safe-first rule:

Refreshing too much is a performance issue.
Missing a required refresh is a correctness issue.

Generic Dependencies

Invalidation is based on generic query metadata, not app-specific concepts.

Useful dependency metadata includes:

  • table names read
  • columns read
  • filters applied
  • indexes or ranges scanned
  • sort order
  • limit/window information
  • returned primary row IDs
  • tenant database route

For example, the runtime treats workspace_id, organization_id, or account_id as ordinary predicates. Those names are not hardcoded into Gonvex core.

Declare dependencies when registering a function:

app.Query(
  "tasks.list",
  ListTasks,
  gonvex.Reads("tasks").
    Columns("id", "title", "status", "updated_at").
    Filters("status").
    OrdersBy("updated_at"),
)

app.Mutation("tasks.update", UpdateTask, gonvex.Writes("tasks"))

The runtime maintains a reverse (project, tenant, table) index, so an update does not scan unrelated connections or tenants. Undeclared dependencies retain the compatibility fallback and broad tenant-scoped behavior when impact is unknown.

Write Sets

Mutations declare write metadata:

  • table changed
  • operation type: insert, update, delete
  • changed primary row IDs where practical
  • changed columns
  • old and new indexed values where needed
  • tenant database route

The runtime compares write metadata to query dependencies.

Conservative Cases

Some writes require broad refreshes because result membership may change:

  • insert into a sorted or filtered result set
  • delete from a paginated result set
  • update to a column used by filtering
  • update to a column used by sorting
  • large write where changed IDs are too expensive to enumerate
  • query with dependencies the runtime did not capture precisely

Broad refresh only affects active subscriptions in the relevant project/tenant/database scope.

Row-Aware Cases

When a query result contains primary row IDs and a small update only changes rows outside that result, the runtime can often skip refresh.

Example:

subscription result ids: [a, b, c]
mutation changed ids: [x, y]
intersection: none
safe action: skip, unless filters/sort/window membership can change

If a changed row could enter or leave the result because of filters, sorting, or pagination, refresh instead.

Tenant Scope

Invalidation must respect tenant routing.

Writes in tenant acme do not refresh subscriptions in tenant globex. Landlord/project data uses a separate scope.

Runtime Behavior

Postgres statement trigger
-> bounded table/operation/row-id/changed-column notification
-> tenant/table reverse index
-> column and row intersection
-> mark matching shared runners dirty
-> serialized rerun and result comparison

Insert/delete and large statements remain broad within the relevant table. Notifications cap row IDs at 500 and changed columns at 100 to stay within PostgreSQL notification limits.

One listener is acquired per active tenant database, subject to a configurable cap. It closes after the tenant becomes idle. On first connection or reconnect, Gonvex broadly refreshes that tenant because PostgreSQL NOTIFY is not durable.

Durable sync collections use their own transactional change log rather than this snapshot-only recovery path. See Durable Sync Collections.

On this page