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