Skip to main content

syncular_client/
remote.rs

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