1use serde::de::DeserializeOwned;
7use serde::ser::SerializeMap;
8use serde::{Serialize, Serializer};
9use serde_json::{json, Map, Value};
10use ssp2::decode::WIRE_VERSION;
11use ssp2::model::{Frame, Message, MsgKind, Op, OpResult, Operation, PushResultDetail, PushStatus};
12use ssp2::{decode_message, encode_message};
13
14use crate::api::{Mutation, RejectionDetails};
15use crate::schema::ClientSchema;
16use crate::transport::{Transport, TransportError};
17use crate::values::{
18 encode_sparse_row_json, full_row_values, normalize_values_casing, render_row_id_json,
19 EncryptionConfig,
20};
21
22#[derive(Debug, Clone)]
23pub struct RemoteCommitInput {
24 pub request_id: String,
25 pub mutations: Vec<Mutation>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PreparedRemoteCommit {
30 pub request_id: String,
31 pub bytes: Vec<u8>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct RemoteCommitResult {
36 pub request_id: String,
37 pub status: PushStatus,
38 pub commit_seq: Option<i64>,
39 pub results: Vec<OpResult>,
40 pub details: Vec<PushResultDetail>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct RemoteQueryResult<Row> {
45 pub rows: Vec<Row>,
46 pub max_commit_seq: i64,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RemoteBytes(pub Vec<u8>);
54
55impl Serialize for RemoteBytes {
56 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
57 where
58 S: Serializer,
59 {
60 let mut map = serializer.serialize_map(Some(1))?;
61 map.serialize_entry("$syncular.bytes", &base64_encode(&self.0))?;
62 map.end()
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct RemoteCommandResult {
68 pub request_id: String,
69 pub status: String,
70 pub commit_seq: Option<i64>,
71 pub results: Vec<Value>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct RemoteClientError {
76 pub code: String,
77 pub message: String,
78 pub retryable: bool,
79 pub details: Option<Box<RejectionDetails>>,
84}
85
86impl From<TransportError> for RemoteClientError {
91 fn from(error: TransportError) -> Self {
92 let retryable = error
93 .details
94 .as_ref()
95 .and_then(|details| details.get("retryable"))
96 .and_then(Value::as_bool)
97 .unwrap_or_else(|| {
98 error.code.starts_with("transport.") || error.code == "sync.transport_failed"
99 });
100 Self::new(error.code, error.message, retryable)
101 }
102}
103
104impl RemoteClientError {
105 fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
106 Self {
107 code: code.into(),
108 message: message.into(),
109 retryable,
110 details: None,
111 }
112 }
113
114 fn invalid(message: impl Into<String>) -> Self {
115 let message = message.into();
118 let message = message
119 .strip_prefix("sync.invalid_request: ")
120 .unwrap_or(&message)
121 .to_owned();
122 Self::new("sync.invalid_request", message, false)
123 }
124
125 fn invalid_response(message: impl Into<String>) -> Self {
126 Self::new("client.invalid_host_response", message, false)
127 }
128}
129
130impl std::fmt::Display for RemoteClientError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 write!(f, "{}: {}", self.code, self.message)
133 }
134}
135
136impl std::error::Error for RemoteClientError {}
137
138pub struct SyncRemoteClient {
139 schema: Option<ClientSchema>,
140 client_id: String,
141 log_epoch: Option<String>,
142 encryption: EncryptionConfig,
143}
144
145impl SyncRemoteClient {
146 pub fn new(
147 schema: ClientSchema,
148 client_id: impl Into<String>,
149 ) -> Result<Self, RemoteClientError> {
150 let mut client = Self::for_operations(client_id)?;
151 client.schema = Some(schema);
152 Ok(client)
153 }
154
155 pub fn for_operations(client_id: impl Into<String>) -> Result<Self, RemoteClientError> {
156 let client_id = client_id.into();
157 if client_id.is_empty() {
158 return Err(RemoteClientError::invalid(
159 "SyncRemoteClient clientId must be non-empty",
160 ));
161 }
162 Ok(Self {
163 schema: None,
164 client_id,
165 log_epoch: None,
166 encryption: EncryptionConfig::default(),
167 })
168 }
169
170 pub fn with_encryption(mut self, encryption: EncryptionConfig) -> Self {
171 self.encryption = encryption;
172 self
173 }
174
175 pub fn with_log_epoch(
177 mut self,
178 log_epoch: impl Into<String>,
179 ) -> Result<Self, RemoteClientError> {
180 let log_epoch = log_epoch.into();
181 if log_epoch.is_empty() {
182 return Err(RemoteClientError::invalid("logEpoch must be non-empty"));
183 }
184 self.log_epoch = Some(log_epoch);
185 Ok(self)
186 }
187
188 pub fn prepare_commit(
189 &self,
190 input: RemoteCommitInput,
191 ) -> Result<PreparedRemoteCommit, RemoteClientError> {
192 if input.request_id.is_empty() {
193 return Err(RemoteClientError::invalid(
194 "remote commit requestId must be non-empty",
195 ));
196 }
197 if input.mutations.is_empty() {
198 return Err(RemoteClientError::new(
199 "sync.empty_commit",
200 "a remote commit must carry at least one mutation (§6.1)",
201 false,
202 ));
203 }
204 let schema = self.schema.as_ref().ok_or_else(|| {
205 RemoteClientError::new(
206 "client.remote_schema_unconfigured",
207 "SyncRemoteClient needs a schema to prepare ordinary commits",
208 false,
209 )
210 })?;
211 let mut operations = Vec::with_capacity(input.mutations.len());
212 for mutation in input.mutations {
213 let partial = matches!(&mutation, Mutation::Patch { .. });
214 match mutation {
215 Mutation::Patch {
216 table,
217 values,
218 base_version,
219 }
220 | Mutation::Upsert {
221 table,
222 values,
223 base_version,
224 } => {
225 let schema_table = schema.table(&table).ok_or_else(|| {
226 RemoteClientError::invalid("remote commit targets an unknown table")
227 })?;
228 let values: Map<String, serde_json::Value> =
229 normalize_values_casing(schema_table, values)
230 .map_err(RemoteClientError::invalid)?;
231 let row_id = render_row_id_json(values.get(&schema_table.primary_key))
232 .map_err(RemoteClientError::invalid)?;
233 let values = if partial {
236 values
237 } else {
238 full_row_values(schema_table, values).map_err(RemoteClientError::invalid)?
239 };
240 let payload = encode_sparse_row_json(
241 schema_table,
242 &row_id,
243 &values,
244 &self.encryption,
245 None,
246 )
247 .map_err(RemoteClientError::invalid)?;
248 operations.push(Operation {
249 table,
250 row_id,
251 op: Op::Upsert,
252 base_version,
253 payload: Some(payload),
254 });
255 }
256 Mutation::Delete {
257 table,
258 row_id,
259 base_version,
260 } => {
261 if schema.table(&table).is_none() {
262 return Err(RemoteClientError::invalid(
263 "remote commit targets an unknown table",
264 ));
265 }
266 if row_id.is_empty() {
267 return Err(RemoteClientError::invalid(
268 "remote delete rowId must be non-empty",
269 ));
270 }
271 operations.push(Operation {
272 table,
273 row_id,
274 op: Op::Delete,
275 base_version,
276 payload: None,
277 });
278 }
279 }
280 }
281 let bytes = encode_message(&Message {
282 wire_version: WIRE_VERSION,
283 msg_kind: MsgKind::Request,
284 frames: vec![
285 Frame::ReqHeader {
286 client_id: self.client_id.clone(),
287 schema_version: schema.version,
288 log_epoch: self.log_epoch.clone(),
289 },
290 Frame::PushCommit {
291 client_commit_id: input.request_id.clone(),
292 operations,
293 },
294 ],
295 });
296 Ok(PreparedRemoteCommit {
297 request_id: input.request_id,
298 bytes,
299 })
300 }
301
302 pub fn send_commit<T: Transport>(
303 &self,
304 transport: &mut T,
305 prepared: &PreparedRemoteCommit,
306 ) -> Result<RemoteCommitResult, RemoteClientError> {
307 let bytes = transport
308 .sync(&prepared.bytes)
309 .map_err(RemoteClientError::from)?;
310 let response = decode_message(&bytes).map_err(|_| {
311 RemoteClientError::invalid_response("remote commit response is not valid SSP2")
312 })?;
313 if response.msg_kind != MsgKind::Response {
314 return Err(RemoteClientError::invalid_response(
315 "remote commit transport returned a non-response SSP2 message",
316 ));
317 }
318 for frame in &response.frames {
319 if let Frame::Error {
320 code,
321 message,
322 retryable,
323 ..
324 } = frame
325 {
326 return Err(RemoteClientError::new(code, message, *retryable));
327 }
328 }
329 let mut matched = None;
330 let mut details = Vec::new();
331 for frame in response.frames {
332 match frame {
333 Frame::PushResult {
334 client_commit_id,
335 status,
336 commit_seq,
337 results,
338 } if client_commit_id == prepared.request_id => {
339 matched = Some((status, commit_seq, results));
340 }
341 Frame::PushResultDetails {
342 client_commit_id,
343 entries,
344 } if client_commit_id == prepared.request_id => {
345 details = entries;
346 }
347 _ => {}
348 }
349 }
350 let Some((status, commit_seq, results)) = matched else {
351 return Err(RemoteClientError::invalid_response(
352 "remote commit response carried no matching PUSH_RESULT",
353 ));
354 };
355 Ok(RemoteCommitResult {
356 request_id: prepared.request_id.clone(),
357 status,
358 commit_seq,
359 results,
360 details,
361 })
362 }
363
364 pub fn commit<T: Transport>(
365 &self,
366 transport: &mut T,
367 input: RemoteCommitInput,
368 ) -> Result<RemoteCommitResult, RemoteClientError> {
369 let prepared = self.prepare_commit(input)?;
370 self.send_commit(transport, &prepared)
371 }
372
373 pub fn query<T, Params, Row>(
374 &self,
375 transport: &mut T,
376 operation_id: &str,
377 params: &Params,
378 ) -> Result<RemoteQueryResult<Row>, RemoteClientError>
379 where
380 T: Transport,
381 Params: Serialize,
382 Row: DeserializeOwned,
383 {
384 if operation_id.is_empty() {
385 return Err(RemoteClientError::invalid(
386 "remote query id must be non-empty",
387 ));
388 }
389 let request = json!({
390 "revision": protocol_revision(),
391 "kind": "query",
392 "clientId": self.client_id,
393 "operationId": operation_id,
394 "params": serde_json::to_value(params)
395 .map_err(|error| RemoteClientError::invalid(error.to_string()))?,
396 });
397 let response = transport
398 .remote_operation(&encode_operation_value(&request)?)
399 .map_err(RemoteClientError::from)?;
400 let decoded = decode_operation_value(&response)?;
401 validate_operation_revision(&decoded)?;
402 if decoded.get("kind").and_then(Value::as_str) == Some("error") {
403 return Err(operation_error(&decoded)?);
404 }
405 if decoded.get("kind").and_then(Value::as_str) != Some("query")
406 || decoded.get("operationId").and_then(Value::as_str) != Some(operation_id)
407 {
408 return Err(RemoteClientError::invalid_response(
409 "remote query returned a mismatched response",
410 ));
411 }
412 let rows = serde_json::from_value(decoded.get("rows").cloned().unwrap_or(Value::Null))
413 .map_err(|error| RemoteClientError::invalid_response(error.to_string()))?;
414 let max_commit_seq = decoded
415 .get("maxCommitSeq")
416 .and_then(Value::as_i64)
417 .filter(|sequence| (0..=9_007_199_254_740_991).contains(sequence))
418 .ok_or_else(|| {
419 RemoteClientError::invalid_response(
420 "remote query response has invalid maxCommitSeq",
421 )
422 })?;
423 Ok(RemoteQueryResult {
424 rows,
425 max_commit_seq,
426 })
427 }
428
429 pub fn command<T, Input>(
430 &self,
431 transport: &mut T,
432 operation_id: &str,
433 request_id: &str,
434 input: &Input,
435 ) -> Result<RemoteCommandResult, RemoteClientError>
436 where
437 T: Transport,
438 Input: Serialize,
439 {
440 if operation_id.is_empty() || request_id.is_empty() {
441 return Err(RemoteClientError::invalid(
442 "remote command id and requestId must be non-empty",
443 ));
444 }
445 let request = json!({
446 "revision": protocol_revision(),
447 "kind": "command",
448 "clientId": self.client_id,
449 "operationId": operation_id,
450 "requestId": request_id,
451 "params": serde_json::to_value(input)
452 .map_err(|error| RemoteClientError::invalid(error.to_string()))?,
453 });
454 let response = transport
455 .remote_operation(&encode_operation_value(&request)?)
456 .map_err(RemoteClientError::from)?;
457 let decoded = decode_operation_value(&response)?;
458 validate_operation_revision(&decoded)?;
459 if decoded.get("kind").and_then(Value::as_str) == Some("error") {
460 return Err(operation_error(&decoded)?);
461 }
462 if decoded.get("kind").and_then(Value::as_str) != Some("command")
463 || decoded.get("operationId").and_then(Value::as_str) != Some(operation_id)
464 || decoded.get("requestId").and_then(Value::as_str) != Some(request_id)
465 {
466 return Err(RemoteClientError::invalid_response(
467 "remote command returned a mismatched response",
468 ));
469 }
470 let status = decoded
471 .get("status")
472 .and_then(Value::as_str)
473 .filter(|status| matches!(*status, "applied" | "cached" | "rejected"))
474 .ok_or_else(|| {
475 RemoteClientError::invalid_response("remote command response has invalid status")
476 })?;
477 let commit_seq = match decoded.get("commitSeq") {
478 Some(value) => Some(
479 value
480 .as_i64()
481 .filter(|sequence| (1..=9_007_199_254_740_991).contains(sequence))
482 .ok_or_else(|| {
483 RemoteClientError::invalid_response(
484 "remote command response has invalid commitSeq",
485 )
486 })?,
487 ),
488 None => None,
489 };
490 Ok(RemoteCommandResult {
491 request_id: request_id.to_owned(),
492 status: status.to_owned(),
493 commit_seq,
494 results: decoded
495 .get("results")
496 .and_then(Value::as_array)
497 .cloned()
498 .ok_or_else(|| {
499 RemoteClientError::invalid_response("remote command response lacks results")
500 })?,
501 })
502 }
503}
504
505fn protocol_revision() -> Value {
506 Value::Number(serde_json::Number::from_f64(1.0).expect("1.0 is finite"))
507}
508
509fn encode_tagged(value: &Value) -> Result<Value, RemoteClientError> {
510 Ok(match value {
511 Value::Null => json!({ "t": "null" }),
512 Value::Bool(value) => json!({ "t": "boolean", "v": value }),
513 Value::Number(value) if value.is_i64() || value.is_u64() => {
517 let integer = value.as_i64().or_else(|| {
518 value
519 .as_u64()
520 .filter(|integer| *integer <= i64::MAX as u64)
521 .map(|integer| integer as i64)
522 });
523 match integer {
524 Some(integer)
525 if (-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&integer) =>
526 {
527 json!({ "t": "number", "v": integer })
528 }
529 Some(_) => json!({ "t": "integer", "v": value.to_string() }),
530 None => {
531 return Err(RemoteClientError::invalid(
532 "remote operation integer exceeds signed 64-bit range",
533 ))
534 }
535 }
536 }
537 Value::Number(value) => json!({ "t": "number", "v": value }),
538 Value::String(value) => json!({ "t": "string", "v": value }),
539 Value::Array(values) => json!({
540 "t": "array",
541 "v": values.iter().map(encode_tagged).collect::<Result<Vec<_>, _>>()?,
542 }),
543 Value::Object(values)
544 if values.len() == 1
545 && values
546 .get("$syncular.bytes")
547 .and_then(Value::as_str)
548 .is_some() =>
549 {
550 json!({
551 "t": "bytes",
552 "v": values
553 .get("$syncular.bytes")
554 .and_then(Value::as_str)
555 .expect("guarded above"),
556 })
557 }
558 Value::Object(values) => json!({
559 "t": "object",
560 "v": values
561 .iter()
562 .map(|(key, value)| Ok(json!([key, encode_tagged(value)?])))
563 .collect::<Result<Vec<Value>, RemoteClientError>>()?,
564 }),
565 })
566}
567
568fn encode_operation_value(value: &Value) -> Result<Vec<u8>, RemoteClientError> {
569 serde_json::to_vec(&encode_tagged(value)?)
570 .map_err(|error| RemoteClientError::invalid(error.to_string()))
571}
572
573fn decode_tagged(value: &Value) -> Result<Value, RemoteClientError> {
574 let tag = value
575 .get("t")
576 .and_then(Value::as_str)
577 .ok_or_else(|| RemoteClientError::invalid_response("remote operation value lacks a tag"))?;
578 match tag {
579 "null" => Ok(Value::Null),
580 "boolean" => value
581 .get("v")
582 .filter(|value| value.is_boolean())
583 .cloned()
584 .ok_or_else(|| RemoteClientError::invalid_response("remote boolean value is invalid")),
585 "number" => value
586 .get("v")
587 .filter(|value| value.is_number())
588 .cloned()
589 .ok_or_else(|| RemoteClientError::invalid_response("remote number value is invalid")),
590 "string" => value
591 .get("v")
592 .filter(|value| value.is_string())
593 .cloned()
594 .ok_or_else(|| RemoteClientError::invalid_response("remote string value is invalid")),
595 "integer" => {
596 let raw = value.get("v").and_then(Value::as_str).ok_or_else(|| {
597 RemoteClientError::invalid_response("remote integer value is invalid")
598 })?;
599 let number = raw
600 .parse::<i64>()
601 .map_err(|_| RemoteClientError::invalid_response("remote integer exceeds i64"))?;
602 if number.to_string() != raw {
603 return Err(RemoteClientError::invalid_response(
604 "remote integer value is not canonical",
605 ));
606 }
607 Ok(json!(number))
608 }
609 "bytes" => {
610 let raw = value.get("v").and_then(Value::as_str).ok_or_else(|| {
611 RemoteClientError::invalid_response("remote bytes value is invalid")
612 })?;
613 Ok(Value::Array(
614 base64_decode(raw)?
615 .into_iter()
616 .map(|byte| json!(byte))
617 .collect(),
618 ))
619 }
620 "array" => {
621 let values = value.get("v").and_then(Value::as_array).ok_or_else(|| {
622 RemoteClientError::invalid_response("remote array value is invalid")
623 })?;
624 Ok(Value::Array(
625 values
626 .iter()
627 .map(decode_tagged)
628 .collect::<Result<Vec<_>, _>>()?,
629 ))
630 }
631 "object" => {
632 let entries = value.get("v").and_then(Value::as_array).ok_or_else(|| {
633 RemoteClientError::invalid_response("remote object value is invalid")
634 })?;
635 let mut object = Map::new();
636 for entry in entries {
637 let pair = entry.as_array().ok_or_else(|| {
638 RemoteClientError::invalid_response("remote object entry is invalid")
639 })?;
640 if pair.len() != 2 {
641 return Err(RemoteClientError::invalid_response(
642 "remote object entry is invalid",
643 ));
644 }
645 let key = pair.first().and_then(Value::as_str).ok_or_else(|| {
646 RemoteClientError::invalid_response("remote object key is invalid")
647 })?;
648 let encoded = pair.get(1).ok_or_else(|| {
649 RemoteClientError::invalid_response("remote object entry lacks a value")
650 })?;
651 if object
652 .insert(key.to_owned(), decode_tagged(encoded)?)
653 .is_some()
654 {
655 return Err(RemoteClientError::invalid_response(
656 "remote object contains a duplicate key",
657 ));
658 }
659 }
660 Ok(Value::Object(object))
661 }
662 _ => Err(RemoteClientError::invalid_response(
663 "remote operation value has an unknown tag",
664 )),
665 }
666}
667
668fn decode_operation_value(bytes: &[u8]) -> Result<Value, RemoteClientError> {
669 let encoded: Value = serde_json::from_slice(bytes)
670 .map_err(|error| RemoteClientError::invalid_response(error.to_string()))?;
671 decode_tagged(&encoded)
672}
673
674fn validate_operation_revision(value: &Value) -> Result<(), RemoteClientError> {
675 if value.get("revision").and_then(Value::as_f64) == Some(1.0) {
676 Ok(())
677 } else {
678 Err(RemoteClientError::invalid_response(
679 "remote operation response has an unsupported revision",
680 ))
681 }
682}
683
684fn operation_error(value: &Value) -> Result<RemoteClientError, RemoteClientError> {
685 let code = value.get("code").and_then(Value::as_str).ok_or_else(|| {
686 RemoteClientError::invalid_response("remote operation error lacks a code")
687 })?;
688 let message = value
689 .get("message")
690 .and_then(Value::as_str)
691 .ok_or_else(|| {
692 RemoteClientError::invalid_response("remote operation error lacks a message")
693 })?;
694 let retryable = value
695 .get("retryable")
696 .and_then(Value::as_bool)
697 .ok_or_else(|| {
698 RemoteClientError::invalid_response("remote operation error lacks retryable")
699 })?;
700 let mut error = RemoteClientError::new(code, message, retryable);
701 if let Some(details) = value.get("details") {
702 error.details = Some(Box::new(
703 RejectionDetails::parse(&details.to_string()).map_err(|_| {
704 RemoteClientError::invalid_response("remote operation error details are malformed")
705 })?,
706 ));
707 }
708 Ok(error)
709}
710
711fn base64_encode(bytes: &[u8]) -> String {
712 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
713 let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
714 for chunk in bytes.chunks(3) {
715 let first = chunk[0];
716 let second = chunk.get(1).copied().unwrap_or(0);
717 let third = chunk.get(2).copied().unwrap_or(0);
718 output.push(ALPHABET[(first >> 2) as usize] as char);
719 output.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char);
720 output.push(if chunk.len() > 1 {
721 ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char
722 } else {
723 '='
724 });
725 output.push(if chunk.len() > 2 {
726 ALPHABET[(third & 0x3f) as usize] as char
727 } else {
728 '='
729 });
730 }
731 output
732}
733
734fn base64_decode(text: &str) -> Result<Vec<u8>, RemoteClientError> {
735 if !text.len().is_multiple_of(4) {
736 return Err(RemoteClientError::invalid_response(
737 "remote bytes value is invalid base64",
738 ));
739 }
740 let mut output = Vec::with_capacity((text.len() / 4) * 3);
741 for (chunk_index, chunk) in text.as_bytes().chunks(4).enumerate() {
742 let last = chunk_index + 1 == text.len() / 4;
743 let padding = if chunk[2] == b'=' {
744 2
745 } else if chunk[3] == b'=' {
746 1
747 } else {
748 0
749 };
750 if (!last && padding != 0) || (padding == 2 && chunk[3] != b'=') {
751 return Err(RemoteClientError::invalid_response(
752 "remote bytes value is invalid base64",
753 ));
754 }
755 let mut values = [0_u8; 4];
756 for (index, byte) in chunk.iter().copied().enumerate() {
757 values[index] = match byte {
758 b'A'..=b'Z' => byte - b'A',
759 b'a'..=b'z' => byte - b'a' + 26,
760 b'0'..=b'9' => byte - b'0' + 52,
761 b'+' => 62,
762 b'/' => 63,
763 b'=' if index >= 2 => 0,
764 _ => {
765 return Err(RemoteClientError::invalid_response(
766 "remote bytes value is invalid base64",
767 ))
768 }
769 };
770 }
771 output.push((values[0] << 2) | (values[1] >> 4));
772 if padding < 2 {
773 output.push((values[1] << 4) | (values[2] >> 2));
774 }
775 if padding == 0 {
776 output.push((values[2] << 6) | values[3]);
777 }
778 }
779 if base64_encode(&output) != text {
780 return Err(RemoteClientError::invalid_response(
781 "remote bytes value is invalid base64",
782 ));
783 }
784 Ok(output)
785}
786
787#[cfg(test)]
788mod tests {
789 use serde::{Deserialize, Serialize};
790 use serde_json::{json, Map, Value};
791 use ssp2::model::{Frame, Message, MsgKind, Op, OpResult, PushStatus};
792 use ssp2::{decode_message, encode_message};
793
794 use super::{
795 decode_operation_value, encode_operation_value, RemoteBytes, RemoteClientError,
796 RemoteCommitInput, SyncRemoteClient,
797 };
798 use crate::api::{Mutation, RejectionDetails};
799 use crate::schema::{compile_schema, ColumnIr, SchemaIr, ScopePatternIr, TableIr};
800 use crate::transport::{SegmentRequest, Transport, TransportError};
801
802 #[test]
803 fn transport_failures_take_retryable_from_details_then_from_the_code() {
804 let classify = |code: &str, details: Option<Value>| {
805 let mut error = TransportError::new(code, "failed");
806 error.details = details;
807 RemoteClientError::from(error).retryable
808 };
809 assert!(classify("transport.timeout", None));
810 assert!(classify(
811 "transport.failed",
812 Some(json!({ "causeKind": "io" }))
813 ));
814 assert!(classify("sync.transport_failed", None));
815 assert!(!classify("client.local_authority_unconfigured", None));
816 assert!(!classify("sync.forbidden", None));
817 assert!(!classify(
818 "sync.rate_limited",
819 Some(json!({ "retryable": false, "causeKind": "status" }))
820 ));
821 assert!(classify(
822 "sync.storage_unavailable",
823 Some(json!({ "retryable": true, "causeKind": "status" }))
824 ));
825 assert!(!classify(
826 "transport.failed",
827 Some(json!({ "retryable": false }))
828 ));
829 }
830
831 struct CachedTransport {
832 requests: Vec<Vec<u8>>,
833 operation_response: Option<Vec<u8>>,
834 }
835
836 impl Transport for CachedTransport {
837 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
838 self.requests.push(request.to_vec());
839 Ok(encode_message(&Message {
840 wire_version: ssp2::decode::WIRE_VERSION,
841 msg_kind: MsgKind::Response,
842 frames: vec![
843 Frame::RespHeader {
844 required_schema_version: None,
845 latest_schema_version: None,
846 log_epoch: "epoch-1".to_owned(),
847 reset_required: false,
848 },
849 Frame::PushResult {
850 client_commit_id: "job-1".to_owned(),
851 status: PushStatus::Cached,
852 commit_seq: Some(7),
853 results: vec![OpResult::Applied { op_index: 0 }],
854 },
855 ],
856 }))
857 }
858
859 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
860 self.requests.push(request.to_vec());
861 self.operation_response
862 .clone()
863 .ok_or_else(|| TransportError::new("unused", "unused"))
864 }
865
866 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
867 Err(TransportError::new("unused", "unused"))
868 }
869
870 fn download_segment(
871 &mut self,
872 _request: &SegmentRequest,
873 _on_progress: &mut dyn FnMut(u64),
874 ) -> Result<Vec<u8>, TransportError> {
875 Err(TransportError::new("unused", "unused"))
876 }
877
878 fn realtime_connect(&mut self) -> Result<(), TransportError> {
879 Err(TransportError::new("unused", "unused"))
880 }
881
882 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
883 Err(TransportError::new("unused", "unused"))
884 }
885
886 fn realtime_close(&mut self) -> Result<(), TransportError> {
887 Ok(())
888 }
889 }
890
891 fn schema() -> crate::schema::ClientSchema {
892 compile_schema(&SchemaIr {
893 version: 1,
894 tables: vec![TableIr {
895 name: "tasks".to_owned(),
896 columns: vec![
897 ColumnIr {
898 name: "id".to_owned(),
899 column_type: "string".to_owned(),
900 nullable: false,
901 encrypted: false,
902 declared_type: None,
903 },
904 ColumnIr {
905 name: "project_id".to_owned(),
906 column_type: "string".to_owned(),
907 nullable: false,
908 encrypted: false,
909 declared_type: None,
910 },
911 ],
912 primary_key: "id".to_owned(),
913 scopes: vec![ScopePatternIr {
914 pattern: "project:{project_id}".to_owned(),
915 column: None,
916 }],
917 indexes: Vec::new(),
918 fts_indexes: Vec::new(),
919 }],
920 })
921 .expect("schema compiles")
922 }
923
924 #[test]
925 fn prepares_push_only_bytes_and_reuses_them_for_retry() {
926 let client = SyncRemoteClient::new(schema(), "worker").expect("client");
927 let prepared = client
928 .prepare_commit(RemoteCommitInput {
929 request_id: "job-1".to_owned(),
930 mutations: vec![Mutation::Upsert {
931 table: "tasks".to_owned(),
932 values: Map::from_iter([
933 ("id".to_owned(), json!("task-1")),
934 ("projectId".to_owned(), json!("project-1")),
935 ]),
936 base_version: None,
937 }],
938 })
939 .expect("prepare");
940 let request = decode_message(&prepared.bytes).expect("request decodes");
941 assert!(matches!(
942 request.frames.as_slice(),
943 [
944 Frame::ReqHeader { .. },
945 Frame::PushCommit { operations, .. }
946 ] if operations.len() == 1 && operations[0].op == Op::Upsert
947 ));
948
949 let mut transport = CachedTransport {
950 requests: Vec::new(),
951 operation_response: None,
952 };
953 let first = client
954 .send_commit(&mut transport, &prepared)
955 .expect("first response");
956 let second = client
957 .send_commit(&mut transport, &prepared)
958 .expect("retry response");
959 assert_eq!(first.status, PushStatus::Cached);
960 assert_eq!(second.commit_seq, Some(7));
961 assert_eq!(
962 transport.requests,
963 vec![prepared.bytes.clone(), prepared.bytes]
964 );
965 }
966
967 #[test]
968 fn rejects_caller_value_failures_with_one_code_identity() {
969 let client = SyncRemoteClient::new(schema(), "worker").expect("client");
970 let prepare = |values: Map<String, Value>| {
971 client.prepare_commit(RemoteCommitInput {
972 request_id: "job-invalid".to_owned(),
973 mutations: vec![Mutation::Upsert {
974 table: "tasks".to_owned(),
975 values,
976 base_version: None,
977 }],
978 })
979 };
980
981 let unknown = prepare(Map::from_iter([
982 ("id".to_owned(), json!("task-1")),
983 ("project_id".to_owned(), json!("project-1")),
984 ("nope".to_owned(), json!(1)),
985 ]))
986 .expect_err("unknown column");
987 assert_eq!(unknown.code, "sync.invalid_request");
988 assert_eq!(
991 unknown.message,
992 "table \"tasks\": unknown column \"nope\" in mutation values (snake_case and camelCase keys are accepted)"
993 );
994
995 let wrong_type = prepare(Map::from_iter([
996 ("id".to_owned(), json!("task-1")),
997 ("project_id".to_owned(), json!(7)),
998 ]))
999 .expect_err("wrong value type");
1000 assert_eq!(wrong_type.code, "sync.invalid_request");
1001 assert_eq!(
1002 wrong_type.message,
1003 "column \"project_id\": expected a string, got 7"
1004 );
1005 }
1006
1007 #[test]
1008 fn command_error_carries_rejection_details() {
1009 let client = SyncRemoteClient::new(schema(), "worker").expect("client");
1010 let respond = |error: Value| {
1011 let mut response = json!({
1012 "revision": super::protocol_revision(),
1013 "kind": "error",
1014 "code": "campaign.not_gm",
1015 "message": "only the game master may do this",
1016 "retryable": false,
1017 });
1018 response
1019 .as_object_mut()
1020 .expect("object")
1021 .extend(error.as_object().expect("object").clone());
1022 let mut transport = CachedTransport {
1023 requests: Vec::new(),
1024 operation_response: Some(encode_operation_value(&response).expect("response")),
1025 };
1026 client
1027 .command(&mut transport, "campaign/roll", "request-1", &json!({}))
1028 .expect_err("command is refused")
1029 };
1030
1031 let with_details = respond(json!({ "details": { "reason": "gm_only" } }));
1032 assert_eq!(with_details.code, "campaign.not_gm");
1033 assert_eq!(with_details.message, "only the game master may do this");
1034 assert!(!with_details.retryable);
1035 assert_eq!(
1036 with_details.details,
1037 Some(Box::new(RejectionDetails {
1038 field_paths: None,
1039 reason: Some("gm_only".to_owned()),
1040 required_action: None,
1041 references: None,
1042 }))
1043 );
1044
1045 let without_details = respond(json!({}));
1046 assert_eq!(without_details.code, "campaign.not_gm");
1047 assert_eq!(without_details.details, None);
1048
1049 let malformed = respond(json!({ "details": { "reason": "Not A Token" } }));
1050 assert_eq!(malformed.code, "client.invalid_host_response");
1051 assert_eq!(
1052 malformed.message,
1053 "remote operation error details are malformed"
1054 );
1055 }
1056
1057 #[derive(Debug, Serialize)]
1058 struct QueryParams {
1059 project_id: String,
1060 digest: RemoteBytes,
1061 }
1062
1063 #[derive(Debug, Deserialize, PartialEq, Eq)]
1064 struct QueryRow {
1065 id: String,
1066 digest: Vec<u8>,
1067 }
1068
1069 #[test]
1070 fn encodes_query_parameters_and_decodes_binary_rows() {
1071 let client = SyncRemoteClient::new(schema(), "worker").expect("client");
1072 let response = json!({
1073 "revision": super::protocol_revision(),
1074 "kind": "query",
1075 "operationId": "tasks/by-project",
1076 "rows": [{
1077 "id": "task-1",
1078 "digest": { "$syncular.bytes": "AAEC/w==" },
1079 }],
1080 "maxCommitSeq": 9,
1081 });
1082 let mut transport = CachedTransport {
1083 requests: Vec::new(),
1084 operation_response: Some(encode_operation_value(&response).expect("response")),
1085 };
1086
1087 let result = client
1088 .query::<_, _, QueryRow>(
1089 &mut transport,
1090 "tasks/by-project",
1091 &QueryParams {
1092 project_id: "project-1".to_owned(),
1093 digest: RemoteBytes(vec![0, 1, 2, 255]),
1094 },
1095 )
1096 .expect("query");
1097
1098 assert_eq!(
1099 result.rows,
1100 vec![QueryRow {
1101 id: "task-1".to_owned(),
1102 digest: vec![0, 1, 2, 255],
1103 }]
1104 );
1105 assert_eq!(result.max_commit_seq, 9);
1106 assert_eq!(
1107 decode_operation_value(&transport.requests[0]).expect("request")["params"]["digest"],
1108 json!([0, 1, 2, 255])
1109 );
1110 assert!(String::from_utf8(transport.requests[0].clone())
1111 .expect("utf8")
1112 .contains("\"t\":\"bytes\""));
1113 }
1114
1115 #[test]
1116 fn encodes_integers_inside_the_safe_range_as_numbers() {
1117 let tagged = |value: Value| {
1118 let bytes = encode_operation_value(&value).expect("encode");
1119 serde_json::from_slice::<Value>(&bytes).expect("json")
1120 };
1121 assert_eq!(tagged(json!(3)), json!({ "t": "number", "v": 3 }));
1122 assert_eq!(
1123 tagged(json!(9_007_199_254_740_991_i64)),
1124 json!({ "t": "number", "v": 9_007_199_254_740_991_i64 })
1125 );
1126 assert_eq!(
1127 tagged(json!(-9_007_199_254_740_991_i64)),
1128 json!({ "t": "number", "v": -9_007_199_254_740_991_i64 })
1129 );
1130 assert_eq!(
1131 tagged(json!(9_007_199_254_740_992_i64)),
1132 json!({ "t": "integer", "v": "9007199254740992" })
1133 );
1134 assert_eq!(
1135 tagged(json!(-9_007_199_254_740_992_i64)),
1136 json!({ "t": "integer", "v": "-9007199254740992" })
1137 );
1138 assert_eq!(
1139 tagged(json!(i64::MAX)),
1140 json!({ "t": "integer", "v": "9223372036854775807" })
1141 );
1142 assert_eq!(
1143 tagged(json!({ "count": 3 })),
1144 json!({ "t": "object", "v": [["count", { "t": "number", "v": 3 }]] })
1145 );
1146 assert!(encode_operation_value(&json!(u64::MAX)).is_err());
1147 assert_eq!(
1149 decode_operation_value(
1150 &serde_json::to_vec(&json!({ "t": "integer", "v": "3" })).expect("bytes")
1151 )
1152 .expect("decode"),
1153 json!(3)
1154 );
1155 }
1156
1157 #[test]
1158 fn rejects_invalid_operation_revision_and_command_status() {
1159 let client = SyncRemoteClient::for_operations("worker").expect("client");
1160 let mut transport = CachedTransport {
1161 requests: Vec::new(),
1162 operation_response: Some(
1163 encode_operation_value(&json!({
1164 "revision": 2.0,
1165 "kind": "query",
1166 "operationId": "tasks/all",
1167 "rows": [],
1168 "maxCommitSeq": 0,
1169 }))
1170 .expect("response"),
1171 ),
1172 };
1173
1174 let revision = client
1175 .query::<_, _, Value>(&mut transport, "tasks/all", &())
1176 .expect_err("revision must fail");
1177 assert_eq!(revision.code, "client.invalid_host_response");
1178
1179 transport.operation_response = Some(
1180 encode_operation_value(&json!({
1181 "revision": super::protocol_revision(),
1182 "kind": "query",
1183 "operationId": "tasks/all",
1184 "rows": [],
1185 "maxCommitSeq": 9_007_199_254_740_992i64,
1186 }))
1187 .expect("response"),
1188 );
1189 let sequence = client
1190 .query::<_, _, Value>(&mut transport, "tasks/all", &())
1191 .expect_err("unsafe sequence must fail");
1192 assert_eq!(sequence.code, "client.invalid_host_response");
1193
1194 transport.operation_response = Some(
1195 encode_operation_value(&json!({
1196 "revision": super::protocol_revision(),
1197 "kind": "command",
1198 "operationId": "tasks/complete",
1199 "requestId": "request-1",
1200 "status": "unknown",
1201 "results": [],
1202 }))
1203 .expect("response"),
1204 );
1205 let status = client
1206 .command(&mut transport, "tasks/complete", "request-1", &())
1207 .expect_err("status must fail");
1208 assert_eq!(status.code, "client.invalid_host_response");
1209
1210 transport.operation_response = Some(
1211 encode_operation_value(&json!({
1212 "revision": super::protocol_revision(),
1213 "kind": "command",
1214 "operationId": "tasks/complete",
1215 "requestId": "request-1",
1216 "status": "applied",
1217 "commitSeq": 0,
1218 "results": [],
1219 }))
1220 .expect("response"),
1221 );
1222 let sequence = client
1223 .command(&mut transport, "tasks/complete", "request-1", &())
1224 .expect_err("zero command sequence must fail");
1225 assert_eq!(sequence.code, "client.invalid_host_response");
1226 }
1227
1228 #[test]
1229 fn rejects_non_portable_integers_and_noncanonical_base64() {
1230 let integer = encode_operation_value(&json!(u64::MAX))
1231 .expect_err("unsigned values outside i64 must fail");
1232 assert_eq!(integer.code, "sync.invalid_request");
1233
1234 let bytes = decode_operation_value(br#"{"t":"bytes","v":"AB=="}"#)
1235 .expect_err("noncanonical base64 must fail");
1236 assert_eq!(bytes.code, "client.invalid_host_response");
1237 }
1238}