Skip to main content

infrastore_server/
client.rs

1//! Typed Rust client for the read-only gRPC service.
2//!
3//! [`RemoteClient`] mirrors the read methods of [`infrastore_core::Store`]
4//! over the wire. Construct it with [`RemoteClient::connect`]; a unifying
5//! `Store`/client trait is deliberately out of scope (the store is sync, the
6//! client async).
7
8use chrono::{DateTime, Utc};
9use infrastore_core::{
10    ForecastSummaryRow, KeyIdentity, OwnerCategory, Period, RequestedType, Result as CoreResult,
11    StaticConsistency, StaticSummaryRow, TimeSeriesCountsDetailed, TimeSeriesData, TimeSeriesError,
12    TimeSeriesKey, TimeSeriesMetadata, TimeSeriesType,
13};
14
15/// Parse an ISO-8601 period received over the wire, mapping failures to a
16/// connection error (the server is the source of truth for the encoding).
17fn iso_to_period(s: &str) -> CoreResult<Period> {
18    Period::from_iso8601(s).map_err(|e| TimeSeriesError::ConnectionError(e.to_string()))
19}
20
21fn opt_iso_to_period(s: Option<String>) -> CoreResult<Option<Period>> {
22    s.filter(|s| !s.is_empty())
23        .map(|s| iso_to_period(&s))
24        .transpose()
25}
26
27/// Map a wire-conversion error to an integrity error (the server is the source
28/// of truth for the encoding).
29fn convert_err(e: impl std::fmt::Display) -> TimeSeriesError {
30    TimeSeriesError::IntegrityError(format!("convert: {e}"))
31}
32
33/// Decode a server-sent full key into the core [`TimeSeriesKey`] enum.
34fn decode_full_key(k: infrastore_proto::pb::TimeSeriesKey) -> CoreResult<TimeSeriesKey> {
35    full_key_from_pb(k).map_err(convert_err)
36}
37
38/// Build a `ListReq` from the typed filter params shared by `list_time_series`
39/// and `list_keys`.
40#[allow(clippy::too_many_arguments)]
41fn build_list_req(
42    owner_id: Option<i64>,
43    owner_category: Option<OwnerCategory>,
44    owner_type: Option<String>,
45    time_series_type: Option<TimeSeriesType>,
46    name: Option<String>,
47    resolution: Option<Period>,
48    interval: Option<Period>,
49    features: Option<&infrastore_core::Features>,
50) -> ListReq {
51    ListReq {
52        owner_id,
53        owner_category: owner_category.map(|c| pb::OwnerCategory::from(c) as i32),
54        owner_type,
55        time_series_type: time_series_type.map(|t| pb::TimeSeriesType::from(t) as i32),
56        name,
57        resolution: resolution.map(|p| p.to_iso8601()),
58        interval: interval.map(|p| p.to_iso8601()),
59        features: features.map(features_to_pb),
60    }
61}
62use infrastore_proto::convert::{
63    features_to_pb, forecast_summary_row_from_pb, full_key_from_pb, get_resp_to_time_series_data,
64    key_to_pb, metadata_from_pb, requested_type_to_pb, static_summary_row_from_pb,
65};
66use infrastore_proto::pb::{
67    self, BulkReadReq, ConsistencyReq, CountsReq, EmptyReq, ForecastParamsReq, GetReq, HasReq,
68    IntervalsReq, KeyReq, KeysReq, ListKeysReq, ListOwnerIdsReq, ListReq, ResolutionsReq,
69    ResolveForecastKeyReq, VerifyReq, catalog_store_client::CatalogStoreClient,
70};
71use tokio::sync::Mutex;
72use tonic::transport::Channel;
73
74/// Read-only client wrapping a tonic-generated client. All methods translate
75/// gRPC `Status` errors back into [`TimeSeriesError::ConnectionError`] so
76/// callers don't need to know whether the store is local or remote.
77pub struct RemoteClient {
78    inner: Mutex<CatalogStoreClient<Channel>>,
79}
80
81impl RemoteClient {
82    pub async fn connect(addr: String) -> CoreResult<Self> {
83        let channel = Channel::from_shared(addr.clone())
84            .map_err(|e| TimeSeriesError::ConnectionError(format!("invalid uri {addr}: {e}")))?
85            .connect()
86            .await
87            .map_err(|e| TimeSeriesError::ConnectionError(format!("{addr}: {e}")))?;
88        Ok(Self::from_channel(channel))
89    }
90
91    pub fn from_channel(channel: Channel) -> Self {
92        Self {
93            inner: Mutex::new(CatalogStoreClient::new(channel)),
94        }
95    }
96
97    fn map_status(s: tonic::Status) -> TimeSeriesError {
98        match s.code() {
99            tonic::Code::NotFound => TimeSeriesError::NotFound,
100            tonic::Code::AlreadyExists => TimeSeriesError::DuplicateTimeSeries,
101            tonic::Code::InvalidArgument => {
102                TimeSeriesError::InvalidParameter(s.message().to_string())
103            }
104            tonic::Code::DataLoss => TimeSeriesError::IntegrityError(s.message().to_string()),
105            tonic::Code::FailedPrecondition => {
106                TimeSeriesError::InvalidParameter(s.message().to_string())
107            }
108            _ => TimeSeriesError::ConnectionError(format!("{}: {}", s.code(), s.message())),
109        }
110    }
111
112    #[allow(clippy::too_many_arguments)]
113    pub async fn list_time_series(
114        &self,
115        owner_id: Option<i64>,
116        owner_category: Option<OwnerCategory>,
117        owner_type: Option<String>,
118        time_series_type: Option<TimeSeriesType>,
119        name: Option<String>,
120        resolution: Option<Period>,
121        interval: Option<Period>,
122        features: Option<&infrastore_core::Features>,
123    ) -> CoreResult<Vec<TimeSeriesMetadata>> {
124        let req = build_list_req(
125            owner_id,
126            owner_category,
127            owner_type,
128            time_series_type,
129            name,
130            resolution,
131            interval,
132            features,
133        );
134        let mut inner = self.inner.lock().await;
135        let resp = inner
136            .list_time_series(req)
137            .await
138            .map_err(Self::map_status)?
139            .into_inner();
140        let mut out = Vec::with_capacity(resp.metadata.len());
141        for m in resp.metadata {
142            out.push(
143                metadata_from_pb(m).map_err(|e| {
144                    TimeSeriesError::IntegrityError(format!("metadata convert: {e}"))
145                })?,
146            );
147        }
148        Ok(out)
149    }
150
151    pub async fn get_time_series(
152        &self,
153        key: &KeyIdentity,
154        time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
155    ) -> CoreResult<TimeSeriesData> {
156        let (start, end) = match time_range {
157            Some((s, e)) => (Some(s.to_rfc3339()), Some(e.to_rfc3339())),
158            None => (None, None),
159        };
160        let req = GetReq {
161            key: Some(key_to_pb(key)),
162            start_rfc3339: start,
163            end_rfc3339: end,
164        };
165        let mut inner = self.inner.lock().await;
166        let resp = inner
167            .get_time_series(req)
168            .await
169            .map_err(Self::map_status)?
170            .into_inner();
171        get_resp_to_time_series_data(resp, key.name.clone())
172            .map_err(|e| TimeSeriesError::IntegrityError(format!("get convert: {e}")))
173    }
174
175    pub async fn get_time_series_keys(
176        &self,
177        owner_id: i64,
178        owner_category: OwnerCategory,
179    ) -> CoreResult<Vec<TimeSeriesKey>> {
180        let mut inner = self.inner.lock().await;
181        let resp = inner
182            .get_time_series_keys(KeysReq {
183                owner_id,
184                owner_category: pb::OwnerCategory::from(owner_category) as i32,
185            })
186            .await
187            .map_err(Self::map_status)?
188            .into_inner();
189        resp.keys.into_iter().map(decode_full_key).collect()
190    }
191
192    pub async fn get_resolutions(
193        &self,
194        time_series_type: Option<TimeSeriesType>,
195    ) -> CoreResult<Vec<Period>> {
196        let req = ResolutionsReq {
197            time_series_type: time_series_type.map(|t| pb::TimeSeriesType::from(t) as i32),
198        };
199        let mut inner = self.inner.lock().await;
200        let resp = inner
201            .get_resolutions(req)
202            .await
203            .map_err(Self::map_status)?
204            .into_inner();
205        resp.resolution.iter().map(|s| iso_to_period(s)).collect()
206    }
207
208    pub async fn get_counts(&self) -> CoreResult<infrastore_core::TimeSeriesCounts> {
209        let mut inner = self.inner.lock().await;
210        let resp = inner
211            .get_counts(CountsReq {})
212            .await
213            .map_err(Self::map_status)?
214            .into_inner();
215        Ok(infrastore_core::TimeSeriesCounts {
216            components_with_time_series: resp.components_with_time_series,
217            static_time_series: resp.static_time_series,
218            forecasts: resp.forecasts,
219        })
220    }
221
222    pub async fn get_forecast_parameters(
223        &self,
224        resolution: Option<Period>,
225        interval: Option<Period>,
226    ) -> CoreResult<infrastore_core::ForecastParameters> {
227        let mut inner = self.inner.lock().await;
228        let resp = inner
229            .get_forecast_parameters(ForecastParamsReq {
230                resolution: resolution.map(|p| p.to_iso8601()),
231                interval: interval.map(|p| p.to_iso8601()),
232            })
233            .await
234            .map_err(Self::map_status)?
235            .into_inner();
236        let initial_timestamp = match resp.initial_timestamp_rfc3339 {
237            Some(s) => Some(
238                DateTime::parse_from_rfc3339(&s)
239                    .map_err(|e| TimeSeriesError::ConnectionError(e.to_string()))?
240                    .with_timezone(&Utc),
241            ),
242            None => None,
243        };
244        Ok(infrastore_core::ForecastParameters {
245            horizon: opt_iso_to_period(resp.horizon)?,
246            interval: opt_iso_to_period(resp.interval)?,
247            count: resp.count.map(|c| c as usize),
248            resolution: opt_iso_to_period(resp.resolution)?,
249            initial_timestamp,
250        })
251    }
252
253    pub async fn has_time_series(&self, key: &KeyIdentity) -> CoreResult<bool> {
254        let mut inner = self.inner.lock().await;
255        let resp = inner
256            .has_time_series(HasReq {
257                key: Some(key_to_pb(key)),
258            })
259            .await
260            .map_err(Self::map_status)?
261            .into_inner();
262        Ok(resp.present)
263    }
264
265    pub async fn verify_integrity(&self) -> CoreResult<infrastore_core::storage::IntegrityReport> {
266        let mut inner = self.inner.lock().await;
267        let resp = inner
268            .verify_integrity(VerifyReq {})
269            .await
270            .map_err(Self::map_status)?
271            .into_inner();
272        Ok(infrastore_core::storage::IntegrityReport {
273            errors: resp.errors,
274        })
275    }
276
277    // ---- Additive read RPCs (Phase 4.4) ----
278
279    /// List full keys matching the filter, each paired with the array content
280    /// hash when `with_hash` is set (`None` otherwise).
281    #[allow(clippy::too_many_arguments)]
282    pub async fn list_keys(
283        &self,
284        owner_id: Option<i64>,
285        owner_category: Option<OwnerCategory>,
286        owner_type: Option<String>,
287        time_series_type: Option<TimeSeriesType>,
288        name: Option<String>,
289        resolution: Option<Period>,
290        interval: Option<Period>,
291        features: Option<&infrastore_core::Features>,
292        with_hash: bool,
293    ) -> CoreResult<Vec<(TimeSeriesKey, Option<[u8; 32]>)>> {
294        let filter = build_list_req(
295            owner_id,
296            owner_category,
297            owner_type,
298            time_series_type,
299            name,
300            resolution,
301            interval,
302            features,
303        );
304        let mut inner = self.inner.lock().await;
305        let resp = inner
306            .list_keys(ListKeysReq {
307                filter: Some(filter),
308                with_hash,
309            })
310            .await
311            .map_err(Self::map_status)?
312            .into_inner();
313        let mut out = Vec::with_capacity(resp.rows.len());
314        for row in resp.rows {
315            let key = decode_full_key(
316                row.key
317                    .ok_or_else(|| convert_err("ListKeys row missing key"))?,
318            )?;
319            let hash = match row.data_hash {
320                Some(h) => Some(
321                    <[u8; 32]>::try_from(h.as_slice())
322                        .map_err(|_| convert_err("data_hash is not 32 bytes"))?,
323                ),
324                None => None,
325            };
326            out.push((key, hash));
327        }
328        Ok(out)
329    }
330
331    /// Full metadata record for a single key.
332    pub async fn get_metadata(&self, key: &KeyIdentity) -> CoreResult<TimeSeriesMetadata> {
333        let mut inner = self.inner.lock().await;
334        let resp = inner
335            .get_metadata(KeyReq {
336                key: Some(key_to_pb(key)),
337            })
338            .await
339            .map_err(Self::map_status)?
340            .into_inner();
341        metadata_from_pb(resp).map_err(convert_err)
342    }
343
344    /// Read several series at once, optionally time-sliced.
345    pub async fn bulk_read(
346        &self,
347        keys: &[&KeyIdentity],
348        time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
349    ) -> CoreResult<Vec<TimeSeriesData>> {
350        let (start_rfc3339, end_rfc3339) = match time_range {
351            Some((s, e)) => (Some(s.to_rfc3339()), Some(e.to_rfc3339())),
352            None => (None, None),
353        };
354        let mut inner = self.inner.lock().await;
355        let resp = inner
356            .bulk_read(BulkReadReq {
357                keys: keys.iter().map(|k| key_to_pb(k)).collect(),
358                start_rfc3339,
359                end_rfc3339,
360            })
361            .await
362            .map_err(Self::map_status)?
363            .into_inner();
364        resp.items
365            .into_iter()
366            .map(|item| get_resp_to_time_series_data(item, String::new()).map_err(convert_err))
367            .collect()
368    }
369
370    /// Distinct owners per category and distinct arrays per kind.
371    pub async fn time_series_counts_detailed(&self) -> CoreResult<TimeSeriesCountsDetailed> {
372        let mut inner = self.inner.lock().await;
373        let resp = inner
374            .get_detailed_counts(EmptyReq {})
375            .await
376            .map_err(Self::map_status)?
377            .into_inner();
378        Ok(TimeSeriesCountsDetailed {
379            components_with_time_series: resp.components_with_time_series,
380            supplemental_attributes_with_time_series: resp.supplemental_attributes_with_time_series,
381            static_time_series_count: resp.static_time_series_count,
382            forecast_count: resp.forecast_count,
383        })
384    }
385
386    /// Association count grouped by time series type.
387    pub async fn counts_by_type(&self) -> CoreResult<Vec<(TimeSeriesType, i64)>> {
388        let mut inner = self.inner.lock().await;
389        let resp = inner
390            .get_counts_by_type(EmptyReq {})
391            .await
392            .map_err(Self::map_status)?
393            .into_inner();
394        resp.entries
395            .into_iter()
396            .map(|e| {
397                let t = pb::TimeSeriesType::try_from(e.time_series_type)
398                    .map(TimeSeriesType::from)
399                    .map_err(|_| convert_err("unknown time_series_type"))?;
400                Ok((t, e.count))
401            })
402            .collect()
403    }
404
405    /// Distinct owner ids of `category` with a time series, optionally scoped.
406    pub async fn list_owner_ids(
407        &self,
408        category: OwnerCategory,
409        time_series_type: Option<TimeSeriesType>,
410        resolution: Option<Period>,
411    ) -> CoreResult<Vec<i64>> {
412        let mut inner = self.inner.lock().await;
413        let resp = inner
414            .list_owner_ids(ListOwnerIdsReq {
415                owner_category: pb::OwnerCategory::from(category) as i32,
416                time_series_type: time_series_type.map(|t| pb::TimeSeriesType::from(t) as i32),
417                resolution: resolution.map(|p| p.to_iso8601()),
418            })
419            .await
420            .map_err(Self::map_status)?
421            .into_inner();
422        Ok(resp.owner_id)
423    }
424
425    /// Distinct forecast intervals, optionally scoped to one type.
426    pub async fn get_intervals(
427        &self,
428        time_series_type: Option<TimeSeriesType>,
429    ) -> CoreResult<Vec<Period>> {
430        let mut inner = self.inner.lock().await;
431        let resp = inner
432            .get_intervals(IntervalsReq {
433                time_series_type: time_series_type.map(|t| pb::TimeSeriesType::from(t) as i32),
434            })
435            .await
436            .map_err(Self::map_status)?
437            .into_inner();
438        resp.interval.iter().map(|s| iso_to_period(s)).collect()
439    }
440
441    /// Grouped static-series summary.
442    pub async fn static_summary(&self) -> CoreResult<Vec<StaticSummaryRow>> {
443        let mut inner = self.inner.lock().await;
444        let resp = inner
445            .get_static_summary(EmptyReq {})
446            .await
447            .map_err(Self::map_status)?
448            .into_inner();
449        resp.rows
450            .into_iter()
451            .map(|r| static_summary_row_from_pb(r).map_err(convert_err))
452            .collect()
453    }
454
455    /// Grouped forecast summary.
456    pub async fn forecast_summary(&self) -> CoreResult<Vec<ForecastSummaryRow>> {
457        let mut inner = self.inner.lock().await;
458        let resp = inner
459            .get_forecast_summary(EmptyReq {})
460            .await
461            .map_err(Self::map_status)?
462            .into_inner();
463        resp.rows
464            .into_iter()
465            .map(|r| forecast_summary_row_from_pb(r).map_err(convert_err))
466            .collect()
467    }
468
469    /// Per-resolution static-grid consistency rows (errors on divergence).
470    pub async fn check_static_consistency(
471        &self,
472        resolution: Option<Period>,
473    ) -> CoreResult<Vec<StaticConsistency>> {
474        let mut inner = self.inner.lock().await;
475        let resp = inner
476            .check_static_consistency(ConsistencyReq {
477                resolution: resolution.map(|p| p.to_iso8601()),
478            })
479            .await
480            .map_err(Self::map_status)?
481            .into_inner();
482        resp.rows
483            .into_iter()
484            .map(|r| {
485                Ok(StaticConsistency {
486                    resolution: iso_to_period(&r.resolution)?,
487                    initial_timestamp: DateTime::parse_from_rfc3339(&r.initial_timestamp_rfc3339)
488                        .map_err(convert_err)?
489                        .with_timezone(&Utc),
490                    length: r.length as usize,
491                })
492            })
493            .collect()
494    }
495
496    /// Resolve a forecast addressed by attributes plus a requested type.
497    #[allow(clippy::too_many_arguments)]
498    pub async fn resolve_forecast_key(
499        &self,
500        owner_id: i64,
501        owner_category: OwnerCategory,
502        name: &str,
503        resolution: Option<Period>,
504        interval: Option<Period>,
505        features: infrastore_core::Features,
506        requested: RequestedType,
507    ) -> CoreResult<TimeSeriesKey> {
508        let mut inner = self.inner.lock().await;
509        let resp = inner
510            .resolve_forecast_key(ResolveForecastKeyReq {
511                owner_id,
512                owner_category: pb::OwnerCategory::from(owner_category) as i32,
513                name: name.to_string(),
514                resolution: resolution.map(|p| p.to_iso8601()),
515                interval: interval.map(|p| p.to_iso8601()),
516                features: Some(features_to_pb(&features)),
517                requested: Some(requested_type_to_pb(requested)),
518            })
519            .await
520            .map_err(Self::map_status)?
521            .into_inner();
522        decode_full_key(resp.key.ok_or_else(|| convert_err("missing key"))?)
523    }
524}