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