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