use crate::{
AccessGrants, AccessRevisionNo, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
CommitId, ContentRef, DisplayName, InodeId, NameKey, NamespaceId, RevisionNo,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Commit {
pub namespace_id: NamespaceId,
pub commit_id: CommitId,
pub committed_seq: ChangeSeq,
pub committed_by: crate::ActorId,
pub committed_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub events: Option<Vec<FilesystemChange>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DirectoryBinding {
#[serde(with = "crate::public_inode_id")]
pub parent_inode_id: InodeId,
pub name_key: NameKey,
pub display_name: DisplayName,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FilesystemChange {
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemChangeDirectoryCreated")
)]
DirectoryCreated {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
#[serde(with = "crate::public_inode_id")]
parent_inode_id: InodeId,
display_name: DisplayName,
binding_generation: BindingGeneration,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
FileCreated {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
#[serde(with = "crate::public_inode_id")]
parent_inode_id: InodeId,
display_name: DisplayName,
binding_generation: BindingGeneration,
revision_no: RevisionNo,
content_ref: ContentRef,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
ContentChanged {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
revision_no: RevisionNo,
content_ref: ContentRef,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
Moved {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
#[serde(with = "crate::public_inode_id")]
source_parent_inode_id: InodeId,
source_display_name: DisplayName,
#[serde(with = "crate::public_inode_id")]
destination_parent_inode_id: InodeId,
destination_display_name: DisplayName,
binding_generation: BindingGeneration,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
Deleted {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
deleted_binding: DirectoryBinding,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
Undeleted {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
#[serde(with = "crate::public_inode_id")]
parent_inode_id: InodeId,
display_name: DisplayName,
binding_generation: BindingGeneration,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemChangeAttributesChanged")
)]
AttributesChanged {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
attributes_revision_no: AttributeRevisionNo,
attributes: Attributes,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeAccessChanged"))]
AccessChanged {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
access_revision_no: AccessRevisionNo,
boundary: bool,
grants: AccessGrants,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListChangesResponse {
pub namespace_id: NamespaceId,
pub after_seq: ChangeSeq,
pub through_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_after_seq: Option<ChangeSeq>,
pub changes: Vec<Commit>,
}
#[cfg(test)]
mod tests {
use super::{Commit, FilesystemChange};
use crate::{AccessRevisionNo, InodeId};
fn binding_generation() -> crate::BindingGeneration {
crate::BindingGeneration::parse("abcdef").expect("binding generation")
}
#[test]
fn commit_uses_committed_by_on_the_wire() {
let change = Commit {
namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
committed_seq: crate::ChangeSeq(7),
commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
committed_by: crate::ActorId::loonfs(),
committed_at_ms: 1_752_624_000_000,
message: None,
events: Some(Vec::new()),
};
assert_eq!(
serde_json::to_value(change).expect("serialize committed change"),
serde_json::json!({
"namespace_id": "demo",
"committed_seq": 7,
"commit_id": "example-commit",
"committed_by": "loonfs",
"committed_at_ms": 1_752_624_000_000_u64,
"events": [],
})
);
}
#[test]
fn a_commit_carries_events_at_the_top_level() {
let response = Commit {
namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
committed_seq: crate::ChangeSeq(419),
commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
committed_by: crate::ActorId::loonfs(),
committed_at_ms: 1_752_624_000_000,
message: Some("import the reports".to_owned()),
events: Some(vec![FilesystemChange::DirectoryCreated {
inode_id: InodeId(43),
parent_inode_id: InodeId(1),
display_name: crate::DisplayName::parse("docs").expect("valid display name"),
binding_generation: binding_generation(),
}]),
};
assert_eq!(
serde_json::to_value(response).expect("serialize commit response"),
serde_json::json!({
"namespace_id": "demo",
"commit_id": "example-commit",
"committed_seq": 419,
"committed_by": "loonfs",
"committed_at_ms": 1_752_624_000_000_u64,
"message": "import the reports",
"events": [{
"kind": "directory_created",
"inode_id": "ino_43",
"parent_inode_id": "ino_1",
"display_name": "docs",
"binding_generation": binding_generation(),
}],
})
);
}
#[test]
fn a_commit_omits_absent_events_and_message() {
let response = Commit {
namespace_id: crate::NamespaceId::parse("demo").expect("valid namespace id"),
commit_id: crate::CommitId::parse("example-commit").expect("valid commit id"),
committed_seq: crate::ChangeSeq(419),
committed_by: crate::ActorId::loonfs(),
committed_at_ms: 1_752_624_000_000,
message: None,
events: None,
};
assert_eq!(
serde_json::to_value(response).expect("serialize commit response"),
serde_json::json!({
"namespace_id": "demo",
"commit_id": "example-commit",
"committed_seq": 419,
"committed_by": "loonfs",
"committed_at_ms": 1_752_624_000_000_u64,
})
);
}
#[test]
fn filesystem_change_events_use_snake_case_kind_tags() {
let sample_content_ref = crate::ContentRef::blob_v1(
crate::NamespaceId::parse("demo").expect("namespace id"),
crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
.expect("valid content id"),
b"hello",
);
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"}}"#;
let generation = binding_generation();
let directory_created = FilesystemChange::DirectoryCreated {
inode_id: InodeId(2),
parent_inode_id: InodeId(1),
display_name: crate::DisplayName::parse("Docs").expect("valid display name"),
binding_generation: generation.clone(),
};
assert_eq!(
serde_json::to_string(&directory_created).expect("serialize directory-created event"),
format!(
r#"{{"kind":"directory_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"Docs","binding_generation":"{generation}"}}"#
)
);
let file_created = FilesystemChange::FileCreated {
inode_id: InodeId(2),
parent_inode_id: InodeId(1),
display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
binding_generation: generation.clone(),
revision_no: crate::RevisionNo(1),
content_ref: sample_content_ref.clone(),
};
assert_eq!(
serde_json::to_string(&file_created).expect("serialize file-created event"),
format!(
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}}}"#
)
);
let missing_content_ref = r#"{"kind":"file_created","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","revision_no":1}"#;
assert!(serde_json::from_str::<FilesystemChange>(missing_content_ref).is_err());
let retired_creation = serde_json::json!({
"kind": (["cre", "ated"].concat()),
"inode_id": "ino_2",
"inode_kind": "file",
"parent_inode_id": "ino_1",
"display_name": "a.txt",
"revision_no": 1,
});
assert!(serde_json::from_value::<FilesystemChange>(retired_creation).is_err());
let content_changed = FilesystemChange::ContentChanged {
inode_id: InodeId(2),
revision_no: crate::RevisionNo(3),
content_ref: sample_content_ref,
};
assert_eq!(
serde_json::to_string(&content_changed).expect("serialize content changed event"),
format!(
r#"{{"kind":"content_changed","inode_id":"ino_2","revision_no":3,"content_ref":{sample_content_ref_json}}}"#
)
);
let moved = FilesystemChange::Moved {
inode_id: InodeId(2),
source_parent_inode_id: InodeId(1),
source_display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
destination_parent_inode_id: InodeId(3),
destination_display_name: crate::DisplayName::parse("b.txt")
.expect("valid display name"),
binding_generation: generation.clone(),
};
assert_eq!(
serde_json::from_value::<FilesystemChange>(serde_json::json!({
"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
}))
.expect("decode moved event"),
moved
);
assert_eq!(
serde_json::to_string(&moved).expect("serialize moved event"),
format!(
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}"}}"#
)
);
let deleted = FilesystemChange::Deleted {
inode_id: InodeId(2),
deleted_binding: super::DirectoryBinding {
parent_inode_id: InodeId(1),
name_key: crate::NameKey::parse("a.txt").expect("valid name key"),
display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
},
};
assert_eq!(
serde_json::to_string(&deleted).expect("serialize deleted event"),
r#"{"kind":"deleted","inode_id":"ino_2","deleted_binding":{"parent_inode_id":"ino_1","name_key":"a.txt","display_name":"a.txt"}}"#
);
let undeleted = FilesystemChange::Undeleted {
inode_id: InodeId(2),
parent_inode_id: InodeId(1),
display_name: crate::DisplayName::parse("a.txt").expect("valid display name"),
binding_generation: generation.clone(),
};
assert_eq!(
serde_json::to_string(&undeleted).expect("serialize undeleted event"),
format!(
r#"{{"kind":"undeleted","inode_id":"ino_2","parent_inode_id":"ino_1","display_name":"a.txt","binding_generation":"{generation}"}}"#
)
);
let attributes_changed = FilesystemChange::AttributesChanged {
inode_id: InodeId(2),
attributes_revision_no: crate::AttributeRevisionNo(4),
attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
crate::AttributeKey::parse("owner").expect("valid attribute key"),
crate::AttributeValue::parse("ada").expect("valid attribute value"),
)]))
.expect("valid attribute map"),
};
assert_eq!(
serde_json::to_string(&attributes_changed).expect("serialize attributes event"),
r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":4,"attributes":{"owner":"ada"}}"#
);
let cleared = FilesystemChange::AttributesChanged {
inode_id: InodeId(2),
attributes_revision_no: crate::AttributeRevisionNo(5),
attributes: crate::Attributes::default(),
};
assert_eq!(
serde_json::to_string(&cleared).expect("serialize cleared attributes event"),
r#"{"kind":"attributes_changed","inode_id":"ino_2","attributes_revision_no":5,"attributes":{}}"#
);
let access_changed = FilesystemChange::AccessChanged {
inode_id: InodeId(2),
access_revision_no: AccessRevisionNo(3),
boundary: true,
grants: serde_json::from_value(serde_json::json!({"prn_ada": ["read", "write"]}))
.expect("grants"),
};
assert_eq!(
serde_json::to_string(&access_changed).expect("serialize access event"),
r#"{"kind":"access_changed","inode_id":"ino_2","access_revision_no":3,"boundary":true,"grants":{"prn_ada":["read","write"]}}"#
);
}
}