Skip to main content

loonfs_api/v0/
commits.rs

1//! The commit shapes for the v0 HTTP API: the envelope every
2//! commit resolves to, and the ordered feed of semantic filesystem events
3//! those commits produce. Path-oriented request shapes live in
4//! [`super::operations`].
5
6use crate::{
7    ChangeSeq, CommitId, ContentRef, DisplayName, InodeId, InodeKind, NamespaceId, RevisionNo,
8};
9use serde::{Deserialize, Serialize};
10
11/// Result of one commit.
12///
13/// Every commit resolves to this envelope — path-oriented operations and
14/// explicit commits, embedded or remote. The commit id is the caller's
15/// reconciliation handle: resubmitting the same request with the same id
16/// replays this result instead of committing twice.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19pub struct CommitResponse {
20    /// Namespace that changed.
21    pub namespace_id: NamespaceId,
22    /// Idempotency key the commit landed under: caller-supplied, or
23    /// generated on the caller's behalf when the request carried none.
24    pub commit_id: CommitId,
25    /// Sequence number where the commit became visible.
26    pub committed_seq: ChangeSeq,
27}
28
29/// One semantic filesystem change inside a commit.
30///
31/// Each event corresponds to one operation of the committed request, in
32/// request order. Events name inodes and their parent-directory bindings
33/// rather than full paths; a consumer that needs paths can stat the inode
34/// or maintain its own binding projection from this feed.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(tag = "kind", rename_all = "snake_case")]
38pub enum FilesystemChange {
39    /// A file or directory was created.
40    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeCreated"))]
41    Created {
42        /// Newly allocated namespace-scoped inode identity.
43        inode_id: InodeId,
44        /// File-or-directory classification fixed at creation.
45        inode_kind: InodeKind,
46        /// Directory the new entry was bound under.
47        parent_inode_id: InodeId,
48        /// User-facing spelling of the new entry.
49        name: DisplayName,
50        /// First revision number, for file creations.
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        revision_no: Option<RevisionNo>,
53        /// Content of the first revision, for file creations.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        content_ref: Option<ContentRef>,
56    },
57    /// A file received a new current revision — a put over an existing
58    /// file, or a revision restore (one durable fact for both).
59    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
60    ContentChanged {
61        /// File inode whose history advanced.
62        inode_id: InodeId,
63        /// New monotonic position in that file's revision history.
64        revision_no: RevisionNo,
65        /// Immutable content published by the revision.
66        content_ref: ContentRef,
67    },
68    /// An inode moved to a new parent directory or name.
69    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
70    Moved {
71        /// Inode whose binding changed.
72        inode_id: InodeId,
73        /// Directory that held the old binding.
74        from_parent_inode_id: InodeId,
75        /// Spelling of the old binding.
76        from_name: DisplayName,
77        /// Directory holding the new binding.
78        to_parent_inode_id: InodeId,
79        /// Spelling of the new binding.
80        to_name: DisplayName,
81    },
82    /// A file or directory subtree was deleted. The enclosing change's
83    /// `seq` is the deletion generation an undelete request passes as
84    /// `deleted_at_seq`.
85    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
86    Deleted {
87        /// Inode at the root of the deleted subtree.
88        inode_id: InodeId,
89        /// Directory that held the deleted binding, when the delete
90        /// recorded one.
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        parent_inode_id: Option<InodeId>,
93        /// Spelling of the deleted binding, when the delete recorded one.
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        name: Option<DisplayName>,
96    },
97    /// A deleted inode was recovered and re-bound.
98    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
99    Undeleted {
100        /// Recovered inode.
101        inode_id: InodeId,
102        /// Directory the recovered entry was bound under.
103        parent_inode_id: InodeId,
104        /// Spelling of the recovered binding.
105        name: DisplayName,
106    },
107}
108
109/// One committed change in namespace order.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
112pub struct CommittedChange {
113    /// Namespace sequence for this logical commit.
114    pub seq: ChangeSeq,
115    /// Client idempotency key for this logical commit.
116    pub commit_id: CommitId,
117    /// Wall-clock stamp of the commit, in Unix milliseconds.
118    /// Observational: `seq` is the order.
119    pub committed_at_ms: u64,
120    /// Caller annotation, omitted when absent and carrying no filesystem semantics.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub message: Option<String>,
123    /// Semantic filesystem events, one per committed operation, in
124    /// request-operation order.
125    pub events: Vec<FilesystemChange>,
126}
127
128/// Change-feed response after a cursor.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
131pub struct ChangesResponse {
132    /// Namespace whose ordered commit stream was read.
133    pub namespace_id: NamespaceId,
134    /// Exclusive cursor supplied by the caller, or the endpoint's initial position.
135    pub after_seq: ChangeSeq,
136    /// Snapshot head through which this page was evaluated.
137    pub through_seq: ChangeSeq,
138    /// Cursor to request when another page remains, or `None` at `through_seq`.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub next_after_seq: Option<ChangeSeq>,
141    /// Logical commits after `after_seq`, ordered by ascending namespace sequence.
142    pub changes: Vec<CommittedChange>,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::FilesystemChange;
148    use crate::{InodeId, InodeKind};
149
150    #[test]
151    fn filesystem_change_events_use_snake_case_kind_tags() {
152        let sample_content_ref = crate::ContentRef::blob_v1(
153            crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
154                .expect("valid content id"),
155            b"hello",
156        );
157        let sample_content_ref_json = r#"{"kind":"blob_v1","content_id":"con_0123456789abcdef0123456789abcdef","size_bytes":5,"storage_checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},"whole_file_sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}"#;
158
159        let created = FilesystemChange::Created {
160            inode_id: InodeId(2),
161            inode_kind: InodeKind::Directory,
162            parent_inode_id: InodeId(1),
163            name: crate::DisplayName::parse("Docs").expect("valid display name"),
164            revision_no: None,
165            content_ref: None,
166        };
167        assert_eq!(
168            serde_json::to_string(&created).expect("serialize created event"),
169            r#"{"kind":"created","inode_id":2,"inode_kind":"dir","parent_inode_id":1,"name":"Docs"}"#
170        );
171
172        let created_file = FilesystemChange::Created {
173            inode_id: InodeId(2),
174            inode_kind: InodeKind::File,
175            parent_inode_id: InodeId(1),
176            name: crate::DisplayName::parse("a.txt").expect("valid display name"),
177            revision_no: Some(crate::RevisionNo(1)),
178            content_ref: Some(sample_content_ref.clone()),
179        };
180        assert_eq!(
181            serde_json::to_string(&created_file).expect("serialize created file event"),
182            format!(
183                r#"{{"kind":"created","inode_id":2,"inode_kind":"file","parent_inode_id":1,"name":"a.txt","revision_no":1,"content_ref":{sample_content_ref_json}}}"#
184            )
185        );
186
187        let content_changed = FilesystemChange::ContentChanged {
188            inode_id: InodeId(2),
189            revision_no: crate::RevisionNo(3),
190            content_ref: sample_content_ref,
191        };
192        assert_eq!(
193            serde_json::to_string(&content_changed).expect("serialize content changed event"),
194            format!(
195                r#"{{"kind":"content_changed","inode_id":2,"revision_no":3,"content_ref":{sample_content_ref_json}}}"#
196            )
197        );
198
199        let moved = FilesystemChange::Moved {
200            inode_id: InodeId(2),
201            from_parent_inode_id: InodeId(1),
202            from_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
203            to_parent_inode_id: InodeId(3),
204            to_name: crate::DisplayName::parse("b.txt").expect("valid display name"),
205        };
206        assert_eq!(
207            serde_json::to_string(&moved).expect("serialize moved event"),
208            r#"{"kind":"moved","inode_id":2,"from_parent_inode_id":1,"from_name":"a.txt","to_parent_inode_id":3,"to_name":"b.txt"}"#
209        );
210
211        let deleted = FilesystemChange::Deleted {
212            inode_id: InodeId(2),
213            parent_inode_id: Some(InodeId(1)),
214            name: Some(crate::DisplayName::parse("a.txt").expect("valid display name")),
215        };
216        assert_eq!(
217            serde_json::to_string(&deleted).expect("serialize deleted event"),
218            r#"{"kind":"deleted","inode_id":2,"parent_inode_id":1,"name":"a.txt"}"#
219        );
220
221        let undeleted = FilesystemChange::Undeleted {
222            inode_id: InodeId(2),
223            parent_inode_id: InodeId(1),
224            name: crate::DisplayName::parse("a.txt").expect("valid display name"),
225        };
226        assert_eq!(
227            serde_json::to_string(&undeleted).expect("serialize undeleted event"),
228            r#"{"kind":"undeleted","inode_id":2,"parent_inode_id":1,"name":"a.txt"}"#
229        );
230    }
231}