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