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    AttributeRevisionNo, Attributes, ChangeSeq, CommitId, ContentRef, DisplayName, InodeId,
8    NameKey, NamespaceId, RevisionNo,
9};
10use serde::{Deserialize, Serialize};
11
12/// Result of one commit.
13///
14/// Every commit resolves to this envelope — path-oriented operations and
15/// explicit commits, embedded or remote. The commit id is the caller's
16/// reconciliation handle: resubmitting the same request with the same id
17/// replays this result instead of committing twice.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20pub struct CommitResponse {
21    /// Namespace that changed.
22    pub namespace_id: NamespaceId,
23    /// Idempotency key the commit landed under: caller-supplied, or
24    /// generated on the caller's behalf when the request carried none.
25    pub commit_id: CommitId,
26    /// Sequence number where the commit became visible.
27    pub committed_seq: ChangeSeq,
28}
29
30/// Directory entry removed by a delete operation.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33pub struct DeletedDirentry {
34    /// Parent directory of the deleted entry.
35    #[serde(with = "crate::public_inode_id")]
36    #[cfg_attr(
37        feature = "openapi",
38        schema(schema_with = crate::public_inode_id::schema)
39    )]
40    pub parent_inode_id: InodeId,
41    /// Name used to look up the entry.
42    pub name_key: NameKey,
43    /// Name shown to users.
44    pub display_name: DisplayName,
45}
46
47/// One semantic filesystem change inside a commit.
48///
49/// A commit's events are the operations it applied, in the order it applied
50/// them. One request operation can apply several: creating missing parent
51/// directories, or replacing a file by moving over it, each produce an event
52/// per directory created or file replaced. So a request with three
53/// operations may report more than three events, and the events stay in
54/// request order. Events name inodes and their parent-directory bindings
55/// rather than full paths; a consumer that needs paths can stat the inode or
56/// maintain its own binding projection from this feed.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum FilesystemChange {
61    /// A directory was created.
62    #[cfg_attr(
63        feature = "openapi",
64        schema(title = "FilesystemChangeDirectoryCreated")
65    )]
66    DirectoryCreated {
67        /// Newly allocated namespace-scoped inode identity.
68        #[serde(with = "crate::public_inode_id")]
69        #[cfg_attr(
70            feature = "openapi",
71            schema(schema_with = crate::public_inode_id::schema)
72        )]
73        inode_id: InodeId,
74        /// Directory the new entry was bound under.
75        #[serde(with = "crate::public_inode_id")]
76        #[cfg_attr(
77            feature = "openapi",
78            schema(schema_with = crate::public_inode_id::schema)
79        )]
80        parent_inode_id: InodeId,
81        /// User-facing spelling of the new entry.
82        display_name: DisplayName,
83    },
84    /// A file and its first revision were created.
85    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
86    FileCreated {
87        /// Newly allocated namespace-scoped inode identity.
88        #[serde(with = "crate::public_inode_id")]
89        #[cfg_attr(
90            feature = "openapi",
91            schema(schema_with = crate::public_inode_id::schema)
92        )]
93        inode_id: InodeId,
94        /// Directory the new entry was bound under.
95        #[serde(with = "crate::public_inode_id")]
96        #[cfg_attr(
97            feature = "openapi",
98            schema(schema_with = crate::public_inode_id::schema)
99        )]
100        parent_inode_id: InodeId,
101        /// User-facing spelling of the new entry.
102        display_name: DisplayName,
103        /// First revision number.
104        revision_no: RevisionNo,
105        /// Content of the first revision.
106        content_ref: ContentRef,
107    },
108    /// A file received a new current revision — a put over an existing
109    /// file, or a revision restore (one durable fact for both).
110    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
111    ContentChanged {
112        /// File inode whose history advanced.
113        #[serde(with = "crate::public_inode_id")]
114        #[cfg_attr(
115            feature = "openapi",
116            schema(schema_with = crate::public_inode_id::schema)
117        )]
118        inode_id: InodeId,
119        /// New monotonic position in that file's revision history.
120        revision_no: RevisionNo,
121        /// Immutable content published by the revision.
122        content_ref: ContentRef,
123    },
124    /// An inode moved to a new parent directory or name.
125    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
126    Moved {
127        /// Inode whose binding changed.
128        #[serde(with = "crate::public_inode_id")]
129        #[cfg_attr(
130            feature = "openapi",
131            schema(schema_with = crate::public_inode_id::schema)
132        )]
133        inode_id: InodeId,
134        /// Directory that held the old binding.
135        #[serde(with = "crate::public_inode_id")]
136        #[cfg_attr(
137            feature = "openapi",
138            schema(schema_with = crate::public_inode_id::schema)
139        )]
140        from_parent_inode_id: InodeId,
141        /// Spelling of the old binding.
142        from_display_name: DisplayName,
143        /// Directory holding the new binding.
144        #[serde(with = "crate::public_inode_id")]
145        #[cfg_attr(
146            feature = "openapi",
147            schema(schema_with = crate::public_inode_id::schema)
148        )]
149        to_parent_inode_id: InodeId,
150        /// Spelling of the new binding.
151        to_display_name: DisplayName,
152    },
153    /// A file or directory subtree was deleted. Use the enclosing change's
154    /// `committed_seq` as `deletion_seq` when restoring it.
155    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
156    Deleted {
157        /// Inode at the root of the deleted subtree.
158        #[serde(with = "crate::public_inode_id")]
159        #[cfg_attr(
160            feature = "openapi",
161            schema(schema_with = crate::public_inode_id::schema)
162        )]
163        inode_id: InodeId,
164        /// Directory binding removed by the deletion, when the delete
165        /// recorded one.
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        deleted_direntry: Option<DeletedDirentry>,
168    },
169    /// A deleted inode was recovered and re-bound.
170    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
171    Undeleted {
172        /// Recovered inode.
173        #[serde(with = "crate::public_inode_id")]
174        #[cfg_attr(
175            feature = "openapi",
176            schema(schema_with = crate::public_inode_id::schema)
177        )]
178        inode_id: InodeId,
179        /// Directory the recovered entry was bound under.
180        #[serde(with = "crate::public_inode_id")]
181        #[cfg_attr(
182            feature = "openapi",
183            schema(schema_with = crate::public_inode_id::schema)
184        )]
185        parent_inode_id: InodeId,
186        /// Spelling of the recovered binding.
187        display_name: DisplayName,
188    },
189    /// An inode's attributes changed.
190    #[cfg_attr(
191        feature = "openapi",
192        schema(title = "FilesystemChangeAttributesChanged")
193    )]
194    AttributesChanged {
195        /// Inode whose attributes advanced.
196        #[serde(with = "crate::public_inode_id")]
197        #[cfg_attr(
198            feature = "openapi",
199            schema(schema_with = crate::public_inode_id::schema)
200        )]
201        inode_id: InodeId,
202        /// New attribute revision for that inode.
203        attributes_revision_no: AttributeRevisionNo,
204        /// The inode's complete attribute map after the update, so a consumer
205        /// projects it without reading anything back. An empty map is the
206        /// cleared state.
207        attributes: Attributes,
208    },
209}
210
211/// One committed change in namespace order.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
214pub struct CommittedChange {
215    /// Namespace sequence for this logical commit.
216    pub committed_seq: ChangeSeq,
217    /// Client idempotency key for this logical commit.
218    pub commit_id: CommitId,
219    /// Actor responsible for the commit, as supplied by the application.
220    pub actor: crate::ActorRef,
221    /// Wall-clock stamp of the commit, in Unix milliseconds.
222    /// Observational: `committed_seq` is the order.
223    pub committed_at_ms: u64,
224    /// Caller annotation, omitted when absent and carrying no filesystem semantics.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub message: Option<String>,
227    /// Semantic filesystem events for this commit, in the order the commit
228    /// applied them. One request operation may produce more than one event
229    /// (see [`FilesystemChange`]).
230    pub events: Vec<FilesystemChange>,
231}
232
233/// Change-feed response after a cursor.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
236pub struct ChangesResponse {
237    /// Namespace whose ordered commit stream was read.
238    pub namespace_id: NamespaceId,
239    /// Exclusive cursor supplied by the caller, or the endpoint's initial position.
240    pub after_seq: ChangeSeq,
241    /// Snapshot head through which this page was evaluated.
242    pub through_seq: ChangeSeq,
243    /// Cursor to request when another page remains, or `None` at `through_seq`.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub next_after_seq: Option<ChangeSeq>,
246    /// Logical commits after `after_seq`, ordered by ascending namespace sequence.
247    pub changes: Vec<CommittedChange>,
248}
249
250#[cfg(test)]
251mod tests {
252    use super::FilesystemChange;
253    use crate::InodeId;
254
255    #[test]
256    fn filesystem_change_events_use_snake_case_kind_tags() {
257        let sample_content_ref = crate::ContentRef::blob_v1(
258            crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
259                .expect("valid content id"),
260            b"hello",
261        );
262        let sample_content_ref_json = r#"{"kind":"blob_v1","content_id":"con_0123456789abcdef0123456789abcdef","size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#;
263
264        let directory_created = FilesystemChange::DirectoryCreated {
265            inode_id: InodeId(2),
266            parent_inode_id: InodeId(1),
267            display_name: crate::DisplayName::parse("Docs").expect("valid display name"),
268        };
269        assert_eq!(
270            serde_json::to_string(&directory_created).expect("serialize directory-created event"),
271            r#"{"kind":"directory_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"Docs"}"#
272        );
273
274        let file_created = FilesystemChange::FileCreated {
275            inode_id: InodeId(2),
276            parent_inode_id: InodeId(1),
277            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
278            revision_no: crate::RevisionNo(1),
279            content_ref: sample_content_ref.clone(),
280        };
281        assert_eq!(
282            serde_json::to_string(&file_created).expect("serialize file-created event"),
283            format!(
284                r#"{{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","revision_no":1,"content_ref":{sample_content_ref_json}}}"#
285            )
286        );
287
288        let missing_content_ref = r#"{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","revision_no":1}"#;
289        assert!(serde_json::from_str::<FilesystemChange>(missing_content_ref).is_err());
290
291        let retired_creation = serde_json::json!({
292            "kind": (["cre", "ated"].concat()),
293            "inode_id": "ino_2",
294            "inode_kind": "file",
295            "parent_inode_id": "ino_1",
296            "display_name": "a.txt",
297            "revision_no": 1,
298        });
299        assert!(serde_json::from_value::<FilesystemChange>(retired_creation).is_err());
300
301        let content_changed = FilesystemChange::ContentChanged {
302            inode_id: InodeId(2),
303            revision_no: crate::RevisionNo(3),
304            content_ref: sample_content_ref,
305        };
306        assert_eq!(
307            serde_json::to_string(&content_changed).expect("serialize content changed event"),
308            format!(
309                r#"{{"kind":"content_changed","inode_id":"ino_2","revision_no":3,"content_ref":{sample_content_ref_json}}}"#
310            )
311        );
312
313        let moved = FilesystemChange::Moved {
314            inode_id: InodeId(2),
315            from_parent_inode_id: InodeId(1),
316            from_display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
317            to_parent_inode_id: InodeId(3),
318            to_display_name: crate::DisplayName::parse("b.txt").expect("valid display name"),
319        };
320        assert_eq!(
321            serde_json::to_string(&moved).expect("serialize moved event"),
322            r#"{"kind":"moved","inode_id":"ino_2","from_parent_inode_id":"ino_1","from_display_name":"a.txt","to_parent_inode_id":"ino_3","to_display_name":"b.txt"}"#
323        );
324
325        let deleted = FilesystemChange::Deleted {
326            inode_id: InodeId(2),
327            deleted_direntry: Some(super::DeletedDirentry {
328                parent_inode_id: InodeId(1),
329                name_key: crate::NameKey::parse("a.txt").expect("valid name key"),
330                display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
331            }),
332        };
333        assert_eq!(
334            serde_json::to_string(&deleted).expect("serialize deleted event"),
335            r#"{"kind":"deleted","inode_id":"ino_2","deleted_direntry":{"parent_inode_id":"ino_1","name_key":"a.txt","display_name":"a.txt"}}"#
336        );
337
338        let undeleted = FilesystemChange::Undeleted {
339            inode_id: InodeId(2),
340            parent_inode_id: InodeId(1),
341            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
342        };
343        assert_eq!(
344            serde_json::to_string(&undeleted).expect("serialize undeleted event"),
345            r#"{"kind":"undeleted","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt"}"#
346        );
347
348        let attributes_changed = FilesystemChange::AttributesChanged {
349            inode_id: InodeId(2),
350            attributes_revision_no: crate::AttributeRevisionNo(4),
351            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
352                crate::AttributeKey::parse("owner").expect("valid attribute key"),
353                crate::AttributeValue::parse("ada").expect("valid attribute value"),
354            )]))
355            .expect("valid attribute map"),
356        };
357        assert_eq!(
358            serde_json::to_string(&attributes_changed).expect("serialize attributes event"),
359            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":4,"attributes":{"owner":"ada"}}"#
360        );
361
362        // A clear is a real event carrying the empty map, not an absence.
363        let cleared = FilesystemChange::AttributesChanged {
364            inode_id: InodeId(2),
365            attributes_revision_no: crate::AttributeRevisionNo(5),
366            attributes: crate::Attributes::default(),
367        };
368        assert_eq!(
369            serde_json::to_string(&cleared).expect("serialize cleared attributes event"),
370            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":5,"attributes":{}}"#
371        );
372    }
373}