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