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