Skip to main content

fslite_sqlite/
lib.rs

1//! Persistent, multi-workspace SQLite implementation of the `fslite-core` filesystem contract.
2//!
3//! One SQLite database can hold many isolated workspaces; every stored
4//! query is scoped by workspace, so two workspaces may contain identically
5//! named paths without collision and cannot observe one another's nodes,
6//! trash, attributes, or change feed.
7//!
8//! ```
9//! use fslite_core::{RequestContext, VirtualPath, WriteSource};
10//! use fslite_sqlite::SqliteFileSystem;
11//!
12//! # #[tokio::main]
13//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let fs = SqliteFileSystem::open_in_memory(Default::default()).await?;
15//! let workspace = fs.create_workspace(Default::default()).await?;
16//! let ctx = RequestContext::trusted(workspace.id);
17//!
18//! let path = VirtualPath::parse("/hello.txt")?;
19//! fs.write(&ctx, &path, WriteSource::from_bytes(b"hello".to_vec()), Default::default())
20//!     .await?;
21//! assert!(fs.exists(&ctx, &path, Default::default()).await?);
22//! # Ok(())
23//! # }
24//! ```
25
26mod backend;
27mod change;
28mod content;
29mod db;
30mod directory;
31mod mutate;
32mod resolve;
33mod search;
34mod trash;
35mod workspace;
36
37use std::path::Path;
38
39use fslite_core::{
40    BatchOperation, BatchResult, Capability, Change, ChangeCursor, ContentQuery, CopyOptions,
41    CreateOptions, FileRead, FindQuery, FsError, FsResult, LinkTarget, MoveOptions,
42    MutationOptions, Node, Page, PageRequest, ReadOptions, RemoveOptions, RequestContext,
43    SearchMatch, StatOptions, TouchOptions, TrashEntry, TrashId, TreeEntry, TreeOptions,
44    VirtualPath, WorkspaceId, WorkspaceUsage, WriteOptions, WriteSource,
45};
46
47pub use db::ConnectOptions;
48pub use fslite_core::FileSystem;
49pub use workspace::{Workspace, WorkspaceOptions};
50
51/// A persistent, multi-workspace filesystem backed by a single SQLite database.
52///
53/// One database may contain many isolated workspaces; every stored query is
54/// scoped to a workspace so no operation observes another workspace's nodes.
55pub struct SqliteFileSystem {
56    conn: tokio_rusqlite::Connection,
57}
58
59impl SqliteFileSystem {
60    /// Opens (creating if absent) a file-backed SQLite database.
61    pub async fn open(path: impl AsRef<Path>, options: ConnectOptions) -> FsResult<Self> {
62        let conn = db::open_file(path.as_ref(), options).await?;
63        Ok(Self { conn })
64    }
65
66    /// Opens a private, in-memory SQLite database.
67    pub async fn open_in_memory(options: ConnectOptions) -> FsResult<Self> {
68        let conn = db::open_memory(options).await?;
69        Ok(Self { conn })
70    }
71
72    /// Creates a new isolated workspace with its root directory.
73    pub async fn create_workspace(&self, options: WorkspaceOptions) -> FsResult<Workspace> {
74        workspace::create_workspace(&self.conn, options).await
75    }
76
77    /// Permanently deletes a workspace and everything it contains.
78    pub async fn delete_workspace(&self, workspace_id: WorkspaceId) -> FsResult<()> {
79        workspace::delete_workspace(&self.conn, workspace_id).await
80    }
81
82    /// Returns logical and quota usage for the context's workspace.
83    pub async fn workspace_usage(&self, ctx: &RequestContext) -> FsResult<WorkspaceUsage> {
84        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
85        workspace::workspace_usage(&self.conn, ctx.workspace_id).await
86    }
87
88    /// Returns metadata for a path.
89    pub async fn stat(
90        &self,
91        ctx: &RequestContext,
92        path: &VirtualPath,
93        options: StatOptions,
94    ) -> FsResult<Node> {
95        require_capability(ctx, Capability::Read, path)?;
96        directory::stat(
97            &self.conn,
98            ctx.workspace_id,
99            path.clone(),
100            options.follow_symlinks,
101        )
102        .await
103    }
104
105    /// Returns whether a path resolves to a visible node.
106    pub async fn exists(
107        &self,
108        ctx: &RequestContext,
109        path: &VirtualPath,
110        options: StatOptions,
111    ) -> FsResult<bool> {
112        require_capability(ctx, Capability::Read, path)?;
113        directory::exists(
114            &self.conn,
115            ctx.workspace_id,
116            path.clone(),
117            options.follow_symlinks,
118        )
119        .await
120    }
121
122    /// Returns one cursor-paginated page of direct directory children.
123    pub async fn read_dir(
124        &self,
125        ctx: &RequestContext,
126        path: &VirtualPath,
127        page: PageRequest,
128    ) -> FsResult<Page<Node>> {
129        require_capability(ctx, Capability::Read, path)?;
130        directory::read_dir(&self.conn, ctx.workspace_id, path.clone(), page).await
131    }
132
133    /// Returns one cursor-paginated page of a recursive tree traversal.
134    pub async fn tree(
135        &self,
136        ctx: &RequestContext,
137        path: &VirtualPath,
138        options: TreeOptions,
139        page: PageRequest,
140    ) -> FsResult<Page<TreeEntry>> {
141        require_capability(ctx, Capability::Read, path)?;
142        directory::tree(&self.conn, ctx.workspace_id, path.clone(), options, page).await
143    }
144
145    /// Creates a directory.
146    pub async fn mkdir(
147        &self,
148        ctx: &RequestContext,
149        path: &VirtualPath,
150        options: CreateOptions,
151    ) -> FsResult<Node> {
152        require_capability(ctx, Capability::Write, path)?;
153        directory::mkdir(
154            &self.conn,
155            ctx.workspace_id,
156            path.clone(),
157            options,
158            actor_json(ctx),
159        )
160        .await
161    }
162
163    /// Opens a bounded-memory byte stream for a regular file.
164    pub async fn read(
165        &self,
166        ctx: &RequestContext,
167        path: &VirtualPath,
168        options: ReadOptions,
169    ) -> FsResult<FileRead> {
170        require_capability(ctx, Capability::Read, path)?;
171        content::read(&self.conn, ctx.workspace_id, path.clone(), options).await
172    }
173
174    /// Atomically creates or replaces a regular file from a byte stream.
175    pub async fn write(
176        &self,
177        ctx: &RequestContext,
178        path: &VirtualPath,
179        source: WriteSource,
180        options: WriteOptions,
181    ) -> FsResult<Node> {
182        require_capability(ctx, Capability::Write, path)?;
183        content::write(
184            &self.conn,
185            ctx.workspace_id,
186            path.clone(),
187            source,
188            options,
189            actor_json(ctx),
190        )
191        .await
192    }
193
194    /// Atomically writes streamed bytes beginning at a logical offset.
195    pub async fn write_at(
196        &self,
197        ctx: &RequestContext,
198        path: &VirtualPath,
199        offset: u64,
200        source: WriteSource,
201        options: WriteOptions,
202    ) -> FsResult<Node> {
203        require_capability(ctx, Capability::Write, path)?;
204        content::write_at(
205            &self.conn,
206            ctx.workspace_id,
207            path.clone(),
208            offset,
209            source,
210            options,
211            actor_json(ctx),
212        )
213        .await
214    }
215
216    /// Atomically appends streamed bytes to a regular file.
217    pub async fn append(
218        &self,
219        ctx: &RequestContext,
220        path: &VirtualPath,
221        source: WriteSource,
222        options: WriteOptions,
223    ) -> FsResult<Node> {
224        require_capability(ctx, Capability::Write, path)?;
225        content::append(
226            &self.conn,
227            ctx.workspace_id,
228            path.clone(),
229            source,
230            options,
231            actor_json(ctx),
232        )
233        .await
234    }
235
236    /// Changes a regular file's logical length.
237    pub async fn truncate(
238        &self,
239        ctx: &RequestContext,
240        path: &VirtualPath,
241        length: u64,
242        options: MutationOptions,
243    ) -> FsResult<Node> {
244        require_capability(ctx, Capability::Write, path)?;
245        content::truncate(
246            &self.conn,
247            ctx.workspace_id,
248            path.clone(),
249            length,
250            options,
251            actor_json(ctx),
252        )
253        .await
254    }
255
256    /// Updates timestamps or creates an empty regular file.
257    pub async fn touch(
258        &self,
259        ctx: &RequestContext,
260        path: &VirtualPath,
261        options: TouchOptions,
262    ) -> FsResult<Node> {
263        require_capability(ctx, Capability::Write, path)?;
264        content::touch(
265            &self.conn,
266            ctx.workspace_id,
267            path.clone(),
268            options,
269            actor_json(ctx),
270        )
271        .await
272    }
273
274    /// Copies a node within the context's workspace.
275    pub async fn copy(
276        &self,
277        ctx: &RequestContext,
278        from: &VirtualPath,
279        to: &VirtualPath,
280        options: CopyOptions,
281    ) -> FsResult<Node> {
282        require_capability(ctx, Capability::Write, to)?;
283        mutate::copy(
284            &self.conn,
285            ctx.workspace_id,
286            from.clone(),
287            to.clone(),
288            options,
289            actor_json(ctx),
290        )
291        .await
292    }
293
294    /// Moves a node within the context's workspace.
295    pub async fn move_path(
296        &self,
297        ctx: &RequestContext,
298        from: &VirtualPath,
299        to: &VirtualPath,
300        options: MoveOptions,
301    ) -> FsResult<Node> {
302        require_capability(ctx, Capability::Write, to)?;
303        mutate::move_path(
304            &self.conn,
305            ctx.workspace_id,
306            from.clone(),
307            to.clone(),
308            options,
309            actor_json(ctx),
310        )
311        .await
312    }
313
314    /// Permanently removes a node.
315    pub async fn remove(
316        &self,
317        ctx: &RequestContext,
318        path: &VirtualPath,
319        options: RemoveOptions,
320    ) -> FsResult<()> {
321        require_capability(ctx, Capability::Delete, path)?;
322        mutate::remove(
323            &self.conn,
324            ctx.workspace_id,
325            path.clone(),
326            options,
327            actor_json(ctx),
328        )
329        .await
330    }
331
332    /// Creates a symbolic link containing a relative or absolute virtual target.
333    pub async fn symlink(
334        &self,
335        ctx: &RequestContext,
336        target: &LinkTarget,
337        link: &VirtualPath,
338        options: CreateOptions,
339    ) -> FsResult<Node> {
340        require_capability(ctx, Capability::Write, link)?;
341        mutate::symlink(
342            &self.conn,
343            ctx.workspace_id,
344            target.clone(),
345            link.clone(),
346            options,
347            actor_json(ctx),
348        )
349        .await
350    }
351
352    /// Returns the stored target of a symbolic link without resolving it.
353    pub async fn read_link(
354        &self,
355        ctx: &RequestContext,
356        path: &VirtualPath,
357    ) -> FsResult<LinkTarget> {
358        require_capability(ctx, Capability::Read, path)?;
359        mutate::read_link(&self.conn, ctx.workspace_id, path.clone()).await
360    }
361
362    /// Moves a node into recoverable trash.
363    pub async fn trash(
364        &self,
365        ctx: &RequestContext,
366        path: &VirtualPath,
367        options: MutationOptions,
368    ) -> FsResult<TrashEntry> {
369        require_capability(ctx, Capability::TrashRestore, path)?;
370        trash::trash(
371            &self.conn,
372            ctx.workspace_id,
373            path.clone(),
374            options,
375            actor_json(ctx),
376        )
377        .await
378    }
379
380    /// Returns one cursor-paginated page of recoverable trash records.
381    pub async fn list_trash(
382        &self,
383        ctx: &RequestContext,
384        page: PageRequest,
385    ) -> FsResult<Page<TrashEntry>> {
386        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
387        trash::list_trash(&self.conn, ctx.workspace_id, page).await
388    }
389
390    /// Restores a recoverable trash record.
391    pub async fn restore(
392        &self,
393        ctx: &RequestContext,
394        trash_id: TrashId,
395        destination: Option<&VirtualPath>,
396        options: MutationOptions,
397    ) -> FsResult<Node> {
398        require_capability(ctx, Capability::TrashRestore, ctx.workspace_id)?;
399        trash::restore(
400            &self.conn,
401            ctx.workspace_id,
402            trash_id,
403            destination.cloned(),
404            options,
405            actor_json(ctx),
406        )
407        .await
408    }
409
410    /// Permanently purges a recoverable trash record.
411    pub async fn purge(&self, ctx: &RequestContext, trash_id: TrashId) -> FsResult<()> {
412        require_capability(ctx, Capability::Delete, ctx.workspace_id)?;
413        trash::purge(&self.conn, ctx.workspace_id, trash_id, actor_json(ctx)).await
414    }
415
416    /// Sets an opaque custom attribute on a node.
417    pub async fn set_attribute(
418        &self,
419        ctx: &RequestContext,
420        path: &VirtualPath,
421        key: &str,
422        value: &[u8],
423        options: MutationOptions,
424    ) -> FsResult<Node> {
425        require_capability(ctx, Capability::Write, path)?;
426        mutate::set_attribute(
427            &self.conn,
428            ctx.workspace_id,
429            path.clone(),
430            key.to_string(),
431            value.to_vec(),
432            options,
433            actor_json(ctx),
434        )
435        .await
436    }
437
438    /// Removes a custom attribute from a node.
439    pub async fn remove_attribute(
440        &self,
441        ctx: &RequestContext,
442        path: &VirtualPath,
443        key: &str,
444        options: MutationOptions,
445    ) -> FsResult<Node> {
446        require_capability(ctx, Capability::Write, path)?;
447        mutate::remove_attribute(
448            &self.conn,
449            ctx.workspace_id,
450            path.clone(),
451            key.to_string(),
452            options,
453            actor_json(ctx),
454        )
455        .await
456    }
457
458    /// Executes non-streaming operations atomically in request order.
459    pub async fn batch(
460        &self,
461        ctx: &RequestContext,
462        operations: Vec<BatchOperation>,
463    ) -> FsResult<Vec<BatchResult>> {
464        mutate::batch(&self.conn, ctx, operations, actor_json(ctx)).await
465    }
466
467    /// Returns nodes whose absolute paths match a virtual-path glob.
468    pub async fn glob(
469        &self,
470        ctx: &RequestContext,
471        pattern: &str,
472        page: PageRequest,
473    ) -> FsResult<Page<Node>> {
474        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
475        search::glob(&self.conn, ctx.workspace_id, pattern.to_string(), page).await
476    }
477
478    /// Returns nodes matching bounded metadata predicates.
479    pub async fn find(
480        &self,
481        ctx: &RequestContext,
482        query: FindQuery,
483        page: PageRequest,
484    ) -> FsResult<Page<Node>> {
485        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
486        search::find(&self.conn, ctx.workspace_id, query, page).await
487    }
488
489    /// Returns bounded literal byte matches from regular files.
490    pub async fn search_content(
491        &self,
492        ctx: &RequestContext,
493        query: ContentQuery,
494        page: PageRequest,
495    ) -> FsResult<Page<SearchMatch>> {
496        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
497        search::search_content(&self.conn, ctx.workspace_id, query, page).await
498    }
499
500    /// Returns committed changes after an optional opaque cursor.
501    pub async fn changes(
502        &self,
503        ctx: &RequestContext,
504        after: Option<ChangeCursor>,
505        page: PageRequest,
506    ) -> FsResult<Page<Change>> {
507        require_capability(ctx, Capability::Read, ctx.workspace_id)?;
508        change::changes(&self.conn, ctx.workspace_id, after, page).await
509    }
510}
511
512fn actor_json(ctx: &RequestContext) -> String {
513    serde_json::to_string(&ctx.actor_metadata).unwrap_or_else(|_| "{}".to_string())
514}
515
516fn require_capability(
517    ctx: &RequestContext,
518    capability: Capability,
519    subject: impl std::fmt::Display,
520) -> FsResult<()> {
521    if ctx.has_capability(capability) {
522        Ok(())
523    } else {
524        Err(FsError::permission_denied(subject))
525    }
526}