Skip to main content

aimdb_core/session/aimx/
dispatch.rs

1//! AimX server dispatch (`no_std + alloc`; features `connector-session` +
2//! `remote`) — the method semantics of AimX remote access, served on the
3//! shared session engine (`serve`/`run_session`).
4//!
5//! Reaches into core's `record.list` / JSON API (the `AnyRecord` JSON + metadata
6//! methods), which are gated on `remote` and de-std'd alongside this
7//! dispatch, so a board can serve a host over serial. A transport pairs this
8//! dispatch with the generic
9//! [`SessionServerConnector`](crate::session::SessionServerConnector) — see
10//! `aimdb-uds-connector`'s `UdsServer`.
11//!
12//! The role is split in two:
13//! - [`AimxDispatch`] — the shared half (one `Arc` per server): peer-only
14//!   `authenticate` + an `open` factory.
15//! - `AimxSession` — the per-connection half the engine owns by value, homing
16//!   `record.drain`'s lazy per-record cursors (`drain_readers`).
17//!
18//! Param shapes follow the client ([`aimdb_client::AimxConnection`]):
19//! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`.
20
21use alloc::boxed::Box;
22use alloc::string::{String, ToString};
23use alloc::sync::Arc;
24use alloc::vec;
25use alloc::vec::Vec;
26use hashbrown::HashMap;
27
28use serde_json::{json, Value};
29
30use futures_util::StreamExt;
31
32use crate::buffer::JsonBufferReader;
33use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage, PROTOCOL_VERSION};
34use crate::session::{
35    is_wildcard, topic_matches, AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo,
36    RpcError, Session, SessionCtx, SubUpdate,
37};
38use crate::{AimDb, DbError};
39
40/// The shared AimX dispatch — `authenticate` (peer-only) + the `AimxSession`
41/// factory. One `Arc<AimxDispatch>` is shared across every accepted connection.
42pub struct AimxDispatch {
43    db: Arc<AimDb>,
44    config: Arc<AimxConfig>,
45}
46
47impl AimxDispatch {
48    /// Build a dispatch over `db` with the given remote-access `config`.
49    pub fn new(db: Arc<AimDb>, config: AimxConfig) -> Self {
50        Self {
51            db,
52            config: Arc::new(config),
53        }
54    }
55}
56
57impl Dispatch for AimxDispatch {
58    fn authenticate<'a>(
59        &'a self,
60        _peer: &'a PeerInfo,
61        _first: Option<&'a [u8]>,
62    ) -> BoxFut<'a, Result<SessionCtx, AuthError>> {
63        // Peer-only: AimX over UDS relies on socket file permissions for access
64        // control. Permission policy (ReadOnly / writable_records) is enforced
65        // per-call from the config.
66        Box::pin(async { Ok(SessionCtx::default()) })
67    }
68
69    fn open(&self, _ctx: &SessionCtx) -> Box<dyn Session> {
70        Box::new(AimxSession {
71            db: self.db.clone(),
72            config: self.config.clone(),
73            drain_readers: HashMap::new(),
74        })
75    }
76}
77
78/// One AimX connection's mutable dispatch state. The engine owns this by value
79/// and threads `&mut self` into each method, so `drain_readers` (lazy per-record
80/// cursors) need no lock.
81struct AimxSession {
82    db: Arc<AimDb>,
83    config: Arc<AimxConfig>,
84    /// Per-record drain readers, created lazily on first `record.drain`.
85    drain_readers: HashMap<String, Box<dyn JsonBufferReader + Send>>,
86}
87
88/// Internal dispatch result. Most methods still build a JSON tree; hot record
89/// paths can hand already-serialized JSON to the session engine.
90enum DispatchReply {
91    Value(Value),
92    RawJson(Payload),
93}
94
95impl DispatchReply {
96    fn into_payload(self) -> Payload {
97        match self {
98            Self::Value(value) => to_payload(&value),
99            Self::RawJson(payload) => payload,
100        }
101    }
102}
103
104impl Session for AimxSession {
105    fn call<'a>(
106        &'a mut self,
107        method: &'a str,
108        params: Payload,
109    ) -> BoxFut<'a, Result<Payload, RpcError>> {
110        Box::pin(async move {
111            let params: Value = serde_json::from_slice(&params).unwrap_or(Value::Null);
112            self.dispatch_call(method, params)
113                .await
114                .map(DispatchReply::into_payload)
115        })
116    }
117
118    fn subscribe<'a>(
119        &'a mut self,
120        topic: &'a str,
121    ) -> BoxFut<'a, Result<BoxStream<'static, SubUpdate>, RpcError>> {
122        // The engine owns the subscription lifecycle and the per-connection cap;
123        // AimX has no async authorization, so this is a trivial wrapper.
124        Box::pin(async move {
125            if is_wildcard(topic) {
126                return Ok(self.subscribe_wildcard(topic));
127            }
128            // Exact-topic fast path: resolve one key, untagged updates.
129            let stream = crate::remote::stream::stream_record_updates(&self.db, topic)
130                .map_err(map_db_err)?;
131            Ok(
132                Box::pin(stream.map(|(data, skipped)| SubUpdate::new(data).with_skipped(skipped)))
133                    as BoxStream<'static, SubUpdate>,
134            )
135        })
136    }
137
138    fn snapshots(&mut self, topic: &str) -> Vec<(String, Payload)> {
139        // Late-join state for wildcard subscriptions: the current value of every
140        // matched record that has one. Exact-topic AimX subscribes stay
141        // event-only (unchanged wire for UDS/serial/TCP clients).
142        if !is_wildcard(topic) {
143            return Vec::new();
144        }
145        self.matched_keys(topic)
146            .into_iter()
147            .filter_map(|key| {
148                let value = self.db.try_latest_as_json(&key)?;
149                Some((key, to_payload(&value)))
150            })
151            .collect()
152    }
153
154    fn write<'a>(
155        &'a mut self,
156        topic: &'a str,
157        payload: Payload,
158    ) -> BoxFut<'a, Result<(), RpcError>> {
159        Box::pin(async move {
160            self.ensure_writable(topic)?;
161            // The v2 client wraps the value as `{"value": <v>}`; fall back to the
162            // whole payload if the wrapper is absent.
163            let v: Value = serde_json::from_slice(&payload).unwrap_or(Value::Null);
164            let value = v.get("value").cloned().unwrap_or(v);
165            // Routes through the producer/arbiter path — single-writer-per-key
166            // stays enforced inside `set_record_from_json`.
167            self.db
168                .set_record_from_json(topic, value)
169                .map_err(map_db_err)
170        })
171    }
172}
173
174impl AimxSession {
175    /// Record keys matching a wildcard pattern. The record set is frozen at
176    /// builder time, so matching once at subscribe time is complete — dynamic
177    /// membership would require runtime registration.
178    fn matched_keys(&self, pattern: &str) -> Vec<String> {
179        self.db
180            .list_records()
181            .into_iter()
182            .map(|m: RecordMetadata| m.record_key)
183            .filter(|key| topic_matches(pattern, key))
184            .collect()
185    }
186
187    /// One wildcard subscription fans in every matched record's update stream,
188    /// each event tagged with the record that fired. Records without remote
189    /// access are skipped — the subscription covers what can stream.
190    fn subscribe_wildcard(&self, pattern: &str) -> BoxStream<'static, SubUpdate> {
191        let mut streams: Vec<BoxStream<'static, SubUpdate>> = Vec::new();
192        for key in self.matched_keys(pattern) {
193            match crate::remote::stream::stream_record_updates(&self.db, &key) {
194                Ok(stream) => {
195                    let tag: Arc<str> = Arc::from(key.as_str());
196                    streams.push(Box::pin(stream.map(move |(data, skipped)| {
197                        SubUpdate::tagged(tag.clone(), data).with_skipped(skipped)
198                    })));
199                }
200                Err(_e) => {
201                    log_warn!(
202                        "wildcard subscribe '{}': skipping '{}': {:?}",
203                        pattern,
204                        key,
205                        _e
206                    );
207                }
208            }
209        }
210        // An empty match set yields a stream that ends immediately — with a
211        // builder-frozen record set, "no matches now" is "no matches ever".
212        Box::pin(futures_util::stream::select_all(streams))
213    }
214
215    /// Match the method and produce a tree or already-serialized JSON reply.
216    async fn dispatch_call(
217        &mut self,
218        method: &str,
219        params: Value,
220    ) -> Result<DispatchReply, RpcError> {
221        // Hot record read: hand already-serialized JSON straight to the engine,
222        // skipping the `serde_json::Value` round-trip (main's clone-avoidance).
223        if method == "record.get" {
224            let name = str_field(&params, "name").ok_or(RpcError::NotFound)?;
225            return self.record_get(&name).map(DispatchReply::RawJson);
226        }
227
228        let value = match method {
229            // Version gate: refuse an incompatible/absent client version at the
230            // handshake rather than letting it trip over the new wire shapes.
231            "hello" => self.hello(params),
232            "record.list" => Ok(json!(self.db.list_records())),
233            "record.set" => self.record_set(params),
234            "record.drain" => self.record_drain(params),
235            "record.query" => self
236                .record_query(params)?
237                .await
238                .map_err(|_| RpcError::Internal),
239            "graph.nodes" => Ok(json!(self.db.inner().dependency_graph().nodes)),
240            "graph.edges" => Ok(json!(self.db.inner().dependency_graph().edges)),
241            "graph.topo_order" => Ok(json!(self.db.inner().dependency_graph().topo_order())),
242            #[cfg(feature = "observability")]
243            "profiling.reset" => {
244                self.ensure_write_permission()?;
245                self.db.reset_stage_profiling();
246                Ok(json!({ "reset": true }))
247            }
248            #[cfg(feature = "observability")]
249            "buffer_metrics.reset" => {
250                self.ensure_write_permission()?;
251                self.db.reset_buffer_metrics();
252                Ok(json!({ "reset": true }))
253            }
254            _ => Err(RpcError::NotFound),
255        }?;
256
257        Ok(DispatchReply::Value(value))
258    }
259
260    /// `record.set` (RPC): permission-checked write that echoes the new value.
261    fn record_set(&self, params: Value) -> Result<Value, RpcError> {
262        let name = str_field(&params, "name").ok_or(RpcError::Internal)?;
263        let value = params.get("value").cloned().ok_or(RpcError::Internal)?;
264        self.ensure_writable(&name)?;
265        self.db
266            .set_record_from_json(&name, value)
267            .map_err(map_db_err)?;
268        // Echo the updated value when available (matches the legacy reply shape).
269        Ok(match self.db.try_latest_as_json(&name) {
270            Some(updated) => json!({ "status": "success", "value": updated }),
271            None => json!({ "status": "success" }),
272        })
273    }
274
275    /// `record.get`: the record's current value.
276    ///
277    /// A `SingleLatest`/state record exposes a non-destructive canonical latest
278    /// ([`try_latest_as_json_bytes`](crate::AimDb::try_latest_as_json_bytes)).
279    /// A ring
280    /// ([`SpmcRing`](crate::buffer::BufferCfg::SpmcRing)) has none, so we fall back
281    /// to the connection's drain cursor and return the **most recent** available
282    /// value. Two consequences of that fallback: it *advances the shared drain
283    /// cursor* (so `record.get` and `record.drain` interleave on one connection),
284    /// and it yields `NotFound` until the ring produces a value *after* the cursor
285    /// is first opened (a fresh broadcast reader starts at the tail). Use
286    /// `record.drain` for a ring's full backlog.
287    fn record_get(&mut self, name: &str) -> Result<Payload, RpcError> {
288        if let Some(bytes) = self.db.try_latest_as_json_bytes(name) {
289            return Ok(Payload::from(bytes));
290        }
291        // Ring fallback: drain to the newest currently-available value (or NotFound).
292        self.drain_values(name, usize::MAX)?
293            .pop()
294            .map(|value| to_payload(&value))
295            .ok_or(RpcError::NotFound)
296    }
297
298    /// `record.drain`: return everything accumulated since the previous drain
299    /// (capped by an optional `limit`), via the per-connection cursor.
300    fn record_drain(&mut self, params: Value) -> Result<Value, RpcError> {
301        let name = str_field(&params, "name").ok_or(RpcError::Internal)?;
302        let limit = params
303            .get("limit")
304            .and_then(|v| v.as_u64())
305            .map(|v| usize::try_from(v).unwrap_or(usize::MAX))
306            .unwrap_or(usize::MAX);
307        let values = self.drain_values(&name, limit)?;
308        let count = values.len();
309        Ok(json!({ "record_name": name, "values": values, "count": count }))
310    }
311
312    /// Lazily open (on first call) the per-record drain cursor and read up to
313    /// `limit` values accumulated since the previous read (oldest-first). Shared
314    /// by [`record.drain`](Self::record_drain) and [`record.get`](Self::record_get)'s
315    /// ring fallback, so both read from the same per-connection cursor.
316    fn drain_values(&mut self, name: &str, limit: usize) -> Result<Vec<Value>, RpcError> {
317        if !self.drain_readers.contains_key(name) {
318            let id = self
319                .db
320                .inner()
321                .resolve_str(name)
322                .ok_or(RpcError::NotFound)?;
323            let record = self.db.inner().storage(id).ok_or(RpcError::NotFound)?;
324            // `subscribe_json` fails if the record was not configured with
325            // `.with_remote_access()`.
326            let reader = record
327                .json_access()
328                .ok_or_else(|| {
329                    map_db_err(DbError::runtime_error(alloc::format!(
330                        "Record '{name}' does not support JSON remote access"
331                    )))
332                })?
333                .subscribe_json()
334                .map_err(map_db_err)?;
335            self.drain_readers.insert(name.to_string(), reader);
336        }
337
338        // Unreachable, but reported rather than asserted: this is the session
339        // pump.
340        let reader = self.drain_readers.get_mut(name).ok_or_else(|| {
341            map_db_err(DbError::runtime_error(alloc::format!(
342                "Record '{name}' drain reader vanished between insert and use"
343            )))
344        })?;
345        let mut values = Vec::new();
346        while values.len() < limit {
347            match reader.try_recv_json() {
348                Ok(val) => values.push(val),
349                Err(DbError::BufferEmpty) => break,
350                // Ring overflowed since the last read — cursor resets; keep going.
351                Err(DbError::BufferLagged { .. }) => continue,
352                Err(_) => break,
353            }
354        }
355        Ok(values)
356    }
357
358    /// `record.query`: resolve the persistence query handler registered in the
359    /// db's `Extensions` (absent → not configured) and return its handler
360    /// future.
361    ///
362    /// Deliberately **not** an `async fn`: an `async fn(&self)` future would
363    /// capture `&self` across its await, forcing `AimxSession: Sync` — which the
364    /// per-connection `drain_readers` (`Box<dyn _ + Send>`, not `Sync`) is not.
365    /// Returning the (`'static`, `Send`) handler future lets the borrow of
366    /// `self` end here; the caller awaits the owned future.
367    #[allow(clippy::type_complexity)]
368    fn record_query(
369        &self,
370        params: Value,
371    ) -> Result<
372        core::pin::Pin<
373            Box<dyn core::future::Future<Output = Result<Value, String>> + Send + 'static>,
374        >,
375        RpcError,
376    > {
377        let handler = self
378            .db
379            .extensions()
380            .get::<crate::remote::QueryHandlerFn>()
381            .ok_or(RpcError::Internal)?;
382        let name = params
383            .get("name")
384            .and_then(|v| v.as_str())
385            .unwrap_or(crate::remote::QUERY_ALL_PATTERN)
386            .to_string();
387        let limit = params
388            .get("limit")
389            .and_then(|v| v.as_u64())
390            .and_then(|v| usize::try_from(v).ok());
391        let start = params.get("start").and_then(|v| v.as_u64());
392        let end = params.get("end").and_then(|v| v.as_u64());
393        Ok(handler(crate::remote::QueryHandlerParams {
394            name,
395            limit,
396            start,
397            end,
398        }))
399    }
400
401    /// `hello`: the version-gated handshake.
402    ///
403    /// The client's declared `version` must be major-compatible with this
404    /// server's [`PROTOCOL_VERSION`] ([`version_compatible`](crate::remote::version_compatible)).
405    /// A pre-3.x — or version-less — client is refused **here** with
406    /// [`RpcError::VersionMismatch`], rather than completing the handshake and
407    /// letting the client trip over the new `reply`/`event` shapes on its first
408    /// `record.query` — a hard refusal at `hello`.
409    fn hello(&self, params: Value) -> Result<Value, RpcError> {
410        let version = str_field(&params, "version").unwrap_or_default();
411        if !crate::remote::version_compatible(&version) {
412            log_warn!(
413                "refusing handshake: client protocol version {:?} incompatible with server {}",
414                version,
415                PROTOCOL_VERSION
416            );
417            return Err(RpcError::VersionMismatch);
418        }
419        Ok(self.welcome())
420    }
421
422    /// Build the `Welcome` from the security policy + writable records.
423    ///
424    /// `writable_records` is derived from the policy directly and intersected with
425    /// the records that actually exist, so the server never advertises a phantom key.
426    fn welcome(&self) -> Value {
427        let (permissions, writable_records) = match &self.config.security_policy {
428            SecurityPolicy::ReadOnly => (vec!["read".to_string()], Vec::new()),
429            SecurityPolicy::ReadWrite { writable_records } => {
430                let existing: hashbrown::HashSet<String> = self
431                    .db
432                    .list_records()
433                    .into_iter()
434                    .map(|m: RecordMetadata| m.record_key)
435                    .collect();
436                let writable = writable_records
437                    .iter()
438                    .filter(|name| existing.contains(name.as_str()))
439                    .cloned()
440                    .collect();
441                (vec!["read".to_string(), "write".to_string()], writable)
442            }
443        };
444        let welcome = WelcomeMessage {
445            version: PROTOCOL_VERSION.to_string(),
446            server: "aimdb".to_string(),
447            permissions,
448            writable_records,
449            max_subscriptions: Some(self.config.max_subs_per_connection),
450            authenticated: Some(false),
451        };
452        json!(welcome)
453    }
454
455    /// Deny unless the policy is ReadWrite and `name` is in its writable set.
456    fn ensure_writable(&self, name: &str) -> Result<(), RpcError> {
457        match &self.config.security_policy {
458            SecurityPolicy::ReadWrite { writable_records } if writable_records.contains(name) => {
459                Ok(())
460            }
461            _ => Err(RpcError::Denied),
462        }
463    }
464
465    /// Deny under a ReadOnly policy (used by the `*.reset` admin methods).
466    #[cfg(feature = "observability")]
467    fn ensure_write_permission(&self) -> Result<(), RpcError> {
468        match self.config.security_policy {
469            SecurityPolicy::ReadOnly => Err(RpcError::Denied),
470            SecurityPolicy::ReadWrite { .. } => Ok(()),
471        }
472    }
473}
474
475/// Serialize a JSON value into an owned record-value [`Payload`] — one serde
476/// pass at the reply boundary.
477fn to_payload(v: &Value) -> Payload {
478    Payload::from(serde_json::to_vec(v).unwrap_or_default().as_slice())
479}
480
481/// Extract a string field from a params object.
482fn str_field(params: &Value, key: &str) -> Option<String> {
483    params.get(key).and_then(|v| v.as_str()).map(String::from)
484}
485
486/// Map a [`DbError`] onto the reshaped wire's coarse [`RpcError`] set.
487fn map_db_err(e: DbError) -> RpcError {
488    match e {
489        DbError::RecordKeyNotFound { .. } | DbError::InvalidRecordId { .. } => RpcError::NotFound,
490        DbError::PermissionDenied { .. } => RpcError::Denied,
491        _ => RpcError::Internal,
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::remote::{QueryHandlerFn, QUERY_ALL_PATTERN};
499    use std::sync::Mutex;
500
501    /// `record.query` with no `name` must reach the handler as the same pattern
502    /// on every transport. It is a *record set*, not a formatting detail: under
503    /// the MQTT grammar `*` spans exactly one dot-separated segment, so the old
504    /// default silently dropped every nested record here while the WS connector
505    /// returned them — the same request answering differently per link.
506    ///
507    /// Driven through the real dispatch (`Dispatch::open` → `Session::call`),
508    /// since the defaulting lives in `record_query`, not at the wire edge. The
509    /// WS dispatch defaults through the same constant, so the two cannot
510    /// drift again.
511    #[tokio::test]
512    async fn an_omitted_query_name_spans_every_record() {
513        static SEEN: Mutex<Option<String>> = Mutex::new(None);
514
515        let handler: QueryHandlerFn = Box::new(|params| {
516            *SEEN.lock().expect("uncontended") = Some(params.name.clone());
517            Box::pin(async { Ok(json!({ "records": [], "total": 0 })) })
518        });
519
520        let mut builder = crate::AimDbBuilder::new()
521            .runtime(Arc::new(crate::executor::test_support::NoopRuntimeOps));
522        builder.extensions_mut().insert(handler);
523        let (db, _runner) = builder.build().await.expect("build db");
524
525        let dispatch = AimxDispatch::new(Arc::new(db), AimxConfig::uds_default());
526        let mut session = dispatch.open(&SessionCtx::default());
527        session
528            .call("record.query", to_payload(&json!({})))
529            .await
530            .expect("the query resolves");
531
532        assert_eq!(
533            SEEN.lock().expect("uncontended").as_deref(),
534            Some(QUERY_ALL_PATTERN),
535            "an omitted name must span every record, at every depth"
536        );
537    }
538}