Skip to main content

link_cli/transactions/
types.rs

1//! Value types and serialization helpers for the transactions layer.
2//!
3//! Every type here is generic over the doublets address type `T` so the
4//! transactions layer can be reused with `usize`- or `u64`-addressed
5//! stores. The `u32` specialisations ([`DoubletLink`], [`Transition`])
6//! are what the `clink` CLI itself uses.
7
8use std::path::PathBuf;
9
10use anyhow::{anyhow, bail, Result};
11use doublets::data::LinkReference;
12
13use crate::error::LinkError;
14use crate::link::GenericLink;
15
16/// The kind of write operation recorded by a [`Transition`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum TransitionKind {
19    Create,
20    Update,
21    Delete,
22}
23
24impl TransitionKind {
25    pub fn as_u8(self) -> u8 {
26        match self {
27            TransitionKind::Create => 0,
28            TransitionKind::Update => 1,
29            TransitionKind::Delete => 2,
30        }
31    }
32
33    pub fn from_u8(value: u8) -> Option<Self> {
34        match value {
35            0 => Some(TransitionKind::Create),
36            1 => Some(TransitionKind::Update),
37            2 => Some(TransitionKind::Delete),
38            _ => None,
39        }
40    }
41}
42
43/// Sync flushes data-store side-effects before `commit` returns.
44///
45/// Async durably persists the transitions then applies the data-store
46/// side-effects on a background-friendly path (already-applied
47/// side-effects are the common case for in-process inner stores).
48///
49/// The Rust port runs both modes synchronously on the calling thread
50/// for predictability; the distinction is preserved for parity with C#
51/// and for future expansion.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum CommitMode {
54    #[default]
55    Sync,
56    Async,
57}
58
59/// Retention policy for the transitions log.
60#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub enum LogRetentionPolicy {
62    /// Keep every transition forever (default).
63    #[default]
64    Infinite,
65    /// Drop the oldest applied transitions once the live log exceeds
66    /// `max_transitions`. Never drops un-applied transitions (R7).
67    Sized { max_transitions: u64 },
68    /// Archive the oldest `chunk_size` applied transitions to a
69    /// rolling file in `archive_directory` once the live log reaches
70    /// `chunk_size`.
71    Chunked {
72        chunk_size: u64,
73        archive_directory: PathBuf,
74    },
75}
76
77impl LogRetentionPolicy {
78    /// Parses a CLI spec: `infinite`, `sized:<n>`, `chunked:<n>:<dir>`.
79    pub fn parse(spec: &str) -> Result<Self> {
80        let trimmed = spec.trim();
81        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("infinite") {
82            return Ok(Self::Infinite);
83        }
84
85        let lowered = trimmed.to_ascii_lowercase();
86        if lowered.starts_with("sized:") {
87            let rest = &trimmed["sized:".len()..];
88            let max: u64 = rest
89                .parse()
90                .map_err(|_| anyhow!("invalid sized retention spec '{spec}'"))?;
91            return Ok(Self::Sized {
92                max_transitions: max,
93            });
94        }
95        if lowered.starts_with("chunked:") {
96            let rest = &trimmed["chunked:".len()..];
97            let (size_text, dir) = rest
98                .split_once(':')
99                .ok_or_else(|| anyhow!("invalid chunked retention spec '{spec}'"))?;
100            let chunk_size: u64 = size_text
101                .parse()
102                .map_err(|_| anyhow!("invalid chunked size in '{spec}'"))?;
103            if chunk_size == 0 {
104                bail!("invalid chunked size in '{spec}'");
105            }
106            if dir.is_empty() {
107                bail!("invalid chunked retention spec '{spec}'");
108            }
109            return Ok(Self::Chunked {
110                chunk_size,
111                archive_directory: PathBuf::from(dir),
112            });
113        }
114        bail!("unknown retention spec '{spec}'");
115    }
116}
117
118/// A single doublet link state captured by a transition (mirror of the
119/// C# `Platform.Data.Doublets.Link<TLinkAddress>`).
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
121pub struct GenericDoubletLink<T> {
122    pub index: T,
123    pub source: T,
124    pub target: T,
125}
126
127impl<T> GenericDoubletLink<T> {
128    pub const fn new(index: T, source: T, target: T) -> Self {
129        Self {
130            index,
131            source,
132            target,
133        }
134    }
135}
136
137impl<T: LinkReference> GenericDoubletLink<T> {
138    /// The all-zero doublet used for the missing side of a create/delete.
139    pub fn empty() -> Self {
140        let zero = T::from_byte(0);
141        Self::new(zero, zero, zero)
142    }
143
144    pub fn from_link(link: &GenericLink<T>) -> Self {
145        Self::new(link.index, link.source, link.target)
146    }
147
148    fn serialize(&self) -> String {
149        format!("{},{},{}", self.index, self.source, self.target)
150    }
151
152    fn parse(text: &str) -> Result<Self, LinkError> {
153        let parts: Vec<&str> = text.split(',').collect();
154        if parts.len() != 3 {
155            return Err(LinkError::InvalidFormat(format!(
156                "expected 'index,source,target' in transition, got '{text}'"
157            )));
158        }
159        Ok(Self::new(
160            parse_address(parts[0])?,
161            parse_address(parts[1])?,
162            parse_address(parts[2])?,
163        ))
164    }
165}
166
167impl<T: LinkReference> From<GenericLink<T>> for GenericDoubletLink<T> {
168    fn from(link: GenericLink<T>) -> Self {
169        Self::new(link.index, link.source, link.target)
170    }
171}
172
173impl<T: LinkReference> From<GenericDoubletLink<T>> for GenericLink<T> {
174    fn from(link: GenericDoubletLink<T>) -> Self {
175        Self::new(link.index, link.source, link.target)
176    }
177}
178
179/// The `u32`-addressed doublet used by the `clink` CLI.
180pub type DoubletLink = GenericDoubletLink<u32>;
181
182/// Reversible write captured by the transactions layer. Holds both
183/// `before` and `after` link states so the operation can be undone or
184/// replayed.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
186pub struct GenericTransition<T> {
187    pub transaction_id: u128,
188    pub sequence: i64,
189    pub timestamp_ms: i64,
190    pub kind: TransitionKind,
191    pub before: GenericDoubletLink<T>,
192    pub after: GenericDoubletLink<T>,
193}
194
195/// The `u32`-addressed transition used by the `clink` CLI.
196pub type Transition = GenericTransition<u32>;
197
198impl<T: LinkReference> GenericTransition<T> {
199    pub const SCHEMA_VERSION: &'static str = "v1";
200
201    /// Encodes the transition as a single line stored as one entry of
202    /// the transitions log.
203    ///
204    /// Addresses are written in decimal, so a log written by a
205    /// `u32`-addressed store reads back unchanged in a `u64`- or
206    /// `usize`-addressed one.
207    pub fn serialize(&self) -> String {
208        format!(
209            "{schema}|{tx:032x}|{seq}|{ms}|{kind}|{before}|{after}",
210            schema = Self::SCHEMA_VERSION,
211            tx = self.transaction_id,
212            seq = self.sequence,
213            ms = self.timestamp_ms,
214            kind = self.kind.as_u8(),
215            before = self.before.serialize(),
216            after = self.after.serialize(),
217        )
218    }
219
220    /// Parses a serialized transition.
221    ///
222    /// Returns [`LinkError::InvalidFormat`] for anything that is not a
223    /// well-formed entry — including the partial line a crash can leave
224    /// at the end of an append-only log — and
225    /// [`LinkError::AddressOutOfRange`] for a structurally valid entry
226    /// whose addresses do not fit into `T`. The two are distinct on
227    /// purpose: recovery skips torn entries but must not silently drop
228    /// a log written by a wider address type.
229    pub fn parse(text: &str) -> Result<Self, LinkError> {
230        let invalid = || LinkError::InvalidFormat(format!("malformed transition entry '{text}'"));
231        if text.is_empty() {
232            return Err(invalid());
233        }
234        let parts: Vec<&str> = text.split('|').collect();
235        if parts.len() < 7 || parts[0] != Self::SCHEMA_VERSION {
236            return Err(invalid());
237        }
238        let transaction_id = u128::from_str_radix(parts[1], 16).map_err(|_| invalid())?;
239        let sequence: i64 = parts[2].parse().map_err(|_| invalid())?;
240        let timestamp_ms: i64 = parts[3].parse().map_err(|_| invalid())?;
241        let kind_value: u8 = parts[4].parse().map_err(|_| invalid())?;
242        let kind = TransitionKind::from_u8(kind_value).ok_or_else(invalid)?;
243        let before =
244            GenericDoubletLink::parse(parts[5]).map_err(|error| keep_range(error, &invalid))?;
245        let after =
246            GenericDoubletLink::parse(parts[6]).map_err(|error| keep_range(error, &invalid))?;
247        Ok(Self {
248            transaction_id,
249            sequence,
250            timestamp_ms,
251            kind,
252            before,
253            after,
254        })
255    }
256
257    /// Lenient variant of [`GenericTransition::parse`].
258    pub fn try_parse(text: &str) -> Option<Self> {
259        Self::parse(text).ok()
260    }
261}
262
263/// Keeps [`LinkError::AddressOutOfRange`] distinguishable while turning
264/// every other doublet parse failure into the caller's format error.
265fn keep_range(error: LinkError, invalid: &dyn Fn() -> LinkError) -> LinkError {
266    match error {
267        LinkError::AddressOutOfRange(value) => LinkError::AddressOutOfRange(value),
268        _ => invalid(),
269    }
270}
271
272/// Parses a decimal link address into any `doublets` address type.
273fn parse_address<T: LinkReference>(text: &str) -> Result<T, LinkError> {
274    let value: u128 = text
275        .parse()
276        .map_err(|_| LinkError::InvalidFormat(format!("invalid link address '{text}'")))?;
277    T::try_from(value).map_err(|_| LinkError::AddressOutOfRange(value))
278}
279
280/// Sidecar-store name prefixes used by the recovery protocol.
281pub const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:";
282pub const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:";
283pub const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:";
284pub const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:";