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, sync::Arc};
5
6use crate::object::{
7    Action, ActionId, Blob, ContentHash, State, StateAttachment, StateAttachmentId, StateId, Tree,
8};
9
10pub mod actor_presence;
11pub mod agent_task;
12pub mod codec;
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 actor_presence::{
25    ActorChainNode, ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary,
26    ContextQueryEntry, generate_actor_session_id,
27};
28pub use agent_task::{
29    AGENT_TASK_SCHEMA_VERSION, AgentTaskRecord, AgentTaskStatus, AgentTaskStore,
30    generate_agent_task_id, validate_task_id,
31};
32pub use fs::{
33    DEFAULT_PACK_INSTALL_INTENT_TTL_SECS, FsStore, PackInstallIntent, PackInstallMetricsSnapshot,
34    PackInstallPhase, PackInstallRecoverReport, install_pack_bytes_journaled,
35    pack_install_metrics_reset, pack_install_metrics_snapshot, recover_pack_install_intents,
36    recover_pack_install_intents_with_ttl,
37};
38pub use heddle_format::compression::{CompressionConfig, CompressionError, compress, decompress};
39pub use liveness::{
40    AGENT_LEASE_DURATION, Liveness, current_boot_id, process_alive, reservation_liveness_at,
41};
42#[cfg(any(test, feature = "memory-backend"))]
43pub use memory::InMemoryStore;
44pub use pack::{PackBuilder, PackObjectId, PackReader, PackStats, StreamingPackBuilder, SyncData};
45pub use shallow::ShallowInfo;
46#[doc(hidden)]
47pub use snapshot_commit::{
48    SNAPSHOT_COMMIT_ARTIFACT_SCHEMA, SnapshotCommitArtifact, SnapshotCommitDescriptor,
49    SnapshotPackManager,
50};
51#[cfg(feature = "async-source")]
52pub use source::AsyncObjectSource;
53pub use source::ObjectSource;
54pub use writer_lease::{
55    WriterLease, WriterLeaseAuthOutcome, WriterLeaseDraft, WriterLeaseGrant,
56    WriterLeaseReserveOutcome, WriterLeaseStatus, WriterLeaseStore, generate_writer_lease_id,
57    generate_writer_lease_token,
58};
59
60/// Read-only objects whose authoritative representation lives outside the
61/// native Heddle object directory. Git-overlay repositories use this seam to
62/// translate objects directly from `.git` without importing a second copy.
63pub trait ExternalObjectSource: Send + Sync {
64    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
65    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
66    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
67    fn list_states(&self) -> Result<Vec<StateId>>;
68}
69
70pub use crate::error::{HeddleError as StoreError, HeddleError, Result};
71
72/// Static-dispatch enum over the concrete object stores Heddle ships.
73///
74/// This is the default `S` for [`Repository`](crate) so the store backend
75/// remains compile-time-monomorphized — no vtable. Each [`ObjectStore`] method
76/// `match`-dispatches to the inner variant, so the compiler inlines through
77/// the enum to the concrete backend's implementation (including its overridden
78/// default methods).
79///
80/// Sealed by construction: only the variants enumerated here are valid
81/// stores. Heddle is the sole implementer (heddle#259 / #283) — `AnyStore`
82/// is not a public extension point.
83#[derive(Clone)]
84pub enum AnyStore {
85    Fs(FsStore),
86}
87
88/// Forward an [`ObjectStore`] call to the active [`AnyStore`] variant.
89///
90/// Every arm calls the *same* method on the inner concrete store, so a
91/// backend's override of a defaulted trait method (e.g. `FsStore::blob_size`)
92/// is preserved rather than falling back to the trait default.
93macro_rules! any_store_dispatch {
94    ($self:ident, $method:ident ( $($arg:expr),* )) => {
95        match $self {
96            AnyStore::Fs(inner) => inner.$method($($arg),*),
97        }
98    };
99}
100
101impl ObjectStore for AnyStore {
102    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
103        match self {
104            AnyStore::Fs(inner) => ObjectStore::get_blob(inner, hash),
105        }
106    }
107    fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
108        any_store_dispatch!(self, put_blob(blob))
109    }
110    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
111        match self {
112            AnyStore::Fs(inner) => ObjectStore::get_blob_bytes(inner, hash),
113        }
114    }
115    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
116        any_store_dispatch!(self, blob_size(hash))
117    }
118    fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
119        any_store_dispatch!(self, loose_blob_path(hash))
120    }
121    fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
122        any_store_dispatch!(self, promote_to_loose_uncompressed(hash))
123    }
124    fn clear_recent_caches(&self) {
125        any_store_dispatch!(self, clear_recent_caches())
126    }
127    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
128        any_store_dispatch!(self, put_blob_with_hash(blob, hash))
129    }
130    fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
131        any_store_dispatch!(self, has_blob(hash))
132    }
133    fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
134        any_store_dispatch!(self, has_blob_locally(hash))
135    }
136    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
137        match self {
138            AnyStore::Fs(inner) => ObjectStore::get_tree(inner, hash),
139        }
140    }
141    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
142        match self {
143            AnyStore::Fs(inner) => ObjectStore::get_tree_serialized(inner, hash),
144        }
145    }
146    fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
147        any_store_dispatch!(self, put_tree(tree))
148    }
149    fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
150        any_store_dispatch!(self, has_tree(hash))
151    }
152    fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
153        any_store_dispatch!(self, has_tree_locally(hash))
154    }
155    fn get_state(&self, id: &StateId) -> Result<Option<State>> {
156        match self {
157            AnyStore::Fs(inner) => ObjectStore::get_state(inner, id),
158        }
159    }
160    fn put_state(&self, state: &State) -> Result<()> {
161        any_store_dispatch!(self, put_state(state))
162    }
163    fn has_state(&self, id: &StateId) -> Result<bool> {
164        any_store_dispatch!(self, has_state(id))
165    }
166    fn list_states(&self) -> Result<Vec<StateId>> {
167        any_store_dispatch!(self, list_states())
168    }
169    fn get_state_attachment(
170        &self,
171        state: &StateId,
172        id: &StateAttachmentId,
173    ) -> Result<Option<StateAttachment>> {
174        any_store_dispatch!(self, get_state_attachment(state, id))
175    }
176    fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
177        any_store_dispatch!(self, put_state_attachment(attachment))
178    }
179    fn list_state_attachments(&self, state: &StateId) -> Result<Vec<StateAttachment>> {
180        any_store_dispatch!(self, list_state_attachments(state))
181    }
182    fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
183        any_store_dispatch!(self, get_action(id))
184    }
185    fn put_action(&self, action: &mut Action) -> Result<ActionId> {
186        any_store_dispatch!(self, put_action(action))
187    }
188    fn list_actions(&self) -> Result<Vec<ActionId>> {
189        any_store_dispatch!(self, list_actions())
190    }
191    fn list_blobs(&self) -> Result<Vec<ContentHash>> {
192        any_store_dispatch!(self, list_blobs())
193    }
194    fn list_trees(&self) -> Result<Vec<ContentHash>> {
195        any_store_dispatch!(self, list_trees())
196    }
197    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
198        any_store_dispatch!(self, put_blob_bytes_with_hash(data, hash))
199    }
200    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
201        match self {
202            AnyStore::Fs(inner) => ObjectStore::put_tree_serialized(inner, data, hash),
203        }
204    }
205    fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
206        any_store_dispatch!(self, put_state_serialized(data, id))
207    }
208    fn put_action_serialized(&self, data: &[u8], id: ActionId) -> Result<()> {
209        any_store_dispatch!(self, put_action_serialized(data, id))
210    }
211    fn get_pack_object(
212        &self,
213        id: &pack::PackObjectId,
214    ) -> Result<Option<(pack::ObjectType, Vec<u8>)>> {
215        any_store_dispatch!(self, get_pack_object(id))
216    }
217    fn put_blobs_packed(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
218        any_store_dispatch!(self, put_blobs_packed(blobs))
219    }
220    fn put_snapshot_objects_packed(
221        &self,
222        blobs: Vec<(ContentHash, Vec<u8>)>,
223        tree: &Tree,
224        state: &State,
225    ) -> Result<()> {
226        any_store_dispatch!(self, put_snapshot_objects_packed(blobs, tree, state))
227    }
228    fn put_snapshot_objects_and_attachments_packed(
229        &self,
230        blobs: Vec<(ContentHash, Vec<u8>)>,
231        tree: &Tree,
232        state: &State,
233        attachments: Vec<StateAttachment>,
234    ) -> Result<()> {
235        any_store_dispatch!(
236            self,
237            put_snapshot_objects_and_attachments_packed(blobs, tree, state, attachments)
238        )
239    }
240
241    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<pack::PackObjectId>> {
242        any_store_dispatch!(self, install_pack(pack_data, index_data))
243    }
244    fn install_pack_streaming(
245        &self,
246        pack_path: &std::path::Path,
247        index_path: &std::path::Path,
248    ) -> Result<Vec<pack::PackObjectId>> {
249        any_store_dispatch!(self, install_pack_streaming(pack_path, index_path))
250    }
251    fn pack_objects(&self, aggressive: bool) -> Result<(u64, u64)> {
252        any_store_dispatch!(self, pack_objects(aggressive))
253    }
254    fn prune_loose_objects(&self) -> Result<(u64, u64)> {
255        any_store_dispatch!(self, prune_loose_objects())
256    }
257    fn begin_snapshot_write_batch(&self) -> Result<()> {
258        any_store_dispatch!(self, begin_snapshot_write_batch())
259    }
260    fn flush_snapshot_write_batch(&self) -> Result<()> {
261        any_store_dispatch!(self, flush_snapshot_write_batch())
262    }
263    fn abort_snapshot_write_batch(&self) {
264        any_store_dispatch!(self, abort_snapshot_write_batch())
265    }
266    fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
267        any_store_dispatch!(self, has_redactions_for_blob(blob))
268    }
269    fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
270        any_store_dispatch!(self, get_redactions_bytes_for_blob(blob))
271    }
272    fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
273        any_store_dispatch!(self, put_redactions_bytes_for_blob(blob, bytes))
274    }
275    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
276        any_store_dispatch!(self, list_blobs_with_redactions())
277    }
278    fn has_state_visibility_for_state(&self, state: &StateId) -> Result<bool> {
279        any_store_dispatch!(self, has_state_visibility_for_state(state))
280    }
281    fn get_state_visibility_bytes_for_state(&self, state: &StateId) -> Result<Option<Vec<u8>>> {
282        any_store_dispatch!(self, get_state_visibility_bytes_for_state(state))
283    }
284    fn put_state_visibility_bytes_for_state(&self, state: &StateId, bytes: &[u8]) -> Result<()> {
285        any_store_dispatch!(self, put_state_visibility_bytes_for_state(state, bytes))
286    }
287    fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
288        any_store_dispatch!(self, list_states_with_visibility())
289    }
290}
291
292impl AnyStore {
293    /// Attach a read-only external source to the local filesystem store.
294    pub fn set_external_source(&mut self, source: Arc<dyn ExternalObjectSource>) {
295        match self {
296            Self::Fs(store) => store.set_external_source(source),
297        }
298    }
299
300    /// Internal repository seam for the local authoritative snapshot artifact.
301    /// Kept off `ObjectStore` so third-party backends do not acquire a Heddle
302    /// filesystem recovery contract.
303    #[doc(hidden)]
304    pub fn snapshot_commit_descriptors(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
305        match self {
306            Self::Fs(store) => store.snapshot_commit_descriptors_impl(),
307        }
308    }
309
310    /// O(1) lookup for the authoritative snapshot pack associated with a
311    /// pushed state. The filesystem store maintains this index as packs are
312    /// installed so hosted Push does not scan historical snapshot artifacts.
313    #[doc(hidden)]
314    pub fn snapshot_commit_descriptor_for_state(
315        &self,
316        state: &StateId,
317    ) -> Result<Option<SnapshotCommitDescriptor>> {
318        match self {
319            Self::Fs(store) => store.snapshot_commit_descriptor_for_state_impl(state),
320        }
321    }
322
323    /// Install a structured snapshot closure and its commit artifact through
324    /// the filesystem store's single durable pack barrier.
325    #[doc(hidden)]
326    pub fn put_committed_snapshot_objects_packed(
327        &self,
328        blobs: Vec<(ContentHash, Vec<u8>)>,
329        tree: &Tree,
330        state: &State,
331        attachments: Vec<StateAttachment>,
332        artifact: SnapshotCommitArtifact,
333    ) -> Result<SnapshotCommitDescriptor> {
334        match self {
335            Self::Fs(store) => store.put_committed_snapshot_objects_packed_impl(
336                blobs,
337                tree,
338                state,
339                attachments,
340                artifact,
341            ),
342        }
343    }
344}
345
346/// Trait for object storage backends.
347pub trait ObjectStore: Send + Sync {
348    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
349    fn put_blob(&self, blob: &Blob) -> Result<ContentHash>;
350
351    /// Zero-copy variant of `get_blob`. Returns a [`bytes::Bytes`]
352    /// view of the blob's content, which for `FsStore` reads is a
353    /// slice into the pack file's mmap when the entry is non-delta
354    /// and uncompressed — no allocation, no memcpy.
355    ///
356    /// Default impl wraps `get_blob`'s `Vec<u8>` in a `Bytes` (one
357    /// Arc allocation, no body copy) so backends without a native
358    /// fast path still satisfy the contract. The mount's hot read
359    /// path goes through this method instead of `get_blob` so the
360    /// pack-mmap fast path lights up automatically.
361    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
362        Ok(self
363            .get_blob(hash)?
364            .map(|blob| bytes::Bytes::from(blob.into_content())))
365    }
366
367    /// Return the *uncompressed* byte length of the blob identified by
368    /// `hash`, or `Ok(None)` when the blob is not in the store.
369    ///
370    /// The contract is "size without paying for content": backends are
371    /// expected to honour this with a header read or index lookup
372    /// rather than a full decompression. This is the hot path for
373    /// directory listings (`ls -l` over a thread mount) where loading
374    /// every blob just to learn its size would dominate.
375    ///
376    /// The default implementation falls back to `get_blob` so backends
377    /// without a cheap size accessor still satisfy the contract; native
378    /// stores (`FsStore`, `InMemoryStore`) override this with a
379    /// header- or hashmap-only path.
380    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
381        Ok(self.get_blob(hash)?.map(|blob| blob.content().len() as u64))
382    }
383
384    /// Filesystem path of the loose blob whose on-disk bytes are
385    /// byte-identical to the blob's *uncompressed* content, suitable
386    /// for `hard_link`/`clonefile` materialization without going
387    /// through `get_blob`.
388    ///
389    /// Returns `None` when the blob is missing, is only available via
390    /// a packfile, is stored compressed (the on-disk bytes wouldn't
391    /// match what a worktree consumer needs to read), or the backend
392    /// doesn't expose stable filesystem paths (e.g. `InMemoryStore`). The
393    /// default impl returns `None` so non-`FsStore` backends silently fall
394    /// through to the bytes path.
395    fn loose_blob_path(&self, _hash: &ContentHash) -> Option<PathBuf> {
396        None
397    }
398
399    /// Ensure the blob identified by `hash` is materialized as an
400    /// uncompressed loose file at the canonical loose path so that
401    /// `loose_blob_path` returns `Some(path)` on a subsequent call.
402    ///
403    /// This is the "warm canonical store" path that lets the
404    /// hardlink-first materializer keep its 5–10× wall-clock and
405    /// storage-allocation wins after `pack_objects + prune_loose_objects`
406    /// has moved everything into a packfile. Without this, the lazy
407    /// hardlink path silently degrades to `fs::write(decompressed)` on
408    /// every materialize, because `loose_blob_path` returns `None` for
409    /// pack-only and compressed-loose blobs.
410    ///
411    /// Cost-amortization: the first promotion of a blob pays
412    /// `decompress + atomic write`. Every subsequent materialize of
413    /// the same blob — into the same worktree on `goto`, or into a
414    /// sibling worktree on `delegate` — is a single `link(2)`. Net
415    /// win for any N > 1 materializations; break-even at N == 1.
416    ///
417    /// Pack invariants are preserved: this method does not remove the
418    /// pack-resident copy. The blob lives in both pack and loose-
419    /// uncompressed until the next `prune_loose_objects` cycle, at
420    /// which point the loose mirror is discarded and a future
421    /// materialize re-promotes on demand.
422    ///
423    /// Idempotent: a blob that's already loose-and-uncompressed is a
424    /// no-op fast path. A blob that's loose-but-compressed is
425    /// rewritten in place (atomically) with the uncompressed bytes.
426    /// A blob that's pack-resident is decompressed out of the pack
427    /// and written loose without touching the pack.
428    ///
429    /// Returns `Ok(true)` when the call did real work (a write
430    /// happened), `Ok(false)` when it was a no-op (blob was already
431    /// loose+uncompressed), and `Err` when the blob isn't in the
432    /// store at all. The default impl returns `Ok(false)` for
433    /// backends that don't expose loose paths (`InMemoryStore`), since the
434    /// hardlink path is fundamentally inapplicable there.
435    fn promote_to_loose_uncompressed(&self, _hash: &ContentHash) -> Result<bool> {
436        Ok(false)
437    }
438
439    /// Drop any in-memory caches of decompressed blobs / trees /
440    /// states. The next access to any object pays full I/O +
441    /// decompression cost. No-op for stores that don't cache
442    /// (`InMemoryStore` is already the source of truth).
443    ///
444    /// Exposed primarily for benchmarks that want to measure the
445    /// true cold-cache path without rebuilding the store from
446    /// scratch. Production callers don't need to invoke this.
447    fn clear_recent_caches(&self) {}
448
449    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
450        if blob.hash() != hash {
451            return Err(HeddleError::InvalidObject("blob hash mismatch".to_string()));
452        }
453        self.put_blob(blob)
454    }
455
456    fn has_blob(&self, hash: &ContentHash) -> Result<bool>;
457    /// Return whether the blob is owned by this store, excluding any configured
458    /// read-through source. Snapshot builders use this to ensure a new native
459    /// state owns its complete object closure.
460    fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
461        self.has_blob(hash)
462    }
463    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
464    fn put_tree(&self, tree: &Tree) -> Result<ContentHash>;
465    fn has_tree(&self, hash: &ContentHash) -> Result<bool>;
466    /// Return whether the tree is owned by this store, excluding any configured
467    /// read-through source.
468    fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
469        self.has_tree(hash)
470    }
471    fn get_state(&self, id: &StateId) -> Result<Option<State>>;
472    fn put_state(&self, state: &State) -> Result<()>;
473    fn has_state(&self, id: &StateId) -> Result<bool>;
474    fn list_states(&self) -> Result<Vec<StateId>>;
475    fn get_state_attachment(
476        &self,
477        _state: &StateId,
478        _id: &StateAttachmentId,
479    ) -> Result<Option<StateAttachment>> {
480        Ok(None)
481    }
482    fn put_state_attachment(&self, _attachment: &StateAttachment) -> Result<StateAttachmentId> {
483        Err(HeddleError::InvalidObject(
484            "object store does not support state attachments".to_string(),
485        ))
486    }
487    fn list_state_attachments(&self, _state: &StateId) -> Result<Vec<StateAttachment>> {
488        Ok(Vec::new())
489    }
490    fn get_action(&self, id: &ActionId) -> Result<Option<Action>>;
491    fn put_action(&self, action: &mut Action) -> Result<ActionId>;
492    fn list_actions(&self) -> Result<Vec<ActionId>>;
493    fn list_blobs(&self) -> Result<Vec<ContentHash>>;
494    fn list_trees(&self) -> Result<Vec<ContentHash>>;
495
496    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
497        self.put_blob_with_hash(&Blob::from_slice(data), hash)
498    }
499
500    /// Return the raw rmp-encoded tree body for `hash`.
501    ///
502    /// This is a migration seam, not a runtime compatibility reader: callers
503    /// that need current tree semantics should use [`ObjectStore::get_tree`].
504    /// Backends with direct raw storage override this so one-shot migrations can
505    /// canonicalize older tree encodings without reintroducing fallback decode
506    /// into the durable `Tree` type.
507    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
508        Ok(self
509            .get_tree(hash)?
510            .map(|tree| rmp_serde::to_vec(&tree))
511            .transpose()?)
512    }
513
514    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
515        let tree: Tree = rmp_serde::from_slice(data)?;
516        tree.validate()?;
517        if tree.hash() != hash {
518            return Err(HeddleError::Corruption {
519                expected: hash,
520                found: tree.hash(),
521            });
522        }
523        self.put_tree(&tree)
524    }
525
526    fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
527        let state: State = rmp_serde::from_slice(data)?;
528        let found = state.id();
529        if found != id {
530            return Err(HeddleError::InvalidObject(format!(
531                "state id mismatch: expected {id}, computed {found}"
532            )));
533        }
534        self.put_state(&state)
535    }
536
537    fn put_action_serialized(&self, data: &[u8], id: ActionId) -> Result<()> {
538        let mut action: Action = rmp_serde::from_slice(data)?;
539        let found_id = action.compute_id();
540        if found_id != id {
541            return Err(HeddleError::InvalidObject(format!(
542                "action id mismatch: expected {}, found {}",
543                id, found_id
544            )));
545        }
546        let stored_id = self.put_action(&mut action)?;
547        if stored_id != id {
548            return Err(HeddleError::InvalidObject(format!(
549                "action id mismatch after write: expected {}, found {}",
550                id, stored_id
551            )));
552        }
553        Ok(())
554    }
555
556    fn get_pack_object(
557        &self,
558        id: &pack::PackObjectId,
559    ) -> Result<Option<(pack::ObjectType, Vec<u8>)>> {
560        match id {
561            pack::PackObjectId::Hash(hash) => {
562                if let Some(blob) = self.get_blob(hash)? {
563                    return Ok(Some((pack::ObjectType::Blob, blob.content().to_vec())));
564                }
565                if let Some(tree) = self.get_tree(hash)? {
566                    return Ok(Some((
567                        pack::ObjectType::Tree,
568                        rmp_serde::to_vec_named(&tree)?,
569                    )));
570                }
571                if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
572                    return Ok(Some((
573                        pack::ObjectType::Action,
574                        rmp_serde::to_vec_named(&action)?,
575                    )));
576                }
577                Ok(None)
578            }
579            pack::PackObjectId::StateId(change_id) => {
580                if let Some(state) = self.get_state(change_id)? {
581                    Ok(Some((
582                        pack::ObjectType::State,
583                        rmp_serde::to_vec_named(&state)?,
584                    )))
585                } else {
586                    Ok(None)
587                }
588            }
589        }
590    }
591
592    /// Bulk-write a batch of blobs as a single durable unit. The default
593    /// implementation falls back to per-blob writes; backends that
594    /// support packfiles (i.e. `FsStore`) override this to install one
595    /// packfile + index — two fsyncs total instead of N. Used by the
596    /// snapshot hot path so writing 1000 small files takes ~one fsync,
597    /// not 1000.
598    ///
599    /// Blobs already present in the store are skipped on the way in
600    /// (the caller would otherwise duplicate them in the pack).
601    fn put_blobs_packed(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
602        for (hash, data) in blobs {
603            if !self.has_blob(&hash)? {
604                self.put_blob_bytes_with_hash(&data, hash)?;
605            }
606        }
607        Ok(())
608    }
609
610    /// Durably install a snapshot's newly-authored immutable object closure as
611    /// one storage batch. Pack-capable backends override this to share one pack
612    /// installation across blobs, the root tree, and the state; other backends
613    /// preserve the same ordering through their ordinary object methods.
614    fn put_snapshot_objects_packed(
615        &self,
616        blobs: Vec<(ContentHash, Vec<u8>)>,
617        tree: &Tree,
618        state: &State,
619    ) -> Result<()> {
620        self.put_blobs_packed(blobs)?;
621        self.put_tree(tree)?;
622        self.put_state(state)
623    }
624
625    /// Snapshot closure variant that also durably installs immutable authored
626    /// attachments. The separate method preserves the existing backend API;
627    /// pack-capable stores override it to share the snapshot pack barrier.
628    fn put_snapshot_objects_and_attachments_packed(
629        &self,
630        blobs: Vec<(ContentHash, Vec<u8>)>,
631        tree: &Tree,
632        state: &State,
633        attachments: Vec<StateAttachment>,
634    ) -> Result<()> {
635        self.put_snapshot_objects_packed(blobs, tree, state)?;
636        for attachment in attachments {
637            self.put_state_attachment(&attachment)?;
638        }
639        Ok(())
640    }
641    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<pack::PackObjectId>> {
642        let reader = pack::PackReader::from_slice(pack_data, index_data)?;
643        let ids = reader.list_ids();
644        for id in &ids {
645            let Some((obj_type, data)) = reader.get_object(id)? else {
646                continue;
647            };
648            match (id, obj_type) {
649                (pack::PackObjectId::Hash(hash), pack::ObjectType::Blob) => {
650                    self.put_blob_bytes_with_hash(&data, *hash)?;
651                }
652                (pack::PackObjectId::Hash(hash), pack::ObjectType::Tree) => {
653                    self.put_tree_serialized(&data, *hash)?;
654                }
655                (pack::PackObjectId::Hash(hash), pack::ObjectType::Action) => {
656                    self.put_action_serialized(&data, ActionId::from_hash(*hash))?;
657                }
658                (pack::PackObjectId::StateId(change_id), pack::ObjectType::State) => {
659                    self.put_state_serialized(&data, *change_id)?;
660                }
661                _ => {
662                    return Err(HeddleError::InvalidObject(format!(
663                        "unsupported native pack object: {:?} {:?}",
664                        id, obj_type
665                    )));
666                }
667            }
668        }
669        Ok(ids)
670    }
671
672    /// Install a pack and its index from on-disk files
673    /// (typically produced by `StreamingPackBuilder`). The default
674    /// impl reads both files fully and delegates to `install_pack`,
675    /// so any backend that doesn't override this still works (at the
676    /// cost of giving back the bounded-memory promise). Real fs-
677    /// backed stores override this to `rename(2)` both files into the
678    /// pack directory without ever loading them.
679    ///
680    /// On success, the source files at `pack_path`/`index_path` may
681    /// have been moved or removed depending on the backend; callers
682    /// shouldn't continue to rely on them.
683    ///
684    /// Returns the ids of the installed objects — the same set
685    /// `install_pack` reports for the equivalent byte-buffer install,
686    /// so callers (e.g. native sync) read the installed ids off the
687    /// install result instead of tracking them out-of-band.
688    fn install_pack_streaming(
689        &self,
690        pack_path: &std::path::Path,
691        index_path: &std::path::Path,
692    ) -> Result<Vec<pack::PackObjectId>> {
693        let pack_data = std::fs::read(pack_path).map_err(StoreError::from)?;
694        let index_data = std::fs::read(index_path).map_err(StoreError::from)?;
695        let ids = self.install_pack(&pack_data, &index_data)?;
696        // Default impl: clean up the staged files. Override
697        // implementations that move/rename should not call super and
698        // should manage the file lifecycle themselves.
699        let _ = std::fs::remove_file(pack_path);
700        let _ = std::fs::remove_file(index_path);
701        Ok(ids)
702    }
703
704    fn pack_objects(&self, aggressive: bool) -> Result<(u64, u64)> {
705        let _ = aggressive;
706        Ok((0, 0))
707    }
708
709    fn prune_loose_objects(&self) -> Result<(u64, u64)> {
710        Ok((0, 0))
711    }
712
713    fn begin_snapshot_write_batch(&self) -> Result<()> {
714        Ok(())
715    }
716
717    fn flush_snapshot_write_batch(&self) -> Result<()> {
718        Ok(())
719    }
720
721    fn abort_snapshot_write_batch(&self) {}
722
723    /// Whether the store holds any redaction record for the given blob.
724    ///
725    /// Redactions live in a sidecar (`<heddle_dir>/redactions/`) that is
726    /// structurally outside the content-addressed object graph so GC
727    /// can't reach them. The wire layer needs a cheap probe to decide
728    /// whether to ship a redaction for a blob in the closure, so this
729    /// is a separate method rather than a `get_*` + null check.
730    ///
731    /// Default impl returns `Ok(false)` — stores that don't model
732    /// redactions silently report "no redactions," which is the
733    /// correct behaviour for purely in-memory or remote-shim stores.
734    fn has_redactions_for_blob(&self, _blob: &ContentHash) -> Result<bool> {
735        Ok(false)
736    }
737
738    /// Return the raw rmp-encoded `RedactionsBlob` bytes for the given
739    /// blob, or `Ok(None)` if no redaction record exists. The bytes
740    /// are byte-identical to what was written by `put_redactions_bytes_for_blob`
741    /// (or by `Repository::put_redaction`); this is the wire-transfer
742    /// payload, not a re-serialized view.
743    ///
744    /// Default impl returns `Ok(None)`.
745    fn get_redactions_bytes_for_blob(&self, _blob: &ContentHash) -> Result<Option<Vec<u8>>> {
746        Ok(None)
747    }
748
749    /// Persist the rmp-encoded `RedactionsBlob` bytes for the given
750    /// blob. Receiver-side replay calls this after signature
751    /// verification so the bytes land in the same sidecar that the
752    /// sender's `Repository::put_redaction` writes to.
753    ///
754    /// Default impl returns an "unsupported" error — stores that don't
755    /// model redactions (e.g. read-only shims) refuse rather than
756    /// silently dropping the record.
757    fn put_redactions_bytes_for_blob(&self, _blob: &ContentHash, _bytes: &[u8]) -> Result<()> {
758        Err(HeddleError::InvalidObject(
759            "this object store does not support persisting redactions".to_string(),
760        ))
761    }
762
763    /// List every blob that has at least one redaction record. Used by
764    /// the GC pin guard and by sync to enumerate redactions for the
765    /// state closure. Order is unspecified; callers that need stable
766    /// ordering should sort.
767    ///
768    /// Default impl returns `Ok(vec![])`.
769    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
770        Ok(Vec::new())
771    }
772
773    /// Whether the store holds any state-visibility record for `state`.
774    ///
775    /// Like redactions, state-visibility records live in a sidecar outside
776    /// the content-addressed object graph and cannot ride native packs.
777    /// Sync uses this probe while enumerating a state closure so a non-public
778    /// state can advertise the sidecar that must travel out-of-pack.
779    ///
780    /// Default impl returns `Ok(false)` for stores that do not model this
781    /// sidecar.
782    fn has_state_visibility_for_state(&self, _state: &StateId) -> Result<bool> {
783        Ok(false)
784    }
785
786    /// Return the raw rmp-encoded `StateVisibilityBlob` bytes for `state`,
787    /// or `Ok(None)` if no sidecar exists. The bytes are the wire-transfer
788    /// payload for state visibility.
789    ///
790    /// Default impl returns `Ok(None)`.
791    fn get_state_visibility_bytes_for_state(&self, _state: &StateId) -> Result<Option<Vec<u8>>> {
792        Ok(None)
793    }
794
795    /// Persist raw `StateVisibilityBlob` bytes for `state`.
796    ///
797    /// Default impl returns an "unsupported" error so stores that do not
798    /// model the sidecar refuse instead of dropping it.
799    fn put_state_visibility_bytes_for_state(&self, _state: &StateId, _bytes: &[u8]) -> Result<()> {
800        Err(HeddleError::InvalidObject(
801            "this object store does not support persisting state visibility".to_string(),
802        ))
803    }
804
805    /// List every state with at least one state-visibility record.
806    ///
807    /// Default impl returns `Ok(vec![])`.
808    fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
809        Ok(Vec::new())
810    }
811}
812
813#[cfg(test)]
814mod any_store_tests {
815    use tempfile::TempDir;
816
817    use super::*;
818    use crate::object::{Attribution, Operation, Principal};
819
820    fn fs_any_store() -> (TempDir, AnyStore) {
821        let temp = TempDir::new().unwrap();
822        let store = FsStore::new(temp.path().join(".heddle"));
823        store.init().unwrap();
824        (temp, AnyStore::Fs(store))
825    }
826
827    /// Drive every `ObjectStore` method through the `AnyStore::Fs` dispatch arm
828    /// so the enum's match-dispatch is exercised end-to-end. This is the
829    /// coverage seam for heddle#283: each arm forwards to the inner concrete
830    /// store, and a missing arm would fail to compile or silently fall back to
831    /// a trait default.
832    #[test]
833    fn fs_variant_dispatches_every_object_store_method() {
834        let (_temp, store) = fs_any_store();
835
836        // ── Blobs ──
837        let blob = Blob::from("any-store dispatch blob");
838        let blob_hash = store.put_blob(&blob).unwrap();
839        assert_eq!(
840            ObjectStore::get_blob(&store, &blob_hash)
841                .unwrap()
842                .unwrap()
843                .content(),
844            blob.content()
845        );
846        assert!(store.has_blob(&blob_hash).unwrap());
847        assert_eq!(
848            ObjectStore::get_blob_bytes(&store, &blob_hash)
849                .unwrap()
850                .unwrap()
851                .as_ref(),
852            blob.content()
853        );
854        assert_eq!(
855            store.blob_size(&blob_hash).unwrap().unwrap(),
856            blob.content().len() as u64
857        );
858        assert!(store.loose_blob_path(&blob_hash).is_some());
859        store.promote_to_loose_uncompressed(&blob_hash).unwrap();
860        assert!(store.list_blobs().unwrap().contains(&blob_hash));
861
862        let bytes_blob = Blob::from("put-with-hash blob");
863        let bytes_hash = bytes_blob.hash();
864        assert_eq!(
865            store.put_blob_with_hash(&bytes_blob, bytes_hash).unwrap(),
866            bytes_hash
867        );
868        let raw_blob = Blob::from("raw bytes blob");
869        let raw_hash = raw_blob.hash();
870        assert_eq!(
871            store
872                .put_blob_bytes_with_hash(raw_blob.content(), raw_hash)
873                .unwrap(),
874            raw_hash
875        );
876
877        // ── Trees ──
878        let tree = Tree::new();
879        let tree_hash = store.put_tree(&tree).unwrap();
880        assert!(ObjectStore::get_tree(&store, &tree_hash).unwrap().is_some());
881        assert!(store.has_tree(&tree_hash).unwrap());
882        assert!(store.list_trees().unwrap().contains(&tree_hash));
883        let tree2 = Tree::new();
884        let tree2_bytes = rmp_serde::to_vec_named(&tree2).unwrap();
885        assert_eq!(
886            store
887                .put_tree_serialized(&tree2_bytes, tree2.hash())
888                .unwrap(),
889            tree2.hash()
890        );
891
892        // ── States ──
893        let attribution =
894            Attribution::human(Principal::new("AnyStore Test", "anystore@example.com"));
895        let state = State::new(tree_hash, vec![], attribution.clone());
896        let state_id = state.id();
897        store.put_state(&state).unwrap();
898        assert!(ObjectStore::get_state(&store, &state_id).unwrap().is_some());
899        assert!(store.has_state(&state_id).unwrap());
900        assert!(store.list_states().unwrap().contains(&state_id));
901        let state2 = State::new(tree2.hash(), vec![], attribution.clone());
902        let state2_bytes = rmp_serde::to_vec_named(&state2).unwrap();
903        store
904            .put_state_serialized(&state2_bytes, state2.id())
905            .unwrap();
906
907        // ── Actions ──
908        let mut action = Action::new(
909            None,
910            StateId::from_bytes([3; 32]),
911            Operation::Snapshot,
912            "any-store action",
913            attribution,
914        );
915        let action_id = store.put_action(&mut action).unwrap();
916        assert!(store.get_action(&action_id).unwrap().is_some());
917        assert!(store.list_actions().unwrap().contains(&action_id));
918        let action_bytes = rmp_serde::to_vec_named(&action).unwrap();
919        store
920            .put_action_serialized(&action_bytes, action_id)
921            .unwrap();
922
923        // ── Packs ──
924        let packed = Blob::from("packed-via-any-store");
925        let packed_hash = packed.hash();
926        store
927            .put_blobs_packed(vec![(packed_hash, packed.into_content())])
928            .unwrap();
929        assert!(
930            store
931                .get_pack_object(&pack::PackObjectId::Hash(packed_hash))
932                .unwrap()
933                .is_some()
934        );
935        store.pack_objects(false).unwrap();
936        store.prune_loose_objects().unwrap();
937        // install_pack / install_pack_streaming need valid packfile inputs;
938        // exercising the dispatch arm with bogus data is enough — we only
939        // assert the call routes through the enum, not the backend behaviour.
940        let _ = store.install_pack(&[], &[]);
941        let _ = store.install_pack_streaming(
942            std::path::Path::new("/nonexistent/pack"),
943            std::path::Path::new("/nonexistent/idx"),
944        );
945
946        // ── Snapshot write batch ──
947        store.begin_snapshot_write_batch().unwrap();
948        store.flush_snapshot_write_batch().unwrap();
949        store.begin_snapshot_write_batch().unwrap();
950        store.abort_snapshot_write_batch();
951
952        // ── Redactions ──
953        let redaction = b"any-store redaction bytes";
954        store
955            .put_redactions_bytes_for_blob(&blob_hash, redaction)
956            .unwrap();
957        assert!(store.has_redactions_for_blob(&blob_hash).unwrap());
958        assert_eq!(
959            store
960                .get_redactions_bytes_for_blob(&blob_hash)
961                .unwrap()
962                .as_deref(),
963            Some(redaction.as_slice())
964        );
965        assert!(
966            store
967                .list_blobs_with_redactions()
968                .unwrap()
969                .contains(&blob_hash)
970        );
971
972        // ── State visibility ──
973        let state_visibility = b"any-store state visibility bytes";
974        store
975            .put_state_visibility_bytes_for_state(&state_id, state_visibility)
976            .unwrap();
977        assert!(store.has_state_visibility_for_state(&state_id).unwrap());
978        assert_eq!(
979            store
980                .get_state_visibility_bytes_for_state(&state_id)
981                .unwrap()
982                .as_deref(),
983            Some(state_visibility.as_slice())
984        );
985        assert!(
986            store
987                .list_states_with_visibility()
988                .unwrap()
989                .contains(&state_id)
990        );
991
992        // ── Caches ──
993        store.clear_recent_caches();
994    }
995}