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