Skip to main content

dex_sdk/
client.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Super Durable Source License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
9use std::any::TypeId;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13
14use dex_protocol::dex::flow_service_client::FlowServiceClient;
15use dex_protocol::dex::{
16    ActiveStepSearchMode as ProtoSearchMode, AttributeStoreNames, AttributeWrite,
17    FlowAlreadyStartedOptions, FlowConfig as ProtoFlowConfig, FlowResetStepMethod, FlowResetType,
18    FlowRetryPolicy, FlowStartOptions, FlowStatus as ProtoFlowStatus,
19    FlowTimeoutPolicy as ProtoFlowTimeoutPolicy, GetAttributesRequest, GetFlowSummaryRequest,
20    IdReusePolicy as ProtoIdReusePolicy, InvokeRpcRequest, PublishToChannelRequest,
21    ReadStreamRequest, ResetFlowRequest, SearchFlowsRequest, SetAttributesRequest,
22    SkipTimerRequest, StartFlowRequest, StepDurability as ProtoStepDurability, StopFlowRequest,
23    StopType as ProtoStopType, TriggerContinueAsNewRequest, UpdateFlowConfigRequest,
24    WaitForAttributeCondition, WaitForAttributeEqual, WaitForAttributeRequest, WaitForFlowRequest,
25    WaitForStepCompletionRequest, WorkerTarget as ProtoWorkerTarget, WriteStreamRequest,
26    wait_for_attribute_condition,
27};
28use tokio::runtime::Runtime;
29use tonic::transport::Endpoint;
30use uuid::Uuid;
31
32use crate::sdk_error::{FlowTargetRequirement, ServiceError};
33use crate::stop_flow_options::StopType;
34use crate::time_travel_options::{TimeTravelPoint, TimeTravelStepMethod};
35use crate::value_hydrator::ValueHydrator;
36use crate::value_mapper;
37use crate::worker_dispatcher::map_step_options;
38use crate::{
39    ActiveStepSearchMode, Attribute, AttributeMap, BlobCache, Channel, ChannelMap, ClientOptions,
40    Flow, FlowConfig, FlowErrorType, FlowInfo, FlowResult, FlowStatus, FlowTimeoutPolicy,
41    IdReusePolicy, Registry, RetryPolicy, Rpc, SdkError, SdkResult, SearchFlowEntry,
42    SearchFlowsPage, StartFlowOptions, StepCompletion, StepDurability, StepExecutionId,
43    StopFlowOptions, Stream, StreamMessage, TimeTravelOptions, TimerId, Value, WorkerTarget,
44};
45
46/// Provides blocking, typed control of registered Dex Flows.
47///
48/// Client methods block the calling thread while an internal Tokio runtime performs gRPC and blob
49/// hydration. The Client owns its transport resources; dropping it releases them. Flow, Step, RPC,
50/// Attribute, and Channel arguments are checked against the [`Registry`].
51///
52/// # Examples
53///
54/// ```no_run
55/// use dex_sdk::{BlobCache, BlobCacheConfig, Client, ClientOptions, Registry};
56/// use std::sync::Arc;
57///
58/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
59///     "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
60/// )?)?);
61/// let client = Client::try_new(Registry::new(), cache, ClientOptions::new())?;
62/// client.health_check()?;
63/// # Ok::<(), Box<dyn std::error::Error>>(())
64/// ```
65pub struct Client {
66    runtime: Runtime,
67    registry: Registry,
68    service: FlowServiceClient<tonic::transport::Channel>,
69    hydrator: ValueHydrator,
70    options: ClientOptions,
71}
72
73impl Client {
74    /// Creates a Client or panics when runtime or endpoint initialization fails.
75    ///
76    /// Prefer [`Self::try_new`] when the application can recover from startup configuration errors.
77    ///
78    /// # Panics
79    ///
80    /// Panics if a Tokio runtime cannot be created or the server address is invalid.
81    pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: ClientOptions) -> Self {
82        Self::try_new(registry, blob_cache, options)
83            .unwrap_or_else(|error| panic!("cannot create Dex Client: {error}"))
84    }
85
86    /// Creates a Client from validated definitions, a shared BlobCache, and connection options.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`SdkError::Service`] if runtime or endpoint initialization fails. The transport
91    /// connects lazily, so server availability is checked by the first operation.
92    pub fn try_new(
93        registry: Registry,
94        blob_cache: Arc<BlobCache>,
95        options: ClientOptions,
96    ) -> SdkResult<Self> {
97        let runtime = Runtime::new().map_err(service_error)?;
98        let endpoint = Endpoint::from_shared(endpoint_address(options.server_address_value()))
99            .map_err(service_error)?;
100        let service = {
101            let _runtime_guard = runtime.enter();
102            FlowServiceClient::new(endpoint.connect_lazy())
103        };
104        let hydrator = ValueHydrator::new(service.clone(), blob_cache);
105        Ok(Self {
106            runtime,
107            registry,
108            service,
109            hydrator,
110            options,
111        })
112    }
113
114    /// Starts a registered Flow with server-default options.
115    ///
116    /// Returns the server-assigned run ID. `input` is encoded for the registered starting Step.
117    ///
118    /// # Errors
119    ///
120    /// Returns a definition or mapping error locally, [`SdkError::FlowAlreadyStarted`] for a reuse
121    /// conflict, or another service-backed [`SdkError`] when Dex rejects the request.
122    pub fn start_flow<SomeFlow: Flow>(
123        &self,
124        flow: &SomeFlow,
125        flow_id: &str,
126        input: SomeFlow::StartInput,
127    ) -> SdkResult<String> {
128        self.start_flow_with_options(flow, flow_id, input, StartFlowOptions::new())
129    }
130
131    /// Starts a registered Flow with explicit lifecycle, retry, and initial persistence options.
132    ///
133    /// Returns the server-assigned run ID. A missing request ID is replaced with a generated UUID.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SdkError`] for invalid IDs, unregistered or mismatched Flow types, encoding
138    /// failures, invalid option durations, reuse conflicts, and service failures.
139    pub fn start_flow_with_options<SomeFlow: Flow>(
140        &self,
141        flow: &SomeFlow,
142        flow_id: &str,
143        input: SomeFlow::StartInput,
144        options: StartFlowOptions,
145    ) -> SdkResult<String> {
146        require_name(flow_id, "Flow ID")?;
147        let registered = self.registry.flow(flow.flow_type())?;
148        if registered.type_id != TypeId::of::<SomeFlow>() {
149            return Err(SdkError::FlowDefinition {
150                message: format!(
151                    "Flow {} does not match the registered Rust type",
152                    flow.flow_type()
153                ),
154            });
155        }
156        let (start_step_type, step_input, step_options) = match &registered.start_step {
157            Some(step) => (
158                step.name.to_string(),
159                Some(value_mapper::encode(&input)?),
160                Some(
161                    map_step_options(
162                        registered,
163                        registered
164                            .handler
165                            .step_options(step.name)
166                            .map_err(sdk_handler_error)?,
167                    )
168                    .map_err(sdk_handler_error)?,
169                ),
170            ),
171            None => (String::new(), None, None),
172        };
173        let flow_timeout_seconds = optional_seconds(options.timeout)?;
174        let flow_timeout_policy =
175            map_flow_timeout_policy(registered, flow_timeout_seconds, options.timeout_policy)?;
176        let request = StartFlowRequest {
177            flow_id: flow_id.to_string(),
178            flow_type: registered.name.to_string(),
179            flow_timeout_seconds,
180            flow_timeout_policy,
181            start_step_type,
182            step_input,
183            step_options,
184            flow_start_options: Some(self.map_start_options(registered, &options)?),
185            request_id: options
186                .request_id
187                .clone()
188                .unwrap_or_else(|| Uuid::new_v4().to_string()),
189        };
190        let mut service = self.service.clone();
191        self.runtime.block_on(async {
192            service
193                .start_flow(request)
194                .await
195                .map(|response| response.into_inner().run_id)
196                .map_err(|status| {
197                    SdkError::from_status(
198                        status,
199                        "start_flow",
200                        Some(flow_id),
201                        FlowTargetRequirement::None,
202                    )
203                })
204        })
205    }
206
207    /// Invokes a registered RPC with typed input and decodes its typed output.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`SdkError::RpcLockConflict`] when locks cannot be acquired, WorkerInvocation for a
212    /// handler failure, FlowNotActive for a terminal Flow, or a mapping/service error.
213    pub fn invoke_rpc<Input: Value, Output: Value>(
214        &self,
215        flow_id: &str,
216        rpc: Rpc<Input, Output>,
217        input: Input,
218    ) -> SdkResult<Output> {
219        self.do_invoke_rpc(flow_id, rpc.name(), &input)
220    }
221
222    /// Invokes a registered no-input RPC and decodes its typed output.
223    ///
224    /// Returns the same errors as [`Self::invoke_rpc`].
225    pub fn invoke_rpc_without_input<Output: Value>(
226        &self,
227        flow_id: &str,
228        rpc: Rpc<(), Output>,
229    ) -> SdkResult<Output> {
230        self.do_invoke_rpc(flow_id, rpc.name(), &())
231    }
232
233    /// Reads one Attribute from the current run.
234    ///
235    /// Returns `Ok(None)` when no value is stored.
236    pub fn get_attribute<T: Value>(
237        &self,
238        flow_id: &str,
239        attribute: &Attribute<T>,
240    ) -> SdkResult<Option<T>> {
241        self.get_attribute_value(flow_id, attribute.name())
242    }
243
244    /// Reads one Attribute-map instance from the current run.
245    ///
246    /// Returns `Ok(None)` when the instance is absent.
247    pub fn get_attribute_map_instance<T: Value>(
248        &self,
249        flow_id: &str,
250        attribute: &AttributeMap<T>,
251        instance: &str,
252    ) -> SdkResult<Option<T>> {
253        self.get_attribute_value(
254            flow_id,
255            &crate::registry::physical_name(attribute.name(), instance),
256        )
257    }
258
259    /// Writes one Attribute on an active Flow.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`SdkError::FlowNotActive`], a value-mapping error, or a service failure.
264    pub fn set_attribute<T: Value>(
265        &self,
266        flow_id: &str,
267        attribute: &Attribute<T>,
268        value: T,
269    ) -> SdkResult<()> {
270        self.set_attribute_value(
271            flow_id,
272            attribute.name(),
273            &value,
274            attribute.index().map(|index| index.proto_config(false)),
275            attribute.sync_config(),
276        )
277    }
278
279    /// Writes one Attribute-map instance on an active Flow.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`SdkError::FlowNotActive`], a value-mapping error, or a service failure.
284    pub fn set_attribute_map_instance<T: Value>(
285        &self,
286        flow_id: &str,
287        attribute: &AttributeMap<T>,
288        instance: &str,
289        value: T,
290    ) -> SdkResult<()> {
291        self.set_attribute_value(
292            flow_id,
293            &crate::registry::physical_name(attribute.name(), instance),
294            &value,
295            attribute.index().map(|index| index.proto_config(true)),
296            attribute.sync_config(),
297        )
298    }
299
300    /// Publishes one typed message to a Channel on an active Flow.
301    pub fn publish<T: Value>(
302        &self,
303        flow_id: &str,
304        channel: &Channel<T>,
305        value: T,
306    ) -> SdkResult<()> {
307        self.publish_values(flow_id, channel.name(), [value])
308    }
309
310    /// Publishes an ordered batch of typed messages to one Channel atomically.
311    pub fn publish_many<T: Value>(
312        &self,
313        flow_id: &str,
314        channel: &Channel<T>,
315        values: impl IntoIterator<Item = T>,
316    ) -> SdkResult<()> {
317        self.publish_values(flow_id, channel.name(), values)
318    }
319
320    /// Publishes an ordered batch to one Channel-map instance atomically.
321    pub fn publish_map<T: Value>(
322        &self,
323        flow_id: &str,
324        channel: &ChannelMap<T>,
325        instance: &str,
326        values: impl IntoIterator<Item = T>,
327    ) -> SdkResult<()> {
328        self.publish_values(
329            flow_id,
330            &crate::registry::physical_name(channel.name(), instance),
331            values,
332        )
333    }
334
335    /// Appends one typed best-effort Stream message with client idempotency.
336    ///
337    /// The Flow instance need not exist or be active. Retained duplicate keys are successful
338    /// first-write-wins no-ops.
339    ///
340    /// # Errors
341    ///
342    /// Returns a definition error for an unregistered Stream, InvalidArgument for an empty ID or
343    /// key containing `#`, a mapping error, or a FlowService failure.
344    pub fn write_stream<T: Value>(
345        &self,
346        flow_id: &str,
347        stream: &Stream<T>,
348        idempotency_key: &str,
349        value: T,
350    ) -> SdkResult<()> {
351        require_name(flow_id, "Flow ID")?;
352        require_name(idempotency_key, "Stream idempotency key")?;
353        if idempotency_key.contains('#') {
354            return Err(invalid("Stream client idempotency key must not contain #"));
355        }
356        let flow_type = self.registry.flow_for_stream(stream)?.name.to_string();
357        let request = WriteStreamRequest {
358            flow_id: flow_id.to_string(),
359            flow_type,
360            stream_name: stream.name().to_string(),
361            max_estimated_bytes: stream.max_estimated_bytes(),
362            value: Some(value_mapper::encode(&value)?),
363            idempotency_key: idempotency_key.to_string(),
364        };
365        self.call_empty(
366            "write_stream",
367            Some(flow_id),
368            FlowTargetRequirement::None,
369            |mut service| async move { service.write_stream(request).await },
370        )
371    }
372
373    /// Blocks for the next retained Stream message using the server default wait.
374    ///
375    /// An empty token starts at the retained head. Pass the returned token unchanged to resume.
376    pub fn read_stream<T: Value>(
377        &self,
378        flow_id: &str,
379        stream: &Stream<T>,
380        resume_token: &str,
381    ) -> SdkResult<StreamMessage<T>> {
382        self.read_stream_result(flow_id, stream, resume_token, None)
383    }
384
385    /// Blocks for the next retained Stream message with an explicit server wait.
386    ///
387    /// # Errors
388    ///
389    /// Returns [`SdkError::LongPollTimeout`] when no message arrives, plus the same definition,
390    /// duration, mapping, and service errors as [`Self::read_stream`].
391    pub fn read_stream_with_timeout<T: Value>(
392        &self,
393        flow_id: &str,
394        stream: &Stream<T>,
395        resume_token: &str,
396        timeout: Duration,
397    ) -> SdkResult<StreamMessage<T>> {
398        self.read_stream_result(flow_id, stream, resume_token, Some(timeout))
399    }
400
401    /// Blocks until a Flow closes and returns its terminal result.
402    ///
403    /// # Errors
404    ///
405    /// Returns FlowNotFound for an unknown ID or a mapping/service error. Every terminal status is
406    /// represented by [`FlowResult`]. This overload has no client-side timeout.
407    pub fn wait_for_flow(&self, flow_id: &str) -> SdkResult<FlowResult> {
408        self.wait_for_flow_result(flow_id, None)
409    }
410
411    /// Blocks up to `timeout` and returns all output-bearing completions for a successful Flow.
412    ///
413    /// # Errors
414    ///
415    /// Returns [`SdkError::LongPollTimeout`] when the timeout elapses while the Flow remains active;
416    /// other errors match [`Self::wait_for_flow`].
417    pub fn wait_for_flow_with_timeout(
418        &self,
419        flow_id: &str,
420        timeout: Duration,
421    ) -> SdkResult<FlowResult> {
422        self.wait_for_flow_result(flow_id, Some(timeout))
423    }
424
425    /// Returns current identity, status, type, and start time for the latest run.
426    ///
427    /// # Errors
428    ///
429    /// Returns [`SdkError::FlowNotFound`] for an unknown ID or a service/protocol error.
430    pub fn describe_flow(&self, flow_id: &str) -> SdkResult<FlowInfo> {
431        let mut service = self.service.clone();
432        let response = self.runtime.block_on(async {
433            service
434                .get_flow_summary(GetFlowSummaryRequest {
435                    flow_id: flow_id.to_string(),
436                    run_id: String::new(),
437                })
438                .await
439                .map(|response| response.into_inner())
440                .map_err(|status| {
441                    SdkError::from_status(
442                        status,
443                        "describe_flow",
444                        Some(flow_id),
445                        FlowTargetRequirement::Existing,
446                    )
447                })
448        })?;
449        let execution = response
450            .flow_execution_id
451            .ok_or_else(|| service_error("GetFlowSummary omitted FlowExecutionID"))?;
452        Ok(FlowInfo {
453            flow_id: execution.flow_id,
454            run_id: execution.run_id,
455            flow_type: response.flow_type,
456            status: map_flow_status(response.flow_status)?,
457            started_at: response
458                .start_time
459                .map_or(SystemTime::UNIX_EPOCH, timestamp),
460        })
461    }
462
463    /// Executes a server search query and returns its first page.
464    ///
465    /// `page_size` must be non-negative; zero uses the server default. Indexed values are hydrated
466    /// and decoded as [`serde_json::Value`].
467    pub fn search_flows(&self, query: &str, page_size: i32) -> SdkResult<SearchFlowsPage> {
468        self.search_flows_page(query, page_size, "")
469    }
470
471    /// Continues a search with the opaque token returned by a previous page.
472    ///
473    /// Keep `query` and `page_size` consistent across pages. An empty returned token marks the last
474    /// page.
475    pub fn search_flows_page(
476        &self,
477        query: &str,
478        page_size: i32,
479        next_page_token: &str,
480    ) -> SdkResult<SearchFlowsPage> {
481        if page_size < 0 {
482            return Err(invalid("search page size must not be negative"));
483        }
484        let mut service = self.service.clone();
485        self.runtime.block_on(async {
486            let response = service
487                .search_flows(SearchFlowsRequest {
488                    query: query.to_string(),
489                    page_size,
490                    next_page_token: next_page_token.to_string(),
491                })
492                .await
493                .map(|response| response.into_inner())
494                .map_err(|status| {
495                    SdkError::from_status(status, "search_flows", None, FlowTargetRequirement::None)
496                })?;
497            let values = response
498                .flow_runs
499                .iter()
500                .flat_map(|entry| entry.indexed_attributes.iter())
501                .map(|attribute| {
502                    attribute
503                        .value
504                        .clone()
505                        .ok_or_else(|| invalid("Indexed Attribute has no Value"))
506                })
507                .collect::<SdkResult<Vec<_>>>()?;
508            let mut values = self.hydrator.hydrate_all(values).await?.into_iter();
509            let mut flows = Vec::with_capacity(response.flow_runs.len());
510            for entry in response.flow_runs {
511                let mut indexed_attributes = BTreeMap::new();
512                for attribute in entry.indexed_attributes {
513                    let value = values
514                        .next()
515                        .ok_or_else(|| invalid("Indexed Attribute hydration count mismatch"))?;
516                    indexed_attributes.insert(attribute.key, value_mapper::decode_untyped(&value)?);
517                }
518                flows.push(SearchFlowEntry {
519                    flow_id: entry.flow_id,
520                    run_id: entry.run_id,
521                    flow_type: entry.flow_type,
522                    status: map_flow_status(entry.flow_status)?,
523                    started_at: entry.start_time.map(timestamp),
524                    closed_at: entry.close_time.map(timestamp),
525                    indexed_attributes,
526                });
527            }
528            Ok(SearchFlowsPage {
529                flows,
530                next_page_token: response.next_page_token,
531            })
532        })
533    }
534
535    /// Cancels, terminates, or fails an active Flow according to `options`.
536    ///
537    /// # Errors
538    ///
539    /// Returns [`SdkError::FlowNotActive`] when no active run exists or another service error.
540    pub fn stop_flow(&self, flow_id: &str, options: StopFlowOptions) -> SdkResult<()> {
541        let stop_type = match options.stop_type {
542            StopType::Cancel => ProtoStopType::Cancel,
543            StopType::Terminate => ProtoStopType::Terminate,
544            StopType::Fail => ProtoStopType::Fail,
545        };
546        self.call_empty(
547            "stop_flow",
548            Some(flow_id),
549            FlowTargetRequirement::Active,
550            |mut service| async move {
551                service
552                    .stop_flow(StopFlowRequest {
553                        flow_id: flow_id.to_string(),
554                        run_id: String::new(),
555                        reason: options.reason.unwrap_or_default(),
556                        stop_type: stop_type as i32,
557                    })
558                    .await
559            },
560        )
561    }
562
563    /// Creates a new run by replaying an existing Flow from the selected historical point.
564    ///
565    /// Returns the new server-assigned run ID.
566    ///
567    /// # Errors
568    ///
569    /// Returns InvalidArgument for an unrepresentable time, FlowNotFound for an unknown ID, or
570    /// another service error.
571    pub fn time_travel(&self, flow_id: &str, options: TimeTravelOptions) -> SdkResult<String> {
572        let mut request = ResetFlowRequest {
573            flow_id: flow_id.to_string(),
574            run_id: String::new(),
575            reset_type: FlowResetType::Unspecified as i32,
576            reason: options.reason.unwrap_or_default(),
577            history_event_time: String::new(),
578            step_type: String::new(),
579            step_execution_id: String::new(),
580            skip_writes_reapply: options.skip_writes_reapply,
581            step_method: FlowResetStepMethod::Unspecified as i32,
582        };
583        match options.point {
584            TimeTravelPoint::Beginning => request.reset_type = FlowResetType::Beginning as i32,
585            TimeTravelPoint::HistoryEventTime(time) => {
586                request.reset_type = FlowResetType::HistoryEventTime as i32;
587                request.history_event_time = rfc3339(time)?;
588            }
589            TimeTravelPoint::StepType(step_type) => {
590                request.reset_type = FlowResetType::StepType as i32;
591                request.step_type = step_type.to_string();
592            }
593            TimeTravelPoint::StepExecution(execution, method) => {
594                request.reset_type = FlowResetType::StepExecutionId as i32;
595                request.step_execution_id =
596                    format!("{}-{}", execution.step_type, execution.execution_number);
597                request.step_method = match method {
598                    TimeTravelStepMethod::WaitFor => FlowResetStepMethod::WaitFor as i32,
599                    TimeTravelStepMethod::Execute => FlowResetStepMethod::Execute as i32,
600                };
601            }
602        }
603        let mut service = self.service.clone();
604        self.runtime.block_on(async {
605            service
606                .reset_flow(request)
607                .await
608                .map(|response| response.into_inner().run_id)
609                .map_err(|status| {
610                    SdkError::from_status(
611                        status,
612                        "time_travel",
613                        Some(flow_id),
614                        FlowTargetRequirement::Existing,
615                    )
616                })
617        })
618    }
619
620    /// Marks one timer in an active Step execution as satisfied immediately.
621    ///
622    /// # Errors
623    ///
624    /// Returns InvalidArgument for an oversized index, FlowNotActive for a terminal Flow, or a
625    /// service error when the Step execution or timer cannot be targeted.
626    pub fn skip_timer(
627        &self,
628        flow_id: &str,
629        step_execution: StepExecutionId,
630        timer: TimerId,
631    ) -> SdkResult<()> {
632        let (timer_condition_id, timer_condition_index) = match timer {
633            TimerId::ConditionId(id) => (id, None),
634            TimerId::ConditionIndex(index) => (
635                String::new(),
636                Some(i32::try_from(index).map_err(|_| invalid("timer index exceeds int32"))?),
637            ),
638        };
639        self.call_empty(
640            "skip_timer",
641            Some(flow_id),
642            FlowTargetRequirement::Active,
643            |mut service| async move {
644                service
645                    .skip_timer(SkipTimerRequest {
646                        flow_id: flow_id.to_string(),
647                        run_id: String::new(),
648                        step_execution_id: format!(
649                            "{}-{}",
650                            step_execution.step_type, step_execution.execution_number
651                        ),
652                        timer_condition_id,
653                        timer_condition_index,
654                    })
655                    .await
656            },
657        )
658    }
659
660    /// Blocks until one Step execution completes or `timeout` elapses.
661    ///
662    /// # Errors
663    ///
664    /// Returns [`SdkError::LongPollTimeout`] on timeout, FlowNotActive when appropriate, or another
665    /// service error. Successful completion returns `()` and does not decode Step output.
666    pub fn wait_for_step_completion(
667        &self,
668        flow_id: &str,
669        step_execution: StepExecutionId,
670        timeout: Duration,
671    ) -> SdkResult<()> {
672        let wait_time_seconds = seconds32(timeout)?;
673        self.call_empty(
674            "wait_for_step_completion",
675            Some(flow_id),
676            FlowTargetRequirement::Active,
677            |mut service| async move {
678                service
679                    .wait_for_step_completion(WaitForStepCompletionRequest {
680                        flow_id: flow_id.to_string(),
681                        step_type: step_execution.step_type.to_string(),
682                        step_execution_number: step_execution.execution_number.to_string(),
683                        wait_time_seconds,
684                        request_id: Uuid::new_v4().to_string(),
685                    })
686                    .await
687            },
688        )
689    }
690
691    /// Blocks until a singleton Attribute in the current run equals `expected`.
692    ///
693    /// String, bool, integer, and double values are supported. Object, bytes,
694    /// and null values return [`SdkError::InvalidArgument`] before transport.
695    /// A server-side expiry returns [`SdkError::LongPollTimeout`].
696    pub fn wait_for_attribute_equal<T: Value>(
697        &self,
698        flow_id: &str,
699        attribute: &Attribute<T>,
700        expected: T,
701        timeout: Duration,
702    ) -> SdkResult<()> {
703        self.wait_for_attribute_value(flow_id, attribute.name(), &expected, timeout)
704    }
705
706    /// Blocks until one AttributeMap instance equals `expected`.
707    ///
708    /// This targets the current run and otherwise has the same primitive-value,
709    /// timeout, request-ID, and error behavior as `wait_for_attribute_equal`.
710    pub fn wait_for_attribute_map_instance_equal<T: Value>(
711        &self,
712        flow_id: &str,
713        attribute: &AttributeMap<T>,
714        instance: &str,
715        expected: T,
716        timeout: Duration,
717    ) -> SdkResult<()> {
718        self.wait_for_attribute_value(
719            flow_id,
720            &crate::registry::physical_name(attribute.name(), instance),
721            &expected,
722            timeout,
723        )
724    }
725
726    fn wait_for_attribute_value<T: Value>(
727        &self,
728        flow_id: &str,
729        key: &str,
730        expected: &T,
731        timeout: Duration,
732    ) -> SdkResult<()> {
733        let value = value_mapper::encode(expected)?;
734        if !matches!(
735            value.kind,
736            Some(dex_protocol::dex::value::Kind::StringValue(_))
737                | Some(dex_protocol::dex::value::Kind::BoolValue(_))
738                | Some(dex_protocol::dex::value::Kind::IntValue(_))
739                | Some(dex_protocol::dex::value::Kind::DoubleValue(_))
740        ) {
741            return Err(invalid(
742                "wait_for_attribute_equal supports only string, boolean, or number values",
743            ));
744        }
745        let wait_time_seconds = seconds32(timeout)?;
746        self.call_empty(
747            "wait_for_attribute_equal",
748            Some(flow_id),
749            FlowTargetRequirement::Active,
750            |mut service| async move {
751                service
752                    .wait_for_attribute(WaitForAttributeRequest {
753                        flow_id: flow_id.to_string(),
754                        run_id: String::new(),
755                        condition: Some(WaitForAttributeCondition {
756                            kind: Some(wait_for_attribute_condition::Kind::Equal(
757                                WaitForAttributeEqual {
758                                    key: key.to_string(),
759                                    value: Some(value),
760                                },
761                            )),
762                        }),
763                        wait_time_seconds,
764                        request_id: Uuid::new_v4().to_string(),
765                    })
766                    .await
767            },
768        )
769    }
770
771    /// Replaces mutable runtime configuration fields on an active Flow.
772    ///
773    /// Unset fields retain server-defined behavior.
774    pub fn update_flow_config(&self, flow_id: &str, config: FlowConfig) -> SdkResult<()> {
775        let flow_config = self.map_flow_config(Some(&config))?;
776        self.call_empty(
777            "update_flow_config",
778            Some(flow_id),
779            FlowTargetRequirement::Active,
780            |mut service| async move {
781                service
782                    .update_flow_config(UpdateFlowConfigRequest {
783                        flow_id: flow_id.to_string(),
784                        run_id: String::new(),
785                        flow_config: Some(flow_config),
786                    })
787                    .await
788            },
789        )
790    }
791
792    /// Requests that an active Flow roll its history into a successor run.
793    pub fn trigger_continue_as_new(&self, flow_id: &str) -> SdkResult<()> {
794        self.call_empty(
795            "trigger_continue_as_new",
796            Some(flow_id),
797            FlowTargetRequirement::Active,
798            |mut service| async move {
799                service
800                    .trigger_continue_as_new(TriggerContinueAsNewRequest {
801                        flow_id: flow_id.to_string(),
802                        run_id: String::new(),
803                    })
804                    .await
805            },
806        )
807    }
808
809    /// Verifies that Dex FlowService responds successfully.
810    ///
811    /// Returns `Ok(())` for a healthy service and a service-backed [`SdkError`] otherwise.
812    pub fn health_check(&self) -> SdkResult<()> {
813        self.call_empty(
814            "health_check",
815            None,
816            FlowTargetRequirement::None,
817            |mut service| async move { service.health_check(()).await },
818        )
819    }
820
821    fn do_invoke_rpc<Input: Value, Output: Value>(
822        &self,
823        flow_id: &str,
824        rpc_name: &str,
825        input: &Input,
826    ) -> SdkResult<Output> {
827        let rpc = self.registry.rpc(rpc_name)?;
828        let request = InvokeRpcRequest {
829            flow_id: flow_id.to_string(),
830            run_id: String::new(),
831            rpc_name: rpc_name.to_string(),
832            input: Some(value_mapper::encode(input)?),
833            timeout_seconds: optional_seconds(rpc.timeout)?,
834            lock_attribute_keys: rpc.locks.iter().map(|lock| lock.physical_name()).collect(),
835            request_id: Uuid::new_v4().to_string(),
836        };
837        let mut service = self.service.clone();
838        let output = self.runtime.block_on(async {
839            service
840                .invoke_rpc(request)
841                .await
842                .map(|response| response.into_inner().output)
843                .map_err(|status| {
844                    SdkError::from_status(
845                        status,
846                        "invoke_rpc",
847                        Some(flow_id),
848                        FlowTargetRequirement::Active,
849                    )
850                })
851        })?;
852        let output = output.ok_or_else(|| invalid("InvokeRPC omitted output"))?;
853        let output = self.runtime.block_on(self.hydrator.hydrate(output))?;
854        value_mapper::decode(&output)
855    }
856
857    fn get_attribute_value<T: Value>(&self, flow_id: &str, key: &str) -> SdkResult<Option<T>> {
858        let mut service = self.service.clone();
859        let response = self.runtime.block_on(async {
860            service
861                .get_attributes(GetAttributesRequest {
862                    flow_id: flow_id.to_string(),
863                    run_id: String::new(),
864                    keys: vec![key.to_string()],
865                    all_keys: false,
866                })
867                .await
868                .map(|response| response.into_inner())
869                .map_err(|status| {
870                    SdkError::from_status(
871                        status,
872                        "get_attribute",
873                        Some(flow_id),
874                        FlowTargetRequirement::Existing,
875                    )
876                })
877        })?;
878        let Some(entry) = response.attributes.into_iter().next() else {
879            return Ok(None);
880        };
881        let value = entry
882            .value
883            .ok_or_else(|| invalid("GetAttributes returned an empty Value"))?;
884        let value = self.runtime.block_on(self.hydrator.hydrate(value))?;
885        value_mapper::decode(&value).map(Some)
886    }
887
888    fn set_attribute_value<T: Value>(
889        &self,
890        flow_id: &str,
891        key: &str,
892        value: &T,
893        index_config: Option<dex_protocol::dex::IndexConfig>,
894        sync_config: Option<dex_protocol::dex::AttributeSyncConfig>,
895    ) -> SdkResult<()> {
896        let write = AttributeWrite {
897            key: key.to_string(),
898            value: Some(value_mapper::encode(value)?),
899            index_config,
900            sync_config,
901        };
902        self.call_empty(
903            "set_attribute",
904            Some(flow_id),
905            FlowTargetRequirement::Active,
906            |mut service| async move {
907                service
908                    .set_attributes(SetAttributesRequest {
909                        flow_id: flow_id.to_string(),
910                        run_id: String::new(),
911                        attributes: vec![write],
912                        request_id: Uuid::new_v4().to_string(),
913                    })
914                    .await
915            },
916        )
917    }
918
919    fn publish_values<T: Value>(
920        &self,
921        flow_id: &str,
922        channel_name: &str,
923        values: impl IntoIterator<Item = T>,
924    ) -> SdkResult<()> {
925        let messages = values
926            .into_iter()
927            .map(|value| {
928                Ok(dex_protocol::dex::ChannelMessage {
929                    channel_name: channel_name.to_string(),
930                    value: Some(value_mapper::encode(&value)?),
931                })
932            })
933            .collect::<SdkResult<Vec<_>>>()?;
934        self.call_empty(
935            "publish",
936            Some(flow_id),
937            FlowTargetRequirement::Active,
938            |mut service| async move {
939                service
940                    .publish_to_channel(PublishToChannelRequest {
941                        flow_id: flow_id.to_string(),
942                        run_id: String::new(),
943                        messages,
944                    })
945                    .await
946            },
947        )
948    }
949
950    fn read_stream_result<T: Value>(
951        &self,
952        flow_id: &str,
953        stream: &Stream<T>,
954        resume_token: &str,
955        timeout: Option<Duration>,
956    ) -> SdkResult<StreamMessage<T>> {
957        require_name(flow_id, "Flow ID")?;
958        let flow_type = self.registry.flow_for_stream(stream)?.name.to_string();
959        let wait_time_seconds = optional_seconds(timeout)?;
960        let request = ReadStreamRequest {
961            flow_id: flow_id.to_string(),
962            flow_type,
963            stream_name: stream.name().to_string(),
964            resume_token: resume_token.to_string(),
965            wait_time_seconds,
966        };
967        let mut service = self.service.clone();
968        let response = self.runtime.block_on(async {
969            service
970                .read_stream(request)
971                .await
972                .map(|response| response.into_inner())
973                .map_err(|status| {
974                    SdkError::from_status(
975                        status,
976                        "read_stream",
977                        Some(flow_id),
978                        FlowTargetRequirement::None,
979                    )
980                })
981        })?;
982        let message = response
983            .message
984            .ok_or_else(|| invalid("ReadStream omitted its message"))?;
985        if message.resume_token.is_empty() {
986            return Err(invalid("ReadStream returned an empty resume token"));
987        }
988        let value = message
989            .value
990            .ok_or_else(|| invalid("ReadStream omitted its Value"))?;
991        let created_time = message
992            .created_time
993            .map(timestamp)
994            .ok_or_else(|| invalid("ReadStream omitted its creation time"))?;
995        Ok(StreamMessage {
996            value: value_mapper::decode(&value)?,
997            resume_token: message.resume_token,
998            created_time,
999            idempotency_key: message.idempotency_key,
1000        })
1001    }
1002
1003    fn wait_for_flow_result(
1004        &self,
1005        flow_id: &str,
1006        timeout: Option<Duration>,
1007    ) -> SdkResult<FlowResult> {
1008        let mut service = self.service.clone();
1009        let response = self.runtime.block_on(async {
1010            service
1011                .wait_for_flow(WaitForFlowRequest {
1012                    flow_id: flow_id.to_string(),
1013                    run_id: String::new(),
1014                    needs_results: true,
1015                    wait_time_seconds: optional_seconds(timeout)?,
1016                })
1017                .await
1018                .map(|response| response.into_inner())
1019                .map_err(|status| {
1020                    SdkError::from_status(
1021                        status,
1022                        "wait_for_flow",
1023                        Some(flow_id),
1024                        FlowTargetRequirement::Existing,
1025                    )
1026                })
1027        });
1028        let response = response?;
1029        let mut outputs = Vec::with_capacity(response.results.len());
1030        for result in &response.results {
1031            outputs.push(result.completed_step_output.clone().ok_or_else(|| {
1032                SdkError::ValueMapping {
1033                    message: "Step completion output is required".to_string(),
1034                }
1035            })?);
1036        }
1037        let outputs = self.runtime.block_on(self.hydrator.hydrate_all(outputs))?;
1038        let completions = response
1039            .results
1040            .into_iter()
1041            .zip(outputs)
1042            .map(|(result, output)| {
1043                StepCompletion::new(
1044                    result.completed_step_type,
1045                    result.completed_step_execution_id,
1046                    output,
1047                )
1048            })
1049            .collect();
1050        Ok(FlowResult::new(
1051            map_flow_status(response.flow_status)?,
1052            map_flow_error_type(response.error_type),
1053            (!response.error_message.is_empty()).then_some(response.error_message),
1054            completions,
1055        ))
1056    }
1057
1058    fn map_start_options(
1059        &self,
1060        flow: &crate::registry::RegisteredFlow,
1061        options: &StartFlowOptions,
1062    ) -> SdkResult<FlowStartOptions> {
1063        let attributes = options
1064            .attributes
1065            .iter()
1066            .map(|attribute| {
1067                let logical_name = attribute.key.split('/').next().unwrap_or(&attribute.key);
1068                if !flow.persistence.contains_key(logical_name) {
1069                    return Err(invalid(format!(
1070                        "initial Attribute is not registered: {}",
1071                        attribute.key
1072                    )));
1073                }
1074                Ok(AttributeWrite {
1075                    key: attribute.key.clone(),
1076                    value: Some(attribute.value.encode()?),
1077                    index_config: attribute.index_config.clone(),
1078                    sync_config: attribute.sync_config,
1079                })
1080            })
1081            .collect::<SdkResult<Vec<_>>>()?;
1082        let config = options.config_override.as_ref();
1083        let flow_config_override =
1084            if config.is_some() || self.options.worker_target_value().is_some() {
1085                Some(self.map_flow_config(config)?)
1086            } else {
1087                None
1088            };
1089        #[allow(clippy::needless_update)]
1090        let flow_start_options = FlowStartOptions {
1091            id_reuse_policy: match options.id_reuse_policy {
1092                IdReusePolicy::Default => ProtoIdReusePolicy::Unspecified,
1093                IdReusePolicy::AllowIfPreviousFailed => {
1094                    ProtoIdReusePolicy::AllowIfPreviousExistsAbnormally
1095                }
1096                IdReusePolicy::AllowIfNotRunning => ProtoIdReusePolicy::AllowIfNoRunning,
1097                IdReusePolicy::Disallow => ProtoIdReusePolicy::DisallowReuse,
1098                IdReusePolicy::TerminateIfRunning => ProtoIdReusePolicy::AllowTerminateIfRunning,
1099            } as i32,
1100            flow_start_delay_seconds: optional_seconds(options.start_delay)?,
1101            retry_policy: options
1102                .retry_policy
1103                .clone()
1104                .map(map_flow_retry)
1105                .transpose()?,
1106            attributes,
1107            flow_config_override,
1108            flow_already_started_options: Some(FlowAlreadyStartedOptions {
1109                ignore_already_started_error: options.ignore_already_started,
1110            }),
1111            ..Default::default()
1112        };
1113        Ok(flow_start_options)
1114    }
1115
1116    fn map_flow_config(&self, config: Option<&FlowConfig>) -> SdkResult<ProtoFlowConfig> {
1117        map_flow_config(config, self.options.worker_target_value())
1118    }
1119
1120    fn call_empty<Response, Future, Call>(
1121        &self,
1122        operation: &'static str,
1123        flow_id: Option<&str>,
1124        requirement: FlowTargetRequirement,
1125        call: Call,
1126    ) -> SdkResult<()>
1127    where
1128        Call: FnOnce(FlowServiceClient<tonic::transport::Channel>) -> Future,
1129        Future: std::future::Future<Output = Result<tonic::Response<Response>, tonic::Status>>,
1130    {
1131        let service = self.service.clone();
1132        self.runtime
1133            .block_on(call(service))
1134            .map(|_| ())
1135            .map_err(|status| SdkError::from_status(status, operation, flow_id, requirement))
1136    }
1137}
1138
1139pub(crate) fn map_flow_config(
1140    config: Option<&FlowConfig>,
1141    default_target: Option<&WorkerTarget>,
1142) -> SdkResult<ProtoFlowConfig> {
1143    let target = config
1144        .and_then(|config| config.worker_target.as_ref())
1145        .or(default_target);
1146    Ok(ProtoFlowConfig {
1147        active_step_search_mode: config
1148            .and_then(|config| config.active_step_search_mode)
1149            .map(|mode| match mode {
1150                ActiveStepSearchMode::All => ProtoSearchMode::EnabledForAll as i32,
1151                ActiveStepSearchMode::WithWaitFor => {
1152                    ProtoSearchMode::EnabledForStepsWithWaitFor as i32
1153                }
1154                ActiveStepSearchMode::Disabled => ProtoSearchMode::Disabled as i32,
1155            }),
1156        continue_as_new_threshold: config
1157            .and_then(|config| config.continue_as_new_threshold)
1158            .map(i32::try_from)
1159            .transpose()
1160            .map_err(|_| invalid("continue-as-new threshold exceeds int32"))?,
1161        continue_as_new_page_size_in_bytes: config
1162            .and_then(|config| config.continue_as_new_page_size_bytes)
1163            .map(i32::try_from)
1164            .transpose()
1165            .map_err(|_| invalid("continue-as-new page size exceeds int32"))?,
1166        step_durability: config
1167            .and_then(|config| config.step_durability)
1168            .map(|durability| {
1169                (match durability {
1170                    StepDurability::Default => ProtoStepDurability::Unspecified,
1171                    StepDurability::Sync => ProtoStepDurability::Sync,
1172                    StepDurability::Async => ProtoStepDurability::Async,
1173                }) as i32
1174            }),
1175        worker_target: target.map(map_worker_target),
1176        attribute_store_names: config.and_then(|config| {
1177            config
1178                .attribute_store_names
1179                .as_ref()
1180                .map(|names| AttributeStoreNames {
1181                    names: names.clone(),
1182                })
1183        }),
1184    })
1185}
1186
1187pub(crate) fn map_flow_retry(retry: RetryPolicy) -> SdkResult<FlowRetryPolicy> {
1188    Ok(FlowRetryPolicy {
1189        initial_interval_seconds: optional_seconds(retry.initial_interval)?,
1190        backoff_coefficient: retry.backoff_coefficient.unwrap_or_default() as f32,
1191        maximum_interval_seconds: optional_seconds(retry.maximum_interval)?,
1192        maximum_attempts: i32::try_from(retry.maximum_attempts.unwrap_or_default())
1193            .map_err(|_| invalid("maximum attempts exceed int32"))?,
1194    })
1195}
1196
1197fn map_worker_target(target: &WorkerTarget) -> ProtoWorkerTarget {
1198    ProtoWorkerTarget {
1199        address: target.address().to_string(),
1200        is_headless_address: target.is_headless(),
1201    }
1202}
1203
1204pub(crate) fn map_flow_status(status: i32) -> SdkResult<FlowStatus> {
1205    match ProtoFlowStatus::try_from(status).ok() {
1206        Some(ProtoFlowStatus::Running) => Ok(FlowStatus::Running),
1207        Some(ProtoFlowStatus::Completed) => Ok(FlowStatus::Completed),
1208        Some(ProtoFlowStatus::Failed) => Ok(FlowStatus::Failed),
1209        Some(ProtoFlowStatus::ServerSideTimeoutInternalOnly) => {
1210            Ok(FlowStatus::ServerSideTimeoutInternalOnly)
1211        }
1212        Some(ProtoFlowStatus::Terminated) => Ok(FlowStatus::Terminated),
1213        Some(ProtoFlowStatus::Canceled) => Ok(FlowStatus::Canceled),
1214        Some(ProtoFlowStatus::ContinuedAsNew) => Ok(FlowStatus::ContinuedAsNew),
1215        _ => Err(invalid(format!("unknown Flow status {status}"))),
1216    }
1217}
1218
1219pub(crate) fn map_flow_error_type(error_type: i32) -> Option<FlowErrorType> {
1220    use dex_protocol::dex::FlowErrorType as ProtoFlowErrorType;
1221
1222    match ProtoFlowErrorType::try_from(error_type).ok() {
1223        Some(ProtoFlowErrorType::StepDecisionFailingFlow) => {
1224            Some(FlowErrorType::StepDecisionFailed)
1225        }
1226        Some(ProtoFlowErrorType::ClientApiFailingFlow) => Some(FlowErrorType::ClientApiFailed),
1227        Some(ProtoFlowErrorType::WorkerApiFail) => Some(FlowErrorType::WorkerApiFailed),
1228        Some(ProtoFlowErrorType::InvalidUserFlowCode) => Some(FlowErrorType::InvalidUserFlowCode),
1229        Some(ProtoFlowErrorType::Internal) => Some(FlowErrorType::Internal),
1230        Some(ProtoFlowErrorType::FlowTimeout) => Some(FlowErrorType::FlowTimeout),
1231        _ => None,
1232    }
1233}
1234
1235pub(crate) fn map_flow_timeout_policy(
1236    flow: &crate::registry::RegisteredFlow,
1237    timeout_seconds: i32,
1238    policy: FlowTimeoutPolicy,
1239) -> SdkResult<i32> {
1240    if timeout_seconds == 0 {
1241        if policy != FlowTimeoutPolicy::Default {
1242            return Err(invalid("Flow timeout policy requires a positive timeout"));
1243        }
1244        return Ok(ProtoFlowTimeoutPolicy::Unspecified as i32);
1245    }
1246    let resolved = match policy {
1247        FlowTimeoutPolicy::Default if flow.handler.has_timeout_handler() => {
1248            FlowTimeoutPolicy::Handler
1249        }
1250        FlowTimeoutPolicy::Default => FlowTimeoutPolicy::Fail,
1251        configured => configured,
1252    };
1253    if resolved == FlowTimeoutPolicy::Handler && !flow.handler.has_timeout_handler() {
1254        return Err(invalid(format!(
1255            "Flow {} has no timeout handler",
1256            flow.name
1257        )));
1258    }
1259    Ok(match resolved {
1260        FlowTimeoutPolicy::Default => ProtoFlowTimeoutPolicy::Unspecified,
1261        FlowTimeoutPolicy::Fail => ProtoFlowTimeoutPolicy::Fail,
1262        FlowTimeoutPolicy::Cancel => ProtoFlowTimeoutPolicy::Cancel,
1263        FlowTimeoutPolicy::Handler => ProtoFlowTimeoutPolicy::Handler,
1264    } as i32)
1265}
1266
1267fn timestamp(timestamp: prost_types::Timestamp) -> SystemTime {
1268    if timestamp.seconds >= 0 {
1269        SystemTime::UNIX_EPOCH
1270            + Duration::from_secs(timestamp.seconds as u64)
1271            + Duration::from_nanos(timestamp.nanos.max(0) as u64)
1272    } else {
1273        SystemTime::UNIX_EPOCH - Duration::from_secs(timestamp.seconds.unsigned_abs())
1274    }
1275}
1276
1277fn rfc3339(time: SystemTime) -> SdkResult<String> {
1278    let duration = time
1279        .duration_since(SystemTime::UNIX_EPOCH)
1280        .map_err(service_error)?;
1281    let days = (duration.as_secs() / 86_400) as i64;
1282    let seconds = duration.as_secs() % 86_400;
1283    let (year, month, day) = civil_from_days(days);
1284    let hour = seconds / 3_600;
1285    let minute = seconds % 3_600 / 60;
1286    let second = seconds % 60;
1287    Ok(format!(
1288        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
1289    ))
1290}
1291
1292fn civil_from_days(days: i64) -> (i64, i64, i64) {
1293    let days = days + 719_468;
1294    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1295    let day_of_era = days - era * 146_097;
1296    let year_of_era =
1297        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1298    let mut year = year_of_era + era * 400;
1299    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1300    let month_prime = (5 * day_of_year + 2) / 153;
1301    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1302    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1303    year += i64::from(month <= 2);
1304    (year, month, day)
1305}
1306
1307fn optional_seconds(duration: Option<Duration>) -> SdkResult<i32> {
1308    duration
1309        .map(seconds32)
1310        .transpose()
1311        .map(Option::unwrap_or_default)
1312}
1313
1314fn seconds32(duration: Duration) -> SdkResult<i32> {
1315    if duration.subsec_nanos() != 0 {
1316        return Err(invalid("Duration must use whole seconds"));
1317    }
1318    i32::try_from(duration.as_secs()).map_err(|_| invalid("Duration exceeds int32"))
1319}
1320
1321fn endpoint_address(address: &str) -> String {
1322    if address.contains("://") {
1323        address.to_string()
1324    } else {
1325        format!("http://{address}")
1326    }
1327}
1328
1329fn require_name(value: &str, kind: &str) -> SdkResult<()> {
1330    if value.is_empty() {
1331        Err(invalid(format!("{kind} is required")))
1332    } else {
1333        Ok(())
1334    }
1335}
1336
1337fn sdk_handler_error(error: impl std::fmt::Display) -> SdkError {
1338    invalid(error.to_string())
1339}
1340
1341fn invalid(message: impl Into<String>) -> SdkError {
1342    SdkError::InvalidArgument {
1343        message: message.into(),
1344    }
1345}
1346
1347fn service_error(error: impl std::fmt::Display) -> SdkError {
1348    SdkError::Service {
1349        service: ServiceError::local("client", error.to_string()),
1350    }
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::map_flow_config;
1356    use crate::FlowConfig;
1357
1358    #[test]
1359    fn attribute_store_names_preserve_protocol_presence() {
1360        let absent = map_flow_config(Some(&FlowConfig::new()), None).expect("map absent config");
1361        let named = map_flow_config(
1362            Some(
1363                &FlowConfig::new()
1364                    .attribute_store_names(vec!["profiles".to_owned(), "audit".to_owned()]),
1365            ),
1366            None,
1367        )
1368        .expect("map named config");
1369        let disabled =
1370            map_flow_config(Some(&FlowConfig::new().attribute_store_names(vec![])), None)
1371                .expect("map disabled config");
1372
1373        assert_eq!(absent.attribute_store_names, None);
1374        assert_eq!(
1375            named.attribute_store_names.expect("named stores").names,
1376            vec!["profiles", "audit"]
1377        );
1378        assert_eq!(
1379            disabled
1380                .attribute_store_names
1381                .expect("disabled stores")
1382                .names,
1383            Vec::<String>::new()
1384        );
1385    }
1386}