Skip to main content

objects/store/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Backend-neutral object storage abstractions and concrete implementations.
3
4use std::path::PathBuf;
5
6use crate::object::{
7    Action, ActionId, AnnotatedTag, Blob, ContentHash, OpenedTreeBody, PartialTree, State,
8    StateAttachment, StateAttachmentId, StateId, Tree, TreeEntry, TreeEntryReader,
9    TreeResumeCursor, is_redacted_tree, is_streamable_tree,
10};
11
12pub mod codec;
13#[cfg(feature = "fs")]
14mod delta_source;
15#[cfg(feature = "fs")]
16pub mod fs;
17pub mod liveness;
18#[cfg(any(test, feature = "memory-backend"))]
19pub mod memory;
20#[cfg(test)]
21mod partial_tree_tests;
22pub use heddle_pack::store::pack;
23#[cfg(feature = "fs")]
24pub mod shallow;
25#[cfg(feature = "fs")]
26mod snapshot_commit;
27pub mod source;
28pub mod store_compliance;
29#[cfg(feature = "fs")]
30pub mod writer_lease;
31
32#[cfg(feature = "fs")]
33pub use fs::{
34    DEFAULT_PACK_INSTALL_INTENT_TTL_SECS, FsRepackOperation, FsStore, PackInstallIntent,
35    PackInstallMetricsSnapshot, PackInstallPhase, PackInstallRecoverReport,
36    install_pack_bytes_journaled, pack_install_metrics_reset, pack_install_metrics_snapshot,
37    recover_pack_install_intents, recover_pack_install_intents_with_ttl,
38};
39pub use heddle_format::compression::{CompressionConfig, CompressionError, compress, decompress};
40pub use liveness::{
41    AGENT_LEASE_DURATION, Liveness, current_boot_id, process_alive, reservation_liveness_at,
42};
43#[cfg(any(test, feature = "memory-backend"))]
44pub use memory::InMemoryStore;
45pub use pack::{
46    CancellationToken as RepackCancellationToken, LoadMonitor as RepackLoadMonitor, PackBuilder,
47    PackObjectId, PackReader, PackStats, RepackContext, RepackError, RepackHandle, RepackInventory,
48    RepackOperation, RepackOutcome, RepackPolicy, RepackReason, RepackReport, RepackResourceLimits,
49    RepackSchedule, RepackScheduler, StreamingPackBuilder, SyncData,
50};
51#[cfg(feature = "fs")]
52pub use shallow::ShallowInfo;
53#[cfg(feature = "fs")]
54#[doc(hidden)]
55pub use snapshot_commit::{
56    SNAPSHOT_COMMIT_ARTIFACT_SCHEMA, SnapshotCommitArtifact, SnapshotCommitDescriptor,
57    SnapshotPackManager,
58};
59#[cfg(feature = "async-source")]
60pub use source::AsyncObjectSource;
61pub use source::ObjectSource;
62#[cfg(feature = "fs")]
63pub use writer_lease::{
64    WriterLease, WriterLeaseAuthOutcome, WriterLeaseDraft, WriterLeaseGrant,
65    WriterLeaseReserveOutcome, WriterLeaseStatus, WriterLeaseStore, generate_writer_lease_id,
66    generate_writer_lease_token,
67};
68
69/// A newly-authored tree plus its immediate parent, when capture already knows
70/// that relationship. Stores may use the hint for bounded HDC1 encoding; it
71/// never changes the tree's semantic content hash.
72#[derive(Clone, Debug)]
73pub struct TreeWrite {
74    pub tree: Tree,
75    pub parent: Option<ContentHash>,
76}
77
78impl TreeWrite {
79    pub fn anchor(tree: Tree) -> Self {
80        Self { tree, parent: None }
81    }
82
83    pub fn descendant(tree: Tree, parent: ContentHash) -> Self {
84        Self {
85            tree,
86            parent: Some(parent),
87        }
88    }
89}
90
91/// Read-only objects whose authoritative representation lives outside the
92/// native Heddle object directory. Git-overlay repositories use this seam to
93/// translate objects directly from `.git` without importing a second copy.
94pub trait ExternalObjectSource: Send + Sync {
95    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
96    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
97    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
98    fn list_states(&self) -> Result<Vec<StateId>>;
99}
100
101/// Explicit cache control for benchmarks and diagnostic tools.
102///
103/// Cache invalidation is not part of durable object storage semantics. Keeping
104/// it separate prevents remote stores such as Weft's backend from having to
105/// pretend they expose process-local cache controls merely to implement
106/// [`ObjectStore`].
107pub trait ObjectCacheControl: Send + Sync {
108    /// Drop process-local decoded-object caches, if this implementation has
109    /// any. The next read should observe the implementation's cold path.
110    fn clear_recent_caches(&self);
111}
112
113pub use crate::error::{HeddleError as StoreError, HeddleError, Result};
114
115/// Sidecar records that live outside the content-addressed object graph —
116/// signed redactions and state-visibility tiers. They never ride native packs
117/// and are transferred out-of-band. Backends that do not model them can use
118/// the default methods, while native stores override the relevant operations.
119pub trait SidecarStore: Send + Sync {
120    /// Whether the store holds any redaction record for the given blob.
121    ///
122    /// Redactions live in a sidecar (`<heddle_dir>/redactions/`) that is
123    /// structurally outside the content-addressed object graph so GC
124    /// can't reach them. The wire layer needs a cheap probe to decide
125    /// whether to ship a redaction for a blob in the closure, so this
126    /// is a separate method rather than a `get_*` + null check.
127    ///
128    /// Default impl returns `Ok(false)` — stores that don't model
129    /// redactions silently report "no redactions," which is the
130    /// correct behaviour for purely in-memory or remote-shim stores.
131    fn has_redactions_for_blob(&self, _blob: &ContentHash) -> Result<bool> {
132        Ok(false)
133    }
134
135    /// Return the raw rmp-encoded `RedactionsBlob` bytes for the given
136    /// blob, or `Ok(None)` if no redaction record exists. The bytes
137    /// are byte-identical to what was written by `put_redactions_bytes_for_blob`
138    /// (or by `Repository::put_redaction`); this is the wire-transfer
139    /// payload, not a re-serialized view.
140    ///
141    /// Default impl returns `Ok(None)`.
142    fn get_redactions_bytes_for_blob(&self, _blob: &ContentHash) -> Result<Option<Vec<u8>>> {
143        Ok(None)
144    }
145
146    /// Persist the rmp-encoded `RedactionsBlob` bytes for the given
147    /// blob. Receiver-side replay calls this after signature
148    /// verification so the bytes land in the same sidecar that the
149    /// sender's `Repository::put_redaction` writes to.
150    ///
151    /// Default impl returns an "unsupported" error — stores that don't
152    /// model redactions (e.g. read-only shims) refuse rather than
153    /// silently dropping the record.
154    fn put_redactions_bytes_for_blob(&self, _blob: &ContentHash, _bytes: &[u8]) -> Result<()> {
155        Err(HeddleError::InvalidObject(
156            "this object store does not support persisting redactions".to_string(),
157        ))
158    }
159
160    /// List every blob that has at least one redaction record. Used by
161    /// the GC pin guard and by sync to enumerate redactions for the
162    /// state closure. Order is unspecified; callers that need stable
163    /// ordering should sort.
164    ///
165    /// Default impl returns `Ok(vec![])`.
166    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
167        Ok(Vec::new())
168    }
169
170    /// Whether the store holds any state-visibility record for `state`.
171    ///
172    /// Like redactions, state-visibility records live in a sidecar outside
173    /// the content-addressed object graph and cannot ride native packs.
174    /// Sync uses this probe while enumerating a state closure so a non-public
175    /// state can advertise the sidecar that must travel out-of-pack.
176    ///
177    /// Default impl returns `Ok(false)` for stores that do not model this
178    /// sidecar.
179    fn has_state_visibility_for_state(&self, _state: &StateId) -> Result<bool> {
180        Ok(false)
181    }
182
183    /// Return the raw rmp-encoded `StateVisibilityBlob` bytes for `state`,
184    /// or `Ok(None)` if no sidecar exists. The bytes are the wire-transfer
185    /// payload for state visibility.
186    ///
187    /// Default impl returns `Ok(None)`.
188    fn get_state_visibility_bytes_for_state(&self, _state: &StateId) -> Result<Option<Vec<u8>>> {
189        Ok(None)
190    }
191
192    /// Persist raw `StateVisibilityBlob` bytes for `state`.
193    ///
194    /// Default impl returns an "unsupported" error so stores that do not
195    /// model the sidecar refuse instead of dropping it.
196    fn put_state_visibility_bytes_for_state(&self, _state: &StateId, _bytes: &[u8]) -> Result<()> {
197        Err(HeddleError::InvalidObject(
198            "this object store does not support persisting state visibility".to_string(),
199        ))
200    }
201
202    /// List every state with at least one state-visibility record.
203    ///
204    /// Default impl returns `Ok(vec![])`.
205    fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
206        Ok(Vec::new())
207    }
208}
209
210/// The result of resolving a tree hash that may be held either as the full
211/// canonical object or only as a redacted partial projection (HRT1).
212///
213/// A full tree SUPERSEDES a partial for the same hash. `Partial` is distinct
214/// from `Absent` on purpose: a partial clone that withholds an entry must be
215/// distinguishable from a repository that is missing or corrupt, so callers can
216/// present "withheld" rather than "gone".
217#[derive(Clone, Debug)]
218pub enum TreeRead {
219    /// The full canonical tree is held.
220    Full(Tree),
221    /// Only a redacted partial projection is held: its visible entries carry
222    /// their preimage and its withheld entries are marked opaque. Verifies
223    /// against the declared `State.tree` via `PartialTree::reconstruct_root`.
224    Partial(PartialTree),
225    /// Neither a full tree nor a partial projection is held for this hash.
226    Absent,
227}
228
229/// The outcome of storing a redacted partial projection under the monotone
230/// discipline enforced by [`ObjectStore::put_partial_tree`].
231#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232pub enum PartialTreeWrite {
233    /// The projection was written to the partial slot.
234    Stored {
235        /// Number of withheld (redacted) leaves.
236        redacted: usize,
237        /// Number of visible leaves.
238        visible: usize,
239    },
240    /// A full canonical tree for this hash is already held, so the projection
241    /// was dropped rather than stored — a full tree supersedes a partial, and a
242    /// partial never overwrites a full (monotone).
243    SupersededByFull,
244}
245
246/// Trait for object storage backends.
247///
248/// Sidecars remain a separate implementation seam, but every object store
249/// exposes that seam. This preserves object-safe `dyn ObjectStore` consumers
250/// such as Weft's local filesystem backend without coupling its S3 backend to
251/// the native store implementation.
252pub trait ObjectStore: SidecarStore + Send + Sync {
253    fn get_annotated_tag(&self, _hash: &ContentHash) -> Result<Option<AnnotatedTag>> {
254        Ok(None)
255    }
256    fn put_annotated_tag(&self, _tag: &AnnotatedTag) -> Result<ContentHash> {
257        Err(HeddleError::InvalidObject(
258            "object store does not support annotated tags".to_string(),
259        ))
260    }
261    fn list_annotated_tags(&self) -> Result<Vec<ContentHash>> {
262        Ok(Vec::new())
263    }
264    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
265    fn put_blob(&self, blob: &Blob) -> Result<ContentHash>;
266
267    /// Zero-copy variant of `get_blob`. Returns a [`bytes::Bytes`]
268    /// view of the blob's content, which for `FsStore` reads is a
269    /// slice into the pack file's mmap when the entry is non-delta
270    /// and uncompressed — no allocation, no memcpy.
271    ///
272    /// Default impl wraps `get_blob`'s `Vec<u8>` in a `Bytes` (one
273    /// Arc allocation, no body copy) so backends without a native
274    /// fast path still satisfy the contract. The mount's hot read
275    /// path goes through this method instead of `get_blob` so the
276    /// pack-mmap fast path lights up automatically.
277    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
278        Ok(self
279            .get_blob(hash)?
280            .map(|blob| bytes::Bytes::from(blob.into_content())))
281    }
282
283    /// Return the *uncompressed* byte length of the blob identified by
284    /// `hash`, or `Ok(None)` when the blob is not in the store.
285    ///
286    /// The contract is "size without paying for content": backends are
287    /// expected to honour this with a header read or index lookup
288    /// rather than a full decompression. This is the hot path for
289    /// directory listings (`ls -l` over a thread mount) where loading
290    /// every blob just to learn its size would dominate.
291    ///
292    /// The default implementation falls back to `get_blob` so backends
293    /// without a cheap size accessor still satisfy the contract; native
294    /// stores (`FsStore`, `InMemoryStore`) override this with a
295    /// header- or hashmap-only path.
296    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
297        Ok(self.get_blob(hash)?.map(|blob| blob.content().len() as u64))
298    }
299
300    /// Filesystem path of the loose blob whose on-disk bytes are
301    /// byte-identical to the blob's *uncompressed* content, suitable
302    /// for `hard_link`/`clonefile` materialization without going
303    /// through `get_blob`.
304    ///
305    /// Returns `None` when the blob is missing, is only available via
306    /// a packfile, is stored compressed (the on-disk bytes wouldn't
307    /// match what a worktree consumer needs to read), or the backend
308    /// doesn't expose stable filesystem paths (e.g. `InMemoryStore`). The
309    /// default impl returns `None` so non-`FsStore` backends silently fall
310    /// through to the bytes path.
311    fn loose_blob_path(&self, _hash: &ContentHash) -> Option<PathBuf> {
312        None
313    }
314
315    /// Ensure the blob identified by `hash` is materialized as an
316    /// uncompressed loose file at the canonical loose path so that
317    /// `loose_blob_path` returns `Some(path)` on a subsequent call.
318    ///
319    /// This is the "warm canonical store" path that lets the
320    /// hardlink-first materializer keep its 5–10× wall-clock and
321    /// storage-allocation wins after `pack_objects + prune_loose_objects`
322    /// has moved everything into a packfile. Without this, the lazy
323    /// hardlink path silently degrades to `fs::write(decompressed)` on
324    /// every materialize, because `loose_blob_path` returns `None` for
325    /// pack-only and compressed-loose blobs.
326    ///
327    /// Cost-amortization: the first promotion of a blob pays
328    /// `decompress + atomic write`. Every subsequent materialize of
329    /// the same blob — into the same worktree on `goto`, or into a
330    /// sibling worktree on `delegate` — is a single `link(2)`. Net
331    /// win for any N > 1 materializations; break-even at N == 1.
332    ///
333    /// Pack invariants are preserved: this method does not remove the
334    /// pack-resident copy. The blob lives in both pack and loose-
335    /// uncompressed until the next `prune_loose_objects` cycle, at
336    /// which point the loose mirror is discarded and a future
337    /// materialize re-promotes on demand.
338    ///
339    /// Idempotent: a blob that's already loose-and-uncompressed is a
340    /// no-op fast path. A blob that's loose-but-compressed is
341    /// rewritten in place (atomically) with the uncompressed bytes.
342    /// A blob that's pack-resident is decompressed out of the pack
343    /// and written loose without touching the pack.
344    ///
345    /// Returns `Ok(true)` when the call did real work (a write
346    /// happened), `Ok(false)` when it was a no-op (blob was already
347    /// loose+uncompressed), and `Err` when the blob isn't in the
348    /// store at all. The default impl returns `Ok(false)` for
349    /// backends that don't expose loose paths (`InMemoryStore`), since the
350    /// hardlink path is fundamentally inapplicable there.
351    fn promote_to_loose_uncompressed(&self, _hash: &ContentHash) -> Result<bool> {
352        Ok(false)
353    }
354
355    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
356        if blob.hash() != hash {
357            return Err(HeddleError::InvalidObject("blob hash mismatch".to_string()));
358        }
359        self.put_blob(blob)
360    }
361
362    fn has_blob(&self, hash: &ContentHash) -> Result<bool>;
363    /// Return whether the blob is owned by this store, excluding any configured
364    /// read-through source. Snapshot builders use this to ensure a new native
365    /// state owns its complete object closure.
366    fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
367        self.has_blob(hash)
368    }
369    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
370    /// Resolve one named tree entry. Pack-capable stores override this so a
371    /// lookup can use a restartable packed record instead of materializing the
372    /// complete tree.
373    fn get_tree_entry(&self, hash: &ContentHash, name: &str) -> Result<Option<TreeEntry>> {
374        Ok(self
375            .get_tree(hash)?
376            .and_then(|tree| tree.get(name).cloned()))
377    }
378    fn put_tree(&self, tree: &Tree) -> Result<ContentHash>;
379    fn has_tree(&self, hash: &ContentHash) -> Result<bool>;
380    /// Return whether the tree is owned by this store, excluding any configured
381    /// read-through source.
382    fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
383        self.has_tree(hash)
384    }
385
386    // ── Redacted partial-tree projections (HRT1) ──────────────────────
387    //
388    // A partial projection lives in a slot keyed by the canonical tree hash it
389    // projects, DISTINCT from the full-tree object slot. The full-tree methods
390    // above (`get_tree`/`has_tree`) never see or return a projection; the
391    // methods below are the only way in and out of the partial slot. Backends
392    // that do not model projections inherit the default impls (report "none" /
393    // refuse the write), mirroring the sidecar seam.
394
395    /// Whether the store holds a redacted partial projection keyed by `hash`.
396    /// Independent of [`ObjectStore::has_tree`], which reports the full object.
397    fn has_partial_tree(&self, _hash: &ContentHash) -> Result<bool> {
398        Ok(false)
399    }
400
401    /// Raw HRT1 partial-projection bytes for `hash`, or `Ok(None)`. These are
402    /// byte-identical to what [`ObjectStore::put_partial_tree_bytes`] wrote —
403    /// the wire-transfer payload, not a re-serialized view.
404    fn get_partial_tree_bytes(&self, _hash: &ContentHash) -> Result<Option<Vec<u8>>> {
405        Ok(None)
406    }
407
408    /// Persist raw HRT1 partial-projection bytes keyed by `hash`. This is the
409    /// low-level slot; callers should prefer [`ObjectStore::put_partial_tree`],
410    /// which verifies the projection and enforces the monotone discipline.
411    fn put_partial_tree_bytes(&self, _hash: &ContentHash, _bytes: &[u8]) -> Result<()> {
412        Err(HeddleError::InvalidObject(
413            "this object store does not support partial tree projections".to_string(),
414        ))
415    }
416
417    /// List every tree hash for which a partial projection is held. Order is
418    /// unspecified; callers that need stable ordering should sort.
419    fn list_partial_trees(&self) -> Result<Vec<ContentHash>> {
420        Ok(Vec::new())
421    }
422
423    /// Drop any partial projection held for `hash`. Idempotent — a no-op when
424    /// none is held. Used to reclaim the partial slot once the full tree is
425    /// backfilled.
426    fn remove_partial_tree(&self, _hash: &ContentHash) -> Result<()> {
427        Ok(())
428    }
429
430    /// Store a redacted partial projection under monotone discipline.
431    ///
432    /// `hrt1` must be an HRT1 body whose visible preimages + withheld leaf
433    /// hashes reconstruct `expected` (verified through
434    /// [`codec::decode_partial_tree`], i.e. Leg 1's `reconstruct_root`).
435    /// Monotone: a partial NEVER overwrites a full tree. If the full canonical
436    /// tree for `expected` is already held, the projection is dropped and
437    /// [`PartialTreeWrite::SupersededByFull`] is returned; otherwise the raw
438    /// bytes land in the partial slot.
439    fn put_partial_tree(&self, expected: &ContentHash, hrt1: &[u8]) -> Result<PartialTreeWrite> {
440        let partial = codec::decode_partial_tree(hrt1, *expected)?;
441        if self.has_tree(expected)? {
442            return Ok(PartialTreeWrite::SupersededByFull);
443        }
444        self.put_partial_tree_bytes(expected, hrt1)?;
445        let redacted = partial.redacted_count();
446        Ok(PartialTreeWrite::Stored {
447            redacted,
448            visible: partial.leaves().len() - redacted,
449        })
450    }
451
452    /// Read the tree for `hash` as the full canonical tree, a redacted partial
453    /// projection, or absent.
454    ///
455    /// A full tree SUPERSEDES a partial: when the full object is held it is
456    /// returned even if a partial projection also exists for the same hash.
457    /// [`ObjectStore::get_tree`] by contrast returns the full tree or `None`
458    /// and NEVER the partial, so a caller that needs the complete tree cannot
459    /// be silently handed a projection.
460    fn read_tree(&self, hash: &ContentHash) -> Result<TreeRead> {
461        if let Some(full) = self.get_tree(hash)? {
462            return Ok(TreeRead::Full(full));
463        }
464        match self.get_partial_tree_bytes(hash)? {
465            Some(bytes) => Ok(TreeRead::Partial(codec::decode_partial_tree(
466                &bytes, *hash,
467            )?)),
468            None => Ok(TreeRead::Absent),
469        }
470    }
471    /// Open a streamable HTR4 tree body. Store backends use sequential
472    /// verify: resume at ordinal > 0 is refused until the bytes are hashed.
473    fn open_tree(
474        &self,
475        tree_id: &ContentHash,
476        cursor: Option<&TreeResumeCursor>,
477    ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
478        let Some(body) = self.get_tree_serialized(tree_id)? else {
479            return Ok(None);
480        };
481        let body = if is_streamable_tree(&body) {
482            body
483        } else {
484            let tree = self
485                .get_tree(tree_id)?
486                .ok_or_else(|| HeddleError::NotFound(format!("tree {tree_id}")))?;
487            tree.encode_lean()?
488        };
489        Ok(Some(TreeEntryReader::open(
490            OpenedTreeBody::Bytes(crate::object::BytesTreeSource::sequential_verify(body)),
491            *tree_id,
492            cursor,
493        )?))
494    }
495    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
496    fn put_state(&self, state: &State) -> Result<()>;
497    fn has_state(&self, id: &StateId) -> Result<bool>;
498    fn list_states(&self) -> Result<Vec<StateId>>;
499    fn get_state_attachment(
500        &self,
501        _state: &StateId,
502        _id: &StateAttachmentId,
503    ) -> Result<Option<StateAttachment>> {
504        Ok(None)
505    }
506    fn put_state_attachment(&self, _attachment: &StateAttachment) -> Result<StateAttachmentId> {
507        Err(HeddleError::InvalidObject(
508            "object store does not support state attachments".to_string(),
509        ))
510    }
511    fn list_state_attachments(&self, _state: &StateId) -> Result<Vec<StateAttachment>> {
512        Ok(Vec::new())
513    }
514    fn get_action(&self, id: &ActionId) -> Result<Option<Action>>;
515    fn put_action(&self, action: &mut Action) -> Result<ActionId>;
516    fn list_actions(&self) -> Result<Vec<ActionId>>;
517    fn list_blobs(&self) -> Result<Vec<ContentHash>>;
518    fn list_trees(&self) -> Result<Vec<ContentHash>>;
519
520    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
521        self.put_blob_with_hash(&Blob::from_slice(data), hash)
522    }
523
524    /// Return the stored tree body for `hash`, without requiring HTR4.
525    ///
526    /// This is a migration seam, not a runtime compatibility reader: callers
527    /// that need current tree semantics should use [`ObjectStore::get_tree`].
528    /// Loose and packed backends must return the raw stored bytes so one-shot
529    /// migrations can canonicalize older encodings without a current-decoder
530    /// gate. Default impls that only have `get_tree` re-encode current trees.
531    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
532        self.get_tree(hash)?
533            .map(|tree| tree.encode_canonical().map_err(HeddleError::from))
534            .transpose()
535    }
536
537    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
538        // An HRT1 redacted projection is not a full tree: route it to the
539        // partial slot (monotone) instead of hard-refusing in the full-tree
540        // decoder.
541        if is_redacted_tree(data) {
542            self.put_partial_tree(&hash, data)?;
543            return Ok(hash);
544        }
545        let tree = codec::decode_tree_serialized_with_key(data, hash, None)?;
546        self.put_tree(&tree)
547    }
548
549    fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
550        let state = State::decode_current_msgpack(data)?;
551        if !state.accepts_stored_id(&id) {
552            return Err(HeddleError::InvalidObject(format!(
553                "state id mismatch: expected {id}, computed {}",
554                state.id()
555            )));
556        }
557        self.put_state(&state)
558    }
559
560    fn put_action_serialized(&self, data: &[u8], id: ActionId) -> Result<()> {
561        let mut action: Action = rmp_serde::from_slice(data)?;
562        let found_id = action.compute_id();
563        if found_id != id {
564            return Err(HeddleError::InvalidObject(format!(
565                "action id mismatch: expected {}, found {}",
566                id, found_id
567            )));
568        }
569        let stored_id = self.put_action(&mut action)?;
570        if stored_id != id {
571            return Err(HeddleError::InvalidObject(format!(
572                "action id mismatch after write: expected {}, found {}",
573                id, stored_id
574            )));
575        }
576        Ok(())
577    }
578
579    fn get_pack_object(
580        &self,
581        id: &pack::PackObjectId,
582    ) -> Result<Option<(pack::ObjectType, Vec<u8>)>> {
583        match id {
584            pack::PackObjectId::AnnotatedTag(hash) => Ok(self
585                .get_annotated_tag(hash)?
586                .map(|tag| (pack::ObjectType::AnnotatedTag, tag.encode_current_msgpack()))),
587            pack::PackObjectId::Hash(hash) => {
588                if let Some(blob) = self.get_blob(hash)? {
589                    return Ok(Some((pack::ObjectType::Blob, blob.content().to_vec())));
590                }
591                if let Some(tree) = self.get_tree(hash)? {
592                    return Ok(Some((pack::ObjectType::Tree, tree.encode_canonical()?)));
593                }
594                if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
595                    return Ok(Some((
596                        pack::ObjectType::Action,
597                        rmp_serde::to_vec_named(&action)?,
598                    )));
599                }
600                Ok(None)
601            }
602            pack::PackObjectId::StateId(change_id) => {
603                if let Some(state) = self.get_state(change_id)? {
604                    Ok(Some((
605                        pack::ObjectType::State,
606                        state.encode_current_msgpack()?,
607                    )))
608                } else {
609                    Ok(None)
610                }
611            }
612        }
613    }
614
615    /// Bulk-write a batch of blobs as a single durable unit. The default
616    /// implementation falls back to per-blob writes; backends that
617    /// support packfiles (i.e. `FsStore`) override this to install one
618    /// packfile + index — two fsyncs total instead of N. Used by the
619    /// snapshot hot path so writing 1000 small files takes ~one fsync,
620    /// not 1000.
621    ///
622    /// Blobs already present in the store are skipped on the way in
623    /// (the caller would otherwise duplicate them in the pack).
624    fn put_blobs_packed(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
625        for (hash, data) in blobs {
626            if !self.has_blob(&hash)? {
627                self.put_blob_bytes_with_hash(&data, hash)?;
628            }
629        }
630        Ok(())
631    }
632
633    /// Durably install a snapshot's newly-authored immutable object closure as
634    /// one storage batch. Pack-capable backends override this to share one pack
635    /// installation across blobs, the root tree, and the state; other backends
636    /// preserve the same ordering through their ordinary object methods.
637    fn put_snapshot_objects_packed(
638        &self,
639        blobs: Vec<(ContentHash, Vec<u8>)>,
640        tree: &Tree,
641        state: &State,
642    ) -> Result<()> {
643        self.put_blobs_packed(blobs)?;
644        self.put_tree(tree)?;
645        self.put_state(state)
646    }
647
648    /// Snapshot closure variant that also durably installs immutable authored
649    /// attachments. The separate method preserves the existing backend API;
650    /// pack-capable stores override it to share the snapshot pack barrier.
651    fn put_snapshot_objects_and_attachments_packed(
652        &self,
653        blobs: Vec<(ContentHash, Vec<u8>)>,
654        tree: &Tree,
655        state: &State,
656        attachments: Vec<StateAttachment>,
657    ) -> Result<()> {
658        self.put_snapshot_objects_packed(blobs, tree, state)?;
659        for attachment in attachments {
660            self.put_state_attachment(&attachment)?;
661        }
662        Ok(())
663    }
664    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<pack::PackObjectId>> {
665        let reader = pack::PackReader::from_slice(pack_data, index_data)?;
666        let ids = reader.list_ids()?;
667        for id in &ids {
668            let Some((obj_type, data)) = reader.get_object(id)? else {
669                continue;
670            };
671            match (id, obj_type) {
672                (pack::PackObjectId::Hash(hash), pack::ObjectType::Blob) => {
673                    self.put_blob_bytes_with_hash(&data, *hash)?;
674                }
675                (pack::PackObjectId::AnnotatedTag(hash), pack::ObjectType::AnnotatedTag) => {
676                    let tag = AnnotatedTag::decode_current_msgpack(&data)
677                        .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
678                    if tag.hash() != *hash {
679                        return Err(HeddleError::InvalidObject(
680                            "annotated tag hash mismatch".to_string(),
681                        ));
682                    }
683                    self.put_annotated_tag(&tag)?;
684                }
685                (pack::PackObjectId::Hash(hash), pack::ObjectType::Tree) => {
686                    self.put_tree_serialized(&data, *hash)?;
687                }
688                (pack::PackObjectId::Hash(hash), pack::ObjectType::Action) => {
689                    self.put_action_serialized(&data, ActionId::from_hash(*hash))?;
690                }
691                (pack::PackObjectId::StateId(change_id), pack::ObjectType::State) => {
692                    self.put_state_serialized(&data, *change_id)?;
693                }
694                (_, pack::ObjectType::TimelineOperation) => {
695                    return Err(HeddleError::InvalidObject(
696                        "timeline operations belong in the timeline pack store".to_string(),
697                    ));
698                }
699                _ => {
700                    return Err(HeddleError::InvalidObject(format!(
701                        "unsupported native pack object: {:?} {:?}",
702                        id, obj_type
703                    )));
704                }
705            }
706        }
707        Ok(ids)
708    }
709
710    /// Install a pack and its index from on-disk files
711    /// (typically produced by `StreamingPackBuilder`). The default
712    /// impl reads both files fully and delegates to `install_pack`,
713    /// so any backend that doesn't override this still works (at the
714    /// cost of giving back the bounded-memory promise). Real fs-
715    /// backed stores override this to `rename(2)` both files into the
716    /// pack directory without ever loading them.
717    ///
718    /// On success, the source files at `pack_path`/`index_path` may
719    /// have been moved or removed depending on the backend; callers
720    /// shouldn't continue to rely on them.
721    ///
722    /// Returns the ids of the installed objects — the same set
723    /// `install_pack` reports for the equivalent byte-buffer install,
724    /// so callers (e.g. native sync) read the installed ids off the
725    /// install result instead of tracking them out-of-band.
726    fn install_pack_streaming(
727        &self,
728        pack_path: &std::path::Path,
729        index_path: &std::path::Path,
730    ) -> Result<Vec<pack::PackObjectId>> {
731        let pack_data = std::fs::read(pack_path).map_err(StoreError::from)?;
732        let index_data = std::fs::read(index_path).map_err(StoreError::from)?;
733        let ids = self.install_pack(&pack_data, &index_data)?;
734        // Default impl: clean up the staged files. Override
735        // implementations that move/rename should not call super and
736        // should manage the file lifecycle themselves.
737        let _ = std::fs::remove_file(pack_path);
738        let _ = std::fs::remove_file(index_path);
739        Ok(ids)
740    }
741
742    fn begin_snapshot_write_batch(&self) -> Result<()> {
743        Ok(())
744    }
745
746    fn flush_snapshot_write_batch(&self) -> Result<()> {
747        Ok(())
748    }
749
750    fn abort_snapshot_write_batch(&self) {}
751}