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(©_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}