Skip to main content

mnesis_store/
import.rs

1//! Import contract — picky, per-stream, halt-not-skip.
2//!
3//! Import takes already-decoded events (the *box* — CBOR default or CESR —
4//! turns chunk bytes into [`PersistedEnvelope`]s; import is box-agnostic) and
5//! places them onto caller-supplied target streams. The store's
6//! sequential-`append` version check does the hard work; import adds routing,
7//! a halt-on-trouble rule, and a per-stream report.
8//!
9//! Events carry no per-event stream id (export does no rewrite), so routing
10//! is driven by the **per-stream section** the box records: each section names
11//! its origin stream once, and import maps that to a target stream via the
12//! caller-supplied `route` closure.
13//!
14//! Resolved semantics (issue #145 §5):
15//!
16//! - **Picky per stream** — a stream's first incoming version must equal that
17//!   stream's next expected version, else that stream is rejected; import
18//!   never silently trims a partial overlap.
19//! - **Halt, never apply-skip** — a bad block (failed checksum, or version
20//!   trouble) halts *its* stream at the last good version and holds back its
21//!   later blocks; it never punches a gap.
22//! - **Atomicity is a caller policy** ([`Atomicity`]) — whole-chunk
23//!   (all-or-nothing, server bulk-restore) vs per-stream (a bad block stops
24//!   only its stream, mobile resilience).
25//! - **Idempotency is a side-effect** of the version check — re-importing
26//!   already-present events is refused, with no dedup machinery.
27//!
28//! This module is the contract: the data types (the per-stream outcomes,
29//! the report, the error) and the [`EventImporter`] trait. The concrete
30//! ingest impl is a later card.
31
32use alloc::vec::Vec;
33
34use bytes::Bytes;
35use mnesis::Version;
36use thiserror::Error;
37
38use crate::envelope::{PendingBatch, PendingEnvelope, PersistedEnvelope};
39use crate::error::AppendError;
40use crate::store::{RawEventStore, Store};
41use crate::stream_id::StreamKey;
42
43/// Atomicity granularity for an import — a caller policy, not a format
44/// property. The same chunk imports either way.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Atomicity {
47    /// All-or-nothing: any bad block rolls back the whole chunk
48    /// ([`ImportError::Aborted`]). Best for server bulk-restore on reliable
49    /// storage — big transactions, retry the lot.
50    WholeChunk,
51    /// Each stream's slice commits in its own transaction: a bad block stops
52    /// only its stream, the rest commit. Best for mobile resilience on flaky
53    /// storage. Reported per stream in an [`ImportReport`].
54    PerStream,
55}
56
57/// How one stream's import ended, and where the stream sits afterward.
58///
59/// "Where it sits" lives inside each variant so the success case always
60/// carries a real [`Version`] while the trouble cases carry `Option`
61/// (`None` = the stream was never touched). Illegal states (a "complete"
62/// stream with no version) are unrepresentable.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum StreamOutcome {
65    /// Every block offered for this stream was applied; it is now at `version`.
66    Complete { version: Version },
67    /// A block failed its per-block checksum. The good prefix (if any) was
68    /// applied, leaving the stream at `reached` (`None` = untouched). The
69    /// corrupt block's own version is deliberately absent — a failed checksum
70    /// means its decoded header cannot be trusted; re-fetch from `reached`'s
71    /// successor.
72    Corrupt { reached: Option<Version> },
73    /// A block's version did not match the stream's next expected version
74    /// (stale overlap or forward gap). Good prefix applied, stream left at
75    /// `reached`; `got` is trustworthy (the checksum passed, only the
76    /// position was wrong).
77    Mismatch {
78        reached: Option<Version>,
79        got: Version,
80    },
81}
82
83impl StreamOutcome {
84    /// Whether every offered block for this stream was applied.
85    #[must_use]
86    pub const fn is_complete(&self) -> bool {
87        matches!(self, Self::Complete { .. })
88    }
89
90    /// Where the stream sits after import (`None` = still empty / untouched).
91    #[must_use]
92    pub const fn reached(&self) -> Option<Version> {
93        match self {
94            Self::Complete { version } => Some(*version),
95            Self::Corrupt { reached } | Self::Mismatch { reached, .. } => *reached,
96        }
97    }
98}
99
100/// One stream's outcome within an import, tagged with the caller's target
101/// stream id (echoed verbatim — import owns no naming policy).
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct StreamReport {
104    /// The target [`StreamKey`] this outcome is for.
105    pub stream: StreamKey,
106    /// What happened to it.
107    pub outcome: StreamOutcome,
108}
109
110/// Per-stream outcomes of an import — one [`StreamReport`] per stream.
111///
112/// In first-seen order. Describes only work that actually ran (a whole-chunk
113/// abort is an [`ImportError`], not a report of "nothing happened").
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ImportReport {
116    streams: Vec<StreamReport>,
117}
118
119impl ImportReport {
120    /// Build a report from per-stream outcomes.
121    #[must_use]
122    pub const fn new(streams: Vec<StreamReport>) -> Self {
123        Self { streams }
124    }
125
126    /// All per-stream outcomes, in first-seen order.
127    #[must_use]
128    pub fn streams(&self) -> &[StreamReport] {
129        &self.streams
130    }
131
132    /// The streams the sync loop must act on — everything that isn't
133    /// [`StreamOutcome::Complete`].
134    pub fn unfinished(&self) -> impl Iterator<Item = &StreamReport> {
135        self.streams.iter().filter(|s| !s.outcome.is_complete())
136    }
137
138    /// Whether every stream completed.
139    #[must_use]
140    pub fn all_complete(&self) -> bool {
141        self.streams.iter().all(|s| s.outcome.is_complete())
142    }
143}
144
145/// Why a whole-chunk import aborted — the first bad block's failure mode.
146#[derive(Debug, Clone, PartialEq, Eq, Error)]
147pub enum AbortReason {
148    /// A block failed its per-block checksum.
149    #[error("block failed checksum")]
150    Corrupt,
151    /// A block's version did not match the target stream's next expected
152    /// version.
153    #[error("version mismatch (expected {expected}, got {got})")]
154    Mismatch { expected: Version, got: Version },
155}
156
157/// A whole-operation import failure — distinct from the *expected* per-stream
158/// outcomes carried in an [`ImportReport`]. `E` is the underlying store error.
159#[derive(Debug, Error)]
160#[non_exhaustive]
161pub enum ImportError<E> {
162    // NOTE: there is no `Malformed` variant. Decoding the backup box is the
163    // box's job (`cbor::decode_chunk` → `ChunkError::Malformed`); `import` takes
164    // already-decoded `&[StreamSection]`, and the only `EventImporter` impl is
165    // the blanket one, so no code path here can produce a malformed-chunk error.
166    // A variant nothing can construct would mislead callers; re-add additively
167    // (behind an inline-decoding importer) if one is ever introduced.
168    /// Whole-chunk atomicity only: a bad block rolled the entire chunk back —
169    /// nothing was written. `stream` is the first offender; retry the whole
170    /// chunk. (Per-stream atomicity never aborts — it reports per stream.)
171    #[error("chunk aborted at stream {stream}: {reason}")]
172    Aborted {
173        stream: StreamKey,
174        reason: AbortReason,
175    },
176    /// The underlying store transaction failed.
177    #[error(transparent)]
178    Store(E),
179    /// A stream's `version + 1` overflowed `u64`. NOT a conflict — not
180    /// retryable.
181    #[error("version overflow")]
182    VersionOverflow,
183}
184
185/// One origin stream's events, as decoded by the backup box (Card 3).
186///
187/// The origin stream id is recorded **once** per section (export stamps no
188/// per-event id); the importer maps it to a target via the `route` closure.
189#[derive(Debug, Clone)]
190pub struct StreamSection {
191    /// The origin stream id, exactly as the box recorded it.
192    pub origin: Bytes,
193    /// The section's blocks, in version order as the box laid them down.
194    pub blocks: Vec<ImportBlock>,
195}
196
197/// One block within a [`StreamSection`].
198#[derive(Debug, Clone)]
199pub enum ImportBlock {
200    /// A block whose per-block checksum passed and decoded to an event.
201    Event(PersistedEnvelope),
202    /// A block whose per-block checksum **failed**. Carries nothing: a failed
203    /// checksum means the decoded header — including its version — cannot be
204    /// trusted, which is exactly why [`StreamOutcome::Corrupt`] omits the
205    /// version.
206    Corrupt,
207}
208
209/// One planned per-stream write for [`AtomicAppend::atomic_append_many`].
210///
211/// The run is a contiguous, version-preserving sequence; `expected_version` is
212/// the head the target stream must currently be at (`None` = the stream must be
213/// fresh). Built by the importer's per-section planner.
214///
215/// The run is split into [`head`](Self::head) + [`tail`](Self::tail) so its
216/// non-emptiness is structural: [`batch`](Self::batch) hands an adapter a
217/// [`PendingBatch`] with no runtime check (#330).
218#[derive(Debug, Clone)]
219pub struct PlannedAppend {
220    /// The resolved target [`StreamKey`].
221    pub target: StreamKey,
222    /// The version the target must currently be at (`None` = fresh stream).
223    pub expected_version: Option<Version>,
224    /// The run's first envelope — the lowest version in the run.
225    pub head: PendingEnvelope,
226    /// The rest of the run, in version order (empty for a one-event run).
227    pub tail: Vec<PendingEnvelope>,
228}
229
230impl PlannedAppend {
231    /// The run as the non-empty batch [`AtomicAppend`] hands to the store.
232    #[must_use]
233    pub fn batch(&self) -> PendingBatch<'_> {
234        PendingBatch::from_parts(&self.head, &self.tail)
235    }
236}
237
238/// Failure of an [`AtomicAppend::atomic_append_many`] transaction.
239///
240/// `Conflict` is the cross-stream picky check: write `index`'s
241/// `expected_version` did not match the target's actual head (`actual`). The
242/// whole transaction is rolled back — nothing landed. `Store` is an
243/// adapter-level failure. Distinct domains, distinct variants (CLAUDE rule 3).
244#[derive(Debug, Error)]
245#[non_exhaustive]
246pub enum AtomicAppendError<E> {
247    /// Write at `index` had a head mismatch; `actual` is the target's real head.
248    #[error("atomic append conflict at write {index}: actual head {actual:?}")]
249    Conflict {
250        index: usize,
251        actual: Option<Version>,
252    },
253    /// Adapter-level failure (I/O, encoding, global-seq overflow, …).
254    #[error("atomic append store error: {0}")]
255    Store(#[source] E),
256}
257
258/// Adapter capability: commit several per-stream runs in **one** atomic
259/// transaction.
260///
261/// Either every run lands or none do. This is the primitive
262/// [`Atomicity::WholeChunk`] needs and that [`RawEventStore::append`]
263/// (per-stream only) cannot provide. Adapters implement it with a real
264/// transaction (fjall cross-partition `write_tx`, postgres `BEGIN..COMMIT`,
265/// `InMemoryStore` its single mutex).
266///
267/// # Contract
268///
269/// - For each write, the target's actual head must equal `expected_version`,
270///   else the whole transaction aborts with [`AtomicAppendError::Conflict`]
271///   carrying that write's `index` and the target's real head.
272/// - Each write's `events` must be a contiguous run starting at
273///   `expected_version + 1`. The caller (the importer's planner) guarantees
274///   this; implementations validate defensively at their own boundary.
275/// - Each write is validated against the target's **running** head, including
276///   prior writes to the same target in this batch. A non-injective route (two
277///   writes to one stream) therefore surfaces as [`AtomicAppendError::Conflict`]
278///   on the second write — never a silently concatenated, gap-creating stream.
279/// - On any failure, **no** write is applied.
280///
281/// # Return value
282///
283/// On success this returns the **highest** [`AllPosition`](RawEventStore::AllPosition)
284/// the transaction committed, across every stream it touched — the
285/// read-your-writes token for the whole batch (#330). A consumer that has
286/// reached it has been delivered every event the batch wrote. `None` iff
287/// `writes` is empty (a no-op commits no position); unlike
288/// [`RawEventStore::append`], the empty case is *reachable* here — a caller may
289/// hand an empty `writes` — so it is answered with `Option` rather than removed.
290///
291/// [`AllPosition`]: RawEventStore::AllPosition
292pub trait AtomicAppend: RawEventStore {
293    /// Append every write atomically. See the trait contract.
294    fn atomic_append_many(
295        &self,
296        writes: &[PlannedAppend],
297    ) -> impl core::future::Future<
298        Output = Result<Option<Self::AllPosition>, AtomicAppendError<Self::Error>>,
299    > + Send;
300}
301
302/// `Store<S>` forwards [`AtomicAppend`] to its inner backend (issue #247). With
303/// `Store<S>` already a [`RawEventStore`], this gives it [`EventImporter`] for
304/// free via the blanket impl below — so a handle holder can `store.import(..)`
305/// without `.raw()`.
306impl<S: AtomicAppend> AtomicAppend for Store<S> {
307    async fn atomic_append_many(
308        &self,
309        writes: &[PlannedAppend],
310    ) -> Result<Option<Self::AllPosition>, AtomicAppendError<Self::Error>> {
311        self.raw().atomic_append_many(writes).await
312    }
313}
314
315/// Place decoded events onto caller-routed target streams, picky per stream,
316/// halt-not-skip, under an [`Atomicity`] policy.
317///
318/// Input is per-stream [`StreamSection`]s carrying [`ImportBlock`]s. Each
319/// section's origin stream id (recorded once by the box — export stamps no
320/// per-event id) is mapped to the receiver's target stream id `I` by `route`
321/// (e.g. `task-123` → `phone:task-123`); import holds no naming policy of its
322/// own.
323///
324/// On success returns an [`ImportReport`] of per-stream outcomes (per-stream
325/// atomicity) or completes (whole-chunk). `Err` is reserved for
326/// whole-operation failures.
327///
328/// [`Atomicity::PerStream`] is best-effort per stream: a store failure stops
329/// the import and surfaces an error, but sections already committed are not
330/// rolled back. Empty sections produce no report entry.
331pub trait EventImporter: RawEventStore + AtomicAppend {
332    /// Import per-stream sections onto caller-routed target streams.
333    ///
334    /// `route` maps each section's origin id bytes to a target [`StreamKey`]
335    /// (e.g. `task-123` → `phone:task-123`); for an identity restore it is
336    /// simply [`StreamKey::from_slice`].
337    fn import<R>(
338        &self,
339        sections: &[StreamSection],
340        route: R,
341        atomicity: Atomicity,
342    ) -> impl core::future::Future<Output = Result<ImportReport, ImportError<Self::Error>>> + Send
343    where
344        R: Fn(&[u8]) -> StreamKey + Send;
345}
346
347// =============================================================================
348// Per-section planner — pure, no store access
349// =============================================================================
350
351/// Where a section's contiguous run stopped.
352#[derive(Debug)]
353enum Halt {
354    /// Every block in the section was consumed.
355    Complete,
356    /// Stopped at a corrupt block.
357    Corrupt,
358    /// Stopped at a version discontinuity; `got` is the offending version.
359    Gap { got: Version },
360}
361
362/// The store-independent plan for one [`StreamSection`].
363#[derive(Debug)]
364enum SectionPlan {
365    /// No blocks — nothing to do, no report entry.
366    Empty,
367    /// The first block was corrupt — nothing can be appended.
368    FirstCorrupt,
369    /// A contiguous run to append, and how it ended.
370    ///
371    /// `(expected_version, events)` become a [`PlannedAppend`] once `route`
372    /// resolves the target (whole-chunk path).
373    Run {
374        /// The run's first version. Cached (not `events[0].version()`) so the
375        /// consumer maps a store conflict to `got` without indexing/unwrap on
376        /// the non-empty run.
377        first: Version,
378        /// The head the target must be at (`None` = fresh).
379        expected_version: Option<Version>,
380        /// The run's first envelope. Split from the tail so the run's
381        /// non-emptiness is carried by the shape rather than asserted in prose
382        /// — `PendingBatch::from_parts` then needs no runtime check (#330).
383        head: PendingEnvelope,
384        /// The rest of the run, in version order (empty for a one-event run).
385        tail: Vec<PendingEnvelope>,
386        /// The run's last version (where the stream lands on success). Cached
387        /// so the consumer needs no `events.last()` unwrap.
388        last: Version,
389        /// Why the run stopped.
390        halt: Halt,
391    },
392}
393
394/// Planner failure — a stream version overflowed `u64` (NOT a conflict).
395#[derive(Debug)]
396enum PlanError {
397    VersionOverflow,
398}
399
400/// Build a section's plan: decode the first block, accumulate the longest
401/// contiguous run, and record why it stopped. Pure — no store access.
402fn plan_section(section: &StreamSection) -> Result<SectionPlan, PlanError> {
403    let mut blocks = section.blocks.iter();
404    let Some(first_block) = blocks.next() else {
405        return Ok(SectionPlan::Empty);
406    };
407    let first_event = match first_block {
408        ImportBlock::Corrupt => return Ok(SectionPlan::FirstCorrupt),
409        ImportBlock::Event(event) => event,
410    };
411
412    let first = first_event.version();
413    // expected head = first - 1; first == 1 → None (fresh stream). Checked.
414    let expected_version = first.as_u64().checked_sub(1).and_then(Version::new);
415
416    let head = PendingEnvelope::from_persisted(first_event);
417    let mut tail = Vec::new();
418    let mut last = first;
419    let halt = loop {
420        let Some(block) = blocks.next() else {
421            break Halt::Complete;
422        };
423        let event = match block {
424            ImportBlock::Corrupt => break Halt::Corrupt,
425            ImportBlock::Event(event) => event,
426        };
427        // Only reached when a successor block exists; a run ending at u64::MAX
428        // with no successor completes above without ever calling next().
429        let expected_next = last.next().ok_or(PlanError::VersionOverflow)?;
430        if event.version() != expected_next {
431            break Halt::Gap {
432                got: event.version(),
433            };
434        }
435        tail.push(PendingEnvelope::from_persisted(event));
436        last = event.version();
437    };
438
439    Ok(SectionPlan::Run {
440        first,
441        expected_version,
442        head,
443        tail,
444        last,
445        halt,
446    })
447}
448
449// =============================================================================
450// Blanket EventImporter impl
451// =============================================================================
452
453impl<S: RawEventStore + AtomicAppend> EventImporter for S {
454    async fn import<R>(
455        &self,
456        sections: &[StreamSection],
457        route: R,
458        atomicity: Atomicity,
459    ) -> Result<ImportReport, ImportError<Self::Error>>
460    where
461        R: Fn(&[u8]) -> StreamKey + Send,
462    {
463        match atomicity {
464            Atomicity::PerStream => import_per_stream(self, sections, route).await,
465            Atomicity::WholeChunk => import_whole_chunk(self, sections, route).await,
466        }
467    }
468}
469
470/// `PerStream` import: each section its own `append` transaction. A bad block
471/// stops only its stream; the rest commit. Always returns `Ok(report)` unless
472/// a genuine store error or version overflow occurs.
473///
474/// This is the **only** import path that needs just [`RawEventStore`] — no
475/// cross-stream [`AtomicAppend`]. A produce-only device adapter that cannot do
476/// a cross-partition transaction (so cannot implement [`EventImporter`], whose
477/// unified `import` offers [`Atomicity::WholeChunk`] too) still gets per-stream
478/// restore by calling this function directly. It is the exact mobile-resilience
479/// path `WholeChunk` cannot serve.
480///
481/// An empty section (no blocks) produces no `StreamReport` entry; a caller
482/// correlating sections to report entries must not assume positional
483/// correspondence.
484///
485/// # Errors
486///
487/// Returns [`ImportError::Store`] if the underlying `append` fails, or
488/// [`ImportError::VersionOverflow`] if a stream's `version + 1` overflows
489/// `u64`. In either case sections already appended remain committed —
490/// `PerStream` performs no cross-stream rollback, and the partial report is
491/// discarded with the error.
492pub async fn import_per_stream<S, R>(
493    store: &S,
494    sections: &[StreamSection],
495    route: R,
496) -> Result<ImportReport, ImportError<S::Error>>
497where
498    S: RawEventStore,
499    R: Fn(&[u8]) -> StreamKey + Send,
500{
501    let mut reports = Vec::with_capacity(sections.len());
502    for section in sections {
503        let target = route(section.origin.as_ref());
504        let plan = match plan_section(section) {
505            Ok(plan) => plan,
506            Err(PlanError::VersionOverflow) => return Err(ImportError::VersionOverflow),
507        };
508        let outcome = match plan {
509            SectionPlan::Empty => continue,
510            SectionPlan::FirstCorrupt => StreamOutcome::Corrupt { reached: None },
511            SectionPlan::Run {
512                first,
513                expected_version,
514                head,
515                tail,
516                last,
517                halt,
518            } => match store
519                .append(
520                    &target,
521                    expected_version,
522                    PendingBatch::from_parts(&head, &tail),
523                )
524                .await
525            {
526                Ok(_position) => match halt {
527                    Halt::Complete => StreamOutcome::Complete { version: last },
528                    Halt::Corrupt => StreamOutcome::Corrupt {
529                        reached: Some(last),
530                    },
531                    Halt::Gap { got } => StreamOutcome::Mismatch {
532                        reached: Some(last),
533                        got,
534                    },
535                },
536                Err(AppendError::Conflict { .. }) => StreamOutcome::Mismatch {
537                    reached: None,
538                    got: first,
539                },
540                Err(AppendError::Store(error)) => return Err(ImportError::Store(error)),
541            },
542        };
543        reports.push(StreamReport {
544            stream: target,
545            outcome,
546        });
547    }
548    Ok(ImportReport::new(reports))
549}
550
551/// [`WholeChunk`] import: all-or-nothing across every section. Any halt (corrupt
552/// block or internal gap) or head conflict aborts the whole chunk — nothing
553/// lands. First offender (section order, then block order) wins.
554///
555/// [`WholeChunk`]: Atomicity::WholeChunk
556async fn import_whole_chunk<S, R>(
557    store: &S,
558    sections: &[StreamSection],
559    route: R,
560) -> Result<ImportReport, ImportError<S::Error>>
561where
562    S: RawEventStore + AtomicAppend,
563    R: Fn(&[u8]) -> StreamKey + Send,
564{
565    // Phase 1 — plan every section purely. Any halt is a hard abort here.
566    let mut writes: Vec<PlannedAppend> = Vec::with_capacity(sections.len());
567    let mut firsts: Vec<Version> = Vec::with_capacity(sections.len());
568    let mut lasts: Vec<Version> = Vec::with_capacity(sections.len());
569    for section in sections {
570        let target = route(section.origin.as_ref());
571        let plan = match plan_section(section) {
572            Ok(plan) => plan,
573            Err(PlanError::VersionOverflow) => return Err(ImportError::VersionOverflow),
574        };
575        // Exhaustive: a future SectionPlan variant must be handled here (no `..`
576        // catch-all), matching import_per_stream's exhaustiveness.
577        let (first, expected_version, head, tail, last, halt) = match plan {
578            SectionPlan::Empty => continue, // skip; no report entry
579            SectionPlan::FirstCorrupt => {
580                return Err(ImportError::Aborted {
581                    stream: target,
582                    reason: AbortReason::Corrupt,
583                });
584            }
585            SectionPlan::Run {
586                first,
587                expected_version,
588                head,
589                tail,
590                last,
591                halt,
592            } => (first, expected_version, head, tail, last, halt),
593        };
594        match halt {
595            Halt::Complete => {}
596            Halt::Corrupt => {
597                return Err(ImportError::Aborted {
598                    stream: target,
599                    reason: AbortReason::Corrupt,
600                });
601            }
602            Halt::Gap { got } => {
603                let expected = last.next().ok_or(ImportError::VersionOverflow)?;
604                return Err(ImportError::Aborted {
605                    stream: target,
606                    reason: AbortReason::Mismatch { expected, got },
607                });
608            }
609        }
610        firsts.push(first);
611        lasts.push(last);
612        writes.push(PlannedAppend {
613            target,
614            expected_version,
615            head,
616            tail,
617        });
618    }
619
620    // Phase 2 — commit every clean run in one transaction. The committed `$all`
621    // position is not surfaced in the report (it is keyed by per-stream
622    // `Version`); a positioned import report is a PR2 follow-up (#330).
623    match store.atomic_append_many(&writes).await {
624        Ok(_position) => {
625            let reports = writes
626                .into_iter()
627                .zip(lasts)
628                .map(|(write, last)| StreamReport {
629                    stream: write.target,
630                    outcome: StreamOutcome::Complete { version: last },
631                })
632                .collect();
633            Ok(ImportReport::new(reports))
634        }
635        Err(AtomicAppendError::Conflict { index, actual }) => {
636            Err(map_atomic_conflict(&firsts, &writes, index, actual))
637        }
638        Err(AtomicAppendError::Store(error)) => Err(ImportError::Store(error)),
639    }
640}
641
642/// Map an [`AtomicAppend`] conflict into an [`ImportError::Aborted`],
643/// defensively (rule 4 — validate at our own boundary). The primitive's
644/// contract is `index < writes.len()` (== `firsts.len()`), but a misbehaving
645/// adapter returning an out-of-range index must NOT cause an OOB panic:
646/// `writes`/`firsts` are non-empty and equal-length whenever a Conflict is
647/// returned, so a `None` from `.get(index)` means a broken adapter, and we
648/// degrade to the first planned run so the abort is still reported coherently.
649/// `firsts[index]` is the conflicting run's cached first version (no
650/// `events[0]` indexing).
651fn map_atomic_conflict<E>(
652    firsts: &[Version],
653    writes: &[PlannedAppend],
654    index: usize,
655    actual: Option<Version>,
656) -> ImportError<E> {
657    let (Some(&got), Some(write)) = (
658        firsts.get(index).or_else(|| firsts.first()),
659        writes.get(index).or_else(|| writes.first()),
660    ) else {
661        // Unreachable: a Conflict implies a non-empty batch.
662        return ImportError::VersionOverflow;
663    };
664    let expected = match actual {
665        Some(head) => match head.next() {
666            Some(next) => next,
667            None => return ImportError::VersionOverflow,
668        },
669        None => Version::INITIAL,
670    };
671    ImportError::Aborted {
672        stream: write.target.clone(),
673        reason: AbortReason::Mismatch { expected, got },
674    }
675}
676
677#[cfg(test)]
678#[allow(
679    clippy::unwrap_used,
680    clippy::expect_used,
681    clippy::panic,
682    reason = "test code asserts exact values"
683)]
684mod plan_tests {
685    use super::*;
686    use crate::envelope::PersistedEnvelope;
687    use crate::value::SchemaVersion;
688    use bytes::Bytes;
689
690    fn v(n: u64) -> Version {
691        Version::new(n).expect("test version must be nonzero")
692    }
693
694    // ── per-section planner ──────────────────────────────────────────────────
695
696    fn persisted(version: u64, payload: &[u8]) -> PersistedEnvelope {
697        let mut buf = Vec::new();
698        buf.extend_from_slice(b"E");
699        buf.extend_from_slice(payload);
700        let et_end = 1u32;
701        let pl_end = et_end + u32::try_from(payload.len()).expect("payload fits u32");
702        PersistedEnvelope::try_new(
703            v(version),
704            Bytes::from(buf),
705            SchemaVersion::INITIAL,
706            0..et_end,
707            et_end..pl_end,
708            None,
709        )
710        .expect("valid persisted envelope")
711    }
712
713    fn evt(version: u64) -> ImportBlock {
714        ImportBlock::Event(persisted(version, b"p"))
715    }
716
717    fn section(origin: &str, blocks: Vec<ImportBlock>) -> StreamSection {
718        StreamSection {
719            origin: Bytes::copy_from_slice(origin.as_bytes()),
720            blocks,
721        }
722    }
723
724    #[test]
725    fn plan_empty_section_is_empty() {
726        assert!(matches!(
727            plan_section(&section("s", vec![])),
728            Ok(SectionPlan::Empty)
729        ));
730    }
731
732    #[test]
733    fn plan_first_block_corrupt_is_first_corrupt() {
734        let s = section("s", vec![ImportBlock::Corrupt, evt(1)]);
735        assert!(matches!(plan_section(&s), Ok(SectionPlan::FirstCorrupt)));
736    }
737
738    #[test]
739    fn plan_contiguous_run_from_one_is_complete() {
740        let s = section("s", vec![evt(1), evt(2), evt(3)]);
741        let plan = plan_section(&s).expect("plans");
742        match plan {
743            SectionPlan::Run {
744                first,
745                expected_version,
746                last,
747                halt,
748                tail,
749                ..
750            } => {
751                assert_eq!(first, v(1));
752                assert_eq!(expected_version, None); // first == 1 → fresh stream
753                assert_eq!(last, v(3));
754                assert_eq!(tail.len(), 2, "head + 2 tail == the 3-event run");
755                assert!(matches!(halt, Halt::Complete));
756            }
757            other => panic!("expected Run, got {other:?}"),
758        }
759    }
760
761    #[test]
762    fn plan_run_from_midstream_sets_expected_to_first_minus_one() {
763        let s = section("s", vec![evt(3), evt(4)]);
764        match plan_section(&s).expect("plans") {
765            SectionPlan::Run {
766                first,
767                expected_version,
768                last,
769                halt,
770                ..
771            } => {
772                assert_eq!(first, v(3));
773                assert_eq!(expected_version, Some(v(2)));
774                assert_eq!(last, v(4));
775                assert!(matches!(halt, Halt::Complete));
776            }
777            other => panic!("expected Run, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn plan_internal_gap_halts_with_got() {
783        // v3,v4,v6 → run [3,4], halt at the gap (got = 6).
784        let s = section("s", vec![evt(3), evt(4), evt(6)]);
785        match plan_section(&s).expect("plans") {
786            SectionPlan::Run {
787                last, halt, tail, ..
788            } => {
789                assert_eq!(last, v(4));
790                assert_eq!(tail.len(), 1, "head + 1 tail == the 2-event run");
791                assert!(matches!(halt, Halt::Gap { got } if got == v(6)));
792            }
793            other => panic!("expected Run, got {other:?}"),
794        }
795    }
796
797    #[test]
798    fn plan_internal_corrupt_halts_corrupt() {
799        let s = section("s", vec![evt(1), evt(2), ImportBlock::Corrupt, evt(3)]);
800        match plan_section(&s).expect("plans") {
801            SectionPlan::Run {
802                last, halt, tail, ..
803            } => {
804                assert_eq!(last, v(2));
805                assert_eq!(tail.len(), 1, "head + 1 tail == the 2-event run");
806                assert!(matches!(halt, Halt::Corrupt));
807            }
808            other => panic!("expected Run, got {other:?}"),
809        }
810    }
811
812    #[test]
813    fn plan_overflow_building_run_errors() {
814        // last == u64::MAX with a following block forces prev.next() overflow.
815        let s = section("s", vec![evt(u64::MAX), evt(1)]);
816        assert!(matches!(plan_section(&s), Err(PlanError::VersionOverflow)));
817    }
818
819    #[test]
820    fn plan_run_ending_at_u64_max_with_no_successor_completes() {
821        // Complement of plan_overflow_building_run_errors: a run that ENDS at
822        // u64::MAX with NO following block must complete — `last.next()` is never
823        // called, so no spurious VersionOverflow. Guards against a refactor that
824        // moves the overflow check to fire unconditionally.
825        let s = section("s", vec![evt(u64::MAX)]);
826        match plan_section(&s).expect("plans") {
827            SectionPlan::Run {
828                first,
829                expected_version,
830                tail,
831                last,
832                halt,
833                ..
834            } => {
835                assert_eq!(first, v(u64::MAX));
836                assert_eq!(expected_version, Some(v(u64::MAX - 1)));
837                assert!(tail.is_empty(), "a one-event run is head-only");
838                assert_eq!(last, v(u64::MAX));
839                assert!(matches!(halt, Halt::Complete));
840            }
841            other => panic!("expected Run, got {other:?}"),
842        }
843    }
844
845    // ── EventImporter PerStream behavioral tests ─────────────────────────────
846}