Skip to main content

nodedb_types/sync/wire/
delta.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Delta push / ack / reject / collection-purged messages.
4
5use serde::{Deserialize, Serialize};
6
7use crate::DatabaseId;
8use crate::sync::compensation::CompensationHint;
9use crate::sync::wire::ack_status::AckStatus;
10
11/// Delta push message (client → server, 0x10).
12#[derive(
13    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
14)]
15pub struct DeltaPushMsg {
16    /// Collection the delta applies to.
17    pub collection: String,
18    /// Document ID.
19    pub document_id: String,
20    /// Loro CRDT delta bytes.
21    pub delta: Vec<u8>,
22    /// Client's peer ID (for CRDT identity).
23    pub peer_id: u64,
24    /// Per-mutation unique ID for dedup.
25    pub mutation_id: u64,
26    /// CRC32C checksum of `delta` bytes for integrity verification.
27    /// Computed by sender, validated by receiver. 0 for legacy clients.
28    #[serde(default)]
29    pub checksum: u32,
30    /// Device-assigned valid-time for the mutation (ms since Unix epoch).
31    ///
32    /// Populated by offline-capable clients so Origin can preserve the
33    /// application's notion of "when did this fact take effect" independently
34    /// of the Origin-assigned `system_from_ms`. `None` means the client did
35    /// not supply a valid-time — Origin will use `system_from_ms` as the
36    /// default valid-from.
37    #[serde(default)]
38    pub device_valid_time_ms: Option<i64>,
39    /// Stable identity of the originating producer (Lite peer ID or Origin node ID).
40    /// 0 for legacy / pre-idempotency clients.
41    #[serde(default)]
42    pub producer_id: u64,
43    /// Monotonic epoch counter incremented on every producer restart.
44    /// 0 for legacy / pre-idempotency clients.
45    #[serde(default)]
46    pub epoch: u64,
47    /// Per-stream monotonic sequence number within the epoch.
48    /// 0 for legacy / pre-idempotency clients.
49    #[serde(default)]
50    pub seq: u64,
51    /// Authenticated device identifier used in delta-key derivation.
52    #[serde(default)]
53    pub device_id: u64,
54    /// HMAC-SHA256 over the session epoch, collection, document id, exact
55    /// delta bytes, and authenticated user/device/sequence tuple.
56    /// All zeros denotes an unsigned legacy delta.
57    #[serde(default)]
58    pub delta_signature: [u8; 32],
59}
60
61/// Delta acknowledgment (server → client, 0x11).
62#[derive(
63    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
64)]
65pub struct DeltaAckMsg {
66    /// Mutation ID being acknowledged.
67    pub mutation_id: u64,
68    /// Server-assigned LSN for this mutation.
69    pub lsn: u64,
70    /// Absolute clock-skew between `device_valid_time_ms` and the Origin
71    /// wall clock at commit, in milliseconds. `None` when the client did
72    /// not supply a device valid-time, or when skew was within tolerance
73    /// (≤ 24h). Populated so clients can surface a warning UX.
74    #[serde(default)]
75    pub clock_skew_warning_ms: Option<i64>,
76    /// Highest sequence number from this producer that has been durably applied.
77    /// 0 when the server has not yet recorded a sequence for this producer.
78    #[serde(default)]
79    pub applied_seq: u64,
80    /// Idempotency outcome of the acknowledged message.
81    #[serde(default)]
82    pub status: AckStatus,
83}
84
85/// Delta rejection (server → client, 0x12).
86#[derive(
87    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
88)]
89pub struct DeltaRejectMsg {
90    /// Mutation ID being rejected.
91    pub mutation_id: u64,
92    /// Reason for rejection.
93    pub reason: String,
94    /// Compensation hints for the client.
95    pub compensation: Option<CompensationHint>,
96}
97
98/// Collection purged notification (server → client, 0x14).
99///
100/// Emitted when Origin hard-deletes a collection (retention window
101/// expired after `DROP COLLECTION` or explicit `DROP COLLECTION ... PURGE`).
102/// The receiving Lite client must:
103///
104/// 1. Drop all local Loro CRDT state for the collection.
105/// 2. Remove the collection's redb record.
106/// 3. Terminate any active shape subscriptions or streaming consumers
107///    sourced from the collection.
108/// 4. Fire the `on_collection_purged` client-trait callback.
109///
110/// `purge_lsn` is the Origin WAL LSN at which the hard-delete committed.
111/// Clients persist it so that on reconnect they can replay any purge
112/// events that landed while they were offline by querying
113/// `_system.dropped_collections` / purge event log at LSN > last_seen.
114#[derive(
115    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
116)]
117pub struct CollectionPurgedMsg {
118    /// Numeric tenant ID the collection belonged to.
119    pub tenant_id: u64,
120    /// Database containing the purged collection.
121    pub database_id: DatabaseId,
122    /// Collection name.
123    pub name: String,
124    /// Origin WAL LSN at which the hard-delete was committed.
125    pub purge_lsn: u64,
126}
127
128/// Row-level operation carried by a [`RowPushMsg`].
129#[derive(
130    Debug,
131    Clone,
132    Copy,
133    PartialEq,
134    Eq,
135    Default,
136    Serialize,
137    Deserialize,
138    zerompk::ToMessagePack,
139    zerompk::FromMessagePack,
140)]
141pub enum RowOp {
142    /// The row was created or updated; `payload` is its post-image.
143    #[default]
144    Upsert,
145    /// The row was removed; `payload` is empty.
146    Delete,
147}
148
149/// Row post-image push (server → client, 0x15).
150///
151/// Origin sends this for writes that originated on the server — SQL DML, or
152/// DDL-managed system rows such as retention policies and alerts — where there
153/// is no client-authored CRDT operation to replicate. The peer applies it as a
154/// row upsert or delete against its local state.
155///
156/// Deliberately NOT [`DeltaPushMsg`]: that message is client → server and its
157/// `delta` field is Loro update bytes. Carrying a MessagePack post-image in it
158/// would leave the receiver unable to tell the two encodings apart, and the
159/// operation (upsert vs delete) would have to be inferred from an empty
160/// payload rather than stated.
161#[derive(
162    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
163)]
164pub struct RowPushMsg {
165    /// Collection the row belongs to.
166    pub collection: String,
167    /// Row / document ID.
168    pub document_id: String,
169    /// MessagePack-encoded row post-image. Empty for [`RowOp::Delete`].
170    pub payload: Vec<u8>,
171    /// Whether the row was written or removed.
172    pub op: RowOp,
173    /// Origin-assigned WAL LSN for the write, when known (`0` otherwise).
174    #[serde(default)]
175    pub lsn: u64,
176    /// Originating peer / node ID.
177    #[serde(default)]
178    pub peer_id: u64,
179    /// Per-collection monotonic sequence, for ordering diagnostics.
180    #[serde(default)]
181    pub sequence: u64,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::sync::wire::{SyncFrame, SyncMessageType};
188
189    #[test]
190    fn delta_reject_with_compensation() {
191        let reject = DeltaRejectMsg {
192            mutation_id: 42,
193            reason: "unique violation".into(),
194            compensation: Some(CompensationHint::UniqueViolation {
195                field: "email".into(),
196                conflicting_value: "alice@example.com".into(),
197            }),
198        };
199        let frame = SyncFrame::new_msgpack(SyncMessageType::DeltaReject, &reject).unwrap();
200        let decoded: DeltaRejectMsg = SyncFrame::from_bytes(&frame.to_bytes())
201            .unwrap()
202            .decode_body()
203            .unwrap();
204        assert_eq!(decoded.mutation_id, 42);
205        assert!(matches!(
206            decoded.compensation,
207            Some(CompensationHint::UniqueViolation { .. })
208        ));
209    }
210
211    #[test]
212    fn collection_purged_roundtrip() {
213        let msg = CollectionPurgedMsg {
214            tenant_id: 7,
215            database_id: DatabaseId::new(42),
216            name: "embeddings".into(),
217            purge_lsn: 987_654_321,
218        };
219        let frame = SyncFrame::new_msgpack(SyncMessageType::CollectionPurged, &msg).unwrap();
220        let bytes = frame.to_bytes();
221        assert_eq!(bytes[0], SyncFrame::FORMAT_VERSION);
222        assert_eq!(bytes[1], 0x14);
223        let decoded: CollectionPurgedMsg = SyncFrame::from_bytes(&bytes)
224            .unwrap()
225            .decode_body()
226            .unwrap();
227        assert_eq!(decoded.tenant_id, 7);
228        assert_eq!(decoded.database_id, DatabaseId::new(42));
229        assert_eq!(decoded.name, "embeddings");
230        assert_eq!(decoded.purge_lsn, 987_654_321);
231    }
232}