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::{
7 Action, ActionId, AnnotatedTag, Blob, ContentHash, OpenedTreeBody, PartialTree, State,
8 StateAttachment, StateAttachmentId, StateId, Tree, TreeEntry, TreeEntryReader,
9 TreeResumeCursor, is_redacted_tree, is_streamable_tree,
10};
11
12pub mod codec;
13mod delta_source;
14pub mod fs;
15pub mod liveness;
16#[cfg(any(test, feature = "memory-backend"))]
17pub mod memory;
18#[cfg(test)]
19mod partial_tree_tests;
20pub use heddle_pack::store::pack;
21pub mod shallow;
22mod snapshot_commit;
23pub mod source;
24pub mod store_compliance;
25pub mod writer_lease;
26
27pub use fs::{
28 DEFAULT_PACK_INSTALL_INTENT_TTL_SECS, FsRepackOperation, FsStore, PackInstallIntent,
29 PackInstallMetricsSnapshot, PackInstallPhase, PackInstallRecoverReport,
30 install_pack_bytes_journaled, pack_install_metrics_reset, pack_install_metrics_snapshot,
31 recover_pack_install_intents, recover_pack_install_intents_with_ttl,
32};
33pub use heddle_format::compression::{CompressionConfig, CompressionError, compress, decompress};
34pub use liveness::{
35 AGENT_LEASE_DURATION, Liveness, current_boot_id, process_alive, reservation_liveness_at,
36};
37#[cfg(any(test, feature = "memory-backend"))]
38pub use memory::InMemoryStore;
39pub use pack::{
40 CancellationToken as RepackCancellationToken, LoadMonitor as RepackLoadMonitor, PackBuilder,
41 PackObjectId, PackReader, PackStats, RepackContext, RepackError, RepackHandle, RepackInventory,
42 RepackOperation, RepackOutcome, RepackPolicy, RepackReason, RepackReport, RepackResourceLimits,
43 RepackSchedule, RepackScheduler, StreamingPackBuilder, SyncData,
44};
45pub use shallow::ShallowInfo;
46#[doc(hidden)]
47pub use snapshot_commit::{
48 SNAPSHOT_COMMIT_ARTIFACT_SCHEMA, SnapshotCommitArtifact, SnapshotCommitDescriptor,
49 SnapshotPackManager,
50};
51#[cfg(feature = "async-source")]
52pub use source::AsyncObjectSource;
53pub use source::ObjectSource;
54pub use writer_lease::{
55 WriterLease, WriterLeaseAuthOutcome, WriterLeaseDraft, WriterLeaseGrant,
56 WriterLeaseReserveOutcome, WriterLeaseStatus, WriterLeaseStore, generate_writer_lease_id,
57 generate_writer_lease_token,
58};
59
60/// A newly-authored tree plus its immediate parent, when capture already knows
61/// that relationship. Stores may use the hint for bounded HDC1 encoding; it
62/// never changes the tree's semantic content hash.
63#[derive(Clone, Debug)]
64pub struct TreeWrite {
65 pub tree: Tree,
66 pub parent: Option<ContentHash>,
67}
68
69impl TreeWrite {
70 pub fn anchor(tree: Tree) -> Self {
71 Self { tree, parent: None }
72 }
73
74 pub fn descendant(tree: Tree, parent: ContentHash) -> Self {
75 Self {
76 tree,
77 parent: Some(parent),
78 }
79 }
80}
81
82/// Read-only objects whose authoritative representation lives outside the
83/// native Heddle object directory. Git-overlay repositories use this seam to
84/// translate objects directly from `.git` without importing a second copy.
85pub trait ExternalObjectSource: Send + Sync {
86 fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
87 fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
88 fn get_state(&self, id: &StateId) -> Result<Option<State>>;
89 fn list_states(&self) -> Result<Vec<StateId>>;
90}
91
92/// Explicit cache control for benchmarks and diagnostic tools.
93///
94/// Cache invalidation is not part of durable object storage semantics. Keeping
95/// it separate prevents remote stores such as Weft's backend from having to
96/// pretend they expose process-local cache controls merely to implement
97/// [`ObjectStore`].
98pub trait ObjectCacheControl: Send + Sync {
99 /// Drop process-local decoded-object caches, if this implementation has
100 /// any. The next read should observe the implementation's cold path.
101 fn clear_recent_caches(&self);
102}
103
104pub use crate::error::{HeddleError as StoreError, HeddleError, Result};
105
106/// Sidecar records that live outside the content-addressed object graph —
107/// signed redactions and state-visibility tiers. They never ride native packs
108/// and are transferred out-of-band. Backends that do not model them can use
109/// the default methods, while native stores override the relevant operations.
110pub trait SidecarStore: Send + Sync {
111 /// Whether the store holds any redaction record for the given blob.
112 ///
113 /// Redactions live in a sidecar (`<heddle_dir>/redactions/`) that is
114 /// structurally outside the content-addressed object graph so GC
115 /// can't reach them. The wire layer needs a cheap probe to decide
116 /// whether to ship a redaction for a blob in the closure, so this
117 /// is a separate method rather than a `get_*` + null check.
118 ///
119 /// Default impl returns `Ok(false)` — stores that don't model
120 /// redactions silently report "no redactions," which is the
121 /// correct behaviour for purely in-memory or remote-shim stores.
122 fn has_redactions_for_blob(&self, _blob: &ContentHash) -> Result<bool> {
123 Ok(false)
124 }
125
126 /// Return the raw rmp-encoded `RedactionsBlob` bytes for the given
127 /// blob, or `Ok(None)` if no redaction record exists. The bytes
128 /// are byte-identical to what was written by `put_redactions_bytes_for_blob`
129 /// (or by `Repository::put_redaction`); this is the wire-transfer
130 /// payload, not a re-serialized view.
131 ///
132 /// Default impl returns `Ok(None)`.
133 fn get_redactions_bytes_for_blob(&self, _blob: &ContentHash) -> Result<Option<Vec<u8>>> {
134 Ok(None)
135 }
136
137 /// Persist the rmp-encoded `RedactionsBlob` bytes for the given
138 /// blob. Receiver-side replay calls this after signature
139 /// verification so the bytes land in the same sidecar that the
140 /// sender's `Repository::put_redaction` writes to.
141 ///
142 /// Default impl returns an "unsupported" error — stores that don't
143 /// model redactions (e.g. read-only shims) refuse rather than
144 /// silently dropping the record.
145 fn put_redactions_bytes_for_blob(&self, _blob: &ContentHash, _bytes: &[u8]) -> Result<()> {
146 Err(HeddleError::InvalidObject(
147 "this object store does not support persisting redactions".to_string(),
148 ))
149 }
150
151 /// List every blob that has at least one redaction record. Used by
152 /// the GC pin guard and by sync to enumerate redactions for the
153 /// state closure. Order is unspecified; callers that need stable
154 /// ordering should sort.
155 ///
156 /// Default impl returns `Ok(vec![])`.
157 fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
158 Ok(Vec::new())
159 }
160
161 /// Whether the store holds any state-visibility record for `state`.
162 ///
163 /// Like redactions, state-visibility records live in a sidecar outside
164 /// the content-addressed object graph and cannot ride native packs.
165 /// Sync uses this probe while enumerating a state closure so a non-public
166 /// state can advertise the sidecar that must travel out-of-pack.
167 ///
168 /// Default impl returns `Ok(false)` for stores that do not model this
169 /// sidecar.
170 fn has_state_visibility_for_state(&self, _state: &StateId) -> Result<bool> {
171 Ok(false)
172 }
173
174 /// Return the raw rmp-encoded `StateVisibilityBlob` bytes for `state`,
175 /// or `Ok(None)` if no sidecar exists. The bytes are the wire-transfer
176 /// payload for state visibility.
177 ///
178 /// Default impl returns `Ok(None)`.
179 fn get_state_visibility_bytes_for_state(&self, _state: &StateId) -> Result<Option<Vec<u8>>> {
180 Ok(None)
181 }
182
183 /// Persist raw `StateVisibilityBlob` bytes for `state`.
184 ///
185 /// Default impl returns an "unsupported" error so stores that do not
186 /// model the sidecar refuse instead of dropping it.
187 fn put_state_visibility_bytes_for_state(&self, _state: &StateId, _bytes: &[u8]) -> Result<()> {
188 Err(HeddleError::InvalidObject(
189 "this object store does not support persisting state visibility".to_string(),
190 ))
191 }
192
193 /// List every state with at least one state-visibility record.
194 ///
195 /// Default impl returns `Ok(vec![])`.
196 fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
197 Ok(Vec::new())
198 }
199}
200
201/// The result of resolving a tree hash that may be held either as the full
202/// canonical object or only as a redacted partial projection (HRT1).
203///
204/// A full tree SUPERSEDES a partial for the same hash. `Partial` is distinct
205/// from `Absent` on purpose: a partial clone that withholds an entry must be
206/// distinguishable from a repository that is missing or corrupt, so callers can
207/// present "withheld" rather than "gone".
208#[derive(Clone, Debug)]
209pub enum TreeRead {
210 /// The full canonical tree is held.
211 Full(Tree),
212 /// Only a redacted partial projection is held: its visible entries carry
213 /// their preimage and its withheld entries are marked opaque. Verifies
214 /// against the declared `State.tree` via `PartialTree::reconstruct_root`.
215 Partial(PartialTree),
216 /// Neither a full tree nor a partial projection is held for this hash.
217 Absent,
218}
219
220/// The outcome of storing a redacted partial projection under the monotone
221/// discipline enforced by [`ObjectStore::put_partial_tree`].
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
223pub enum PartialTreeWrite {
224 /// The projection was written to the partial slot.
225 Stored {
226 /// Number of withheld (redacted) leaves.
227 redacted: usize,
228 /// Number of visible leaves.
229 visible: usize,
230 },
231 /// A full canonical tree for this hash is already held, so the projection
232 /// was dropped rather than stored — a full tree supersedes a partial, and a
233 /// partial never overwrites a full (monotone).
234 SupersededByFull,
235}
236
237/// Trait for object storage backends.
238///
239/// Sidecars remain a separate implementation seam, but every object store
240/// exposes that seam. This preserves object-safe `dyn ObjectStore` consumers
241/// such as Weft's local filesystem backend without coupling its S3 backend to
242/// the native store implementation.
243pub trait ObjectStore: SidecarStore + Send + Sync {
244 fn get_annotated_tag(&self, _hash: &ContentHash) -> Result<Option<AnnotatedTag>> {
245 Ok(None)
246 }
247 fn put_annotated_tag(&self, _tag: &AnnotatedTag) -> Result<ContentHash> {
248 Err(HeddleError::InvalidObject(
249 "object store does not support annotated tags".to_string(),
250 ))
251 }
252 fn list_annotated_tags(&self) -> Result<Vec<ContentHash>> {
253 Ok(Vec::new())
254 }
255 fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>>;
256 fn put_blob(&self, blob: &Blob) -> Result<ContentHash>;
257
258 /// Zero-copy variant of `get_blob`. Returns a [`bytes::Bytes`]
259 /// view of the blob's content, which for `FsStore` reads is a
260 /// slice into the pack file's mmap when the entry is non-delta
261 /// and uncompressed — no allocation, no memcpy.
262 ///
263 /// Default impl wraps `get_blob`'s `Vec<u8>` in a `Bytes` (one
264 /// Arc allocation, no body copy) so backends without a native
265 /// fast path still satisfy the contract. The mount's hot read
266 /// path goes through this method instead of `get_blob` so the
267 /// pack-mmap fast path lights up automatically.
268 fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
269 Ok(self
270 .get_blob(hash)?
271 .map(|blob| bytes::Bytes::from(blob.into_content())))
272 }
273
274 /// Return the *uncompressed* byte length of the blob identified by
275 /// `hash`, or `Ok(None)` when the blob is not in the store.
276 ///
277 /// The contract is "size without paying for content": backends are
278 /// expected to honour this with a header read or index lookup
279 /// rather than a full decompression. This is the hot path for
280 /// directory listings (`ls -l` over a thread mount) where loading
281 /// every blob just to learn its size would dominate.
282 ///
283 /// The default implementation falls back to `get_blob` so backends
284 /// without a cheap size accessor still satisfy the contract; native
285 /// stores (`FsStore`, `InMemoryStore`) override this with a
286 /// header- or hashmap-only path.
287 fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
288 Ok(self.get_blob(hash)?.map(|blob| blob.content().len() as u64))
289 }
290
291 /// Filesystem path of the loose blob whose on-disk bytes are
292 /// byte-identical to the blob's *uncompressed* content, suitable
293 /// for `hard_link`/`clonefile` materialization without going
294 /// through `get_blob`.
295 ///
296 /// Returns `None` when the blob is missing, is only available via
297 /// a packfile, is stored compressed (the on-disk bytes wouldn't
298 /// match what a worktree consumer needs to read), or the backend
299 /// doesn't expose stable filesystem paths (e.g. `InMemoryStore`). The
300 /// default impl returns `None` so non-`FsStore` backends silently fall
301 /// through to the bytes path.
302 fn loose_blob_path(&self, _hash: &ContentHash) -> Option<PathBuf> {
303 None
304 }
305
306 /// Ensure the blob identified by `hash` is materialized as an
307 /// uncompressed loose file at the canonical loose path so that
308 /// `loose_blob_path` returns `Some(path)` on a subsequent call.
309 ///
310 /// This is the "warm canonical store" path that lets the
311 /// hardlink-first materializer keep its 5–10× wall-clock and
312 /// storage-allocation wins after `pack_objects + prune_loose_objects`
313 /// has moved everything into a packfile. Without this, the lazy
314 /// hardlink path silently degrades to `fs::write(decompressed)` on
315 /// every materialize, because `loose_blob_path` returns `None` for
316 /// pack-only and compressed-loose blobs.
317 ///
318 /// Cost-amortization: the first promotion of a blob pays
319 /// `decompress + atomic write`. Every subsequent materialize of
320 /// the same blob — into the same worktree on `goto`, or into a
321 /// sibling worktree on `delegate` — is a single `link(2)`. Net
322 /// win for any N > 1 materializations; break-even at N == 1.
323 ///
324 /// Pack invariants are preserved: this method does not remove the
325 /// pack-resident copy. The blob lives in both pack and loose-
326 /// uncompressed until the next `prune_loose_objects` cycle, at
327 /// which point the loose mirror is discarded and a future
328 /// materialize re-promotes on demand.
329 ///
330 /// Idempotent: a blob that's already loose-and-uncompressed is a
331 /// no-op fast path. A blob that's loose-but-compressed is
332 /// rewritten in place (atomically) with the uncompressed bytes.
333 /// A blob that's pack-resident is decompressed out of the pack
334 /// and written loose without touching the pack.
335 ///
336 /// Returns `Ok(true)` when the call did real work (a write
337 /// happened), `Ok(false)` when it was a no-op (blob was already
338 /// loose+uncompressed), and `Err` when the blob isn't in the
339 /// store at all. The default impl returns `Ok(false)` for
340 /// backends that don't expose loose paths (`InMemoryStore`), since the
341 /// hardlink path is fundamentally inapplicable there.
342 fn promote_to_loose_uncompressed(&self, _hash: &ContentHash) -> Result<bool> {
343 Ok(false)
344 }
345
346 fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
347 if blob.hash() != hash {
348 return Err(HeddleError::InvalidObject("blob hash mismatch".to_string()));
349 }
350 self.put_blob(blob)
351 }
352
353 fn has_blob(&self, hash: &ContentHash) -> Result<bool>;
354 /// Return whether the blob is owned by this store, excluding any configured
355 /// read-through source. Snapshot builders use this to ensure a new native
356 /// state owns its complete object closure.
357 fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
358 self.has_blob(hash)
359 }
360 fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>>;
361 /// Resolve one named tree entry. Pack-capable stores override this so a
362 /// lookup can use a restartable packed record instead of materializing the
363 /// complete tree.
364 fn get_tree_entry(&self, hash: &ContentHash, name: &str) -> Result<Option<TreeEntry>> {
365 Ok(self
366 .get_tree(hash)?
367 .and_then(|tree| tree.get(name).cloned()))
368 }
369 fn put_tree(&self, tree: &Tree) -> Result<ContentHash>;
370 fn has_tree(&self, hash: &ContentHash) -> Result<bool>;
371 /// Return whether the tree is owned by this store, excluding any configured
372 /// read-through source.
373 fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
374 self.has_tree(hash)
375 }
376
377 // ── Redacted partial-tree projections (HRT1) ──────────────────────
378 //
379 // A partial projection lives in a slot keyed by the canonical tree hash it
380 // projects, DISTINCT from the full-tree object slot. The full-tree methods
381 // above (`get_tree`/`has_tree`) never see or return a projection; the
382 // methods below are the only way in and out of the partial slot. Backends
383 // that do not model projections inherit the default impls (report "none" /
384 // refuse the write), mirroring the sidecar seam.
385
386 /// Whether the store holds a redacted partial projection keyed by `hash`.
387 /// Independent of [`ObjectStore::has_tree`], which reports the full object.
388 fn has_partial_tree(&self, _hash: &ContentHash) -> Result<bool> {
389 Ok(false)
390 }
391
392 /// Raw HRT1 partial-projection bytes for `hash`, or `Ok(None)`. These are
393 /// byte-identical to what [`ObjectStore::put_partial_tree_bytes`] wrote —
394 /// the wire-transfer payload, not a re-serialized view.
395 fn get_partial_tree_bytes(&self, _hash: &ContentHash) -> Result<Option<Vec<u8>>> {
396 Ok(None)
397 }
398
399 /// Persist raw HRT1 partial-projection bytes keyed by `hash`. This is the
400 /// low-level slot; callers should prefer [`ObjectStore::put_partial_tree`],
401 /// which verifies the projection and enforces the monotone discipline.
402 fn put_partial_tree_bytes(&self, _hash: &ContentHash, _bytes: &[u8]) -> Result<()> {
403 Err(HeddleError::InvalidObject(
404 "this object store does not support partial tree projections".to_string(),
405 ))
406 }
407
408 /// List every tree hash for which a partial projection is held. Order is
409 /// unspecified; callers that need stable ordering should sort.
410 fn list_partial_trees(&self) -> Result<Vec<ContentHash>> {
411 Ok(Vec::new())
412 }
413
414 /// Drop any partial projection held for `hash`. Idempotent — a no-op when
415 /// none is held. Used to reclaim the partial slot once the full tree is
416 /// backfilled.
417 fn remove_partial_tree(&self, _hash: &ContentHash) -> Result<()> {
418 Ok(())
419 }
420
421 /// Store a redacted partial projection under monotone discipline.
422 ///
423 /// `hrt1` must be an HRT1 body whose visible preimages + withheld leaf
424 /// hashes reconstruct `expected` (verified through
425 /// [`codec::decode_partial_tree`], i.e. Leg 1's `reconstruct_root`).
426 /// Monotone: a partial NEVER overwrites a full tree. If the full canonical
427 /// tree for `expected` is already held, the projection is dropped and
428 /// [`PartialTreeWrite::SupersededByFull`] is returned; otherwise the raw
429 /// bytes land in the partial slot.
430 fn put_partial_tree(&self, expected: &ContentHash, hrt1: &[u8]) -> Result<PartialTreeWrite> {
431 let partial = codec::decode_partial_tree(hrt1, *expected)?;
432 if self.has_tree(expected)? {
433 return Ok(PartialTreeWrite::SupersededByFull);
434 }
435 self.put_partial_tree_bytes(expected, hrt1)?;
436 let redacted = partial.redacted_count();
437 Ok(PartialTreeWrite::Stored {
438 redacted,
439 visible: partial.leaves().len() - redacted,
440 })
441 }
442
443 /// Read the tree for `hash` as the full canonical tree, a redacted partial
444 /// projection, or absent.
445 ///
446 /// A full tree SUPERSEDES a partial: when the full object is held it is
447 /// returned even if a partial projection also exists for the same hash.
448 /// [`ObjectStore::get_tree`] by contrast returns the full tree or `None`
449 /// and NEVER the partial, so a caller that needs the complete tree cannot
450 /// be silently handed a projection.
451 fn read_tree(&self, hash: &ContentHash) -> Result<TreeRead> {
452 if let Some(full) = self.get_tree(hash)? {
453 return Ok(TreeRead::Full(full));
454 }
455 match self.get_partial_tree_bytes(hash)? {
456 Some(bytes) => Ok(TreeRead::Partial(codec::decode_partial_tree(
457 &bytes, *hash,
458 )?)),
459 None => Ok(TreeRead::Absent),
460 }
461 }
462 /// Open a streamable HTR4 tree body. Store backends use sequential
463 /// verify: resume at ordinal > 0 is refused until the bytes are hashed.
464 fn open_tree(
465 &self,
466 tree_id: &ContentHash,
467 cursor: Option<&TreeResumeCursor>,
468 ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
469 let Some(body) = self.get_tree_serialized(tree_id)? else {
470 return Ok(None);
471 };
472 let body = if is_streamable_tree(&body) {
473 body
474 } else {
475 let tree = self
476 .get_tree(tree_id)?
477 .ok_or_else(|| HeddleError::NotFound(format!("tree {tree_id}")))?;
478 tree.encode_lean()?
479 };
480 Ok(Some(TreeEntryReader::open(
481 OpenedTreeBody::Bytes(crate::object::BytesTreeSource::sequential_verify(body)),
482 *tree_id,
483 cursor,
484 )?))
485 }
486 fn get_state(&self, id: &StateId) -> Result<Option<State>>;
487 fn put_state(&self, state: &State) -> Result<()>;
488 fn has_state(&self, id: &StateId) -> Result<bool>;
489 fn list_states(&self) -> Result<Vec<StateId>>;
490 fn get_state_attachment(
491 &self,
492 _state: &StateId,
493 _id: &StateAttachmentId,
494 ) -> Result<Option<StateAttachment>> {
495 Ok(None)
496 }
497 fn put_state_attachment(&self, _attachment: &StateAttachment) -> Result<StateAttachmentId> {
498 Err(HeddleError::InvalidObject(
499 "object store does not support state attachments".to_string(),
500 ))
501 }
502 fn list_state_attachments(&self, _state: &StateId) -> Result<Vec<StateAttachment>> {
503 Ok(Vec::new())
504 }
505 fn get_action(&self, id: &ActionId) -> Result<Option<Action>>;
506 fn put_action(&self, action: &mut Action) -> Result<ActionId>;
507 fn list_actions(&self) -> Result<Vec<ActionId>>;
508 fn list_blobs(&self) -> Result<Vec<ContentHash>>;
509 fn list_trees(&self) -> Result<Vec<ContentHash>>;
510
511 fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
512 self.put_blob_with_hash(&Blob::from_slice(data), hash)
513 }
514
515 /// Return the stored tree body for `hash`, without requiring HTR4.
516 ///
517 /// This is a migration seam, not a runtime compatibility reader: callers
518 /// that need current tree semantics should use [`ObjectStore::get_tree`].
519 /// Loose and packed backends must return the raw stored bytes so one-shot
520 /// migrations can canonicalize older encodings without a current-decoder
521 /// gate. Default impls that only have `get_tree` re-encode current trees.
522 fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
523 self.get_tree(hash)?
524 .map(|tree| tree.encode_canonical().map_err(HeddleError::from))
525 .transpose()
526 }
527
528 fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
529 // An HRT1 redacted projection is not a full tree: route it to the
530 // partial slot (monotone) instead of hard-refusing in the full-tree
531 // decoder.
532 if is_redacted_tree(data) {
533 self.put_partial_tree(&hash, data)?;
534 return Ok(hash);
535 }
536 let tree = codec::decode_tree_serialized_with_key(data, hash, None)?;
537 self.put_tree(&tree)
538 }
539
540 fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
541 let state = State::decode_current_msgpack(data)?;
542 if !state.accepts_stored_id(&id) {
543 return Err(HeddleError::InvalidObject(format!(
544 "state id mismatch: expected {id}, computed {}",
545 state.id()
546 )));
547 }
548 self.put_state(&state)
549 }
550
551 fn put_action_serialized(&self, data: &[u8], id: ActionId) -> Result<()> {
552 let mut action: Action = rmp_serde::from_slice(data)?;
553 let found_id = action.compute_id();
554 if found_id != id {
555 return Err(HeddleError::InvalidObject(format!(
556 "action id mismatch: expected {}, found {}",
557 id, found_id
558 )));
559 }
560 let stored_id = self.put_action(&mut action)?;
561 if stored_id != id {
562 return Err(HeddleError::InvalidObject(format!(
563 "action id mismatch after write: expected {}, found {}",
564 id, stored_id
565 )));
566 }
567 Ok(())
568 }
569
570 fn get_pack_object(
571 &self,
572 id: &pack::PackObjectId,
573 ) -> Result<Option<(pack::ObjectType, Vec<u8>)>> {
574 match id {
575 pack::PackObjectId::AnnotatedTag(hash) => Ok(self
576 .get_annotated_tag(hash)?
577 .map(|tag| (pack::ObjectType::AnnotatedTag, tag.encode_current_msgpack()))),
578 pack::PackObjectId::Hash(hash) => {
579 if let Some(blob) = self.get_blob(hash)? {
580 return Ok(Some((pack::ObjectType::Blob, blob.content().to_vec())));
581 }
582 if let Some(tree) = self.get_tree(hash)? {
583 return Ok(Some((pack::ObjectType::Tree, tree.encode_canonical()?)));
584 }
585 if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
586 return Ok(Some((
587 pack::ObjectType::Action,
588 rmp_serde::to_vec_named(&action)?,
589 )));
590 }
591 Ok(None)
592 }
593 pack::PackObjectId::StateId(change_id) => {
594 if let Some(state) = self.get_state(change_id)? {
595 Ok(Some((
596 pack::ObjectType::State,
597 state.encode_current_msgpack()?,
598 )))
599 } else {
600 Ok(None)
601 }
602 }
603 }
604 }
605
606 /// Bulk-write a batch of blobs as a single durable unit. The default
607 /// implementation falls back to per-blob writes; backends that
608 /// support packfiles (i.e. `FsStore`) override this to install one
609 /// packfile + index — two fsyncs total instead of N. Used by the
610 /// snapshot hot path so writing 1000 small files takes ~one fsync,
611 /// not 1000.
612 ///
613 /// Blobs already present in the store are skipped on the way in
614 /// (the caller would otherwise duplicate them in the pack).
615 fn put_blobs_packed(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
616 for (hash, data) in blobs {
617 if !self.has_blob(&hash)? {
618 self.put_blob_bytes_with_hash(&data, hash)?;
619 }
620 }
621 Ok(())
622 }
623
624 /// Durably install a snapshot's newly-authored immutable object closure as
625 /// one storage batch. Pack-capable backends override this to share one pack
626 /// installation across blobs, the root tree, and the state; other backends
627 /// preserve the same ordering through their ordinary object methods.
628 fn put_snapshot_objects_packed(
629 &self,
630 blobs: Vec<(ContentHash, Vec<u8>)>,
631 tree: &Tree,
632 state: &State,
633 ) -> Result<()> {
634 self.put_blobs_packed(blobs)?;
635 self.put_tree(tree)?;
636 self.put_state(state)
637 }
638
639 /// Snapshot closure variant that also durably installs immutable authored
640 /// attachments. The separate method preserves the existing backend API;
641 /// pack-capable stores override it to share the snapshot pack barrier.
642 fn put_snapshot_objects_and_attachments_packed(
643 &self,
644 blobs: Vec<(ContentHash, Vec<u8>)>,
645 tree: &Tree,
646 state: &State,
647 attachments: Vec<StateAttachment>,
648 ) -> Result<()> {
649 self.put_snapshot_objects_packed(blobs, tree, state)?;
650 for attachment in attachments {
651 self.put_state_attachment(&attachment)?;
652 }
653 Ok(())
654 }
655 fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<pack::PackObjectId>> {
656 let reader = pack::PackReader::from_slice(pack_data, index_data)?;
657 let ids = reader.list_ids()?;
658 for id in &ids {
659 let Some((obj_type, data)) = reader.get_object(id)? else {
660 continue;
661 };
662 match (id, obj_type) {
663 (pack::PackObjectId::Hash(hash), pack::ObjectType::Blob) => {
664 self.put_blob_bytes_with_hash(&data, *hash)?;
665 }
666 (pack::PackObjectId::AnnotatedTag(hash), pack::ObjectType::AnnotatedTag) => {
667 let tag = AnnotatedTag::decode_current_msgpack(&data)
668 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
669 if tag.hash() != *hash {
670 return Err(HeddleError::InvalidObject(
671 "annotated tag hash mismatch".to_string(),
672 ));
673 }
674 self.put_annotated_tag(&tag)?;
675 }
676 (pack::PackObjectId::Hash(hash), pack::ObjectType::Tree) => {
677 self.put_tree_serialized(&data, *hash)?;
678 }
679 (pack::PackObjectId::Hash(hash), pack::ObjectType::Action) => {
680 self.put_action_serialized(&data, ActionId::from_hash(*hash))?;
681 }
682 (pack::PackObjectId::StateId(change_id), pack::ObjectType::State) => {
683 self.put_state_serialized(&data, *change_id)?;
684 }
685 (_, pack::ObjectType::TimelineOperation) => {
686 return Err(HeddleError::InvalidObject(
687 "timeline operations belong in the timeline pack store".to_string(),
688 ));
689 }
690 _ => {
691 return Err(HeddleError::InvalidObject(format!(
692 "unsupported native pack object: {:?} {:?}",
693 id, obj_type
694 )));
695 }
696 }
697 }
698 Ok(ids)
699 }
700
701 /// Install a pack and its index from on-disk files
702 /// (typically produced by `StreamingPackBuilder`). The default
703 /// impl reads both files fully and delegates to `install_pack`,
704 /// so any backend that doesn't override this still works (at the
705 /// cost of giving back the bounded-memory promise). Real fs-
706 /// backed stores override this to `rename(2)` both files into the
707 /// pack directory without ever loading them.
708 ///
709 /// On success, the source files at `pack_path`/`index_path` may
710 /// have been moved or removed depending on the backend; callers
711 /// shouldn't continue to rely on them.
712 ///
713 /// Returns the ids of the installed objects — the same set
714 /// `install_pack` reports for the equivalent byte-buffer install,
715 /// so callers (e.g. native sync) read the installed ids off the
716 /// install result instead of tracking them out-of-band.
717 fn install_pack_streaming(
718 &self,
719 pack_path: &std::path::Path,
720 index_path: &std::path::Path,
721 ) -> Result<Vec<pack::PackObjectId>> {
722 let pack_data = std::fs::read(pack_path).map_err(StoreError::from)?;
723 let index_data = std::fs::read(index_path).map_err(StoreError::from)?;
724 let ids = self.install_pack(&pack_data, &index_data)?;
725 // Default impl: clean up the staged files. Override
726 // implementations that move/rename should not call super and
727 // should manage the file lifecycle themselves.
728 let _ = std::fs::remove_file(pack_path);
729 let _ = std::fs::remove_file(index_path);
730 Ok(ids)
731 }
732
733 fn begin_snapshot_write_batch(&self) -> Result<()> {
734 Ok(())
735 }
736
737 fn flush_snapshot_write_batch(&self) -> Result<()> {
738 Ok(())
739 }
740
741 fn abort_snapshot_write_batch(&self) {}
742}