1use crate::error::InvalidError;
2use crate::limits::MAX_FORK_ID_BYTES;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(
8 Clone,
9 Copy,
10 Debug,
11 Default,
12 PartialEq,
13 Eq,
14 Serialize,
15 Deserialize,
16 strum::Display,
17 strum::EnumString,
18 strum::VariantArray,
19)]
20#[serde(rename_all = "snake_case")]
21#[strum(serialize_all = "snake_case")]
22#[non_exhaustive]
23pub enum ForkKind {
24 Severed,
27 #[default]
29 Continuous,
30}
31
32#[derive(
34 Clone,
35 Copy,
36 Debug,
37 Default,
38 PartialEq,
39 Eq,
40 Serialize,
41 Deserialize,
42 strum::Display,
43 strum::EnumString,
44 strum::VariantArray,
45)]
46#[serde(rename_all = "snake_case")]
47#[strum(serialize_all = "snake_case")]
48#[non_exhaustive]
49pub enum ForkStatus {
50 #[default]
51 Open,
52 Promoted,
53 Squashed,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ForkInfo {
59 pub fork_id: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub parent: Option<String>,
62 pub kind: ForkKind,
63 pub user_id: u32,
64 pub status: ForkStatus,
65 pub created_at_micros: u64,
66 pub row_count: u64,
67}
68
69#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ForkCreate {
73 pub v: u32,
74 pub fork_id: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub parent: Option<String>,
77 #[serde(default)]
78 pub kind: ForkKind,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub tables: Vec<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub points: Option<Vec<ForkPoint>>,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ForkPoint {
92 pub table: String,
93 pub partition_id: u32,
94 pub offset: u64,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub source_incarnation: Option<u64>,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct ForkDelete {
103 pub v: u32,
104 pub fork_id: String,
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct ForkPromote {
110 pub v: u32,
111 pub fork_id: String,
112}
113
114#[derive(Clone, Debug, Serialize, Deserialize)]
116pub struct ForkList {
117 pub v: u32,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub stream: Option<String>,
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize)]
128pub struct ForkPut {
129 pub v: u32,
130 pub fork_id: String,
131 pub table: String,
132 pub partition_id: u32,
133 pub offset: u64,
134 #[serde(default)]
135 pub projection_id: String,
136 #[serde(default)]
137 pub projection_version: u32,
138 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
139 pub fields: BTreeMap<String, String>,
140 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
141 pub metadata: BTreeMap<String, String>,
142 #[serde(
143 default,
144 skip_serializing_if = "Option::is_none",
145 with = "crate::encoding::opt_bin_bytes"
146 )]
147 pub payload: Option<Vec<u8>>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub embedding: Option<String>,
150 #[serde(default)]
151 pub tombstone: bool,
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize)]
156#[non_exhaustive]
157pub enum ForkReply {
158 Ok(ForkOutcome),
159 Err(ForkError),
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
164#[non_exhaustive]
165pub enum ForkOutcome {
166 Created(ForkInfo),
167 Deleted(bool),
168 Promoted { rows: usize },
169 List(Vec<ForkInfo>),
170 Written,
171}
172
173#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
175#[non_exhaustive]
176pub enum ForkError {
177 #[error("forks not supported: {0}")]
178 Unsupported(String),
179 #[error("fork not found: {0}")]
180 NotFound(String),
181 #[error("invalid fork: {0}")]
182 InvalidFork(String),
183 #[error("fork conflict: {0}")]
184 Conflict(String),
185 #[error("fork backend error: {0}")]
186 Backend(String),
187 #[error("temporarily unavailable: {0}")]
192 Unavailable(String),
193 #[error("unsupported fork op version (expected {expected}, got {got})")]
194 Version { expected: u32, got: u32 },
195 #[error("not the partition leader for this fork")]
197 NotLeader,
198}
199
200pub fn validate_fork_id(fork_id: &str) -> Result<(), InvalidError> {
211 let Some((stream, local)) = crate::authz::split_scoped_resource(fork_id) else {
212 return crate::validate::validate_safelisted_name("fork id", fork_id, MAX_FORK_ID_BYTES);
213 };
214 if fork_id.len() > MAX_FORK_ID_BYTES {
215 return Err(InvalidError::new(format!(
216 "fork id is {}B, exceeds cap {MAX_FORK_ID_BYTES}B",
217 fork_id.len()
218 )));
219 }
220 crate::validate::validate_safelisted_name("fork id stream", stream, MAX_FORK_ID_BYTES)?;
221 crate::validate::validate_safelisted_name("fork id", local, MAX_FORK_ID_BYTES)
222}
223
224#[cfg(all(test, feature = "cbor"))]
225mod tests {
226 use super::*;
227 use crate::codes::FORK_OP_VERSION;
228 use crate::framing::{decode_named, encode_named};
229
230 #[test]
231 fn given_fork_ids_when_validated_then_should_enforce_charset_and_length() {
232 assert!(validate_fork_id("experiment-2026-q2").is_ok());
233 assert!(validate_fork_id("run_7.v2").is_ok());
234 assert!(validate_fork_id("").is_err(), "empty");
235 assert!(validate_fork_id("bad id").is_err(), "space");
236 assert!(
237 validate_fork_id("o'brien; drop table").is_err(),
238 "sql metachars rejected"
239 );
240 assert!(
241 validate_fork_id("name/../etc").is_err(),
242 "slash and dot-dot"
243 );
244 assert!(validate_fork_id(&"f".repeat(MAX_FORK_ID_BYTES)).is_ok());
245 assert!(validate_fork_id(&"f".repeat(MAX_FORK_ID_BYTES + 1)).is_err());
246 }
247
248 #[test]
249 fn given_stream_scoped_fork_ids_when_validated_then_should_safelist_both_parts() {
250 assert!(validate_fork_id("stream:acme/experiment-2026-q2").is_ok());
251 assert!(
252 validate_fork_id("stream:acme/a/b").is_err(),
253 "slash in the local part"
254 );
255 assert!(
256 validate_fork_id("stream:ac me/run").is_err(),
257 "space in the stream"
258 );
259 assert!(
260 validate_fork_id("stream:acme/").is_err(),
261 "empty local part"
262 );
263 assert!(
264 validate_fork_id("stream:acme").is_err(),
265 "the stream resource itself"
266 );
267 let local = "f".repeat(MAX_FORK_ID_BYTES - "stream:acme/".len());
268 assert!(validate_fork_id(&format!("stream:acme/{local}")).is_ok());
269 assert!(validate_fork_id(&format!("stream:acme/{local}f")).is_err());
270 }
271
272 #[test]
273 fn given_fork_create_when_round_tripped_then_should_preserve_kind() {
274 let request = ForkCreate {
275 v: FORK_OP_VERSION,
276 fork_id: "agent-run-7".to_owned(),
277 parent: None,
278 kind: ForkKind::Severed,
279 tables: vec!["readings".to_owned()],
280 points: None,
281 };
282 let bytes = encode_named(&request).expect("serializes");
283 let back: ForkCreate = decode_named(&bytes).expect("deserializes");
284 assert_eq!(back.fork_id, "agent-run-7");
285 assert_eq!(back.kind, ForkKind::Severed);
286 assert_eq!(back.points, None);
287 }
288
289 #[test]
290 fn given_fork_create_with_points_when_round_tripped_then_should_preserve_them() {
291 let points = vec![
292 ForkPoint {
293 table: "readings".to_owned(),
294 partition_id: 2,
295 offset: 41,
296 source_incarnation: Some(1_700_000_000_000_000),
297 },
298 ForkPoint {
299 table: "readings".to_owned(),
300 partition_id: 3,
301 offset: 7,
302 source_incarnation: None,
303 },
304 ];
305 let request = ForkCreate {
306 v: FORK_OP_VERSION,
307 fork_id: "agent-run-8".to_owned(),
308 parent: None,
309 kind: ForkKind::Severed,
310 tables: Vec::new(),
311 points: Some(points.clone()),
312 };
313 let bytes = encode_named(&request).expect("serializes");
314 let back: ForkCreate = decode_named(&bytes).expect("deserializes");
315 assert_eq!(back.points, Some(points));
316 }
317
318 #[test]
319 fn given_fork_reply_created_when_round_tripped_then_should_preserve_info() {
320 let reply = ForkReply::Ok(ForkOutcome::Created(ForkInfo {
321 fork_id: "f1".to_owned(),
322 parent: None,
323 kind: ForkKind::Continuous,
324 user_id: 5,
325 status: ForkStatus::Open,
326 created_at_micros: 1,
327 row_count: 0,
328 }));
329 let bytes = encode_named(&reply).expect("serializes");
330 let back: ForkReply = decode_named(&bytes).expect("deserializes");
331 let ForkReply::Ok(ForkOutcome::Created(info)) = back else {
332 panic!("expected Created");
333 };
334 assert_eq!(info.user_id, 5);
335 assert_eq!(info.kind, ForkKind::Continuous);
336 }
337
338 #[test]
339 fn given_fork_kind_when_serialized_then_should_be_snake_case() {
340 assert_eq!(
342 serde_json::to_string(&ForkKind::Severed).expect("serializes"),
343 "\"severed\""
344 );
345 assert_eq!(
346 serde_json::to_string(&ForkKind::Continuous).expect("serializes"),
347 "\"continuous\""
348 );
349 }
350}