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