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(¶ms))
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(¶ms))
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(¶ms))
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(¶ms))
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(¶ms))
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}