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        if self.replaying {
326            return self.inner.update_link(id, source, target);
327        }
328        let before = self.snapshot(id);
329        let owns = self.ensure_open_transaction();
330        let mut observed: Vec<ObservedChange<T>> = Vec::new();
331        let outcome = self
332            .inner
333            .update_link_observed(id, source, target, &mut |before, after| {
334                record_observed(&mut observed, before, after)
335            });
336        let prev = match outcome {
337            Ok(prev) => prev,
338            Err(err) => {
339                if owns {
340                    self.current = None;
341                }
342                return Err(err);
343            }
344        };
345        if observed.is_empty() {
346            let after = self
347                .inner
348                .get_link(id)
349                .map(|link| GenericDoubletLink::from_link(&link))
350                .unwrap_or_else(|| GenericDoubletLink::new(id, source, target));
351            self.record_transition(TransitionKind::Update, before, after)?;
352        } else {
353            self.record_observed_transitions(&observed)?;
354        }
355        if owns {
356            self.commit_current()?;
357        }
358        Ok(prev)
359    }
360
361    pub fn delete(&mut self, id: T) -> Result<GenericLink<T>, LinkError> {
362        self.delete_observed(id, &mut |_, _| {})
363    }
364
365    /// [`Self::delete`], reporting every change the underlying store made.
366    ///
367    /// Deleting a link cascades into every link that still used it, and those
368    /// deletions are changes of their own: the C# CLI hands
369    /// `AdvancedMixedQueryProcessor.RemoveLinks` a handler that `links.Delete`
370    /// calls once per removed link, so `--changes` lists the usages too. The
371    /// observer is the same seam, threaded through the decorator stack.
372    pub fn delete_observed(
373        &mut self,
374        id: T,
375        observer: &mut dyn FnMut(GenericLink<T>, GenericLink<T>),
376    ) -> Result<GenericLink<T>, LinkError> {
377        if self.replaying {
378            let deleted = self.inner.delete_link(id)?;
379            observer(deleted, GenericLink::null());
380            return Ok(deleted);
381        }
382        let before = self.snapshot(id);
383        let owns = self.ensure_open_transaction();
384        let mut observed: Vec<ObservedChange<T>> = Vec::new();
385        let outcome = self.inner.delete_link_observed(id, &mut |before, after| {
386            observer(before, after);
387            record_observed(&mut observed, before, after)
388        });
389        let deleted = match outcome {
390            Ok(d) => d,
391            Err(err) => {
392                if owns {
393                    self.current = None;
394                }
395                return Err(err);
396            }
397        };
398        if observed.is_empty() {
399            self.record_transition(TransitionKind::Delete, before, GenericDoubletLink::empty())?;
400        } else {
401            self.record_observed_transitions(&observed)?;
402        }
403        if owns {
404            self.commit_current()?;
405        }
406        Ok(deleted)
407    }
408
409    /// Composite create-and-update used by callers that want a link
410    /// initialised with source/target in a single pair of transitions
411    /// (matches the C# `CreateAndUpdate` extension semantics, which
412    /// always emits a Create followed by an Update transition).
413    pub fn create_and_update(&mut self, source: T, target: T) -> Result<T, LinkError> {
414        let owns = self.ensure_open_transaction();
415        let zero = T::from_byte(0);
416        let id = self.create(zero, zero)?;
417        self.update(id, source, target)?;
418        if owns {
419            self.commit_current()?;
420        }
421        Ok(id)
422    }
423
424    pub fn exists(&self, id: T) -> bool {
425        self.inner.link_exists(id)
426    }
427
428    pub fn search(&self, source: T, target: T) -> Option<T> {
429        self.inner.search_link(source, target)
430    }
431
432    pub fn get_or_create(&mut self, source: T, target: T) -> Result<T, LinkError> {
433        if let Some(existing) = self.inner.search_link(source, target) {
434            return Ok(existing);
435        }
436        self.create(source, target)
437    }
438
439    pub fn ensure_created(&mut self, id: T) -> Result<T, LinkError> {
440        // ensure_created is used by recovery/replay only and is not
441        // itself a logical write; bypass transition recording.
442        self.inner.ensure_link_created(id)
443    }
444
445    /// Current state of `id` as a doublet, or an empty one at `id`.
446    fn snapshot(&self, id: T) -> GenericDoubletLink<T> {
447        let zero = T::from_byte(0);
448        self.inner
449            .get_link(id)
450            .map(|link| GenericDoubletLink::from_link(&link))
451            .unwrap_or_else(|| GenericDoubletLink::new(id, zero, zero))
452    }
453
454    fn ensure_open_transaction(&mut self) -> bool {
455        if self.current.is_none() {
456            self.current = Some(PendingTransaction {
457                id: new_transaction_id(),
458                transitions: Vec::new(),
459                auto_commit: true,
460                started_ms: now_unix_ms(),
461            });
462            true
463        } else {
464            false
465        }
466    }
467
468    /// Writes one transition per link a single logical write touched.
469    ///
470    /// A resolved write is not necessarily a single-link change: the
471    /// upstream uniqueness and usages decorators merge duplicates and
472    /// cascade through usages, so one `update`/`delete` call can rewrite
473    /// or remove several links. Each of them needs its own transition,
474    /// otherwise a rollback (or a version-control branch switch, which
475    /// replays the same transitions) cannot restore the links the
476    /// cascade touched.
477    ///
478    /// The kind is derived from the observed pair rather than taken from
479    /// the outer operation, because a cascade can delete a link during an
480    /// `update` — recording that as an `Update` would make the revert a
481    /// no-op, since the link no longer exists to be updated back.
482    fn record_observed_transitions(
483        &mut self,
484        observed: &[ObservedChange<T>],
485    ) -> Result<(), LinkError> {
486        let zero = T::from_byte(0);
487        for (_, before, after) in observed {
488            let kind = match (before.index != zero, after.index != zero) {
489                (false, true) => TransitionKind::Create,
490                (true, false) => TransitionKind::Delete,
491                _ => TransitionKind::Update,
492            };
493            self.record_transition(kind, *before, *after)?;
494        }
495        Ok(())
496    }
497
498    fn record_transition(
499        &mut self,
500        kind: TransitionKind,
501        before: GenericDoubletLink<T>,
502        after: GenericDoubletLink<T>,
503    ) -> Result<(), LinkError> {
504        self.sequence_counter += 1;
505        let sequence = self.sequence_counter;
506        let timestamp_ms = now_unix_ms();
507        let transaction_id = self.current.as_ref().map(|tx| tx.id).ok_or_else(|| {
508            LinkError::Transaction(
509                "internal: missing open transaction while recording transition".to_string(),
510            )
511        })?;
512        let transition = GenericTransition {
513            transaction_id,
514            sequence,
515            timestamp_ms,
516            kind,
517            before,
518            after,
519        };
520        if let Some(current) = self.current.as_mut() {
521            current.transitions.push(transition);
522        }
523        self.log.push(transition);
524        self.write_transition_to_log(&transition)?;
525        if self.trace {
526            eprintln!(
527                "[Transactions] Recorded {:?} seq={} tx={:032x}: ({},{},{}) -> ({},{},{}).",
528                kind,
529                sequence,
530                transaction_id,
531                before.index,
532                before.source,
533                before.target,
534                after.index,
535                after.source,
536                after.target,
537            );
538        }
539        Ok(())
540    }
541
542    fn write_transition_to_log(
543        &mut self,
544        transition: &GenericTransition<T>,
545    ) -> Result<(), LinkError> {
546        self.log_store.append_log_entry(&format!(
547            "{TRANSITION_NAME_PREFIX}{}",
548            transition.serialize()
549        ))
550    }
551
552    fn write_marker(&mut self, name: &str) -> Result<(), LinkError> {
553        self.log_store.append_log_entry(name)
554    }
555
556    // ----- Transaction handle --------------------------------------------
557
558    pub fn begin_transaction(&mut self) -> Result<TransactionHandle, LinkError> {
559        if self.current.is_some() {
560            return Err(LinkError::Transaction(
561                "Nested transactions are not supported.".to_string(),
562            ));
563        }
564        let id = new_transaction_id();
565        let started_ms = now_unix_ms();
566        self.current = Some(PendingTransaction {
567            id,
568            transitions: Vec::new(),
569            auto_commit: false,
570            started_ms,
571        });
572        Ok(TransactionHandle { id, started_ms })
573    }
574
575    pub fn commit(&mut self) -> Result<(), LinkError> {
576        if self.current.is_none() {
577            return Ok(());
578        }
579        self.commit_current()
580    }
581
582    fn commit_current(&mut self) -> Result<(), LinkError> {
583        let pending = match self.current.take() {
584            Some(p) => p,
585            None => return Ok(()),
586        };
587        self.committed.insert(pending.id);
588        self.write_marker(&format!("{COMMIT_MARKER_PREFIX}{:032x}", pending.id))?;
589        if self.trace {
590            eprintln!(
591                "[Transactions] Committed tx {:032x} (mode={:?}, transitions={}).",
592                pending.id,
593                self.commit_mode,
594                pending.transitions.len()
595            );
596        }
597        for transition in &pending.transitions {
598            self.mark_applied(transition)?;
599        }
600        let _ = pending.auto_commit;
601        let _ = pending.started_ms;
602        self.enforce_retention()?;
603        Ok(())
604    }
605
606    pub fn rollback(&mut self) -> Result<(), LinkError> {
607        let pending = match self.current.take() {
608            Some(p) => p,
609            None => return Ok(()),
610        };
611        self.rolled_back.insert(pending.id);
612        self.replaying = true;
613        for transition in pending.transitions.iter().rev() {
614            self.try_revert_transition(transition);
615        }
616        self.replaying = false;
617        self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{:032x}", pending.id))?;
618        if self.trace {
619            eprintln!(
620                "[Transactions] Rolled back tx {:032x} ({} transitions).",
621                pending.id,
622                pending.transitions.len(),
623            );
624        }
625        self.enforce_retention()?;
626        Ok(())
627    }
628}
629
630/// Read paths that lend out references, available whenever the wrapped
631/// store keeps its links resident in memory.
632impl<T, S, L> GenericTransactionsDecorator<T, S, L>
633where
634    T: LinkReference,
635    S: LinksStorageRef<T>,
636    L: TransitionLogStore,
637{
638    pub fn get(&self, id: T) -> Option<&GenericLink<T>> {
639        self.inner.get_link_ref(id)
640    }
641
642    pub fn all(&self) -> Vec<&GenericLink<T>> {
643        self.inner.all_link_refs()
644    }
645
646    pub fn query(
647        &self,
648        index: Option<T>,
649        source: Option<T>,
650        target: Option<T>,
651    ) -> Vec<&GenericLink<T>> {
652        self.inner.query_link_refs(index, source, target)
653    }
654}
655
656// ----- Helpers ----------------------------------------------------------
657
658fn insert_ordered<T: LinkReference>(
659    list: &mut Vec<GenericTransition<T>>,
660    transition: GenericTransition<T>,
661) {
662    let mut lo = 0usize;
663    let mut hi = list.len();
664    while lo < hi {
665        let mid = (lo + hi) / 2;
666        if list[mid].sequence < transition.sequence {
667            lo = mid + 1;
668        } else {
669            hi = mid;
670        }
671    }
672    list.insert(lo, transition);
673}
674
675static TX_COUNTER: AtomicU64 = AtomicU64::new(0);
676
677fn new_transaction_id() -> u128 {
678    // Combine a per-process counter with the current timestamp to
679    // approximate a Guid without pulling in the `uuid` crate.
680    let count = TX_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
681    let now = now_unix_ms() as u128;
682    (now << 64) | count
683}
684
685fn now_unix_ms() -> i64 {
686    SystemTime::now()
687        .duration_since(UNIX_EPOCH)
688        .map(|d| d.as_millis() as i64)
689        .unwrap_or(0)
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn retention_policy_parses_specs() {
698        assert!(matches!(
699            LogRetentionPolicy::parse("infinite").unwrap(),
700            LogRetentionPolicy::Infinite
701        ));
702        assert!(matches!(
703            LogRetentionPolicy::parse("sized:1000").unwrap(),
704            LogRetentionPolicy::Sized {
705                max_transitions: 1000
706            }
707        ));
708        match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() {
709            LogRetentionPolicy::Chunked {
710                chunk_size,
711                archive_directory,
712            } => {
713                assert_eq!(chunk_size, 500);
714                assert_eq!(archive_directory, PathBuf::from("/tmp/x"));
715            }
716            _ => panic!("expected Chunked"),
717        }
718        assert!(LogRetentionPolicy::parse("garbage").is_err());
719    }
720
721    #[test]
722    fn transition_round_trips_through_serialize() {
723        let t = Transition {
724            transaction_id: 0xabcdef1234567890u128,
725            sequence: 42,
726            timestamp_ms: 1234567890,
727            kind: TransitionKind::Update,
728            before: DoubletLink::new(1, 2, 3),
729            after: DoubletLink::new(1, 4, 5),
730        };
731        let parsed = Transition::try_parse(&t.serialize()).unwrap();
732        assert_eq!(t, parsed);
733    }
734
735    #[test]
736    fn wide_transition_is_rejected_by_a_narrow_address_type() {
737        let wide = GenericTransition::<u64> {
738            transaction_id: 7,
739            sequence: 1,
740            timestamp_ms: 0,
741            kind: TransitionKind::Create,
742            before: GenericDoubletLink::empty(),
743            after: GenericDoubletLink::new(u32::MAX as u64 + 1, 0, 0),
744        };
745        assert!(matches!(
746            GenericTransition::<u32>::parse(&wide.serialize()),
747            Err(LinkError::AddressOutOfRange(_))
748        ));
749        assert!(GenericTransition::<u64>::parse(&wide.serialize()).is_ok());
750    }
751}