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