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