Skip to main content

uni_plugin/
registry.rs

1//! The [`PluginRegistry`] — per-surface trait-object tables.
2//!
3//! All registrations land here. Reads are wait-free via `arc-swap`; writes
4//! are CAS-style. Hot reload swaps a per-plugin entry; queries holding an
5//! `Arc::clone()` of the old entry continue against the old version until
6//! their reference is dropped.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use arc_swap::ArcSwap;
12use dashmap::DashMap;
13use parking_lot::{Mutex, RwLock};
14use smol_str::SmolStr;
15
16use crate::capability::CapabilitySet;
17use crate::errors::PluginError;
18use crate::plugin::PluginId;
19use crate::qname::QName;
20use crate::traits::aggregate::{AggSignature, AggregatePluginFn};
21use crate::traits::algorithm::AlgorithmProvider;
22use crate::traits::background::BackgroundJobProvider;
23use crate::traits::catalog::{CatalogProvider, ReplacementScanProvider};
24use crate::traits::cdc::CdcOutputProvider;
25use crate::traits::collation::CollationProvider;
26use crate::traits::connector::{AuthProvider, AuthzPolicy};
27use crate::traits::crdt::{CrdtKind, CrdtKindProvider};
28use crate::traits::hook::SessionHook;
29use crate::traits::index::{IndexHandle, IndexKind, IndexKindProvider};
30use crate::traits::locy::{
31    GenSignature, LocyAggregate, LocyGenerator, LocyPredicate, PredSignature,
32};
33use crate::traits::operator::OptimizerRuleProvider;
34use crate::traits::procedure::{ProcedurePlugin, ProcedureSignature};
35use crate::traits::scalar::{FnSignature, ScalarPluginFn};
36use crate::traits::trigger::TriggerPlugin;
37use crate::traits::types::LogicalTypeProvider;
38use crate::traits::window::{WindowPluginFn, WindowSignature};
39
40/// A single scalar-fn registry entry.
41pub struct ScalarEntry {
42    /// Owning plugin id.
43    pub plugin: PluginId,
44    /// Function signature.
45    pub signature: FnSignature,
46    /// The registered function.
47    pub function: Arc<dyn ScalarPluginFn>,
48}
49
50impl std::fmt::Debug for ScalarEntry {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("ScalarEntry")
53            .field("plugin", &self.plugin)
54            .field("signature", &self.signature)
55            .finish_non_exhaustive()
56    }
57}
58
59/// A single aggregate-fn registry entry.
60pub struct AggregateEntry {
61    /// Owning plugin id.
62    pub plugin: PluginId,
63    /// Aggregate signature.
64    pub signature: AggSignature,
65    /// The registered aggregate.
66    pub aggregate: Arc<dyn AggregatePluginFn>,
67}
68
69impl std::fmt::Debug for AggregateEntry {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("AggregateEntry")
72            .field("plugin", &self.plugin)
73            .field("signature", &self.signature)
74            .finish_non_exhaustive()
75    }
76}
77
78/// A single window-fn registry entry.
79pub struct WindowEntry {
80    /// Owning plugin id.
81    pub plugin: PluginId,
82    /// Window signature.
83    pub signature: WindowSignature,
84    /// The registered window function.
85    pub window: Arc<dyn WindowPluginFn>,
86}
87
88impl std::fmt::Debug for WindowEntry {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("WindowEntry")
91            .field("plugin", &self.plugin)
92            .field("signature", &self.signature)
93            .finish_non_exhaustive()
94    }
95}
96
97/// A single graph-algorithm registry entry.
98///
99/// Carries the owning plugin's effective capability set so the CALL
100/// dispatcher can enforce host-access grants (e.g. `HostQuery`) when
101/// building the algorithm host at invocation time.
102pub struct AlgorithmEntry {
103    /// Owning plugin id.
104    pub plugin: PluginId,
105    /// Effective capabilities granted to the owning plugin.
106    pub effective_caps: CapabilitySet,
107    /// The registered algorithm provider.
108    pub provider: Arc<dyn AlgorithmProvider>,
109}
110
111impl std::fmt::Debug for AlgorithmEntry {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("AlgorithmEntry")
114            .field("plugin", &self.plugin)
115            .field("effective_caps", &self.effective_caps)
116            .finish_non_exhaustive()
117    }
118}
119
120/// A single procedure registry entry.
121pub struct ProcedureEntry {
122    /// Owning plugin id.
123    pub plugin: PluginId,
124    /// Procedure signature.
125    pub signature: ProcedureSignature,
126    /// The registered procedure.
127    pub procedure: Arc<dyn ProcedurePlugin>,
128}
129
130impl std::fmt::Debug for ProcedureEntry {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("ProcedureEntry")
133            .field("plugin", &self.plugin)
134            .field("signature", &self.signature)
135            .finish_non_exhaustive()
136    }
137}
138
139/// A Locy aggregate entry.
140pub struct LocyAggregateEntry {
141    /// Owning plugin id.
142    pub plugin: PluginId,
143    /// The registered aggregate.
144    pub aggregate: Arc<dyn LocyAggregate>,
145}
146
147impl std::fmt::Debug for LocyAggregateEntry {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("LocyAggregateEntry")
150            .field("plugin", &self.plugin)
151            .finish_non_exhaustive()
152    }
153}
154
155/// A Locy predicate entry.
156pub struct LocyPredicateEntry {
157    /// Owning plugin id.
158    pub plugin: PluginId,
159    /// Predicate signature.
160    pub signature: PredSignature,
161    /// The registered predicate.
162    pub predicate: Arc<dyn LocyPredicate>,
163}
164
165impl std::fmt::Debug for LocyPredicateEntry {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("LocyPredicateEntry")
168            .field("plugin", &self.plugin)
169            .field("signature", &self.signature)
170            .finish_non_exhaustive()
171    }
172}
173
174/// A Locy generator-predicate entry.
175pub struct LocyGeneratorEntry {
176    /// Owning plugin id.
177    pub plugin: PluginId,
178    /// Generator signature.
179    pub signature: GenSignature,
180    /// The registered generator.
181    pub generator: Arc<dyn LocyGenerator>,
182}
183
184impl std::fmt::Debug for LocyGeneratorEntry {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct("LocyGeneratorEntry")
187            .field("plugin", &self.plugin)
188            .field("signature", &self.signature)
189            .finish_non_exhaustive()
190    }
191}
192
193/// A live index handle keyed by index *name* (e.g., `"vec_idx_embedding"`).
194///
195/// Unlike `IndexKindProvider`, which is plugin-registered via the
196/// `PluginRegistrar` and describes a *kind* of index, an `IndexHandleEntry`
197/// represents a *specific* live index — the runtime object produced by
198/// `IndexKindProvider::build().finalize()` (or `IndexKindProvider::open()`).
199/// Handles are inserted by the host (not by the plugin's `register()` call)
200/// because their lifetime tracks the storage layer rather than plugin
201/// metadata.
202///
203/// The planner consults this table by index name when dispatching a vector
204/// KNN query (see `plan_vector_knn`). When `Some`, the planner routes the
205/// probe through the plugin handle; when `None`, the native storage path
206/// runs (preserving the "no behavior change for built-ins" invariant).
207#[derive(Clone)]
208pub struct IndexHandleEntry {
209    /// Kind that produced this handle (informational; matches the
210    /// `IndexKindProvider::kind` that built it).
211    pub kind: IndexKind,
212    /// The live handle.
213    pub handle: Arc<dyn IndexHandle>,
214}
215
216impl std::fmt::Debug for IndexHandleEntry {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("IndexHandleEntry")
219            .field("kind", &self.kind)
220            .finish_non_exhaustive()
221    }
222}
223
224/// One slot in the virtual label / edge-type allocation table — bundles
225/// the name the planner saw with the `CatalogTable` that owns its rows.
226///
227/// Used by [`PluginRegistry::register_virtual_label`] / `_edge_type`.
228/// Lookups by ID (via `virtual_label_by_id`) return a cheap clone of
229/// this entry so the planner's physical-scan layer can route directly
230/// to `table.scan(...)` without re-consulting the providers.
231#[derive(Clone)]
232pub struct VirtualEntry {
233    /// The user-typed name (e.g. `"External"`).
234    pub name: SmolStr,
235    /// The catalog table that owns the rows for this virtual identifier.
236    pub table: Arc<dyn crate::traits::catalog::CatalogTable>,
237}
238
239impl std::fmt::Debug for VirtualEntry {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        f.debug_struct("VirtualEntry")
242            .field("name", &self.name)
243            .finish_non_exhaustive()
244    }
245}
246
247/// A virtual identifier type (label `u16` or edge-type `u32`) that the
248/// allocator can hand out. Captures the per-type `START`/`SENTINEL`
249/// bounds and the saturating increment so the allocator body can be
250/// written once, generically.
251trait VirtualId:
252    Copy + Eq + Ord + std::hash::Hash + std::fmt::Debug + std::fmt::LowerHex + 'static
253{
254    /// First ID handed out (inclusive lower bound of the virtual range).
255    const START: Self;
256    /// Reserved upper bound (exclusive); reaching it means the space is
257    /// exhausted.
258    const SENTINEL: Self;
259    /// Human-facing label for the kind of identifier, used in the
260    /// exhaustion error message (e.g. `"label"`, `"edge-type"`).
261    const KIND_LABEL: &'static str;
262
263    /// Increment without overflow (the allocator never relies on the
264    /// wrapped value because it bails at `SENTINEL` first).
265    fn next(self) -> Self;
266}
267
268impl VirtualId for u16 {
269    const START: Self = uni_common::core::schema::VIRTUAL_LABEL_ID_START;
270    const SENTINEL: Self = uni_common::core::schema::VIRTUAL_LABEL_ID_SENTINEL;
271    const KIND_LABEL: &'static str = "label";
272
273    fn next(self) -> Self {
274        self.saturating_add(1)
275    }
276}
277
278impl VirtualId for u32 {
279    const START: Self = uni_common::core::edge_type::VIRTUAL_EDGE_TYPE_ID_START;
280    const SENTINEL: Self = uni_common::core::edge_type::VIRTUAL_EDGE_TYPE_ID_SENTINEL;
281    const KIND_LABEL: &'static str = "edge-type";
282
283    fn next(self) -> Self {
284        self.saturating_add(1)
285    }
286}
287
288/// Inner mutable state for a virtual-ID allocator (labels use `u16`,
289/// edge-types use `u32`). Held behind a `parking_lot::Mutex` because
290/// allocations are rare (one per first reference to a previously-unseen
291/// name) and the contention surface is tiny.
292#[derive(Debug)]
293struct VirtualIdSpace<Id: VirtualId> {
294    name_to_id: HashMap<SmolStr, Id>,
295    id_to_entry: HashMap<Id, VirtualEntry>,
296    next_id: Id,
297}
298
299impl<Id: VirtualId> Default for VirtualIdSpace<Id> {
300    fn default() -> Self {
301        Self {
302            name_to_id: HashMap::new(),
303            id_to_entry: HashMap::new(),
304            next_id: Id::START,
305        }
306    }
307}
308
309impl<Id: VirtualId> VirtualIdSpace<Id> {
310    /// Allocate (or look up) an ID for `name`, replacing the stored
311    /// table on re-registration. Returns `Err` when the virtual range is
312    /// exhausted.
313    fn register(
314        &mut self,
315        name: SmolStr,
316        table: Arc<dyn crate::traits::catalog::CatalogTable>,
317    ) -> Result<Id, PluginError> {
318        if let Some(&id) = self.name_to_id.get(&name) {
319            self.id_to_entry.insert(
320                id,
321                VirtualEntry {
322                    name: name.clone(),
323                    table,
324                },
325            );
326            return Ok(id);
327        }
328        if self.next_id >= Id::SENTINEL {
329            return Err(PluginError::Internal(format!(
330                "virtual {}-ID space exhausted ({} slots taken; sentinel {:#x})",
331                Id::KIND_LABEL,
332                self.id_to_entry.len(),
333                Id::SENTINEL,
334            )));
335        }
336        let id = self.next_id;
337        self.next_id = self.next_id.next();
338        self.name_to_id.insert(name.clone(), id);
339        self.id_to_entry.insert(id, VirtualEntry { name, table });
340        Ok(id)
341    }
342}
343
344/// Per-plugin record of *what* this plugin registered, for unregister /
345/// hot-reload.
346///
347/// `pub(crate)` (with `pub(crate)` fields) so the family-ops traits in
348/// [`crate::surfaces`] can update the record without an accessor for each
349/// surface during the Phase 4 migration.
350#[derive(Default, Debug)]
351pub(crate) struct PluginRecord {
352    pub(crate) scalars: Vec<QName>,
353    pub(crate) aggregates: Vec<QName>,
354    pub(crate) windows: Vec<QName>,
355    /// Procedures are arity-overloaded: a given `QName` may be registered
356    /// multiple times with different arities (see `procedure_with_arity`).
357    /// The `usize` is the procedure's positional argument count, used by
358    /// `remove_plugin` to drop the exact overload this plugin owns.
359    pub(crate) procedures: Vec<(QName, usize)>,
360    pub(crate) locy_aggregates: Vec<QName>,
361    pub(crate) locy_predicates: Vec<QName>,
362    pub(crate) locy_generators: Vec<QName>,
363    pub(crate) algorithms: Vec<QName>,
364    pub(crate) index_kinds: Vec<IndexKind>,
365    pub(crate) label_storages: Vec<SmolStr>,
366    pub(crate) crdt_kinds: Vec<CrdtKind>,
367    /// Logical-type extension names this plugin registered. Tracked
368    /// per-key (not count-only) so `remove_plugin` can drop the entries
369    /// on hot reload.
370    pub(crate) logical_types: Vec<SmolStr>,
371    /// Collation names this plugin registered.
372    pub(crate) collations: Vec<SmolStr>,
373    /// CDC output sink names this plugin registered.
374    pub(crate) cdc_outputs: Vec<SmolStr>,
375    /// Catalog names this plugin registered.
376    pub(crate) catalogs: Vec<SmolStr>,
377    pub(crate) hook_count: usize,
378    pub(crate) auth_count: usize,
379    pub(crate) authz_count: usize,
380    pub(crate) trigger_count: usize,
381    pub(crate) replacement_scan_count: usize,
382    pub(crate) optimizer_rule_count: usize,
383    pub(crate) background_job_count: usize,
384}
385
386impl PluginRecord {
387    /// Merge another record's surfaces into this one: append every owned-key
388    /// vector and sum the count-only tallies.
389    ///
390    /// `apply_pending` must merge, not overwrite, when a plugin id commits more
391    /// than once (e.g. two `declareFunction` calls that each run their own
392    /// registrar under the same namespace id). Overwriting the record drops the
393    /// earlier commit's surfaces from the ownership map, so `remove_plugin` later
394    /// leaks them (they stay live in the registry slots but are untracked).
395    fn merge(&mut self, other: PluginRecord) {
396        self.scalars.extend(other.scalars);
397        self.aggregates.extend(other.aggregates);
398        self.windows.extend(other.windows);
399        self.procedures.extend(other.procedures);
400        self.locy_aggregates.extend(other.locy_aggregates);
401        self.locy_predicates.extend(other.locy_predicates);
402        self.locy_generators.extend(other.locy_generators);
403        self.algorithms.extend(other.algorithms);
404        self.index_kinds.extend(other.index_kinds);
405        self.label_storages.extend(other.label_storages);
406        self.crdt_kinds.extend(other.crdt_kinds);
407        self.logical_types.extend(other.logical_types);
408        self.collations.extend(other.collations);
409        self.cdc_outputs.extend(other.cdc_outputs);
410        self.catalogs.extend(other.catalogs);
411        self.hook_count += other.hook_count;
412        self.auth_count += other.auth_count;
413        self.authz_count += other.authz_count;
414        self.trigger_count += other.trigger_count;
415        self.replacement_scan_count += other.replacement_scan_count;
416        self.optimizer_rule_count += other.optimizer_rule_count;
417        self.background_job_count += other.background_job_count;
418    }
419}
420
421/// A deep-clone snapshot of one plugin's registry footprint.
422///
423/// Produced by [`PluginRegistry::iter_for_plugin`] and consumed by
424/// [`crate::reload::ReloadDispatcher`]. The snapshot is **not** kept
425/// in sync with the live registry; it represents the surfaces a
426/// plugin owned at the moment the snapshot was taken.
427#[derive(Clone, Debug, Default)]
428pub struct PluginRecordSnapshot {
429    /// Scalar fns this plugin registered.
430    pub scalars: Vec<QName>,
431    /// Aggregate fns this plugin registered.
432    pub aggregates: Vec<QName>,
433    /// Window fns this plugin registered.
434    pub windows: Vec<QName>,
435    /// Procedures (qname + arity) this plugin registered.
436    pub procedures: Vec<(QName, usize)>,
437    /// Locy aggregates this plugin registered.
438    pub locy_aggregates: Vec<QName>,
439    /// Locy predicates this plugin registered.
440    pub locy_predicates: Vec<QName>,
441    /// Locy generator predicates this plugin registered.
442    pub locy_generators: Vec<QName>,
443    /// Algorithms this plugin registered.
444    pub algorithms: Vec<QName>,
445    /// Index kinds this plugin registered.
446    pub index_kinds: Vec<IndexKind>,
447    /// Label storages this plugin registered.
448    pub label_storages: Vec<SmolStr>,
449    /// CRDT kinds this plugin registered.
450    pub crdt_kinds: Vec<CrdtKind>,
451    /// Logical-type extension names this plugin registered.
452    pub logical_types: Vec<SmolStr>,
453    /// Collation names this plugin registered.
454    pub collations: Vec<SmolStr>,
455    /// CDC output sink names this plugin registered.
456    pub cdc_outputs: Vec<SmolStr>,
457    /// Catalog names this plugin registered.
458    pub catalogs: Vec<SmolStr>,
459    /// Number of `SessionHook`s this plugin registered.
460    pub hook_count: usize,
461    /// Number of `AuthProvider`s this plugin registered.
462    pub auth_count: usize,
463    /// Number of `AuthzPolicy`s this plugin registered.
464    pub authz_count: usize,
465    /// Number of `TriggerPlugin`s this plugin registered.
466    pub trigger_count: usize,
467    /// Number of `ReplacementScanProvider`s this plugin registered.
468    pub replacement_scan_count: usize,
469    /// Number of `OptimizerRuleProvider`s this plugin registered.
470    pub optimizer_rule_count: usize,
471    /// Number of `BackgroundJobProvider`s this plugin registered.
472    pub background_job_count: usize,
473}
474
475impl From<&PluginRecord> for PluginRecordSnapshot {
476    /// Deep-clone a live `PluginRecord` into a standalone snapshot. The
477    /// field list lives only on the two struct definitions; this clones
478    /// each (`Vec`s deep-clone their elements, counts are `Copy`).
479    fn from(r: &PluginRecord) -> Self {
480        Self {
481            scalars: r.scalars.clone(),
482            aggregates: r.aggregates.clone(),
483            windows: r.windows.clone(),
484            procedures: r.procedures.clone(),
485            locy_aggregates: r.locy_aggregates.clone(),
486            locy_predicates: r.locy_predicates.clone(),
487            locy_generators: r.locy_generators.clone(),
488            algorithms: r.algorithms.clone(),
489            index_kinds: r.index_kinds.clone(),
490            label_storages: r.label_storages.clone(),
491            crdt_kinds: r.crdt_kinds.clone(),
492            logical_types: r.logical_types.clone(),
493            collations: r.collations.clone(),
494            cdc_outputs: r.cdc_outputs.clone(),
495            catalogs: r.catalogs.clone(),
496            hook_count: r.hook_count,
497            auth_count: r.auth_count,
498            authz_count: r.authz_count,
499            trigger_count: r.trigger_count,
500            replacement_scan_count: r.replacement_scan_count,
501            optimizer_rule_count: r.optimizer_rule_count,
502            background_job_count: r.background_job_count,
503        }
504    }
505}
506
507/// All-surfaces plugin registry.
508///
509/// Per-surface tables wrapped in `arc-swap` for wait-free reads. The
510/// registry tracks per-plugin ownership so `remove_plugin` can clean up
511/// all of a plugin's registrations in one pass.
512#[derive(Default)]
513pub struct PluginRegistry {
514    pub(crate) scalars: DashMap<QName, Arc<ScalarEntry>>,
515    pub(crate) aggregates: DashMap<QName, Arc<AggregateEntry>>,
516    pub(crate) windows: DashMap<QName, Arc<WindowEntry>>,
517    /// Procedures keyed by qname. Each qname may carry multiple overload
518    /// entries discriminated by `entry.signature.args.len()` so callers can
519    /// register two registrations under the same name with different
520    /// arities (M5c.2: legacy 5-arg + new 2-arg algorithm signatures).
521    /// `procedure(&q)` returns the first registration; arity-aware callers
522    /// use `procedure_with_arity(&q, arity)`.
523    pub(crate) procedures: DashMap<QName, Vec<Arc<ProcedureEntry>>>,
524    pub(crate) locy_aggregates: DashMap<QName, Arc<LocyAggregateEntry>>,
525    pub(crate) locy_predicates: DashMap<QName, Arc<LocyPredicateEntry>>,
526    pub(crate) locy_generators: DashMap<QName, Arc<LocyGeneratorEntry>>,
527    pub(crate) optimizer_rules:
528        ArcSwap<Vec<crate::surfaces::AppendEntry<dyn OptimizerRuleProvider>>>,
529    pub(crate) algorithms: DashMap<QName, Arc<AlgorithmEntry>>,
530    pub(crate) index_kinds: DashMap<IndexKind, Arc<dyn IndexKindProvider>>,
531    index_handles: DashMap<SmolStr, IndexHandleEntry>,
532    /// Per-label plugin storage (M5h.2). Keyed by *label name* and
533    /// resolves to an already-open `Storage`. The host's
534    /// `StorageManager::scan_vertex_table` consults this map before
535    /// falling through to the native backend so a third-party plugin
536    /// can serve a native-schema label from its own storage.
537    pub(crate) label_storages: DashMap<SmolStr, Arc<dyn crate::traits::storage::Storage>>,
538    pub(crate) crdt_kinds: DashMap<CrdtKind, Arc<dyn CrdtKindProvider>>,
539    pub(crate) hooks: ArcSwap<Vec<crate::surfaces::AppendEntry<dyn SessionHook>>>,
540    pub(crate) logical_types: DashMap<SmolStr, Arc<dyn LogicalTypeProvider>>,
541    pub(crate) auth_providers: ArcSwap<Vec<crate::surfaces::AppendEntry<dyn AuthProvider>>>,
542    pub(crate) authz_policies: ArcSwap<Vec<crate::surfaces::AppendEntry<dyn AuthzPolicy>>>,
543    pub(crate) triggers: ArcSwap<Vec<crate::surfaces::AppendEntry<dyn TriggerPlugin>>>,
544    pub(crate) collations: DashMap<SmolStr, Arc<dyn CollationProvider>>,
545    pub(crate) cdc_outputs: DashMap<SmolStr, Arc<dyn CdcOutputProvider>>,
546    pub(crate) catalogs: DashMap<SmolStr, Arc<dyn CatalogProvider>>,
547    pub(crate) replacement_scans:
548        ArcSwap<Vec<crate::surfaces::AppendEntry<dyn ReplacementScanProvider>>>,
549    pub(crate) background_jobs:
550        ArcSwap<Vec<crate::surfaces::AppendEntry<dyn BackgroundJobProvider>>>,
551    /// Virtual label-ID allocator. Allocates IDs in the schema's reserved
552    /// virtual range (`uni_common::core::schema::VIRTUAL_LABEL_ID_START..
553    /// VIRTUAL_LABEL_ID_SENTINEL`) on first observation of an unknown label
554    /// name that a `CatalogProvider` or `ReplacementScanProvider` claims.
555    /// See [`Self::register_virtual_label`] / [`Self::virtual_label_by_id`].
556    virtual_labels: Mutex<VirtualIdSpace<u16>>,
557    /// Virtual edge-type allocator. Allocates IDs in
558    /// `uni_common::core::edge_type::VIRTUAL_EDGE_TYPE_ID_START..
559    /// VIRTUAL_EDGE_TYPE_ID_SENTINEL`. Same first-observation semantics.
560    virtual_edge_types: Mutex<VirtualIdSpace<u32>>,
561    per_plugin: RwLock<dashmap::DashMap<PluginId, PluginRecord>>,
562}
563
564impl std::fmt::Debug for PluginRegistry {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        f.debug_struct("PluginRegistry")
567            .field("scalar_fns", &self.scalars.len())
568            .field("aggregates", &self.aggregates.len())
569            .field("procedures", &self.procedures.len())
570            .field("locy_aggregates", &self.locy_aggregates.len())
571            .field("algorithms", &self.algorithms.len())
572            .field("index_kinds", &self.index_kinds.len())
573            .field("hooks", &self.hooks.load().len())
574            .field("plugins", &self.per_plugin.read().len())
575            .finish()
576    }
577}
578
579impl PluginRegistry {
580    /// Construct an empty registry.
581    #[must_use]
582    pub fn new() -> Self {
583        Self::default()
584    }
585
586    /// Look up a registered scalar function by qname.
587    #[must_use]
588    pub fn scalar_fn(&self, q: &QName) -> Option<Arc<ScalarEntry>> {
589        self.scalars.get(q).map(|e| Arc::clone(e.value()))
590    }
591
592    /// Iterate every registered scalar function — `(QName, ScalarEntry)`.
593    ///
594    /// Collects into a `Vec` so the iteration does not hold a long-lived
595    /// reference to the underlying `DashMap` (avoids subtle aliasing
596    /// hazards when callers register or remove plugins mid-iteration).
597    ///
598    /// # Examples
599    ///
600    /// ```ignore
601    /// for (qname, entry) in registry.iter_scalars() {
602    ///     ctx.register_udf(ScalarUDF::new_from_impl(adapt(qname, entry)));
603    /// }
604    /// ```
605    #[must_use]
606    pub fn iter_scalars(&self) -> Vec<(QName, Arc<ScalarEntry>)> {
607        self.scalars
608            .iter()
609            .map(|kv| (kv.key().clone(), Arc::clone(kv.value())))
610            .collect()
611    }
612
613    /// Iterate every registered Locy predicate — `(QName, LocyPredicateEntry)`.
614    #[must_use]
615    pub fn iter_locy_predicates(&self) -> Vec<(QName, Arc<LocyPredicateEntry>)> {
616        self.locy_predicates
617            .iter()
618            .map(|kv| (kv.key().clone(), Arc::clone(kv.value())))
619            .collect()
620    }
621
622    /// Iterate every registered algorithm — `(QName, AlgorithmProvider)`.
623    #[must_use]
624    pub fn iter_algorithms(&self) -> Vec<(QName, Arc<dyn AlgorithmProvider>)> {
625        self.algorithms
626            .iter()
627            .map(|kv| (kv.key().clone(), Arc::clone(&kv.value().provider)))
628            .collect()
629    }
630
631    /// Iterate every registered index kind — `(IndexKind, IndexKindProvider)`.
632    #[must_use]
633    pub fn iter_index_kinds(&self) -> Vec<(IndexKind, Arc<dyn IndexKindProvider>)> {
634        self.index_kinds
635            .iter()
636            .map(|kv| (kv.key().clone(), Arc::clone(kv.value())))
637            .collect()
638    }
639
640    /// Snapshot the registered catalog providers.
641    ///
642    /// Returns a `Vec` so the iteration does not hold a long-lived reference
643    /// to the underlying `DashMap`.
644    #[must_use]
645    pub fn catalogs(&self) -> Vec<Arc<dyn CatalogProvider>> {
646        self.catalogs
647            .iter()
648            .map(|kv| Arc::clone(kv.value()))
649            .collect()
650    }
651
652    /// Look up a registered aggregate by qname.
653    #[must_use]
654    pub fn aggregate(&self, q: &QName) -> Option<Arc<AggregateEntry>> {
655        self.aggregates.get(q).map(|e| Arc::clone(e.value()))
656    }
657
658    /// Look up a registered window function by qname.
659    #[must_use]
660    pub fn window(&self, q: &QName) -> Option<Arc<WindowEntry>> {
661        self.windows.get(q).map(|e| Arc::clone(e.value()))
662    }
663
664    /// Look up a registered procedure by qname.
665    ///
666    /// If the qname carries multiple arity overloads (M5c.2), this returns
667    /// the *first* registered entry, which preserves the legacy
668    /// single-arity lookup contract. Arity-aware callers should use
669    /// [`Self::procedure_with_arity`] instead.
670    #[must_use]
671    pub fn procedure(&self, q: &QName) -> Option<Arc<ProcedureEntry>> {
672        self.procedures
673            .get(q)
674            .and_then(|e| e.value().first().map(Arc::clone))
675    }
676
677    /// Look up a registered procedure by qname *and* positional argument
678    /// count. Returns the entry whose signature has exactly `arity`
679    /// arguments, or `None` if no overload matches.
680    ///
681    /// Procedures may be registered with the same qname under multiple
682    /// arities (e.g. an algorithm's legacy 5-arg form alongside the new
683    /// `(graphRef, config)` 2-arg form). Resolution sites that know the
684    /// call's argument count should prefer this method; the bare
685    /// [`Self::procedure`] is preserved for callers that only need the
686    /// first registration.
687    #[must_use]
688    pub fn procedure_with_arity(&self, q: &QName, arity: usize) -> Option<Arc<ProcedureEntry>> {
689        self.procedures.get(q).and_then(|e| {
690            e.value()
691                .iter()
692                .find(|entry| entry.signature.args.len() == arity)
693                .map(Arc::clone)
694        })
695    }
696
697    /// Return all arity overloads registered under `q`.
698    ///
699    /// The returned `Vec` is empty when nothing is registered. Useful for
700    /// diagnostic surfaces (e.g. `EXPLAIN` of an ambiguous call) and for
701    /// listing API.
702    #[must_use]
703    pub fn procedure_overloads(&self, q: &QName) -> Vec<Arc<ProcedureEntry>> {
704        self.procedures
705            .get(q)
706            .map(|e| e.value().iter().map(Arc::clone).collect())
707            .unwrap_or_default()
708    }
709
710    /// Look up a registered Locy aggregate by qname.
711    #[must_use]
712    pub fn locy_aggregate(&self, q: &QName) -> Option<Arc<LocyAggregateEntry>> {
713        self.locy_aggregates.get(q).map(|e| Arc::clone(e.value()))
714    }
715
716    /// Look up a registered Locy predicate by qname.
717    #[must_use]
718    pub fn locy_predicate(&self, q: &QName) -> Option<Arc<LocyPredicateEntry>> {
719        self.locy_predicates.get(q).map(|e| Arc::clone(e.value()))
720    }
721
722    /// Look up a registered Locy generator by qname.
723    #[must_use]
724    pub fn locy_generator(&self, q: &QName) -> Option<Arc<LocyGeneratorEntry>> {
725        self.locy_generators.get(q).map(|e| Arc::clone(e.value()))
726    }
727
728    /// Look up the plugin `Storage` (if any) registered to serve the
729    /// given native label name (M5h.2). Consulted by the host's
730    /// `StorageManager::scan_vertex_table` before the native backend
731    /// fallback — when this returns `Some`, the planner's graph-scan
732    /// path is routed through plugin storage instead of Lance.
733    #[must_use]
734    pub fn lookup_label_storage(
735        &self,
736        label: &str,
737    ) -> Option<Arc<dyn crate::traits::storage::Storage>> {
738        self.label_storages
739            .get(&SmolStr::new(label))
740            .map(|e| Arc::clone(e.value()))
741    }
742
743    /// Look up a registered index-kind by kind.
744    #[must_use]
745    pub fn index_kind(&self, k: &IndexKind) -> Option<Arc<dyn IndexKindProvider>> {
746        self.index_kinds.get(k).map(|e| Arc::clone(e.value()))
747    }
748
749    /// Register a live `IndexHandle` under an index name.
750    ///
751    /// The host calls this after building a handle from a custom
752    /// `IndexKindProvider` (or after `open()` from persisted bytes). The
753    /// planner consults this table from `plan_vector_knn` to route probes
754    /// through the plugin handle instead of the native storage path.
755    ///
756    /// If an entry already exists under the same name, it is replaced.
757    pub fn register_index_handle(
758        &self,
759        name: impl Into<SmolStr>,
760        kind: IndexKind,
761        handle: Arc<dyn IndexHandle>,
762    ) {
763        self.index_handles
764            .insert(name.into(), IndexHandleEntry { kind, handle });
765    }
766
767    /// Look up a live `IndexHandle` by index name. Returns a cheap clone
768    /// (the inner handle is `Arc`-wrapped).
769    #[must_use]
770    pub fn index_handle(&self, name: &str) -> Option<IndexHandleEntry> {
771        self.index_handles
772            .get(&SmolStr::new(name))
773            .map(|e| e.value().clone())
774    }
775
776    /// Remove a live `IndexHandle`. Returns the removed entry if one
777    /// existed.
778    pub fn deregister_index_handle(&self, name: &str) -> Option<IndexHandleEntry> {
779        self.index_handles
780            .remove(&SmolStr::new(name))
781            .map(|(_, v)| v)
782    }
783
784    /// Allocate (or look up) a virtual label ID for `name`, owned by
785    /// `table`. The host's `QueryPlanner` calls this when an unknown
786    /// label name is claimed by a `CatalogProvider` or
787    /// `ReplacementScanProvider`; subsequent references to the same name
788    /// return the cached ID without re-running discovery.
789    ///
790    /// Idempotent: a second call with the same name returns the
791    /// previously-allocated ID and *replaces* the stored `CatalogTable`
792    /// (so cached `LogicalPlan`s naturally pick up the latest table on
793    /// next execute). Returns `Err` if the virtual range is exhausted
794    /// (255 slots, see `uni_common::core::schema`).
795    pub fn register_virtual_label(
796        &self,
797        name: impl Into<SmolStr>,
798        table: Arc<dyn crate::traits::catalog::CatalogTable>,
799    ) -> Result<u16, PluginError> {
800        self.virtual_labels.lock().register(name.into(), table)
801    }
802
803    /// Look up a virtual label by name. Returns `None` if no provider
804    /// has claimed it yet (the caller hasn't called
805    /// `register_virtual_label`).
806    #[must_use]
807    pub fn virtual_label_by_name(&self, name: &str) -> Option<u16> {
808        let inner = self.virtual_labels.lock();
809        inner.name_to_id.get(&SmolStr::new(name)).copied()
810    }
811
812    /// Look up the catalog table behind a virtual label ID. Returns the
813    /// entry cheaply cloned (inner `Arc<dyn CatalogTable>`).
814    #[must_use]
815    pub fn virtual_label_by_id(&self, id: u16) -> Option<VirtualEntry> {
816        self.virtual_labels.lock().id_to_entry.get(&id).cloned()
817    }
818
819    /// Allocate (or look up) a virtual edge-type ID for `name`. Same
820    /// semantics as [`Self::register_virtual_label`] but for the
821    /// `u32` edge-type ID space.
822    pub fn register_virtual_edge_type(
823        &self,
824        name: impl Into<SmolStr>,
825        table: Arc<dyn crate::traits::catalog::CatalogTable>,
826    ) -> Result<u32, PluginError> {
827        self.virtual_edge_types.lock().register(name.into(), table)
828    }
829
830    /// Look up the catalog table behind a virtual edge-type ID.
831    #[must_use]
832    pub fn virtual_edge_type_by_id(&self, id: u32) -> Option<VirtualEntry> {
833        self.virtual_edge_types.lock().id_to_entry.get(&id).cloned()
834    }
835
836    /// Look up a registered algorithm provider by qname.
837    #[must_use]
838    pub fn algorithm(&self, q: &QName) -> Option<Arc<dyn AlgorithmProvider>> {
839        self.algorithms
840            .get(q)
841            .map(|e| Arc::clone(&e.value().provider))
842    }
843
844    /// Look up a registered algorithm's full entry by qname.
845    ///
846    /// Unlike [`Self::algorithm`], the returned [`AlgorithmEntry`] also
847    /// carries the owning plugin's effective capabilities, which the CALL
848    /// dispatcher needs to gate host graph access.
849    #[must_use]
850    pub fn algorithm_entry(&self, q: &QName) -> Option<Arc<AlgorithmEntry>> {
851        self.algorithms.get(q).map(|e| Arc::clone(e.value()))
852    }
853
854    /// Look up a registered CRDT kind.
855    #[must_use]
856    pub fn crdt_kind(&self, k: &CrdtKind) -> Option<Arc<dyn CrdtKindProvider>> {
857        self.crdt_kinds.get(k).map(|e| Arc::clone(e.value()))
858    }
859
860    /// Look up a registered logical type by its Arrow extension name.
861    #[must_use]
862    pub fn logical_type(&self, name: &SmolStr) -> Option<Arc<dyn LogicalTypeProvider>> {
863        self.logical_types.get(name).map(|e| Arc::clone(e.value()))
864    }
865
866    /// Snapshot the registered hook chain.
867    #[must_use]
868    pub fn hooks(&self) -> Arc<Vec<Arc<dyn SessionHook>>> {
869        Self::project_append(&self.hooks)
870    }
871
872    /// Snapshot the registered optimizer-rule providers (M5h).
873    #[must_use]
874    pub fn optimizer_rules(&self) -> Arc<Vec<Arc<dyn OptimizerRuleProvider>>> {
875        Self::project_append(&self.optimizer_rules)
876    }
877
878    /// Snapshot the registered trigger chain.
879    #[must_use]
880    pub fn triggers(&self) -> Arc<Vec<Arc<dyn TriggerPlugin>>> {
881        Self::project_append(&self.triggers)
882    }
883
884    /// Snapshot every registered [`CdcOutputProvider`] keyed by name (FU-4).
885    ///
886    /// Used by `Uni::build` to start a CDC stream per provider before
887    /// the commit broadcaster begins pushing `CdcBatch`es.
888    #[must_use]
889    pub fn cdc_outputs_snapshot(&self) -> Vec<(SmolStr, Arc<dyn CdcOutputProvider>)> {
890        self.cdc_outputs
891            .iter()
892            .map(|e| (e.key().clone(), Arc::clone(e.value())))
893            .collect()
894    }
895
896    /// `true` when no [`CdcOutputProvider`] is registered.
897    ///
898    /// Used by the commit hot-path to skip mutation-row materialization
899    /// when there are no CDC subscribers — preserves the empty-registry
900    /// fast path.
901    #[must_use]
902    pub fn cdc_outputs_is_empty(&self) -> bool {
903        self.cdc_outputs.is_empty()
904    }
905
906    /// Snapshot the registered authentication providers (M5i).
907    #[must_use]
908    pub fn auth_providers(&self) -> Arc<Vec<Arc<dyn AuthProvider>>> {
909        Self::project_append(&self.auth_providers)
910    }
911
912    /// Snapshot the registered authorization policies (M5i).
913    #[must_use]
914    pub fn authz_policies(&self) -> Arc<Vec<Arc<dyn AuthzPolicy>>> {
915        Self::project_append(&self.authz_policies)
916    }
917
918    /// Snapshot the registered replacement-scan providers.
919    #[must_use]
920    pub fn replacement_scans(&self) -> Arc<Vec<Arc<dyn ReplacementScanProvider>>> {
921        Self::project_append(&self.replacement_scans)
922    }
923
924    /// Apply a batch of pending registrations atomically.
925    ///
926    /// Preflights every entry against the live registry first, then
927    /// applies them in order. Dispatch is per-family (see
928    /// [`crate::surfaces`]): static-typed `*Ops` impls handle storage and
929    /// per-plugin record-keeping; the `DynPendingRegistration` boxes
930    /// erase the family type so a heterogeneous batch can be queued.
931    ///
932    /// # Errors
933    ///
934    /// Returns the first preflight failure (e.g.
935    /// [`PluginError::DuplicateRegistration`] or
936    /// [`PluginError::StorageSchemeConflict`]); nothing in the batch is
937    /// applied in that case.
938    pub(crate) fn apply_pending(
939        &self,
940        plugin_id: &PluginId,
941        pending: Vec<Box<dyn crate::surfaces::DynPendingRegistration>>,
942    ) -> Result<(), PluginError> {
943        // Preflight against the live registry, and — because that only sees the
944        // live registry, not the rest of this batch — also reject duplicate
945        // unique keys WITHIN the batch (two entries for the same qname in one
946        // register() call would otherwise both pass and silently last-write-win).
947        let mut seen: std::collections::HashSet<QName> = std::collections::HashSet::new();
948        for reg in &pending {
949            reg.preflight(self)?;
950            if let Some(qname) = reg.dedup_key()
951                && !seen.insert(qname.clone())
952            {
953                return Err(PluginError::DuplicateRegistration(qname));
954            }
955        }
956
957        let mut record = PluginRecord::default();
958        for reg in pending {
959            reg.apply(self, plugin_id.clone(), &mut record);
960        }
961
962        // Merge (do NOT overwrite) so a second commit under the same plugin id
963        // keeps the surfaces the earlier commit registered.
964        self.per_plugin
965            .read()
966            .entry(plugin_id.clone())
967            .or_default()
968            .merge(record);
969
970        Ok(())
971    }
972
973    /// Snapshot the registered background jobs.
974    #[must_use]
975    pub fn background_jobs(&self) -> Arc<Vec<Arc<dyn BackgroundJobProvider>>> {
976        Self::project_append(&self.background_jobs)
977    }
978
979    /// Materialize an `Arc<Vec<Arc<dyn P>>>` view of an append-family slot,
980    /// stripping the per-entry `AppendEntry` ownership tag.
981    ///
982    /// The legacy public read-accessor signature returns `Arc<Vec<Arc<dyn
983    /// P>>>` for wait-free callers (`hooks()`, `triggers()`, …). The
984    /// owner-tagged storage required for proper `remove_plugin`
985    /// implementation (closes the M5e gap; see [`crate::surfaces`]
986    /// foundation work) carries the plugin id inline, so projecting back to
987    /// the legacy shape costs one allocation + N `Arc` clones per call.
988    /// Phase 4f will retire this helper in favour of returning the typed
989    /// `AppendEntry` slice directly.
990    fn project_append<P: ?Sized>(
991        slot: &ArcSwap<Vec<crate::surfaces::AppendEntry<P>>>,
992    ) -> Arc<Vec<Arc<P>>> {
993        let snap = slot.load();
994        let v: Vec<Arc<P>> = snap.iter().map(|e| Arc::clone(&e.provider)).collect();
995        Arc::new(v)
996    }
997
998    /// Snapshot the surfaces a plugin currently owns.
999    ///
1000    /// Returns `None` when the plugin has never registered anything (or
1001    /// has already been removed). Used by
1002    /// [`crate::reload::ReloadDispatcher`] to determine which per-kind
1003    /// reload protocols to invoke for the old plugin.
1004    ///
1005    /// The snapshot is a deep clone of the registry's internal
1006    /// `PluginRecord`; mutating the registry afterward does not affect
1007    /// the snapshot.
1008    #[must_use]
1009    pub fn iter_for_plugin(&self, plugin: &PluginId) -> Option<PluginRecordSnapshot> {
1010        let guard = self.per_plugin.read();
1011        guard.get(plugin).map(|r| PluginRecordSnapshot::from(&*r))
1012    }
1013
1014    /// Remove a single named-unique surface (scalar or aggregate) that `plugin`
1015    /// registered under `qname`, leaving the plugin's other surfaces intact.
1016    ///
1017    /// [`Self::remove_plugin`] drops an entire plugin id at once; declared-function
1018    /// stores pack many functions under one namespace id (e.g. `mycorp.f1`,
1019    /// `mycorp.f2` both under `mycorp`), so dropping one must not unregister its
1020    /// siblings. It is also used to drop the prior entry when a declared qname is
1021    /// re-declared, so re-registration is not mistaken for shadowing a native fn.
1022    ///
1023    /// Returns whether anything was removed.
1024    pub fn remove_named_unique(&self, plugin: &PluginId, qname: &QName) -> bool {
1025        use crate::surfaces::{AggregateSurface, NamedUniqueOps, ScalarSurface};
1026        let mut removed = false;
1027        if let Some(mut rec) = self.per_plugin.read().get_mut(plugin) {
1028            if let Some(pos) = rec.scalars.iter().position(|q| q == qname) {
1029                rec.scalars.remove(pos);
1030                <ScalarSurface as NamedUniqueOps>::remove(self, qname);
1031                removed = true;
1032            }
1033            if let Some(pos) = rec.aggregates.iter().position(|q| q == qname) {
1034                rec.aggregates.remove(pos);
1035                <AggregateSurface as NamedUniqueOps>::remove(self, qname);
1036                removed = true;
1037            }
1038        }
1039        removed
1040    }
1041
1042    /// Remove all registrations for the given plugin.
1043    ///
1044    /// Used by `Uni::remove_plugin` and as part of hot reload's drain step.
1045    /// Dispatches per family via the `*Ops` traits in [`crate::surfaces`];
1046    /// the label-storage / logical-type / collation / cdc / catalog
1047    /// surfaces are dropped here too (the per-key tracking lifts the old
1048    /// count-only gap where hot reload leaked entries on those slots).
1049    pub fn remove_plugin(&self, plugin: &PluginId) {
1050        use crate::surfaces::{
1051            AggregateSurface, AlgorithmSurface, AppendOps, AuthSurface, AuthzSurface,
1052            BackgroundJobSurface, CatalogSurface, CdcSurface, CollationSurface, CrdtSurface,
1053            Discriminator, HookSurface, IndexKindSurface, KeyedUniqueOps, LabelStorageSurface,
1054            LocyAggregateSurface, LocyGeneratorSurface, LocyPredicateSurface, LogicalTypeSurface,
1055            NamedUniqueOps, OptimizerRuleSurface, ProcedureSurface, ReplacementScanSurface,
1056            ScalarSurface, TriggerSurface, VersionedOps, WindowSurface,
1057        };
1058
1059        let record = self.per_plugin.read().remove(plugin).map(|(_, r)| r);
1060        let Some(record) = record else { return };
1061
1062        for q in record.scalars {
1063            <ScalarSurface as NamedUniqueOps>::remove(self, &q);
1064        }
1065        for q in record.aggregates {
1066            <AggregateSurface as NamedUniqueOps>::remove(self, &q);
1067        }
1068        for q in record.windows {
1069            <WindowSurface as NamedUniqueOps>::remove(self, &q);
1070        }
1071        for (q, arity) in record.procedures {
1072            <ProcedureSurface as VersionedOps>::remove(self, &q, Discriminator::Arity(arity));
1073        }
1074        for q in record.locy_aggregates {
1075            <LocyAggregateSurface as NamedUniqueOps>::remove(self, &q);
1076        }
1077        for q in record.locy_predicates {
1078            <LocyPredicateSurface as NamedUniqueOps>::remove(self, &q);
1079        }
1080        for q in record.locy_generators {
1081            <LocyGeneratorSurface as NamedUniqueOps>::remove(self, &q);
1082        }
1083        for q in record.algorithms {
1084            <AlgorithmSurface as NamedUniqueOps>::remove(self, &q);
1085        }
1086        for k in record.index_kinds {
1087            <IndexKindSurface as KeyedUniqueOps>::remove(self, &k);
1088        }
1089        for l in record.label_storages {
1090            <LabelStorageSurface as KeyedUniqueOps>::remove(self, &l);
1091        }
1092        for k in record.crdt_kinds {
1093            <CrdtSurface as KeyedUniqueOps>::remove(self, &k);
1094        }
1095        for k in record.logical_types {
1096            <LogicalTypeSurface as KeyedUniqueOps>::remove(self, &k);
1097        }
1098        for k in record.collations {
1099            <CollationSurface as KeyedUniqueOps>::remove(self, &k);
1100        }
1101        for k in record.cdc_outputs {
1102            <CdcSurface as KeyedUniqueOps>::remove(self, &k);
1103        }
1104        for k in record.catalogs {
1105            <CatalogSurface as KeyedUniqueOps>::remove(self, &k);
1106        }
1107
1108        <OptimizerRuleSurface as AppendOps>::remove_plugin(self, plugin);
1109        <HookSurface as AppendOps>::remove_plugin(self, plugin);
1110        <AuthSurface as AppendOps>::remove_plugin(self, plugin);
1111        <AuthzSurface as AppendOps>::remove_plugin(self, plugin);
1112        <TriggerSurface as AppendOps>::remove_plugin(self, plugin);
1113        <ReplacementScanSurface as AppendOps>::remove_plugin(self, plugin);
1114        <BackgroundJobSurface as AppendOps>::remove_plugin(self, plugin);
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    #[test]
1123    fn registry_default_is_empty() {
1124        let r = PluginRegistry::new();
1125        assert!(r.scalar_fn(&QName::builtin("anything")).is_none());
1126        assert!(r.procedure(&QName::builtin("anything")).is_none());
1127        assert_eq!(r.hooks().len(), 0);
1128    }
1129
1130    #[test]
1131    fn debug_smoke() {
1132        let r = PluginRegistry::new();
1133        let s = format!("{r:?}");
1134        assert!(s.contains("PluginRegistry"));
1135    }
1136}