Skip to main content

syncular_client/
remote.rs

1//! Database-less SSP2 producer (`SPEC.md` §6.10).
2//!
3//! Prepared request bytes are the retry unit. A caller that needs crash-safe
4//! retry persists them in its own job or request store.
5
6use 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/// Binary value for registered query and command parameters. Plain `Vec<u8>`
48/// serializes as a JSON array, so callers use this wrapper when the remote
49/// operation schema expects the protocol's `bytes` value.
50#[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}