1use crate::{
7 AttributeRevisionNo, Attributes, ChangeSeq, CommitId, ContentRef, DisplayName, InodeId,
8 NameKey, NamespaceId, RevisionNo,
9};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct CommitResponse {
24 pub namespace_id: NamespaceId,
26 pub commit_id: CommitId,
29 pub committed_seq: ChangeSeq,
31 pub committed_by: crate::ActorRef,
33 pub committed_at_ms: u64,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[cfg_attr(feature = "openapi", schema(nullable = false))]
40 pub message: Option<String>,
41 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66pub struct DirectoryBinding {
67 #[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 pub name_key: NameKey,
76 pub display_name: DisplayName,
78}
79
80#[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 #[cfg_attr(
96 feature = "openapi",
97 schema(title = "FilesystemChangeDirectoryCreated")
98 )]
99 DirectoryCreated {
100 #[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 #[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 display_name: DisplayName,
116 binding_generation: String,
118 },
119 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeFileCreated"))]
121 FileCreated {
122 #[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 #[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 display_name: DisplayName,
138 binding_generation: String,
140 revision_no: RevisionNo,
142 content_ref: ContentRef,
144 },
145 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeContentChanged"))]
148 ContentChanged {
149 #[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 revision_no: RevisionNo,
158 content_ref: ContentRef,
160 },
161 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeMoved"))]
163 Moved {
164 #[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 #[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 from_display_name: DisplayName,
180 #[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 to_display_name: DisplayName,
189 binding_generation: String,
191 },
192 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeDeleted"))]
195 Deleted {
196 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
206 #[cfg_attr(feature = "openapi", schema(nullable = false))]
207 deleted_binding: Option<DirectoryBinding>,
208 },
209 #[cfg_attr(feature = "openapi", schema(title = "FilesystemChangeUndeleted"))]
211 Undeleted {
212 #[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 #[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 display_name: DisplayName,
228 binding_generation: String,
230 },
231 #[cfg_attr(
233 feature = "openapi",
234 schema(title = "FilesystemChangeAttributesChanged")
235 )]
236 AttributesChanged {
237 #[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 attributes_revision_no: AttributeRevisionNo,
246 attributes: Attributes,
250 },
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
256pub struct CommittedChange {
257 pub committed_seq: ChangeSeq,
259 pub commit_id: CommitId,
261 pub committed_by: crate::ActorRef,
263 pub committed_at_ms: u64,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub message: Option<String>,
269 pub events: Vec<FilesystemChange>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
278pub struct ListChangesResponse {
279 pub namespace_id: NamespaceId,
281 pub after_seq: ChangeSeq,
283 pub through_seq: ChangeSeq,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 #[cfg_attr(feature = "openapi", schema(nullable = false))]
288 pub next_after_seq: Option<ChangeSeq>,
289 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 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}