Skip to main content

link_cli/transactions/
mod.rs

1//! Optional transactions layer for the Rust link-cli.
2//!
3//! Mirrors the C# `TransactionsDecorator` in
4//! `csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs`.
5//!
6//! The decorator records every `create` / `update` / `delete` as a
7//! reversible [`GenericTransition`] in a sidecar log. It supports
8//! explicit transactions, sync commits, three retention policies, and
9//! crash recovery (R1-R7, R10).
10//!
11//! Optional — when not opted in, the bare
12//! [`NamedTypesDecorator`] behaves
13//! identically (R8, R9, R17).
14//!
15//! # Reuse outside the CLI
16//!
17//! [`GenericTransactionsDecorator`] is generic over three things:
18//!
19//! * `T` — the doublets address type (`u32`, `u64`, `usize`, ...);
20//! * `S` — the wrapped store, any [`LinksStorage<T>`] implementation,
21//!   including [`DoubletsStorage`](crate::DoubletsStorage) over a
22//!   file-mapped or caller-owned `doublets::unit::Store`;
23//! * `L` — the transitions log, any [`TransitionLogStore`].
24//!
25//! [`TransactionsDecorator`] is the `u32` + `NamedTypesDecorator`
26//! specialisation used by `clink` itself.
27//!
28//! ```no_run
29//! use link_cli::transactions::{
30//!     CommitMode, FileTransitionLog, GenericTransactionsDecorator, LogRetentionPolicy,
31//! };
32//! use link_cli::DoubletsStorage;
33//!
34//! # fn main() -> Result<(), link_cli::LinkError> {
35//! let store = DoubletsStorage::<usize, _>::open_exclusive("db.links")?;
36//! let log = FileTransitionLog::open("db.transitions.log")?;
37//! let mut tx = GenericTransactionsDecorator::new(
38//!     store,
39//!     log,
40//!     LogRetentionPolicy::default(),
41//!     CommitMode::default(),
42//!     false,
43//! )?;
44//!
45//! tx.begin_transaction()?;
46//! let link = tx.create(0, 0)?;
47//! tx.commit()?;
48//! tx.save()?;
49//! # let _ = link;
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Durability
55//!
56//! Transitions are appended to the log *before* the write they describe
57//! is reported as committed, and [`FileTransitionLog`] `fsync`s each
58//! append by default, so a crash can lose at most the transition that
59//! was in flight. The data store itself is made durable by
60//! [`GenericTransactionsDecorator::save`], which calls
61//! [`LinksStorage::flush`] — for a memory-mapped store that is the
62//! `fsync` of the mapping; for the CLI's in-memory store it is the
63//! rewrite of the database file. Recovery, run by
64//! [`GenericTransactionsDecorator::new`], replays committed-but-unapplied
65//! transitions and rolls back transitions that were never committed, so
66//! a store that lost unflushed writes is brought back in line with the
67//! log.
68
69mod log;
70// Replay, recovery and retention live in a submodule; see transactions/recovery.rs.
71mod recovery;
72mod types;
73
74use std::collections::HashSet;
75use std::path::{Path, PathBuf};
76use std::sync::atomic::{AtomicU64, Ordering};
77use std::time::{SystemTime, UNIX_EPOCH};
78
79use doublets::data::LinkReference;
80
81use crate::error::LinkError;
82use crate::link::GenericLink;
83use crate::named_types::NamedTypesDecorator;
84use crate::storage::{LinksStorage, LinksStorageRef};
85
86pub use log::{FileTransitionLog, TransitionLogStore};
87pub use types::{
88    CommitMode, DoubletLink, GenericDoubletLink, GenericTransition, LogRetentionPolicy, Transition,
89    TransitionKind,
90};
91use types::{COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX};
92
93/// Pending state of a transaction (used by the explicit transaction
94/// handle and by per-write auto-transactions).
95struct PendingTransaction<T> {
96    id: u128,
97    transitions: Vec<GenericTransition<T>>,
98    auto_commit: bool,
99    started_ms: i64,
100}
101
102/// One link address and the `(before, after)` states a single logical
103/// write left it in, after collapsing repeated callbacks for that address.
104type ObservedChange<T> = (T, GenericDoubletLink<T>, GenericDoubletLink<T>);
105
106/// Folds one `(before, after)` callback into `observed`.
107///
108/// Mirrors the handler `TransactionsDecorator.RunWrite` installs in the C#
109/// implementation: repeated callbacks for the same address are collapsed into
110/// a single change that keeps the *first* `before` (the state the rollback has
111/// to restore) and the *last* `after` (the state the write ended at), and the
112/// first-seen order of addresses is preserved so the transitions replay in the
113/// order the storage produced them.
114fn record_observed<T: LinkReference>(
115    observed: &mut Vec<ObservedChange<T>>,
116    before: GenericLink<T>,
117    after: GenericLink<T>,
118) {
119    let zero = T::from_byte(0);
120    let key = if before.index != zero {
121        before.index
122    } else {
123        after.index
124    };
125    if key == zero {
126        return;
127    }
128    let before = GenericDoubletLink::from_link(&before);
129    let after = GenericDoubletLink::from_link(&after);
130    match observed.iter_mut().find(|(index, _, _)| *index == key) {
131        Some(entry) => {
132            if entry.1.index == zero {
133                entry.1 = before;
134            }
135            entry.2 = after;
136        }
137        None => observed.push((key, before, after)),
138    }
139}
140
141/// Snapshot of an open transaction (returned by [`GenericTransactionsDecorator::begin_transaction`]).
142#[derive(Debug, Clone)]
143pub struct TransactionHandle {
144    pub id: u128,
145    pub started_ms: i64,
146}
147
148/// The transactions decorator wraps any [`LinksStorage`] and records
149/// every write as a reversible [`GenericTransition`] in `log_store`.
150pub struct GenericTransactionsDecorator<T, S, L>
151where
152    T: LinkReference,
153    S: LinksStorage<T>,
154    L: TransitionLogStore,
155{
156    inner: S,
157    log_store: L,
158    log: Vec<GenericTransition<T>>,
159    committed: HashSet<u128>,
160    rolled_back: HashSet<u128>,
161    applied: HashSet<i64>,
162    current: Option<PendingTransaction<T>>,
163    sequence_counter: i64,
164    applied_sequence: i64,
165    retention_policy: LogRetentionPolicy,
166    commit_mode: CommitMode,
167    replaying: bool,
168    trace: bool,
169}
170
171/// The `u32` + [`NamedTypesDecorator`] specialisation used by `clink`.
172pub type TransactionsDecorator =
173    GenericTransactionsDecorator<u32, NamedTypesDecorator, NamedTypesDecorator>;
174
175impl<T, S, L> GenericTransactionsDecorator<T, S, L>
176where
177    T: LinkReference,
178    S: LinksStorage<T>,
179    L: TransitionLogStore,
180{
181    /// Creates a new transactions decorator wrapping `inner`, using
182    /// `log_store` as the sidecar log. Runs crash recovery before
183    /// returning.
184    pub fn new(
185        inner: S,
186        log_store: L,
187        retention_policy: LogRetentionPolicy,
188        commit_mode: CommitMode,
189        trace: bool,
190    ) -> Result<Self, LinkError> {
191        let mut decorator = Self {
192            inner,
193            log_store,
194            log: Vec::new(),
195            committed: HashSet::new(),
196            rolled_back: HashSet::new(),
197            applied: HashSet::new(),
198            current: None,
199            sequence_counter: 0,
200            applied_sequence: 0,
201            retention_policy,
202            commit_mode,
203            replaying: false,
204            trace,
205        };
206        decorator.recover()?;
207        Ok(decorator)
208    }
209
210    /// Conventional sidecar filename for the transitions log.
211    pub fn make_transitions_database_filename<P: AsRef<Path>>(database_filename: P) -> PathBuf {
212        let path = database_filename.as_ref();
213        let stem = path
214            .file_stem()
215            .and_then(|s| s.to_str())
216            .unwrap_or_default();
217        let name = format!("{stem}.transitions.links");
218        match path.parent() {
219            Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
220            _ => PathBuf::from(name),
221        }
222    }
223
224    pub fn retention_policy(&self) -> &LogRetentionPolicy {
225        &self.retention_policy
226    }
227
228    pub fn set_retention_policy(&mut self, policy: LogRetentionPolicy) {
229        self.retention_policy = policy;
230    }
231
232    pub fn commit_mode(&self) -> CommitMode {
233        self.commit_mode
234    }
235
236    pub fn set_commit_mode(&mut self, mode: CommitMode) {
237        self.commit_mode = mode;
238    }
239
240    pub fn applied_sequence(&self) -> i64 {
241        self.applied_sequence
242    }
243
244    pub fn last_logged_sequence(&self) -> i64 {
245        self.sequence_counter
246    }
247
248    /// Returns a snapshot of the transitions log in sequence order.
249    pub fn log(&self) -> Vec<GenericTransition<T>> {
250        self.log.clone()
251    }
252
253    pub fn inner(&self) -> &S {
254        &self.inner
255    }
256
257    pub fn inner_mut(&mut self) -> &mut S {
258        &mut self.inner
259    }
260
261    pub fn log_store(&self) -> &L {
262        &self.log_store
263    }
264
265    pub fn log_store_mut(&mut self) -> &mut L {
266        &mut self.log_store
267    }
268
269    pub fn into_inner(self) -> (S, L) {
270        (self.inner, self.log_store)
271    }
272
273    /// Makes both the data store and the transitions log durable.
274    pub fn flush(&mut self) -> Result<(), LinkError> {
275        self.inner.flush()?;
276        self.log_store.flush_log()?;
277        Ok(())
278    }
279
280    /// Alias of [`flush`](Self::flush), kept for parity with the other
281    /// decorators and with the C# API.
282    pub fn save(&mut self) -> Result<(), LinkError> {
283        self.flush()
284    }
285
286    /// Cheap check for "has another process written to the wrapped
287    /// store since we last read or wrote it?".
288    pub fn has_external_changes(&self) -> Result<bool, LinkError> {
289        self.inner.has_external_changes()
290    }
291
292    /// Re-reads the wrapped store and rebuilds the transactions state
293    /// from the log — the recovery path after another process wrote.
294    pub fn reload(&mut self) -> Result<(), LinkError> {
295        if self.current.is_some() {
296            return Err(LinkError::Transaction(
297                "Cannot reload while a transaction is open.".to_string(),
298            ));
299        }
300        self.inner.reload()?;
301        self.recover()
302    }
303
304    // ----- Write API ------------------------------------------------------
305
306    pub fn create(&mut self, source: T, target: T) -> Result<T, LinkError> {
307        if self.replaying {
308            return self.inner.create_link(source, target);
309        }
310        let owns = self.ensure_open_transaction();
311        let id = self.inner.create_link(source, target)?;
312        let after = self
313            .inner
314            .get_link(id)
315            .map(|link| GenericDoubletLink::from_link(&link))
316            .unwrap_or_else(|| GenericDoubletLink::new(id, source, target));
317        self.record_transition(TransitionKind::Create, GenericDoubletLink::empty(), after)?;
318        if owns {
319            self.commit_current()?;
320        }
321        Ok(id)
322    }
323
324    pub fn update(&mut self, id: T, source: T, target: T) -> Result<GenericLink<T>, LinkError> {
325        self.update_observed(id, source, target, &mut |_, _| {})
326    }
327
328    /// [`Self::update`], reporting every change the underlying store made.
329    ///
330    /// An update that would duplicate an existing doublet is resolved into a
331    /// merge that deletes the updated link, so one call can report several
332    /// changes, exactly like C#'s `links.Update(restriction, substitution, handler)`.
333    pub fn update_observed(
334        &mut self,
335        id: T,
336        source: T,
337        target: T,
338        observer: &mut dyn FnMut(GenericLink<T>, GenericLink<T>),
339    ) -> Result<GenericLink<T>, LinkError> {
340        if self.replaying {
341            return self
342                .inner
343                .update_link_observed(id, source, target, observer);
344        }
345        let before = self.snapshot(id);
346        let owns = self.ensure_open_transaction();
347        let mut observed: Vec<ObservedChange<T>> = Vec::new();
348        let outcome = self
349            .inner
350            .update_link_observed(id, source, target, &mut |before, after| {
351                observer(before, after);
352                record_observed(&mut observed, before, after)
353            });
354        let prev = match outcome {
355            Ok(prev) => prev,
356            Err(err) => {
357                if owns {
358                    self.current = None;
359                }
360                return Err(err);
361            }
362        };
363        if observed.is_empty() {
364            let after = self
365                .inner
366                .get_link(id)
367                .map(|link| GenericDoubletLink::from_link(&link))
368                .unwrap_or_else(|| GenericDoubletLink::new(id, source, target));
369            self.record_transition(TransitionKind::Update, before, after)?;
370        } else {
371            self.record_observed_transitions(&observed)?;
372        }
373        if owns {
374            self.commit_current()?;
375        }
376        Ok(prev)
377    }
378
379    pub fn delete(&mut self, id: T) -> Result<GenericLink<T>, LinkError> {
380        self.delete_observed(id, &mut |_, _| {})
381    }
382
383    /// [`Self::delete`], reporting every change the underlying store made.
384    ///
385    /// Deleting a link cascades into every link that still used it, and those
386    /// deletions are changes of their own: the C# CLI hands
387    /// `AdvancedMixedQueryProcessor.RemoveLinks` a handler that `links.Delete`
388    /// calls once per removed link, so `--changes` lists the usages too. The
389    /// observer is the same seam, threaded through the decorator stack.
390    pub fn delete_observed(
391        &mut self,
392        id: T,
393        observer: &mut dyn FnMut(GenericLink<T>, GenericLink<T>),
394    ) -> Result<GenericLink<T>, LinkError> {
395        if self.replaying {
396            let deleted = self.inner.delete_link(id)?;
397            observer(deleted, GenericLink::null());
398            return Ok(deleted);
399        }
400        let before = self.snapshot(id);
401        let owns = self.ensure_open_transaction();
402        let mut observed: Vec<ObservedChange<T>> = Vec::new();
403        let outcome = self.inner.delete_link_observed(id, &mut |before, after| {
404            observer(before, after);
405            record_observed(&mut observed, before, after)
406        });
407        let deleted = match outcome {
408            Ok(d) => d,
409            Err(err) => {
410                if owns {
411                    self.current = None;
412                }
413                return Err(err);
414            }
415        };
416        if observed.is_empty() {
417            self.record_transition(TransitionKind::Delete, before, GenericDoubletLink::empty())?;
418        } else {
419            self.record_observed_transitions(&observed)?;
420        }
421        if owns {
422            self.commit_current()?;
423        }
424        Ok(deleted)
425    }
426
427    /// Composite create-and-update used by callers that want a link
428    /// initialised with source/target in a single pair of transitions
429    /// (matches the C# `CreateAndUpdate` extension semantics, which
430    /// always emits a Create followed by an Update transition).
431    pub fn create_and_update(&mut self, source: T, target: T) -> Result<T, LinkError> {
432        let owns = self.ensure_open_transaction();
433        let zero = T::from_byte(0);
434        let id = self.create(zero, zero)?;
435        self.update(id, source, target)?;
436        if owns {
437            self.commit_current()?;
438        }
439        Ok(id)
440    }
441
442    pub fn exists(&self, id: T) -> bool {
443        self.inner.link_exists(id)
444    }
445
446    pub fn search(&self, source: T, target: T) -> Option<T> {
447        self.inner.search_link(source, target)
448    }
449
450    pub fn get_or_create(&mut self, source: T, target: T) -> Result<T, LinkError> {
451        if let Some(existing) = self.inner.search_link(source, target) {
452            return Ok(existing);
453        }
454        self.create(source, target)
455    }
456
457    pub fn ensure_created(&mut self, id: T) -> Result<T, LinkError> {
458        // ensure_created is used by recovery/replay only and is not
459        // itself a logical write; bypass transition recording.
460        self.inner.ensure_link_created(id)
461    }
462
463    /// Current state of `id` as a doublet, or an empty one at `id`.
464    fn snapshot(&self, id: T) -> GenericDoubletLink<T> {
465        let zero = T::from_byte(0);
466        self.inner
467            .get_link(id)
468            .map(|link| GenericDoubletLink::from_link(&link))
469            .unwrap_or_else(|| GenericDoubletLink::new(id, zero, zero))
470    }
471
472    fn ensure_open_transaction(&mut self) -> bool {
473        if self.current.is_none() {
474            self.current = Some(PendingTransaction {
475                id: new_transaction_id(),
476                transitions: Vec::new(),
477                auto_commit: true,
478                started_ms: now_unix_ms(),
479            });
480            true
481        } else {
482            false
483        }
484    }
485
486    /// Writes one transition per link a single logical write touched.
487    ///
488    /// A resolved write is not necessarily a single-link change: the
489    /// upstream uniqueness and usages decorators merge duplicates and
490    /// cascade through usages, so one `update`/`delete` call can rewrite
491    /// or remove several links. Each of them needs its own transition,
492    /// otherwise a rollback (or a version-control branch switch, which
493    /// replays the same transitions) cannot restore the links the
494    /// cascade touched.
495    ///
496    /// The kind is derived from the observed pair rather than taken from
497    /// the outer operation, because a cascade can delete a link during an
498    /// `update` — recording that as an `Update` would make the revert a
499    /// no-op, since the link no longer exists to be updated back.
500    fn record_observed_transitions(
501        &mut self,
502        observed: &[ObservedChange<T>],
503    ) -> Result<(), LinkError> {
504        let zero = T::from_byte(0);
505        for (_, before, after) in observed {
506            let kind = match (before.index != zero, after.index != zero) {
507                (false, true) => TransitionKind::Create,
508                (true, false) => TransitionKind::Delete,
509                _ => TransitionKind::Update,
510            };
511            self.record_transition(kind, *before, *after)?;
512        }
513        Ok(())
514    }
515
516    fn record_transition(
517        &mut self,
518        kind: TransitionKind,
519        before: GenericDoubletLink<T>,
520        after: GenericDoubletLink<T>,
521    ) -> Result<(), LinkError> {
522        self.sequence_counter += 1;
523        let sequence = self.sequence_counter;
524        let timestamp_ms = now_unix_ms();
525        let transaction_id = self.current.as_ref().map(|tx| tx.id).ok_or_else(|| {
526            LinkError::Transaction(
527                "internal: missing open transaction while recording transition".to_string(),
528            )
529        })?;
530        let transition = GenericTransition {
531            transaction_id,
532            sequence,
533            timestamp_ms,
534            kind,
535            before,
536            after,
537        };
538        if let Some(current) = self.current.as_mut() {
539            current.transitions.push(transition);
540        }
541        self.log.push(transition);
542        self.write_transition_to_log(&transition)?;
543        if self.trace {
544            eprintln!(
545                "[Transactions] Recorded {:?} seq={} tx={:032x}: ({},{},{}) -> ({},{},{}).",
546                kind,
547                sequence,
548                transaction_id,
549                before.index,
550                before.source,
551                before.target,
552                after.index,
553                after.source,
554                after.target,
555            );
556        }
557        Ok(())
558    }
559
560    fn write_transition_to_log(
561        &mut self,
562        transition: &GenericTransition<T>,
563    ) -> Result<(), LinkError> {
564        self.log_store.append_log_entry(&format!(
565            "{TRANSITION_NAME_PREFIX}{}",
566            transition.serialize()
567        ))
568    }
569
570    fn write_marker(&mut self, name: &str) -> Result<(), LinkError> {
571        self.log_store.append_log_entry(name)
572    }
573
574    // ----- Transaction handle --------------------------------------------
575
576    pub fn begin_transaction(&mut self) -> Result<TransactionHandle, LinkError> {
577        if self.current.is_some() {
578            return Err(LinkError::Transaction(
579                "Nested transactions are not supported.".to_string(),
580            ));
581        }
582        let id = new_transaction_id();
583        let started_ms = now_unix_ms();
584        self.current = Some(PendingTransaction {
585            id,
586            transitions: Vec::new(),
587            auto_commit: false,
588            started_ms,
589        });
590        Ok(TransactionHandle { id, started_ms })
591    }
592
593    pub fn commit(&mut self) -> Result<(), LinkError> {
594        if self.current.is_none() {
595            return Ok(());
596        }
597        self.commit_current()
598    }
599
600    fn commit_current(&mut self) -> Result<(), LinkError> {
601        let pending = match self.current.take() {
602            Some(p) => p,
603            None => return Ok(()),
604        };
605        self.committed.insert(pending.id);
606        self.write_marker(&format!("{COMMIT_MARKER_PREFIX}{:032x}", pending.id))?;
607        if self.trace {
608            eprintln!(
609                "[Transactions] Committed tx {:032x} (mode={:?}, transitions={}).",
610                pending.id,
611                self.commit_mode,
612                pending.transitions.len()
613            );
614        }
615        for transition in &pending.transitions {
616            self.mark_applied(transition)?;
617        }
618        let _ = pending.auto_commit;
619        let _ = pending.started_ms;
620        self.enforce_retention()?;
621        Ok(())
622    }
623
624    pub fn rollback(&mut self) -> Result<(), LinkError> {
625        let pending = match self.current.take() {
626            Some(p) => p,
627            None => return Ok(()),
628        };
629        self.rolled_back.insert(pending.id);
630        self.replaying = true;
631        for transition in pending.transitions.iter().rev() {
632            self.try_revert_transition(transition);
633        }
634        self.replaying = false;
635        self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{:032x}", pending.id))?;
636        if self.trace {
637            eprintln!(
638                "[Transactions] Rolled back tx {:032x} ({} transitions).",
639                pending.id,
640                pending.transitions.len(),
641            );
642        }
643        self.enforce_retention()?;
644        Ok(())
645    }
646}
647
648/// Read paths that lend out references, available whenever the wrapped
649/// store keeps its links resident in memory.
650impl<T, S, L> GenericTransactionsDecorator<T, S, L>
651where
652    T: LinkReference,
653    S: LinksStorageRef<T>,
654    L: TransitionLogStore,
655{
656    pub fn get(&self, id: T) -> Option<&GenericLink<T>> {
657        self.inner.get_link_ref(id)
658    }
659
660    pub fn all(&self) -> Vec<&GenericLink<T>> {
661        self.inner.all_link_refs()
662    }
663
664    pub fn query(
665        &self,
666        index: Option<T>,
667        source: Option<T>,
668        target: Option<T>,
669    ) -> Vec<&GenericLink<T>> {
670        self.inner.query_link_refs(index, source, target)
671    }
672}
673
674// ----- Helpers ----------------------------------------------------------
675
676fn insert_ordered<T: LinkReference>(
677    list: &mut Vec<GenericTransition<T>>,
678    transition: GenericTransition<T>,
679) {
680    let mut lo = 0usize;
681    let mut hi = list.len();
682    while lo < hi {
683        let mid = (lo + hi) / 2;
684        if list[mid].sequence < transition.sequence {
685            lo = mid + 1;
686        } else {
687            hi = mid;
688        }
689    }
690    list.insert(lo, transition);
691}
692
693static TX_COUNTER: AtomicU64 = AtomicU64::new(0);
694
695fn new_transaction_id() -> u128 {
696    // Combine a per-process counter with the current timestamp to
697    // approximate a Guid without pulling in the `uuid` crate.
698    let count = TX_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
699    let now = now_unix_ms() as u128;
700    (now << 64) | count
701}
702
703fn now_unix_ms() -> i64 {
704    SystemTime::now()
705        .duration_since(UNIX_EPOCH)
706        .map(|d| d.as_millis() as i64)
707        .unwrap_or(0)
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn retention_policy_parses_specs() {
716        assert!(matches!(
717            LogRetentionPolicy::parse("infinite").unwrap(),
718            LogRetentionPolicy::Infinite
719        ));
720        assert!(matches!(
721            LogRetentionPolicy::parse("sized:1000").unwrap(),
722            LogRetentionPolicy::Sized {
723                max_transitions: 1000
724            }
725        ));
726        match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() {
727            LogRetentionPolicy::Chunked {
728                chunk_size,
729                archive_directory,
730            } => {
731                assert_eq!(chunk_size, 500);
732                assert_eq!(archive_directory, PathBuf::from("/tmp/x"));
733            }
734            _ => panic!("expected Chunked"),
735        }
736        assert!(LogRetentionPolicy::parse("garbage").is_err());
737    }
738
739    #[test]
740    fn transition_round_trips_through_serialize() {
741        let t = Transition {
742            transaction_id: 0xabcdef1234567890u128,
743            sequence: 42,
744            timestamp_ms: 1234567890,
745            kind: TransitionKind::Update,
746            before: DoubletLink::new(1, 2, 3),
747            after: DoubletLink::new(1, 4, 5),
748        };
749        let parsed = Transition::try_parse(&t.serialize()).unwrap();
750        assert_eq!(t, parsed);
751    }
752
753    #[test]
754    fn wide_transition_is_rejected_by_a_narrow_address_type() {
755        let wide = GenericTransition::<u64> {
756            transaction_id: 7,
757            sequence: 1,
758            timestamp_ms: 0,
759            kind: TransitionKind::Create,
760            before: GenericDoubletLink::empty(),
761            after: GenericDoubletLink::new(u32::MAX as u64 + 1, 0, 0),
762        };
763        assert!(matches!(
764            GenericTransition::<u32>::parse(&wide.serialize()),
765            Err(LinkError::AddressOutOfRange(_))
766        ));
767        assert!(GenericTransition::<u64>::parse(&wide.serialize()).is_ok());
768    }
769}