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