Rindle docs and package mapSkip to main content

rindle_sqlite/
table_source.rs

1//! The server-side leaf [`Source`]: a `Source` backed by a SQLite table (the
2//! server-side replica of Postgres). Production port of
3//! `packages/zqlite/src/table-source.ts` (`class TableSource`), the peer of the
4//! client-side [`MemorySource`](rindle::MemorySource). It owns three things
5//! (`05` §1.1):
6//!
7//! 1. **The leaf scan** — a `FetchRequest` is compiled to a parameterized
8//!    `SELECT` ([`build_select_query`]) and executed against `rusqlite`, vending
9//!    rows **lazily and zero-copy**: `Str`/`Json` values borrow directly from the
10//!    `sqlite3_column_text` buffers (valid only until the next step), and a row is
11//!    materialized exactly once — at the connection boundary — via the borrow
12//!    checker-forced [`RowRef::to_owned_row`]. The whole conn→stmt→cursor borrow
13//!    chain is owned by one `OwnedSqliteRows` so the lazy stream can be boxed
14//!    and returned (the `05` §13 Q1 self-reference, solved with one contained,
15//!    drop-ordered `unsafe`).
16//! 2. **Connections + the overlay/push/edit-split machinery** — identical to the
17//!    memory leaf: it reuses the backend-agnostic [`source_common`](rindle::source_common) seam verbatim
18//!    (`generate_with_overlay_checked`, [`generate_with_start`],
19//!    `try_gen_push_and_write_with_split_edit`). The leaf only differs in the scan.
20//! 3. **The write path** — `INSERT`/`DELETE`/`UPDATE`, committed **after** the
21//!    vend (the self-join correctness invariant, §3.11).
22//!
23//! **A source emits rows, not nodes** (`04`/`05` §1.1): `fetch` returns a
24//! [`RowFlow`]; the *connection boundary* (`Graph::fetch` on a `SourceConn`) wraps
25//! each row in a leaf node. The Node concept lives entirely above this module —
26//! exactly as for [`MemorySource`](rindle::MemorySource).
27//!
28//! Build target: `feature = "sqlite"` only.
29
30use std::cell::{Cell, RefCell};
31use std::cmp::Ordering;
32use std::rc::Rc;
33
34use rusqlite::types::ValueRef;
35use rusqlite::{params_from_iter, CachedStatement, Connection, Rows};
36
37use crate::batch_delta::{BatchDelta, BatchMerge, BatchMergeUnordered, DeltaLookup};
38use crate::query_builder::{build_select_query, ident, to_sqlite_param, ColumnDef, SqliteParam};
39use crate::sqlite::{is_exact_f64_integer, ColType, SqliteError, SqliteRow};
40use crate::tiebreak::TiebreakStream;
41use rindle::change::{Basis, FetchRequest, OutEdge, RowFlow, SourceChange, Start};
42use rindle::graph::{ConnId, Source};
43use rindle::source_common::{
44    generate_with_overlay_checked, generate_with_overlay_unordered_checked, generate_with_start,
45    try_gen_push_and_write_with_split_edit, ConnTable, Connection as Conn, ConnectionFilters,
46    Overlay,
47};
48use rindle::value::{
49    compare_values, ColId, OwnedRow as Row, OwnedValue, RowRef, Schema, Sort, SourceSchema,
50    ValueType,
51};
52use rindle::RindleError;
53
54/// SQLite's own per-statement work counters (`sqlite3_stmt_status`), accumulated
55/// across every leaf `SELECT` a query runs. This is how the `plan_quality` test
56/// measures how much work SQLite actually did for a planned query vs its unplanned
57/// variant — the Rust analogue of the JS planner's "scanned rows" tracking.
58///
59/// Surfaced **only** under the `scan-stats` feature, so no instrumentation ships in
60/// production. Read off the executed statement at the cursor's drop via
61/// [`rusqlite::Statement::reset_status`], so early-termination (an EXISTS limit-1
62/// probe that stops before the end) is honoured: the counters reflect the steps
63/// SQLite actually took.
64#[cfg(feature = "scan-stats")]
65#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
66pub struct ScanStats {
67    /// `SQLITE_STMTSTATUS_FULLSCAN_STEP`: rows visited by full (un-indexed) table
68    /// scans — precisely the work a good plan avoids.
69    pub fullscan_steps: u64,
70    /// `SQLITE_STMTSTATUS_SORT`: number of sort operations performed.
71    pub sorts: u64,
72    /// `SQLITE_STMTSTATUS_VM_STEP`: total VM operations executed — the best single
73    /// proxy for "how much work SQLite did" across the whole query.
74    pub vm_steps: u64,
75    /// `SQLITE_STMTSTATUS_RUN`: number of times a leaf statement was run (≈ the number
76    /// of fetch/probe calls the dataflow issued).
77    pub runs: u64,
78}
79
80/// A shared accumulator for [`ScanStats`]. One sink is attached to every
81/// [`TableSource`] in a graph (via the test backend) so a single read yields the
82/// query's total SQLite work. `scan-stats` only.
83#[cfg(feature = "scan-stats")]
84pub type ScanSink = Rc<Cell<ScanStats>>;
85
86/// A shared slot for the **representative fetch SQL** a source ran — the exact leaf
87/// `SELECT` (`?`-parameterized) that `analyze query` re-`EXPLAIN`s to report the chosen
88/// access path per leaf. First-write-wins: a parent-driven scan runs the same SQL text
89/// N times (only the bound values differ), so one capture is enough. `scan-stats` only;
90/// never populated in production (no sink attached).
91#[cfg(feature = "scan-stats")]
92pub type PlanSink = Rc<RefCell<Option<String>>>;
93
94/// Map a logical [`ValueType`] to the leaf's storage-class tag (`05` §4.7). A
95/// `number` column is `Float` (it widens INTEGER→f64 after an exact round-trip
96/// check at the boundary); a `null`-typed column
97/// is treated as `Text` (any storage class passes through the leaf's fallback arms).
98fn col_type_of(ty: ValueType) -> ColType {
99    match ty {
100        ValueType::Boolean => ColType::Bool,
101        ValueType::Number => ColType::Float,
102        // The declared exact-i64 plane (design 226 §4.2): INTEGER cells read as
103        // exact `Value::Int` (the long-dormant `sqlite.rs` read arm); the scan
104        // guard beside `check_number_bounds` enforces NULL-or-INTEGER storage.
105        ValueType::Int => ColType::Int,
106        ValueType::String => ColType::Text,
107        ValueType::Json => ColType::Json,
108        ValueType::Null => ColType::Text,
109    }
110}
111
112/// Immutable, shared table metadata (one allocation, `Rc`-shared between the
113/// source and every in-flight cursor so a `fetch` clones one `Rc` instead of
114/// re-deriving names/types/SQL). The mutable source state (db handle, connections,
115/// overlay, epoch) lives on [`TableSource`] directly.
116struct TableMeta {
117    table: Box<str>,
118    columns: Vec<ColumnDef>,
119    /// Per-column storage tag, parallel to `columns`; lent to each [`SqliteRow`].
120    col_types: Vec<ColType>,
121    /// The `Float`-tagged column indices — the only columns the per-row lossless-f64
122    /// check ([`check_number_bounds`]) applies to, precomputed so the hot loop
123    /// doesn't re-walk (and re-`get_ref`) every column on every row.
124    float_cols: Vec<ColId>,
125    /// The `Int`-tagged (declared `int64`, design 226 §4.2) column indices — the
126    /// columns the per-row storage-class check ([`check_int_storage`]) applies to:
127    /// a cell must be NULL or INTEGER, never REAL/TEXT/BLOB.
128    int_cols: Vec<ColId>,
129    primary_key: Vec<ColId>,
130    /// The non-PK columns, in declared order — the `UPDATE … SET` list (§3.11).
131    non_pk: Vec<ColId>,
132    /// Reported to downstream operators (`Source::schema`). Per-connection sort is
133    /// on each [`Conn`]; this carries the table's primary-index sort.
134    schema: Schema,
135    // -- write statements, precomputed once (text only; prepared via the
136    //    per-connection statement cache on use, `05` §4.3) --
137    insert_sql: String,
138    delete_sql: String,
139    /// `None` iff every column is part of the PK (no non-PK column to SET, §3.11).
140    update_sql: Option<String>,
141    check_exists_sql: String,
142    // -- in-engine sort tiebreak metadata (design 204) --
143    /// True when the PK is a single `INTEGER PRIMARY KEY` (a rowid alias): an `(ord)`
144    /// index is then physically `(ord, rowid)` = `(ord, pk)`, so SQL already serves
145    /// the appended-PK order and the tiebreak adapter bypasses.
146    pk_is_rowid_alias: bool,
147    /// True for a `WITHOUT ROWID` table: secondary indexes carry the PK tail, so an
148    /// `(ord)` index is physically `(ord, pk)` and the tiebreak adapter bypasses.
149    without_rowid: bool,
150    /// Every index's `(name, leading resolvable key columns in index order)`. The
151    /// structural basis for [`TableMeta::tiebreak_prefix_len`]'s bypass decision —
152    /// answers "can SQL stream the prefix / the full `(prefix, pk)` order?" without a
153    /// query planner. The name keys the `sqlite_stat1` run-size lookup
154    /// ([`TableSource::prefix_run_within_cap`]) that gates a large-run bypass.
155    all_index_cols: Vec<(String, Vec<ColId>)>,
156}
157
158impl TableMeta {
159    /// Whether the in-engine sort tiebreak adapter (design 204) should engage for a
160    /// connection ordered by `sort`, and if so how many leading (prefix) columns
161    /// SQLite should `ORDER BY`. `None` ⇒ bypass (hand the full sort to SQL).
162    ///
163    /// Correctness holds on **either** path, so this is a pure performance heuristic
164    /// (design §"When to bypass"): engage only when an index can stream the user prefix
165    /// but no index serves the full `(prefix, pk)` order.
166    fn tiebreak_prefix_len(&self, sort: &Sort) -> Option<usize> {
167        let appended = appended_pk_len(sort, &self.primary_key);
168        // Nothing to relocate (no appended PK), or a pure-PK order (an empty prefix
169        // would collapse the whole table into one buffered run): bypass.
170        if appended == 0 || appended >= sort.len() {
171            return None;
172        }
173        let prefix_len = sort.len() - appended;
174
175        // SQL already streams the full `(prefix, pk)` order for these shapes: a rowid
176        // alias makes any `(ord)` index physically `(ord, rowid)` = `(ord, pk)`, and a
177        // WITHOUT ROWID index carries the PK tail.
178        if self.pk_is_rowid_alias || self.without_rowid {
179            return None;
180        }
181
182        let prefix_cols: Vec<ColId> = sort[..prefix_len].iter().map(|&(c, _)| c).collect();
183        let full_cols: Vec<ColId> = sort.iter().map(|&(c, _)| c).collect();
184
185        // A user-declared `(prefix ++ pk)` index already serves the whole order.
186        if self
187            .all_index_cols
188            .iter()
189            .any(|(_, ix)| leads_with(ix, &full_cols))
190        {
191            return None;
192        }
193        // Engage only if some index can stream the prefix order; otherwise SQL
194        // temp-b-trees either way and the adapter would only add buffering.
195        if self
196            .all_index_cols
197            .iter()
198            .any(|(_, ix)| leads_with(ix, &prefix_cols))
199        {
200            Some(prefix_len)
201        } else {
202            None
203        }
204    }
205
206    /// The name of an index whose leading columns are exactly `sort[..prefix_len]` —
207    /// the one whose `sqlite_stat1` row estimates the equal-prefix run size for the
208    /// large-run bypass gate ([`TableSource::prefix_run_within_cap`]). Any such index
209    /// gives the same estimate (run size is a property of the data, not the index), so
210    /// the first is fine. `tiebreak_prefix_len` returning `Some` guarantees one exists.
211    fn prefix_stream_index(&self, sort: &Sort, prefix_len: usize) -> Option<&str> {
212        let prefix_cols: Vec<ColId> = sort[..prefix_len].iter().map(|&(c, _)| c).collect();
213        self.all_index_cols
214            .iter()
215            .find(|(_, ix)| leads_with(ix, &prefix_cols))
216            .map(|(name, _)| name.as_str())
217    }
218}
219
220/// Length of the trailing run of ascending PK columns `resolve_sort` appended to
221/// `sort` (design 204). A PK column the user placed is indistinguishable from an
222/// appended one, so this over-counts when the user explicitly ordered by an ascending
223/// PK column at the tail — harmless, since treating it as the tiebreak is exactly what
224/// the engine would append anyway (perf-only, never a correctness issue).
225fn appended_pk_len(sort: &Sort, pk: &[ColId]) -> usize {
226    let mut n = 0;
227    for &(col, asc) in sort.iter().rev() {
228        if asc && n < pk.len() && pk.contains(&col) {
229            n += 1;
230        } else {
231            break;
232        }
233    }
234    n
235}
236
237/// Does index key-column list `ix` begin with exactly `cols` (order-sensitive)? Used
238/// to decide, structurally, whether an index can stream a given ordering. Direction is
239/// ignored — SQLite can scan an index in reverse — which is safe because the decision
240/// is perf-only (design 204).
241fn leads_with(ix: &[ColId], cols: &[ColId]) -> bool {
242    ix.len() >= cols.len() && ix[..cols.len()] == *cols
243}
244
245/// Estimated equal-prefix run size above which the tiebreak adapter bypasses to SQL's
246/// own sort (design 204 §"Costs / risks"). A buffered run is bounded to this many
247/// [`OwnedRow`](rindle::value::OwnedRow)s, so a run key with fewer than
248/// `rows / TIEBREAK_MAX_ENGAGE_RUN` distinct values falls back to the temp b-tree. A
249/// heuristic ceiling, not a correctness bound — tune against first-page latency vs.
250/// buffering cost.
251const TIEBREAK_MAX_ENGAGE_RUN: u64 = 4096;
252
253/// Parse the average number of rows sharing the first `prefix_len` index columns from an
254/// `sqlite_stat1.stat` string (design 204). The stat is space-separated integers
255/// `"<nRows> <k1> <k2> …"` where `kN` = avg rows sharing the first `N` index columns
256/// (mirrors `stat_fanout.rs`'s `parts[depth]`), so the equal-prefix run estimate is
257/// `parts[prefix_len]`. `None` if the stat is empty/short/malformed (caller engages).
258fn stat1_prefix_run(stat: &str, prefix_len: usize) -> Option<u64> {
259    stat.split(' ').nth(prefix_len)?.parse::<u64>().ok()
260}
261
262/// `TableSource` — the SQLite-backed leaf source. Mirrors [`MemorySource`](rindle::MemorySource)'s shape:
263/// the overlay/push/edit-split machinery is shared; only the leaf scan (a real
264/// `rusqlite` cursor instead of a COW B+tree) and the write path differ.
265pub struct TableSource {
266    meta: Rc<TableMeta>,
267    /// The active snapshot DB handle (swapped by [`Self::set_db`], §5.7). `Rc` so
268    /// each in-flight cursor keeps its own snapshot alive (the SQLite analogue of
269    /// the COW snapshot stability the memory leaf gets from `Arc`).
270    db: RefCell<Rc<Connection>>,
271    conns: ConnTable,
272    /// The single in-flight change, epoch-tagged. Read LIVE via a tightly-scoped
273    /// borrow during a reentrant fetch, cloned out, cleared after the push drains
274    /// BEFORE the write (§3.11, foundations §6.3).
275    overlay: RefCell<Option<Overlay>>,
276    epoch: Cell<u32>,
277    /// Primitive #2: open-cursor count. A fetch stream bumps it; the cursor's
278    /// `Drop` (`CursorGuard`) decrements — even on an early `Take` break — so a
279    /// test can prove no statement leaked (the "database is busy" guard, §3.10).
280    cursors_open: Rc<Cell<i64>>,
281    /// Where a cursor parks a value-conversion / step error to be resurfaced at
282    /// the owning boundary (`05` §4.5, §13 Q9): `next_row` can only return
283    /// `Option`, so the error lands here and the consumer drains then checks
284    /// [`Self::take_fetch_error`] — NEVER silently mapped to end-of-stream.
285    fetch_error: Rc<RefCell<Option<RindleError>>>,
286    /// Unique-key column sets discovered from `pragma_index_list`/`index_info`
287    /// (§5.1); used for the PK-UNIQUE construction assert.
288    unique_indexes: Vec<Vec<ColId>>,
289    /// The batch delta (design 306): the current derivation transaction's in-memory
290    /// divergence from the pinned base snapshot. Constructed **inactive**, so every
291    /// embedded caller pays nothing; the replica engine retrieves the shared handle
292    /// via [`Self::batch_delta`] at registration and drives its lifecycle.
293    /// `RefCell<Rc<..>>` mirrors `db`; **forks share it** (the delta models storage,
294    /// which forks share — plan D1).
295    delta: RefCell<Rc<BatchDelta>>,
296    /// Optional shared work-counter sink (`scan-stats` only). When set, every cursor
297    /// this source vends accumulates its `sqlite3_stmt_status` counters here on drop.
298    #[cfg(feature = "scan-stats")]
299    scan_sink: RefCell<Option<ScanSink>>,
300    /// Optional capture of the representative fetch SQL (`scan-stats` only). When set,
301    /// the first leaf `SELECT` this source runs is recorded here so `analyze query` can
302    /// re-`EXPLAIN` it for the per-leaf access-path text.
303    #[cfg(feature = "scan-stats")]
304    plan_sink: RefCell<Option<PlanSink>>,
305}
306
307impl TableSource {
308    /// Build a source over an existing `table` in `db`. Sets the load-bearing
309    /// `PRAGMA case_sensitive_like = ON` (db.ts:45 — makes bare `LIKE`
310    /// case-sensitive, the contract the `ILIKE`→`lower()` lowering relies on),
311    /// discovers unique indexes, asserts the PK has one (§5.1), and precomputes the
312    /// write SQL + the reported `Schema`.
313    pub fn new(
314        db: Rc<Connection>,
315        table: &str,
316        columns: Vec<ColumnDef>,
317        primary_key: Vec<ColId>,
318    ) -> TableSource {
319        Self::try_new(db, table, columns, primary_key).expect("create SQLite TableSource")
320    }
321
322    pub fn try_new(
323        db: Rc<Connection>,
324        table: &str,
325        columns: Vec<ColumnDef>,
326        primary_key: Vec<ColId>,
327    ) -> Result<TableSource, RindleError> {
328        Self::new_inner(db, table, columns, primary_key, None)
329    }
330
331    /// Build a source while preserving the caller's full reported [`Schema`]
332    /// (notably relationship slots). The SQLite projection still comes from
333    /// `columns` in `ColId` order; `schema` is what downstream operators see.
334    pub fn new_with_schema(
335        db: Rc<Connection>,
336        table: &str,
337        columns: Vec<ColumnDef>,
338        primary_key: Vec<ColId>,
339        schema: SourceSchema,
340    ) -> TableSource {
341        Self::try_new_with_schema(db, table, columns, primary_key, schema)
342            .expect("create SQLite TableSource")
343    }
344
345    pub fn try_new_with_schema(
346        db: Rc<Connection>,
347        table: &str,
348        columns: Vec<ColumnDef>,
349        primary_key: Vec<ColId>,
350        schema: SourceSchema,
351    ) -> Result<TableSource, RindleError> {
352        Self::new_inner(db, table, columns, primary_key, Some(schema.into_schema()))
353    }
354
355    fn new_inner(
356        db: Rc<Connection>,
357        table: &str,
358        columns: Vec<ColumnDef>,
359        primary_key: Vec<ColId>,
360        reported_schema: Option<Schema>,
361    ) -> Result<TableSource, RindleError> {
362        db.execute_batch("PRAGMA case_sensitive_like = ON;")
363            .map_err(|e| RindleError::sqlite("set case_sensitive_like", e))?;
364        // Query Planner Stability Guarantee (see rindle-replica `parallel::open_wal2` for the full
365        // story): the IVM leaf seeks are cached (`prepare_cached`) and re-bound with a fresh key on
366        // every derive. QPSG makes their plans value-independent so a re-bound seek is never
367        // re-parsed — on the bedrock-SQLite build that re-prepare cost ~2x the seek itself. The
368        // cluster connections set this in `open_wal2`; stamping it here too covers every *embedded*
369        // SQLite source (a caller's own `Connection`), the one guaranteed chokepoint for "an IVM
370        // source runs on this connection." A whole-connection dbconfig, idempotent to re-set — so
371        // it is harmless when several sources share one connection or the connection already had it.
372        db.set_db_config(
373            rusqlite::config::DbConfig::SQLITE_DBCONFIG_ENABLE_QPSG,
374            true,
375        )
376        .map_err(|e| RindleError::sqlite("set QPSG", e))?;
377
378        let unique_indexes = discover_unique_indexes(&db, table, &columns, &primary_key)?;
379        debug_assert!(
380            {
381                let mut pk = primary_key.clone();
382                pk.sort_unstable();
383                unique_indexes.iter().any(|ix| {
384                    let mut s = ix.clone();
385                    s.sort_unstable();
386                    s == pk
387                })
388            },
389            "primary key {primary_key:?} does not have a UNIQUE index in table {table:?}"
390        );
391
392        // In-engine sort tiebreak metadata (design 204). All three are structural
393        // (schema/index shape), computed once here; the per-fetch decision in
394        // `TableMeta::tiebreak_prefix_len` is then allocation-light.
395        let pk_is_rowid_alias = is_pk_rowid_alias(&db, table, &columns, &primary_key);
396        let without_rowid = db
397            .prepare(&format!("SELECT rowid FROM {} LIMIT 0", ident(table)))
398            .is_err();
399        let all_index_cols = discover_all_index_columns(&db, table, &columns)?;
400
401        let col_types: Vec<ColType> = columns.iter().map(|c| col_type_of(c.ty)).collect();
402        let float_cols: Vec<ColId> = col_types
403            .iter()
404            .enumerate()
405            .filter(|(_, ty)| **ty == ColType::Float)
406            .map(|(i, _)| i)
407            .collect();
408        let int_cols: Vec<ColId> = col_types
409            .iter()
410            .enumerate()
411            .filter(|(_, ty)| **ty == ColType::Int)
412            .map(|(i, _)| i)
413            .collect();
414        let non_pk: Vec<ColId> = (0..columns.len())
415            .filter(|c| !primary_key.contains(c))
416            .collect();
417
418        // The reported schema. Column names are leaked once (the source lives for
419        // the program's life); they are never read on the hot path — the IVM is
420        // index-addressed — but kept correct for downstream/debug use.
421        let leaked_cols: Vec<&'static str> = columns
422            .iter()
423            .map(|c| {
424                let s: &'static str = Box::leak(c.name.clone());
425                s
426            })
427            .collect();
428        let primary_index_sort: Sort = primary_key.iter().map(|&c| (c, true)).collect();
429        let schema = reported_schema.unwrap_or_else(|| {
430            Schema::new(leaked_cols, primary_key.clone(), primary_index_sort)
431                .with_column_types(columns.iter().map(|c| c.ty).collect())
432        });
433
434        let insert_sql = build_insert_sql(table, &columns);
435        let delete_sql = build_delete_sql(table, &primary_key, &columns);
436        let update_sql = build_update_sql(table, &primary_key, &non_pk, &columns);
437        let check_exists_sql = build_check_exists_sql(table, &primary_key, &columns);
438        let delta = Rc::new(BatchDelta::new(primary_key.clone()));
439
440        let meta = TableMeta {
441            table: table.into(),
442            columns,
443            col_types,
444            float_cols,
445            int_cols,
446            primary_key,
447            non_pk,
448            schema,
449            insert_sql,
450            delete_sql,
451            update_sql,
452            check_exists_sql,
453            pk_is_rowid_alias,
454            without_rowid,
455            all_index_cols,
456        };
457
458        Ok(TableSource {
459            meta: Rc::new(meta),
460            db: RefCell::new(db),
461            conns: ConnTable::new(),
462            overlay: RefCell::new(None),
463            epoch: Cell::new(0),
464            cursors_open: Rc::new(Cell::new(0)),
465            fetch_error: Rc::new(RefCell::new(None)),
466            unique_indexes,
467            delta: RefCell::new(delta),
468            #[cfg(feature = "scan-stats")]
469            scan_sink: RefCell::new(None),
470            #[cfg(feature = "scan-stats")]
471            plan_sink: RefCell::new(None),
472        })
473    }
474
475    /// Cheaply build a new source handle over the same SQLite snapshot and table
476    /// metadata, with fresh connections and overlay state. This is the SQLite
477    /// peer of [`MemorySource::fork`](rindle::MemorySource::fork): useful for
478    /// building a clean graph/pipeline around an already-seeded backing store
479    /// without re-discovering schema metadata or re-inserting rows.
480    pub fn fork(&self) -> TableSource {
481        TableSource {
482            meta: self.meta.clone(),
483            db: RefCell::new(self.db.borrow().clone()),
484            conns: ConnTable::new(),
485            overlay: RefCell::new(None),
486            epoch: Cell::new(0),
487            cursors_open: Rc::new(Cell::new(0)),
488            fetch_error: Rc::new(RefCell::new(None)),
489            unique_indexes: self.unique_indexes.clone(),
490            // The ONE line that makes forks share storage-equivalent state (plan D1):
491            // conns/overlay/epoch are per-handle and reset above; the delta, like the
492            // physical table, is what the fork reads *through*.
493            delta: RefCell::new(self.delta.borrow().clone()),
494            #[cfg(feature = "scan-stats")]
495            scan_sink: RefCell::new(self.scan_sink.borrow().clone()),
496            #[cfg(feature = "scan-stats")]
497            plan_sink: RefCell::new(self.plan_sink.borrow().clone()),
498        }
499    }
500
501    /// Open-cursor count (Primitive #2). `0` ⇒ no cursor is mid-iteration and the
502    /// connection is free for a write.
503    pub fn cursors_open(&self) -> i64 {
504        self.cursors_open.get()
505    }
506
507    /// Attach a shared [`ScanSink`]: every leaf `SELECT` this source vends from now on
508    /// accumulates its `sqlite3_stmt_status` work counters into `sink` when its cursor
509    /// drops. Test-only (the `scan-stats` feature). Attach *after* seeding so the seed
510    /// writes are not counted.
511    #[cfg(feature = "scan-stats")]
512    pub fn set_scan_sink(&self, sink: ScanSink) {
513        *self.scan_sink.borrow_mut() = Some(sink);
514    }
515
516    /// Attach a shared [`PlanSink`]: the first leaf `SELECT` this source runs from now on
517    /// records its (`?`-parameterized) SQL text into `sink`, for `analyze query` to
518    /// re-`EXPLAIN`. `scan-stats` only. First-write-wins, so it names the representative
519    /// access path even when the query drives the leaf many times.
520    #[cfg(feature = "scan-stats")]
521    pub fn set_plan_sink(&self, sink: PlanSink) {
522        *self.plan_sink.borrow_mut() = Some(sink);
523    }
524
525    /// Take the error a cursor parked during the last drained fetch (`05` §4.5):
526    /// a lossy integer-to-f64 conversion in a `number` column (`UnsupportedValue`) or a
527    /// `sqlite3_step` failure. `None` if the fetch drained cleanly. The consumer
528    /// drains the stream, THEN calls this — the error is resurfaced, not swallowed.
529    pub fn take_fetch_error(&self) -> Option<RindleError> {
530        self.fetch_error.borrow_mut().take()
531    }
532
533    /// The large-run bypass gate for the tiebreak adapter (design 204 §"Costs / risks").
534    /// The structural [`TableMeta::tiebreak_prefix_len`] decides the adapter *can*
535    /// engage; this decides it *should*, by estimating the equal-prefix run size from
536    /// `sqlite_stat1`. A low-cardinality sort column (few distinct values ⇒ large runs)
537    /// would have the adapter buffer a huge run in memory while holding the read cursor
538    /// open, so bypass to SQL's own (temp-b-tree) sort instead. Correctness holds on
539    /// either path, so an absent/stale estimate only costs performance.
540    ///
541    /// No cache: one `prepare_cached` lookup per engage-candidate fetch keeps the read
542    /// path allocation-light while staying current with a later `ANALYZE` (revisit
543    /// caching if this shows up in a profile). `true` when no stats exist yet, so the
544    /// adapter still engages on un-`ANALYZE`d tables (today's behavior); `ANALYZE` (R5)
545    /// is what makes the bypass fire for the tables where it matters.
546    ///
547    /// **Estimate is the average, not the max** (`sqlite_stat1` has no histogram): a
548    /// skewed key with one giant value can still slip a large run past an in-range
549    /// average — a bounded residual, not a hard cap.
550    fn prefix_run_within_cap(&self, sort: &Sort, prefix_len: usize) -> bool {
551        let Some(idx_name) = self.meta.prefix_stream_index(sort, prefix_len) else {
552            return true; // structurally impossible after `tiebreak_prefix_len` → engage
553        };
554        let db = self.db.borrow();
555        let stat: Option<String> = db
556            .prepare_cached("SELECT stat FROM sqlite_stat1 WHERE tbl = ?1 AND idx = ?2")
557            .ok()
558            .and_then(|mut s| {
559                s.query_row(rusqlite::params![&self.meta.table, idx_name], |r| r.get(0))
560                    .ok()
561            });
562        match stat
563            .as_deref()
564            .and_then(|s| stat1_prefix_run(s, prefix_len))
565        {
566            Some(avg_run) => avg_run <= TIEBREAK_MAX_ENGAGE_RUN,
567            None => true, // no ANALYZE stats (or malformed) → engage
568        }
569    }
570
571    /// The full per-fetch tiebreak engage decision (design 204): the SQL `ORDER BY`
572    /// prefix length to engage the adapter with, or `None` to bypass to the full-sort
573    /// SQL plan. Engage only for an **ordered, unconstrained** fetch whose predicted
574    /// equal-prefix run fits the cap.
575    ///
576    /// The **unconstrained** guard is the review fix for join children. A fetch carrying
577    /// a constraint / multiConstraint (a join's FK equality, a pushed-down `where`) can
578    /// steer SQLite to a *more selective* index than the prefix one; narrowing the
579    /// `ORDER BY` to the prefix would then either layer redundant in-engine buffering on
580    /// a temp b-tree SQLite still builds, or flip its plan to a full ordered scan.
581    /// [`TableMeta::tiebreak_prefix_len`] is constraint-blind, so we can't judge that
582    /// locally — bypass when constrained, keeping every unconstrained win with no
583    /// regression (design 204 §"When to bypass": bias to bypass when unsure).
584    fn tiebreak_engage(&self, req: &FetchRequest, sort: &Sort, unordered: bool) -> Option<usize> {
585        if unordered || req.constraint.is_some() || req.has_multi() {
586            return None;
587        }
588        self.meta
589            .tiebreak_prefix_len(sort)
590            .filter(|&prefix_len| self.prefix_run_within_cap(sort, prefix_len))
591    }
592
593    /// The unique-key column sets discovered at construction (§5.1). Each is a set
594    /// of `ColId`s in `pragma_index_info` order.
595    pub fn unique_indexes(&self) -> &[Vec<ColId>] {
596        &self.unique_indexes
597    }
598
599    /// Swap the active snapshot DB handle (`setDB`, §5.7). In-flight cursors keep
600    /// their own `Rc<Connection>`, so they are unaffected. Full Snapshotter
601    /// integration is out of scope; only the handle rebind is wired here.
602    pub fn set_db(&self, db: Rc<Connection>) {
603        *self.db.borrow_mut() = db;
604    }
605
606    /// The shared [`BatchDelta`] this source reads through (design 306, plan D1). The
607    /// constructor builds one inactive delta per source; `fork()` clones the `Rc`, so
608    /// the original and every fork read the same storage. The replica engine retrieves
609    /// it here at `register_table` — **before** forking — and drives its lifecycle
610    /// (`begin` on snapshot open, `end` on rollback).
611    pub fn batch_delta(&self) -> Rc<BatchDelta> {
612        self.delta.borrow().clone()
613    }
614
615    /// Wire a connection's downstream edge (mirrors `input.setOutput`).
616    pub fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
617        self.conns.set_output(conn, edge);
618    }
619
620    // -- push orchestration (identical shape to MemorySource::push) --
621
622    /// Eager push: fan `change` to every wired connection (overlay live,
623    /// epoch-gated), clear the overlay, then write — via the backend-agnostic
624    /// [`try_gen_push_and_write_with_split_edit`]. The write is committed **after**
625    /// the drain (§3.11), so a reentrant self-join fetch sees the overlay but not
626    /// the written row. `push_one` is the graph's downstream driver.
627    pub fn push(&self, change: SourceChange, push_one: &dyn Fn(&Conn, SourceChange)) {
628        self.try_push(change, push_one, false)
629            .expect("push SQLite change")
630    }
631
632    pub fn try_push(
633        &self,
634        change: SourceChange,
635        push_one: &dyn Fn(&Conn, SourceChange),
636        strict: bool,
637    ) -> Result<(), RindleError> {
638        try_gen_push_and_write_with_split_edit(
639            &self.conns,
640            change,
641            &|row| self.exists(row),
642            &|o| *self.overlay.borrow_mut() = o,
643            &|c| {
644                // Design 306: during an active derivation the batch delta IS storage —
645                // fold the change instead of writing it. The branch lives INSIDE the
646                // write closure so the push/fan-out call site — and with it invariants
647                // I1 (write-after-drain) and I2 (the epoch gate) — is byte-identical
648                // between the two derivation modes.
649                let delta = self.delta.borrow().clone();
650                if delta.is_active() {
651                    delta.apply(c)
652                } else {
653                    self.write_change(c)
654                }
655            },
656            &|| {
657                let e = self.epoch.get() + 1;
658                self.epoch.set(e);
659                e
660            },
661            push_one,
662            strict,
663        )
664    }
665
666    /// `exists` — the PK-keyed `checkExists` (`SELECT 1 … WHERE pk=? LIMIT 1`,
667    /// §3.11). Backs the ADD/REMOVE/EDIT existence asserts in `gen_push`.
668    /// **Delta-first** (design §6): a touched pk is answered by the delta — the base
669    /// row, if any, is stale; `Untouched` falls through to the `SELECT`.
670    fn exists(&self, row: &Row) -> Result<bool, RindleError> {
671        match self.delta.borrow().lookup_pk(row) {
672            DeltaLookup::Present(_) => return Ok(true),
673            DeltaLookup::Absent => return Ok(false),
674            DeltaLookup::Untouched => {}
675        }
676        let db = self.db.borrow();
677        let params = self.pk_params(row);
678        // Bind to a local so the `CachedStatement` temporary drops at this `;`,
679        // before the `db` borrow — not after it (E0597 on a tail expression).
680        let found = db
681            .prepare_cached(&self.meta.check_exists_sql)
682            .map_err(|e| RindleError::sqlite("prepare checkExists", e))?
683            .exists(params_from_iter(&params))
684            .map_err(|e| RindleError::sqlite("run checkExists", e))?;
685        Ok(found)
686    }
687
688    /// Apply ADD/REMOVE/EDIT to the backing table (`#writeChange`, §3.11). EDIT
689    /// uses `UPDATE` when the PK is unchanged and a non-PK column exists, else
690    /// `DELETE old` + `INSERT row`. Committed after the vend (caller ordering).
691    fn write_change(&self, change: &SourceChange) -> Result<(), RindleError> {
692        let db = self.db.borrow();
693        match change {
694            SourceChange::Add(row) => {
695                let params = self.all_col_params(row);
696                db.prepare_cached(&self.meta.insert_sql)
697                    .map_err(|e| RindleError::sqlite("prepare insert", e))?
698                    .execute(params_from_iter(&params))
699                    .map_err(|e| RindleError::sqlite("run insert", e))?;
700            }
701            SourceChange::Remove(row) => {
702                let params = self.pk_params(row);
703                db.prepare_cached(&self.meta.delete_sql)
704                    .map_err(|e| RindleError::sqlite("prepare delete", e))?
705                    .execute(params_from_iter(&params))
706                    .map_err(|e| RindleError::sqlite("run delete", e))?;
707            }
708            SourceChange::Edit { row, old } => {
709                if self.can_use_update(old, row) {
710                    // UPDATE binds the merged `{...old, ...row}` row; since a Rust
711                    // Edit's `row` is always a FULL row, the merge collapses to
712                    // `row` (§13 Q12). Params: non-PK new values, then PK values.
713                    let mut params = self.non_pk_params(row);
714                    params.extend(self.pk_params(row));
715                    let update_sql = self
716                        .meta
717                        .update_sql
718                        .as_ref()
719                        .ok_or_else(|| RindleError::Storage("missing update SQL".into()))?;
720                    db.prepare_cached(update_sql)
721                        .map_err(|e| RindleError::sqlite("prepare update", e))?
722                        .execute(params_from_iter(&params))
723                        .map_err(|e| RindleError::sqlite("run update", e))?;
724                } else {
725                    let dparams = self.pk_params(old);
726                    db.prepare_cached(&self.meta.delete_sql)
727                        .map_err(|e| RindleError::sqlite("prepare delete", e))?
728                        .execute(params_from_iter(&dparams))
729                        .map_err(|e| RindleError::sqlite("run delete (edit)", e))?;
730                    let iparams = self.all_col_params(row);
731                    db.prepare_cached(&self.meta.insert_sql)
732                        .map_err(|e| RindleError::sqlite("prepare insert", e))?
733                        .execute(params_from_iter(&iparams))
734                        .map_err(|e| RindleError::sqlite("run insert (edit)", e))?;
735                }
736            }
737        }
738        Ok(())
739    }
740
741    /// `canUseUpdate` (§3.11, table-source.ts:661): every PK column unchanged AND
742    /// a non-PK column exists. The PK comparator is **identity** (`null == null` via
743    /// [`compare_values`]) — the OPPOSITE polarity of the split-edit comparator
744    /// (§3.6 / §13 Q13), so a null PK counts as "unchanged" and permits UPDATE.
745    fn can_use_update(&self, old: &Row, row: &Row) -> bool {
746        if self.meta.update_sql.is_none() {
747            return false; // every column is PK → no SET list
748        }
749        self.meta
750            .primary_key
751            .iter()
752            .all(|&pk| compare_values(old.col(pk), row.col(pk)) == Ordering::Equal)
753    }
754
755    /// Bind every column (the INSERT value list), in declared order.
756    fn all_col_params(&self, row: &Row) -> Vec<SqliteParam> {
757        (0..self.meta.columns.len())
758            .map(|c| to_sqlite_param(row.col(c), self.meta.columns[c].ty))
759            .collect()
760    }
761
762    /// Bind the PK columns (the DELETE/UPDATE/checkExists WHERE), in PK order.
763    fn pk_params(&self, row: &Row) -> Vec<SqliteParam> {
764        self.meta
765            .primary_key
766            .iter()
767            .map(|&c| to_sqlite_param(row.col(c), self.meta.columns[c].ty))
768            .collect()
769    }
770
771    /// Bind the non-PK columns (the UPDATE SET list), in declared order.
772    fn non_pk_params(&self, row: &Row) -> Vec<SqliteParam> {
773        self.meta
774            .non_pk
775            .iter()
776            .map(|&c| to_sqlite_param(row.col(c), self.meta.columns[c].ty))
777            .collect()
778    }
779
780    /// Retrieve a single row by an **arbitrary unique key** (`getRow`, §3.12) — not
781    /// used in the IVM pipeline but useful for consistency reads. Builds `SELECT
782    /// <all declared cols> … WHERE keyCols = ?` (bare `=`, like the constraint
783    /// path), runs it, and returns an **owned** row (it is meant to escape) or
784    /// `None`. Use [`Self::try_get_row`] to surface SQLite and value-conversion
785    /// failures as [`RindleError`]; this compatibility wrapper panics on those errors.
786    pub fn get_row(&self, key: &[(ColId, OwnedValue)]) -> Option<Row> {
787        self.try_get_row(key).expect("get SQLite row")
788    }
789
790    pub fn try_get_row(&self, key: &[(ColId, OwnedValue)]) -> Result<Option<Row>, RindleError> {
791        // Delta-first (design §6, plan §4.4): the delta may hold the current content
792        // for `key` — and a base hit whose pk the delta touched is stale (the batch
793        // edited it away from `key`, or removed it), so it must NOT be returned.
794        let delta = self.delta.borrow().clone();
795        if let Some(r) = delta.find_by_key(key) {
796            return Ok(Some(r));
797        }
798        let sql = self.get_row_sql(key);
799        let params: Vec<SqliteParam> = key
800            .iter()
801            .map(|(c, v)| to_sqlite_param(v.as_ref(), self.meta.columns[*c].ty))
802            .collect();
803        let db = self.db.borrow();
804        let mut stmt = db
805            .prepare_cached(&sql)
806            .map_err(|e| RindleError::sqlite("prepare getRow", e))?;
807        let mut rows = stmt
808            .query(params_from_iter(&params))
809            .map_err(|e| RindleError::sqlite("run getRow", e))?;
810        match rows
811            .next()
812            .map_err(|e| RindleError::sqlite("step getRow", e))?
813        {
814            Some(row) => {
815                if let Err(e) = check_row_cells(row, &self.meta) {
816                    return Err(e.into());
817                }
818                let row = SqliteRow::new(row, &self.meta.col_types).try_to_owned_row()?;
819                if delta.suppresses(&row) {
820                    return Ok(None);
821                }
822                Ok(Some(row))
823            }
824            None => Ok(None),
825        }
826    }
827
828    /// `SELECT <all declared cols> FROM t WHERE k0=? AND k1=?` for a key set
829    /// (`#getRowStmt`, §3.12). Built per call; `prepare_cached` caches the prepared
830    /// statement by text, so the repeated-key-shape cost is just the string build.
831    fn get_row_sql(&self, key: &[(ColId, OwnedValue)]) -> String {
832        let mut sql = String::from("SELECT ");
833        for (i, c) in self.meta.columns.iter().enumerate() {
834            if i > 0 {
835                sql.push_str(", ");
836            }
837            sql.push_str(&ident(&c.name));
838        }
839        sql.push_str(" FROM ");
840        sql.push_str(&ident(&self.meta.table));
841        sql.push_str(" WHERE ");
842        for (i, (col, _)) in key.iter().enumerate() {
843            if i > 0 {
844                sql.push_str(" AND ");
845            }
846            sql.push_str(&ident(&self.meta.columns[*col].name));
847            sql.push_str(" = ?");
848        }
849        sql
850    }
851}
852
853/// `assertOrderingIncludesPK` (`complete-ordering.ts`): an ordered connection's
854/// sort must include every PK column (so sort-equality ⇒ identity — the overlay
855/// remove-suppression depends on it, §3.5). Mirrors the memory leaf's private check.
856fn assert_ordering_includes_pk(sort: &Sort, pk: &[ColId]) {
857    for &p in pk {
858        assert!(
859            sort.iter().any(|(c, _)| *c == p),
860            "connection ordering must include the primary key column {p}",
861        );
862    }
863}
864
865impl Source for TableSource {
866    fn connect(
867        &self,
868        sort: Option<Sort>,
869        filters: Option<ConnectionFilters>,
870        split_edit_keys: Vec<ColId>,
871    ) -> ConnId {
872        let unordered = sort.is_none();
873        let internal_sort =
874            sort.unwrap_or_else(|| self.meta.primary_key.iter().map(|&c| (c, true)).collect());
875        if !unordered {
876            assert_ordering_includes_pk(&internal_sort, &self.meta.primary_key);
877        }
878        self.conns.connect(Conn {
879            sort: internal_sort,
880            unordered,
881            split_edit_keys,
882            filters,
883            last_pushed_epoch: Cell::new(0),
884            output: Cell::new(None),
885        })
886    }
887
888    fn schema(&self) -> &Schema {
889        &self.meta.schema
890    }
891
892    fn conn_sort(&self, conn: ConnId) -> Sort {
893        self.conns.sort(conn)
894    }
895
896    fn destroy(&self, conn: ConnId) {
897        // Null the output edge AND recycle the slot (the shared `ConnTable` free-list);
898        // backing SQLite indexes are kept (§3.10), like the memory leaf.
899        self.conns.destroy(conn);
900    }
901
902    fn cursors_open(&self) -> i64 {
903        TableSource::cursors_open(self)
904    }
905
906    fn try_push(
907        &self,
908        change: SourceChange,
909        push_one: &dyn Fn(&Conn, SourceChange),
910        strict: bool,
911    ) -> Result<(), RindleError> {
912        TableSource::try_push(self, change, push_one, strict)
913    }
914
915    fn take_error(&self) -> Option<RindleError> {
916        TableSource::take_fetch_error(self)
917    }
918
919    fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
920        TableSource::set_conn_output(self, conn, edge)
921    }
922
923    fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
924        self.conns.add_guard_value(conn, value)
925    }
926
927    fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
928        self.conns.remove_guard_value(conn, value)
929    }
930
931    /// `#fetch` (the hot read path, §5.3). Compile the request to a `SELECT`
932    /// (constraint + multiConstraints + start-prefilter + filter + ORDER BY all
933    /// lowered into SQL), open the lazy zero-copy cursor, then drive it through the
934    /// shared overlay/start seam. **Unlike the memory leaf there is NO committed-row
935    /// constraint-trim or filter pass** — SQL already did them; the connection's
936    /// `predicate` narrows only the (not-in-SQL) overlay rows (table-source.ts:297).
937    fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g> {
938        let idx = self.conns.live_ix(conn);
939
940        // Snapshot the connection's needed bits (short borrow, cloned out — no
941        // RefCell borrow held across the vend, foundations §6.2).
942        let (sort, unordered, predicate, sql_condition, last_epoch) = {
943            let conns = self.conns.borrow();
944            let c = &conns[idx];
945            (
946                c.sort.clone(),
947                c.unordered,
948                c.filters.as_ref().map(|f| f.predicate.clone()),
949                c.filters.as_ref().and_then(|f| f.sql_condition.clone()),
950                c.last_pushed_epoch.get(),
951            )
952        };
953
954        // In-engine sort tiebreak (design 204). `Some(n)` narrows the SQL `ORDER BY` to
955        // the first `n` (prefix) columns and re-applies the appended-PK tiebreak in
956        // engine; `None` keeps today's full-sort SQL plan. `tiebreak_engage` holds the
957        // ordered / unconstrained / run-size gates.
958        let tiebreak = self.tiebreak_engage(req, &sort, unordered);
959
960        // What the SELECT orders by, and the start bound it prefilters on. When the
961        // adapter is engaged the SQL order narrows to the prefix and the start bound is
962        // relaxed to be inclusive on the prefix boundary (`Basis::At` over the prefix),
963        // so the whole boundary run is returned; the exact At/After cut is re-applied in
964        // engine by `generate_with_start` over the full sort. The full `sort`/`reverse`
965        // still flow to the overlay seam below.
966        let prefix_sort: Option<Sort> = tiebreak.map(|n| sort[..n].to_vec());
967        let sql_order: Option<&Sort> = if unordered {
968            None
969        } else if let Some(p) = prefix_sort.as_ref() {
970            Some(p)
971        } else {
972            Some(&sort)
973        };
974        let relaxed_start: Option<Start> = if tiebreak.is_some() {
975            req.start.as_ref().map(|s| Start {
976                row: s.row.clone(),
977                basis: Basis::At,
978            })
979        } else {
980            req.start.clone()
981        };
982        let compiled = build_select_query(
983            &self.meta.table,
984            &self.meta.columns,
985            req.constraint.as_ref(),
986            &req.multi_constraints,
987            sql_condition.as_ref(),
988            sql_order,
989            req.reverse,
990            relaxed_start.as_ref(),
991        );
992
993        // Open the cursor: it OWNS the conn (Rc snapshot) + prepared statement +
994        // rows, so the lazy stream can be boxed and returned (the §13 Q1 self-ref).
995        let db = self.db.borrow().clone();
996        let guard = CursorGuard::new(self.cursors_open.clone());
997        let leaf = match OwnedSqliteRows::new(
998            db,
999            &compiled.sql,
1000            &compiled.params,
1001            self.meta.clone(),
1002            self.fetch_error.clone(),
1003            guard,
1004        ) {
1005            Ok(leaf) => leaf,
1006            Err(e) => {
1007                *self.fetch_error.borrow_mut() =
1008                    Some(RindleError::sqlite("prepare/query fetch", e));
1009                return Box::new(std::iter::empty());
1010            }
1011        };
1012
1013        // `scan-stats`: hand this cursor the source's shared work-counter sink so it
1014        // accumulates the statement's `sqlite3_stmt_status` counters when it drops; and
1015        // capture the representative fetch SQL (first-write-wins) for `analyze`'s per-leaf
1016        // `EXPLAIN`. Both are no-ops unless a sink was attached (only during analyze).
1017        #[cfg(feature = "scan-stats")]
1018        let leaf = {
1019            if let Some(sink) = self.plan_sink.borrow().as_ref() {
1020                let mut slot = sink.borrow_mut();
1021                if slot.is_none() {
1022                    *slot = Some(compiled.sql.clone());
1023                }
1024            }
1025            leaf.with_scan_sink(self.scan_sink.borrow().clone())
1026        };
1027
1028        // Snapshot the epoch-gated overlay (tight borrow, cloned out). The shared
1029        // seam computes the narrowed overlays eagerly, then yields the lazy splice.
1030        let overlay = self.overlay.borrow().clone();
1031
1032        // The batch delta (design 306): during a derivation that has folded at least
1033        // one change, the leaf stream is merged with the delta's rows for this request.
1034        // Otherwise the leaf is used UNTOUCHED — hydration, `analyze`, `read_snapshot`,
1035        // and every fetch before a batch's first change pay nothing (plan §4.2).
1036        let delta = self.delta.borrow().clone();
1037        let delta_live = delta.is_active() && !delta.is_empty();
1038
1039        if unordered {
1040            merged_spliced_unordered(
1041                leaf,
1042                delta,
1043                delta_live,
1044                req,
1045                &sort,
1046                &self.meta.primary_key,
1047                overlay.as_ref(),
1048                last_epoch,
1049                predicate.as_ref(),
1050                self.fetch_error.clone(),
1051            )
1052        } else {
1053            let start_at = req.start.as_ref().map(|s| s.row.clone());
1054            // When engaged, restore the full `(prefix, pk)` order in-engine *before* the
1055            // overlay splice sees the stream (design 204). The seam still receives the
1056            // full `sort`/`reverse`. The batch merge (design 306) wraps outermost of the
1057            // leaf shapes — both of its inputs are then in full connection-sort order,
1058            // the ONE merge comparator this function has (plan §4.2) — assembled in
1059            // exactly one place, `merged_spliced`.
1060            let stream = match tiebreak {
1061                Some(prefix_len) => {
1062                    let tb = TiebreakStream::new(
1063                        leaf,
1064                        sort.clone(),
1065                        prefix_len,
1066                        req.reverse,
1067                        self.fetch_error.clone(),
1068                    );
1069                    merged_spliced(
1070                        tb,
1071                        delta,
1072                        delta_live,
1073                        start_at.as_ref(),
1074                        req,
1075                        overlay.as_ref(),
1076                        last_epoch,
1077                        &sort,
1078                        predicate.as_ref(),
1079                        self.fetch_error.clone(),
1080                    )
1081                }
1082                None => merged_spliced(
1083                    leaf,
1084                    delta,
1085                    delta_live,
1086                    start_at.as_ref(),
1087                    req,
1088                    overlay.as_ref(),
1089                    last_epoch,
1090                    &sort,
1091                    predicate.as_ref(),
1092                    self.fetch_error.clone(),
1093                ),
1094            };
1095            generate_with_start(stream, req.start.clone(), sort, req.reverse)
1096        }
1097    }
1098}
1099
1100/// The ONE place the ordered batch merge + overlay splice pair is assembled (plan
1101/// §4.2): wrap `leaf` in [`BatchMerge`] iff a delta is live, then drive it through the
1102/// shared splice tail — so the tiebreak and plain leaf shapes cannot drift.
1103#[allow(clippy::too_many_arguments)]
1104fn merged_spliced<'g, S: rindle::value::RowStream + 'g>(
1105    leaf: S,
1106    delta: Rc<BatchDelta>,
1107    delta_live: bool,
1108    start_at: Option<&Row>,
1109    req: &FetchRequest,
1110    overlay: Option<&Overlay>,
1111    last_epoch: u32,
1112    sort: &Sort,
1113    predicate: Option<&rindle::source_common::RowPredicate>,
1114    error_sink: Rc<RefCell<Option<RindleError>>>,
1115) -> RowFlow<'g> {
1116    if delta_live {
1117        let merged = BatchMerge::new(leaf, delta, req, sort, predicate, error_sink.clone());
1118        spliced_ordered(
1119            merged, start_at, req, overlay, last_epoch, sort, predicate, error_sink,
1120        )
1121    } else {
1122        spliced_ordered(
1123            leaf, start_at, req, overlay, last_epoch, sort, predicate, error_sink,
1124        )
1125    }
1126}
1127
1128/// The unordered twin of [`merged_spliced`]: wrap `leaf` in [`BatchMergeUnordered`]
1129/// iff a delta is live — stripping `start` from the delta's request, because nothing
1130/// downstream of the unordered splice re-applies a cut — then the unordered overlay
1131/// splice. The 8-argument splice call exists only here.
1132#[allow(clippy::too_many_arguments)]
1133fn merged_spliced_unordered<'g, S: rindle::value::RowStream + 'g>(
1134    leaf: S,
1135    delta: Rc<BatchDelta>,
1136    delta_live: bool,
1137    req: &FetchRequest,
1138    sort: &Sort,
1139    primary_key: &[ColId],
1140    overlay: Option<&Overlay>,
1141    last_epoch: u32,
1142    predicate: Option<&rindle::source_common::RowPredicate>,
1143    error_sink: Rc<RefCell<Option<RindleError>>>,
1144) -> RowFlow<'g> {
1145    if delta_live {
1146        let delta_req = FetchRequest {
1147            start: None,
1148            ..req.clone()
1149        };
1150        let merged =
1151            BatchMergeUnordered::new(leaf, delta, &delta_req, sort, predicate, error_sink.clone());
1152        generate_with_overlay_unordered_checked(
1153            merged,
1154            req.constraint.as_ref(),
1155            overlay,
1156            last_epoch,
1157            primary_key,
1158            predicate,
1159            &req.multi_constraints,
1160            Some(error_sink),
1161        )
1162    } else {
1163        generate_with_overlay_unordered_checked(
1164            leaf,
1165            req.constraint.as_ref(),
1166            overlay,
1167            last_epoch,
1168            primary_key,
1169            predicate,
1170            &req.multi_constraints,
1171            Some(error_sink),
1172        )
1173    }
1174}
1175
1176/// The shared tail of the ordered fetch: drive a leaf-shaped stream through the
1177/// epoch-gated overlay splice. One helper so the leaf shapes (plain / tiebreak,
1178/// each with or without the batch merge) do not quadruplicate the argument list.
1179#[allow(clippy::too_many_arguments)]
1180fn spliced_ordered<'g, S: rindle::value::RowStream + 'g>(
1181    leaf: S,
1182    start_at: Option<&Row>,
1183    req: &FetchRequest,
1184    overlay: Option<&Overlay>,
1185    last_epoch: u32,
1186    sort: &Sort,
1187    predicate: Option<&rindle::source_common::RowPredicate>,
1188    error_sink: Rc<RefCell<Option<RindleError>>>,
1189) -> RowFlow<'g> {
1190    // Splice sort == gate sort here: SQL emits rows in the resolved connection
1191    // order (`SELECT … ORDER BY sort`), so the scan order IS the connection sort —
1192    // unlike the memory leaf's constrained index scan, the two comparators never
1193    // diverge on this path.
1194    generate_with_overlay_checked(
1195        start_at,
1196        leaf,
1197        req.constraint.as_ref(),
1198        overlay,
1199        last_epoch,
1200        sort,
1201        sort,
1202        req.reverse,
1203        predicate,
1204        &req.multi_constraints,
1205        Some(error_sink),
1206    )
1207}
1208
1209/// The build-time read seam for scalar-subquery resolution
1210/// (`SCALAR-SUBQUERY-DESIGN.md` §8): uniqueness metadata + a point lookup. Distinct
1211/// from the runtime [`Source`] trait — no connect/fetch/push, just a read.
1212impl rindle::ScalarSource for TableSource {
1213    fn schema(&self) -> &Schema {
1214        &self.meta.schema
1215    }
1216
1217    /// PK first — always available, even for a rowid-alias PK with no separate index
1218    /// row — then each *discovered* non-PK unique key (the hardened
1219    /// `discover_unique_indexes` set, design §4.1).
1220    fn unique_keys(&self) -> Vec<Vec<ColId>> {
1221        let pk = &self.meta.primary_key;
1222        let mut keys = vec![pk.clone()];
1223        for ix in &self.unique_indexes {
1224            if !same_col_set(ix, pk) {
1225                keys.push(ix.clone());
1226            }
1227        }
1228        keys
1229    }
1230
1231    /// The single row whose `bound` key columns match — a real `SELECT … WHERE k = ?`
1232    /// point read against the snapshot ([`TableSource::get_row`]).
1233    fn lookup_unique(&self, bound: &[(ColId, OwnedValue)]) -> Option<Row> {
1234        self.get_row(bound)
1235    }
1236}
1237
1238// ---------------------------------------------------------------------------
1239// The owning zero-copy cursor (the §13 Q1 self-reference, solved)
1240// ---------------------------------------------------------------------------
1241
1242/// Owns the whole conn→stmt→cursor borrow chain as one movable, boxable unit so a
1243/// lazy [`RowFlow`] can return it. This is the spec's #1 implementation risk
1244/// (`05` §13 Q1): a `rusqlite::Rows` borrows a `Statement` which borrows the
1245/// `Connection`, and safe Rust cannot store a value alongside what it borrows. We
1246/// solve it with one contained `unsafe`: the statement is **`Box`-pinned** (stable
1247/// heap address) and the `Connection` is kept alive by an owned `Rc`, so the two
1248/// borrows are real and the lifetimes are erased to `'static` only internally —
1249/// never exposed. The field **drop order is load-bearing** (declaration order):
1250/// `rows` (resets the cursor) → `_stmt` (returns the raw statement to the
1251/// connection's cache) → `_conn` (last `Rc` release). Reordering would return the
1252/// statement to a freed connection.
1253struct OwnedSqliteRows {
1254    rows: Rows<'static>,
1255    _stmt: Box<CachedStatement<'static>>,
1256    _conn: Rc<Connection>,
1257    meta: Rc<TableMeta>,
1258    /// Shared with the source: a parked error is written here and resurfaced via
1259    /// [`TableSource::take_fetch_error`] after the drain.
1260    error_sink: Rc<RefCell<Option<RindleError>>>,
1261    _guard: CursorGuard,
1262    /// `scan-stats` only: where this cursor reports its `sqlite3_stmt_status` work
1263    /// counters on drop. `None` ⇒ uninstrumented (the production default).
1264    #[cfg(feature = "scan-stats")]
1265    scan_sink: Option<ScanSink>,
1266}
1267
1268impl OwnedSqliteRows {
1269    fn new(
1270        conn: Rc<Connection>,
1271        sql: &str,
1272        params: &[SqliteParam],
1273        meta: Rc<TableMeta>,
1274        error_sink: Rc<RefCell<Option<RindleError>>>,
1275        guard: CursorGuard,
1276    ) -> rusqlite::Result<OwnedSqliteRows> {
1277        let stmt_borrowed: CachedStatement<'_> = conn.prepare_cached(sql)?;
1278        // SAFETY: erase the connection-borrow lifetime to 'static. `_conn` (an Rc
1279        // clone) is stored in this struct and dropped AFTER `_stmt`, so the
1280        // Connection strictly outlives the statement. The 'static reference is
1281        // never exposed outside this struct.
1282        let mut stmt: Box<CachedStatement<'static>> = Box::new(unsafe {
1283            std::mem::transmute::<CachedStatement<'_>, CachedStatement<'static>>(stmt_borrowed)
1284        });
1285        let rows_borrowed = stmt.query(params_from_iter(params))?;
1286        // SAFETY: `rows` borrows `*stmt`. `stmt` is `Box`-pinned, so moving the Box
1287        // into the struct leaves the heap `CachedStatement` (and thus the pointer
1288        // inside `rows`) at a stable address. `_stmt` is dropped AFTER `rows`
1289        // (declaration order). We only ever touch the statement through `rows`
1290        // after this point, so the aliasing is never observed.
1291        let rows: Rows<'static> =
1292            unsafe { std::mem::transmute::<Rows<'_>, Rows<'static>>(rows_borrowed) };
1293        Ok(OwnedSqliteRows {
1294            rows,
1295            _stmt: stmt,
1296            _conn: conn,
1297            meta,
1298            error_sink,
1299            _guard: guard,
1300            #[cfg(feature = "scan-stats")]
1301            scan_sink: None,
1302        })
1303    }
1304}
1305
1306#[cfg(feature = "scan-stats")]
1307impl OwnedSqliteRows {
1308    /// Attach the source's shared work-counter sink to this cursor.
1309    ///
1310    /// When a sink is attached, the statement's counters are **zeroed first**: the
1311    /// prepared statement comes from the connection's statement cache, and a *live*
1312    /// cursor (no sink) accumulates `sqlite3_stmt_status` counts it never resets — so
1313    /// without this reset an instrumented run over previously-live leaf SQL would read
1314    /// that history into its own totals.
1315    fn with_scan_sink(mut self, sink: Option<ScanSink>) -> Self {
1316        if sink.is_some() {
1317            use rusqlite::StatementStatus::{FullscanStep, Run, Sort, VmStep};
1318            for s in [FullscanStep, Run, Sort, VmStep] {
1319                let _ = self._stmt.reset_status(s);
1320            }
1321        }
1322        self.scan_sink = sink;
1323        self
1324    }
1325}
1326
1327/// On drop, read the executed statement's `sqlite3_stmt_status` counters into the
1328/// shared sink. `reset_status` returns the prior value AND zeroes the counter — which
1329/// is required for correctness: these counters persist across `sqlite3_reset`, so a
1330/// cached statement reused on the next fetch must start from zero or the totals would
1331/// double-count. Drop runs before the fields, so `_stmt` is still live here; the read
1332/// is a counter query (no execution-state mutation), so it does not disturb the
1333/// (already drop-ordered) `rows`→`_stmt`→`_conn` teardown.
1334#[cfg(feature = "scan-stats")]
1335impl Drop for OwnedSqliteRows {
1336    fn drop(&mut self) {
1337        let Some(sink) = &self.scan_sink else { return };
1338        use rusqlite::StatementStatus::{FullscanStep, Run, Sort, VmStep};
1339        let read = |s| self._stmt.reset_status(s).max(0) as u64;
1340        let mut acc = sink.get();
1341        acc.fullscan_steps += read(FullscanStep);
1342        acc.sorts += read(Sort);
1343        acc.vm_steps += read(VmStep);
1344        acc.runs += read(Run);
1345        sink.set(acc);
1346    }
1347}
1348
1349impl rindle::value::RowStream for OwnedSqliteRows {
1350    type Row<'a>
1351        = SqliteRow<'a>
1352    where
1353        Self: 'a;
1354
1355    fn next_row(&mut self) -> Option<SqliteRow<'_>> {
1356        match self.rows.next() {
1357            Ok(Some(row)) => {
1358                // Eager per-cell contract validation — lossless f64 for `number`
1359                // columns, NULL-or-INTEGER storage class for `int64` columns —
1360                // matching the capture and SQL-client value boundary. A violation
1361                // parks `UnsupportedValue` and ends the stream — NOT a silent end.
1362                // (json/UTF-8 stay deferred to `to_owned`.)
1363                if let Err(e) = check_row_cells(row, &self.meta) {
1364                    *self.error_sink.borrow_mut() = Some(e.into());
1365                    return None;
1366                }
1367                Some(SqliteRow::new(row, &self.meta.col_types))
1368            }
1369            Ok(None) => None,
1370            Err(e) => {
1371                // A `sqlite3_step` failure (I/O, corruption, expression error) —
1372                // parked, never mapped to a clean end-of-stream (`05` §4.5).
1373                *self.error_sink.borrow_mut() = Some(SqliteError::Step(e).into());
1374                None
1375            }
1376        }
1377    }
1378}
1379
1380/// Eager lossless-f64 check: a `number`-column (`ColType::Float`) cell stored as
1381/// INTEGER must widen to `f64` and narrow back without precision loss, or it is an
1382/// [`SqliteError::UnsupportedValue`]. Only `number` columns are checked because
1383/// their canonical engine representation is `f64`.
1384fn check_number_bounds(row: &rusqlite::Row, meta: &TableMeta) -> Result<(), SqliteError> {
1385    for &i in &meta.float_cols {
1386        if let ValueRef::Integer(v) = row.get_ref_unwrap(i) {
1387            if !is_exact_f64_integer(v) {
1388                return Err(SqliteError::UnsupportedValue(format!(
1389                    "value {v} (in {}.{}) cannot round-trip through f64",
1390                    meta.table, meta.columns[i].name
1391                )));
1392            }
1393        }
1394    }
1395    Ok(())
1396}
1397
1398/// Eager storage-class check for declared `int64` (`ColType::Int`) columns
1399/// (design 226 §4.2): a cell must be NULL or INTEGER. SQLite affinity would
1400/// happily hold REAL/TEXT/BLOB under a `BIGINT` declaration; refusing it here —
1401/// before [`SqliteRow::col`] can hit its deterministic mismatch pass-through —
1402/// is what makes an `int64` position provably `Int`, mirroring the CDC capture
1403/// guard so cold hydrate and live capture agree.
1404fn check_int_storage(row: &rusqlite::Row, meta: &TableMeta) -> Result<(), SqliteError> {
1405    for &i in &meta.int_cols {
1406        match row.get_ref_unwrap(i) {
1407            ValueRef::Null | ValueRef::Integer(_) => {}
1408            other => {
1409                return Err(SqliteError::UnsupportedValue(format!(
1410                    "storage class {} (in {}.{}) in an int64 column (int64 cells are NULL or INTEGER)",
1411                    other.data_type(),
1412                    meta.table,
1413                    meta.columns[i].name
1414                )));
1415            }
1416        }
1417    }
1418    Ok(())
1419}
1420
1421/// The per-row cell contract at the read boundary: the `number` lossless-f64
1422/// check plus the `int64` storage-class check. One call site per cursor shape.
1423fn check_row_cells(row: &rusqlite::Row, meta: &TableMeta) -> Result<(), SqliteError> {
1424    check_number_bounds(row, meta)?;
1425    check_int_storage(row, meta)
1426}
1427
1428// ---------------------------------------------------------------------------
1429// RAII cursor guard (Primitive #2): Drop == JS finally/.return() + cache return
1430// ---------------------------------------------------------------------------
1431
1432/// Bumps the open-cursor count on creation, decrements on `Drop`. Rides *inside*
1433/// [`OwnedSqliteRows`], so it fires when the boxed stream drops — on normal end,
1434/// an early `Take` break, `?`, or early return — for free, no `finally`. NOTE:
1435/// this leaf is `sqlite`-only (a native server target). Run the server with the
1436/// `release-server` profile (`panic = "unwind"`), where `Drop` also runs on panic;
1437/// under a plain `release` build (`panic = "abort"`) a panic aborts the process and
1438/// NO destructor runs, so panic is covered only in unwinding builds (`release-server`,
1439/// `cargo test`). See WS02.
1440struct CursorGuard(Rc<Cell<i64>>);
1441impl CursorGuard {
1442    fn new(c: Rc<Cell<i64>>) -> CursorGuard {
1443        c.set(c.get() + 1);
1444        CursorGuard(c)
1445    }
1446}
1447impl Drop for CursorGuard {
1448    fn drop(&mut self) {
1449        self.0.set(self.0.get() - 1);
1450    }
1451}
1452
1453// ---------------------------------------------------------------------------
1454// Construction helpers: unique-index discovery + write-statement SQL
1455// ---------------------------------------------------------------------------
1456
1457/// Discover the table's **statically-unique, soundly-inlineable** key column sets
1458/// (`getUniqueIndexes`, §5.1; scalar-subquery design §4.1–4.2) via `pragma_index_list`
1459/// / `pragma_index_xinfo` (bound params, no interpolation). Each result is a
1460/// `Vec<ColId>` of one unique index's key columns. Finds the auto-created PK/UNIQUE
1461/// indexes too, so the PK-UNIQUE construction assert holds for an ordinary
1462/// `PRIMARY KEY(...)` table, and the scalar resolver can prove single-row.
1463///
1464/// Two hardening rules make this sound for *inlining* (not just the assert):
1465/// - **PARTIAL unique indexes are skipped** (`partial = 1`): they enforce uniqueness
1466///   only within their predicate, so a bound match could still hit >1 row globally.
1467/// - **An expression key column discards the whole index.** `index_xinfo` reports a
1468///   NULL `name` for an expression key (e.g. `lower(x)`); silently dropping it would
1469///   make the index look *narrower* than it is — the unsound direction. If any key
1470///   column is not a plain declared column, the index is unusable and dropped whole.
1471///
1472/// (Collation §4.5 — only `BINARY` keys are strictly safe to inline against — is a
1473/// follow-up; the slice's real use is the PK, `BINARY` by default.)
1474fn discover_unique_indexes(
1475    db: &Connection,
1476    table: &str,
1477    columns: &[ColumnDef],
1478    primary_key: &[ColId],
1479) -> Result<Vec<Vec<ColId>>, RindleError> {
1480    let name_to_id = |n: &str| -> Option<ColId> { columns.iter().position(|c| &*c.name == n) };
1481
1482    // (index name, unique flag, partial flag) for every index on the table.
1483    let idx_list: Vec<(String, i64, i64)> = {
1484        let mut stmt = db
1485            .prepare("SELECT name, \"unique\", partial FROM pragma_index_list(?1)")
1486            .map_err(|e| RindleError::sqlite("prepare pragma_index_list", e))?;
1487        let out = stmt
1488            .query_map([table], |r| {
1489                Ok((
1490                    r.get::<_, String>(0)?,
1491                    r.get::<_, i64>(1)?,
1492                    r.get::<_, i64>(2)?,
1493                ))
1494            })
1495            .map_err(|e| RindleError::sqlite("run pragma_index_list", e))?
1496            .filter_map(|r| r.ok())
1497            .collect();
1498        out
1499    };
1500
1501    let mut result = Vec::new();
1502    for (idx_name, unique, partial) in idx_list {
1503        if unique == 0 || partial != 0 {
1504            continue; // non-unique, or partial (predicate-scoped) → not inlineable
1505        }
1506        // Key columns of this index (`key = 1`); a NULL name ⇒ expression column.
1507        let mut info = db
1508            .prepare("SELECT name, key FROM pragma_index_xinfo(?1)")
1509            .map_err(|e| RindleError::sqlite("prepare pragma_index_xinfo", e))?;
1510        let key_cols: Vec<Option<String>> = info
1511            .query_map([&idx_name], |r| {
1512                Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
1513            })
1514            .map_err(|e| RindleError::sqlite("run pragma_index_xinfo", e))?
1515            .filter_map(|r| r.ok())
1516            .filter(|(_, key)| *key == 1)
1517            .map(|(name, _)| name)
1518            .collect();
1519
1520        // Resolve every key column to a declared `ColId`; an expression (NULL) or
1521        // unknown column makes the whole index unusable (`collect` to `Option`
1522        // short-circuits to `None`).
1523        let resolved: Option<Vec<ColId>> = key_cols
1524            .into_iter()
1525            .map(|n| n.as_deref().and_then(name_to_id))
1526            .collect();
1527        if let Some(cols) = resolved {
1528            if !cols.is_empty() {
1529                result.push(cols);
1530            }
1531        }
1532    }
1533
1534    // A rowid-alias `INTEGER PRIMARY KEY` (a single column whose DECLARED type is exactly
1535    // `INTEGER`) has no entry in `pragma_index_list`, yet it IS a unique key: the rowid enforces
1536    // uniqueness and SQLite point-looks-it-up natively (`SEARCH … USING INTEGER PRIMARY KEY`).
1537    // Recognize it so the source needs no redundant synthetic PK index (GitHub #68) and the
1538    // PK-has-a-unique-key invariant still holds.
1539    if primary_key.len() == 1 && !result.iter().any(|ix| same_col_set(ix, primary_key)) {
1540        let pk_name: &str = &columns[primary_key[0]].name;
1541        let decl: rusqlite::Result<String> = db.query_row(
1542            "SELECT type FROM pragma_table_info(?1) WHERE name = ?2",
1543            rusqlite::params![table, pk_name],
1544            |r| r.get(0),
1545        );
1546        if matches!(decl, Ok(t) if t.trim().eq_ignore_ascii_case("INTEGER")) {
1547            result.push(primary_key.to_vec());
1548        }
1549    }
1550    Ok(result)
1551}
1552
1553/// True when `primary_key` is a single `INTEGER PRIMARY KEY` — SQLite's rowid alias
1554/// (design 204 / GitHub #68). Runs the same declared-type probe
1555/// [`discover_unique_indexes`] uses to recognize the alias.
1556fn is_pk_rowid_alias(
1557    db: &Connection,
1558    table: &str,
1559    columns: &[ColumnDef],
1560    primary_key: &[ColId],
1561) -> bool {
1562    if primary_key.len() != 1 {
1563        return false;
1564    }
1565    let pk_name: &str = &columns[primary_key[0]].name;
1566    let decl: rusqlite::Result<String> = db.query_row(
1567        "SELECT type FROM pragma_table_info(?1) WHERE name = ?2",
1568        rusqlite::params![table, pk_name],
1569        |r| r.get(0),
1570    );
1571    matches!(decl, Ok(t) if t.trim().eq_ignore_ascii_case("INTEGER"))
1572}
1573
1574/// Every index's leading resolvable key columns (in index order), unique or not — the
1575/// structural basis for the tiebreak bypass decision (design 204,
1576/// [`TableMeta::tiebreak_prefix_len`]). Unlike [`discover_unique_indexes`] this keeps
1577/// non-unique indexes (a user's `CREATE INDEX ix ON t(ord)`) and stops each index at
1578/// its first expression/unknown key column (the leading resolvable run is all the
1579/// ordering decision needs).
1580fn discover_all_index_columns(
1581    db: &Connection,
1582    table: &str,
1583    columns: &[ColumnDef],
1584) -> Result<Vec<(String, Vec<ColId>)>, RindleError> {
1585    let name_to_id = |n: &str| -> Option<ColId> { columns.iter().position(|c| &*c.name == n) };
1586
1587    let idx_names: Vec<String> = {
1588        let mut stmt = db
1589            .prepare("SELECT name FROM pragma_index_list(?1)")
1590            .map_err(|e| RindleError::sqlite("prepare pragma_index_list (all)", e))?;
1591        let out = stmt
1592            .query_map([table], |r| r.get::<_, String>(0))
1593            .map_err(|e| RindleError::sqlite("run pragma_index_list (all)", e))?
1594            .filter_map(|r| r.ok())
1595            .collect();
1596        out
1597    };
1598
1599    let mut result = Vec::new();
1600    for idx_name in idx_names {
1601        let mut info = db
1602            .prepare("SELECT name, key FROM pragma_index_xinfo(?1)")
1603            .map_err(|e| RindleError::sqlite("prepare pragma_index_xinfo (all)", e))?;
1604        // Key columns only (`key = 1`), in index order (the pragma yields seqno order).
1605        let key_cols: Vec<Option<String>> = info
1606            .query_map([&idx_name], |r| {
1607                Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
1608            })
1609            .map_err(|e| RindleError::sqlite("run pragma_index_xinfo (all)", e))?
1610            .filter_map(|r| r.ok())
1611            .filter(|(_, key)| *key == 1)
1612            .map(|(name, _)| name)
1613            .collect();
1614
1615        // Leading resolvable columns only: stop at the first expression/unknown key.
1616        let mut cols = Vec::new();
1617        for n in key_cols {
1618            match n.as_deref().and_then(name_to_id) {
1619                Some(id) => cols.push(id),
1620                None => break,
1621            }
1622        }
1623        if !cols.is_empty() {
1624            result.push((idx_name, cols));
1625        }
1626    }
1627    Ok(result)
1628}
1629
1630/// Set-equality over two `ColId` lists (order-insensitive) — used to dedup the PK
1631/// against the discovered unique indexes in [`TableSource`]'s `unique_keys`.
1632fn same_col_set(a: &[ColId], b: &[ColId]) -> bool {
1633    if a.len() != b.len() {
1634        return false;
1635    }
1636    let mut a = a.to_vec();
1637    let mut b = b.to_vec();
1638    a.sort_unstable();
1639    b.sort_unstable();
1640    a == b
1641}
1642
1643fn build_insert_sql(table: &str, columns: &[ColumnDef]) -> String {
1644    let cols = columns
1645        .iter()
1646        .map(|c| ident(&c.name))
1647        .collect::<Vec<_>>()
1648        .join(", ");
1649    let placeholders = vec!["?"; columns.len()].join(", ");
1650    format!(
1651        "INSERT INTO {} ({cols}) VALUES ({placeholders})",
1652        ident(table)
1653    )
1654}
1655
1656fn build_delete_sql(table: &str, pk: &[ColId], columns: &[ColumnDef]) -> String {
1657    format!(
1658        "DELETE FROM {} WHERE {}",
1659        ident(table),
1660        pk_eq_clause(pk, columns)
1661    )
1662}
1663
1664fn build_update_sql(
1665    table: &str,
1666    pk: &[ColId],
1667    non_pk: &[ColId],
1668    columns: &[ColumnDef],
1669) -> Option<String> {
1670    if non_pk.is_empty() {
1671        return None; // every column is PK — cannot UPDATE (§3.11)
1672    }
1673    let set = non_pk
1674        .iter()
1675        .map(|&c| format!("{} = ?", ident(&columns[c].name)))
1676        .collect::<Vec<_>>()
1677        .join(", ");
1678    Some(format!(
1679        "UPDATE {} SET {set} WHERE {}",
1680        ident(table),
1681        pk_eq_clause(pk, columns)
1682    ))
1683}
1684
1685fn build_check_exists_sql(table: &str, pk: &[ColId], columns: &[ColumnDef]) -> String {
1686    format!(
1687        "SELECT 1 FROM {} WHERE {} LIMIT 1",
1688        ident(table),
1689        pk_eq_clause(pk, columns)
1690    )
1691}
1692
1693/// `pk0 = ? AND pk1 = ? …` — the shared WHERE for delete/update/checkExists.
1694fn pk_eq_clause(pk: &[ColId], columns: &[ColumnDef]) -> String {
1695    pk.iter()
1696        .map(|&c| format!("{} = ?", ident(&columns[c].name)))
1697        .collect::<Vec<_>>()
1698        .join(" AND ")
1699}
1700
1701/// The in-engine sort tiebreak *bypass decision* (design 204). Correctness holds on
1702/// both paths, so these assert the perf heuristic itself — that the adapter engages
1703/// for the rowid+non-rowid-PK shape and bypasses everywhere SQL can already stream the
1704/// full `(prefix, pk)` order. Without these, a silently-bypassing regression would
1705/// still pass every differential correctness test.
1706#[cfg(test)]
1707mod tiebreak_decision {
1708    use super::*;
1709
1710    fn cols_k_ord() -> Vec<ColumnDef> {
1711        vec![
1712            ColumnDef {
1713                name: "k".into(),
1714                ty: ValueType::String,
1715                optional: false,
1716            },
1717            ColumnDef {
1718                name: "ord".into(),
1719                ty: ValueType::Number,
1720                optional: false,
1721            },
1722        ]
1723    }
1724
1725    /// Build a `TableSource` over table `t` from `ddl`.
1726    fn source(ddl: &str, cols: Vec<ColumnDef>, pk: Vec<ColId>) -> TableSource {
1727        let conn = Rc::new(rusqlite::Connection::open_in_memory().unwrap());
1728        conn.execute_batch(ddl).unwrap();
1729        TableSource::new(conn, "t", cols, pk)
1730    }
1731
1732    /// The engine's resolved order for `ORDER BY ord`: `(ord=1 asc, appended pk=0 asc)`.
1733    fn ord_then_pk() -> Sort {
1734        vec![(1, true), (0, true)]
1735    }
1736
1737    #[test]
1738    fn engages_for_rowid_text_pk_with_prefix_index() {
1739        let ts = source(
1740            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1741             CREATE INDEX ix ON t (ord);",
1742            cols_k_ord(),
1743            vec![0],
1744        );
1745        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), Some(1));
1746    }
1747
1748    #[test]
1749    fn bypasses_rowid_alias_integer_pk() {
1750        // `id INTEGER PRIMARY KEY` is the rowid; an (ord) index is (ord, rowid) = (ord, pk).
1751        let cols = vec![
1752            ColumnDef {
1753                name: "id".into(),
1754                ty: ValueType::Number,
1755                optional: false,
1756            },
1757            ColumnDef {
1758                name: "ord".into(),
1759                ty: ValueType::Number,
1760                optional: false,
1761            },
1762        ];
1763        let ts = source(
1764            "CREATE TABLE t (id INTEGER PRIMARY KEY, ord INTEGER NOT NULL);
1765             CREATE INDEX ix ON t (ord);",
1766            cols,
1767            vec![0],
1768        );
1769        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), None);
1770    }
1771
1772    #[test]
1773    fn bypasses_without_rowid() {
1774        let ts = source(
1775            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL) WITHOUT ROWID;
1776             CREATE INDEX ix ON t (ord);",
1777            cols_k_ord(),
1778            vec![0],
1779        );
1780        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), None);
1781    }
1782
1783    #[test]
1784    fn bypasses_when_covering_index_exists() {
1785        // The good-citizen user who followed R3 and added the suffixed (ord, k) index.
1786        let ts = source(
1787            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1788             CREATE INDEX ix ON t (ord, k);",
1789            cols_k_ord(),
1790            vec![0],
1791        );
1792        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), None);
1793    }
1794
1795    #[test]
1796    fn bypasses_without_prefix_index() {
1797        // No (ord) index: SQL temp-b-trees either way, so the adapter would only buffer.
1798        let ts = source(
1799            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);",
1800            cols_k_ord(),
1801            vec![0],
1802        );
1803        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), None);
1804    }
1805
1806    #[test]
1807    fn bypasses_pure_pk_order() {
1808        // `ORDER BY k` (the pk): nothing is appended, so there is no tiebreak to relocate.
1809        let ts = source(
1810            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1811             CREATE INDEX ix ON t (ord);",
1812            cols_k_ord(),
1813            vec![0],
1814        );
1815        let pk_only: Sort = vec![(0, true)];
1816        assert_eq!(ts.meta.tiebreak_prefix_len(&pk_only), None);
1817    }
1818
1819    // -- the sqlite_stat1 large-run bypass gate (design 204 §"Costs / risks") --
1820
1821    /// `stat1.stat` is `"<nRows> <k1> <k2> …"`; the run estimate for a length-`L`
1822    /// prefix is `parts[L]` (avg rows sharing the first `L` index columns).
1823    #[test]
1824    fn stat1_prefix_run_parsing() {
1825        assert_eq!(stat1_prefix_run("1000 500", 1), Some(500));
1826        assert_eq!(stat1_prefix_run("1000 500 2", 1), Some(500));
1827        assert_eq!(stat1_prefix_run("1000 500 2", 2), Some(2)); // composite prefix
1828        assert_eq!(stat1_prefix_run("1000 500", 2), None); // stat too short
1829        assert_eq!(stat1_prefix_run("", 1), None);
1830        assert_eq!(stat1_prefix_run("1000 x", 1), None); // malformed
1831    }
1832
1833    /// Build a rowid+TEXT-pk table with an unsuffixed `(ord)` index and bulk-load
1834    /// `rows` rows whose `ord` is `ord_expr(n)` (a SQL expression over the row number).
1835    fn analyzed_kv(rows: u32, ord_expr: &str) -> TableSource {
1836        let conn = Rc::new(rusqlite::Connection::open_in_memory().unwrap());
1837        conn.execute_batch(
1838            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1839             CREATE INDEX ix ON t (ord);",
1840        )
1841        .unwrap();
1842        let ts = TableSource::new(conn.clone(), "t", cols_k_ord(), vec![0]);
1843        conn.execute_batch(&format!(
1844            "INSERT INTO t(k, ord)
1845             WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < {rows})
1846             SELECT 'k' || n, {ord_expr} FROM c;
1847             ANALYZE;"
1848        ))
1849        .unwrap();
1850        ts
1851    }
1852
1853    #[test]
1854    fn gate_bypasses_low_cardinality_after_analyze() {
1855        // One distinct `ord` over 5000 rows ⇒ a single ~5000-row run, above the cap:
1856        // the structural verdict engages, but the stat gate bypasses.
1857        let ts = analyzed_kv(5000, "1");
1858        assert_eq!(ts.meta.tiebreak_prefix_len(&ord_then_pk()), Some(1));
1859        assert!(
1860            !ts.prefix_run_within_cap(&ord_then_pk(), 1),
1861            "a low-cardinality run above the cap must bypass"
1862        );
1863    }
1864
1865    #[test]
1866    fn gate_engages_high_cardinality_after_analyze() {
1867        // Distinct `ord` per row ⇒ runs of ~1: engage.
1868        let ts = analyzed_kv(5000, "n");
1869        assert!(ts.prefix_run_within_cap(&ord_then_pk(), 1));
1870    }
1871
1872    #[test]
1873    fn gate_engages_when_unanalyzed() {
1874        // No ANALYZE ⇒ no sqlite_stat1 row ⇒ engage (today's behavior; ANALYZE is what
1875        // makes the bypass fire for the tables where it matters).
1876        let ts = source(
1877            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1878             CREATE INDEX ix ON t (ord);",
1879            cols_k_ord(),
1880            vec![0],
1881        );
1882        assert!(ts.prefix_run_within_cap(&ord_then_pk(), 1));
1883    }
1884
1885    /// The unconstrained guard (design 204 review): a fetch carrying a constraint /
1886    /// multiConstraint bypasses even where the structural + stat verdict would engage,
1887    /// so a join child's FK-equality fetch can't be steered onto a bad plan.
1888    #[test]
1889    fn bypasses_constrained_fetch() {
1890        let ts = source(
1891            "CREATE TABLE t (k TEXT NOT NULL PRIMARY KEY, ord INTEGER NOT NULL);
1892             CREATE INDEX ix ON t (ord);",
1893            cols_k_ord(),
1894            vec![0],
1895        );
1896        let sort = ord_then_pk();
1897
1898        // Unconstrained ordered scan (un-ANALYZE'd ⇒ stat gate engages): the win.
1899        assert_eq!(
1900            ts.tiebreak_engage(&FetchRequest::all(), &sort, false),
1901            Some(1)
1902        );
1903
1904        // A single constraint (a join child's FK equality): bypass.
1905        let constrained = FetchRequest::with_constraint(vec![(1, OwnedValue::Float(1.0))]);
1906        assert_eq!(ts.tiebreak_engage(&constrained, &sort, false), None);
1907
1908        // A non-empty multiConstraint (FlippedJoin IN-batch): bypass.
1909        let multi = FetchRequest {
1910            multi_constraints: vec![vec![vec![(1, OwnedValue::Float(1.0))]]],
1911            ..Default::default()
1912        };
1913        assert_eq!(ts.tiebreak_engage(&multi, &sort, false), None);
1914
1915        // An *empty* multiConstraint entry is not a constraint (`has_multi` false): engage.
1916        let empty_multi = FetchRequest {
1917            multi_constraints: vec![vec![]],
1918            ..Default::default()
1919        };
1920        assert_eq!(ts.tiebreak_engage(&empty_multi, &sort, false), Some(1));
1921
1922        // Unordered bypasses regardless.
1923        assert_eq!(ts.tiebreak_engage(&FetchRequest::all(), &sort, true), None);
1924    }
1925}
1926
1927/// The leaf's reported schema carries the declared column types (design 226 §4.1,
1928/// Stage C1): `TableSource`'s default reported [`Schema`] derives `column_types`
1929/// from its `ColumnDef`s, so the builder's `resolve` seam sees the same types the
1930/// SQLite value boundary enforces. (The `new_with_schema` path instead trusts the
1931/// caller's `SourceSchema` — the replica installs its discovered types there.)
1932#[cfg(test)]
1933mod reported_schema_types {
1934    use super::*;
1935
1936    #[test]
1937    fn default_reported_schema_derives_types_from_column_defs() {
1938        let conn = Rc::new(rusqlite::Connection::open_in_memory().unwrap());
1939        conn.execute_batch(
1940            "CREATE TABLE t (id INTEGER NOT NULL PRIMARY KEY, title TEXT, open BOOLEAN, meta JSON);",
1941        )
1942        .unwrap();
1943        let cols = vec![
1944            ColumnDef {
1945                name: "id".into(),
1946                ty: ValueType::Number,
1947                optional: false,
1948            },
1949            ColumnDef {
1950                name: "title".into(),
1951                ty: ValueType::String,
1952                optional: true,
1953            },
1954            ColumnDef {
1955                name: "open".into(),
1956                ty: ValueType::Boolean,
1957                optional: true,
1958            },
1959            ColumnDef {
1960                name: "meta".into(),
1961                ty: ValueType::Json,
1962                optional: true,
1963            },
1964        ];
1965        let ts = TableSource::new(conn, "t", cols, vec![0]);
1966        assert_eq!(
1967            ts.meta.schema.column_types,
1968            vec![
1969                ValueType::Number,
1970                ValueType::String,
1971                ValueType::Boolean,
1972                ValueType::Json,
1973            ]
1974        );
1975    }
1976}