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