Skip to content

fix(data): observe.select re-emits on membership contraction#144

Merged
krisnye merged 4 commits into
mainfrom
krisnye/query-remove-fix
Jul 14, 2026
Merged

fix(data): observe.select re-emits on membership contraction#144
krisnye merged 4 commits into
mainfrom
krisnye/query-remove-fix

Conversation

@krisnye

@krisnye krisnye commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Description

db.observe.select(include) did not re-emit when a transaction removed an entity from the result set by clearing a required (membership) component that the caller selected but did not where/order on. The observer kept emitting a stale list still containing the removed entity.

Root cause

The per-transaction check in observeSelectEntities has two branches for each changed entity:

  • entry (entity not currently in the set) — already re-derives membership authoritatively via store.locate(...).archetype.components.isSupersetOf(includeSet).
  • in-set (entity currently in the set) — only re-tested order and where components. It never re-derived archetype membership, so removing an include component that isn't also a where/order key migrated the row out of the result archetype silently.

Note the top-level changedComponents.isDisjointFrom(includeSet) guard is not the culprit: where/order keys are type-constrained to be within include, and a component removal always lands in changedComponents, so the guard never wrongly bails on a contraction.

Fix

Add the symmetric counterpart to the entry-path check in the in-set branch: when an entity currently in the set has an include component changed, re-derive membership from the authoritative store and re-emit if it has left the set. whereSet ⊆ includeSet, so the existing where-predicate re-check is folded into the same single store.locate.

Performance

Preserves the O(changes) contract: the loop still iterates only changed entities, with at most one O(1) store.locate each; value-only transactions still hit the early-exit without notifying. A full O(result-rows) re-query happens only on a genuine membership / order / where change (unavoidable, since results are returned as fresh arrays).

Tests

  • New regression test: removing a required include component drops the entity and triggers a re-emit (fails on main, passes here).
  • New guard test: removing a non-selected component does not notify (the early-exit still fires).
  • Full @adobe/data database suite (709 tests) + O(changes) performance test green; lint + typecheck clean.

Downstream impact

This is a behavior change: observe.select now correctly re-emits on membership contraction. Consumers that worked around the gap by separately subscribing to the membership component can drop that extra subscription.

krisnye and others added 4 commits July 14, 2026 10:17
observeSelectEntities did not re-emit when a transaction removed an
entity from the result set by clearing a required (membership) component
that the caller selected but did not filter or order on. The in-set
branch of the per-transaction check only re-tested `order` and `where`
components; it never re-derived archetype membership, so a removed
`include` component migrated the row out of the result archetype
silently and the observer kept emitting a stale list.

Add the symmetric counterpart to the existing entry-path check: when an
entity currently in the set has an `include` component changed, re-derive
membership from the authoritative store (`store.locate` +
`isSupersetOf(includeSet)`) and re-emit if it has left the set. `whereSet`
is a subset of `includeSet`, so the existing where-predicate re-check is
folded into the same single locate.

Preserves O(changes): the loop still iterates only changed entities, with
at most one O(1) locate each; value-only transactions still hit the
early-exit without notifying. A full O(result-rows) re-query happens only
on a genuine membership/order/where change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-entity O(|include|) `isSupersetOf(include)` scan in the
observe.select transaction handler with a `Map<ArchetypeId, boolean>`
memoized per subscription. Archetype component sets never change and ids
are stable/dense, so each archetype is classified at most once; every
subsequent entity in it is an O(1) map lookup. Archetypes created after
subscription are classified on first sight, so late migrations into a
brand-new qualifying archetype are still detected.

Unify the in-set and entry branches through the shared `qualifies`
helper. This also fixes a latent staleness bug: the reactive path
previously ignored the `exclude` clause entirely (only the initial
`store.select` honored it), so an entity gaining or losing an excluded
component never re-emitted. The top-level guard and the per-entity gate
now test `include ∪ exclude`; when there is no exclude clause the extra
`isDisjointFrom(∅)` is a free O(1) check, preserving the fast path.

Still O(changes): the loop iterates only changed entities, each costing
one O(1) locate + O(1) memoized lookup; value-only transactions still
hit the early-exit without notifying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two `rowPredicate(location.archetype as any, ...)` calls discarded the
whole table type. Localize them into one `matchesFilter` helper that
asserts only the fuller `ReadonlyTable<Pick<…, Include>>` shape the
predicate expects (`store.locate` types just the base `id` column). The
invariant is real and compiler-invisible: callers reach it only after
`qualifies` confirms the archetype is a superset of `include`, and
`where` keys are a subset of `include`, so every column the predicate
reads is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An order-key change on an in-set entity always forces a requery — a row
that stays a member may reorder, one that migrated out is removed — so
the membership locate is unnecessary in that case. Move the order-key
test ahead of store.locate to skip the lookup entirely for order-value
churn (common in ordered/timeline queries). Behavior is unchanged; only
the redundant locate is avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@krisnye krisnye merged commit 348ccc1 into main Jul 14, 2026
4 checks passed
@krisnye krisnye deleted the krisnye/query-remove-fix branch July 14, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant