Skip to main content

syncular_client/
transport.rs

1//! The transport seam handed TO the client by its host (the conformance
2//! harness, an app shell, …). Bytes and strings only — mirroring the
3//! `ClientEndpoints` inversion of the conformance driver contract. The
4//! client is synchronous: the driver protocol is request/response.
5
6/// A transport-level or request-level failure (§1.1 HTTP-JSON surface).
7#[derive(Debug, Clone)]
8pub struct TransportError {
9    pub code: String,
10    pub message: String,
11    pub details: Option<serde_json::Value>,
12}
13
14impl TransportError {
15    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
16        TransportError {
17            code: code.into(),
18            message: message.into(),
19            details: None,
20        }
21    }
22}
23
24/// Direct-endpoint segment fetch (§5.5). Signed-URL descriptors never
25/// reach this call: the §5.4 resolution lives in the client core, which
26/// routes url-carrying descriptors through `fetch_url` instead.
27#[derive(Debug, Clone)]
28pub struct SegmentRequest {
29    pub segment_id: String,
30    pub table: String,
31    /// Canonical JSON (§11.2) of the requested scope map (§5.5).
32    pub requested_scopes_json: String,
33}
34
35/// One segment body fetch of a response's batch: the direct endpoint (§5.5) or
36/// a signed URL (§5.4). The client resolves §5.4 descriptor routing before the
37/// batch, so a URL arm here is always fetchable (never at/past expiry).
38#[derive(Debug, Clone)]
39pub enum SegmentFetch {
40    /// Direct-endpoint request (§5.5).
41    Direct(SegmentRequest),
42    /// Plain GET of a signed URL; the URL is the entire grant (§5.4).
43    Url(String),
44}
45
46/// A §5.9.5 blob download result: inline bytes, or a presigned url the client
47/// fetches directly (always-issue). `url_expires_at_ms` is present iff `url`.
48#[derive(Debug, Clone)]
49pub enum BlobDownload {
50    Bytes(Vec<u8>),
51    Url {
52        url: String,
53        url_expires_at_ms: Option<i64>,
54    },
55}
56
57/// A §5.9.3 presigned-upload grant: a single PUT url, an already-present
58/// marker (skip the PUT), or none (stream through the direct endpoint).
59#[derive(Debug, Clone)]
60pub enum BlobUploadGrant {
61    Url {
62        url: String,
63        url_expires_at_ms: Option<i64>,
64    },
65    Present,
66    None,
67}
68
69pub trait Transport {
70    /// One combined push+pull round trip (§1.5) over the request/response
71    /// binding (`POST /sync`, loopback, …).
72    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError>;
73    /// One registered authoritative query or command request. Hosts that do
74    /// not expose `<mount>/operations` keep the default fail-loud behavior.
75    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
76        let _ = request;
77        Err(TransportError::new(
78            "client.remote_operations_unconfigured",
79            "this transport has no remote operation endpoint",
80        ))
81    }
82    /// One combined push+pull round over the realtime socket (§8.7). The
83    /// host owns the WS-binding mechanics — channel tags, chunk assembly
84    /// to the response's END — and returns the assembled response bytes.
85    /// The client calls this instead of `sync` whenever realtime is
86    /// connected (the socket IS the sync-round
87    /// transport, not a fallback pair); the server registers the round's
88    /// subscriptions on the connection at round end, so no reconnect is
89    /// needed after subscription changes.
90    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError>;
91    /// Segment download via the direct endpoint (§5.5).
92    fn download_segment(
93        &mut self,
94        request: &SegmentRequest,
95        _on_progress: &mut dyn FnMut(u64),
96    ) -> Result<Vec<u8>, TransportError>;
97    /// Every eligible segment body of one response, in wire order (§5.4 / §5.5).
98    /// `on_progress` receives the fetch index and that fetch's cumulative
99    /// decoded bytes.
100    ///
101    /// Returns one result per attempted fetch, in the same order. The default
102    /// runs the single `download_segment` / `fetch_url` calls in order and
103    /// stops after the first error, so a shorter result list means the
104    /// remaining fetches were not attempted. A host that overlaps the fetches
105    /// may attempt all of them; the client still stops consuming at the first
106    /// error, at that descriptor's position in the wire order.
107    fn download_segments(
108        &mut self,
109        fetches: &[SegmentFetch],
110        on_progress: &mut (dyn FnMut(usize, u64) + Send),
111    ) -> Vec<Result<Vec<u8>, TransportError>> {
112        let mut results = Vec::new();
113        for (index, fetch) in fetches.iter().enumerate() {
114            let mut report = |bytes| on_progress(index, bytes);
115            let result = match fetch {
116                SegmentFetch::Direct(request) => self.download_segment(request, &mut report),
117                SegmentFetch::Url(url) => self.fetch_url(url, &mut report),
118            };
119            let failed = result.is_err();
120            results.push(result);
121            if failed {
122                break;
123            }
124        }
125        results
126    }
127    /// §5.4 direct URL fetch capability: `true` makes the client
128    /// advertise accept bit 3 (capability negotiation, §4.2). Default:
129    /// not capable.
130    fn supports_url_fetch(&self) -> bool {
131        false
132    }
133    /// Plain GET of a signed URL (§5.4). The URL is the entire grant —
134    /// implementations MUST NOT attach sync-server authentication or the
135    /// `X-Syncular-Scopes` header. Only called when `supports_url_fetch`
136    /// returned `true`.
137    fn fetch_url(
138        &mut self,
139        url: &str,
140        _on_progress: &mut dyn FnMut(u64),
141    ) -> Result<Vec<u8>, TransportError> {
142        let _ = url;
143        Err(TransportError::new(
144            "sync.invalid_request",
145            "this transport has no direct URL fetch (§5.4)",
146        ))
147    }
148    /// The I/O executor records when a signed segment fetch began. Apply
149    /// checks expiry against that instant, preserving the fetch-start rule.
150    fn segment_fetch_started_at(&self, _url: &str) -> Option<i64> {
151        None
152    }
153    /// §5.9.3 blob upload: host-authenticated `PUT <mount>/blobs/{blobId}`.
154    /// The server verifies the content address. Default: unsupported.
155    fn blob_upload(
156        &mut self,
157        blob_id: &str,
158        bytes: &[u8],
159        media_type: Option<&str>,
160    ) -> Result<(), TransportError> {
161        let _ = (blob_id, bytes, media_type);
162        Err(TransportError::new(
163            "sync.invalid_request",
164            "this transport has no blob upload (§5.9)",
165        ))
166    }
167    /// §5.9.5 blob download: host-authenticated `GET <mount>/blobs/{blobId}`,
168    /// re-authorized server-side against referencing rows. Returns inline
169    /// bytes OR (always-issue, presign configured) a signed url the client
170    /// fetches directly. Default: none.
171    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
172        let _ = blob_id;
173        Err(TransportError::new(
174            "blob.not_found",
175            "this transport has no blob download (§5.9)",
176        ))
177    }
178    /// §5.9.5 presigned-download fetch: a bare GET of the signed url. MUST
179    /// attach NO host authentication — the url is the entire grant (§5.4).
180    /// Only called when `blob_download` returned a `Url` arm.
181    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
182        let _ = url;
183        Err(TransportError::new(
184            "sync.invalid_request",
185            "this transport has no blob url fetch (§5.9.5)",
186        ))
187    }
188    /// §5.9.3 presigned-upload grant: `POST /blobs/{blobId}/upload-grant` with
189    /// the declared size. Absent (`None`) ⇒ the client always streams through
190    /// `blob_upload`. A `Url` grant is PUT via `blob_put_url`.
191    fn blob_upload_grant(
192        &mut self,
193        blob_id: &str,
194        byte_length: u64,
195        media_type: Option<&str>,
196    ) -> Result<BlobUploadGrant, TransportError> {
197        let _ = (blob_id, byte_length, media_type);
198        Ok(BlobUploadGrant::None)
199    }
200    /// §5.9.3 direct-to-storage PUT of the granted url. MUST attach NO host
201    /// authentication — the presigned url is the entire grant (§5.4). Only
202    /// called when `blob_upload_grant` returned a `Url` arm.
203    fn blob_put_url(
204        &mut self,
205        url: &str,
206        bytes: &[u8],
207        media_type: Option<&str>,
208    ) -> Result<(), TransportError> {
209        let _ = (url, bytes, media_type);
210        Err(TransportError::new(
211            "sync.invalid_request",
212            "this transport has no blob put url (§5.9.3)",
213        ))
214    }
215    /// Realtime attach (§8.1). Inbound traffic is delivered by the host via
216    /// `SyncClient::on_realtime_text` / `on_realtime_binary`.
217    /// Legacy host-configured realtime connection. Implementations which do
218    /// not bind identity at the URL layer may implement only this method.
219    fn realtime_connect(&mut self) -> Result<(), TransportError>;
220    /// Open realtime for this exact persisted client identity. Native socket
221    /// servers bind registrations/cursors to the URL-level client id before
222    /// any protocol frame is exchanged, so client-owned hosts override this.
223    /// The default preserves compatibility with connectors that already close
224    /// over their identity.
225    fn realtime_connect_for_client(&mut self, _client_id: &str) -> Result<(), TransportError> {
226        self.realtime_connect()
227    }
228    /// The whole-round network deadline this transport would apply, as a
229    /// budget. `None` (the default) means no round deadline. The client
230    /// anchors the returned budget once per round and scopes it to each
231    /// `PreparedSyncRound::exchange`, so a reused transport never carries
232    /// stale deadline state.
233    fn round_deadline(&self) -> Option<std::time::Duration> {
234        None
235    }
236    /// Scope the transport to one absolute round deadline for the duration of
237    /// a single exchange; `None` clears it. The default ignores it.
238    fn set_round_deadline(&mut self, _deadline: Option<std::time::Instant>) {}
239    /// Client → server JSON control message (acks, §8.2).
240    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError>;
241    fn realtime_close(&mut self) -> Result<(), TransportError>;
242}