Skip to content

Retain structured query predicates at lowering (insert-membership for live updates) #51

Description

@rubys

Retain structured query predicates at lowering (insert-membership for live updates)

Component of #44 (write/invalidation graph as a lowering), surfaced by the capture-seam audit in #44's comment. Filing it separately because it's a concrete, self-contained lowering change with a clear before/after.

Problem

For live updates, a live fragment's subscription needs to know not just which rows it rendered (handled by runtime read-capture at the from_stmt funnel) but which future rows would enter its result set — the insert-membership question ("would a new Comment with article_id=7 belong in this fragment?").

Today that's unanswerable at runtime, because the query lowering flattens structured queries to opaque concatenated SQL before emit, and inlines the query into the call site (no relation object to introspect):

# generated articles_controller#index — predicate is a string by the time it runs
stmt = Db.prepare("SELECT id, body, created_at, title, updated_at FROM articles" +
                  " ORDER BY created_at DESC")
# generated Article#comments — association predicate is also opaque SQL
stmt = Db.prepare("SELECT ... FROM comments" + " WHERE " + "article_id = " + Db.escape_int(@id))

Testing membership against a SQL string means parsing SQL at runtime — a thicket.

Key insight: retain, don't recover

The structured predicate (table, where-columns, ops, values, scope chain) existed in the AST at lowering time and was discarded during emit. So this is a retention problem, not a recovery problem. When a fragment is marked live, the query lowering should preserve the structured predicate as IR metadata alongside (not instead of) the emitted SQL.

This sits naturally in #44: rows are knowable only at runtime (capture at from_stmt); predicates are knowable at lowering (retain in IR before SQL concat). The two halves compose into the subscription's read-set.

Worked case

fixtures/real-blog/app/models/comment.rb:13-14:

after_create_commit  { article.broadcast_replace_to("articles") rescue nil }
after_destroy_commit { article.broadcast_replace_to("articles") rescue nil }

The index preloads comments WHERE article_id IN (...). A comment update/delete already invalidates precisely (those rows were in the captured read-set). A new comment is the insert-membership case — no row was captured, so the dev had to hand-wire this rescue nil callback. Retaining the article_id IN (...) predicate is exactly what would derive it instead.

Scope / concrete targets

  • Define the IR shape for a retained predicate (table + structured WHERE: column, op, value/value-source; plus ORDER/LIMIT for window-membership later).
  • Emit it for live-marked fragments at the query-lowering site (finders, scopes, associations, and the inlined controller queries).
  • Gate on the live annotation so non-live actions pay nothing.
  • Membership test: given a retained predicate + a write-set row (from _adapter_{insert,update,delete}), decide intersect / maybe-intersect / no.
  • Authz recheck hook for new matches (a new matching row isn't automatically visible to a given subscriber — the one spot where insert-membership reopens the authz question that update/delete close by construction).

Non-goals

  • Runtime SQL parsing (the whole point is to avoid it).
  • Column-level precision (separate, optional layer via accessor capture).

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions