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