1use super::ContentToken;
8use crate::{
9 AbsolutePath, AttributeKey, AttributeRevisionNo, AttributeValue, ChangeSeq, CheckpointId,
10 CommitId, ContentRef, InodeId, ManifestId, 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")]
35 pub request_id: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub details: Option<Box<ErrorDetails>>,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50pub struct ErrorDetails {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub commit_id: Option<CommitId>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub committed_seq: Option<ChangeSeq>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub committed_fingerprint: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub operation_index: Option<u32>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub fenced_epoch: Option<WriterEpoch>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub active_writer_epoch: Option<WriterEpoch>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub active_writer: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub active_acquired_at_ms: Option<u64>,
88 #[serde(
90 default,
91 skip_serializing_if = "Option::is_none",
92 with = "crate::public_inode_id::option"
93 )]
94 #[cfg_attr(
95 feature = "openapi",
96 schema(schema_with = crate::public_inode_id::optional_schema)
97 )]
98 pub inode_id: Option<InodeId>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub expected_revision_no: Option<RevisionNo>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub actual_revision_no: Option<RevisionNo>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub after_seq: Option<ChangeSeq>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub retention_floor_seq: Option<ChangeSeq>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub requested_deletion_seq: Option<ChangeSeq>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub active_deletion_seq: Option<ChangeSeq>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub expected_head_seq: Option<ChangeSeq>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub actual_head_seq: Option<ChangeSeq>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
136#[serde(deny_unknown_fields)]
137pub struct CreateNamespaceRequest {
138 pub namespace_id: NamespaceId,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[serde(deny_unknown_fields)]
146pub struct ForkNamespaceRequest {
147 pub new_namespace_id: NamespaceId,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157pub struct NamespaceStatusResponse {
158 pub namespace_id: NamespaceId,
160 pub head_seq: ChangeSeq,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub current_manifest_id: Option<ManifestId>,
165 pub wal_tail_segments: u64,
167 pub retention_floor_seq: ChangeSeq,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
174pub struct DeleteNamespaceResponse {
175 pub namespace_id: NamespaceId,
177 pub head_seq: ChangeSeq,
180}
181
182#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
185#[serde(rename_all = "snake_case")]
186pub enum DestinationBehavior {
187 #[default]
189 NoReplace,
190 Replace,
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198#[serde(rename_all = "snake_case")]
199pub enum DeleteDirectoryBehavior {
200 #[default]
202 NonRecursive,
203 Recursive,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
215#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
216pub enum FilesystemOperation {
217 #[cfg_attr(feature = "openapi", schema(title = "FsOpCreateDirectory"))]
219 CreateDirectory {
220 path: AbsolutePath,
222 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
225 parents: bool,
226 },
227 #[cfg_attr(feature = "openapi", schema(title = "FsOpPutFile"))]
229 PutFile {
230 path: AbsolutePath,
232 content_ref: ContentRef,
234 #[serde(default)]
236 behavior: DestinationBehavior,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
242 expected_revision_no: Option<RevisionNo>,
243 },
244 #[cfg_attr(feature = "openapi", schema(title = "FsOpDeletePath"))]
246 DeletePath {
247 path: AbsolutePath,
249 #[serde(default)]
251 behavior: DeleteDirectoryBehavior,
252 #[serde(
256 default,
257 skip_serializing_if = "Option::is_none",
258 with = "crate::public_inode_id::option"
259 )]
260 #[cfg_attr(
261 feature = "openapi",
262 schema(schema_with = crate::public_inode_id::optional_schema)
263 )]
264 expected_inode_id: Option<InodeId>,
265 },
266 #[cfg_attr(feature = "openapi", schema(title = "FsOpMovePath"))]
268 MovePath {
269 from_path: AbsolutePath,
271 to_path: AbsolutePath,
273 #[serde(default)]
275 behavior: DestinationBehavior,
276 },
277 #[cfg_attr(feature = "openapi", schema(title = "FsOpCopyPath"))]
279 CopyPath {
280 from_path: AbsolutePath,
282 to_path: AbsolutePath,
284 #[serde(default)]
286 behavior: DestinationBehavior,
287 },
288 #[cfg_attr(feature = "openapi", schema(title = "FsOpUndelete"))]
293 Undelete {
294 #[serde(with = "crate::public_inode_id")]
296 #[cfg_attr(
297 feature = "openapi",
298 schema(schema_with = crate::public_inode_id::schema)
299 )]
300 inode_id: InodeId,
301 deletion_seq: ChangeSeq,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
310 path: Option<AbsolutePath>,
311 },
312 #[cfg_attr(feature = "openapi", schema(title = "FsOpRestoreRevision"))]
314 RestoreRevision {
315 path: AbsolutePath,
317 source_revision_no: RevisionNo,
319 },
320 #[cfg_attr(feature = "openapi", schema(title = "FsOpUpdateAttributes"))]
322 UpdateAttributes {
323 path: AbsolutePath,
325 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
329 set: BTreeMap<AttributeKey, AttributeValue>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 remove: Vec<AttributeKey>,
336 #[serde(
340 default,
341 skip_serializing_if = "Option::is_none",
342 with = "crate::public_inode_id::option"
343 )]
344 #[cfg_attr(
345 feature = "openapi",
346 schema(schema_with = crate::public_inode_id::optional_schema)
347 )]
348 expected_inode_id: Option<InodeId>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
355 expected_attributes_revision_no: Option<AttributeRevisionNo>,
356 },
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369#[serde(deny_unknown_fields)]
370pub struct CommitRequest {
371 pub commit_id: CommitId,
373 pub actor: crate::ActorRef,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub message: Option<String>,
381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub content_tokens: Vec<ContentToken>,
385 pub operations: Vec<FilesystemOperation>,
388}
389
390impl CommitRequest {
391 pub fn single(
393 commit_id: CommitId,
394 actor: crate::ActorRef,
395 message: Option<String>,
396 operation: FilesystemOperation,
397 ) -> Self {
398 Self {
399 commit_id,
400 actor,
401 message,
402 content_tokens: Vec::new(),
403 operations: vec![operation],
404 }
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
411pub struct FileRevision {
412 #[serde(with = "crate::public_inode_id")]
414 #[cfg_attr(
415 feature = "openapi",
416 schema(schema_with = crate::public_inode_id::schema)
417 )]
418 pub inode_id: InodeId,
419 pub revision_no: RevisionNo,
421 pub committed_seq: ChangeSeq,
423 pub committed_at_ms: u64,
426 pub actor: crate::ActorRef,
428 pub content_ref: ContentRef,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
435pub struct ListFileRevisionsResponse {
436 pub namespace_id: NamespaceId,
438 #[serde(with = "crate::public_inode_id")]
440 #[cfg_attr(
441 feature = "openapi",
442 schema(schema_with = crate::public_inode_id::schema)
443 )]
444 pub inode_id: InodeId,
445 pub head_seq: ChangeSeq,
447 pub revisions: Vec<FileRevision>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub next_cursor: Option<String>,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
457#[serde(deny_unknown_fields)]
458pub struct CreateCheckpointRequest {
459 pub name: String,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub ttl_ms: Option<u64>,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
471pub struct CreateCheckpointResponse {
472 pub namespace_id: NamespaceId,
474 #[serde(flatten)]
476 pub checkpoint: Checkpoint,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
482pub struct ReleaseCheckpointResponse {
483 pub namespace_id: NamespaceId,
485 pub checkpoint_id: CheckpointId,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
497#[serde(tag = "kind", rename_all = "snake_case")]
498pub enum CheckpointOwnerSummary {
499 #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
501 User {
502 name: String,
505 },
506 #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
509 Fork {
510 target_namespace_id: NamespaceId,
512 },
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
518pub struct Checkpoint {
519 pub checkpoint_id: CheckpointId,
521 pub owner: CheckpointOwnerSummary,
523 pub created_at_ms: u64,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub expires_at_ms: Option<u64>,
532 pub checkpoint_seq: ChangeSeq,
534 pub manifest_id: ManifestId,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
541pub struct ListCheckpointsResponse {
542 pub namespace_id: NamespaceId,
544 pub checkpoints: Vec<Checkpoint>,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub next_cursor: Option<String>,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
555#[serde(rename_all = "snake_case")]
556pub enum FlushWalOutcome {
557 AlreadyCurrent,
559 Published,
561 Superseded,
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
569pub struct FlushWalResponse {
570 pub namespace_id: NamespaceId,
572 pub target_head_seq: ChangeSeq,
574 pub manifest_id: ManifestId,
576 pub manifest_head_seq: ChangeSeq,
578 pub outcome: FlushWalOutcome,
580}
581
582#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
589#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
590#[serde(deny_unknown_fields)]
591pub struct GcRequest {
592 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub grace_window_ms: Option<u64>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub max_objects: Option<u64>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub cursor: Option<String>,
616}
617
618#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
629#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
630pub struct RetainedCandidates {
631 pub referenced: u64,
637 pub grace_window: u64,
640 pub no_provider_timestamp: u64,
643 pub no_reference_manifest: u64,
649 pub degraded_roots: u64,
652 pub unrecognized_key: u64,
655 pub checkpoint_not_releasable: u64,
662 pub upload_session_window: u64,
667 pub upload_session_undecided: u64,
671 pub content_scan_deferred: u64,
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
680pub struct GcResponse {
681 pub namespace_id: NamespaceId,
683 pub deleted_wal_segments: u64,
685 pub deleted_metadata_tables: u64,
687 pub deleted_manifests: u64,
689 pub deleted_checkpoint_records: u64,
691 pub released_fork_checkpoints: u64,
694 #[serde(default)]
697 pub released_expired_checkpoints: u64,
698 #[serde(default)]
700 pub deleted_upload_sessions: u64,
701 #[serde(default)]
707 pub deleted_content_objects: u64,
708 #[serde(default)]
711 pub released_missing_basis_checkpoints: u64,
712 pub retained_candidates: u64,
715 #[serde(default)]
719 pub retained: RetainedCandidates,
720 pub degraded_retention: bool,
722 #[serde(default)]
730 pub content_reclamation_deferred: bool,
731 #[serde(default)]
738 pub budget_exhausted: bool,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub next_cursor: Option<String>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub next_reclamation_at_ms: Option<u64>,
758}
759
760impl GcResponse {
761 pub fn empty(namespace_id: NamespaceId) -> Self {
763 Self {
764 namespace_id,
765 deleted_wal_segments: 0,
766 deleted_metadata_tables: 0,
767 deleted_manifests: 0,
768 deleted_checkpoint_records: 0,
769 released_fork_checkpoints: 0,
770 released_expired_checkpoints: 0,
771 deleted_upload_sessions: 0,
772 deleted_content_objects: 0,
773 released_missing_basis_checkpoints: 0,
774 retained_candidates: 0,
775 retained: RetainedCandidates::default(),
776 degraded_retention: false,
777 content_reclamation_deferred: false,
778 budget_exhausted: false,
779 next_cursor: None,
780 next_reclamation_at_ms: None,
781 }
782 }
783
784 pub fn retain(&mut self, reason: RetainedReason) {
790 self.retained_candidates += 1;
791 *reason.counter(&mut self.retained) += 1;
792 }
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
799pub enum RetainedReason {
800 Referenced,
802 GraceWindow,
804 NoProviderTimestamp,
806 NoReferenceManifest,
808 DegradedRoots,
810 UnrecognizedKey,
812 CheckpointNotReleasable,
814 UploadSessionWindow,
816 UploadSessionUndecided,
818 ContentScanDeferred,
820}
821
822impl RetainedReason {
823 fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
824 match self {
825 Self::Referenced => &mut retained.referenced,
826 Self::GraceWindow => &mut retained.grace_window,
827 Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
828 Self::NoReferenceManifest => &mut retained.no_reference_manifest,
829 Self::DegradedRoots => &mut retained.degraded_roots,
830 Self::UnrecognizedKey => &mut retained.unrecognized_key,
831 Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
832 Self::UploadSessionWindow => &mut retained.upload_session_window,
833 Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
834 Self::ContentScanDeferred => &mut retained.content_scan_deferred,
835 }
836 }
837}
838
839impl RetainedCandidates {
840 pub fn by_reason(&self) -> [(&'static str, u64); 10] {
843 [
844 ("referenced", self.referenced),
845 ("grace_window", self.grace_window),
846 ("no_provider_timestamp", self.no_provider_timestamp),
847 ("no_reference_manifest", self.no_reference_manifest),
848 ("degraded_roots", self.degraded_roots),
849 ("unrecognized_key", self.unrecognized_key),
850 ("checkpoint_not_releasable", self.checkpoint_not_releasable),
851 ("upload_session_window", self.upload_session_window),
852 ("upload_session_undecided", self.upload_session_undecided),
853 ("content_scan_deferred", self.content_scan_deferred),
854 ]
855 }
856
857 pub fn add(&mut self, other: &Self) {
859 self.referenced += other.referenced;
860 self.grace_window += other.grace_window;
861 self.no_provider_timestamp += other.no_provider_timestamp;
862 self.no_reference_manifest += other.no_reference_manifest;
863 self.degraded_roots += other.degraded_roots;
864 self.unrecognized_key += other.unrecognized_key;
865 self.checkpoint_not_releasable += other.checkpoint_not_releasable;
866 self.upload_session_window += other.upload_session_window;
867 self.upload_session_undecided += other.upload_session_undecided;
868 self.content_scan_deferred += other.content_scan_deferred;
869 }
870
871 pub fn top_reason(&self) -> Option<(&'static str, u64)> {
875 self.by_reason()
876 .into_iter()
877 .filter(|(_, count)| *count > 0)
878 .rev()
881 .max_by_key(|(_, count)| *count)
882 }
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
888pub struct AdvanceRetentionResponse {
889 pub retention_floor_seq: ChangeSeq,
891}
892
893#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
902#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
903#[serde(deny_unknown_fields)]
904pub struct MaintenanceStepRequest {
905 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub metadata: Option<MetadataMaintenanceRequest>,
909 #[serde(default)]
912 pub advance_retention: bool,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub gc: Option<GcRequest>,
917}
918
919#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
921#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
922#[serde(deny_unknown_fields)]
923pub struct MetadataMaintenanceRequest {
924 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub max_wal_tail_segments: Option<u64>,
929}
930
931#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
934#[serde(tag = "outcome", rename_all = "snake_case")]
935pub enum WalFlushStepOutcome {
936 NotNeeded,
938 Flushed {
940 manifest_head_seq: ChangeSeq,
942 },
943 Superseded {
946 attempted_seq: ChangeSeq,
948 current_manifest_id: ManifestId,
950 },
951 RaceLost {
953 observed_head_seq: ChangeSeq,
955 },
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
963#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
964#[serde(tag = "outcome", rename_all = "snake_case")]
965pub enum ReorganizeStepOutcome {
966 NotNeeded,
968 UnitPublished,
970 CompactionStarted,
974 CompactionRunning,
978 CompactionAtCapacity,
982 CompactionRequired,
986 Superseded,
988}
989
990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
996#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
997pub struct MaintenanceStepResponse {
998 pub namespace_id: NamespaceId,
1000 pub status_before: NamespaceStatusResponse,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub metadata: Option<MetadataMaintenanceResponse>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub retention: Option<AdvanceRetentionResponse>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub gc: Option<GcResponse>,
1011}
1012
1013#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1015#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1016pub struct MetadataMaintenanceResponse {
1017 pub wal_flush: WalFlushStepOutcome,
1019 pub reorganize: ReorganizeStepOutcome,
1021}
1022
1023#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1028#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1029#[serde(deny_unknown_fields)]
1030pub struct StoreProbeRequest {}
1031
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1034#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1035pub struct StoreProbeResponse {
1036 pub run_id: String,
1039 pub checks: Vec<StoreProbeCheckResult>,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1048pub struct StoreProbeCheckResult {
1049 pub name: String,
1051 pub outcome: StoreProbeCheckOutcome,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub message: Option<String>,
1057}
1058
1059#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1061#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1062#[serde(rename_all = "snake_case")]
1063pub enum StoreProbeCheckOutcome {
1064 Passed,
1066 Unsupported,
1070 Failed,
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078 use crate::ContentId;
1079
1080 fn path(value: &str) -> AbsolutePath {
1081 AbsolutePath::parse(value).expect("valid test path")
1082 }
1083
1084 fn attribute_key(value: &str) -> AttributeKey {
1085 AttributeKey::parse(value).expect("valid test attribute key")
1086 }
1087
1088 fn sample_content_ref() -> ContentRef {
1089 ContentRef::blob_v1(
1090 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
1091 b"hello",
1092 )
1093 }
1094
1095 #[test]
1096 fn namespace_create_and_fork_responses_use_the_status_shape() {
1097 let create = NamespaceStatusResponse {
1098 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1099 head_seq: ChangeSeq(0),
1100 current_manifest_id: None,
1101 wal_tail_segments: 0,
1102 retention_floor_seq: ChangeSeq(0),
1103 };
1104 assert_eq!(
1105 serde_json::to_value(create).expect("serialize create response"),
1106 serde_json::json!({
1107 "namespace_id": "demo",
1108 "head_seq": 0,
1109 "wal_tail_segments": 0,
1110 "retention_floor_seq": 0
1111 })
1112 );
1113
1114 let fork = NamespaceStatusResponse {
1115 namespace_id: NamespaceId::parse("demo-branch").expect("namespace id"),
1116 head_seq: ChangeSeq(7),
1117 current_manifest_id: None,
1118 wal_tail_segments: 0,
1119 retention_floor_seq: ChangeSeq(7),
1120 };
1121 assert_eq!(
1122 serde_json::to_value(fork).expect("serialize fork response"),
1123 serde_json::json!({
1124 "namespace_id": "demo-branch",
1125 "head_seq": 7,
1126 "wal_tail_segments": 0,
1127 "retention_floor_seq": 7
1128 })
1129 );
1130 }
1131
1132 #[test]
1133 fn behavior_enums_use_snake_case_wire_values() {
1134 assert_eq!(
1135 DestinationBehavior::default(),
1136 DestinationBehavior::NoReplace
1137 );
1138 assert_eq!(
1139 DeleteDirectoryBehavior::default(),
1140 DeleteDirectoryBehavior::NonRecursive
1141 );
1142 assert_eq!(
1143 serde_json::to_value(DestinationBehavior::NoReplace)
1144 .expect("destination behavior json"),
1145 serde_json::json!("no_replace")
1146 );
1147 assert_eq!(
1148 serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
1149 serde_json::json!("replace")
1150 );
1151 assert_eq!(
1152 serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
1153 .expect("delete behavior json"),
1154 serde_json::json!("non_recursive")
1155 );
1156 assert_eq!(
1157 serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1158 serde_json::json!("recursive")
1159 );
1160 }
1161
1162 #[test]
1163 fn filesystem_delete_and_move_operations_use_behavior_field() {
1164 let create_directory = FilesystemOperation::CreateDirectory {
1165 path: path("/docs"),
1166 parents: false,
1167 };
1168 assert_eq!(
1169 serde_json::to_value(&create_directory).expect("create directory op json"),
1170 serde_json::json!({
1171 "kind": "create_directory",
1172 "path": "/docs"
1173 })
1174 );
1175
1176 let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1177 path: path("/docs/notes"),
1178 parents: true,
1179 };
1180 assert_eq!(
1181 serde_json::to_value(&create_directory_with_parents)
1182 .expect("create directory with parents op json"),
1183 serde_json::json!({
1184 "kind": "create_directory",
1185 "path": "/docs/notes",
1186 "parents": true
1187 })
1188 );
1189
1190 let delete = FilesystemOperation::DeletePath {
1191 path: path("/docs"),
1192 behavior: DeleteDirectoryBehavior::Recursive,
1193 expected_inode_id: None,
1194 };
1195 assert_eq!(
1196 serde_json::to_value(&delete).expect("delete op json"),
1197 serde_json::json!({
1198 "kind": "delete_path",
1199 "path": "/docs",
1200 "behavior": "recursive"
1201 })
1202 );
1203
1204 let move_path = FilesystemOperation::MovePath {
1205 from_path: path("/docs/a.txt"),
1206 to_path: path("/docs/b.txt"),
1207 behavior: DestinationBehavior::Replace,
1208 };
1209 assert_eq!(
1210 serde_json::to_value(&move_path).expect("move op json"),
1211 serde_json::json!({
1212 "kind": "move_path",
1213 "from_path": "/docs/a.txt",
1214 "to_path": "/docs/b.txt",
1215 "behavior": "replace"
1216 })
1217 );
1218
1219 let copy_path = FilesystemOperation::CopyPath {
1220 from_path: path("/docs/a.txt"),
1221 to_path: path("/docs/b.txt"),
1222 behavior: DestinationBehavior::Replace,
1223 };
1224 assert_eq!(
1225 serde_json::to_value(©_path).expect("copy op json"),
1226 serde_json::json!({
1227 "kind": "copy_path",
1228 "from_path": "/docs/a.txt",
1229 "to_path": "/docs/b.txt",
1230 "behavior": "replace"
1231 })
1232 );
1233
1234 let update_attributes = FilesystemOperation::UpdateAttributes {
1235 path: path("/docs/a.txt"),
1236 set: BTreeMap::from([(
1237 attribute_key("owner"),
1238 AttributeValue::parse("ada").expect("valid attribute value"),
1239 )]),
1240 remove: vec![attribute_key("draft")],
1241 expected_inode_id: Some(InodeId(7)),
1242 expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
1243 };
1244 assert_eq!(
1245 serde_json::to_value(&update_attributes).expect("update attributes op json"),
1246 serde_json::json!({
1247 "kind": "update_attributes",
1248 "path": "/docs/a.txt",
1249 "set": {"owner": "ada"},
1250 "remove": ["draft"],
1251 "expected_inode_id": "ino_7",
1252 "expected_attributes_revision_no": 3
1253 })
1254 );
1255 }
1256
1257 #[test]
1258 fn update_attributes_omits_empty_collections_and_absent_guards() {
1259 let set_only = FilesystemOperation::UpdateAttributes {
1260 path: path("/docs/a.txt"),
1261 set: BTreeMap::from([(
1262 attribute_key("owner"),
1263 AttributeValue::parse("ada,grace").expect("valid attribute value"),
1264 )]),
1265 remove: Vec::new(),
1266 expected_inode_id: None,
1267 expected_attributes_revision_no: None,
1268 };
1269 assert_eq!(
1270 serde_json::to_value(&set_only).expect("set-only op json"),
1271 serde_json::json!({
1272 "kind": "update_attributes",
1273 "path": "/docs/a.txt",
1274 "set": {"owner": "ada,grace"}
1275 })
1276 );
1277
1278 let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
1279 "kind": "update_attributes",
1280 "path": "/docs/a.txt",
1281 "remove": ["draft"]
1282 }))
1283 .expect("remove-only op defaults the set map and both guards");
1284 assert_eq!(
1285 decoded,
1286 FilesystemOperation::UpdateAttributes {
1287 path: path("/docs/a.txt"),
1288 set: BTreeMap::new(),
1289 remove: vec![attribute_key("draft")],
1290 expected_inode_id: None,
1291 expected_attributes_revision_no: None,
1292 }
1293 );
1294 }
1295
1296 #[test]
1297 fn update_attributes_validates_keys_and_values_during_deserialization() {
1298 for encoded in [
1301 serde_json::json!({
1302 "kind": "update_attributes",
1303 "path": "/docs/a.txt",
1304 "set": {"": "ada"}
1305 }),
1306 serde_json::json!({
1307 "kind": "update_attributes",
1308 "path": "/docs/a.txt",
1309 "set": {"owner": {"kind": "string", "value": "ada"}}
1310 }),
1311 serde_json::json!({
1312 "kind": "update_attributes",
1313 "path": "/docs/a.txt",
1314 "remove": ["a\u{0}b"]
1315 }),
1316 ] {
1317 assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1318 }
1319 }
1320
1321 #[test]
1322 fn filesystem_operations_default_omitted_behavior_fields() {
1323 let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1324 "kind": "put_file",
1325 "path": "/docs/a.txt",
1326 "content_ref": {
1327 "kind": "blob_v1",
1328 "content_id": "con_0123456789abcdef0123456789abcdef",
1329 "size_bytes": 1,
1330 "checksum": {
1331 "algorithm": "sha256",
1332 "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1333 }
1334 }
1335 }))
1336 .expect("put op defaults behavior");
1337 assert!(matches!(
1338 put,
1339 FilesystemOperation::PutFile {
1340 behavior: DestinationBehavior::NoReplace,
1341 expected_revision_no: None,
1342 ..
1343 }
1344 ));
1345
1346 let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1347 "kind": "delete_path",
1348 "path": "/docs"
1349 }))
1350 .expect("delete op defaults behavior");
1351 assert_eq!(
1352 delete,
1353 FilesystemOperation::DeletePath {
1354 path: path("/docs"),
1355 behavior: DeleteDirectoryBehavior::NonRecursive,
1356 expected_inode_id: None,
1357 }
1358 );
1359
1360 let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1361 "kind": "move_path",
1362 "from_path": "/docs/a.txt",
1363 "to_path": "/docs/b.txt"
1364 }))
1365 .expect("move op defaults behavior");
1366 assert_eq!(
1367 move_path,
1368 FilesystemOperation::MovePath {
1369 from_path: path("/docs/a.txt"),
1370 to_path: path("/docs/b.txt"),
1371 behavior: DestinationBehavior::NoReplace,
1372 }
1373 );
1374
1375 let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1376 "kind": "copy_path",
1377 "from_path": "/docs/a.txt",
1378 "to_path": "/docs/b.txt"
1379 }))
1380 .expect("copy op defaults behavior");
1381 assert_eq!(
1382 copy_path,
1383 FilesystemOperation::CopyPath {
1384 from_path: path("/docs/a.txt"),
1385 to_path: path("/docs/b.txt"),
1386 behavior: DestinationBehavior::NoReplace,
1387 }
1388 );
1389 }
1390
1391 #[test]
1392 fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1393 let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
1394 let cases = [
1395 (
1396 FilesystemOperation::PutFile {
1397 path: path("/docs/a.txt"),
1398 content_ref: content_ref.clone(),
1399 behavior: DestinationBehavior::NoReplace,
1400 expected_revision_no: None,
1401 },
1402 serde_json::json!({
1403 "kind": "put_file",
1404 "path": "/docs/a.txt",
1405 "content_ref": content_ref,
1406 "behavior": "no_replace"
1407 }),
1408 ),
1409 (
1410 FilesystemOperation::Undelete {
1411 inode_id: InodeId(7),
1412 deletion_seq: ChangeSeq(8),
1413 path: Some(path("/docs/restored")),
1414 },
1415 serde_json::json!({
1416 "kind": "undelete",
1417 "inode_id": "ino_7",
1418 "deletion_seq": 8,
1419 "path": "/docs/restored"
1420 }),
1421 ),
1422 (
1423 FilesystemOperation::RestoreRevision {
1424 path: path("/docs/a.txt"),
1425 source_revision_no: RevisionNo(2),
1426 },
1427 serde_json::json!({
1428 "kind": "restore_revision",
1429 "path": "/docs/a.txt",
1430 "source_revision_no": 2
1431 }),
1432 ),
1433 (
1434 FilesystemOperation::UpdateAttributes {
1435 path: path("/docs/a.txt"),
1436 set: BTreeMap::new(),
1437 remove: vec![attribute_key("draft")],
1438 expected_inode_id: None,
1439 expected_attributes_revision_no: None,
1440 },
1441 serde_json::json!({
1442 "kind": "update_attributes",
1443 "path": "/docs/a.txt",
1444 "remove": ["draft"]
1445 }),
1446 ),
1447 ];
1448
1449 for (operation, string_shaped_json) in cases {
1450 assert_eq!(
1451 serde_json::to_value(operation).expect("serialize filesystem operation"),
1452 string_shaped_json
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn filesystem_operation_paths_validate_during_deserialization() {
1459 for encoded in [
1460 serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
1461 serde_json::json!({
1462 "kind": "put_file",
1463 "path": "relative",
1464 "content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
1465 }),
1466 serde_json::json!({"kind": "delete_path", "path": "relative"}),
1467 serde_json::json!({
1468 "kind": "move_path",
1469 "from_path": "relative",
1470 "to_path": "/target"
1471 }),
1472 serde_json::json!({
1473 "kind": "copy_path",
1474 "from_path": "/source",
1475 "to_path": "relative"
1476 }),
1477 serde_json::json!({
1478 "kind": "undelete",
1479 "inode_id": "ino_7",
1480 "deletion_seq": 8,
1481 "path": "relative"
1482 }),
1483 serde_json::json!({
1484 "kind": "restore_revision",
1485 "path": "relative",
1486 "source_revision_no": 2
1487 }),
1488 serde_json::json!({
1489 "kind": "update_attributes",
1490 "path": "relative",
1491 "remove": ["draft"]
1492 }),
1493 ] {
1494 assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1495 }
1496 }
1497
1498 #[test]
1499 fn inode_request_fields_accept_only_the_public_format() {
1500 let operations = [
1501 serde_json::json!({
1502 "kind": "delete_path",
1503 "path": "/docs/a.txt",
1504 "expected_inode_id": "ino_27"
1505 }),
1506 serde_json::json!({
1507 "kind": "undelete",
1508 "inode_id": "ino_27",
1509 "deletion_seq": 8
1510 }),
1511 serde_json::json!({
1512 "kind": "update_attributes",
1513 "path": "/docs/a.txt",
1514 "expected_inode_id": "ino_27"
1515 }),
1516 ];
1517
1518 for operation in operations {
1519 serde_json::from_value::<FilesystemOperation>(operation.clone())
1520 .expect("valid public inode ID");
1521
1522 let inode_key = if operation["kind"] == "undelete" {
1523 "inode_id"
1524 } else {
1525 "expected_inode_id"
1526 };
1527 for invalid in [serde_json::json!(27), serde_json::json!("27")] {
1528 let mut invalid_operation = operation.clone();
1529 invalid_operation[inode_key] = invalid;
1530 assert!(
1531 serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
1532 "{inode_key} accepted an invalid inode ID"
1533 );
1534 }
1535 }
1536 }
1537
1538 #[test]
1542 fn a_misspelled_guard_does_not_decode() {
1543 let put = |guard: &str| {
1544 let mut operation = serde_json::json!({
1545 "kind": "put_file",
1546 "path": "/docs/a.txt",
1547 "content_ref": sample_content_ref(),
1548 "behavior": "replace"
1549 });
1550 operation[guard] = serde_json::json!(3);
1551 serde_json::json!({
1552 "commit_id": "guarded-put",
1553 "actor": crate::ActorRef::loonfs_system(),
1554 "operations": [operation]
1555 })
1556 };
1557
1558 let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
1559 .expect("the guard spelled correctly decodes");
1560 assert!(matches!(
1561 spelled.operations.as_slice(),
1562 [FilesystemOperation::PutFile {
1563 expected_revision_no: Some(RevisionNo(3)),
1564 ..
1565 }]
1566 ));
1567
1568 for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
1569 assert!(
1570 serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
1571 "`{misspelling}` decoded instead of failing the request"
1572 );
1573 }
1574 }
1575
1576 #[test]
1577 fn expected_revision_no_must_fit_the_public_integer_range() {
1578 let body = |expected_revision_no: u64| {
1579 serde_json::json!({
1580 "commit_id": "bounded-revision-guard",
1581 "actor": crate::ActorRef::loonfs_system(),
1582 "operations": [{
1583 "kind": "put_file",
1584 "path": "/docs/a.txt",
1585 "content_ref": sample_content_ref(),
1586 "behavior": "replace",
1587 "expected_revision_no": expected_revision_no
1588 }]
1589 })
1590 };
1591
1592 let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
1593 .expect("deserialize the maximum revision number");
1594 assert!(matches!(
1595 request.operations.as_slice(),
1596 [FilesystemOperation::PutFile {
1597 expected_revision_no: Some(RevisionNo(value)),
1598 ..
1599 }] if *value == crate::MAX_PUBLIC_INTEGER
1600 ));
1601
1602 let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
1603 .expect_err("reject a revision number above the public limit");
1604 assert!(
1605 error
1606 .to_string()
1607 .contains("must be an integer from 0 through 9007199254740991"),
1608 "unexpected range error: {error}"
1609 );
1610 }
1611
1612 #[test]
1615 fn a_commit_request_rejects_unknown_fields_at_every_level() {
1616 let valid = || {
1617 serde_json::json!({
1618 "commit_id": "strict-commit",
1619 "actor": crate::ActorRef::loonfs_system(),
1620 "content_tokens": [{
1621 "content_ref": sample_content_ref(),
1622 "token": "opaque-proof"
1623 }],
1624 "operations": [{
1625 "kind": "update_attributes",
1626 "path": "/docs/a.txt",
1627 "set": {"owner": "ada"},
1628 "expected_inode_id": "ino_7"
1629 }]
1630 })
1631 };
1632 serde_json::from_value::<CommitRequest>(valid())
1633 .expect("the same body without a typo decodes");
1634
1635 let mut at_root = valid();
1636 at_root["mesage"] = serde_json::json!("a note");
1637
1638 let mut in_operation = valid();
1639 in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
1640
1641 let mut in_content_token = valid();
1642 in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
1643
1644 let mut in_content_ref = valid();
1645 in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
1646
1647 for (level, body) in [
1648 ("the request root", at_root),
1649 ("an operation variant", in_operation),
1650 ("a nested content token", in_content_token),
1651 ("a content ref below that", in_content_ref),
1652 ] {
1653 assert!(
1654 serde_json::from_value::<CommitRequest>(body).is_err(),
1655 "an unknown field in {level} decoded instead of failing the request"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn checkpoint_responses_use_one_checkpoint_wire_object() {
1662 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
1663 let checkpoint = Checkpoint {
1664 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1665 .expect("checkpoint id"),
1666 owner: CheckpointOwnerSummary::User {
1667 name: "release".to_owned(),
1668 },
1669 created_at_ms: 1_752_623_000_000,
1670 expires_at_ms: Some(1_752_626_600_000),
1671 checkpoint_seq: ChangeSeq(12),
1672 manifest_id: ManifestId(9),
1673 };
1674 let checkpoint_json = serde_json::json!({
1675 "checkpoint_id": "chk_00000000000000000000000000000001",
1676 "owner": {"kind": "user", "name": "release"},
1677 "created_at_ms": 1_752_623_000_000_u64,
1678 "expires_at_ms": 1_752_626_600_000_u64,
1679 "checkpoint_seq": 12,
1680 "manifest_id": 9,
1681 });
1682 let mut create_json = checkpoint_json.clone();
1683 create_json["namespace_id"] = serde_json::json!("demo");
1684 assert_eq!(
1685 serde_json::to_value(CreateCheckpointResponse {
1686 namespace_id: namespace_id.clone(),
1687 checkpoint: checkpoint.clone(),
1688 })
1689 .expect("serialize create checkpoint response"),
1690 create_json,
1691 );
1692 assert_eq!(
1693 serde_json::to_value(ListCheckpointsResponse {
1694 namespace_id: namespace_id.clone(),
1695 checkpoints: vec![checkpoint.clone()],
1696 next_cursor: None,
1697 })
1698 .expect("serialize list checkpoints response"),
1699 serde_json::json!({
1700 "namespace_id": "demo",
1701 "checkpoints": [checkpoint_json],
1702 }),
1703 );
1704 assert_eq!(
1705 serde_json::to_value(ReleaseCheckpointResponse {
1706 namespace_id,
1707 checkpoint_id: checkpoint.checkpoint_id,
1708 })
1709 .expect("serialize release checkpoint response"),
1710 serde_json::json!({
1711 "namespace_id": "demo",
1712 "checkpoint_id": "chk_00000000000000000000000000000001",
1713 }),
1714 );
1715 }
1716
1717 #[test]
1718 fn optional_response_fields_are_omitted_and_default_when_absent() {
1719 let checkpoint_json = serde_json::to_value(Checkpoint {
1720 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1721 .expect("checkpoint id"),
1722 owner: CheckpointOwnerSummary::User {
1723 name: "release".to_owned(),
1724 },
1725 created_at_ms: 1_752_623_000_000,
1726 expires_at_ms: None,
1727 checkpoint_seq: ChangeSeq(3),
1728 manifest_id: ManifestId(3),
1729 })
1730 .expect("serialize checkpoint");
1731 assert!(checkpoint_json.get("expires_at_ms").is_none());
1732 let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
1733 .expect("decode checkpoint without optional fields");
1734 assert_eq!(checkpoint.expires_at_ms, None);
1735
1736 let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
1737 let gc_json = serde_json::to_value(gc).expect("serialize gc response");
1738 assert!(gc_json.get("next_reclamation_at_ms").is_none());
1739 let gc: GcResponse =
1740 serde_json::from_value(gc_json).expect("decode gc response without optional fields");
1741 assert_eq!(gc.next_reclamation_at_ms, None);
1742 }
1743
1744 #[test]
1745 fn maintenance_step_outcomes_use_the_outcome_tag() {
1746 assert_eq!(
1747 serde_json::to_value(WalFlushStepOutcome::Flushed {
1748 manifest_head_seq: ChangeSeq(9),
1749 })
1750 .expect("serialize WAL flush outcome"),
1751 serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
1752 );
1753 assert_eq!(
1754 serde_json::to_value(ReorganizeStepOutcome::UnitPublished)
1755 .expect("serialize reorganize outcome"),
1756 serde_json::json!({"outcome": "unit_published"})
1757 );
1758 }
1759
1760 #[test]
1764 fn maintenance_request_bodies_reject_unknown_fields() {
1765 serde_json::from_value::<MaintenanceStepRequest>(serde_json::json!({
1766 "metadata": {"max_wal_tail_segments": 4},
1767 "advance_retention": true,
1768 "gc": {"grace_window_ms": 1_800_000, "max_objects": 32}
1769 }))
1770 .expect("the same body without a typo decodes");
1771
1772 for body in [
1773 serde_json::json!({"advance_retenton": true}),
1774 serde_json::json!({"metadata": {"maxWalTailSegments": 4}}),
1775 serde_json::json!({"gc": {"max_object": 32}}),
1776 ] {
1777 assert!(
1778 serde_json::from_value::<MaintenanceStepRequest>(body.clone()).is_err(),
1779 "an unknown field decoded instead of failing the step: {body}"
1780 );
1781 }
1782
1783 serde_json::from_value::<CreateCheckpointRequest>(
1784 serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
1785 )
1786 .expect("the same checkpoint body without a typo decodes");
1787 assert!(serde_json::from_value::<CreateCheckpointRequest>(
1788 serde_json::json!({"name": "nightly", "ttlMs": 60_000})
1789 )
1790 .is_err());
1791
1792 serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
1795 .expect("an empty probe body decodes");
1796 assert!(
1797 serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
1798 );
1799
1800 serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
1801 "namespace_id": "demo"
1802 }))
1803 .expect("the same create body without a typo decodes");
1804 assert!(
1805 serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
1806 "namespace_id": "demo",
1807 "fork_of": "other"
1808 }))
1809 .is_err()
1810 );
1811 assert!(
1812 serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
1813 "new_namespace_id": "demo",
1814 "source_namespace_id": "other"
1815 }))
1816 .is_err()
1817 );
1818 }
1819}