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    /// Returns `Ok` when some registered Flow declares an Attribute or AttributeMap definition
179    /// equal to `definition`, including its value type and write settings.
180    pub(crate) fn require_registered_attribute(
181        &self,
182        definition: &PersistenceDefinition,
183    ) -> SdkResult<()> {
184        let mut differing_flow_names = Vec::new();
185        for flow in self.inner.flows.values() {
186            let Some(registered) = flow.persistence.get(&definition.name) else {
187                continue;
188            };
189            if !matches!(
190                registered.kind,
191                PersistenceKind::Attribute | PersistenceKind::AttributeMap
192            ) {
193                continue;
194            }
195            if registered == definition {
196                return Ok(());
197            }
198            differing_flow_names.push(flow.name);
199        }
200        differing_flow_names.sort_unstable();
201        let name = &definition.name;
202        Err(match differing_flow_names.first() {
203            Some(flow_name) => definition_error(format!(
204                "Attribute {name} differs from the definition registered by Flow {flow_name}; \
205                 the kind, value type, index, sync_to_attribute_store, and hard_delete_on_null \
206                 settings must all match"
207            )),
208            None => definition_error(format!(
209                "Attribute {name} is not registered by any Flow in the Registry"
210            )),
211        })
212    }
213
214    pub(crate) fn attribute_indexes(&self) -> &HashMap<String, i32> {
215        &self.inner.attribute_indexes
216    }
217}
218
219fn assemble_flow<SomeFlow: Flow>(
220    name: &'static str,
221    flow: Arc<SomeFlow>,
222) -> SdkResult<RegisteredFlow> {
223    let timeout_handler = flow.timeout_handler();
224    let mut steps = HashMap::new();
225    let mut start_step = None;
226    for step in flow.steps().into_definitions() {
227        require_static_name(step.name, "Step type")?;
228        if step.starting {
229            if start_step.is_some() {
230                return Err(definition_error(format!(
231                    "Flow {name} must not have multiple start Steps"
232                )));
233            }
234            start_step = Some(step.clone());
235        }
236        if steps.insert(step.name, step).is_some() {
237            return Err(definition_error(format!(
238                "Flow {name} has a duplicate Step"
239            )));
240        }
241    }
242
243    let persistence = assemble_persistence(name, flow.persistence().definitions())?;
244    for step in steps.values() {
245        let options = flow
246            .steps()
247            .find(step.name)
248            .ok_or_else(|| definition_error(format!("Flow {name} Step disappeared")))?
249            .options();
250        validate_step_state_loads(name, step.name, &options, &persistence)?;
251    }
252    let mut rpcs = HashMap::new();
253    for rpc in flow.rpcs().bind(Arc::clone(&flow)) {
254        require_static_name(rpc.name, "RPC name")?;
255        validate_rpc(name, &rpc, &persistence)?;
256        if rpcs.insert(rpc.name, rpc).is_some() {
257            return Err(definition_error(format!("Flow {name} has a duplicate RPC")));
258        }
259    }
260    Ok(RegisteredFlow {
261        name,
262        type_id: TypeId::of::<SomeFlow>(),
263        steps,
264        start_step,
265        rpcs,
266        persistence,
267        handler: Arc::new(TypedFlow {
268            flow,
269            timeout_handler,
270        }),
271    })
272}
273
274pub(crate) trait ErasedFlow: Send + Sync {
275    fn has_timeout_handler(&self) -> bool;
276    fn handle_timeout(&self, context: &mut Context) -> HandlerResult<StepDecision>;
277    fn wait_for(
278        &self,
279        step_type: &str,
280        context: &mut Context,
281        input: &ProtoValue,
282    ) -> HandlerResult<Wait>;
283    fn execute(
284        &self,
285        step_type: &str,
286        context: &mut Context,
287        input: &ProtoValue,
288    ) -> HandlerResult<StepDecision>;
289    fn step_options(&self, step_type: &str) -> HandlerResult<ErasedStepOptions>;
290}
291
292struct TypedFlow<SomeFlow> {
293    flow: Arc<SomeFlow>,
294    timeout_handler: Option<crate::FlowTimeoutHandler<SomeFlow>>,
295}
296
297impl<SomeFlow: Flow> ErasedFlow for TypedFlow<SomeFlow> {
298    fn has_timeout_handler(&self) -> bool {
299        self.timeout_handler.is_some()
300    }
301
302    fn handle_timeout(&self, context: &mut Context) -> HandlerResult<StepDecision> {
303        let handler = self.timeout_handler.ok_or_else(|| {
304            crate::HandlerError::new("dex_sdk::HandlerError", "Flow has no timeout handler")
305        })?;
306        handler(self.flow.as_ref(), context)
307    }
308    fn wait_for(
309        &self,
310        step_type: &str,
311        context: &mut Context,
312        input: &ProtoValue,
313    ) -> HandlerResult<Wait> {
314        self.flow
315            .steps()
316            .find(step_type)
317            .ok_or_else(|| {
318                crate::HandlerError::new(
319                    "dex_sdk::HandlerError",
320                    format!("Step is not registered: {step_type}"),
321                )
322            })?
323            .wait_for(context, input)
324    }
325
326    fn execute(
327        &self,
328        step_type: &str,
329        context: &mut Context,
330        input: &ProtoValue,
331    ) -> HandlerResult<StepDecision> {
332        self.flow
333            .steps()
334            .find(step_type)
335            .ok_or_else(|| {
336                crate::HandlerError::new(
337                    "dex_sdk::HandlerError",
338                    format!("Step is not registered: {step_type}"),
339                )
340            })?
341            .execute(context, input)
342    }
343
344    fn step_options(&self, step_type: &str) -> HandlerResult<ErasedStepOptions> {
345        Ok(self
346            .flow
347            .steps()
348            .find(step_type)
349            .ok_or_else(|| {
350                crate::HandlerError::new(
351                    "dex_sdk::HandlerError",
352                    format!("Step is not registered: {step_type}"),
353                )
354            })?
355            .options())
356    }
357}
358
359fn assemble_persistence(
360    flow_name: &str,
361    definitions: &[PersistenceDefinition],
362) -> SdkResult<HashMap<String, PersistenceDefinition>> {
363    let mut persistence = HashMap::new();
364    for definition in definitions {
365        if definition.name.is_empty() {
366            return Err(definition_error(format!(
367                "Flow {flow_name} has an empty persistence definition name"
368            )));
369        }
370        if matches!(
371            definition.kind,
372            PersistenceKind::Attribute
373                | PersistenceKind::AttributeMap
374                | PersistenceKind::Channel
375                | PersistenceKind::ChannelMap
376        ) && definition.name.contains('/')
377        {
378            return Err(definition_error(format!(
379                "Flow {flow_name} persistence definition {} must not contain /",
380                definition.name
381            )));
382        }
383        validate_hard_delete_definition(flow_name, definition)?;
384        if persistence
385            .insert(definition.name.clone(), definition.clone())
386            .is_some()
387        {
388            return Err(definition_error(format!(
389                "Flow {flow_name} has duplicate persistence definition {}",
390                definition.name
391            )));
392        }
393    }
394    Ok(persistence)
395}
396
397fn validate_hard_delete_definition(
398    flow_name: &str,
399    definition: &PersistenceDefinition,
400) -> SdkResult<()> {
401    if !definition.is_hard_delete_on_null {
402        return Ok(());
403    }
404    let name = &definition.name;
405    if definition.index.is_some() {
406        return Err(definition_error(format!(
407            "Flow {flow_name} hard-delete Attribute {name} cannot be combined with an index"
408        )));
409    }
410    if definition.sync_to_attribute_store {
411        return Err(definition_error(format!(
412            "Flow {flow_name} hard-delete Attribute {name} cannot be combined with \
413             sync_to_attribute_store"
414        )));
415    }
416    if let Some(type_name) = definition.numeric_or_boolean_type_name {
417        return Err(definition_error(format!(
418            "Flow {flow_name} hard-delete Attribute {name} supports only string and object \
419             values, but its value type is {type_name}"
420        )));
421    }
422    Ok(())
423}
424
425fn validate_rpc(
426    flow_name: &str,
427    rpc: &RegisteredRpc,
428    persistence: &HashMap<String, PersistenceDefinition>,
429) -> SdkResult<()> {
430    if rpc.timeout.is_some_and(|timeout| timeout.is_zero()) {
431        return Err(definition_error(format!(
432            "Flow {flow_name} RPC {} timeout must be positive",
433            rpc.name
434        )));
435    }
436    let mut locks = HashSet::new();
437    for lock in &rpc.locks {
438        let physical_name = lock.physical_name();
439        let logical_name = physical_name.split('/').next().unwrap_or(&physical_name);
440        let definition = persistence.get(logical_name).ok_or_else(|| {
441            definition_error(format!(
442                "Flow {flow_name} RPC {} locks an unregistered Attribute",
443                rpc.name
444            ))
445        })?;
446        if !matches!(
447            definition.kind,
448            PersistenceKind::Attribute | PersistenceKind::AttributeMap
449        ) {
450            return Err(definition_error(format!(
451                "Flow {flow_name} RPC {} locks a non-Attribute",
452                rpc.name
453            )));
454        }
455        if !locks.insert(physical_name) {
456            return Err(definition_error(format!(
457                "Flow {flow_name} RPC {} has a duplicate lock",
458                rpc.name
459            )));
460        }
461    }
462    validate_map_loads(
463        flow_name,
464        &format!("RPC {}", rpc.name),
465        &rpc.load_attribute_maps,
466        PersistenceKind::AttributeMap,
467        persistence,
468        |load| (&load.name, load.instance.as_deref()),
469    )?;
470    validate_definition_loads(
471        flow_name,
472        &format!("RPC {}", rpc.name),
473        &rpc.load_channels,
474        PersistenceKind::Channel,
475        persistence,
476        |load| &load.name,
477    )?;
478    validate_map_loads(
479        flow_name,
480        &format!("RPC {}", rpc.name),
481        &rpc.load_channel_maps,
482        PersistenceKind::ChannelMap,
483        persistence,
484        |load| (&load.name, load.instance.as_deref()),
485    )?;
486    Ok(())
487}
488
489fn validate_step_state_loads(
490    flow_name: &str,
491    step_name: &str,
492    options: &ErasedStepOptions,
493    persistence: &HashMap<String, PersistenceDefinition>,
494) -> SdkResult<()> {
495    validate_step_method_state_loads(
496        flow_name,
497        &format!("Step {step_name} WaitFor"),
498        &options.wait_for_load_attribute_maps,
499        &options.wait_for_load_channels,
500        &options.wait_for_load_channel_maps,
501        persistence,
502    )?;
503    validate_step_method_state_loads(
504        flow_name,
505        &format!("Step {step_name} Execute"),
506        &options.execute_load_attribute_maps,
507        &options.execute_load_channels,
508        &options.execute_load_channel_maps,
509        persistence,
510    )?;
511    Ok(())
512}
513
514fn validate_step_method_state_loads(
515    flow_name: &str,
516    source: &str,
517    attribute_maps: &[crate::AttributeMapLoad],
518    channels: &[crate::channel::ChannelLoad],
519    channel_maps: &[crate::ChannelMapLoad],
520    persistence: &HashMap<String, PersistenceDefinition>,
521) -> SdkResult<()> {
522    validate_map_loads(
523        flow_name,
524        source,
525        attribute_maps,
526        PersistenceKind::AttributeMap,
527        persistence,
528        |load| (&load.name, load.instance.as_deref()),
529    )?;
530    validate_definition_loads(
531        flow_name,
532        source,
533        channels,
534        PersistenceKind::Channel,
535        persistence,
536        |load| &load.name,
537    )?;
538    validate_map_loads(
539        flow_name,
540        source,
541        channel_maps,
542        PersistenceKind::ChannelMap,
543        persistence,
544        |load| (&load.name, load.instance.as_deref()),
545    )?;
546    Ok(())
547}
548
549fn validate_map_loads<Load, GetLoad>(
550    flow_name: &str,
551    source: &str,
552    loads: &[Load],
553    kind: PersistenceKind,
554    persistence: &HashMap<String, PersistenceDefinition>,
555    get_load: GetLoad,
556) -> SdkResult<()>
557where
558    GetLoad: for<'a> Fn(&'a Load) -> (&'a String, Option<&'a str>),
559{
560    let mut physical_names = HashSet::new();
561    for load in loads {
562        let (name, instance) = get_load(load);
563        validate_load_definition(flow_name, source, name, kind, persistence)?;
564        if instance.is_some_and(str::is_empty) {
565            return Err(definition_error(format!(
566                "Flow {flow_name} {source} loads an empty map instance"
567            )));
568        }
569        if instance.is_some_and(|value| value.contains('/')) {
570            return Err(definition_error(format!(
571                "Flow {flow_name} {source} map instance must not contain /"
572            )));
573        }
574        let physical_name = match instance {
575            Some(instance) => physical_name(name, instance),
576            None => format!("{name}/"),
577        };
578        if !physical_names.insert(physical_name) {
579            return Err(definition_error(format!(
580                "Flow {flow_name} {source} has a duplicate state load"
581            )));
582        }
583    }
584    Ok(())
585}
586
587fn validate_definition_loads<Load, GetLoad>(
588    flow_name: &str,
589    source: &str,
590    loads: &[Load],
591    kind: PersistenceKind,
592    persistence: &HashMap<String, PersistenceDefinition>,
593    get_load: GetLoad,
594) -> SdkResult<()>
595where
596    GetLoad: for<'a> Fn(&'a Load) -> &'a String,
597{
598    let mut names = HashSet::new();
599    for load in loads {
600        let name = get_load(load);
601        validate_load_definition(flow_name, source, name, kind, persistence)?;
602        if !names.insert(name) {
603            return Err(definition_error(format!(
604                "Flow {flow_name} {source} has a duplicate state load"
605            )));
606        }
607    }
608    Ok(())
609}
610
611fn validate_load_definition(
612    flow_name: &str,
613    source: &str,
614    name: &str,
615    kind: PersistenceKind,
616    persistence: &HashMap<String, PersistenceDefinition>,
617) -> SdkResult<()> {
618    let definition = persistence.get(name).ok_or_else(|| {
619        definition_error(format!(
620            "Flow {flow_name} {source} loads unregistered state: {name}"
621        ))
622    })?;
623    if definition.kind != kind {
624        return Err(definition_error(format!(
625            "Flow {flow_name} {source} loads the wrong state kind: {name}"
626        )));
627    }
628    Ok(())
629}
630
631fn require_static_name(name: &'static str, kind: &str) -> SdkResult<&'static str> {
632    if name.is_empty() {
633        Err(definition_error(format!("{kind} is required")))
634    } else {
635        Ok(name)
636    }
637}
638
639fn definition_error(message: impl Into<String>) -> SdkError {
640    SdkError::FlowDefinition {
641        message: message.into(),
642    }
643}
644
645pub(crate) fn physical_name(name: &str, instance: &str) -> String {
646    assert_map_instance(instance);
647    let mut encoded = String::new();
648    for byte in instance.bytes() {
649        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
650            encoded.push(char::from(byte));
651        } else {
652            encoded.push_str(&format!("%{byte:02X}"));
653        }
654    }
655    format!("{name}/{encoded}")
656}
657
658pub(crate) fn decode_instance(physical_name: &str, prefix: &str) -> Result<String, String> {
659    let encoded = physical_name
660        .strip_prefix(prefix)
661        .ok_or_else(|| format!("map key {physical_name} does not start with {prefix}"))?;
662    let bytes = encoded.as_bytes();
663    let mut decoded = Vec::with_capacity(bytes.len());
664    let mut index = 0;
665    while index < bytes.len() {
666        if bytes[index] == b'%' {
667            if index + 2 >= bytes.len() {
668                return Err(format!("invalid percent encoding in {physical_name}"));
669            }
670            let high = hex_value(bytes[index + 1])?;
671            let low = hex_value(bytes[index + 2])?;
672            decoded.push(high * 16 + low);
673            index += 3;
674        } else {
675            decoded.push(bytes[index]);
676            index += 1;
677        }
678    }
679    let instance = String::from_utf8(decoded).map_err(|error| error.to_string())?;
680    validate_map_instance(&instance)?;
681    Ok(instance)
682}
683
684pub(crate) fn assert_map_instance(instance: &str) {
685    if let Err(error) = validate_map_instance(instance) {
686        panic!("{error}");
687    }
688}
689
690pub(crate) fn validate_map_instance(instance: &str) -> Result<(), String> {
691    if instance.is_empty() {
692        return Err("map instance is required".to_string());
693    }
694    if instance.contains('/') {
695        return Err("map instances must not contain '/'".to_string());
696    }
697    Ok(())
698}
699
700fn hex_value(byte: u8) -> Result<u8, String> {
701    match byte {
702        b'0'..=b'9' => Ok(byte - b'0'),
703        b'a'..=b'f' => Ok(byte - b'a' + 10),
704        b'A'..=b'F' => Ok(byte - b'A' + 10),
705        _ => Err(format!(
706            "invalid percent-encoding digit {}",
707            char::from(byte)
708        )),
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::{PersistenceDefinition, PersistenceKind, assemble_persistence, physical_name};
715    use crate::{Attribute, AttributeIndex, AttributeMap, PersistenceSchema, SdkError};
716
717    #[test]
718    fn persistence_definition_names_reserve_slash() {
719        for kind in [
720            PersistenceKind::Attribute,
721            PersistenceKind::AttributeMap,
722            PersistenceKind::Channel,
723            PersistenceKind::ChannelMap,
724        ] {
725            let result = assemble_persistence(
726                "TestFlow",
727                &[PersistenceDefinition::new("orders/by-id", kind)],
728            );
729            assert!(result.is_err());
730        }
731
732        assert!(std::panic::catch_unwind(|| physical_name("messages", "orders/by-id")).is_err());
733    }
734
735    #[test]
736    fn hard_delete_definitions_reject_index_sync_and_scalar_types() {
737        let indexed = PersistenceSchema::new().attribute(
738            &Attribute::<String>::new("secret")
739                .indexed(AttributeIndex::keyword())
740                .hard_delete_on_null(),
741        );
742        let synced = PersistenceSchema::new().attribute_map(
743            &AttributeMap::<String>::new("secrets")
744                .hard_delete_on_null()
745                .sync_to_attribute_store(),
746        );
747        let boolean = PersistenceSchema::new()
748            .attribute(&Attribute::<Option<bool>>::new("flag").hard_delete_on_null());
749        let floating = PersistenceSchema::new()
750            .attribute_map(&AttributeMap::<f64>::new("ratios").hard_delete_on_null());
751        for (schema, expected) in [
752            (indexed, "cannot be combined with an index"),
753            (synced, "cannot be combined with sync_to_attribute_store"),
754            (boolean, "supports only string and object values"),
755            (floating, "supports only string and object values"),
756        ] {
757            let error = assemble_persistence("TestFlow", schema.definitions())
758                .expect_err("invalid hard-delete definition");
759            let SdkError::FlowDefinition { message } = error else {
760                panic!("unexpected error {error}");
761            };
762            assert!(message.contains(expected), "{message}");
763        }
764
765        let valid = PersistenceSchema::new()
766            .attribute(&Attribute::<String>::new("secret").hard_delete_on_null())
767            .attribute(&Attribute::<Vec<u8>>::new("bytes").hard_delete_on_null())
768            .attribute_map(
769                &AttributeMap::<std::collections::HashMap<String, u64>>::new("tokens")
770                    .hard_delete_on_null(),
771            );
772        let persistence =
773            assemble_persistence("TestFlow", valid.definitions()).expect("valid definitions");
774        assert!(persistence["secret"].is_hard_delete_on_null);
775    }
776
777    #[test]
778    fn registered_attribute_lookup_matches_kind_value_type_and_settings() {
779        struct StatusFlow;
780        impl crate::Flow for StatusFlow {
781            type StartInput = ();
782            fn steps(&self) -> crate::StepList<'_, ()> {
783                crate::StepList::empty()
784            }
785            fn persistence(&self) -> PersistenceSchema {
786                PersistenceSchema::new()
787                    .attribute(&Attribute::<String>::new("status"))
788                    .attribute_map(&AttributeMap::<u32>::new("quantities"))
789                    .attribute(&Attribute::<String>::new("secret").hard_delete_on_null())
790                    .channel(&crate::Channel::<String>::new("approvals"))
791            }
792        }
793        let registry = super::Registry::new()
794            .register(StatusFlow)
795            .expect("register StatusFlow");
796        let lookup = |definition: PersistenceDefinition| {
797            registry
798                .require_registered_attribute(&definition)
799                .map_err(|error| error.to_string())
800        };
801
802        assert!(
803            lookup(PersistenceDefinition::for_attribute(
804                &Attribute::<String>::new("status")
805            ))
806            .is_ok()
807        );
808        assert!(
809            lookup(PersistenceDefinition::for_attribute_map(
810                &AttributeMap::<u32>::new("quantities")
811            ))
812            .is_ok()
813        );
814        assert!(
815            lookup(PersistenceDefinition::for_attribute(
816                &Attribute::<String>::new("secret").hard_delete_on_null()
817            ))
818            .is_ok()
819        );
820        for mismatched in [
821            PersistenceDefinition::for_attribute(&Attribute::<i64>::new("status")),
822            PersistenceDefinition::for_attribute_map(&AttributeMap::<String>::new("status")),
823            PersistenceDefinition::for_attribute(&Attribute::<String>::new("secret")),
824            PersistenceDefinition::for_attribute(
825                &Attribute::<String>::new("status").indexed(AttributeIndex::keyword()),
826            ),
827        ] {
828            let message = lookup(mismatched).expect_err("mismatched definition");
829            assert!(
830                message.contains("differs from the definition registered by Flow StatusFlow"),
831                "{message}"
832            );
833        }
834        let channel_name = lookup(PersistenceDefinition::for_attribute(
835            &Attribute::<String>::new("approvals"),
836        ))
837        .expect_err("a Channel is not an Attribute");
838        assert!(
839            channel_name.contains("approvals is not registered"),
840            "{channel_name}"
841        );
842    }
843}