Skip to main content

myko/core/query/
registration.rs

1//! Query registration via inventory.
2
3use std::{
4    any::Any,
5    collections::{HashMap, HashSet, VecDeque},
6    sync::{
7        Arc, Mutex, OnceLock, Weak,
8        atomic::{AtomicBool, AtomicU64, Ordering},
9    },
10};
11
12use dashmap::DashMap;
13use hyphae::{MapDiff, SelectExt, Signal, SubscriptionGuard, Watchable};
14use serde::de::DeserializeOwned;
15use serde_json::Value;
16use uuid::Uuid;
17
18use super::{
19    super::item::Eventable,
20    cell::FilteredCellMap,
21    context::{QueryBuildContext, QueryContext},
22    filter::{
23        BelongsToRoute, CompoundFkExtractor, CompoundKey, ID_ROUTE_FIELD_NAMES, LiveFilterQuery,
24        QueryRoute,
25    },
26    request::QueryRequest,
27    traits::{
28        AnyQuery, QueryBuildArgs, QueryHandler, QueryParams, QueryTestContext, QueryWindowBuildArgs,
29    },
30};
31use crate::{
32    common::with_id::WithId, core::item::downcast_any_item_arc, request::RequestContext,
33    server::MykoServerContext, store::StoreRegistry,
34};
35
36// ─────────────────────────────────────────────────────────────────────────────
37// Type aliases for function pointers
38// ─────────────────────────────────────────────────────────────────────────────
39
40/// Type alias for query parse function.
41pub type QueryParseFn = fn(Value) -> Result<Arc<dyn AnyQuery>, anyhow::Error>;
42
43/// Type-erased cell factory for queries.
44/// Takes a typed query, registry, and `host_id`, returns a `FilteredCellMap`.
45pub type QueryCellFactory = fn(
46    Arc<dyn AnyQuery>,
47    Arc<StoreRegistry>,
48    Arc<RequestContext>,
49    Option<Arc<MykoServerContext>>,
50) -> Result<FilteredCellMap, String>;
51
52/// Type-erased factory for a query that can push a requested window into its
53/// backing source instead of materializing the complete result map.
54pub type QueryWindowCellFactory = fn(
55    Arc<dyn AnyQuery>,
56    Arc<StoreRegistry>,
57    Arc<RequestContext>,
58    Arc<MykoServerContext>,
59    crate::wire::QueryWindow,
60) -> Result<Option<super::WindowedQuerySource>, String>;
61
62type AnyItemArc = Arc<dyn crate::core::item::AnyItem>;
63type AnyItemMap = hyphae::CellMap<Arc<str>, AnyItemArc>;
64type WeakAnyItemMap = hyphae::WeakCellMap<Arc<str>, AnyItemArc>;
65type BucketEntries = Vec<(Arc<str>, AnyItemArc)>;
66type BucketDiff = MapDiff<Arc<str>, AnyItemArc>;
67type BucketDiffs = Vec<BucketDiff>;
68type BucketAction = (AnyItemMap, BucketDiff);
69
70#[derive(Default)]
71struct BelongsToMutationState {
72    pending: VecDeque<Vec<BucketAction>>,
73    dispatching: bool,
74}
75
76/// Lazily-built per-key source constructor for whichever indexed mode a
77/// `reconcile_live_query` tick is in (id route or `belongs_to` route).
78type BucketSourceFn = Box<dyn Fn(&CompoundKey) -> FilteredCellMap>;
79
80// ─────────────────────────────────────────────────────────────────────────────
81// QueryRegistration - inventory-based registration
82// ─────────────────────────────────────────────────────────────────────────────
83
84inventory::collect!(QueryRegistration);
85
86#[derive(Debug, Clone, Copy, Default)]
87pub struct QueryRuntimeMetrics {
88    pub cell_factories_created: u64,
89    pub per_item_guards_created: u64,
90    pub per_item_guards_removed: u64,
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct QueryRuntimePerIdMetrics {
95    pub query_id: Arc<str>,
96    pub cell_factories_created: u64,
97    pub per_item_guards_created: u64,
98    pub per_item_guards_removed: u64,
99}
100
101static QUERY_CELL_FACTORIES_CREATED: AtomicU64 = AtomicU64::new(0);
102static QUERY_PER_ITEM_GUARDS_CREATED: AtomicU64 = AtomicU64::new(0);
103static QUERY_PER_ITEM_GUARDS_REMOVED: AtomicU64 = AtomicU64::new(0);
104static QUERY_FACTORIES_BY_ID: OnceLock<DashMap<Arc<str>, u64>> = OnceLock::new();
105static QUERY_GUARDS_CREATED_BY_ID: OnceLock<DashMap<Arc<str>, u64>> = OnceLock::new();
106static QUERY_GUARDS_REMOVED_BY_ID: OnceLock<DashMap<Arc<str>, u64>> = OnceLock::new();
107static BELONGS_TO_SOURCE_INDEXES: OnceLock<DashMap<String, Weak<BelongsToSourceIndex>>> =
108    OnceLock::new();
109
110fn query_factories_by_id() -> &'static DashMap<Arc<str>, u64> {
111    QUERY_FACTORIES_BY_ID.get_or_init(DashMap::new)
112}
113
114fn query_guards_created_by_id() -> &'static DashMap<Arc<str>, u64> {
115    QUERY_GUARDS_CREATED_BY_ID.get_or_init(DashMap::new)
116}
117
118fn query_guards_removed_by_id() -> &'static DashMap<Arc<str>, u64> {
119    QUERY_GUARDS_REMOVED_BY_ID.get_or_init(DashMap::new)
120}
121
122fn belongs_to_source_indexes() -> &'static DashMap<String, Weak<BelongsToSourceIndex>> {
123    BELONGS_TO_SOURCE_INDEXES.get_or_init(DashMap::new)
124}
125
126fn increment_counter(map: &DashMap<Arc<str>, u64>, key: Arc<str>) {
127    if let Some(mut value) = map.get_mut(&key) {
128        *value = value.saturating_add(1);
129    } else {
130        map.insert(key, 1);
131    }
132}
133
134pub fn query_runtime_metrics() -> QueryRuntimeMetrics {
135    QueryRuntimeMetrics {
136        cell_factories_created: QUERY_CELL_FACTORIES_CREATED.load(Ordering::Relaxed),
137        per_item_guards_created: QUERY_PER_ITEM_GUARDS_CREATED.load(Ordering::Relaxed),
138        per_item_guards_removed: QUERY_PER_ITEM_GUARDS_REMOVED.load(Ordering::Relaxed),
139    }
140}
141
142#[must_use]
143pub fn query_runtime_metrics_by_id(limit: usize) -> Vec<QueryRuntimePerIdMetrics> {
144    let mut rows: Vec<QueryRuntimePerIdMetrics> = query_factories_by_id()
145        .iter()
146        .map(|entry| {
147            let query_id = entry.key().clone();
148            let cell_factories_created = *entry.value();
149            let per_item_guards_created = query_guards_created_by_id()
150                .get(&query_id)
151                .map_or(0, |v| *v.value());
152            let per_item_guards_removed = query_guards_removed_by_id()
153                .get(&query_id)
154                .map_or(0, |v| *v.value());
155            QueryRuntimePerIdMetrics {
156                query_id,
157                cell_factories_created,
158                per_item_guards_created,
159                per_item_guards_removed,
160            }
161        })
162        .collect();
163
164    rows.sort_by(|a, b| {
165        let a_live = a
166            .per_item_guards_created
167            .saturating_sub(a.per_item_guards_removed);
168        let b_live = b
169            .per_item_guards_created
170            .saturating_sub(b.per_item_guards_removed);
171        b_live
172            .cmp(&a_live)
173            .then_with(|| b.cell_factories_created.cmp(&a.cell_factories_created))
174    });
175    if rows.len() > limit {
176        rows.truncate(limit);
177    }
178    rows
179}
180
181/// Per-compound-key bucket index backing `#[belongs_to]` reactive queries.
182///
183/// One `BelongsToSourceIndex` instance is scoped to a single SET of
184/// `belongs_to` fields (see `build_belongs_to_source_map`'s registry key,
185/// which includes the field-name list) — routing a query that sets fields
186/// `{node_id}` and one that sets `{node_id, session_id}` always land in
187/// different indexes, never sharing buckets, even though both touch
188/// `node_id`. Within one index, `buckets` holds only *weak* handles: a
189/// bucket's `AnyItemMap` stays alive exactly as long as some subscriber (via
190/// [`build_belongs_to_source_map`]) holds a strong reference to it. Once the
191/// last subscriber drops it, the weak entry naturally fails to upgrade and
192/// gets lazily reaped — the alternative (a strong `Arc<AnyItemMap>` retained
193/// forever) is a real memory leak: one permanent bucket per distinct key
194/// ever seen, which never shrinks even after every relation is unassigned.
195struct BelongsToSourceIndex {
196    store: Arc<crate::store::EntityStore>,
197    buckets: DashMap<CompoundKey, WeakAnyItemMap>,
198    mutation_gate: Mutex<BelongsToMutationState>,
199    driver: Arc<AnyItemMap>,
200}
201
202impl BelongsToSourceIndex {
203    fn new(store: Arc<crate::store::EntityStore>, extract_fk: CompoundFkExtractor) -> Arc<Self> {
204        let driver = Arc::new(AnyItemMap::new());
205        let index = Arc::new(Self {
206            store,
207            buckets: DashMap::new(),
208            mutation_gate: Mutex::new(BelongsToMutationState::default()),
209            driver: driver.clone(),
210        });
211
212        let index_for_diffs = Arc::downgrade(&index);
213        let guard = index.store.subscribe_diffs(move |diff| {
214            if let Some(index) = index_for_diffs.upgrade() {
215                index.apply_diff(diff, extract_fk);
216            }
217        });
218        driver.own_guard(guard);
219        index
220    }
221
222    /// Look up a bucket for *internal diff routing only* — never creates
223    /// one. If nobody's subscribed to `key` there's no bucket state worth
224    /// maintaining; a dead weak entry found here is reaped immediately, so
225    /// `self.buckets` never accumulates more than what's currently live. See
226    /// [`sweep_dead_buckets`](Self::sweep_dead_buckets) for the backstop
227    /// covering keys that go dead but are never looked up again.
228    fn route_to_live_bucket(&self, key: &CompoundKey) -> Option<AnyItemMap> {
229        let entry = self.buckets.get(key)?;
230        if let Some(map) = entry.upgrade() {
231            return Some(map);
232        }
233        drop(entry);
234        self.buckets.remove(key);
235        None
236    }
237
238    /// Subscriber entry point (via [`build_belongs_to_source_map`]): returns
239    /// the live bucket if one exists, or creates a fresh one backfilled from
240    /// the current store state.
241    ///
242    /// The backfill matters: `apply_diff` (via `route_to_live_bucket`) never
243    /// creates or updates a bucket nobody's watching, so a newly-live bucket
244    /// may have missed every diff since this relation's one-time index-wide
245    /// bootstrap in [`new`](Self::new). Without backfilling here, a client
246    /// subscribing (or re-subscribing after a prior subscriber dropped off)
247    /// would silently see an empty result instead of the parent's actual
248    /// current children.
249    ///
250    /// Uses `self.buckets.entry(key)` rather than a separate get-then-insert
251    /// (what `route_to_live_bucket` does for pure lookups) because this path
252    /// *creates*: two concurrent callers for the same key that both observe
253    /// no live bucket would otherwise each build and backfill their own
254    /// `AnyItemMap`, then race an unconditional `insert` — whichever lands
255    /// second wins `self.buckets`, silently orphaning the other's bucket
256    /// (still a valid handle, already returned to its caller and subscribed
257    /// to, but now unreachable from `apply_diff`'s routing, so it gets its
258    /// one-time backfill and then nothing else, ever). `entry()` holds the
259    /// shard lock across the whole check-or-create, so only one caller per
260    /// key can ever end up as the live entry.
261    fn bucket_for(
262        self: &Arc<Self>,
263        key: CompoundKey,
264        extract_fk: CompoundFkExtractor,
265    ) -> AnyItemMap {
266        // Store callbacks take the same gate. This makes the snapshot and
267        // bucket publication one logical operation relative to every diff:
268        // a concurrent write is either present in the backfill or routed to
269        // the newly published bucket after this critical section.
270        let _mutation = self
271            .mutation_gate
272            .lock()
273            .unwrap_or_else(std::sync::PoisonError::into_inner);
274        match self.buckets.entry(key) {
275            dashmap::mapref::entry::Entry::Occupied(mut occupied) => {
276                if let Some(map) = occupied.get().upgrade() {
277                    return map;
278                }
279                let map = Self::build_backfilled_bucket(&self.store, occupied.key(), extract_fk);
280                self.retain_for_bucket(&map);
281                occupied.insert(map.downgrade());
282                map
283            }
284            dashmap::mapref::entry::Entry::Vacant(vacant) => {
285                let map = Self::build_backfilled_bucket(&self.store, vacant.key(), extract_fk);
286                self.retain_for_bucket(&map);
287                vacant.insert(map.downgrade());
288                map
289            }
290        }
291    }
292
293    /// Keep the routing subscription alive exactly as long as a bucket is
294    /// live. The global registry is intentionally weak, and the index holds
295    /// only weak bucket handles, so this bucket -> index edge cannot form a
296    /// cycle but still prevents active subscribers from losing their driver.
297    fn retain_for_bucket(self: &Arc<Self>, map: &AnyItemMap) {
298        let index = self.clone();
299        let guard = self.driver.subscribe_diffs(move |_| {
300            let _ = &index;
301        });
302        map.own_guard(guard);
303    }
304
305    fn build_backfilled_bucket(
306        store: &crate::store::EntityStore,
307        key: &CompoundKey,
308        extract_fk: CompoundFkExtractor,
309    ) -> AnyItemMap {
310        let map = AnyItemMap::new();
311        let backfill: BucketEntries = store
312            .snapshot()
313            .into_iter()
314            .filter(|(_, item)| extract_fk(item.as_any()).as_ref() == Some(key))
315            .collect();
316        if !backfill.is_empty() {
317            map.apply_diff_owned(MapDiff::Initial { entries: backfill });
318        }
319        map
320    }
321
322    /// Drop bucket entries nobody's subscribed to anymore. `route_to_live_bucket`
323    /// already reaps dead entries lazily on next access, but a key that goes
324    /// dead and is never looked up again would otherwise sit in
325    /// `self.buckets` forever (just a `Weak` + a small `Vec<Arc<str>>` key,
326    /// far smaller than the leak this replaces, but still unbounded over
327    /// time). Called from `MykoServerContext::sweep_dead_cache_entries` via
328    /// [`sweep_all_belongs_to_source_indexes`].
329    fn sweep_dead_buckets(&self) {
330        self.buckets.retain(|_, weak| weak.upgrade().is_some());
331    }
332
333    fn apply_diff(&self, diff: &BucketDiff, extract_fk: CompoundFkExtractor) {
334        {
335            let mut mutation = self
336                .mutation_gate
337                .lock()
338                .unwrap_or_else(std::sync::PoisonError::into_inner);
339            let actions = self.prepare_diff_locked(diff, extract_fk);
340            mutation.pending.push_back(actions);
341            if mutation.dispatching {
342                return;
343            }
344            mutation.dispatching = true;
345        }
346
347        // CellMap fanout is synchronous and may construct another query,
348        // which re-enters bucket_for on this index. Never notify while the
349        // non-reentrant publication gate is held. A single drainer preserves
350        // store-diff order even when callbacks arrive concurrently; reentrant
351        // diffs enqueue above and are picked up by this loop.
352        loop {
353            let actions = {
354                let mut mutation = self
355                    .mutation_gate
356                    .lock()
357                    .unwrap_or_else(std::sync::PoisonError::into_inner);
358                if let Some(actions) = mutation.pending.pop_front() {
359                    drop(mutation);
360                    actions
361                } else {
362                    mutation.dispatching = false;
363                    drop(mutation);
364                    return;
365                }
366            };
367            for (bucket, diff) in actions {
368                bucket.apply_diff_owned(diff);
369            }
370        }
371    }
372
373    fn prepare_diff_locked(
374        &self,
375        diff: &BucketDiff,
376        extract_fk: CompoundFkExtractor,
377    ) -> Vec<BucketAction> {
378        match diff {
379            MapDiff::Initial { entries } => self.prepare_initial(entries, extract_fk),
380            MapDiff::Insert { key, value } => extract_fk(value.as_any())
381                .and_then(|fk| {
382                    self.route_action(
383                        &fk,
384                        MapDiff::Insert {
385                            key: key.clone(),
386                            value: value.clone(),
387                        },
388                    )
389                })
390                .into_iter()
391                .collect(),
392            MapDiff::Remove { key, old_value } => extract_fk(old_value.as_any())
393                .and_then(|fk| {
394                    self.route_action(
395                        &fk,
396                        MapDiff::Remove {
397                            key: key.clone(),
398                            old_value: old_value.clone(),
399                        },
400                    )
401                })
402                .into_iter()
403                .collect(),
404            MapDiff::Update {
405                key,
406                old_value,
407                new_value,
408            } => self.prepare_update(key, old_value, new_value, extract_fk),
409            MapDiff::Batch { changes } => self.prepare_batch(changes, extract_fk),
410        }
411    }
412
413    fn route_action(&self, foreign_key: &CompoundKey, diff: BucketDiff) -> Option<BucketAction> {
414        self.route_to_live_bucket(foreign_key)
415            .map(|bucket| (bucket, diff))
416    }
417
418    fn prepare_initial(
419        &self,
420        entries: &BucketEntries,
421        extract_fk: CompoundFkExtractor,
422    ) -> Vec<BucketAction> {
423        let mut grouped: HashMap<CompoundKey, BucketEntries> = HashMap::new();
424        for (id, item) in entries {
425            if let Some(fk) = extract_fk(item.as_any()) {
426                grouped
427                    .entry(fk)
428                    .or_default()
429                    .push((id.clone(), item.clone()));
430            }
431        }
432
433        self.buckets
434            .iter()
435            .filter(|entry| entry.value().upgrade().is_some())
436            .map(|entry| entry.key().clone())
437            .collect::<Vec<_>>()
438            .into_iter()
439            .filter_map(|key| {
440                self.route_action(
441                    &key,
442                    MapDiff::Initial {
443                        entries: grouped.remove(&key).unwrap_or_default(),
444                    },
445                )
446            })
447            .collect()
448    }
449
450    fn prepare_update(
451        &self,
452        key: &Arc<str>,
453        old_value: &AnyItemArc,
454        new_value: &AnyItemArc,
455        extract_fk: CompoundFkExtractor,
456    ) -> Vec<BucketAction> {
457        let old_fk = extract_fk(old_value.as_any());
458        let new_fk = extract_fk(new_value.as_any());
459        let mut actions = Vec::new();
460        match (old_fk, new_fk) {
461            (Some(old_fk), Some(new_fk)) if old_fk == new_fk => {
462                actions.extend(self.route_action(
463                    &new_fk,
464                    MapDiff::Update {
465                        key: key.clone(),
466                        old_value: old_value.clone(),
467                        new_value: new_value.clone(),
468                    },
469                ));
470            }
471            (old_fk, new_fk) => {
472                if let Some(old_fk) = old_fk {
473                    actions.extend(self.route_action(
474                        &old_fk,
475                        MapDiff::Remove {
476                            key: key.clone(),
477                            old_value: old_value.clone(),
478                        },
479                    ));
480                }
481                if let Some(new_fk) = new_fk {
482                    actions.extend(self.route_action(
483                        &new_fk,
484                        MapDiff::Insert {
485                            key: key.clone(),
486                            value: new_value.clone(),
487                        },
488                    ));
489                }
490            }
491        }
492        actions
493    }
494
495    fn group_change(
496        by_fk: &mut HashMap<CompoundKey, BucketDiffs>,
497        change: &BucketDiff,
498        extract_fk: CompoundFkExtractor,
499    ) {
500        let mut push = |foreign_key: Option<CompoundKey>, diff: BucketDiff| {
501            if let Some(foreign_key) = foreign_key {
502                by_fk.entry(foreign_key).or_default().push(diff);
503            }
504        };
505        match change {
506            MapDiff::Insert { key, value } => push(
507                extract_fk(value.as_any()),
508                MapDiff::Insert {
509                    key: key.clone(),
510                    value: value.clone(),
511                },
512            ),
513            MapDiff::Remove { key, old_value } => push(
514                extract_fk(old_value.as_any()),
515                MapDiff::Remove {
516                    key: key.clone(),
517                    old_value: old_value.clone(),
518                },
519            ),
520            MapDiff::Update {
521                key,
522                old_value,
523                new_value,
524            } => {
525                let old_fk = extract_fk(old_value.as_any());
526                let new_fk = extract_fk(new_value.as_any());
527                if old_fk == new_fk {
528                    push(
529                        new_fk,
530                        MapDiff::Update {
531                            key: key.clone(),
532                            old_value: old_value.clone(),
533                            new_value: new_value.clone(),
534                        },
535                    );
536                } else {
537                    push(
538                        old_fk,
539                        MapDiff::Remove {
540                            key: key.clone(),
541                            old_value: old_value.clone(),
542                        },
543                    );
544                    push(
545                        new_fk,
546                        MapDiff::Insert {
547                            key: key.clone(),
548                            value: new_value.clone(),
549                        },
550                    );
551                }
552            }
553            MapDiff::Initial { .. } | MapDiff::Batch { .. } => {}
554        }
555    }
556
557    fn prepare_batch(
558        &self,
559        changes: &BucketDiffs,
560        extract_fk: CompoundFkExtractor,
561    ) -> Vec<BucketAction> {
562        let mut actions = Vec::new();
563        let mut by_fk: HashMap<CompoundKey, BucketDiffs> = HashMap::new();
564        for change in changes {
565            if matches!(change, MapDiff::Initial { .. } | MapDiff::Batch { .. }) {
566                actions.extend(self.prepare_diff_locked(change, extract_fk));
567            } else {
568                Self::group_change(&mut by_fk, change, extract_fk);
569            }
570        }
571        actions.extend(by_fk.into_iter().filter_map(|(foreign_key, changes)| {
572            self.route_action(&foreign_key, MapDiff::Batch { changes })
573        }));
574        actions
575    }
576}
577
578/// Build a reactive, `#[belongs_to]`-routed source map for a query that has
579///
580/// one or more `belongs_to` fields set. `field_names` and `foreign_ids` are
581/// positionally paired (same order `extract_fk` reads them in) and must be
582/// the SAME LENGTH — exactly the fields the caller's query populated, not
583/// necessarily every `belongs_to` field the entity declares. Two calls for the
584/// same `local_type` with different `field_names` sets (e.g. `["node_id"]`
585/// vs `["node_id", "session_id"]`) always route through separate indexes —
586/// see [`BelongsToSourceIndex`] — so a query that pins more fields never
587/// shares a bucket with one that pins fewer, even when the fields overlap.
588pub fn build_belongs_to_source_map(
589    registry: Arc<StoreRegistry>,
590    host_id: Uuid,
591    local_type: &'static str,
592    field_names: &'static [&'static str],
593    extract_fk: CompoundFkExtractor,
594    foreign_ids: CompoundKey,
595) -> FilteredCellMap {
596    debug_assert_eq!(
597        field_names.len(),
598        foreign_ids.len(),
599        "build_belongs_to_source_map: field_names and foreign_ids must be positionally paired"
600    );
601    let index =
602        belongs_to_source_index_for(&registry, host_id, local_type, field_names, extract_fk);
603    drop(registry);
604    index.bucket_for(foreign_ids, extract_fk).lock()
605}
606
607fn belongs_to_source_index_for(
608    registry: &Arc<StoreRegistry>,
609    host_id: Uuid,
610    local_type: &'static str,
611    field_names: &'static [&'static str],
612    extract_fk: CompoundFkExtractor,
613) -> Arc<BelongsToSourceIndex> {
614    let key = format!("{host_id}:{local_type}:{}", field_names.join("+"));
615    match belongs_to_source_indexes().entry(key) {
616        dashmap::mapref::entry::Entry::Occupied(mut occupied) => {
617            if let Some(index) = occupied.get().upgrade() {
618                return index;
619            }
620            let store = registry.get_or_create(local_type);
621            let index = BelongsToSourceIndex::new(store, extract_fk);
622            occupied.insert(Arc::downgrade(&index));
623            index
624        }
625        dashmap::mapref::entry::Entry::Vacant(vacant) => {
626            let store = registry.get_or_create(local_type);
627            let index = BelongsToSourceIndex::new(store, extract_fk);
628            vacant.insert(Arc::downgrade(&index));
629            index
630        }
631    }
632}
633
634/// Above this many union keys, `build_belongs_to_union_source_map` logs a
635///
636/// warning instead of silently subscribing to a huge number of buckets — a
637/// caller combining several large `In` fields on the same query hits a
638/// cartesian-product blow-up (spec §4: "either cap the product size or
639/// document the blow-up and log when it exceeds a threshold"). No hard cap:
640/// per the spec's hard requirement, an indexed field must never fall back
641/// to a table scan, so this only observes, never degrades.
642pub const UNION_KEYS_WARN_THRESHOLD: usize = 1000;
643
644/// Rewrite one bucket's diffs into incremental changes safe to apply to the
645/// union of several buckets. An `Initial` is a replacement of this bucket's
646/// contribution, so the previous contribution must be retracted before the
647/// new snapshot is inserted. Treating it as inserts alone loses an
648/// `Initial { entries: [] }` clear when the last source row is deleted.
649fn additive_union_diff(
650    diff: &BucketDiff,
651    contribution: &mut HashMap<Arc<str>, AnyItemArc>,
652) -> Option<BucketDiff> {
653    match diff {
654        MapDiff::Initial { entries } => {
655            let mut changes = Vec::with_capacity(contribution.len().saturating_add(entries.len()));
656            for (key, old_value) in contribution.drain() {
657                changes.push(MapDiff::Remove { key, old_value });
658            }
659            for (key, value) in entries {
660                contribution.insert(key.clone(), value.clone());
661                changes.push(MapDiff::Insert {
662                    key: key.clone(),
663                    value: value.clone(),
664                });
665            }
666            (!changes.is_empty()).then_some(MapDiff::Batch { changes })
667        }
668        MapDiff::Batch { changes } => {
669            let rewritten: Vec<BucketDiff> = changes
670                .iter()
671                .filter_map(|change| additive_union_diff(change, contribution))
672                .collect();
673            if rewritten.is_empty() {
674                None
675            } else {
676                Some(MapDiff::Batch { changes: rewritten })
677            }
678        }
679        MapDiff::Insert { key, value } => {
680            contribution.insert(key.clone(), value.clone());
681            Some(diff.clone())
682        }
683        MapDiff::Remove { key, .. } => {
684            contribution.remove(key);
685            Some(diff.clone())
686        }
687        MapDiff::Update { key, new_value, .. } => {
688            contribution.insert(key.clone(), new_value.clone());
689            Some(diff.clone())
690        }
691    }
692}
693
694/// Union of K `belongs_to` buckets — routes an `In` (or multi-field compound
695///
696/// `In`) query through [`BelongsToSourceIndex`] as a union of exact-key
697/// lookups instead of a table scan (spec §4 hard requirement: an `In` on an
698/// indexed field must be index-servable, by construction, since id filters
699/// only ever express `Eq`/`In`). Each item can only ever satisfy exactly
700/// one of the given compound keys — a field has exactly one value — so this
701/// is a partition merge, not a proper union: no dedup/collision handling is
702/// needed between the K sources.
703///
704/// Each source bucket is kept alive by `result.own(guard)` — the same
705/// pattern `typed_map_from_any_item_with_typed_id` uses, and deliberately
706/// NOT the bare-clone-with-nothing-retained shape that froze `CellMap::
707/// size()` (see the `count_fresh_cell_test.rs` regression this mirrors):
708/// the guard's own strong reference back to its source is what keeps each
709/// bucket's store subscription alive for as long as `result` is.
710pub fn build_belongs_to_union_source_map(
711    registry: Arc<StoreRegistry>,
712    host_id: Uuid,
713    local_type: &'static str,
714    field_names: &'static [&'static str],
715    extract_fk: CompoundFkExtractor,
716    keys: Vec<CompoundKey>,
717) -> FilteredCellMap {
718    if keys.len() > UNION_KEYS_WARN_THRESHOLD {
719        tracing::warn!(
720            target: "myko::core::query::registration",
721            "belongs_to union route for {local_type}[{}] is subscribing to {} buckets \
722             (> {UNION_KEYS_WARN_THRESHOLD}) — likely a cartesian product of several \
723             large `In` fields on the same query",
724            field_names.join("+"),
725            keys.len(),
726        );
727    }
728
729    let index =
730        belongs_to_source_index_for(&registry, host_id, local_type, field_names, extract_fk);
731    let result: AnyItemMap = AnyItemMap::new();
732    for key in keys {
733        let bucket = index.bucket_for(key, extract_fk).lock();
734        let result_weak = result.downgrade();
735        let contribution = Mutex::new(HashMap::new());
736        let guard = bucket.subscribe_diffs(move |diff| {
737            let Some(result) = result_weak.upgrade() else {
738                return;
739            };
740            // Each source's `Initial` replaces only that bucket's current
741            // contribution. Applying it directly would wipe the other
742            // buckets, while treating it as inserts would fail to retract
743            // rows absent from the replacement. Track this bucket's prior
744            // contribution and rewrite the replacement into removals plus
745            // inserts before forwarding it to the shared union.
746            let additive = additive_union_diff(
747                diff,
748                &mut contribution
749                    .lock()
750                    .unwrap_or_else(std::sync::PoisonError::into_inner),
751            );
752            if let Some(additive) = additive {
753                result.apply_diff_owned(additive);
754            }
755        });
756        result.own(guard);
757    }
758    drop(registry);
759    result.lock()
760}
761
762/// Cartesian product of N value sets — expands multi-field compound `In`
763///
764/// filters into the full set of (v1, v2, ..., vN) compound keys to
765/// union-route through [`build_belongs_to_union_source_map`]. Empty if any
766/// input set is empty: an empty value set for one field means no key any
767/// item could satisfy exists, matching `In([])` matching nothing (spec §1).
768#[must_use]
769pub fn cartesian_product(sets: Vec<Vec<Arc<str>>>) -> Vec<CompoundKey> {
770    sets.into_iter().fold(vec![CompoundKey::new()], |acc, set| {
771        acc.into_iter()
772            .flat_map(|prefix| {
773                set.iter().map(move |v| {
774                    let mut next = prefix.clone();
775                    next.push(v.clone());
776                    next
777                })
778            })
779            .collect()
780    })
781}
782
783// ─────────────────────────────────────────────────────────────────────────
784// query_live — phase 2 of the advanced-query-design spec (§5): a reactive
785// filter *parameter* instead of a plain value, so a filter derived from
786// other cells no longer needs a switch_map wrapper. Server-side only.
787// ─────────────────────────────────────────────────────────────────────────
788
789fn live_filter_matches<F: LiveFilterQuery>(filter: &F, item: &AnyItemArc) -> bool {
790    downcast_any_item_arc::<F::Item>(item, "query_live").is_some_and(|typed| filter.matches(&typed))
791}
792
793/// Re-test `candidates` — ONE bucket's `snapshot()`, nothing more — against
794/// `filter`, inserting newly-matching items and retracting no-longer-
795/// matching ones. Deliberately does NOT touch any id outside `candidates`:
796/// `result` is a union of potentially several buckets (the partition-merge
797/// invariant means an id can only ever come from one bucket for a given
798/// field combination), so removing "anything not in this bucket" would
799/// wrongly retract items OTHER buckets contributed. Safe for both a
800/// freshly-added bucket's initial population (nothing to retract, every
801/// candidate either inserts or no-ops) and an unchanged bucket rescanned
802/// because a non-indexed field changed (retracts exactly this bucket's own
803/// now-stale contributions).
804fn apply_bucket_candidates<F: LiveFilterQuery>(
805    result: &AnyItemMap,
806    filter: &F,
807    candidates: Vec<(Arc<str>, AnyItemArc)>,
808) {
809    for (id, item) in candidates {
810        if live_filter_matches(filter, &item) {
811            result.insert(id, item);
812        } else {
813            result.remove(&id);
814        }
815    }
816}
817
818/// Diff `candidates` — the WHOLE authoritative scope of `result` (the
819/// entire store, in scan mode) — against `result`'s current membership
820/// under `filter`: insert newly-matching items, remove no-longer-matching
821/// ones, AND remove anything in `result` that isn't in `candidates` at all
822/// (covers deletes — an item removed from the store entirely is simply
823/// absent from a fresh `snapshot()`, not present-but-non-matching). Unlike
824/// [`apply_bucket_candidates`], this only makes sense when `candidates` is
825/// the FULL scope `result` is meant to reflect, never a single bucket's
826/// contents alongside other buckets' contributions.
827fn reconcile_full_scope_membership<F: LiveFilterQuery>(
828    result: &AnyItemMap,
829    filter: &F,
830    candidates: Vec<(Arc<str>, AnyItemArc)>,
831) {
832    // for_each: read-only visit — no snapshot Vec cloned just to build the
833    // id set. All mutation of `result` happens after this returns.
834    let mut current_ids: HashSet<Arc<str>> = HashSet::new();
835    result.for_each(|id, _| {
836        current_ids.insert(id.clone());
837    });
838    let mut seen: HashSet<Arc<str>> = HashSet::with_capacity(candidates.len());
839    for (id, item) in candidates {
840        seen.insert(id.clone());
841        let should_have = live_filter_matches(filter, &item);
842        let currently_has = current_ids.contains(&id);
843        match (should_have, currently_has) {
844            (true, false) => {
845                result.insert(id, item);
846            }
847            (false, true) => {
848                result.remove(&id);
849            }
850            _ => {}
851        }
852    }
853    for id in current_ids.difference(&seen) {
854        result.remove(id);
855    }
856}
857
858/// Whether an [`apply_live_diff`] callback is watching one compound-key
859/// bucket (only ever affects the ids that bucket itself owns) or the whole
860/// store in scan mode (its `Initial` represents `result`'s entire scope, so
861/// deletes must retract ids absent from it too — see
862/// [`reconcile_full_scope_membership`] vs [`apply_bucket_candidates`]).
863#[derive(Clone, Copy)]
864enum LiveDiffScope {
865    Bucket,
866    FullStore,
867}
868
869/// Apply one incoming diff from a live-tracked source (a bucket, or the
870/// whole store in scan mode) to `result`, re-testing every affected item
871/// against `filter` — always read fresh at the moment the diff arrives
872/// (never a value captured at subscribe time), since a bucket/store
873/// subscription outlives many filter ticks.
874fn apply_live_diff<F: LiveFilterQuery>(
875    result: &AnyItemMap,
876    diff: &BucketDiff,
877    filter: &F,
878    scope: LiveDiffScope,
879) {
880    match diff {
881        MapDiff::Initial { entries } => match scope {
882            LiveDiffScope::Bucket => apply_bucket_candidates(result, filter, entries.clone()),
883            LiveDiffScope::FullStore => {
884                reconcile_full_scope_membership(result, filter, entries.clone());
885            }
886        },
887        MapDiff::Insert { key, value } => {
888            if live_filter_matches(filter, value) {
889                result.insert(key.clone(), value.clone());
890            }
891        }
892        MapDiff::Remove { key, .. } => {
893            result.remove(key);
894        }
895        MapDiff::Update { key, new_value, .. } => {
896            if live_filter_matches(filter, new_value) {
897                result.insert(key.clone(), new_value.clone());
898            } else {
899                result.remove(key);
900            }
901        }
902        MapDiff::Batch { changes } => {
903            for change in changes {
904                apply_live_diff(result, change, filter, scope);
905            }
906        }
907    }
908}
909
910/// Per-`query_live`-call state: which compound-key buckets are currently
911/// subscribed (with a handle to each bucket for `snapshot()`-based
912/// retraction/rescan), the scan-mode store subscription if active, and the
913/// filter value from the previous tick (to detect route-shape changes).
914struct LiveQueryState<F> {
915    bucket_guards: HashMap<CompoundKey, (FilteredCellMap, SubscriptionGuard)>,
916    // Shared with each bucket's subscribe_diffs callback so it always
917    // reads the LATEST filter (never one captured at subscribe time) —
918    // a bucket subscription outlives many filter ticks.
919    bucket_filter_refs: HashMap<CompoundKey, Arc<Mutex<LiveFilterGeneration<F>>>>,
920    scan_guard: Option<SubscriptionGuard>,
921    scan_filter_ref: Option<Arc<Mutex<LiveFilterGeneration<F>>>>,
922    prev_route_field_names: Option<&'static [&'static str]>,
923}
924
925struct LiveFilterGeneration<F> {
926    generation: u64,
927    filter: F,
928}
929
930#[derive(Default)]
931struct LiveQuerySynchronization {
932    generation: AtomicU64,
933    reconciliation_gate: Mutex<()>,
934}
935
936impl<F> Default for LiveQueryState<F> {
937    fn default() -> Self {
938        Self {
939            bucket_guards: HashMap::new(),
940            bucket_filter_refs: HashMap::new(),
941            scan_guard: None,
942            scan_filter_ref: None,
943            prev_route_field_names: None,
944        }
945    }
946}
947
948/// Reactive filter parameters: `filter_cell` replaces the value-based
949///
950/// `GetXsByQuery` query with a live `Cell`, so a filter whose value is
951/// itself derived reactively no longer needs a `switch_map` wrapper. The
952/// returned map is a single persistent reactive graph node —
953/// filter changes are handled by incrementally adding/removing bucket
954/// subscriptions (for `In`/`Eq` changes on indexed `#[belongs_to]` fields)
955/// or, for non-indexed (`Range`/`Contains`) changes, rescanning the
956/// currently-scoped item set — never by tearing down and rebuilding
957/// `result` itself. Downstream cells built on the returned map keep their
958/// subscription across every filter change.
959///
960/// Server-side only (a `Cell` can't cross the wire); no cache — a cell is
961/// object identity, so callers get an independent graph node per call site
962/// (see spec §5's "no value-identity cache sharing").
963pub fn query_live<F>(
964    registry: Arc<StoreRegistry>,
965    host_id: Uuid,
966    filter_cell: impl Watchable<F>,
967) -> FilteredCellMap
968where
969    F: LiveFilterQuery,
970{
971    let result: AnyItemMap = AnyItemMap::new();
972    let state: Arc<Mutex<LiveQueryState<F>>> = Arc::new(Mutex::new(LiveQueryState::default()));
973    let synchronization = Arc::new(LiveQuerySynchronization::default());
974
975    let result_weak = result.downgrade();
976    let guard = filter_cell.subscribe(move |signal| {
977        let Signal::Value(new_filter) = signal else {
978            return;
979        };
980        let Some(result) = result_weak.upgrade() else {
981            return;
982        };
983        let _reconciliation = synchronization
984            .reconciliation_gate
985            .lock()
986            .unwrap_or_else(std::sync::PoisonError::into_inner);
987        let mut state = state
988            .lock()
989            .unwrap_or_else(std::sync::PoisonError::into_inner);
990        let current_generation = synchronization
991            .generation
992            .fetch_add(1, Ordering::Relaxed)
993            .saturating_add(1);
994        reconcile_live_query(
995            &registry,
996            host_id,
997            &result,
998            &mut state,
999            new_filter.as_ref(),
1000            current_generation,
1001            &synchronization,
1002        );
1003    });
1004    drop(filter_cell);
1005    result.own(guard);
1006    result.lock()
1007}
1008
1009fn reconcile_live_query<F: LiveFilterQuery>(
1010    registry: &Arc<StoreRegistry>,
1011    host_id: Uuid,
1012    result: &AnyItemMap,
1013    state: &mut LiveQueryState<F>,
1014    new_filter: &F,
1015    current_generation: u64,
1016    synchronization: &Arc<LiveQuerySynchronization>,
1017) {
1018    if let Some(route) = new_filter.query_route() {
1019        reconcile_indexed_live_query(
1020            registry,
1021            host_id,
1022            result,
1023            state,
1024            new_filter,
1025            (route, current_generation),
1026            synchronization,
1027        );
1028    } else {
1029        reconcile_scan_live_query(
1030            registry,
1031            result,
1032            state,
1033            new_filter,
1034            current_generation,
1035            synchronization,
1036        );
1037    }
1038}
1039
1040fn current_live_filter<F: LiveFilterQuery>(
1041    filter_state: &Mutex<LiveFilterGeneration<F>>,
1042    synchronization: &LiveQuerySynchronization,
1043) -> Option<F> {
1044    let filter_state = filter_state
1045        .lock()
1046        .unwrap_or_else(std::sync::PoisonError::into_inner);
1047    (filter_state.generation == synchronization.generation.load(Ordering::Relaxed))
1048        .then(|| filter_state.filter.clone())
1049}
1050
1051fn build_live_diff_callback<F: LiveFilterQuery>(
1052    result: &AnyItemMap,
1053    filter_state: Arc<Mutex<LiveFilterGeneration<F>>>,
1054    synchronization: &Arc<LiveQuerySynchronization>,
1055    scope: LiveDiffScope,
1056) -> impl Fn(&BucketDiff) + Send + Sync + 'static {
1057    let synchronization = synchronization.clone();
1058    let result = result.downgrade();
1059    let first = AtomicBool::new(true);
1060    move |diff| {
1061        let Some(result) = result.upgrade() else {
1062            return;
1063        };
1064        if first.swap(false, Ordering::Relaxed) {
1065            if let Some(filter) = current_live_filter(&filter_state, &synchronization) {
1066                apply_live_diff(&result, diff, &filter, scope);
1067            }
1068            return;
1069        }
1070        let _reconciliation = synchronization
1071            .reconciliation_gate
1072            .lock()
1073            .unwrap_or_else(std::sync::PoisonError::into_inner);
1074        let Some(filter) = current_live_filter(&filter_state, &synchronization) else {
1075            return;
1076        };
1077        apply_live_diff(&result, diff, &filter, scope);
1078    }
1079}
1080
1081fn clear_live_result(result: &AnyItemMap) {
1082    for (id, _) in result.snapshot() {
1083        result.remove(&id);
1084    }
1085}
1086
1087fn split_query_route(
1088    route: QueryRoute,
1089) -> (
1090    &'static [&'static str],
1091    HashSet<CompoundKey>,
1092    Option<CompoundFkExtractor>,
1093) {
1094    match route {
1095        QueryRoute::Ids(ids) => (
1096            ID_ROUTE_FIELD_NAMES,
1097            ids.into_iter()
1098                .map(|id| CompoundKey::from_iter([id]))
1099                .collect(),
1100            None,
1101        ),
1102        QueryRoute::BelongsTo(BelongsToRoute {
1103            field_names,
1104            keys,
1105            extract_fk,
1106        }) => (field_names, keys.into_iter().collect(), Some(extract_fk)),
1107    }
1108}
1109
1110fn build_live_bucket_source<F: LiveFilterQuery>(
1111    registry: &Arc<StoreRegistry>,
1112    host_id: Uuid,
1113    route_field_names: &'static [&'static str],
1114    extract_fk: Option<CompoundFkExtractor>,
1115) -> BucketSourceFn {
1116    extract_fk.map_or_else(
1117        || {
1118            let store = registry.get_or_create(F::entity_type());
1119            let make: BucketSourceFn = Box::new(move |key: &CompoundKey| {
1120                key.first().map_or_else(
1121                    || AnyItemMap::new().lock(),
1122                    |id| build_ids_source_map(&store, std::slice::from_ref(id)),
1123                )
1124            });
1125            make
1126        },
1127        |extract_fk| {
1128            let index = belongs_to_source_index_for(
1129                registry,
1130                host_id,
1131                F::entity_type(),
1132                route_field_names,
1133                extract_fk,
1134            );
1135            let make: BucketSourceFn =
1136                Box::new(move |key: &CompoundKey| index.bucket_for(key.clone(), extract_fk).lock());
1137            make
1138        },
1139    )
1140}
1141
1142fn reconcile_existing_buckets<F: LiveFilterQuery>(
1143    result: &AnyItemMap,
1144    state: &mut LiveQueryState<F>,
1145    new_filter: &F,
1146    new_keys: &HashSet<CompoundKey>,
1147    current_generation: u64,
1148) {
1149    for (key, (bucket, _guard)) in state
1150        .bucket_guards
1151        .extract_if(|key, _| !new_keys.contains(key))
1152    {
1153        for (id, _) in bucket.snapshot() {
1154            result.remove(&id);
1155        }
1156        state.bucket_filter_refs.remove(&key);
1157    }
1158    for key in new_keys {
1159        if let Some(filter_state) = state.bucket_filter_refs.get(key) {
1160            *filter_state
1161                .lock()
1162                .unwrap_or_else(std::sync::PoisonError::into_inner) = LiveFilterGeneration {
1163                generation: current_generation,
1164                filter: new_filter.clone(),
1165            };
1166        }
1167        if let Some((bucket, _)) = state.bucket_guards.get(key) {
1168            apply_bucket_candidates(result, new_filter, bucket.snapshot());
1169        }
1170    }
1171}
1172
1173fn add_live_buckets<F: LiveFilterQuery>(
1174    result: &AnyItemMap,
1175    state: &mut LiveQueryState<F>,
1176    new_filter: &F,
1177    new_keys: &HashSet<CompoundKey>,
1178    current_generation: u64,
1179    make_source: &BucketSourceFn,
1180    synchronization: &Arc<LiveQuerySynchronization>,
1181) {
1182    for key in new_keys {
1183        if state.bucket_guards.contains_key(key) {
1184            continue;
1185        }
1186        let bucket = make_source(key);
1187        apply_bucket_candidates(result, new_filter, bucket.snapshot());
1188        let filter_state = Arc::new(Mutex::new(LiveFilterGeneration {
1189            generation: current_generation,
1190            filter: new_filter.clone(),
1191        }));
1192        let guard = bucket.subscribe_diffs(build_live_diff_callback(
1193            result,
1194            filter_state.clone(),
1195            synchronization,
1196            LiveDiffScope::Bucket,
1197        ));
1198        state.bucket_guards.insert(key.clone(), (bucket, guard));
1199        state.bucket_filter_refs.insert(key.clone(), filter_state);
1200    }
1201}
1202
1203fn reconcile_indexed_live_query<F: LiveFilterQuery>(
1204    registry: &Arc<StoreRegistry>,
1205    host_id: Uuid,
1206    result: &AnyItemMap,
1207    state: &mut LiveQueryState<F>,
1208    new_filter: &F,
1209    route_generation: (QueryRoute, u64),
1210    synchronization: &Arc<LiveQuerySynchronization>,
1211) {
1212    state.scan_guard = None;
1213    let (route, current_generation) = route_generation;
1214    let (route_field_names, new_keys, extract_fk) = split_query_route(route);
1215    if state.prev_route_field_names != Some(route_field_names) {
1216        state.bucket_guards.clear();
1217        state.bucket_filter_refs.clear();
1218        clear_live_result(result);
1219    }
1220    reconcile_existing_buckets(result, state, new_filter, &new_keys, current_generation);
1221    if new_keys
1222        .iter()
1223        .any(|key| !state.bucket_guards.contains_key(key))
1224    {
1225        let make_source =
1226            build_live_bucket_source::<F>(registry, host_id, route_field_names, extract_fk);
1227        add_live_buckets(
1228            result,
1229            state,
1230            new_filter,
1231            &new_keys,
1232            current_generation,
1233            &make_source,
1234            synchronization,
1235        );
1236    }
1237    state.prev_route_field_names = Some(route_field_names);
1238}
1239
1240fn reconcile_scan_live_query<F: LiveFilterQuery>(
1241    registry: &Arc<StoreRegistry>,
1242    result: &AnyItemMap,
1243    state: &mut LiveQueryState<F>,
1244    new_filter: &F,
1245    current_generation: u64,
1246    synchronization: &Arc<LiveQuerySynchronization>,
1247) {
1248    if state.prev_route_field_names.is_some() {
1249        state.bucket_guards.clear();
1250        state.bucket_filter_refs.clear();
1251        clear_live_result(result);
1252        state.prev_route_field_names = None;
1253    }
1254    let store = registry.get_or_create(F::entity_type());
1255    reconcile_full_scope_membership(result, new_filter, store.snapshot());
1256    if state.scan_guard.is_none() {
1257        let filter_state = Arc::new(Mutex::new(LiveFilterGeneration {
1258            generation: current_generation,
1259            filter: new_filter.clone(),
1260        }));
1261        state.scan_filter_ref = Some(filter_state.clone());
1262        state.scan_guard = Some(store.subscribe_diffs(build_live_diff_callback(
1263            result,
1264            filter_state,
1265            synchronization,
1266            LiveDiffScope::FullStore,
1267        )));
1268    } else if let Some(filter_state) = &state.scan_filter_ref {
1269        *filter_state
1270            .lock()
1271            .unwrap_or_else(std::sync::PoisonError::into_inner) = LiveFilterGeneration {
1272            generation: current_generation,
1273            filter: new_filter.clone(),
1274        };
1275    }
1276}
1277
1278/// Sweep dead (no-longer-subscribed) buckets across every belongs-to
1279///
1280/// relation's source index. `route_to_live_bucket` reaps dead entries lazily
1281/// on next access, but a foreign id that goes dead and is never looked up
1282/// again would otherwise linger; called from
1283/// `MykoServerContext::sweep_dead_cache_entries`.
1284pub fn sweep_all_belongs_to_source_indexes() {
1285    belongs_to_source_indexes().retain(|_, weak| {
1286        let Some(index) = weak.upgrade() else {
1287            return false;
1288        };
1289        index.sweep_dead_buckets();
1290        true
1291    });
1292}
1293
1294/// Build a `FilteredCellMap` containing only the entries at the given ids,
1295/// using direct per-key store lookups instead of an O(N) `test_entity` scan.
1296///
1297/// Used by `Get<Entity>sByIds::build_view` so the initial query result is
1298/// constructed in O(M) where M = `ids.len()`. Per-key cells from the store
1299/// keep the result reactive to inserts / updates / deletes for those
1300/// specific ids; `test_entity` semantics still hold because the returned
1301/// map only ever contains keys from `ids`.
1302#[must_use]
1303pub fn build_ids_source_map(
1304    store: &Arc<crate::store::EntityStore>,
1305    ids: &[Arc<str>],
1306) -> FilteredCellMap {
1307    use hyphae::{Materialize, Signal, Watchable};
1308
1309    let result: hyphae::CellMap<Arc<str>, AnyItemArc> = hyphae::CellMap::new();
1310    for id in ids {
1311        let key_cell = store.get(id).materialize();
1312        // Weak, not a strong clone: `key_cell` belongs to the store, which
1313        // lives for the whole process — a strong capture here would make the
1314        // store's per-key subscriber list hold `result` (and everything
1315        // built on top of it downstream) alive forever, regardless of
1316        // whether any external caller still references it. This is exactly
1317        // the reference cycle `query_cache`'s weak-ref design assumes never
1318        // happens (see `MapCacheEntry` in server/context.rs).
1319        let result_weak = result.downgrade();
1320        let key_for_cb = id.clone();
1321        let guard = key_cell.subscribe(move |signal| {
1322            let Some(result_for_cb) = result_weak.upgrade() else {
1323                return;
1324            };
1325            if let Signal::Value(arc_opt) = signal {
1326                match arc_opt.as_ref() {
1327                    Some(item) => {
1328                        result_for_cb.insert(key_for_cb.clone(), item.clone());
1329                    }
1330                    None => {
1331                        result_for_cb.remove(&key_for_cb);
1332                    }
1333                }
1334            }
1335        });
1336        result.own(guard);
1337    }
1338    result.lock()
1339}
1340
1341pub fn filter_query_over_source<Q>(
1342    source: FilteredCellMap,
1343    query: Arc<Q>,
1344    query_context: Arc<QueryContext>,
1345) -> impl hyphae::MapQuery<Key = Arc<str>, Value = AnyItemArc>
1346where
1347    Q: QueryHandler + QueryParams + Clone + Send + Sync + 'static,
1348    Q::Item:
1349        DeserializeOwned + Eventable + WithId + Clone + std::fmt::Debug + Send + Sync + 'static,
1350{
1351    source.select(move |item_any: &AnyItemArc| {
1352        downcast_any_item_arc::<Q::Item>(item_any, "filter_query_over_source").is_some_and(|item| {
1353            Q::test_entity(QueryTestContext {
1354                item,
1355                query: query.clone(),
1356                query_context: query_context.clone(),
1357            })
1358        })
1359    })
1360}
1361
1362/// Apply a typed predicate to an already-keyed source map.
1363///
1364/// This is useful when the source itself establishes key membership (for
1365/// example, a direct multi-ID lookup) and the remaining predicate only needs
1366/// to validate a secondary scope. Keeping key membership out of that hot
1367/// predicate avoids turning M direct lookups into M scans of the requested ID
1368/// list.
1369pub fn filter_typed_source<T, F>(
1370    source: FilteredCellMap,
1371    predicate: F,
1372) -> impl hyphae::MapQuery<Key = Arc<str>, Value = AnyItemArc>
1373where
1374    T: Eventable + WithId + Clone + std::fmt::Debug + Send + Sync + 'static,
1375    F: Fn(&Arc<T>) -> bool + Send + Sync + 'static,
1376{
1377    source.select(move |item_any: &AnyItemArc| {
1378        downcast_any_item_arc::<T>(item_any, "filter_typed_source")
1379            .is_some_and(|item| predicate(&item))
1380    })
1381}
1382
1383/// Registration entry for a query type.
1384/// Collected via inventory for automatic discovery.
1385pub struct QueryRegistration {
1386    /// Query identifier (e.g., "`GetAllTargets`")
1387    pub query_id: &'static str,
1388    /// Entity type this query returns (e.g., "Target")
1389    pub query_item_type: &'static str,
1390    /// Crate where this query is defined (for `type_gen` filtering)
1391    pub crate_name: &'static str,
1392    /// Parse function for deserializing query from JSON
1393    pub parse: QueryParseFn,
1394    /// Factory for creating reactive cell from query
1395    pub cell_factory: QueryCellFactory,
1396    /// Factory for an optional source-level bounded query window.
1397    pub window_cell_factory: QueryWindowCellFactory,
1398    /// Query struct's own fields, captured at macro-expansion time. Backs
1399    /// the MCP `search()` tool's operation index — see `crate::reflection`.
1400    pub args: &'static [crate::reflection::OperationArgField],
1401    /// Query struct's doc comment, if any.
1402    pub description: Option<&'static str>,
1403    /// Whether language binding generators should publish this query.
1404    pub include_in_typegen: bool,
1405}
1406
1407// ─────────────────────────────────────────────────────────────────────────────
1408// QueryFactory - Static methods for query types
1409// ─────────────────────────────────────────────────────────────────────────────
1410
1411/// Factory trait for creating query registration data.
1412///
1413/// This trait has a blanket implementation for all types implementing `QueryParams`,
1414/// so user-defined queries automatically get `parse` and `cell_factory` methods.
1415pub trait QueryFactory: QueryParams {
1416    /// Parse JSON into this query type.
1417    ///
1418    /// # Errors
1419    ///
1420    /// Returns an error when the requested operation cannot be completed.
1421    fn parse(value: Value) -> Result<Arc<dyn AnyQuery>, anyhow::Error>;
1422
1423    /// Create a reactive cell for this query.
1424    ///
1425    /// # Errors
1426    ///
1427    /// Returns an error when the requested operation cannot be completed.
1428    fn cell_factory(
1429        query: Arc<dyn AnyQuery>,
1430        registry: Arc<StoreRegistry>,
1431        request_ctx: Arc<RequestContext>,
1432        server_ctx: Option<Arc<MykoServerContext>>,
1433    ) -> Result<FilteredCellMap, String>;
1434
1435    /// Create a source-level bounded query window when the handler supports
1436    /// pushdown.
1437    ///
1438    /// # Errors
1439    ///
1440    /// Returns an error when the query payload is invalid or the source
1441    /// cannot build the requested window.
1442    fn window_cell_factory(
1443        query: Arc<dyn AnyQuery>,
1444        registry: Arc<StoreRegistry>,
1445        request_ctx: Arc<RequestContext>,
1446        server_ctx: Arc<MykoServerContext>,
1447        window: crate::wire::QueryWindow,
1448    ) -> Result<Option<super::WindowedQuerySource>, String>;
1449}
1450
1451impl<Q: QueryParams> QueryFactory for Q
1452where
1453    Q::Item:
1454        Eventable + WithId + DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1455{
1456    fn parse(value: Value) -> Result<Arc<dyn AnyQuery>, anyhow::Error> {
1457        let query = serde_json::from_value::<QueryRequest<Q>>(value)?;
1458        Ok(Arc::new(query))
1459    }
1460
1461    fn cell_factory(
1462        any_query: Arc<dyn AnyQuery>,
1463        registry: Arc<StoreRegistry>,
1464        request_ctx: Arc<RequestContext>,
1465        server_ctx: Option<Arc<MykoServerContext>>,
1466    ) -> Result<FilteredCellMap, String> {
1467        QUERY_CELL_FACTORIES_CREATED.fetch_add(1, Ordering::Relaxed);
1468        let query_id = Q::query_id_static();
1469        // Bounded cardinality (one span per query *registration*, not per
1470        // `test_entity` item test — that runs reactively per store mutation
1471        // and would be far too hot to span), matching `myko.command`.
1472        let _span = tracing::trace_span!("myko.query", query = query_id.as_ref()).entered();
1473        crate::server::dispatch_metrics::record_query(query_id.as_ref(), request_ctx.origin());
1474        increment_counter(query_factories_by_id(), query_id);
1475        let any_ref: &dyn Any = any_query.as_ref();
1476        let request: QueryRequest<Q> =
1477            crate::common::downcast::downcast_request(any_ref, "query payload")?;
1478        let query: Arc<Q> = Arc::new(request.query);
1479
1480        let query_ctx = Arc::new(QueryContext { req: request_ctx });
1481        let query_cell_ctx =
1482            QueryBuildContext::new(query_ctx.clone(), registry.clone(), server_ctx);
1483
1484        if let Some(built) = Q::build_view(QueryBuildArgs {
1485            query: query.clone(),
1486            query_context: query_cell_ctx,
1487        }) {
1488            return Ok(hyphae::MapQuery::materialize(built));
1489        }
1490
1491        let store: crate::store::EntityStore =
1492            (*registry.get_or_create(&Q::query_item_type_static())).clone();
1493        Ok(hyphae::MapQuery::materialize(store.select(
1494            move |item_any: &AnyItemArc| {
1495                downcast_any_item_arc::<Q::Item>(item_any, "QueryFactory::cell_factory")
1496                    .is_some_and(|item| {
1497                        Q::test_entity(QueryTestContext {
1498                            item,
1499                            query: query.clone(),
1500                            query_context: query_ctx.clone(),
1501                        })
1502                    })
1503            },
1504        )))
1505    }
1506
1507    fn window_cell_factory(
1508        any_query: Arc<dyn AnyQuery>,
1509        registry: Arc<StoreRegistry>,
1510        request_ctx: Arc<RequestContext>,
1511        server_ctx: Arc<MykoServerContext>,
1512        window: crate::wire::QueryWindow,
1513    ) -> Result<Option<super::WindowedQuerySource>, String> {
1514        let any_ref: &dyn Any = any_query.as_ref();
1515        let request: QueryRequest<Q> =
1516            crate::common::downcast::downcast_request(any_ref, "windowed query payload")?;
1517        let query_context = Arc::new(QueryContext { req: request_ctx });
1518        Q::build_window(QueryWindowBuildArgs {
1519            query: Arc::new(request.query),
1520            query_context: QueryBuildContext::new(query_context, registry, Some(server_ctx)),
1521            window,
1522        })
1523    }
1524}
1525
1526#[cfg(test)]
1527mod belongs_to_source_index_tests {
1528    use std::any::Any;
1529
1530    use hyphae::{Gettable, Materialize};
1531    use serde::Serialize;
1532
1533    use super::*;
1534    use crate::common::with_id::WithId;
1535
1536    #[derive(Debug, Clone, PartialEq, Serialize)]
1537    struct TestChild {
1538        id: Arc<str>,
1539        parent_id: Arc<str>,
1540    }
1541
1542    impl WithId for TestChild {
1543        fn id(&self) -> Arc<str> {
1544            self.id.clone()
1545        }
1546    }
1547
1548    impl crate::core::item::AnyItem for TestChild {
1549        fn as_any(&self) -> &dyn Any {
1550            self
1551        }
1552
1553        fn entity_type(&self) -> &'static str {
1554            "TestChild"
1555        }
1556
1557        fn equals(&self, other: &dyn crate::core::item::AnyItem) -> bool {
1558            other
1559                .as_any()
1560                .downcast_ref::<Self>()
1561                .is_some_and(|t| t == self)
1562        }
1563    }
1564
1565    fn extract_parent_fk(item: &dyn Any) -> Option<CompoundKey> {
1566        item.downcast_ref::<TestChild>()
1567            .map(|c| smallvec::smallvec![c.parent_id.clone()])
1568    }
1569
1570    fn child(id: &str, parent: &str) -> (Arc<str>, AnyItemArc) {
1571        let item: AnyItemArc = Arc::new(TestChild {
1572            id: Arc::from(id),
1573            parent_id: Arc::from(parent),
1574        });
1575        (Arc::from(id), item)
1576    }
1577
1578    fn new_store() -> Arc<crate::store::EntityStore> {
1579        Arc::new(hyphae::CellMap::new())
1580    }
1581
1582    #[test]
1583    fn dropped_subscriptions_do_not_leak_across_many_distinct_parents() {
1584        // Reproduces the leak: every distinct foreign id ever subscribed to
1585        // used to leave a permanent bucket behind. With weak-ref buckets,
1586        // dropping every subscriber and sweeping must bring the count to 0
1587        // regardless of how many distinct parents were ever seen.
1588        let store = new_store();
1589        let index = BelongsToSourceIndex::new(store, extract_parent_fk);
1590
1591        for i in 0..50 {
1592            let parent: Arc<str> = Arc::from(format!("parent-{i}"));
1593            let bucket = index.bucket_for(smallvec::smallvec![parent], extract_parent_fk);
1594            drop(bucket); // simulates every subscriber unsubscribing
1595        }
1596
1597        index.sweep_dead_buckets();
1598        assert_eq!(
1599            index.buckets.len(),
1600            0,
1601            "sweep must reap all buckets once every subscriber has dropped"
1602        );
1603    }
1604
1605    #[test]
1606    fn live_subscription_survives_going_empty_then_repopulating() {
1607        // The naive fix (remove a bucket the moment it goes empty) breaks
1608        // this: a still-live subscriber would get orphaned from a bucket
1609        // that later gets silently replaced. Weak-ref buckets avoid this —
1610        // as long as the subscriber holds their strong handle, `bucket_for`
1611        // keeps returning the *same* object.
1612        let store = new_store();
1613        let (id, item) = child("c1", "parent-x");
1614        store.insert(id.clone(), item);
1615
1616        let index = BelongsToSourceIndex::new(store.clone(), extract_parent_fk);
1617        let bucket = index.bucket_for(
1618            smallvec::smallvec![Arc::from("parent-x")],
1619            extract_parent_fk,
1620        );
1621        assert_eq!(bucket.snapshot().len(), 1);
1622
1623        // Remove the only child — bucket goes empty, but `bucket` is still
1624        // held here, simulating a live subscriber.
1625        store.remove_many(vec![id]);
1626        assert_eq!(bucket.snapshot().len(), 0);
1627
1628        // A new child arrives under the same parent — the still-held handle
1629        // must see it, not a disconnected/orphaned bucket.
1630        let (id2, item2) = child("c2", "parent-x");
1631        store.insert(id2, item2);
1632        assert_eq!(
1633            bucket.snapshot().len(),
1634            1,
1635            "a live subscriber must see re-population after its bucket went empty"
1636        );
1637    }
1638
1639    #[test]
1640    fn resubscribing_after_reap_backfills_current_children() {
1641        // The bug a naive weak-ref swap alone would introduce: once a
1642        // bucket is reaped, apply_diff never creates buckets nobody's
1643        // watching, so a fresh subscription must explicitly backfill from
1644        // the current store state rather than starting empty.
1645        let store = new_store();
1646        let (id, item) = child("c1", "parent-y");
1647        store.insert(id, item);
1648
1649        let index = BelongsToSourceIndex::new(store.clone(), extract_parent_fk);
1650
1651        {
1652            let bucket = index.bucket_for(
1653                smallvec::smallvec![Arc::from("parent-y")],
1654                extract_parent_fk,
1655            );
1656            assert_eq!(bucket.snapshot().len(), 1);
1657        }
1658        index.sweep_dead_buckets();
1659        assert!(index.buckets.is_empty());
1660
1661        let bucket = index.bucket_for(
1662            smallvec::smallvec![Arc::from("parent-y")],
1663            extract_parent_fk,
1664        );
1665        assert_eq!(
1666            bucket.snapshot().len(),
1667            1,
1668            "resubscribing after the bucket was reaped must backfill current children, not start empty"
1669        );
1670    }
1671
1672    #[test]
1673    fn concurrent_bucket_for_calls_for_the_same_key_never_orphan_a_bucket() {
1674        const N: usize = 16;
1675
1676        // The layer-2 mechanism bright-eagle's investigation converged on:
1677        // bucket_for used to check-then-act (route_to_live_bucket, then a
1678        // separate unconditional insert) with no lock held across the gap.
1679        // N callers racing for the same key could each observe "no live
1680        // bucket," each build and backfill their own AnyItemMap, then race
1681        // an unconditional `insert` — whichever landed last won
1682        // `self.buckets`, silently orphaning every other caller's bucket:
1683        // still a valid handle, already returned and subscribed to, but
1684        // unreachable from apply_diff's routing from then on. That's
1685        // exactly "first/arbitrary winner, everyone else starves forever,
1686        // outcome varies by scheduling" — matches rship-qtu's calm-time,
1687        // registration-order-dependent symptom. entry()-based bucket_for
1688        // holds the shard lock across the whole check-or-create, so this
1689        // must converge on exactly one live bucket no matter how many
1690        // callers race.
1691        let store = new_store();
1692        let index = Arc::new(BelongsToSourceIndex::new(store.clone(), extract_parent_fk));
1693        let key: CompoundKey = smallvec::smallvec![Arc::from("parent-race")];
1694
1695        let barrier = Arc::new(std::sync::Barrier::new(N));
1696        let mut handles = Vec::with_capacity(N);
1697        for _ in 0..N {
1698            let index = index.clone();
1699            let key = key.clone();
1700            let barrier = barrier.clone();
1701            handles.push(std::thread::spawn(move || {
1702                barrier.wait();
1703                index.bucket_for(key, extract_parent_fk)
1704            }));
1705        }
1706        let buckets: Vec<AnyItemMap> = handles
1707            .into_iter()
1708            .filter_map(|handle| handle.join().ok())
1709            .collect();
1710        assert_eq!(buckets.len(), N, "all bucket threads must complete");
1711
1712        assert_eq!(
1713            index.buckets.len(),
1714            1,
1715            "N concurrent creators for the same key must converge on exactly one bucket entry"
1716        );
1717
1718        // A new matching item must be visible through EVERY returned
1719        // handle — an orphaned loser of the old insert race would have
1720        // received its one-time (empty) backfill and nothing since.
1721        let (id, item) = child("c-race", "parent-race");
1722        store.insert(id, item);
1723        for (i, bucket) in buckets.iter().enumerate() {
1724            assert_eq!(
1725                bucket.snapshot().len(),
1726                1,
1727                "handle {i} must observe the post-race insert — an orphaned bucket stays empty forever"
1728            );
1729        }
1730    }
1731
1732    #[test]
1733    fn concurrent_first_subscription_and_insert_cannot_lose_the_insert() {
1734        // Exercise the publication/backfill boundary repeatedly. The index
1735        // mutation gate makes the insert callback fall wholly before or
1736        // after bucket construction, so either ordering must converge on
1737        // the inserted child without requiring a later source diff.
1738        for i in 0..128 {
1739            let store = new_store();
1740            let index = BelongsToSourceIndex::new(store.clone(), extract_parent_fk);
1741            let barrier = Arc::new(std::sync::Barrier::new(2));
1742
1743            let bucket_thread = {
1744                let index = index.clone();
1745                let barrier = barrier.clone();
1746                std::thread::spawn(move || {
1747                    barrier.wait();
1748                    index.bucket_for(
1749                        smallvec::smallvec![Arc::from("parent-race")],
1750                        extract_parent_fk,
1751                    )
1752                })
1753            };
1754            let insert_thread = {
1755                let store = store.clone();
1756                let barrier = barrier.clone();
1757                std::thread::spawn(move || {
1758                    barrier.wait();
1759                    let (id, item) = child(&format!("child-{i}"), "parent-race");
1760                    store.insert(id, item);
1761                })
1762            };
1763
1764            let bucket = bucket_thread.join();
1765            assert!(bucket.is_ok(), "bucket thread must complete");
1766            let Ok(bucket) = bucket else {
1767                return;
1768            };
1769            assert!(insert_thread.join().is_ok());
1770            assert_eq!(
1771                bucket.snapshot().len(),
1772                1,
1773                "iteration {i} lost the insert racing first bucket construction"
1774            );
1775        }
1776    }
1777
1778    #[test]
1779    fn bucket_diff_fanout_can_reenter_bucket_creation() {
1780        let store = new_store();
1781        let index = BelongsToSourceIndex::new(store.clone(), extract_parent_fk);
1782        let bucket = index.bucket_for(
1783            smallvec::smallvec![Arc::from("parent-source")],
1784            extract_parent_fk,
1785        );
1786        let (sent, received) = std::sync::mpsc::channel();
1787        let index_for_callback = index.clone();
1788        let guard = bucket.subscribe_diffs(move |_| {
1789            // Real query graphs can synchronously construct another routed
1790            // query while handling this diff. That must not attempt to
1791            // reacquire an index gate still held by apply_diff.
1792            let nested = index_for_callback.bucket_for(
1793                smallvec::smallvec![Arc::from("parent-nested")],
1794                extract_parent_fk,
1795            );
1796            drop(nested);
1797            let _send_result = sent.send(());
1798        });
1799
1800        // Discard subscribe_diffs' synchronous Initial notification.
1801        assert!(received.recv().is_ok());
1802        let insert = std::thread::spawn(move || {
1803            let (id, item) = child("child-reentrant", "parent-source");
1804            store.insert(id, item);
1805        });
1806        assert!(
1807            received
1808                .recv_timeout(std::time::Duration::from_secs(2))
1809                .is_ok(),
1810            "bucket fanout deadlocked while re-entering bucket_for"
1811        );
1812        assert!(insert.join().is_ok());
1813        drop(guard);
1814    }
1815
1816    #[test]
1817    fn global_registry_does_not_retain_an_unused_index() {
1818        let registry = Arc::new(crate::store::StoreRegistry::new());
1819        let host_id = Uuid::new_v4();
1820        let registry_key = format!("{host_id}:TestChild:parent_id");
1821        let index = belongs_to_source_index_for(
1822            &registry,
1823            host_id,
1824            "TestChild",
1825            &["parent_id"],
1826            extract_parent_fk,
1827        );
1828        let weak = Arc::downgrade(&index);
1829        let bucket = index.bucket_for(
1830            smallvec::smallvec![Arc::from("parent-live")],
1831            extract_parent_fk,
1832        );
1833
1834        drop(index);
1835        assert!(
1836            weak.upgrade().is_some(),
1837            "a live bucket must retain the index that routes its updates"
1838        );
1839        drop(bucket);
1840        assert!(
1841            weak.upgrade().is_none(),
1842            "the global registry or driver subscription retained an unused index"
1843        );
1844
1845        sweep_all_belongs_to_source_indexes();
1846        assert!(
1847            !belongs_to_source_indexes().contains_key(&registry_key),
1848            "sweeping must remove the dead weak registry entry"
1849        );
1850    }
1851
1852    // ─────────────────────────────────────────────────────────────────
1853    // Compound (multi-field) routing — the layer-1 fix for rship-qtu:
1854    // an entity with 2+ belongs_to fields, queried with more than one
1855    // set at once, must NOT collapse different combinations onto one
1856    // shared bucket.
1857    // ─────────────────────────────────────────────────────────────────
1858
1859    #[derive(Debug, Clone, PartialEq, Serialize)]
1860    struct TestCursor {
1861        id: Arc<str>,
1862        node_id: Arc<str>,
1863        session_id: Arc<str>,
1864        anchor_id: Arc<str>,
1865    }
1866
1867    impl WithId for TestCursor {
1868        fn id(&self) -> Arc<str> {
1869            self.id.clone()
1870        }
1871    }
1872
1873    impl crate::core::item::AnyItem for TestCursor {
1874        fn as_any(&self) -> &dyn Any {
1875            self
1876        }
1877
1878        fn entity_type(&self) -> &'static str {
1879            "TestCursor"
1880        }
1881
1882        fn equals(&self, other: &dyn crate::core::item::AnyItem) -> bool {
1883            other
1884                .as_any()
1885                .downcast_ref::<Self>()
1886                .is_some_and(|t| t == self)
1887        }
1888    }
1889
1890    fn cursor(id: &str, node: &str, session: &str) -> (Arc<str>, AnyItemArc) {
1891        cursor_with_anchor(id, node, session, "anchor-default")
1892    }
1893
1894    fn cursor_with_anchor(
1895        id: &str,
1896        node: &str,
1897        session: &str,
1898        anchor: &str,
1899    ) -> (Arc<str>, AnyItemArc) {
1900        let item: AnyItemArc = Arc::new(TestCursor {
1901            id: Arc::from(id),
1902            node_id: Arc::from(node),
1903            session_id: Arc::from(session),
1904            anchor_id: Arc::from(anchor),
1905        });
1906        (Arc::from(id), item)
1907    }
1908
1909    // Mirrors the macro-generated compound extractor for a query that pins
1910    // BOTH belongs_to fields: position 0 = node_id, position 1 = session_id.
1911    fn extract_node_and_session_fk(item: &dyn Any) -> Option<CompoundKey> {
1912        item.downcast_ref::<TestCursor>()
1913            .map(|c| smallvec::smallvec![c.node_id.clone(), c.session_id.clone()])
1914    }
1915
1916    #[test]
1917    fn compound_key_separates_watchers_sharing_one_field_but_not_the_other() {
1918        // Two cursors in the SAME session but for DIFFERENT nodes. Before
1919        // compound routing, both watchers would collapse onto one
1920        // session-keyed bucket (single-field routing on whichever field
1921        // came first in the struct). With compound (node_id, session_id)
1922        // keys, each watcher gets its own bucket, scoped to exactly its
1923        // node+session pair.
1924        let store = new_store();
1925        let (id_a, item_a) = cursor("cursor-a", "node-A", "session-PROD");
1926        let (id_b, item_b) = cursor("cursor-b", "node-B", "session-PROD");
1927        store.insert(id_a, item_a);
1928        store.insert(id_b, item_b);
1929
1930        let index = BelongsToSourceIndex::new(store.clone(), extract_node_and_session_fk);
1931
1932        let key_a: CompoundKey =
1933            smallvec::smallvec![Arc::from("node-A"), Arc::from("session-PROD")];
1934        let key_b: CompoundKey =
1935            smallvec::smallvec![Arc::from("node-B"), Arc::from("session-PROD")];
1936
1937        let bucket_a = index.bucket_for(key_a, extract_node_and_session_fk);
1938        let bucket_b = index.bucket_for(key_b, extract_node_and_session_fk);
1939
1940        assert_eq!(
1941            bucket_a.snapshot().len(),
1942            1,
1943            "node-A's bucket sees only its own cursor"
1944        );
1945        assert_eq!(
1946            bucket_b.snapshot().len(),
1947            1,
1948            "node-B's bucket sees only its own cursor"
1949        );
1950        assert_eq!(
1951            index.buckets.len(),
1952            2,
1953            "distinct (node, session) pairs get distinct buckets"
1954        );
1955
1956        // Live diffs must reach BOTH watchers independently — this is the
1957        // exact symptom rship-qtu reported: one watcher (bucket_a) must not
1958        // starve the other (bucket_b) of updates once both are subscribed.
1959        let (alpha_id, alpha_item) = cursor("cursor-a-tick2", "node-A", "session-PROD");
1960        store.insert(alpha_id, alpha_item);
1961        assert_eq!(
1962            bucket_a.snapshot().len(),
1963            2,
1964            "node-A's bucket must see its own new entry"
1965        );
1966        assert_eq!(
1967            bucket_b.snapshot().len(),
1968            1,
1969            "node-B's bucket must be unaffected by node-A's insert"
1970        );
1971
1972        let (bravo_id, bravo_item) = cursor("cursor-b-tick2", "node-B", "session-PROD");
1973        store.insert(bravo_id, bravo_item);
1974        assert_eq!(
1975            bucket_b.snapshot().len(),
1976            2,
1977            "node-B's bucket must independently see its own new entry — this is the \
1978             regression rship-qtu hit: the second-registered watcher never received \
1979             a diff under single-field session-only routing"
1980        );
1981    }
1982
1983    #[test]
1984    fn compound_and_single_field_routing_never_share_a_bucket() {
1985        // A query pinning only node_id (single-field key) and a query
1986        // pinning (node_id, session_id) (compound key) for the same node
1987        // must land in different BelongsToSourceIndex instances entirely —
1988        // build_belongs_to_source_map keys the outer index registry by the
1989        // field-name SET, not just the entity type, so this is exercised at
1990        // that layer instead of here (single BelongsToSourceIndex is always
1991        // scoped to one fixed field combination by construction — a
1992        // `bucket_for` call with a 1-element key and one with a 2-element
1993        // key against the SAME index would be a caller bug, not a
1994        // supported mixed-arity usage).
1995        let store = new_store();
1996        let index = BelongsToSourceIndex::new(store, extract_node_and_session_fk);
1997        let key: CompoundKey = smallvec::smallvec![Arc::from("node-A"), Arc::from("session-PROD")];
1998        let bucket = index.bucket_for(key.clone(), extract_node_and_session_fk);
1999        assert_eq!(bucket.snapshot().len(), 0);
2000        assert!(index.buckets.contains_key(&key));
2001    }
2002
2003    // ─────────────────────────────────────────────────────────────────
2004    // K-bucket union routing — what build_view routes an `In` (or
2005    // multi-field compound `In`) filter on a #[belongs_to] field through,
2006    // instead of a table scan (spec §4 hard requirement).
2007    // ─────────────────────────────────────────────────────────────────
2008
2009    #[test]
2010    fn cartesian_product_expands_multi_field_value_sets() {
2011        let sets = vec![
2012            vec![Arc::from("node-A"), Arc::from("node-B")],
2013            vec![Arc::from("session-PROD")],
2014        ];
2015        let mut product = cartesian_product(sets);
2016        product.sort();
2017        assert_eq!(
2018            product,
2019            vec![
2020                CompoundKey::from_iter([Arc::<str>::from("node-A"), Arc::from("session-PROD")]),
2021                CompoundKey::from_iter([Arc::<str>::from("node-B"), Arc::from("session-PROD")]),
2022            ]
2023        );
2024    }
2025
2026    #[test]
2027    fn cartesian_product_empty_set_yields_no_keys() {
2028        // In([]) on any one field means no key any item could satisfy
2029        // exists — matches "In([]) matches nothing" (spec §1).
2030        let sets = vec![vec![Arc::from("node-A")], vec![]];
2031        assert!(cartesian_product(sets).is_empty());
2032    }
2033
2034    #[test]
2035    fn union_source_map_unions_k_buckets_and_stays_reactive() {
2036        // The mechanism build_view routes an `In` filter through: N
2037        // compound keys, each backed by its own BelongsToSourceIndex
2038        // bucket, unioned into one reactive result. Proves both the
2039        // partition-merge (every item appears exactly once, from whichever
2040        // bucket it actually belongs to) and that the union keeps tracking
2041        // writes made after construction — the keepalive pattern
2042        // deliberately mirrors typed_map_from_any_item_with_typed_id's
2043        // own(guard), not the bare-clone shape that froze hyphae's
2044        // CellMap::size() (see count_fresh_cell_test.rs).
2045        let registry = Arc::new(crate::store::StoreRegistry::new());
2046        let store = registry.get_or_create("TestCursor");
2047        let (id_a, item_a) = cursor("cursor-a", "node-A", "session-PROD");
2048        let (id_b, item_b) = cursor("cursor-b", "node-B", "session-PROD");
2049        let (id_c, item_c) = cursor("cursor-c", "node-C", "session-PROD"); // not in the union
2050        store.insert(id_a, item_a);
2051        store.insert(id_b, item_b);
2052        store.insert(id_c, item_c);
2053
2054        let host_id = Uuid::new_v4();
2055        let keys = vec![
2056            smallvec::smallvec![Arc::from("node-A"), Arc::from("session-PROD")],
2057            smallvec::smallvec![Arc::from("node-B"), Arc::from("session-PROD")],
2058        ];
2059        let union = build_belongs_to_union_source_map(
2060            registry.clone(),
2061            host_id,
2062            "TestCursor",
2063            &["node_id", "session_id"],
2064            extract_node_and_session_fk,
2065            keys,
2066        );
2067
2068        assert_eq!(
2069            union.snapshot().len(),
2070            2,
2071            "union must contain exactly node-A's and node-B's cursors, not node-C's"
2072        );
2073
2074        let (alpha_id, alpha_item) = cursor("cursor-a2", "node-A", "session-PROD");
2075        store.insert(alpha_id, alpha_item);
2076        assert_eq!(
2077            union.snapshot().len(),
2078            3,
2079            "union must keep tracking writes to any of its unioned buckets"
2080        );
2081
2082        let (charlie_id, charlie_item) = cursor("cursor-c2", "node-C", "session-PROD");
2083        store.insert(charlie_id, charlie_item);
2084        assert_eq!(
2085            union.snapshot().len(),
2086            3,
2087            "writes to a non-unioned bucket must never appear in the union"
2088        );
2089    }
2090
2091    #[test]
2092    fn compound_union_with_residual_filter_propagates_batch_delete() {
2093        let registry = Arc::new(crate::store::StoreRegistry::new());
2094        let store = registry.get_or_create("TestCursor");
2095        let (matched_id, matched) =
2096            cursor_with_anchor("cursor-match", "node-A", "session-PROD", "anchor-match");
2097        let (residual_miss_id, residual_miss) = cursor_with_anchor(
2098            "cursor-residual-miss",
2099            "node-A",
2100            "session-PROD",
2101            "anchor-other",
2102        );
2103        store.insert(matched_id.clone(), matched);
2104        store.insert(residual_miss_id, residual_miss);
2105
2106        let source = build_belongs_to_union_source_map(
2107            registry,
2108            Uuid::new_v4(),
2109            "TestCursor",
2110            &["node_id", "session_id"],
2111            extract_node_and_session_fk,
2112            vec![smallvec::smallvec![
2113                Arc::from("node-A"),
2114                Arc::from("session-PROD")
2115            ]],
2116        );
2117        let filtered = hyphae::MapQuery::materialize(source.select(|item| {
2118            item.as_any()
2119                .downcast_ref::<TestCursor>()
2120                .is_some_and(|cursor| cursor.anchor_id.as_ref() == "anchor-match")
2121        }));
2122        let items = filtered.items().materialize();
2123        assert_eq!(items.get().len(), 1);
2124
2125        // One row remains in the store, so remove_many emits Batch { Remove }
2126        // rather than Initial { empty }. Both deletion shapes must propagate.
2127        store.remove_many(vec![matched_id]);
2128
2129        assert_eq!(items.get().len(), 0);
2130    }
2131
2132    #[test]
2133    fn union_source_map_empty_keys_yields_empty_reactive_map() {
2134        let registry = Arc::new(crate::store::StoreRegistry::new());
2135        let store = registry.get_or_create("TestCursor");
2136        let (id, item) = cursor("cursor-a", "node-A", "session-PROD");
2137        store.insert(id, item);
2138
2139        let union = build_belongs_to_union_source_map(
2140            registry,
2141            Uuid::new_v4(),
2142            "TestCursor",
2143            &["node_id", "session_id"],
2144            extract_node_and_session_fk,
2145            Vec::new(),
2146        );
2147        assert_eq!(union.snapshot().len(), 0);
2148    }
2149}