1use crate::{
4 AccessGrants, AccessRevisionNo, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
5 CommitId, ContentRef, DisplayName, InodeId, NameKey, NamespaceId, RevisionNo,
6};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
12pub struct Commit {
13 pub namespace_id: NamespaceId,
15 pub commit_id: CommitId,
17 pub committed_seq: ChangeSeq,
19 pub committed_by: crate::ActorId,
21 pub committed_at_ms: u64,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 #[cfg_attr(feature = "openapi", schema(nullable = false))]
26 pub message: Option<String>,
27 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37pub struct DirectoryBinding {
38 #[serde(with = "crate::public_inode_id")]
40 pub parent_inode_id: InodeId,
41 pub name_key: NameKey,
43 pub display_name: DisplayName,
45}
46
47#[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 #[cfg_attr(
56 feature = "openapi",
57 schema(title = "FilesystemChangeDirectoryCreated")
58 )]
59 DirectoryCreated {
60 #[serde(with = "crate::public_inode_id")]
62 inode_id: InodeId,
63 #[serde(with = "crate::public_inode_id")]
65 parent_inode_id: InodeId,
66 display_name: DisplayName,
68 binding_generation: BindingGeneration,
70 },
71 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
73 FileCreated {
74 #[serde(with = "crate::public_inode_id")]
76 inode_id: InodeId,
77 #[serde(with = "crate::public_inode_id")]
79 parent_inode_id: InodeId,
80 display_name: DisplayName,
82 binding_generation: BindingGeneration,
84 revision_no: RevisionNo,
86 content_ref: ContentRef,
88 },
89 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
91 ContentChanged {
92 #[serde(with = "crate::public_inode_id")]
94 inode_id: InodeId,
95 revision_no: RevisionNo,
97 content_ref: ContentRef,
99 },
100 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
102 Moved {
103 #[serde(with = "crate::public_inode_id")]
105 inode_id: InodeId,
106 #[serde(with = "crate::public_inode_id")]
108 source_parent_inode_id: InodeId,
109 source_display_name: DisplayName,
111 #[serde(with = "crate::public_inode_id")]
113 destination_parent_inode_id: InodeId,
114 destination_display_name: DisplayName,
116 binding_generation: BindingGeneration,
118 },
119 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
121 Deleted {
122 #[serde(with = "crate::public_inode_id")]
124 inode_id: InodeId,
125 deleted_binding: DirectoryBinding,
127 },
128 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
130 Undeleted {
131 #[serde(with = "crate::public_inode_id")]
133 inode_id: InodeId,
134 #[serde(with = "crate::public_inode_id")]
136 parent_inode_id: InodeId,
137 display_name: DisplayName,
139 binding_generation: BindingGeneration,
141 },
142 #[cfg_attr(
144 feature = "openapi",
145 schema(title = "FilesystemChangeAttributesChanged")
146 )]
147 AttributesChanged {
148 #[serde(with = "crate::public_inode_id")]
150 inode_id: InodeId,
151 attributes_revision_no: AttributeRevisionNo,
153 attributes: Attributes,
156 },
157 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeAccessChanged"))]
160 AccessChanged {
161 #[serde(with = "crate::public_inode_id")]
163 inode_id: InodeId,
164 access_revision_no: AccessRevisionNo,
166 boundary: bool,
168 grants: AccessGrants,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
176pub struct ListChangesResponse {
177 pub namespace_id: NamespaceId,
179 pub after_seq: ChangeSeq,
181 pub through_seq: ChangeSeq,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 #[cfg_attr(feature = "openapi", schema(nullable = false))]
186 pub next_after_seq: Option<ChangeSeq>,
187 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 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}