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