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///
19/// The response includes the same attribution and events as
20/// [`CommittedChange`], including IDs created by the commit.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct CommitResponse {
24    /// Namespace that changed.
25    pub namespace_id: NamespaceId,
26    /// Idempotency key the commit landed under: caller-supplied, or
27    /// generated on the caller's behalf when the request carried none.
28    pub commit_id: CommitId,
29    /// Sequence number where the commit became visible.
30    pub committed_seq: ChangeSeq,
31    /// Actor responsible for the commit, as supplied by the application.
32    pub committed_by: crate::ActorRef,
33    /// Wall-clock stamp of the commit, in Unix milliseconds.
34    /// Observational: `committed_seq` is the order.
35    pub committed_at_ms: u64,
36    /// Caller annotation, omitted when absent and carrying no filesystem
37    /// semantics.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    #[cfg_attr(feature = "openapi", schema(nullable = false))]
40    pub message: Option<String>,
41    /// Semantic filesystem events in commit order. This is omitted only when
42    /// replaying a commit whose WAL history is no longer retained.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[cfg_attr(feature = "openapi", schema(nullable = false))]
45    pub events: Option<Vec<FilesystemChange>>,
46}
47
48impl CommitResponse {
49    /// Builds the response for a commit the change feed reports in full.
50    pub fn from_committed_change(namespace_id: NamespaceId, change: CommittedChange) -> Self {
51        Self {
52            namespace_id,
53            commit_id: change.commit_id,
54            committed_seq: change.committed_seq,
55            committed_by: change.committed_by,
56            committed_at_ms: change.committed_at_ms,
57            message: change.message,
58            events: Some(change.events),
59        }
60    }
61}
62
63/// A directory entry's parent and name.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66pub struct DirectoryBinding {
67    /// Parent directory containing the entry.
68    #[serde(with = "crate::public_inode_id")]
69    #[cfg_attr(
70        feature = "openapi",
71        schema(schema_with = crate::public_inode_id::schema)
72    )]
73    pub parent_inode_id: InodeId,
74    /// Name used to look up the entry.
75    pub name_key: NameKey,
76    /// Name shown to users.
77    pub display_name: DisplayName,
78}
79
80/// One semantic filesystem change inside a commit.
81///
82/// A commit's events are the operations it applied, in the order it applied
83/// them. One request operation can apply several: creating missing parent
84/// directories, or replacing a file by moving over it, each produce an event
85/// per directory created or file replaced. So a request with three
86/// operations may report more than three events, and the events stay in
87/// request order. Events name inodes and their parent-directory bindings
88/// rather than full paths; a consumer that needs paths can stat the inode or
89/// maintain its own binding projection from this feed.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92#[serde(tag = "kind", rename_all = "snake_case")]
93pub enum FilesystemChange {
94    /// A directory was created.
95    #[cfg_attr(
96        feature = "openapi",
97        schema(title = "FilesystemChangeDirectoryCreated")
98    )]
99    DirectoryCreated {
100        /// Newly allocated namespace-scoped inode identity.
101        #[serde(with = "crate::public_inode_id")]
102        #[cfg_attr(
103            feature = "openapi",
104            schema(schema_with = crate::public_inode_id::schema)
105        )]
106        inode_id: InodeId,
107        /// Directory the new entry was bound under.
108        #[serde(with = "crate::public_inode_id")]
109        #[cfg_attr(
110            feature = "openapi",
111            schema(schema_with = crate::public_inode_id::schema)
112        )]
113        parent_inode_id: InodeId,
114        /// User-facing spelling of the new entry.
115        display_name: DisplayName,
116        /// Opaque identifier for the binding created by this event.
117        binding_generation: String,
118    },
119    /// A file and its first revision were created.
120    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
121    FileCreated {
122        /// Newly allocated namespace-scoped inode identity.
123        #[serde(with = "crate::public_inode_id")]
124        #[cfg_attr(
125            feature = "openapi",
126            schema(schema_with = crate::public_inode_id::schema)
127        )]
128        inode_id: InodeId,
129        /// Directory the new entry was bound under.
130        #[serde(with = "crate::public_inode_id")]
131        #[cfg_attr(
132            feature = "openapi",
133            schema(schema_with = crate::public_inode_id::schema)
134        )]
135        parent_inode_id: InodeId,
136        /// User-facing spelling of the new entry.
137        display_name: DisplayName,
138        /// Opaque identifier for the binding created by this event.
139        binding_generation: String,
140        /// First revision number.
141        revision_no: RevisionNo,
142        /// Content of the first revision.
143        content_ref: ContentRef,
144    },
145    /// A file received a new current revision — a put over an existing
146    /// file, or a revision restore (one durable fact for both).
147    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
148    ContentChanged {
149        /// File inode whose history advanced.
150        #[serde(with = "crate::public_inode_id")]
151        #[cfg_attr(
152            feature = "openapi",
153            schema(schema_with = crate::public_inode_id::schema)
154        )]
155        inode_id: InodeId,
156        /// New monotonic position in that file's revision history.
157        revision_no: RevisionNo,
158        /// Immutable content published by the revision.
159        content_ref: ContentRef,
160    },
161    /// An inode moved to a new parent directory or name.
162    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
163    Moved {
164        /// Inode whose binding changed.
165        #[serde(with = "crate::public_inode_id")]
166        #[cfg_attr(
167            feature = "openapi",
168            schema(schema_with = crate::public_inode_id::schema)
169        )]
170        inode_id: InodeId,
171        /// Directory that held the old binding.
172        #[serde(with = "crate::public_inode_id")]
173        #[cfg_attr(
174            feature = "openapi",
175            schema(schema_with = crate::public_inode_id::schema)
176        )]
177        from_parent_inode_id: InodeId,
178        /// Spelling of the old binding.
179        from_display_name: DisplayName,
180        /// Directory holding the new binding.
181        #[serde(with = "crate::public_inode_id")]
182        #[cfg_attr(
183            feature = "openapi",
184            schema(schema_with = crate::public_inode_id::schema)
185        )]
186        to_parent_inode_id: InodeId,
187        /// Spelling of the new binding.
188        to_display_name: DisplayName,
189        /// Opaque identifier for the binding created by this event.
190        binding_generation: String,
191    },
192    /// A file or directory subtree was deleted. Use the enclosing change's
193    /// `committed_seq` as `deletion_seq` when restoring it.
194    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
195    Deleted {
196        /// Inode at the root of the deleted subtree.
197        #[serde(with = "crate::public_inode_id")]
198        #[cfg_attr(
199            feature = "openapi",
200            schema(schema_with = crate::public_inode_id::schema)
201        )]
202        inode_id: InodeId,
203        /// Directory binding removed by the deletion, when the delete
204        /// recorded one.
205        #[serde(default, skip_serializing_if = "Option::is_none")]
206        #[cfg_attr(feature = "openapi", schema(nullable = false))]
207        deleted_binding: Option<DirectoryBinding>,
208    },
209    /// A deleted inode was recovered and re-bound.
210    #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
211    Undeleted {
212        /// Recovered inode.
213        #[serde(with = "crate::public_inode_id")]
214        #[cfg_attr(
215            feature = "openapi",
216            schema(schema_with = crate::public_inode_id::schema)
217        )]
218        inode_id: InodeId,
219        /// Directory the recovered entry was bound under.
220        #[serde(with = "crate::public_inode_id")]
221        #[cfg_attr(
222            feature = "openapi",
223            schema(schema_with = crate::public_inode_id::schema)
224        )]
225        parent_inode_id: InodeId,
226        /// Spelling of the recovered binding.
227        display_name: DisplayName,
228        /// Opaque identifier for the binding created by this event.
229        binding_generation: String,
230    },
231    /// An inode's attributes changed.
232    #[cfg_attr(
233        feature = "openapi",
234        schema(title = "FilesystemChangeAttributesChanged")
235    )]
236    AttributesChanged {
237        /// Inode whose attributes advanced.
238        #[serde(with = "crate::public_inode_id")]
239        #[cfg_attr(
240            feature = "openapi",
241            schema(schema_with = crate::public_inode_id::schema)
242        )]
243        inode_id: InodeId,
244        /// New attribute revision for that inode.
245        attributes_revision_no: AttributeRevisionNo,
246        /// The inode's complete attribute map after the update, so a consumer
247        /// projects it without reading anything back. An empty map is the
248        /// cleared state.
249        attributes: Attributes,
250    },
251}
252
253/// One committed change in namespace order.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
256pub struct CommittedChange {
257    /// Namespace sequence for this logical commit.
258    pub committed_seq: ChangeSeq,
259    /// Client idempotency key for this logical commit.
260    pub commit_id: CommitId,
261    /// Actor responsible for the commit, as supplied by the application.
262    pub committed_by: crate::ActorRef,
263    /// Wall-clock stamp of the commit, in Unix milliseconds.
264    /// Observational: `committed_seq` is the order.
265    pub committed_at_ms: u64,
266    /// Caller annotation, omitted when absent and carrying no filesystem semantics.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub message: Option<String>,
269    /// Semantic filesystem events for this commit, in the order the commit
270    /// applied them. One request operation may produce more than one event
271    /// (see [`FilesystemChange`]).
272    pub events: Vec<FilesystemChange>,
273}
274
275/// Change-feed response after a cursor.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
278pub struct ListChangesResponse {
279    /// Namespace whose ordered commit stream was read.
280    pub namespace_id: NamespaceId,
281    /// Exclusive cursor supplied by the caller, or the endpoint's initial position.
282    pub after_seq: ChangeSeq,
283    /// Snapshot head through which this page was evaluated.
284    pub through_seq: ChangeSeq,
285    /// Cursor to request when another page remains, or `None` at `through_seq`.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    #[cfg_attr(feature = "openapi", schema(nullable = false))]
288    pub next_after_seq: Option<ChangeSeq>,
289    /// Logical commits after `after_seq`, ordered by ascending namespace sequence.
290    pub changes: Vec<CommittedChange>,
291}
292
293#[cfg(test)]
294mod tests {
295    use super::{CommitResponse, CommittedChange, FilesystemChange};
296    use crate::InodeId;
297
298    fn binding_generation() -> String {
299        "generation".to_owned()
300    }
301
302    #[test]
303    fn committed_change_uses_committed_by_on_the_wire() {
304        let change = CommittedChange {
305            committed_seq: crate::ChangeSeq(7),
306            commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
307            committed_by: crate::ActorRef::loonfs_system(),
308            committed_at_ms: 1_752_624_000_000,
309            message: None,
310            events: Vec::new(),
311        };
312
313        assert_eq!(
314            serde_json::to_value(change).expect("serialize committed change"),
315            serde_json::json!({
316                "committed_seq": 7,
317                "commit_id": "example-commit",
318                "committed_by": { "kind": "system", "id": "loonfs" },
319                "committed_at_ms": 1_752_624_000_000_u64,
320                "events": [],
321            })
322        );
323    }
324
325    #[test]
326    fn a_commit_response_carries_the_committed_change_at_the_top_level() {
327        let response = CommitResponse::from_committed_change(
328            crate::NamespaceId::parse("demo").expect("valid namespace id"),
329            CommittedChange {
330                committed_seq: crate::ChangeSeq(419),
331                commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
332                committed_by: crate::ActorRef::loonfs_system(),
333                committed_at_ms: 1_752_624_000_000,
334                message: Some("import the reports".to_owned()),
335                events: vec![FilesystemChange::DirectoryCreated {
336                    inode_id: InodeId(43),
337                    parent_inode_id: InodeId(1),
338                    display_name: crate::DisplayName::parse("docs").expect("valid display name"),
339                    binding_generation: binding_generation(),
340                }],
341            },
342        );
343
344        assert_eq!(
345            serde_json::to_value(response).expect("serialize commit response"),
346            serde_json::json!({
347                "namespace_id": "demo",
348                "commit_id": "example-commit",
349                "committed_seq": 419,
350                "committed_by": { "kind": "system", "id": "loonfs" },
351                "committed_at_ms": 1_752_624_000_000_u64,
352                "message": "import the reports",
353                "events": [{
354                    "kind": "directory_created",
355                    "inode_id": "ino_43",
356                    "parent_inode_id": "ino_1",
357                    "display_name": "docs",
358                    "binding_generation": binding_generation(),
359                }],
360            })
361        );
362    }
363
364    #[test]
365    fn a_commit_response_omits_absent_events_and_message() {
366        let response = CommitResponse {
367            namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
368            commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
369            committed_seq: crate::ChangeSeq(419),
370            committed_by: crate::ActorRef::loonfs_system(),
371            committed_at_ms: 1_752_624_000_000,
372            message: None,
373            events: None,
374        };
375
376        assert_eq!(
377            serde_json::to_value(response).expect("serialize commit response"),
378            serde_json::json!({
379                "namespace_id": "demo",
380                "commit_id": "example-commit",
381                "committed_seq": 419,
382                "committed_by": { "kind": "system", "id": "loonfs" },
383                "committed_at_ms": 1_752_624_000_000_u64,
384            })
385        );
386    }
387
388    #[test]
389    fn filesystem_change_events_use_snake_case_kind_tags() {
390        let sample_content_ref = crate::ContentRef::blob_v1(
391            crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
392                .expect("valid content id"),
393            b"hello",
394        );
395        let sample_content_ref_json = r#"{"kind":"blob_v1","content_id":"con_0123456789abcdef0123456789abcdef","size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#;
396
397        let generation = binding_generation();
398        let directory_created = FilesystemChange::DirectoryCreated {
399            inode_id: InodeId(2),
400            parent_inode_id: InodeId(1),
401            display_name: crate::DisplayName::parse("Docs").expect("valid display name"),
402            binding_generation: generation.clone(),
403        };
404        assert_eq!(
405            serde_json::to_string(&directory_created).expect("serialize directory-created event"),
406            format!(
407                r#"{{"kind":"directory_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"Docs","binding_generation":"{generation}"}}"#
408            )
409        );
410
411        let file_created = FilesystemChange::FileCreated {
412            inode_id: InodeId(2),
413            parent_inode_id: InodeId(1),
414            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
415            binding_generation: generation.clone(),
416            revision_no: crate::RevisionNo(1),
417            content_ref: sample_content_ref.clone(),
418        };
419        assert_eq!(
420            serde_json::to_string(&file_created).expect("serialize file-created event"),
421            format!(
422                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}}}"#
423            )
424        );
425
426        let missing_content_ref = r#"{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","revision_no":1}"#;
427        assert!(serde_json::from_str::<FilesystemChange>(missing_content_ref).is_err());
428
429        let retired_creation = serde_json::json!({
430            "kind": (["cre", "ated"].concat()),
431            "inode_id": "ino_2",
432            "inode_kind": "file",
433            "parent_inode_id": "ino_1",
434            "display_name": "a.txt",
435            "revision_no": 1,
436        });
437        assert!(serde_json::from_value::<FilesystemChange>(retired_creation).is_err());
438
439        let content_changed = FilesystemChange::ContentChanged {
440            inode_id: InodeId(2),
441            revision_no: crate::RevisionNo(3),
442            content_ref: sample_content_ref,
443        };
444        assert_eq!(
445            serde_json::to_string(&content_changed).expect("serialize content changed event"),
446            format!(
447                r#"{{"kind":"content_changed","inode_id":"ino_2","revision_no":3,"content_ref":{sample_content_ref_json}}}"#
448            )
449        );
450
451        let moved = FilesystemChange::Moved {
452            inode_id: InodeId(2),
453            from_parent_inode_id: InodeId(1),
454            from_display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
455            to_parent_inode_id: InodeId(3),
456            to_display_name: crate::DisplayName::parse("b.txt").expect("valid display name"),
457            binding_generation: generation.clone(),
458        };
459        assert_eq!(
460            serde_json::to_string(&moved).expect("serialize moved event"),
461            format!(
462                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","binding_generation":"{generation}"}}"#
463            )
464        );
465
466        let deleted = FilesystemChange::Deleted {
467            inode_id: InodeId(2),
468            deleted_binding: Some(super::DirectoryBinding {
469                parent_inode_id: InodeId(1),
470                name_key: crate::NameKey::parse("a.txt").expect("valid name key"),
471                display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
472            }),
473        };
474        assert_eq!(
475            serde_json::to_string(&deleted).expect("serialize deleted event"),
476            r#"{"kind":"deleted","inode_id":"ino_2","deleted_binding":{"parent_inode_id":"ino_1","name_key":"a.txt","display_name":"a.txt"}}"#
477        );
478
479        let undeleted = FilesystemChange::Undeleted {
480            inode_id: InodeId(2),
481            parent_inode_id: InodeId(1),
482            display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
483            binding_generation: generation.clone(),
484        };
485        assert_eq!(
486            serde_json::to_string(&undeleted).expect("serialize undeleted event"),
487            format!(
488                r#"{{"kind":"undeleted","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","binding_generation":"{generation}"}}"#
489            )
490        );
491
492        let attributes_changed = FilesystemChange::AttributesChanged {
493            inode_id: InodeId(2),
494            attributes_revision_no: crate::AttributeRevisionNo(4),
495            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
496                crate::AttributeKey::parse("owner").expect("valid attribute key"),
497                crate::AttributeValue::parse("ada").expect("valid attribute value"),
498            )]))
499            .expect("valid attribute map"),
500        };
501        assert_eq!(
502            serde_json::to_string(&attributes_changed).expect("serialize attributes event"),
503            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":4,"attributes":{"owner":"ada"}}"#
504        );
505
506        // A clear is a real event carrying the empty map, not an absence.
507        let cleared = FilesystemChange::AttributesChanged {
508            inode_id: InodeId(2),
509            attributes_revision_no: crate::AttributeRevisionNo(5),
510            attributes: crate::Attributes::default(),
511        };
512        assert_eq!(
513            serde_json::to_string(&cleared).expect("serialize cleared attributes event"),
514            r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":5,"attributes":{}}"#
515        );
516    }
517}