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:
- Snapshot fetch: the framework runs the eager-load query once, delivers the result as
_tag: 'snapshot'to the subscriber. - 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. - 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).
- Projected columns (from
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:
mutatedColumns(event)returns the columns the change actually touched.- Insert / delete → wildcard
*(membership change always relevant). - Update → diff
oldvsnewfor differing values. - Incomplete event (old=null on update) → wildcard (soundness).
- Insert / delete → wildcard
isEventRelevant(event, relevantMap)intersects mutated × relevant.- 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 ⇒ skipThe dispatcher logs the skip count per change event so operators can verify the filter is paying off:
voltro logs --tail 50 | grep handleChange/prefilteredSoundness
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'ssourceKey(oridif not specified) + target'sid+ target's projected/predicate/order columns + target's own eager-load contributions.many(): target'sforeignKey(the FK on the target side) + target'sid+ target's per-branch where/order/limit columns + target's full schema columns (sincewith: truedoesn't constrain projection).manyToMany(): junction'ssourceKey+ junction'stargetKey+ target'sid+ 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: trueon 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, andupdated_atis in the order clause).
For these cases, mitigations:
- Project narrowly.
projection: ['id', 'email']on the subscription's descriptor narrows the relevance set. - 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. 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 handleChangeLook 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+mutatedColumnsvoltro/packages/runtime/src/dependencyGraph.ts—resolveDependentTablesv1 walkervoltro/packages/runtime/src/dispatcher.ts—handleChangepre-filter call + skip-count logvoltro/packages/runtime/src/dispatcherReactive.test.ts— dependency-graph + pre-filter tests