1use super::ContentToken;
8use crate::{
9 AbsolutePath, AttributeKey, AttributeRevisionNo, AttributeValue, ChangeSeq, CheckpointId,
10 CommitId, ContentRef, DisplayName, InodeId, ManifestNo, NamespaceId, RevisionNo, WriterEpoch,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18pub struct ApiError {
19 pub code: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub feature: Option<String>,
30 pub message: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub param: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub request_id: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 #[cfg_attr(feature = "openapi", schema(nullable = false))]
46 pub details: Option<Box<ErrorDetails>>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
56pub struct ErrorDetails {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 #[cfg_attr(feature = "openapi", schema(nullable = false))]
60 pub commit_id: Option<CommitId>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
66 #[cfg_attr(feature = "openapi", schema(nullable = false))]
67 pub committed_seq: Option<ChangeSeq>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub committed_fingerprint: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub operation_index: Option<u32>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 #[cfg_attr(feature = "openapi", schema(nullable = false))]
84 pub fenced_writer_epoch: Option<WriterEpoch>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 #[cfg_attr(feature = "openapi", schema(nullable = false))]
88 pub active_writer_epoch: Option<WriterEpoch>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub active_writer: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub active_acquired_at_ms: Option<u64>,
98 #[serde(
100 default,
101 skip_serializing_if = "Option::is_none",
102 with = "crate::public_inode_id::option"
103 )]
104 #[cfg_attr(
105 feature = "openapi",
106 schema(schema_with = crate::public_inode_id::schema)
107 )]
108 pub inode_id: Option<InodeId>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 #[cfg_attr(feature = "openapi", schema(nullable = false))]
112 pub expected_revision_no: Option<RevisionNo>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 #[cfg_attr(feature = "openapi", schema(nullable = false))]
116 pub actual_revision_no: Option<RevisionNo>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 #[cfg_attr(feature = "openapi", schema(nullable = false))]
120 pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 #[cfg_attr(feature = "openapi", schema(nullable = false))]
124 pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 #[cfg_attr(feature = "openapi", schema(nullable = false))]
128 pub after_seq: Option<ChangeSeq>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 #[cfg_attr(feature = "openapi", schema(nullable = false))]
132 pub retention_floor_seq: Option<ChangeSeq>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 #[cfg_attr(feature = "openapi", schema(nullable = false))]
136 pub expected_deletion_seq: Option<ChangeSeq>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 #[cfg_attr(feature = "openapi", schema(nullable = false))]
140 pub actual_deletion_seq: Option<ChangeSeq>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 #[cfg_attr(feature = "openapi", schema(nullable = false))]
145 pub expected_head_seq: Option<ChangeSeq>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
149 #[cfg_attr(feature = "openapi", schema(nullable = false))]
150 pub actual_head_seq: Option<ChangeSeq>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(deny_unknown_fields)]
157pub struct CreateNamespaceRequest {
158 pub namespace_id: NamespaceId,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
165#[serde(deny_unknown_fields)]
166pub struct ForkNamespaceRequest {
167 pub new_namespace_id: NamespaceId,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
174pub struct Namespace {
175 pub namespace_id: NamespaceId,
177 pub head_seq: ChangeSeq,
179 pub retention_floor_seq: ChangeSeq,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186pub struct NamespaceDiagnostics {
187 pub namespace_id: NamespaceId,
189 pub head_seq: ChangeSeq,
191 pub retention_floor_seq: ChangeSeq,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 #[cfg_attr(feature = "openapi", schema(nullable = false))]
196 pub current_manifest_no: Option<ManifestNo>,
197 pub wal_tail_segments: u64,
199 pub live_snapshots: u64,
201 pub live_checkpoints: u64,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208pub struct DeleteNamespaceResponse {
209 pub namespace_id: NamespaceId,
211 pub head_seq: ChangeSeq,
214}
215
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "snake_case")]
220pub enum DestinationBehavior {
221 #[default]
223 NoReplace,
224 Replace,
227}
228
229#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
232#[serde(rename_all = "snake_case")]
233pub enum DeleteDirectoryBehavior {
234 #[default]
236 NonRecursive,
237 Recursive,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
247#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
248pub enum FilesystemOperation {
249 #[cfg_attr(
251 feature = "openapi",
252 schema(title = "FilesystemOperationCreateDirectory")
253 )]
254 CreateDirectory {
255 path: AbsolutePath,
257 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260 parents: bool,
261 },
262 #[cfg_attr(
264 feature = "openapi",
265 schema(title = "FilesystemOperationCreateDirectoryByInode")
266 )]
267 CreateDirectoryByInode {
268 #[serde(with = "crate::public_inode_id")]
270 #[cfg_attr(
271 feature = "openapi",
272 schema(schema_with = crate::public_inode_id::schema)
273 )]
274 parent_inode_id: InodeId,
275 display_name: DisplayName,
277 },
278 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationPutFile"))]
280 PutFile {
281 path: AbsolutePath,
283 content_ref: ContentRef,
285 #[serde(default)]
287 behavior: DestinationBehavior,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
293 #[cfg_attr(feature = "openapi", schema(nullable = false))]
294 expected_revision_no: Option<RevisionNo>,
295 },
296 #[cfg_attr(
298 feature = "openapi",
299 schema(title = "FilesystemOperationPutFileByInode")
300 )]
301 PutFileByInode {
302 #[serde(with = "crate::public_inode_id")]
304 #[cfg_attr(
305 feature = "openapi",
306 schema(schema_with = crate::public_inode_id::schema)
307 )]
308 parent_inode_id: InodeId,
309 display_name: DisplayName,
311 content_ref: ContentRef,
313 },
314 #[cfg_attr(
316 feature = "openapi",
317 schema(title = "FilesystemOperationPutFileRevisionByInode")
318 )]
319 PutFileRevisionByInode {
320 #[serde(with = "crate::public_inode_id")]
322 #[cfg_attr(
323 feature = "openapi",
324 schema(schema_with = crate::public_inode_id::schema)
325 )]
326 inode_id: InodeId,
327 content_ref: ContentRef,
329 expected_revision_no: RevisionNo,
331 },
332 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationDeletePath"))]
334 DeletePath {
335 path: AbsolutePath,
337 #[serde(default)]
339 behavior: DeleteDirectoryBehavior,
340 #[serde(
344 default,
345 skip_serializing_if = "Option::is_none",
346 with = "crate::public_inode_id::option"
347 )]
348 #[cfg_attr(
349 feature = "openapi",
350 schema(schema_with = crate::public_inode_id::schema)
351 )]
352 expected_inode_id: Option<InodeId>,
353 },
354 #[cfg_attr(
356 feature = "openapi",
357 schema(title = "FilesystemOperationDeleteByInode")
358 )]
359 DeleteByInode {
360 #[serde(with = "crate::public_inode_id")]
362 #[cfg_attr(
363 feature = "openapi",
364 schema(schema_with = crate::public_inode_id::schema)
365 )]
366 inode_id: InodeId,
367 expected_binding_generation: String,
369 #[serde(default)]
371 behavior: DeleteDirectoryBehavior,
372 },
373 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMovePath"))]
375 MovePath {
376 from_path: AbsolutePath,
378 to_path: AbsolutePath,
380 #[serde(default)]
382 behavior: DestinationBehavior,
383 },
384 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMoveByInode"))]
386 MoveByInode {
387 #[serde(with = "crate::public_inode_id")]
389 #[cfg_attr(
390 feature = "openapi",
391 schema(schema_with = crate::public_inode_id::schema)
392 )]
393 inode_id: InodeId,
394 expected_binding_generation: String,
396 #[serde(with = "crate::public_inode_id")]
398 #[cfg_attr(
399 feature = "openapi",
400 schema(schema_with = crate::public_inode_id::schema)
401 )]
402 to_parent_inode_id: InodeId,
403 to_display_name: DisplayName,
405 #[serde(default)]
407 behavior: DestinationBehavior,
408 },
409 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationCopyPath"))]
411 CopyPath {
412 from_path: AbsolutePath,
414 to_path: AbsolutePath,
416 #[serde(default)]
418 behavior: DestinationBehavior,
419 },
420 #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUndelete"))]
425 Undelete {
426 #[serde(with = "crate::public_inode_id")]
428 #[cfg_attr(
429 feature = "openapi",
430 schema(schema_with = crate::public_inode_id::schema)
431 )]
432 inode_id: InodeId,
433 deletion_seq: ChangeSeq,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
442 #[cfg_attr(feature = "openapi", schema(nullable = false))]
443 path: Option<AbsolutePath>,
444 },
445 #[cfg_attr(
447 feature = "openapi",
448 schema(title = "FilesystemOperationRestoreRevision")
449 )]
450 RestoreRevision {
451 path: AbsolutePath,
453 source_revision_no: RevisionNo,
455 },
456 #[cfg_attr(
458 feature = "openapi",
459 schema(title = "FilesystemOperationUpdateAttributes")
460 )]
461 UpdateAttributes {
462 path: AbsolutePath,
464 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
468 set: BTreeMap<AttributeKey, AttributeValue>,
469 #[serde(default, skip_serializing_if = "Vec::is_empty")]
474 remove: Vec<AttributeKey>,
475 #[serde(
479 default,
480 skip_serializing_if = "Option::is_none",
481 with = "crate::public_inode_id::option"
482 )]
483 #[cfg_attr(
484 feature = "openapi",
485 schema(schema_with = crate::public_inode_id::schema)
486 )]
487 expected_inode_id: Option<InodeId>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
494 #[cfg_attr(feature = "openapi", schema(nullable = false))]
495 expected_attributes_revision_no: Option<AttributeRevisionNo>,
496 },
497}
498
499impl FilesystemOperation {
500 pub const fn content_ref(&self) -> Option<&ContentRef> {
502 match self {
503 Self::PutFile { content_ref, .. }
504 | Self::PutFileByInode { content_ref, .. }
505 | Self::PutFileRevisionByInode { content_ref, .. } => Some(content_ref),
506 Self::CreateDirectory { .. }
507 | Self::CreateDirectoryByInode { .. }
508 | Self::DeletePath { .. }
509 | Self::DeleteByInode { .. }
510 | Self::MovePath { .. }
511 | Self::MoveByInode { .. }
512 | Self::CopyPath { .. }
513 | Self::Undelete { .. }
514 | Self::RestoreRevision { .. }
515 | Self::UpdateAttributes { .. } => None,
516 }
517 }
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
530#[serde(deny_unknown_fields)]
531pub struct CommitRequest {
532 pub commit_id: CommitId,
534 pub actor: crate::ActorRef,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub message: Option<String>,
542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
545 pub content_tokens: Vec<ContentToken>,
546 pub operations: Vec<FilesystemOperation>,
549}
550
551impl CommitRequest {
552 pub fn single(
554 commit_id: CommitId,
555 actor: crate::ActorRef,
556 message: Option<String>,
557 operation: FilesystemOperation,
558 ) -> Self {
559 Self {
560 commit_id,
561 actor,
562 message,
563 content_tokens: Vec::new(),
564 operations: vec![operation],
565 }
566 }
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
572pub struct FileRevision {
573 #[serde(with = "crate::public_inode_id")]
575 #[cfg_attr(
576 feature = "openapi",
577 schema(schema_with = crate::public_inode_id::schema)
578 )]
579 pub inode_id: InodeId,
580 pub revision_no: RevisionNo,
582 pub committed_seq: ChangeSeq,
584 pub commit_id: CommitId,
586 pub committed_at_ms: u64,
589 pub committed_by: crate::ActorRef,
591 pub content_ref: ContentRef,
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
598pub struct ListFileRevisionsResponse {
599 pub namespace_id: NamespaceId,
601 #[serde(with = "crate::public_inode_id")]
603 #[cfg_attr(
604 feature = "openapi",
605 schema(schema_with = crate::public_inode_id::schema)
606 )]
607 pub inode_id: InodeId,
608 pub head_seq: ChangeSeq,
610 pub revisions: Vec<FileRevision>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub next_cursor: Option<String>,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
620#[serde(deny_unknown_fields)]
621pub struct CreateCheckpointRequest {
622 pub name: String,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub ttl_ms: Option<u64>,
629}
630
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
633#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
634#[serde(deny_unknown_fields)]
635pub struct CreateSnapshotRequest {
636 pub name: String,
638 pub ttl_ms: u64,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
644#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
645#[serde(deny_unknown_fields)]
646pub struct ExtendSnapshotRequest {
647 pub ttl_ms: u64,
649}
650
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
654pub struct ReleaseCheckpointResponse {
655 pub namespace_id: NamespaceId,
657 pub checkpoint_id: CheckpointId,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
664#[serde(tag = "kind", rename_all = "snake_case")]
665pub enum CheckpointOwnerSummary {
666 #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
668 User {
669 name: String,
672 },
673 #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
676 Fork {
677 target_namespace_id: NamespaceId,
679 },
680 #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerSnapshot"))]
682 Snapshot {
683 name: String,
685 expires_at_ms: u64,
687 },
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
693pub struct Checkpoint {
694 pub namespace_id: NamespaceId,
696 pub checkpoint_id: CheckpointId,
698 pub owner: CheckpointOwnerSummary,
700 pub created_at_ms: u64,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub expires_at_ms: Option<u64>,
709 pub checkpoint_seq: ChangeSeq,
711 pub manifest_no: ManifestNo,
713}
714
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
717#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
718pub struct SnapshotSummary {
719 pub snapshot_id: CheckpointId,
721 pub namespace_id: NamespaceId,
723 pub name: String,
725 pub head_seq: ChangeSeq,
727 pub created_at_ms: u64,
729 pub expires_at_ms: u64,
731}
732
733impl SnapshotSummary {
734 pub fn from_checkpoint(checkpoint: Checkpoint) -> Option<Self> {
736 let CheckpointOwnerSummary::Snapshot {
737 name,
738 expires_at_ms,
739 } = checkpoint.owner
740 else {
741 return None;
742 };
743 Some(Self {
744 snapshot_id: checkpoint.checkpoint_id,
745 namespace_id: checkpoint.namespace_id,
746 name,
747 head_seq: checkpoint.checkpoint_seq,
748 created_at_ms: checkpoint.created_at_ms,
749 expires_at_ms,
750 })
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
757pub struct ListCheckpointsResponse {
758 pub namespace_id: NamespaceId,
760 pub checkpoints: Vec<Checkpoint>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub next_cursor: Option<String>,
766}
767
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
770#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
771pub struct ListSnapshotsResponse {
772 pub namespace_id: NamespaceId,
774 pub snapshots: Vec<SnapshotSummary>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub next_cursor: Option<String>,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
784pub struct ReleaseSnapshotResponse {
785 pub namespace_id: NamespaceId,
787 pub snapshot_id: CheckpointId,
789}
790
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
793#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
794#[serde(rename_all = "snake_case")]
795pub enum FlushWalOutcome {
796 AlreadyCurrent,
798 Published,
800 RootAdvanced,
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
807#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
808pub struct FlushWalResponse {
809 pub namespace_id: NamespaceId,
811 pub target_head_seq: ChangeSeq,
813 pub manifest_no: ManifestNo,
815 pub manifest_head_seq: ChangeSeq,
817 pub outcome: FlushWalOutcome,
819}
820
821#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
828#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
829#[serde(deny_unknown_fields)]
830pub struct GcRequest {
831 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub grace_window_ms: Option<u64>,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub max_objects: Option<u64>,
851 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub cursor: Option<String>,
855}
856
857#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
864#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
865pub struct RetainedCandidates {
866 pub referenced: u64,
868 pub within_grace_window: u64,
871 pub no_provider_timestamp: u64,
874 pub no_reference_manifest: u64,
877 pub degraded_roots: u64,
880 pub unrecognized_key: u64,
883 pub checkpoint_not_releasable: u64,
885 pub upload_session_window: u64,
887 pub upload_session_undecided: u64,
890 pub content_scan_deferred: u64,
893}
894
895#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
898#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
899pub struct DeletedObjectCounts {
900 pub wal_segments: u64,
902 pub metadata_segments: u64,
904 pub manifests: u64,
906 pub checkpoint_records: u64,
908 pub upload_sessions: u64,
910 pub content_objects: u64,
914}
915
916impl DeletedObjectCounts {
917 pub fn add(&mut self, other: &Self) {
919 let Self {
920 wal_segments,
921 metadata_segments,
922 manifests,
923 checkpoint_records,
924 upload_sessions,
925 content_objects,
926 } = other;
927 self.wal_segments += wal_segments;
928 self.metadata_segments += metadata_segments;
929 self.manifests += manifests;
930 self.checkpoint_records += checkpoint_records;
931 self.upload_sessions += upload_sessions;
932 self.content_objects += content_objects;
933 }
934}
935
936#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
942#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
943pub struct ReleasedCheckpointCounts {
944 pub fork: u64,
947 pub expired: u64,
950 pub missing_basis: u64,
953 pub snapshot: u64,
956}
957
958impl ReleasedCheckpointCounts {
959 pub fn add(&mut self, other: &Self) {
961 let Self {
962 fork,
963 expired,
964 missing_basis,
965 snapshot,
966 } = other;
967 self.fork += fork;
968 self.expired += expired;
969 self.missing_basis += missing_basis;
970 self.snapshot += snapshot;
971 }
972}
973
974#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
979#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
980pub struct GcResponse {
981 pub namespace_id: NamespaceId,
983 pub deleted: DeletedObjectCounts,
985 pub released_checkpoints: ReleasedCheckpointCounts,
988 pub retained_candidates: u64,
991 pub retained: RetainedCandidates,
993 pub retention_degraded: bool,
995 pub content_reclamation_deferred: bool,
998 pub budget_exhausted: bool,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub next_cursor: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub next_reclamation_at_ms: Option<u64>,
1011}
1012
1013impl GcResponse {
1014 pub fn empty(namespace_id: NamespaceId) -> Self {
1016 Self {
1017 namespace_id,
1018 deleted: DeletedObjectCounts::default(),
1019 released_checkpoints: ReleasedCheckpointCounts::default(),
1020 retained_candidates: 0,
1021 retained: RetainedCandidates::default(),
1022 retention_degraded: false,
1023 content_reclamation_deferred: false,
1024 budget_exhausted: false,
1025 next_cursor: None,
1026 next_reclamation_at_ms: None,
1027 }
1028 }
1029
1030 pub fn retain(&mut self, reason: RetainedReason) {
1036 self.retained_candidates += 1;
1037 *reason.counter(&mut self.retained) += 1;
1038 }
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum RetainedReason {
1046 Referenced,
1048 WithinGraceWindow,
1050 NoProviderTimestamp,
1052 NoReferenceManifest,
1054 DegradedRoots,
1056 UnrecognizedKey,
1058 CheckpointNotReleasable,
1060 UploadSessionWindow,
1062 UploadSessionUndecided,
1064 ContentScanDeferred,
1066}
1067
1068impl RetainedReason {
1069 fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
1070 match self {
1071 Self::Referenced => &mut retained.referenced,
1072 Self::WithinGraceWindow => &mut retained.within_grace_window,
1073 Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
1074 Self::NoReferenceManifest => &mut retained.no_reference_manifest,
1075 Self::DegradedRoots => &mut retained.degraded_roots,
1076 Self::UnrecognizedKey => &mut retained.unrecognized_key,
1077 Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
1078 Self::UploadSessionWindow => &mut retained.upload_session_window,
1079 Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
1080 Self::ContentScanDeferred => &mut retained.content_scan_deferred,
1081 }
1082 }
1083}
1084
1085impl RetainedCandidates {
1086 pub fn by_reason(&self) -> [(&'static str, u64); 10] {
1088 let Self {
1089 referenced,
1090 within_grace_window,
1091 no_provider_timestamp,
1092 no_reference_manifest,
1093 degraded_roots,
1094 unrecognized_key,
1095 checkpoint_not_releasable,
1096 upload_session_window,
1097 upload_session_undecided,
1098 content_scan_deferred,
1099 } = *self;
1100 [
1101 ("referenced", referenced),
1102 ("within_grace_window", within_grace_window),
1103 ("no_provider_timestamp", no_provider_timestamp),
1104 ("no_reference_manifest", no_reference_manifest),
1105 ("degraded_roots", degraded_roots),
1106 ("unrecognized_key", unrecognized_key),
1107 ("checkpoint_not_releasable", checkpoint_not_releasable),
1108 ("upload_session_window", upload_session_window),
1109 ("upload_session_undecided", upload_session_undecided),
1110 ("content_scan_deferred", content_scan_deferred),
1111 ]
1112 }
1113
1114 pub fn add(&mut self, other: &Self) {
1116 let Self {
1117 referenced,
1118 within_grace_window,
1119 no_provider_timestamp,
1120 no_reference_manifest,
1121 degraded_roots,
1122 unrecognized_key,
1123 checkpoint_not_releasable,
1124 upload_session_window,
1125 upload_session_undecided,
1126 content_scan_deferred,
1127 } = other;
1128 self.referenced += referenced;
1129 self.within_grace_window += within_grace_window;
1130 self.no_provider_timestamp += no_provider_timestamp;
1131 self.no_reference_manifest += no_reference_manifest;
1132 self.degraded_roots += degraded_roots;
1133 self.unrecognized_key += unrecognized_key;
1134 self.checkpoint_not_releasable += checkpoint_not_releasable;
1135 self.upload_session_window += upload_session_window;
1136 self.upload_session_undecided += upload_session_undecided;
1137 self.content_scan_deferred += content_scan_deferred;
1138 }
1139
1140 pub fn top_reason(&self) -> Option<(&'static str, u64)> {
1144 self.by_reason()
1145 .into_iter()
1146 .filter(|(_, count)| *count > 0)
1147 .rev()
1150 .max_by_key(|(_, count)| *count)
1151 }
1152}
1153
1154#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1157#[serde(deny_unknown_fields)]
1158pub struct AdvanceRetentionRequest {}
1159
1160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1163pub struct AdvanceRetentionResponse {
1164 pub retention_floor_seq: ChangeSeq,
1166}
1167
1168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1177#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1178#[serde(deny_unknown_fields)]
1179pub struct MaintenanceStepRequest {
1180 #[serde(default, skip_serializing_if = "Option::is_none")]
1183 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1184 pub metadata_maintenance: Option<MetadataMaintenanceRequest>,
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1189 pub retention: Option<AdvanceRetentionRequest>,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1194 pub gc: Option<GcRequest>,
1195}
1196
1197#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1200#[serde(deny_unknown_fields)]
1201pub struct MetadataMaintenanceRequest {
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub max_wal_tail_segments: Option<u64>,
1207}
1208
1209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1212#[serde(tag = "outcome", rename_all = "snake_case")]
1213pub enum WalFlushStepOutcome {
1214 NotNeeded,
1216 Flushed {
1218 manifest_head_seq: ChangeSeq,
1220 },
1221 AlreadyPublished {
1224 attempted_seq: ChangeSeq,
1226 current_manifest_no: ManifestNo,
1228 },
1229 RetriesExhausted {
1232 observed_head_seq: ChangeSeq,
1234 },
1235}
1236
1237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1242#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1243#[serde(tag = "outcome", rename_all = "snake_case")]
1244pub enum ReorganizeStepOutcome {
1245 NotNeeded,
1247 UnitPublished,
1249 CompactionStarted,
1253 CompactionRunning,
1257 CompactionAtCapacity,
1261 CompactionRequired,
1265 RootAdvanced,
1268}
1269
1270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1276#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1277pub struct MaintenanceStepResponse {
1278 pub namespace_id: NamespaceId,
1280 pub status_before: NamespaceDiagnostics,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1284 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1285 pub metadata_maintenance: Option<MetadataMaintenanceResponse>,
1286 #[serde(default, skip_serializing_if = "Option::is_none")]
1288 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1289 pub retention: Option<AdvanceRetentionResponse>,
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 #[cfg_attr(feature = "openapi", schema(nullable = false))]
1293 pub gc: Option<GcResponse>,
1294}
1295
1296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1298#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1299pub struct MetadataMaintenanceResponse {
1300 pub wal_flush: WalFlushStepOutcome,
1302 pub reorganize: ReorganizeStepOutcome,
1304}
1305
1306#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1311#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1312#[serde(deny_unknown_fields)]
1313pub struct StoreProbeRequest {}
1314
1315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1318pub struct StoreProbeResponse {
1319 pub run_id: String,
1322 pub checks: Vec<StoreProbeCheckResult>,
1326}
1327
1328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1330#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1331pub struct StoreProbeCheckResult {
1332 pub name: String,
1334 pub outcome: StoreProbeCheckOutcome,
1336 #[serde(default, skip_serializing_if = "Option::is_none")]
1339 pub message: Option<String>,
1340}
1341
1342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1344#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1345#[serde(rename_all = "snake_case")]
1346pub enum StoreProbeCheckOutcome {
1347 Passed,
1349 Unsupported,
1353 Failed,
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361 use crate::ContentId;
1362
1363 #[test]
1364 fn file_revision_provenance_fields_are_pinned_on_the_wire() {
1365 let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
1366 let revision = FileRevision {
1367 inode_id: InodeId(2),
1368 revision_no: RevisionNo(3),
1369 committed_seq: ChangeSeq(7),
1370 commit_id: CommitId::parse("c_revision_owner").expect("commit id"),
1371 committed_at_ms: 1_752_624_000_000,
1372 committed_by: crate::ActorRef::loonfs_system(),
1373 content_ref: content_ref.clone(),
1374 };
1375
1376 assert_eq!(
1377 serde_json::to_value(revision).expect("serialize file revision"),
1378 serde_json::json!({
1379 "inode_id": "ino_2",
1380 "revision_no": 3,
1381 "committed_seq": 7,
1382 "commit_id": "c_revision_owner",
1383 "committed_at_ms": 1_752_624_000_000_u64,
1384 "committed_by": { "kind": "system", "id": "loonfs" },
1385 "content_ref": content_ref,
1386 })
1387 );
1388 }
1389 fn path(value: &str) -> AbsolutePath {
1390 AbsolutePath::parse(value).expect("valid test path")
1391 }
1392
1393 fn attribute_key(value: &str) -> AttributeKey {
1394 AttributeKey::parse(value).expect("valid test attribute key")
1395 }
1396
1397 fn sample_content_ref() -> ContentRef {
1398 ContentRef::blob_v1(
1399 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
1400 b"hello",
1401 )
1402 }
1403
1404 #[test]
1405 fn namespace_wire_shape_has_only_core_state() {
1406 let namespace = Namespace {
1407 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1408 head_seq: ChangeSeq(11),
1409 retention_floor_seq: ChangeSeq(4),
1410 };
1411 assert_eq!(
1412 serde_json::to_value(namespace).expect("serialize namespace"),
1413 serde_json::json!({
1414 "namespace_id": "demo",
1415 "head_seq": 11,
1416 "retention_floor_seq": 4
1417 })
1418 );
1419 }
1420
1421 #[test]
1422 fn namespace_diagnostics_wire_shape_keeps_storage_fields() {
1423 let diagnostics = NamespaceDiagnostics {
1424 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1425 head_seq: ChangeSeq(11),
1426 retention_floor_seq: ChangeSeq(4),
1427 current_manifest_no: Some(ManifestNo(8)),
1428 wal_tail_segments: 3,
1429 live_snapshots: 2,
1430 live_checkpoints: 5,
1431 };
1432 assert_eq!(
1433 serde_json::to_value(diagnostics).expect("serialize namespace diagnostics"),
1434 serde_json::json!({
1435 "namespace_id": "demo",
1436 "head_seq": 11,
1437 "retention_floor_seq": 4,
1438 "current_manifest_no": 8,
1439 "wal_tail_segments": 3,
1440 "live_snapshots": 2,
1441 "live_checkpoints": 5
1442 })
1443 );
1444 }
1445
1446 #[test]
1447 fn behavior_enums_use_snake_case_wire_values() {
1448 assert_eq!(
1449 DestinationBehavior::default(),
1450 DestinationBehavior::NoReplace
1451 );
1452 assert_eq!(
1453 DeleteDirectoryBehavior::default(),
1454 DeleteDirectoryBehavior::NonRecursive
1455 );
1456 assert_eq!(
1457 serde_json::to_value(DestinationBehavior::NoReplace)
1458 .expect("destination behavior json"),
1459 serde_json::json!("no_replace")
1460 );
1461 assert_eq!(
1462 serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
1463 serde_json::json!("replace")
1464 );
1465 assert_eq!(
1466 serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
1467 .expect("delete behavior json"),
1468 serde_json::json!("non_recursive")
1469 );
1470 assert_eq!(
1471 serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1472 serde_json::json!("recursive")
1473 );
1474 }
1475
1476 #[test]
1477 fn filesystem_delete_and_move_operations_use_behavior_field() {
1478 let create_directory = FilesystemOperation::CreateDirectory {
1479 path: path("/docs"),
1480 parents: false,
1481 };
1482 assert_eq!(
1483 serde_json::to_value(&create_directory).expect("create directory op json"),
1484 serde_json::json!({
1485 "kind": "create_directory",
1486 "path": "/docs"
1487 })
1488 );
1489
1490 let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1491 path: path("/docs/notes"),
1492 parents: true,
1493 };
1494 assert_eq!(
1495 serde_json::to_value(&create_directory_with_parents)
1496 .expect("create directory with parents op json"),
1497 serde_json::json!({
1498 "kind": "create_directory",
1499 "path": "/docs/notes",
1500 "parents": true
1501 })
1502 );
1503
1504 let delete = FilesystemOperation::DeletePath {
1505 path: path("/docs"),
1506 behavior: DeleteDirectoryBehavior::Recursive,
1507 expected_inode_id: None,
1508 };
1509 assert_eq!(
1510 serde_json::to_value(&delete).expect("delete op json"),
1511 serde_json::json!({
1512 "kind": "delete_path",
1513 "path": "/docs",
1514 "behavior": "recursive"
1515 })
1516 );
1517
1518 let move_path = FilesystemOperation::MovePath {
1519 from_path: path("/docs/a.txt"),
1520 to_path: path("/docs/b.txt"),
1521 behavior: DestinationBehavior::Replace,
1522 };
1523 assert_eq!(
1524 serde_json::to_value(&move_path).expect("move op json"),
1525 serde_json::json!({
1526 "kind": "move_path",
1527 "from_path": "/docs/a.txt",
1528 "to_path": "/docs/b.txt",
1529 "behavior": "replace"
1530 })
1531 );
1532
1533 let copy_path = FilesystemOperation::CopyPath {
1534 from_path: path("/docs/a.txt"),
1535 to_path: path("/docs/b.txt"),
1536 behavior: DestinationBehavior::Replace,
1537 };
1538 assert_eq!(
1539 serde_json::to_value(©_path).expect("copy op json"),
1540 serde_json::json!({
1541 "kind": "copy_path",
1542 "from_path": "/docs/a.txt",
1543 "to_path": "/docs/b.txt",
1544 "behavior": "replace"
1545 })
1546 );
1547
1548 let update_attributes = FilesystemOperation::UpdateAttributes {
1549 path: path("/docs/a.txt"),
1550 set: BTreeMap::from([(
1551 attribute_key("owner"),
1552 AttributeValue::parse("ada").expect("valid attribute value"),
1553 )]),
1554 remove: vec![attribute_key("draft")],
1555 expected_inode_id: Some(InodeId(7)),
1556 expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
1557 };
1558 assert_eq!(
1559 serde_json::to_value(&update_attributes).expect("update attributes op json"),
1560 serde_json::json!({
1561 "kind": "update_attributes",
1562 "path": "/docs/a.txt",
1563 "set": {"owner": "ada"},
1564 "remove": ["draft"],
1565 "expected_inode_id": "ino_7",
1566 "expected_attributes_revision_no": 3
1567 })
1568 );
1569 }
1570
1571 #[test]
1572 fn update_attributes_omits_empty_collections_and_absent_guards() {
1573 let set_only = FilesystemOperation::UpdateAttributes {
1574 path: path("/docs/a.txt"),
1575 set: BTreeMap::from([(
1576 attribute_key("owner"),
1577 AttributeValue::parse("ada,grace").expect("valid attribute value"),
1578 )]),
1579 remove: Vec::new(),
1580 expected_inode_id: None,
1581 expected_attributes_revision_no: None,
1582 };
1583 assert_eq!(
1584 serde_json::to_value(&set_only).expect("set-only op json"),
1585 serde_json::json!({
1586 "kind": "update_attributes",
1587 "path": "/docs/a.txt",
1588 "set": {"owner": "ada,grace"}
1589 })
1590 );
1591
1592 let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
1593 "kind": "update_attributes",
1594 "path": "/docs/a.txt",
1595 "remove": ["draft"]
1596 }))
1597 .expect("remove-only op defaults the set map and both guards");
1598 assert_eq!(
1599 decoded,
1600 FilesystemOperation::UpdateAttributes {
1601 path: path("/docs/a.txt"),
1602 set: BTreeMap::new(),
1603 remove: vec![attribute_key("draft")],
1604 expected_inode_id: None,
1605 expected_attributes_revision_no: None,
1606 }
1607 );
1608 }
1609
1610 #[test]
1611 fn update_attributes_validates_keys_and_values_during_deserialization() {
1612 for encoded in [
1615 serde_json::json!({
1616 "kind": "update_attributes",
1617 "path": "/docs/a.txt",
1618 "set": {"": "ada"}
1619 }),
1620 serde_json::json!({
1621 "kind": "update_attributes",
1622 "path": "/docs/a.txt",
1623 "set": {"owner": {"kind": "string", "value": "ada"}}
1624 }),
1625 serde_json::json!({
1626 "kind": "update_attributes",
1627 "path": "/docs/a.txt",
1628 "remove": ["a\u{0}b"]
1629 }),
1630 ] {
1631 assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1632 }
1633 }
1634
1635 #[test]
1636 fn filesystem_operations_default_omitted_behavior_fields() {
1637 let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1638 "kind": "put_file",
1639 "path": "/docs/a.txt",
1640 "content_ref": {
1641 "kind": "blob_v1",
1642 "content_id": "con_0123456789abcdef0123456789abcdef",
1643 "size_bytes": 1,
1644 "checksum": {
1645 "algorithm": "sha256",
1646 "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1647 }
1648 }
1649 }))
1650 .expect("put op defaults behavior");
1651 assert!(matches!(
1652 put,
1653 FilesystemOperation::PutFile {
1654 behavior: DestinationBehavior::NoReplace,
1655 expected_revision_no: None,
1656 ..
1657 }
1658 ));
1659
1660 let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1661 "kind": "delete_path",
1662 "path": "/docs"
1663 }))
1664 .expect("delete op defaults behavior");
1665 assert_eq!(
1666 delete,
1667 FilesystemOperation::DeletePath {
1668 path: path("/docs"),
1669 behavior: DeleteDirectoryBehavior::NonRecursive,
1670 expected_inode_id: None,
1671 }
1672 );
1673
1674 let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1675 "kind": "move_path",
1676 "from_path": "/docs/a.txt",
1677 "to_path": "/docs/b.txt"
1678 }))
1679 .expect("move op defaults behavior");
1680 assert_eq!(
1681 move_path,
1682 FilesystemOperation::MovePath {
1683 from_path: path("/docs/a.txt"),
1684 to_path: path("/docs/b.txt"),
1685 behavior: DestinationBehavior::NoReplace,
1686 }
1687 );
1688
1689 let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1690 "kind": "copy_path",
1691 "from_path": "/docs/a.txt",
1692 "to_path": "/docs/b.txt"
1693 }))
1694 .expect("copy op defaults behavior");
1695 assert_eq!(
1696 copy_path,
1697 FilesystemOperation::CopyPath {
1698 from_path: path("/docs/a.txt"),
1699 to_path: path("/docs/b.txt"),
1700 behavior: DestinationBehavior::NoReplace,
1701 }
1702 );
1703 }
1704
1705 #[test]
1706 fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1707 let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
1708 let cases = [
1709 (
1710 FilesystemOperation::PutFile {
1711 path: path("/docs/a.txt"),
1712 content_ref: content_ref.clone(),
1713 behavior: DestinationBehavior::NoReplace,
1714 expected_revision_no: None,
1715 },
1716 serde_json::json!({
1717 "kind": "put_file",
1718 "path": "/docs/a.txt",
1719 "content_ref": content_ref,
1720 "behavior": "no_replace"
1721 }),
1722 ),
1723 (
1724 FilesystemOperation::Undelete {
1725 inode_id: InodeId(7),
1726 deletion_seq: ChangeSeq(8),
1727 path: Some(path("/docs/restored")),
1728 },
1729 serde_json::json!({
1730 "kind": "undelete",
1731 "inode_id": "ino_7",
1732 "deletion_seq": 8,
1733 "path": "/docs/restored"
1734 }),
1735 ),
1736 (
1737 FilesystemOperation::RestoreRevision {
1738 path: path("/docs/a.txt"),
1739 source_revision_no: RevisionNo(2),
1740 },
1741 serde_json::json!({
1742 "kind": "restore_revision",
1743 "path": "/docs/a.txt",
1744 "source_revision_no": 2
1745 }),
1746 ),
1747 (
1748 FilesystemOperation::UpdateAttributes {
1749 path: path("/docs/a.txt"),
1750 set: BTreeMap::new(),
1751 remove: vec![attribute_key("draft")],
1752 expected_inode_id: None,
1753 expected_attributes_revision_no: None,
1754 },
1755 serde_json::json!({
1756 "kind": "update_attributes",
1757 "path": "/docs/a.txt",
1758 "remove": ["draft"]
1759 }),
1760 ),
1761 ];
1762
1763 for (operation, string_shaped_json) in cases {
1764 assert_eq!(
1765 serde_json::to_value(operation).expect("serialize filesystem operation"),
1766 string_shaped_json
1767 );
1768 }
1769 }
1770
1771 #[test]
1772 fn filesystem_operation_paths_validate_during_deserialization() {
1773 for encoded in [
1774 serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
1775 serde_json::json!({
1776 "kind": "put_file",
1777 "path": "relative",
1778 "content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
1779 }),
1780 serde_json::json!({"kind": "delete_path", "path": "relative"}),
1781 serde_json::json!({
1782 "kind": "move_path",
1783 "from_path": "relative",
1784 "to_path": "/target"
1785 }),
1786 serde_json::json!({
1787 "kind": "copy_path",
1788 "from_path": "/source",
1789 "to_path": "relative"
1790 }),
1791 serde_json::json!({
1792 "kind": "undelete",
1793 "inode_id": "ino_7",
1794 "deletion_seq": 8,
1795 "path": "relative"
1796 }),
1797 serde_json::json!({
1798 "kind": "restore_revision",
1799 "path": "relative",
1800 "source_revision_no": 2
1801 }),
1802 serde_json::json!({
1803 "kind": "update_attributes",
1804 "path": "relative",
1805 "remove": ["draft"]
1806 }),
1807 ] {
1808 assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1809 }
1810 }
1811
1812 #[test]
1813 fn inode_request_fields_accept_only_the_public_format() {
1814 let operations = [
1815 serde_json::json!({
1816 "kind": "delete_path",
1817 "path": "/docs/a.txt",
1818 "expected_inode_id": "ino_27"
1819 }),
1820 serde_json::json!({
1821 "kind": "undelete",
1822 "inode_id": "ino_27",
1823 "deletion_seq": 8
1824 }),
1825 serde_json::json!({
1826 "kind": "update_attributes",
1827 "path": "/docs/a.txt",
1828 "expected_inode_id": "ino_27"
1829 }),
1830 ];
1831
1832 for operation in operations {
1833 serde_json::from_value::<FilesystemOperation>(operation.clone())
1834 .expect("valid public inode ID");
1835
1836 let inode_key = if operation["kind"] == "undelete" {
1837 "inode_id"
1838 } else {
1839 "expected_inode_id"
1840 };
1841 for invalid in [serde_json::json!(27), serde_json::json!("27")] {
1842 let mut invalid_operation = operation.clone();
1843 invalid_operation[inode_key] = invalid;
1844 assert!(
1845 serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
1846 "{inode_key} accepted an invalid inode ID"
1847 );
1848 }
1849 }
1850 }
1851
1852 #[test]
1853 fn a_misspelled_guard_does_not_decode() {
1854 let put = |guard: &str| {
1855 let mut operation = serde_json::json!({
1856 "kind": "put_file",
1857 "path": "/docs/a.txt",
1858 "content_ref": sample_content_ref(),
1859 "behavior": "replace"
1860 });
1861 operation[guard] = serde_json::json!(3);
1862 serde_json::json!({
1863 "commit_id": "guarded-put",
1864 "actor": crate::ActorRef::loonfs_system(),
1865 "operations": [operation]
1866 })
1867 };
1868
1869 let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
1870 .expect("the guard spelled correctly decodes");
1871 assert!(matches!(
1872 spelled.operations.as_slice(),
1873 [FilesystemOperation::PutFile {
1874 expected_revision_no: Some(RevisionNo(3)),
1875 ..
1876 }]
1877 ));
1878
1879 for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
1880 assert!(
1881 serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
1882 "`{misspelling}` decoded instead of failing the request"
1883 );
1884 }
1885 }
1886
1887 #[test]
1888 fn expected_revision_no_must_fit_the_public_integer_range() {
1889 let body = |expected_revision_no: u64| {
1890 serde_json::json!({
1891 "commit_id": "bounded-revision-guard",
1892 "actor": crate::ActorRef::loonfs_system(),
1893 "operations": [{
1894 "kind": "put_file",
1895 "path": "/docs/a.txt",
1896 "content_ref": sample_content_ref(),
1897 "behavior": "replace",
1898 "expected_revision_no": expected_revision_no
1899 }]
1900 })
1901 };
1902
1903 let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
1904 .expect("deserialize the maximum revision number");
1905 assert!(matches!(
1906 request.operations.as_slice(),
1907 [FilesystemOperation::PutFile {
1908 expected_revision_no: Some(RevisionNo(value)),
1909 ..
1910 }] if *value == crate::MAX_PUBLIC_INTEGER
1911 ));
1912
1913 let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
1914 .expect_err("reject a revision number above the public limit");
1915 assert!(
1916 error
1917 .to_string()
1918 .contains("must be an integer from 0 through 9007199254740991"),
1919 "unexpected range error: {error}"
1920 );
1921 }
1922
1923 #[test]
1924 fn a_commit_request_rejects_unknown_fields_at_every_level() {
1925 let valid = || {
1926 serde_json::json!({
1927 "commit_id": "strict-commit",
1928 "actor": crate::ActorRef::loonfs_system(),
1929 "content_tokens": [{
1930 "content_ref": sample_content_ref(),
1931 "token": "opaque-proof"
1932 }],
1933 "operations": [{
1934 "kind": "update_attributes",
1935 "path": "/docs/a.txt",
1936 "set": {"owner": "ada"},
1937 "expected_inode_id": "ino_7"
1938 }]
1939 })
1940 };
1941 serde_json::from_value::<CommitRequest>(valid())
1942 .expect("the same body without a typo decodes");
1943
1944 let mut at_root = valid();
1945 at_root["mesage"] = serde_json::json!("a note");
1946
1947 let mut in_operation = valid();
1948 in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
1949
1950 let mut in_content_token = valid();
1951 in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
1952
1953 let mut in_content_ref = valid();
1954 in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
1955
1956 for (level, body) in [
1957 ("the request root", at_root),
1958 ("an operation variant", in_operation),
1959 ("a nested content token", in_content_token),
1960 ("a content ref below that", in_content_ref),
1961 ] {
1962 assert!(
1963 serde_json::from_value::<CommitRequest>(body).is_err(),
1964 "an unknown field in {level} decoded instead of failing the request"
1965 );
1966 }
1967 }
1968
1969 #[test]
1970 fn checkpoint_responses_use_one_checkpoint_wire_object() {
1971 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
1972 let checkpoint = Checkpoint {
1973 namespace_id: namespace_id.clone(),
1974 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1975 .expect("checkpoint id"),
1976 owner: CheckpointOwnerSummary::User {
1977 name: "release".to_owned(),
1978 },
1979 created_at_ms: 1_752_623_000_000,
1980 expires_at_ms: Some(1_752_626_600_000),
1981 checkpoint_seq: ChangeSeq(12),
1982 manifest_no: ManifestNo(9),
1983 };
1984 let checkpoint_json = serde_json::json!({
1985 "namespace_id": "demo",
1986 "checkpoint_id": "chk_00000000000000000000000000000001",
1987 "owner": {"kind": "user", "name": "release"},
1988 "created_at_ms": 1_752_623_000_000_u64,
1989 "expires_at_ms": 1_752_626_600_000_u64,
1990 "checkpoint_seq": 12,
1991 "manifest_no": 9,
1992 });
1993 assert_eq!(
1994 serde_json::to_value(checkpoint.clone()).expect("serialize checkpoint"),
1995 checkpoint_json,
1996 );
1997 assert_eq!(
1998 serde_json::to_value(ListCheckpointsResponse {
1999 namespace_id: namespace_id.clone(),
2000 checkpoints: vec![checkpoint.clone()],
2001 next_cursor: None,
2002 })
2003 .expect("serialize list checkpoints response"),
2004 serde_json::json!({
2005 "namespace_id": "demo",
2006 "checkpoints": [checkpoint_json],
2007 }),
2008 );
2009 assert_eq!(
2010 serde_json::to_value(ReleaseCheckpointResponse {
2011 namespace_id,
2012 checkpoint_id: checkpoint.checkpoint_id,
2013 })
2014 .expect("serialize release checkpoint response"),
2015 serde_json::json!({
2016 "namespace_id": "demo",
2017 "checkpoint_id": "chk_00000000000000000000000000000001",
2018 }),
2019 );
2020 }
2021
2022 #[test]
2023 fn optional_response_fields_are_omitted_and_default_when_absent() {
2024 let checkpoint_json = serde_json::to_value(Checkpoint {
2025 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
2026 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
2027 .expect("checkpoint id"),
2028 owner: CheckpointOwnerSummary::User {
2029 name: "release".to_owned(),
2030 },
2031 created_at_ms: 1_752_623_000_000,
2032 expires_at_ms: None,
2033 checkpoint_seq: ChangeSeq(3),
2034 manifest_no: ManifestNo(3),
2035 })
2036 .expect("serialize checkpoint");
2037 assert!(checkpoint_json.get("expires_at_ms").is_none());
2038 let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
2039 .expect("decode checkpoint without optional fields");
2040 assert_eq!(checkpoint.expires_at_ms, None);
2041
2042 let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
2043 let gc_json = serde_json::to_value(gc).expect("serialize gc response");
2044 assert!(gc_json.get("next_reclamation_at_ms").is_none());
2045 let gc: GcResponse =
2046 serde_json::from_value(gc_json).expect("decode gc response without optional fields");
2047 assert_eq!(gc.next_reclamation_at_ms, None);
2048 }
2049
2050 #[test]
2051 fn maintenance_step_outcomes_use_the_outcome_tag() {
2052 assert_eq!(
2053 serde_json::to_value(WalFlushStepOutcome::Flushed {
2054 manifest_head_seq: ChangeSeq(9),
2055 })
2056 .expect("serialize WAL flush outcome"),
2057 serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
2058 );
2059 assert_eq!(
2060 serde_json::to_value(ReorganizeStepOutcome::UnitPublished)
2061 .expect("serialize reorganize outcome"),
2062 serde_json::json!({"outcome": "unit_published"})
2063 );
2064 }
2065
2066 #[test]
2067 fn maintenance_request_bodies_reject_unknown_fields() {
2068 serde_json::from_value::<MaintenanceStepRequest>(serde_json::json!({
2069 "metadata_maintenance": {"max_wal_tail_segments": 4},
2070 "retention": {},
2071 "gc": {"grace_window_ms": 1_800_000, "max_objects": 32}
2072 }))
2073 .expect("the same body without a typo decodes");
2074
2075 for body in [
2076 serde_json::json!({"retenton": {}}),
2077 serde_json::json!({"retention": {"through_seq": 4}}),
2078 serde_json::json!({"metadata_maintenance": {"maxWalTailSegments": 4}}),
2079 serde_json::json!({"gc": {"max_object": 32}}),
2080 ] {
2081 assert!(
2082 serde_json::from_value::<MaintenanceStepRequest>(body.clone()).is_err(),
2083 "an unknown field decoded instead of failing the step: {body}"
2084 );
2085 }
2086
2087 serde_json::from_value::<CreateCheckpointRequest>(
2088 serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
2089 )
2090 .expect("the same checkpoint body without a typo decodes");
2091 assert!(serde_json::from_value::<CreateCheckpointRequest>(
2092 serde_json::json!({"name": "nightly", "ttlMs": 60_000})
2093 )
2094 .is_err());
2095
2096 serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
2099 .expect("an empty probe body decodes");
2100 assert!(
2101 serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
2102 );
2103
2104 serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2105 "namespace_id": "demo"
2106 }))
2107 .expect("the same create body without a typo decodes");
2108 assert!(
2109 serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2110 "namespace_id": "demo",
2111 "fork_of": "other"
2112 }))
2113 .is_err()
2114 );
2115 assert!(
2116 serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
2117 "new_namespace_id": "demo",
2118 "source_namespace_id": "other"
2119 }))
2120 .is_err()
2121 );
2122 }
2123}