Skip to main content

loonfs_api/v0/
commits.rs

1//! Commit responses and change-feed shapes for the v0 HTTP API.
2
3use crate::{
4    AccessGrants, AccessRevisionNo, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
5    CommitId, ContentRef, DisplayName, InodeId, NameKey, NamespaceId, RevisionNo,
6};
7use serde::{Deserialize, Serialize};
8
9/// One committed logical commit: its identity and the events it applied.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
12pub struct Commit {
13    /// Namespace that changed.
14    pub namespace_id: NamespaceId,
15    /// The idempotency key for the commit.
16    pub commit_id: CommitId,
17    /// Sequence number where the commit became visible.
18    pub committed_seq: ChangeSeq,
19    /// Actor responsible for the commit, as supplied by the application.
20    pub committed_by: crate::ActorId,
21    /// The commit time in Unix milliseconds; `committed_seq` defines commit order.
22    pub committed_at_ms: u64,
23    /// The optional caller annotation for the commit.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    #[cfg_attr(feature = "openapi", schema(nullable = false))]
26    pub message: Option<String>,
27    /// Always present on the change feed. Absent only from a replayed
28    /// `POST /commits` response whose WAL record has been retired.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    #[cfg_attr(feature = "openapi", schema(nullable = false))]
31    pub events: Option<Vec<FilesystemChange>>,
32}
33
34/// A directory entry's parent and name.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37pub struct DirectoryBinding {
38    /// Parent directory containing the entry.
39    #[serde(with = "crate::public_inode_id")]
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 filesystem change within a commit.
48///
49/// One request operation can produce multiple changes.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum FilesystemChange {
54    /// A directory was created.
55    #[cfg_attr(
56        feature = "openapi",
57        schema(title = "FilesystemChangeDirectoryCreated")
58    )]
59    DirectoryCreated {
60        /// Newly allocated namespace-scoped inode identity.
61        #[serde(with = "crate::public_inode_id")]
62        inode_id: InodeId,
63        /// Directory the new entry was bound under.
64        #[serde(with = "crate::public_inode_id")]
65        parent_inode_id: InodeId,
66        /// User-facing spelling of the new entry.
67        display_name: DisplayName,
68        /// Opaque identifier for the binding created by this event.
69        binding_generation: BindingGeneration,
70    },
71    /// A file and its first revision were created.
72    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
73    FileCreated {
74        /// Newly allocated namespace-scoped inode identity.
75        #[serde(with = "crate::public_inode_id")]
76        inode_id: InodeId,
77        /// Directory the new entry was bound under.
78        #[serde(with = "crate::public_inode_id")]
79        parent_inode_id: InodeId,
80        /// User-facing spelling of the new entry.
81        display_name: DisplayName,
82        /// Opaque identifier for the binding created by this event.
83        binding_generation: BindingGeneration,
84        /// First revision number.
85        revision_no: RevisionNo,
86        /// Content of the first revision.
87        content_ref: ContentRef,
88    },
89    /// A file received a new current revision from a put or revision restore.
90    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
91    ContentChanged {
92        /// File inode whose history advanced.
93        #[serde(with = "crate::public_inode_id")]
94        inode_id: InodeId,
95        /// New monotonic position in that file's revision history.
96        revision_no: RevisionNo,
97        /// Immutable content published by the revision.
98        content_ref: ContentRef,
99    },
100    /// An inode moved to a new parent directory or name.
101    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
102    Moved {
103        /// Inode whose binding changed.
104        #[serde(with = "crate::public_inode_id")]
105        inode_id: InodeId,
106        /// Directory that held the removed binding.
107        #[serde(with = "crate::public_inode_id")]
108        source_parent_inode_id: InodeId,
109        /// Spelling of the removed binding.
110        source_display_name: DisplayName,
111        /// Directory holding the new binding.
112        #[serde(with = "crate::public_inode_id")]
113        destination_parent_inode_id: InodeId,
114        /// Spelling of the new binding.
115        destination_display_name: DisplayName,
116        /// Opaque identifier for the binding created by this event.
117        binding_generation: BindingGeneration,
118    },
119    /// A file or directory subtree was deleted.
120    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
121    Deleted {
122        /// Inode at the root of the deleted subtree.
123        #[serde(with = "crate::public_inode_id")]
124        inode_id: InodeId,
125        /// Directory binding removed by the deletion.
126        deleted_binding: DirectoryBinding,
127    },
128    /// A deleted inode was recovered and re-bound.
129    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
130    Undeleted {
131        /// Recovered inode.
132        #[serde(with = "crate::public_inode_id")]
133        inode_id: InodeId,
134        /// Directory the recovered entry was bound under.
135        #[serde(with = "crate::public_inode_id")]
136        parent_inode_id: InodeId,
137        /// Spelling of the recovered binding.
138        display_name: DisplayName,
139        /// Opaque identifier for the binding created by this event.
140        binding_generation: BindingGeneration,
141    },
142    /// An inode's attributes changed.
143    #[cfg_attr(
144        feature = "openapi",
145        schema(title = "FilesystemChangeAttributesChanged")
146    )]
147    AttributesChanged {
148        /// Inode whose attributes advanced.
149        #[serde(with = "crate::public_inode_id")]
150        inode_id: InodeId,
151        /// New attribute revision for that inode.
152        attributes_revision_no: AttributeRevisionNo,
153        /// The inode's complete attribute map after the update, including an empty map
154        /// when all attributes were cleared.
155        attributes: Attributes,
156    },
157    /// An inode's access row was replaced. `grants` is the complete
158    /// direct grant map after the update.
159    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeAccessChanged"))]
160    AccessChanged {
161        /// Inode whose access state advanced.
162        #[serde(with = "crate::public_inode_id")]
163        inode_id: InodeId,
164        /// Revision published by the update.
165        access_revision_no: AccessRevisionNo,
166        /// Whether the directory stops inheritance from its ancestors.
167        boundary: bool,
168        /// The inode's complete direct grants after this update.
169        grants: AccessGrants,
170    },
171}
172
173/// Change-feed response after a cursor.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
176pub struct ListChangesResponse {
177    /// Namespace whose ordered commit stream was read.
178    pub namespace_id: NamespaceId,
179    /// Exclusive cursor supplied by the caller, or the endpoint's initial position.
180    pub after_seq: ChangeSeq,
181    /// Snapshot head through which this page was evaluated.
182    pub through_seq: ChangeSeq,
183    /// Cursor to request when another page remains, or `None` at `through_seq`.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    #[cfg_attr(feature = "openapi", schema(nullable = false))]
186    pub next_after_seq: Option<ChangeSeq>,
187    /// Logical commits after `after_seq`, ordered by ascending namespace sequence.
188    pub changes: Vec<Commit>,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{Commit, FilesystemChange};
194    use crate::{AccessRevisionNo, InodeId};
195
196    fn binding_generation() -> crate::BindingGeneration {
197        crate::BindingGeneration::parse("abcdef").expect("binding generation")
198    }
199
200    #[test]
201    fn commit_uses_committed_by_on_the_wire() {
202        let change = Commit {
203            namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
204            committed_seq: crate::ChangeSeq(7),
205            commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
206            committed_by: crate::ActorId::loonfs(),
207            committed_at_ms: 1_752_624_000_000,
208            message: None,
209            events: Some(Vec::new()),
210        };
211
212        assert_eq!(
213            serde_json::to_value(change).expect("serialize committed change"),
214            serde_json::json!({
215                "namespace_id": "demo",
216                "committed_seq": 7,
217                "commit_id": "example-commit",
218                "committed_by": "loonfs",
219                "committed_at_ms": 1_752_624_000_000_u64,
220                "events": [],
221            })
222        );
223    }
224
225    #[test]
226    fn a_commit_carries_events_at_the_top_level() {
227        let response = Commit {
228            namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
229            committed_seq: crate::ChangeSeq(419),
230            commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
231            committed_by: crate::ActorId::loonfs(),
232            committed_at_ms: 1_752_624_000_000,
233            message: Some("import the reports".to_owned()),
234            events: Some(vec![FilesystemChange::DirectoryCreated {
235                inode_id: InodeId(43),
236                parent_inode_id: InodeId(1),
237                display_name: crate::DisplayName::parse("docs").expect("valid display name"),
238                binding_generation: binding_generation(),
239            }]),
240        };
241
242        assert_eq!(
243            serde_json::to_value(response).expect("serialize commit response"),
244            serde_json::json!({
245                "namespace_id": "demo",
246                "commit_id": "example-commit",
247                "committed_seq": 419,
248                "committed_by": "loonfs",
249                "committed_at_ms": 1_752_624_000_000_u64,
250                "message": "import the reports",
251                "events": [{
252                    "kind": "directory_created",
253                    "inode_id": "ino_43",
254                    "parent_inode_id": "ino_1",
255                    "display_name": "docs",
256                    "binding_generation": binding_generation(),
257                }],
258            })
259        );
260    }
261
262    #[test]
263    fn a_commit_omits_absent_events_and_message() {
264        let response = Commit {
265            namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
266            commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
267            committed_seq: crate::ChangeSeq(419),
268            committed_by: crate::ActorId::loonfs(),
269            committed_at_ms: 1_752_624_000_000,
270            message: None,
271            events: None,
272        };
273
274        assert_eq!(
275            serde_json::to_value(response).expect("serialize commit response"),
276            serde_json::json!({
277                "namespace_id": "demo",
278                "commit_id": "example-commit",
279                "committed_seq": 419,
280                "committed_by": "loonfs",
281                "committed_at_ms": 1_752_624_000_000_u64,
282            })
283        );
284    }
285
286    #[test]
287    fn filesystem_change_events_use_snake_case_kind_tags() {
288        let sample_content_ref = crate::ContentRef::blob_v1(
289            crate::NamespaceId::parse("demo").expect("namespace id"),
290            crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
291                .expect("valid content id"),
292            b"hello",
293        );
294        let sample_content_ref_json = r#"{"kind":"blob_v1","owner_namespace_id":"demo","content_id":"con_0123456789abcdef0123456789abcdef","size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#;
295
296        let generation = binding_generation();
297        let directory_created = FilesystemChange::DirectoryCreated {
298            inode_id: InodeId(2),
299            parent_inode_id: InodeId(1),
300            display_name: crate::DisplayName::parse("Docs").expect("valid display name"),
301            binding_generation: generation.clone(),
302        };
303        assert_eq!(
304            serde_json::to_string(&directory_created).expect("serialize directory-created event"),
305            format!(
306                r#"{{"kind":"directory_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"Docs","binding_generation":"{generation}"}}"#
307            )
308        );
309
310        let file_created = FilesystemChange::FileCreated {
311            inode_id: InodeId(2),
312            parent_inode_id: InodeId(1),
313            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
314            binding_generation: generation.clone(),
315            revision_no: crate::RevisionNo(1),
316            content_ref: sample_content_ref.clone(),
317        };
318        assert_eq!(
319            serde_json::to_string(&file_created).expect("serialize file-created event"),
320            format!(
321                r#"{{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","binding_generation":"{generation}","revision_no":1,"content_ref":{sample_content_ref_json}}}"#
322            )
323        );
324
325        let missing_content_ref = r#"{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","revision_no":1}"#;
326        assert!(serde_json::from_str::<FilesystemChange>(missing_content_ref).is_err());
327
328        let retired_creation = serde_json::json!({
329            "kind": (["cre", "ated"].concat()),
330            "inode_id": "ino_2",
331            "inode_kind": "file",
332            "parent_inode_id": "ino_1",
333            "display_name": "a.txt",
334            "revision_no": 1,
335        });
336        assert!(serde_json::from_value::<FilesystemChange>(retired_creation).is_err());
337
338        let content_changed = FilesystemChange::ContentChanged {
339            inode_id: InodeId(2),
340            revision_no: crate::RevisionNo(3),
341            content_ref: sample_content_ref,
342        };
343        assert_eq!(
344            serde_json::to_string(&content_changed).expect("serialize content changed event"),
345            format!(
346                r#"{{"kind":"content_changed","inode_id":"ino_2","revision_no":3,"content_ref":{sample_content_ref_json}}}"#
347            )
348        );
349
350        let moved = FilesystemChange::Moved {
351            inode_id: InodeId(2),
352            source_parent_inode_id: InodeId(1),
353            source_display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
354            destination_parent_inode_id: InodeId(3),
355            destination_display_name: crate::DisplayName::parse("b.txt")
356                .expect("valid display name"),
357            binding_generation: generation.clone(),
358        };
359        assert_eq!(
360            serde_json::from_value::<FilesystemChange>(serde_json::json!({
361                "kind": "moved",
362                "inode_id": "ino_2",
363                "source_parent_inode_id": "ino_1",
364                "source_display_name": "a.txt",
365                "destination_parent_inode_id": "ino_3",
366                "destination_display_name": "b.txt",
367                "binding_generation": generation
368            }))
369            .expect("decode moved event"),
370            moved
371        );
372        assert_eq!(
373            serde_json::to_string(&moved).expect("serialize moved event"),
374            format!(
375                r#"{{"kind":"moved","inode_id":"ino_2","source_parent_inode_id":"ino_1","source_display_name":"a.txt","destination_parent_inode_id":"ino_3","destination_display_name":"b.txt","binding_generation":"{generation}"}}"#
376            )
377        );
378
379        let deleted = FilesystemChange::Deleted {
380            inode_id: InodeId(2),
381            deleted_binding: super::DirectoryBinding {
382                parent_inode_id: InodeId(1),
383                name_key: crate::NameKey::parse("a.txt").expect("valid name key"),
384                display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
385            },
386        };
387        assert_eq!(
388            serde_json::to_string(&deleted).expect("serialize deleted event"),
389            r#"{"kind":"deleted","inode_id":"ino_2","deleted_binding":{"parent_inode_id":"ino_1","name_key":"a.txt","display_name":"a.txt"}}"#
390        );
391
392        let undeleted = FilesystemChange::Undeleted {
393            inode_id: InodeId(2),
394            parent_inode_id: InodeId(1),
395            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
396            binding_generation: generation.clone(),
397        };
398        assert_eq!(
399            serde_json::to_string(&undeleted).expect("serialize undeleted event"),
400            format!(
401                r#"{{"kind":"undeleted","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","binding_generation":"{generation}"}}"#
402            )
403        );
404
405        let attributes_changed = FilesystemChange::AttributesChanged {
406            inode_id: InodeId(2),
407            attributes_revision_no: crate::AttributeRevisionNo(4),
408            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
409                crate::AttributeKey::parse("owner").expect("valid attribute key"),
410                crate::AttributeValue::parse("ada").expect("valid attribute value"),
411            )]))
412            .expect("valid attribute map"),
413        };
414        assert_eq!(
415            serde_json::to_string(&attributes_changed).expect("serialize attributes event"),
416            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":4,"attributes":{"owner":"ada"}}"#
417        );
418
419        // A clear is a real event carrying the empty map, not an absence.
420        let cleared = FilesystemChange::AttributesChanged {
421            inode_id: InodeId(2),
422            attributes_revision_no: crate::AttributeRevisionNo(5),
423            attributes: crate::Attributes::default(),
424        };
425        assert_eq!(
426            serde_json::to_string(&cleared).expect("serialize cleared attributes event"),
427            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":5,"attributes":{}}"#
428        );
429
430        let access_changed = FilesystemChange::AccessChanged {
431            inode_id: InodeId(2),
432            access_revision_no: AccessRevisionNo(3),
433            boundary: true,
434            grants: serde_json::from_value(serde_json::json!({"prn_ada": ["read", "write"]}))
435                .expect("grants"),
436        };
437        assert_eq!(
438            serde_json::to_string(&access_changed).expect("serialize access event"),
439            r#"{"kind":"access_changed","inode_id":"ino_2","access_revision_no":3,"boundary":true,"grants":{"prn_ada":["read","write"]}}"#
440        );
441    }
442}