yeti_types/plugins/mod.rs
1//! Plugin traits and registration context.
2//!
3//! Defines `Plugin`, `RegistrationContext`, `VectorHook`, `AiHook`,
4//! `ComputedFieldHook`, `FieldMapping`, `ModelInfo`, `VectorBatcher`,
5//! `EmbeddedFile`. (The former `ServiceContext` was unified into
6//! `yeti_types::resource::Context` .)
7//!
8//! # Errors
9//!
10//! Result-returning methods on the traits in this file (`VectorHook`,
11//! `AiHook`, `Plugin`, `ComputedFieldHook`) carry implementation-defined
12//! error semantics — the trait declares the *shape* (`Result<T, String>`
13//! for hot-path hook traits, `Result<T, YetiError>` for `Plugin`
14//! lifecycle), but the actual conditions that produce `Err` belong to
15//! each implementor.
16//!
17//! - `VectorHook` / `AiHook` impls in yeti-ai surface model-load,
18//! tokenizer, and inference failures as `String`. Hook implementors
19//! should document their own error contracts.
20//! - `Plugin::resources` / `on_ready` / `install_event_subscriber`
21//! propagate `YetiError` from plugin setup (table registration,
22//! route conflicts, bootstrap failures).
23//!
24//! Per-method `# Errors` sections at the trait level would just say
25//! "implementation-defined", so the lint is suppressed at the module
26//! level. Each impl crate documents its own error conditions.
27#![allow(clippy::missing_errors_doc)]
28
29use async_trait::async_trait;
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::Arc;
33
34// ============================================================================
35// Plugin extension contracts (relocated from yeti_types::extension)
36// ============================================================================
37//
38// One trait — `tower::Service<R>` — for every plugin surface. Composition
39// via `tower::Layer<S>` / `ServiceBuilder`. See ADR-006.
40//
41// | Module | Domain | Request | Response |
42// |-------------|---------------------------------|-------------------------|------------------------|
43// | `request` | HTTP-shaped per-request work | `YetiRequest` | `YetiResponse` |
44// | `lifecycle` | Fire-and-forget lifecycle taps | `LifecycleEvent` | `()` |
45// | `auth` | Identity → access resolution | `ResolveRequest` | `ResolveResponse` |
46// | `token` | JWT mint extension | `TokenRequest` | `TokenResponse` |
47// | `oauth` | OAuth callback claim mutation | `OAuthRequest` | `OAuthResponse` |
48// | `mcp` | MCP tool list / call hooks | `ListToolsRequest` | `ListToolsResponse` |
49// | `queue` | Durable / async job execution | `JobRequest` | `JobResponse` |
50
51pub use tower::util::{BoxCloneService, BoxCloneSyncService, BoxService, ServiceFn, service_fn};
52pub use tower::{Layer, Service, ServiceBuilder, ServiceExt};
53
54pub mod auth;
55pub mod lifecycle;
56pub mod mcp;
57pub mod oauth;
58pub mod queue;
59pub mod request;
60pub mod token;
61pub mod trust;
62
63// Re-export trust tier at the plugins level — commonly referenced.
64pub use trust::PluginTrustTier;
65
66// Hot-path re-exports for the most common pipeline types.
67pub use lifecycle::{LifecycleEvent, TelemetryEvent, TelemetryService};
68pub use mcp::{
69 CallToolRequest, CallToolResponse, CallToolService, ListToolsRequest, ListToolsResponse,
70 ListToolsService, ToolAnnotations, ToolDefinition,
71};
72pub use oauth::{OAuthRequest, OAuthResponse, OAuthService};
73pub use request::{ContextService, RequestPipeline, YetiRequest, YetiResponse};
74pub use token::{TokenRequest, TokenResponse, TokenService};
75
76// ============================================================================
77// ComputedFieldHook
78// ============================================================================
79
80/// Hook for computing virtual field values at read time.
81#[async_trait]
82pub trait ComputedFieldHook: Send + Sync {
83 /// Resolve computed fields for a record.
84 async fn resolve(
85 &self,
86 record: serde_json::Value,
87 fields: &[(String, String)],
88 ) -> std::result::Result<serde_json::Value, String>;
89}
90
91// ============================================================================
92// Vector Embedding Support
93// ============================================================================
94
95/// Mapping from a source field to a target vector field via an embedding model.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct FieldMapping {
98 /// Source field name containing text or image data
99 pub source: String,
100 /// Target field name for the generated embedding vector
101 pub target: String,
102 /// Model identifier (e.g., "BAAI/bge-small-en-v1.5")
103 pub model: String,
104 /// Field type: "text" (default) or "image"
105 #[serde(default = "default_field_type")]
106 pub field_type: String,
107}
108
109fn default_field_type() -> String {
110 "text".to_owned()
111}
112
113/// Hook for automatic vector embedding generation.
114///
115/// Implementations must be **sync** for safety across the WIT boundary.
116pub trait VectorHook: Send + Sync {
117 /// Embed text/image fields in a record based on mappings.
118 fn vectorize_fields(
119 &self,
120 record: serde_json::Value,
121 mappings: &[FieldMapping],
122 ) -> std::result::Result<serde_json::Value, String>;
123
124 /// Convert a text query to a vector for search.
125 fn vectorize_text(&self, text: &str, model: &str) -> std::result::Result<Vec<f32>, String>;
126
127 /// Convert raw image bytes to a vector.
128 fn vectorize_image(&self, bytes: &[u8], model: &str) -> std::result::Result<Vec<f32>, String>;
129
130 /// Check if a model is available for use.
131 fn validate_model(&self, _model: &str) -> std::result::Result<(), String> {
132 Ok(())
133 }
134
135 /// Eagerly load a model into memory during startup.
136 fn warmup_model(&self, _model: &str, _field_type: &str) -> std::result::Result<(), String> {
137 Ok(())
138 }
139
140 /// Batch-embed multiple records.
141 fn vectorize_fields_batch(
142 &self,
143 records: Vec<serde_json::Value>,
144 mappings: &[FieldMapping],
145 ) -> std::result::Result<Vec<serde_json::Value>, String> {
146 records
147 .into_iter()
148 .map(|r| self.vectorize_fields(r, mappings))
149 .collect()
150 }
151}
152
153// ============================================================================
154// AI Inference Support
155// ============================================================================
156
157/// Metadata about an available AI model.
158#[derive(Debug, Clone, serde::Serialize)]
159pub struct ModelInfo {
160 /// Unique identifier
161 pub id: String,
162 /// Human-readable model name
163 pub name: String,
164 /// Parameter count label ("8B", "3B")
165 pub parameters: String,
166 /// Quantization level ("`Q4_K_M`", "F16")
167 pub quantization: String,
168 /// Whether the model is currently loaded
169 pub loaded: bool,
170 /// Memory usage in bytes (0 if not loaded)
171 pub memory_bytes: u64,
172}
173
174/// Hook for local LLM inference.
175///
176/// Implementations must be **sync** for safety across the WIT boundary.
177pub trait AiHook: Send + Sync {
178 /// Single-turn text completion.
179 fn complete(&self, prompt: &str, max_tokens: u32) -> std::result::Result<String, String>;
180
181 /// Multi-turn chat completion.
182 fn chat(
183 &self,
184 messages: &[(&str, &str)],
185 max_tokens: u32,
186 ) -> std::result::Result<String, String>;
187
188 /// Structured JSON output.
189 fn complete_json(
190 &self,
191 prompt: &str,
192 max_tokens: u32,
193 ) -> std::result::Result<serde_json::Value, String>;
194
195 /// List available models.
196 fn models(&self) -> Vec<ModelInfo>;
197
198 /// Check if a specific model is loaded.
199 fn is_loaded(&self, model: &str) -> bool;
200}
201
202// ============================================================================
203// VectorContext — per-app vector capabilities (set once at startup)
204// ============================================================================
205
206/// Per-app vector capabilities. Set once at startup, never changes per-request.
207///
208/// Stored on `BackendManager` via `OnceLock` so any code with access to the
209/// backend manager can reach vector hooks without threading them through Context.
210pub struct VectorContext {
211 /// Vector embedding hook (from yeti-ai plugin)
212 pub hook: Arc<dyn VectorHook>,
213 /// Plugin `app_id` that provided the vector hook
214 pub hook_ext_id: String,
215 /// Vector micro-batcher for server-side batching
216 pub batcher: Option<Arc<VectorBatcher>>,
217 /// Pre-parsed vector field mappings
218 pub mappings: Vec<FieldMapping>,
219 /// Vector embedding cache backend
220 pub cache: Option<Arc<dyn crate::backend::KvBackend>>,
221}
222
223impl std::fmt::Debug for VectorContext {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("VectorContext")
226 .field("hook_ext_id", &self.hook_ext_id)
227 .field("mappings", &self.mappings)
228 .field("has_batcher", &self.batcher.is_some())
229 .field("has_cache", &self.cache.is_some())
230 // `hook` (dyn VectorHook), `cache` (dyn KvBackend) elided
231 .finish_non_exhaustive()
232 }
233}
234
235// ============================================================================
236// VectorBatcher
237// ============================================================================
238
239/// Server-side micro-batcher for vector embeddings.
240#[derive(Debug)]
241pub struct VectorBatcher {
242 tx: tokio::sync::mpsc::UnboundedSender<(
243 serde_json::Value,
244 tokio::sync::oneshot::Sender<serde_json::Value>,
245 )>,
246}
247
248impl VectorBatcher {
249 /// Create from a channel sender.
250 #[must_use]
251 pub const fn from_sender(
252 tx: tokio::sync::mpsc::UnboundedSender<(
253 serde_json::Value,
254 tokio::sync::oneshot::Sender<serde_json::Value>,
255 )>,
256 ) -> Self {
257 Self { tx }
258 }
259
260 /// Submit a record for embedding and wait for the result.
261 pub async fn embed(&self, record: serde_json::Value) -> serde_json::Value {
262 let fallback = record.clone();
263 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
264 if self.tx.send((record, reply_tx)).is_err() {
265 return fallback;
266 }
267 reply_rx.await.unwrap_or(fallback)
268 }
269}
270
271// ============================================================================
272// EmbeddedFile
273// ============================================================================
274
275/// A file embedded in the binary at compile time for static web serving.
276#[derive(Debug)]
277pub struct EmbeddedFile {
278 /// Relative path (e.g., "index.html", "assets/app.js")
279 pub path: &'static str,
280 /// File content bytes
281 pub content: &'static [u8],
282}
283
284// ============================================================================
285// Context Lookup Types
286// ============================================================================
287
288/// Closure type for looking up `PubSub` managers by table name.
289///
290/// `Arc<dyn Fn>` (rather than `Box<dyn Fn>`) so that the enclosing
291/// `Context` can be `Clone` — required for flowing the owned `Context`
292/// through Tower `Service<Context>` chains (each link clones once, then
293/// consumes its clone). `Arc::clone` is a single atomic op; the closure
294/// body is shared, not copied.
295pub type PubSubLookupFn =
296 Arc<dyn Fn(&str) -> Option<Arc<crate::pubsub::PubSubManager>> + Send + Sync>;
297
298// ============================================================================
299// TableSubscriber — host-bridged table pubsub for wasm applications
300// ============================================================================
301
302/// Subscribe to row changes on a named table from a wasm application.
303///
304/// # Why not just `ps.subscribe(table).await` in the guest?
305///
306/// The guest runs in a separate wasm sandbox with its own linear memory
307/// and no visibility into the host tokio runtime — there is no host
308/// reactor for guest-constructed futures to register wakers against, and
309/// the guest cannot hold a host `broadcast::Receiver` whose `Sender`
310/// lives host-side. Long-running subscription work has to be host-driven;
311/// the guest only awaits a channel the host hands it.
312///
313/// # How this bridge works
314///
315/// The app provides a `TableSubscriber` implementation via
316/// `RegistrationContext::table_subscribers`. During app-loader drain,
317/// the host:
318///
319/// 1. Calls `ps.subscribe(subscriber.table()).await` — receiver is
320/// **host-allocated**.
321/// 2. Creates an `mpsc::UnboundedChannel` pair (the mpsc is the
322/// safe-across-the-boundary primitive).
323/// 3. `tokio::spawn`s a forwarder task on the host runtime that loops
324/// `broadcast_rx.recv().await` → `mpsc_tx.send()`. Both primitives
325/// live in host tokio.
326/// 4. `tokio::spawn`s `subscriber.run(mpsc_rx)` on the host runtime.
327/// The loop body is guest-defined, but it only awaits on the
328/// host-allocated `mpsc::UnboundedReceiver` — no guest-side
329/// subscribe or broadcast-receive happens.
330///
331/// The result is symmetric with the publish path: publish goes guest →
332/// host via a WIT import; subscribe goes host → guest via an mpsc
333/// receiver handed in at `run()` time.
334pub trait TableSubscriber: Send + Sync + 'static {
335 /// Name of the table to subscribe to (matches `@table` name in schema).
336 fn table(&self) -> &str;
337
338 /// Long-running loop. `rx` carries every row change published to the
339 /// table's pubsub stream. Runs on the host tokio runtime; is
340 /// host-driven on behalf of the guest.
341 fn run(
342 self: Box<Self>,
343 rx: tokio::sync::mpsc::UnboundedReceiver<crate::resource::SubscriptionMessage>,
344 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>;
345}
346
347// ============================================================================
348// Plugin trait
349// ============================================================================
350
351/// Contract between the yeti binary and service crates.
352///
353/// The runtime calls methods in order:
354/// `is_required()` → `schemas()` → `resources()` → `on_ready()` → `on_shutdown()`
355///
356/// `schemas()` is called during discovery (before backends exist).
357/// `resources()` is called during loading (after backends exist).
358/// Each is called exactly once.
359pub trait Plugin: Send + Sync + 'static {
360 /// Unique identifier (e.g., "yeti-auth").
361 fn id(&self) -> &'static str;
362 /// Human-readable name.
363 fn name(&self) -> &'static str;
364 /// Semantic version.
365 fn version(&self) -> &'static str {
366 "0.1.0"
367 }
368 /// Plugin IDs this service depends on (for topological ordering).
369 fn depends_on(&self) -> &[&'static str] {
370 &[]
371 }
372 /// Whether this service should be activated given the current config.
373 fn is_required(&self, ctx: &StartupContext) -> bool {
374 let _ = ctx;
375 true
376 }
377 /// Whether this is a global plugin (loaded before user apps).
378 fn is_plugin(&self) -> bool {
379 false
380 }
381 /// Whether registration failure should abort startup.
382 fn is_critical(&self) -> bool {
383 false
384 }
385 /// Embedded `Cargo.toml` content. Discovery extracts
386 /// `[package.metadata.app]` (and any plugin metadata blocks) via
387 /// `yeti_sdk_host::application::cargo_manifest`.
388 fn config_toml(&self) -> Option<&'static str> {
389 None
390 }
391 /// GraphQL schema strings. Called once during discovery, before backends exist.
392 /// Filesystem apps return empty — their schemas come from schema.graphql files.
393 fn schemas(&self) -> Vec<&'static str> {
394 vec![]
395 }
396 /// Register resources, hooks, web files, and providers.
397 /// Called once during loading, after backends exist.
398 fn resources(&self, ctx: &mut RegistrationContext) -> crate::error::Result<()> {
399 let _ = ctx;
400 Ok(())
401 }
402
403 /// Register in-process hook services (ADR-009).
404 ///
405 /// Called by the app loader between [`Self::resources`] and
406 /// [`Self::on_ready`]. Static plugins that want to install
407 /// services into another static plugin's hook chain (e.g.
408 /// yeti-auth's OAuth/Token chains, yeti-mcp's tool/resource
409 /// chains) call the per-domain hook surface directly here (e.g.
410 /// `yeti_mcp::McpHooks::register_list_tools_service`), once per
411 /// chain they want to extend.
412 ///
413 /// Slotted before `on_ready` so registrations are visible by the
414 /// time any request can hit a dispatcher that reads the chain.
415 /// Default-empty so existing plugins compile unchanged.
416 fn register_hooks(&self) -> crate::error::Result<()> {
417 Ok(())
418 }
419
420 /// Post-registration setup (bootstrap data, background tasks, etc.).
421 ///
422 /// Receives a `Context` with `request`-less defaults (no method/headers/body).
423 /// The runtime populates `backend_manager`, `root_directory`, `app_id`,
424 /// and `pubsub_lookup` before calling.
425 fn on_ready(&self, ctx: &crate::resource::Context) -> crate::error::Result<()> {
426 let _ = ctx;
427 Ok(())
428 }
429
430 /// Build and return the service's telemetry-subscriber Service, if any.
431 ///
432 /// Per ADR-006 this returns a `TelemetryService` (Tower
433 /// `Service<TelemetryEvent>`) instead of the legacy
434 /// `Box<dyn EventSubscriber>`. The runtime creates an mpsc
435 /// channel, registers the sender globally so any code can
436 /// `event_sender().send(event)` to fire an event, and drives a
437 /// per-event call loop on the host tokio runtime:
438 ///
439 /// ```ignore
440 /// while let Some(ev) = rx.recv().await {
441 /// if let Ok(svc) = service.ready().await {
442 /// let _ = svc.call(ev).await;
443 /// }
444 /// }
445 /// ```
446 ///
447 /// Plugin authors stack `tower::ServiceBuilder` layers
448 /// (timeout, trace, concurrency-limit) onto the Service before
449 /// returning it.
450 ///
451 /// Default: no subscriber.
452 fn install_event_subscriber(
453 &self,
454 ctx: &crate::resource::Context,
455 ) -> crate::error::Result<Option<crate::plugins::lifecycle::TelemetryService>> {
456 let _ = ctx;
457 Ok(None)
458 }
459
460 /// Called during graceful shutdown.
461 fn on_shutdown(&self) {}
462}
463
464/// Ordered list of service instances.
465pub type PluginRegistry = Vec<Box<dyn Plugin>>;
466
467// ============================================================================
468// StartupContext
469// ============================================================================
470
471/// Read-only context passed to `Plugin::is_required()`.
472#[derive(Debug)]
473pub struct StartupContext<'a> {
474 /// All discovered table definitions across all apps.
475 pub tables: &'a [crate::schema::TableDefinition],
476 /// All discovered application configs (as raw JSON for introspection).
477 pub app_configs: &'a [serde_json::Value],
478 /// Whether the MQTT interface is enabled (`mqtt.enabled` in
479 /// yeti-config.yaml). The broker requires the auth backend, so a plugin
480 /// that provides auth declares itself required when this is set — even when
481 /// no app otherwise needs auth.
482 pub mqtt_enabled: bool,
483}
484
485impl StartupContext<'_> {
486 /// Check if any application has a given top-level config key (e.g., "auth", "kafka").
487 #[must_use]
488 pub fn any_app_has_config(&self, key: &str) -> bool {
489 self.app_configs
490 .iter()
491 .any(|c| c.as_object().is_some_and(|obj| obj.contains_key(key)))
492 }
493
494 /// Check if any table has a field whose type matches the given name (case-insensitive).
495 #[must_use]
496 pub fn any_table_has_field_directive(&self, directive: &str) -> bool {
497 self.tables.iter().any(|t| {
498 t.fields
499 .iter()
500 .any(|f| f.field_type.eq_ignore_ascii_case(directive))
501 })
502 }
503
504 /// Check if any application requires authentication (has non-empty `required_roles`).
505 #[must_use]
506 pub fn any_app_requires_auth(&self) -> bool {
507 self.app_configs.iter().any(|c| {
508 c.get("requiredRoles")
509 .or_else(|| c.get("required_roles"))
510 .and_then(|v| v.as_array())
511 .is_some_and(|arr| !arr.is_empty())
512 })
513 }
514}
515
516// ============================================================================
517// RegistrationContext
518// ============================================================================
519
520/// Write context passed to `Plugin::register()`.
521pub struct RegistrationContext {
522 /// GraphQL schema strings
523 pub schemas: Vec<String>,
524 /// Seed data JSON strings
525 pub seed_data: Vec<String>,
526 /// Resource handlers
527 pub resources: Vec<crate::resource::ResourceEntry>,
528 /// Embedded web files for static serving
529 pub web_files: Vec<EmbeddedFile>,
530 /// Authentication providers
531 pub auth_providers: Vec<Arc<dyn crate::auth::AuthProvider>>,
532 // Per ADR-006: `auth_hooks` (legacy `AuthHook` trait objects) is deleted.
533 // yeti-auth reintroduces a plugin-side registry of
534 // `Service<ResolveRequest>` impls (see `plugins::auth`).
535 /// Vector embedding hooks
536 pub vector_hooks: Vec<Arc<dyn VectorHook>>,
537 /// AI inference hooks
538 pub ai_hooks: Vec<Arc<dyn AiHook>>,
539 /// Computed field hooks
540 pub computed_field_hooks: Vec<Arc<dyn ComputedFieldHook>>,
541 /// Pipeline services — Tower-native replacement for the legacy
542 /// `RequestMiddleware` Vec. Each entry is a
543 /// `BoxCloneService<Context, Context, YetiError>` (see
544 /// `plugins::request::ContextService`); the router runs them in
545 /// registration order via `RequestPipeline::run`.
546 pub request_services: Vec<crate::plugins::request::ContextService>,
547 /// Table subscribers (pubsub safe across the WIT boundary via host-created mpsc bridge)
548 pub table_subscribers: Vec<Box<dyn TableSubscriber>>,
549 /// Background tasks to spawn after registration
550 pub background_tasks: Vec<Pin<Box<dyn Future<Output = ()> + Send>>>,
551 /// Application ID
552 pub app_id: String,
553 /// Root directory
554 pub root_directory: String,
555}
556
557impl std::fmt::Debug for RegistrationContext {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 f.debug_struct("RegistrationContext")
560 .field("app_id", &self.app_id)
561 .field("root_directory", &self.root_directory)
562 .field("schemas", &self.schemas.len())
563 .field("seed_data", &self.seed_data.len())
564 .field("resources", &self.resources.len())
565 .field("web_files", &self.web_files.len())
566 .field("auth_providers", &self.auth_providers.len())
567 .field("vector_hooks", &self.vector_hooks.len())
568 .field("ai_hooks", &self.ai_hooks.len())
569 .field("computed_field_hooks", &self.computed_field_hooks.len())
570 .field("request_services", &self.request_services.len())
571 .field("table_subscribers", &self.table_subscribers.len())
572 .field("background_tasks", &self.background_tasks.len())
573 .finish_non_exhaustive()
574 }
575}
576
577impl RegistrationContext {
578 /// Create a new empty registration context.
579 #[must_use]
580 pub fn new(app_id: String, root_directory: String) -> Self {
581 Self {
582 schemas: Vec::new(),
583 seed_data: Vec::new(),
584 resources: Vec::new(),
585 web_files: Vec::new(),
586 auth_providers: Vec::new(),
587 vector_hooks: Vec::new(),
588 ai_hooks: Vec::new(),
589 computed_field_hooks: Vec::new(),
590 request_services: Vec::new(),
591 table_subscribers: Vec::new(),
592 background_tasks: Vec::new(),
593 app_id,
594 root_directory,
595 }
596 }
597
598 /// Add a GraphQL schema string.
599 pub fn add_schema(&mut self, graphql: &str) {
600 self.schemas.push(graphql.to_owned());
601 }
602 /// Add a resource handler.
603 pub fn add_resource(&mut self, entry: crate::resource::ResourceEntry) {
604 self.resources.push(entry);
605 }
606 /// Add embedded web files.
607 pub fn add_web_files(&mut self, files: Vec<EmbeddedFile>) {
608 self.web_files.extend(files);
609 }
610 /// Add an authentication provider.
611 pub fn add_auth_provider(&mut self, p: Arc<dyn crate::auth::AuthProvider>) {
612 self.auth_providers.push(p);
613 }
614 /// Add a vector embedding hook.
615 pub fn add_vector_hook(&mut self, h: Arc<dyn VectorHook>) {
616 self.vector_hooks.push(h);
617 }
618 /// Add an AI inference hook.
619 pub fn add_ai_hook(&mut self, h: Arc<dyn AiHook>) {
620 self.ai_hooks.push(h);
621 }
622 /// Register a request-pipeline service. The service runs as part of
623 /// the per-request middleware chain built by yeti-router. Plugins
624 /// produce a `ContextService` via `tower::service_fn` (or by
625 /// implementing `tower::Service<Context>` directly) and call
626 /// `BoxCloneService::new(svc)` before passing it here.
627 pub fn add_request_service(&mut self, svc: crate::plugins::request::ContextService) {
628 self.request_services.push(svc);
629 }
630 /// Add a table subscriber. The runtime will subscribe to the named
631 /// table's pubsub stream on the host tokio runtime and forward each
632 /// message to the subscriber's `run()` loop via a host-allocated
633 /// mpsc channel. Safe to call from wasm applications.
634 pub fn add_table_subscriber(&mut self, s: Box<dyn TableSubscriber>) {
635 self.table_subscribers.push(s);
636 }
637 /// Get the root directory path.
638 #[must_use]
639 pub fn root_dir(&self) -> &str {
640 &self.root_directory
641 }
642 /// Get the application ID.
643 #[must_use]
644 pub fn app_id(&self) -> &str {
645 &self.app_id
646 }
647}
648
649// now receives `&crate::resource::Context` directly. Non-request helpers
650// (`root_dir`, `pubsub`, `backend`) live on Context.