Reactive invalidation

How `.with()` subscriptions wake on dependent-table changes. two-stage gate: a per-table dependency-graph plus a per-field pre-filter, soundness argument, trade-offs.

Reactive subscriptions on .with() queries don't just track the root table — they walk the eager spec at subscribe time, register against every dependent table, and pre-filter change events at the column level so updates that don't matter never reach the SQL planner.

How it works

When a subscription opens with database.users.with({ posts: { with: { author: true } } }), three things happen at subscribe time:

  1. Snapshot fetch: the framework runs the eager-load query once, delivers the result as _tag: 'snapshot' to the subscriber.
  2. Dependency graph registration: resolveDependentTables(descriptor) walks the spec + relations registry to produce {users, posts} (author resolves back to users so it deduplicates). The dispatcher registers the subscription against EACH table.
  3. Relevance map computation: resolveRelevantFields(descriptor) collects, per dependent table, the column SET whose change could affect the snapshot:
    • Projected columns (from descriptor.projection; wildcard * when unset).
    • Predicate columns (every column reachable from the WHERE clause AST).
    • Order columns (descriptor.order[*].column).
    • Eager-load join keys (source + target FK on every relation).

At runtime, every ChangeEvent consults this map BEFORE triggering a re-query.

v1 — Dependency graph fan-out

Stage 1 — the dependency graph tracks per-table dependency. When posts changes, the dispatcher finds every subscription whose dependent-table set includes posts and triggers a re-query. After the re-query, a shallow row-set compare (JSON.stringify per row) suppresses the delta if the result is shape-identical to the last delivered snapshot.

┌───────────────────────────────────────────────────────────────────────┐
│ Subscription: users.with({ posts: { with: { author: true } } })       │
│                                                                       │
│   change(users)     → re-query  → diff vs lastDelivered → maybe delta │
│   change(posts)     → re-query  → diff vs lastDelivered → maybe delta │
│                                                                       │
│   change(orgs)      → no-op (not in dependency graph)                 │
└───────────────────────────────────────────────────────────────────────┘

This is CORRECT but expensive: every write to a dependent table costs a SQL round-trip, even when the write touched a column the subscription doesn't care about.

Stage 2 — the per-field pre-filter

The per-field pre-filter adds a column-grain gate AHEAD of the re-query. On a write:

  1. mutatedColumns(event) returns the columns the change actually touched.
    • Insert / delete → wildcard * (membership change always relevant).
    • Update → diff old vs new for differing values.
    • Incomplete event (old=null on update) → wildcard (soundness).
  2. isEventRelevant(event, relevantMap) intersects mutated × relevant.
  3. Empty intersection → skip the re-query entirely. No SQL round-trip. No deep-equal compare. No delta.
relevantMap = {
  users:        { id, email, *projected, …predicate, …order },
  posts:        { id, author_id, *projected, …predicate, …order },
}

mutatedColumns(event{table: 'users', op: 'update', old: {x:1}, new: {x:2}}) = { 'x' }
isEventRelevant ⇒ 'x' ∉ relevantMap.users ⇒ skip

The dispatcher logs the skip count per change event so operators can verify the filter is paying off:

voltro logs --tail 50 | grep handleChange/prefiltered

Soundness

The relevance set is a SUPER-set of fields whose change can flip the snapshot. The collection rules (resolveRelevantFields) overshoot deliberately:

  • Predicate columns: a write to a predicate column may change set membership, even if the value the predicate compares against doesn't change.
  • Order columns: a write to an order column may shift the row's position, changing the slice the subscription sees.
  • Eager-load join keys (FK + PK on each side): a write to a FK may shift which children belong to which parent.
  • Projected columns: if the snapshot emits the column, every change to it is by definition a delta.
  • * wildcard when projection is unset: the snapshot emits the full row, so every column is potentially load-bearing.

Skipping a write that lies OUTSIDE this set cannot produce a missed delta: the resulting snapshot was already correct. The pre-filter has zero false negatives.

The only failure mode is a false positive (slower v1 path runs unnecessarily) — no correctness consequence, just wasted work.

Insert / delete always pass through

Membership changes are load-bearing regardless of which columns the row carries. The pre-filter treats insert and delete as wildcard-relevant, so they always reach the re-query path. v1's deep-equal compare still suppresses the DELTA if the snapshot happens to be shape-identical (rare on insert/delete but possible if the new/deleted row was outside the subscription's predicate set).

Walking the eager spec

For each relation in the .with() spec, the relevance walker adds:

  • one(): source's sourceKey (or id if not specified) + target's id + target's projected/predicate/order columns + target's own eager-load contributions.
  • many(): target's foreignKey (the FK on the target side) + target's id + target's per-branch where/order/limit columns + target's full schema columns (since with: true doesn't constrain projection).
  • manyToMany(): junction's sourceKey + junction's targetKey + target's id + target's per-branch contributions.

For very deep nested specs (depth > 3), the relevance set can include MANY tables. The dispatcher's per-table lookup is O(1) so the cost is dominated by the actual column intersection check, not by tree depth.

When the pre-filter helps the most

The pre-filter is most effective when:

  • Subscriptions read NARROW projections (projection: ['id', 'email']) — every column-narrow write to other fields is skipped.
  • Tables have schema-wide hot columns (updatedAt, lastSeenAt, view counters) that lots of writes touch but no subscription reads.
  • Eager-loaded child tables have high write rates (chat messages, log entries) where most writes touch fields the parent doesn't project.

When the pre-filter helps less

  • Subscriptions without projection (the default — with: true on every branch reads every column). The relevance set is wildcard everywhere; v1 fan-out kicks in for every dependent change.
  • Hot tables where every write touches at least one relevant column (every row carries updated_at = NOW() on every update, and updated_at is in the order clause).

For these cases, mitigations:

  1. Project narrowly. projection: ['id', 'email'] on the subscription's descriptor narrows the relevance set.
  2. Split the subscription. database.users.where(eq('id', uid)) (no .with()) for the root, database.orgs.where(...) for the children. Each fires only on its own table.
  3. unsafe() for hand-tuned queries when the framework's portable shape doesn't match what you need.

Observability

Two ways to verify the filter is working:

Trace logs

voltro logs --tail 100 | grep handleChange

Look for the per-change-event summary:

{ "table": "posts", "op": "update", "matcher": 3, "dependent": 12, "triggered": 15 }
{ "table": "posts", "op": "update", "skipped": 11 }

triggered is how many subscriptions had posts in their dependent-table set. skipped is how many of those the pre-filter filtered out before the re-query. Ideal ratio: high skipped / triggered on hot tables.

Voltro Cloud dashboard

The reactive subscriptions panel (under the per-app Inspect tab) shows per-subscription details:

  • Dependent table count.
  • Relevant columns per table.
  • Skip rate (recent window).

Use these to find subscriptions that aren't benefiting from the pre-filter and need narrowing.

Where it lives

  • voltro/packages/runtime/src/relevantFields.ts — relevance walker + isEventRelevant + mutatedColumns
  • voltro/packages/runtime/src/dependencyGraph.tsresolveDependentTables v1 walker
  • voltro/packages/runtime/src/dispatcher.tshandleChange pre-filter call + skip-count log
  • voltro/packages/runtime/src/dispatcherReactive.test.ts — dependency-graph + pre-filter tests