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::ValidatedContentToken;
8use crate::{
9    AbsolutePath, ChangeSeq, CheckpointId, CommitId, ContentRef, InodeId, ManifestId, NamespaceId,
10    RevisionNo, WriterEpoch,
11};
12use serde::{Deserialize, Serialize};
13
14/// HTTP error body used by LoonFS APIs.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17pub struct ApiError {
18    /// Stable machine-readable reason from the [`ErrorCode`](crate::ErrorCode)
19    /// registry.
20    ///
21    /// Carried as a string so clients keep working when a newer server
22    /// introduces a code they do not know; use
23    /// [`ErrorCode::parse`](crate::ErrorCode::parse) for typed access.
24    pub code: String,
25    /// For `not_supported` errors, the capability-document feature key the
26    /// client should reconcile against.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub feature: Option<String>,
29    /// Human-readable error message.
30    pub message: String,
31    /// Correlation id the server assigned to the failed request; the same
32    /// value is sent as the `x-request-id` response header.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub request_id: Option<String>,
35    /// Structured context for the code, present when the failure carries
36    /// machine-usable identity (API spec, "Standard error contract"). Boxed
37    /// so the rare detailed error does not widen every error-carrying result.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub details: Option<Box<ErrorDetails>>,
40}
41
42/// Structured, machine-readable context accompanying an [`ApiError`].
43///
44/// Every field is optional: a code populates the fields that apply to it
45/// (API spec, "Standard error contract"), and clients must tolerate absent
46/// fields exactly as they tolerate unknown codes. Retry decisions still key
47/// off the code; these fields carry the identity a caller needs to act —
48/// which commit to resubmit, which epoch displaced it, which revision the
49/// precondition saw.
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52pub struct ErrorDetails {
53    /// Idempotency key of the commit the error concerns.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub commit_id: Option<CommitId>,
56    /// Sequence at which that commit id already landed. Present when the
57    /// failure was decided against a durable commit receipt, which is what
58    /// holds the sequence; absent when nothing has committed under the id
59    /// yet and two live requests are simply claiming it at once.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub committed_seq: Option<ChangeSeq>,
62    /// Semantic identity of the mutation that already landed under that
63    /// commit id, from the same receipt as `committed_seq` and present
64    /// exactly when it is. A retry recomputes this value from the request it
65    /// just made — see
66    /// [`put_retry_fingerprint`](crate::put_retry_fingerprint) — and equality
67    /// is what proves the two are the same request.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub committed_fingerprint: Option<String>,
70    /// Position, in the request's operation list, of the operation that
71    /// failed. A commit applies all of its operations or none of them, so
72    /// this names the one that stopped the whole request.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub operation_index: Option<u32>,
75    /// Epoch the failing writer session held when it was displaced.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub fenced_epoch: Option<WriterEpoch>,
78    /// Epoch that currently owns the namespace.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub active_writer_epoch: Option<WriterEpoch>,
81    /// Writer id recorded by the current epoch's acquirer, when the head
82    /// recorded one.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub active_writer: Option<String>,
85    /// Unix milliseconds at which the current epoch's acquirer took it, when
86    /// the head recorded one. Writer ids are process labels, so two runs on
87    /// one machine can share one; the stamp is what tells them apart.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub active_acquired_at_ms: Option<u64>,
90    /// Inode the failed precondition or operation targeted.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub inode_id: Option<InodeId>,
93    /// Revision the request expected to be current.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub expected_revision: Option<RevisionNo>,
96    /// Revision that is actually current; absent when the inode has none.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub actual_revision: Option<RevisionNo>,
99    /// Change-feed cursor the request asked to resume after.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub after_seq: Option<ChangeSeq>,
102    /// Oldest sequence still promised for incremental replay.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub retention_floor_seq: Option<ChangeSeq>,
105    /// Deletion generation an undelete asked to recover.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub requested_deletion_seq: Option<ChangeSeq>,
108    /// Deletion generation actually active for the inode.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub active_deletion_seq: Option<ChangeSeq>,
111    /// Head sequence a namespace delete required the namespace to still be
112    /// at.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub expected_head_seq: Option<ChangeSeq>,
115    /// Head sequence the namespace was actually at, which is what a caller
116    /// that still means to delete it retries against.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub actual_head_seq: Option<ChangeSeq>,
119}
120
121/// Request to create a namespace.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
124pub struct CreateNamespaceRequest {
125    /// Durable namespace id to create.
126    pub namespace_id: NamespaceId,
127}
128
129/// Request to fork a namespace.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
132pub struct ForkNamespaceRequest {
133    /// Durable namespace id for the fork target.
134    pub new_namespace_id: NamespaceId,
135}
136
137/// Short namespace identifier returned by namespace create/fork operations.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140pub struct NamespaceSummary {
141    /// Durable namespace id.
142    pub namespace_id: NamespaceId,
143}
144
145/// Status summary for one namespace.
146///
147/// This is the point-lookup answer to "does this namespace exist, and where
148/// is its head?" — cheaper than listing all namespaces when only one matters.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
151pub struct NamespaceStatusResponse {
152    /// Namespace being inspected.
153    pub namespace_id: NamespaceId,
154    /// Current visible namespace sequence.
155    pub head_seq: ChangeSeq,
156    /// Current manifest pointer recorded by the head.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub current_manifest_id: Option<ManifestId>,
159    /// Number of visible WAL segments after the current manifest.
160    pub wal_tail_segments: u64,
161    /// Oldest sequence still promised for incremental replay.
162    pub retention_floor_seq: ChangeSeq,
163}
164
165/// Result of deleting a namespace.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168pub struct DeleteNamespaceResponse {
169    /// Namespace whose history ended.
170    pub namespace_id: NamespaceId,
171    /// The head's last committed sequence; the delete linearized
172    /// immediately after it, so this is where history ended.
173    pub head_seq: ChangeSeq,
174}
175
176/// Destination-conflict behavior for path-oriented puts, moves, and copies.
177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179#[serde(rename_all = "snake_case")]
180pub enum DestinationBehavior {
181    /// Fail if the destination path already exists.
182    #[default]
183    NoReplace,
184    /// Replace the current file at the destination; only a file
185    /// destination can be replaced.
186    Replace,
187}
188
189/// Directory delete behavior for path-oriented deletes.
190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "snake_case")]
193pub enum DeleteDirectoryBehavior {
194    /// Fail if the target is a non-empty directory.
195    #[default]
196    NonRecursive,
197    /// Delete a directory subtree.
198    Recursive,
199}
200
201/// One path-oriented filesystem operation.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
204#[serde(tag = "kind", rename_all = "snake_case")]
205pub enum FilesystemOperation {
206    /// Create one directory.
207    #[cfg_attr(feature = "openapi", schema(title = "FsOpCreateDirectory"))]
208    CreateDirectory {
209        /// Absolute destination path, rejected when invalid or already bound.
210        path: AbsolutePath,
211        /// Also create missing ancestor directories (the same auto-create
212        /// `put_file` performs). The final component must still be new.
213        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
214        parents: bool,
215    },
216    /// Create or replace one file with an already-durable content ref.
217    #[cfg_attr(feature = "openapi", schema(title = "FsOpPutFile"))]
218    PutFile {
219        /// Absolute destination path; missing ancestors are created automatically.
220        path: AbsolutePath,
221        /// Immutable bytes that must be covered by a valid preparation proof.
222        content_ref: ContentRef,
223        /// Whether an existing file may receive a new revision instead of causing a conflict.
224        #[serde(default)]
225        behavior: DestinationBehavior,
226        /// When set (with `replace` behavior), the put applies only while
227        /// the file's current revision is still this one; a raced write
228        /// fails the request instead of silently stacking on it, and a
229        /// missing file answers `path_not_found`.
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        expected_revision_no: Option<RevisionNo>,
232    },
233    /// Delete one path.
234    #[cfg_attr(feature = "openapi", schema(title = "FsOpDeletePath"))]
235    DeletePath {
236        /// Absolute path that must resolve to a visible inode.
237        path: AbsolutePath,
238        /// Whether a non-empty directory may be tombstoned recursively.
239        #[serde(default)]
240        behavior: DeleteDirectoryBehavior,
241        /// When set, the delete applies only if the path still resolves to
242        /// this inode; a raced rebinding fails the request instead of
243        /// deleting (and reporting a recovery handle for) the wrong inode.
244        #[serde(default, skip_serializing_if = "Option::is_none")]
245        expected_inode_id: Option<InodeId>,
246    },
247    /// Move one path to another path.
248    #[cfg_attr(feature = "openapi", schema(title = "FsOpMovePath"))]
249    MovePath {
250        /// Absolute source path that must resolve to a visible inode.
251        from_path: AbsolutePath,
252        /// Absolute destination whose parent must be visible and writable.
253        to_path: AbsolutePath,
254        /// Whether an existing destination file may be replaced.
255        #[serde(default)]
256        behavior: DestinationBehavior,
257    },
258    /// Copy one file path to another path.
259    #[cfg_attr(feature = "openapi", schema(title = "FsOpCopyPath"))]
260    CopyPath {
261        /// Absolute source path that must resolve to a visible file.
262        from_path: AbsolutePath,
263        /// Absolute destination whose parent must be visible and writable.
264        to_path: AbsolutePath,
265        /// Whether an existing destination file may receive a copied revision.
266        #[serde(default)]
267        behavior: DestinationBehavior,
268    },
269    /// Recover a deleted file or subtree: revoke the deletion of
270    /// `inode_id` recorded at `deleted_at_seq` (both reported by the
271    /// delete and by the change feed) and re-bind it. Answers
272    /// `not_deleted` when that generation is not the live one, so a stale
273    /// request never cancels a later delete.
274    #[cfg_attr(feature = "openapi", schema(title = "FsOpUndelete"))]
275    Undelete {
276        /// Deleted inode to make reachable again.
277        inode_id: InodeId,
278        /// Observed deletion sequence, which prevents cancelling a newer tombstone generation.
279        deleted_at_seq: ChangeSeq,
280        /// Absolute destination path whose parent must be visible and whose
281        /// name must be absent. Absent means restore in place: re-bind
282        /// under the parent and name the deletion recorded, anchored on
283        /// the parent's identity rather than any remembered spelling, so
284        /// the entry lands correctly even when ancestors were renamed
285        /// since. A deletion that recorded no binding needs the explicit
286        /// path.
287        #[serde(default, skip_serializing_if = "Option::is_none")]
288        path: Option<AbsolutePath>,
289    },
290    /// Restore an older revision as the current revision for a path.
291    #[cfg_attr(feature = "openapi", schema(title = "FsOpRestoreRevision"))]
292    RestoreRevision {
293        /// Absolute path that must resolve to a visible file.
294        path: AbsolutePath,
295        /// Existing historical revision whose content will be copied into a new current revision.
296        source_revision_no: RevisionNo,
297    },
298}
299
300/// One commit: an idempotency key, an optional annotation, and an ordered
301/// list of path operations that commit together (API spec, section 5.1).
302///
303/// A one-operation request is the one-element case of this shape, not a
304/// different request: a convenience call and a batch produce the same commit
305/// and the same fingerprint.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
308pub struct CommitRequest {
309    /// Caller-supplied idempotency key for the whole request.
310    pub commit_id: CommitId,
311    /// Caller annotation recorded on the commit and reported by the change
312    /// feed. Part of the commit's identity: reusing `commit_id` with a
313    /// different message is a `commit_id_reuse_conflict`, exactly as it is
314    /// for an explicit commit.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub message: Option<String>,
317    /// Proofs for any new external content refs introduced by this request.
318    /// One proof covers every operation that names its content ref.
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub content_tokens: Vec<ValidatedContentToken>,
321    /// Ordered operations to apply. Must be non-empty; they commit all
322    /// together or not at all.
323    pub operations: Vec<FilesystemOperation>,
324}
325
326impl CommitRequest {
327    /// A request carrying exactly one operation.
328    pub fn single(
329        commit_id: CommitId,
330        message: Option<String>,
331        operation: FilesystemOperation,
332    ) -> Self {
333        Self {
334            commit_id,
335            message,
336            content_tokens: Vec::new(),
337            operations: vec![operation],
338        }
339    }
340}
341
342/// One immutable file revision.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
345pub struct FileRevision {
346    /// File inode that owns this revision.
347    pub inode_id: InodeId,
348    /// Revision number within the file inode.
349    pub revision_no: RevisionNo,
350    /// Namespace sequence that created this revision.
351    pub committed_seq: ChangeSeq,
352    /// Wall-clock stamp of the commit that created this revision, in Unix
353    /// milliseconds. Observational: `committed_seq` is the order.
354    pub committed_at_ms: u64,
355    /// Content stored for this revision.
356    pub content_ref: ContentRef,
357}
358
359/// Response for listing file revisions.
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
362pub struct ListFileRevisionsResponse {
363    /// Namespace that was read.
364    pub namespace_id: NamespaceId,
365    /// File inode whose revisions were returned.
366    pub inode_id: InodeId,
367    /// Namespace head sequence used for the read.
368    pub head_seq: ChangeSeq,
369    /// Retained revisions in order.
370    pub revisions: Vec<FileRevision>,
371    /// Opaque cursor for the next page, if more revisions are available.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub next_cursor: Option<String>,
374}
375
376/// Request to create a durable checkpoint pin.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
379pub struct CreateCheckpointRequest {
380    /// Label recorded on the checkpoint record. A label, not a key: several
381    /// records may carry the same name over different bases.
382    pub name: String,
383    /// Optional lifetime; the server computes the record's expiry from its
384    /// own clock. Absent means the pin holds until explicitly released.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub ttl_ms: Option<u64>,
387}
388
389/// Result of creating a checkpoint.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
392pub struct CreateCheckpointResponse {
393    /// Namespace that was checkpointed.
394    pub namespace_id: NamespaceId,
395    /// Durable checkpoint id.
396    pub checkpoint_id: CheckpointId,
397    /// Sequence covered by the checkpoint.
398    pub checkpoint_seq: ChangeSeq,
399    /// Manifest pinned by the checkpoint.
400    pub manifest_id: ManifestId,
401    /// Manifest `metadata/root.json` references after the operation.
402    pub current_manifest_id: Option<ManifestId>,
403    /// Expiry recorded on the record, when the request carried a `ttl_ms`.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub expires_at_ms: Option<u64>,
406}
407
408/// Result of releasing a checkpoint pin.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
411pub struct ReleaseCheckpointResponse {
412    /// Namespace the checkpoint belonged to.
413    pub namespace_id: NamespaceId,
414    /// Checkpoint the release targeted.
415    pub checkpoint_id: CheckpointId,
416    /// True when this call flipped an active record to released; false when
417    /// the record was already released or no longer exists. Release is
418    /// idempotent — the end state is the same either way.
419    pub was_active: bool,
420}
421
422/// Who a checkpoint record answers to, as the record durably records it.
423///
424/// The two owners have different releases, so a listing that names the
425/// owner also says which records the release endpoint will act on: a user
426/// pin is released by id, and a fork lease is released by deleting the
427/// target namespace it protects.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
430#[serde(tag = "kind", rename_all = "snake_case")]
431pub enum CheckpointOwnerSummary {
432    /// An operator-created pin, released by id or by its own expiry.
433    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerUser"))]
434    User {
435        /// The label the creator recorded. Not a key: several records may
436        /// carry one label over different bases.
437        name: String,
438    },
439    /// A fork target keeping its source basis alive for the length of one
440    /// fork attempt.
441    #[cfg_attr(feature = "openapi", schema(title = "CheckpointOwnerFork"))]
442    Fork {
443        /// Namespace whose continued existence keeps this pin standing.
444        target_namespace_id: NamespaceId,
445    },
446}
447
448/// One active checkpoint record, reported from what the record carries.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
451pub struct CheckpointSummary {
452    /// Durable checkpoint id, as the creation response returned it. This is
453    /// what the release endpoint takes.
454    pub checkpoint_id: CheckpointId,
455    /// Who the record answers to, and the label a user pin carries.
456    pub owner: CheckpointOwnerSummary,
457    /// When the record was written, in Unix milliseconds.
458    pub created_at_ms: u64,
459    /// When garbage collection may release the record without being asked,
460    /// in Unix milliseconds. Absent means the pin holds until it is
461    /// released. An instant already in the past is a record whose expiry
462    /// has passed and which no collection pass has reached yet: it is still
463    /// a root, so it is still listed.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub expires_at_ms: Option<u64>,
466    /// Sequence the pinned basis covers — the same number the creation
467    /// response reported as `checkpoint_seq`.
468    pub checkpoint_seq: ChangeSeq,
469    /// Manifest the record pins.
470    pub manifest_id: ManifestId,
471}
472
473/// Every active checkpoint record a namespace currently carries.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
476pub struct ListCheckpointsResponse {
477    /// Namespace the records belong to.
478    pub namespace_id: NamespaceId,
479    /// Active records, oldest first. Released records are absent because a
480    /// release is what stops a record pinning anything; a released record
481    /// that garbage collection has not yet deleted is not reported either.
482    pub checkpoints: Vec<CheckpointSummary>,
483}
484
485/// How one WAL flush satisfied its goal.
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
487#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
488#[serde(rename_all = "snake_case")]
489pub enum FlushWalOutcome {
490    /// The root already covered the head; nothing was published.
491    AlreadyCurrent,
492    /// This call published a new manifest and advanced the root to it.
493    Published,
494    /// This call published a manifest, but a newer root already covered
495    /// the attempted sequence.
496    Superseded,
497}
498
499/// Result of one WAL flush: how the metadata root covers the head.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
502pub struct FlushWalResponse {
503    /// Namespace whose WAL tail was flushed.
504    pub namespace_id: NamespaceId,
505    /// Head sequence the flush attempted to cover.
506    pub target_head_seq: ChangeSeq,
507    /// Manifest `metadata/root.json` references after the operation.
508    pub manifest_id: ManifestId,
509    /// Sequence covered by that manifest.
510    pub manifest_head_seq: ChangeSeq,
511    /// How the root came to cover the head.
512    pub outcome: FlushWalOutcome,
513}
514
515/// Optional overrides for one garbage-collection pass. Absent fields use
516/// the server's conservative defaults.
517#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
518#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
519pub struct GcRequest {
520    /// Objects younger than this are never deleted, reachable or not. The
521    /// window has a derived safety floor (publication budgets plus provider
522    /// deadlines); a smaller value is rejected as `invalid_request`.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub grace_window_ms: Option<u64>,
525    /// Maximum objects this invocation may read or decide. Omit to retain
526    /// the run-to-completion behavior.
527    ///
528    /// A completed upload session past its reclamation grace makes the pass
529    /// read every live manifest and retained WAL segment to find out
530    /// whether anything still references its content, and that read is
531    /// charged here like any other. A budget too small to finish it does
532    /// not stall the pass: the session is retained, the response sets
533    /// `content_reclamation_deferred`, and the sweep carries on through
534    /// everything else. What a chronically small budget costs is content
535    /// left unreclaimed, not progress. Give a pass at least as many objects
536    /// as the namespace has live manifests and retained segments for that
537    /// content to come back.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub max_objects: Option<u64>,
540    /// Opaque resume token returned as `next_cursor` by an earlier pass
541    /// against the same namespace.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub cursor: Option<String>,
544}
545
546/// Why a pass kept what it kept: `retained_candidates` split by the
547/// decision that spared each candidate.
548///
549/// The reasons are a closed set — one per place the sweep decides against
550/// deleting — so every field is always reported, and a zero is the answer
551/// that nothing was kept for that reason. The counts sum to
552/// `retained_candidates`.
553///
554/// Retention is a decision per candidate examined, not per object in the
555/// namespace: one object examined by two passes is counted by each.
556#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
557#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
558pub struct RetainedCandidates {
559    /// Selected as unreachable, then found reachable by the re-verification
560    /// that runs immediately before every deletion. A candidate the pass
561    /// already knew was reachable is never examined at all, so this counts
562    /// the namespace moving underneath the pass rather than the size of its
563    /// live set.
564    pub referenced: u64,
565    /// Unreachable, but younger than the grace window by the object's own
566    /// provider timestamp. A later pass deletes it.
567    pub grace_window: u64,
568    /// Unreachable, and the provider reported no last-modified time at all,
569    /// so the object's age is unknown and it is treated as young.
570    pub no_provider_timestamp: u64,
571    /// Root resolution failed somewhere in this pass, so manifest and table
572    /// deletion was suppressed wholesale (`degraded_retention` is set too).
573    pub degraded_roots: u64,
574    /// A key under a swept family that this collector does not recognize as
575    /// one of its own. Never deleted, whatever its age.
576    pub unrecognized_key: u64,
577    /// A checkpoint record this pass could have advanced but could not
578    /// prove ready: a lost compare-and-swap, an unreadable record, a fork
579    /// target not provably gone, a released record still inside its grace
580    /// window, or an active pin that is simply doing its job. The pins
581    /// themselves are listed by
582    /// `GET /v0/admin/namespaces/{ns}/checkpoints`.
583    pub checkpoint_not_releasable: u64,
584    /// An upload session waiting out a window a clock resolves: an open
585    /// session's lease plus the grace, an aborted session's grace, or a
586    /// completed session's derived content-reclamation grace.
587    /// `next_reclamation_at_ms` reports the soonest of these.
588    pub upload_session_window: u64,
589    /// An upload session held over for a reason no clock resolves: a lost
590    /// compare-and-swap, a record that vanished mid-pass, or a reference
591    /// set this pass could not establish. Only a later pass answers it.
592    pub upload_session_undecided: u64,
593    /// A completed session whose content reclamation was skipped because
594    /// the reference scan did not fit in `max_objects`
595    /// (`content_reclamation_deferred` is set too).
596    pub content_scan_deferred: u64,
597}
598
599/// Result of one mark-and-sweep garbage-collection pass.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
602pub struct GcResponse {
603    /// Namespace the pass ran against.
604    pub namespace_id: NamespaceId,
605    /// Unreferenced WAL segments deleted.
606    pub deleted_wal_segments: u64,
607    /// Unreferenced metadata tables deleted.
608    pub deleted_metadata_tables: u64,
609    /// Unreferenced manifests deleted.
610    pub deleted_manifests: u64,
611    /// Released checkpoint records deleted after their grace window.
612    pub deleted_checkpoint_records: u64,
613    /// Fork-owned checkpoint records released because their target namespace
614    /// is provably gone.
615    pub released_fork_checkpoints: u64,
616    /// Checkpoint records released because their expiry passed, or because
617    /// they sit on a terminally deleted namespace.
618    #[serde(default)]
619    pub released_expired_checkpoints: u64,
620    /// Upload-session control objects deleted after the reap window.
621    #[serde(default)]
622    pub deleted_upload_sessions: u64,
623    /// Content objects reclaimed because their upload session completed,
624    /// aged past the derived reclamation grace, and nothing the namespace
625    /// can reach references them. The upload half's cleanup of abandoned
626    /// sessions is not counted here: it deletes unconditionally, whether or
627    /// not the session ever wrote anything.
628    #[serde(default)]
629    pub deleted_content_objects: u64,
630    /// Active checkpoint records released because their basis manifest is
631    /// verifiably gone.
632    #[serde(default)]
633    pub released_missing_basis_checkpoints: u64,
634    /// Candidates retained at delete time (grace window, missing
635    /// timestamps, or reachable from the fresh root set).
636    pub retained_candidates: u64,
637    /// The same total, split by the decision that spared each candidate.
638    /// The total above stays because it is what every existing consumer
639    /// reads; this says why.
640    #[serde(default)]
641    pub retained: RetainedCandidates,
642    /// True when ambiguous roots suppressed manifest/table deletion.
643    pub degraded_retention: bool,
644    /// True when the pass skipped completed-content reclamation because
645    /// the reference collection it needs did not fit in `max_objects`.
646    /// Nothing was ever decided from a partial collection and the rest of
647    /// the sweep ran normally; a later pass with room for the whole scan
648    /// reclaims what this one left behind.
649    #[serde(default)]
650    pub content_reclamation_deferred: bool,
651    /// Opaque resume token when more candidates remain. Resuming rebuilds
652    /// every safety proof; the token carries enumeration position only and
653    /// is valid only against the same namespace.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub next_cursor: Option<String>,
656    /// The soonest instant still ahead of this pass at which something it
657    /// retained becomes reclaimable: an open session's lease plus the grace
658    /// window, an aborted session's grace, or a completed session's derived
659    /// content-reclamation grace. A scheduler reads this to decide when to
660    /// come back, so a namespace needs no other side channel to have its
661    /// reclamation happen.
662    ///
663    /// It reports what this pass saw and nothing more. A pass that stopped
664    /// on `next_cursor` examined only part of the keyspace, and candidates
665    /// that age out under a plain grace window on their object timestamps
666    /// carry no deadline here at all, so `None` is never a claim that
667    /// nothing is owed.
668    ///
669    /// Always serialized, `null` included, unlike `next_cursor` beside it:
670    /// a cursor's presence is what says the enumeration is unfinished,
671    /// while this is an answer every pass has — and one whose absence
672    /// otherwise makes the response's shape depend on what happened to be
673    /// in the namespace.
674    #[serde(default)]
675    pub next_reclamation_at_ms: Option<u64>,
676}
677
678impl GcResponse {
679    /// An empty report for `namespace_id`, before any candidate is examined.
680    pub fn empty(namespace_id: NamespaceId) -> Self {
681        Self {
682            namespace_id,
683            deleted_wal_segments: 0,
684            deleted_metadata_tables: 0,
685            deleted_manifests: 0,
686            deleted_checkpoint_records: 0,
687            released_fork_checkpoints: 0,
688            released_expired_checkpoints: 0,
689            deleted_upload_sessions: 0,
690            deleted_content_objects: 0,
691            released_missing_basis_checkpoints: 0,
692            retained_candidates: 0,
693            retained: RetainedCandidates::default(),
694            degraded_retention: false,
695            content_reclamation_deferred: false,
696            next_cursor: None,
697            next_reclamation_at_ms: None,
698        }
699    }
700
701    /// Records one retained candidate under the reason that spared it.
702    ///
703    /// The total and the breakdown move together here so they cannot drift:
704    /// every sweep site names a reason, and no site can count a retention
705    /// without naming one.
706    pub fn retain(&mut self, reason: RetainedReason) {
707        self.retained_candidates += 1;
708        *reason.counter(&mut self.retained) += 1;
709    }
710}
711
712/// The reason one candidate was retained, as the sweep site knows it. Each
713/// variant is the field of [`RetainedCandidates`] it counts into, where the
714/// reason itself is described.
715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
716#[non_exhaustive]
717pub enum RetainedReason {
718    /// Counts into [`RetainedCandidates::referenced`].
719    Referenced,
720    /// Counts into [`RetainedCandidates::grace_window`].
721    GraceWindow,
722    /// Counts into [`RetainedCandidates::no_provider_timestamp`].
723    NoProviderTimestamp,
724    /// Counts into [`RetainedCandidates::degraded_roots`].
725    DegradedRoots,
726    /// Counts into [`RetainedCandidates::unrecognized_key`].
727    UnrecognizedKey,
728    /// Counts into [`RetainedCandidates::checkpoint_not_releasable`].
729    CheckpointNotReleasable,
730    /// Counts into [`RetainedCandidates::upload_session_window`].
731    UploadSessionWindow,
732    /// Counts into [`RetainedCandidates::upload_session_undecided`].
733    UploadSessionUndecided,
734    /// Counts into [`RetainedCandidates::content_scan_deferred`].
735    ContentScanDeferred,
736}
737
738impl RetainedReason {
739    fn counter(self, retained: &mut RetainedCandidates) -> &mut u64 {
740        match self {
741            Self::Referenced => &mut retained.referenced,
742            Self::GraceWindow => &mut retained.grace_window,
743            Self::NoProviderTimestamp => &mut retained.no_provider_timestamp,
744            Self::DegradedRoots => &mut retained.degraded_roots,
745            Self::UnrecognizedKey => &mut retained.unrecognized_key,
746            Self::CheckpointNotReleasable => &mut retained.checkpoint_not_releasable,
747            Self::UploadSessionWindow => &mut retained.upload_session_window,
748            Self::UploadSessionUndecided => &mut retained.upload_session_undecided,
749            Self::ContentScanDeferred => &mut retained.content_scan_deferred,
750        }
751    }
752}
753
754impl RetainedCandidates {
755    /// Every reason and its count, in a fixed order, for callers that
756    /// report the breakdown rather than read one field of it.
757    pub fn by_reason(&self) -> [(&'static str, u64); 9] {
758        [
759            ("referenced", self.referenced),
760            ("grace_window", self.grace_window),
761            ("no_provider_timestamp", self.no_provider_timestamp),
762            ("degraded_roots", self.degraded_roots),
763            ("unrecognized_key", self.unrecognized_key),
764            ("checkpoint_not_releasable", self.checkpoint_not_releasable),
765            ("upload_session_window", self.upload_session_window),
766            ("upload_session_undecided", self.upload_session_undecided),
767            ("content_scan_deferred", self.content_scan_deferred),
768        ]
769    }
770
771    /// Folds another pass's breakdown into this one.
772    pub fn add(&mut self, other: &Self) {
773        self.referenced += other.referenced;
774        self.grace_window += other.grace_window;
775        self.no_provider_timestamp += other.no_provider_timestamp;
776        self.degraded_roots += other.degraded_roots;
777        self.unrecognized_key += other.unrecognized_key;
778        self.checkpoint_not_releasable += other.checkpoint_not_releasable;
779        self.upload_session_window += other.upload_session_window;
780        self.upload_session_undecided += other.upload_session_undecided;
781        self.content_scan_deferred += other.content_scan_deferred;
782    }
783
784    /// The reason with the highest count, and that count. `None` when
785    /// nothing was retained. Ties go to the first in [`Self::by_reason`]
786    /// order, so one pass's report is stable.
787    pub fn top_reason(&self) -> Option<(&'static str, u64)> {
788        self.by_reason()
789            .into_iter()
790            .filter(|(_, count)| *count > 0)
791            // `max_by_key` keeps the last of equal maxima, so the reversal
792            // is what makes a tie report the earlier reason.
793            .rev()
794            .max_by_key(|(_, count)| *count)
795    }
796}
797
798/// Result of advancing the retention floor.
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
800#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
801pub struct AdvanceRetentionResponse {
802    /// Namespace whose retention floor changed.
803    pub namespace_id: NamespaceId,
804    /// New minimum sequence for incremental replay.
805    pub retention_floor_seq: ChangeSeq,
806}
807
808/// One sub-step of a maintenance step, for callers that want to run exactly
809/// one of them.
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
812#[serde(rename_all = "snake_case")]
813pub enum MaintenanceStepKind {
814    /// Fold the visible WAL tail into metadata tables and advance the root.
815    WalFlush,
816    /// Merge one bounded group of metadata delta runs into its base.
817    Reorganize,
818    /// Advance the retention floor behind a verified checkpoint.
819    Retention,
820    /// Run the mark-and-sweep garbage collector.
821    Gc,
822}
823
824/// Options for one explicit maintenance step. Absent fields use the
825/// server's defaults; retention advance runs only when `retention` is true
826/// and garbage collection only when `gc` is present.
827#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
828#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
829pub struct MaintenanceStepRequest {
830    /// Flush the visible WAL tail into metadata tables when it reaches this
831    /// many segments. Values above the write-rejection threshold are
832    /// rejected as `invalid_request`.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub max_wal_tail_segments: Option<u64>,
835    /// Advance the retention floor to the flushed manifest head. Nothing
836    /// surrenders replay history unless this is true or `only` selects
837    /// `retention`.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub retention: Option<bool>,
840    /// Run the mark-and-sweep garbage collector after the step's
841    /// flush work. Nothing sweeps unless this is present.
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub gc: Option<GcRequest>,
844    /// Restrict the step to one sub-step. Absent runs the whole step: WAL
845    /// flush, then reorganization, then retention if `retention` opted in,
846    /// then garbage collection if `gc` opted in.
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub only: Option<MaintenanceStepKind>,
849}
850
851/// What the WAL-flush part of a maintenance step did.
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
854#[serde(tag = "kind", rename_all = "snake_case")]
855pub enum WalFlushStepOutcome {
856    /// The step did not run this sub-step: the tail was below the threshold,
857    /// or `only` selected something else.
858    NotNeeded,
859    /// The step flushed the WAL tail and advanced the metadata root.
860    Flushed {
861        /// Sequence covered by the published manifest.
862        manifest_head_seq: ChangeSeq,
863    },
864    /// The root already covered the attempted sequence — another publisher
865    /// got there first.
866    Superseded {
867        /// Sequence this step attempted to flush through.
868        attempted_seq: ChangeSeq,
869        /// Manifest the root currently references.
870        current_manifest_id: ManifestId,
871    },
872    /// A concurrent head update won the race.
873    RaceLost {
874        /// Head sequence observed before the advance attempt.
875        observed_head_seq: ChangeSeq,
876    },
877}
878
879/// What the metadata-reorganization part of a maintenance step did.
880///
881/// Deliberately coarse: the run counts and byte budgets a reorganization
882/// consumes are engine policy, not a wire contract.
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
885#[serde(tag = "kind", rename_all = "snake_case")]
886pub enum ReorganizeStepOutcome {
887    /// No family group had enough delta runs to merge, or `only` selected
888    /// something else.
889    NotNeeded,
890    /// One family group was merged and a manifest published.
891    UnitPublished,
892    /// A group needs merging but no progress-making subset fits the
893    /// per-step budget.
894    BudgetExhausted,
895    /// Another publisher advanced the root first; a later step retries.
896    Superseded,
897}
898
899/// Result of one explicit maintenance step.
900#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
901#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
902pub struct MaintenanceStepResponse {
903    /// Namespace the step ran against.
904    pub namespace_id: NamespaceId,
905    /// Namespace status observed before the step acted.
906    pub status_before: NamespaceStatusResponse,
907    /// What the WAL-flush sub-step did.
908    pub wal_flush: WalFlushStepOutcome,
909    /// What the metadata-reorganization sub-step did.
910    pub reorganize: ReorganizeStepOutcome,
911    /// The retention floor after the step. Compare with
912    /// `status_before.retention_floor_seq` to see whether it moved.
913    pub retention_floor_seq: ChangeSeq,
914    /// Garbage-collection report when the step opted into sweeping.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub gc: Option<GcResponse>,
917}
918
919/// Options for one store contract probe. Empty today; a body is still sent
920/// so later options do not change the shape of the request.
921#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
922#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
923pub struct StoreProbeRequest {}
924
925/// What one store contract probe observed, check by check.
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
927#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
928pub struct StoreProbeResponse {
929    /// Label the server minted for this run. It scopes the objects the run
930    /// wrote, so it identifies the run in provider logs too.
931    pub run_id: String,
932    /// Every check the run performed, in the order it performed them. A
933    /// failed check lives here rather than in an error: the probe answered
934    /// the question, and the answer is that the store is wrong.
935    pub checks: Vec<StoreProbeCheckResult>,
936}
937
938/// One named contract check and what the store did with it.
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
940#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
941pub struct StoreProbeCheckResult {
942    /// Stable check name.
943    pub name: String,
944    /// What the store did.
945    pub outcome: StoreProbeCheckOutcome,
946    /// What was expected and what happened instead. Present only on
947    /// `failed`.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub message: Option<String>,
950}
951
952/// What one contract check concluded about the store.
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
954#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
955#[serde(rename_all = "snake_case")]
956pub enum StoreProbeCheckOutcome {
957    /// The store behaved as the contract requires.
958    Passed,
959    /// The store declares it cannot do this at all. Only the optional
960    /// capabilities answer this way, and it is an answer rather than a
961    /// fault.
962    Unsupported,
963    /// The store did something the contract forbids, or the operation
964    /// failed outright.
965    Failed,
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971    use crate::ContentId;
972
973    fn path(value: &str) -> AbsolutePath {
974        AbsolutePath::parse(value).expect("valid test path")
975    }
976
977    #[test]
978    fn behavior_enums_use_snake_case_wire_values() {
979        assert_eq!(
980            DestinationBehavior::default(),
981            DestinationBehavior::NoReplace
982        );
983        assert_eq!(
984            DeleteDirectoryBehavior::default(),
985            DeleteDirectoryBehavior::NonRecursive
986        );
987        assert_eq!(
988            serde_json::to_value(DestinationBehavior::NoReplace)
989                .expect("destination behavior json"),
990            serde_json::json!("no_replace")
991        );
992        assert_eq!(
993            serde_json::to_value(DestinationBehavior::Replace).expect("destination behavior json"),
994            serde_json::json!("replace")
995        );
996        assert_eq!(
997            serde_json::to_value(DeleteDirectoryBehavior::NonRecursive)
998                .expect("delete behavior json"),
999            serde_json::json!("non_recursive")
1000        );
1001        assert_eq!(
1002            serde_json::to_value(DeleteDirectoryBehavior::Recursive).expect("delete behavior json"),
1003            serde_json::json!("recursive")
1004        );
1005    }
1006
1007    #[test]
1008    fn filesystem_delete_and_move_operations_use_behavior_field() {
1009        let create_directory = FilesystemOperation::CreateDirectory {
1010            path: path("/docs"),
1011            parents: false,
1012        };
1013        assert_eq!(
1014            serde_json::to_value(&create_directory).expect("create directory op json"),
1015            serde_json::json!({
1016                "kind": "create_directory",
1017                "path": "/docs"
1018            })
1019        );
1020
1021        let create_directory_with_parents = FilesystemOperation::CreateDirectory {
1022            path: path("/docs/notes"),
1023            parents: true,
1024        };
1025        assert_eq!(
1026            serde_json::to_value(&create_directory_with_parents)
1027                .expect("create directory with parents op json"),
1028            serde_json::json!({
1029                "kind": "create_directory",
1030                "path": "/docs/notes",
1031                "parents": true
1032            })
1033        );
1034
1035        let delete = FilesystemOperation::DeletePath {
1036            path: path("/docs"),
1037            behavior: DeleteDirectoryBehavior::Recursive,
1038            expected_inode_id: None,
1039        };
1040        assert_eq!(
1041            serde_json::to_value(&delete).expect("delete op json"),
1042            serde_json::json!({
1043                "kind": "delete_path",
1044                "path": "/docs",
1045                "behavior": "recursive"
1046            })
1047        );
1048
1049        let move_path = FilesystemOperation::MovePath {
1050            from_path: path("/docs/a.txt"),
1051            to_path: path("/docs/b.txt"),
1052            behavior: DestinationBehavior::Replace,
1053        };
1054        assert_eq!(
1055            serde_json::to_value(&move_path).expect("move op json"),
1056            serde_json::json!({
1057                "kind": "move_path",
1058                "from_path": "/docs/a.txt",
1059                "to_path": "/docs/b.txt",
1060                "behavior": "replace"
1061            })
1062        );
1063
1064        let copy_path = FilesystemOperation::CopyPath {
1065            from_path: path("/docs/a.txt"),
1066            to_path: path("/docs/b.txt"),
1067            behavior: DestinationBehavior::Replace,
1068        };
1069        assert_eq!(
1070            serde_json::to_value(&copy_path).expect("copy op json"),
1071            serde_json::json!({
1072                "kind": "copy_path",
1073                "from_path": "/docs/a.txt",
1074                "to_path": "/docs/b.txt",
1075                "behavior": "replace"
1076            })
1077        );
1078    }
1079
1080    #[test]
1081    fn filesystem_operations_default_omitted_behavior_fields() {
1082        let put: FilesystemOperation = serde_json::from_value(serde_json::json!({
1083            "kind": "put_file",
1084            "path": "/docs/a.txt",
1085            "content_ref": {
1086                "kind": "blob_v1",
1087                "content_id": "con_0123456789abcdef0123456789abcdef",
1088                "size_bytes": 1,
1089                "storage_checksum": {
1090                    "algorithm": "sha256",
1091                    "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1092                }
1093            }
1094        }))
1095        .expect("put op defaults behavior");
1096        assert!(matches!(
1097            put,
1098            FilesystemOperation::PutFile {
1099                behavior: DestinationBehavior::NoReplace,
1100                expected_revision_no: None,
1101                ..
1102            }
1103        ));
1104
1105        let delete: FilesystemOperation = serde_json::from_value(serde_json::json!({
1106            "kind": "delete_path",
1107            "path": "/docs"
1108        }))
1109        .expect("delete op defaults behavior");
1110        assert_eq!(
1111            delete,
1112            FilesystemOperation::DeletePath {
1113                path: path("/docs"),
1114                behavior: DeleteDirectoryBehavior::NonRecursive,
1115                expected_inode_id: None,
1116            }
1117        );
1118
1119        let move_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1120            "kind": "move_path",
1121            "from_path": "/docs/a.txt",
1122            "to_path": "/docs/b.txt"
1123        }))
1124        .expect("move op defaults behavior");
1125        assert_eq!(
1126            move_path,
1127            FilesystemOperation::MovePath {
1128                from_path: path("/docs/a.txt"),
1129                to_path: path("/docs/b.txt"),
1130                behavior: DestinationBehavior::NoReplace,
1131            }
1132        );
1133
1134        let copy_path: FilesystemOperation = serde_json::from_value(serde_json::json!({
1135            "kind": "copy_path",
1136            "from_path": "/docs/a.txt",
1137            "to_path": "/docs/b.txt"
1138        }))
1139        .expect("copy op defaults behavior");
1140        assert_eq!(
1141            copy_path,
1142            FilesystemOperation::CopyPath {
1143                from_path: path("/docs/a.txt"),
1144                to_path: path("/docs/b.txt"),
1145                behavior: DestinationBehavior::NoReplace,
1146            }
1147        );
1148    }
1149
1150    #[test]
1151    fn filesystem_operation_paths_keep_the_plain_string_wire_shape() {
1152        let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
1153        let cases = [
1154            (
1155                FilesystemOperation::PutFile {
1156                    path: path("/docs/a.txt"),
1157                    content_ref: content_ref.clone(),
1158                    behavior: DestinationBehavior::NoReplace,
1159                    expected_revision_no: None,
1160                },
1161                serde_json::json!({
1162                    "kind": "put_file",
1163                    "path": "/docs/a.txt",
1164                    "content_ref": content_ref,
1165                    "behavior": "no_replace"
1166                }),
1167            ),
1168            (
1169                FilesystemOperation::Undelete {
1170                    inode_id: InodeId(7),
1171                    deleted_at_seq: ChangeSeq(8),
1172                    path: Some(path("/docs/restored")),
1173                },
1174                serde_json::json!({
1175                    "kind": "undelete",
1176                    "inode_id": 7,
1177                    "deleted_at_seq": 8,
1178                    "path": "/docs/restored"
1179                }),
1180            ),
1181            (
1182                FilesystemOperation::RestoreRevision {
1183                    path: path("/docs/a.txt"),
1184                    source_revision_no: RevisionNo(2),
1185                },
1186                serde_json::json!({
1187                    "kind": "restore_revision",
1188                    "path": "/docs/a.txt",
1189                    "source_revision_no": 2
1190                }),
1191            ),
1192        ];
1193
1194        for (operation, string_shaped_json) in cases {
1195            assert_eq!(
1196                serde_json::to_value(operation).expect("serialize filesystem operation"),
1197                string_shaped_json
1198            );
1199        }
1200    }
1201
1202    #[test]
1203    fn filesystem_operation_paths_validate_during_deserialization() {
1204        for encoded in [
1205            serde_json::json!({"kind": "create_directory", "path": "relative", "parents": false}),
1206            serde_json::json!({
1207                "kind": "put_file",
1208                "path": "relative",
1209                "content_ref": ContentRef::blob_v1(ContentId::generate(), b"hello")
1210            }),
1211            serde_json::json!({"kind": "delete_path", "path": "relative"}),
1212            serde_json::json!({
1213                "kind": "move_path",
1214                "from_path": "relative",
1215                "to_path": "/target"
1216            }),
1217            serde_json::json!({
1218                "kind": "copy_path",
1219                "from_path": "/source",
1220                "to_path": "relative"
1221            }),
1222            serde_json::json!({
1223                "kind": "undelete",
1224                "inode_id": 7,
1225                "deleted_at_seq": 8,
1226                "path": "relative"
1227            }),
1228            serde_json::json!({
1229                "kind": "restore_revision",
1230                "path": "relative",
1231                "source_revision_no": 2
1232            }),
1233        ] {
1234            assert!(serde_json::from_value::<FilesystemOperation>(encoded).is_err());
1235        }
1236    }
1237}