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}