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