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