Skip to main content

graphrefly_structures/
changeset.rs

1//! Universal change envelope (Phase 14 — DS-14 locked 2026-05-05; M4.A 2026-05-10).
2//!
3//! Mirrors the TS impl at
4//! `packages/pure-ts/src/extra/data-structures/change.ts`. Every reactive
5//! structure's delta log emits records conforming to [`BaseChange<T>`]; storage
6//! WAL frames ([`crate`] consumer `graphrefly-storage::wal::WALFrame<T>`) and
7//! worker-bridge wire frames both carry the same envelope.
8//!
9//! Two-level discriminant:
10//! - Envelope-level [`Lifecycle`] (`spec` / `data` / `ownership`) for
11//!   cross-scope replay-boundary safety (DS-14 PART 4).
12//! - Payload-level `kind` discriminator (per-structure verb) inside the
13//!   `change: T` slot — porting of the per-structure payload unions
14//!   (`MapChangePayload`, `ListChangePayload`, etc.) lands with M5
15//!   reactive-structure ports.
16//!
17//! The `serde-support` feature gates `Serialize` / `Deserialize` derives.
18//! Storage / bridge consumers enable it; in-process structure consumers can
19//! skip the codec footprint.
20
21#[cfg(feature = "serde-support")]
22use serde::{Deserialize, Serialize};
23
24/// Cross-scope replay-boundary discriminant.
25///
26/// Replay order is `Spec → Data → Ownership` (canonical spec §b — fixed in
27/// `graphrefly-storage::wal::REPLAY_ORDER`). Allows a `restoreSnapshot
28/// mode:"diff"` caller to filter restore to a single lifecycle.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
31#[cfg_attr(feature = "serde-support", serde(rename_all = "lowercase"))]
32pub enum Lifecycle {
33    Spec,
34    Data,
35    Ownership,
36}
37
38/// Monotonic identity field for [`BaseChange::version`]. V0 is a counter
39/// (`u64`); V1+ is a content-id (CID string). The TS impl uses `number |
40/// string`; this enum is `#[serde(untagged)]` so the wire format is identical
41/// — bare number for `Counter`, bare string for `Cid`. Mixed-type sequences
42/// across versions are user-resolved per spec.
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
45#[cfg_attr(feature = "serde-support", serde(untagged))]
46pub enum Version {
47    Counter(u64),
48    Cid(String),
49}
50
51/// Universal change envelope (DS-14 PART 4).
52///
53/// Field semantics:
54/// - `structure` — open string namespace per structure variant
55///   (`"reactiveMap"`, `"graphValue"`, `"ownership"`, …).
56/// - `version` — monotonic identity ([`Version::Counter`] V0 or
57///   [`Version::Cid`] V1+).
58/// - `t_ns` — wall-clock at mutation entry (matches the TS `wallClockNs()` call
59///   site).
60/// - `seq` — optional cursor seq for joining with audit logs. **Distinct from**
61///   `WALFrame::frame_seq` — `seq` is the bundle's `mutations` cursor (DS-14
62///   T1); `frame_seq` is the WAL tier's own cursor.
63/// - `lifecycle` — scope discriminant (see [`Lifecycle`]).
64/// - `change` — structure-specific delta payload, discriminated internally
65///   by `kind` (porting of per-structure payload unions arrives with M5).
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
68pub struct BaseChange<T> {
69    pub structure: String,
70    pub version: Version,
71    pub t_ns: u64,
72    #[cfg_attr(
73        feature = "serde-support",
74        serde(default, skip_serializing_if = "Option::is_none")
75    )]
76    pub seq: Option<u64>,
77    pub lifecycle: Lifecycle,
78    pub change: T,
79}
80
81// ---------------------------------------------------------------------------
82// Per-structure change payload enums (M5.A — D179)
83// ---------------------------------------------------------------------------
84
85/// Delta payload for [`crate::ReactiveLog`] mutations.
86#[derive(Debug, Clone, PartialEq, Eq)]
87#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
88#[cfg_attr(
89    feature = "serde-support",
90    serde(tag = "kind", rename_all = "camelCase")
91)]
92pub enum LogChange<T> {
93    Append { value: T },
94    AppendMany { values: Vec<T> },
95    Clear { count: usize },
96    TrimHead { n: usize },
97}
98
99/// Delta payload for [`crate::ReactiveList`] mutations.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
102#[cfg_attr(
103    feature = "serde-support",
104    serde(tag = "kind", rename_all = "camelCase")
105)]
106pub enum ListChange<T> {
107    Append { value: T },
108    AppendMany { values: Vec<T> },
109    Insert { index: usize, value: T },
110    InsertMany { index: usize, values: Vec<T> },
111    Pop { index: i64, value: T },
112    Clear { count: usize },
113}
114
115/// Reason a map key was deleted — tracked in mutation log for audit.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
118#[cfg_attr(feature = "serde-support", serde(rename_all = "camelCase"))]
119pub enum DeleteReason {
120    Explicit,
121    Expired,
122    LruEvict,
123    Archived,
124}
125
126/// Delta payload for [`crate::ReactiveMap`] mutations.
127#[derive(Debug, Clone, PartialEq, Eq)]
128#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
129#[cfg_attr(
130    feature = "serde-support",
131    serde(tag = "kind", rename_all = "camelCase")
132)]
133pub enum MapChange<K, V> {
134    Set {
135        key: K,
136        value: V,
137    },
138    Delete {
139        key: K,
140        previous: V,
141        reason: DeleteReason,
142    },
143    Clear {
144        count: usize,
145    },
146}
147
148/// Delta payload for [`crate::ReactiveIndex`] mutations.
149#[derive(Debug, Clone, PartialEq, Eq)]
150#[cfg_attr(feature = "serde-support", derive(Serialize, Deserialize))]
151#[cfg_attr(
152    feature = "serde-support",
153    serde(tag = "kind", rename_all = "camelCase")
154)]
155pub enum IndexChange<K, V> {
156    Upsert {
157        primary: K,
158        secondary: String,
159        value: V,
160    },
161    Delete {
162        primary: K,
163    },
164    DeleteMany {
165        primaries: Vec<K>,
166    },
167    Clear {
168        count: usize,
169    },
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[cfg(feature = "serde-support")]
177    #[test]
178    fn lifecycle_serde_lowercase() {
179        assert_eq!(serde_json::to_string(&Lifecycle::Spec).unwrap(), "\"spec\"");
180        assert_eq!(serde_json::to_string(&Lifecycle::Data).unwrap(), "\"data\"");
181        assert_eq!(
182            serde_json::to_string(&Lifecycle::Ownership).unwrap(),
183            "\"ownership\""
184        );
185        let parsed: Lifecycle = serde_json::from_str("\"data\"").unwrap();
186        assert_eq!(parsed, Lifecycle::Data);
187    }
188
189    #[cfg(feature = "serde-support")]
190    #[test]
191    fn version_serde_untagged_matches_ts_number_or_string() {
192        // Counter serializes as a bare JSON number — wire-identical to TS V0.
193        assert_eq!(serde_json::to_string(&Version::Counter(42)).unwrap(), "42");
194        // Cid serializes as a bare JSON string — wire-identical to TS V1+.
195        assert_eq!(
196            serde_json::to_string(&Version::Cid("bafy123".into())).unwrap(),
197            "\"bafy123\""
198        );
199        // Round-trip: number → Counter, string → Cid.
200        let parsed_num: Version = serde_json::from_str("7").unwrap();
201        assert_eq!(parsed_num, Version::Counter(7));
202        let parsed_str: Version = serde_json::from_str("\"bafyabc\"").unwrap();
203        assert_eq!(parsed_str, Version::Cid("bafyabc".into()));
204    }
205
206    #[cfg(feature = "serde-support")]
207    #[test]
208    fn base_change_skips_optional_seq() {
209        let c: BaseChange<u64> = BaseChange {
210            structure: "test".into(),
211            version: Version::Counter(1),
212            t_ns: 100,
213            seq: None,
214            lifecycle: Lifecycle::Data,
215            change: 99,
216        };
217        let s = serde_json::to_string(&c).unwrap();
218        assert!(
219            !s.contains("seq"),
220            "Option::None should not emit seq field: {s}"
221        );
222    }
223}