1use crate::error::LaserError;
2use crate::fork::{ForkError, ForkInfo, ForkKind};
3use crate::laser::Laser;
4use crate::query::{
5 AGDX_FORK_CREATE_CODE, AGDX_FORK_DELETE_CODE, AGDX_FORK_LIST_CODE, AGDX_FORK_PROMOTE_CODE,
6 AGDX_FORK_PUT_CODE, FORK_OP_VERSION,
7};
8use laser_wire::fork::{
9 ForkCreate, ForkDelete, ForkList, ForkOutcome, ForkPromote, ForkPut, ForkReply,
10};
11use laser_wire::framing::encode_named;
12#[cfg(test)]
13use laser_wire::limits::MAX_FORK_ID_BYTES;
14use serde::Serialize;
15use std::collections::BTreeMap;
16
17impl Laser {
18 pub fn fork<'a>(&'a self, fork_id: impl Into<String>) -> ForkHandle<'a> {
24 ForkHandle {
25 laser: self,
26 fork_id: self.resource_name(&fork_id.into()),
27 }
28 }
29
30 pub async fn forks(&self) -> Result<Vec<ForkInfo>, LaserError> {
34 match self
35 .execute_fork(AGDX_FORK_LIST_CODE, &ForkList { v: FORK_OP_VERSION })
36 .await?
37 {
38 ForkOutcome::List(forks) => Ok(self.local_forks(forks)),
39 other => Err(unexpected("list", &other)),
40 }
41 }
42
43 fn local_forks(&self, forks: Vec<ForkInfo>) -> Vec<ForkInfo> {
44 forks
45 .into_iter()
46 .filter_map(|mut fork| {
47 fork.fork_id = self.local_resource_name(&fork.fork_id)?.to_owned();
48 if let Some(parent) = fork.parent.take() {
49 let local = self.local_resource_name(&parent).map(str::to_owned);
50 fork.parent = Some(local.unwrap_or(parent));
51 }
52 Some(fork)
53 })
54 .collect()
55 }
56
57 pub(crate) async fn execute_fork(
60 &self,
61 code: u32,
62 request: &impl Serialize,
63 ) -> Result<ForkOutcome, LaserError> {
64 let capabilities = self.capabilities().await;
65 if !capabilities.forks {
66 return Err(LaserError::unsupported(
67 "fork",
68 "forks are not served by this deployment",
69 ));
70 }
71 if let Some(versions) = capabilities.versions
74 && versions.fork != FORK_OP_VERSION
75 {
76 return Err(ForkError::Version {
77 expected: versions.fork,
78 got: FORK_OP_VERSION,
79 }
80 .into());
81 }
82 let payload = encode_named(request)
83 .map_err(|error| LaserError::Codec(format!("encode fork request: {error}")))?;
84 let payload = self.send_raw_with_response(code, payload).await?;
85 match crate::error::decode_managed_reply::<ForkReply>(&payload)? {
86 ForkReply::Ok(outcome) => Ok(outcome),
87 ForkReply::Err(error) => Err(error.into()),
88 _ => Err(LaserError::Protocol(
89 "fork: unknown reply variant".to_owned(),
90 )),
91 }
92 }
93}
94
95pub struct ForkHandle<'a> {
97 laser: &'a Laser,
98 fork_id: String,
99}
100
101impl<'a> ForkHandle<'a> {
102 pub fn id(&self) -> &str {
104 self.laser
105 .local_resource_name(&self.fork_id)
106 .unwrap_or(&self.fork_id)
107 }
108
109 pub fn resource_id(&self) -> &str {
113 &self.fork_id
114 }
115
116 pub fn create(&self) -> ForkCreateRequest<'a> {
120 ForkCreateRequest {
121 laser: self.laser,
122 fork_id: self.fork_id.clone(),
123 parent: None,
124 kind: ForkKind::Continuous,
125 tables: Vec::new(),
126 }
127 }
128
129 pub async fn promote(&self) -> Result<usize, LaserError> {
132 match self
133 .laser
134 .execute_fork(
135 AGDX_FORK_PROMOTE_CODE,
136 &ForkPromote {
137 v: FORK_OP_VERSION,
138 fork_id: self.fork_id.clone(),
139 },
140 )
141 .await?
142 {
143 ForkOutcome::Promoted { rows } => Ok(rows),
144 other => Err(unexpected("promote", &other)),
145 }
146 }
147
148 pub async fn squash(&self) -> Result<bool, LaserError> {
151 match self
152 .laser
153 .execute_fork(
154 AGDX_FORK_DELETE_CODE,
155 &ForkDelete {
156 v: FORK_OP_VERSION,
157 fork_id: self.fork_id.clone(),
158 },
159 )
160 .await?
161 {
162 ForkOutcome::Deleted(existed) => Ok(existed),
163 other => Err(unexpected("squash", &other)),
164 }
165 }
166
167 pub fn put_row(
172 &self,
173 table: impl Into<String>,
174 partition_id: u32,
175 offset: u64,
176 ) -> ForkPutRequest<'a> {
177 ForkPutRequest {
178 laser: self.laser,
179 fork_id: self.fork_id.clone(),
180 table: self.laser.resource_name(&table.into()),
181 partition_id,
182 offset,
183 projection_id: String::new(),
184 projection_version: 0,
185 fields: BTreeMap::new(),
186 metadata: BTreeMap::new(),
187 payload: None,
188 embedding: None,
189 tombstone: false,
190 }
191 }
192}
193
194#[must_use = "call .send().await to create the fork"]
196pub struct ForkCreateRequest<'a> {
197 laser: &'a Laser,
198 fork_id: String,
199 parent: Option<String>,
200 kind: ForkKind,
201 tables: Vec<String>,
202}
203
204impl<'a> ForkCreateRequest<'a> {
205 pub fn severed(mut self) -> Self {
207 self.kind = ForkKind::Severed;
208 self
209 }
210
211 pub fn continuous(mut self) -> Self {
213 self.kind = ForkKind::Continuous;
214 self
215 }
216
217 pub fn parent(mut self, parent: impl Into<String>) -> Self {
219 self.parent = Some(self.laser.resource_name(&parent.into()));
220 self
221 }
222
223 pub fn tables(mut self, tables: impl IntoIterator<Item = impl Into<String>>) -> Self {
225 self.tables = tables
226 .into_iter()
227 .map(|table| self.laser.resource_name(&table.into()))
228 .collect();
229 self
230 }
231
232 pub async fn send(self) -> Result<ForkInfo, LaserError> {
234 validated_fork_id(&self.fork_id)?;
235 let request = ForkCreate {
236 v: FORK_OP_VERSION,
237 fork_id: self.fork_id,
238 parent: self.parent,
239 kind: self.kind,
240 tables: self.tables,
241 };
242 match self
243 .laser
244 .execute_fork(AGDX_FORK_CREATE_CODE, &request)
245 .await?
246 {
247 ForkOutcome::Created(info) => Ok(info),
248 other => Err(unexpected("create", &other)),
249 }
250 }
251}
252
253#[must_use = "call .send().await to write the speculative row"]
255pub struct ForkPutRequest<'a> {
256 laser: &'a Laser,
257 fork_id: String,
258 table: String,
259 partition_id: u32,
260 offset: u64,
261 projection_id: String,
262 projection_version: u32,
263 fields: BTreeMap<String, String>,
264 metadata: BTreeMap<String, String>,
265 payload: Option<Vec<u8>>,
266 embedding: Option<Vec<f32>>,
267 tombstone: bool,
268}
269
270impl<'a> ForkPutRequest<'a> {
271 pub fn projection(mut self, id: impl Into<String>, version: u32) -> Self {
273 self.projection_id = self.laser.resource_name(&id.into());
274 self.projection_version = version;
275 self
276 }
277
278 pub fn field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
280 self.fields.insert(name.into(), value.into());
281 self
282 }
283
284 pub fn metadata(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
286 self.metadata.insert(name.into(), value.into());
287 self
288 }
289
290 pub fn payload(mut self, payload: impl AsRef<[u8]>) -> Self {
292 self.payload = Some(payload.as_ref().to_vec());
293 self
294 }
295
296 pub fn embedding(mut self, embedding: impl IntoIterator<Item = f32>) -> Self {
299 self.embedding = Some(embedding.into_iter().collect());
300 self
301 }
302
303 pub fn tombstone(mut self) -> Self {
306 self.tombstone = true;
307 self
308 }
309
310 pub async fn send(self) -> Result<(), LaserError> {
312 let embedding = self
313 .embedding
314 .as_deref()
315 .map(embedding_literal)
316 .transpose()?;
317 let request = ForkPut {
318 v: FORK_OP_VERSION,
319 fork_id: self.fork_id,
320 table: self.table,
321 partition_id: self.partition_id,
322 offset: self.offset,
323 projection_id: self.projection_id,
324 projection_version: self.projection_version,
325 fields: self.fields,
326 metadata: self.metadata,
327 payload: self.payload,
328 embedding,
329 tombstone: self.tombstone,
330 };
331 match self
332 .laser
333 .execute_fork(AGDX_FORK_PUT_CODE, &request)
334 .await?
335 {
336 ForkOutcome::Written => Ok(()),
337 other => Err(unexpected("put_row", &other)),
338 }
339 }
340}
341
342fn unexpected(op: &str, outcome: &ForkOutcome) -> LaserError {
343 LaserError::Protocol(format!("fork {op}: unexpected outcome {outcome:?}"))
344}
345
346fn validated_fork_id(fork_id: &str) -> Result<(), LaserError> {
351 laser_wire::fork::validate_fork_id(fork_id).map_err(|error| LaserError::Invalid(error.0))
352}
353
354fn embedding_literal(embedding: &[f32]) -> Result<String, LaserError> {
355 if embedding.iter().any(|component| !component.is_finite()) {
356 return Err(LaserError::Invalid(
357 "an embedding component must be a finite number".to_owned(),
358 ));
359 }
360 serde_json::to_string(embedding).map_err(|error| LaserError::Codec(error.to_string()))
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn info(fork_id: &str, parent: Option<&str>) -> ForkInfo {
368 ForkInfo {
369 fork_id: fork_id.to_owned(),
370 parent: parent.map(str::to_owned),
371 kind: ForkKind::Continuous,
372 user_id: 1,
373 status: laser_wire::fork::ForkStatus::Open,
374 created_at_micros: 0,
375 row_count: 0,
376 }
377 }
378
379 #[test]
380 fn given_a_default_stream_when_opening_a_fork_then_should_scope_its_names() {
381 let laser = Laser::from_client(crate::iggy::prelude::IggyClient::default())
382 .with_default_stream("acme");
383 let fork = laser.fork("experiment");
384 assert_eq!(fork.resource_id(), "stream:acme/experiment");
385 assert_eq!(fork.id(), "experiment");
386 validated_fork_id(fork.resource_id()).expect("a scoped fork id is valid");
387 let create = fork.create().parent("baseline").tables(["readings"]);
388 assert_eq!(create.parent.as_deref(), Some("stream:acme/baseline"));
389 assert_eq!(create.tables, ["stream:acme/readings"]);
390 let put = fork.put_row("readings", 0, 1).projection("reading.v1", 1);
391 assert_eq!(put.table, "stream:acme/readings");
392 assert_eq!(put.projection_id, "stream:acme/reading.v1");
393 }
394
395 #[test]
396 fn given_listed_forks_when_localized_then_should_keep_only_the_own_stream() {
397 let laser = Laser::from_client(crate::iggy::prelude::IggyClient::default())
398 .with_default_stream("acme");
399 let local = laser.local_forks(vec![
400 info("stream:acme/experiment", Some("stream:acme/baseline")),
401 info("stream:other/experiment", None),
402 info("legacy", None),
403 ]);
404 assert_eq!(local.len(), 1);
405 assert_eq!(local[0].fork_id, "experiment");
406 assert_eq!(local[0].parent.as_deref(), Some("baseline"));
407 }
408
409 #[test]
410 fn given_an_over_long_or_empty_fork_id_when_validated_then_should_reject() {
411 assert!(validated_fork_id("").is_err());
412 assert!(validated_fork_id("experiment-2026-q2").is_ok());
413 let too_long = "f".repeat(MAX_FORK_ID_BYTES + 1);
414 assert!(validated_fork_id(&too_long).is_err());
415 let at_cap = "f".repeat(MAX_FORK_ID_BYTES);
416 assert!(validated_fork_id(&at_cap).is_ok());
417 }
418
419 #[test]
420 fn given_fork_error_when_mapped_then_should_nest_the_typed_error() {
421 let error: LaserError = ForkError::NotFound("f9".to_owned()).into();
422 assert!(matches!(error, LaserError::Fork(ForkError::NotFound(_))));
423 assert!(error.is_not_found());
424 let unsupported: LaserError = ForkError::Unsupported("x".to_owned()).into();
425 assert!(matches!(
426 unsupported,
427 LaserError::Fork(ForkError::Unsupported(_))
428 ));
429 assert!(unsupported.is_unsupported());
430 }
431
432 #[test]
433 fn given_an_embedding_when_encoded_then_should_be_a_json_array_and_reject_non_finite() {
434 assert_eq!(
435 embedding_literal(&[0.5, 1.0]).expect("finite components encode"),
436 "[0.5,1.0]"
437 );
438 assert!(matches!(
439 embedding_literal(&[f32::NAN]),
440 Err(LaserError::Invalid(_))
441 ));
442 }
443}