Skip to main content

heddle_api/
v2.rs

1//! Shared v2 client behavior. Transport adapters retain key and connection ownership.
2pub mod client;
3pub mod identity_management;
4use crate::StreamingShape;
5use crate::heddle::api::common::{
6    AuthorizationAccess, AuthorizationExistence, AuthorizationRole, AuthorizationScopeSource,
7    CallContext, DeploymentTarget, RetryBehavior, RpcEffect, ServiceMaturity, SigningTier,
8};
9use crate::heddle::api::v1alpha2::{StreamDataKind, StreamFrame, stream_frame};
10
11include!(concat!(env!("OUT_DIR"), "/heddle_api_v2_methods.rs"));
12
13/// Generated resource guard selected by a protobuf request field path.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct AuthorizationTarget {
16    pub path: &'static str,
17    pub role: AuthorizationRole,
18}
19
20/// Complete method authorization declaration. Hosts must resolve every
21/// populated target and enforce the role against current authority; metadata
22/// describes that obligation and is never itself a capability.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct AuthorizationPolicy {
25    pub role: AuthorizationRole,
26    pub scope_source: AuthorizationScopeSource,
27    pub existence: AuthorizationExistence,
28    pub targets: &'static [AuthorizationTarget],
29}
30
31/// Decoded transport context bound to a v2 route, before body interpretation.
32#[derive(Debug)]
33pub struct RoutedCall<'a> {
34    pub method: &'static MethodDescriptor,
35    pub context: &'a CallContext,
36}
37
38impl<'a> RoutedCall<'a> {
39    pub fn new(path: &str, context: &'a CallContext) -> Option<Self> {
40        method_descriptor(path).map(|method| Self { method, context })
41    }
42}
43
44impl MethodDescriptor {
45    /// Only safe reads can be sent on replayable 0-RTT connections.
46    pub const fn allows_zero_rtt(&self) -> bool {
47        matches!(self.effect, RpcEffect::ReadOnly)
48            && matches!(self.retry_behavior, RetryBehavior::Safe)
49    }
50    /// Extract the operation ID for the transport context from the exact request
51    /// bytes. Adapters must not maintain a second route/field-number inventory.
52    pub fn client_operation_id<'a>(
53        &self,
54        request: &'a [u8],
55    ) -> Result<Option<&'a str>, crate::RequestMetadataError> {
56        self.client_operation_id_field_number
57            .map(|field| crate::transport::protobuf_string_field(request, field))
58            .transpose()
59            .map(Option::flatten)
60    }
61}
62
63/// Upper bound for an opaque observation cursor; independent of payload limits.
64pub const MAX_CURSOR_BYTES: usize = 4096;
65
66/// Invalid stream framing must never advance a client's durable observation.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
68pub enum StreamProtocolError {
69    #[error("non-contiguous stream sequence")]
70    Sequence,
71    #[error("stream query binding mismatch")]
72    Binding,
73    #[error("stream resumed from an unexpected cursor")]
74    Resume,
75    #[error("invalid stream phase")]
76    Phase,
77    #[error("payload does not match the frame kind")]
78    Payload,
79    #[error("invalid or oversized checkpoint cursor")]
80    Cursor,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84enum Phase {
85    Opening,
86    Snapshot,
87    Live,
88    Reset,
89    Complete,
90}
91
92/// Instructions for the typed view reducer. Apply staged data atomically at
93/// Commit, and persist the cursor only after that reducer succeeds.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum ObservationAction {
96    BeginSnapshot,
97    Resumed,
98    Stage(StreamDataKind),
99    Commit,
100    Heartbeat,
101    Reset,
102    Complete,
103}
104
105#[derive(Debug, thiserror::Error)]
106pub enum ObservationApplyError<E> {
107    #[error("stream protocol failed: {0}")]
108    Protocol(#[from] StreamProtocolError),
109    #[error("view reducer failed: {0}")]
110    Reducer(E),
111}
112
113/// Cursor and lifecycle tracker; typed consumers own staged view data.
114#[derive(Clone)]
115pub struct ObservationState {
116    binding_digest: [u8; 32],
117    cursor: Vec<u8>,
118    sequence: u64,
119    phase: Phase,
120    pending: bool,
121}
122
123impl ObservationState {
124    /// Advances this tracker only after the view reducer succeeds. At Commit,
125    /// the reducer must atomically apply staged data and persist the supplied
126    /// cursor. A failed reducer leaves the tracker at its previous checkpoint.
127    pub async fn apply<E, F, Fut>(
128        &mut self,
129        frame: &StreamFrame,
130        has_payload: bool,
131        reducer: F,
132    ) -> Result<ObservationAction, ObservationApplyError<E>>
133    where
134        F: FnOnce(ObservationAction, Vec<u8>) -> Fut,
135        Fut: std::future::Future<Output = Result<(), E>>,
136    {
137        let mut next = self.clone();
138        let action = next.accept(frame, has_payload)?;
139        reducer(action, next.cursor.clone())
140            .await
141            .map_err(ObservationApplyError::Reducer)?;
142        *self = next;
143        Ok(action)
144    }
145
146    pub fn new(binding_digest: [u8; 32], cursor: Vec<u8>) -> Self {
147        Self {
148            binding_digest,
149            cursor,
150            sequence: 0,
151            phase: Phase::Opening,
152            pending: false,
153        }
154    }
155
156    pub fn cursor(&self) -> &[u8] {
157        &self.cursor
158    }
159
160    pub fn is_complete(&self) -> bool {
161        self.phase == Phase::Complete
162    }
163
164    /// Validate a frame before changing lifecycle state. A rejected frame leaves
165    /// the state unchanged. Transport adapters enforce negotiated byte budgets
166    /// before decoding; callers validate the method-specific typed payload.
167    ///
168    /// For reducers that can fail, run this on a clone, apply the returned action,
169    /// then replace the original tracker only if the reducer succeeds.
170    pub fn accept(
171        &mut self,
172        frame: &StreamFrame,
173        has_payload: bool,
174    ) -> Result<ObservationAction, StreamProtocolError> {
175        use stream_frame::Body;
176        if matches!(self.phase, Phase::Reset | Phase::Complete) {
177            return Err(StreamProtocolError::Phase);
178        }
179        if self.sequence.checked_add(1) != Some(frame.sequence) {
180            return Err(StreamProtocolError::Sequence);
181        }
182        let body = frame.body.as_ref().ok_or(StreamProtocolError::Phase)?;
183        if has_payload != matches!(body, Body::Data(_)) {
184            return Err(StreamProtocolError::Payload);
185        }
186        let action = match body {
187            Body::Open(open) => {
188                if self.phase != Phase::Opening {
189                    return Err(StreamProtocolError::Phase);
190                }
191                if open.binding_digest.as_slice() != self.binding_digest {
192                    return Err(StreamProtocolError::Binding);
193                }
194                if open.resumed_from != self.cursor {
195                    return Err(StreamProtocolError::Resume);
196                }
197                if self.cursor.len() > MAX_CURSOR_BYTES {
198                    return Err(StreamProtocolError::Cursor);
199                }
200                if self.cursor.is_empty() {
201                    self.phase = Phase::Snapshot;
202                    ObservationAction::BeginSnapshot
203                } else {
204                    self.phase = Phase::Live;
205                    ObservationAction::Resumed
206                }
207            }
208            Body::Data(data) => {
209                let kind =
210                    StreamDataKind::try_from(data.kind).map_err(|_| StreamProtocolError::Phase)?;
211                let valid = match self.phase {
212                    Phase::Snapshot => kind == StreamDataKind::Snapshot,
213                    Phase::Live => matches!(kind, StreamDataKind::Upsert | StreamDataKind::Remove),
214                    _ => false,
215                };
216                if !valid {
217                    return Err(StreamProtocolError::Phase);
218                }
219                self.pending = true;
220                ObservationAction::Stage(kind)
221            }
222            Body::Checkpoint(checkpoint) => {
223                if !matches!(self.phase, Phase::Snapshot | Phase::Live)
224                    || checkpoint.snapshot_complete != (self.phase == Phase::Snapshot)
225                {
226                    return Err(StreamProtocolError::Phase);
227                }
228                if checkpoint.previous_cursor != self.cursor
229                    || checkpoint.cursor.is_empty()
230                    || checkpoint.cursor.len() > MAX_CURSOR_BYTES
231                    || checkpoint.cursor == self.cursor
232                {
233                    return Err(StreamProtocolError::Cursor);
234                }
235                self.cursor.clone_from(&checkpoint.cursor);
236                self.phase = Phase::Live;
237                self.pending = false;
238                ObservationAction::Commit
239            }
240            Body::Reset(_) => {
241                self.phase = Phase::Reset;
242                self.cursor.clear();
243                self.pending = false;
244                ObservationAction::Reset
245            }
246            Body::Complete(complete) => {
247                if self.phase != Phase::Live || self.pending {
248                    return Err(StreamProtocolError::Phase);
249                }
250                if complete.cursor != self.cursor {
251                    return Err(StreamProtocolError::Cursor);
252                }
253                self.phase = Phase::Complete;
254                ObservationAction::Complete
255            }
256            Body::Heartbeat(_) => {
257                if self.phase == Phase::Opening {
258                    return Err(StreamProtocolError::Phase);
259                }
260                ObservationAction::Heartbeat
261            }
262        };
263        self.sequence = frame.sequence;
264        Ok(action)
265    }
266}