Skip to main content

dex_sdk/
registry.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use 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-Sustainable-Use-1.0
8
9use std::any::TypeId;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use crate::persistence::{PersistenceDefinition, PersistenceKind};
14use crate::rpc::RegisteredRpc;
15use crate::step::RegisteredStep;
16use crate::step_options::ErasedStepOptions;
17use crate::{Context, Flow, HandlerResult, SdkError, SdkResult, StepDecision, Stream, Wait};
18use dex_protocol::dex::Value as ProtoValue;
19
20#[derive(Clone, Default)]
21/// Validates and stores all Flow definitions used by a Client or Worker.
22///
23/// Registration checks unique Flow, Step, RPC, and persistence names; starting-Step constraints;
24/// Attribute index consistency; RPC locks; and handler options. Cloning is cheap because it shares
25/// the registered data.
26///
27/// # Examples
28///
29/// ```
30/// use dex_sdk::{Flow, Registry};
31///
32/// struct OrderFlow;
33/// impl Flow for OrderFlow { type StartInput = String; }
34///
35/// let registry = Registry::new().register(OrderFlow)?;
36/// # Ok::<(), dex_sdk::SdkError>(())
37/// ```
38pub struct Registry {
39    inner: Arc<RegistryInner>,
40}
41
42#[derive(Clone, Default)]
43struct RegistryInner {
44    flows: HashMap<&'static str, RegisteredFlow>,
45    attribute_indexes: HashMap<String, i32>,
46    stream_flows: HashMap<usize, &'static str>,
47}
48
49#[derive(Clone)]
50pub(crate) struct RegisteredFlow {
51    pub(crate) name: &'static str,
52    pub(crate) type_id: TypeId,
53    pub(crate) steps: HashMap<&'static str, RegisteredStep>,
54    pub(crate) start_step: Option<RegisteredStep>,
55    pub(crate) rpcs: HashMap<&'static str, RegisteredRpc>,
56    pub(crate) persistence: HashMap<String, PersistenceDefinition>,
57    pub(crate) handler: Arc<dyn ErasedFlow>,
58}
59
60impl Registry {
61    /// Creates an empty registry.
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Validates and adds one owned Flow definition.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SdkError::FlowDefinition`] for invalid or conflicting names, schemas, indexes,
71    /// Steps, RPCs, locks, or options.
72    pub fn register<SomeFlow: Flow>(mut self, flow: SomeFlow) -> SdkResult<Self> {
73        let flow = Arc::new(flow);
74        let name = require_static_name(flow.flow_type(), "Flow type")?;
75        let registered = assemble_flow(name, Arc::clone(&flow))?;
76        let inner = Arc::make_mut(&mut self.inner);
77        let mut additions = Vec::new();
78        let mut stream_identities = Vec::new();
79        for definition in registered.persistence.values() {
80            if definition.kind == PersistenceKind::Stream {
81                let identity = definition.stream_identity.ok_or_else(|| {
82                    definition_error(format!(
83                        "Flow {name} Stream {} has no definition identity",
84                        definition.name
85                    ))
86                })?;
87                if inner.stream_flows.contains_key(&identity) {
88                    return Err(definition_error(format!(
89                        "Stream {} is registered by multiple Flows",
90                        definition.name
91                    )));
92                }
93                stream_identities.push(identity);
94            }
95            let Some(index) = &definition.index else {
96                continue;
97            };
98            if definition.kind == PersistenceKind::AttributeMap
99                && index.key().is_none_or(str::is_empty)
100            {
101                return Err(definition_error(format!(
102                    "Flow {name} indexed AttributeMap {} requires an index key",
103                    definition.name
104                )));
105            }
106            let key = index.key().unwrap_or(&definition.name).to_string();
107            let index_type = index.proto_value();
108            if inner
109                .attribute_indexes
110                .get(&key)
111                .is_some_and(|existing| *existing != index_type)
112            {
113                return Err(definition_error(format!(
114                    "Attribute index {key} has conflicting types"
115                )));
116            }
117            additions.push((key, index_type));
118        }
119        if inner.flows.insert(name, registered).is_some() {
120            return Err(definition_error(format!("duplicate Flow {name}")));
121        }
122        inner.attribute_indexes.extend(additions);
123        for identity in stream_identities {
124            inner.stream_flows.insert(identity, name);
125        }
126        Ok(self)
127    }
128
129    pub(crate) fn flow(&self, name: &str) -> SdkResult<&RegisteredFlow> {
130        self.inner
131            .flows
132            .get(name)
133            .ok_or_else(|| SdkError::FlowDefinition {
134                message: format!("Flow is not registered: {name}"),
135            })
136    }
137
138    pub(crate) fn flow_types(&self) -> Vec<String> {
139        let mut flow_types = self
140            .inner
141            .flows
142            .keys()
143            .map(|name| (*name).to_string())
144            .collect::<Vec<_>>();
145        flow_types.sort();
146        flow_types
147    }
148
149    pub(crate) fn rpc_with_flow(&self, name: &str) -> SdkResult<(&RegisteredFlow, &RegisteredRpc)> {
150        let mut matched = None;
151        for flow in self.inner.flows.values() {
152            let Some(rpc) = flow.rpcs.get(name) else {
153                continue;
154            };
155            if matched.is_some() {
156                return Err(SdkError::FlowDefinition {
157                    message: format!("RPC name is ambiguous across registered Flows: {name}"),
158                });
159            }
160            matched = Some((flow, rpc));
161        }
162        matched.ok_or_else(|| SdkError::FlowDefinition {
163            message: format!("RPC is not registered: {name}"),
164        })
165    }
166
167    pub(crate) fn flow_for_stream<T>(&self, stream: &Stream<T>) -> SdkResult<&RegisteredFlow> {
168        let flow_name = self
169            .inner
170            .stream_flows
171            .get(&stream.identity())
172            .ok_or_else(|| SdkError::FlowDefinition {
173                message: format!("Stream is not registered: {}", stream.name()),
174            })?;
175        self.flow(flow_name)
176    }
177
178    pub(crate) fn attribute_indexes(&self) -> &HashMap<String, i32> {
179        &self.inner.attribute_indexes
180    }
181}
182
183fn assemble_flow<SomeFlow: Flow>(
184    name: &'static str,
185    flow: Arc<SomeFlow>,
186) -> SdkResult<RegisteredFlow> {
187    let timeout_handler = flow.timeout_handler();
188    let mut steps = HashMap::new();
189    let mut start_step = None;
190    for step in flow.steps().into_definitions() {
191        require_static_name(step.name, "Step type")?;
192        if step.starting {
193            if start_step.is_some() {
194                return Err(definition_error(format!(
195                    "Flow {name} must not have multiple start Steps"
196                )));
197            }
198            start_step = Some(step.clone());
199        }
200        if steps.insert(step.name, step).is_some() {
201            return Err(definition_error(format!(
202                "Flow {name} has a duplicate Step"
203            )));
204        }
205    }
206
207    let persistence = assemble_persistence(name, flow.persistence().definitions())?;
208    for step in steps.values() {
209        let options = flow
210            .steps()
211            .find(step.name)
212            .ok_or_else(|| definition_error(format!("Flow {name} Step disappeared")))?
213            .options();
214        validate_step_state_loads(name, step.name, &options, &persistence)?;
215    }
216    let mut rpcs = HashMap::new();
217    for rpc in flow.rpcs().bind(Arc::clone(&flow)) {
218        require_static_name(rpc.name, "RPC name")?;
219        validate_rpc(name, &rpc, &persistence)?;
220        if rpcs.insert(rpc.name, rpc).is_some() {
221            return Err(definition_error(format!("Flow {name} has a duplicate RPC")));
222        }
223    }
224    Ok(RegisteredFlow {
225        name,
226        type_id: TypeId::of::<SomeFlow>(),
227        steps,
228        start_step,
229        rpcs,
230        persistence,
231        handler: Arc::new(TypedFlow {
232            flow,
233            timeout_handler,
234        }),
235    })
236}
237
238pub(crate) trait ErasedFlow: Send + Sync {
239    fn has_timeout_handler(&self) -> bool;
240    fn handle_timeout(&self, context: &mut Context) -> HandlerResult<StepDecision>;
241    fn wait_for(
242        &self,
243        step_type: &str,
244        context: &mut Context,
245        input: &ProtoValue,
246    ) -> HandlerResult<Wait>;
247    fn execute(
248        &self,
249        step_type: &str,
250        context: &mut Context,
251        input: &ProtoValue,
252    ) -> HandlerResult<StepDecision>;
253    fn step_options(&self, step_type: &str) -> HandlerResult<ErasedStepOptions>;
254}
255
256struct TypedFlow<SomeFlow> {
257    flow: Arc<SomeFlow>,
258    timeout_handler: Option<crate::FlowTimeoutHandler<SomeFlow>>,
259}
260
261impl<SomeFlow: Flow> ErasedFlow for TypedFlow<SomeFlow> {
262    fn has_timeout_handler(&self) -> bool {
263        self.timeout_handler.is_some()
264    }
265
266    fn handle_timeout(&self, context: &mut Context) -> HandlerResult<StepDecision> {
267        let handler = self.timeout_handler.ok_or_else(|| {
268            crate::HandlerError::new("dex_sdk::HandlerError", "Flow has no timeout handler")
269        })?;
270        handler(self.flow.as_ref(), context)
271    }
272    fn wait_for(
273        &self,
274        step_type: &str,
275        context: &mut Context,
276        input: &ProtoValue,
277    ) -> HandlerResult<Wait> {
278        self.flow
279            .steps()
280            .find(step_type)
281            .ok_or_else(|| {
282                crate::HandlerError::new(
283                    "dex_sdk::HandlerError",
284                    format!("Step is not registered: {step_type}"),
285                )
286            })?
287            .wait_for(context, input)
288    }
289
290    fn execute(
291        &self,
292        step_type: &str,
293        context: &mut Context,
294        input: &ProtoValue,
295    ) -> HandlerResult<StepDecision> {
296        self.flow
297            .steps()
298            .find(step_type)
299            .ok_or_else(|| {
300                crate::HandlerError::new(
301                    "dex_sdk::HandlerError",
302                    format!("Step is not registered: {step_type}"),
303                )
304            })?
305            .execute(context, input)
306    }
307
308    fn step_options(&self, step_type: &str) -> HandlerResult<ErasedStepOptions> {
309        Ok(self
310            .flow
311            .steps()
312            .find(step_type)
313            .ok_or_else(|| {
314                crate::HandlerError::new(
315                    "dex_sdk::HandlerError",
316                    format!("Step is not registered: {step_type}"),
317                )
318            })?
319            .options())
320    }
321}
322
323fn assemble_persistence(
324    flow_name: &str,
325    definitions: &[PersistenceDefinition],
326) -> SdkResult<HashMap<String, PersistenceDefinition>> {
327    let mut persistence = HashMap::new();
328    for definition in definitions {
329        if definition.name.is_empty() {
330            return Err(definition_error(format!(
331                "Flow {flow_name} has an empty persistence definition name"
332            )));
333        }
334        if matches!(
335            definition.kind,
336            PersistenceKind::Attribute
337                | PersistenceKind::AttributeMap
338                | PersistenceKind::Channel
339                | PersistenceKind::ChannelMap
340        ) && definition.name.contains('/')
341        {
342            return Err(definition_error(format!(
343                "Flow {flow_name} persistence definition {} must not contain /",
344                definition.name
345            )));
346        }
347        validate_hard_delete_definition(flow_name, definition)?;
348        if persistence
349            .insert(definition.name.clone(), definition.clone())
350            .is_some()
351        {
352            return Err(definition_error(format!(
353                "Flow {flow_name} has duplicate persistence definition {}",
354                definition.name
355            )));
356        }
357    }
358    Ok(persistence)
359}
360
361fn validate_hard_delete_definition(
362    flow_name: &str,
363    definition: &PersistenceDefinition,
364) -> SdkResult<()> {
365    if !definition.is_hard_delete_on_null {
366        return Ok(());
367    }
368    let name = &definition.name;
369    if definition.index.is_some() {
370        return Err(definition_error(format!(
371            "Flow {flow_name} hard-delete Attribute {name} cannot be combined with an index"
372        )));
373    }
374    if definition.sync_to_attribute_store {
375        return Err(definition_error(format!(
376            "Flow {flow_name} hard-delete Attribute {name} cannot be combined with \
377             sync_to_attribute_store"
378        )));
379    }
380    if let Some(type_name) = definition.numeric_or_boolean_type_name {
381        return Err(definition_error(format!(
382            "Flow {flow_name} hard-delete Attribute {name} supports only string and object \
383             values, but its value type is {type_name}"
384        )));
385    }
386    Ok(())
387}
388
389fn validate_rpc(
390    flow_name: &str,
391    rpc: &RegisteredRpc,
392    persistence: &HashMap<String, PersistenceDefinition>,
393) -> SdkResult<()> {
394    if rpc.timeout.is_some_and(|timeout| timeout.is_zero()) {
395        return Err(definition_error(format!(
396            "Flow {flow_name} RPC {} timeout must be positive",
397            rpc.name
398        )));
399    }
400    let mut locks = HashSet::new();
401    for lock in &rpc.locks {
402        let physical_name = lock.physical_name();
403        let logical_name = physical_name.split('/').next().unwrap_or(&physical_name);
404        let definition = persistence.get(logical_name).ok_or_else(|| {
405            definition_error(format!(
406                "Flow {flow_name} RPC {} locks an unregistered Attribute",
407                rpc.name
408            ))
409        })?;
410        if !matches!(
411            definition.kind,
412            PersistenceKind::Attribute | PersistenceKind::AttributeMap
413        ) {
414            return Err(definition_error(format!(
415                "Flow {flow_name} RPC {} locks a non-Attribute",
416                rpc.name
417            )));
418        }
419        if !locks.insert(physical_name) {
420            return Err(definition_error(format!(
421                "Flow {flow_name} RPC {} has a duplicate lock",
422                rpc.name
423            )));
424        }
425    }
426    validate_map_loads(
427        flow_name,
428        &format!("RPC {}", rpc.name),
429        &rpc.load_attribute_maps,
430        PersistenceKind::AttributeMap,
431        persistence,
432        |load| (&load.name, load.instance.as_deref()),
433    )?;
434    validate_definition_loads(
435        flow_name,
436        &format!("RPC {}", rpc.name),
437        &rpc.load_channels,
438        PersistenceKind::Channel,
439        persistence,
440        |load| &load.name,
441    )?;
442    validate_map_loads(
443        flow_name,
444        &format!("RPC {}", rpc.name),
445        &rpc.load_channel_maps,
446        PersistenceKind::ChannelMap,
447        persistence,
448        |load| (&load.name, load.instance.as_deref()),
449    )?;
450    Ok(())
451}
452
453fn validate_step_state_loads(
454    flow_name: &str,
455    step_name: &str,
456    options: &ErasedStepOptions,
457    persistence: &HashMap<String, PersistenceDefinition>,
458) -> SdkResult<()> {
459    validate_step_method_state_loads(
460        flow_name,
461        &format!("Step {step_name} WaitFor"),
462        &options.wait_for_load_attribute_maps,
463        &options.wait_for_load_channels,
464        &options.wait_for_load_channel_maps,
465        persistence,
466    )?;
467    validate_step_method_state_loads(
468        flow_name,
469        &format!("Step {step_name} Execute"),
470        &options.execute_load_attribute_maps,
471        &options.execute_load_channels,
472        &options.execute_load_channel_maps,
473        persistence,
474    )?;
475    Ok(())
476}
477
478fn validate_step_method_state_loads(
479    flow_name: &str,
480    source: &str,
481    attribute_maps: &[crate::AttributeMapLoad],
482    channels: &[crate::channel::ChannelLoad],
483    channel_maps: &[crate::ChannelMapLoad],
484    persistence: &HashMap<String, PersistenceDefinition>,
485) -> SdkResult<()> {
486    validate_map_loads(
487        flow_name,
488        source,
489        attribute_maps,
490        PersistenceKind::AttributeMap,
491        persistence,
492        |load| (&load.name, load.instance.as_deref()),
493    )?;
494    validate_definition_loads(
495        flow_name,
496        source,
497        channels,
498        PersistenceKind::Channel,
499        persistence,
500        |load| &load.name,
501    )?;
502    validate_map_loads(
503        flow_name,
504        source,
505        channel_maps,
506        PersistenceKind::ChannelMap,
507        persistence,
508        |load| (&load.name, load.instance.as_deref()),
509    )?;
510    Ok(())
511}
512
513fn validate_map_loads<Load, GetLoad>(
514    flow_name: &str,
515    source: &str,
516    loads: &[Load],
517    kind: PersistenceKind,
518    persistence: &HashMap<String, PersistenceDefinition>,
519    get_load: GetLoad,
520) -> SdkResult<()>
521where
522    GetLoad: for<'a> Fn(&'a Load) -> (&'a String, Option<&'a str>),
523{
524    let mut physical_names = HashSet::new();
525    for load in loads {
526        let (name, instance) = get_load(load);
527        validate_load_definition(flow_name, source, name, kind, persistence)?;
528        if instance.is_some_and(str::is_empty) {
529            return Err(definition_error(format!(
530                "Flow {flow_name} {source} loads an empty map instance"
531            )));
532        }
533        if instance.is_some_and(|value| value.contains('/')) {
534            return Err(definition_error(format!(
535                "Flow {flow_name} {source} map instance must not contain /"
536            )));
537        }
538        let physical_name = match instance {
539            Some(instance) => physical_name(name, instance),
540            None => format!("{name}/"),
541        };
542        if !physical_names.insert(physical_name) {
543            return Err(definition_error(format!(
544                "Flow {flow_name} {source} has a duplicate state load"
545            )));
546        }
547    }
548    Ok(())
549}
550
551fn validate_definition_loads<Load, GetLoad>(
552    flow_name: &str,
553    source: &str,
554    loads: &[Load],
555    kind: PersistenceKind,
556    persistence: &HashMap<String, PersistenceDefinition>,
557    get_load: GetLoad,
558) -> SdkResult<()>
559where
560    GetLoad: for<'a> Fn(&'a Load) -> &'a String,
561{
562    let mut names = HashSet::new();
563    for load in loads {
564        let name = get_load(load);
565        validate_load_definition(flow_name, source, name, kind, persistence)?;
566        if !names.insert(name) {
567            return Err(definition_error(format!(
568                "Flow {flow_name} {source} has a duplicate state load"
569            )));
570        }
571    }
572    Ok(())
573}
574
575fn validate_load_definition(
576    flow_name: &str,
577    source: &str,
578    name: &str,
579    kind: PersistenceKind,
580    persistence: &HashMap<String, PersistenceDefinition>,
581) -> SdkResult<()> {
582    let definition = persistence.get(name).ok_or_else(|| {
583        definition_error(format!(
584            "Flow {flow_name} {source} loads unregistered state: {name}"
585        ))
586    })?;
587    if definition.kind != kind {
588        return Err(definition_error(format!(
589            "Flow {flow_name} {source} loads the wrong state kind: {name}"
590        )));
591    }
592    Ok(())
593}
594
595fn require_static_name(name: &'static str, kind: &str) -> SdkResult<&'static str> {
596    if name.is_empty() {
597        Err(definition_error(format!("{kind} is required")))
598    } else {
599        Ok(name)
600    }
601}
602
603fn definition_error(message: impl Into<String>) -> SdkError {
604    SdkError::FlowDefinition {
605        message: message.into(),
606    }
607}
608
609pub(crate) fn physical_name(name: &str, instance: &str) -> String {
610    assert_map_instance(instance);
611    let mut encoded = String::new();
612    for byte in instance.bytes() {
613        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
614            encoded.push(char::from(byte));
615        } else {
616            encoded.push_str(&format!("%{byte:02X}"));
617        }
618    }
619    format!("{name}/{encoded}")
620}
621
622pub(crate) fn decode_instance(physical_name: &str, prefix: &str) -> Result<String, String> {
623    let encoded = physical_name
624        .strip_prefix(prefix)
625        .ok_or_else(|| format!("map key {physical_name} does not start with {prefix}"))?;
626    let bytes = encoded.as_bytes();
627    let mut decoded = Vec::with_capacity(bytes.len());
628    let mut index = 0;
629    while index < bytes.len() {
630        if bytes[index] == b'%' {
631            if index + 2 >= bytes.len() {
632                return Err(format!("invalid percent encoding in {physical_name}"));
633            }
634            let high = hex_value(bytes[index + 1])?;
635            let low = hex_value(bytes[index + 2])?;
636            decoded.push(high * 16 + low);
637            index += 3;
638        } else {
639            decoded.push(bytes[index]);
640            index += 1;
641        }
642    }
643    let instance = String::from_utf8(decoded).map_err(|error| error.to_string())?;
644    validate_map_instance(&instance)?;
645    Ok(instance)
646}
647
648pub(crate) fn assert_map_instance(instance: &str) {
649    if let Err(error) = validate_map_instance(instance) {
650        panic!("{error}");
651    }
652}
653
654pub(crate) fn validate_map_instance(instance: &str) -> Result<(), String> {
655    if instance.is_empty() {
656        return Err("map instance is required".to_string());
657    }
658    if instance.contains('/') {
659        return Err("map instances must not contain '/'".to_string());
660    }
661    Ok(())
662}
663
664fn hex_value(byte: u8) -> Result<u8, String> {
665    match byte {
666        b'0'..=b'9' => Ok(byte - b'0'),
667        b'a'..=b'f' => Ok(byte - b'a' + 10),
668        b'A'..=b'F' => Ok(byte - b'A' + 10),
669        _ => Err(format!(
670            "invalid percent-encoding digit {}",
671            char::from(byte)
672        )),
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::{PersistenceDefinition, PersistenceKind, assemble_persistence, physical_name};
679    use crate::{Attribute, AttributeIndex, AttributeMap, PersistenceSchema, SdkError};
680
681    #[test]
682    fn persistence_definition_names_reserve_slash() {
683        for kind in [
684            PersistenceKind::Attribute,
685            PersistenceKind::AttributeMap,
686            PersistenceKind::Channel,
687            PersistenceKind::ChannelMap,
688        ] {
689            let result = assemble_persistence(
690                "TestFlow",
691                &[PersistenceDefinition::new("orders/by-id", kind)],
692            );
693            assert!(result.is_err());
694        }
695
696        assert!(std::panic::catch_unwind(|| physical_name("messages", "orders/by-id")).is_err());
697    }
698
699    #[test]
700    fn hard_delete_definitions_reject_index_sync_and_scalar_types() {
701        let indexed = PersistenceSchema::new().attribute(
702            &Attribute::<String>::new("secret")
703                .indexed(AttributeIndex::keyword())
704                .hard_delete_on_null(),
705        );
706        let synced = PersistenceSchema::new().attribute_map(
707            &AttributeMap::<String>::new("secrets")
708                .hard_delete_on_null()
709                .sync_to_attribute_store(),
710        );
711        let boolean = PersistenceSchema::new()
712            .attribute(&Attribute::<Option<bool>>::new("flag").hard_delete_on_null());
713        let floating = PersistenceSchema::new()
714            .attribute_map(&AttributeMap::<f64>::new("ratios").hard_delete_on_null());
715        for (schema, expected) in [
716            (indexed, "cannot be combined with an index"),
717            (synced, "cannot be combined with sync_to_attribute_store"),
718            (boolean, "supports only string and object values"),
719            (floating, "supports only string and object values"),
720        ] {
721            let error = assemble_persistence("TestFlow", schema.definitions())
722                .expect_err("invalid hard-delete definition");
723            let SdkError::FlowDefinition { message } = error else {
724                panic!("unexpected error {error}");
725            };
726            assert!(message.contains(expected), "{message}");
727        }
728
729        let valid = PersistenceSchema::new()
730            .attribute(&Attribute::<String>::new("secret").hard_delete_on_null())
731            .attribute(&Attribute::<Vec<u8>>::new("bytes").hard_delete_on_null())
732            .attribute_map(
733                &AttributeMap::<std::collections::HashMap<String, u64>>::new("tokens")
734                    .hard_delete_on_null(),
735            );
736        let persistence =
737            assemble_persistence("TestFlow", valid.definitions()).expect("valid definitions");
738        assert!(persistence["secret"].is_hard_delete_on_null);
739    }
740}