Skip to main content

superdoc_sdk/
client.rs

1use crate::{
2    generated::{
3        operations::DocApi,
4        types::{DocCloseBoundParams, DocCloseResult, DocOpenParams, DocOpenResult},
5    },
6    invoke::Invoker,
7    runtime::{
8        contract, policy,
9        process::{CallState, HostMode, HostOptions, Transport},
10        routing,
11    },
12    Error, InvokeOptions, Result,
13};
14use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15use serde_json::{json, Value};
16use std::{
17    collections::BTreeSet,
18    path::Path,
19    sync::{
20        atomic::{AtomicU64, AtomicU8, Ordering},
21        Arc, Mutex,
22    },
23    time::{Duration, SystemTime, UNIX_EPOCH},
24};
25use tokio::sync::broadcast;
26
27#[derive(Clone)]
28pub struct CollaborationAuth {
29    token: String,
30}
31impl CollaborationAuth {
32    pub fn token(token: impl Into<String>) -> Result<Self> {
33        let token = token.into();
34        if token.is_empty() {
35            return Err(Error::new(
36                "INVALID_ARGUMENT",
37                "collaboration token must not be empty",
38            ));
39        }
40        Ok(Self { token })
41    }
42    fn json(&self) -> Value {
43        json!({"type":"token","token":self.token})
44    }
45}
46impl std::fmt::Debug for CollaborationAuth {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str("CollaborationAuth([redacted])")
49    }
50}
51
52#[derive(Clone, Debug, Default)]
53pub struct OpenOptions {
54    pub invoke: InvokeOptions,
55    pub collaboration_auth: Option<CollaborationAuth>,
56}
57
58#[derive(Clone, Default)]
59pub struct ClientOptions {
60    pub default_change_mode: Option<String>,
61    pub user_name: Option<String>,
62    pub user_email: Option<String>,
63    /// Opt in to negotiated document RPC on the CLI host. Collaboration and
64    /// other ineligible opens keep their CLI route before any request is sent.
65    pub document_rpc: bool,
66}
67
68struct ClientInner {
69    transport: Transport,
70    options: ClientOptions,
71    opening: Mutex<BTreeSet<String>>,
72}
73
74#[derive(Clone)]
75pub struct Client(Arc<ClientInner>);
76
77struct OpenGuard {
78    client: Client,
79    id: String,
80}
81impl Drop for OpenGuard {
82    fn drop(&mut self) {
83        self.client
84            .0
85            .opening
86            .lock()
87            .expect("SDK opening lock")
88            .remove(&self.id);
89    }
90}
91
92impl Client {
93    pub async fn connect(host: HostOptions) -> Result<Self> {
94        Self::connect_with_options(host, ClientOptions::default()).await
95    }
96
97    pub async fn connect_with_options(host: HostOptions, options: ClientOptions) -> Result<Self> {
98        if options
99            .default_change_mode
100            .as_deref()
101            .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
102        {
103            return Err(Error::new(
104                "INVALID_ARGUMENT",
105                "default change mode must be direct or tracked",
106            ));
107        }
108        if options
109            .user_name
110            .as_deref()
111            .is_some_and(|name| name.trim().is_empty())
112            || (options.user_email.is_some() && options.user_name.is_none())
113        {
114            return Err(Error::new(
115                "INVALID_ARGUMENT",
116                "user identity requires a non-empty name",
117            ));
118        }
119        Ok(Self(Arc::new(ClientInner {
120            transport: Transport::connect(host).await?,
121            options,
122            opening: Mutex::new(BTreeSet::new()),
123        })))
124    }
125
126    /// Local contract introspection requires no running host.
127    pub fn describe() -> Value {
128        contract()["introspection"]["overview"].clone()
129    }
130
131    pub fn describe_command(lookup: &str) -> Result<Value> {
132        let index = contract()["introspection"]["lookup"][lookup.trim().to_lowercase()]
133            .as_u64()
134            .ok_or_else(|| {
135                Error::new(
136                    "OPERATION_NOT_FOUND",
137                    "no contract operation matches the requested lookup",
138                )
139            })?;
140        Ok(contract()["introspection"]["overview"]["operations"][index as usize].clone())
141    }
142
143    pub fn subscribe(&self) -> Notifications {
144        Notifications(self.0.transport.subscribe())
145    }
146    pub fn state_directory(&self) -> &Path {
147        self.0.transport.state_directory()
148    }
149
150    pub async fn open(&self, params: &DocOpenParams) -> Result<Document> {
151        self.open_with_options(params, OpenOptions::default()).await
152    }
153
154    pub async fn open_with_options(
155        &self,
156        params: &DocOpenParams,
157        options: OpenOptions,
158    ) -> Result<Document> {
159        let mut params = serde_json::to_value(params)
160            .map_err(|_| Error::new("INVALID_ARGUMENT", "open parameters could not be encoded"))?;
161        let session_id = match params.get("sessionId") {
162            Some(value) => value
163                .as_str()
164                .filter(|id| !id.is_empty())
165                .ok_or_else(|| Error::new("INVALID_ARGUMENT", "session ID must be non-empty"))?
166                .to_owned(),
167            None => fresh_session_id(),
168        };
169        {
170            let mut opening = self.0.opening.lock().expect("SDK opening lock");
171            if self.0.transport.has_session(&session_id) || !opening.insert(session_id.clone()) {
172                return Err(Error::new(
173                    "SESSION_ALREADY_OPEN",
174                    "session ID is already open or opening",
175                ));
176            }
177        }
178        let _guard = OpenGuard {
179            client: self.clone(),
180            id: session_id.clone(),
181        };
182        params["sessionId"] = json!(session_id);
183        let features = self.0.transport.features();
184        let structured = options.collaboration_auth.is_none()
185            && routing::can_open_document(&params, &options.invoke)
186            && (self.0.transport.options().mode == HostMode::Document
187                || self.0.options.document_rpc)
188            && policy()["documentRpcFeatures"]
189                .as_array()
190                .is_some_and(|required| {
191                    required
192                        .iter()
193                        .all(|feature| features.contains(feature.as_str().unwrap_or("")))
194                });
195        if self.0.transport.options().mode == HostMode::Document && !structured {
196            return Err(Error::new(
197                "DOCUMENT_RPC_INPUT_UNSUPPORTED",
198                "standalone document host cannot support these open parameters",
199            ));
200        }
201        let result = if structured {
202            if self.0.options.default_change_mode.is_some() {
203                self.require_feature("defaultChangeMode")?;
204            }
205            if self.0.options.user_name.is_some() {
206                self.require_feature("documentAuthor")?;
207            }
208            let mut request = json!({"sessionId":session_id,"path":params["doc"]});
209            if let Some(mode) = &self.0.options.default_change_mode {
210                request["defaultChangeMode"] = json!(mode);
211            }
212            if let Some(name) = &self.0.options.user_name {
213                let mut author = json!({"name":name});
214                if let Some(email) = &self.0.options.user_email {
215                    author["email"] = json!(email);
216                }
217                request["author"] = author;
218            }
219            self.rpc("document.open", request, &options.invoke, true, true, true)
220                .await
221                .and_then(|response| {
222                    routing::map_lifecycle(
223                        "doc.open",
224                        response,
225                        &session_id,
226                        params["doc"].as_str(),
227                    )
228                })
229        } else {
230            if let Some(name) = &self.0.options.user_name {
231                if params.get("userName").is_none() {
232                    params["userName"] = json!(name);
233                }
234            }
235            if let Some(email) = &self.0.options.user_email {
236                if params.get("userEmail").is_none() {
237                    params["userEmail"] = json!(email);
238                }
239            }
240            self.invoke_cli(
241                "doc.open",
242                params,
243                &options.invoke,
244                options.collaboration_auth.as_ref(),
245            )
246            .await
247        };
248        let result = match result {
249            Ok(value) => serde_json::from_value::<DocOpenResult>(value).map_err(Error::decode),
250            Err(mut error) => {
251                error.operation_id = Some("doc.open".to_owned());
252                if ["TIMEOUT", "HOST_DISCONNECTED", "HOST_PROTOCOL_ERROR"]
253                    .contains(&error.code.as_str())
254                {
255                    let _ = self.shutdown().await;
256                    error.outcome_unknown = true;
257                }
258                return Err(error);
259            }
260        };
261        let result = match result {
262            Ok(result) if result.context_id == session_id && result.active => result,
263            invalid => {
264                // An invalid open result cannot leave an unreachable native
265                // session alive. Retiring also invalidates any sibling handles.
266                let _ = self.shutdown().await;
267                let mut error = match invalid {
268                    Err(error) => error,
269                    Ok(_) => Error::new(
270                        "HOST_PROTOCOL_ERROR",
271                        "host returned an invalid open result",
272                    ),
273                };
274                error.operation_id = Some("doc.open".into());
275                error.outcome_unknown = true;
276                return Err(error);
277            }
278        };
279        let state = self.0.transport.session_state(&session_id);
280        if state.load(Ordering::Acquire) != 0 {
281            return Err(Error::new(
282                "DOCUMENT_CLOSED",
283                "session closed while it was opening",
284            ));
285        }
286        Ok(Document(Arc::new(DocumentInner {
287            client: self.clone(),
288            session_id,
289            state,
290            structured,
291            open_result: result,
292        })))
293    }
294
295    pub async fn shutdown(&self) -> Result<()> {
296        self.0.transport.shutdown().await
297    }
298
299    fn require_feature(&self, key: &str) -> Result<()> {
300        if !self
301            .0
302            .transport
303            .features()
304            .contains(policy()["features"][key].as_str().unwrap_or(""))
305        {
306            return Err(Error::new(
307                "CAPABILITY_UNSUPPORTED",
308                "connected host lacks the requested optional capability",
309            ));
310        }
311        Ok(())
312    }
313
314    async fn rpc(
315        &self,
316        method: &str,
317        params: Value,
318        options: &InvokeOptions,
319        mutates: bool,
320        open: bool,
321        supports_deadline: bool,
322    ) -> Result<Value> {
323        self.rpc_tracked(
324            method,
325            params,
326            options,
327            mutates,
328            open,
329            supports_deadline,
330            None,
331        )
332        .await
333    }
334
335    #[allow(clippy::too_many_arguments)]
336    async fn rpc_tracked(
337        &self,
338        method: &str,
339        params: Value,
340        options: &InvokeOptions,
341        mutates: bool,
342        open: bool,
343        supports_deadline: bool,
344        state: Option<Arc<CallState>>,
345    ) -> Result<Value> {
346        let effective = options
347            .timeout
348            .unwrap_or(self.0.transport.options().request_timeout);
349        let headroom =
350            Duration::from_millis(policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000));
351        if options.timeout.is_some() {
352            self.require_feature("requestTimeout")?;
353            if !supports_deadline {
354                return Err(Error::new(
355                    "DOCUMENT_RPC_INPUT_UNSUPPORTED",
356                    "this native operation does not support an explicit request deadline",
357                ));
358            }
359        }
360        let deadline = (supports_deadline
361            && self.0.transport.features().contains(
362                policy()["features"]["requestTimeout"]
363                    .as_str()
364                    .unwrap_or(""),
365            ))
366        .then_some(effective);
367        self.0
368            .transport
369            .request_tracked(
370                method,
371                params,
372                deadline,
373                effective.saturating_add(headroom),
374                mutates,
375                open,
376                state.unwrap_or_default(),
377            )
378            .await
379    }
380
381    async fn invoke_cli(
382        &self,
383        id: &str,
384        params: Value,
385        options: &InvokeOptions,
386        auth: Option<&CollaborationAuth>,
387    ) -> Result<Value> {
388        self.invoke_cli_tracked(id, params, options, auth, None)
389            .await
390    }
391
392    async fn invoke_cli_tracked(
393        &self,
394        id: &str,
395        params: Value,
396        options: &InvokeOptions,
397        auth: Option<&CollaborationAuth>,
398        state: Option<Arc<CallState>>,
399    ) -> Result<Value> {
400        if self.0.transport.options().mode != HostMode::Cli {
401            return Err(Error::new(
402                "DOCUMENT_RPC_INPUT_UNSUPPORTED",
403                "operation requires the CLI host",
404            ));
405        }
406        let operation = operation(id)?;
407        if auth.is_some() {
408            if id != "doc.open" {
409                return Err(Error::new(
410                    "INVALID_ARGUMENT",
411                    "authentication is scoped to document open",
412                ));
413            }
414            self.require_feature("collaborationAuthPerOpen")?;
415        }
416        if options
417            .stdin_bytes
418            .as_ref()
419            .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
420        {
421            return Err(Error::new(
422                "PAYLOAD_TOO_LARGE",
423                "stdin exceeds the host's 32 MiB limit",
424            ));
425        }
426        validate_json(&params)?;
427        let mut request = json!({"argv":routing::argv(operation,&params,options,self.0.options.default_change_mode.as_deref())?,"stdinBase64":options.stdin_bytes.as_ref().map(|bytes|BASE64.encode(bytes)).unwrap_or_default()});
428        if let Some(auth) = auth {
429            request["collaborationAuth"] = auth.json();
430        }
431        let response = self
432            .rpc_tracked(
433                "cli.invoke",
434                request,
435                options,
436                operation["mutates"] == true,
437                id == "doc.open",
438                true,
439                state,
440            )
441            .await?;
442        routing::map_cli(operation, response).map_err(|mut error| {
443            error.outcome_unknown = operation["mutates"] == true;
444            error
445        })
446    }
447
448    pub async fn get_tools(&self, provider: &str) -> Result<Value> {
449        self.invoke_cli(
450            "doc.preset.getTools",
451            json!({"provider":provider,"preset":"core"}),
452            &InvokeOptions::default(),
453            None,
454        )
455        .await
456    }
457
458    pub async fn get_system_prompt(&self) -> Result<Value> {
459        self.invoke_cli(
460            "doc.preset.getSystemPrompt",
461            json!({"preset":"core"}),
462            &InvokeOptions::default(),
463            None,
464        )
465        .await
466    }
467}
468
469fn operation(id: &str) -> Result<&'static Value> {
470    contract()["operations"].get(id).ok_or_else(|| {
471        Error::new(
472            "OPERATION_NOT_FOUND",
473            "operation is not in the generated contract",
474        )
475    })
476}
477
478fn fresh_session_id() -> String {
479    static NEXT: AtomicU64 = AtomicU64::new(1);
480    format!(
481        "rust-{}-{}-{}",
482        std::process::id(),
483        SystemTime::now()
484            .duration_since(UNIX_EPOCH)
485            .unwrap_or_default()
486            .as_nanos(),
487        NEXT.fetch_add(1, Ordering::Relaxed)
488    )
489}
490
491fn validate_json(value: &Value) -> Result<()> {
492    let mut stack = vec![(value, 0)];
493    while let Some((value, depth)) = stack.pop() {
494        if depth > 128 {
495            return Err(Error::new(
496                "INVALID_ARGUMENT",
497                "request JSON exceeds the supported nesting depth",
498            ));
499        }
500        match value {
501            Value::Number(number)
502                if number.is_i64()
503                    && number.as_i64().is_some_and(|n| {
504                        !(-crate::wire::Integer::MAX..=crate::wire::Integer::MAX).contains(&n)
505                    }) =>
506            {
507                return Err(Error::new(
508                    "INVALID_ARGUMENT",
509                    "JSON integer cannot be represented exactly by the host",
510                ))
511            }
512            Value::Number(number)
513                if number.is_u64()
514                    && number
515                        .as_u64()
516                        .is_some_and(|n| n > crate::wire::Integer::MAX as u64) =>
517            {
518                return Err(Error::new(
519                    "INVALID_ARGUMENT",
520                    "JSON integer cannot be represented exactly by the host",
521                ))
522            }
523            Value::Array(values) => stack.extend(values.iter().map(|value| (value, depth + 1))),
524            Value::Object(values) => stack.extend(values.values().map(|value| (value, depth + 1))),
525            _ => {}
526        }
527    }
528    Ok(())
529}
530
531struct DocumentInner {
532    client: Client,
533    session_id: String,
534    // Active, closing, closed, or outcome unknown. All clones share this state.
535    state: Arc<AtomicU8>,
536    structured: bool,
537    open_result: DocOpenResult,
538}
539
540impl Drop for DocumentInner {
541    fn drop(&mut self) {
542        if self.state.load(Ordering::Acquire) >= 2 {
543            self.client
544                .0
545                .transport
546                .remove_session(&self.session_id, &self.state);
547        }
548    }
549}
550
551#[derive(Clone)]
552pub struct Document(Arc<DocumentInner>);
553
554struct CloseGuard {
555    state: Arc<AtomicU8>,
556    transmission: Arc<CallState>,
557}
558impl Drop for CloseGuard {
559    fn drop(&mut self) {
560        let _ = self.state.compare_exchange(
561            1,
562            if self.transmission.cancel_before_send() {
563                0
564            } else {
565                3
566            },
567            Ordering::AcqRel,
568            Ordering::Acquire,
569        );
570    }
571}
572
573impl Document {
574    pub fn session_id(&self) -> &str {
575        &self.0.session_id
576    }
577    pub fn open_result(&self) -> &DocOpenResult {
578        &self.0.open_result
579    }
580    pub fn api(&self) -> DocApi<'_, Self> {
581        DocApi::new(self)
582    }
583
584    pub async fn close(&self, params: &DocCloseBoundParams) -> Result<DocCloseResult> {
585        self.api().close(params).await
586    }
587
588    pub async fn replace_file(
589        &self,
590        path: impl AsRef<Path>,
591        options: InvokeOptions,
592    ) -> Result<Value> {
593        self.replace_path(path.as_ref(), options, None).await
594    }
595
596    /// The transport owns staged bytes until the native reply or confirmed exit,
597    /// including when the caller drops this future after transmission.
598    pub async fn replace_bytes(&self, bytes: &[u8], options: InvokeOptions) -> Result<Value> {
599        use std::io::Write;
600        if bytes.len() > 32 * 1024 * 1024 {
601            return Err(Error::new(
602                "PAYLOAD_TOO_LARGE",
603                "replacement exceeds 32 MiB",
604            ));
605        }
606        self.active()?;
607        let mut file = tempfile::NamedTempFile::new_in(self.0.client.state_directory())
608            .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
609        file.write_all(bytes)
610            .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
611        let asset = file.into_temp_path();
612        let path = asset.to_path_buf();
613        self.replace_path(&path, options, Some(CallState::with_asset(asset)))
614            .await
615    }
616
617    async fn replace_path(
618        &self,
619        path: &Path,
620        options: InvokeOptions,
621        state: Option<Arc<CallState>>,
622    ) -> Result<Value> {
623        self.active()?;
624        self.0.client.require_feature("replaceFile")?;
625        let path = path
626            .to_str()
627            .ok_or_else(|| Error::new("INVALID_ARGUMENT", "replacement path must be UTF-8"))?;
628        let value = self
629            .0
630            .client
631            .rpc_tracked(
632                "document.replaceFile",
633                json!({"sessionId":self.0.session_id,"path":path}),
634                &options,
635                true,
636                false,
637                false,
638                state,
639            )
640            .await?;
641        if value["sessionId"] != self.0.session_id
642            || value["replaced"] != true
643            || !value["byteLength"].is_number()
644            || !value["revision"].is_string()
645        {
646            let mut error = Error::new(
647                "HOST_PROTOCOL_ERROR",
648                "host returned an invalid replacement result",
649            );
650            error.outcome_unknown = true;
651            return Err(error);
652        }
653        Ok(
654            json!({"contextId":self.0.session_id,"runtime":"v2","replaced":true,"document":{"byteLength":value["byteLength"],"revision":value["revision"]}}),
655        )
656    }
657
658    pub async fn dispatch_tool(
659        &self,
660        name: &str,
661        args: Value,
662        options: InvokeOptions,
663    ) -> Result<Value> {
664        self.active()?;
665        self.0
666            .client
667            .invoke_cli(
668                "doc.preset.dispatch",
669                json!({"sessionId":self.0.session_id,"toolName":name,"args":args,"preset":"core"}),
670                &options,
671                None,
672            )
673            .await
674    }
675
676    fn active(&self) -> Result<()> {
677        match self.0.state.load(Ordering::Acquire) {
678            0 if !self.0.client.0.transport.is_closed() => Ok(()),
679            3 => Err(Error::new(
680                "DOCUMENT_STATE_UNKNOWN",
681                "document state is uncertain; use a new client to reopen verified saved content",
682            )),
683            _ => Err(Error::new(
684                "DOCUMENT_CLOSED",
685                "document handle is closed or closing",
686            )),
687        }
688    }
689}
690
691impl Invoker for Document {
692    async fn invoke(
693        &self,
694        id: &str,
695        mut params: Value,
696        mut options: InvokeOptions,
697    ) -> Result<Value> {
698        self.active()?;
699        let operation = operation(id)?;
700        if operation["sdkSurface"] != "document" {
701            return Err(Error::new(
702                "INVALID_ARGUMENT",
703                "operation is not on the public document surface",
704            ));
705        }
706        let binds_session = operation["params"]
707            .as_array()
708            .is_some_and(|params| params.iter().any(|param| param["name"] == "sessionId"));
709        if binds_session {
710            params["sessionId"] = json!(self.0.session_id);
711        }
712        let closing = id == "doc.close";
713        let transmission = closing.then(|| Arc::new(CallState::default()));
714        let _close = if closing {
715            self.0
716                .state
717                .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
718                .map_err(|_| Error::new("DOCUMENT_CLOSED", "document is already closing"))?;
719            Some(CloseGuard {
720                state: self.0.state.clone(),
721                transmission: transmission.clone().expect("close transmission state"),
722            })
723        } else {
724            None
725        };
726        if options.change_mode.is_none()
727            && operation["params"]
728                .as_array()
729                .is_some_and(|params| params.iter().any(|param| param["name"] == "changeMode"))
730        {
731            options.change_mode = self.0.client.0.options.default_change_mode.clone();
732        }
733        let result: Result<Value> = async {
734            if self.0.structured && binds_session {
735                let (method, request) = routing::document_request(
736                    &self.0.session_id,
737                    operation,
738                    &params,
739                    &options,
740                    self.0.client.0.transport.features(),
741                )?;
742                let safe_deadline = method != "document.save"
743                    && (operation["mutates"] == false
744                        || operation["idempotency"] == "idempotent"
745                        || request["options"]["dryRun"] == true
746                        || method != "document.invoke");
747                validate_json(&request)?;
748                let response = self
749                    .0
750                    .client
751                    .rpc_tracked(
752                        &method,
753                        request,
754                        &options,
755                        operation["mutates"] == true,
756                        false,
757                        safe_deadline,
758                        transmission.clone(),
759                    )
760                    .await;
761                response.and_then(|response| {
762                    if id == "doc.save" || closing {
763                        routing::map_lifecycle(id, response, &self.0.session_id, None).map_err(
764                            |mut error| {
765                                error.outcome_unknown = operation["mutates"] == true;
766                                error
767                            },
768                        )
769                    } else {
770                        Ok(response)
771                    }
772                })
773            } else {
774                self.0
775                    .client
776                    .invoke_cli_tracked(id, params, &options, None, transmission)
777                    .await
778            }
779        }
780        .await;
781        let result = result.and_then(|value| routing::map_domain_result(operation, value));
782        match result {
783            Ok(value) => {
784                if closing {
785                    if value["closed"] != true || value["contextId"] != self.0.session_id {
786                        let mut error = Error::new(
787                            "HOST_PROTOCOL_ERROR",
788                            "close was not confirmed by the host",
789                        );
790                        error.outcome_unknown = true;
791                        return Err(error);
792                    }
793                    self.0.state.store(2, Ordering::Release);
794                    self.0
795                        .client
796                        .0
797                        .transport
798                        .remove_session(&self.0.session_id, &self.0.state);
799                }
800                Ok(value)
801            }
802            Err(mut error) => {
803                error.operation_id = Some(id.to_owned());
804                if closing && !error.outcome_unknown && !self.0.client.0.transport.is_closed() {
805                    let _ =
806                        self.0
807                            .state
808                            .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
809                }
810                Err(error)
811            }
812        }
813    }
814}
815
816/// Lag is explicit; callers must resynchronize rather than assume every remote
817/// change notification was observed. Session invalidation runs in the reader's
818/// owner independently of this public notification stream.
819pub struct Notifications(broadcast::Receiver<Value>);
820impl Notifications {
821    pub async fn recv(&mut self) -> Result<Value> {
822        self.0.recv().await.map_err(|error| match error {
823            broadcast::error::RecvError::Lagged(_) => Error::new(
824                "NOTIFICATIONS_LAGGED",
825                "resynchronize document state before using notification-derived caches",
826            ),
827            broadcast::error::RecvError::Closed => {
828                Error::new("HOST_DISCONNECTED", "notification stream closed")
829            }
830        })
831    }
832}