Skip to main content

loonfs_api/v0/
operations.rs

1//! Request/response shapes for the v0 HTTP API's operation endpoints:
2//! namespace lifecycle (create/fork/status/delete), path-oriented filesystem
3//! operations, file revisions, maintenance (checkpoint/retention), and the
4//! shared [`ApiError`] body. Explicit commits and the change feed live in
5//! [`super::commits`]; read-result shapes live in [`super::reads`].
6
7use super::ContentToken;
8use crate::{
9    AbsolutePath, AttributeKey, AttributeRevisionNo, AttributeValue, ChangeSeq, CheckpointId,
10    CommitId, ContentRef, DisplayName, InodeId, ManifestNo, NamespaceId, RevisionNo, WriterEpoch,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15/// HTTP error body used by LoonFS APIs.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18pub struct ApiError {
19    /// Stable machine-readable reason from the [`ErrorCode`](crate::ErrorCode)
20    /// registry.
21    ///
22    /// Carried as a string so clients keep working when a newer server
23    /// introduces a code they do not know; use
24    /// [`ErrorCode::parse`](crate::ErrorCode::parse) for typed access.
25    pub code: String,
26    /// For `not_supported` errors, the capability-document feature key the
27    /// client should reconcile against.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub feature: Option<String>,
30    /// Human-readable error message.
31    pub message: String,
32    /// Identifies the invalid input. Body fields use JSON Pointer paths;
33    /// query and path parameters use their names; CLI errors use the flag or
34    /// argument as written.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub param: Option<String>,
37    /// Correlation id the server assigned to the failed request; the same
38    /// value is sent as the `x-request-id` response header.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub request_id: Option<String>,
41    /// Structured context for the code, present when the failure carries
42    /// machine-usable identity (API spec, "Standard error contract"). Boxed
43    /// so the rare detailed error does not widen every error-carrying result.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    #[cfg_attr(feature = "openapi", schema(nullable = false))]
46    pub details: Option<Box<ErrorDetails>>,
47}
48
49/// Optional machine-readable details for an [`ApiError`].
50///
51/// Clients make retry decisions from the error code and use these fields for
52/// relevant identifiers such as commit ids, writer epochs, and revisions.
53/// Fields may be absent and clients must ignore fields they do not use.
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
56pub struct ErrorDetails {
57    /// Idempotency key of the commit the error concerns.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    #[cfg_attr(feature = "openapi", schema(nullable = false))]
60    pub commit_id: Option<CommitId>,
61    /// Sequence at which that commit id already landed. Present when the
62    /// failure was decided against a durable commit receipt, which is what
63    /// holds the sequence; absent when nothing has committed under the id
64    /// yet and two live requests are simply claiming it at once.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    #[cfg_attr(feature = "openapi", schema(nullable = false))]
67    pub committed_seq: Option<ChangeSeq>,
68    /// Semantic identity of the mutation that already landed under that
69    /// commit id, from the same receipt as `committed_seq` and present
70    /// exactly when it is. A retry recomputes this value from the request it
71    /// just made — see
72    /// [`put_retry_fingerprint`](crate::put_retry_fingerprint) — and equality
73    /// is what proves the two are the same request.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub committed_fingerprint: Option<String>,
76    /// Position, in the request's operation list, of the operation that
77    /// failed. A commit applies all of its operations or none of them, so
78    /// this names the one that stopped the whole request.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub operation_index: Option<u32>,
81    /// Epoch the failing writer session held when it was displaced.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    #[cfg_attr(feature = "openapi", schema(nullable = false))]
84    pub fenced_writer_epoch: Option<WriterEpoch>,
85    /// Epoch that currently owns the namespace.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    #[cfg_attr(feature = "openapi", schema(nullable = false))]
88    pub active_writer_epoch: Option<WriterEpoch>,
89    /// Writer id recorded by the current epoch's acquirer, when the head
90    /// recorded one.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub active_writer: Option<String>,
93    /// Unix milliseconds at which the current epoch's acquirer took it, when
94    /// the head recorded one. Writer ids are process labels, so two runs on
95    /// one machine can share one; the stamp is what tells them apart.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub active_acquired_at_ms: Option<u64>,
98    /// Inode the failed precondition or operation targeted.
99    #[serde(
100        default,
101        skip_serializing_if = "Option::is_none",
102        with = "crate::public_inode_id::option"
103    )]
104    #[cfg_attr(
105        feature = "openapi",
106        schema(schema_with = crate::public_inode_id::schema)
107    )]
108    pub inode_id: Option<InodeId>,
109    /// Revision the request expected to be current.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    #[cfg_attr(feature = "openapi", schema(nullable = false))]
112    pub expected_revision_no: Option<RevisionNo>,
113    /// Revision that is actually current; absent when the inode has none.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    #[cfg_attr(feature = "openapi", schema(nullable = false))]
116    pub actual_revision_no: Option<RevisionNo>,
117    /// Attribute revision the request expected to be current.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    #[cfg_attr(feature = "openapi", schema(nullable = false))]
120    pub expected_attributes_revision_no: Option<AttributeRevisionNo>,
121    /// Attribute revision that is actually current for the inode.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    #[cfg_attr(feature = "openapi", schema(nullable = false))]
124    pub actual_attributes_revision_no: Option<AttributeRevisionNo>,
125    /// Change-feed cursor the request asked to resume after.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    #[cfg_attr(feature = "openapi", schema(nullable = false))]
128    pub after_seq: Option<ChangeSeq>,
129    /// Oldest sequence still promised for incremental replay.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    #[cfg_attr(feature = "openapi", schema(nullable = false))]
132    pub retention_floor_seq: Option<ChangeSeq>,
133    /// Deletion generation the undelete expected to be active.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    #[cfg_attr(feature = "openapi", schema(nullable = false))]
136    pub expected_deletion_seq: Option<ChangeSeq>,
137    /// Deletion generation actually active for the inode.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    #[cfg_attr(feature = "openapi", schema(nullable = false))]
140    pub actual_deletion_seq: Option<ChangeSeq>,
141    /// Head sequence a namespace delete required the namespace to still be
142    /// at.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    #[cfg_attr(feature = "openapi", schema(nullable = false))]
145    pub expected_head_seq: Option<ChangeSeq>,
146    /// Head sequence the namespace was actually at, which is what a caller
147    /// that still means to delete it retries against.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    #[cfg_attr(feature = "openapi", schema(nullable = false))]
150    pub actual_head_seq: Option<ChangeSeq>,
151}
152
153/// Request to create a namespace.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(deny_unknown_fields)]
157pub struct CreateNamespaceRequest {
158    /// Durable namespace id to create.
159    pub namespace_id: NamespaceId,
160}
161
162/// Request to fork a namespace.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
165#[serde(deny_unknown_fields)]
166pub struct ForkNamespaceRequest {
167    /// Durable namespace id for the fork target.
168    pub new_namespace_id: NamespaceId,
169}
170
171/// Current state for one namespace.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
174pub struct Namespace {
175    /// Namespace ID.
176    pub namespace_id: NamespaceId,
177    /// Current visible namespace sequence.
178    pub head_seq: ChangeSeq,
179    /// Oldest sequence still promised for incremental replay.
180    pub retention_floor_seq: ChangeSeq,
181}
182
183/// Namespace state and storage details used by maintenance.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186pub struct NamespaceDiagnostics {
187    /// Namespace ID.
188    pub namespace_id: NamespaceId,
189    /// Current visible namespace sequence.
190    pub head_seq: ChangeSeq,
191    /// Oldest sequence still promised for incremental replay.
192    pub retention_floor_seq: ChangeSeq,
193    /// Current manifest pointer recorded by the head.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    #[cfg_attr(feature = "openapi", schema(nullable = false))]
196    pub current_manifest_no: Option<ManifestNo>,
197    /// Number of visible WAL segments after the current manifest.
198    pub wal_tail_segments: u64,
199    /// Number of snapshots that had not expired when diagnostics began.
200    pub live_snapshots: u64,
201    /// Number of active user checkpoints, including expired records awaiting collection.
202    pub live_checkpoints: u64,
203}
204
205/// Result of deleting a namespace.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208pub struct DeleteNamespaceResponse {
209    /// Namespace whose history ended.
210    pub namespace_id: NamespaceId,
211    /// The head's last committed sequence; the delete linearized
212    /// immediately after it, so this is where history ended.
213    pub head_seq: ChangeSeq,
214}
215
216/// Destination-conflict behavior for path-oriented puts, moves, and copies.
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "snake_case")]
220pub enum DestinationBehavior {
221    /// Fail if the destination path already exists.
222    #[default]
223    NoReplace,
224    /// Replace the current file at the destination; only a file
225    /// destination can be replaced.
226    Replace,
227}
228
229/// Directory delete behavior for path-oriented deletes.
230#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
232#[serde(rename_all = "snake_case")]
233pub enum DeleteDirectoryBehavior {
234    /// Fail if the target is a non-empty directory.
235    #[default]
236    NonRecursive,
237    /// Delete a directory subtree.
238    Recursive,
239}
240
241/// One filesystem operation.
242///
243/// Unknown fields are rejected so a misspelled concurrency guard cannot be ignored.
244/// Fieldless variants must use empty braces so serde rejects unexpected fields.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
247#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
248pub enum FilesystemOperation {
249    /// Create one directory.
250    #[cfg_attr(
251        feature = "openapi",
252        schema(title = "FilesystemOperationCreateDirectory")
253    )]
254    CreateDirectory {
255        /// Absolute destination path, rejected when invalid or already bound.
256        path: AbsolutePath,
257        /// Also create missing ancestor directories (the same auto-create
258        /// `put_file` performs). The final component must still be new.
259        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260        parents: bool,
261    },
262    /// Create a directory under an existing parent inode.
263    #[cfg_attr(
264        feature = "openapi",
265        schema(title = "FilesystemOperationCreateDirectoryByInode")
266    )]
267    CreateDirectoryByInode {
268        /// Parent directory.
269        #[serde(with = "crate::public_inode_id")]
270        #[cfg_attr(
271            feature = "openapi",
272            schema(schema_with = crate::public_inode_id::schema)
273        )]
274        parent_inode_id: InodeId,
275        /// New directory name.
276        display_name: DisplayName,
277    },
278    /// Create or replace one file with an already-durable content ref.
279    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationPutFile"))]
280    PutFile {
281        /// Absolute destination path; missing ancestors are created automatically.
282        path: AbsolutePath,
283        /// Immutable bytes that must be covered by a valid preparation proof.
284        content_ref: ContentRef,
285        /// Whether an existing file may receive a new revision instead of causing a conflict.
286        #[serde(default)]
287        behavior: DestinationBehavior,
288        /// When set (with `replace` behavior), the put applies only while
289        /// the file's current revision is still this one; a raced write
290        /// fails the request instead of silently stacking on it, and a
291        /// missing file answers `path_not_found`.
292        #[serde(default, skip_serializing_if = "Option::is_none")]
293        #[cfg_attr(feature = "openapi", schema(nullable = false))]
294        expected_revision_no: Option<RevisionNo>,
295    },
296    /// Create a file under an existing parent inode. The name must be unused.
297    #[cfg_attr(
298        feature = "openapi",
299        schema(title = "FilesystemOperationPutFileByInode")
300    )]
301    PutFileByInode {
302        /// Parent directory.
303        #[serde(with = "crate::public_inode_id")]
304        #[cfg_attr(
305            feature = "openapi",
306            schema(schema_with = crate::public_inode_id::schema)
307        )]
308        parent_inode_id: InodeId,
309        /// New file name.
310        display_name: DisplayName,
311        /// Immutable bytes that must be covered by a valid preparation proof.
312        content_ref: ContentRef,
313    },
314    /// Append a revision to a file inode if its current revision matches.
315    #[cfg_attr(
316        feature = "openapi",
317        schema(title = "FilesystemOperationPutFileRevisionByInode")
318    )]
319    PutFileRevisionByInode {
320        /// File to update.
321        #[serde(with = "crate::public_inode_id")]
322        #[cfg_attr(
323            feature = "openapi",
324            schema(schema_with = crate::public_inode_id::schema)
325        )]
326        inode_id: InodeId,
327        /// Immutable bytes that must be covered by a valid preparation proof.
328        content_ref: ContentRef,
329        /// Current revision required for the write.
330        expected_revision_no: RevisionNo,
331    },
332    /// Delete one path.
333    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationDeletePath"))]
334    DeletePath {
335        /// Absolute path that must resolve to a visible inode.
336        path: AbsolutePath,
337        /// Whether a non-empty directory may be tombstoned recursively.
338        #[serde(default)]
339        behavior: DeleteDirectoryBehavior,
340        /// When set, the delete applies only if the path still resolves to
341        /// this inode; a raced rebinding fails the request instead of
342        /// deleting (and reporting a recovery handle for) the wrong inode.
343        #[serde(
344            default,
345            skip_serializing_if = "Option::is_none",
346            with = "crate::public_inode_id::option"
347        )]
348        #[cfg_attr(
349            feature = "openapi",
350            schema(schema_with = crate::public_inode_id::schema)
351        )]
352        expected_inode_id: Option<InodeId>,
353    },
354    /// Delete an inode if its current binding matches.
355    #[cfg_attr(
356        feature = "openapi",
357        schema(title = "FilesystemOperationDeleteByInode")
358    )]
359    DeleteByInode {
360        /// Inode to delete.
361        #[serde(with = "crate::public_inode_id")]
362        #[cfg_attr(
363            feature = "openapi",
364            schema(schema_with = crate::public_inode_id::schema)
365        )]
366        inode_id: InodeId,
367        /// Binding generation required for the delete.
368        expected_binding_generation: String,
369        /// Whether a non-empty directory may be tombstoned recursively.
370        #[serde(default)]
371        behavior: DeleteDirectoryBehavior,
372    },
373    /// Move one path to another path.
374    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMovePath"))]
375    MovePath {
376        /// Absolute source path that must resolve to a visible inode.
377        from_path: AbsolutePath,
378        /// Absolute destination whose parent must be visible and writable.
379        to_path: AbsolutePath,
380        /// Whether an existing destination file may be replaced.
381        #[serde(default)]
382        behavior: DestinationBehavior,
383    },
384    /// Move an inode if its current binding matches.
385    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationMoveByInode"))]
386    MoveByInode {
387        /// Inode to move.
388        #[serde(with = "crate::public_inode_id")]
389        #[cfg_attr(
390            feature = "openapi",
391            schema(schema_with = crate::public_inode_id::schema)
392        )]
393        inode_id: InodeId,
394        /// Binding generation required for the move.
395        expected_binding_generation: String,
396        /// Destination directory.
397        #[serde(with = "crate::public_inode_id")]
398        #[cfg_attr(
399            feature = "openapi",
400            schema(schema_with = crate::public_inode_id::schema)
401        )]
402        to_parent_inode_id: InodeId,
403        /// New name.
404        to_display_name: DisplayName,
405        /// Whether an existing destination file may be replaced.
406        #[serde(default)]
407        behavior: DestinationBehavior,
408    },
409    /// Copy one file path to another path.
410    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationCopyPath"))]
411    CopyPath {
412        /// Absolute source path that must resolve to a visible file.
413        from_path: AbsolutePath,
414        /// Absolute destination whose parent must be visible and writable.
415        to_path: AbsolutePath,
416        /// Whether an existing destination file may receive a copied revision.
417        #[serde(default)]
418        behavior: DestinationBehavior,
419    },
420    /// Restore a deleted file or subtree.
421    ///
422    /// `inode_id` and `deletion_seq` identify one exact deletion. A stale
423    /// sequence returns `not_deleted` and cannot undo a later deletion.
424    #[cfg_attr(feature = "openapi", schema(title = "FilesystemOperationUndelete"))]
425    Undelete {
426        /// Deleted inode to make reachable again.
427        #[serde(with = "crate::public_inode_id")]
428        #[cfg_attr(
429            feature = "openapi",
430            schema(schema_with = crate::public_inode_id::schema)
431        )]
432        inode_id: InodeId,
433        /// Observed deletion sequence, which prevents cancelling a newer tombstone generation.
434        deletion_seq: ChangeSeq,
435        /// Optional destination for the restored inode.
436        ///
437        /// When absent, the inode is rebound to the parent and name recorded by the
438        /// deletion. Parent identity, rather than an old path string, keeps this
439        /// correct after ancestor renames. An explicit path is required when the
440        /// deletion recorded no binding.
441        #[serde(default, skip_serializing_if = "Option::is_none")]
442        #[cfg_attr(feature = "openapi", schema(nullable = false))]
443        path: Option<AbsolutePath>,
444    },
445    /// Restore an older revision as the current revision for a path.
446    #[cfg_attr(
447        feature = "openapi",
448        schema(title = "FilesystemOperationRestoreRevision")
449    )]
450    RestoreRevision {
451        /// Absolute path that must resolve to a visible file.
452        path: AbsolutePath,
453        /// Existing historical revision whose content will be copied into a new current revision.
454        source_revision_no: RevisionNo,
455    },
456    /// Write and remove attributes on the inode one path resolves to.
457    #[cfg_attr(
458        feature = "openapi",
459        schema(title = "FilesystemOperationUpdateAttributes")
460    )]
461    UpdateAttributes {
462        /// Absolute path that must resolve to a visible file or directory.
463        path: AbsolutePath,
464        /// Attributes to write. Each key replaces whatever the inode
465        /// currently holds under it; keys the inode holds and this map does
466        /// not name are left alone.
467        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
468        set: BTreeMap<AttributeKey, AttributeValue>,
469        /// Attribute keys to remove.
470        ///
471        /// A list preserves duplicate entries so validation can report them instead
472        /// of silently deduplicating the request.
473        #[serde(default, skip_serializing_if = "Vec::is_empty")]
474        remove: Vec<AttributeKey>,
475        /// When set, the update applies only if the path still resolves to
476        /// this inode; a raced rebinding fails the request instead of
477        /// writing attributes onto the wrong inode.
478        #[serde(
479            default,
480            skip_serializing_if = "Option::is_none",
481            with = "crate::public_inode_id::option"
482        )]
483        #[cfg_attr(
484            feature = "openapi",
485            schema(schema_with = crate::public_inode_id::schema)
486        )]
487        expected_inode_id: Option<InodeId>,
488        /// When set, the update applies only while the inode's attribute
489        /// revision is still this one. Absent means the update is applied
490        /// over whatever revision is current; either way the write carries
491        /// its own revision guard, so a concurrent update never merges
492        /// silently.
493        #[serde(default, skip_serializing_if = "Option::is_none")]
494        #[cfg_attr(feature = "openapi", schema(nullable = false))]
495        expected_attributes_revision_no: Option<AttributeRevisionNo>,
496    },
497}
498
499impl FilesystemOperation {
500    /// Returns the content written by this operation, if any.
501    pub const fn content_ref(&self) -> Option<&ContentRef> {
502        match self {
503            Self::PutFile { content_ref, .. }
504            | Self::PutFileByInode { content_ref, .. }
505            | Self::PutFileRevisionByInode { content_ref, .. } => Some(content_ref),
506            Self::CreateDirectory { .. }
507            | Self::CreateDirectoryByInode { .. }
508            | Self::DeletePath { .. }
509            | Self::DeleteByInode { .. }
510            | Self::MovePath { .. }
511            | Self::MoveByInode { .. }
512            | Self::CopyPath { .. }
513            | Self::Undelete { .. }
514            | Self::RestoreRevision { .. }
515            | Self::UpdateAttributes { .. } => None,
516        }
517    }
518}
519
520/// A request to commit one or more filesystem operations.
521///
522/// Operations run in order and either all succeed or none are committed. A
523/// request with one operation uses the same fingerprint rules as a batch.
524///
525/// Unknown fields are rejected here for the same reason they are on
526/// [`FilesystemOperation`]: the fields a typo can hide are the ones that
527/// decide whether the commit is guarded at all.
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
530#[serde(deny_unknown_fields)]
531pub struct CommitRequest {
532    /// Caller-supplied idempotency key for the whole request.
533    pub commit_id: CommitId,
534    /// Actor responsible for the commit, as supplied by the application.
535    pub actor: crate::ActorRef,
536    /// Caller annotation recorded on the commit and reported by the change
537    /// feed. Part of the commit's identity: reusing `commit_id` with a
538    /// different message is a `commit_id_reuse_conflict`, exactly as it is
539    /// for an explicit commit.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub message: Option<String>,
542    /// Proofs for any new external content refs introduced by this request.
543    /// One proof covers every operation that names its content ref.
544    #[serde(default, skip_serializing_if = "Vec::is_empty")]
545    pub content_tokens: Vec<ContentToken>,
546    /// Ordered operations to apply. Must be non-empty; they commit all
547    /// together or not at all.
548    pub operations: Vec<FilesystemOperation>,
549}
550
551impl CommitRequest {
552    /// A request carrying exactly one operation.
553    pub fn single(
554        commit_id: CommitId,
555        actor: crate::ActorRef,
556        message: Option<String>,
557        operation: FilesystemOperation,
558    ) -> Self {
559        Self {
560            commit_id,
561            actor,
562            message,
563            content_tokens: Vec::new(),
564            operations: vec![operation],
565        }
566    }
567}
568
569/// One immutable file revision.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
572pub struct FileRevision {
573    /// File inode that owns this revision.
574    #[serde(with = "crate::public_inode_id")]
575    #[cfg_attr(
576        feature = "openapi",
577        schema(schema_with = crate::public_inode_id::schema)
578    )]
579    pub inode_id: InodeId,
580    /// Revision number within the file inode.
581    pub revision_no: RevisionNo,
582    /// Namespace sequence that created this revision.
583    pub committed_seq: ChangeSeq,
584    /// Commit ID for this revision.
585    pub commit_id: CommitId,
586    /// Wall-clock stamp of the commit that created this revision, in Unix
587    /// milliseconds. Observational: `committed_seq` is the order.
588    pub committed_at_ms: u64,
589    /// Actor responsible for this revision, as supplied by the application.
590    pub committed_by: crate::ActorRef,
591    /// Content stored for this revision.
592    pub content_ref: ContentRef,
593}
594
595/// Response for listing file revisions.
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
598pub struct ListFileRevisionsResponse {
599    /// Namespace that was read.
600    pub namespace_id: NamespaceId,
601    /// File inode whose revisions were returned.
602    #[serde(with = "crate::public_inode_id")]
603    #[cfg_attr(
604        feature = "openapi",
605        schema(schema_with = crate::public_inode_id::schema)
606    )]
607    pub inode_id: InodeId,
608    /// Namespace head sequence used for the read.
609    pub head_seq: ChangeSeq,
610    /// Retained revisions in order.
611    pub revisions: Vec<FileRevision>,
612    /// Opaque cursor for the next page, if more revisions are available.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub next_cursor: Option<String>,
615}
616
617/// Request to create a durable checkpoint pin.
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
620#[serde(deny_unknown_fields)]
621pub struct CreateCheckpointRequest {
622    /// Label recorded on the checkpoint record. A label, not a key: several
623    /// records may carry the same name over different bases.
624    pub name: String,
625    /// Optional lifetime; the server computes the record's expiry from its
626    /// own clock. Absent means the pin holds until explicitly released.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub ttl_ms: Option<u64>,
629}
630
631/// Request to create a snapshot.
632#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
633#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
634#[serde(deny_unknown_fields)]
635pub struct CreateSnapshotRequest {
636    /// A label that does not need to be unique.
637    pub name: String,
638    /// Snapshot lifetime from the current server time, in milliseconds.
639    pub ttl_ms: u64,
640}
641
642/// Request to extend a read snapshot.
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
644#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
645#[serde(deny_unknown_fields)]
646pub struct ExtendSnapshotRequest {
647    /// Requested lifetime from the server's current time, in milliseconds.
648    pub ttl_ms: u64,
649}
650
651/// Result of releasing a checkpoint pin.
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
654pub struct ReleaseCheckpointResponse {
655    /// Namespace the checkpoint belonged to.
656    pub namespace_id: NamespaceId,
657    /// Checkpoint the release targeted.
658    pub checkpoint_id: CheckpointId,
659}
660
661/// The owner of a checkpoint record.
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
664#[serde(tag = "kind", rename_all = "snake_case")]
665pub enum CheckpointOwnerSummary {
666    /// An operator-created pin, released by id or by its own expiry.
667    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
668    User {
669        /// The label the creator recorded. Not a key: several records may
670        /// carry one label over different bases.
671        name: String,
672    },
673    /// A fork target keeping its source basis alive for the length of one
674    /// fork attempt.
675    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
676    Fork {
677        /// Namespace whose continued existence keeps this pin standing.
678        target_namespace_id: NamespaceId,
679    },
680    /// An application-created read view.
681    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerSnapshot"))]
682    Snapshot {
683        /// A label that does not need to be unique.
684        name: String,
685        /// When the snapshot lease expires, in Unix milliseconds.
686        expires_at_ms: u64,
687    },
688}
689
690/// One checkpoint resource, reported from what its durable record carries.
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
693pub struct Checkpoint {
694    /// Namespace that owns the checkpoint.
695    pub namespace_id: NamespaceId,
696    /// Durable checkpoint id used to address the checkpoint for release.
697    pub checkpoint_id: CheckpointId,
698    /// Who owns the checkpoint, including the label carried by a user pin.
699    pub owner: CheckpointOwnerSummary,
700    /// Time the checkpoint record was created, in Unix milliseconds.
701    pub created_at_ms: u64,
702    /// When garbage collection may release the record without being asked,
703    /// in Unix milliseconds. Absent means the pin holds until it is
704    /// released. An instant already in the past is a record whose expiry
705    /// has passed and which no collection pass has reached yet: it is still
706    /// a root, so it is still listed.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub expires_at_ms: Option<u64>,
709    /// Sequence covered by the checkpoint's pinned basis.
710    pub checkpoint_seq: ChangeSeq,
711    /// Manifest pinned by the checkpoint.
712    pub manifest_no: ManifestNo,
713}
714
715/// A live snapshot.
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
717#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
718pub struct SnapshotSummary {
719    /// Snapshot id.
720    pub snapshot_id: CheckpointId,
721    /// Namespace whose state the snapshot captured.
722    pub namespace_id: NamespaceId,
723    /// Snapshot label.
724    pub name: String,
725    /// Namespace sequence captured by the snapshot.
726    pub head_seq: ChangeSeq,
727    /// Time the snapshot record was created, in Unix milliseconds.
728    pub created_at_ms: u64,
729    /// When the snapshot lease expires, in Unix milliseconds.
730    pub expires_at_ms: u64,
731}
732
733impl SnapshotSummary {
734    /// Converts a snapshot-owned checkpoint to a snapshot summary.
735    pub fn from_checkpoint(checkpoint: Checkpoint) -> Option<Self> {
736        let CheckpointOwnerSummary::Snapshot {
737            name,
738            expires_at_ms,
739        } = checkpoint.owner
740        else {
741            return None;
742        };
743        Some(Self {
744            snapshot_id: checkpoint.checkpoint_id,
745            namespace_id: checkpoint.namespace_id,
746            name,
747            head_seq: checkpoint.checkpoint_seq,
748            created_at_ms: checkpoint.created_at_ms,
749            expires_at_ms,
750        })
751    }
752}
753
754/// One page of active checkpoint records.
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
757pub struct ListCheckpointsResponse {
758    /// Namespace the records belong to.
759    pub namespace_id: NamespaceId,
760    /// Active records in ascending checkpoint-id order. Released records are
761    /// omitted even if garbage collection has not deleted them yet.
762    pub checkpoints: Vec<Checkpoint>,
763    /// Opaque cursor for the next page.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub next_cursor: Option<String>,
766}
767
768/// One page of live read snapshots.
769#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
770#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
771pub struct ListSnapshotsResponse {
772    /// Namespace the snapshots belong to.
773    pub namespace_id: NamespaceId,
774    /// Live snapshot records in ascending snapshot-id order.
775    pub snapshots: Vec<SnapshotSummary>,
776    /// Opaque cursor for the next page.
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub next_cursor: Option<String>,
779}
780
781/// Result of releasing a read snapshot.
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
784pub struct ReleaseSnapshotResponse {
785    /// Namespace the snapshot belonged to.
786    pub namespace_id: NamespaceId,
787    /// Released snapshot id.
788    pub snapshot_id: CheckpointId,
789}
790
791/// How one WAL flush satisfied its goal.
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
793#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
794#[serde(rename_all = "snake_case")]
795pub enum FlushWalOutcome {
796    /// The root already covered the head; nothing was published.
797    AlreadyCurrent,
798    /// This call published a new manifest and advanced the root to it.
799    Published,
800    /// Another publisher updated the root first, so this call's manifest is
801    /// not referenced by the root.
802    RootAdvanced,
803}
804
805/// Result of one WAL flush: what the metadata root references afterward.
806#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
807#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
808pub struct FlushWalResponse {
809    /// Namespace whose WAL tail was flushed.
810    pub namespace_id: NamespaceId,
811    /// Head sequence the flush attempted to cover.
812    pub target_head_seq: ChangeSeq,
813    /// Manifest `metadata/root.json` references after the operation.
814    pub manifest_no: ManifestNo,
815    /// Sequence covered by that manifest.
816    pub manifest_head_seq: ChangeSeq,
817    /// What this call did to the metadata root.
818    pub outcome: FlushWalOutcome,
819}
820
821/// Optional overrides for one garbage-collection pass. Absent fields use
822/// the server's conservative defaults.
823///
824/// Every field is optional, so a typo would take the default instead of the
825/// override the caller asked for. Unknown fields are rejected so a misspelled
826/// `max_objects` fails loudly rather than running an unbounded pass.
827#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
828#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
829#[serde(deny_unknown_fields)]
830pub struct GcRequest {
831    /// Objects younger than this are never deleted, reachable or not. The
832    /// window has a derived safety floor (publication budgets plus provider
833    /// deadlines); a smaller value is rejected as `invalid_request`.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub grace_window_ms: Option<u64>,
836    /// Maximum objects this invocation may read or decide. Omit to retain
837    /// the run-to-completion behavior.
838    ///
839    /// A completed upload session past its reclamation grace makes the pass
840    /// read every live manifest and retained WAL segment to find out
841    /// whether anything still references its content, and that read is
842    /// charged here like any other. A budget too small to finish it does
843    /// not stall the pass: the session is retained, the response sets
844    /// `content_reclamation_deferred`, and the sweep carries on through
845    /// everything else. What a chronically small budget costs is content
846    /// left unreclaimed, not progress. Give a pass at least as many objects
847    /// as the namespace has live manifests and retained segments for that
848    /// content to come back.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub max_objects: Option<u64>,
851    /// Opaque resume token returned as `next_cursor` by an earlier pass
852    /// against the same namespace.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub cursor: Option<String>,
855}
856
857/// Candidates inspected but not deleted by one GC pass.
858///
859/// Every field is present, including zero counts. The fields sum to
860/// `retained_candidates` in [`GcResponse`].
861///
862/// An object inspected by multiple passes is counted once per pass.
863#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
864#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
865pub struct RetainedCandidates {
866    /// Candidates found reachable during the final check before deletion.
867    pub referenced: u64,
868    /// Unreachable, but younger than the grace window by the object's own
869    /// provider timestamp. A later pass deletes it.
870    pub within_grace_window: u64,
871    /// Unreachable candidates with no provider timestamp. Their age is
872    /// unknown, so the pass keeps them.
873    pub no_provider_timestamp: u64,
874    /// Unreachable candidates that cannot be checked against a manifest old
875    /// enough to cover the grace window.
876    pub no_reference_manifest: u64,
877    /// Candidates kept because root resolution failed. The response also
878    /// sets `retention_degraded`.
879    pub degraded_roots: u64,
880    /// Unrecognized keys in a family scanned by GC. These keys are never
881    /// deleted.
882    pub unrecognized_key: u64,
883    /// Checkpoint records that could not be safely released or deleted.
884    pub checkpoint_not_releasable: u64,
885    /// Upload sessions still protected by a lease or grace window.
886    pub upload_session_window: u64,
887    /// Upload sessions kept because the pass could not determine whether
888    /// they were safe to delete.
889    pub upload_session_undecided: u64,
890    /// Completed sessions skipped because the reference scan exceeded
891    /// `max_objects`. The response also sets `content_reclamation_deferred`.
892    pub content_scan_deferred: u64,
893}
894
895/// Objects deleted by one GC pass, grouped by object family. Every field is
896/// present, including zero counts.
897#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
898#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
899pub struct DeletedObjectCounts {
900    /// Unreferenced WAL segments deleted.
901    pub wal_segments: u64,
902    /// Unreferenced metadata segments deleted.
903    pub metadata_segments: u64,
904    /// Unreferenced manifests deleted.
905    pub manifests: u64,
906    /// Released checkpoint records deleted after their grace window.
907    pub checkpoint_records: u64,
908    /// Upload-session control objects deleted after the reap window.
909    pub upload_sessions: u64,
910    /// Content objects deleted after their completed upload session passed
911    /// the reclamation grace period and no reachable data referenced them.
912    /// Cleanup of abandoned sessions is not counted here.
913    pub content_objects: u64,
914}
915
916impl DeletedObjectCounts {
917    /// Adds counts from another pass.
918    pub fn add(&mut self, other: &Self) {
919        let Self {
920            wal_segments,
921            metadata_segments,
922            manifests,
923            checkpoint_records,
924            upload_sessions,
925            content_objects,
926        } = other;
927        self.wal_segments += wal_segments;
928        self.metadata_segments += metadata_segments;
929        self.manifests += manifests;
930        self.checkpoint_records += checkpoint_records;
931        self.upload_sessions += upload_sessions;
932        self.content_objects += content_objects;
933    }
934}
935
936/// Checkpoint records released by one GC pass, grouped by reason.
937///
938/// Releasing a record stops it from pinning data. A later pass may delete the
939/// record after its grace window and count that under
940/// [`DeletedObjectCounts::checkpoint_records`].
941#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
942#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
943pub struct ReleasedCheckpointCounts {
944    /// Fork-owned records released because their target namespace is
945    /// provably gone.
946    pub fork: u64,
947    /// User-owned records released because their expiry passed, or because
948    /// they sit on a terminally deleted namespace.
949    pub expired: u64,
950    /// Active records released because their basis manifest is verifiably
951    /// gone.
952    pub missing_basis: u64,
953    /// Snapshot-owned records released because their expiry passed, or
954    /// because they sit on a terminally deleted namespace.
955    pub snapshot: u64,
956}
957
958impl ReleasedCheckpointCounts {
959    /// Adds counts from another pass.
960    pub fn add(&mut self, other: &Self) {
961        let Self {
962            fork,
963            expired,
964            missing_basis,
965            snapshot,
966        } = other;
967        self.fork += fork;
968        self.expired += expired;
969        self.missing_basis += missing_basis;
970        self.snapshot += snapshot;
971    }
972}
973
974/// Result of one mark-and-sweep garbage-collection pass.
975///
976/// Deletion counts are grouped by object family. Checkpoint releases and
977/// retained candidates are grouped by reason.
978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
979#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
980pub struct GcResponse {
981    /// Namespace the pass ran against.
982    pub namespace_id: NamespaceId,
983    /// Objects the pass deleted, split by object family.
984    pub deleted: DeletedObjectCounts,
985    /// Checkpoint records the pass released, split by the reason each one
986    /// was released.
987    pub released_checkpoints: ReleasedCheckpointCounts,
988    /// Candidates retained at delete time (grace window, missing
989    /// timestamps, or reachable from the fresh root set).
990    pub retained_candidates: u64,
991    /// `retained_candidates` grouped by reason.
992    pub retained: RetainedCandidates,
993    /// True when ambiguous roots suppressed manifest/segment deletion.
994    pub retention_degraded: bool,
995    /// True when `max_objects` was too small to build the complete reference
996    /// set required for completed-content reclamation.
997    pub content_reclamation_deferred: bool,
998    /// True when the pass reached `max_objects` before it finished. Use
999    /// `next_cursor` to continue or run again with a larger limit.
1000    pub budget_exhausted: bool,
1001    /// Opaque resume token when more candidates remain. It is valid only for
1002    /// the same namespace.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub next_cursor: Option<String>,
1005    /// Earliest known time when a retained upload session may become
1006    /// reclaimable. This covers open-session leases and grace periods for
1007    /// aborted or completed sessions. It only reflects candidates inspected
1008    /// by this pass, so absence does not mean no future work remains.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub next_reclamation_at_ms: Option<u64>,
1011}
1012
1013impl GcResponse {
1014    /// An empty report for `namespace_id`, before any candidate is examined.
1015    pub fn empty(namespace_id: NamespaceId) -> Self {
1016        Self {
1017            namespace_id,
1018            deleted: DeletedObjectCounts::default(),
1019            released_checkpoints: ReleasedCheckpointCounts::default(),
1020            retained_candidates: 0,
1021            retained: RetainedCandidates::default(),
1022            retention_degraded: false,
1023            content_reclamation_deferred: false,
1024            budget_exhausted: false,
1025            next_cursor: None,
1026            next_reclamation_at_ms: None,
1027        }
1028    }
1029
1030    /// Records one retained candidate under the reason that spared it.
1031    ///
1032    /// The total and the breakdown move together here so they cannot drift:
1033    /// every sweep site names a reason, and no site can count a retention
1034    /// without naming one.
1035    pub fn retain(&mut self, reason: RetainedReason) {
1036        self.retained_candidates += 1;
1037        *reason.counter(&mut self.retained) += 1;
1038    }
1039}
1040
1041/// The reason one candidate was retained, as the sweep site knows it. Each
1042/// variant is the field of [`RetainedCandidates`] it counts into, where the
1043/// reason itself is described.
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum RetainedReason {
1046    /// Counts into [`RetainedCandidates::referenced`].
1047    Referenced,
1048    /// Counts into [`RetainedCandidates::within_grace_window`].
1049    WithinGraceWindow,
1050    /// Counts into [`RetainedCandidates::no_provider_timestamp`].
1051    NoProviderTimestamp,
1052    /// Counts into [`RetainedCandidates::no_reference_manifest`].
1053    NoReferenceManifest,
1054    /// Counts into [`RetainedCandidates::degraded_roots`].
1055    DegradedRoots,
1056    /// Counts into [`RetainedCandidates::unrecognized_key`].
1057    UnrecognizedKey,
1058    /// Counts into [`RetainedCandidates::checkpoint_not_releasable`].
1059    CheckpointNotReleasable,
1060    /// Counts into [`RetainedCandidates::upload_session_window`].
1061    UploadSessionWindow,
1062    /// Counts into [`RetainedCandidates::upload_session_undecided`].
1063    UploadSessionUndecided,
1064    /// Counts into [`RetainedCandidates::content_scan_deferred`].
1065    ContentScanDeferred,
1066}
1067
1068impl RetainedReason {
1069    fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
1070        match self {
1071            Self::Referenced => &mut retained.referenced,
1072            Self::WithinGraceWindow => &mut retained.within_grace_window,
1073            Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
1074            Self::NoReferenceManifest => &mut retained.no_reference_manifest,
1075            Self::DegradedRoots => &mut retained.degraded_roots,
1076            Self::UnrecognizedKey => &mut retained.unrecognized_key,
1077            Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
1078            Self::UploadSessionWindow => &mut retained.upload_session_window,
1079            Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
1080            Self::ContentScanDeferred => &mut retained.content_scan_deferred,
1081        }
1082    }
1083}
1084
1085impl RetainedCandidates {
1086    /// Returns every reason and count in a fixed order.
1087    pub fn by_reason(&self) -> [(&'static str, u64); 10] {
1088        let Self {
1089            referenced,
1090            within_grace_window,
1091            no_provider_timestamp,
1092            no_reference_manifest,
1093            degraded_roots,
1094            unrecognized_key,
1095            checkpoint_not_releasable,
1096            upload_session_window,
1097            upload_session_undecided,
1098            content_scan_deferred,
1099        } = *self;
1100        [
1101            ("referenced", referenced),
1102            ("within_grace_window", within_grace_window),
1103            ("no_provider_timestamp", no_provider_timestamp),
1104            ("no_reference_manifest", no_reference_manifest),
1105            ("degraded_roots", degraded_roots),
1106            ("unrecognized_key", unrecognized_key),
1107            ("checkpoint_not_releasable", checkpoint_not_releasable),
1108            ("upload_session_window", upload_session_window),
1109            ("upload_session_undecided", upload_session_undecided),
1110            ("content_scan_deferred", content_scan_deferred),
1111        ]
1112    }
1113
1114    /// Adds counts from another pass.
1115    pub fn add(&mut self, other: &Self) {
1116        let Self {
1117            referenced,
1118            within_grace_window,
1119            no_provider_timestamp,
1120            no_reference_manifest,
1121            degraded_roots,
1122            unrecognized_key,
1123            checkpoint_not_releasable,
1124            upload_session_window,
1125            upload_session_undecided,
1126            content_scan_deferred,
1127        } = other;
1128        self.referenced += referenced;
1129        self.within_grace_window += within_grace_window;
1130        self.no_provider_timestamp += no_provider_timestamp;
1131        self.no_reference_manifest += no_reference_manifest;
1132        self.degraded_roots += degraded_roots;
1133        self.unrecognized_key += unrecognized_key;
1134        self.checkpoint_not_releasable += checkpoint_not_releasable;
1135        self.upload_session_window += upload_session_window;
1136        self.upload_session_undecided += upload_session_undecided;
1137        self.content_scan_deferred += content_scan_deferred;
1138    }
1139
1140    /// The reason with the highest count, and that count. `None` when
1141    /// nothing was retained. Ties go to the first in [`Self::by_reason`]
1142    /// order, so one pass's report is stable.
1143    pub fn top_reason(&self) -> Option<(&'static str, u64)> {
1144        self.by_reason()
1145            .into_iter()
1146            .filter(|(_, count)| *count > 0)
1147            // `max_by_key` keeps the last of equal maxima, so the reversal
1148            // is what makes a tie report the earlier reason.
1149            .rev()
1150            .max_by_key(|(_, count)| *count)
1151    }
1152}
1153
1154/// Selects retention-floor advancement. This request has no options yet.
1155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1157#[serde(deny_unknown_fields)]
1158pub struct AdvanceRetentionRequest {}
1159
1160/// Result of advancing the retention floor.
1161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1163pub struct AdvanceRetentionResponse {
1164    /// New minimum sequence for incremental replay.
1165    pub retention_floor_seq: ChangeSeq,
1166}
1167
1168/// One explicit maintenance step: the actions it selects, and nothing more.
1169///
1170/// Selection is presence. Each field names one independent action, and a
1171/// step runs exactly the ones the body carries — a request that selects
1172/// nothing is rejected rather than quietly doing nothing. Unknown fields are
1173/// rejected for the same reason: a misspelled selector would leave its action
1174/// unrun, and the caller would read the empty report as "there was nothing to
1175/// do".
1176#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1177#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1178#[serde(deny_unknown_fields)]
1179pub struct MaintenanceStepRequest {
1180    /// Flush the visible WAL tail into metadata segments, then run one bounded
1181    /// reorganization step.
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1184    pub metadata_maintenance: Option<MetadataMaintenanceRequest>,
1185    /// Advance the retention floor to the flushed manifest head. Include this
1186    /// field to select the action.
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1189    pub retention: Option<AdvanceRetentionRequest>,
1190    /// Run one bounded mark-and-sweep garbage-collection pass. Omit this
1191    /// field to skip garbage collection.
1192    #[serde(default, skip_serializing_if = "Option::is_none")]
1193    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1194    pub gc: Option<GcRequest>,
1195}
1196
1197/// Overrides for the metadata-upkeep action.
1198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1200#[serde(deny_unknown_fields)]
1201pub struct MetadataMaintenanceRequest {
1202    /// Flush the visible WAL tail once it reaches this many segments.
1203    /// Absent uses the server's default threshold; zero, and any value above
1204    /// the write-rejection threshold, are rejected as `invalid_request`.
1205    #[serde(default, skip_serializing_if = "Option::is_none")]
1206    pub max_wal_tail_segments: Option<u64>,
1207}
1208
1209/// What the WAL-flush part of a maintenance step did.
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1212#[serde(tag = "outcome", rename_all = "snake_case")]
1213pub enum WalFlushStepOutcome {
1214    /// The tail was below the threshold, so there was nothing to flush.
1215    NotNeeded,
1216    /// The step flushed the WAL tail and advanced the metadata root.
1217    Flushed {
1218        /// Sequence covered by the published manifest.
1219        manifest_head_seq: ChangeSeq,
1220    },
1221    /// The step did not update the root because it already referenced a
1222    /// different manifest.
1223    AlreadyPublished {
1224        /// Sequence this step attempted to flush through.
1225        attempted_seq: ChangeSeq,
1226        /// Manifest the root currently references.
1227        current_manifest_no: ManifestNo,
1228    },
1229    /// Concurrent updates prevented every attempt from publishing. Nothing
1230    /// was flushed, and a later step can try again.
1231    RetriesExhausted {
1232        /// Head sequence observed before the step ran.
1233        observed_head_seq: ChangeSeq,
1234    },
1235}
1236
1237/// What the metadata-reorganization part of a maintenance step did.
1238///
1239/// Deliberately coarse: the run counts and byte budgets a reorganization
1240/// consumes are engine policy, not a wire contract.
1241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1242#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1243#[serde(tag = "outcome", rename_all = "snake_case")]
1244pub enum ReorganizeStepOutcome {
1245    /// No family group had enough delta runs to merge.
1246    NotNeeded,
1247    /// One family group was merged and a manifest published.
1248    UnitPublished,
1249    /// A group has outgrown one step, and this step started the background
1250    /// streaming compaction that rebuilds it. The step published nothing;
1251    /// the job publishes once, when it finishes.
1252    CompactionStarted,
1253    /// A job for this namespace is already running, so this step started
1254    /// none. One runs at a time per namespace; a later step plans this group
1255    /// again.
1256    CompactionRunning,
1257    /// This step's job holds the namespace's slot and is waiting for a
1258    /// process compaction permit. It starts when one frees; nothing is
1259    /// needed to make it.
1260    CompactionAtCapacity,
1261    /// A group needs a streaming compaction and this handle schedules no
1262    /// background work, so nothing will run one until an operator does. The
1263    /// self-hosting guide names the call.
1264    CompactionRequired,
1265    /// Another publisher updated the metadata root first. This step's
1266    /// manifest is unreferenced, and a later step can retry the merge.
1267    RootAdvanced,
1268}
1269
1270/// Result of one explicit maintenance step.
1271///
1272/// One report per action the request selected, and none for an action it
1273/// did not: an absent field means "not selected", never "ran and found
1274/// nothing to do". The latter is what the outcomes inside a report say.
1275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1276#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1277pub struct MaintenanceStepResponse {
1278    /// Namespace the step ran against.
1279    pub namespace_id: NamespaceId,
1280    /// Namespace diagnostics observed before the step acted.
1281    pub status_before: NamespaceDiagnostics,
1282    /// What the metadata-upkeep action did.
1283    #[serde(default, skip_serializing_if = "Option::is_none")]
1284    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1285    pub metadata_maintenance: Option<MetadataMaintenanceResponse>,
1286    /// Where the retention floor ended up.
1287    #[serde(default, skip_serializing_if = "Option::is_none")]
1288    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1289    pub retention: Option<AdvanceRetentionResponse>,
1290    /// What the collection pass reclaimed.
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    #[cfg_attr(feature = "openapi", schema(nullable = false))]
1293    pub gc: Option<GcResponse>,
1294}
1295
1296/// What one metadata-upkeep action did, part by part.
1297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1298#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1299pub struct MetadataMaintenanceResponse {
1300    /// What the WAL flush did.
1301    pub wal_flush: WalFlushStepOutcome,
1302    /// What the reorganization unit did.
1303    pub reorganize: ReorganizeStepOutcome,
1304}
1305
1306/// Options for one store contract probe. Empty today; a body is still sent
1307/// so later options do not change the shape of the request. An option this
1308/// build does not know is rejected rather than ignored, so a caller never
1309/// believes it selected something.
1310#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1311#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1312#[serde(deny_unknown_fields)]
1313pub struct StoreProbeRequest {}
1314
1315/// What one store contract probe observed, check by check.
1316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1318pub struct StoreProbeResponse {
1319    /// Label the server minted for this run. It scopes the objects the run
1320    /// wrote, so it identifies the run in provider logs too.
1321    pub run_id: String,
1322    /// Every check the run performed, in the order it performed them. A
1323    /// failed check lives here rather than in an error: the probe answered
1324    /// the question, and the answer is that the store is wrong.
1325    pub checks: Vec<StoreProbeCheckResult>,
1326}
1327
1328/// One named contract check and what the store did with it.
1329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1330#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1331pub struct StoreProbeCheckResult {
1332    /// Stable check name.
1333    pub name: String,
1334    /// What the store did.
1335    pub outcome: StoreProbeCheckOutcome,
1336    /// What was expected and what happened instead. Present only on
1337    /// `failed`.
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub message: Option<String>,
1340}
1341
1342/// What one contract check concluded about the store.
1343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1344#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1345#[serde(rename_all = "snake_case")]
1346pub enum StoreProbeCheckOutcome {
1347    /// The store behaved as the contract requires.
1348    Passed,
1349    /// The store declares it cannot do this at all. Only the optional
1350    /// capabilities answer this way, and it is an answer rather than a
1351    /// fault.
1352    Unsupported,
1353    /// The store did something the contract forbids, or the operation
1354    /// failed outright.
1355    Failed,
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360    use super::*;
1361    use crate::ContentId;
1362
1363    #[test]
1364    fn file_revision_provenance_fields_are_pinned_on_the_wire() {
1365        let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
1366        let revision = FileRevision {
1367            inode_id: InodeId(2),
1368            revision_no: RevisionNo(3),
1369            committed_seq: ChangeSeq(7),
1370            commit_id: CommitId::parse("c_revision_owner").expect("commit id"),
1371            committed_at_ms: 1_752_624_000_000,
1372            committed_by: crate::ActorRef::loonfs_system(),
1373            content_ref: content_ref.clone(),
1374        };
1375
1376        assert_eq!(
1377            serde_json::to_value(revision).expect("serialize file revision"),
1378            serde_json::json!({
1379                "inode_id": "ino_2",
1380                "revision_no": 3,
1381                "committed_seq": 7,
1382                "commit_id": "c_revision_owner",
1383                "committed_at_ms": 1_752_624_000_000_u64,
1384                "committed_by": { "kind": "system", "id": "loonfs" },
1385                "content_ref": content_ref,
1386            })
1387        );
1388    }
1389    fn path(value: &str) -> AbsolutePath {
1390        AbsolutePath::parse(value).expect("valid test path")
1391    }
1392
1393    fn attribute_key(value: &str) -> AttributeKey {
1394        AttributeKey::parse(value).expect("valid test attribute key")
1395    }
1396
1397    fn sample_content_ref() -> ContentRef {
1398        ContentRef::blob_v1(
1399            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id"),
1400            b"hello",
1401        )
1402    }
1403
1404    #[test]
1405    fn namespace_wire_shape_has_only_core_state() {
1406        let namespace = Namespace {
1407            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1408            head_seq: ChangeSeq(11),
1409            retention_floor_seq: ChangeSeq(4),
1410        };
1411        assert_eq!(
1412            serde_json::to_value(namespace).expect("serialize namespace"),
1413            serde_json::json!({
1414                "namespace_id": "demo",
1415                "head_seq": 11,
1416                "retention_floor_seq": 4
1417            })
1418        );
1419    }
1420
1421    #[test]
1422    fn namespace_diagnostics_wire_shape_keeps_storage_fields() {
1423        let diagnostics = NamespaceDiagnostics {
1424            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
1425            head_seq: ChangeSeq(11),
1426            retention_floor_seq: ChangeSeq(4),
1427            current_manifest_no: Some(ManifestNo(8)),
1428            wal_tail_segments: 3,
1429            live_snapshots: 2,
1430            live_checkpoints: 5,
1431        };
1432        assert_eq!(
1433            serde_json::to_value(diagnostics).expect("serialize namespace diagnostics"),
1434            serde_json::json!({
1435                "namespace_id": "demo",
1436                "head_seq": 11,
1437                "retention_floor_seq": 4,
1438                "current_manifest_no": 8,
1439                "wal_tail_segments": 3,
1440                "live_snapshots": 2,
1441                "live_checkpoints": 5
1442            })
1443        );
1444    }
1445
1446    #[test]
1447    fn behavior_enums_use_snake_case_wire_values() {
1448        assert_eq!(
1449            DestinationBehavior::default(),
1450            DestinationBehavior::NoReplace
1451        );
1452        assert_eq!(
1453            DeleteDirectoryBehavior::default(),
1454            DeleteDirectoryBehavior::NonRecursive
1455        );
1456        assert_eq!(
1457            serde_json::to_value(DestinationBehavior::NoReplace)
1458                .expect("destination behavior json"),
1459            serde_json::json!("no_replace")
1460        );
1461        assert_eq!(
1462            serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
1463            serde_json::json!("replace")
1464        );
1465        assert_eq!(
1466            serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
1467                .expect("delete behavior json"),
1468            serde_json::json!("non_recursive")
1469        );
1470        assert_eq!(
1471            serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1472            serde_json::json!("recursive")
1473        );
1474    }
1475
1476    #[test]
1477    fn filesystem_delete_and_move_operations_use_behavior_field() {
1478        let create_directory = FilesystemOperation::CreateDirectory {
1479            path: path("/docs"),
1480            parents: false,
1481        };
1482        assert_eq!(
1483            serde_json::to_value(&create_directory).expect("create directory op json"),
1484            serde_json::json!({
1485                "kind": "create_directory",
1486                "path": "/docs"
1487            })
1488        );
1489
1490        let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1491            path: path("/docs/notes"),
1492            parents: true,
1493        };
1494        assert_eq!(
1495            serde_json::to_value(&create_directory_with_parents)
1496                .expect("create directory with parents op json"),
1497            serde_json::json!({
1498                "kind": "create_directory",
1499                "path": "/docs/notes",
1500                "parents": true
1501            })
1502        );
1503
1504        let delete = FilesystemOperation::DeletePath {
1505            path: path("/docs"),
1506            behavior: DeleteDirectoryBehavior::Recursive,
1507            expected_inode_id: None,
1508        };
1509        assert_eq!(
1510            serde_json::to_value(&delete).expect("delete op json"),
1511            serde_json::json!({
1512                "kind": "delete_path",
1513                "path": "/docs",
1514                "behavior": "recursive"
1515            })
1516        );
1517
1518        let move_path = FilesystemOperation::MovePath {
1519            from_path: path("/docs/a.txt"),
1520            to_path: path("/docs/b.txt"),
1521            behavior: DestinationBehavior::Replace,
1522        };
1523        assert_eq!(
1524            serde_json::to_value(&move_path).expect("move op json"),
1525            serde_json::json!({
1526                "kind": "move_path",
1527                "from_path": "/docs/a.txt",
1528                "to_path": "/docs/b.txt",
1529                "behavior": "replace"
1530            })
1531        );
1532
1533        let copy_path = FilesystemOperation::CopyPath {
1534            from_path: path("/docs/a.txt"),
1535            to_path: path("/docs/b.txt"),
1536            behavior: DestinationBehavior::Replace,
1537        };
1538        assert_eq!(
1539            serde_json::to_value(&copy_path).expect("copy op json"),
1540            serde_json::json!({
1541                "kind": "copy_path",
1542                "from_path": "/docs/a.txt",
1543                "to_path": "/docs/b.txt",
1544                "behavior": "replace"
1545            })
1546        );
1547
1548        let update_attributes = FilesystemOperation::UpdateAttributes {
1549            path: path("/docs/a.txt"),
1550            set: BTreeMap::from([(
1551                attribute_key("owner"),
1552                AttributeValue::parse("ada").expect("valid attribute value"),
1553            )]),
1554            remove: vec![attribute_key("draft")],
1555            expected_inode_id: Some(InodeId(7)),
1556            expected_attributes_revision_no: Some(AttributeRevisionNo(3)),
1557        };
1558        assert_eq!(
1559            serde_json::to_value(&update_attributes).expect("update attributes op json"),
1560            serde_json::json!({
1561                "kind": "update_attributes",
1562                "path": "/docs/a.txt",
1563                "set": {"owner": "ada"},
1564                "remove": ["draft"],
1565                "expected_inode_id": "ino_7",
1566                "expected_attributes_revision_no": 3
1567            })
1568        );
1569    }
1570
1571    #[test]
1572    fn update_attributes_omits_empty_collections_and_absent_guards() {
1573        let set_only = FilesystemOperation::UpdateAttributes {
1574            path: path("/docs/a.txt"),
1575            set: BTreeMap::from([(
1576                attribute_key("owner"),
1577                AttributeValue::parse("ada,grace").expect("valid attribute value"),
1578            )]),
1579            remove: Vec::new(),
1580            expected_inode_id: None,
1581            expected_attributes_revision_no: None,
1582        };
1583        assert_eq!(
1584            serde_json::to_value(&set_only).expect("set-only op json"),
1585            serde_json::json!({
1586                "kind": "update_attributes",
1587                "path": "/docs/a.txt",
1588                "set": {"owner": "ada,grace"}
1589            })
1590        );
1591
1592        let decoded: FilesystemOperation = serde_json::from_value(serde_json::json!({
1593            "kind": "update_attributes",
1594            "path": "/docs/a.txt",
1595            "remove": ["draft"]
1596        }))
1597        .expect("remove-only op defaults the set map and both guards");
1598        assert_eq!(
1599            decoded,
1600            FilesystemOperation::UpdateAttributes {
1601                path: path("/docs/a.txt"),
1602                set: BTreeMap::new(),
1603                remove: vec![attribute_key("draft")],
1604                expected_inode_id: None,
1605                expected_attributes_revision_no: None,
1606            }
1607        );
1608    }
1609
1610    #[test]
1611    fn update_attributes_validates_keys_and_values_during_deserialization() {
1612        // The key grammar and the value shape are enforced on the way in, so
1613        // a malformed update never reaches planning.
1614        for encoded in [
1615            serde_json::json!({
1616                "kind": "update_attributes",
1617                "path": "/docs/a.txt",
1618                "set": {"": "ada"}
1619            }),
1620            serde_json::json!({
1621                "kind": "update_attributes",
1622                "path": "/docs/a.txt",
1623                "set": {"owner": {"kind": "string", "value": "ada"}}
1624            }),
1625            serde_json::json!({
1626                "kind": "update_attributes",
1627                "path": "/docs/a.txt",
1628                "remove": ["a\u{0}b"]
1629            }),
1630        ] {
1631            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1632        }
1633    }
1634
1635    #[test]
1636    fn filesystem_operations_default_omitted_behavior_fields() {
1637        let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1638            "kind": "put_file",
1639            "path": "/docs/a.txt",
1640            "content_ref": {
1641                "kind": "blob_v1",
1642                "content_id": "con_0123456789abcdef0123456789abcdef",
1643                "size_bytes": 1,
1644                "checksum": {
1645                    "algorithm": "sha256",
1646                    "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1647                }
1648            }
1649        }))
1650        .expect("put op defaults behavior");
1651        assert!(matches!(
1652            put,
1653            FilesystemOperation::PutFile {
1654                behavior: DestinationBehavior::NoReplace,
1655                expected_revision_no: None,
1656                ..
1657            }
1658        ));
1659
1660        let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1661            "kind": "delete_path",
1662            "path": "/docs"
1663        }))
1664        .expect("delete op defaults behavior");
1665        assert_eq!(
1666            delete,
1667            FilesystemOperation::DeletePath {
1668                path: path("/docs"),
1669                behavior: DeleteDirectoryBehavior::NonRecursive,
1670                expected_inode_id: None,
1671            }
1672        );
1673
1674        let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1675            "kind": "move_path",
1676            "from_path": "/docs/a.txt",
1677            "to_path": "/docs/b.txt"
1678        }))
1679        .expect("move op defaults behavior");
1680        assert_eq!(
1681            move_path,
1682            FilesystemOperation::MovePath {
1683                from_path: path("/docs/a.txt"),
1684                to_path: path("/docs/b.txt"),
1685                behavior: DestinationBehavior::NoReplace,
1686            }
1687        );
1688
1689        let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1690            "kind": "copy_path",
1691            "from_path": "/docs/a.txt",
1692            "to_path": "/docs/b.txt"
1693        }))
1694        .expect("copy op defaults behavior");
1695        assert_eq!(
1696            copy_path,
1697            FilesystemOperation::CopyPath {
1698                from_path: path("/docs/a.txt"),
1699                to_path: path("/docs/b.txt"),
1700                behavior: DestinationBehavior::NoReplace,
1701            }
1702        );
1703    }
1704
1705    #[test]
1706    fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1707        let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
1708        let cases = [
1709            (
1710                FilesystemOperation::PutFile {
1711                    path: path("/docs/a.txt"),
1712                    content_ref: content_ref.clone(),
1713                    behavior: DestinationBehavior::NoReplace,
1714                    expected_revision_no: None,
1715                },
1716                serde_json::json!({
1717                    "kind": "put_file",
1718                    "path": "/docs/a.txt",
1719                    "content_ref": content_ref,
1720                    "behavior": "no_replace"
1721                }),
1722            ),
1723            (
1724                FilesystemOperation::Undelete {
1725                    inode_id: InodeId(7),
1726                    deletion_seq: ChangeSeq(8),
1727                    path: Some(path("/docs/restored")),
1728                },
1729                serde_json::json!({
1730                    "kind": "undelete",
1731                    "inode_id": "ino_7",
1732                    "deletion_seq": 8,
1733                    "path": "/docs/restored"
1734                }),
1735            ),
1736            (
1737                FilesystemOperation::RestoreRevision {
1738                    path: path("/docs/a.txt"),
1739                    source_revision_no: RevisionNo(2),
1740                },
1741                serde_json::json!({
1742                    "kind": "restore_revision",
1743                    "path": "/docs/a.txt",
1744                    "source_revision_no": 2
1745                }),
1746            ),
1747            (
1748                FilesystemOperation::UpdateAttributes {
1749                    path: path("/docs/a.txt"),
1750                    set: BTreeMap::new(),
1751                    remove: vec![attribute_key("draft")],
1752                    expected_inode_id: None,
1753                    expected_attributes_revision_no: None,
1754                },
1755                serde_json::json!({
1756                    "kind": "update_attributes",
1757                    "path": "/docs/a.txt",
1758                    "remove": ["draft"]
1759                }),
1760            ),
1761        ];
1762
1763        for (operation, string_shaped_json) in cases {
1764            assert_eq!(
1765                serde_json::to_value(operation).expect("serialize filesystem operation"),
1766                string_shaped_json
1767            );
1768        }
1769    }
1770
1771    #[test]
1772    fn filesystem_operation_paths_validate_during_deserialization() {
1773        for encoded in [
1774            serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
1775            serde_json::json!({
1776                "kind": "put_file",
1777                "path": "relative",
1778                "content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
1779            }),
1780            serde_json::json!({"kind": "delete_path", "path": "relative"}),
1781            serde_json::json!({
1782                "kind": "move_path",
1783                "from_path": "relative",
1784                "to_path": "/target"
1785            }),
1786            serde_json::json!({
1787                "kind": "copy_path",
1788                "from_path": "/source",
1789                "to_path": "relative"
1790            }),
1791            serde_json::json!({
1792                "kind": "undelete",
1793                "inode_id": "ino_7",
1794                "deletion_seq": 8,
1795                "path": "relative"
1796            }),
1797            serde_json::json!({
1798                "kind": "restore_revision",
1799                "path": "relative",
1800                "source_revision_no": 2
1801            }),
1802            serde_json::json!({
1803                "kind": "update_attributes",
1804                "path": "relative",
1805                "remove": ["draft"]
1806            }),
1807        ] {
1808            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1809        }
1810    }
1811
1812    #[test]
1813    fn inode_request_fields_accept_only_the_public_format() {
1814        let operations = [
1815            serde_json::json!({
1816                "kind": "delete_path",
1817                "path": "/docs/a.txt",
1818                "expected_inode_id": "ino_27"
1819            }),
1820            serde_json::json!({
1821                "kind": "undelete",
1822                "inode_id": "ino_27",
1823                "deletion_seq": 8
1824            }),
1825            serde_json::json!({
1826                "kind": "update_attributes",
1827                "path": "/docs/a.txt",
1828                "expected_inode_id": "ino_27"
1829            }),
1830        ];
1831
1832        for operation in operations {
1833            serde_json::from_value::<FilesystemOperation>(operation.clone())
1834                .expect("valid public inode ID");
1835
1836            let inode_key = if operation["kind"] == "undelete" {
1837                "inode_id"
1838            } else {
1839                "expected_inode_id"
1840            };
1841            for invalid in [serde_json::json!(27), serde_json::json!("27")] {
1842                let mut invalid_operation = operation.clone();
1843                invalid_operation[inode_key] = invalid;
1844                assert!(
1845                    serde_json::from_value::<FilesystemOperation>(invalid_operation).is_err(),
1846                    "{inode_key} accepted an invalid inode ID"
1847                );
1848            }
1849        }
1850    }
1851
1852    #[test]
1853    fn a_misspelled_guard_does_not_decode() {
1854        let put = |guard: &str| {
1855            let mut operation = serde_json::json!({
1856                "kind": "put_file",
1857                "path": "/docs/a.txt",
1858                "content_ref": sample_content_ref(),
1859                "behavior": "replace"
1860            });
1861            operation[guard] = serde_json::json!(3);
1862            serde_json::json!({
1863                "commit_id": "guarded-put",
1864                "actor": crate::ActorRef::loonfs_system(),
1865                "operations": [operation]
1866            })
1867        };
1868
1869        let spelled: CommitRequest = serde_json::from_value(put("expected_revision_no"))
1870            .expect("the guard spelled correctly decodes");
1871        assert!(matches!(
1872            spelled.operations.as_slice(),
1873            [FilesystemOperation::PutFile {
1874                expected_revision_no: Some(RevisionNo(3)),
1875                ..
1876            }]
1877        ));
1878
1879        for misspelling in ["expected_revsion_no", "expectedRevisionNo"] {
1880            assert!(
1881                serde_json::from_value::<CommitRequest>(put(misspelling)).is_err(),
1882                "`{misspelling}` decoded instead of failing the request"
1883            );
1884        }
1885    }
1886
1887    #[test]
1888    fn expected_revision_no_must_fit_the_public_integer_range() {
1889        let body = |expected_revision_no: u64| {
1890            serde_json::json!({
1891                "commit_id": "bounded-revision-guard",
1892                "actor": crate::ActorRef::loonfs_system(),
1893                "operations": [{
1894                    "kind": "put_file",
1895                    "path": "/docs/a.txt",
1896                    "content_ref": sample_content_ref(),
1897                    "behavior": "replace",
1898                    "expected_revision_no": expected_revision_no
1899                }]
1900            })
1901        };
1902
1903        let request: CommitRequest = serde_json::from_value(body(crate::MAX_PUBLIC_INTEGER))
1904            .expect("deserialize the maximum revision number");
1905        assert!(matches!(
1906            request.operations.as_slice(),
1907            [FilesystemOperation::PutFile {
1908                expected_revision_no: Some(RevisionNo(value)),
1909                ..
1910            }] if *value == crate::MAX_PUBLIC_INTEGER
1911        ));
1912
1913        let error = serde_json::from_value::<CommitRequest>(body(crate::MAX_PUBLIC_INTEGER + 1))
1914            .expect_err("reject a revision number above the public limit");
1915        assert!(
1916            error
1917                .to_string()
1918                .contains("must be an integer from 0 through 9007199254740991"),
1919            "unexpected range error: {error}"
1920        );
1921    }
1922
1923    #[test]
1924    fn a_commit_request_rejects_unknown_fields_at_every_level() {
1925        let valid = || {
1926            serde_json::json!({
1927                "commit_id": "strict-commit",
1928                "actor": crate::ActorRef::loonfs_system(),
1929                "content_tokens": [{
1930                    "content_ref": sample_content_ref(),
1931                    "token": "opaque-proof"
1932                }],
1933                "operations": [{
1934                    "kind": "update_attributes",
1935                    "path": "/docs/a.txt",
1936                    "set": {"owner": "ada"},
1937                    "expected_inode_id": "ino_7"
1938                }]
1939            })
1940        };
1941        serde_json::from_value::<CommitRequest>(valid())
1942            .expect("the same body without a typo decodes");
1943
1944        let mut at_root = valid();
1945        at_root["mesage"] = serde_json::json!("a note");
1946
1947        let mut in_operation = valid();
1948        in_operation["operations"][0]["expectedAttributesRevisionNo"] = serde_json::json!(3);
1949
1950        let mut in_content_token = valid();
1951        in_content_token["content_tokens"][0]["expires_at_ms"] = serde_json::json!(1);
1952
1953        let mut in_content_ref = valid();
1954        in_content_ref["content_tokens"][0]["content_ref"]["sizeBytes"] = serde_json::json!(5);
1955
1956        for (level, body) in [
1957            ("the request root", at_root),
1958            ("an operation variant", in_operation),
1959            ("a nested content token", in_content_token),
1960            ("a content ref below that", in_content_ref),
1961        ] {
1962            assert!(
1963                serde_json::from_value::<CommitRequest>(body).is_err(),
1964                "an unknown field in {level} decoded instead of failing the request"
1965            );
1966        }
1967    }
1968
1969    #[test]
1970    fn checkpoint_responses_use_one_checkpoint_wire_object() {
1971        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
1972        let checkpoint = Checkpoint {
1973            namespace_id: namespace_id.clone(),
1974            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
1975                .expect("checkpoint id"),
1976            owner: CheckpointOwnerSummary::User {
1977                name: "release".to_owned(),
1978            },
1979            created_at_ms: 1_752_623_000_000,
1980            expires_at_ms: Some(1_752_626_600_000),
1981            checkpoint_seq: ChangeSeq(12),
1982            manifest_no: ManifestNo(9),
1983        };
1984        let checkpoint_json = serde_json::json!({
1985            "namespace_id": "demo",
1986            "checkpoint_id": "chk_00000000000000000000000000000001",
1987            "owner": {"kind": "user", "name": "release"},
1988            "created_at_ms": 1_752_623_000_000_u64,
1989            "expires_at_ms": 1_752_626_600_000_u64,
1990            "checkpoint_seq": 12,
1991            "manifest_no": 9,
1992        });
1993        assert_eq!(
1994            serde_json::to_value(checkpoint.clone()).expect("serialize checkpoint"),
1995            checkpoint_json,
1996        );
1997        assert_eq!(
1998            serde_json::to_value(ListCheckpointsResponse {
1999                namespace_id: namespace_id.clone(),
2000                checkpoints: vec![checkpoint.clone()],
2001                next_cursor: None,
2002            })
2003            .expect("serialize list checkpoints response"),
2004            serde_json::json!({
2005                "namespace_id": "demo",
2006                "checkpoints": [checkpoint_json],
2007            }),
2008        );
2009        assert_eq!(
2010            serde_json::to_value(ReleaseCheckpointResponse {
2011                namespace_id,
2012                checkpoint_id: checkpoint.checkpoint_id,
2013            })
2014            .expect("serialize release checkpoint response"),
2015            serde_json::json!({
2016                "namespace_id": "demo",
2017                "checkpoint_id": "chk_00000000000000000000000000000001",
2018            }),
2019        );
2020    }
2021
2022    #[test]
2023    fn optional_response_fields_are_omitted_and_default_when_absent() {
2024        let checkpoint_json = serde_json::to_value(Checkpoint {
2025            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
2026            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000001")
2027                .expect("checkpoint id"),
2028            owner: CheckpointOwnerSummary::User {
2029                name: "release".to_owned(),
2030            },
2031            created_at_ms: 1_752_623_000_000,
2032            expires_at_ms: None,
2033            checkpoint_seq: ChangeSeq(3),
2034            manifest_no: ManifestNo(3),
2035        })
2036        .expect("serialize checkpoint");
2037        assert!(checkpoint_json.get("expires_at_ms").is_none());
2038        let checkpoint: Checkpoint = serde_json::from_value(checkpoint_json)
2039            .expect("decode checkpoint without optional fields");
2040        assert_eq!(checkpoint.expires_at_ms, None);
2041
2042        let gc = GcResponse::empty(NamespaceId::parse("demo").expect("namespace id"));
2043        let gc_json = serde_json::to_value(gc).expect("serialize gc response");
2044        assert!(gc_json.get("next_reclamation_at_ms").is_none());
2045        let gc: GcResponse =
2046            serde_json::from_value(gc_json).expect("decode gc response without optional fields");
2047        assert_eq!(gc.next_reclamation_at_ms, None);
2048    }
2049
2050    #[test]
2051    fn maintenance_step_outcomes_use_the_outcome_tag() {
2052        assert_eq!(
2053            serde_json::to_value(WalFlushStepOutcome::Flushed {
2054                manifest_head_seq: ChangeSeq(9),
2055            })
2056            .expect("serialize WAL flush outcome"),
2057            serde_json::json!({"outcome": "flushed", "manifest_head_seq": 9})
2058        );
2059        assert_eq!(
2060            serde_json::to_value(ReorganizeStepOutcome::UnitPublished)
2061                .expect("serialize reorganize outcome"),
2062            serde_json::json!({"outcome": "unit_published"})
2063        );
2064    }
2065
2066    #[test]
2067    fn maintenance_request_bodies_reject_unknown_fields() {
2068        serde_json::from_value::<MaintenanceStepRequest>(serde_json::json!({
2069            "metadata_maintenance": {"max_wal_tail_segments": 4},
2070            "retention": {},
2071            "gc": {"grace_window_ms": 1_800_000, "max_objects": 32}
2072        }))
2073        .expect("the same body without a typo decodes");
2074
2075        for body in [
2076            serde_json::json!({"retenton": {}}),
2077            serde_json::json!({"retention": {"through_seq": 4}}),
2078            serde_json::json!({"metadata_maintenance": {"maxWalTailSegments": 4}}),
2079            serde_json::json!({"gc": {"max_object": 32}}),
2080        ] {
2081            assert!(
2082                serde_json::from_value::<MaintenanceStepRequest>(body.clone()).is_err(),
2083                "an unknown field decoded instead of failing the step: {body}"
2084            );
2085        }
2086
2087        serde_json::from_value::<CreateCheckpointRequest>(
2088            serde_json::json!({"name": "nightly", "ttl_ms": 60_000}),
2089        )
2090        .expect("the same checkpoint body without a typo decodes");
2091        assert!(serde_json::from_value::<CreateCheckpointRequest>(
2092            serde_json::json!({"name": "nightly", "ttlMs": 60_000})
2093        )
2094        .is_err());
2095
2096        // The probe body carries no options yet, so an unknown one is the
2097        // only thing it can be sent.
2098        serde_json::from_value::<StoreProbeRequest>(serde_json::json!({}))
2099            .expect("an empty probe body decodes");
2100        assert!(
2101            serde_json::from_value::<StoreProbeRequest>(serde_json::json!({"deep": true})).is_err()
2102        );
2103
2104        serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2105            "namespace_id": "demo"
2106        }))
2107        .expect("the same create body without a typo decodes");
2108        assert!(
2109            serde_json::from_value::<CreateNamespaceRequest>(serde_json::json!({
2110                "namespace_id": "demo",
2111                "fork_of": "other"
2112            }))
2113            .is_err()
2114        );
2115        assert!(
2116            serde_json::from_value::<ForkNamespaceRequest>(serde_json::json!({
2117                "new_namespace_id": "demo",
2118                "source_namespace_id": "other"
2119            }))
2120            .is_err()
2121        );
2122    }
2123}