Skip to main content

formualizer_eval/engine/graph/editor/
change_log.rs

1//! Standalone change logging infrastructure for tracking graph mutations
2//!
3//! This module provides:
4//! - ChangeLog: Audit trail of all graph changes
5//! - ChangeEvent: Granular representation of individual changes
6//! - ChangeLogger: Trait for pluggable logging strategies
7
8use crate::SheetId;
9use crate::engine::addr::GridAddr;
10use crate::engine::named_range::{NameScope, NamedDefinition};
11use crate::engine::row_visibility::RowVisibilitySource;
12use crate::engine::vertex::VertexId;
13use crate::reference::CellRef;
14use formualizer_common::LiteralValue;
15use formualizer_parse::parser::ASTNode;
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct SpillSnapshot {
19    /// Owned cells in row-major order, starting at the anchor. User-edited cells may be omitted.
20    pub target_cells: Vec<CellRef>,
21    /// Dense values for the original rectangle, relative to the first target cell.
22    /// Values at omitted cells do not confer ownership and are never replayed.
23    pub values: Vec<Vec<LiteralValue>>,
24}
25
26/// Per-event metadata attached by the caller.
27///
28/// This is intentionally lightweight (Strings) to avoid leaking application types
29/// into the engine layer.
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct ChangeEventMeta {
32    pub actor_id: Option<String>,
33    pub correlation_id: Option<String>,
34    pub reason: Option<String>,
35}
36
37/// Represents a single change to the dependency graph
38#[derive(Debug, Clone, PartialEq)]
39pub enum ChangeEvent {
40    // Simple events
41    SetValue {
42        addr: CellRef,
43        old_value: Option<LiteralValue>,
44        old_formula: Option<ASTNode>,
45        new: LiteralValue,
46    },
47    SetFormula {
48        addr: CellRef,
49        old_value: Option<LiteralValue>,
50        old_formula: Option<ASTNode>,
51        new: ASTNode,
52    },
53    TableResized {
54        name: String,
55        old_range: crate::reference::RangeRef,
56        new_range: crate::reference::RangeRef,
57        old_empty_body: bool,
58        new_empty_body: bool,
59    },
60    SetRowVisibility {
61        sheet_id: SheetId,
62        row0: u32,
63        source: RowVisibilitySource,
64        old_hidden: bool,
65        new_hidden: bool,
66    },
67    /// Vertex creation snapshot (for undo). Minimal for now.
68    AddVertex {
69        id: VertexId,
70        coord: GridAddr,
71        sheet_id: SheetId,
72        value: Option<LiteralValue>,
73        formula: Option<ASTNode>,
74        kind: Option<crate::engine::vertex::VertexKind>,
75        flags: Option<u8>,
76    },
77    RemoveVertex {
78        id: VertexId,
79        // Need to capture more for rollback!
80        old_value: Option<LiteralValue>,
81        old_formula: Option<ASTNode>,
82        old_dependencies: Vec<VertexId>, // outgoing
83        old_dependents: Vec<VertexId>,   // incoming
84        coord: Option<GridAddr>,
85        sheet_id: Option<SheetId>,
86        kind: Option<crate::engine::vertex::VertexKind>,
87        flags: Option<u8>,
88    },
89
90    // Compound operation markers
91    CompoundStart {
92        description: String, // e.g., "InsertRows(sheet=0, before=5, count=2)"
93        depth: usize,
94    },
95    CompoundEnd {
96        depth: usize,
97    },
98
99    // Granular events for compound operations
100    VertexMoved {
101        id: VertexId,
102        sheet_id: SheetId,
103        old_coord: GridAddr,
104        new_coord: GridAddr,
105    },
106    FormulaAdjusted {
107        id: VertexId,
108        /// Cell address for replay. May be None for non-cell formula vertices.
109        addr: Option<CellRef>,
110        old_ast: ASTNode,
111        new_ast: ASTNode,
112    },
113    NamedRangeAdjusted {
114        name: String,
115        scope: NameScope,
116        old_definition: NamedDefinition,
117        new_definition: NamedDefinition,
118    },
119    EdgeAdded {
120        from: VertexId,
121        to: VertexId,
122    },
123    EdgeRemoved {
124        from: VertexId,
125        to: VertexId,
126    },
127
128    // Named range operations
129    DefineName {
130        name: String,
131        scope: NameScope,
132        definition: NamedDefinition,
133    },
134    UpdateName {
135        name: String,
136        scope: NameScope,
137        old_definition: NamedDefinition,
138        new_definition: NamedDefinition,
139    },
140    DeleteName {
141        name: String,
142        scope: NameScope,
143        old_definition: Option<NamedDefinition>,
144    },
145
146    // Spill region changes (dynamic arrays)
147    SpillCommitted {
148        anchor: VertexId,
149        old: Option<SpillSnapshot>,
150        new: SpillSnapshot,
151    },
152    SpillCleared {
153        anchor: VertexId,
154        old: SpillSnapshot,
155    },
156    /// Workbook-level per-cell staged formula delta used to keep deferred edits
157    /// undoable.
158    ///
159    /// Replaces the former `StagedFormulaStateChanged` full before/after snapshot
160    /// pair (which made interactive `set_formula` O(N) per edit and O(N^2) in
161    /// changelog memory — see #126). Each edit records only the affected cell's
162    /// staged text transition, so a sequence of N edits costs O(N) total.
163    ///
164    /// - `old`: the staged formula text for the cell before the edit, if any.
165    /// - `new`: the staged formula text for the cell after the edit, if any.
166    ///
167    /// Undo restores `old` (re-stage if `Some`, clear if `None`); redo applies
168    /// `new` (re-stage if `Some`, clear if `None`).
169    StagedFormulaCellChanged {
170        sheet: String,
171        row: u32,
172        col: u32,
173        old: Option<String>,
174        new: Option<String>,
175    },
176}
177
178/// The `FormulaAdjusted` events of a run of consecutive family members
179/// that a structural edit rewrote as one (Program 2, contract decision
180/// 20.5): members `0..len` are vertices `first + i` at rows `row0 + i` of
181/// column `col` (after the edit); each member's old and new formula is the
182/// first member's relocated down by `i` rows. Logs keep the record and
183/// expand it into the per-member events only when those are read.
184#[derive(Debug, Clone, PartialEq)]
185pub(crate) struct FormulaRunAdjusted {
186    pub(crate) first: VertexId,
187    pub(crate) len: u32,
188    pub(crate) sheet_id: SheetId,
189    pub(crate) col: u32,
190    pub(crate) row0: u32,
191    pub(crate) old_first: ASTNode,
192    pub(crate) new_first: ASTNode,
193}
194
195impl FormulaRunAdjusted {
196    #[inline]
197    pub(crate) fn len(&self) -> usize {
198        self.len as usize
199    }
200
201    /// Member `i`'s event, exactly as the per-cell editor logs it.
202    pub(crate) fn event(&self, i: u32) -> ChangeEvent {
203        let relocate = |ast: &ASTNode| {
204            if i == 0 {
205                return ast.clone();
206            }
207            // Every member between the run's (checked) first and last
208            // relocates: references are affine in the member row.
209            crate::engine::template::relocate::instantiate_member_ast(ast, i64::from(i), 0)
210                .expect("a run member's formula relocates")
211        };
212        ChangeEvent::FormulaAdjusted {
213            id: VertexId(self.first.0 + i),
214            addr: Some(CellRef::new(
215                self.sheet_id,
216                crate::reference::Coord::new(self.row0 + i, self.col, true, true),
217            )),
218            old_ast: relocate(&self.old_first),
219            new_ast: relocate(&self.new_first),
220        }
221    }
222
223    /// Every member's event, in vertex order.
224    pub(crate) fn events(&self) -> impl Iterator<Item = ChangeEvent> + '_ {
225        (0..self.len).map(|i| self.event(i))
226    }
227}
228
229/// A run record held by a log: it stands for `run.len` events placed
230/// before retained event `pos`, with consecutive sequence numbers from
231/// `seq0`, one group and one meta.
232#[derive(Debug)]
233struct LazyEntry {
234    pos: usize,
235    seq0: u64,
236    group: Option<u64>,
237    meta: ChangeEventMeta,
238    run: std::sync::Arc<FormulaRunAdjusted>,
239}
240
241/// The retained events of a log. `events`/`metas`/`seqs`/`groups` are the
242/// canonical (parallel) vectors; `lazy` holds run records not yet expanded
243/// into them, in log order, each positioned before `events[pos]`. Every
244/// record's position is at or after the previous record's, and the events
245/// before the first record are expanded history.
246#[derive(Debug, Default)]
247struct Retained {
248    events: Vec<ChangeEvent>,
249    metas: Vec<ChangeEventMeta>,
250    seqs: Vec<u64>,
251    groups: Vec<Option<u64>>,
252    lazy: Vec<LazyEntry>,
253}
254
255impl Retained {
256    /// Expand every pending run record in place. Only the events from the
257    /// first record's position on are touched (moved, never cloned), so
258    /// history expanded by an earlier call costs nothing again. Returns the
259    /// number of events written (expanded plus moved), for the work gate.
260    fn expand(&mut self) -> usize {
261        let Some(first) = self.lazy.first() else {
262            return 0;
263        };
264        let start = first.pos;
265        let tail_events = self.events.split_off(start);
266        let tail_metas = self.metas.split_off(start);
267        let tail_seqs = self.seqs.split_off(start);
268        let tail_groups = self.groups.split_off(start);
269        let tail_len = tail_events.len();
270        let added: usize = self.lazy.iter().map(|e| e.run.len()).sum();
271        self.events.reserve(tail_len + added);
272        self.metas.reserve(tail_len + added);
273        self.seqs.reserve(tail_len + added);
274        self.groups.reserve(tail_len + added);
275        let mut tail = tail_events
276            .into_iter()
277            .zip(tail_metas)
278            .zip(tail_seqs.into_iter().zip(tail_groups));
279        let mut p = start;
280        for entry in std::mem::take(&mut self.lazy) {
281            while p < entry.pos {
282                let ((event, meta), (seq, group)) = tail.next().expect("record position in range");
283                self.events.push(event);
284                self.metas.push(meta);
285                self.seqs.push(seq);
286                self.groups.push(group);
287                p += 1;
288            }
289            for (i, event) in entry.run.events().enumerate() {
290                self.events.push(event);
291                self.metas.push(entry.meta.clone());
292                self.seqs.push(entry.seq0 + i as u64);
293                self.groups.push(entry.group);
294            }
295        }
296        for ((event, meta), (seq, group)) in tail {
297            self.events.push(event);
298            self.metas.push(meta);
299            self.seqs.push(seq);
300            self.groups.push(group);
301        }
302        tail_len + added
303    }
304}
305
306/// [`Retained`] behind a cell that lets a shared read expand pending run
307/// records in place, once, so readers borrow one contiguous expanded log
308/// and every run record is expanded exactly once over the log's life.
309///
310/// Invariant: `expanded` is initialized exactly when `inner.lazy` is
311/// empty. Shared access to `inner` goes only through [`Self::get`], which
312/// runs the expansion inside `expanded.get_or_init` and hands out
313/// references only after it completes; every other access takes `&mut
314/// self`, and a record is added (resetting `expanded`) only through
315/// `&mut self` as well.
316struct RetainedCell {
317    inner: std::cell::UnsafeCell<Retained>,
318    expanded: std::sync::OnceLock<()>,
319    /// Retained events, pending records included.
320    len: usize,
321    /// Pending run records (meaningful while `expanded` is uninitialized).
322    pending_records: usize,
323    /// Events written by expansion (expanded and moved) over the log's
324    /// life: the work gate of the changelog scaling test.
325    #[cfg(test)]
326    work: std::sync::atomic::AtomicUsize,
327}
328
329// SAFETY: `inner` is mutated through a shared reference only inside
330// `expanded.get_or_init`, which runs the closure at most once per
331// initialization, blocks concurrent callers until it finishes and
332// publishes its writes (happens-before) to them. No shared reference into
333// `inner` can exist while it runs: `get` returns only after initialization,
334// and `expanded` is reset only through `&mut self`. This is the same
335// contract as `Mutex<Retained>` / `OnceLock<Retained>`, hence the
336// `Send + Sync` requirement on the contents (asserted below).
337unsafe impl Sync for RetainedCell {}
338
339const _: () = {
340    const fn assert_send_sync<T: Send + Sync>() {}
341    assert_send_sync::<Retained>();
342};
343
344impl Default for RetainedCell {
345    fn default() -> Self {
346        Self {
347            inner: std::cell::UnsafeCell::new(Retained::default()),
348            expanded: std::sync::OnceLock::from(()),
349            len: 0,
350            pending_records: 0,
351            #[cfg(test)]
352            work: std::sync::atomic::AtomicUsize::new(0),
353        }
354    }
355}
356
357impl std::fmt::Debug for RetainedCell {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("RetainedCell")
360            .field("len", &self.len)
361            .field("expanded", &self.expanded.get().is_some())
362            .finish()
363    }
364}
365
366impl RetainedCell {
367    #[inline]
368    fn note_work(&self, _work: usize) {
369        #[cfg(test)]
370        self.work
371            .fetch_add(_work, std::sync::atomic::Ordering::Relaxed);
372    }
373
374    /// The expanded log (pending records are expanded first, once).
375    fn get(&self) -> &Retained {
376        self.expanded.get_or_init(|| {
377            // SAFETY: see the `Sync` impl: this closure is the only access
378            // to `inner` while it runs.
379            let inner = unsafe { &mut *self.inner.get() };
380            self.note_work(inner.expand());
381        });
382        // SAFETY: initialized: no mutation through `&self` until `&mut self`.
383        unsafe { &*self.inner.get() }
384    }
385
386    /// Expand pending records in place and return the expanded vectors.
387    fn settle(&mut self) -> &mut Retained {
388        if self.expanded.get().is_none() {
389            let work = self.inner.get_mut().expand();
390            self.note_work(work);
391            self.expanded = std::sync::OnceLock::from(());
392            self.pending_records = 0;
393        }
394        self.inner.get_mut()
395    }
396
397    /// Plain events so far (the position of a record appended now).
398    fn plain_len(&mut self) -> usize {
399        self.inner.get_mut().events.len()
400    }
401
402    fn push(&mut self, event: ChangeEvent, meta: ChangeEventMeta, seq: u64, group: Option<u64>) {
403        let r = self.inner.get_mut();
404        r.events.push(event);
405        r.metas.push(meta);
406        r.seqs.push(seq);
407        r.groups.push(group);
408        self.len += 1;
409    }
410
411    fn push_run(&mut self, entry: LazyEntry) {
412        self.len += entry.run.len();
413        self.inner.get_mut().lazy.push(entry);
414        self.pending_records += 1;
415        self.expanded = std::sync::OnceLock::new();
416    }
417
418    fn drain_front(&mut self, n: usize) {
419        let r = self.settle();
420        r.events.drain(0..n);
421        r.metas.drain(0..n);
422        r.seqs.drain(0..n);
423        r.groups.drain(0..n);
424        self.len = r.events.len();
425    }
426
427    fn truncate(&mut self, len: usize) {
428        let r = self.settle();
429        r.events.truncate(len);
430        r.metas.truncate(len);
431        r.seqs.truncate(len);
432        r.groups.truncate(len);
433        self.len = r.events.len();
434    }
435
436    fn split_off(&mut self, index: usize) -> Vec<ChangeEvent> {
437        let r = self.settle();
438        let events = r.events.split_off(index);
439        let _ = r.metas.split_off(index);
440        let _ = r.seqs.split_off(index);
441        let _ = r.groups.split_off(index);
442        self.len = r.events.len();
443        events
444    }
445
446    fn clear(&mut self) {
447        *self = Self {
448            #[cfg(test)]
449            work: std::sync::atomic::AtomicUsize::new(
450                self.work.load(std::sync::atomic::Ordering::Relaxed),
451            ),
452            ..Self::default()
453        };
454    }
455}
456
457/// Audit trail for tracking all changes to the dependency graph
458#[derive(Debug, Default)]
459pub struct ChangeLog {
460    enabled: bool,
461    /// Optional cap on retained events; when exceeded, oldest events are evicted (FIFO).
462    max_changelog_events: Option<usize>,
463    /// Track compound operations for atomic rollback
464    compound_depth: usize,
465    next_seq: u64,
466    spill_clear_revision: u64,
467    /// Stack of active group ids for nested compounds
468    group_stack: Vec<u64>,
469    next_group_id: u64,
470
471    current_meta: ChangeEventMeta,
472
473    /// Retained events with their metadata, monotonic sequence numbers and
474    /// optional group (compound) ids. Run records (Program 2) stay
475    /// unexpanded until the log is read or indexed by a mutation; each
476    /// record is expanded in place exactly once (see [`RetainedCell`]).
477    retained: RetainedCell,
478}
479
480/// Complete, operation-local mutation capture used by `Engine` correctness paths.
481///
482/// Unlike `ChangeLog`, this sink is always enabled and never evicts. It is crate-private so
483/// audit retention remains a property of `ChangeLog`, not of graph mutation.
484#[derive(Debug)]
485pub(crate) struct MutationCapture {
486    events: Vec<ChangeEvent>,
487    /// Run records (Program 2): `(pos, run)` stands for the run's events
488    /// placed before `events[pos]`, in order.
489    lazy: Vec<(usize, std::sync::Arc<FormulaRunAdjusted>)>,
490    compound_depth: usize,
491    current_meta: ChangeEventMeta,
492}
493
494impl MutationCapture {
495    pub(crate) fn new(current_meta: ChangeEventMeta) -> Self {
496        Self {
497            events: Vec::new(),
498            lazy: Vec::new(),
499            compound_depth: 0,
500            current_meta,
501        }
502    }
503
504    /// Number of plain (non-run) events: a position marker for
505    /// [`Self::events`].
506    pub(crate) fn len(&self) -> usize {
507        self.events.len()
508    }
509
510    /// The plain events; run records are not in this slice (see
511    /// [`Self::expanded_events_from`]). Every consumer of a forward edit's
512    /// events ignores `FormulaAdjusted` except invalidation, which also
513    /// asks [`Self::lazy_len`].
514    pub(crate) fn events(&self) -> &[ChangeEvent] {
515        &self.events
516    }
517
518    /// Number of run records (a position marker for records).
519    pub(crate) fn lazy_len(&self) -> usize {
520        self.lazy.len()
521    }
522
523    /// Record a run's `FormulaAdjusted` events without expanding them.
524    pub(crate) fn record_lazy(&mut self, run: FormulaRunAdjusted) {
525        if run.len == 0 {
526            return;
527        }
528        self.lazy
529            .push((self.events.len(), std::sync::Arc::new(run)));
530    }
531
532    /// Every event from plain position `start` (and run record
533    /// `lazy_start`) on, run records expanded in place.
534    pub(crate) fn expanded_events_from(&self, start: usize, lazy_start: usize) -> Vec<ChangeEvent> {
535        let lazy = &self.lazy[lazy_start.min(self.lazy.len())..];
536        let mut out = Vec::with_capacity(
537            self.events.len().saturating_sub(start)
538                + lazy.iter().map(|(_, r)| r.len()).sum::<usize>(),
539        );
540        let mut k = 0;
541        for p in start..=self.events.len() {
542            while k < lazy.len() && lazy[k].0 <= p {
543                out.extend(lazy[k].1.events());
544                k += 1;
545            }
546            if let Some(e) = self.events.get(p) {
547                out.push(e.clone());
548            }
549        }
550        out
551    }
552
553    pub(crate) fn close_compounds(&mut self) {
554        while self.compound_depth > 0 {
555            self.end_compound();
556        }
557    }
558
559    fn push(&mut self, event: ChangeEvent) {
560        self.events.push(event);
561    }
562}
563
564/// Backward replay helper: the description of the compound whose end
565/// marker is event `end` (`event(i)` reads the events in forward order):
566/// the start marker that closes it, counting nesting (depth fields are not
567/// unique: a redo re-logs a group's markers inside its own compound).
568pub(crate) fn compound_start_description<'a>(
569    end: usize,
570    event: impl Fn(usize) -> &'a ChangeEvent,
571) -> Option<&'a str> {
572    if !matches!(event(end), ChangeEvent::CompoundEnd { .. }) {
573        return None;
574    }
575    let mut open = 1usize;
576    for i in (0..end).rev() {
577        match event(i) {
578            ChangeEvent::CompoundEnd { .. } => open += 1,
579            ChangeEvent::CompoundStart { description, .. } => {
580                open -= 1;
581                if open == 0 {
582                    return Some(description.as_str());
583                }
584            }
585            _ => {}
586        }
587    }
588    None
589}
590
591impl ChangeLogger for MutationCapture {
592    fn record(&mut self, event: ChangeEvent) {
593        self.push(event);
594    }
595
596    fn set_enabled(&mut self, _: bool) {}
597
598    fn begin_compound(&mut self, description: String) {
599        self.compound_depth += 1;
600        self.push(ChangeEvent::CompoundStart {
601            description,
602            depth: self.compound_depth,
603        });
604    }
605
606    fn end_compound(&mut self) {
607        if self.compound_depth == 0 {
608            return;
609        }
610        self.push(ChangeEvent::CompoundEnd {
611            depth: self.compound_depth,
612        });
613        self.compound_depth -= 1;
614    }
615}
616
617impl ChangeLog {
618    pub fn new() -> Self {
619        Self {
620            enabled: true,
621            max_changelog_events: None,
622            compound_depth: 0,
623            next_seq: 0,
624            spill_clear_revision: 0,
625            group_stack: Vec::new(),
626            next_group_id: 1,
627            current_meta: ChangeEventMeta::default(),
628            retained: RetainedCell::default(),
629        }
630    }
631
632    pub fn with_max_changelog_events(max: usize) -> Self {
633        let mut out = Self::new();
634        out.max_changelog_events = Some(max);
635        out
636    }
637
638    pub fn set_max_changelog_events(&mut self, max: Option<usize>) {
639        self.max_changelog_events = max;
640        self.enforce_cap();
641    }
642
643    fn enforce_cap(&mut self) {
644        let Some(max) = self.max_changelog_events else {
645            return;
646        };
647        if max == 0 {
648            self.retained.clear();
649            return;
650        }
651        if self.len() <= max {
652            return;
653        }
654        let drop_n = self.len() - max;
655        self.retained.drain_front(drop_n);
656    }
657
658    fn replay_lazy(
659        &mut self,
660        run: std::sync::Arc<FormulaRunAdjusted>,
661        meta: &ChangeEventMeta,
662        retain: bool,
663    ) {
664        if !self.enabled {
665            return;
666        }
667        let seq0 = self.next_seq;
668        self.next_seq += run.len as u64;
669        if retain {
670            let entry = LazyEntry {
671                pos: self.retained.plain_len(),
672                seq0,
673                group: self.group_stack.last().copied(),
674                meta: meta.clone(),
675                run,
676            };
677            self.retained.push_run(entry);
678        }
679    }
680
681    fn replay_record(&mut self, event: ChangeEvent, meta: &ChangeEventMeta, retain: bool) {
682        if !self.enabled {
683            return;
684        }
685        let seq = self.next_seq;
686        self.next_seq += 1;
687        if retain {
688            self.note_spill_clear(&event);
689            let group = self.group_stack.last().copied();
690            self.retained.push(event, meta.clone(), seq, group);
691        }
692    }
693
694    fn replay_begin_compound(&mut self, description: String, meta: &ChangeEventMeta, retain: bool) {
695        self.compound_depth += 1;
696        if self.compound_depth == 1 {
697            let gid = self.next_group_id;
698            self.next_group_id += 1;
699            self.group_stack.push(gid);
700        } else if let Some(&gid) = self.group_stack.last() {
701            self.group_stack.push(gid);
702        }
703        self.replay_record(
704            ChangeEvent::CompoundStart {
705                description,
706                depth: self.compound_depth,
707            },
708            meta,
709            retain,
710        );
711    }
712
713    fn replay_end_compound(&mut self, meta: &ChangeEventMeta, retain: bool) {
714        if self.compound_depth == 0 {
715            return;
716        }
717        self.replay_record(
718            ChangeEvent::CompoundEnd {
719                depth: self.compound_depth,
720            },
721            meta,
722            retain,
723        );
724        self.compound_depth -= 1;
725        self.group_stack.pop();
726    }
727
728    fn replay_capture(&mut self, capture: MutationCapture, retain: bool) {
729        let mut lazy = capture.lazy.into_iter().peekable();
730        for (p, event) in capture.events.into_iter().enumerate() {
731            while let Some((_, run)) = lazy.next_if(|(pos, _)| *pos <= p) {
732                self.replay_lazy(run, &capture.current_meta, retain);
733            }
734            match event {
735                ChangeEvent::CompoundStart { description, .. } => {
736                    self.replay_begin_compound(description, &capture.current_meta, retain);
737                }
738                ChangeEvent::CompoundEnd { .. } => {
739                    self.replay_end_compound(&capture.current_meta, retain);
740                }
741                event => self.replay_record(event, &capture.current_meta, retain),
742            }
743        }
744        for (_, run) in lazy {
745            self.replay_lazy(run, &capture.current_meta, retain);
746        }
747        if retain {
748            self.enforce_cap();
749        }
750    }
751
752    pub(crate) fn current_meta(&self) -> ChangeEventMeta {
753        self.current_meta.clone()
754    }
755
756    pub(crate) fn publish_capture(&mut self, capture: MutationCapture) {
757        self.replay_capture(capture, true);
758    }
759
760    pub(crate) fn discard_capture(&mut self, capture: MutationCapture) {
761        self.replay_capture(capture, false);
762    }
763
764    /// Monotonic revision of published spill-clear events. Retention, undo and
765    /// truncation do not rewind it; consumers can skip unchanged spill history.
766    pub fn spill_clear_revision(&self) -> u64 {
767        self.spill_clear_revision
768    }
769
770    fn note_spill_clear(&mut self, event: &ChangeEvent) {
771        if matches!(event, ChangeEvent::SpillCleared { .. }) {
772            self.spill_clear_revision = self.spill_clear_revision.wrapping_add(1);
773        }
774    }
775
776    pub fn record(&mut self, event: ChangeEvent) {
777        if self.enabled {
778            self.note_spill_clear(&event);
779            let seq = self.next_seq;
780            self.next_seq += 1;
781            let current_group = self.group_stack.last().copied();
782            self.retained
783                .push(event, self.current_meta.clone(), seq, current_group);
784            self.enforce_cap();
785        }
786    }
787
788    /// Record an event with explicit metadata (used for replay/redo).
789    pub fn record_with_meta(&mut self, event: ChangeEvent, meta: ChangeEventMeta) {
790        if self.enabled {
791            self.note_spill_clear(&event);
792            let seq = self.next_seq;
793            self.next_seq += 1;
794            let current_group = self.group_stack.last().copied();
795            self.retained.push(event, meta, seq, current_group);
796            self.enforce_cap();
797        }
798    }
799
800    /// Begin a compound operation (multiple changes from single action)
801    pub fn begin_compound(&mut self, description: String) {
802        self.compound_depth += 1;
803        if self.compound_depth == 1 {
804            // allocate new group id
805            let gid = self.next_group_id;
806            self.next_group_id += 1;
807            self.group_stack.push(gid);
808        } else {
809            // nested: reuse top id
810            if let Some(&gid) = self.group_stack.last() {
811                self.group_stack.push(gid);
812            }
813        }
814        if self.enabled {
815            self.record(ChangeEvent::CompoundStart {
816                description,
817                depth: self.compound_depth,
818            });
819        }
820    }
821
822    /// End a compound operation
823    pub fn end_compound(&mut self) {
824        if self.compound_depth > 0 {
825            if self.enabled {
826                self.record(ChangeEvent::CompoundEnd {
827                    depth: self.compound_depth,
828                });
829            }
830            self.compound_depth -= 1;
831            self.group_stack.pop();
832        }
833    }
834
835    /// Run records not yet expanded in place (tests: laziness).
836    #[cfg(test)]
837    pub(crate) fn unexpanded_run_records(&self) -> usize {
838        if self.retained.expanded.get().is_some() {
839            0
840        } else {
841            self.retained.pending_records
842        }
843    }
844
845    /// Events written into the retained vectors by run-record expansion
846    /// (expanded members plus plain events moved behind them) over the
847    /// log's life (tests: the work-scaling gate).
848    #[cfg(test)]
849    pub(crate) fn expansion_work(&self) -> usize {
850        self.retained
851            .work
852            .load(std::sync::atomic::Ordering::Relaxed)
853    }
854
855    pub fn events(&self) -> &[ChangeEvent] {
856        &self.retained.get().events
857    }
858
859    pub fn event_meta(&self, index: usize) -> Option<&ChangeEventMeta> {
860        self.retained.get().metas.get(index)
861    }
862
863    pub fn set_actor_id(&mut self, actor_id: Option<String>) {
864        self.current_meta.actor_id = actor_id;
865    }
866
867    pub fn set_correlation_id(&mut self, correlation_id: Option<String>) {
868        self.current_meta.correlation_id = correlation_id;
869    }
870
871    pub fn set_reason(&mut self, reason: Option<String>) {
872        self.current_meta.reason = reason;
873    }
874
875    /// Truncate log (and metadata) to len
876    pub fn truncate(&mut self, len: usize) {
877        self.retained.truncate(len);
878    }
879
880    pub fn clear(&mut self) {
881        self.retained.clear();
882        self.compound_depth = 0;
883        self.group_stack.clear();
884    }
885
886    pub fn len(&self) -> usize {
887        self.retained.len
888    }
889
890    pub fn is_empty(&self) -> bool {
891        self.len() == 0
892    }
893
894    /// Extract events from index to end
895    pub fn take_from(&mut self, index: usize) -> Vec<ChangeEvent> {
896        self.retained.split_off(index)
897    }
898
899    /// Temporarily disable logging (for rollback operations)
900    pub fn set_enabled(&mut self, enabled: bool) {
901        self.enabled = enabled;
902    }
903
904    /// Get current compound depth (for testing)
905    pub fn compound_depth(&self) -> usize {
906        self.compound_depth
907    }
908
909    /// Return (sequence_number, group_id) metadata for event index
910    pub fn meta(&self, index: usize) -> Option<(u64, Option<u64>)> {
911        let r = self.retained.get();
912        r.seqs.get(index).copied().zip(r.groups.get(index).copied())
913    }
914
915    /// Collect indices belonging to the last (innermost) complete group. Fallback: last single event.
916    pub fn last_group_indices(&self) -> Vec<usize> {
917        let groups = &self.retained.get().groups;
918        if let Some(&last_gid) = groups.iter().rev().flatten().next() {
919            let idxs: Vec<usize> = groups
920                .iter()
921                .enumerate()
922                .filter_map(|(i, g)| if *g == Some(last_gid) { Some(i) } else { None })
923                .collect();
924            if !idxs.is_empty() {
925                return idxs;
926            }
927        }
928        self.len().checked_sub(1).into_iter().collect()
929    }
930}
931
932/// Trait for pluggable logging strategies
933pub trait ChangeLogger {
934    fn record(&mut self, event: ChangeEvent);
935    fn set_enabled(&mut self, enabled: bool);
936    fn begin_compound(&mut self, description: String);
937    fn end_compound(&mut self);
938}
939
940impl ChangeLogger for ChangeLog {
941    fn record(&mut self, event: ChangeEvent) {
942        ChangeLog::record(self, event);
943    }
944
945    fn set_enabled(&mut self, enabled: bool) {
946        self.enabled = enabled;
947    }
948
949    fn begin_compound(&mut self, description: String) {
950        ChangeLog::begin_compound(self, description);
951    }
952
953    fn end_compound(&mut self) {
954        ChangeLog::end_compound(self);
955    }
956}
957
958/// Null logger for when change tracking not needed
959pub struct NullChangeLogger;
960
961impl ChangeLogger for NullChangeLogger {
962    fn record(&mut self, _: ChangeEvent) {}
963    fn set_enabled(&mut self, _: bool) {}
964    fn begin_compound(&mut self, _: String) {}
965    fn end_compound(&mut self) {}
966}