dex_sdk/flow_definition/mod.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::time::SystemTime;
10
11use dex_protocol::dex::{
12 FlowDefinitionUpload, FlowDefinitionUploadOutcome as ProtoFlowDefinitionUploadOutcome,
13 FlowDefinitionUploadResult as ProtoFlowDefinitionUploadResult, UploadFlowDefinitionsRequest,
14 UploadFlowDefinitionsResponse,
15};
16
17#[cfg(test)]
18mod conformance_tests;
19mod definition_sources;
20mod generated_at;
21mod source_path;
22mod worker_upload;
23#[cfg(test)]
24mod worker_upload_tests;
25
26pub(crate) use definition_sources::{collect_flow_definitions, validate_document};
27pub(crate) use source_path::{current_working_directory, rewrite_source_paths};
28pub(crate) use worker_upload::upload_flow_definitions_at_worker_start;
29
30const MAX_DEFINITIONS_PER_REQUEST: usize = 256;
31// Half the Server's default 32 MiB gRPC message limit; a larger document goes alone.
32const MAX_CONTENT_BYTES_PER_REQUEST: usize = 16 * 1024 * 1024;
33
34/// Holds one Flow Definition Graph JSON document in memory for upload to the Dex Server.
35///
36/// Dex Web draws a Flow type's graph from the document stored for it. Pass documents to
37/// [`crate::WorkerOptions::flow_definitions`] to upload them when a Worker starts, or to
38/// [`crate::Client::upload_flow_definitions`] from a deploy script. Use
39/// [`crate::WorkerOptions::flow_definition_paths`] instead when the documents are files, such as
40/// the output of `dexcli visualize`.
41///
42/// The Flow type must equal `flow.name` in the content. The content must be a UTF-8 JSON object
43/// whose `schemaVersion` starts with `2.`. The Dex Server compares `generated_at` with the stored
44/// document: a different document replaces the stored one only when its generation time is
45/// strictly later. Pass a fixed time for a document generated from a template, such as the
46/// template's release time, so every Worker sends the same time.
47///
48/// `Debug` prints the content length, not the content.
49///
50/// Since 1.45.0.
51///
52/// # Examples
53///
54/// ```no_run
55/// use std::time::{Duration, SystemTime};
56/// use dex_sdk::{FlowDefinitionDocument, WorkerOptions};
57///
58/// let content = std::fs::read("flow-definitions/OrderFlow.json")?;
59/// let generated_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_793_491_200);
60/// let options = WorkerOptions::new().flow_definitions([FlowDefinitionDocument::new(
61/// "OrderFlow",
62/// content,
63/// generated_at,
64/// )]);
65/// # Ok::<(), Box<dyn std::error::Error>>(())
66/// ```
67#[derive(Clone, PartialEq, Eq)]
68pub struct FlowDefinitionDocument {
69 flow_type: String,
70 content: Vec<u8>,
71 generated_at: SystemTime,
72}
73
74impl FlowDefinitionDocument {
75 /// Creates a document from its Flow type, JSON content, and generation time.
76 ///
77 /// `flow_type` must equal `flow.name` in `content`, and `content` must hold the UTF-8 JSON
78 /// bytes of the Flow Definition Graph. The constructor does not validate them.
79 /// [`crate::Worker::try_new`] returns [`crate::SdkError::FlowDefinition`], and
80 /// [`crate::Client::upload_flow_definitions`] returns [`crate::SdkError::InvalidArgument`],
81 /// when a document is not a UTF-8 JSON object whose `schemaVersion` starts with `2.` and whose
82 /// `flow.name` equals the Flow type. The Dex Server rejects other invalid documents.
83 ///
84 /// Since 1.45.0.
85 pub fn new(
86 flow_type: impl Into<String>,
87 content: impl Into<Vec<u8>>,
88 generated_at: SystemTime,
89 ) -> Self {
90 Self {
91 flow_type: flow_type.into(),
92 content: content.into(),
93 generated_at,
94 }
95 }
96
97 /// Returns the Flow type that the document describes.
98 ///
99 /// Since 1.45.0.
100 pub fn flow_type(&self) -> &str {
101 &self.flow_type
102 }
103
104 /// Returns the UTF-8 JSON bytes of the document.
105 ///
106 /// Since 1.45.0.
107 pub fn content(&self) -> &[u8] {
108 &self.content
109 }
110
111 /// Returns the time when the document was generated.
112 ///
113 /// Since 1.45.0.
114 pub fn generated_at(&self) -> SystemTime {
115 self.generated_at
116 }
117}
118
119impl std::fmt::Debug for FlowDefinitionDocument {
120 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 formatter
122 .debug_struct("FlowDefinitionDocument")
123 .field("flow_type", &self.flow_type)
124 .field("content_bytes", &self.content.len())
125 .field("generated_at", &self.generated_at)
126 .finish()
127 }
128}
129
130/// Reports what the Dex Server did with one uploaded Flow definition.
131///
132/// The Server decides each Flow type separately. A stored document is replaced only by a
133/// different document with a strictly later generation time, unless the upload uses `force`.
134/// See [`crate::Client::upload_flow_definitions`] for an example.
135///
136/// Since 1.45.0.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138#[non_exhaustive]
139pub enum FlowDefinitionUploadOutcome {
140 /// No document was stored for the Flow type; the uploaded one is now stored.
141 Created,
142 /// The uploaded document replaced a different, older one.
143 Replaced,
144 /// The stored document is identical; nothing was written.
145 Unchanged,
146 /// The stored document differs and is at least as new, so it stays.
147 ///
148 /// Upload with `force` to replace it anyway.
149 KeptExisting,
150 /// The Server reported an outcome that this SDK version does not know.
151 Unknown,
152}
153
154/// Describes the outcome of uploading one Flow definition and the document stored afterwards.
155///
156/// [`crate::Client::upload_flow_definitions`] returns one result for each uploaded document, in
157/// request order. The stored digest and generation time describe the document that the Server
158/// holds after the call. They differ from the uploaded document only for
159/// [`FlowDefinitionUploadOutcome::KeptExisting`].
160///
161/// Since 1.45.0.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct FlowDefinitionUploadResult {
164 flow_type: String,
165 outcome: FlowDefinitionUploadOutcome,
166 stored_digest: String,
167 stored_generated_at: Option<SystemTime>,
168}
169
170impl FlowDefinitionUploadResult {
171 /// Returns the Flow type of the uploaded document.
172 ///
173 /// Since 1.45.0.
174 pub fn flow_type(&self) -> &str {
175 &self.flow_type
176 }
177
178 /// Returns what the Server did with the uploaded document.
179 ///
180 /// Since 1.45.0.
181 pub fn outcome(&self) -> FlowDefinitionUploadOutcome {
182 self.outcome
183 }
184
185 /// Returns the lowercase hexadecimal SHA-256 digest of the document stored after the call.
186 ///
187 /// Since 1.45.0.
188 pub fn stored_digest(&self) -> &str {
189 &self.stored_digest
190 }
191
192 /// Returns the generation time of the document stored after the call.
193 ///
194 /// Returns the time at microsecond precision, or `None` when the Server did not report one
195 /// or it is outside the platform's `SystemTime` range.
196 ///
197 /// Since 1.45.0.
198 pub fn stored_generated_at(&self) -> Option<SystemTime> {
199 self.stored_generated_at
200 }
201}
202
203fn uploader() -> String {
204 format!("sdk-rust/{}", crate::server_protocol::SDK_VERSION)
205}
206
207/// Builds upload requests of at most 256 documents each, in document order.
208pub(crate) fn upload_requests(
209 documents: Vec<FlowDefinitionDocument>,
210 force: bool,
211) -> Vec<UploadFlowDefinitionsRequest> {
212 let mut requests: Vec<UploadFlowDefinitionsRequest> = Vec::new();
213 for document in documents {
214 let needs_new_request = requests.last().is_none_or(|request| {
215 request.definitions.len() == MAX_DEFINITIONS_PER_REQUEST
216 || request_content_bytes(request) + document.content.len()
217 > MAX_CONTENT_BYTES_PER_REQUEST
218 });
219 if needs_new_request {
220 requests.push(UploadFlowDefinitionsRequest {
221 definitions: Vec::new(),
222 uploader: uploader(),
223 force,
224 });
225 }
226 let request = requests
227 .last_mut()
228 .expect("a request was pushed for this document");
229 request.definitions.push(FlowDefinitionUpload {
230 flow_type: document.flow_type,
231 content: document.content,
232 generated_at: Some(generated_at::truncate_timestamp_to_millis(
233 prost_types::Timestamp::from(document.generated_at),
234 )),
235 });
236 }
237 requests
238}
239
240fn request_content_bytes(request: &UploadFlowDefinitionsRequest) -> usize {
241 request
242 .definitions
243 .iter()
244 .map(|definition| definition.content.len())
245 .sum()
246}
247
248pub(crate) fn map_upload_results(
249 response: UploadFlowDefinitionsResponse,
250) -> Vec<FlowDefinitionUploadResult> {
251 response
252 .results
253 .into_iter()
254 .map(map_upload_result)
255 .collect()
256}
257
258pub(crate) fn map_upload_result(
259 result: ProtoFlowDefinitionUploadResult,
260) -> FlowDefinitionUploadResult {
261 let outcome = match ProtoFlowDefinitionUploadOutcome::try_from(result.outcome) {
262 Ok(ProtoFlowDefinitionUploadOutcome::Created) => FlowDefinitionUploadOutcome::Created,
263 Ok(ProtoFlowDefinitionUploadOutcome::Replaced) => FlowDefinitionUploadOutcome::Replaced,
264 Ok(ProtoFlowDefinitionUploadOutcome::Unchanged) => FlowDefinitionUploadOutcome::Unchanged,
265 Ok(ProtoFlowDefinitionUploadOutcome::KeptExisting) => {
266 FlowDefinitionUploadOutcome::KeptExisting
267 }
268 Ok(ProtoFlowDefinitionUploadOutcome::Unspecified) | Err(_) => {
269 FlowDefinitionUploadOutcome::Unknown
270 }
271 };
272 FlowDefinitionUploadResult {
273 flow_type: result.flow_type,
274 outcome,
275 stored_digest: result.stored_digest,
276 // A time outside the SystemTime range is reported as None, as documented.
277 stored_generated_at: result
278 .stored_generated_at
279 .and_then(|timestamp| SystemTime::try_from(timestamp).ok()),
280 }
281}