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