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