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