Skip to main content

reddb_server/storage/
columnar_projection.rs

1//! Columnar analytics projection — first end-to-end tracer (ADR 0069, #1766).
2//!
3//! This module is the initial vertical slice of the *columnar analytics
4//! projection*: a **derived, disposable** columnar representation of one
5//! append-only collection with a minimal scalar type set. It composes the
6//! authorities that already exist rather than inventing new persistence:
7//!
8//! * columnar segments are the native `RDCC` column-block
9//!   ([`write_column_block`]/[`read_column_block`]), so every segment carries
10//!   the standard CRC-32 checksum baked into that frame;
11//! * each segment is sealed under the crypto **page envelope**
12//!   ([`reddb_crypto::encrypt_page`]/[`reddb_crypto::decrypt_page`], ADR 0054);
13//! * the [`ProjectionManifest`] records every segment as *derived* — so
14//!   backup/restore skip or rebuild them and recovery never depends on them.
15//!
16//! The contract this slice fixes (ADR 0069):
17//!
18//! 1. **Derived on the checkpoint path.** [`ColumnarProjection::emit_at_checkpoint`]
19//!    is the *only* producer of columnar segments. The write path
20//!    ([`AppendOnlyCollection::append`]) never dual-writes columnar.
21//! 2. **Transcoding budget.** A checkpoint that cannot afford to transcode the
22//!    whole un-materialized tail transcodes a prefix within budget and *always
23//!    completes*; the deferred rows are picked up by the next checkpoint.
24//! 3. **One consistency class — the LSN-pinned analytical scan.**
25//!    [`ColumnarProjection::analytical_scan`] reads columnar segments up to the
26//!    last materialized LSN and concatenates the un-materialized tail through
27//!    the normal row read path ([`AppendOnlyCollection::row_scan`]), all under a
28//!    single pinned LSN. A row committed after the last checkpoint is therefore
29//!    immediately visible; `AS OF` composes by pinning a historical LSN.
30//!
31//! Scope is deliberately narrow: one append-only collection, the full storable
32//! [`Value`] type set, in-process segment store. Wiring the emitter into the
33//! live `SegmentManager` checkpoint and the query executor's scan is the
34//! follow-up slice; the equivalence/freshness/budget oracles here prove the
35//! loop is correct end to end first.
36
37use crate::storage::schema::types::{DataType, Value};
38use crate::storage::unified::{
39    read_column_block, write_column_block, ColumnBlockError, ColumnInput, ColumnSemantics,
40};
41
42/// Rows per sparse granule mark inside a sealed segment. Mirrors the default
43/// the timeseries seal path uses; the exact value only affects skip-index
44/// granularity, never correctness of the round-trip.
45const GRANULE_SIZE: u32 = 128;
46
47/// A single append-only row: one scalar [`Value`] per projected column, in
48/// schema order. This is the source-of-truth shape the row read path yields
49/// and the columnar scan must reproduce bit-for-bit.
50pub type Row = Vec<Value>;
51
52/// One projected column's identity and declared scalar type.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ProjectionColumn {
55    /// Stable column id — the key the `RDCC` directory addresses columns by.
56    pub column_id: u32,
57    /// Declared value type.
58    pub data_type: DataType,
59}
60
61/// The scalar schema of the projected append-only collection.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ProjectionSchema {
64    pub columns: Vec<ProjectionColumn>,
65}
66
67impl ProjectionSchema {
68    pub fn new(columns: Vec<ProjectionColumn>) -> Self {
69        Self { columns }
70    }
71
72    fn width(&self) -> usize {
73        self.columns.len()
74    }
75}
76
77/// Errors from projection emit/scan. Every variant fails closed — a corrupt
78/// or unsupported input is rejected, never silently mis-decoded.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum ProjectionError {
81    /// A value's runtime type did not match the column's declared type.
82    TypeMismatch {
83        column: usize,
84        expected: DataType,
85        found: &'static str,
86    },
87    /// A row's arity did not match the schema width.
88    RowWidth { expected: usize, found: usize },
89    /// LSNs must be strictly increasing on the append-only log.
90    NonMonotonicLsn { last: u64, next: u64 },
91    /// The sealed `RDCC` frame failed to decode (bad magic, CRC, directory…).
92    Block(ColumnBlockError),
93    /// The crypto page envelope failed to open (wrong key, tampering, clip).
94    Envelope(String),
95    /// A decoded column stream was ragged for its fixed-width type, or a
96    /// column expected by the schema was absent from the segment.
97    CorruptSegment(&'static str),
98}
99
100impl std::fmt::Display for ProjectionError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::TypeMismatch {
104                column,
105                expected,
106                found,
107            } => write!(
108                f,
109                "projection column {column}: expected {expected}, found {found}"
110            ),
111            Self::RowWidth { expected, found } => {
112                write!(
113                    f,
114                    "projection row width mismatch: expected {expected}, got {found}"
115                )
116            }
117            Self::NonMonotonicLsn { last, next } => {
118                write!(f, "projection: non-monotonic lsn {next} after {last}")
119            }
120            Self::Block(e) => write!(f, "projection segment decode failed: {e}"),
121            Self::Envelope(detail) => write!(f, "projection segment envelope failed: {detail}"),
122            Self::CorruptSegment(detail) => write!(f, "projection segment corrupt: {detail}"),
123        }
124    }
125}
126
127impl std::error::Error for ProjectionError {}
128
129impl ProjectionError {
130    fn is_projection_artifact_failure(&self) -> bool {
131        matches!(
132            self,
133            Self::Block(_) | Self::Envelope(_) | Self::CorruptSegment(_)
134        )
135    }
136}
137
138impl From<ColumnBlockError> for ProjectionError {
139    fn from(e: ColumnBlockError) -> Self {
140        Self::Block(e)
141    }
142}
143
144/// One append-only row stamped with its commit LSN.
145#[derive(Debug, Clone, PartialEq)]
146struct LsnRow {
147    lsn: u64,
148    row: Row,
149}
150
151/// The append-only collection: the **sole source of truth** for this slice.
152/// Rows are appended in strictly increasing LSN order; the write path only
153/// ever grows this log and never touches columnar state (ADR 0069 §2).
154#[derive(Debug, Clone)]
155pub struct AppendOnlyCollection {
156    schema: ProjectionSchema,
157    rows: Vec<LsnRow>,
158    last_lsn: u64,
159}
160
161impl AppendOnlyCollection {
162    pub fn new(schema: ProjectionSchema) -> Self {
163        Self {
164            schema,
165            rows: Vec::new(),
166            last_lsn: 0,
167        }
168    }
169
170    pub fn schema(&self) -> &ProjectionSchema {
171        &self.schema
172    }
173
174    /// The highest LSN committed so far (0 when empty). A caller pins this to
175    /// scan "as of now".
176    pub fn latest_lsn(&self) -> u64 {
177        self.last_lsn
178    }
179
180    /// Append one row at `lsn`. This is the transactional write path: it grows
181    /// the row log only — never the columnar projection.
182    pub fn append(&mut self, lsn: u64, row: Row) -> Result<(), ProjectionError> {
183        if row.len() != self.schema.width() {
184            return Err(ProjectionError::RowWidth {
185                expected: self.schema.width(),
186                found: row.len(),
187            });
188        }
189        if lsn <= self.last_lsn {
190            return Err(ProjectionError::NonMonotonicLsn {
191                last: self.last_lsn,
192                next: lsn,
193            });
194        }
195        for (idx, value) in row.iter().enumerate() {
196            check_value_type(idx, &self.schema.columns[idx].data_type, value)?;
197        }
198        self.last_lsn = lsn;
199        self.rows.push(LsnRow { lsn, row });
200        Ok(())
201    }
202
203    /// The row read path — the equivalence oracle. Returns every row visible
204    /// at `pinned_lsn` (lsn ≤ pinned), in commit order. This is what an
205    /// ordinary MVCC snapshot scan would yield.
206    pub fn row_scan(&self, pinned_lsn: u64) -> Vec<Row> {
207        self.rows
208            .iter()
209            .filter(|r| r.lsn <= pinned_lsn)
210            .map(|r| r.row.clone())
211            .collect()
212    }
213
214    /// Rows in the half-open LSN window `(after, up_to]`, in commit order.
215    fn rows_between(&self, after: u64, up_to: u64) -> impl Iterator<Item = &LsnRow> {
216        self.rows
217            .iter()
218            .filter(move |r| r.lsn > after && r.lsn <= up_to)
219    }
220
221    fn next_visible_lsn_after(&self, after: u64, up_to: u64) -> Option<u64> {
222        self.rows_between(after, up_to).next().map(|r| r.lsn)
223    }
224}
225
226/// A manifest record for one emitted columnar segment. Always `derived`:
227/// backup/restore skip these and recovery rebuilds rather than restores them
228/// (ADR 0069 §1).
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ProjectionSegmentEntry {
231    pub segment_id: u64,
232    /// Inclusive LSN range this segment materializes.
233    pub first_lsn: u64,
234    pub last_lsn: u64,
235    pub row_count: u64,
236    /// CRC-32 over the sealed (post-envelope) bytes — the "standard checksum"
237    /// that guards the segment file before it is ever decrypted.
238    pub sealed_crc32: u32,
239    /// Always true: a projection segment is never source of truth.
240    pub derived: bool,
241}
242
243/// The projection's operational manifest: the ordered set of derived segment
244/// entries plus the materialization watermark (the last LSN a segment covers).
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct ProjectionManifest {
247    segments: Vec<ProjectionSegmentEntry>,
248    last_materialized_lsn: u64,
249}
250
251impl ProjectionManifest {
252    /// The materialization watermark: columnar segments cover every LSN up to
253    /// and including this value; everything past it is the un-materialized
254    /// tail served by the row path.
255    pub fn last_materialized_lsn(&self) -> u64 {
256        self.last_materialized_lsn
257    }
258
259    pub fn segments(&self) -> &[ProjectionSegmentEntry] {
260        &self.segments
261    }
262
263    /// Every projection entry is derived — this is an invariant, asserted so a
264    /// future edit that forgets to mark an entry derived fails a test.
265    pub fn all_derived(&self) -> bool {
266        self.segments.iter().all(|s| s.derived)
267    }
268
269    /// What backup/restore must persist: nothing. Projection segments are
270    /// rebuildable, so a backup that skips them loses no truth (ADR 0069 §1).
271    pub fn durable_entries(&self) -> impl Iterator<Item = &ProjectionSegmentEntry> {
272        self.segments.iter().filter(|s| !s.derived)
273    }
274}
275
276/// Knobs for a checkpoint emission.
277#[derive(Debug, Clone, Copy)]
278pub struct TranscodeBudget {
279    /// Maximum rows this checkpoint may transcode into columnar segments.
280    /// A checkpoint always completes: rows beyond the budget are deferred.
281    pub max_rows: u64,
282    /// Rows per emitted segment (the last segment may be shorter).
283    pub segment_rows: u64,
284    /// Size floor: if the un-materialized tail up to the checkpoint boundary is
285    /// smaller than this, materialization is skipped this round — the tail
286    /// stays visible through the row path, so correctness is unaffected while
287    /// bookkeeping is saved (ADR 0069 §6).
288    pub size_floor_rows: u64,
289}
290
291impl Default for TranscodeBudget {
292    fn default() -> Self {
293        Self {
294            max_rows: u64::MAX,
295            segment_rows: 1024,
296            size_floor_rows: 1,
297        }
298    }
299}
300
301/// Outcome of one [`ColumnarProjection::emit_at_checkpoint`].
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct EmitOutcome {
304    pub segments_emitted: usize,
305    pub rows_materialized: u64,
306    /// Rows within the checkpoint boundary the budget could not afford; the
307    /// next checkpoint picks them up.
308    pub rows_deferred: u64,
309    /// True when the budget stopped emission before draining the tail.
310    pub budget_exhausted: bool,
311    /// True when the size floor skipped materialization this round.
312    pub floor_skipped: bool,
313}
314
315/// A sealed segment kept in the projection's in-process store: its manifest
316/// entry plus the sealed (enveloped) bytes.
317#[derive(Debug, Clone)]
318struct StoredSegment {
319    entry: ProjectionSegmentEntry,
320    sealed: Vec<u8>,
321}
322
323/// The columnar analytics projection for one append-only collection.
324#[derive(Debug, Clone)]
325pub struct ColumnarProjection {
326    schema: ProjectionSchema,
327    key: [u8; 32],
328    segments: Vec<StoredSegment>,
329    manifest: ProjectionManifest,
330    next_segment_id: u64,
331}
332
333impl ColumnarProjection {
334    /// Create a projection over `schema`, sealing segments under `key`.
335    pub fn new(schema: ProjectionSchema, key: [u8; 32]) -> Self {
336        Self {
337            schema,
338            key,
339            segments: Vec::new(),
340            manifest: ProjectionManifest::default(),
341            next_segment_id: 1,
342        }
343    }
344
345    pub fn manifest(&self) -> &ProjectionManifest {
346        &self.manifest
347    }
348
349    /// Drop every materialized segment. Repair is always "regenerate": after a
350    /// drop, the next `emit_at_checkpoint` rebuilds the projection from the row
351    /// log, and reads keep working meanwhile (served entirely by the row tail).
352    pub fn drop_projection(&mut self) {
353        self.segments.clear();
354        self.manifest = ProjectionManifest::default();
355    }
356
357    /// Rebuild the derived projection from the row log. This is the repair
358    /// primitive for missing or corrupt projection artifacts: truth stays in
359    /// [`AppendOnlyCollection`], so repair is drop-and-regenerate.
360    pub fn rebuild_from_truth(
361        &mut self,
362        collection: &AppendOnlyCollection,
363        checkpoint_lsn: u64,
364        budget: TranscodeBudget,
365    ) -> Result<EmitOutcome, ProjectionError> {
366        self.drop_projection();
367        self.emit_at_checkpoint(collection, checkpoint_lsn, budget)
368    }
369
370    /// Emit columnar segments for the un-materialized tail up to `checkpoint_lsn`,
371    /// bounded by `budget`. Never fails for lack of budget: it transcodes a
372    /// prefix and defers the rest. This is the *only* producer of columnar
373    /// segments (ADR 0069 §2).
374    pub fn emit_at_checkpoint(
375        &mut self,
376        collection: &AppendOnlyCollection,
377        checkpoint_lsn: u64,
378        budget: TranscodeBudget,
379    ) -> Result<EmitOutcome, ProjectionError> {
380        let watermark = self.manifest.last_materialized_lsn;
381        let pending: Vec<&LsnRow> = collection.rows_between(watermark, checkpoint_lsn).collect();
382        let available = pending.len() as u64;
383
384        if available < budget.size_floor_rows.max(1) {
385            return Ok(EmitOutcome {
386                segments_emitted: 0,
387                rows_materialized: 0,
388                rows_deferred: available,
389                budget_exhausted: false,
390                floor_skipped: available > 0,
391            });
392        }
393
394        let to_materialize = available.min(budget.max_rows) as usize;
395        let segment_rows = budget.segment_rows.max(1) as usize;
396        let mut segments_emitted = 0usize;
397
398        for chunk in pending[..to_materialize].chunks(segment_rows) {
399            let entry = self.seal_segment(chunk)?;
400            self.manifest.last_materialized_lsn = entry.last_lsn;
401            self.manifest.segments.push(entry);
402            segments_emitted += 1;
403        }
404
405        let rows_deferred = available - to_materialize as u64;
406        Ok(EmitOutcome {
407            segments_emitted,
408            rows_materialized: to_materialize as u64,
409            rows_deferred,
410            budget_exhausted: rows_deferred > 0,
411            floor_skipped: false,
412        })
413    }
414
415    /// Transcode one contiguous run of rows into a sealed columnar segment and
416    /// record its derived manifest entry.
417    fn seal_segment(
418        &mut self,
419        rows: &[&LsnRow],
420    ) -> Result<ProjectionSegmentEntry, ProjectionError> {
421        let segment_id = self.next_segment_id;
422        self.next_segment_id += 1;
423        let first_lsn = rows.first().map(|r| r.lsn).unwrap_or(0);
424        let last_lsn = rows.last().map(|r| r.lsn).unwrap_or(0);
425
426        // Transpose rows → per-column raw little-endian byte streams.
427        let mut column_bytes: Vec<Vec<u8>> = vec![Vec::new(); self.schema.columns.len()];
428        for lsn_row in rows {
429            for (idx, value) in lsn_row.row.iter().enumerate() {
430                let dt = self.schema.columns[idx].data_type;
431                check_value_type(idx, &dt, value)?;
432                encode_cell(dt, value, &mut column_bytes[idx])?;
433            }
434        }
435
436        let inputs: Vec<ColumnInput<'_>> = self
437            .schema
438            .columns
439            .iter()
440            .zip(column_bytes.iter())
441            .map(|(col, data)| ColumnInput {
442                column_id: col.column_id,
443                logical_type: col.data_type.to_byte(),
444                semantics: ColumnSemantics::Generic,
445                data,
446            })
447            .collect();
448
449        // The RDCC frame carries its own CRC-32 (checksum coverage).
450        let frame = write_column_block(
451            segment_id,
452            self.schema.columns.len() as u64,
453            rows.len() as u64,
454            first_lsn,
455            last_lsn,
456            GRANULE_SIZE,
457            &inputs,
458        )?;
459
460        // Seal under the crypto page envelope (ADR 0054); bind the segment id
461        // as the page-id AAD so a swapped segment fails the tag check.
462        let sealed = reddb_crypto::encrypt_page(&self.key, segment_id as u32, &frame)
463            .map_err(|e| ProjectionError::Envelope(e.to_string()))?;
464        let sealed_crc32 = crc32fast::hash(&sealed);
465
466        let entry = ProjectionSegmentEntry {
467            segment_id,
468            first_lsn,
469            last_lsn,
470            row_count: rows.len() as u64,
471            sealed_crc32,
472            derived: true,
473        };
474        self.segments.push(StoredSegment {
475            entry: entry.clone(),
476            sealed,
477        });
478        Ok(entry)
479    }
480
481    /// The LSN-pinned analytical scan (ADR 0069 §4). Under a single pinned LSN:
482    /// decode columnar segments up to the last materialized LSN, then
483    /// concatenate the un-materialized tail through the row read path. The
484    /// result is identical, row-for-row and in order, to
485    /// [`AppendOnlyCollection::row_scan`] at the same `pinned_lsn` — one
486    /// consistency class, always fresh; `AS OF` is just a historical pin.
487    pub fn analytical_scan(
488        &self,
489        collection: &AppendOnlyCollection,
490        pinned_lsn: u64,
491    ) -> Result<Vec<Row>, ProjectionError> {
492        let columnar_ceiling = self.manifest.last_materialized_lsn.min(pinned_lsn);
493
494        let mut out = Vec::new();
495        let mut tail_start = 0u64;
496        for stored in &self.segments {
497            if stored.entry.last_lsn > columnar_ceiling {
498                continue;
499            }
500            match collection.next_visible_lsn_after(tail_start, pinned_lsn) {
501                Some(next_lsn) if next_lsn == stored.entry.first_lsn => {}
502                Some(_) | None => return Ok(collection.row_scan(pinned_lsn)),
503            }
504            self.verify_and_decode_segment(stored, &mut out)?;
505            tail_start = tail_start.max(stored.entry.last_lsn);
506        }
507
508        // Un-materialized tail: everything past the last included segment up to
509        // the pin, straight from the row path. Fresh rows land here.
510        for lsn_row in collection.rows_between(tail_start, pinned_lsn) {
511            out.push(lsn_row.row.clone());
512        }
513        Ok(out)
514    }
515
516    /// Analytical scan with projection repair. Missing derived segment bytes
517    /// or checksum/envelope/frame failures never make the query depend on a
518    /// bad projection: the projection is dropped, rebuilt from the row log, and
519    /// scanned again under the same LSN pin.
520    pub fn repairing_analytical_scan(
521        &mut self,
522        collection: &AppendOnlyCollection,
523        pinned_lsn: u64,
524        budget: TranscodeBudget,
525    ) -> Result<Vec<Row>, ProjectionError> {
526        if self.projection_artifacts_missing() {
527            self.rebuild_from_truth(collection, pinned_lsn, budget)?;
528            return self.analytical_scan(collection, pinned_lsn);
529        }
530
531        match self.analytical_scan(collection, pinned_lsn) {
532            Ok(rows) => Ok(rows),
533            Err(err) if err.is_projection_artifact_failure() => {
534                self.rebuild_from_truth(collection, pinned_lsn, budget)?;
535                self.analytical_scan(collection, pinned_lsn)
536            }
537            Err(err) => Err(err),
538        }
539    }
540
541    fn projection_artifacts_missing(&self) -> bool {
542        if self.manifest.segments.is_empty() {
543            return false;
544        }
545        if self.manifest.segments.len() != self.segments.len() {
546            return true;
547        }
548        self.manifest
549            .segments
550            .iter()
551            .zip(self.segments.iter())
552            .any(|(manifest, stored)| manifest != &stored.entry)
553    }
554
555    /// Verify a sealed segment (envelope + `RDCC` CRC), decode it, and push its
556    /// rows onto `out` in stored order.
557    fn verify_and_decode_segment(
558        &self,
559        stored: &StoredSegment,
560        out: &mut Vec<Row>,
561    ) -> Result<(), ProjectionError> {
562        if crc32fast::hash(&stored.sealed) != stored.entry.sealed_crc32 {
563            return Err(ProjectionError::CorruptSegment("sealed checksum mismatch"));
564        }
565        let frame =
566            reddb_crypto::decrypt_page(&self.key, stored.entry.segment_id as u32, &stored.sealed)
567                .map_err(|e| ProjectionError::Envelope(e.to_string()))?;
568        // `read_column_block` verifies the RDCC CRC before handing back streams.
569        let block = read_column_block(&frame)?;
570        let row_count = block.row_count as usize;
571
572        // Decode each schema column, matched by stable column id.
573        let mut decoded_columns: Vec<Vec<Value>> = Vec::with_capacity(self.schema.columns.len());
574        for col in &self.schema.columns {
575            let decoded = block
576                .columns
577                .iter()
578                .find(|c| c.column_id == col.column_id)
579                .ok_or(ProjectionError::CorruptSegment("missing column"))?;
580            decoded_columns.push(decode_column(col.data_type, &decoded.data, row_count)?);
581        }
582
583        // Transpose column-major decode into row-major output by *moving* each
584        // cell out of `decoded_columns` (which is local and dies here) instead
585        // of cloning it. Rows are pre-allocated, then every column is drained in
586        // lockstep into the matching cell, preserving schema column order.
587        let base = out.len();
588        for _ in 0..row_count {
589            out.push(Vec::with_capacity(self.schema.columns.len()));
590        }
591        for column in decoded_columns {
592            // `decode_column` yields exactly `row_count` values; the drain below
593            // relies on that to keep rows rectangular. Fail loud in debug if the
594            // decoder ever breaks the invariant.
595            debug_assert_eq!(
596                column.len(),
597                row_count,
598                "decoded column is not row_count long"
599            );
600            for (r, value) in column.into_iter().take(row_count).enumerate() {
601                out[base + r].push(value);
602            }
603        }
604        Ok(())
605    }
606}
607
608/// The full storable value set this slice materializes. Runtime values must
609/// match the declared column type; nothing is coerced through the projection.
610fn check_value_type(column: usize, dt: &DataType, value: &Value) -> Result<(), ProjectionError> {
611    if value_matches_declared_type(dt, value) {
612        return Ok(());
613    }
614    Err(ProjectionError::TypeMismatch {
615        column,
616        expected: *dt,
617        found: value_kind(value),
618    })
619}
620
621fn value_matches_declared_type(dt: &DataType, value: &Value) -> bool {
622    match (dt, value) {
623        (DataType::Nullable, Value::Null) => true,
624        (DataType::TextZstd, Value::Text(_)) => true,
625        (DataType::BlobZstd, Value::Blob(_)) => true,
626        _ => value.data_type() == *dt,
627    }
628}
629
630fn value_kind(value: &Value) -> &'static str {
631    match value {
632        Value::Null => "Null",
633        Value::Integer(_) => "Integer",
634        Value::UnsignedInteger(_) => "UnsignedInteger",
635        Value::Timestamp(_) => "Timestamp",
636        Value::Float(_) => "Float",
637        Value::Boolean(_) => "Boolean",
638        Value::Text(_) => "Text",
639        Value::Blob(_) => "Blob",
640        Value::Duration(_) => "Duration",
641        Value::IpAddr(_) => "IpAddr",
642        Value::MacAddr(_) => "MacAddr",
643        Value::Vector(_) => "Vector",
644        Value::Json(_) => "Json",
645        Value::Uuid(_) => "Uuid",
646        Value::NodeRef(_) => "NodeRef",
647        Value::EdgeRef(_) => "EdgeRef",
648        Value::VectorRef(_, _) => "VectorRef",
649        Value::RowRef(_, _) => "RowRef",
650        Value::Color(_) => "Color",
651        Value::Email(_) => "Email",
652        Value::Url(_) => "Url",
653        Value::Phone(_) => "Phone",
654        Value::Semver(_) => "Semver",
655        Value::Cidr(_, _) => "Cidr",
656        Value::Date(_) => "Date",
657        Value::Time(_) => "Time",
658        Value::Decimal(_) => "Decimal",
659        Value::DecimalText(_) => "DecimalText",
660        Value::EnumValue(_) => "Enum",
661        Value::Array(_) => "Array",
662        Value::TimestampMs(_) => "TimestampMs",
663        Value::Ipv4(_) => "Ipv4",
664        Value::Ipv6(_) => "Ipv6",
665        Value::Subnet(_, _) => "Subnet",
666        Value::Port(_) => "Port",
667        Value::Latitude(_) => "Latitude",
668        Value::Longitude(_) => "Longitude",
669        Value::GeoPoint(_, _) => "GeoPoint",
670        Value::Country2(_) => "Country2",
671        Value::Country3(_) => "Country3",
672        Value::Lang2(_) => "Lang2",
673        Value::Lang5(_) => "Lang5",
674        Value::Currency(_) => "Currency",
675        Value::AssetCode(_) => "AssetCode",
676        Value::Money { .. } => "Money",
677        Value::ColorAlpha(_) => "ColorAlpha",
678        Value::BigInt(_) => "BigInt",
679        Value::KeyRef(_, _) => "KeyRef",
680        Value::DocRef(_, _) => "DocRef",
681        Value::TableRef(_) => "TableRef",
682        Value::PageRef(_) => "PageRef",
683        Value::Secret(_) => "Secret",
684        Value::Password(_) => "Password",
685    }
686}
687
688/// Append one typed value's canonical bytes to its column stream.
689fn encode_cell(dt: DataType, value: &Value, out: &mut Vec<u8>) -> Result<(), ProjectionError> {
690    check_value_type(0, &dt, value)?;
691    let encoded = value.to_bytes();
692    out.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
693    out.extend_from_slice(&encoded);
694    Ok(())
695}
696
697/// Decode a column's raw stream back into `row_count` values, the exact
698/// inverse of [`encode_cell`].
699fn decode_column(
700    dt: DataType,
701    data: &[u8],
702    row_count: usize,
703) -> Result<Vec<Value>, ProjectionError> {
704    let mut out = Vec::with_capacity(row_count);
705    let mut cur = 0usize;
706    for _ in 0..row_count {
707        if cur + 4 > data.len() {
708            return Err(ProjectionError::CorruptSegment("truncated value length"));
709        }
710        let len = u32::from_le_bytes(to_4(&data[cur..cur + 4])) as usize;
711        cur += 4;
712        let end = cur
713            .checked_add(len)
714            .filter(|e| *e <= data.len())
715            .ok_or(ProjectionError::CorruptSegment("truncated value body"))?;
716        let (value, consumed) = Value::from_bytes(&data[cur..end])
717            .map_err(|_| ProjectionError::CorruptSegment("invalid value bytes"))?;
718        if consumed != len {
719            return Err(ProjectionError::CorruptSegment("trailing value bytes"));
720        }
721        if !value_matches_declared_type(&dt, &value) {
722            return Err(ProjectionError::CorruptSegment(
723                "decoded value type mismatch",
724            ));
725        }
726        out.push(value);
727        cur = end;
728    }
729    if cur != data.len() {
730        return Err(ProjectionError::CorruptSegment("trailing column bytes"));
731    }
732    Ok(out)
733}
734
735fn to_4(b: &[u8]) -> [u8; 4] {
736    let mut a = [0u8; 4];
737    a.copy_from_slice(b);
738    a
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
745
746    const KEY: [u8; 32] = [7u8; 32];
747
748    fn schema() -> ProjectionSchema {
749        ProjectionSchema::new(vec![
750            ProjectionColumn {
751                column_id: 0,
752                data_type: DataType::Timestamp,
753            },
754            ProjectionColumn {
755                column_id: 1,
756                data_type: DataType::Integer,
757            },
758            ProjectionColumn {
759                column_id: 2,
760                data_type: DataType::Float,
761            },
762            ProjectionColumn {
763                column_id: 3,
764                data_type: DataType::Boolean,
765            },
766            ProjectionColumn {
767                column_id: 4,
768                data_type: DataType::Text,
769            },
770        ])
771    }
772
773    fn row(i: i64) -> Row {
774        vec![
775            Value::Timestamp(1_700_000_000 + i),
776            Value::Integer(i * 3),
777            Value::Float(i as f64 * 0.5),
778            Value::Boolean(i % 2 == 0),
779            Value::Text(format!("event-{i}").into()),
780        ]
781    }
782
783    fn full_type_schema_and_row(seed: u8) -> (ProjectionSchema, Row) {
784        let types_and_values = vec![
785            (DataType::Nullable, Value::Null),
786            (DataType::Integer, Value::Integer(i64::MIN + seed as i64)),
787            (
788                DataType::UnsignedInteger,
789                Value::UnsignedInteger(u64::MAX - seed as u64),
790            ),
791            (
792                DataType::Float,
793                Value::Float(f64::from_bits(0x4009_21fb_5444_2d18)),
794            ),
795            (DataType::Text, Value::Text(format!("text-{seed}").into())),
796            (DataType::Blob, Value::Blob(vec![0, seed, 255])),
797            (DataType::Boolean, Value::Boolean(seed % 2 == 0)),
798            (
799                DataType::Timestamp,
800                Value::Timestamp(-1_700_000_000 + seed as i64),
801            ),
802            (DataType::Duration, Value::Duration(-42 + seed as i64)),
803            (
804                DataType::IpAddr,
805                Value::IpAddr(IpAddr::V6(Ipv6Addr::new(
806                    0x2606,
807                    0x4700,
808                    0,
809                    0,
810                    0,
811                    0,
812                    0,
813                    seed as u16,
814                ))),
815            ),
816            (DataType::MacAddr, Value::MacAddr([0, 1, 2, 3, 4, seed])),
817            (
818                DataType::Vector,
819                Value::Vector(vec![1.25, -2.5, seed as f32]),
820            ),
821            (
822                DataType::Json,
823                Value::Json(format!(r#"{{"seed":{seed}}}"#).into_bytes()),
824            ),
825            (DataType::Uuid, Value::Uuid([seed; 16])),
826            (DataType::NodeRef, Value::NodeRef(format!("node-{seed}"))),
827            (DataType::EdgeRef, Value::EdgeRef(format!("edge-{seed}"))),
828            (
829                DataType::VectorRef,
830                Value::VectorRef(format!("vectors-{seed}"), 99),
831            ),
832            (
833                DataType::RowRef,
834                Value::RowRef(format!("table-{seed}"), 100),
835            ),
836            (DataType::Color, Value::Color([seed, 2, 3])),
837            (
838                DataType::Email,
839                Value::Email(format!("u{seed}@example.com")),
840            ),
841            (
842                DataType::Url,
843                Value::Url(format!("https://example.com/{seed}")),
844            ),
845            (DataType::Phone, Value::Phone(55_119_000_0000 + seed as u64)),
846            (DataType::Semver, Value::Semver(1_002_003 + seed as u32)),
847            (DataType::Cidr, Value::Cidr(0x0a00_0000 + seed as u32, 24)),
848            (DataType::Date, Value::Date(-20_000 + seed as i32)),
849            (DataType::Time, Value::Time(86_399_000 - seed as u32)),
850            (DataType::Decimal, Value::Decimal(-123_456 + seed as i64)),
851            (DataType::Enum, Value::EnumValue(seed)),
852            (
853                DataType::Array,
854                Value::Array(vec![
855                    Value::Integer(seed as i64),
856                    Value::Text("nested".into()),
857                ]),
858            ),
859            (
860                DataType::TimestampMs,
861                Value::TimestampMs(1_700_000_000_000 + seed as i64),
862            ),
863            (
864                DataType::Ipv4,
865                Value::Ipv4(u32::from(Ipv4Addr::new(192, 0, 2, seed))),
866            ),
867            (DataType::Ipv6, Value::Ipv6(Ipv6Addr::LOCALHOST.octets())),
868            (
869                DataType::Subnet,
870                Value::Subnet(0xc000_0200 + seed as u32, 0xffff_ff00),
871            ),
872            (DataType::Port, Value::Port(8000 + seed as u16)),
873            (
874                DataType::Latitude,
875                Value::Latitude(-23_550_000 + seed as i32),
876            ),
877            (
878                DataType::Longitude,
879                Value::Longitude(-46_633_000 + seed as i32),
880            ),
881            (
882                DataType::GeoPoint,
883                Value::GeoPoint(-23_550_000, -46_633_000 + seed as i32),
884            ),
885            (DataType::Country2, Value::Country2(*b"BR")),
886            (DataType::Country3, Value::Country3(*b"BRA")),
887            (DataType::Lang2, Value::Lang2(*b"pt")),
888            (DataType::Lang5, Value::Lang5(*b"pt-BR")),
889            (DataType::Currency, Value::Currency(*b"BRL")),
890            (
891                DataType::AssetCode,
892                Value::AssetCode(format!("ASSET{seed}")),
893            ),
894            (
895                DataType::Money,
896                Value::Money {
897                    asset_code: "BRL".to_string(),
898                    minor_units: -1_234_567 + seed as i64,
899                    scale: 2,
900                },
901            ),
902            (DataType::ColorAlpha, Value::ColorAlpha([seed, 2, 3, 4])),
903            (DataType::BigInt, Value::BigInt(i64::MAX - seed as i64)),
904            (
905                DataType::KeyRef,
906                Value::KeyRef(format!("kv-{seed}"), format!("key-{seed}")),
907            ),
908            (DataType::DocRef, Value::DocRef(format!("docs-{seed}"), 321)),
909            (DataType::TableRef, Value::TableRef(format!("table-{seed}"))),
910            (DataType::PageRef, Value::PageRef(42 + seed as u32)),
911            (DataType::Secret, Value::Secret(vec![seed, 7, 8, 9])),
912            (
913                DataType::Password,
914                Value::Password(format!("argon2id-hash-{seed}")),
915            ),
916            (
917                DataType::TextZstd,
918                Value::Text(format!("toast-text-{seed}").into()),
919            ),
920            (DataType::BlobZstd, Value::Blob(vec![9, 8, 7, seed])),
921        ];
922
923        let columns = types_and_values
924            .iter()
925            .enumerate()
926            .map(|(idx, (data_type, _))| ProjectionColumn {
927                column_id: idx as u32,
928                data_type: *data_type,
929            })
930            .collect();
931        let row = types_and_values
932            .into_iter()
933            .map(|(_, value)| value)
934            .collect();
935        (ProjectionSchema::new(columns), row)
936    }
937
938    /// Fill a collection with rows at lsn = 1..=n.
939    fn filled(n: i64) -> AppendOnlyCollection {
940        let mut c = AppendOnlyCollection::new(schema());
941        for i in 1..=n {
942            c.append(i as u64, row(i)).expect("append");
943        }
944        c
945    }
946
947    #[test]
948    fn write_path_never_dual_writes_columnar() {
949        // AC1: appending rows produces no columnar segments; only an explicit
950        // checkpoint emit does.
951        let collection = filled(10);
952        let projection = ColumnarProjection::new(schema(), KEY);
953        assert_eq!(projection.manifest().segments().len(), 0);
954        assert_eq!(projection.manifest().last_materialized_lsn(), 0);
955        // The collection scan still returns everything via the row path.
956        assert_eq!(collection.row_scan(collection.latest_lsn()).len(), 10);
957    }
958
959    #[test]
960    fn equivalence_oracle_scan_matches_row_scan() {
961        // AC2: projection scan == row scan under the same pinned snapshot.
962        let collection = filled(500);
963        let mut projection = ColumnarProjection::new(schema(), KEY);
964        projection
965            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
966            .expect("emit");
967
968        let pinned = collection.latest_lsn();
969        let via_projection = projection
970            .analytical_scan(&collection, pinned)
971            .expect("scan");
972        let via_row = collection.row_scan(pinned);
973
974        assert_eq!(via_projection.len(), 500);
975        assert_eq!(
976            via_projection, via_row,
977            "projection scan must equal row scan"
978        );
979    }
980
981    #[test]
982    fn columnar_segments_are_actually_exercised() {
983        // Guard: the equivalence must come from a real columnar decode, not an
984        // empty projection that falls entirely through the row tail.
985        let collection = filled(300);
986        let mut projection = ColumnarProjection::new(schema(), KEY);
987        let outcome = projection
988            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
989            .expect("emit");
990        assert!(outcome.segments_emitted >= 1);
991        assert_eq!(projection.manifest().last_materialized_lsn(), 300);
992        // Scanning exactly at the materialized ceiling uses only columnar rows.
993        let scan = projection.analytical_scan(&collection, 300).expect("scan");
994        assert_eq!(scan, collection.row_scan(300));
995    }
996
997    #[test]
998    fn full_value_type_set_round_trips_through_columnar_projection() {
999        let (schema, first) = full_type_schema_and_row(1);
1000        let (_, second) = full_type_schema_and_row(2);
1001        let mut collection = AppendOnlyCollection::new(schema.clone());
1002        collection.append(1, first).expect("append first");
1003        collection.append(2, second).expect("append second");
1004
1005        let mut projection = ColumnarProjection::new(schema, KEY);
1006        let outcome = projection
1007            .emit_at_checkpoint(&collection, 2, TranscodeBudget::default())
1008            .expect("emit");
1009        assert_eq!(outcome.rows_materialized, 2);
1010        assert_eq!(outcome.rows_deferred, 0);
1011
1012        let via_projection = projection.analytical_scan(&collection, 2).expect("scan");
1013        let via_row = collection.row_scan(2);
1014        assert_eq!(
1015            via_projection, via_row,
1016            "every storable value type must round-trip without coercion or loss"
1017        );
1018    }
1019
1020    #[test]
1021    fn freshness_row_after_checkpoint_is_immediately_visible() {
1022        // AC3: a row inserted after the last checkpoint is visible to the
1023        // analytical scan with no re-materialization — one consistency class.
1024        let mut collection = filled(100);
1025        let mut projection = ColumnarProjection::new(schema(), KEY);
1026        projection
1027            .emit_at_checkpoint(&collection, 100, TranscodeBudget::default())
1028            .expect("emit");
1029        assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1030
1031        // Commit one more row *after* the checkpoint.
1032        collection.append(101, row(101)).expect("append fresh");
1033
1034        let scan = projection.analytical_scan(&collection, 101).expect("scan");
1035        assert_eq!(scan.len(), 101, "fresh row must be visible immediately");
1036        assert_eq!(scan.last().cloned().unwrap(), row(101));
1037        // And the projection was NOT re-materialized to make it visible.
1038        assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1039    }
1040
1041    #[test]
1042    fn as_of_composes_by_pinning_a_historical_lsn() {
1043        // AC (ADR §4): AS OF is just a historical pin.
1044        let collection = filled(200);
1045        let mut projection = ColumnarProjection::new(schema(), KEY);
1046        projection
1047            .emit_at_checkpoint(&collection, 200, TranscodeBudget::default())
1048            .expect("emit");
1049
1050        for pin in [1u64, 57, 150, 200] {
1051            let via_projection = projection.analytical_scan(&collection, pin).expect("scan");
1052            assert_eq!(
1053                via_projection,
1054                collection.row_scan(pin),
1055                "AS OF {pin} must match row scan"
1056            );
1057            assert_eq!(via_projection.len(), pin as usize);
1058        }
1059    }
1060
1061    #[test]
1062    fn historical_pin_before_projection_coverage_falls_back_to_row_path() {
1063        // Issue #1770: a projection can retain only newer columnar coverage.
1064        // An AS OF pin whose visible history starts before that coverage must
1065        // answer from the row/time-travel path rather than returning only the
1066        // retained columnar suffix plus tail.
1067        let collection = filled(300);
1068        let mut projection = ColumnarProjection::new(schema(), KEY);
1069        projection
1070            .emit_at_checkpoint(
1071                &collection,
1072                300,
1073                TranscodeBudget {
1074                    max_rows: u64::MAX,
1075                    segment_rows: 100,
1076                    size_floor_rows: 1,
1077                },
1078            )
1079            .expect("emit segmented projection");
1080
1081        projection.segments.remove(0);
1082        projection.manifest.segments.remove(0);
1083
1084        let pinned = 250;
1085        let via_projection = projection
1086            .analytical_scan(&collection, pinned)
1087            .expect("historical scan");
1088        let via_row = collection.row_scan(pinned);
1089
1090        assert_eq!(
1091            via_projection, via_row,
1092            "historical AS OF must not drop rows older than projection coverage"
1093        );
1094    }
1095
1096    #[test]
1097    fn manifest_entries_are_derived_and_checksummed_and_enveloped() {
1098        // AC4: entries marked derived; chunks carry checksums + crypto envelope.
1099        let collection = filled(50);
1100        let mut projection = ColumnarProjection::new(schema(), KEY);
1101        projection
1102            .emit_at_checkpoint(&collection, 50, TranscodeBudget::default())
1103            .expect("emit");
1104
1105        let manifest = projection.manifest();
1106        assert!(manifest.all_derived());
1107        assert_eq!(
1108            manifest.durable_entries().count(),
1109            0,
1110            "derived → backup skips"
1111        );
1112        for entry in manifest.segments() {
1113            assert!(entry.derived);
1114            assert_ne!(entry.sealed_crc32, 0);
1115        }
1116
1117        // The stored bytes are the crypto envelope, not the raw RDCC frame:
1118        // a wrong key fails the GCM tag check (envelope is real).
1119        let seg = &projection.segments[0];
1120        let wrong = ColumnarProjection::new(schema(), [9u8; 32]);
1121        let err = wrong
1122            .verify_and_decode_segment(seg, &mut Vec::new())
1123            .expect_err("wrong key must fail");
1124        assert!(matches!(err, ProjectionError::Envelope(_)));
1125    }
1126
1127    #[test]
1128    fn sealed_segment_tamper_is_rejected_by_checksum() {
1129        let collection = filled(20);
1130        let mut projection = ColumnarProjection::new(schema(), KEY);
1131        projection
1132            .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1133            .expect("emit");
1134        // Flip a byte in the sealed segment; the manifest crc must catch it.
1135        let mut tampered = projection.segments[0].clone();
1136        tampered.sealed[0] ^= 0xFF;
1137        let err = projection
1138            .verify_and_decode_segment(&tampered, &mut Vec::new())
1139            .expect_err("tamper must be rejected");
1140        assert_eq!(
1141            err,
1142            ProjectionError::CorruptSegment("sealed checksum mismatch")
1143        );
1144    }
1145
1146    #[test]
1147    fn transcoding_budget_completes_and_defers() {
1148        // AC5: checkpoint completes even when the budget is exhausted;
1149        // deferred rows are picked up by the next checkpoint.
1150        let collection = filled(1000);
1151        let mut projection = ColumnarProjection::new(schema(), KEY);
1152
1153        let budget = TranscodeBudget {
1154            max_rows: 400,
1155            segment_rows: 128,
1156            size_floor_rows: 1,
1157        };
1158        let first = projection
1159            .emit_at_checkpoint(&collection, 1000, budget)
1160            .expect("first checkpoint");
1161        assert!(first.budget_exhausted);
1162        assert_eq!(first.rows_materialized, 400);
1163        assert_eq!(first.rows_deferred, 600);
1164        assert_eq!(projection.manifest().last_materialized_lsn(), 400);
1165
1166        // Even mid-budget, the scan is still complete and correct: the deferred
1167        // tail is served by the row path.
1168        let mid = projection.analytical_scan(&collection, 1000).expect("scan");
1169        assert_eq!(mid, collection.row_scan(1000));
1170        assert_eq!(mid.len(), 1000);
1171
1172        // Each subsequent checkpoint transcodes another budget's worth; the
1173        // deferred tail shrinks monotonically until it is drained. Every round
1174        // completes, and the scan stays correct throughout.
1175        let mut rounds = 1;
1176        loop {
1177            let out = projection
1178                .emit_at_checkpoint(&collection, 1000, budget)
1179                .expect("checkpoint");
1180            rounds += 1;
1181            assert_eq!(
1182                projection.analytical_scan(&collection, 1000).expect("scan"),
1183                collection.row_scan(1000)
1184            );
1185            if !out.budget_exhausted {
1186                assert_eq!(out.rows_deferred, 0);
1187                break;
1188            }
1189        }
1190        // 1000 rows / 400 per round → 3 checkpoints total.
1191        assert_eq!(rounds, 3);
1192        assert_eq!(projection.manifest().last_materialized_lsn(), 1000);
1193
1194        let full = projection.analytical_scan(&collection, 1000).expect("scan");
1195        assert_eq!(full, collection.row_scan(1000));
1196    }
1197
1198    #[test]
1199    fn size_floor_skips_tiny_materialization() {
1200        // ADR §6: a tail below the floor is skipped, still fully visible.
1201        let collection = filled(3);
1202        let mut projection = ColumnarProjection::new(schema(), KEY);
1203        let budget = TranscodeBudget {
1204            max_rows: u64::MAX,
1205            segment_rows: 128,
1206            size_floor_rows: 10,
1207        };
1208        let outcome = projection
1209            .emit_at_checkpoint(&collection, 3, budget)
1210            .expect("emit");
1211        assert!(outcome.floor_skipped);
1212        assert_eq!(outcome.segments_emitted, 0);
1213        assert_eq!(projection.manifest().last_materialized_lsn(), 0);
1214        // Still correct: everything served by the row path.
1215        assert_eq!(
1216            projection.analytical_scan(&collection, 3).expect("scan"),
1217            collection.row_scan(3)
1218        );
1219    }
1220
1221    #[test]
1222    fn drop_and_rebuild_is_repair() {
1223        // ADR §1: repair is regenerate, not restore.
1224        let collection = filled(300);
1225        let mut projection = ColumnarProjection::new(schema(), KEY);
1226        projection
1227            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1228            .expect("emit");
1229        let before = projection.analytical_scan(&collection, 300).expect("scan");
1230
1231        projection.drop_projection();
1232        assert_eq!(projection.manifest().segments().len(), 0);
1233        // Reads keep working while un-materialized (pure row tail).
1234        assert_eq!(
1235            projection.analytical_scan(&collection, 300).expect("scan"),
1236            collection.row_scan(300)
1237        );
1238
1239        // Rebuild from the row log → identical scan.
1240        projection
1241            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1242            .expect("rebuild");
1243        let after = projection.analytical_scan(&collection, 300).expect("scan");
1244        assert_eq!(before, after);
1245    }
1246
1247    #[test]
1248    fn repairing_scan_rebuilds_after_projection_artifacts_are_deleted() {
1249        let collection = filled(300);
1250        let mut projection = ColumnarProjection::new(schema(), KEY);
1251        projection
1252            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1253            .expect("emit");
1254        assert!(!projection.segments.is_empty());
1255
1256        // Simulate a backup/restore or operator cleanup that preserves truth
1257        // but omits the derived segment files.
1258        projection.segments.clear();
1259
1260        let scan = projection
1261            .repairing_analytical_scan(&collection, 300, TranscodeBudget::default())
1262            .expect("repairing scan");
1263
1264        assert_eq!(scan, collection.row_scan(300));
1265        assert!(!projection.manifest().segments().is_empty());
1266        assert!(!projection.segments.is_empty());
1267    }
1268
1269    #[test]
1270    fn repairing_scan_rebuilds_after_projection_checksum_mismatch() {
1271        let collection = filled(20);
1272        let mut projection = ColumnarProjection::new(schema(), KEY);
1273        projection
1274            .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1275            .expect("emit");
1276        let original_segment_id = projection.manifest().segments()[0].segment_id;
1277
1278        projection.segments[0].sealed[0] ^= 0xFF;
1279
1280        let scan = projection
1281            .repairing_analytical_scan(&collection, 20, TranscodeBudget::default())
1282            .expect("repairing scan");
1283
1284        assert_eq!(scan, collection.row_scan(20));
1285        assert_ne!(
1286            projection.manifest().segments()[0].segment_id,
1287            original_segment_id,
1288            "corrupt projection bytes must be replaced by a rebuilt segment"
1289        );
1290    }
1291
1292    #[test]
1293    fn append_rejects_non_monotonic_lsn_and_bad_arity() {
1294        let mut c = AppendOnlyCollection::new(schema());
1295        c.append(5, row(1)).expect("first");
1296        assert!(matches!(
1297            c.append(5, row(2)),
1298            Err(ProjectionError::NonMonotonicLsn { .. })
1299        ));
1300        assert!(matches!(
1301            c.append(6, vec![Value::Integer(1)]),
1302            Err(ProjectionError::RowWidth { .. })
1303        ));
1304    }
1305
1306    #[test]
1307    fn mismatched_types_are_rejected() {
1308        let s = ProjectionSchema::new(vec![ProjectionColumn {
1309            column_id: 0,
1310            data_type: DataType::Integer,
1311        }]);
1312        let mut c = AppendOnlyCollection::new(s);
1313        assert!(matches!(
1314            c.append(1, vec![Value::Text("x".into())]),
1315            Err(ProjectionError::TypeMismatch { .. })
1316        ));
1317    }
1318}