aster_forge_cloud_files_core/
backend.rs

1//! Provisional read-only backend ports and revision-bound content read values.
2//!
3//! These traits intentionally cover Phase 1 metadata, enumeration, change discovery, and content
4//! reads. Resumable content mutation uses the separate upload port and durable journal model. The
5//! async dispatch shape remains provisional until synthetic and platform `PoCs` exercise hot paths.
6
7use std::num::NonZeroU64;
8
9use async_trait::async_trait;
10use bytes::Bytes;
11
12use crate::{
13    BackendResult, ChangeCursor, ChangePage, CloudFilesCapabilities, CloudFilesCoreError,
14    CloudItem, CloudItemKey, CloudItemPage, CloudScope, ContentRevision, PageCursor, Result,
15};
16
17/// Non-empty half-open byte range `[offset, end_exclusive)`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct ByteRange {
20    offset: u64,
21    length: NonZeroU64,
22}
23
24impl ByteRange {
25    /// Creates a non-empty range and rejects end-position overflow.
26    /// # Errors
27    ///
28    /// Returns an error when validation fails or an underlying backend, store, or platform
29    /// operation fails.
30    pub fn new(offset: u64, length: u64) -> Result<Self> {
31        let Some(length) = NonZeroU64::new(length) else {
32            return Err(CloudFilesCoreError::invalid_byte_range(
33                "range length must be greater than zero",
34            ));
35        };
36        if offset.checked_add(length.get()).is_none() {
37            return Err(CloudFilesCoreError::invalid_byte_range(
38                "range end exceeds u64",
39            ));
40        }
41        Ok(Self { offset, length })
42    }
43
44    /// Returns the first requested byte offset.
45    #[must_use]
46    pub const fn offset(self) -> u64 {
47        self.offset
48    }
49
50    /// Returns the requested byte length.
51    #[must_use]
52    pub const fn length(self) -> u64 {
53        self.length.get()
54    }
55
56    /// Returns the exclusive range end. Construction guarantees this addition cannot overflow.
57    #[must_use]
58    pub const fn end_exclusive(self) -> u64 {
59        self.offset + self.length.get()
60    }
61
62    /// Returns the smallest range covering two already validated ranges.
63    pub(crate) fn covering(self, other: Self) -> Self {
64        let (first, end) = if self.offset <= other.offset {
65            (self, self.end_exclusive().max(other.end_exclusive()))
66        } else {
67            (other, self.end_exclusive().max(other.end_exclusive()))
68        };
69        let extension = end - first.end_exclusive();
70        Self {
71            offset: first.offset,
72            length: first.length.saturating_add(extension),
73        }
74    }
75}
76
77/// Requested content extent.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum ContentReadRange {
80    /// Read the complete file.
81    Whole,
82    /// Read one exact logical range, truncated only at end-of-file.
83    Range(ByteRange),
84}
85
86/// Revision-bound content read request.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ContentReadRequest {
89    key: CloudItemKey,
90    revision: ContentRevision,
91    expected_size: u64,
92    range: ContentReadRange,
93}
94
95impl ContentReadRequest {
96    /// Creates a complete-file read request.
97    #[must_use]
98    pub const fn whole(key: CloudItemKey, revision: ContentRevision, expected_size: u64) -> Self {
99        Self {
100            key,
101            revision,
102            expected_size,
103            range: ContentReadRange::Whole,
104        }
105    }
106
107    /// Creates a range read request.
108    #[must_use]
109    pub const fn range(
110        key: CloudItemKey,
111        revision: ContentRevision,
112        expected_size: u64,
113        range: ByteRange,
114    ) -> Self {
115        Self {
116            key,
117            revision,
118            expected_size,
119            range: ContentReadRange::Range(range),
120        }
121    }
122
123    /// Returns the fully scoped item identity.
124    #[must_use]
125    pub const fn key(&self) -> &CloudItemKey {
126        &self.key
127    }
128
129    /// Returns the exact expected content revision.
130    #[must_use]
131    pub const fn revision(&self) -> &ContentRevision {
132        &self.revision
133    }
134
135    /// Returns the logical size reported by metadata for this exact revision.
136    #[must_use]
137    pub const fn expected_size(&self) -> u64 {
138        self.expected_size
139    }
140
141    /// Returns the requested extent.
142    #[must_use]
143    pub const fn read_range(&self) -> ContentReadRange {
144        self.range
145    }
146
147    /// Validates that a backend response satisfies this exact request.
148    /// # Errors
149    ///
150    /// Returns an error when validation fails or an underlying backend, store, or platform
151    /// operation fails.
152    pub fn validate_response(&self, response: &ContentReadResponse) -> Result<()> {
153        if response.revision() != &self.revision {
154            return Err(CloudFilesCoreError::invalid_content_response(
155                "response revision does not match the requested revision",
156            ));
157        }
158        if response.total_size() != self.expected_size {
159            return Err(CloudFilesCoreError::invalid_content_response(
160                "response size does not match metadata for the requested revision",
161            ));
162        }
163        match self.range {
164            ContentReadRange::Whole => {
165                if response.offset() != 0
166                    || response.byte_len() != response.total_size()
167                    || !response.is_complete_file()
168                {
169                    return Err(CloudFilesCoreError::invalid_content_response(
170                        "whole-file response does not contain the complete file",
171                    ));
172                }
173            }
174            ContentReadRange::Range(range) => {
175                if response.offset() != range.offset() {
176                    return Err(CloudFilesCoreError::invalid_content_response(
177                        "range response starts at a different offset",
178                    ));
179                }
180                let expected_len = if range.offset() >= response.total_size() {
181                    0
182                } else {
183                    std::cmp::min(
184                        range.length(),
185                        response.total_size().saturating_sub(range.offset()),
186                    )
187                };
188                if response.byte_len() != expected_len {
189                    return Err(CloudFilesCoreError::invalid_content_response(
190                        "range response length does not match the requested extent",
191                    ));
192                }
193            }
194        }
195        Ok(())
196    }
197}
198
199/// Owned content bytes returned for one exact revision and logical offset.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ContentReadResponse {
202    revision: ContentRevision,
203    offset: u64,
204    bytes: Bytes,
205    byte_len: u64,
206    total_size: u64,
207}
208
209impl ContentReadResponse {
210    /// Creates a response and verifies that its bytes remain within the logical file size.
211    /// # Errors
212    ///
213    /// Returns an error when validation fails or an underlying backend, store, or platform
214    /// operation fails.
215    pub fn new(
216        revision: ContentRevision,
217        offset: u64,
218        bytes: Bytes,
219        total_size: u64,
220    ) -> Result<Self> {
221        let byte_len = bytes.len() as u64;
222        let Some(end) = offset.checked_add(byte_len) else {
223            return Err(CloudFilesCoreError::invalid_content_response(
224                "response byte range exceeds u64",
225            ));
226        };
227        if offset > total_size || end > total_size {
228            return Err(CloudFilesCoreError::invalid_content_response(
229                "response bytes exceed the logical file size",
230            ));
231        }
232        Ok(Self {
233            revision,
234            offset,
235            bytes,
236            byte_len,
237            total_size,
238        })
239    }
240
241    /// Returns the exact content revision represented by these bytes.
242    pub const fn revision(&self) -> &ContentRevision {
243        &self.revision
244    }
245
246    /// Returns the logical byte offset.
247    pub const fn offset(&self) -> u64 {
248        self.offset
249    }
250
251    /// Returns the content bytes.
252    pub const fn bytes(&self) -> &Bytes {
253        &self.bytes
254    }
255
256    /// Returns the number of content bytes.
257    pub const fn byte_len(&self) -> u64 {
258        self.byte_len
259    }
260
261    /// Returns the complete logical file size for this revision.
262    pub const fn total_size(&self) -> u64 {
263        self.total_size
264    }
265
266    /// Returns whether the response contains the complete file, including an empty file.
267    pub fn is_complete_file(&self) -> bool {
268        self.offset == 0 && self.byte_len() == self.total_size
269    }
270
271    /// Consumes the response and returns its revision, offset, bytes, and total size.
272    pub fn into_parts(self) -> (ContentRevision, u64, Bytes, u64) {
273        (self.revision, self.offset, self.bytes, self.total_size)
274    }
275}
276
277/// Product-owned metadata, enumeration, and change-discovery adapter.
278#[async_trait]
279pub trait CloudMetadataBackend: Send + Sync {
280    /// Loads one item by stable scoped identity.
281    async fn get_item(&self, key: &CloudItemKey) -> BackendResult<CloudItem>;
282
283    /// Lists one directory page. A page cursor is valid only for the same parent enumeration.
284    async fn list_children(
285        &self,
286        parent: &CloudItemKey,
287        cursor: Option<&PageCursor>,
288    ) -> BackendResult<CloudItemPage>;
289
290    /// Loads the next anchored change batch or an explicit reset result.
291    async fn changes_since(
292        &self,
293        scope: &CloudScope,
294        cursor: Option<&ChangeCursor>,
295    ) -> BackendResult<ChangePage>;
296}
297
298/// Product-owned revision-bound content adapter.
299#[async_trait]
300pub trait CloudContentBackend: Send + Sync {
301    /// Reads complete or ranged content for one exact revision.
302    async fn read_content(
303        &self,
304        request: &ContentReadRequest,
305    ) -> BackendResult<ContentReadResponse>;
306}
307
308/// Complete provisional read-only backend contract used by Phase 1 executable models.
309pub trait CloudFilesBackend: CloudMetadataBackend + CloudContentBackend {
310    /// Returns backend constraints. Dimensions not owned by the backend use `unconstrained()`.
311    fn capabilities(&self) -> CloudFilesCapabilities;
312}