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