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