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