Skip to main content

fslite_core/
fs.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::pin::Pin;
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use futures::{Stream, stream};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::{
13    ByteRange, ContentQuery, CopyOptions, CreateOptions, FindQuery, FsResult, LinkTarget,
14    MoveOptions, MutationOptions, Node, NodeId, PageRequest, ReadOptions, RemoveOptions, Revision,
15    StatOptions, TouchOptions, TreeOptions, VirtualPath, WorkspaceId, WriteOptions,
16};
17
18/// A pinned asynchronous stream of filesystem byte chunks.
19pub type ByteStream = Pin<Box<dyn Stream<Item = FsResult<Bytes>> + Send + 'static>>;
20
21/// A caller capability enforced within one workspace.
22#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Capability {
25    /// Inspect metadata and read file contents.
26    Read,
27    /// Create and mutate nodes and file contents.
28    Write,
29    /// Permanently delete nodes and purge trash entries.
30    Delete,
31    /// Trash and restore nodes.
32    TrashRestore,
33    /// Administer workspace-level configuration and lifecycle.
34    WorkspaceAdmin,
35}
36
37/// Transport-independent authorization and audit context for an operation.
38#[non_exhaustive]
39#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
40pub struct RequestContext {
41    /// The workspace in which the operation executes.
42    pub workspace_id: WorkspaceId,
43    /// Safe host-supplied actor fields suitable for audit records.
44    pub actor_metadata: BTreeMap<String, Value>,
45    /// The workspace-scoped capabilities granted to the caller.
46    pub capabilities: BTreeSet<Capability>,
47}
48
49impl RequestContext {
50    /// Creates a request context from host-provided actor metadata and capabilities.
51    pub fn new(
52        workspace_id: WorkspaceId,
53        actor_metadata: BTreeMap<String, Value>,
54        capabilities: impl IntoIterator<Item = Capability>,
55    ) -> Self {
56        Self {
57            workspace_id,
58            actor_metadata,
59            capabilities: capabilities.into_iter().collect(),
60        }
61    }
62
63    /// Creates an explicit trusted context with every capability.
64    pub fn trusted(workspace_id: WorkspaceId) -> Self {
65        Self::new(
66            workspace_id,
67            BTreeMap::new(),
68            [
69                Capability::Read,
70                Capability::Write,
71                Capability::Delete,
72                Capability::TrashRestore,
73                Capability::WorkspaceAdmin,
74            ],
75        )
76    }
77
78    /// Returns whether this context contains a capability.
79    pub fn has_capability(&self, capability: Capability) -> bool {
80        self.capabilities.contains(&capability)
81    }
82}
83
84/// A source of byte chunks consumed by a streamed write.
85pub struct WriteSource {
86    stream: ByteStream,
87}
88
89impl fmt::Debug for WriteSource {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        formatter
92            .debug_struct("WriteSource")
93            .finish_non_exhaustive()
94    }
95}
96
97impl WriteSource {
98    /// Wraps a sendable stream of byte chunks and filesystem errors.
99    pub fn new(source: impl Stream<Item = FsResult<Bytes>> + Send + 'static) -> Self {
100        Self {
101            stream: Box::pin(source),
102        }
103    }
104
105    /// Creates a one-chunk source from in-memory bytes.
106    pub fn from_bytes(bytes: impl Into<Bytes>) -> Self {
107        let bytes = bytes.into();
108        Self::new(stream::once(async move { Ok(bytes) }))
109    }
110
111    /// Returns the wrapped stream.
112    pub fn into_stream(self) -> ByteStream {
113        self.stream
114    }
115}
116
117/// Metadata accompanying a streamed file read.
118pub struct FileRead {
119    /// The complete logical length of the file.
120    pub logical_length: u64,
121    /// The revision from which the stream reads.
122    pub revision: Revision,
123    /// The actual inclusive-start, exclusive-end range returned.
124    pub range: ByteRange,
125    /// The requested byte chunks.
126    pub stream: ByteStream,
127}
128
129impl fmt::Debug for FileRead {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter
132            .debug_struct("FileRead")
133            .field("logical_length", &self.logical_length)
134            .field("revision", &self.revision)
135            .field("range", &self.range)
136            .field("stream", &"<byte stream>")
137            .finish()
138    }
139}
140
141impl FileRead {
142    /// Returns the file's byte stream.
143    pub fn into_stream(self) -> ByteStream {
144        self.stream
145    }
146}
147
148/// Logical and quota usage for one workspace.
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct WorkspaceUsage {
151    /// The workspace described by this snapshot.
152    pub workspace_id: WorkspaceId,
153    /// Logical bytes reachable through active nodes.
154    pub active_logical_bytes: u64,
155    /// Logical bytes retained in recoverable trash.
156    pub trashed_logical_bytes: u64,
157    /// Temporary bytes held internally by incomplete streamed writes.
158    ///
159    /// This is a read-only accounting metric, not a caller-visible staged
160    /// content identifier, upload handle, or staging API.
161    pub staged_bytes: u64,
162    /// The number of active nodes, including the workspace root.
163    pub active_nodes: u64,
164    /// The number of nodes retained in recoverable trash.
165    pub trashed_nodes: u64,
166    /// The configured logical-byte quota.
167    pub max_logical_bytes: u64,
168    /// The configured node-count quota.
169    pub max_nodes: u64,
170    /// The configured maximum size of one regular file.
171    pub max_file_bytes: u64,
172}
173
174/// One entry in a recursive tree traversal.
175#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
176pub struct TreeEntry {
177    /// The absolute path of the returned node.
178    pub path: VirtualPath,
179    /// The depth below the traversal root.
180    pub depth: u32,
181    /// The node metadata.
182    pub node: Node,
183}
184
185/// One page of results and an optional continuation cursor.
186#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
187pub struct Page<T> {
188    /// Results in the backend's documented stable order.
189    pub items: Vec<T>,
190    /// An opaque cursor for the next page, or `None` at the end.
191    pub next_cursor: Option<String>,
192}
193
194impl<T> Page<T> {
195    /// Creates a page from result items and an optional continuation cursor.
196    pub fn new(items: Vec<T>, next_cursor: Option<String>) -> Self {
197        Self { items, next_cursor }
198    }
199}
200
201/// Identifies a recoverable trash record.
202#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
203#[serde(transparent)]
204pub struct TrashId(Uuid);
205
206impl TrashId {
207    /// Creates a time-ordered UUIDv7 trash identifier.
208    pub fn new() -> Self {
209        Self(Uuid::now_v7())
210    }
211
212    /// Parses a UUID trash identifier.
213    pub fn parse(input: &str) -> Result<Self, uuid::Error> {
214        Uuid::parse_str(input).map(Self)
215    }
216}
217
218impl Default for TrashId {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl fmt::Display for TrashId {
225    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226        self.0.fmt(formatter)
227    }
228}
229
230/// Metadata for a recoverable trashed subtree.
231#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
232pub struct TrashEntry {
233    /// The stable trash record identifier.
234    pub id: TrashId,
235    /// The root node of the trashed subtree.
236    pub node: Node,
237    /// The node's absolute path before it was trashed.
238    pub original_path: VirtualPath,
239    /// The Unix timestamp in milliseconds when the node was trashed.
240    pub trashed_at_ms: i64,
241    /// Safe actor fields captured when the node was trashed.
242    pub actor_metadata: BTreeMap<String, Value>,
243}
244
245/// One literal content-search match.
246#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
247pub struct SearchMatch {
248    /// The matching file's metadata.
249    pub node: Node,
250    /// The matching file's absolute path.
251    pub path: VirtualPath,
252    /// The exact logical byte range occupied by the match.
253    pub range: ByteRange,
254    /// A bounded byte preview around the match.
255    pub preview: Vec<u8>,
256}
257
258/// An opaque continuation cursor for the workspace change feed.
259#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
260#[serde(transparent)]
261pub struct ChangeCursor(String);
262
263impl ChangeCursor {
264    /// Wraps a backend-produced opaque cursor.
265    pub fn new(cursor: impl Into<String>) -> Self {
266        Self(cursor.into())
267    }
268
269    /// Returns the opaque cursor value.
270    pub fn as_str(&self) -> &str {
271        &self.0
272    }
273}
274
275impl fmt::Display for ChangeCursor {
276    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
277        formatter.write_str(self.as_str())
278    }
279}
280
281/// The stable category of a committed filesystem change.
282#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
283#[serde(rename_all = "snake_case")]
284pub enum ChangeKind {
285    /// A node was created.
286    Created,
287    /// Node metadata or regular-file contents changed.
288    Modified,
289    /// A node was copied to a new path.
290    Copied,
291    /// A node moved to a new path.
292    Moved,
293    /// A node was permanently removed.
294    Removed,
295    /// A node entered recoverable trash.
296    Trashed,
297    /// A node was restored from trash.
298    Restored,
299    /// A trash record was permanently purged.
300    Purged,
301    /// A custom attribute was set.
302    AttributeSet,
303    /// A custom attribute was removed.
304    AttributeRemoved,
305}
306
307/// One committed, workspace-scoped filesystem change.
308#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
309pub struct Change {
310    /// The strictly increasing sequence within the workspace.
311    pub sequence: u64,
312    /// The change category.
313    pub kind: ChangeKind,
314    /// The affected node when it remains identifiable.
315    pub node_id: Option<NodeId>,
316    /// The path before the change, when applicable.
317    pub old_path: Option<VirtualPath>,
318    /// The path after the change, when applicable.
319    pub new_path: Option<VirtualPath>,
320    /// The resulting revision, when the affected node remains active.
321    pub revision: Option<Revision>,
322    /// The Unix timestamp in milliseconds when the change committed.
323    pub created_at_ms: i64,
324    /// Safe actor fields captured for the mutation.
325    pub actor_metadata: BTreeMap<String, Value>,
326}
327
328/// A metadata or namespace operation that may participate in an atomic batch.
329///
330/// Batches never carry file bytes, staged-content identifiers, or upload
331/// handles. Content-changing file operations (`write`, `write_at`, `append`,
332/// and `truncate`) remain separate atomic filesystem calls.
333#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
334#[serde(rename_all = "snake_case")]
335pub enum BatchOperation {
336    /// Creates a directory.
337    Mkdir {
338        /// The directory path.
339        path: VirtualPath,
340        /// Directory creation controls.
341        options: CreateOptions,
342    },
343    /// Touches or creates a regular file.
344    Touch {
345        /// The file path.
346        path: VirtualPath,
347        /// Touch controls.
348        options: TouchOptions,
349    },
350    /// Copies a node.
351    Copy {
352        /// The source path.
353        from: VirtualPath,
354        /// The destination path.
355        to: VirtualPath,
356        /// Copy controls.
357        options: CopyOptions,
358    },
359    /// Moves a node.
360    Move {
361        /// The source path.
362        from: VirtualPath,
363        /// The destination path.
364        to: VirtualPath,
365        /// Move controls.
366        options: MoveOptions,
367    },
368    /// Permanently removes a node.
369    Remove {
370        /// The target path.
371        path: VirtualPath,
372        /// Removal controls.
373        options: RemoveOptions,
374    },
375    /// Creates a symbolic link.
376    Symlink {
377        /// The normalized relative or absolute virtual target.
378        target: LinkTarget,
379        /// The new link path.
380        link: VirtualPath,
381        /// Link creation controls.
382        options: CreateOptions,
383    },
384    /// Moves a node into recoverable trash.
385    Trash {
386        /// The target path.
387        path: VirtualPath,
388        /// Optimistic mutation controls.
389        options: MutationOptions,
390    },
391    /// Restores a recoverable trash record.
392    Restore {
393        /// The trash record to restore.
394        trash: TrashId,
395        /// An alternate destination, or the original path when absent.
396        destination: Option<VirtualPath>,
397        /// Optimistic mutation controls.
398        options: MutationOptions,
399    },
400    /// Permanently purges a trash record.
401    Purge {
402        /// The trash record to purge.
403        trash: TrashId,
404    },
405    /// Sets a custom node attribute.
406    SetAttribute {
407        /// The target path.
408        path: VirtualPath,
409        /// The attribute key.
410        key: String,
411        /// The opaque attribute value.
412        value: Vec<u8>,
413        /// Optimistic mutation controls.
414        options: MutationOptions,
415    },
416    /// Removes a custom node attribute.
417    RemoveAttribute {
418        /// The target path.
419        path: VirtualPath,
420        /// The attribute key.
421        key: String,
422        /// Optimistic mutation controls.
423        options: MutationOptions,
424    },
425}
426
427/// The result of one successful atomic batch member.
428#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
429#[serde(rename_all = "snake_case")]
430pub enum BatchResult {
431    /// An operation returned node metadata.
432    Node(Node),
433    /// A trash operation returned a recoverable record.
434    Trash(TrashEntry),
435    /// An operation completed without a value.
436    Unit,
437}
438
439/// Canonical asynchronous filesystem behavior shared by all backends.
440///
441/// Implementations must enforce the workspace and capabilities in each
442/// [`RequestContext`] and may be used through `dyn FileSystem`.
443#[async_trait]
444pub trait FileSystem: Send + Sync {
445    /// Returns logical and quota usage for the context's workspace.
446    async fn workspace_usage(&self, ctx: &RequestContext) -> FsResult<WorkspaceUsage>;
447
448    /// Returns metadata for a path.
449    async fn stat(
450        &self,
451        ctx: &RequestContext,
452        path: &VirtualPath,
453        options: StatOptions,
454    ) -> FsResult<Node>;
455
456    /// Returns whether a path resolves to a visible node.
457    async fn exists(
458        &self,
459        ctx: &RequestContext,
460        path: &VirtualPath,
461        options: StatOptions,
462    ) -> FsResult<bool>;
463
464    /// Returns one cursor-paginated page of direct directory children.
465    async fn read_dir(
466        &self,
467        ctx: &RequestContext,
468        path: &VirtualPath,
469        page: PageRequest,
470    ) -> FsResult<Page<Node>>;
471
472    /// Returns one cursor-paginated page of a recursive tree traversal.
473    async fn tree(
474        &self,
475        ctx: &RequestContext,
476        path: &VirtualPath,
477        options: TreeOptions,
478        page: PageRequest,
479    ) -> FsResult<Page<TreeEntry>>;
480
481    /// Creates a directory.
482    async fn mkdir(
483        &self,
484        ctx: &RequestContext,
485        path: &VirtualPath,
486        options: CreateOptions,
487    ) -> FsResult<Node>;
488
489    /// Opens a bounded-memory byte stream for a regular file.
490    async fn read(
491        &self,
492        ctx: &RequestContext,
493        path: &VirtualPath,
494        options: ReadOptions,
495    ) -> FsResult<FileRead>;
496
497    /// Atomically creates or replaces a regular file from a byte stream.
498    async fn write(
499        &self,
500        ctx: &RequestContext,
501        path: &VirtualPath,
502        source: WriteSource,
503        options: WriteOptions,
504    ) -> FsResult<Node>;
505
506    /// Atomically writes streamed bytes beginning at a logical offset.
507    async fn write_at(
508        &self,
509        ctx: &RequestContext,
510        path: &VirtualPath,
511        offset: u64,
512        source: WriteSource,
513        options: WriteOptions,
514    ) -> FsResult<Node>;
515
516    /// Atomically appends streamed bytes to a regular file.
517    async fn append(
518        &self,
519        ctx: &RequestContext,
520        path: &VirtualPath,
521        source: WriteSource,
522        options: WriteOptions,
523    ) -> FsResult<Node>;
524
525    /// Changes a regular file's logical length.
526    async fn truncate(
527        &self,
528        ctx: &RequestContext,
529        path: &VirtualPath,
530        length: u64,
531        options: MutationOptions,
532    ) -> FsResult<Node>;
533
534    /// Updates timestamps or creates an empty regular file.
535    async fn touch(
536        &self,
537        ctx: &RequestContext,
538        path: &VirtualPath,
539        options: TouchOptions,
540    ) -> FsResult<Node>;
541
542    /// Copies a node within the context's workspace.
543    async fn copy(
544        &self,
545        ctx: &RequestContext,
546        from: &VirtualPath,
547        to: &VirtualPath,
548        options: CopyOptions,
549    ) -> FsResult<Node>;
550
551    /// Moves a node within the context's workspace.
552    async fn move_path(
553        &self,
554        ctx: &RequestContext,
555        from: &VirtualPath,
556        to: &VirtualPath,
557        options: MoveOptions,
558    ) -> FsResult<Node>;
559
560    /// Permanently removes a node.
561    async fn remove(
562        &self,
563        ctx: &RequestContext,
564        path: &VirtualPath,
565        options: RemoveOptions,
566    ) -> FsResult<()>;
567
568    /// Creates a symbolic link containing a relative or absolute virtual target.
569    async fn symlink(
570        &self,
571        ctx: &RequestContext,
572        target: &LinkTarget,
573        link: &VirtualPath,
574        options: CreateOptions,
575    ) -> FsResult<Node>;
576
577    /// Returns the stored target of a symbolic link without resolving it.
578    async fn read_link(&self, ctx: &RequestContext, path: &VirtualPath) -> FsResult<LinkTarget>;
579
580    /// Moves a node into recoverable trash.
581    async fn trash(
582        &self,
583        ctx: &RequestContext,
584        path: &VirtualPath,
585        options: MutationOptions,
586    ) -> FsResult<TrashEntry>;
587
588    /// Returns one cursor-paginated page of recoverable trash records.
589    async fn list_trash(
590        &self,
591        ctx: &RequestContext,
592        page: PageRequest,
593    ) -> FsResult<Page<TrashEntry>>;
594
595    /// Restores a recoverable trash record.
596    async fn restore(
597        &self,
598        ctx: &RequestContext,
599        trash: TrashId,
600        destination: Option<&VirtualPath>,
601        options: MutationOptions,
602    ) -> FsResult<Node>;
603
604    /// Permanently purges a recoverable trash record.
605    async fn purge(&self, ctx: &RequestContext, trash: TrashId) -> FsResult<()>;
606
607    /// Sets an opaque custom attribute on a node.
608    async fn set_attribute(
609        &self,
610        ctx: &RequestContext,
611        path: &VirtualPath,
612        key: &str,
613        value: &[u8],
614        options: MutationOptions,
615    ) -> FsResult<Node>;
616
617    /// Removes a custom attribute from a node.
618    async fn remove_attribute(
619        &self,
620        ctx: &RequestContext,
621        path: &VirtualPath,
622        key: &str,
623        options: MutationOptions,
624    ) -> FsResult<Node>;
625
626    /// Returns nodes whose absolute paths match a virtual-path glob.
627    async fn glob(
628        &self,
629        ctx: &RequestContext,
630        pattern: &str,
631        page: PageRequest,
632    ) -> FsResult<Page<Node>>;
633
634    /// Returns nodes matching bounded metadata predicates.
635    async fn find(
636        &self,
637        ctx: &RequestContext,
638        query: FindQuery,
639        page: PageRequest,
640    ) -> FsResult<Page<Node>>;
641
642    /// Returns bounded literal byte matches from regular files.
643    async fn search_content(
644        &self,
645        ctx: &RequestContext,
646        query: ContentQuery,
647        page: PageRequest,
648    ) -> FsResult<Page<SearchMatch>>;
649
650    /// Executes non-streaming operations atomically in request order.
651    async fn batch(
652        &self,
653        ctx: &RequestContext,
654        operations: Vec<BatchOperation>,
655    ) -> FsResult<Vec<BatchResult>>;
656
657    /// Returns committed changes after an optional opaque cursor.
658    async fn changes(
659        &self,
660        ctx: &RequestContext,
661        after: Option<ChangeCursor>,
662        page: PageRequest,
663    ) -> FsResult<Page<Change>>;
664}