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