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