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}