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, State, StateAttachment,
8    StateAttachmentId, StateId, Tree, TreeEntryReader, TreeResumeCursor, is_canonical_tree,
9};
10
11pub mod codec;
12pub mod fs;
13pub mod liveness;
14#[cfg(any(test, feature = "memory-backend"))]
15pub mod memory;
16pub use heddle_pack::store::pack;
17pub mod shallow;
18mod snapshot_commit;
19pub mod source;
20pub mod store_compliance;
21pub mod writer_lease;
22
23pub use fs::{
24    DEFAULT_PACK_INSTALL_INTENT_TTL_SECS, FsRepackOperation, FsStore, PackInstallIntent,
25    PackInstallMetricsSnapshot, PackInstallPhase, PackInstallRecoverReport,
26    install_pack_bytes_journaled, pack_install_metrics_reset, pack_install_metrics_snapshot,
27    recover_pack_install_intents, recover_pack_install_intents_with_ttl,
28};
29pub use heddle_format::compression::{CompressionConfig, CompressionError, compress, decompress};
30pub use liveness::{
31    AGENT_LEASE_DURATION, Liveness, current_boot_id, process_alive, reservation_liveness_at,
32};
33#[cfg(any(test, feature = "memory-backend"))]
34pub use memory::InMemoryStore;
35pub use pack::{
36    CancellationToken as RepackCancellationToken, LoadMonitor as RepackLoadMonitor, PackBuilder,
37    PackObjectId, PackReader, PackStats, RepackContext, RepackError, RepackHandle, RepackInventory,
38    RepackOperation, RepackOutcome, RepackPolicy, RepackReason, RepackReport, RepackResourceLimits,
39    RepackSchedule, RepackScheduler, StreamingPackBuilder, SyncData,
40};
41pub use shallow::ShallowInfo;
42#[doc(hidden)]
43pub use snapshot_commit::{
44    SNAPSHOT_COMMIT_ARTIFACT_SCHEMA, SnapshotCommitArtifact, SnapshotCommitDescriptor,
45    SnapshotPackManager,
46};
47#[cfg(feature = "async-source")]
48pub use source::AsyncObjectSource;
49pub use source::ObjectSource;
50pub use writer_lease::{
51    WriterLease, WriterLeaseAuthOutcome, WriterLeaseDraft, WriterLeaseGrant,
52    WriterLeaseReserveOutcome, WriterLeaseStatus, WriterLeaseStore, generate_writer_lease_id,
53    generate_writer_lease_token,
54};
55
56/// Read-only objects whose authoritative representation lives outside the
57/// native Heddle object directory. Git-overlay repositories use this seam to
58/// translate objects directly from `.git` without importing a second copy.
59pub trait ExternalObjectSource: Send + Sync {
60    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
61    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
62    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
63    fn list_states(&self) -> Result<Vec<StateId>>;
64}
65
66pub use crate::error::{HeddleError as StoreError, HeddleError, Result};
67
68/// Sidecar records that live outside the content-addressed object graph —
69/// signed redactions and state-visibility tiers. They never ride native packs
70/// and are transferred out-of-band. Backends that do not model them can use
71/// the default methods, while native stores override the relevant operations.
72pub trait SidecarStore: Send + Sync {
73    /// Whether the store holds any redaction record for the given blob.
74    ///
75    /// Redactions live in a sidecar (`<heddle_dir>/redactions/`) that is
76    /// structurally outside the content-addressed object graph so GC
77    /// can't reach them. The wire layer needs a cheap probe to decide
78    /// whether to ship a redaction for a blob in the closure, so this
79    /// is a separate method rather than a `get_*` + null check.
80    ///
81    /// Default impl returns `Ok(false)` — stores that don't model
82    /// redactions silently report "no redactions," which is the
83    /// correct behaviour for purely in-memory or remote-shim stores.
84    fn has_redactions_for_blob(&self, _blob: &ContentHash) -> Result<bool> {
85        Ok(false)
86    }
87
88    /// Return the raw rmp-encoded `RedactionsBlob` bytes for the given
89    /// blob, or `Ok(None)` if no redaction record exists. The bytes
90    /// are byte-identical to what was written by `put_redactions_bytes_for_blob`
91    /// (or by `Repository::put_redaction`); this is the wire-transfer
92    /// payload, not a re-serialized view.
93    ///
94    /// Default impl returns `Ok(None)`.
95    fn get_redactions_bytes_for_blob(&self, _blob: &ContentHash) -> Result<Option<Vec<u8>>> {
96        Ok(None)
97    }
98
99    /// Persist the rmp-encoded `RedactionsBlob` bytes for the given
100    /// blob. Receiver-side replay calls this after signature
101    /// verification so the bytes land in the same sidecar that the
102    /// sender's `Repository::put_redaction` writes to.
103    ///
104    /// Default impl returns an "unsupported" error — stores that don't
105    /// model redactions (e.g. read-only shims) refuse rather than
106    /// silently dropping the record.
107    fn put_redactions_bytes_for_blob(&self, _blob: &ContentHash, _bytes: &[u8]) -> Result<()> {
108        Err(HeddleError::InvalidObject(
109            "this object store does not support persisting redactions".to_string(),
110        ))
111    }
112
113    /// List every blob that has at least one redaction record. Used by
114    /// the GC pin guard and by sync to enumerate redactions for the
115    /// state closure. Order is unspecified; callers that need stable
116    /// ordering should sort.
117    ///
118    /// Default impl returns `Ok(vec![])`.
119    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
120        Ok(Vec::new())
121    }
122
123    /// Whether the store holds any state-visibility record for `state`.
124    ///
125    /// Like redactions, state-visibility records live in a sidecar outside
126    /// the content-addressed object graph and cannot ride native packs.
127    /// Sync uses this probe while enumerating a state closure so a non-public
128    /// state can advertise the sidecar that must travel out-of-pack.
129    ///
130    /// Default impl returns `Ok(false)` for stores that do not model this
131    /// sidecar.
132    fn has_state_visibility_for_state(&self, _state: &StateId) -> Result<bool> {
133        Ok(false)
134    }
135
136    /// Return the raw rmp-encoded `StateVisibilityBlob` bytes for `state`,
137    /// or `Ok(None)` if no sidecar exists. The bytes are the wire-transfer
138    /// payload for state visibility.
139    ///
140    /// Default impl returns `Ok(None)`.
141    fn get_state_visibility_bytes_for_state(&self, _state: &StateId) -> Result<Option<Vec<u8>>> {
142        Ok(None)
143    }
144
145    /// Persist raw `StateVisibilityBlob` bytes for `state`.
146    ///
147    /// Default impl returns an "unsupported" error so stores that do not
148    /// model the sidecar refuse instead of dropping it.
149    fn put_state_visibility_bytes_for_state(&self, _state: &StateId, _bytes: &[u8]) -> Result<()> {
150        Err(HeddleError::InvalidObject(
151            "this object store does not support persisting state visibility".to_string(),
152        ))
153    }
154
155    /// List every state with at least one state-visibility record.
156    ///
157    /// Default impl returns `Ok(vec![])`.
158    fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
159        Ok(Vec::new())
160    }
161}
162
163/// Trait for object storage backends.
164///
165/// Sidecars remain a separate implementation seam, but every object store
166/// exposes that seam. This preserves object-safe `dyn ObjectStore` consumers
167/// such as Weft's local filesystem backend without coupling its S3 backend to
168/// the native store implementation.
169pub trait ObjectStore: SidecarStore + Send + Sync {
170    fn get_annotated_tag(&self, _hash: &ContentHash) -> Result<Option<AnnotatedTag>> {
171        Ok(None)
172    }
173    fn put_annotated_tag(&self, _tag: &AnnotatedTag) -> Result<ContentHash> {
174        Err(HeddleError::InvalidObject(
175            "object store does not support annotated tags".to_string(),
176        ))
177    }
178    fn list_annotated_tags(&self) -> Result<Vec<ContentHash>> {
179        Ok(Vec::new())
180    }
181    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
182    fn put_blob(&self, blob: &Blob) -> Result<ContentHash>;
183
184    /// Zero-copy variant of `get_blob`. Returns a [`bytes::Bytes`]
185    /// view of the blob's content, which for `FsStore` reads is a
186    /// slice into the pack file's mmap when the entry is non-delta
187    /// and uncompressed — no allocation, no memcpy.
188    ///
189    /// Default impl wraps `get_blob`'s `Vec<u8>` in a `Bytes` (one
190    /// Arc allocation, no body copy) so backends without a native
191    /// fast path still satisfy the contract. The mount's hot read
192    /// path goes through this method instead of `get_blob` so the
193    /// pack-mmap fast path lights up automatically.
194    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
195        Ok(self
196            .get_blob(hash)?
197            .map(|blob| bytes::Bytes::from(blob.into_content())))
198    }
199
200    /// Return the *uncompressed* byte length of the blob identified by
201    /// `hash`, or `Ok(None)` when the blob is not in the store.
202    ///
203    /// The contract is "size without paying for content": backends are
204    /// expected to honour this with a header read or index lookup
205    /// rather than a full decompression. This is the hot path for
206    /// directory listings (`ls -l` over a thread mount) where loading
207    /// every blob just to learn its size would dominate.
208    ///
209    /// The default implementation falls back to `get_blob` so backends
210    /// without a cheap size accessor still satisfy the contract; native
211    /// stores (`FsStore`, `InMemoryStore`) override this with a
212    /// header- or hashmap-only path.
213    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
214        Ok(self.get_blob(hash)?.map(|blob| blob.content().len() as u64))
215    }
216
217    /// Filesystem path of the loose blob whose on-disk bytes are
218    /// byte-identical to the blob's *uncompressed* content, suitable
219    /// for `hard_link`/`clonefile` materialization without going
220    /// through `get_blob`.
221    ///
222    /// Returns `None` when the blob is missing, is only available via
223    /// a packfile, is stored compressed (the on-disk bytes wouldn't
224    /// match what a worktree consumer needs to read), or the backend
225    /// doesn't expose stable filesystem paths (e.g. `InMemoryStore`). The
226    /// default impl returns `None` so non-`FsStore` backends silently fall
227    /// through to the bytes path.
228    fn loose_blob_path(&self, _hash: &ContentHash) -> Option<PathBuf> {
229        None
230    }
231
232    /// Ensure the blob identified by `hash` is materialized as an
233    /// uncompressed loose file at the canonical loose path so that
234    /// `loose_blob_path` returns `Some(path)` on a subsequent call.
235    ///
236    /// This is the "warm canonical store" path that lets the
237    /// hardlink-first materializer keep its 5–10× wall-clock and
238    /// storage-allocation wins after `pack_objects + prune_loose_objects`
239    /// has moved everything into a packfile. Without this, the lazy
240    /// hardlink path silently degrades to `fs::write(decompressed)` on
241    /// every materialize, because `loose_blob_path` returns `None` for
242    /// pack-only and compressed-loose blobs.
243    ///
244    /// Cost-amortization: the first promotion of a blob pays
245    /// `decompress + atomic write`. Every subsequent materialize of
246    /// the same blob — into the same worktree on `goto`, or into a
247    /// sibling worktree on `delegate` — is a single `link(2)`. Net
248    /// win for any N > 1 materializations; break-even at N == 1.
249    ///
250    /// Pack invariants are preserved: this method does not remove the
251    /// pack-resident copy. The blob lives in both pack and loose-
252    /// uncompressed until the next `prune_loose_objects` cycle, at
253    /// which point the loose mirror is discarded and a future
254    /// materialize re-promotes on demand.
255    ///
256    /// Idempotent: a blob that's already loose-and-uncompressed is a
257    /// no-op fast path. A blob that's loose-but-compressed is
258    /// rewritten in place (atomically) with the uncompressed bytes.
259    /// A blob that's pack-resident is decompressed out of the pack
260    /// and written loose without touching the pack.
261    ///
262    /// Returns `Ok(true)` when the call did real work (a write
263    /// happened), `Ok(false)` when it was a no-op (blob was already
264    /// loose+uncompressed), and `Err` when the blob isn't in the
265    /// store at all. The default impl returns `Ok(false)` for
266    /// backends that don't expose loose paths (`InMemoryStore`), since the
267    /// hardlink path is fundamentally inapplicable there.
268    fn promote_to_loose_uncompressed(&self, _hash: &ContentHash) -> Result<bool> {
269        Ok(false)
270    }
271
272    /// Drop any in-memory caches of decompressed blobs / trees /
273    /// states. The next access to any object pays full I/O +
274    /// decompression cost. No-op for stores that don't cache
275    /// (`InMemoryStore` is already the source of truth).
276    ///
277    /// Exposed primarily for benchmarks that want to measure the
278    /// true cold-cache path without rebuilding the store from
279    /// scratch. Production callers don't need to invoke this.
280    fn clear_recent_caches(&self) {}
281
282    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
283        if blob.hash() != hash {
284            return Err(HeddleError::InvalidObject("blob hash mismatch".to_string()));
285        }
286        self.put_blob(blob)
287    }
288
289    fn has_blob(&self, hash: &ContentHash) -> Result<bool>;
290    /// Return whether the blob is owned by this store, excluding any configured
291    /// read-through source. Snapshot builders use this to ensure a new native
292    /// state owns its complete object closure.
293    fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
294        self.has_blob(hash)
295    }
296    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
297    fn put_tree(&self, tree: &Tree) -> Result<ContentHash>;
298    fn has_tree(&self, hash: &ContentHash) -> Result<bool>;
299    /// Return whether the tree is owned by this store, excluding any configured
300    /// read-through source.
301    fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
302        self.has_tree(hash)
303    }
304    /// Open a streamable HTR4 tree body. Store backends use sequential
305    /// verify: resume at ordinal > 0 is refused until the bytes are hashed.
306    fn open_tree(
307        &self,
308        tree_id: &ContentHash,
309        cursor: Option<&TreeResumeCursor>,
310    ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
311        let Some(body) = self.get_tree_serialized(tree_id)? else {
312            return Ok(None);
313        };
314        if !is_canonical_tree(&body) {
315            return Err(HeddleError::InvalidObject(
316                "tree body is not the streamable canonical encoding".to_string(),
317            ));
318        }
319        Ok(Some(TreeEntryReader::open(
320            OpenedTreeBody::Bytes(crate::object::BytesTreeSource::sequential_verify(body)),
321            *tree_id,
322            cursor,
323        )?))
324    }
325    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
326    fn put_state(&self, state: &State) -> Result<()>;
327    fn has_state(&self, id: &StateId) -> Result<bool>;
328    fn list_states(&self) -> Result<Vec<StateId>>;
329    fn get_state_attachment(
330        &self,
331        _state: &StateId,
332        _id: &StateAttachmentId,
333    ) -> Result<Option<StateAttachment>> {
334        Ok(None)
335    }
336    fn put_state_attachment(&self, _attachment: &StateAttachment) -> Result<StateAttachmentId> {
337        Err(HeddleError::InvalidObject(
338            "object store does not support state attachments".to_string(),
339        ))
340    }
341    fn list_state_attachments(&self, _state: &StateId) -> Result<Vec<StateAttachment>> {
342        Ok(Vec::new())
343    }
344    fn get_action(&self, id: &ActionId) -> Result<Option<Action>>;
345    fn put_action(&self, action: &mut Action) -> Result<ActionId>;
346    fn list_actions(&self) -> Result<Vec<ActionId>>;
347    fn list_blobs(&self) -> Result<Vec<ContentHash>>;
348    fn list_trees(&self) -> Result<Vec<ContentHash>>;
349
350    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
351        self.put_blob_with_hash(&Blob::from_slice(data), hash)
352    }
353
354    /// Return the stored tree body for `hash`, without requiring HTR4.
355    ///
356    /// This is a migration seam, not a runtime compatibility reader: callers
357    /// that need current tree semantics should use [`ObjectStore::get_tree`].
358    /// Loose and packed backends must return the raw stored bytes so one-shot
359    /// migrations can canonicalize older encodings without a current-decoder
360    /// gate. Default impls that only have `get_tree` re-encode current trees.
361    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
362        self.get_tree(hash)?
363            .map(|tree| tree.encode_canonical().map_err(HeddleError::from))
364            .transpose()
365    }
366
367    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
368        let tree = Tree::decode_canonical(data)?;
369        if tree.hash() != hash {
370            return Err(HeddleError::Corruption {
371                expected: hash,
372                found: tree.hash(),
373            });
374        }
375        self.put_tree(&tree)
376    }
377
378    fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
379        let state: State = rmp_serde::from_slice(data)?;
380        if !state.accepts_stored_id(&id) {
381            return Err(HeddleError::InvalidObject(format!(
382                "state id mismatch: expected {id}, computed {}",
383                state.id()
384            )));
385        }
386        self.put_state(&state)
387    }
388
389    fn put_action_serialized(&self, data: &[u8], id: ActionId) -> Result<()> {
390        let mut action: Action = rmp_serde::from_slice(data)?;
391        let found_id = action.compute_id();
392        if found_id != id {
393            return Err(HeddleError::InvalidObject(format!(
394                "action id mismatch: expected {}, found {}",
395                id, found_id
396            )));
397        }
398        let stored_id = self.put_action(&mut action)?;
399        if stored_id != id {
400            return Err(HeddleError::InvalidObject(format!(
401                "action id mismatch after write: expected {}, found {}",
402                id, stored_id
403            )));
404        }
405        Ok(())
406    }
407
408    fn get_pack_object(
409        &self,
410        id: &pack::PackObjectId,
411    ) -> Result<Option<(pack::ObjectType, Vec<u8>)>> {
412        match id {
413            pack::PackObjectId::AnnotatedTag(hash) => Ok(self
414                .get_annotated_tag(hash)?
415                .map(|tag| (pack::ObjectType::AnnotatedTag, tag.encode_current_msgpack()))),
416            pack::PackObjectId::Hash(hash) => {
417                if let Some(blob) = self.get_blob(hash)? {
418                    return Ok(Some((pack::ObjectType::Blob, blob.content().to_vec())));
419                }
420                if let Some(tree) = self.get_tree(hash)? {
421                    return Ok(Some((pack::ObjectType::Tree, tree.encode_canonical()?)));
422                }
423                if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
424                    return Ok(Some((
425                        pack::ObjectType::Action,
426                        rmp_serde::to_vec_named(&action)?,
427                    )));
428                }
429                Ok(None)
430            }
431            pack::PackObjectId::StateId(change_id) => {
432                if let Some(state) = self.get_state(change_id)? {
433                    Ok(Some((
434                        pack::ObjectType::State,
435                        rmp_serde::to_vec_named(&state)?,
436                    )))
437                } else {
438                    Ok(None)
439                }
440            }
441        }
442    }
443
444    /// Bulk-write a batch of blobs as a single durable unit. The default
445    /// implementation falls back to per-blob writes; backends that
446    /// support packfiles (i.e. `FsStore`) override this to install one
447    /// packfile + index — two fsyncs total instead of N. Used by the
448    /// snapshot hot path so writing 1000 small files takes ~one fsync,
449    /// not 1000.
450    ///
451    /// Blobs already present in the store are skipped on the way in
452    /// (the caller would otherwise duplicate them in the pack).
453    fn put_blobs_packed(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
454        for (hash, data) in blobs {
455            if !self.has_blob(&hash)? {
456                self.put_blob_bytes_with_hash(&data, hash)?;
457            }
458        }
459        Ok(())
460    }
461
462    /// Durably install a snapshot's newly-authored immutable object closure as
463    /// one storage batch. Pack-capable backends override this to share one pack
464    /// installation across blobs, the root tree, and the state; other backends
465    /// preserve the same ordering through their ordinary object methods.
466    fn put_snapshot_objects_packed(
467        &self,
468        blobs: Vec<(ContentHash, Vec<u8>)>,
469        tree: &Tree,
470        state: &State,
471    ) -> Result<()> {
472        self.put_blobs_packed(blobs)?;
473        self.put_tree(tree)?;
474        self.put_state(state)
475    }
476
477    /// Snapshot closure variant that also durably installs immutable authored
478    /// attachments. The separate method preserves the existing backend API;
479    /// pack-capable stores override it to share the snapshot pack barrier.
480    fn put_snapshot_objects_and_attachments_packed(
481        &self,
482        blobs: Vec<(ContentHash, Vec<u8>)>,
483        tree: &Tree,
484        state: &State,
485        attachments: Vec<StateAttachment>,
486    ) -> Result<()> {
487        self.put_snapshot_objects_packed(blobs, tree, state)?;
488        for attachment in attachments {
489            self.put_state_attachment(&attachment)?;
490        }
491        Ok(())
492    }
493    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<pack::PackObjectId>> {
494        let reader = pack::PackReader::from_slice(pack_data, index_data)?;
495        let ids = reader.list_ids()?;
496        for id in &ids {
497            let Some((obj_type, data)) = reader.get_object(id)? else {
498                continue;
499            };
500            match (id, obj_type) {
501                (pack::PackObjectId::Hash(hash), pack::ObjectType::Blob) => {
502                    self.put_blob_bytes_with_hash(&data, *hash)?;
503                }
504                (pack::PackObjectId::AnnotatedTag(hash), pack::ObjectType::AnnotatedTag) => {
505                    let tag = AnnotatedTag::decode_current_msgpack(&data)
506                        .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
507                    if tag.hash() != *hash {
508                        return Err(HeddleError::InvalidObject(
509                            "annotated tag hash mismatch".to_string(),
510                        ));
511                    }
512                    self.put_annotated_tag(&tag)?;
513                }
514                (pack::PackObjectId::Hash(hash), pack::ObjectType::Tree) => {
515                    self.put_tree_serialized(&data, *hash)?;
516                }
517                (pack::PackObjectId::Hash(hash), pack::ObjectType::Action) => {
518                    self.put_action_serialized(&data, ActionId::from_hash(*hash))?;
519                }
520                (pack::PackObjectId::StateId(change_id), pack::ObjectType::State) => {
521                    self.put_state_serialized(&data, *change_id)?;
522                }
523                (_, pack::ObjectType::TimelineOperation) => {
524                    return Err(HeddleError::InvalidObject(
525                        "timeline operations belong in the timeline pack store".to_string(),
526                    ));
527                }
528                _ => {
529                    return Err(HeddleError::InvalidObject(format!(
530                        "unsupported native pack object: {:?} {:?}",
531                        id, obj_type
532                    )));
533                }
534            }
535        }
536        Ok(ids)
537    }
538
539    /// Install a pack and its index from on-disk files
540    /// (typically produced by `StreamingPackBuilder`). The default
541    /// impl reads both files fully and delegates to `install_pack`,
542    /// so any backend that doesn't override this still works (at the
543    /// cost of giving back the bounded-memory promise). Real fs-
544    /// backed stores override this to `rename(2)` both files into the
545    /// pack directory without ever loading them.
546    ///
547    /// On success, the source files at `pack_path`/`index_path` may
548    /// have been moved or removed depending on the backend; callers
549    /// shouldn't continue to rely on them.
550    ///
551    /// Returns the ids of the installed objects — the same set
552    /// `install_pack` reports for the equivalent byte-buffer install,
553    /// so callers (e.g. native sync) read the installed ids off the
554    /// install result instead of tracking them out-of-band.
555    fn install_pack_streaming(
556        &self,
557        pack_path: &std::path::Path,
558        index_path: &std::path::Path,
559    ) -> Result<Vec<pack::PackObjectId>> {
560        let pack_data = std::fs::read(pack_path).map_err(StoreError::from)?;
561        let index_data = std::fs::read(index_path).map_err(StoreError::from)?;
562        let ids = self.install_pack(&pack_data, &index_data)?;
563        // Default impl: clean up the staged files. Override
564        // implementations that move/rename should not call super and
565        // should manage the file lifecycle themselves.
566        let _ = std::fs::remove_file(pack_path);
567        let _ = std::fs::remove_file(index_path);
568        Ok(ids)
569    }
570
571    fn pack_objects(&self, delta_search: bool) -> Result<(u64, u64)> {
572        let _ = delta_search;
573        Ok((0, 0))
574    }
575
576    fn prune_loose_objects(&self) -> Result<(u64, u64)> {
577        Ok((0, 0))
578    }
579
580    /// Remove only pack/index pairs that fail checksum, index, or object-hash
581    /// validation so a clone repair pull advertises their objects as missing.
582    fn discard_corrupt_clone_packs(&self) -> Result<usize> {
583        Ok(0)
584    }
585
586    fn begin_snapshot_write_batch(&self) -> Result<()> {
587        Ok(())
588    }
589
590    fn flush_snapshot_write_batch(&self) -> Result<()> {
591        Ok(())
592    }
593
594    fn abort_snapshot_write_batch(&self) {}
595}