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::EnumValue(_) => "Enum",
660        Value::Array(_) => "Array",
661        Value::TimestampMs(_) => "TimestampMs",
662        Value::Ipv4(_) => "Ipv4",
663        Value::Ipv6(_) => "Ipv6",
664        Value::Subnet(_, _) => "Subnet",
665        Value::Port(_) => "Port",
666        Value::Latitude(_) => "Latitude",
667        Value::Longitude(_) => "Longitude",
668        Value::GeoPoint(_, _) => "GeoPoint",
669        Value::Country2(_) => "Country2",
670        Value::Country3(_) => "Country3",
671        Value::Lang2(_) => "Lang2",
672        Value::Lang5(_) => "Lang5",
673        Value::Currency(_) => "Currency",
674        Value::AssetCode(_) => "AssetCode",
675        Value::Money { .. } => "Money",
676        Value::ColorAlpha(_) => "ColorAlpha",
677        Value::BigInt(_) => "BigInt",
678        Value::KeyRef(_, _) => "KeyRef",
679        Value::DocRef(_, _) => "DocRef",
680        Value::TableRef(_) => "TableRef",
681        Value::PageRef(_) => "PageRef",
682        Value::Secret(_) => "Secret",
683        Value::Password(_) => "Password",
684    }
685}
686
687/// Append one typed value's canonical bytes to its column stream.
688fn encode_cell(dt: DataType, value: &Value, out: &mut Vec<u8>) -> Result<(), ProjectionError> {
689    check_value_type(0, &dt, value)?;
690    let encoded = value.to_bytes();
691    out.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
692    out.extend_from_slice(&encoded);
693    Ok(())
694}
695
696/// Decode a column's raw stream back into `row_count` values, the exact
697/// inverse of [`encode_cell`].
698fn decode_column(
699    dt: DataType,
700    data: &[u8],
701    row_count: usize,
702) -> Result<Vec<Value>, ProjectionError> {
703    let mut out = Vec::with_capacity(row_count);
704    let mut cur = 0usize;
705    for _ in 0..row_count {
706        if cur + 4 > data.len() {
707            return Err(ProjectionError::CorruptSegment("truncated value length"));
708        }
709        let len = u32::from_le_bytes(to_4(&data[cur..cur + 4])) as usize;
710        cur += 4;
711        let end = cur
712            .checked_add(len)
713            .filter(|e| *e <= data.len())
714            .ok_or(ProjectionError::CorruptSegment("truncated value body"))?;
715        let (value, consumed) = Value::from_bytes(&data[cur..end])
716            .map_err(|_| ProjectionError::CorruptSegment("invalid value bytes"))?;
717        if consumed != len {
718            return Err(ProjectionError::CorruptSegment("trailing value bytes"));
719        }
720        if !value_matches_declared_type(&dt, &value) {
721            return Err(ProjectionError::CorruptSegment(
722                "decoded value type mismatch",
723            ));
724        }
725        out.push(value);
726        cur = end;
727    }
728    if cur != data.len() {
729        return Err(ProjectionError::CorruptSegment("trailing column bytes"));
730    }
731    Ok(out)
732}
733
734fn to_4(b: &[u8]) -> [u8; 4] {
735    let mut a = [0u8; 4];
736    a.copy_from_slice(b);
737    a
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
744
745    const KEY: [u8; 32] = [7u8; 32];
746
747    fn schema() -> ProjectionSchema {
748        ProjectionSchema::new(vec![
749            ProjectionColumn {
750                column_id: 0,
751                data_type: DataType::Timestamp,
752            },
753            ProjectionColumn {
754                column_id: 1,
755                data_type: DataType::Integer,
756            },
757            ProjectionColumn {
758                column_id: 2,
759                data_type: DataType::Float,
760            },
761            ProjectionColumn {
762                column_id: 3,
763                data_type: DataType::Boolean,
764            },
765            ProjectionColumn {
766                column_id: 4,
767                data_type: DataType::Text,
768            },
769        ])
770    }
771
772    fn row(i: i64) -> Row {
773        vec![
774            Value::Timestamp(1_700_000_000 + i),
775            Value::Integer(i * 3),
776            Value::Float(i as f64 * 0.5),
777            Value::Boolean(i % 2 == 0),
778            Value::Text(format!("event-{i}").into()),
779        ]
780    }
781
782    fn full_type_schema_and_row(seed: u8) -> (ProjectionSchema, Row) {
783        let types_and_values = vec![
784            (DataType::Nullable, Value::Null),
785            (DataType::Integer, Value::Integer(i64::MIN + seed as i64)),
786            (
787                DataType::UnsignedInteger,
788                Value::UnsignedInteger(u64::MAX - seed as u64),
789            ),
790            (
791                DataType::Float,
792                Value::Float(f64::from_bits(0x4009_21fb_5444_2d18)),
793            ),
794            (DataType::Text, Value::Text(format!("text-{seed}").into())),
795            (DataType::Blob, Value::Blob(vec![0, seed, 255])),
796            (DataType::Boolean, Value::Boolean(seed % 2 == 0)),
797            (
798                DataType::Timestamp,
799                Value::Timestamp(-1_700_000_000 + seed as i64),
800            ),
801            (DataType::Duration, Value::Duration(-42 + seed as i64)),
802            (
803                DataType::IpAddr,
804                Value::IpAddr(IpAddr::V6(Ipv6Addr::new(
805                    0x2606,
806                    0x4700,
807                    0,
808                    0,
809                    0,
810                    0,
811                    0,
812                    seed as u16,
813                ))),
814            ),
815            (DataType::MacAddr, Value::MacAddr([0, 1, 2, 3, 4, seed])),
816            (
817                DataType::Vector,
818                Value::Vector(vec![1.25, -2.5, seed as f32]),
819            ),
820            (
821                DataType::Json,
822                Value::Json(format!(r#"{{"seed":{seed}}}"#).into_bytes()),
823            ),
824            (DataType::Uuid, Value::Uuid([seed; 16])),
825            (DataType::NodeRef, Value::NodeRef(format!("node-{seed}"))),
826            (DataType::EdgeRef, Value::EdgeRef(format!("edge-{seed}"))),
827            (
828                DataType::VectorRef,
829                Value::VectorRef(format!("vectors-{seed}"), 99),
830            ),
831            (
832                DataType::RowRef,
833                Value::RowRef(format!("table-{seed}"), 100),
834            ),
835            (DataType::Color, Value::Color([seed, 2, 3])),
836            (
837                DataType::Email,
838                Value::Email(format!("u{seed}@example.com")),
839            ),
840            (
841                DataType::Url,
842                Value::Url(format!("https://example.com/{seed}")),
843            ),
844            (DataType::Phone, Value::Phone(55_119_000_0000 + seed as u64)),
845            (DataType::Semver, Value::Semver(1_002_003 + seed as u32)),
846            (DataType::Cidr, Value::Cidr(0x0a00_0000 + seed as u32, 24)),
847            (DataType::Date, Value::Date(-20_000 + seed as i32)),
848            (DataType::Time, Value::Time(86_399_000 - seed as u32)),
849            (DataType::Decimal, Value::Decimal(-123_456 + seed as i64)),
850            (DataType::Enum, Value::EnumValue(seed)),
851            (
852                DataType::Array,
853                Value::Array(vec![
854                    Value::Integer(seed as i64),
855                    Value::Text("nested".into()),
856                ]),
857            ),
858            (
859                DataType::TimestampMs,
860                Value::TimestampMs(1_700_000_000_000 + seed as i64),
861            ),
862            (
863                DataType::Ipv4,
864                Value::Ipv4(u32::from(Ipv4Addr::new(192, 0, 2, seed))),
865            ),
866            (DataType::Ipv6, Value::Ipv6(Ipv6Addr::LOCALHOST.octets())),
867            (
868                DataType::Subnet,
869                Value::Subnet(0xc000_0200 + seed as u32, 0xffff_ff00),
870            ),
871            (DataType::Port, Value::Port(8000 + seed as u16)),
872            (
873                DataType::Latitude,
874                Value::Latitude(-23_550_000 + seed as i32),
875            ),
876            (
877                DataType::Longitude,
878                Value::Longitude(-46_633_000 + seed as i32),
879            ),
880            (
881                DataType::GeoPoint,
882                Value::GeoPoint(-23_550_000, -46_633_000 + seed as i32),
883            ),
884            (DataType::Country2, Value::Country2(*b"BR")),
885            (DataType::Country3, Value::Country3(*b"BRA")),
886            (DataType::Lang2, Value::Lang2(*b"pt")),
887            (DataType::Lang5, Value::Lang5(*b"pt-BR")),
888            (DataType::Currency, Value::Currency(*b"BRL")),
889            (
890                DataType::AssetCode,
891                Value::AssetCode(format!("ASSET{seed}")),
892            ),
893            (
894                DataType::Money,
895                Value::Money {
896                    asset_code: "BRL".to_string(),
897                    minor_units: -1_234_567 + seed as i64,
898                    scale: 2,
899                },
900            ),
901            (DataType::ColorAlpha, Value::ColorAlpha([seed, 2, 3, 4])),
902            (DataType::BigInt, Value::BigInt(i64::MAX - seed as i64)),
903            (
904                DataType::KeyRef,
905                Value::KeyRef(format!("kv-{seed}"), format!("key-{seed}")),
906            ),
907            (DataType::DocRef, Value::DocRef(format!("docs-{seed}"), 321)),
908            (DataType::TableRef, Value::TableRef(format!("table-{seed}"))),
909            (DataType::PageRef, Value::PageRef(42 + seed as u32)),
910            (DataType::Secret, Value::Secret(vec![seed, 7, 8, 9])),
911            (
912                DataType::Password,
913                Value::Password(format!("argon2id-hash-{seed}")),
914            ),
915            (
916                DataType::TextZstd,
917                Value::Text(format!("toast-text-{seed}").into()),
918            ),
919            (DataType::BlobZstd, Value::Blob(vec![9, 8, 7, seed])),
920        ];
921
922        let columns = types_and_values
923            .iter()
924            .enumerate()
925            .map(|(idx, (data_type, _))| ProjectionColumn {
926                column_id: idx as u32,
927                data_type: *data_type,
928            })
929            .collect();
930        let row = types_and_values
931            .into_iter()
932            .map(|(_, value)| value)
933            .collect();
934        (ProjectionSchema::new(columns), row)
935    }
936
937    /// Fill a collection with rows at lsn = 1..=n.
938    fn filled(n: i64) -> AppendOnlyCollection {
939        let mut c = AppendOnlyCollection::new(schema());
940        for i in 1..=n {
941            c.append(i as u64, row(i)).expect("append");
942        }
943        c
944    }
945
946    #[test]
947    fn write_path_never_dual_writes_columnar() {
948        // AC1: appending rows produces no columnar segments; only an explicit
949        // checkpoint emit does.
950        let collection = filled(10);
951        let projection = ColumnarProjection::new(schema(), KEY);
952        assert_eq!(projection.manifest().segments().len(), 0);
953        assert_eq!(projection.manifest().last_materialized_lsn(), 0);
954        // The collection scan still returns everything via the row path.
955        assert_eq!(collection.row_scan(collection.latest_lsn()).len(), 10);
956    }
957
958    #[test]
959    fn equivalence_oracle_scan_matches_row_scan() {
960        // AC2: projection scan == row scan under the same pinned snapshot.
961        let collection = filled(500);
962        let mut projection = ColumnarProjection::new(schema(), KEY);
963        projection
964            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
965            .expect("emit");
966
967        let pinned = collection.latest_lsn();
968        let via_projection = projection
969            .analytical_scan(&collection, pinned)
970            .expect("scan");
971        let via_row = collection.row_scan(pinned);
972
973        assert_eq!(via_projection.len(), 500);
974        assert_eq!(
975            via_projection, via_row,
976            "projection scan must equal row scan"
977        );
978    }
979
980    #[test]
981    fn columnar_segments_are_actually_exercised() {
982        // Guard: the equivalence must come from a real columnar decode, not an
983        // empty projection that falls entirely through the row tail.
984        let collection = filled(300);
985        let mut projection = ColumnarProjection::new(schema(), KEY);
986        let outcome = projection
987            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
988            .expect("emit");
989        assert!(outcome.segments_emitted >= 1);
990        assert_eq!(projection.manifest().last_materialized_lsn(), 300);
991        // Scanning exactly at the materialized ceiling uses only columnar rows.
992        let scan = projection.analytical_scan(&collection, 300).expect("scan");
993        assert_eq!(scan, collection.row_scan(300));
994    }
995
996    #[test]
997    fn full_value_type_set_round_trips_through_columnar_projection() {
998        let (schema, first) = full_type_schema_and_row(1);
999        let (_, second) = full_type_schema_and_row(2);
1000        let mut collection = AppendOnlyCollection::new(schema.clone());
1001        collection.append(1, first).expect("append first");
1002        collection.append(2, second).expect("append second");
1003
1004        let mut projection = ColumnarProjection::new(schema, KEY);
1005        let outcome = projection
1006            .emit_at_checkpoint(&collection, 2, TranscodeBudget::default())
1007            .expect("emit");
1008        assert_eq!(outcome.rows_materialized, 2);
1009        assert_eq!(outcome.rows_deferred, 0);
1010
1011        let via_projection = projection.analytical_scan(&collection, 2).expect("scan");
1012        let via_row = collection.row_scan(2);
1013        assert_eq!(
1014            via_projection, via_row,
1015            "every storable value type must round-trip without coercion or loss"
1016        );
1017    }
1018
1019    #[test]
1020    fn freshness_row_after_checkpoint_is_immediately_visible() {
1021        // AC3: a row inserted after the last checkpoint is visible to the
1022        // analytical scan with no re-materialization — one consistency class.
1023        let mut collection = filled(100);
1024        let mut projection = ColumnarProjection::new(schema(), KEY);
1025        projection
1026            .emit_at_checkpoint(&collection, 100, TranscodeBudget::default())
1027            .expect("emit");
1028        assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1029
1030        // Commit one more row *after* the checkpoint.
1031        collection.append(101, row(101)).expect("append fresh");
1032
1033        let scan = projection.analytical_scan(&collection, 101).expect("scan");
1034        assert_eq!(scan.len(), 101, "fresh row must be visible immediately");
1035        assert_eq!(scan.last().cloned().unwrap(), row(101));
1036        // And the projection was NOT re-materialized to make it visible.
1037        assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1038    }
1039
1040    #[test]
1041    fn as_of_composes_by_pinning_a_historical_lsn() {
1042        // AC (ADR §4): AS OF is just a historical pin.
1043        let collection = filled(200);
1044        let mut projection = ColumnarProjection::new(schema(), KEY);
1045        projection
1046            .emit_at_checkpoint(&collection, 200, TranscodeBudget::default())
1047            .expect("emit");
1048
1049        for pin in [1u64, 57, 150, 200] {
1050            let via_projection = projection.analytical_scan(&collection, pin).expect("scan");
1051            assert_eq!(
1052                via_projection,
1053                collection.row_scan(pin),
1054                "AS OF {pin} must match row scan"
1055            );
1056            assert_eq!(via_projection.len(), pin as usize);
1057        }
1058    }
1059
1060    #[test]
1061    fn historical_pin_before_projection_coverage_falls_back_to_row_path() {
1062        // Issue #1770: a projection can retain only newer columnar coverage.
1063        // An AS OF pin whose visible history starts before that coverage must
1064        // answer from the row/time-travel path rather than returning only the
1065        // retained columnar suffix plus tail.
1066        let collection = filled(300);
1067        let mut projection = ColumnarProjection::new(schema(), KEY);
1068        projection
1069            .emit_at_checkpoint(
1070                &collection,
1071                300,
1072                TranscodeBudget {
1073                    max_rows: u64::MAX,
1074                    segment_rows: 100,
1075                    size_floor_rows: 1,
1076                },
1077            )
1078            .expect("emit segmented projection");
1079
1080        projection.segments.remove(0);
1081        projection.manifest.segments.remove(0);
1082
1083        let pinned = 250;
1084        let via_projection = projection
1085            .analytical_scan(&collection, pinned)
1086            .expect("historical scan");
1087        let via_row = collection.row_scan(pinned);
1088
1089        assert_eq!(
1090            via_projection, via_row,
1091            "historical AS OF must not drop rows older than projection coverage"
1092        );
1093    }
1094
1095    #[test]
1096    fn manifest_entries_are_derived_and_checksummed_and_enveloped() {
1097        // AC4: entries marked derived; chunks carry checksums + crypto envelope.
1098        let collection = filled(50);
1099        let mut projection = ColumnarProjection::new(schema(), KEY);
1100        projection
1101            .emit_at_checkpoint(&collection, 50, TranscodeBudget::default())
1102            .expect("emit");
1103
1104        let manifest = projection.manifest();
1105        assert!(manifest.all_derived());
1106        assert_eq!(
1107            manifest.durable_entries().count(),
1108            0,
1109            "derived → backup skips"
1110        );
1111        for entry in manifest.segments() {
1112            assert!(entry.derived);
1113            assert_ne!(entry.sealed_crc32, 0);
1114        }
1115
1116        // The stored bytes are the crypto envelope, not the raw RDCC frame:
1117        // a wrong key fails the GCM tag check (envelope is real).
1118        let seg = &projection.segments[0];
1119        let wrong = ColumnarProjection::new(schema(), [9u8; 32]);
1120        let err = wrong
1121            .verify_and_decode_segment(seg, &mut Vec::new())
1122            .expect_err("wrong key must fail");
1123        assert!(matches!(err, ProjectionError::Envelope(_)));
1124    }
1125
1126    #[test]
1127    fn sealed_segment_tamper_is_rejected_by_checksum() {
1128        let collection = filled(20);
1129        let mut projection = ColumnarProjection::new(schema(), KEY);
1130        projection
1131            .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1132            .expect("emit");
1133        // Flip a byte in the sealed segment; the manifest crc must catch it.
1134        let mut tampered = projection.segments[0].clone();
1135        tampered.sealed[0] ^= 0xFF;
1136        let err = projection
1137            .verify_and_decode_segment(&tampered, &mut Vec::new())
1138            .expect_err("tamper must be rejected");
1139        assert_eq!(
1140            err,
1141            ProjectionError::CorruptSegment("sealed checksum mismatch")
1142        );
1143    }
1144
1145    #[test]
1146    fn transcoding_budget_completes_and_defers() {
1147        // AC5: checkpoint completes even when the budget is exhausted;
1148        // deferred rows are picked up by the next checkpoint.
1149        let collection = filled(1000);
1150        let mut projection = ColumnarProjection::new(schema(), KEY);
1151
1152        let budget = TranscodeBudget {
1153            max_rows: 400,
1154            segment_rows: 128,
1155            size_floor_rows: 1,
1156        };
1157        let first = projection
1158            .emit_at_checkpoint(&collection, 1000, budget)
1159            .expect("first checkpoint");
1160        assert!(first.budget_exhausted);
1161        assert_eq!(first.rows_materialized, 400);
1162        assert_eq!(first.rows_deferred, 600);
1163        assert_eq!(projection.manifest().last_materialized_lsn(), 400);
1164
1165        // Even mid-budget, the scan is still complete and correct: the deferred
1166        // tail is served by the row path.
1167        let mid = projection.analytical_scan(&collection, 1000).expect("scan");
1168        assert_eq!(mid, collection.row_scan(1000));
1169        assert_eq!(mid.len(), 1000);
1170
1171        // Each subsequent checkpoint transcodes another budget's worth; the
1172        // deferred tail shrinks monotonically until it is drained. Every round
1173        // completes, and the scan stays correct throughout.
1174        let mut rounds = 1;
1175        loop {
1176            let out = projection
1177                .emit_at_checkpoint(&collection, 1000, budget)
1178                .expect("checkpoint");
1179            rounds += 1;
1180            assert_eq!(
1181                projection.analytical_scan(&collection, 1000).expect("scan"),
1182                collection.row_scan(1000)
1183            );
1184            if !out.budget_exhausted {
1185                assert_eq!(out.rows_deferred, 0);
1186                break;
1187            }
1188        }
1189        // 1000 rows / 400 per round → 3 checkpoints total.
1190        assert_eq!(rounds, 3);
1191        assert_eq!(projection.manifest().last_materialized_lsn(), 1000);
1192
1193        let full = projection.analytical_scan(&collection, 1000).expect("scan");
1194        assert_eq!(full, collection.row_scan(1000));
1195    }
1196
1197    #[test]
1198    fn size_floor_skips_tiny_materialization() {
1199        // ADR §6: a tail below the floor is skipped, still fully visible.
1200        let collection = filled(3);
1201        let mut projection = ColumnarProjection::new(schema(), KEY);
1202        let budget = TranscodeBudget {
1203            max_rows: u64::MAX,
1204            segment_rows: 128,
1205            size_floor_rows: 10,
1206        };
1207        let outcome = projection
1208            .emit_at_checkpoint(&collection, 3, budget)
1209            .expect("emit");
1210        assert!(outcome.floor_skipped);
1211        assert_eq!(outcome.segments_emitted, 0);
1212        assert_eq!(projection.manifest().last_materialized_lsn(), 0);
1213        // Still correct: everything served by the row path.
1214        assert_eq!(
1215            projection.analytical_scan(&collection, 3).expect("scan"),
1216            collection.row_scan(3)
1217        );
1218    }
1219
1220    #[test]
1221    fn drop_and_rebuild_is_repair() {
1222        // ADR §1: repair is regenerate, not restore.
1223        let collection = filled(300);
1224        let mut projection = ColumnarProjection::new(schema(), KEY);
1225        projection
1226            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1227            .expect("emit");
1228        let before = projection.analytical_scan(&collection, 300).expect("scan");
1229
1230        projection.drop_projection();
1231        assert_eq!(projection.manifest().segments().len(), 0);
1232        // Reads keep working while un-materialized (pure row tail).
1233        assert_eq!(
1234            projection.analytical_scan(&collection, 300).expect("scan"),
1235            collection.row_scan(300)
1236        );
1237
1238        // Rebuild from the row log → identical scan.
1239        projection
1240            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1241            .expect("rebuild");
1242        let after = projection.analytical_scan(&collection, 300).expect("scan");
1243        assert_eq!(before, after);
1244    }
1245
1246    #[test]
1247    fn repairing_scan_rebuilds_after_projection_artifacts_are_deleted() {
1248        let collection = filled(300);
1249        let mut projection = ColumnarProjection::new(schema(), KEY);
1250        projection
1251            .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1252            .expect("emit");
1253        assert!(!projection.segments.is_empty());
1254
1255        // Simulate a backup/restore or operator cleanup that preserves truth
1256        // but omits the derived segment files.
1257        projection.segments.clear();
1258
1259        let scan = projection
1260            .repairing_analytical_scan(&collection, 300, TranscodeBudget::default())
1261            .expect("repairing scan");
1262
1263        assert_eq!(scan, collection.row_scan(300));
1264        assert!(!projection.manifest().segments().is_empty());
1265        assert!(!projection.segments.is_empty());
1266    }
1267
1268    #[test]
1269    fn repairing_scan_rebuilds_after_projection_checksum_mismatch() {
1270        let collection = filled(20);
1271        let mut projection = ColumnarProjection::new(schema(), KEY);
1272        projection
1273            .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1274            .expect("emit");
1275        let original_segment_id = projection.manifest().segments()[0].segment_id;
1276
1277        projection.segments[0].sealed[0] ^= 0xFF;
1278
1279        let scan = projection
1280            .repairing_analytical_scan(&collection, 20, TranscodeBudget::default())
1281            .expect("repairing scan");
1282
1283        assert_eq!(scan, collection.row_scan(20));
1284        assert_ne!(
1285            projection.manifest().segments()[0].segment_id,
1286            original_segment_id,
1287            "corrupt projection bytes must be replaced by a rebuilt segment"
1288        );
1289    }
1290
1291    #[test]
1292    fn append_rejects_non_monotonic_lsn_and_bad_arity() {
1293        let mut c = AppendOnlyCollection::new(schema());
1294        c.append(5, row(1)).expect("first");
1295        assert!(matches!(
1296            c.append(5, row(2)),
1297            Err(ProjectionError::NonMonotonicLsn { .. })
1298        ));
1299        assert!(matches!(
1300            c.append(6, vec![Value::Integer(1)]),
1301            Err(ProjectionError::RowWidth { .. })
1302        ));
1303    }
1304
1305    #[test]
1306    fn mismatched_types_are_rejected() {
1307        let s = ProjectionSchema::new(vec![ProjectionColumn {
1308            column_id: 0,
1309            data_type: DataType::Integer,
1310        }]);
1311        let mut c = AppendOnlyCollection::new(s);
1312        assert!(matches!(
1313            c.append(1, vec![Value::Text("x".into())]),
1314            Err(ProjectionError::TypeMismatch { .. })
1315        ));
1316    }
1317}