Skip to content

Commit 2acb12a

Browse files
Design strongly typed Rust SDK contracts (#190)
1 parent de900dc commit 2acb12a

40 files changed

Lines changed: 4470 additions & 0 deletions
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Rust SDK User Interface
2+
3+
Status: proposed through compile contracts.
4+
5+
## Principles
6+
7+
- User handlers are synchronous.
8+
- Flow start input, Step input, RPC input/output, Attributes, and Channels retain
9+
their Rust types.
10+
- User code derives `serde::Serialize` and `serde::Deserialize`; it does not
11+
provide codecs at each SDK call site.
12+
- Durable Flow and Step names default to Rust type names and may be overridden.
13+
- Runtime internals may erase types only after the public API checks them.
14+
- Calls should read naturally in Rust rather than reproduce Java syntax.
15+
16+
## Flow and Step
17+
18+
`Flow::StartInput` binds a Flow to its start Step at compile time:
19+
20+
```rust
21+
struct OrderFlow {
22+
validate: ValidateOrder,
23+
charge: ChargeOrder,
24+
}
25+
26+
impl Flow for OrderFlow {
27+
type StartInput = Order;
28+
29+
fn steps(&self) -> StepList<Self::StartInput> {
30+
StepList::start(&self.validate).and(&self.charge)
31+
}
32+
}
33+
```
34+
35+
`StepDecision::go_to` accepts only the target Step's input type:
36+
37+
```rust
38+
impl Step for ValidateOrder {
39+
type Input = Order;
40+
41+
fn execute(
42+
&self,
43+
context: &mut Context,
44+
order: Order,
45+
) -> HandlerResult<StepDecision> {
46+
self.status.set(context, "validated".to_string())?;
47+
Ok(StepDecision::go_to(&self.charge, ChargeInput::from(order)))
48+
}
49+
}
50+
```
51+
52+
Execute-only Steps omit `wait_for`. Wait expressions use domain nouns:
53+
54+
```rust
55+
fn wait_for(&self, _context: &mut Context, _input: OrderId) -> HandlerResult<Wait> {
56+
Ok(Wait::until(self.approved.for_one()))
57+
}
58+
```
59+
60+
Aggregate factories remain available for multiple or dynamic conditions:
61+
62+
```rust
63+
fn wait_for(&self, _context: &mut Context, _input: OrderId) -> HandlerResult<Wait> {
64+
Ok(Wait::any_of([
65+
self.approved.for_one(),
66+
Timer::by_duration(Duration::from_secs(30)).with_id("approval-timeout"),
67+
]))
68+
}
69+
```
70+
71+
## Persistence
72+
73+
Definitions carry their value types. Persistence operations therefore do not
74+
take runtime type tokens or codecs:
75+
76+
```rust
77+
struct OrderFlow {
78+
status: Attribute<String>,
79+
attempts: AttributeMap<u32>,
80+
commands: Channel<Command>,
81+
}
82+
83+
fn persistence(&self) -> PersistenceSchema {
84+
PersistenceSchema::new()
85+
.attribute(&self.status)
86+
.attribute_map(&self.attempts)
87+
.channel(&self.commands)
88+
}
89+
```
90+
91+
Indexed Attributes use named factories such as `AttributeIndex::keyword()` and
92+
`AttributeIndex::full_text()` instead of a separate public index-kind enum.
93+
94+
Reads return `Option<T>` because an Attribute may be absent. Handlers can use
95+
`get_required` when absence is an application error.
96+
97+
## RPC
98+
99+
Rust uses typed RPC tokens instead of reflection or proxy stubs:
100+
101+
```rust
102+
impl OrderFlow {
103+
const GET_ORDER: Rpc<OrderId, OrderView> = Rpc::new("get_order");
104+
105+
fn get_order(
106+
&self,
107+
context: &mut Context,
108+
order_id: OrderId,
109+
) -> HandlerResult<RpcResult<OrderView>> {
110+
Ok(RpcResult::new(self.load(context, order_id)?))
111+
}
112+
}
113+
114+
fn rpcs(&self) -> RpcList<Self> {
115+
RpcList::new().function(
116+
Self::GET_ORDER.lock(self.status.lock()),
117+
Self::get_order,
118+
)
119+
}
120+
121+
let order: OrderView = client.invoke_rpc(flow_id, OrderFlow::GET_ORDER, order_id)?;
122+
```
123+
124+
RPC registration checks that the method signature agrees with the token.
125+
Timeouts and locks chain directly from the token without an intermediate
126+
options type.
127+
128+
Flow IDs and run IDs remain strings, matching the server protocol and the other
129+
SDKs. Rust newtypes are reserved for IDs whose APIs benefit from distinct types.
130+
131+
## Errors
132+
133+
Client operations return domain-specific `SdkError` variants. Applications
134+
match the outcome directly instead of branching on gRPC codes and server
135+
sub-status metadata:
136+
137+
```rust
138+
match client.start_flow(&orders, "order-123", order) {
139+
Ok(run_id) => println!("started {run_id}"),
140+
Err(SdkError::FlowAlreadyStarted { .. }) => println!("already started"),
141+
Err(error) => return Err(error),
142+
}
143+
```
144+
145+
`FlowNotFound` is used by operations requiring an existing Flow;
146+
`FlowNotActive` is used by mutations and RPCs requiring a running Flow.
147+
`RpcLockConflict` and `LongPollTimeout` can be retried explicitly, while
148+
`WorkerInvocation` retains the original Worker error metadata.
149+
150+
`HandlerError` remains separate because it crosses the user Step/RPC boundary;
151+
`SdkError` represents Client, registration, mapping, and service failures.
152+
153+
## Runtime construction
154+
155+
Client and Worker share an `Arc<BlobCache>` and cloned Registry metadata:
156+
157+
```rust
158+
let registry = Registry::new().register(OrderFlow::new());
159+
let cache = Arc::new(BlobCache::open(cache_config)?);
160+
let client = Client::new(registry.clone(), cache.clone(), ClientOptions::new());
161+
let worker = Worker::new(registry, cache, WorkerOptions::new());
162+
```
163+
164+
## Current scope
165+
166+
The current crate defines and type-checks the public contracts. Client transport,
167+
error translation, Registry erasure, WorkerService dispatch, and value encoding
168+
deliberately return not-implemented errors until their runtime phases are
169+
implemented.
170+
171+
## Tests
172+
173+
The `dex-sdk` IWF compatibility target ports the Java workflow definitions and
174+
client assertions as compile contracts. `cargo test --no-run` validates the user
175+
experience without claiming that the unfinished runtime passes E2E scenarios.
176+
177+
## Documentation
178+
179+
Keep this document and `sdk-rust/README.md` synchronized with public API changes.
180+
181+
## UI/UX
182+
183+
N/A: no in-repo web UI.

‎script/licenseheaders/policy.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"third_party_mixed_prefixes": [
2424
"sdk-java/src/test/java/io/superdurable/dex/integ",
2525
"sdk-python/tests/integ",
26+
"sdk-rust/crates/dex-sdk/tests/iwfcompat",
2627
"sdk-typescript/test/integ"
2728
],
2829
"excluded_prefixes": [

‎sdk-rust/Cargo.lock‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎sdk-rust/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"crates/dex-blob-cache-node",
66
"crates/dex-blob-cache-python",
77
"crates/dex-protocol",
8+
"crates/dex-sdk",
89
]
910
resolver = "3"
1011

@@ -26,6 +27,7 @@ prost = "0.14"
2627
prost-types = "0.14"
2728
protoc-bin-vendored = "3.2"
2829
sha2 = "0.10"
30+
serde = { version = "1", features = ["derive"] }
2931
stretto = "0.9"
3032
tempfile = "3"
3133
tonic = "0.14"

‎sdk-rust/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ foundation for the Rust SDK. Each language SDK owns its Worker runtime.
77

88
The crates are:
99

10+
- `dex-sdk`: strongly typed Rust Flow, Step, RPC, persistence, and client contracts.
1011
- `dex-blob-cache`: transport-neutral, Go-compatible disk cache.
1112
- `dex-blob-cache-jni`: Java 8-compatible binding containing only cache APIs.
1213
- `dex-blob-cache-python`: PyO3 binding for the Python SDK.
@@ -15,6 +16,20 @@ The crates are:
1516

1617
The architecture is defined in
1718
[Multi-language Rust SDK Core](../docs/design/multi-language-rust-sdk-core.md).
19+
The public Rust API is defined in
20+
[Rust SDK User Interface](../docs/design/rust-sdk-user-interface.md).
21+
22+
The `dex-sdk` source layout follows the application developer's mental model:
23+
Flows, Steps, Attributes, Channels, RPCs, timers, and waits each have dedicated
24+
modules. Client, Worker, Registry, and each options family are separated as
25+
their own entry points instead of being collected into infrastructure-oriented
26+
files. Handler failures and SDK/service failures are also separate modules.
27+
28+
Single-condition waits read as `Wait::until(condition)`. `Wait::all_of` and
29+
`Wait::any_of` remain available for aggregate conditions. Client failures use
30+
domain-specific `SdkError` variants such as `FlowNotFound`, `FlowNotActive`,
31+
`FlowAlreadyStarted`, `RpcLockConflict`, and `WorkerInvocation` instead of
32+
requiring callers to inspect transport metadata.
1833

1934
## Blob cache
2035

‎sdk-rust/crates/dex-sdk/Cargo.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "dex-sdk"
3+
description = "Strongly typed Rust SDK for Dex"
4+
version.workspace = true
5+
edition.workspace = true
6+
license-file.workspace = true
7+
repository.workspace = true
8+
rust-version.workspace = true
9+
10+
[dependencies]
11+
dex-blob-cache = { path = "../dex-blob-cache" }
12+
serde.workspace = true
13+
tonic.workspace = true

0 commit comments

Comments
 (0)