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