Skip to main content

dex_sdk/
attribute.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::marker::PhantomData;
11
12use dex_protocol::dex::{AttributeSyncConfig, AttributeWrite, IndexConfig, value};
13
14use crate::{Context, HandlerError, HandlerResult, Value};
15
16/// Selects one AttributeMap instance to load for a handler invocation.
17///
18/// Create instance loads with [`AttributeMap::load`], then attach them with
19/// [`Rpc::load_attribute_map_instance`](crate::Rpc::load_attribute_map_instance).
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct AttributeMapLoad {
22    pub(crate) name: String,
23    pub(crate) instance: Option<String>,
24}
25
26/// Defines one durable, typed value stored with a Flow.
27///
28/// Declare Attributes on the Flow, add them to [`crate::PersistenceSchema`], and reuse the same
29/// definition from Step and RPC code. Values use Serde serialization. `get` distinguishes a missing
30/// value with `None`; `get_required` converts absence into [`HandlerError`].
31///
32/// # Examples
33///
34/// ```
35/// use dex_sdk::{Attribute, AttributeIndex};
36///
37/// let status = Attribute::<String>::new("status")
38///     .indexed(AttributeIndex::keyword());
39/// ```
40pub struct Attribute<T> {
41    name: String,
42    index: Option<AttributeIndex>,
43    sync_to_attribute_store: bool,
44    is_hard_delete_on_null: bool,
45    numeric_or_boolean_type_name: Option<&'static str>,
46    marker: PhantomData<fn() -> T>,
47}
48
49impl<T> Attribute<T> {
50    /// Defines an unindexed Attribute with a stable `name`.
51    ///
52    /// Slash is prohibited because it is a reserved character.
53    pub fn new(name: impl Into<String>) -> Self {
54        Self {
55            name: name.into(),
56            index: None,
57            sync_to_attribute_store: false,
58            is_hard_delete_on_null: false,
59            numeric_or_boolean_type_name: None,
60            marker: PhantomData,
61        }
62    }
63
64    /// Enables Flow-search indexing with the selected index type.
65    pub fn indexed(mut self, index: AttributeIndex) -> Self {
66        self.index = Some(index);
67        self
68    }
69
70    /// Enables asynchronous latest-state projection to the Flow's selected Attribute Store.
71    ///
72    /// The Flow must select a server-configured store with
73    /// [`FlowConfig::attribute_store_names`](crate::FlowConfig::attribute_store_names). Deletions
74    /// project SQL `NULL`, and projection failures never roll back the Flow Attribute write.
75    #[must_use]
76    pub fn sync_to_attribute_store(mut self) -> Self {
77        self.sync_to_attribute_store = true;
78        self
79    }
80
81    /// Stores this Attribute's values only in the Dex blob store and permanently deletes the stored
82    /// value when the Attribute is deleted or set to null.
83    ///
84    /// Use this option for short-lived secrets, such as API keys or access tokens. Dex never writes
85    /// the value into Flow history, regardless of its size. Flow history keeps only a reference to
86    /// the stored value. [`Self::delete`], [`Context::delete_attribute`], and a write whose value
87    /// encodes to null permanently delete the stored value before the write commits. A deleted
88    /// value reads as an absent Attribute, so [`Self::get`] returns `Ok(None)`.
89    ///
90    /// Step handlers, RPC handlers, and Attribute reads receive the value inline. The SDK never
91    /// stores the value in the local [`BlobCache`](crate::BlobCache).
92    ///
93    /// # Supported values
94    ///
95    /// Only values that encode to a string or an object are supported. Strings, Serde structs,
96    /// maps, sequences, and bytes are supported. Numeric and Boolean values are rejected.
97    /// [`Registry::register`](crate::Registry::register) returns
98    /// [`SdkError::FlowDefinition`](crate::SdkError::FlowDefinition) when `T` is a Rust integer,
99    /// floating-point, or `bool` type, or an `Option` of one. Every write also checks its encoded
100    /// value. A value of another type that encodes to a number or a Boolean, such as a newtype
101    /// around `u64`, fails when it is written. Deletion and null values are always allowed.
102    ///
103    /// To store a numeric or Boolean secret, wrap it in a struct so that it encodes as an object:
104    ///
105    /// ```
106    /// use dex_sdk::Attribute;
107    /// use serde::{Deserialize, Serialize};
108    ///
109    /// #[derive(Deserialize, Serialize)]
110    /// struct OneTimeCode {
111    ///     code: u32,
112    /// }
113    ///
114    /// let one_time_code = Attribute::<OneTimeCode>::new("one-time-code").hard_delete_on_null();
115    /// ```
116    ///
117    /// # Restrictions
118    ///
119    /// - This option cannot be combined with [`Self::indexed`] or
120    ///   [`Self::sync_to_attribute_store`]. Registration returns
121    ///   [`SdkError::FlowDefinition`](crate::SdkError::FlowDefinition) for either combination.
122    /// - [`Client::wait_for_attribute_match`](crate::Client::wait_for_attribute_match) rejects a
123    ///   hard-delete Attribute with [`SdkError::InvalidArgument`](crate::SdkError::InvalidArgument)
124    ///   before it contacts Dex.
125    /// - Action and Action permission conditions cannot read a hard-delete Attribute. Dex
126    ///   matches those conditions against stored Attribute values, which hard-delete Attributes
127    ///   never keep in Flow history. Use an ordinary Attribute as the condition source instead.
128    /// - Every write must use a definition whose hard-delete setting matches the definition that
129    ///   the Flow registered. A mismatched write is rejected.
130    /// - The Dex Server must have an enabled blob store. The Server rejects writes when its blob
131    ///   store is disabled.
132    /// - The Dex Server must support protocol version 3 or later. A Worker learns the protocol
133    ///   version when it starts. A Client requests it once, before its first hard-delete write,
134    ///   and reuses the result. Writes fail with an error when the Server is older.
135    ///
136    /// # Limitations
137    ///
138    /// - Each Flow keeps one stored copy per Attribute key. Every write overwrites it in place.
139    /// - A Step or RPC attempt writes or deletes the stored value before Dex commits the attempt's
140    ///   result. A retried attempt, other Steps, and Attribute reads can therefore observe a write
141    ///   or deletion from an attempt that later failed.
142    /// - When concurrent Steps or RPCs write the same key, the final stored value may not be the
143    ///   value of the last committed write.
144    /// - A Flow start can fail because a Flow with the same Flow ID is already running. That
145    ///   failed start still overwrites the running Flow's stored value. A null initial value
146    ///   deletes it. This applies to
147    ///   [`Client::start_flow_with_options`](crate::Client::start_flow_with_options) and to SubFlow
148    ///   starts.
149    /// - Executions that reuse a Flow ID share the stored value.
150    /// - Resetting a Flow does not restore a deleted value. A deleted value reads as absent.
151    /// - A Flow retry or a restarted SubFlow starts from the original initial value. If an
152    ///   earlier run deleted that value, the new run reads it as absent.
153    /// - Removing `hard_delete_on_null` from a definition leaves the stored value in place until
154    ///   Dex removes the Flow. Delete the value with the hard-delete definition before removing it.
155    /// - Stored values remain until they are deleted, or until Dex removes the Flow through
156    ///   retention and blob cleanup.
157    /// - Blob store backups and bucket versioning can retain copies of stored values.
158    ///
159    /// # Examples
160    ///
161    /// ```
162    /// use dex_sdk::{Attribute, Context, HandlerResult, PersistenceSchema, StartFlowOptions};
163    /// use std::sync::LazyLock;
164    ///
165    /// static API_KEY: LazyLock<Attribute<String>> =
166    ///     LazyLock::new(|| Attribute::new("api-key").hard_delete_on_null());
167    ///
168    /// // Register the definition in the Flow's persistence schema.
169    /// let schema = PersistenceSchema::new().attribute(&API_KEY);
170    ///
171    /// // Supply the first secret when the Flow starts.
172    /// let options = StartFlowOptions::new().initial_attribute(&API_KEY, "key-1".to_owned());
173    ///
174    /// // A Step reads the secret inline and rotates it.
175    /// fn rotate_api_key(context: &mut Context) -> HandlerResult<()> {
176    ///     let current = API_KEY.get_required(context)?;
177    ///     API_KEY.set(context, format!("{current}-rotated"))
178    /// }
179    ///
180    /// // A later Step permanently deletes the stored secret.
181    /// fn revoke_api_key(context: &mut Context) -> HandlerResult<()> {
182    ///     API_KEY.delete(context)
183    /// }
184    /// # let _ = (schema, options, rotate_api_key, revoke_api_key);
185    /// ```
186    #[must_use]
187    pub fn hard_delete_on_null(mut self) -> Self
188    where
189        T: 'static,
190    {
191        self.is_hard_delete_on_null = true;
192        self.numeric_or_boolean_type_name = numeric_or_boolean_type_name::<T>();
193        self
194    }
195
196    /// Reads the current value from a Step or RPC invocation.
197    ///
198    /// Returns `Ok(None)` when no value is stored.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`HandlerError`] when the payload cannot be decoded.
203    pub fn get(&self, context: &Context) -> HandlerResult<Option<T>>
204    where
205        T: Value,
206    {
207        context.get_attribute(self)
208    }
209
210    /// Reads the current value and treats absence as an error.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`HandlerError`] when the Attribute is missing or cannot be decoded.
215    pub fn get_required(&self, context: &Context) -> HandlerResult<T>
216    where
217        T: Value,
218    {
219        self.get(context)?.ok_or_else(|| {
220            HandlerError::new(
221                "dex_sdk::HandlerError",
222                format!("attribute {} is missing", self.name),
223            )
224        })
225    }
226
227    /// Stages `value` for durable persistence in the current invocation.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`HandlerError`] when the value cannot be encoded or indexed.
232    pub fn set(&self, context: &mut Context, value: T) -> HandlerResult<()>
233    where
234        T: Value,
235    {
236        context.set_attribute(self, value)
237    }
238
239    /// Stages deletion of the current value.
240    ///
241    /// Deleting a missing Attribute is valid.
242    pub fn delete(&self, context: &mut Context) -> HandlerResult<()> {
243        context.delete_attribute(self)
244    }
245
246    /// Creates a lock request for Step options or an RPC definition.
247    pub fn lock(&self) -> AttributeLock {
248        AttributeLock {
249            attribute: self.name.clone(),
250            instance: None,
251        }
252    }
253
254    pub(crate) fn name(&self) -> &str {
255        &self.name
256    }
257
258    pub(crate) fn index(&self) -> Option<&AttributeIndex> {
259        self.index.as_ref()
260    }
261
262    pub(crate) fn sync_config(&self) -> Option<AttributeSyncConfig> {
263        self.sync_to_attribute_store
264            .then_some(AttributeSyncConfig { enabled: true })
265    }
266
267    pub(crate) fn is_sync_to_attribute_store(&self) -> bool {
268        self.sync_to_attribute_store
269    }
270
271    pub(crate) fn is_hard_delete_on_null(&self) -> bool {
272        self.is_hard_delete_on_null
273    }
274
275    pub(crate) fn numeric_or_boolean_type_name(&self) -> Option<&'static str> {
276        self.numeric_or_boolean_type_name
277    }
278
279    pub(crate) fn write_settings(&self) -> AttributeWriteSettings {
280        AttributeWriteSettings {
281            index_config: self.index().map(|index| index.proto_config(false)),
282            sync_config: self.sync_config(),
283            is_hard_delete_on_null: self.is_hard_delete_on_null,
284        }
285    }
286}
287
288impl<T> Clone for Attribute<T> {
289    fn clone(&self) -> Self {
290        Self {
291            name: self.name.clone(),
292            index: self.index.clone(),
293            sync_to_attribute_store: self.sync_to_attribute_store,
294            is_hard_delete_on_null: self.is_hard_delete_on_null,
295            numeric_or_boolean_type_name: self.numeric_or_boolean_type_name,
296            marker: PhantomData,
297        }
298    }
299}
300
301/// Defines keyed durable values sharing one Attribute name.
302///
303/// Each `instance` identifies an independent stored value and lock. Add the map definition once to
304/// [`crate::PersistenceSchema`], then pass an instance on each access.
305/// Slash is prohibited in instance keys because it is a reserved character.
306pub struct AttributeMap<T> {
307    name: String,
308    index: Option<AttributeIndex>,
309    sync_to_attribute_store: bool,
310    is_hard_delete_on_null: bool,
311    numeric_or_boolean_type_name: Option<&'static str>,
312    marker: PhantomData<fn() -> T>,
313}
314
315impl<T> AttributeMap<T> {
316    /// Defines an unindexed Attribute map with a stable `name`.
317    ///
318    /// Slash is prohibited because it is a reserved character.
319    pub fn new(name: impl Into<String>) -> Self {
320        Self {
321            name: name.into(),
322            index: None,
323            sync_to_attribute_store: false,
324            is_hard_delete_on_null: false,
325            numeric_or_boolean_type_name: None,
326            marker: PhantomData,
327        }
328    }
329
330    /// Enables search indexing for every instance using the selected index type.
331    pub fn indexed(mut self, index: AttributeIndex) -> Self {
332        self.index = Some(index);
333        self
334    }
335
336    /// Enables asynchronous latest-state projection for every AttributeMap instance.
337    ///
338    /// Each instance maps by its physical Attribute name. The Flow must select a server-configured
339    /// stores with [`FlowConfig::attribute_store_names`](crate::FlowConfig::attribute_store_names).
340    /// Deletions project SQL `NULL`, and projection failures never roll back Flow Attribute writes.
341    #[must_use]
342    pub fn sync_to_attribute_store(mut self) -> Self {
343        self.sync_to_attribute_store = true;
344        self
345    }
346
347    /// Stores every instance's values only in the Dex blob store and permanently deletes an
348    /// instance's stored value when that instance is deleted or set to null.
349    ///
350    /// Use this option for keyed short-lived secrets, such as one API key per tenant. Each
351    /// instance has its own stored value. Dex never writes instance values into Flow history.
352    /// [`Self::delete`] and a write whose value encodes to null permanently delete the instance's
353    /// stored value. The instance then reads as absent. Handlers and Attribute reads receive
354    /// values inline. The SDK never stores them in the local [`BlobCache`](crate::BlobCache).
355    ///
356    /// Only string and object values, including bytes, are supported. Numeric and Boolean values
357    /// are rejected. This option cannot be combined with [`Self::indexed`] or
358    /// [`Self::sync_to_attribute_store`].
359    /// [`Client::wait_for_attribute_map_instance_match`](crate::Client::wait_for_attribute_map_instance_match)
360    /// rejects it before it contacts Dex. Action and Action permission conditions cannot read
361    /// it. Writes require a Server with an enabled blob store and
362    /// protocol version 3 or later. Each instance key keeps one stored copy, and later writes
363    /// overwrite it in place.
364    ///
365    /// Supported values, restrictions, Server requirements, and limitations are otherwise the same
366    /// as for [`Attribute::hard_delete_on_null`]. Read that method before storing secrets.
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use dex_sdk::{AttributeMap, Context, HandlerResult, PersistenceSchema, StartFlowOptions};
372    /// use std::sync::LazyLock;
373    ///
374    /// static TENANT_API_KEYS: LazyLock<AttributeMap<String>> =
375    ///     LazyLock::new(|| AttributeMap::new("tenant-api-keys").hard_delete_on_null());
376    ///
377    /// let schema = PersistenceSchema::new().attribute_map(&TENANT_API_KEYS);
378    /// let options = StartFlowOptions::new().initial_attribute_map(
379    ///     &TENANT_API_KEYS,
380    ///     "tenant-1",
381    ///     "key-1".to_owned(),
382    /// );
383    ///
384    /// // The Step or RPC must load the instance before reading it.
385    /// fn revoke_tenant_api_key(context: &mut Context, tenant: &str) -> HandlerResult<()> {
386    ///     if TENANT_API_KEYS.get(context, tenant)?.is_some() {
387    ///         TENANT_API_KEYS.delete(context, tenant)?;
388    ///     }
389    ///     Ok(())
390    /// }
391    /// # let _ = (schema, options, revoke_tenant_api_key);
392    /// ```
393    #[must_use]
394    pub fn hard_delete_on_null(mut self) -> Self
395    where
396        T: 'static,
397    {
398        self.is_hard_delete_on_null = true;
399        self.numeric_or_boolean_type_name = numeric_or_boolean_type_name::<T>();
400        self
401    }
402
403    /// Selects one slash-free map `instance` for loading into a handler Context.
404    ///
405    /// The instance remains readable for the handler even when it is absent; absence is returned as
406    /// `None`. Load the map definition when the RPC must enumerate every instance.
407    pub fn load(&self, instance: impl Into<String>) -> AttributeMapLoad {
408        AttributeMapLoad {
409            name: self.name.clone(),
410            instance: Some(instance.into()),
411        }
412    }
413
414    /// Reads one instance, returning `Ok(None)` when it is absent.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`HandlerError`] when the payload cannot be decoded.
419    pub fn get(&self, context: &Context, instance: &str) -> HandlerResult<Option<T>>
420    where
421        T: Value,
422    {
423        context.get_attribute_map(self, instance)
424    }
425
426    /// Reads one instance and treats absence as an error.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`HandlerError`] when the instance is missing or cannot be decoded.
431    pub fn get_required(&self, context: &Context, instance: &str) -> HandlerResult<T>
432    where
433        T: Value,
434    {
435        self.get(context, instance)?.ok_or_else(|| {
436            HandlerError::new(
437                "dex_sdk::HandlerError",
438                format!("attribute {}[{instance}] is missing", self.name),
439            )
440        })
441    }
442
443    /// Stages one instance value for durable persistence.
444    ///
445    /// # Errors
446    ///
447    /// Returns [`HandlerError`] when the value cannot be encoded or indexed.
448    pub fn set(&self, context: &mut Context, instance: &str, value: T) -> HandlerResult<()>
449    where
450        T: Value,
451    {
452        context.set_attribute_map(self, instance, value)
453    }
454
455    /// Stages deletion of one instance; missing instances are valid.
456    pub fn delete(&self, context: &mut Context, instance: &str) -> HandlerResult<()> {
457        context.delete_attribute_map(self, instance)
458    }
459
460    /// Returns the effective number of instances, including buffered writes.
461    pub fn map_size(&self, context: &Context) -> HandlerResult<usize> {
462        Ok(self.all_instance_keys(context)?.len())
463    }
464
465    /// Returns decoded effective instance keys in ascending string order.
466    pub fn all_instance_keys(&self, context: &Context) -> HandlerResult<Vec<String>> {
467        context.attribute_map_keys(self)
468    }
469
470    /// Creates a lock request scoped to one map instance.
471    pub fn lock(&self, instance: impl Into<String>) -> AttributeLock {
472        let instance = instance.into();
473        crate::registry::assert_map_instance(&instance);
474        AttributeLock {
475            attribute: self.name.clone(),
476            instance: Some(instance),
477        }
478    }
479
480    pub(crate) fn name(&self) -> &str {
481        &self.name
482    }
483
484    pub(crate) fn index(&self) -> Option<&AttributeIndex> {
485        self.index.as_ref()
486    }
487
488    pub(crate) fn sync_config(&self) -> Option<AttributeSyncConfig> {
489        self.sync_to_attribute_store
490            .then_some(AttributeSyncConfig { enabled: true })
491    }
492
493    pub(crate) fn is_sync_to_attribute_store(&self) -> bool {
494        self.sync_to_attribute_store
495    }
496
497    pub(crate) fn is_hard_delete_on_null(&self) -> bool {
498        self.is_hard_delete_on_null
499    }
500
501    pub(crate) fn numeric_or_boolean_type_name(&self) -> Option<&'static str> {
502        self.numeric_or_boolean_type_name
503    }
504
505    pub(crate) fn write_settings(&self) -> AttributeWriteSettings {
506        AttributeWriteSettings {
507            index_config: self.index().map(|index| index.proto_config(true)),
508            sync_config: self.sync_config(),
509            is_hard_delete_on_null: self.is_hard_delete_on_null,
510        }
511    }
512}
513
514impl<T> Clone for AttributeMap<T> {
515    fn clone(&self) -> Self {
516        Self {
517            name: self.name.clone(),
518            index: self.index.clone(),
519            sync_to_attribute_store: self.sync_to_attribute_store,
520            is_hard_delete_on_null: self.is_hard_delete_on_null,
521            numeric_or_boolean_type_name: self.numeric_or_boolean_type_name,
522            marker: PhantomData,
523        }
524    }
525}
526
527/// Carries the definition-derived fields copied onto every write of one Attribute definition.
528#[derive(Clone, Debug, Default)]
529pub(crate) struct AttributeWriteSettings {
530    pub(crate) index_config: Option<IndexConfig>,
531    pub(crate) sync_config: Option<AttributeSyncConfig>,
532    pub(crate) is_hard_delete_on_null: bool,
533}
534
535impl AttributeWriteSettings {
536    pub(crate) fn attribute_write(
537        self,
538        key: String,
539        value: dex_protocol::dex::Value,
540    ) -> AttributeWrite {
541        AttributeWrite {
542            key,
543            value: Some(value),
544            index_config: self.index_config,
545            sync_config: self.sync_config,
546            is_hard_delete_on_null: self.is_hard_delete_on_null,
547        }
548    }
549}
550
551/// Returns `Ok(())` for a valid hard-delete write and for every write without the flag.
552///
553/// Returns an error when a hard-delete write also enables indexing or Attribute Store
554/// synchronization, or when its value encodes to an integer, floating-point, or Boolean value.
555pub(crate) fn validate_hard_delete_attribute_write(write: &AttributeWrite) -> Result<(), String> {
556    if !write.is_hard_delete_on_null {
557        return Ok(());
558    }
559    let key = &write.key;
560    if write
561        .index_config
562        .as_ref()
563        .is_some_and(|config| config.enable)
564    {
565        return Err(format!(
566            "hard-delete Attribute {key} cannot be combined with an index"
567        ));
568    }
569    if write
570        .sync_config
571        .as_ref()
572        .is_some_and(|config| config.enabled)
573    {
574        return Err(format!(
575            "hard-delete Attribute {key} cannot be combined with sync_to_attribute_store"
576        ));
577    }
578    match write.value.as_ref().and_then(|value| value.kind.as_ref()) {
579        Some(value::Kind::IntValue(_))
580        | Some(value::Kind::DoubleValue(_))
581        | Some(value::Kind::BoolValue(_)) => Err(format!(
582            "hard-delete Attribute {key} supports only string and object values; numeric and \
583             Boolean values are rejected"
584        )),
585        _ => Ok(()),
586    }
587}
588
589/// Returns `Ok(())` when a write's hard-delete setting matches the registered definition of
590/// `attribute_name`, and an error naming the Attribute otherwise.
591pub(crate) fn require_registered_hard_delete_setting(
592    attribute_name: &str,
593    is_registered_hard_delete_on_null: bool,
594    is_write_hard_delete_on_null: bool,
595) -> Result<(), String> {
596    if is_registered_hard_delete_on_null == is_write_hard_delete_on_null {
597        return Ok(());
598    }
599    Err(format!(
600        "Attribute {attribute_name} hard_delete_on_null setting does not match its registered \
601         definition"
602    ))
603}
604
605/// Returns the type name when `T` is a Rust numeric or Boolean type, or an `Option` of one.
606///
607/// Returns `None` for every other type, including newtypes that still encode to numbers.
608fn numeric_or_boolean_type_name<T: 'static>() -> Option<&'static str> {
609    macro_rules! scalar_type_ids {
610        ($($scalar:ty),* $(,)?) => {
611            [$(TypeId::of::<$scalar>(), TypeId::of::<Option<$scalar>>()),*]
612        };
613    }
614    let scalar_type_ids = scalar_type_ids!(
615        i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool,
616    );
617    scalar_type_ids
618        .contains(&TypeId::of::<T>())
619        .then(std::any::type_name::<T>)
620}
621
622#[derive(Clone, Debug, Eq, PartialEq)]
623/// Describes how Dex projects an Attribute value into its Flow-search index.
624///
625/// Without [`Self::with_key`], a single Attribute uses its name as the physical key. Attribute maps
626/// derive per-instance physical keys unless an explicit key is set.
627pub struct AttributeIndex {
628    kind: AttributeIndexKind,
629    key: Option<String>,
630}
631
632impl AttributeIndex {
633    fn new(kind: AttributeIndexKind) -> Self {
634        Self { kind, key: None }
635    }
636
637    /// Indexes one exact UTF-8 string for equality and aggregation queries.
638    pub fn keyword() -> Self {
639        Self::new(AttributeIndexKind::Keyword)
640    }
641
642    /// Indexes analyzed UTF-8 text for full-text queries.
643    pub fn full_text() -> Self {
644        Self::new(AttributeIndexKind::FullText)
645    }
646
647    /// Indexes an array of exact UTF-8 strings.
648    pub fn keyword_array() -> Self {
649        Self::new(AttributeIndexKind::KeywordArray)
650    }
651
652    /// Indexes a signed 64-bit integer.
653    pub fn int() -> Self {
654        Self::new(AttributeIndexKind::Int)
655    }
656
657    /// Indexes a finite 64-bit floating-point number.
658    pub fn double() -> Self {
659        Self::new(AttributeIndexKind::Double)
660    }
661
662    /// Indexes a Boolean value.
663    pub fn bool() -> Self {
664        Self::new(AttributeIndexKind::Bool)
665    }
666
667    /// Indexes an RFC 3339 date-time value.
668    pub fn date_time() -> Self {
669        Self::new(AttributeIndexKind::DateTime)
670    }
671
672    /// Uses an explicit physical search-index key instead of the default Attribute-derived key.
673    pub fn with_key(mut self, key: impl Into<String>) -> Self {
674        self.key = Some(key.into());
675        self
676    }
677}
678
679#[derive(Clone, Copy, Debug, Eq, PartialEq)]
680pub(crate) enum AttributeIndexKind {
681    Keyword,
682    FullText,
683    KeywordArray,
684    Int,
685    Double,
686    Bool,
687    DateTime,
688}
689
690#[derive(Clone, Debug, Eq, PartialEq)]
691/// Identifies an Attribute or Attribute-map instance lock.
692///
693/// Create locks with [`Attribute::lock`] or [`AttributeMap::lock`], then attach them to
694/// [`crate::StepOptions`] or [`crate::RpcDefinition`](crate::Rpc).
695pub struct AttributeLock {
696    attribute: String,
697    instance: Option<String>,
698}
699
700impl AttributeIndex {
701    pub(crate) fn proto_config(&self, dynamic: bool) -> IndexConfig {
702        IndexConfig {
703            enable: true,
704            r#type: self.kind.proto_value(),
705            index_key: self
706                .key
707                .clone()
708                .or_else(|| dynamic.then(String::new))
709                .unwrap_or_default(),
710        }
711    }
712
713    pub(crate) fn key(&self) -> Option<&str> {
714        self.key.as_deref()
715    }
716
717    pub(crate) fn proto_value(&self) -> i32 {
718        self.kind.proto_value()
719    }
720}
721
722impl AttributeIndexKind {
723    pub(crate) fn proto_value(self) -> i32 {
724        use dex_protocol::dex::IndexType;
725
726        match self {
727            Self::Keyword => IndexType::Keyword.into(),
728            Self::FullText => IndexType::Text.into(),
729            Self::KeywordArray => IndexType::KeywordArray.into(),
730            Self::Int => IndexType::Int.into(),
731            Self::Double => IndexType::Double.into(),
732            Self::Bool => IndexType::Bool.into(),
733            Self::DateTime => IndexType::Datetime.into(),
734        }
735    }
736}
737
738impl AttributeLock {
739    pub(crate) fn attribute_name(&self) -> &str {
740        &self.attribute
741    }
742
743    pub(crate) fn instance(&self) -> Option<&str> {
744        self.instance.as_deref()
745    }
746
747    pub(crate) fn physical_name(&self) -> String {
748        match self.instance.as_deref() {
749            Some(instance) => crate::registry::physical_name(&self.attribute, instance),
750            None => self.attribute.clone(),
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::{Attribute, AttributeMap};
758
759    #[test]
760    fn sync_configuration_is_opt_in_and_survives_clone() {
761        let plain = Attribute::<String>::new("plain");
762        let synced = Attribute::<String>::new("synced").sync_to_attribute_store();
763        let synced_map = AttributeMap::<String>::new("synced_map").sync_to_attribute_store();
764
765        assert!(plain.sync_config().is_none());
766        assert_eq!(
767            synced.sync_config().map(|config| config.enabled),
768            Some(true)
769        );
770        assert_eq!(
771            synced_map
772                .clone()
773                .sync_config()
774                .map(|config| config.enabled),
775            Some(true)
776        );
777    }
778}