Skip to main content

loonfs_api/
manifest.rs

1//! The namespace manifest format: the durable document naming the
2//! metadata SST runs that materialize one namespace file-set version
3//! (format spec, "Namespace manifests").
4
5use crate::envelope::EnvelopeCodecError;
6use crate::sst_blocks::BlockHandle;
7use crate::WriterEpoch;
8use crate::{
9    ChangeSeq, CommitId, ContentRef, DisplayName, InodeId, InodeKind, ManifestId, ManifestObjectId,
10    MetadataTableId, NameKey, NamespaceId, RevisionNo,
11};
12use serde::{Deserialize, Serialize};
13
14/// Version 1: an uncompressed JSON envelope document carrying the payload as
15/// a raw JSON fragment. `payload_checksum` covers the fragment's exact bytes.
16pub const NAMESPACE_MANIFEST_FORMAT_VERSION: u32 = 1;
17
18/// Identifies the durable payload family carried by a namespace-manifest envelope.
19///
20/// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum NamespaceManifestKind {
24    /// Marks the file-set descriptor used to materialize a namespace snapshot.
25    NamespaceManifest,
26}
27
28impl NamespaceManifestKind {
29    /// Returns the frozen envelope discriminator written to durable storage.
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::NamespaceManifest => "namespace_manifest",
33        }
34    }
35}
36
37/// Selects a metadata row family and its durable lookup ordering.
38///
39/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum MetadataTableFamily {
43    /// Stores inode identity, kind, and creation position.
44    Inodes,
45    /// Orders directory bindings for parent-and-name visibility lookups.
46    DirentryBinds,
47    /// Re-indexes directory bindings by child for parent discovery.
48    DirentryChildBinds,
49    /// Stores immutable events that retire exact historical bindings.
50    DirentryUnbinds,
51    /// Stores file revisions in their canonical durable ordering.
52    Revisions,
53    /// Re-indexes file revisions for newest-first per-inode reads.
54    RevisionsByInodeDesc,
55    /// Stores set and revoke events used to determine active subtree tombstones.
56    Tombstones,
57    /// Names the deletions that are recoverable right now, derived from the
58    /// tombstone family and ordered by deletion time.
59    ActiveDeletions,
60    /// Preserves commit idempotency evidence independently of retained WAL history.
61    CommitReceipts,
62}
63
64/// Describes one immutable metadata SST object referenced by a namespace manifest.
65///
66/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct MetadataFileRef {
69    /// Namespace whose keyspace owns the object, which may be a fork source rather than the reader.
70    pub owner_namespace_id: NamespaceId,
71    /// Immutable table identity incorporated into the object's durable key.
72    pub table_id: MetadataTableId,
73    /// Fully resolved object-store key trusted only after descriptor validation.
74    pub object_key: String,
75    /// Namespace sequence at which this run was produced.
76    pub run_seq: ChangeSeq,
77    /// Compaction tier used to order overlapping runs during reads and reorganization.
78    pub level: u32,
79    /// Row schema and lookup ordering encoded in this segment.
80    pub family: MetadataTableFamily,
81    /// Zero-based shard position among segments emitted for the same family and run.
82    pub segment_index: u32,
83    /// Number of row payloads in the segment, used for validation and planning.
84    pub row_count: u64,
85    /// Inclusive least durable row key; the segment is corrupt if decoded rows disagree.
86    pub min_key: String,
87    /// Inclusive greatest durable row key; range planning skips disjoint segments.
88    pub max_key: String,
89    /// Where the segment's index block lives and how to verify it. The
90    /// descriptor is the only entry point into a segment object — there is
91    /// no footer — so a reader starts here.
92    pub index_block: BlockHandle,
93    /// Where the segment's bloom filter block lives and how to verify it.
94    pub filter_block: BlockHandle,
95    /// The filter block's stored bytes inlined as hex, present when the
96    /// filter is small (small delta runs). Point lookups consult it to skip
97    /// the segment without any object fetch; `filter_block` still names and
98    /// verifies the same bytes, so the inline copy must decode byte-for-byte
99    /// identical (same length and CRC32C) or the manifest is corrupt.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub filter_inline: Option<String>,
102    /// Checksum of the segment object's full bytes, in `sha256:<hex>` form.
103    /// The ranged read path verifies per-block CRCs instead; this digest is
104    /// the segment's identity in the decoded-block cache.
105    pub payload_checksum: String,
106}
107
108/// Stores one materialized metadata event in an SST segment.
109///
110/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum MetadataRow {
114    /// Establishes one inode's immutable identity and kind.
115    Inode {
116        /// Namespace-scoped inode identity allocated by the publishing writer.
117        inode_id: InodeId,
118        /// Classification fixed when the inode was created.
119        inode_kind: InodeKind,
120        /// Commit sequence from which the inode can become visible.
121        created_seq: ChangeSeq,
122    },
123    /// Records one generation of a directory name binding.
124    DirentryBind {
125        /// Directory in which the name was bound.
126        parent_inode_id: InodeId,
127        /// Policy-derived key used for uniqueness and lookup.
128        name_key: NameKey,
129        /// User-facing component spelling retained for directory responses.
130        display_name: DisplayName,
131        /// Inode reached while this binding generation remains active.
132        child_inode_id: InodeId,
133        /// Commit sequence that created this binding generation.
134        bind_seq: ChangeSeq,
135        /// Position that disambiguates the binding within `bind_seq`.
136        bind_delta_index: u32,
137    },
138    /// Retires one exact directory-binding generation.
139    DirentryUnbind {
140        /// Directory that held the targeted binding.
141        parent_inode_id: InodeId,
142        /// Canonical name key of the targeted binding.
143        name_key: NameKey,
144        /// User-facing spelling the retired binding carried.
145        display_name: DisplayName,
146        /// Child identity recorded by the targeted binding.
147        child_inode_id: InodeId,
148        /// Commit sequence that created the binding being retired.
149        bind_seq: ChangeSeq,
150        /// Delta position of the binding being retired.
151        bind_delta_index: u32,
152        /// Commit sequence from which this unbind takes effect.
153        unbind_seq: ChangeSeq,
154        /// Position that disambiguates the unbind within `unbind_seq`.
155        unbind_delta_index: u32,
156    },
157    /// Publishes one immutable content revision for a file inode.
158    Revision {
159        /// File inode whose history contains the revision.
160        inode_id: InodeId,
161        /// Monotonic revision number within that file's history.
162        revision_no: RevisionNo,
163        /// Namespace sequence that published the revision.
164        committed_seq: ChangeSeq,
165        /// The owning commit's observational wall-clock stamp, denormalized
166        /// onto the row so revision reads answer times without a receipt
167        /// join. Never a validity input; `committed_seq` is the order.
168        committed_at_ms: u64,
169        /// Delta position that disambiguates the revision within `committed_seq`.
170        revision_delta_index: u32,
171        /// Immutable bytes published by the revision.
172        content_ref: ContentRef,
173    },
174    /// Changes whether one root inode has an active subtree tombstone.
175    Tombstone {
176        /// Inode whose rooted subtree the event governs.
177        root_inode_id: InodeId,
178        /// Commit sequence that published this tombstone event.
179        tombstone_seq: ChangeSeq,
180        /// Position that disambiguates the event within `tombstone_seq`.
181        tombstone_delta_index: u32,
182        /// What this event did; readers take the newest row per root and
183        /// treat a `revoke` newest row as "no active tombstone".
184        action: TombstoneRowAction,
185        /// Wall-clock stamp of the recording commit. Observational, like
186        /// every `committed_at_ms`.
187        deleted_at_ms: u64,
188        /// Directory that held the deleted binding, for `set` rows from
189        /// path deletes; tombstone rows are immortal, so this is where a
190        /// deleted name survives after unbind rows age out.
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        parent_inode_id: Option<InodeId>,
193        /// Canonical key of the deleted binding, when known.
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        name_key: Option<NameKey>,
196        /// User-facing spelling of the deleted binding, when known.
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        display_name: Option<DisplayName>,
199    },
200    /// Names one deletion generation in the derived active-deletions family.
201    ///
202    /// The row is not a new event: the materializer derives it from the
203    /// tombstone family, adding a `listed` row for each `set` and a `removed`
204    /// row for each `revoke`, so the trash listing is a range scan instead of
205    /// a walk over every deletion the namespace ever recorded.
206    ActiveDeletion {
207        /// Subtree root the deletion covers. With `deleted_at_seq` this is
208        /// exactly the handle `undelete` addresses.
209        root_inode_id: InodeId,
210        /// Commit sequence of the deletion this row speaks for. A `removed`
211        /// row repeats its target's sequence, not the undelete's, so the two
212        /// rows sort together.
213        deleted_at_seq: ChangeSeq,
214        /// Whether the deletion is still recoverable, and the listing detail
215        /// it carries while it is.
216        action: ActiveDeletionRowAction,
217    },
218    /// Preserves the evidence needed to answer a retried logical commit.
219    CommitReceipt {
220        /// Caller idempotency key whose later reuse is checked against this row.
221        commit_id: CommitId,
222        /// Digest used to distinguish a safe retry from conflicting id reuse.
223        semantic_commit_fingerprint: String,
224        /// Namespace sequence assigned to the accepted commit.
225        committed_seq: ChangeSeq,
226        /// The commit's observational wall-clock stamp. Receipts are the
227        /// durable per-commit record once WAL history drops below the
228        /// retention floor, so the stamp lives here for every commit,
229        /// revision-bearing or not.
230        committed_at_ms: u64,
231        /// Caller annotation preserved for idempotent response reconstruction.
232        #[serde(default, skip_serializing_if = "Option::is_none")]
233        message: Option<String>,
234    },
235}
236
237/// Tombstone-row event vocabulary (format spec, "Tombstones and deletion").
238#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239#[serde(tag = "kind", rename_all = "snake_case")]
240pub enum TombstoneRowAction {
241    /// The subtree rooted at the row's inode is deleted.
242    Set,
243    /// The deletion recorded at `(target_seq, target_delta_index)` is
244    /// revoked.
245    Revoke {
246        /// Commit sequence of the exact `Set` event being compensated.
247        target_seq: ChangeSeq,
248        /// Delta position of the exact `Set` event within `target_seq`.
249        target_delta_index: u32,
250    },
251}
252
253/// Active-deletion row vocabulary (format spec, "Tombstones and deletion").
254///
255/// The family holds current state, not history: a `listed` row means the
256/// deletion is recoverable and the trash lists it, and a `removed` row is the
257/// undelete's compensating marker that hides it. The two rows for one
258/// deletion share a key prefix and `removed` sorts first, so an ascending
259/// scan always sees the removal before the row it removes; reorganization
260/// then drops the pair, because a cancelled deletion is not state anyone can
261/// still observe.
262#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
263#[serde(tag = "kind", rename_all = "snake_case")]
264pub enum ActiveDeletionRowAction {
265    /// The deletion is recoverable; these are the fields the trash entry
266    /// renders, denormalized so a page needs no per-entry join.
267    Listed {
268        /// Wall-clock stamp of the deleting commit. Observational, like every
269        /// `committed_at_ms`.
270        deleted_at_ms: u64,
271        /// Directory that held the deleted binding, when the delete recorded
272        /// one.
273        #[serde(default, skip_serializing_if = "Option::is_none")]
274        parent_inode_id: Option<InodeId>,
275        /// Canonical key of the deleted binding, when known.
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        name_key: Option<NameKey>,
278        /// User-facing spelling of the deleted binding as of the deletion,
279        /// when known.
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        display_name: Option<DisplayName>,
282    },
283    /// An undelete at `revoked_at_seq` cancelled the deletion this row's key
284    /// names, so the listing skips the key.
285    Removed {
286        /// Commit sequence of the undelete that cancelled the deletion.
287        revoked_at_seq: ChangeSeq,
288    },
289}
290
291impl ActiveDeletionRowAction {
292    /// The row-key component that orders a removal ahead of the row it
293    /// removes.
294    fn sort_rank(&self) -> u8 {
295        match self {
296            Self::Removed { .. } => lookup_keys::ACTIVE_DELETION_RANK_REMOVED,
297            Self::Listed { .. } => lookup_keys::ACTIVE_DELETION_RANK_LISTED,
298        }
299    }
300}
301
302impl MetadataRow {
303    /// Builds this row's canonical durable key in its primary table family.
304    ///
305    /// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
306    pub fn row_key(&self) -> String {
307        self.row_key_for_family(match self {
308            Self::Inode { .. } => MetadataTableFamily::Inodes,
309            Self::DirentryBind { .. } => MetadataTableFamily::DirentryBinds,
310            Self::DirentryUnbind { .. } => MetadataTableFamily::DirentryUnbinds,
311            Self::Revision { .. } => MetadataTableFamily::Revisions,
312            Self::Tombstone { .. } => MetadataTableFamily::Tombstones,
313            Self::ActiveDeletion { .. } => MetadataTableFamily::ActiveDeletions,
314            Self::CommitReceipt { .. } => MetadataTableFamily::CommitReceipts,
315        })
316    }
317
318    /// Builds this row's durable key using the selected primary or secondary ordering.
319    ///
320    /// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
321    pub fn row_key_for_family(&self, family: MetadataTableFamily) -> String {
322        match self {
323            Self::Inode { inode_id, .. } => format!("inode-{:020}", inode_id.0),
324            Self::DirentryBind {
325                parent_inode_id,
326                name_key,
327                child_inode_id,
328                bind_seq,
329                bind_delta_index,
330                ..
331            } => match family {
332                MetadataTableFamily::DirentryChildBinds => {
333                    let name_key = hex_encode_row_key_component(name_key.as_str());
334                    format!(
335                        "direntry-child-{:020}-{:020}-{:010}-{:020}-{name_key}",
336                        child_inode_id.0, bind_seq.0, bind_delta_index, parent_inode_id.0
337                    )
338                }
339                _ => {
340                    let name_key = hex_encode_row_key_component(name_key.as_str());
341                    format!(
342                        "direntry-{:020}-{name_key}-{:020}-{:010}",
343                        parent_inode_id.0, bind_seq.0, bind_delta_index
344                    )
345                }
346            },
347            Self::DirentryUnbind {
348                parent_inode_id,
349                name_key,
350                bind_seq,
351                bind_delta_index,
352                unbind_seq,
353                unbind_delta_index,
354                ..
355            } => {
356                let name_key = hex_encode_row_key_component(name_key.as_str());
357                format!(
358                    "direntry-unbind-{:020}-{name_key}-{:020}-{:010}-{:020}-{:010}",
359                    parent_inode_id.0,
360                    bind_seq.0,
361                    bind_delta_index,
362                    unbind_seq.0,
363                    unbind_delta_index
364                )
365            }
366            Self::Revision {
367                inode_id,
368                revision_no,
369                committed_seq,
370                revision_delta_index,
371                ..
372            } => match family {
373                MetadataTableFamily::RevisionsByInodeDesc => {
374                    let reverse_revision_no = u64::MAX - revision_no.0;
375                    let reverse_committed_seq = u64::MAX - committed_seq.0;
376                    let reverse_delta_index = u32::MAX - revision_delta_index;
377                    format!(
378                        "revision-by-inode-desc-{:020}-{:020}-{:020}-{:010}",
379                        inode_id.0, reverse_revision_no, reverse_committed_seq, reverse_delta_index
380                    )
381                }
382                _ => {
383                    format!(
384                        "revision-{:020}-{:020}-{:010}",
385                        inode_id.0, revision_no.0, revision_delta_index
386                    )
387                }
388            },
389            Self::Tombstone {
390                root_inode_id,
391                tombstone_seq,
392                tombstone_delta_index,
393                // The action and binding context live in the value: revoke
394                // rows sort exactly like the deletions they cancel, so
395                // newest-per-root scans see them in one prefix pass.
396                ..
397            } => {
398                format!(
399                    "tombstone-{:020}-{:020}-{:010}",
400                    root_inode_id.0, tombstone_seq.0, tombstone_delta_index
401                )
402            }
403            Self::ActiveDeletion {
404                root_inode_id,
405                deleted_at_seq,
406                action,
407            } => lookup_keys::active_deletion_row_key(
408                *deleted_at_seq,
409                *root_inode_id,
410                action.sort_rank(),
411            ),
412            Self::CommitReceipt {
413                committed_seq,
414                commit_id,
415                ..
416            } => {
417                let commit_id = hex_encode_row_key_component(commit_id.as_str());
418                format!("commit-receipt-{commit_id}-{:020}", committed_seq.0)
419            }
420        }
421    }
422
423    /// The exact lookup prefix a point read probes for this row in `family`,
424    /// and therefore the key inserted into the segment's bloom filter. The
425    /// two sides must agree byte-for-byte — a filter is an exact-match
426    /// structure — so both are defined here, next to the row keys they
427    /// shorten. Range scans at coarser granularity (a whole directory, a
428    /// wave of names) do not consult filters.
429    pub fn filter_key_for_family(&self, family: MetadataTableFamily) -> String {
430        match self {
431            Self::Inode { .. } => self.row_key_for_family(family),
432            Self::DirentryBind {
433                parent_inode_id,
434                name_key,
435                child_inode_id,
436                ..
437            } => match family {
438                MetadataTableFamily::DirentryChildBinds => {
439                    format!("direntry-child-{:020}", child_inode_id.0)
440                }
441                _ => {
442                    let name_key = hex_encode_row_key_component(name_key.as_str());
443                    format!("direntry-{:020}-{name_key}", parent_inode_id.0)
444                }
445            },
446            Self::DirentryUnbind {
447                parent_inode_id,
448                name_key,
449                ..
450            } => {
451                let name_key = hex_encode_row_key_component(name_key.as_str());
452                format!("direntry-unbind-{:020}-{name_key}", parent_inode_id.0)
453            }
454            Self::Revision { inode_id, .. } => match family {
455                MetadataTableFamily::RevisionsByInodeDesc => {
456                    format!("revision-by-inode-desc-{:020}", inode_id.0)
457                }
458                _ => format!("revision-{:020}", inode_id.0),
459            },
460            Self::Tombstone { root_inode_id, .. } => {
461                format!("tombstone-{:020}", root_inode_id.0)
462            }
463            // The family is only ever range-scanned in key order, never
464            // probed for one deletion, so the filter key is the row key.
465            Self::ActiveDeletion { .. } => self.row_key_for_family(family),
466            Self::CommitReceipt { commit_id, .. } => {
467                let commit_id = hex_encode_row_key_component(commit_id.as_str());
468                format!("commit-receipt-{commit_id}")
469            }
470        }
471    }
472}
473
474/// Encodes an arbitrary string so it can occupy one component of a durable row key.
475///
476/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
477pub fn hex_encode_row_key_component(value: &str) -> String {
478    crate::hex::hex_encode_bytes(value.as_bytes())
479}
480
481/// Reader-side lookup grammar: the probes, prefixes, and resume keys that
482/// point lookups and scans build per family. Defined beside
483/// `row_key_for_family` and `filter_key_for_family` because the pairing is
484/// byte-for-byte — a probe must equal the filter key the writer stored, and
485/// a prefix must be a prefix of the row keys it selects. Change a key
486/// format and its lookup grammar together, here.
487pub mod lookup_keys {
488    use super::hex_encode_row_key_component;
489    use crate::{ChangeSeq, InodeId, RevisionNo};
490
491    /// The prefix every inode row key starts with. Inode ids are
492    /// zero-padded to a fixed width after it, so a range scan over this
493    /// prefix walks the inode family in ascending inode-id order.
494    ///
495    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
496    pub const INODE_ROW_PREFIX: &str = "inode-";
497
498    /// The prefix every canonical revision row key starts with. A range scan
499    /// over it walks every revision the manifest records, superseded ones
500    /// included, which is what a reachability question about content needs.
501    ///
502    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
503    pub const REVISION_ROW_PREFIX: &str = "revision-";
504
505    /// Builds the exact point-lookup key for an inode row.
506    ///
507    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
508    pub fn inode_key(inode_id: InodeId) -> String {
509        format!("{INODE_ROW_PREFIX}{:020}", inode_id.0)
510    }
511
512    /// Builds the resume bound for a scan that must continue strictly after
513    /// `inode_id`'s row.
514    ///
515    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
516    pub fn inode_key_after(inode_id: InodeId) -> String {
517        format!("{}\0", inode_key(inode_id))
518    }
519
520    /// Builds the range prefix selecting directory bindings under one parent.
521    ///
522    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
523    pub fn direntry_parent_prefix(parent_inode_id: InodeId) -> String {
524        format!("direntry-{:020}-", parent_inode_id.0)
525    }
526
527    /// Builds the bloom-filter probe shared by all generations of one parent/name binding.
528    ///
529    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
530    pub fn direntry_bind_probe(parent_inode_id: InodeId, name_key: &str) -> String {
531        format!(
532            "direntry-{:020}-{}",
533            parent_inode_id.0,
534            hex_encode_row_key_component(name_key)
535        )
536    }
537
538    /// Builds the range prefix selecting every generation of one parent/name binding.
539    ///
540    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
541    pub fn direntry_bind_prefix(parent_inode_id: InodeId, name_key: &str) -> String {
542        format!("{}-", direntry_bind_probe(parent_inode_id, name_key))
543    }
544
545    /// Builds the bloom-filter probe shared by bindings that target one child inode.
546    ///
547    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
548    pub fn direntry_child_probe(child_inode_id: InodeId) -> String {
549        format!("direntry-child-{:020}", child_inode_id.0)
550    }
551
552    /// Builds the reverse-index range prefix selecting bindings to one child inode.
553    ///
554    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
555    pub fn direntry_child_prefix(child_inode_id: InodeId) -> String {
556        format!("{}-", direntry_child_probe(child_inode_id))
557    }
558
559    /// Builds the bloom-filter probe shared by unbinds for one parent/name pair.
560    ///
561    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
562    pub fn direntry_unbind_probe(parent_inode_id: InodeId, name_key: &str) -> String {
563        format!(
564            "direntry-unbind-{:020}-{}",
565            parent_inode_id.0,
566            hex_encode_row_key_component(name_key)
567        )
568    }
569
570    /// Rows for one specific binding generation under the unbind probe.
571    ///
572    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
573    pub fn direntry_unbind_binding_prefix(
574        parent_inode_id: InodeId,
575        name_key: &str,
576        bind_seq: ChangeSeq,
577        bind_delta_index: u32,
578    ) -> String {
579        format!(
580            "{}-{:020}-{:010}-",
581            direntry_unbind_probe(parent_inode_id, name_key),
582            bind_seq.0,
583            bind_delta_index
584        )
585    }
586
587    /// Builds the range prefix selecting every unbind below one parent directory.
588    ///
589    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
590    pub fn direntry_unbind_parent_prefix(parent_inode_id: InodeId) -> String {
591        format!("direntry-unbind-{:020}-", parent_inode_id.0)
592    }
593
594    /// Builds the range prefix selecting unbinds for one parent/name pair.
595    ///
596    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
597    pub fn direntry_unbind_name_prefix(parent_inode_id: InodeId, name_key: &str) -> String {
598        format!(
599            "{}{}-",
600            direntry_unbind_parent_prefix(parent_inode_id),
601            hex_encode_row_key_component(name_key)
602        )
603    }
604
605    /// Builds the bloom-filter probe shared by tombstone events for one root inode.
606    ///
607    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
608    pub fn tombstone_probe(root_inode_id: InodeId) -> String {
609        format!("tombstone-{:020}", root_inode_id.0)
610    }
611
612    /// Builds the range prefix selecting the tombstone history of one root inode.
613    ///
614    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
615    pub fn tombstone_prefix(root_inode_id: InodeId) -> String {
616        format!("{}-", tombstone_probe(root_inode_id))
617    }
618
619    /// The prefix every active-deletion row key starts with. Deletion
620    /// sequence and root inode are zero-padded to fixed widths after it, so a
621    /// range scan over this prefix walks the namespace's recoverable
622    /// deletions oldest deletion first — the trash listing's whole read.
623    ///
624    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
625    pub const ACTIVE_DELETION_ROW_PREFIX: &str = "active-deletion-";
626
627    /// Rank of an undelete's removal marker within one deletion generation.
628    /// It is the lowest rank on purpose: an ascending scan sees the removal
629    /// before the row it removes, so a page never lists a deletion whose
630    /// marker was going to arrive one page later.
631    pub const ACTIVE_DELETION_RANK_REMOVED: u8 = 0;
632
633    /// Rank of the listed row within one deletion generation, and the highest
634    /// rank the family defines.
635    pub const ACTIVE_DELETION_RANK_LISTED: u8 = 1;
636
637    /// Builds an active-deletion row key from its generation and rank.
638    ///
639    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
640    pub fn active_deletion_row_key(
641        deleted_at_seq: ChangeSeq,
642        root_inode_id: InodeId,
643        sort_rank: u8,
644    ) -> String {
645        format!(
646            "{ACTIVE_DELETION_ROW_PREFIX}{:020}-{:020}-{sort_rank}",
647            deleted_at_seq.0, root_inode_id.0
648        )
649    }
650
651    /// Builds the resume bound for a trash page that must continue strictly
652    /// after the deletion generation it last returned. The listed row is the
653    /// generation's highest-ranked row, so resuming past it skips the whole
654    /// generation and nothing else.
655    ///
656    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
657    pub fn active_deletion_key_after(deleted_at_seq: ChangeSeq, root_inode_id: InodeId) -> String {
658        format!(
659            "{}\0",
660            active_deletion_row_key(deleted_at_seq, root_inode_id, ACTIVE_DELETION_RANK_LISTED)
661        )
662    }
663
664    /// Builds the bloom-filter probe shared by receipts for one commit id.
665    ///
666    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
667    pub fn commit_receipt_probe(commit_id: &str) -> String {
668        format!("commit-receipt-{}", hex_encode_row_key_component(commit_id))
669    }
670
671    /// Builds the range prefix selecting durable receipts for one commit id.
672    ///
673    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
674    pub fn commit_receipt_prefix(commit_id: &str) -> String {
675        format!("{}-", commit_receipt_probe(commit_id))
676    }
677
678    /// Builds the bloom-filter probe shared by newest-first revisions of one inode.
679    ///
680    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
681    pub fn revision_by_inode_desc_probe(inode_id: InodeId) -> String {
682        format!("revision-by-inode-desc-{:020}", inode_id.0)
683    }
684
685    /// Builds the range prefix selecting newest-first revisions of one inode.
686    ///
687    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
688    pub fn revision_by_inode_desc_prefix(inode_id: InodeId) -> String {
689        format!("{}-", revision_by_inode_desc_probe(inode_id))
690    }
691
692    /// Revision numbers are stored inverted so newest sorts first.
693    ///
694    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
695    pub fn revision_by_inode_desc_revision_prefix(
696        inode_id: InodeId,
697        revision_no: RevisionNo,
698    ) -> String {
699        format!(
700            "{}{:020}-",
701            revision_by_inode_desc_prefix(inode_id),
702            u64::MAX - revision_no.0
703        )
704    }
705
706    /// The full descending-index row key: revision number, commit seq, and
707    /// delta index all inverted so newest sorts first.
708    ///
709    /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
710    pub fn revision_by_inode_desc_row_key(
711        inode_id: InodeId,
712        revision_no: RevisionNo,
713        committed_seq: ChangeSeq,
714        revision_delta_index: u32,
715    ) -> String {
716        format!(
717            "{}{:020}-{:020}-{:010}",
718            revision_by_inode_desc_prefix(inode_id),
719            u64::MAX - revision_no.0,
720            u64::MAX - committed_seq.0,
721            u32::MAX - revision_delta_index
722        )
723    }
724}
725
726/// Carries one complete namespace file-set description inside a manifest envelope.
727///
728/// See [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730pub struct NamespaceManifestPayload {
731    /// Namespace whose materialized state this manifest describes.
732    pub namespace_id: NamespaceId,
733    /// Monotonic logical manifest position selected by the namespace root.
734    pub manifest_id: ManifestId,
735    /// Immutable object identity that distinguishes speculative candidates at `manifest_id`.
736    pub manifest_object_id: ManifestObjectId,
737    /// Greatest namespace sequence materialized by the referenced file set.
738    pub head_seq: ChangeSeq,
739    /// Commit id assigned to `head_seq`, used to validate agreement with the head.
740    pub head_commit_id: CommitId,
741    /// Oldest run sequence still represented by `metadata_files`.
742    pub base_seq: ChangeSeq,
743    /// Fencing epoch of the writer that produced this candidate.
744    pub writer_epoch: WriterEpoch,
745    /// First inode identity available after replaying the manifest snapshot.
746    pub next_inode_id: InodeId,
747    /// Earliest sequence for which retained history remains readable.
748    pub retention_floor_seq: ChangeSeq,
749    /// Complete ordered set of metadata segments required to reconstruct the snapshot.
750    pub metadata_files: Vec<MetadataFileRef>,
751}
752
753/// In-memory view of a namespace manifest envelope.
754///
755/// This struct is not the durable layout; durable bytes are produced only by
756/// [`encode_namespace_manifest_json`] and validated only by
757/// [`decode_namespace_manifest_json`].
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct NamespaceManifestEnvelope {
760    /// Durable-family discriminator checked before payload decoding.
761    pub kind: NamespaceManifestKind,
762    /// Family-local format version, which must equal [`NAMESPACE_MANIFEST_FORMAT_VERSION`].
763    pub format_version: u32,
764    /// Digest of the payload JSON exactly as stored in the durable document,
765    /// in `sha256:<hex>` form.
766    pub payload_checksum: String,
767    /// Decoded file-set description protected by `payload_checksum`.
768    pub payload: NamespaceManifestPayload,
769}
770
771impl NamespaceManifestEnvelope {
772    /// Builds a versioned envelope and computes its checksum from canonical payload JSON.
773    ///
774    /// Construction fails when the payload cannot be encoded.
775    pub fn from_payload(payload: NamespaceManifestPayload) -> Result<Self, EnvelopeCodecError> {
776        Ok(Self {
777            kind: NamespaceManifestKind::NamespaceManifest,
778            format_version: NAMESPACE_MANIFEST_FORMAT_VERSION,
779            payload_checksum: namespace_manifest_payload_checksum(&payload)?,
780            payload,
781        })
782    }
783}
784
785fn namespace_manifest_payload_checksum(
786    payload: &NamespaceManifestPayload,
787) -> Result<String, EnvelopeCodecError> {
788    crate::envelope::json_payload_checksum(payload)
789}
790
791/// Encodes a namespace-manifest envelope as its durable JSON representation.
792///
793/// Encoding fails when the version is unsupported, the in-memory checksum is
794/// stale, or JSON serialization fails. See
795/// [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
796pub fn encode_namespace_manifest_json(
797    envelope: &NamespaceManifestEnvelope,
798) -> Result<Vec<u8>, EnvelopeCodecError> {
799    crate::envelope::encode_json_envelope(
800        envelope.kind.as_str(),
801        envelope.format_version,
802        NAMESPACE_MANIFEST_FORMAT_VERSION,
803        &envelope.payload_checksum,
804        &envelope.payload,
805    )
806}
807
808/// Decodes and verifies a durable namespace-manifest JSON envelope.
809///
810/// Decoding fails for invalid JSON, the wrong kind or version, a checksum
811/// mismatch, or an invalid payload. See
812/// [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
813pub fn decode_namespace_manifest_json(
814    bytes: &[u8],
815) -> Result<NamespaceManifestEnvelope, EnvelopeCodecError> {
816    let expected_kind = NamespaceManifestKind::NamespaceManifest;
817    let decoded =
818        crate::envelope::decode_json_envelope(bytes, NAMESPACE_MANIFEST_FORMAT_VERSION, |found| {
819            crate::envelope::verify_kind(expected_kind.as_str(), found)
820        })?;
821
822    Ok(NamespaceManifestEnvelope {
823        kind: expected_kind,
824        format_version: decoded.format_version,
825        payload_checksum: decoded.payload_checksum,
826        payload: decoded.payload,
827    })
828}
829
830#[cfg(test)]
831mod tests {
832    use super::{
833        decode_namespace_manifest_json, encode_namespace_manifest_json, BlockHandle,
834        MetadataFileRef, MetadataTableFamily, NamespaceManifestEnvelope, NamespaceManifestPayload,
835    };
836    use crate::{
837        ChangeSeq, CommitId, InodeId, ManifestId, ManifestObjectId, MetadataTableId, NameKey,
838        NamespaceId, WriterEpoch,
839    };
840
841    #[test]
842    fn inode_row_keys_sort_by_ascending_inode_id() {
843        // The inode family's durable order IS ascending inode id, which is
844        // what lets a whole-namespace file walk resume from one bound.
845        let ids = [9_u64, 1, 100, 10, 2];
846        let key_of = |id: u64| super::lookup_keys::inode_key(InodeId(id));
847        let mut keys: Vec<String> = ids.iter().copied().map(key_of).collect();
848        keys.sort();
849
850        let mut ascending_ids = ids;
851        ascending_ids.sort_unstable();
852        assert_eq!(
853            keys,
854            ascending_ids
855                .iter()
856                .copied()
857                .map(key_of)
858                .collect::<Vec<_>>(),
859            "row-key order must agree with inode-id order"
860        );
861        assert!(keys
862            .iter()
863            .all(|key| key.starts_with(super::lookup_keys::INODE_ROW_PREFIX)));
864    }
865
866    #[test]
867    fn the_inode_resume_bound_skips_its_own_row_and_nothing_after_it() {
868        let resume = super::lookup_keys::inode_key_after(InodeId(7));
869        assert!(resume > super::lookup_keys::inode_key(InodeId(7)));
870        assert!(resume < super::lookup_keys::inode_key(InodeId(8)));
871    }
872
873    #[test]
874    fn namespace_manifest_kind_string_matches_serde() {
875        let kind = super::NamespaceManifestKind::NamespaceManifest;
876        let serialized = serde_json::to_value(kind).expect("serialize kind");
877        assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
878    }
879
880    #[test]
881    fn namespace_manifest_codec_round_trips_base_only_materialization() {
882        let envelope = NamespaceManifestEnvelope::from_payload(NamespaceManifestPayload {
883            namespace_id: NamespaceId::parse("demo").expect("valid namespace id"),
884            manifest_id: ManifestId(10),
885            manifest_object_id: ManifestObjectId::parse("00000000000000000010-0123456789abcdef")
886                .expect("valid manifest object id"),
887            head_seq: ChangeSeq(10),
888            head_commit_id: CommitId::parse("c_00000000000000000000000000000001")
889                .expect("commit id"),
890            base_seq: ChangeSeq(10),
891            writer_epoch: WriterEpoch(2),
892            next_inode_id: InodeId(42),
893            retention_floor_seq: ChangeSeq(0),
894            metadata_files: vec![metadata_file_ref(
895                "demo",
896                "tbl_00000000000000000000000000000001",
897                ChangeSeq(10),
898                1,
899                "namespaces/demo/metadata/tables/tbl_00000000000000000000000000000001.sst.zst",
900            )],
901        })
902        .expect("manifest");
903
904        let encoded = encode_namespace_manifest_json(&envelope).expect("encode manifest");
905        let decoded = decode_namespace_manifest_json(&encoded).expect("decode manifest");
906
907        assert_eq!(decoded, envelope);
908        assert_eq!(decoded.payload.base_seq, ChangeSeq(10));
909        assert_eq!(decoded.payload.metadata_files.len(), 1);
910        assert_eq!(decoded.payload.metadata_files[0].run_seq, ChangeSeq(10));
911    }
912
913    /// A fork target's first own manifest keeps referencing the source's
914    /// metadata objects: ownership travels with each file reference, not
915    /// with the manifest.
916    #[test]
917    fn namespace_manifest_codec_round_trips_inherited_source_tables() {
918        let envelope = NamespaceManifestEnvelope::from_payload(
919            NamespaceManifestPayload {
920                namespace_id: NamespaceId::parse("demo").expect("valid namespace id"),
921                manifest_id: ManifestId(12),
922                manifest_object_id: ManifestObjectId::parse(
923                    "00000000000000000012-0123456789abcdef",
924                )
925                .expect("valid manifest object id"),
926                head_seq: ChangeSeq(12),
927                head_commit_id: CommitId::parse("c_00000000000000000000000000000002")
928                    .expect("commit id"),
929                base_seq: ChangeSeq(10),
930                writer_epoch: WriterEpoch(2),
931                next_inode_id: InodeId(42),
932                retention_floor_seq: ChangeSeq(0),
933                metadata_files: vec![
934                    metadata_file_ref(
935                        "source",
936                        "tbl_00000000000000000000000000000001",
937                        ChangeSeq(10),
938                        1,
939                        "namespaces/source/tables/metadata/tbl_00000000000000000000000000000001.sst.zst",
940                    ),
941                    metadata_file_ref(
942                        "demo",
943                        "tbl_00000000000000000000000000000002",
944                        ChangeSeq(12),
945                        0,
946                        "namespaces/demo/metadata/tables/tbl_00000000000000000000000000000002.sst.zst",
947                    ),
948                ],
949            },
950        )
951        .expect("manifest");
952
953        let encoded = encode_namespace_manifest_json(&envelope).expect("encode manifest");
954        let decoded = decode_namespace_manifest_json(&encoded).expect("decode manifest");
955
956        assert_eq!(decoded, envelope);
957        assert_eq!(decoded.payload.metadata_files[0].level, 1);
958        assert_eq!(decoded.payload.metadata_files[1].level, 0);
959        assert_eq!(decoded.payload.metadata_files[1].run_seq, ChangeSeq(12));
960        assert_eq!(
961            decoded.payload.metadata_files[0].owner_namespace_id,
962            NamespaceId::parse("source").expect("valid namespace id")
963        );
964    }
965
966    #[test]
967    fn direntry_bind_row_key_supports_parent_and_child_indexes() {
968        let row = super::MetadataRow::DirentryBind {
969            parent_inode_id: InodeId(9),
970            name_key: NameKey::parse("report.txt").expect("valid name key"),
971            display_name: crate::DisplayName::parse("Report.txt").expect("valid display name"),
972            child_inode_id: InodeId(42),
973            bind_seq: ChangeSeq(17),
974            bind_delta_index: 3,
975        };
976
977        assert_eq!(
978            row.row_key_for_family(MetadataTableFamily::DirentryBinds),
979            "direntry-00000000000000000009-7265706f72742e747874-00000000000000000017-0000000003"
980        );
981        assert_eq!(
982            row.row_key_for_family(MetadataTableFamily::DirentryChildBinds),
983            "direntry-child-00000000000000000042-00000000000000000017-0000000003-00000000000000000009-7265706f72742e747874"
984        );
985    }
986
987    #[test]
988    fn row_keys_hex_encode_dash_containing_variable_components() {
989        let row = super::MetadataRow::DirentryBind {
990            parent_inode_id: InodeId(9),
991            name_key: NameKey::parse("report-2024").expect("valid name key"),
992            display_name: crate::DisplayName::parse("report-2024").expect("valid display name"),
993            child_inode_id: InodeId(42),
994            bind_seq: ChangeSeq(17),
995            bind_delta_index: 3,
996        };
997
998        assert_eq!(
999            row.row_key_for_family(MetadataTableFamily::DirentryBinds),
1000            "direntry-00000000000000000009-7265706f72742d32303234-00000000000000000017-0000000003"
1001        );
1002    }
1003
1004    #[test]
1005    fn revision_row_key_supports_newest_first_inode_index() {
1006        let row = super::MetadataRow::Revision {
1007            inode_id: InodeId(42),
1008            revision_no: crate::RevisionNo(7),
1009            committed_seq: ChangeSeq(12),
1010            committed_at_ms: 12_000,
1011            revision_delta_index: 3,
1012            content_ref: crate::ContentRef::blob_v1(
1013                crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
1014                    .expect("valid content id"),
1015                b"row key sample",
1016            ),
1017        };
1018
1019        assert_eq!(
1020            row.row_key_for_family(MetadataTableFamily::Revisions),
1021            "revision-00000000000000000042-00000000000000000007-0000000003"
1022        );
1023        assert_eq!(
1024            row.row_key_for_family(MetadataTableFamily::RevisionsByInodeDesc),
1025            "revision-by-inode-desc-00000000000000000042-18446744073709551608-18446744073709551603-4294967292"
1026        );
1027    }
1028
1029    fn metadata_file_ref(
1030        owner_namespace_id: &str,
1031        table_id: &str,
1032        run_seq: ChangeSeq,
1033        level: u32,
1034        object_key: &str,
1035    ) -> MetadataFileRef {
1036        MetadataFileRef {
1037            owner_namespace_id: NamespaceId::parse(owner_namespace_id).expect("valid namespace id"),
1038            table_id: MetadataTableId::parse(table_id).expect("valid table id"),
1039            object_key: object_key.to_owned(),
1040            run_seq,
1041            level,
1042            family: MetadataTableFamily::Inodes,
1043            segment_index: 0,
1044            row_count: 0,
1045            min_key: String::new(),
1046            max_key: String::new(),
1047            index_block: BlockHandle {
1048                offset: 0,
1049                stored_len: 0,
1050                decoded_len: 0,
1051                crc32c: 0,
1052            },
1053            filter_block: BlockHandle {
1054                offset: 0,
1055                stored_len: 0,
1056                decoded_len: 0,
1057                crc32c: 0,
1058            },
1059            filter_inline: None,
1060            payload_checksum: "sha256:unused".to_owned(),
1061        }
1062    }
1063}