1use crate::sync::error::{SyncError, SyncResult, UPDATE_REQUIRED_MESSAGE};
14use crate::sync::types::SyncMessage;
15use appcore_core::{CoreCompatibilityPolicy, CoreIdentity};
16
17pub const SYNC_WIRE_SCHEMA_V1: &str = "appcore.sync.v1";
19
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub struct SyncEnvelopeV1 {
23 pub schema: String,
25 pub source_identity: CoreIdentity,
27 pub message: SyncMessage,
29}
30
31#[derive(serde::Serialize)]
32struct SyncEnvelopeV1Ref<'a> {
33 schema: &'static str,
34 source_identity: &'a CoreIdentity,
35 message: &'a SyncMessage,
36}
37
38impl SyncEnvelopeV1 {
39 pub fn new(source_identity: CoreIdentity, message: SyncMessage) -> SyncResult<Self> {
41 validate_source_binding(&source_identity, &message)?;
42 Ok(Self {
43 schema: SYNC_WIRE_SCHEMA_V1.to_string(),
44 source_identity,
45 message,
46 })
47 }
48
49 pub fn validate_for(&self, local_identity: &CoreIdentity) -> SyncResult<()> {
51 if self.schema != SYNC_WIRE_SCHEMA_V1 {
52 return Err(SyncError::InvalidSyncMessage(
53 "unsupported sync wire schema",
54 ));
55 }
56 if self.source_identity.runtime.node_id != self.message.source_node_id {
57 return Err(SyncError::InvalidSyncMessage(
58 "source identity does not match message node",
59 ));
60 }
61 let policy = CoreCompatibilityPolicy {
62 require_same_cluster: true,
63 required_capability: None,
64 };
65 local_identity
66 .ensure_compatible(&self.source_identity, &policy, &[])
67 .map_err(|_| SyncError::IncompatiblePeer)
68 }
69}
70
71pub fn encode_sync_envelope_v1(
73 source_identity: &CoreIdentity,
74 message: &SyncMessage,
75) -> SyncResult<String> {
76 validate_source_binding(source_identity, message)?;
77 let envelope = SyncEnvelopeV1Ref {
78 schema: SYNC_WIRE_SCHEMA_V1,
79 source_identity,
80 message,
81 };
82 serde_json::to_string(&envelope)
83 .map_err(|_| SyncError::InvalidSyncMessage("sync wire serialization failed"))
84}
85
86fn validate_source_binding(
87 source_identity: &CoreIdentity,
88 message: &SyncMessage,
89) -> SyncResult<()> {
90 if source_identity.runtime.node_id != message.source_node_id {
91 return Err(SyncError::InvalidSyncMessage(
92 "source identity does not match message node",
93 ));
94 }
95 Ok(())
96}
97
98pub fn decode_sync_envelope(input: &str) -> SyncResult<SyncEnvelopeV1> {
100 if input.is_empty() {
101 return Err(SyncError::EmptyRequestBody);
102 }
103 if !input.trim_start().starts_with('{') {
104 return Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE));
105 }
106 let envelope = serde_json::from_str::<SyncEnvelopeV1>(input)
107 .map_err(|_| SyncError::InvalidSyncMessage("invalid sync wire envelope"))?;
108 if envelope.schema != SYNC_WIRE_SCHEMA_V1 {
109 return Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE));
110 }
111 if envelope.source_identity.runtime.node_id != envelope.message.source_node_id {
112 return Err(SyncError::InvalidSyncMessage(
113 "source identity does not match message node",
114 ));
115 }
116 Ok(envelope)
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::sync::{MAX_SYNC_BATCH_PAYLOAD_BYTES, MAX_SYNC_REQUEST_BODY_BYTES};
123 use appcore_core::{
124 AppFamily, AppId, ClusterId, CoreId, CoreKind, InstanceId, NodeId, ProtocolVersion,
125 RuntimeContractVersion, RuntimeIdentity, SyncGroup, TenantId,
126 };
127
128 fn identity(tenant: &str, node: &str) -> CoreIdentity {
129 CoreIdentity {
130 tenant_id: TenantId::new(tenant).unwrap(),
131 cluster_id: ClusterId::new("cluster-a").unwrap(),
132 core_id: CoreId::new(format!("core-{node}")).unwrap(),
133 instance_id: InstanceId::new(format!("instance-{node}")).unwrap(),
134 kind: CoreKind::new("replica").unwrap(),
135 protocol_version: ProtocolVersion::new(1),
136 runtime: RuntimeIdentity {
137 app_id: AppId::new("app-a").unwrap(),
138 app_family: AppFamily::new("family-a").unwrap(),
139 sync_group: SyncGroup::new("cluster-a").unwrap(),
140 runtime_contract: RuntimeContractVersion::new(1),
141 node_id: NodeId::new(node).unwrap(),
142 },
143 }
144 }
145
146 fn message() -> SyncMessage {
147 SyncMessage {
148 batch_id: "batch-1".to_string(),
149 source_node_id: NodeId::new("node-a").unwrap(),
150 sequence_start: 1,
151 sequence_end: 1,
152 event_count: 1,
153 events_hash: "hash".to_string(),
154 created_at_ms: 10,
155 previous_batch_hash: None,
156 events: vec![b"event".to_vec()],
157 }
158 }
159
160 #[test]
161 fn v1_encoding_matches_golden_fixture() {
162 let encoded = encode_sync_envelope_v1(&identity("tenant-a", "node-a"), &message())
163 .expect("v1 envelope");
164
165 assert_eq!(encoded, include_str!("fixtures/sync-wire-v1.json").trim());
166 assert!(decode_sync_envelope(&encoded).is_ok());
167 }
168
169 #[test]
170 fn maximum_raw_batch_fits_the_bounded_http_envelope() {
171 let message = SyncMessage::new(
172 "batch-max".to_string(),
173 NodeId::new("node-a").unwrap(),
174 1,
175 1,
176 10,
177 None,
178 vec![vec![u8::MAX; MAX_SYNC_BATCH_PAYLOAD_BYTES]],
179 );
180 let encoded = encode_sync_envelope_v1(&identity("tenant-a", "node-a"), &message).unwrap();
181
182 assert!(encoded.len() <= MAX_SYNC_REQUEST_BODY_BYTES);
183 }
184
185 #[test]
186 fn borrowed_v1_encoding_matches_the_owned_contract() {
187 let source_identity = identity("tenant-a", "node-a");
188 let mut message = message();
189 message.events = vec!["Olá 日本語 العربية".as_bytes().to_vec(), vec![0, 1, 255]];
190 message.event_count = 2;
191 message.sequence_end = 2;
192 message.previous_batch_hash = Some("previous-hash".to_string());
193 let owned = SyncEnvelopeV1::new(source_identity.clone(), message.clone()).unwrap();
194
195 assert_eq!(
196 encode_sync_envelope_v1(&source_identity, &message).unwrap(),
197 serde_json::to_string(&owned).unwrap()
198 );
199 }
200
201 #[test]
202 fn v1_rejects_source_node_mismatch() {
203 assert!(matches!(
204 SyncEnvelopeV1::new(identity("tenant-a", "node-b"), message()),
205 Err(SyncError::InvalidSyncMessage(_))
206 ));
207 assert!(matches!(
208 encode_sync_envelope_v1(&identity("tenant-a", "node-b"), &message()),
209 Err(SyncError::InvalidSyncMessage(_))
210 ));
211 }
212
213 #[test]
214 fn v1_rejects_incompatible_tenant() {
215 let envelope = SyncEnvelopeV1::new(identity("tenant-a", "node-a"), message()).unwrap();
216
217 assert_eq!(
218 envelope.validate_for(&identity("tenant-b", "node-b")),
219 Err(SyncError::IncompatiblePeer)
220 );
221 }
222
223 #[test]
224 fn decoder_rejects_unversioned_wire_with_update_wall() {
225 assert_eq!(
226 decode_sync_envelope("batch-1\nnode-a\n1\n1\n"),
227 Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE))
228 );
229 }
230}