cratestack_client_rust/rpc/batch_call.rs
1// -----------------------------------------------------------------------------
2// `BatchableCall` + `BatchHandle` — the prepared-call / typed-key duo
3// that the typed batch surface is built around. Sits alongside
4// `rpc::batch::{BatchBuilder, BatchResults}`, which consume them.
5// -----------------------------------------------------------------------------
6
7use cratestack_core::CratestackError;
8
9use crate::codec::HttpClientCodec;
10use crate::rpc::batch::BatchBuilder;
11use crate::rpc::client::RpcClient;
12use crate::rpc::error::RpcClientError;
13
14/// A typed unary RPC call that has been *prepared* but not yet sent.
15///
16/// Produced by every macro-generated unary RPC method on the typed
17/// client (model CRUD + unary procedures). Two consumption modes:
18///
19/// - **Eager.** `.await` directly — `IntoFuture` desugars to the same
20/// HTTP request `RpcClient::call` would have made.
21/// - **Batched.** `.queue(&mut batch)` registers the call with a
22/// [`BatchBuilder`] for a single multiplexed `POST /rpc/batch`.
23/// Returns a typed [`BatchHandle`] for `.take(...)` on the results
24/// after `batch.send().await` resolves.
25///
26/// The input is eagerly converted to `serde_json::Value` at
27/// construction time so the same prepared call can flow into either
28/// consumption mode without re-borrowing the input. Conversion errors
29/// surface lazily — eagerly on `.await`, per-handle on the batch path.
30#[must_use = "BatchableCall does nothing until `.await`ed or `.queue(&mut batch)`d"]
31pub struct BatchableCall<C, O> {
32 rpc: RpcClient<C>,
33 op_id: String,
34 input_value: Result<serde_json::Value, CratestackError>,
35 /// `fn() -> O` instead of `O` so `BatchableCall` is `Send` + `Sync`
36 /// regardless of whether `O` is — the marker is variance-only.
37 _output: std::marker::PhantomData<fn() -> O>,
38}
39
40impl<C, O> BatchableCall<C, O>
41where
42 C: HttpClientCodec + Clone + Send + 'static,
43 O: serde::de::DeserializeOwned + Send + 'static,
44{
45 /// Construct a prepared call. Callers should generally use the
46 /// macro-generated typed methods rather than building this by hand.
47 pub fn new<I>(rpc: RpcClient<C>, op_id: impl Into<String>, input: &I) -> Self
48 where
49 I: serde::Serialize,
50 {
51 // cratestack#677: this used to strip `null` object entries here
52 // before handing the value to the codec — a workaround for
53 // `serde_json::Value::Null` mis-encoding as the CBOR empty-array
54 // marker (`0x80`) instead of CBOR null (`0xf6`). That root cause
55 // was fixed in `CborCodec::encode` (cratestack#657, via
56 // `serialize_unit_as_null`), which makes the strip both
57 // unnecessary and actively harmful: it recursed into nested
58 // objects, so an explicit `null` on a `model.<Model>.update`
59 // patch — meaning "clear this nullable column" — was
60 // indistinguishable from an untouched field by the time it
61 // reached the codec, silently dropping the clear. See
62 // `crates/cratestack-pg/tests/rpc_batch_explicit_null_clear.rs`
63 // for the regression test (fails if the strip is restored).
64 let input_value = serde_json::to_value(input)
65 .map_err(|error| CratestackError::Codec(format!("encode batch input: {error}")));
66 Self {
67 rpc,
68 op_id: op_id.into(),
69 input_value,
70 _output: std::marker::PhantomData,
71 }
72 }
73
74 /// Queue this call into a [`BatchBuilder`] for deferred
75 /// execution. The returned [`BatchHandle`] is the key to
76 /// retrieve the typed result via [`BatchResults::take`] after
77 /// [`BatchBuilder::send`] resolves.
78 ///
79 /// Input-encoding errors observed at construction time are
80 /// preserved per-handle, so a single bad input in a batch
81 /// produces a per-handle `take(...)?` error rather than
82 /// poisoning the whole batch.
83 pub fn queue(self, batch: &mut BatchBuilder<C>) -> BatchHandle<O> {
84 let id = match self.input_value {
85 Ok(value) => batch.push_frame(self.op_id, value),
86 Err(error) => batch.push_failed_frame(error),
87 };
88 BatchHandle {
89 id,
90 _output: std::marker::PhantomData,
91 }
92 }
93}
94
95impl<C, O> std::future::IntoFuture for BatchableCall<C, O>
96where
97 C: HttpClientCodec + Clone + Send + 'static,
98 O: serde::de::DeserializeOwned + Send + 'static,
99{
100 type Output = Result<O, RpcClientError>;
101 type IntoFuture = crate::task::BoxFuture<'static, Self::Output>;
102
103 fn into_future(self) -> Self::IntoFuture {
104 Box::pin(async move {
105 let value = self.input_value.map_err(RpcClientError::Codec)?;
106 self.rpc
107 .call::<serde_json::Value, O>(&self.op_id, &value)
108 .await
109 })
110 }
111}
112
113/// A typed key returned by [`BatchableCall::queue`]. Pair it with
114/// [`BatchResults::take`] to extract the typed output for that op
115/// from the batch response.
116///
117/// Carries `O` only as a phantom type — there's no runtime overhead.
118/// Cheap to clone; clones share identity (you can `take(handle)` only
119/// once, but the type tracks across passes).
120pub struct BatchHandle<O> {
121 pub(crate) id: u64,
122 pub(crate) _output: std::marker::PhantomData<fn() -> O>,
123}
124
125impl<O> Clone for BatchHandle<O> {
126 // Hand-written (not derived) so the impl doesn't pick up a spurious
127 // `O: Clone` bound — `O` is purely a phantom. `BatchHandle` is `Copy`,
128 // so the canonical clone is just a copy of `*self`.
129 fn clone(&self) -> Self {
130 *self
131 }
132}
133
134impl<O> Copy for BatchHandle<O> {}
135
136impl<O> std::fmt::Debug for BatchHandle<O> {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("BatchHandle").field("id", &self.id).finish()
139 }
140}
141
142#[cfg(test)]
143mod no_null_strip_tests {
144 use cratestack_codec_cbor::CborCodec;
145 use cratestack_core::CratestackCodec;
146 use serde::{Deserialize, Serialize};
147
148 #[derive(Serialize, Deserialize, PartialEq, Debug)]
149 struct Req {
150 required: String,
151 #[serde(default)]
152 optional: Option<String>,
153 #[serde(default)]
154 nested: Option<Inner>,
155 }
156
157 #[derive(Serialize, Deserialize, PartialEq, Debug)]
158 struct Inner {
159 #[serde(default)]
160 maybe: Option<String>,
161 kept: String,
162 }
163
164 /// cratestack#677: `BatchableCall::new` no longer strips `null` object
165 /// entries before handing the value to the codec (that workaround for
166 /// the `0x80`-vs-`0xf6` mis-encoding was obsoleted by cratestack#657's
167 /// `serialize_unit_as_null` fix, and was actively dropping explicit
168 /// nullable-column clears — see `crates/cratestack-pg/tests/
169 /// rpc_batch_explicit_null_clear.rs` for the production-path
170 /// regression test). This is the narrower codec-level guarantee the
171 /// removal now leans on: an explicit `null`, nested or not, must
172 /// still reach the wire as CBOR null and decode cleanly.
173 #[test]
174 fn unstripped_null_reaches_wire_as_cbor_null_and_decodes_cleanly() {
175 let value = serde_json::json!({
176 "required": "x",
177 "optional": null,
178 "nested": { "maybe": null, "kept": "k" },
179 });
180 let bytes = CborCodec.encode(&value).expect("encode");
181 assert!(
182 bytes.contains(&0xf6),
183 "an explicit null, nested or not, must reach the wire as RFC 8949 null (0xf6): {bytes:02x?}"
184 );
185 let decoded: Req = CborCodec
186 .decode(&bytes)
187 .expect("an unstripped null must decode, not error");
188 assert_eq!(decoded.required, "x");
189 assert_eq!(decoded.optional, None, "top-level null decodes to None");
190 assert_eq!(
191 decoded.nested,
192 Some(Inner {
193 maybe: None,
194 kept: "k".to_owned(),
195 }),
196 "nested null decodes to None, sibling field untouched"
197 );
198 }
199}