appcore_sync/sync/
wire.rs1use 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
31impl SyncEnvelopeV1 {
32 pub fn new(source_identity: CoreIdentity, message: SyncMessage) -> SyncResult<Self> {
34 if source_identity.runtime.node_id != message.source_node_id {
35 return Err(SyncError::InvalidSyncMessage(
36 "source identity does not match message node",
37 ));
38 }
39 Ok(Self {
40 schema: SYNC_WIRE_SCHEMA_V1.to_string(),
41 source_identity,
42 message,
43 })
44 }
45
46 pub fn validate_for(&self, local_identity: &CoreIdentity) -> SyncResult<()> {
48 if self.schema != SYNC_WIRE_SCHEMA_V1 {
49 return Err(SyncError::InvalidSyncMessage(
50 "unsupported sync wire schema",
51 ));
52 }
53 if self.source_identity.runtime.node_id != self.message.source_node_id {
54 return Err(SyncError::InvalidSyncMessage(
55 "source identity does not match message node",
56 ));
57 }
58 let policy = CoreCompatibilityPolicy {
59 require_same_cluster: true,
60 required_capability: None,
61 };
62 local_identity
63 .ensure_compatible(&self.source_identity, &policy, &[])
64 .map_err(|_| SyncError::IncompatiblePeer)
65 }
66}
67
68pub fn encode_sync_envelope_v1(
70 source_identity: &CoreIdentity,
71 message: &SyncMessage,
72) -> SyncResult<String> {
73 let envelope = SyncEnvelopeV1::new(source_identity.clone(), message.clone())?;
74 serde_json::to_string(&envelope)
75 .map_err(|_| SyncError::InvalidSyncMessage("sync wire serialization failed"))
76}
77
78pub fn decode_sync_envelope(input: &str) -> SyncResult<SyncEnvelopeV1> {
80 if input.is_empty() {
81 return Err(SyncError::EmptyRequestBody);
82 }
83 if !input.trim_start().starts_with('{') {
84 return Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE));
85 }
86 let envelope = serde_json::from_str::<SyncEnvelopeV1>(input)
87 .map_err(|_| SyncError::InvalidSyncMessage("invalid sync wire envelope"))?;
88 if envelope.schema != SYNC_WIRE_SCHEMA_V1 {
89 return Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE));
90 }
91 if envelope.source_identity.runtime.node_id != envelope.message.source_node_id {
92 return Err(SyncError::InvalidSyncMessage(
93 "source identity does not match message node",
94 ));
95 }
96 Ok(envelope)
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use appcore_core::{
103 AppFamily, AppId, ClusterId, CoreId, CoreKind, InstanceId, NodeId, ProtocolVersion,
104 RuntimeContractVersion, RuntimeIdentity, SyncGroup, TenantId,
105 };
106
107 fn identity(tenant: &str, node: &str) -> CoreIdentity {
108 CoreIdentity {
109 tenant_id: TenantId::new(tenant).unwrap(),
110 cluster_id: ClusterId::new("cluster-a").unwrap(),
111 core_id: CoreId::new(format!("core-{node}")).unwrap(),
112 instance_id: InstanceId::new(format!("instance-{node}")).unwrap(),
113 kind: CoreKind::new("replica").unwrap(),
114 protocol_version: ProtocolVersion::new(1),
115 runtime: RuntimeIdentity {
116 app_id: AppId::new("app-a").unwrap(),
117 app_family: AppFamily::new("family-a").unwrap(),
118 sync_group: SyncGroup::new("cluster-a").unwrap(),
119 runtime_contract: RuntimeContractVersion::new(1),
120 node_id: NodeId::new(node).unwrap(),
121 },
122 }
123 }
124
125 fn message() -> SyncMessage {
126 SyncMessage {
127 batch_id: "batch-1".to_string(),
128 source_node_id: NodeId::new("node-a").unwrap(),
129 sequence_start: 1,
130 sequence_end: 1,
131 event_count: 1,
132 events_hash: "hash".to_string(),
133 created_at_ms: 10,
134 previous_batch_hash: None,
135 events: vec![b"event".to_vec()],
136 }
137 }
138
139 #[test]
140 fn v1_encoding_matches_golden_fixture() {
141 let encoded = encode_sync_envelope_v1(&identity("tenant-a", "node-a"), &message())
142 .expect("v1 envelope");
143
144 assert_eq!(encoded, include_str!("fixtures/sync-wire-v1.json").trim());
145 assert!(decode_sync_envelope(&encoded).is_ok());
146 }
147
148 #[test]
149 fn v1_rejects_source_node_mismatch() {
150 assert!(matches!(
151 SyncEnvelopeV1::new(identity("tenant-a", "node-b"), message()),
152 Err(SyncError::InvalidSyncMessage(_))
153 ));
154 }
155
156 #[test]
157 fn v1_rejects_incompatible_tenant() {
158 let envelope = SyncEnvelopeV1::new(identity("tenant-a", "node-a"), message()).unwrap();
159
160 assert_eq!(
161 envelope.validate_for(&identity("tenant-b", "node-b")),
162 Err(SyncError::IncompatiblePeer)
163 );
164 }
165
166 #[test]
167 fn decoder_rejects_unversioned_wire_with_update_wall() {
168 assert_eq!(
169 decode_sync_envelope("batch-1\nnode-a\n1\n1\n"),
170 Err(SyncError::InvalidSyncMessage(UPDATE_REQUIRED_MESSAGE))
171 );
172 }
173}