use super::ContentToken;
use crate::SnapshotId;
use crate::{
AbsolutePath, AccessGrants, AccessRevisionNo, ActorId, AttributeKey, AttributeRevisionNo,
AttributeValue, BindingGeneration, ChangeSeq, CheckpointId, CommitId, ContentRef, DisplayName,
InodeId, ManifestNo, NamespaceId, RevisionNo, WriterEpoch, WriterId,
};
use crate::{NamespaceAccess, PrincipalId, PrincipalScope};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "openapi", schema(as = ErrorResponse))]
pub struct ApiError {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub feature: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub param: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub commit_id: Option<CommitId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub committed_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub committed_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub operation_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub precondition_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub fenced_writer_epoch: Option<WriterEpoch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub active_writer_epoch: Option<WriterEpoch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub active_writer: Option<WriterId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub active_acquired_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub max_writer_sessions: Option<usize>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub inode_id: Option<InodeId>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_inode_id: Option<InodeId>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_binding_generation: Option<BindingGeneration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_binding_generation: Option<BindingGeneration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_revision_no: Option<RevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_revision_no: Option<RevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_access_revision_no: Option<AccessRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_access_revision_no: Option<AccessRevisionNo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub after_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub retention_floor_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_deletion_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub actual_deletion_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_head_seq: Option<ChangeSeq>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
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 {
#[serde(default = "NamespaceAccess::unrestricted")]
pub access: NamespaceAccess,
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,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub snapshot_id: Option<SnapshotId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Namespace {
pub access: NamespaceAccessMode,
pub namespace_id: NamespaceId,
pub created_at_ms: u64,
pub created_by: ActorId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub fork_basis: Option<NamespaceForkBasis>,
pub head_seq: ChangeSeq,
pub retention_floor_seq: ChangeSeq,
}
#[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 NamespaceAccessMode {
Unrestricted {},
Acl {
principal_scope: PrincipalScope,
},
}
impl From<&NamespaceAccess> for NamespaceAccessMode {
fn from(access: &NamespaceAccess) -> Self {
match access {
NamespaceAccess::Unrestricted {} => Self::Unrestricted {},
NamespaceAccess::Acl {
principal_scope, ..
} => Self::Acl {
principal_scope: principal_scope.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct NamespaceForkBasis {
pub source_namespace_id: NamespaceId,
pub source_head_seq: ChangeSeq,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct NamespaceDiagnostics {
pub namespace_id: NamespaceId,
pub created_at_ms: u64,
pub created_by: ActorId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub fork_basis: Option<NamespaceForkBasis>,
pub head_seq: ChangeSeq,
pub retention_floor_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub current_manifest_no: Option<ManifestNo>,
pub wal_tail_segments: u64,
pub live_snapshots: u64,
pub live_checkpoints: u64,
}
#[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))]
pub struct DestinationPrecondition {
#[serde(default)]
pub behavior: DestinationBehavior,
#[serde(
rename = "expected_destination_inode_id",
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_inode_id: Option<InodeId>,
#[serde(
rename = "expected_destination_revision_no",
default,
skip_serializing_if = "Option::is_none"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expected_revision_no: Option<RevisionNo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreconditionFields {
Put,
Destination,
}
impl PreconditionFields {
fn names(self) -> (&'static str, &'static str) {
match self {
Self::Put => ("expected_revision_no", "expected_inode_id"),
Self::Destination => (
"expected_destination_revision_no",
"expected_destination_inode_id",
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExpectedFileState {
pub inode_id: InodeId,
pub revision_no: Option<RevisionNo>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DestinationPreconditionError {
#[error("destination preconditions require replace behavior")]
PreconditionsRequireReplace {
field: &'static str,
},
#[error(
"`{revision_field}` names the revision of one inode; pair it with `{inode_field}` so the precondition names which inode"
)]
RevisionRequiresInode {
revision_field: &'static str,
inode_field: &'static str,
},
}
impl DestinationPrecondition {
pub fn resolve(
&self,
fields: PreconditionFields,
) -> Result<Option<ExpectedFileState>, DestinationPreconditionError> {
if self.behavior == DestinationBehavior::NoReplace
&& !matches!(
(self.expected_inode_id, self.expected_revision_no),
(None, None)
)
{
let (revision_field, inode_field) = fields.names();
return Err(DestinationPreconditionError::PreconditionsRequireReplace {
field: if self.expected_inode_id.is_some() {
inode_field
} else {
revision_field
},
});
}
let Some(inode_id) = self.expected_inode_id else {
if self.expected_revision_no.is_some() {
let (revision_field, inode_field) = fields.names();
return Err(DestinationPreconditionError::RevisionRequiresInode {
revision_field,
inode_field,
});
}
return Ok(None);
};
Ok(Some(ExpectedFileState {
inode_id,
revision_no: self.expected_revision_no,
}))
}
}
pub fn validate_attributes_precondition(
expected_inode_id: Option<InodeId>,
expected_attributes_revision_no: Option<AttributeRevisionNo>,
) -> Result<(), DestinationPreconditionError> {
validate_revision_precondition(
expected_inode_id,
expected_attributes_revision_no.is_some(),
"expected_attributes_revision_no",
)
}
pub fn validate_access_precondition(
expected_inode_id: Option<InodeId>,
expected_access_revision_no: Option<AccessRevisionNo>,
) -> Result<(), DestinationPreconditionError> {
validate_revision_precondition(
expected_inode_id,
expected_access_revision_no.is_some(),
"expected_access_revision_no",
)
}
fn validate_revision_precondition(
expected_inode_id: Option<InodeId>,
has_revision: bool,
revision_field: &'static str,
) -> Result<(), DestinationPreconditionError> {
if has_revision && expected_inode_id.is_none() {
return Err(DestinationPreconditionError::RevisionRequiresInode {
revision_field,
inode_field: "expected_inode_id",
});
}
Ok(())
}
#[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 = "FilesystemOperationCreateDirectory")
)]
CreateDirectory {
path: AbsolutePath,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
parents: bool,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationCreateDirectoryByInode")
)]
CreateDirectoryByInode {
#[serde(with = "crate::public_inode_id")]
parent_inode_id: InodeId,
display_name: DisplayName,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationPutFile"))]
PutFile {
path: AbsolutePath,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
content_ref: Option<ContentRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::base64_bytes"
)]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
inline_content: Option<Vec<u8>>,
#[serde(default)]
behavior: DestinationBehavior,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_revision_no: Option<RevisionNo>,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationCreateFileByInode")
)]
CreateFileByInode {
#[serde(with = "crate::public_inode_id")]
parent_inode_id: InodeId,
display_name: DisplayName,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
content_ref: Option<ContentRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::base64_bytes"
)]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
inline_content: Option<Vec<u8>>,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationPutFileRevisionByInode")
)]
PutFileRevisionByInode {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
content_ref: Option<ContentRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::base64_bytes"
)]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = Byte, nullable = false))]
inline_content: Option<Vec<u8>>,
expected_revision_no: RevisionNo,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationDeletePath"))]
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(nullable = false))]
expected_inode_id: Option<InodeId>,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationDeleteByInode")
)]
DeleteByInode {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
expected_binding_generation: BindingGeneration,
#[serde(default)]
behavior: DeleteDirectoryBehavior,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMovePath"))]
MovePath {
source_path: AbsolutePath,
destination_path: AbsolutePath,
#[serde(flatten)]
precondition: DestinationPrecondition,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMoveByInode"))]
MoveByInode {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
expected_binding_generation: BindingGeneration,
#[serde(with = "crate::public_inode_id")]
destination_parent_inode_id: InodeId,
destination_display_name: DisplayName,
#[serde(flatten)]
precondition: DestinationPrecondition,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationCopyPath"))]
CopyPath {
source_path: AbsolutePath,
destination_path: AbsolutePath,
#[serde(flatten)]
precondition: DestinationPrecondition,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUndelete"))]
Undelete {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
deletion_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
destination_path: Option<AbsolutePath>,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationRestoreRevision")
)]
RestoreRevision {
path: AbsolutePath,
source_revision_no: RevisionNo,
},
#[cfg_attr(
feature = "openapi",
schema(title = "FilesystemOperationUpdateAttributes")
)]
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(nullable = false))]
expected_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_attributes_revision_no: Option<AttributeRevisionNo>,
},
#[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUpdateAccess"))]
UpdateAccess {
path: AbsolutePath,
boundary: bool,
grants: AccessGrants,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_access_revision_no: Option<AccessRevisionNo>,
},
}
impl FilesystemOperation {
pub const fn content_ref(&self) -> Option<&ContentRef> {
match self {
Self::PutFile { content_ref, .. }
| Self::CreateFileByInode { content_ref, .. }
| Self::PutFileRevisionByInode { content_ref, .. } => content_ref.as_ref(),
Self::CreateDirectory { .. }
| Self::CreateDirectoryByInode { .. }
| Self::DeletePath { .. }
| Self::DeleteByInode { .. }
| Self::MovePath { .. }
| Self::MoveByInode { .. }
| Self::CopyPath { .. }
| Self::Undelete { .. }
| Self::RestoreRevision { .. }
| Self::UpdateAttributes { .. }
| Self::UpdateAccess { .. } => None,
}
}
}
#[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 CommitPrecondition {
#[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionNamespaceHead"))]
NamespaceHead {
expected_head_seq: ChangeSeq,
},
#[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionFileRevision"))]
FileRevision {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
expected_revision_no: RevisionNo,
},
#[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionPathBinding"))]
PathBinding {
path: AbsolutePath,
#[serde(with = "crate::public_inode_id")]
expected_inode_id: InodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
expected_binding_generation: Option<BindingGeneration>,
},
#[cfg_attr(
feature = "openapi",
schema(title = "CommitPreconditionAttributesRevision")
)]
AttributesRevision {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
expected_attributes_revision_no: AttributeRevisionNo,
},
#[cfg_attr(
feature = "openapi",
schema(title = "CommitPreconditionAccessRevision")
)]
AccessRevision {
#[serde(with = "crate::public_inode_id")]
inode_id: InodeId,
expected_access_revision_no: AccessRevisionNo,
},
#[cfg_attr(feature = "openapi", schema(title = "CommitPreconditionPathAbsence"))]
PathAbsence {
path: AbsolutePath,
},
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content_tokens: Vec<ContentToken>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preconditions: Vec<CommitPrecondition>,
pub operations: Vec<FilesystemOperation>,
}
impl CommitRequest {
pub fn preconditions(mut self, preconditions: Vec<CommitPrecondition>) -> Self {
self.preconditions = preconditions;
self
}
pub fn single(
commit_id: CommitId,
message: Option<String>,
operation: FilesystemOperation,
) -> Self {
Self {
commit_id,
message,
content_tokens: Vec::new(),
preconditions: 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")]
pub inode_id: InodeId,
pub revision_no: RevisionNo,
pub committed_seq: ChangeSeq,
pub commit_id: CommitId,
pub committed_at_ms: u64,
pub committed_by: crate::ActorId,
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")]
pub inode_id: InodeId,
pub head_seq: ChangeSeq,
pub revisions: Vec<FileRevision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub ttl_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct CreateSnapshotRequest {
pub name: String,
pub ttl_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ExtendSnapshotRequest {
pub ttl_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DeleteCheckpointResponse {
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,
},
#[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerSnapshot"))]
Snapshot {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Checkpoint {
pub namespace_id: NamespaceId,
pub checkpoint_id: CheckpointId,
pub owner: CheckpointOwnerSummary,
pub created_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub expires_at_ms: Option<u64>,
pub captured_seq: ChangeSeq,
pub manifest_no: ManifestNo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "openapi", schema(as = Snapshot))]
pub struct SnapshotSummary {
pub snapshot_id: SnapshotId,
pub namespace_id: NamespaceId,
pub name: String,
pub captured_seq: ChangeSeq,
pub created_at_ms: u64,
pub expires_at_ms: u64,
}
impl SnapshotSummary {
pub fn from_checkpoint(checkpoint: Checkpoint) -> Option<Self> {
let CheckpointOwnerSummary::Snapshot { name } = checkpoint.owner else {
return None;
};
Some(Self {
snapshot_id: checkpoint.checkpoint_id.into(),
namespace_id: checkpoint.namespace_id,
name,
captured_seq: checkpoint.captured_seq,
created_at_ms: checkpoint.created_at_ms,
expires_at_ms: checkpoint.expires_at_ms?,
})
}
}
#[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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListSnapshotsResponse {
pub namespace_id: NamespaceId,
pub snapshots: Vec<SnapshotSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DeleteSnapshotResponse {
pub namespace_id: NamespaceId,
pub snapshot_id: SnapshotId,
}
#[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,
ManifestAdvanced,
}
#[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_no: ManifestNo,
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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub grace_window_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct RetainedCandidates {
pub referenced: u64,
pub within_grace_window: u64,
pub no_provider_timestamp: u64,
pub unrecognized_key: u64,
pub checkpoint_not_deletable: u64,
pub upload_session_window: u64,
pub upload_session_undecided: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DeletedObjectCounts {
pub wal_segments: u64,
pub metadata_segments: u64,
pub manifests: u64,
pub upload_sessions: u64,
pub content_objects: u64,
pub retired_content_objects: u64,
}
impl DeletedObjectCounts {
pub fn add(&mut self, other: &Self) {
let Self {
wal_segments,
metadata_segments,
manifests,
upload_sessions,
content_objects,
retired_content_objects,
} = other;
self.wal_segments += wal_segments;
self.metadata_segments += metadata_segments;
self.manifests += manifests;
self.upload_sessions += upload_sessions;
self.content_objects += content_objects;
self.retired_content_objects += retired_content_objects;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DeletedCheckpointsByOwner {
pub fork: u64,
pub expired: u64,
pub snapshot: u64,
}
impl DeletedCheckpointsByOwner {
pub fn add(&mut self, other: &Self) {
let Self {
fork,
expired,
snapshot,
} = other;
self.fork += fork;
self.expired += expired;
self.snapshot += snapshot;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GcResponse {
pub namespace_id: NamespaceId,
pub deleted: DeletedObjectCounts,
pub deleted_checkpoints_by_owner: DeletedCheckpointsByOwner,
pub retained: RetainedCandidates,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_reclamation_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub reclaim_after_ms: Option<u64>,
}
impl GcResponse {
pub fn empty(namespace_id: NamespaceId) -> Self {
Self {
namespace_id,
deleted: DeletedObjectCounts::default(),
deleted_checkpoints_by_owner: DeletedCheckpointsByOwner::default(),
retained: RetainedCandidates::default(),
next_reclamation_at_ms: None,
reclaim_after_ms: None,
}
}
pub fn retain(&mut self, reason: RetainedReason) {
*reason.counter(&mut self.retained) += 1;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetainedReason {
Referenced,
WithinGraceWindow,
NoProviderTimestamp,
UnrecognizedKey,
CheckpointNotDeletable,
UploadSessionWindow,
UploadSessionUndecided,
}
impl RetainedReason {
fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
match self {
Self::Referenced => &mut retained.referenced,
Self::WithinGraceWindow => &mut retained.within_grace_window,
Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
Self::UnrecognizedKey => &mut retained.unrecognized_key,
Self::CheckpointNotDeletable => &mut retained.checkpoint_not_deletable,
Self::UploadSessionWindow => &mut retained.upload_session_window,
Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
}
}
}
impl RetainedCandidates {
pub fn total(&self) -> u64 {
self.by_reason().into_iter().map(|(_, count)| count).sum()
}
pub(crate) fn by_reason(&self) -> [(&'static str, u64); 7] {
let Self {
referenced,
within_grace_window,
no_provider_timestamp,
unrecognized_key,
checkpoint_not_deletable,
upload_session_window,
upload_session_undecided,
} = *self;
[
("referenced", referenced),
("within_grace_window", within_grace_window),
("no_provider_timestamp", no_provider_timestamp),
("unrecognized_key", unrecognized_key),
("checkpoint_not_deletable", checkpoint_not_deletable),
("upload_session_window", upload_session_window),
("upload_session_undecided", upload_session_undecided),
]
}
pub fn add(&mut self, other: &Self) {
let Self {
referenced,
within_grace_window,
no_provider_timestamp,
unrecognized_key,
checkpoint_not_deletable,
upload_session_window,
upload_session_undecided,
} = other;
self.referenced += referenced;
self.within_grace_window += within_grace_window;
self.no_provider_timestamp += no_provider_timestamp;
self.unrecognized_key += unrecognized_key;
self.checkpoint_not_deletable += checkpoint_not_deletable;
self.upload_session_window += upload_session_window;
self.upload_session_undecided += upload_session_undecided;
}
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, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct AdvanceRetentionRequest {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AdvanceRetentionResponse {
pub namespace_id: NamespaceId,
pub retention_floor_seq: ChangeSeq,
}
#[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 RunMaintenanceRequest {
Metadata(MetadataMaintenanceRequest),
MetadataCompaction(MetadataCompactionRequest),
Gc(GcRequest),
Retention(AdvanceRetentionRequest),
RecoverAdministrator(RecoverAdministratorRequest),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct RecoverAdministratorRequest {
pub principal_id: PrincipalId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct RecoverAdministratorResponse {
pub namespace_id: NamespaceId,
pub commit_id: CommitId,
pub committed_seq: ChangeSeq,
pub access_revision_no: AccessRevisionNo,
}
#[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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub max_wal_tail_segments: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct MetadataCompactionRequest {}
#[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,
},
AlreadyPublished {
attempted_seq: ChangeSeq,
current_manifest_no: ManifestNo,
},
RetriesExhausted {
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 {
#[cfg_attr(feature = "openapi", schema(title = "ReorganizeStepOutcomeNotNeeded"))]
NotNeeded {},
#[cfg_attr(
feature = "openapi",
schema(title = "ReorganizeStepOutcomeUnitPublished")
)]
UnitPublished {},
#[cfg_attr(
feature = "openapi",
schema(title = "ReorganizeStepOutcomeCompactionRequired")
)]
CompactionRequired {},
#[cfg_attr(
feature = "openapi",
schema(title = "ReorganizeStepOutcomeManifestAdvanced")
)]
ManifestAdvanced {},
#[cfg_attr(feature = "openapi", schema(title = "ReorganizeStepOutcomeFenced"))]
Fenced {},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunMaintenanceResponse {
Metadata(MetadataMaintenanceResponse),
MetadataCompaction(MetadataCompactionResponse),
Gc(GcResponse),
Retention(AdvanceRetentionResponse),
RecoverAdministrator(RecoverAdministratorResponse),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MetadataMaintenanceResponse {
pub namespace_id: NamespaceId,
pub wal_flush: WalFlushStepOutcome,
pub reorganize: ReorganizeStepOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct MetadataCompactionResponse {
pub namespace_id: NamespaceId,
pub compaction: MetadataCompactionOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum MetadataCompactionOutcome {
NotNeeded,
BoundedMergePublished,
Published {
manifest_no: ManifestNo,
rows_read: u64,
rows_written: u64,
input_bytes: u64,
output_bytes: u64,
output_segments: u64,
},
Cancelled,
Abandoned,
Fenced,
}
#[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")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
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;
#[test]
fn file_revision_provenance_fields_are_pinned_on_the_wire() {
let content_ref = ContentRef::blob_v1(
crate::NamespaceId::parse("demo").expect("namespace id"),
crate::ContentId::generate(),
b"hello",
);
let revision = FileRevision {
inode_id: InodeId(2),
revision_no: RevisionNo(3),
committed_seq: ChangeSeq(7),
commit_id: CommitId::parse("c_revision_owner").expect("commit id"),
committed_at_ms: 1_752_624_000_000,
committed_by: crate::ActorId::loonfs(),
content_ref: content_ref.clone(),
};
assert_eq!(
serde_json::to_value(revision).expect("serialize file revision"),
serde_json::json!({
"inode_id": "ino_2",
"revision_no": 3,
"committed_seq": 7,
"commit_id": "c_revision_owner",
"committed_at_ms": 1_752_624_000_000_u64,
"committed_by": "loonfs",
"content_ref": content_ref,
})
);
}
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(
crate::NamespaceId::parse("demo").expect("namespace id"),
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
b"hello",
)
}
#[test]
fn namespace_wire_shape_has_only_core_state() {
let namespace = Namespace {
access: NamespaceAccessMode::Unrestricted {},
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
created_at_ms: 1_000,
created_by: crate::ActorId::parse("test").expect("actor"),
fork_basis: None,
head_seq: ChangeSeq(11),
retention_floor_seq: ChangeSeq(4),
};
assert_eq!(
serde_json::to_value(namespace).expect("serialize namespace"),
serde_json::json!({
"namespace_id": "demo",
"access": {"kind": "unrestricted"},
"created_at_ms": 1000,
"created_by": "test",
"head_seq": 11,
"retention_floor_seq": 4
})
);
}
#[test]
fn namespace_diagnostics_wire_shape_keeps_storage_fields() {
let diagnostics = NamespaceDiagnostics {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
created_at_ms: 1_000,
created_by: crate::ActorId::parse("test").expect("actor"),
fork_basis: None,
head_seq: ChangeSeq(11),
retention_floor_seq: ChangeSeq(4),
current_manifest_no: Some(ManifestNo(8)),
wal_tail_segments: 3,
live_snapshots: 2,
live_checkpoints: 5,
};
assert_eq!(
serde_json::to_value(diagnostics).expect("serialize namespace diagnostics"),
serde_json::json!({
"namespace_id": "demo",
"created_at_ms": 1000,
"created_by": "test",
"head_seq": 11,
"retention_floor_seq": 4,
"current_manifest_no": 8,
"wal_tail_segments": 3,
"live_snapshots": 2,
"live_checkpoints": 5
})
);
}
#[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 {
source_path: path("/docs/a.txt"),
destination_path: path("/docs/b.txt"),
precondition: crate::DestinationPrecondition {
behavior: DestinationBehavior::Replace,
expected_inode_id: Some(InodeId(7)),
expected_revision_no: Some(RevisionNo(3)),
},
};
assert_eq!(
serde_json::to_value(&move_path).expect("move op json"),
serde_json::json!({
"kind": "move_path",
"source_path": "/docs/a.txt",
"destination_path": "/docs/b.txt",
"behavior": "replace",
"expected_destination_inode_id": "ino_7",
"expected_destination_revision_no": 3
})
);
let copy_path = FilesystemOperation::CopyPath {
source_path: path("/docs/a.txt"),
destination_path: path("/docs/b.txt"),
precondition: crate::DestinationPrecondition {
behavior: DestinationBehavior::Replace,
expected_inode_id: Some(InodeId(7)),
expected_revision_no: Some(RevisionNo(3)),
},
};
assert_eq!(
serde_json::to_value(©_path).expect("copy op json"),
serde_json::json!({
"kind": "copy_path",
"source_path": "/docs/a.txt",
"destination_path": "/docs/b.txt",
"behavior": "replace",
"expected_destination_inode_id": "ino_7",
"expected_destination_revision_no": 3
})
);
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_preconditions() {
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 preconditions");
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",
"owner_namespace_id": "demo",
"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_inode_id: None,
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",
"source_path": "/docs/a.txt",
"destination_path": "/docs/b.txt"
}))
.expect("move op defaults behavior");
assert_eq!(
move_path,
FilesystemOperation::MovePath {
source_path: path("/docs/a.txt"),
destination_path: path("/docs/b.txt"),
precondition: crate::DestinationPrecondition {
behavior: DestinationBehavior::NoReplace,
expected_inode_id: None,
expected_revision_no: None,
},
}
);
let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "copy_path",
"source_path": "/docs/a.txt",
"destination_path": "/docs/b.txt"
}))
.expect("copy op defaults behavior");
assert_eq!(
copy_path,
FilesystemOperation::CopyPath {
source_path: path("/docs/a.txt"),
destination_path: path("/docs/b.txt"),
precondition: crate::DestinationPrecondition {
behavior: DestinationBehavior::NoReplace,
expected_inode_id: None,
expected_revision_no: None,
},
}
);
let move_by_inode: FilesystemOperation = serde_json::from_value(serde_json::json!({
"kind": "move_by_inode",
"inode_id": "ino_7",
"expected_binding_generation": "aaaa",
"destination_parent_inode_id": "ino_1",
"destination_display_name": "b.txt"
}))
.expect("inode move defaults behavior");
assert_eq!(
move_by_inode,
FilesystemOperation::MoveByInode {
inode_id: InodeId(7),
expected_binding_generation: BindingGeneration::parse("aaaa")
.expect("binding generation"),
destination_parent_inode_id: InodeId(1),
destination_display_name: DisplayName::parse("b.txt").expect("display name"),
precondition: DestinationPrecondition::default(),
}
);
}
#[test]
fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
let content_ref = ContentRef::blob_v1(
crate::NamespaceId::parse("demo").expect("namespace id"),
ContentId::generate(),
b"hello",
);
let cases = [
(
FilesystemOperation::PutFile {
path: path("/docs/a.txt"),
content_ref: Some(content_ref.clone()),
inline_content: None,
behavior: DestinationBehavior::NoReplace,
expected_inode_id: None,
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),
destination_path: Some(path("/docs/restored")),
},
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_7",
"deletion_seq": 8,
"destination_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(crate::NamespaceId::parse("demo").expect("namespace id"), ContentId::generate(), b"hello")
}),
serde_json::json!({"kind": "delete_path", "path": "relative"}),
serde_json::json!({
"kind": "move_path",
"source_path": "relative",
"destination_path": "/target"
}),
serde_json::json!({
"kind": "copy_path",
"source_path": "/source",
"destination_path": "relative"
}),
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_7",
"deletion_seq": 8,
"destination_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,
"destination_path": "/docs/restored"
}),
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 path_preconditions_reject_ambiguous_shapes() {
let missing = serde_json::from_value::<CommitPrecondition>(
serde_json::json!({"kind": "path_binding", "path": "/docs/input"}),
)
.expect_err("binding requires an inode");
assert!(
missing.to_string().contains("expected_inode_id"),
"{missing}"
);
serde_json::from_value::<CommitPrecondition>(serde_json::json!({
"kind": "path_binding", "path": "/docs/input", "expected_inode_id": null
}))
.expect_err("a null inode is not an absence check");
let error = serde_json::from_value::<CommitPrecondition>(serde_json::json!({
"kind": "path_absence", "path": "/docs/input", "expected_inode_id": "ino_42"
}))
.expect_err("absence accepts only a path");
assert!(
error
.to_string()
.contains("unknown field `expected_inode_id`"),
"{error}"
);
}
#[test]
fn a_misspelled_precondition_does_not_decode() {
let put = |precondition: &str| {
let mut operation = serde_json::json!({
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": sample_content_ref(),
"behavior": "replace",
"expected_inode_id": "ino_7"
});
operation[precondition] = serde_json::json!(3);
serde_json::json!({
"commit_id": "with_preconditions-put",
"operations": [operation]
})
};
let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
.expect("the precondition 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-precondition",
"operations": [{
"kind": "put_file",
"path": "/docs/a.txt",
"content_ref": sample_content_ref(),
"behavior": "replace",
"expected_inode_id": "ino_7",
"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",
"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"
);
}
for (field, operation) in [
(
"path",
serde_json::json!({
"kind": "undelete",
"inode_id": "ino_7",
"deletion_seq": 8,
"path": "/docs/restored"
}),
),
(
"from_path",
serde_json::json!({
"kind": "move_path",
"source_path": "/docs/a.txt",
"destination_path": "/docs/b.txt",
"from_path": "/docs/a.txt"
}),
),
] {
let mut body = valid();
body["operations"] = serde_json::json!([operation]);
let error = serde_json::from_value::<CommitRequest>(body)
.expect_err("obsolete operation field must be rejected");
assert!(
error
.to_string()
.contains(&format!("unknown field `{field}`")),
"{error}"
);
}
}
#[test]
fn checkpoint_responses_use_one_checkpoint_wire_object() {
let namespace_id = NamespaceId::parse("demo").expect("namespace id");
let checkpoint = Checkpoint {
namespace_id: namespace_id.clone(),
checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000001")
.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),
captured_seq: ChangeSeq(12),
manifest_no: ManifestNo(9),
};
let checkpoint_json = serde_json::json!({
"namespace_id": "demo",
"checkpoint_id": "pin_00000000000000000001-0000000000000001",
"owner": {"kind": "user", "name": "release"},
"created_at_ms": 1_752_623_000_000_u64,
"expires_at_ms": 1_752_626_600_000_u64,
"captured_seq": 12,
"manifest_no": 9,
});
assert_eq!(
serde_json::to_value(checkpoint.clone()).expect("serialize checkpoint"),
checkpoint_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(DeleteCheckpointResponse {
namespace_id,
checkpoint_id: checkpoint.checkpoint_id,
})
.expect("serialize delete checkpoint response"),
serde_json::json!({
"namespace_id": "demo",
"checkpoint_id": "pin_00000000000000000001-0000000000000001",
}),
);
}
#[test]
fn optional_response_fields_are_omitted_and_default_when_absent() {
let checkpoint_json = serde_json::to_value(Checkpoint {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000001")
.expect("checkpoint id"),
owner: CheckpointOwnerSummary::User {
name: "release".to_owned(),
},
created_at_ms: 1_752_623_000_000,
expires_at_ms: None,
captured_seq: ChangeSeq(3),
manifest_no: ManifestNo(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());
assert!(gc_json.get("reclaim_after_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);
assert_eq!(gc.reclaim_after_ms, None);
let retired = GcResponse {
reclaim_after_ms: Some(2_000_000),
..gc
};
let json = serde_json::to_value(&retired).expect("encode retirement");
assert_eq!(json["reclaim_after_ms"], 2_000_000);
assert_eq!(
serde_json::from_value::<GcResponse>(json).expect("decode retirement"),
retired
);
}
#[test]
fn maintenance_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"})
);
assert_eq!(
serde_json::to_value(RunMaintenanceResponse::MetadataCompaction(
MetadataCompactionResponse {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
compaction: MetadataCompactionOutcome::Published {
manifest_no: ManifestNo(7),
rows_read: 11,
rows_written: 9,
input_bytes: 120,
output_bytes: 80,
output_segments: 2,
},
},
))
.expect("serialize metadata compaction response"),
serde_json::json!({
"kind": "metadata_compaction",
"namespace_id": "demo",
"compaction": {
"outcome": "published",
"manifest_no": 7,
"rows_read": 11,
"rows_written": 9,
"input_bytes": 120,
"output_bytes": 80,
"output_segments": 2
}
})
);
}
#[test]
fn run_maintenance_requests_are_strict_and_round_trip() {
let cases = [
(
serde_json::json!({"kind": "metadata"}),
Some(RunMaintenanceRequest::Metadata(
MetadataMaintenanceRequest::default(),
)),
),
(
serde_json::json!({"kind": "metadata", "max_wal_tail_segments": 4}),
Some(RunMaintenanceRequest::Metadata(
MetadataMaintenanceRequest {
max_wal_tail_segments: Some(4),
},
)),
),
(
serde_json::json!({"kind": "metadata_compaction"}),
Some(RunMaintenanceRequest::MetadataCompaction(
MetadataCompactionRequest {},
)),
),
(
serde_json::json!({"kind": "gc"}),
Some(RunMaintenanceRequest::Gc(GcRequest::default())),
),
(
serde_json::json!({
"kind": "gc",
"grace_window_ms": 600_000
}),
Some(RunMaintenanceRequest::Gc(GcRequest {
grace_window_ms: Some(600_000),
})),
),
(
serde_json::json!({"kind": "retention"}),
Some(RunMaintenanceRequest::Retention(AdvanceRetentionRequest {})),
),
(serde_json::json!({}), None),
(serde_json::json!({"kind": "nope"}), None),
(serde_json::json!({"kind": "gc", "bogus": 1}), None),
(serde_json::json!({"kind": "gc", "max_objects": 1}), None),
(serde_json::json!({"kind": "gc", "max_steps": 1}), None),
(serde_json::json!({"kind": "retention", "bogus": 1}), None),
(
serde_json::json!({"kind": "metadata_compaction", "bogus": 1}),
None,
),
];
for (body, expected) in cases {
let decoded = serde_json::from_value::<RunMaintenanceRequest>(body.clone());
match expected {
Some(expected) => {
let decoded = decoded.expect("valid maintenance request should decode");
assert_eq!(decoded, expected);
assert_eq!(
serde_json::to_value(decoded)
.expect("maintenance request should serialize"),
body
);
}
None => assert!(
decoded.is_err(),
"invalid maintenance request decoded: {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()
);
}
#[test]
fn update_access_round_trips_and_requires_boundary_and_grants() {
let operation = FilesystemOperation::UpdateAccess {
path: AbsolutePath::parse("/docs/secret").expect("path"),
boundary: true,
grants: serde_json::from_value(serde_json::json!({"prn_ada": ["read", "write"]}))
.expect("grants"),
expected_inode_id: Some(InodeId(9)),
expected_access_revision_no: Some(AccessRevisionNo(2)),
};
let json = serde_json::json!({
"kind": "update_access",
"path": "/docs/secret",
"boundary": true,
"grants": {"prn_ada": ["read", "write"]},
"expected_inode_id": "ino_9",
"expected_access_revision_no": 2
});
assert_eq!(serde_json::to_value(&operation).expect("serialize"), json);
assert_eq!(
serde_json::from_value::<FilesystemOperation>(json.clone()).expect("decode"),
operation
);
for field in ["boundary", "grants"] {
let mut missing = json.clone();
missing.as_object_mut().expect("object").remove(field);
assert!(
serde_json::from_value::<FilesystemOperation>(missing).is_err(),
"missing {field}"
);
}
assert_eq!(
serde_json::to_value(CommitPrecondition::AccessRevision {
inode_id: InodeId(9),
expected_access_revision_no: AccessRevisionNo(2),
})
.expect("serialize precondition"),
serde_json::json!({"kind": "access_revision", "inode_id": "ino_9", "expected_access_revision_no": 2})
);
}
}