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(§ion("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}