1use alloc::format;
2use alloc::string::{String, ToString};
3use alloc::sync::Arc;
4use core::fmt;
5use core::marker::PhantomData;
6use core::pin::Pin;
7use core::task::{Context, Poll};
8
9use futures_core::Stream;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12
13use crate::{
14 ChannelHandle, ConversationEvent, ConversationHandle, ConversationId, PressureResponse,
15 SchemaMetadata, SchemaValidate, SdkError,
16};
17
18#[cfg(test)]
19mod tests;
20
21pub trait EmbeddedChannelMessage {
26 #[must_use]
28 fn schema_metadata(&self) -> SchemaMetadata;
29
30 #[must_use]
32 fn type_name(&self) -> &'static str;
33}
34
35impl<M> EmbeddedChannelMessage for M
36where
37 M: Serialize + SchemaValidate,
38{
39 fn schema_metadata(&self) -> SchemaMetadata {
40 M::schema_metadata()
41 }
42
43 fn type_name(&self) -> &'static str {
44 core::any::type_name::<M>()
45 }
46}
47
48pub trait EmbeddedConversationMessage {
50 #[must_use]
52 fn type_name(&self) -> &'static str;
53}
54
55impl<M> EmbeddedConversationMessage for M
56where
57 M: Serialize,
58{
59 fn type_name(&self) -> &'static str {
60 core::any::type_name::<M>()
61 }
62}
63
64pub struct SdkSubscription<M> {
66 pending_error: Option<SdkError>,
67 message: PhantomData<M>,
68}
69
70impl<M> SdkSubscription<M> {
71 #[must_use]
73 pub const fn empty() -> Self {
74 Self {
75 pending_error: None,
76 message: PhantomData,
77 }
78 }
79
80 #[must_use]
82 pub const fn error(error: SdkError) -> Self {
83 Self {
84 pending_error: Some(error),
85 message: PhantomData,
86 }
87 }
88
89 #[must_use]
91 pub const fn is_empty(&self) -> bool {
92 self.pending_error.is_none()
93 }
94}
95
96impl<M> Default for SdkSubscription<M> {
97 fn default() -> Self {
98 Self::empty()
99 }
100}
101
102impl<M> fmt::Debug for SdkSubscription<M> {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter
105 .debug_struct("SdkSubscription")
106 .field("has_pending_error", &self.pending_error.is_some())
107 .finish()
108 }
109}
110
111impl<M> Unpin for SdkSubscription<M> {}
112
113impl<M> Stream for SdkSubscription<M> {
114 type Item = Result<M, SdkError>;
115
116 fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
117 core::hint::black_box(context.waker());
118 Poll::Ready(self.pending_error.take().map(Err))
119 }
120}
121
122#[derive(Clone, Debug, Default)]
124pub struct EmptyLifecycleStream;
125
126impl Unpin for EmptyLifecycleStream {}
127
128impl Stream for EmptyLifecycleStream {
129 type Item = ConversationEvent;
130
131 fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
132 core::hint::black_box((self, context.waker()));
133 Poll::Ready(None)
134 }
135}
136
137pub struct ReadyResult<T> {
139 result: Option<Result<T, SdkError>>,
140}
141
142impl<T> fmt::Debug for ReadyResult<T> {
143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144 formatter
145 .debug_struct("ReadyResult")
146 .field("is_ready", &self.result.is_some())
147 .finish()
148 }
149}
150
151impl<T> ReadyResult<T> {
152 #[must_use]
154 pub const fn new(result: Result<T, SdkError>) -> Self {
155 Self {
156 result: Some(result),
157 }
158 }
159}
160
161impl<T> Unpin for ReadyResult<T> {}
162
163impl<T> core::future::Future for ReadyResult<T> {
164 type Output = Result<T, SdkError>;
165
166 fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
167 core::hint::black_box(context.waker());
168 let Some(result) = self.result.take() else {
169 return Poll::Ready(Err(SdkError::Protocol {
170 description: "ready future polled after completion".to_string(),
171 }));
172 };
173
174 Poll::Ready(result)
175 }
176}
177
178pub trait EmbeddedChannelBackend: fmt::Debug + Send + Sync {
185 fn publish(&self, message: &dyn EmbeddedChannelMessage) -> Result<PressureResponse, SdkError>;
191}
192
193pub trait EmbeddedConversationBackend: fmt::Debug + Send + Sync {
195 fn send(&self, message: &dyn EmbeddedConversationMessage) -> Result<(), SdkError>;
201}
202
203const B1_SEAM: &str = "seam B1 — Embedded/frame-conv integration \
205 (docs/stack-review/liminal-ledger.md)";
206
207fn unwired_embedded_subscribe() -> SdkError {
226 SdkError::Unwired {
227 surface: "typed subscribe delivery (ChannelHandle::subscribe on an embedded handle)"
228 .to_string(),
229 seam: "no ledger row records this gap; B1 covers the no-op embedded backend, which is \
230 publish-only and has no subscription surface at all"
231 .to_string(),
232 alternative: "embedded mode has no in-process delivery path to point at. For real \
233 delivery use a remote handle against a running server: \
234 SubscriptionStream::open plus SubscriptionStream::recv_timeout, or \
235 WebSocketSubscriptionStream::open on the WebSocket transport"
236 .to_string(),
237 }
238}
239
240#[derive(Clone, Debug, Default)]
256pub struct DirectEmbeddedChannelBackend;
257
258impl EmbeddedChannelBackend for DirectEmbeddedChannelBackend {
259 fn publish(&self, message: &dyn EmbeddedChannelMessage) -> Result<PressureResponse, SdkError> {
260 Err(SdkError::Unwired {
261 surface: format!(
262 "the default embedded channel backend (publish of {})",
263 message.type_name()
264 ),
265 seam: B1_SEAM.to_string(),
266 alternative: "install an in-process backend that genuinely delivers with \
267 EmbeddedConfig::with_channel_backend"
268 .to_string(),
269 })
270 }
271}
272
273#[derive(Clone, Debug, Default)]
281pub struct DirectEmbeddedConversationBackend;
282
283impl EmbeddedConversationBackend for DirectEmbeddedConversationBackend {
284 fn send(&self, message: &dyn EmbeddedConversationMessage) -> Result<(), SdkError> {
285 Err(SdkError::Unwired {
286 surface: format!(
287 "the default embedded conversation backend (send of {})",
288 message.type_name()
289 ),
290 seam: B1_SEAM.to_string(),
291 alternative: "install an in-process backend that genuinely delivers with \
292 EmbeddedConfig::with_conversation_backend"
293 .to_string(),
294 })
295 }
296}
297
298#[derive(Clone, Debug)]
300pub struct EmbeddedConfig {
301 pub channel_name: String,
303 pub conversation_id: ConversationId,
305 pub channel_backend: Arc<dyn EmbeddedChannelBackend>,
307 pub conversation_backend: Arc<dyn EmbeddedConversationBackend>,
309}
310
311impl EmbeddedConfig {
312 #[must_use]
322 pub fn new(
323 channel_name: impl Into<String>,
324 conversation_id: impl Into<ConversationId>,
325 ) -> Self {
326 Self {
327 channel_name: channel_name.into(),
328 conversation_id: conversation_id.into(),
329 channel_backend: Arc::new(DirectEmbeddedChannelBackend),
330 conversation_backend: Arc::new(DirectEmbeddedConversationBackend),
331 }
332 }
333
334 #[must_use]
341 pub fn with_channel_backend(mut self, backend: Arc<dyn EmbeddedChannelBackend>) -> Self {
342 self.channel_backend = backend;
343 self
344 }
345
346 #[must_use]
348 pub fn with_conversation_backend(
349 mut self,
350 backend: Arc<dyn EmbeddedConversationBackend>,
351 ) -> Self {
352 self.conversation_backend = backend;
353 self
354 }
355}
356
357#[derive(Clone, Debug)]
364pub struct EmbeddedChannelHandle {
365 channel_name: String,
366 backend: Arc<dyn EmbeddedChannelBackend>,
367}
368
369impl EmbeddedChannelHandle {
370 #[must_use]
372 pub fn new(config: &EmbeddedConfig) -> Self {
373 Self {
374 channel_name: config.channel_name.clone(),
375 backend: Arc::clone(&config.channel_backend),
376 }
377 }
378
379 #[must_use]
381 pub fn channel_name(&self) -> &str {
382 self.channel_name.as_str()
383 }
384}
385
386impl ChannelHandle for EmbeddedChannelHandle {
387 type Subscription<M>
388 = SdkSubscription<M>
389 where
390 M: DeserializeOwned;
391
392 type ReplyFuture<'a, Resp>
393 = ReadyResult<Resp>
394 where
395 Self: 'a,
396 Resp: DeserializeOwned + 'a;
397
398 fn publish<M>(&self, message: M) -> Result<PressureResponse, SdkError>
399 where
400 M: Serialize + SchemaValidate,
401 {
402 self.backend.publish(&message)
403 }
404
405 fn subscribe<M>(&self) -> Self::Subscription<M>
425 where
426 M: DeserializeOwned,
427 {
428 SdkSubscription::error(unwired_embedded_subscribe())
429 }
430
431 fn request_reply<Req, Resp>(&self, request: Req) -> ReadyResult<Resp>
432 where
433 Req: Serialize + SchemaValidate,
434 Resp: DeserializeOwned,
435 {
436 let schema = Req::schema_metadata();
437 core::hint::black_box((&request, &schema));
438 ReadyResult::new(Err(SdkError::Protocol {
439 description: "embedded request/reply requires an in-process responder backend"
440 .to_string(),
441 }))
442 }
443}
444
445#[derive(Clone, Debug)]
447pub struct EmbeddedConversationHandle {
448 conversation_id: ConversationId,
449 backend: Arc<dyn EmbeddedConversationBackend>,
450}
451
452impl EmbeddedConversationHandle {
453 #[must_use]
455 pub fn new(config: &EmbeddedConfig) -> Self {
456 Self {
457 conversation_id: config.conversation_id.clone(),
458 backend: Arc::clone(&config.conversation_backend),
459 }
460 }
461
462 #[must_use]
464 pub const fn conversation_id(&self) -> &ConversationId {
465 &self.conversation_id
466 }
467}
468
469impl ConversationHandle for EmbeddedConversationHandle {
470 type ReceiveFuture<'a, M>
471 = ReadyResult<M>
472 where
473 Self: 'a,
474 M: DeserializeOwned + 'a;
475
476 type LifecycleStream = EmptyLifecycleStream;
477
478 fn send<M>(&self, message: M) -> Result<(), SdkError>
479 where
480 M: Serialize,
481 {
482 self.backend.send(&message)
483 }
484
485 fn receive<M>(&self) -> ReadyResult<M>
486 where
487 M: DeserializeOwned,
488 {
489 ReadyResult::new(Err(SdkError::Conversation {
490 conversation_id: self.conversation_id.as_str().to_string(),
491 description: "embedded receive requires an in-process inbox backend".to_string(),
492 }))
493 }
494
495 fn lifecycle(&self) -> Self::LifecycleStream {
496 EmptyLifecycleStream
497 }
498}