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/// A §5.9.5 blob download result: inline bytes, or a presigned url the client
36/// fetches directly (always-issue). `url_expires_at_ms` is present iff `url`.
37#[derive(Debug, Clone)]
38pub enum BlobDownload {
39    Bytes(Vec<u8>),
40    Url {
41        url: String,
42        url_expires_at_ms: Option<i64>,
43    },
44}
45
46/// A §5.9.3 presigned-upload grant: a single PUT url, an already-present
47/// marker (skip the PUT), or none (stream through the direct endpoint).
48#[derive(Debug, Clone)]
49pub enum BlobUploadGrant {
50    Url {
51        url: String,
52        url_expires_at_ms: Option<i64>,
53    },
54    Present,
55    None,
56}
57
58pub trait Transport {
59    /// One combined push+pull round trip (§1.5) over the request/response
60    /// binding (`POST /sync`, loopback, …).
61    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError>;
62    /// One registered authoritative query or command request. Hosts that do
63    /// not expose `<mount>/operations` keep the default fail-loud behavior.
64    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
65        let _ = request;
66        Err(TransportError::new(
67            "client.remote_operations_unconfigured",
68            "this transport has no remote operation endpoint",
69        ))
70    }
71    /// One combined push+pull round over the realtime socket (§8.7). The
72    /// host owns the WS-binding mechanics — channel tags, chunk assembly
73    /// to the response's END — and returns the assembled response bytes.
74    /// The client calls this instead of `sync` whenever realtime is
75    /// connected (the socket IS the sync-round
76    /// transport, not a fallback pair); the server registers the round's
77    /// subscriptions on the connection at round end, so no reconnect is
78    /// needed after subscription changes.
79    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError>;
80    /// Segment download via the direct endpoint (§5.5).
81    fn download_segment(
82        &mut self,
83        request: &SegmentRequest,
84        _on_progress: &mut dyn FnMut(u64),
85    ) -> Result<Vec<u8>, TransportError>;
86    /// §5.4 direct URL fetch capability: `true` makes the client
87    /// advertise accept bit 3 (capability negotiation, §4.2). Default:
88    /// not capable.
89    fn supports_url_fetch(&self) -> bool {
90        false
91    }
92    /// Plain GET of a signed URL (§5.4). The URL is the entire grant —
93    /// implementations MUST NOT attach sync-server authentication or the
94    /// `X-Syncular-Scopes` header. Only called when `supports_url_fetch`
95    /// returned `true`.
96    fn fetch_url(
97        &mut self,
98        url: &str,
99        _on_progress: &mut dyn FnMut(u64),
100    ) -> Result<Vec<u8>, TransportError> {
101        let _ = url;
102        Err(TransportError::new(
103            "sync.invalid_request",
104            "this transport has no direct URL fetch (§5.4)",
105        ))
106    }
107    /// The I/O executor records when a signed segment fetch began. Apply
108    /// checks expiry against that instant, preserving the fetch-start rule.
109    fn segment_fetch_started_at(&self, _url: &str) -> Option<i64> {
110        None
111    }
112    /// §5.9.3 blob upload: host-authenticated `PUT <mount>/blobs/{blobId}`.
113    /// The server verifies the content address. Default: unsupported.
114    fn blob_upload(
115        &mut self,
116        blob_id: &str,
117        bytes: &[u8],
118        media_type: Option<&str>,
119    ) -> Result<(), TransportError> {
120        let _ = (blob_id, bytes, media_type);
121        Err(TransportError::new(
122            "sync.invalid_request",
123            "this transport has no blob upload (§5.9)",
124        ))
125    }
126    /// §5.9.5 blob download: host-authenticated `GET <mount>/blobs/{blobId}`,
127    /// re-authorized server-side against referencing rows. Returns inline
128    /// bytes OR (always-issue, presign configured) a signed url the client
129    /// fetches directly. Default: none.
130    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
131        let _ = blob_id;
132        Err(TransportError::new(
133            "blob.not_found",
134            "this transport has no blob download (§5.9)",
135        ))
136    }
137    /// §5.9.5 presigned-download fetch: a bare GET of the signed url. MUST
138    /// attach NO host authentication — the url is the entire grant (§5.4).
139    /// Only called when `blob_download` returned a `Url` arm.
140    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
141        let _ = url;
142        Err(TransportError::new(
143            "sync.invalid_request",
144            "this transport has no blob url fetch (§5.9.5)",
145        ))
146    }
147    /// §5.9.3 presigned-upload grant: `POST /blobs/{blobId}/upload-grant` with
148    /// the declared size. Absent (`None`) ⇒ the client always streams through
149    /// `blob_upload`. A `Url` grant is PUT via `blob_put_url`.
150    fn blob_upload_grant(
151        &mut self,
152        blob_id: &str,
153        byte_length: u64,
154        media_type: Option<&str>,
155    ) -> Result<BlobUploadGrant, TransportError> {
156        let _ = (blob_id, byte_length, media_type);
157        Ok(BlobUploadGrant::None)
158    }
159    /// §5.9.3 direct-to-storage PUT of the granted url. MUST attach NO host
160    /// authentication — the presigned url is the entire grant (§5.4). Only
161    /// called when `blob_upload_grant` returned a `Url` arm.
162    fn blob_put_url(
163        &mut self,
164        url: &str,
165        bytes: &[u8],
166        media_type: Option<&str>,
167    ) -> Result<(), TransportError> {
168        let _ = (url, bytes, media_type);
169        Err(TransportError::new(
170            "sync.invalid_request",
171            "this transport has no blob put url (§5.9.3)",
172        ))
173    }
174    /// Realtime attach (§8.1). Inbound traffic is delivered by the host via
175    /// `SyncClient::on_realtime_text` / `on_realtime_binary`.
176    /// Legacy host-configured realtime connection. Implementations which do
177    /// not bind identity at the URL layer may implement only this method.
178    fn realtime_connect(&mut self) -> Result<(), TransportError>;
179    /// Open realtime for this exact persisted client identity. Native socket
180    /// servers bind registrations/cursors to the URL-level client id before
181    /// any protocol frame is exchanged, so client-owned hosts override this.
182    /// The default preserves compatibility with connectors that already close
183    /// over their identity.
184    fn realtime_connect_for_client(&mut self, _client_id: &str) -> Result<(), TransportError> {
185        self.realtime_connect()
186    }
187    /// The whole-round network deadline this transport would apply, as a
188    /// budget. `None` (the default) means no round deadline. The client
189    /// anchors the returned budget once per round and scopes it to each
190    /// `PreparedSyncRound::exchange`, so a reused transport never carries
191    /// stale deadline state.
192    fn round_deadline(&self) -> Option<std::time::Duration> {
193        None
194    }
195    /// Scope the transport to one absolute round deadline for the duration of
196    /// a single exchange; `None` clears it. The default ignores it.
197    fn set_round_deadline(&mut self, _deadline: Option<std::time::Instant>) {}
198    /// Client → server JSON control message (acks, §8.2).
199    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError>;
200    fn realtime_close(&mut self) -> Result<(), TransportError>;
201}