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: usize,
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}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
85pub struct ForkDelete {
86 pub v: u32,
87 pub fork_id: String,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct ForkPromote {
93 pub v: u32,
94 pub fork_id: String,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize)]
99pub struct ForkList {
100 pub v: u32,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize)]
106pub struct ForkPut {
107 pub v: u32,
108 pub fork_id: String,
109 pub table: String,
110 pub partition_id: u32,
111 pub offset: u64,
112 #[serde(default)]
113 pub projection_id: String,
114 #[serde(default)]
115 pub projection_version: u32,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub fields: BTreeMap<String, String>,
118 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
119 pub metadata: BTreeMap<String, String>,
120 #[serde(
121 default,
122 skip_serializing_if = "Option::is_none",
123 with = "crate::encoding::opt_bin_bytes"
124 )]
125 pub payload: Option<Vec<u8>>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub embedding: Option<String>,
128 #[serde(default)]
129 pub tombstone: bool,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize)]
134#[non_exhaustive]
135pub enum ForkReply {
136 Ok(ForkOutcome),
137 Err(ForkError),
138}
139
140#[derive(Clone, Debug, Serialize, Deserialize)]
142#[non_exhaustive]
143pub enum ForkOutcome {
144 Created(ForkInfo),
145 Deleted(bool),
146 Promoted { rows: usize },
147 List(Vec<ForkInfo>),
148 Written,
149}
150
151#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
153#[non_exhaustive]
154pub enum ForkError {
155 #[error("forks not supported: {0}")]
156 Unsupported(String),
157 #[error("fork not found: {0}")]
158 NotFound(String),
159 #[error("invalid fork: {0}")]
160 InvalidFork(String),
161 #[error("fork conflict: {0}")]
162 Conflict(String),
163 #[error("fork backend error: {0}")]
164 Backend(String),
165 #[error("unsupported fork op version (expected {expected}, got {got})")]
166 Version { expected: u32, got: u32 },
167}
168
169pub fn validate_fork_id(fork_id: &str) -> Result<(), InvalidError> {
178 crate::validate::validate_safelisted_name("fork id", fork_id, MAX_FORK_ID_BYTES)
179}
180
181#[cfg(all(test, feature = "cbor"))]
182mod tests {
183 use super::*;
184 use crate::codes::FORK_OP_VERSION;
185 use crate::framing::{decode_named, encode_named};
186
187 #[test]
188 fn given_fork_ids_when_validated_then_should_enforce_charset_and_length() {
189 assert!(validate_fork_id("experiment-2026-q2").is_ok());
190 assert!(validate_fork_id("run_7.v2").is_ok());
191 assert!(validate_fork_id("").is_err(), "empty");
192 assert!(validate_fork_id("bad id").is_err(), "space");
193 assert!(
194 validate_fork_id("o'brien; drop table").is_err(),
195 "sql metachars rejected"
196 );
197 assert!(
198 validate_fork_id("name/../etc").is_err(),
199 "slash and dot-dot"
200 );
201 assert!(validate_fork_id(&"f".repeat(MAX_FORK_ID_BYTES)).is_ok());
202 assert!(validate_fork_id(&"f".repeat(MAX_FORK_ID_BYTES + 1)).is_err());
203 }
204
205 #[test]
206 fn given_fork_create_when_round_tripped_then_should_preserve_kind() {
207 let request = ForkCreate {
208 v: FORK_OP_VERSION,
209 fork_id: "agent-run-7".to_owned(),
210 parent: None,
211 kind: ForkKind::Severed,
212 tables: vec!["orders".to_owned()],
213 };
214 let bytes = encode_named(&request).expect("serializes");
215 let back: ForkCreate = decode_named(&bytes).expect("deserializes");
216 assert_eq!(back.fork_id, "agent-run-7");
217 assert_eq!(back.kind, ForkKind::Severed);
218 }
219
220 #[test]
221 fn given_fork_reply_created_when_round_tripped_then_should_preserve_info() {
222 let reply = ForkReply::Ok(ForkOutcome::Created(ForkInfo {
223 fork_id: "f1".to_owned(),
224 parent: None,
225 kind: ForkKind::Continuous,
226 user_id: 5,
227 status: ForkStatus::Open,
228 created_at_micros: 1,
229 row_count: 0,
230 }));
231 let bytes = encode_named(&reply).expect("serializes");
232 let back: ForkReply = decode_named(&bytes).expect("deserializes");
233 let ForkReply::Ok(ForkOutcome::Created(info)) = back else {
234 panic!("expected Created");
235 };
236 assert_eq!(info.user_id, 5);
237 assert_eq!(info.kind, ForkKind::Continuous);
238 }
239
240 #[test]
241 fn given_fork_kind_when_serialized_then_should_be_snake_case() {
242 assert_eq!(
244 serde_json::to_string(&ForkKind::Severed).expect("serializes"),
245 "\"severed\""
246 );
247 assert_eq!(
248 serde_json::to_string(&ForkKind::Continuous).expect("serializes"),
249 "\"continuous\""
250 );
251 }
252}