use super::ContentToken;
use crate::{
AbsolutePath, AttributeKey, AttributeRevisionNo, AttributeValue, ChangeSeq, CheckpointId,
CommitId, ContentRef, InodeId, ManifestId, NamespaceId, RevisionNo, WriterEpoch,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ApiError {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feature: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Box<ErrorDetails>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ErrorDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_id: Option<CommitId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub committed_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub committed_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fenced_epoch: Option<WriterEpoch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_writer_epoch: Option<WriterEpoch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_writer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_acquired_at_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::optional_schema)
)]
pub inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_revision_no: Option<RevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual_revision_no: Option<RevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_floor_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_deletion_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_deletion_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_head_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual_head_seq: Option<ChangeSeq>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct CreateNamespaceRequest {
pub namespace_id: NamespaceId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ForkNamespaceRequest {
pub new_namespace_id: NamespaceId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct NamespaceStatusResponse {
pub namespace_id: NamespaceId,
pub head_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_manifest_id: Option<ManifestId>,
pub wal_tail_segments: u64,
pub retention_floor_seq: ChangeSeq,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DeleteNamespaceResponse {
pub namespace_id: NamespaceId,
pub head_seq: ChangeSeq,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum DestinationBehavior {
#[default]
NoReplace,
Replace,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum DeleteDirectoryBehavior {
#[default]
NonRecursive,
Recursive,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FilesystemOperation {
#[cfg_attr(feature = "openapi", schema(title = "FsOpCreateDirectory"))]
CreateDirectory {
path: AbsolutePath,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
parents: bool,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpPutFile"))]
PutFile {
path: AbsolutePath,
content_ref: ContentRef,
#[serde(default)]
behavior: DestinationBehavior,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_revision_no: Option<RevisionNo>,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpDeletePath"))]
DeletePath {
path: AbsolutePath,
#[serde(default)]
behavior: DeleteDirectoryBehavior,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::optional_schema)
)]
expected_inode_id: Option<InodeId>,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpMovePath"))]
MovePath {
from_path: AbsolutePath,
to_path: AbsolutePath,
#[serde(default)]
behavior: DestinationBehavior,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpCopyPath"))]
CopyPath {
from_path: AbsolutePath,
to_path: AbsolutePath,
#[serde(default)]
behavior: DestinationBehavior,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpUndelete"))]
Undelete {
#[serde(with = "crate::public_inode_id")]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::schema)
)]
inode_id: InodeId,
deletion_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<AbsolutePath>,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpRestoreRevision"))]
RestoreRevision {
path: AbsolutePath,
source_revision_no: RevisionNo,
},
#[cfg_attr(feature = "openapi", schema(title = "FsOpUpdateAttributes"))]
UpdateAttributes {
path: AbsolutePath,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
set: BTreeMap<AttributeKey, AttributeValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
remove: Vec<AttributeKey>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::optional_schema)
)]
expected_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_attributes_revision_no: Option<AttributeRevisionNo>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct CommitRequest {
pub commit_id: CommitId,
pub actor: crate::ActorRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content_tokens: Vec<ContentToken>,
pub operations: Vec<FilesystemOperation>,
}
impl CommitRequest {
pub fn single(
commit_id: CommitId,
actor: crate::ActorRef,
message: Option<String>,
operation: FilesystemOperation,
) -> Self {
Self {
commit_id,
actor,
message,
content_tokens: Vec::new(),
operations: vec![operation],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FileRevision {
#[serde(with = "crate::public_inode_id")]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::schema)
)]
pub inode_id: InodeId,
pub revision_no: RevisionNo,
pub committed_seq: ChangeSeq,
pub committed_at_ms: u64,
pub actor: crate::ActorRef,
pub content_ref: ContentRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListFileRevisionsResponse {
pub namespace_id: NamespaceId,
#[serde(with = "crate::public_inode_id")]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::schema)
)]
pub inode_id: InodeId,
pub head_seq: ChangeSeq,
pub revisions: Vec<FileRevision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct CreateCheckpointRequest {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CreateCheckpointResponse {
pub namespace_id: NamespaceId,
#[serde(flatten)]
pub checkpoint: Checkpoint,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ReleaseCheckpointResponse {
pub namespace_id: NamespaceId,
pub checkpoint_id: CheckpointId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CheckpointOwnerSummary {
#[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
User {
name: String,
},
#[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
Fork {
target_namespace_id: NamespaceId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Checkpoint {
pub checkpoint_id: CheckpointId,
pub owner: CheckpointOwnerSummary,
pub created_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_ms: Option<u64>,
pub checkpoint_seq: ChangeSeq,
pub manifest_id: ManifestId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListCheckpointsResponse {
pub namespace_id: NamespaceId,
pub checkpoints: Vec<Checkpoint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum FlushWalOutcome {
AlreadyCurrent,
Published,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FlushWalResponse {
pub namespace_id: NamespaceId,
pub target_head_seq: ChangeSeq,
pub manifest_id: ManifestId,
pub manifest_head_seq: ChangeSeq,
pub outcome: FlushWalOutcome,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GcRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grace_window_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_objects: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct RetainedCandidates {
pub referenced: u64,
pub grace_window: u64,
pub no_provider_timestamp: u64,
pub no_reference_manifest: u64,
pub degraded_roots: u64,
pub unrecognized_key: u64,
pub checkpoint_not_releasable: u64,
pub upload_session_window: u64,
pub upload_session_undecided: u64,
pub content_scan_deferred: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GcResponse {
pub namespace_id: NamespaceId,
pub deleted_wal_segments: u64,
pub deleted_metadata_tables: u64,
pub deleted_manifests: u64,
pub deleted_checkpoint_records: u64,
pub released_fork_checkpoints: u64,
#[serde(default)]
pub released_expired_checkpoints: u64,
#[serde(default)]
pub deleted_upload_sessions: u64,
#[serde(default)]
pub deleted_content_objects: u64,
#[serde(default)]
pub released_missing_basis_checkpoints: u64,
pub retained_candidates: u64,
#[serde(default)]
pub retained: RetainedCandidates,
pub degraded_retention: bool,
#[serde(default)]
pub content_reclamation_deferred: bool,
#[serde(default)]
pub budget_exhausted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_reclamation_at_ms: Option<u64>,
}
impl GcResponse {
pub fn empty(namespace_id: NamespaceId) -> Self {
Self {
namespace_id,
deleted_wal_segments: 0,
deleted_metadata_tables: 0,
deleted_manifests: 0,
deleted_checkpoint_records: 0,
released_fork_checkpoints: 0,
released_expired_checkpoints: 0,
deleted_upload_sessions: 0,
deleted_content_objects: 0,
released_missing_basis_checkpoints: 0,
retained_candidates: 0,
retained: RetainedCandidates::default(),
degraded_retention: false,
content_reclamation_deferred: false,
budget_exhausted: false,
next_cursor: None,
next_reclamation_at_ms: None,
}
}
pub fn retain(&mut self, reason: RetainedReason) {
self.retained_candidates += 1;
*reason.counter(&mut self.retained) += 1;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetainedReason {
Referenced,
GraceWindow,
NoProviderTimestamp,
NoReferenceManifest,
DegradedRoots,
UnrecognizedKey,
CheckpointNotReleasable,
UploadSessionWindow,
UploadSessionUndecided,
ContentScanDeferred,
}
impl RetainedReason {
fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
match self {
Self::Referenced => &mut retained.referenced,
Self::GraceWindow => &mut retained.grace_window,
Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
Self::NoReferenceManifest => &mut retained.no_reference_manifest,
Self::DegradedRoots => &mut retained.degraded_roots,
Self::UnrecognizedKey => &mut retained.unrecognized_key,
Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
Self::UploadSessionWindow => &mut retained.upload_session_window,
Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
Self::ContentScanDeferred => &mut retained.content_scan_deferred,
}
}
}
impl RetainedCandidates {
pub fn by_reason(&self) -> [(&'static str, u64); 10] {
[
("referenced", self.referenced),
("grace_window", self.grace_window),
("no_provider_timestamp", self.no_provider_timestamp),
("no_reference_manifest", self.no_reference_manifest),
("degraded_roots", self.degraded_roots),
("unrecognized_key", self.unrecognized_key),
("checkpoint_not_releasable", self.checkpoint_not_releasable),
("upload_session_window", self.upload_session_window),
("upload_session_undecided", self.upload_session_undecided),
("content_scan_deferred", self.content_scan_deferred),
]
}
pub fn add(&mut self, other: &Self) {
self.referenced += other.referenced;
self.grace_window += other.grace_window;
self.no_provider_timestamp += other.no_provider_timestamp;
self.no_reference_manifest += other.no_reference_manifest;
self.degraded_roots += other.degraded_roots;
self.unrecognized_key += other.unrecognized_key;
self.checkpoint_not_releasable += other.checkpoint_not_releasable;
self.upload_session_window += other.upload_session_window;
self.upload_session_undecided += other.upload_session_undecided;
self.content_scan_deferred += other.content_scan_deferred;
}
pub fn top_reason(&self) -> Option<(&'static str, u64)> {
self.by_reason()
.into_iter()
.filter(|(_, count)| *count > 0)
.rev()
.max_by_key(|(_, count)| *count)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AdvanceRetentionResponse {
pub retention_floor_seq: ChangeSeq,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct MaintenanceStepRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<MetadataMaintenanceRequest>,
#[serde(default)]
pub advance_retention: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gc: Option<GcRequest>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct MetadataMaintenanceRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wal_tail_segments: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum WalFlushStepOutcome {
NotNeeded,
Flushed {
manifest_head_seq: ChangeSeq,
},
Superseded {
attempted_seq: ChangeSeq,
current_manifest_id: ManifestId,
},
RaceLost {
observed_head_seq: ChangeSeq,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum ReorganizeStepOutcome {
NotNeeded,
UnitPublished,
CompactionStarted,
CompactionRunning,
CompactionAtCapacity,
CompactionRequired,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MaintenanceStepResponse {
pub namespace_id: NamespaceId,
pub status_before: NamespaceStatusResponse,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<MetadataMaintenanceResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention: Option<AdvanceRetentionResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gc: Option<GcResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MetadataMaintenanceResponse {
pub wal_flush: WalFlushStepOutcome,
pub reorganize: ReorganizeStepOutcome,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct StoreProbeRequest {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct StoreProbeResponse {
pub run_id: String,
pub checks: Vec<StoreProbeCheckResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct StoreProbeCheckResult {
pub name: String,
pub outcome: StoreProbeCheckOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum StoreProbeCheckOutcome {
Passed,
Unsupported,
Failed,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ContentId;
fn path(value: &str) -> AbsolutePath {
AbsolutePath::parse(value).expect("valid test path")
}
fn attribute_key(value: &str) -> AttributeKey {
AttributeKey::parse(value).expect("valid test attribute key")
}
fn sample_content_ref() -> ContentRef {
ContentRef::blob_v1(
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
b"hello",
)
}
#[test]
fn namespace_create_and_fork_responses_use_the_status_shape() {
let create = NamespaceStatusResponse {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
head_seq: ChangeSeq(0),
current_manifest_id: None,
wal_tail_segments: 0,
retention_floor_seq: ChangeSeq(0),
};
assert_eq!(
serde_json::to_value(create).expect("serialize create response"),
serde_json::json!({
"namespace_id": "demo",
"head_seq": 0,
"wal_tail_segments": 0,
"retention_floor_seq": 0
})
);
let fork = NamespaceStatusResponse {
namespace_id: NamespaceId::parse("demo-branch").expect("namespace id"),
head_seq: ChangeSeq(7),
current_manifest_id: None,
wal_tail_segments: 0,
retention_floor_seq: ChangeSeq(7),
};
assert_eq!(
serde_json::to_value(fork).expect("serialize fork response"),
serde_json::json!({
"namespace_id": "demo-branch",
"head_seq": 7,
"wal_tail_segments": 0,
"retention_floor_seq": 7
})
);
}
#[test]
fn behavior_enums_use_snake_case_wire_values() {
assert_eq!(
DestinationBehavior::default(),
DestinationBehavior::NoReplace
);
assert_eq!(
DeleteDirectoryBehavior::default(),
DeleteDirectoryBehavior::NonRecursive
);
assert_eq!(
serde_json::to_value(DestinationBehavior::NoReplace)
.expect("destination behavior json"),
serde_json::json!("no_replace")
);
assert_eq!(
serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
serde_json::json!("replace")
);
assert_eq!(
serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
.expect("delete behavior json"),
serde_json::json!("non_recursive")
);
assert_eq!(
serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
serde_json::json!("recursive")
);
}
#[test]
fn filesystem_delete_and_move_operations_use_behavior_field() {
let create_directory = FilesystemOperation::CreateDirectory {
path: path("/docs"),
parents: false,
};
assert_eq!(
serde_json::to_value(&create_directory).expect("create directory op json"),
serde_json::json!({
"kind": "create_directory",
"path": "/docs"
})
);
let create_directory_with_parents = FilesystemOperation::CreateDirectory {
path: path("/docs/notes"),
parents: true,
};
assert_eq!(
serde_json::to_value(&create_directory_with_parents)
.expect("create directory with parents op json"),
serde_json::json!({
"kind": "create_directory",
"path": "/docs/notes",
"parents": true
})
);
let delete = FilesystemOperation::DeletePath {
path: path("/docs"),
behavior: DeleteDirectoryBehavior::Recursive,
expected_inode_id: None,
};
assert_eq!(
serde_json::to_value(&delete).expect("delete op json"),
serde_json::json!({
"kind": "delete_path",
"path": "/docs",
"behavior": "recursive"
})
);
let move_path = FilesystemOperation::MovePath {
from_path: path("/docs/a.txt"),
to_path: path("/docs/b.txt"),
behavior: DestinationBehavior::Replace,
};
assert_eq!(
serde_json::to_value(&move_path).expect("move op json"),
serde_json::json!({
"kind": "move_path",
"from_path": "/docs/a.txt",
"to_path": "/docs/b.txt",
"behavior": "replace"
})
);
let copy_path = FilesystemOperation::CopyPath {
from_path: path("/docs/a.txt"),
to_path: path("/docs/b.txt"),
behavior: DestinationBehavior::Replace,
};
assert_eq!(
serde_json::to_value(©_path).expect("copy op json"),
serde_json::json!({
"kind": "copy_path",
"from_path": "/docs/a.txt",
"to_path": "/docs/b.txt",
"behavior": "replace"
})
);
let update_attributes = FilesystemOperation::UpdateAttributes {
path: path("/docs/a.txt"),
set: BTreeMap::from([(
attribute_key("owner"),
AttributeValue::parse("ada").expect("valid attribute value"),
)]),
remove: vec![attribute_key("draft")],
expected_inode_id: Some(InodeId(7)),
expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
};
assert_eq!(
serde_json::to_value(&update_attributes).expect("update attributes op json"),
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"set": {"owner": "ada"},
"remove": ["draft"],
"expected_inode_id": "ino_7",
"expected_attributes_revision_no": 3
})
);
}
#[test]
fn update_attributes_omits_empty_collections_and_absent_guards() {
let set_only = FilesystemOperation::UpdateAttributes {
path: path("/docs/a.txt"),
set: BTreeMap::from([(
attribute_key("owner"),
AttributeValue::parse("ada,grace").expect("valid attribute value"),
)]),
remove: Vec::new(),
expected_inode_id: None,
expected_attributes_revision_no: None,
};
assert_eq!(
serde_json::to_value(&set_only).expect("set-only op json"),
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"set": {"owner": "ada,grace"}
})
);
let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"remove": ["draft"]
}))
.expect("remove-only op defaults the set map and both guards");
assert_eq!(
decoded,
FilesystemOperation::UpdateAttributes {
path: path("/docs/a.txt"),
set: BTreeMap::new(),
remove: vec![attribute_key("draft")],
expected_inode_id: None,
expected_attributes_revision_no: None,
}
);
}
#[test]
fn update_attributes_validates_keys_and_values_during_deserialization() {
for encoded in [
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"set": {"": "ada"}
}),
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"set": {"owner": {"kind": "string", "value": "ada"}}
}),
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"remove": ["a\u{0}b"]
}),
] {
assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
}
}
#[test]
fn filesystem_operations_default_omitted_behavior_fields() {
let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": {
"kind": "blob_v1",
"content_id": "con_0123456789abcdef0123456789abcdef",
"size_bytes": 1,
"checksum": {
"algorithm": "sha256",
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}))
.expect("put op defaults behavior");
assert!(matches!(
put,
FilesystemOperation::PutFile {
behavior: DestinationBehavior::NoReplace,
expected_revision_no: None,
..
}
));
let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "delete_path",
"path": "/docs"
}))
.expect("delete op defaults behavior");
assert_eq!(
delete,
FilesystemOperation::DeletePath {
path: path("/docs"),
behavior: DeleteDirectoryBehavior::NonRecursive,
expected_inode_id: None,
}
);
let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "move_path",
"from_path": "/docs/a.txt",
"to_path": "/docs/b.txt"
}))
.expect("move op defaults behavior");
assert_eq!(
move_path,
FilesystemOperation::MovePath {
from_path: path("/docs/a.txt"),
to_path: path("/docs/b.txt"),
behavior: DestinationBehavior::NoReplace,
}
);
let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "copy_path",
"from_path": "/docs/a.txt",
"to_path": "/docs/b.txt"
}))
.expect("copy op defaults behavior");
assert_eq!(
copy_path,
FilesystemOperation::CopyPath {
from_path: path("/docs/a.txt"),
to_path: path("/docs/b.txt"),
behavior: DestinationBehavior::NoReplace,
}
);
}
#[test]
fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
let cases = [
(
FilesystemOperation::PutFile {
path: path("/docs/a.txt"),
content_ref: content_ref.clone(),
behavior: DestinationBehavior::NoReplace,
expected_revision_no: None,
},
serde_json::json!({
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": content_ref,
"behavior": "no_replace"
}),
),
(
FilesystemOperation::Undelete {
inode_id: InodeId(7),
deletion_seq: ChangeSeq(8),
path: Some(path("/docs/restored")),
},
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_7",
"deletion_seq": 8,
"path": "/docs/restored"
}),
),
(
FilesystemOperation::RestoreRevision {
path: path("/docs/a.txt"),
source_revision_no: RevisionNo(2),
},
serde_json::json!({
"kind": "restore_revision",
"path": "/docs/a.txt",
"source_revision_no": 2
}),
),
(
FilesystemOperation::UpdateAttributes {
path: path("/docs/a.txt"),
set: BTreeMap::new(),
remove: vec![attribute_key("draft")],
expected_inode_id: None,
expected_attributes_revision_no: None,
},
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"remove": ["draft"]
}),
),
];
for (operation, string_shaped_json) in cases {
assert_eq!(
serde_json::to_value(operation).expect("serialize filesystem operation"),
string_shaped_json
);
}
}
#[test]
fn filesystem_operation_paths_validate_during_deserialization() {
for encoded in [
serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
serde_json::json!({
"kind": "put_file",
"path": "relative",
"content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
}),
serde_json::json!({"kind": "delete_path", "path": "relative"}),
serde_json::json!({
"kind": "move_path",
"from_path": "relative",
"to_path": "/target"
}),
serde_json::json!({
"kind": "copy_path",
"from_path": "/source",
"to_path": "relative"
}),
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_7",
"deletion_seq": 8,
"path": "relative"
}),
serde_json::json!({
"kind": "restore_revision",
"path": "relative",
"source_revision_no": 2
}),
serde_json::json!({
"kind": "update_attributes",
"path": "relative",
"remove": ["draft"]
}),
] {
assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
}
}
#[test]
fn inode_request_fields_accept_only_the_public_format() {
let operations = [
serde_json::json!({
"kind": "delete_path",
"path": "/docs/a.txt",
"expected_inode_id": "ino_27"
}),
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_27",
"deletion_seq": 8
}),
serde_json::json!({
"kind": "update_attributes",
"path": "/docs/a.txt",
"expected_inode_id": "ino_27"
}),
];
for operation in operations {
serde_json::from_value::<FilesystemOperation>(operation.clone())
.expect("valid public inode ID");
let inode_key = if operation["kind"] == "undelete" {
"inode_id"
} else {
"expected_inode_id"
};
for invalid in [serde_json::json!(27), serde_json::json!("27")] {
let mut invalid_operation = operation.clone();
invalid_operation[inode_key] = invalid;
assert!(
serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
"{inode_key} accepted an invalid inode ID"
);
}
}
}
#[test]
fn a_misspelled_guard_does_not_decode() {
let put = |guard: &str| {
let mut operation = serde_json::json!({
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": sample_content_ref(),
"behavior": "replace"
});
operation[guard] = serde_json::json!(3);
serde_json::json!({
"commit_id": "guarded-put",
"actor": crate::ActorRef::loonfs_system(),
"operations": [operation]
})
};
let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
.expect("the guard spelled correctly decodes");
assert!(matches!(
spelled.operations.as_slice(),
[FilesystemOperation::PutFile {
expected_revision_no: Some(RevisionNo(3)),
..
}]
));
for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
assert!(
serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
"`{misspelling}` decoded instead of failing the request"
);
}
}
#[test]
fn expected_revision_no_must_fit_the_public_integer_range() {
let body = |expected_revision_no: u64| {
serde_json::json!({
"commit_id": "bounded-revision-guard",
"actor": crate::ActorRef::loonfs_system(),
"operations": [{
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": sample_content_ref(),
"behavior": "replace",
"expected_revision_no": expected_revision_no
}]
})
};
let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
.expect("deserialize the maximum revision number");
assert!(matches!(
request.operations.as_slice(),
[FilesystemOperation::PutFile {
expected_revision_no: Some(RevisionNo(value)),
..
}] if *value == crate::MAX_PUBLIC_INTEGER
));
let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
.expect_err("reject a revision number above the public limit");
assert!(
error
.to_string()
.contains("must be an integer from 0 through 9007199254740991"),
"unexpected range error: {error}"
);
}
#[test]
fn a_commit_request_rejects_unknown_fields_at_every_level() {
let valid = || {
serde_json::json!({
"commit_id": "strict-commit",
"actor": crate::ActorRef::loonfs_system(),
"content_tokens": [{
"content_ref": sample_content_ref(),
"token": "opaque-proof"
}],
"operations": [{
"kind": "update_attributes",
"path": "/docs/a.txt",
"set": {"owner": "ada"},
"expected_inode_id": "ino_7"
}]
})
};
serde_json::from_value::<CommitRequest>(valid())
.expect("the same body without a typo decodes");
let mut at_root = valid();
at_root["mesage"] = serde_json::json!("a note");
let mut in_operation = valid();
in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
let mut in_content_token = valid();
in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
let mut in_content_ref = valid();
in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
for (level, body) in [
("the request root", at_root),
("an operation variant", in_operation),
("a nested content token", in_content_token),
("a content ref below that", in_content_ref),
] {
assert!(
serde_json::from_value::<CommitRequest>(body).is_err(),
"an unknown field in {level} decoded instead of failing the request"
);
}
}
#[test]
fn checkpoint_responses_use_one_checkpoint_wire_object() {
let namespace_id = NamespaceId::parse("demo").expect("namespace id");
let checkpoint = Checkpoint {
checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
.expect("checkpoint id"),
owner: CheckpointOwnerSummary::User {
name: "release".to_owned(),
},
created_at_ms: 1_752_623_000_000,
expires_at_ms: Some(1_752_626_600_000),
checkpoint_seq: ChangeSeq(12),
manifest_id: ManifestId(9),
};
let checkpoint_json = serde_json::json!({
"checkpoint_id": "chk_00000000000000000000000000000001",
"owner": {"kind": "user", "name": "release"},
"created_at_ms": 1_752_623_000_000_u64,
"expires_at_ms": 1_752_626_600_000_u64,
"checkpoint_seq": 12,
"manifest_id": 9,
});
let mut create_json = checkpoint_json.clone();
create_json["namespace_id"] = serde_json::json!("demo");
assert_eq!(
serde_json::to_value(CreateCheckpointResponse {
namespace_id: namespace_id.clone(),
checkpoint: checkpoint.clone(),
})
.expect("serialize create checkpoint response"),
create_json,
);
assert_eq!(
serde_json::to_value(ListCheckpointsResponse {
namespace_id: namespace_id.clone(),
checkpoints: vec![checkpoint.clone()],
next_cursor: None,
})
.expect("serialize list checkpoints response"),
serde_json::json!({
"namespace_id": "demo",
"checkpoints": [checkpoint_json],
}),
);
assert_eq!(
serde_json::to_value(ReleaseCheckpointResponse {
namespace_id,
checkpoint_id: checkpoint.checkpoint_id,
})
.expect("serialize release checkpoint response"),
serde_json::json!({
"namespace_id": "demo",
"checkpoint_id": "chk_00000000000000000000000000000001",
}),
);
}
#[test]
fn optional_response_fields_are_omitted_and_default_when_absent() {
let checkpoint_json = serde_json::to_value(Checkpoint {
checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
.expect("checkpoint id"),
owner: CheckpointOwnerSummary::User {
name: "release".to_owned(),
},
created_at_ms: 1_752_623_000_000,
expires_at_ms: None,
checkpoint_seq: ChangeSeq(3),
manifest_id: ManifestId(3),
})
.expect("serialize checkpoint");
assert!(checkpoint_json.get("expires_at_ms").is_none());
let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
.expect("decode checkpoint without optional fields");
assert_eq!(checkpoint.expires_at_ms, None);
let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
let gc_json = serde_json::to_value(gc).expect("serialize gc response");
assert!(gc_json.get("next_reclamation_at_ms").is_none());
let gc: GcResponse =
serde_json::from_value(gc_json).expect("decode gc response without optional fields");
assert_eq!(gc.next_reclamation_at_ms, None);
}
#[test]
fn maintenance_step_outcomes_use_the_outcome_tag() {
assert_eq!(
serde_json::to_value(WalFlushStepOutcome::Flushed {
manifest_head_seq: ChangeSeq(9),
})
.expect("serialize WAL flush outcome"),
serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
);
assert_eq!(
serde_json::to_value(ReorganizeStepOutcome::UnitPublished)
.expect("serialize reorganize outcome"),
serde_json::json!({"outcome": "unit_published"})
);
}
#[test]
fn maintenance_request_bodies_reject_unknown_fields() {
serde_json::from_value::<MaintenanceStepRequest>(serde_json::json!({
"metadata": {"max_wal_tail_segments": 4},
"advance_retention": true,
"gc": {"grace_window_ms": 1_800_000, "max_objects": 32}
}))
.expect("the same body without a typo decodes");
for body in [
serde_json::json!({"advance_retenton": true}),
serde_json::json!({"metadata": {"maxWalTailSegments": 4}}),
serde_json::json!({"gc": {"max_object": 32}}),
] {
assert!(
serde_json::from_value::<MaintenanceStepRequest>(body.clone()).is_err(),
"an unknown field decoded instead of failing the step: {body}"
);
}
serde_json::from_value::<CreateCheckpointRequest>(
serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
)
.expect("the same checkpoint body without a typo decodes");
assert!(serde_json::from_value::<CreateCheckpointRequest>(
serde_json::json!({"name": "nightly", "ttlMs": 60_000})
)
.is_err());
serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
.expect("an empty probe body decodes");
assert!(
serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
);
serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
"namespace_id": "demo"
}))
.expect("the same create body without a typo decodes");
assert!(
serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
"namespace_id": "demo",
"fork_of": "other"
}))
.is_err()
);
assert!(
serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
"new_namespace_id": "demo",
"source_namespace_id": "other"
}))
.is_err()
);
}
}