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}