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