Skip to main content

fsqlite_core/
compat_persist.rs

1//! Compat persistence: read/write real SQLite-format database files.
2//!
3//! Bridges the in-memory `MemDatabase` to on-disk SQLite files via the
4//! pager + B-tree stack. The VDBE continues to execute against `MemDatabase`;
5//! this module serializes/deserializes that state to proper binary format.
6//!
7//! On **persist**, all tables and their rows are written to a real SQLite
8//! database file (with a valid header, sqlite_master, and B-tree pages).
9//!
10//! On **load**, a real `.db` file is read via B-tree cursors and its
11//! contents are replayed into a fresh `MemDatabase` + schema vector.
12
13#![cfg_attr(
14    any(target_arch = "wasm32", not(feature = "native")),
15    allow(dead_code, unused_imports)
16)]
17
18use std::collections::{HashMap, HashSet};
19#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
20use std::hash::BuildHasher;
21#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
22use std::path::Path;
23use std::sync::Arc;
24
25use fsqlite_ast::{
26    ColumnConstraintKind, CreateTableBody, CreateTableStatement, DefaultValue, Expr,
27    GeneratedStorage, Literal, SortDirection, Statement, TableConstraintKind, TriggerTiming,
28    UnaryOp,
29};
30#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
31use fsqlite_btree::BtreeCursorOps;
32#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
33use fsqlite_btree::cursor::TransactionPageIo;
34use fsqlite_error::{FrankenError, Result};
35#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
36use fsqlite_func::collation::{CollationFunction, CollationRegistry};
37#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
38use fsqlite_pager::{MvccPager, SimplePager, TransactionHandle, TransactionMode};
39use fsqlite_parser::Parser;
40use fsqlite_types::StrictColumnType;
41#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
42use fsqlite_types::cx::Cx;
43#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
44use fsqlite_types::record::{
45    RecordProfileScope, enter_record_profile_scope, parse_record,
46    serialize_record_with_encoding,
47};
48use fsqlite_types::value::SqliteValue;
49
50use crate::connection::{
51    ImplicitAutoindexSlot, codegen_error_to_franken, collect_primary_key_desc_flags,
52    column_def_is_exact_integer, implicit_autoindex_layout,
53    validate_builtin_persisted_index_expr_functions,
54};
55#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
56use crate::connection::{eval_join_expr, is_sqlite_truthy};
57use fsqlite_types::{
58    DATABASE_HEADER_SIZE, DatabaseHeader, PageNumber, PageSize, without_rowid_storage_order,
59};
60use fsqlite_vdbe::codegen::{
61    CheckConstraint, ColumnInfo, FkActionType, FkDef, IndexSchema, TableSchema,
62    bind_explicit_index, without_rowid_pk_indices,
63};
64use fsqlite_vdbe::engine::MemDatabase;
65#[cfg(all(not(target_arch = "wasm32"), feature = "native", unix))]
66use fsqlite_vfs::UnixVfs as PlatformVfs;
67#[cfg(all(not(target_arch = "wasm32"), feature = "native", target_os = "windows"))]
68use fsqlite_vfs::WindowsVfs as PlatformVfs;
69#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
70use fsqlite_vfs::{FileIdentity, host_fs};
71
72/// SQLite file header magic bytes (first 16 bytes).
73#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
74const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
75
76/// Default page size used for newly-created databases.
77#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
78const DEFAULT_PAGE_SIZE: PageSize = PageSize::DEFAULT;
79
80/// Owned sqlite_master row payload used when persistence must preserve
81/// non-table entries such as views and triggers during file rebuilds.
82pub type SqliteMasterEntry = (String, String, String, u32, Option<String>);
83
84/// GH#347: resolve each index-key column's collation name to its comparator
85/// once, up front, so the bulk-load sort below can call it without re-locking
86/// the registry per comparison. `None` (no `COLLATE`) and an unresolved name
87/// both leave the slot empty, which makes the sort fall back to the plain
88/// SQLite value ordering — exactly what the cursor's comparator does.
89#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
90fn resolve_index_collations(
91    collations: &[Option<String>],
92    registry: &Arc<std::sync::Mutex<CollationRegistry>>,
93) -> Vec<Option<Arc<dyn CollationFunction>>> {
94    let guard = registry
95        .lock()
96        .unwrap_or_else(std::sync::PoisonError::into_inner);
97    collations
98        .iter()
99        .map(|coll| coll.as_deref().and_then(|name| guard.find(name)))
100        .collect()
101}
102
103/// GH#347: order two serialized-record key tuples the way the b-tree's own
104/// index comparator does, so a bulk rebuild can insert them in ascending
105/// b-tree order (dense sequential appends) instead of random rowid order
106/// (sparse pages + a freelist trunk).
107///
108/// This is a faithful reconstruction of `BtCursor::compare_index_key_values`
109/// (private to `fsqlite-btree`): per-column SQLite value ordering, replaced by
110/// a resolved collation only when *both* operands are TEXT, then reversed for
111/// DESC columns, with the first non-equal column deciding and ties falling
112/// through to key arity. `SqliteValue`'s `Ord` never yields "incomparable", so
113/// the cursor's value path never falls back to raw bytes and this mirror is
114/// exact. `index_insert` re-seeks on every insert, so even a divergent order
115/// could only change page layout, never corrupt the index; matching the
116/// comparator is what makes the rebuilt b-tree dense.
117#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
118fn compare_rebuilt_index_key(
119    lhs: &[SqliteValue],
120    rhs: &[SqliteValue],
121    desc_flags: &[bool],
122    collations: &[Option<Arc<dyn CollationFunction>>],
123) -> std::cmp::Ordering {
124    use std::cmp::Ordering;
125    let shared = lhs.len().min(rhs.len());
126    for idx in 0..shared {
127        let mut ord = match (
128            collations.get(idx).and_then(|slot| slot.as_ref()),
129            &lhs[idx],
130            &rhs[idx],
131        ) {
132            (Some(coll), SqliteValue::Text(left), SqliteValue::Text(right)) => {
133                coll.compare(left.as_bytes(), right.as_bytes())
134            }
135            _ => lhs[idx].cmp(&rhs[idx]),
136        };
137        if desc_flags.get(idx).copied().unwrap_or(false) {
138            ord = ord.reverse();
139        }
140        if ord != Ordering::Equal {
141            return ord;
142        }
143    }
144    lhs.len().cmp(&rhs.len())
145}
146
147/// Select the SQL text persisted for an index entry in `sqlite_master`.
148///
149/// A stored, non-NULL SQL definition is authoritative: it identifies an
150/// explicit index even when a legacy database gave that index a reserved
151/// `sqlite_autoindex_*`-looking name. Only an index without preserved DDL whose
152/// name canonically maps to its table and a positive decimal ordinal may be
153/// classified as an implicit autoindex and serialized with NULL SQL.
154#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
155fn index_sql_for_persistence<S, F>(
156    index_name: &str,
157    table_name: &str,
158    original_ddl: &HashMap<String, String, S>,
159    synthesize: F,
160) -> Option<String>
161where
162    S: BuildHasher,
163    F: FnOnce() -> String,
164{
165    if let Some(original) = original_ddl.get(&index_name.to_ascii_lowercase()) {
166        return Some(original.clone());
167    }
168    if parse_autoindex_ordinal(index_name, table_name).is_some() {
169        return None;
170    }
171    Some(synthesize())
172}
173
174fn parse_autoindex_ordinal(index_name: &str, table_name: &str) -> Option<usize> {
175    let index_name_lower = index_name.to_ascii_lowercase();
176    let prefix = format!("sqlite_autoindex_{}_", table_name.to_ascii_lowercase());
177    let suffix = index_name_lower.strip_prefix(&prefix)?;
178    if suffix.is_empty() || suffix.starts_with('0') || !suffix.chars().all(|ch| ch.is_ascii_digit())
179    {
180        return None;
181    }
182    suffix.parse::<usize>().ok()
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub(crate) enum BoundImplicitAutoindexStorage {
187    /// The logical WITHOUT ROWID primary-key index is stored in the table
188    /// B-tree itself and therefore has no separate sqlite_master row.
189    TableRoot,
190    /// A physical implicit index backed by its own B-tree root.
191    IndexRoot(i32),
192}
193
194#[derive(Debug, Clone)]
195pub(crate) struct BoundImplicitAutoindexSlot {
196    ordinal: usize,
197    slot: ImplicitAutoindexSlot,
198    storage: BoundImplicitAutoindexStorage,
199}
200
201#[derive(Debug, Clone)]
202pub(crate) struct BoundTableAutoindexes {
203    slots: Vec<BoundImplicitAutoindexSlot>,
204}
205
206impl BoundTableAutoindexes {
207    pub(crate) fn implicit_slots(&self) -> impl Iterator<Item = &ImplicitAutoindexSlot> {
208        self.slots.iter().map(|bound| &bound.slot)
209    }
210
211    pub(crate) fn physical_index_schemas(&self, table_name: &str) -> Vec<IndexSchema> {
212        self.slots
213            .iter()
214            .filter_map(|bound| match bound.storage {
215                BoundImplicitAutoindexStorage::TableRoot => None,
216                BoundImplicitAutoindexStorage::IndexRoot(root_page) => Some(
217                    bound
218                        .slot
219                        .clone()
220                        .into_index_schema(table_name, bound.ordinal, root_page),
221                ),
222            })
223            .collect()
224    }
225}
226
227#[derive(Debug, Clone)]
228pub(crate) struct BoundImplicitAutoindexCatalog {
229    by_table: HashMap<String, BoundTableAutoindexes>,
230    canonical_virtual_table_rows: HashSet<usize>,
231}
232
233impl BoundImplicitAutoindexCatalog {
234    pub(crate) fn table(&self, table_name: &str) -> Option<&BoundTableAutoindexes> {
235        self.by_table.get(&table_name.to_ascii_lowercase())
236    }
237
238    pub(crate) fn is_canonical_virtual_table_row(&self, row_index: usize) -> bool {
239        self.canonical_virtual_table_rows.contains(&row_index)
240    }
241}
242
243#[derive(Debug, Clone, Copy)]
244struct DecodedSqliteMasterEntry<'a> {
245    entry_type: &'a str,
246    name: &'a str,
247    table_name: &'a str,
248    root_page: i64,
249    sql: Option<&'a str>,
250}
251
252#[derive(Debug)]
253struct PendingTableAutoindexes {
254    table_name: String,
255    slots: Vec<ImplicitAutoindexSlot>,
256    physical_roots: Vec<Option<i32>>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260enum SqliteMasterSchemaNameKind {
261    Table,
262    VirtualTable,
263    View,
264    Index,
265}
266
267impl SqliteMasterSchemaNameKind {
268    const fn label(self) -> &'static str {
269        match self {
270            Self::Table => "table",
271            Self::VirtualTable => "virtual table",
272            Self::View => "view",
273            Self::Index => "index",
274        }
275    }
276}
277
278#[derive(Debug)]
279struct SqliteMasterSchemaNameOwner {
280    name: String,
281    kind: SqliteMasterSchemaNameKind,
282}
283
284#[derive(Debug)]
285struct VirtualTableCatalogVariant {
286    row_index: usize,
287    name: String,
288    root_page: i64,
289    module: String,
290    args: Vec<String>,
291}
292
293fn sqlite_master_corrupt(detail: impl Into<String>) -> FrankenError {
294    FrankenError::DatabaseCorrupt {
295        detail: detail.into(),
296    }
297}
298
299fn qualified_catalog_name_targets_main(schema: Option<&str>) -> bool {
300    schema.is_none_or(|schema| schema.eq_ignore_ascii_case("main"))
301}
302
303fn claim_sqlite_master_schema_name(
304    name: &str,
305    kind: SqliteMasterSchemaNameKind,
306    schema_names: &mut HashMap<String, SqliteMasterSchemaNameOwner>,
307) -> Result<()> {
308    let key = name.to_ascii_lowercase();
309    if let Some(existing) = schema_names.get(&key) {
310        return Err(sqlite_master_corrupt(format!(
311            "sqlite_master schema name `{name}` is shared by {} `{}` and {} `{name}`",
312            existing.kind.label(),
313            existing.name,
314            kind.label()
315        )));
316    }
317    schema_names.insert(
318        key,
319        SqliteMasterSchemaNameOwner {
320            name: name.to_owned(),
321            kind,
322        },
323    );
324    Ok(())
325}
326
327fn normalize_virtual_table_option_token(token: &str) -> String {
328    let trimmed = token.trim();
329    if trimmed.len() >= 2 {
330        let bytes = trimmed.as_bytes();
331        let first = bytes[0];
332        let last = bytes[bytes.len() - 1];
333        if matches!((first, last), (b'\'', b'\'') | (b'"', b'"') | (b'`', b'`')) {
334            let body = &trimmed[1..trimmed.len() - 1];
335            let quote = char::from(first);
336            return body
337                .replace(&format!("{quote}{quote}"), &quote.to_string())
338                .to_ascii_lowercase();
339        }
340        if first == b'[' && last == b']' {
341            return trimmed[1..trimmed.len() - 1].to_ascii_lowercase();
342        }
343    }
344    trimmed.to_ascii_lowercase()
345}
346
347fn fts5_content_variant(args: &[String]) -> Option<(Option<String>, Vec<&str>)> {
348    let mut content = None;
349    let mut non_content = Vec::with_capacity(args.len());
350    for arg in args {
351        let is_content_option = arg
352            .split_once('=')
353            .is_some_and(|(key, _)| normalize_virtual_table_option_token(key) == "content");
354        if is_content_option {
355            let (_, value) = arg
356                .split_once('=')
357                .expect("content option was identified by an equals sign");
358            if content
359                .replace(normalize_virtual_table_option_token(value))
360                .is_some()
361            {
362                return None;
363            }
364        } else {
365            non_content.push(arg.trim());
366        }
367    }
368    Some((content, non_content))
369}
370
371fn supported_virtual_table_duplicate(
372    existing: &VirtualTableCatalogVariant,
373    root_page: i64,
374    module: &str,
375    args: &[String],
376) -> bool {
377    // Two positive roots are rejected by the caller. An otherwise identical
378    // positive/root-zero pair is the legacy materialized-vtab migration shape;
379    // two root-zero rows are accepted only for FTS5's repair path.
380    if existing.module.eq_ignore_ascii_case(module) && existing.args == args {
381        return existing.root_page > 0 || root_page > 0 || module.eq_ignore_ascii_case("fts5");
382    }
383    if existing.root_page != 0
384        || root_page != 0
385        || !existing.module.eq_ignore_ascii_case("fts5")
386        || !module.eq_ignore_ascii_case("fts5")
387    {
388        return false;
389    }
390    // A historical FTS5 repair can leave the authoritative contentless row
391    // beside a stale default-content declaration. The non-content arguments
392    // must still be identical, and no third catalog row is accepted.
393    let Some((existing_content, existing_non_content)) = fts5_content_variant(&existing.args)
394    else {
395        return false;
396    };
397    let Some((candidate_content, candidate_non_content)) = fts5_content_variant(args) else {
398        return false;
399    };
400    existing_non_content == candidate_non_content
401        && matches!(
402            (existing_content.as_deref(), candidate_content.as_deref()),
403            (None, Some("")) | (Some(""), None)
404        )
405}
406
407fn canonical_virtual_table_variant(
408    variants: &[VirtualTableCatalogVariant],
409) -> &VirtualTableCatalogVariant {
410    variants
411        .iter()
412        .find(|variant| variant.root_page > 0)
413        .or_else(|| {
414            variants.iter().find(|variant| {
415                variant.module.eq_ignore_ascii_case("fts5")
416                    && fts5_content_variant(&variant.args)
417                        .is_some_and(|(content, _)| content.as_deref() == Some(""))
418            })
419        })
420        .unwrap_or_else(|| {
421            variants
422                .first()
423                .expect("validated virtual-table variant set is non-empty")
424        })
425}
426
427fn claim_sqlite_master_virtual_table(
428    row_index: usize,
429    name: &str,
430    root_page: i64,
431    module: &str,
432    args: &[String],
433    schema_names: &mut HashMap<String, SqliteMasterSchemaNameOwner>,
434    variants: &mut HashMap<String, Vec<VirtualTableCatalogVariant>>,
435) -> Result<()> {
436    let key = name.to_ascii_lowercase();
437    if let Some(existing_variants) = variants.get_mut(&key) {
438        let Some(existing) = existing_variants.first() else {
439            unreachable!("virtual-table variant map entries are never empty");
440        };
441        if existing_variants.len() != 1
442            || (existing.root_page > 0 && root_page > 0)
443            || !supported_virtual_table_duplicate(existing, root_page, module, args)
444        {
445            return Err(sqlite_master_corrupt(format!(
446                "sqlite_master contains conflicting virtual-table entries for `{}` and `{name}`",
447                existing.name
448            )));
449        }
450        existing_variants.push(VirtualTableCatalogVariant {
451            row_index,
452            name: name.to_owned(),
453            root_page,
454            module: module.to_owned(),
455            args: args.to_vec(),
456        });
457        return Ok(());
458    }
459
460    claim_sqlite_master_schema_name(name, SqliteMasterSchemaNameKind::VirtualTable, schema_names)?;
461    variants.insert(
462        key,
463        vec![VirtualTableCatalogVariant {
464            row_index,
465            name: name.to_owned(),
466            root_page,
467            module: module.to_owned(),
468            args: args.to_vec(),
469        }],
470    );
471    Ok(())
472}
473
474fn decode_sqlite_master_entry(
475    entry: &[SqliteValue],
476    row_index: usize,
477) -> Result<DecodedSqliteMasterEntry<'_>> {
478    if entry.len() != 5 {
479        return Err(sqlite_master_corrupt(format!(
480            "sqlite_master row {} has {} columns instead of 5",
481            row_index + 1,
482            entry.len()
483        )));
484    }
485    let text_column = |column_index: usize, column_name: &str| match &entry[column_index] {
486        SqliteValue::Text(value) => Ok(value.as_ref()),
487        value => Err(sqlite_master_corrupt(format!(
488            "sqlite_master row {} column `{column_name}` must be TEXT, found {value:?}",
489            row_index + 1
490        ))),
491    };
492    let entry_type = text_column(0, "type")?;
493    let name = text_column(1, "name")?;
494    let table_name = text_column(2, "tbl_name")?;
495    let root_page = match &entry[3] {
496        SqliteValue::Integer(value) => *value,
497        value => {
498            return Err(sqlite_master_corrupt(format!(
499                "sqlite_master row {} column `rootpage` must be INTEGER, found {value:?}",
500                row_index + 1
501            )));
502        }
503    };
504    let sql = match &entry[4] {
505        SqliteValue::Text(value) => Some(value.as_ref()),
506        SqliteValue::Null => None,
507        value => {
508            return Err(sqlite_master_corrupt(format!(
509                "sqlite_master row {} column `sql` must be TEXT or NULL, found {value:?}",
510                row_index + 1
511            )));
512        }
513    };
514    Ok(DecodedSqliteMasterEntry {
515        entry_type,
516        name,
517        table_name,
518        root_page,
519        sql,
520    })
521}
522
523fn claim_sqlite_master_root(
524    entry_kind: &str,
525    entry_name: &str,
526    root_page: i64,
527    max_root_page: u32,
528    header: &DatabaseHeader,
529    free_pages: &HashSet<PageNumber>,
530    root_owners: &mut HashMap<i32, String>,
531) -> Result<i32> {
532    let root_page_u32 = validate_sqlite_master_root_page(entry_name, root_page)?;
533    let root_page_i32 = i32::try_from(root_page_u32).map_err(|_| {
534        sqlite_master_corrupt(format!(
535            "sqlite_master {entry_kind} `{entry_name}` has unsupported rootpage {root_page}"
536        ))
537    })?;
538    if root_page_i32 >= i32::MAX - 1 {
539        return Err(sqlite_master_corrupt(format!(
540            "sqlite_master {entry_kind} `{entry_name}` has terminal rootpage {root_page}, which leaves no safe MemDatabase allocation sentinel"
541        )));
542    }
543    if root_page_u32 > max_root_page {
544        return Err(sqlite_master_corrupt(format!(
545            "sqlite_master {entry_kind} `{entry_name}` has rootpage {root_page}, which exceeds the visible database page count {max_root_page}"
546        )));
547    }
548    if root_page_u32 == fsqlite_pager::lock_byte_page(header.page_size) {
549        return Err(sqlite_master_corrupt(format!(
550            "sqlite_master {entry_kind} `{entry_name}` uses reserved lock-byte rootpage {root_page}"
551        )));
552    }
553    let root_page_number =
554        PageNumber::new(root_page_u32).expect("validated positive rootpage must be nonzero");
555    if header.largest_root_page != 0
556        && fsqlite_btree::freelist::is_ptrmap_page(
557            root_page_number,
558            header.page_size.usable(header.reserved_per_page),
559            header.page_size.get(),
560        )
561    {
562        return Err(sqlite_master_corrupt(format!(
563            "sqlite_master {entry_kind} `{entry_name}` uses auto-vacuum pointer-map rootpage {root_page}"
564        )));
565    }
566    if free_pages.contains(&root_page_number) {
567        return Err(sqlite_master_corrupt(format!(
568            "sqlite_master {entry_kind} `{entry_name}` uses free rootpage {root_page}"
569        )));
570    }
571    let owner = format!("{entry_kind} `{entry_name}`");
572    if let Some(existing_owner) = root_owners.insert(root_page_i32, owner.clone()) {
573        return Err(sqlite_master_corrupt(format!(
574            "sqlite_master rootpage {root_page_i32} is shared by {existing_owner} and {owner}"
575        )));
576    }
577    Ok(root_page_i32)
578}
579
580/// Validate and bind every implicit autoindex row in a sqlite_master snapshot.
581///
582/// This is deliberately a global, failure-atomic prepass. Both schema reload
583/// paths must prove the complete expected implicit-index set and root ownership
584/// before either mutates `MemDatabase`, because `create_table_at` replaces an
585/// existing root on collision.
586pub(crate) fn bind_implicit_autoindex_catalog(
587    master_entries: &[Vec<SqliteValue>],
588    max_root_page: u32,
589    header: &DatabaseHeader,
590    free_pages: &HashSet<PageNumber>,
591) -> Result<BoundImplicitAutoindexCatalog> {
592    if max_root_page == 0 {
593        return Err(sqlite_master_corrupt(
594            "sqlite_master cannot be bound without a visible database page",
595        ));
596    }
597    let decoded = master_entries
598        .iter()
599        .enumerate()
600        .map(|(row_index, entry)| decode_sqlite_master_entry(entry, row_index))
601        .collect::<Result<Vec<_>>>()?;
602    let mut root_owners = HashMap::new();
603    root_owners.insert(1, "sqlite_master".to_owned());
604    let mut pending_by_table = HashMap::<String, PendingTableAutoindexes>::new();
605    let mut schema_names = HashMap::<String, SqliteMasterSchemaNameOwner>::new();
606    let mut trigger_names = HashMap::<String, String>::new();
607    let mut virtual_table_variants = HashMap::<String, Vec<VirtualTableCatalogVariant>>::new();
608    let mut logical_autoindex_names = HashMap::<String, String>::new();
609    let mut pending_trigger_parents = Vec::<(String, String, bool)>::new();
610
611    // Claim every table root before any index root, independent of catalog row
612    // order, so an index can never replace a table placeholder during reload.
613    for (row_index, entry) in decoded.iter().enumerate() {
614        if entry.entry_type.eq_ignore_ascii_case("view") {
615            if entry.root_page != 0 || entry.sql.is_none() {
616                return Err(sqlite_master_corrupt(format!(
617                    "sqlite_master view `{}` must have rootpage 0 and non-NULL sql",
618                    entry.name
619                )));
620            }
621            if !entry.name.eq_ignore_ascii_case(entry.table_name) {
622                return Err(sqlite_master_corrupt(format!(
623                    "sqlite_master view `{}` has mismatched tbl_name `{}`",
624                    entry.name, entry.table_name
625                )));
626            }
627            let create_sql = entry.sql.expect("view sql was validated above");
628            let Some(Statement::CreateView(create)) = parse_single_statement(create_sql) else {
629                return Err(sqlite_master_corrupt(format!(
630                    "could not parse CREATE VIEW SQL for `{}`",
631                    entry.name
632                )));
633            };
634            if create.temporary
635                || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
636                || !create.name.name.eq_ignore_ascii_case(entry.name)
637            {
638                return Err(sqlite_master_corrupt(format!(
639                    "CREATE VIEW SQL for `{}` declares a temporary, non-main, or differently named view `{}`",
640                    entry.name, create.name.name
641                )));
642            }
643            claim_sqlite_master_schema_name(
644                entry.name,
645                SqliteMasterSchemaNameKind::View,
646                &mut schema_names,
647            )?;
648            continue;
649        }
650        if entry.entry_type.eq_ignore_ascii_case("trigger") {
651            if entry.root_page != 0 || entry.sql.is_none() {
652                return Err(sqlite_master_corrupt(format!(
653                    "sqlite_master trigger `{}` must have rootpage 0 and non-NULL sql",
654                    entry.name
655                )));
656            }
657            let create_sql = entry.sql.expect("trigger sql was validated above");
658            let Some(Statement::CreateTrigger(create)) = parse_single_statement(create_sql) else {
659                return Err(sqlite_master_corrupt(format!(
660                    "could not parse CREATE TRIGGER SQL for `{}`",
661                    entry.name
662                )));
663            };
664            if create.temporary
665                || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
666                || !create.name.name.eq_ignore_ascii_case(entry.name)
667                || !create.table.eq_ignore_ascii_case(entry.table_name)
668            {
669                return Err(sqlite_master_corrupt(format!(
670                    "CREATE TRIGGER SQL for `{}` does not match its main-catalog name or target `{}`",
671                    entry.name, entry.table_name
672                )));
673            }
674            let trigger_key = entry.name.to_ascii_lowercase();
675            if let Some(existing) = trigger_names.insert(trigger_key, entry.name.to_owned()) {
676                return Err(sqlite_master_corrupt(format!(
677                    "sqlite_master contains duplicate trigger entries for `{existing}` and `{}`",
678                    entry.name
679                )));
680            }
681            pending_trigger_parents.push((
682                entry.name.to_owned(),
683                entry.table_name.to_owned(),
684                matches!(create.timing, TriggerTiming::InsteadOf),
685            ));
686            continue;
687        }
688        if entry.entry_type.eq_ignore_ascii_case("index") {
689            continue;
690        }
691        if !entry.entry_type.eq_ignore_ascii_case("table") {
692            return Err(sqlite_master_corrupt(format!(
693                "sqlite_master entry `{}` has unsupported type `{}`",
694                entry.name, entry.entry_type
695            )));
696        }
697        let Some(create_sql) = entry.sql else {
698            return Err(sqlite_master_corrupt(format!(
699                "sqlite_master table `{}` has NULL sql",
700                entry.name
701            )));
702        };
703        if !entry.name.eq_ignore_ascii_case(entry.table_name) {
704            return Err(sqlite_master_corrupt(format!(
705                "sqlite_master table `{}` has mismatched tbl_name `{}`",
706                entry.name, entry.table_name
707            )));
708        }
709
710        if is_virtual_table_sql(create_sql) {
711            if entry.root_page < 0 {
712                return Err(sqlite_master_corrupt(format!(
713                    "sqlite_master virtual table `{}` has invalid rootpage {}",
714                    entry.name, entry.root_page
715                )));
716            }
717            let Some(Statement::CreateVirtualTable(create)) = parse_single_statement(create_sql)
718            else {
719                return Err(sqlite_master_corrupt(format!(
720                    "could not parse CREATE VIRTUAL TABLE SQL for `{}`",
721                    entry.name
722                )));
723            };
724            if !create.name.name.eq_ignore_ascii_case(entry.name) {
725                return Err(sqlite_master_corrupt(format!(
726                    "CREATE VIRTUAL TABLE SQL for `{}` declares `{}`",
727                    entry.name, create.name.name
728                )));
729            }
730            if !qualified_catalog_name_targets_main(create.name.schema.as_deref()) {
731                return Err(sqlite_master_corrupt(format!(
732                    "CREATE VIRTUAL TABLE SQL for `{}` targets a non-main schema",
733                    entry.name
734                )));
735            }
736            if entry.root_page > 0 {
737                claim_sqlite_master_root(
738                    "virtual table",
739                    entry.name,
740                    entry.root_page,
741                    max_root_page,
742                    header,
743                    free_pages,
744                    &mut root_owners,
745                )?;
746            }
747            claim_sqlite_master_virtual_table(
748                row_index,
749                entry.name,
750                entry.root_page,
751                &create.module,
752                &create.args,
753                &mut schema_names,
754                &mut virtual_table_variants,
755            )?;
756            continue;
757        }
758
759        claim_sqlite_master_root(
760            "table",
761            entry.name,
762            entry.root_page,
763            max_root_page,
764            header,
765            free_pages,
766            &mut root_owners,
767        )?;
768        let Some(Statement::CreateTable(create)) = parse_single_statement(create_sql) else {
769            return Err(sqlite_master_corrupt(format!(
770                "could not parse CREATE TABLE SQL for `{}`",
771                entry.name
772            )));
773        };
774        if create.temporary
775            || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
776            || !create.name.name.eq_ignore_ascii_case(entry.name)
777            || !create.name.name.eq_ignore_ascii_case(entry.table_name)
778        {
779            return Err(sqlite_master_corrupt(format!(
780                "CREATE TABLE SQL for `{}` declares a temporary, non-main, or differently named table `{}`",
781                entry.name, create.name.name
782            )));
783        }
784        let slots = match &create.body {
785            CreateTableBody::Columns {
786                columns,
787                constraints,
788            } => implicit_autoindex_layout(columns, constraints, create.without_rowid).map_err(
789                |error| {
790                    sqlite_master_corrupt(format!(
791                        "invalid implicit autoindex layout for table `{}`: {error}",
792                        entry.name
793                    ))
794                },
795            )?,
796            CreateTableBody::AsSelect(_) => {
797                return Err(sqlite_master_corrupt(format!(
798                    "sqlite_master table `{}` stores CREATE TABLE AS SELECT instead of a normalized column definition",
799                    entry.name
800                )));
801            }
802        };
803        let slot_count = slots.len();
804        for ordinal in 1..=slot_count {
805            let logical_name = format!("sqlite_autoindex_{}_{ordinal}", entry.name);
806            logical_autoindex_names.insert(logical_name.to_ascii_lowercase(), logical_name);
807        }
808        let key = entry.name.to_ascii_lowercase();
809        let pending = PendingTableAutoindexes {
810            table_name: entry.name.to_owned(),
811            slots,
812            physical_roots: vec![None; slot_count],
813        };
814        if let Some(existing) = pending_by_table.insert(key, pending) {
815            return Err(sqlite_master_corrupt(format!(
816                "sqlite_master contains duplicate table entries for `{}` and `{}`",
817                existing.table_name, entry.name
818            )));
819        }
820        claim_sqlite_master_schema_name(
821            entry.name,
822            SqliteMasterSchemaNameKind::Table,
823            &mut schema_names,
824        )?;
825    }
826
827    for (trigger_name, table_name, requires_view) in pending_trigger_parents {
828        let target = schema_names.get(&table_name.to_ascii_lowercase());
829        let target_is_view =
830            target.is_some_and(|owner| owner.kind == SqliteMasterSchemaNameKind::View);
831        let target_is_table = target.is_some_and(|owner| {
832            matches!(
833                owner.kind,
834                SqliteMasterSchemaNameKind::Table | SqliteMasterSchemaNameKind::VirtualTable
835            )
836        });
837        if (!requires_view && !target_is_table) || (requires_view && !target_is_view) {
838            let expected_kind = if requires_view { "view" } else { "table" };
839            return Err(sqlite_master_corrupt(format!(
840                "trigger `{trigger_name}` refers to missing or incompatible {expected_kind} `{table_name}`"
841            )));
842        }
843    }
844
845    let mut index_names = HashMap::<String, String>::new();
846    for entry in &decoded {
847        if !entry.entry_type.eq_ignore_ascii_case("index") {
848            continue;
849        }
850        let index_key = entry.name.to_ascii_lowercase();
851        let logical_autoindex_name = logical_autoindex_names.get(&index_key);
852        if let Some(existing_name) = index_names.insert(index_key, entry.name.to_owned()) {
853            return Err(sqlite_master_corrupt(format!(
854                "sqlite_master contains duplicate index entries for `{existing_name}` and `{}`",
855                entry.name
856            )));
857        }
858        claim_sqlite_master_schema_name(
859            entry.name,
860            SqliteMasterSchemaNameKind::Index,
861            &mut schema_names,
862        )?;
863        let root_page = claim_sqlite_master_root(
864            "index",
865            entry.name,
866            entry.root_page,
867            max_root_page,
868            header,
869            free_pages,
870            &mut root_owners,
871        )?;
872        let table_key = entry.table_name.to_ascii_lowercase();
873        if !pending_by_table.contains_key(&table_key) {
874            return Err(sqlite_master_corrupt(format!(
875                "index `{}` refers to missing ordinary table `{}`",
876                entry.name, entry.table_name
877            )));
878        }
879        // A stored CREATE INDEX statement is authoritative even when its name
880        // resembles SQLite's reserved autoindex naming convention, unless a
881        // real declaration slot (including a hidden WITHOUT ROWID PK slot)
882        // already owns that logical name.
883        if let Some(create_sql) = entry.sql {
884            if let Some(logical_name) = logical_autoindex_name {
885                return Err(sqlite_master_corrupt(format!(
886                    "explicit index `{}` collides with logical implicit index `{logical_name}`",
887                    entry.name
888                )));
889            }
890            let Some(Statement::CreateIndex(create)) = parse_single_statement(create_sql) else {
891                return Err(sqlite_master_corrupt(format!(
892                    "could not parse CREATE INDEX SQL for `{}`",
893                    entry.name
894                )));
895            };
896            if !create.name.name.eq_ignore_ascii_case(entry.name)
897                || !create.table.eq_ignore_ascii_case(entry.table_name)
898                || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
899            {
900                return Err(sqlite_master_corrupt(format!(
901                    "CREATE INDEX SQL for `{}` declares index `{}` on table `{}` instead of `{}`",
902                    entry.name, create.name.name, create.table, entry.table_name
903                )));
904            }
905            continue;
906        }
907
908        let Some(table) = pending_by_table.get_mut(&table_key) else {
909            unreachable!("ordinary index parent was validated above");
910        };
911        let Some(ordinal) = parse_autoindex_ordinal(entry.name, entry.table_name) else {
912            return Err(sqlite_master_corrupt(format!(
913                "implicit index `{}` does not have a canonical autoindex name for table `{}`",
914                entry.name, entry.table_name
915            )));
916        };
917        let Some(slot_index) = ordinal.checked_sub(1) else {
918            return Err(sqlite_master_corrupt(format!(
919                "implicit index `{}` has invalid ordinal {ordinal}",
920                entry.name
921            )));
922        };
923        let Some(slot) = table.slots.get(slot_index) else {
924            return Err(sqlite_master_corrupt(format!(
925                "implicit index `{}` selects nonexistent declaration slot {ordinal} on table `{}`",
926                entry.name, table.table_name
927            )));
928        };
929        if slot.is_hidden_without_rowid_primary_key() {
930            return Err(sqlite_master_corrupt(format!(
931                "implicit index `{}` illegally materializes hidden WITHOUT ROWID primary-key slot {ordinal}",
932                entry.name
933            )));
934        }
935        let root = table
936            .physical_roots
937            .get_mut(slot_index)
938            .expect("validated implicit autoindex slot must have a root binding");
939        if root.replace(root_page).is_some() {
940            return Err(sqlite_master_corrupt(format!(
941                "implicit autoindex slot {ordinal} on table `{}` is bound more than once",
942                table.table_name
943            )));
944        }
945    }
946
947    let mut by_table = HashMap::with_capacity(pending_by_table.len());
948    for (table_key, pending) in pending_by_table {
949        let mut bound_slots = Vec::with_capacity(pending.slots.len());
950        for (slot_index, slot) in pending.slots.into_iter().enumerate() {
951            let ordinal = slot_index + 1;
952            let storage = if slot.is_hidden_without_rowid_primary_key() {
953                BoundImplicitAutoindexStorage::TableRoot
954            } else {
955                let root_page = pending.physical_roots[slot_index].ok_or_else(|| {
956                    sqlite_master_corrupt(format!(
957                        "sqlite_master is missing implicit autoindex slot {ordinal} for table `{}`",
958                        pending.table_name
959                    ))
960                })?;
961                BoundImplicitAutoindexStorage::IndexRoot(root_page)
962            };
963            bound_slots.push(BoundImplicitAutoindexSlot {
964                ordinal,
965                slot,
966                storage,
967            });
968        }
969        by_table.insert(table_key, BoundTableAutoindexes { slots: bound_slots });
970    }
971
972    let canonical_virtual_table_rows = virtual_table_variants
973        .values()
974        .map(|variants| canonical_virtual_table_variant(variants).row_index)
975        .collect();
976
977    Ok(BoundImplicitAutoindexCatalog {
978        by_table,
979        canonical_virtual_table_rows,
980    })
981}
982
983#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
984fn load_sqlite_header_from_page1(page1_bytes: &[u8]) -> Result<DatabaseHeader> {
985    let header_bytes: &[u8; DATABASE_HEADER_SIZE] = page1_bytes
986        .get(..DATABASE_HEADER_SIZE)
987        .ok_or_else(|| FrankenError::DatabaseCorrupt {
988            detail: format!(
989                "database header truncated: expected at least {DATABASE_HEADER_SIZE} bytes, found {}",
990                page1_bytes.len()
991            ),
992        })?
993        .try_into()
994        .map_err(|_| FrankenError::DatabaseCorrupt {
995            detail: "database header is not a fixed-size 100-byte prefix".to_owned(),
996        })?;
997    DatabaseHeader::from_bytes(header_bytes).map_err(|error| match error {
998        // bd-3j2c5: a database written by a newer FrankenSQLite format is a
999        // dedicated refusal (SQLITE_OPEN_NEWER_FORMAT), not corruption.
1000        fsqlite_types::DatabaseHeaderError::NewerFormat { on_disk, supported } => {
1001            FrankenError::NewerFormat { on_disk, supported }
1002        }
1003        other => FrankenError::DatabaseCorrupt {
1004            detail: format!("invalid database header: {other}"),
1005        },
1006    })
1007}
1008
1009#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1010fn configure_btree_cursor_page_size<P: fsqlite_btree::PageReader>(
1011    cursor: &mut fsqlite_btree::BtCursor<P>,
1012    usable_size: u32,
1013    page_size: u32,
1014) {
1015    if page_size != usable_size {
1016        cursor.set_page_size(page_size);
1017    }
1018}
1019
1020// ── Public API ──────────────────────────────────────────────────────────
1021
1022/// State loaded from a real SQLite file.
1023#[derive(Debug)]
1024pub struct LoadedState {
1025    /// Reconstructed table schemas.
1026    pub schema: Vec<TableSchema>,
1027    /// In-memory database populated with all rows.
1028    pub db: MemDatabase,
1029    /// Number of sqlite_master entries loaded (the next available rowid
1030    /// for sqlite_master is `master_row_count + 1`).
1031    pub master_row_count: i64,
1032    /// Schema cookie read from the database header (offset 40).
1033    pub schema_cookie: u32,
1034    /// File change counter read from the database header (offset 24).
1035    pub change_counter: u32,
1036}
1037
1038/// Detect whether a file starts with the SQLite magic header.
1039///
1040/// Returns `false` for non-existent, empty, or non-SQLite files.
1041#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1042pub fn is_sqlite_format(path: &Path) -> bool {
1043    let Ok(data) = host_fs::read(path) else {
1044        return false;
1045    };
1046    data.len() >= SQLITE_MAGIC.len() && data[..SQLITE_MAGIC.len()] == *SQLITE_MAGIC
1047}
1048
1049/// Persist `schema` + `db` to a real SQLite-format database file at `path`.
1050///
1051/// Overwrites any existing file. The resulting file is readable by `sqlite3`.
1052/// The caller supplies the capability context so pager and B-tree work stay
1053/// attached to the active runtime lineage.
1054///
1055/// # Errors
1056///
1057/// Returns an error on I/O failure or if the B-tree layer rejects an
1058/// insertion (e.g. duplicate rowid in sqlite_master).
1059#[allow(clippy::too_many_lines)]
1060#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1061pub async fn persist_to_sqlite(
1062    cx: &Cx,
1063    path: &Path,
1064    schema: &[TableSchema],
1065    db: &MemDatabase,
1066    schema_cookie: u32,
1067    change_counter: u32,
1068) -> Result<()> {
1069    let mut header = DatabaseHeader {
1070        page_size: DEFAULT_PAGE_SIZE,
1071        schema_cookie,
1072        change_counter,
1073        ..DatabaseHeader::default()
1074    };
1075    let effective_counter = header.change_counter.max(1);
1076    header.change_counter = effective_counter;
1077    header.schema_cookie = header.schema_cookie.max(1);
1078    header.version_valid_for = effective_counter;
1079    persist_to_sqlite_with_header(cx, path, schema, db, &header).await
1080}
1081
1082/// Persist `schema` + `db` using the provided database header template.
1083///
1084/// The supplied `header` controls page-size-sensitive layout plus header
1085/// metadata that must survive rebuild flows like `VACUUM`.
1086#[allow(clippy::too_many_lines)]
1087#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1088pub async fn persist_to_sqlite_with_header(
1089    cx: &Cx,
1090    path: &Path,
1091    schema: &[TableSchema],
1092    db: &MemDatabase,
1093    header_template: &DatabaseHeader,
1094) -> Result<()> {
1095    persist_to_sqlite_with_header_and_master_entries(
1096        cx,
1097        path,
1098        schema,
1099        db,
1100        header_template,
1101        &[],
1102        &HashMap::new(),
1103    )
1104    .await
1105}
1106
1107/// Persist `schema` + `db` plus additional sqlite_master rows using the
1108/// provided database header template.
1109#[allow(clippy::too_many_lines)]
1110#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1111pub async fn persist_to_sqlite_with_header_and_master_entries<S: BuildHasher>(
1112    cx: &Cx,
1113    path: &Path,
1114    schema: &[TableSchema],
1115    db: &MemDatabase,
1116    header_template: &DatabaseHeader,
1117    extra_master_entries: &[SqliteMasterEntry],
1118    original_ddl: &HashMap<String, String, S>,
1119) -> Result<()> {
1120    persist_to_sqlite_with_header_and_master_entries_impl(
1121        cx,
1122        path,
1123        schema,
1124        db,
1125        header_template,
1126        extra_master_entries,
1127        original_ddl,
1128        None,
1129    )
1130    .await
1131}
1132
1133/// Persist into an atomically caller-reserved empty file.
1134///
1135/// The path is opened only through the pager's identity-bound `ReservedEmpty`
1136/// mode. A missing, replaced, non-empty, or sidecar-bearing reservation is
1137/// rejected before any database byte is initialized.
1138#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1139#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1140pub async fn persist_to_reserved_sqlite_with_header_and_master_entries<S: BuildHasher>(
1141    cx: &Cx,
1142    path: &Path,
1143    expected_identity: FileIdentity,
1144    schema: &[TableSchema],
1145    db: &MemDatabase,
1146    header_template: &DatabaseHeader,
1147    extra_master_entries: &[SqliteMasterEntry],
1148    original_ddl: &HashMap<String, String, S>,
1149) -> Result<()> {
1150    persist_to_sqlite_with_header_and_master_entries_impl(
1151        cx,
1152        path,
1153        schema,
1154        db,
1155        header_template,
1156        extra_master_entries,
1157        original_ddl,
1158        Some(expected_identity),
1159    )
1160    .await
1161}
1162
1163#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1164#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1165async fn persist_to_sqlite_with_header_and_master_entries_impl<S: BuildHasher>(
1166    cx: &Cx,
1167    path: &Path,
1168    schema: &[TableSchema],
1169    db: &MemDatabase,
1170    header_template: &DatabaseHeader,
1171    extra_master_entries: &[SqliteMasterEntry],
1172    original_ddl: &HashMap<String, String, S>,
1173    expected_empty_identity: Option<FileIdentity>,
1174) -> Result<()> {
1175    if expected_empty_identity.is_none() && path.exists() {
1176        // Legacy overwrite callers deliberately replace their own target.
1177        // Identity-sensitive exports must use the reserved entry point above.
1178        host_fs::create_empty_file(path)?;
1179    }
1180
1181    let vfs = PlatformVfs::new();
1182    let pager = if let Some(expected_identity) = expected_empty_identity {
1183        SimplePager::open_reserved_with_cx_and_page_buffer_max(
1184            cx,
1185            vfs,
1186            path,
1187            header_template.page_size,
1188            expected_identity,
1189            None,
1190        )
1191        .await?
1192    } else {
1193        SimplePager::open_with_cx(cx, vfs, path, header_template.page_size).await?
1194    };
1195    let mut txn = pager.begin(cx, TransactionMode::Immediate).await?;
1196
1197    let page_size = header_template.page_size;
1198    let page_size_usize = page_size.as_usize();
1199    let usable_size = page_size.usable(header_template.reserved_per_page);
1200    let full_page_size = page_size.get();
1201
1202    // Track (type, name, tbl_name, root_page, create_sql) for sqlite_master entries.
1203    // Extended from just tables to also include indexes, views, and triggers.
1204    // The sql column is Option<String> because autoindex entries (sqlite_autoindex_*)
1205    // must have NULL sql, matching stock SQLite behavior.
1206    let mut master_entries: Vec<SqliteMasterEntry> = Vec::new();
1207
1208    // Write each table's data into its own B-tree.
1209    for table in schema {
1210        let Some(mem_table) = db.get_table(table.root_page) else {
1211            continue;
1212        };
1213
1214        // Prefer the original DDL when available — it preserves column-level
1215        // CHECK constraints, exact DEFAULT formatting, and constraint ordering
1216        // that build_create_table_sql might not reconstruct perfectly.
1217        // Keys in original_ddl are lowercased (per reload_memdb_from_txn_with_mode).
1218        //
1219        // GH #340: resolved BEFORE any page is written, not after. A WITHOUT
1220        // ROWID root must be keyed by the primary key exactly as the schema
1221        // text published beside it declares, so the text has to be settled
1222        // first. For rowid tables the move is a pure reordering.
1223        let create_sql = original_ddl
1224            .get(&table.name.to_ascii_lowercase())
1225            .cloned()
1226            .unwrap_or_else(|| {
1227                build_create_table_sql_with_implicit_index_predicate(table, |index| {
1228                    parse_autoindex_ordinal(&index.name, &table.name).is_some()
1229                        && !original_ddl.contains_key(&index.name.to_ascii_lowercase())
1230                })
1231            });
1232
1233        // GH #340: a WITHOUT ROWID table is stored as an index b-tree keyed by
1234        // its PRIMARY KEY, never as a leaf-table b-tree under a synthetic
1235        // rowid. Resolving the layout here also decides the row-locator suffix
1236        // every secondary index below must carry.
1237        let without_rowid_pk = if table.without_rowid {
1238            Some(resolve_without_rowid_pk_layout(table, &create_sql)?)
1239        } else {
1240            None
1241        };
1242
1243        // Allocate a fresh root page for this table in the on-disk file.
1244        let root_page = txn.allocate_page(cx).await?;
1245
1246        if let Some(pk) = without_rowid_pk.as_ref() {
1247            // Index root (0x0A), not a table root (0x0D). Flipping only the
1248            // type byte would be worse than the bug: it would leave table
1249            // cells, which carry a rowid varint the index format has no slot
1250            // for, sitting on a page every reader parses as index cells.
1251            init_leaf_index_page(cx, &mut txn, root_page, page_size_usize, usable_size).await?;
1252
1253            let mut cursor = fsqlite_btree::BtCursor::new_with_index_desc(
1254                TransactionPageIo::new(&mut txn),
1255                root_page,
1256                usable_size,
1257                // An index cursor: `table_insert` takes a rowid this table
1258                // does not have.
1259                false,
1260                pk.desc_flags.clone(),
1261            );
1262            let collation_registry = cursor.collation_registry();
1263            // Same refusal the secondary-index builder makes below, for the
1264            // same reason: a collation this builder cannot resolve degrades
1265            // silently to BINARY and produces a b-tree whose physical order
1266            // contradicts its own declared schema. For a WITHOUT ROWID table
1267            // that order IS the table, so the consequence is worse than a
1268            // mis-ordered index — refuse rather than approximate.
1269            {
1270                let registry = collation_registry.lock().map_err(|_| {
1271                    FrankenError::internal(
1272                        "collation registry lock poisoned while rebuilding a WITHOUT ROWID table"
1273                            .to_owned(),
1274                    )
1275                })?;
1276                for collation in pk.collations.iter().flatten() {
1277                    if !registry.contains(collation) {
1278                        return Err(FrankenError::not_implemented(format!(
1279                            "WITHOUT ROWID table `{}` declares primary-key collation \
1280                             `{collation}`, which is not available to the compatibility \
1281                             builder; rebuilding it would order the table by BINARY and \
1282                             contradict its own declaration",
1283                            table.name
1284                        )));
1285                    }
1286                }
1287            }
1288            cursor.set_index_collation_context(pk.collations.clone(), collation_registry);
1289            configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1290
1291            // GH#347: sort the primary-key records into ascending b-tree order
1292            // before bulk-inserting, so this WITHOUT ROWID index b-tree is built
1293            // by dense sequential appends instead of random-position inserts in
1294            // rowid order (which leaves sparse pages and a freed-scratch freelist
1295            // trunk — a non-compact image). The b-tree orders by the leading pk
1296            // columns; sort with the SAME comparator the cursor uses on lookup.
1297            let pk_sort_collations =
1298                resolve_index_collations(&pk.collations, &cursor.collation_registry());
1299            // bd-v6pjf: a non-leading / reordered PRIMARY KEY stores the record
1300            // physically PK-leading (PK columns in PK order, then the remaining
1301            // columns in declared order) to match C SQLite's on-disk layout.
1302            // `MemDatabase` holds the row in DECLARED order, so reorder each row
1303            // to physical order before it becomes the b-tree key and the
1304            // serialized record. Leading-PK tables give the identity permutation
1305            // (physical == declared), so this is a no-op for them.
1306            let storage_order = without_rowid_storage_order(&pk.indices, table.columns.len());
1307            let mut pk_rows: Vec<Vec<SqliteValue>> = mem_table
1308                .iter_rows()
1309                .map(|(_synthetic_rowid, values)| {
1310                    storage_order
1311                        .iter()
1312                        .map(|&declared_idx| {
1313                            values.get(declared_idx).cloned().unwrap_or(SqliteValue::Null)
1314                        })
1315                        .collect()
1316                })
1317                .collect();
1318            pk_rows.sort_by(|a, b| {
1319                compare_rebuilt_index_key(a, b, &pk.desc_flags, &pk_sort_collations)
1320            });
1321            for values in &pk_rows {
1322                // Rows are already physical PK-leading (reordered above), so the
1323                // b-tree key is the leading `pk.indices.len()` columns and the
1324                // serialized record matches C SQLite's on-disk WITHOUT ROWID
1325                // layout. `MemDatabase` keys these rows under a synthetic counter
1326                // assigned at load purely to index the map — not part of the row,
1327                // and it never reaches the image.
1328                cursor
1329                    .index_insert(
1330                        cx,
1331                        &serialize_record_with_encoding(values, header_template.text_encoding),
1332                    )
1333                    .await?;
1334            }
1335        } else {
1336            // Initialize the root page as an empty leaf table B-tree.
1337            init_leaf_table_page(cx, &mut txn, root_page, page_size_usize, usable_size).await?;
1338
1339            // Insert all rows.
1340            let mut cursor = fsqlite_btree::BtCursor::new(
1341                TransactionPageIo::new(&mut txn),
1342                root_page,
1343                usable_size,
1344                true,
1345            );
1346            configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1347            for (rowid, values) in mem_table.iter_rows() {
1348                let payload = serialize_record_with_encoding(values, header_template.text_encoding);
1349                cursor.table_insert(cx, rowid, &payload).await?;
1350            }
1351        }
1352
1353        let table_name = table.name.clone();
1354        master_entries.push((
1355            "table".to_owned(),
1356            table_name.clone(),
1357            table_name.clone(),
1358            root_page.get(),
1359            Some(create_sql),
1360        ));
1361
1362        // Build column map once for evaluating partial index WHERE predicates.
1363        // [(table_name, column_name, is_rowid_alias), ...]
1364        let col_map: Vec<(String, String, bool)> = table
1365            .columns
1366            .iter()
1367            .map(|c| (table.name.clone(), c.name.clone(), false))
1368            .collect();
1369
1370        // Write index B-trees for all indexes including autoindexes.
1371        // Autoindexes (sqlite_autoindex_*) are created for UNIQUE constraints
1372        // and non-IPK PRIMARY KEY columns. Their sqlite_master entries point to
1373        // root pages that must contain valid B-tree data. Skipping them causes
1374        // "wrong # of entries in index" and "page N: never used" errors when
1375        // stock SQLite runs integrity_check (issue #55).
1376        for index in &table.indexes {
1377            let is_expression_index = index.columns.is_empty() && !index.key_expressions.is_empty();
1378            if index.columns.is_empty() && !is_expression_index {
1379                continue;
1380            }
1381            let key_exprs = if is_expression_index {
1382                index
1383                    .key_expressions
1384                    .iter()
1385                    .map(|expr| {
1386                        fsqlite_parser::expr::parse_expr(expr).map_err(|err| {
1387                            FrankenError::Internal(format!(
1388                                "failed to parse expression index term `{expr}` while persisting `{}`: {err}",
1389                                index.name
1390                            ))
1391                        })
1392                    })
1393                    .collect::<Result<Vec<_>>>()?
1394            } else {
1395                Vec::new()
1396            };
1397            // Allocate and initialize root page as leaf index page (0x0A).
1398            let idx_root = txn.allocate_page(cx).await?;
1399            init_leaf_index_page(cx, &mut txn, idx_root, page_size_usize, usable_size).await?;
1400
1401            // Parse the partial index WHERE clause (if any) so we can skip
1402            // rows that don't satisfy the predicate.
1403            let partial_predicate = index
1404                .where_clause
1405                .as_deref()
1406                .map(fsqlite_parser::expr::parse_expr)
1407                .transpose()
1408                .ok()
1409                .flatten();
1410
1411            // Populate the index B-tree from table rows.
1412            {
1413                // GH #304: carry the declared key semantics into the physical
1414                // builder. `key_sort_directions` / `key_collations` were
1415                // previously consumed only when regenerating CREATE INDEX text,
1416                // so a DESC or non-BINARY term produced a b-tree whose physical
1417                // order contradicted its own sqlite_master declaration. Stock
1418                // SQLite then reads the index with the declared semantics and
1419                // reports the image as malformed.
1420                //
1421                // Scope of the trailing entries, stated exactly: the key loop
1422                // below emits a row locator as the suffix. For a rowid table
1423                // that is one synthetic integer rowid
1424                // (`key_values.push(SqliteValue::Integer(rowid))`), described
1425                // by the single trailing ASC/BINARY entry. For a WITHOUT ROWID
1426                // table it is the primary-key columns in declared PK order,
1427                // described by one entry per PK column carrying that column's
1428                // own direction and collation (GH #340, which closed the
1429                // WITHOUT ROWID suffix gap GH #304 recorded as unfixed).
1430                //
1431                // Collations resolve against the cursor's default registry
1432                // (BINARY/NOCASE/RTRIM only). A collation registered on the
1433                // source connection is still not reachable from this function,
1434                // but it no longer falls back silently: an unresolvable *name* is
1435                // refused below, before any row is inserted, so a mis-ordered
1436                // image is never produced for it.
1437                //
1438                // What remains unresolved for GH #304 is narrower and is a
1439                // name-collision problem rather than a missing-name one: a source
1440                // connection that overrides a built-in — registering its own
1441                // implementation under BINARY, NOCASE, or RTRIM — still passes
1442                // the name check and is then built with the default
1443                // implementation. See the guard below for the full statement.
1444                //
1445                // Derive arity from the SAME discriminator the key loop below
1446                // uses, not from `key_term_count()`: that helper prefers
1447                // `key_expressions` whenever it is non-empty, while the loop
1448                // only takes the expression branch when `columns` is empty.
1449                // If both were ever populated the two would disagree and the
1450                // metadata vectors would silently mis-align with the key.
1451                let key_terms = if is_expression_index {
1452                    index.key_expressions.len()
1453                } else {
1454                    index.columns.len()
1455                };
1456                let mut index_desc_flags: Vec<bool> = (0..key_terms)
1457                    .map(|term| {
1458                        index.key_sort_directions.get(term).copied() == Some(SortDirection::Desc)
1459                    })
1460                    .collect();
1461                let mut index_collations: Vec<Option<String>> = (0..key_terms)
1462                    .map(|term| index.key_collations.get(term).cloned().flatten())
1463                    .collect();
1464                // GH #340: metadata for the row-locator suffix. A rowid table's
1465                // suffix is one synthetic integer, so one ASC/BINARY entry
1466                // describes it. A WITHOUT ROWID table's suffix is its whole
1467                // primary key, and each of those columns carries its own
1468                // declared direction and collation — the single trailing entry
1469                // that used to be pushed here described a rowid this branch
1470                // does not write.
1471                if let Some(pk) = without_rowid_pk.as_ref() {
1472                    index_desc_flags.extend(pk.desc_flags.iter().copied());
1473                    index_collations.extend(pk.collations.iter().cloned());
1474                } else {
1475                    index_desc_flags.push(false);
1476                    index_collations.push(None);
1477                }
1478
1479                // GH#347: keep the key semantics for the bulk-load sort below,
1480                // before they are moved into the cursor.
1481                let sort_desc_flags = index_desc_flags.clone();
1482                let mut idx_cursor = fsqlite_btree::BtCursor::new_with_index_desc(
1483                    TransactionPageIo::new(&mut txn),
1484                    idx_root,
1485                    usable_size,
1486                    true,
1487                    index_desc_flags,
1488                );
1489                let collation_registry = idx_cursor.collation_registry();
1490                // GH #304: a collation the builder cannot resolve silently
1491                // degrades to BINARY inside the cursor comparator, producing an
1492                // index whose physical order contradicts the `COLLATE` term in
1493                // its own regenerated DDL — exactly the malformed-image class
1494                // this issue was filed for, but without the DESC symptom that
1495                // made the original report visible. The source connection's
1496                // registry is not reachable from this function, so the honest
1497                // outcome is refusal rather than a quietly mis-ordered rebuild.
1498                // This mirrors the hidden WITHOUT ROWID primary-key slot, which
1499                // is likewise refused instead of approximated.
1500                //
1501                // This is a supported-schema limitation, not a violated internal
1502                // invariant, so it is reported as `NotImplemented`: the schema is
1503                // legitimate and the caller can act on it (register the collation
1504                // on the rebuilding path, or export without that index).
1505                //
1506                // KNOWN GAP (GH #304, unresolved): this guard keys on the
1507                // presence of a *name*, so it cannot see a source connection that
1508                // overrides a built-in — registering its own implementation under
1509                // `BINARY`, `NOCASE`, or `RTRIM`. `contains()` answers true, the
1510                // guard admits the index, and the builder then orders it with the
1511                // default implementation instead of the caller's. That is the
1512                // same silently-wrong-order defect this guard exists to prevent,
1513                // reachable through a name the guard trusts. Closing it needs the
1514                // source connection's registry (or a per-collation identity, not
1515                // just a name), which is not reachable from this function.
1516                //
1517                // Cleanup of the partially written candidate is NOT performed
1518                // here — this function never removes its own output on any error
1519                // path. The enclosing VACUUM caller owns that through
1520                // `VacuumTargetReservation`, which is identity-bound; returning
1521                // early simply leaves the reservation to clean up as it already
1522                // does for every other failure in this function.
1523                {
1524                    let registry = collation_registry.lock().map_err(|_| {
1525                        FrankenError::internal(
1526                            "collation registry lock poisoned while rebuilding an index".to_owned(),
1527                        )
1528                    })?;
1529                    for collation in index_collations.iter().flatten() {
1530                        if !registry.contains(collation) {
1531                            return Err(FrankenError::not_implemented(format!(
1532                                "index `{}` declares collation `{collation}`, which is not \
1533                                 available to the compatibility index builder; rebuilding it \
1534                                 would order the index by BINARY and contradict its own \
1535                                 declaration",
1536                                index.name
1537                            )));
1538                        }
1539                    }
1540                }
1541                // GH#347: resolve the key collations for the bulk-load sort
1542                // before `index_collations` is moved into the cursor.
1543                let sort_collations =
1544                    resolve_index_collations(&index_collations, &collation_registry);
1545                idx_cursor.set_index_collation_context(index_collations, collation_registry);
1546                configure_btree_cursor_page_size(&mut idx_cursor, usable_size, full_page_size);
1547                if let Some(mem_table) = db.get_table(table.root_page) {
1548                    // GH#347: collect every index key, then sort into ascending
1549                    // b-tree order before bulk-inserting. Iterating rows in rowid
1550                    // order and inserting per row makes each insert a random-
1551                    // position b-tree insert whenever the indexed column's order
1552                    // differs from rowid order (any non-monotonic index), which
1553                    // leaves ~30% sparse pages plus freed balance-scratch pages
1554                    // that serialize as a freelist trunk — a non-compact image
1555                    // larger than stock's. Stock SQLite's VACUUM sorts index
1556                    // entries first; mirror that with the cursor's own comparator.
1557                    let mut index_keys: Vec<Vec<SqliteValue>> = Vec::new();
1558                    for (rowid, values) in mem_table.iter_rows() {
1559                        // For partial indexes, skip rows that don't match
1560                        // the WHERE predicate. If evaluation fails, include
1561                        // the row (safe default).
1562                        if let Some(ref predicate) = partial_predicate
1563                            && let Ok(result) = eval_join_expr(predicate, values, &col_map)
1564                            && !is_sqlite_truthy(&result)
1565                        {
1566                            continue;
1567                        }
1568
1569                        // Build index key: (indexed_terms..., rowid).
1570                        let mut key_values: Vec<SqliteValue> = Vec::new();
1571                        if is_expression_index {
1572                            for expr in &key_exprs {
1573                                key_values.push(eval_join_expr(expr, values, &col_map)?);
1574                            }
1575                        } else {
1576                            for col_name in &index.columns {
1577                                let col_idx = table
1578                                    .columns
1579                                    .iter()
1580                                    .position(|c| c.name.eq_ignore_ascii_case(col_name));
1581                                if let Some(idx) = col_idx {
1582                                    key_values.push(
1583                                        values.get(idx).cloned().unwrap_or(SqliteValue::Null),
1584                                    );
1585                                } else {
1586                                    key_values.push(SqliteValue::Null);
1587                                }
1588                            }
1589                        }
1590                        // GH #340: append the row locator. A rowid table
1591                        // appends its rowid; a WITHOUT ROWID table appends
1592                        // every primary-key column in declared PK order, which
1593                        // is the only way the entry can identify its row —
1594                        // there is no rowid to point at. This matches the
1595                        // ordinary index-backfill path.
1596                        if let Some(pk) = without_rowid_pk.as_ref() {
1597                            for &col_idx in &pk.indices {
1598                                key_values.push(
1599                                    values.get(col_idx).cloned().unwrap_or(SqliteValue::Null),
1600                                );
1601                            }
1602                        } else {
1603                            key_values.push(SqliteValue::Integer(rowid));
1604                        }
1605                        index_keys.push(key_values);
1606                    }
1607                    index_keys.sort_by(|a, b| {
1608                        compare_rebuilt_index_key(a, b, &sort_desc_flags, &sort_collations)
1609                    });
1610                    for key_values in &index_keys {
1611                        idx_cursor
1612                            .index_insert(
1613                                cx,
1614                                &serialize_record_with_encoding(
1615                                    key_values,
1616                                    header_template.text_encoding,
1617                                ),
1618                            )
1619                            .await?;
1620                    }
1621                }
1622            }
1623
1624            // Preserve stored non-NULL DDL before considering the reserved
1625            // prefix. Legacy databases can contain explicit indexes with a
1626            // sqlite_autoindex_* name; erasing their SQL turns them into
1627            // unreconstructable implicit entries. Only names that canonically
1628            // map to this table and a positive ordinal, with no preserved DDL,
1629            // are genuine implicit autoindexes.
1630            let idx_sql = index_sql_for_persistence(&index.name, &table_name, original_ddl, || {
1631                if is_expression_index {
1632                    build_create_expression_index_sql(
1633                        &index.name,
1634                        &table_name,
1635                        index.is_unique,
1636                        &index.key_expressions,
1637                        &index.key_collations,
1638                        &index.key_sort_directions,
1639                        index.where_clause.as_deref(),
1640                    )
1641                } else {
1642                    let terms: Vec<CreateIndexSqlTerm<'_>> = index
1643                        .columns
1644                        .iter()
1645                        .enumerate()
1646                        .map(|(i, col)| CreateIndexSqlTerm {
1647                            column_name: col.as_str(),
1648                            collation: index.key_collations.get(i).and_then(|c| c.as_deref()),
1649                            direction: index.key_sort_directions.get(i).copied(),
1650                        })
1651                        .collect();
1652                    let sql = build_create_index_sql(
1653                        &index.name,
1654                        &table_name,
1655                        index.is_unique,
1656                        &terms,
1657                        None,
1658                    );
1659                    if let Some(ref wc) = index.where_clause {
1660                        format!("{sql} WHERE {wc}")
1661                    } else {
1662                        sql
1663                    }
1664                }
1665            });
1666            master_entries.push((
1667                "index".to_owned(),
1668                index.name.clone(),
1669                table_name.clone(),
1670                idx_root.get(),
1671                idx_sql,
1672            ));
1673        }
1674    }
1675
1676    master_entries.extend(extra_master_entries.iter().cloned());
1677
1678    // Write sqlite_master entries into page 1's B-tree.
1679    // sqlite_master columns: type TEXT, name TEXT, tbl_name TEXT, rootpage INTEGER, sql TEXT
1680    {
1681        let mut page1 = txn.get_page(cx, PageNumber::ONE).await?.into_vec();
1682        if page1.len() < DATABASE_HEADER_SIZE + 8 {
1683            return Err(FrankenError::internal(format!(
1684                "page 1 too short for sqlite_master root header: {} bytes",
1685                page1.len()
1686            )));
1687        }
1688        page1[DATABASE_HEADER_SIZE] = 0x0D;
1689        page1[DATABASE_HEADER_SIZE + 3..DATABASE_HEADER_SIZE + 5]
1690            .copy_from_slice(&0u16.to_be_bytes());
1691        let master_content_start: u16 = if usable_size == 65536 {
1692            0
1693        } else {
1694            u16::try_from(usable_size).map_err(|_| {
1695                FrankenError::internal(format!(
1696                    "usable_size {usable_size} does not fit in u16 and is not 65536"
1697                ))
1698            })?
1699        };
1700        page1[DATABASE_HEADER_SIZE + 5..DATABASE_HEADER_SIZE + 7]
1701            .copy_from_slice(&master_content_start.to_be_bytes());
1702        txn.write_page(cx, PageNumber::ONE, &page1).await?;
1703
1704        let master_root = PageNumber::ONE;
1705        let mut cursor = fsqlite_btree::BtCursor::new(
1706            TransactionPageIo::new(&mut txn),
1707            master_root,
1708            usable_size,
1709            true,
1710        );
1711        configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1712
1713        for (rowid, (entry_type, name, tbl_name, root_page_num, create_sql)) in
1714            master_entries.iter().enumerate()
1715        {
1716            let sql_value = match create_sql {
1717                Some(sql) => SqliteValue::Text(sql.clone().into()),
1718                None => SqliteValue::Null,
1719            };
1720            let record = serialize_record_with_encoding(
1721                &[
1722                    SqliteValue::Text(entry_type.clone().into()),
1723                    SqliteValue::Text(name.clone().into()),
1724                    SqliteValue::Text(tbl_name.clone().into()),
1725                    SqliteValue::Integer(i64::from(*root_page_num)),
1726                    sql_value,
1727                ],
1728                header_template.text_encoding,
1729            );
1730            #[allow(clippy::cast_possible_wrap)]
1731            let rid = (rowid as i64) + 1;
1732            cursor.table_insert(cx, rid, &record).await?;
1733        }
1734    }
1735
1736    // Fix up the database header on page 1: update page_count,
1737    // change_counter, and schema_cookie so sqlite3 validates the file.
1738    {
1739        let mut hdr_page = txn.get_page(cx, PageNumber::ONE).await?.into_vec();
1740
1741        // Discover the current page count by allocating one more page.
1742        // The extra page is included in the commit (the pager does not
1743        // support free_page), so the exported file has one trailing empty
1744        // page. This is benign: SQLite tolerates pages beyond the last
1745        // B-tree node, and the page_count header excludes it.
1746        let next_page = txn.allocate_page(cx).await?.get();
1747        let max_page = next_page.saturating_sub(1).max(1);
1748
1749        let mut final_header = header_template.clone();
1750        final_header.page_count = max_page;
1751        final_header.freelist_trunk = 0;
1752        final_header.freelist_count = 0;
1753        final_header.change_counter = final_header.change_counter.max(1);
1754        final_header.schema_cookie = final_header.schema_cookie.max(1);
1755        final_header.version_valid_for = final_header.change_counter;
1756
1757        let encoded_header = final_header.to_bytes().map_err(|err| {
1758            FrankenError::internal(format!("failed to encode database header: {err}"))
1759        })?;
1760        hdr_page[..DATABASE_HEADER_SIZE].copy_from_slice(&encoded_header);
1761
1762        txn.write_page(cx, PageNumber::ONE, &hdr_page).await?;
1763    }
1764
1765    txn.commit(cx).await?;
1766    Ok(())
1767}
1768
1769/// Load a real SQLite-format database file into `MemDatabase` + schema.
1770///
1771/// Reads sqlite_master from page 1, then reads each table's B-tree to
1772/// populate the in-memory store.
1773/// The caller supplies the capability context so pager reads inherit the
1774/// active trace and budget lineage.
1775///
1776/// # Errors
1777///
1778/// Returns an error if the file is not a valid SQLite database, or on
1779/// I/O / B-tree navigation failures.
1780#[allow(clippy::too_many_lines, clippy::similar_names)]
1781#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1782pub async fn load_from_sqlite(cx: &Cx, path: &Path) -> Result<LoadedState> {
1783    let _record_profile_scope = enter_record_profile_scope(RecordProfileScope::CoreCompatPersist);
1784    let vfs = PlatformVfs::new();
1785    let pager = Arc::new(SimplePager::open_with_cx(cx, vfs, path, DEFAULT_PAGE_SIZE).await?);
1786    // bd-asupersync-043 release triage: a database whose writer left live WAL
1787    // frames (HEAD `close()` no longer guarantees a truncating checkpoint)
1788    // must be read THROUGH the WAL, exactly like stock SQLite's reader path.
1789    // Without a backend, page reads on a WAL-mode header die with
1790    // "WAL mode active but no WAL backend installed".
1791    let wal_path = crate::connection::wal_path_for_db_path(&path.to_string_lossy());
1792    crate::connection::install_wal_backend_with_vfs(&pager, PlatformVfs::new(), cx, &wal_path)
1793        .await?;
1794    let mut txn = pager.begin(cx, TransactionMode::ReadOnly).await?;
1795    let max_root_page = txn.snapshot_db_size();
1796    let page1 = txn.get_page(cx, PageNumber::ONE).await?;
1797    let header = load_sqlite_header_from_page1(page1.as_ref())?;
1798    let usable_size = header.page_size.usable(header.reserved_per_page);
1799    let page_size = header.page_size.get();
1800
1801    // Read sqlite_master entries from page 1.
1802    let master_entries = {
1803        let mut entries = Vec::new();
1804        let master_root = PageNumber::ONE;
1805        let mut cursor = fsqlite_btree::BtCursor::new(
1806            TransactionPageIo::new(&mut txn),
1807            master_root,
1808            usable_size,
1809            true,
1810        );
1811        configure_btree_cursor_page_size(&mut cursor, usable_size, page_size);
1812
1813        if cursor.first(cx).await? {
1814            let mut payload_buf: Vec<u8> = Vec::new();
1815            loop {
1816                // bd-9e3xf.6: fuse rowid+payload via the cursor accessor
1817                // landed in 5459d778 — saves one parse_cell_at per row on
1818                // the schema replay path, where N can be the full count of
1819                // sqlite_master rows in the database.
1820                payload_buf.clear();
1821                let rowid = cursor.rowid_and_payload_into(cx, &mut payload_buf).await?;
1822                let values =
1823                    parse_record(&payload_buf).ok_or_else(|| FrankenError::DatabaseCorrupt {
1824                        detail: format!(
1825                            "sqlite_master row {rowid} payload is not a valid SQLite record"
1826                        ),
1827                    })?;
1828                entries.push(values);
1829                if !cursor.next(cx).await? {
1830                    break;
1831                }
1832            }
1833        }
1834        entries
1835    };
1836
1837    let free_pages = txn.live_freelist_pages().into_iter().collect();
1838    let bound_implicit_autoindexes =
1839        bind_implicit_autoindex_catalog(&master_entries, max_root_page, &header, &free_pages)?;
1840
1841    // Parse each sqlite_master row.
1842    // Columns: type(0), name(1), tbl_name(2), rootpage(3), sql(4)
1843    let materialized_virtual_tables: HashSet<String> = master_entries
1844        .iter()
1845        .filter_map(|entry| {
1846            if entry.len() < 5 {
1847                return None;
1848            }
1849            let entry_type = match &entry[0] {
1850                SqliteValue::Text(s) => s,
1851                _ => return None,
1852            };
1853            if !entry_type.eq_ignore_ascii_case("table") {
1854                return None;
1855            }
1856            let name = match &entry[1] {
1857                SqliteValue::Text(s) => s,
1858                _ => return None,
1859            };
1860            let root_page_num = match &entry[3] {
1861                SqliteValue::Integer(n) => *n,
1862                _ => return None,
1863            };
1864            let create_sql = match &entry[4] {
1865                SqliteValue::Text(s) => s,
1866                _ => return None,
1867            };
1868            if root_page_num > 0 && is_virtual_table_sql(create_sql) {
1869                Some(name.to_ascii_lowercase())
1870            } else {
1871                None
1872            }
1873        })
1874        .collect();
1875    let mut schema = Vec::new();
1876    let mut db = MemDatabase::new();
1877
1878    for entry in &master_entries {
1879        if entry.len() < 5 {
1880            continue;
1881        }
1882        let entry_type = match &entry[0] {
1883            SqliteValue::Text(s) => s,
1884            _ => continue,
1885        };
1886        if !entry_type.eq_ignore_ascii_case("table") {
1887            continue; // Skip indexes, views, triggers for now.
1888        }
1889
1890        let name = match &entry[1] {
1891            SqliteValue::Text(s) => s.clone(),
1892            _ => continue,
1893        };
1894        let root_page_num = match &entry[3] {
1895            SqliteValue::Integer(n) => *n,
1896            _ => continue,
1897        };
1898        let create_sql = match &entry[4] {
1899            SqliteValue::Text(s) => s.clone(),
1900            _ => continue,
1901        };
1902
1903        // Stock SQLite records virtual tables with rootpage=0. Those legacy
1904        // declarations have no materialized root page to load, so skip them.
1905        // Positive-rootpage virtual tables are real B-trees and must remain
1906        // visible on reopen just like ordinary tables.
1907        let is_virtual_table = is_virtual_table_sql(&create_sql);
1908        if root_page_num == 0 && is_virtual_table {
1909            let _shadowed_by_materialized =
1910                materialized_virtual_tables.contains(&name.to_ascii_lowercase());
1911            continue;
1912        }
1913        let root_page_u32 = validate_sqlite_master_root_page(&name, root_page_num)?;
1914
1915        // Parse the CREATE TABLE to extract column info and schema decorations.
1916        let columns = parse_columns_from_sqlite_master_sql(&create_sql);
1917        let bound_table_autoindexes = if is_virtual_table {
1918            None
1919        } else {
1920            Some(bound_implicit_autoindexes.table(&name).ok_or_else(|| {
1921                sqlite_master_corrupt(format!(
1922                    "validated ordinary table `{name}` has no bound autoindex layout"
1923                ))
1924            })?)
1925        };
1926        let indexes = bound_table_autoindexes
1927            .map_or_else(Vec::new, |bound| bound.physical_index_schemas(&name));
1928        let primary_key_constraints = extract_primary_key_constraints_from_sql(&create_sql);
1929        let foreign_keys = extract_foreign_keys_from_sql(&create_sql, &columns);
1930        let check_constraints = extract_check_constraints_with_owners_from_sql(&create_sql);
1931        let num_columns = columns.len();
1932        let without_rowid = is_without_rowid_table_sql(&create_sql);
1933        let ipk_col_idx = columns.iter().position(|c| c.is_ipk);
1934
1935        // Use the REAL root page from sqlite_master (5A.4: bd-1soh).
1936        let real_root_page =
1937            i32::try_from(root_page_u32).expect("validated root page must fit MemDatabase");
1938        db.create_table_at(real_root_page, num_columns);
1939        for index in &indexes {
1940            db.create_table_at(index.root_page, 0);
1941        }
1942
1943        let table_name_for_err = name.to_string();
1944        schema.push(TableSchema {
1945            name: name.to_string(),
1946            root_page: real_root_page,
1947            columns,
1948            indexes: indexes.clone(),
1949            strict: is_strict_table_sql(&create_sql),
1950            without_rowid,
1951            primary_key_constraints,
1952            foreign_keys,
1953            check_constraints,
1954        });
1955        let current_table_schema = schema.last().ok_or_else(|| {
1956            FrankenError::Internal(format!(
1957                "compat loader lost table schema after registering `{table_name_for_err}`"
1958            ))
1959        })?;
1960
1961        // Read all rows from this table's B-tree.
1962        let file_root =
1963            PageNumber::new(root_page_u32).expect("validated sqlite_master root page is positive");
1964
1965        let mut cursor = fsqlite_btree::BtCursor::new(
1966            TransactionPageIo::new(&mut txn),
1967            file_root,
1968            usable_size,
1969            !without_rowid,
1970        );
1971        configure_btree_cursor_page_size(&mut cursor, usable_size, page_size);
1972
1973        if let Some(mem_table) = db.tables.get_mut(&real_root_page) {
1974            for slot in bound_table_autoindexes
1975                .into_iter()
1976                .flat_map(BoundTableAutoindexes::implicit_slots)
1977            {
1978                let Some(column_indices) = slot
1979                    .columns()
1980                    .iter()
1981                    .map(|column_name| {
1982                        current_table_schema
1983                            .columns
1984                            .iter()
1985                            .position(|column| column.name.eq_ignore_ascii_case(column_name))
1986                    })
1987                    .collect::<Option<Vec<_>>>()
1988                else {
1989                    return Err(FrankenError::DatabaseCorrupt {
1990                        detail: format!(
1991                            "canonical autoindex layout for `{table_name_for_err}` references a missing column"
1992                        ),
1993                    });
1994                };
1995                if !column_indices.is_empty() {
1996                    mem_table.add_unique_column_group_with_collations(
1997                        column_indices,
1998                        slot.key_collations().to_vec(),
1999                    );
2000                }
2001            }
2002            if cursor.first(cx).await? {
2003                if without_rowid {
2004                    let mut synthetic_rowid = 1_i64;
2005                    let mut payload_buf: Vec<u8> = Vec::new();
2006                    loop {
2007                        payload_buf.clear();
2008                        cursor.payload_into(cx, &mut payload_buf).await?;
2009                        let mut values = parse_record(&payload_buf).ok_or_else(|| {
2010                            FrankenError::DatabaseCorrupt {
2011                                detail: format!(
2012                                    "WITHOUT ROWID table `{table_name_for_err}` payload is not a valid SQLite record"
2013                                ),
2014                            }
2015                        })?;
2016                        inflate_loaded_table_row_values(
2017                            &mut values,
2018                            synthetic_rowid,
2019                            &current_table_schema.columns,
2020                            None,
2021                            &table_name_for_err,
2022                        )?;
2023                        mem_table.insert_row(synthetic_rowid, values);
2024                        synthetic_rowid = synthetic_rowid.saturating_add(1);
2025                        if !cursor.next(cx).await? {
2026                            break;
2027                        }
2028                    }
2029                    continue;
2030                }
2031                let mut payload_buf: Vec<u8> = Vec::new();
2032                loop {
2033                    // bd-9e3xf.6: fused accessor (5459d778) avoids a second
2034                    // parse_cell_at on every row of the legacy table-replay
2035                    // hot path used by file-backed schema hydration.
2036                    payload_buf.clear();
2037                    let rowid = cursor.rowid_and_payload_into(cx, &mut payload_buf).await?;
2038                    let mut values = parse_record(&payload_buf).ok_or_else(|| {
2039                        FrankenError::DatabaseCorrupt {
2040                            detail: format!(
2041                                "table `{table_name_for_err}` rowid {rowid} payload is not a valid SQLite record"
2042                            ),
2043                        }
2044                    })?;
2045                    inflate_loaded_table_row_values(
2046                        &mut values,
2047                        rowid,
2048                        &current_table_schema.columns,
2049                        if without_rowid { None } else { ipk_col_idx },
2050                        &table_name_for_err,
2051                    )?;
2052                    mem_table.insert_row(rowid, values);
2053                    if !cursor.next(cx).await? {
2054                        break;
2055                    }
2056                }
2057            }
2058        }
2059    }
2060
2061    // Second pass: load explicit indexes from sqlite_master "index" entries.
2062    // Autoindexes from UNIQUE/PK constraints are already extracted from
2063    // CREATE TABLE SQL above; this handles `CREATE INDEX ...` definitions.
2064    for entry in &master_entries {
2065        if entry.len() < 5 {
2066            continue;
2067        }
2068        let entry_type = match &entry[0] {
2069            SqliteValue::Text(s) => s,
2070            _ => continue,
2071        };
2072        if !entry_type.eq_ignore_ascii_case("index") {
2073            continue;
2074        }
2075        let index_name = match &entry[1] {
2076            SqliteValue::Text(s) => s.to_string(),
2077            _ => continue,
2078        };
2079        let tbl_name = match &entry[2] {
2080            SqliteValue::Text(s) => s.to_string(),
2081            _ => continue,
2082        };
2083        let root_page_num = match &entry[3] {
2084            SqliteValue::Integer(n) => *n,
2085            _ => continue,
2086        };
2087        let create_sql = match &entry[4] {
2088            SqliteValue::Text(s) => s.to_string(),
2089            _ => continue,
2090        };
2091
2092        let root_page_u32 = validate_sqlite_master_root_page(&index_name, root_page_num)?;
2093        let root_page_i32 =
2094            i32::try_from(root_page_u32).map_err(|_| FrankenError::DatabaseCorrupt {
2095                detail: format!(
2096                    "sqlite_master index `{index_name}` has rootpage {root_page_num} that exceeds supported range"
2097                ),
2098            })?;
2099
2100        // Find the parent table in the schema and bind the authoritative SQL
2101        // against it before mutating either schema or MemDatabase state.
2102        let Some(table_position) = schema
2103            .iter()
2104            .position(|table| table.name.eq_ignore_ascii_case(&tbl_name))
2105        else {
2106            return Err(sqlite_master_corrupt(format!(
2107                "validated index `{index_name}` lost parent table `{tbl_name}` during load"
2108            )));
2109        };
2110
2111        let Some(Statement::CreateIndex(create_stmt)) = parse_single_statement(&create_sql) else {
2112            return Err(sqlite_master_corrupt(format!(
2113                "validated CREATE INDEX SQL for `{index_name}` could not be parsed during load"
2114            )));
2115        };
2116        if let Some(schema_name) = create_stmt.name.schema.as_deref() {
2117            return Err(sqlite_master_corrupt(format!(
2118                "explicit index `{index_name}` on table `{tbl_name}` has non-canonical schema-qualified CREATE INDEX SQL (`{schema_name}`) during load"
2119            )));
2120        }
2121        let table = &schema[table_position];
2122        if table
2123            .indexes
2124            .iter()
2125            .any(|index| index.name.eq_ignore_ascii_case(&index_name))
2126        {
2127            return Err(sqlite_master_corrupt(format!(
2128                "validated explicit index `{index_name}` duplicates an existing index on table `{tbl_name}` during load"
2129            )));
2130        }
2131        let bound_index = bind_explicit_index(&create_stmt, &index_name, &tbl_name, table)
2132            .map_err(|error| {
2133                sqlite_master_corrupt(format!(
2134                    "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
2135                ))
2136            })?;
2137        for indexed in &create_stmt.columns {
2138            validate_builtin_persisted_index_expr_functions(&indexed.expr).map_err(|error| {
2139                sqlite_master_corrupt(format!(
2140                    "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
2141                ))
2142            })?;
2143        }
2144        if let Some(predicate) = create_stmt.where_clause.as_ref() {
2145            validate_builtin_persisted_index_expr_functions(predicate).map_err(|error| {
2146                sqlite_master_corrupt(format!(
2147                    "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
2148                ))
2149            })?;
2150        }
2151        schema[table_position]
2152            .indexes
2153            .push(bound_index.into_index_schema(root_page_i32));
2154        db.create_table_at(root_page_i32, 0);
2155    }
2156
2157    // Read schema_cookie and change_counter from the database header (page 1).
2158    let (schema_cookie, change_counter) = {
2159        let header_buf = txn.get_page(cx, PageNumber::ONE).await?;
2160        let hdr = header_buf.as_ref();
2161        let cookie = if hdr.len() >= 44 {
2162            u32::from_be_bytes([hdr[40], hdr[41], hdr[42], hdr[43]])
2163        } else {
2164            0
2165        };
2166        let counter = if hdr.len() >= 28 {
2167            u32::from_be_bytes([hdr[24], hdr[25], hdr[26], hdr[27]])
2168        } else {
2169            0
2170        };
2171        (cookie, counter)
2172    };
2173
2174    #[allow(clippy::cast_possible_wrap)]
2175    let master_row_count = master_entries.len() as i64;
2176    Ok(LoadedState {
2177        schema,
2178        db,
2179        master_row_count,
2180        schema_cookie,
2181        change_counter,
2182    })
2183}
2184
2185// ── Helpers ─────────────────────────────────────────────────────────────
2186
2187/// Physical primary-key layout of a `WITHOUT ROWID` table root (GH #340).
2188///
2189/// A `WITHOUT ROWID` table has no rowid: its rows live directly in an **index**
2190/// b-tree whose stored record is the full row in declared column order, keyed
2191/// by the leading `indices.len()` columns. This is not a compat-layer
2192/// invention — it is exactly what ordinary DML emits, `MakeRecord` over every
2193/// column followed by `IdxInsert` with the PK count as its key arity (see
2194/// `emit_without_rowid_row_insert` in `fsqlite-vdbe`).
2195#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2196struct WithoutRowidPkLayout {
2197    /// Declared-order column positions of the primary key.
2198    ///
2199    /// `without_rowid_pk_indices` guarantees these are the *leading* columns,
2200    /// which is what makes "key = leading N columns" well defined.
2201    indices: Vec<usize>,
2202    /// One flag per PK column: true when that column is declared `DESC`.
2203    desc_flags: Vec<bool>,
2204    /// One entry per PK column: that column's declared `COLLATE`, if any.
2205    collations: Vec<Option<String>>,
2206}
2207
2208/// Resolve the primary-key ordering a `WITHOUT ROWID` table root must be keyed
2209/// by, from the exact `CREATE TABLE` text this rebuild is about to publish.
2210///
2211/// The direction is read from `create_sql` rather than from any ambient
2212/// connection state on purpose: the emitted image must be *internally*
2213/// consistent, so that the physical b-tree order agrees with the schema text
2214/// stored beside it. Deriving order from one source and declaring it from
2215/// another is precisely how an image ends up contradicting itself and failing
2216/// `quick_check` — the failure class GH #340 was filed for.
2217///
2218/// Collations mirror the runtime table-cursor registration, which takes the
2219/// *column's* declared `COLLATE` (see the `without_rowid` branch that builds
2220/// `index_collations_by_root_page` in `connection.rs`). A `COLLATE` written on
2221/// the PK term itself is not consulted there, so it is not consulted here
2222/// either; diverging would make the rebuilt order disagree with the order the
2223/// engine uses to read it back. That inherited limitation is the runtime's,
2224/// not one introduced by this repair.
2225///
2226/// Refuses rather than guesses. An unparseable DDL, a PK this builder cannot
2227/// place, or a direction vector whose arity disagrees with the key all produce
2228/// a typed refusal, because the alternative is a silently mis-ordered image
2229/// that still passes as "successful VACUUM".
2230#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2231fn resolve_without_rowid_pk_layout(
2232    table: &TableSchema,
2233    create_sql: &str,
2234) -> Result<WithoutRowidPkLayout> {
2235    // Leading-PK placement is fsqlite's storage rule for WITHOUT ROWID tables.
2236    // Reuse the codegen helper that already owns it instead of restating it:
2237    // two copies of this rule silently drifting apart is a worse failure than
2238    // the one being fixed.
2239    let indices = without_rowid_pk_indices(table).map_err(codegen_error_to_franken)?;
2240
2241    let Some(Statement::CreateTable(create)) = parse_single_statement(create_sql) else {
2242        return Err(FrankenError::not_implemented(format!(
2243            "WITHOUT ROWID table `{}` cannot be rebuilt: its CREATE TABLE text does not parse, \
2244             so the primary-key direction its b-tree must be keyed by is unknown",
2245            table.name
2246        )));
2247    };
2248    let CreateTableBody::Columns {
2249        columns: column_defs,
2250        constraints,
2251    } = &create.body
2252    else {
2253        return Err(FrankenError::not_implemented(format!(
2254            "WITHOUT ROWID table `{}` cannot be rebuilt from a CREATE TABLE ... AS SELECT form: \
2255             it declares no primary-key columns to key the table b-tree by",
2256            table.name
2257        )));
2258    };
2259
2260    let desc_flags = collect_primary_key_desc_flags(column_defs, constraints)
2261        .into_iter()
2262        .next()
2263        .unwrap_or_default();
2264    if desc_flags.len() != indices.len() {
2265        return Err(FrankenError::not_implemented(format!(
2266            "WITHOUT ROWID table `{}` declares {} primary-key column(s) but its CREATE TABLE text \
2267             yields {} sort direction(s); refusing to rebuild rather than key the table b-tree by \
2268             a direction vector that does not describe it",
2269            table.name,
2270            indices.len(),
2271            desc_flags.len()
2272        )));
2273    }
2274
2275    let collations = indices
2276        .iter()
2277        .map(|&col_idx| {
2278            table
2279                .columns
2280                .get(col_idx)
2281                .and_then(|column| column.collation.clone())
2282        })
2283        .collect();
2284
2285    Ok(WithoutRowidPkLayout {
2286        indices,
2287        desc_flags,
2288        collations,
2289    })
2290}
2291
2292/// Initialize a page as an empty leaf table B-tree page (type 0x0D).
2293#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2294async fn init_leaf_table_page(
2295    cx: &Cx,
2296    txn: &mut impl TransactionHandle,
2297    page_no: PageNumber,
2298    full_page_size: usize,
2299    usable_size: u32,
2300) -> Result<()> {
2301    let mut page = vec![0u8; full_page_size];
2302    page[0] = 0x0D; // Leaf table
2303    // cell_count = 0 (bytes 3..5)
2304    page[3..5].copy_from_slice(&0u16.to_be_bytes());
2305    // cell content area starts at end of page
2306    // SQLite encodes a content offset of 65536 as 0 in the 2-byte header field.
2307    // For all other valid page sizes (512..=32768), the value fits in u16 directly.
2308    let content_start: u16 = if usable_size == 65536 {
2309        0
2310    } else {
2311        u16::try_from(usable_size).map_err(|_| {
2312            FrankenError::internal(format!(
2313                "usable_size {usable_size} does not fit in u16 and is not 65536"
2314            ))
2315        })?
2316    };
2317    page[5..7].copy_from_slice(&content_start.to_be_bytes());
2318    txn.write_page(cx, page_no, &page).await
2319}
2320
2321#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2322async fn init_leaf_index_page(
2323    cx: &Cx,
2324    txn: &mut impl TransactionHandle,
2325    page_no: PageNumber,
2326    full_page_size: usize,
2327    usable_size: u32,
2328) -> Result<()> {
2329    let mut page = vec![0u8; full_page_size];
2330    page[0] = 0x0A; // Leaf index (vs 0x0D for leaf table)
2331    page[3..5].copy_from_slice(&0u16.to_be_bytes());
2332    let content_start: u16 = if usable_size == 65536 {
2333        0
2334    } else {
2335        u16::try_from(usable_size).map_err(|_| {
2336            FrankenError::internal(format!(
2337                "usable_size {usable_size} does not fit in u16 and is not 65536"
2338            ))
2339        })?
2340    };
2341    page[5..7].copy_from_slice(&content_start.to_be_bytes());
2342    txn.write_page(cx, page_no, &page).await
2343}
2344
2345fn quote_identifier(identifier: &str) -> String {
2346    let escaped = identifier.replace('"', "\"\"");
2347    format!("\"{escaped}\"")
2348}
2349
2350fn append_fk_reference_clause(sql: &mut String, fk: &FkDef) {
2351    use std::fmt::Write as _;
2352
2353    let _ = write!(sql, " REFERENCES {}", quote_identifier(&fk.parent_table));
2354    if !fk.parent_columns.is_empty() {
2355        let parent_columns = fk
2356            .parent_columns
2357            .iter()
2358            .map(|column_name| quote_identifier(column_name))
2359            .collect::<Vec<_>>()
2360            .join(", ");
2361        let _ = write!(sql, "({parent_columns})");
2362    }
2363    if fk.on_delete != FkActionType::NoAction {
2364        let _ = write!(sql, " ON DELETE {}", fk_action_sql(fk.on_delete));
2365    }
2366    if fk.on_update != FkActionType::NoAction {
2367        let _ = write!(sql, " ON UPDATE {}", fk_action_sql(fk.on_update));
2368    }
2369    if fk.deferred {
2370        sql.push_str(" DEFERRABLE INITIALLY DEFERRED");
2371    }
2372}
2373
2374/// Reconstruct a `CREATE TABLE` statement from a `TableSchema`.
2375pub(crate) fn build_create_table_sql(table: &TableSchema) -> String {
2376    build_create_table_sql_with_implicit_index_predicate(table, |index| {
2377        parse_autoindex_ordinal(&index.name, &table.name).is_some()
2378    })
2379}
2380
2381fn build_create_table_sql_with_implicit_index_predicate<F>(
2382    table: &TableSchema,
2383    is_implicit_autoindex: F,
2384) -> String
2385where
2386    F: Fn(&IndexSchema) -> bool,
2387{
2388    use std::fmt::Write as _;
2389    let mut sql = format!("CREATE TABLE {} (", quote_identifier(&table.name));
2390    let is_single_column_primary_key = |column_name: &str| {
2391        table
2392            .primary_key_constraints
2393            .iter()
2394            .any(|pk| pk.len() == 1 && pk[0].eq_ignore_ascii_case(column_name))
2395    };
2396    let primary_key_matches_index = |index: &fsqlite_vdbe::codegen::IndexSchema| {
2397        table.primary_key_constraints.iter().any(|pk| {
2398            pk.len() == index.columns.len()
2399                && pk
2400                    .iter()
2401                    .zip(index.columns.iter())
2402                    .all(|(lhs, rhs): (&String, &String)| lhs.eq_ignore_ascii_case(rhs))
2403        })
2404    };
2405    for (i, col) in table.columns.iter().enumerate() {
2406        if i > 0 {
2407            sql.push_str(", ");
2408        }
2409        sql.push_str(&quote_identifier(&col.name));
2410        if let Some(type_kw) = col.type_name.as_deref() {
2411            let _ = write!(sql, " {type_kw}");
2412        }
2413        if col.is_ipk {
2414            sql.push_str(" PRIMARY KEY");
2415        }
2416        if col.notnull && !col.is_ipk {
2417            sql.push_str(" NOT NULL");
2418        }
2419        if col.unique && !col.is_ipk && !is_single_column_primary_key(&col.name) {
2420            sql.push_str(" UNIQUE");
2421        }
2422        if let Some(ref default) = col.default_value {
2423            sql.push_str(" DEFAULT ");
2424            sql.push_str(default);
2425        }
2426        if let Some(ref collation) = col.collation {
2427            sql.push_str(" COLLATE ");
2428            sql.push_str(&quote_identifier(collation));
2429        }
2430        if let Some(ref gen_expr) = col.generated_expr {
2431            sql.push_str(" GENERATED ALWAYS AS (");
2432            sql.push_str(gen_expr);
2433            sql.push(')');
2434            if col.generated_stored == Some(true) {
2435                sql.push_str(" STORED");
2436            } else {
2437                sql.push_str(" VIRTUAL");
2438            }
2439        }
2440        for check in table.check_constraints.iter().filter(|check| {
2441            check
2442                .owner_column
2443                .as_deref()
2444                .is_some_and(|owner| owner.eq_ignore_ascii_case(&col.name))
2445        }) {
2446            let _ = write!(sql, " CHECK({})", check.expr);
2447        }
2448        for fk in table.foreign_keys.iter().filter(|fk| {
2449            fk.owner_column
2450                .as_deref()
2451                .is_some_and(|owner| owner.eq_ignore_ascii_case(&col.name))
2452        }) {
2453            append_fk_reference_clause(&mut sql, fk);
2454        }
2455    }
2456    // Emit PRIMARY KEY constraints BEFORE UNIQUE constraints.  Stock SQLite
2457    // assigns autoindex ordinals (sqlite_autoindex_T_N) in the order they
2458    // appear: first column-level PK, then table-level PK, then column-level
2459    // UNIQUE, then table-level UNIQUE.  If we emit UNIQUE before PRIMARY KEY,
2460    // the ordinal-to-definition mapping in
2461    // `infer_implicit_index_definition_from_master_entries` will assign the
2462    // wrong columns to autoindexes, corrupting the schema on reload (#239).
2463    for pk in &table.primary_key_constraints {
2464        if pk.len() == 1
2465            && table
2466                .columns
2467                .iter()
2468                .any(|column| column.is_ipk && column.name.eq_ignore_ascii_case(&pk[0]))
2469        {
2470            continue;
2471        }
2472        let cols = pk
2473            .iter()
2474            .map(|name| quote_identifier(name))
2475            .collect::<Vec<_>>()
2476            .join(", ");
2477        let _ = write!(sql, ", PRIMARY KEY ({cols})");
2478    }
2479    for index in &table.indexes {
2480        if !index.is_unique || index.columns.is_empty() || primary_key_matches_index(index) {
2481            continue;
2482        }
2483        // Only emit table-level UNIQUE for autoindexes.  Explicitly-named
2484        // indexes (e.g. `idx_issues_external_ref_unique`) are written as
2485        // separate CREATE INDEX entries in sqlite_master; emitting them
2486        // here as well would create a phantom sqlite_autoindex that stock
2487        // SQLite tries to populate, causing "wrong # of entries" errors.
2488        if !is_implicit_autoindex(index) {
2489            continue;
2490        }
2491        if index.columns.len() == 1
2492            && table.columns.iter().any(|column| {
2493                column.unique
2494                    && !column.is_ipk
2495                    && column.name.eq_ignore_ascii_case(&index.columns[0])
2496            })
2497        {
2498            continue;
2499        }
2500        let cols = index
2501            .columns
2502            .iter()
2503            .map(|name| quote_identifier(name))
2504            .collect::<Vec<_>>()
2505            .join(", ");
2506        let _ = write!(sql, ", UNIQUE ({cols})");
2507    }
2508    for fk in table
2509        .foreign_keys
2510        .iter()
2511        .filter(|fk| fk.owner_column.is_none())
2512    {
2513        let child_columns = fk
2514            .child_columns
2515            .iter()
2516            .filter_map(|&column_index| table.columns.get(column_index))
2517            .map(|column| quote_identifier(&column.name))
2518            .collect::<Vec<_>>();
2519        if child_columns.is_empty() {
2520            continue;
2521        }
2522        let _ = write!(sql, ", FOREIGN KEY({})", child_columns.join(", "));
2523        append_fk_reference_clause(&mut sql, fk);
2524    }
2525    for check in table
2526        .check_constraints
2527        .iter()
2528        .filter(|check| check.owner_column.is_none())
2529    {
2530        let _ = write!(sql, ", CHECK({})", check.expr);
2531    }
2532    sql.push(')');
2533    let mut table_options = Vec::new();
2534    if table.without_rowid {
2535        table_options.push("WITHOUT ROWID");
2536    }
2537    if table.strict {
2538        table_options.push("STRICT");
2539    }
2540    if !table_options.is_empty() {
2541        sql.push(' ');
2542        sql.push_str(&table_options.join(", "));
2543    }
2544    sql
2545}
2546
2547const fn fk_action_sql(action: FkActionType) -> &'static str {
2548    match action {
2549        FkActionType::NoAction => "NO ACTION",
2550        FkActionType::Restrict => "RESTRICT",
2551        FkActionType::SetNull => "SET NULL",
2552        FkActionType::SetDefault => "SET DEFAULT",
2553        FkActionType::Cascade => "CASCADE",
2554    }
2555}
2556
2557pub(crate) fn extract_primary_key_constraints_from_sql(sql: &str) -> Vec<Vec<String>> {
2558    let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2559        return Vec::new();
2560    };
2561    let CreateTableBody::Columns {
2562        columns,
2563        constraints,
2564    } = &create.body
2565    else {
2566        return Vec::new();
2567    };
2568
2569    let mut primary_keys = columns
2570        .iter()
2571        .filter(|column| {
2572            column.constraints.iter().any(|constraint| {
2573                matches!(constraint.kind, ColumnConstraintKind::PrimaryKey { .. })
2574            })
2575        })
2576        .map(|column| vec![column.name.clone()])
2577        .collect::<Vec<_>>();
2578
2579    primary_keys.extend(constraints.iter().filter_map(|constraint| {
2580        let TableConstraintKind::PrimaryKey {
2581            columns: indexed_columns,
2582            ..
2583        } = &constraint.kind
2584        else {
2585            return None;
2586        };
2587        let columns = indexed_columns
2588            .iter()
2589            .filter_map(indexed_column_name)
2590            .map(str::to_owned)
2591            .collect::<Vec<_>>();
2592        (!columns.is_empty()).then_some(columns)
2593    }));
2594
2595    primary_keys
2596}
2597
2598#[cfg(test)]
2599fn extract_unique_constraint_indexes_from_sql(
2600    sql: &str,
2601    table_name: &str,
2602) -> Result<Vec<IndexSchema>> {
2603    let slots = extract_implicit_autoindex_slots_from_sql(sql, table_name)?;
2604    Ok(slots
2605        .into_iter()
2606        .enumerate()
2607        .filter(|(_, slot)| !slot.is_hidden_without_rowid_primary_key())
2608        .map(|(slot_index, slot)| slot.into_index_schema(table_name, slot_index + 1, 0))
2609        .collect())
2610}
2611
2612#[cfg(test)]
2613fn extract_implicit_autoindex_slots_from_sql(
2614    sql: &str,
2615    table_name: &str,
2616) -> Result<Vec<ImplicitAutoindexSlot>> {
2617    let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2618        return Err(FrankenError::DatabaseCorrupt {
2619            detail: format!("could not parse CREATE TABLE SQL for `{table_name}`"),
2620        });
2621    };
2622    if !create.name.name.eq_ignore_ascii_case(table_name) {
2623        return Err(FrankenError::DatabaseCorrupt {
2624            detail: format!(
2625                "CREATE TABLE SQL for `{table_name}` declares `{}`",
2626                create.name.name
2627            ),
2628        });
2629    }
2630    let CreateTableBody::Columns {
2631        columns,
2632        constraints,
2633    } = &create.body
2634    else {
2635        return Ok(Vec::new());
2636    };
2637    implicit_autoindex_layout(columns, constraints, create.without_rowid)
2638}
2639
2640pub(crate) fn extract_foreign_keys_from_sql(sql: &str, columns: &[ColumnInfo]) -> Vec<FkDef> {
2641    let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2642        return extract_foreign_keys_sql_fallback(sql, columns);
2643    };
2644    let CreateTableBody::Columns {
2645        columns: column_defs,
2646        constraints,
2647    } = &create.body
2648    else {
2649        return Vec::new();
2650    };
2651
2652    let mut foreign_keys = Vec::new();
2653    for (column_index, column) in column_defs.iter().enumerate() {
2654        for constraint in &column.constraints {
2655            if let ColumnConstraintKind::ForeignKey(clause) = &constraint.kind {
2656                foreign_keys.push(fk_clause_to_def(
2657                    &[column_index],
2658                    Some(column.name.clone()),
2659                    clause,
2660                ));
2661            }
2662        }
2663    }
2664    for constraint in constraints {
2665        if let TableConstraintKind::ForeignKey {
2666            columns: child_columns,
2667            clause,
2668        } = &constraint.kind
2669        {
2670            let child_indices = child_columns
2671                .iter()
2672                .filter_map(|column_name| {
2673                    columns
2674                        .iter()
2675                        .position(|column| column.name.eq_ignore_ascii_case(column_name))
2676                })
2677                .collect::<Vec<_>>();
2678            if !child_indices.is_empty() {
2679                foreign_keys.push(fk_clause_to_def(&child_indices, None, clause));
2680            }
2681        }
2682    }
2683
2684    foreign_keys
2685}
2686
2687fn extract_foreign_keys_sql_fallback(sql: &str, columns: &[ColumnInfo]) -> Vec<FkDef> {
2688    let Some(open) = find_unquoted_sql_char(sql, '(') else {
2689        return Vec::new();
2690    };
2691    let Some(close) = find_matching_sql_paren(sql, open) else {
2692        return Vec::new();
2693    };
2694    let mut foreign_keys = Vec::new();
2695
2696    for definition in split_top_level_csv_items(&sql[open + 1..close]) {
2697        if starts_with_unquoted_table_constraint(&definition) {
2698            let Some(foreign_pos) = find_top_level_unquoted_sql_keyword(&definition, "FOREIGN")
2699            else {
2700                continue;
2701            };
2702            let after_foreign = &definition[foreign_pos + "FOREIGN".len()..];
2703            let Some(key_pos) = find_top_level_unquoted_sql_keyword(after_foreign, "KEY") else {
2704                continue;
2705            };
2706            let after_key = &after_foreign[key_pos + "KEY".len()..];
2707            let child_list = trim_leading_sql_space_and_comments(after_key);
2708            if !child_list.starts_with('(') {
2709                continue;
2710            }
2711            let open_paren = definition.len() - child_list.len();
2712            let Some(close_paren) = find_matching_sql_paren(&definition, open_paren) else {
2713                continue;
2714            };
2715            let Some(child_names) =
2716                parse_sql_identifier_list(&definition[open_paren + 1..close_paren])
2717            else {
2718                continue;
2719            };
2720            let Some(child_indices) = child_names
2721                .iter()
2722                .map(|name| {
2723                    columns
2724                        .iter()
2725                        .position(|column| column.name.eq_ignore_ascii_case(name))
2726                })
2727                .collect::<Option<Vec<_>>>()
2728            else {
2729                continue;
2730            };
2731            if let Some(fk) =
2732                parse_fk_reference_sql(&definition[close_paren + 1..], &child_indices, None)
2733            {
2734                foreign_keys.push(fk);
2735            }
2736            continue;
2737        }
2738
2739        let Some((column_name, remainder)) = parse_column_name_and_remainder(&definition) else {
2740            continue;
2741        };
2742        let Some(column_index) = columns
2743            .iter()
2744            .position(|column| column.name.eq_ignore_ascii_case(&column_name))
2745        else {
2746            continue;
2747        };
2748        if let Some(fk) = parse_fk_reference_sql(remainder, &[column_index], Some(column_name)) {
2749            foreign_keys.push(fk);
2750        }
2751    }
2752
2753    foreign_keys
2754}
2755
2756fn parse_sql_identifier_list(input: &str) -> Option<Vec<String>> {
2757    split_top_level_csv_items(input)
2758        .into_iter()
2759        .map(|item| {
2760            let (name, remainder) = parse_column_name_and_remainder(&item)?;
2761            trim_leading_sql_space_and_comments(remainder)
2762                .is_empty()
2763                .then_some(name)
2764        })
2765        .collect()
2766}
2767
2768fn parse_fk_reference_sql(
2769    input: &str,
2770    child_indices: &[usize],
2771    owner_column: Option<String>,
2772) -> Option<FkDef> {
2773    let references_pos = find_top_level_unquoted_sql_keyword(input, "REFERENCES")?;
2774    let after_references =
2775        trim_leading_sql_space_and_comments(&input[references_pos + "REFERENCES".len()..]);
2776    let (parent_table, after_parent_table) = parse_fk_parent_table(after_references)?;
2777    let after_parent_table = trim_leading_sql_space_and_comments(after_parent_table);
2778    let (parent_columns, action_sql) = if after_parent_table.starts_with('(') {
2779        let open_paren = input.len() - after_parent_table.len();
2780        let close_paren = find_matching_sql_paren(input, open_paren)?;
2781        (
2782            parse_sql_identifier_list(&input[open_paren + 1..close_paren])?,
2783            &input[close_paren + 1..],
2784        )
2785    } else {
2786        (Vec::new(), after_parent_table)
2787    };
2788    let tokens = collect_unquoted_sql_keyword_tokens(action_sql)
2789        .into_iter()
2790        .map(|(token, _)| token)
2791        .collect::<Vec<_>>();
2792    let mut on_delete = FkActionType::NoAction;
2793    let mut on_update = FkActionType::NoAction;
2794    for (index, token) in tokens.iter().enumerate() {
2795        if token != "ON" || index + 2 >= tokens.len() {
2796            continue;
2797        }
2798        let action = match tokens[index + 2].as_str() {
2799            "CASCADE" => Some(FkActionType::Cascade),
2800            "RESTRICT" => Some(FkActionType::Restrict),
2801            "SET" if tokens.get(index + 3).is_some_and(|token| token == "NULL") => {
2802                Some(FkActionType::SetNull)
2803            }
2804            "SET"
2805                if tokens
2806                    .get(index + 3)
2807                    .is_some_and(|token| token == "DEFAULT") =>
2808            {
2809                Some(FkActionType::SetDefault)
2810            }
2811            "NO" if tokens.get(index + 3).is_some_and(|token| token == "ACTION") => {
2812                Some(FkActionType::NoAction)
2813            }
2814            _ => None,
2815        };
2816        match (tokens[index + 1].as_str(), action) {
2817            ("DELETE", Some(action)) => on_delete = action,
2818            ("UPDATE", Some(action)) => on_update = action,
2819            _ => {}
2820        }
2821    }
2822    let deferred = tokens.windows(3).enumerate().any(|(index, window)| {
2823        (index == 0 || tokens[index - 1] != "NOT")
2824            && window[0] == "DEFERRABLE"
2825            && window[1] == "INITIALLY"
2826            && window[2] == "DEFERRED"
2827    });
2828
2829    Some(FkDef {
2830        child_columns: child_indices.to_vec(),
2831        owner_column,
2832        parent_table,
2833        parent_columns,
2834        on_delete,
2835        on_update,
2836        deferred,
2837    })
2838}
2839
2840fn parse_fk_parent_table(input: &str) -> Option<(String, &str)> {
2841    let trimmed = trim_leading_sql_space_and_comments(input);
2842    if trimmed.is_empty() {
2843        return None;
2844    }
2845    let (name_raw, remainder) = match trimmed.as_bytes()[0] {
2846        b'"' => parse_quoted_identifier(trimmed, b'"', b'"')?,
2847        b'`' => parse_quoted_identifier(trimmed, b'`', b'`')?,
2848        b'[' => parse_bracket_identifier(trimmed)?,
2849        _ => {
2850            let mut chars = trimmed.char_indices().peekable();
2851            let mut end = trimmed.len();
2852            while let Some((index, ch)) = chars.next() {
2853                let starts_comment = matches!(ch, '-' | '/')
2854                    && chars.peek().is_some_and(|(_, next)| {
2855                        (ch == '-' && *next == '-') || (ch == '/' && *next == '*')
2856                    });
2857                if ch.is_whitespace() || ch == '(' || starts_comment {
2858                    end = index;
2859                    break;
2860                }
2861            }
2862            (&trimmed[..end], &trimmed[end..])
2863        }
2864    };
2865    (!name_raw.is_empty()).then(|| {
2866        (
2867            strip_identifier_quotes(name_raw),
2868            trim_leading_sql_space_and_comments(remainder),
2869        )
2870    })
2871}
2872
2873fn fk_clause_to_def(
2874    child_indices: &[usize],
2875    owner_column: Option<String>,
2876    clause: &fsqlite_ast::ForeignKeyClause,
2877) -> FkDef {
2878    let mut on_delete = FkActionType::NoAction;
2879    let mut on_update = FkActionType::NoAction;
2880    for action in &clause.actions {
2881        let action_type = match action.action {
2882            fsqlite_ast::ForeignKeyActionType::SetNull => FkActionType::SetNull,
2883            fsqlite_ast::ForeignKeyActionType::SetDefault => FkActionType::SetDefault,
2884            fsqlite_ast::ForeignKeyActionType::Cascade => FkActionType::Cascade,
2885            fsqlite_ast::ForeignKeyActionType::Restrict => FkActionType::Restrict,
2886            fsqlite_ast::ForeignKeyActionType::NoAction => FkActionType::NoAction,
2887        };
2888        match action.trigger {
2889            fsqlite_ast::ForeignKeyTrigger::OnDelete => on_delete = action_type,
2890            fsqlite_ast::ForeignKeyTrigger::OnUpdate => on_update = action_type,
2891        }
2892    }
2893    let deferred = clause.deferrable.as_ref().is_some_and(|d| {
2894        !d.not
2895            && matches!(
2896                d.initially,
2897                Some(fsqlite_ast::DeferrableInitially::Deferred)
2898            )
2899    });
2900    FkDef {
2901        child_columns: child_indices.to_vec(),
2902        owner_column,
2903        parent_table: clause.table.clone(),
2904        parent_columns: clause.columns.clone(),
2905        on_delete,
2906        on_update,
2907        deferred,
2908    }
2909}
2910
2911/// Indexed term metadata used to reconstruct `CREATE INDEX` SQL.
2912#[derive(Debug, Clone, Copy)]
2913#[allow(dead_code)]
2914pub(crate) struct CreateIndexSqlTerm<'a> {
2915    pub(crate) column_name: &'a str,
2916    pub(crate) collation: Option<&'a str>,
2917    pub(crate) direction: Option<SortDirection>,
2918}
2919
2920/// Reconstruct a `CREATE INDEX` statement from index metadata.
2921/// Needed for sqlite_master row generation during schema persistence — not
2922/// yet wired into the live schema write-back path.
2923#[allow(dead_code)]
2924pub(crate) fn build_create_index_sql(
2925    index_name: &str,
2926    table_name: &str,
2927    unique: bool,
2928    terms: &[CreateIndexSqlTerm<'_>],
2929    where_clause: Option<&fsqlite_ast::Expr>,
2930) -> String {
2931    use std::fmt::Write as _;
2932    let mut sql = if unique {
2933        format!(
2934            "CREATE UNIQUE INDEX {} ON {} (",
2935            quote_identifier(index_name),
2936            quote_identifier(table_name)
2937        )
2938    } else {
2939        format!(
2940            "CREATE INDEX {} ON {} (",
2941            quote_identifier(index_name),
2942            quote_identifier(table_name)
2943        )
2944    };
2945    for (i, term) in terms.iter().enumerate() {
2946        if i > 0 {
2947            sql.push_str(", ");
2948        }
2949        sql.push_str(&quote_identifier(term.column_name));
2950        if let Some(collation) = term.collation {
2951            let _ = write!(sql, " COLLATE {}", quote_identifier(collation));
2952        }
2953        match term.direction {
2954            Some(SortDirection::Asc) => sql.push_str(" ASC"),
2955            Some(SortDirection::Desc) => sql.push_str(" DESC"),
2956            None => {}
2957        }
2958    }
2959    sql.push(')');
2960    if let Some(expr) = where_clause {
2961        let _ = write!(sql, " WHERE {expr}");
2962    }
2963    sql
2964}
2965
2966fn build_create_expression_index_sql(
2967    index_name: &str,
2968    table_name: &str,
2969    unique: bool,
2970    expressions: &[String],
2971    collations: &[Option<String>],
2972    directions: &[SortDirection],
2973    where_clause: Option<&str>,
2974) -> String {
2975    use std::fmt::Write as _;
2976    let mut sql = if unique {
2977        format!(
2978            "CREATE UNIQUE INDEX {} ON {} (",
2979            quote_identifier(index_name),
2980            quote_identifier(table_name)
2981        )
2982    } else {
2983        format!(
2984            "CREATE INDEX {} ON {} (",
2985            quote_identifier(index_name),
2986            quote_identifier(table_name)
2987        )
2988    };
2989    for (i, expr) in expressions.iter().enumerate() {
2990        if i > 0 {
2991            sql.push_str(", ");
2992        }
2993        sql.push_str(expr);
2994        let expression_already_declares_collation = unquoted_sql_keyword_tokens(expr)
2995            .iter()
2996            .any(|token| token == "COLLATE");
2997        if !expression_already_declares_collation
2998            && let Some(collation) = collations.get(i).and_then(|c| c.as_deref())
2999        {
3000            let _ = write!(sql, " COLLATE {}", quote_identifier(collation));
3001        }
3002        match directions.get(i).copied() {
3003            Some(SortDirection::Asc) => sql.push_str(" ASC"),
3004            Some(SortDirection::Desc) => sql.push_str(" DESC"),
3005            None => {}
3006        }
3007    }
3008    sql.push(')');
3009    if let Some(predicate) = where_clause {
3010        let _ = write!(sql, " WHERE {predicate}");
3011    }
3012    sql
3013}
3014
3015/// Parse column info from a CREATE TABLE SQL string.
3016///
3017/// This is a best-effort parser that handles the common case of
3018/// `CREATE TABLE "name" ("col1" TYPE, "col2" TYPE, ...)`.
3019/// Extracts column names and affinities from the column definitions.
3020/// Used by `load_from_sqlite` and `reload_memdb_from_pager` (bd-1ene).
3021pub fn parse_columns_from_create_sql(sql: &str) -> Vec<ColumnInfo> {
3022    if let Some(columns) = try_parse_columns_from_create_sql_ast(sql) {
3023        return columns;
3024    }
3025
3026    let is_strict = is_strict_table_sql(sql);
3027    let is_without_rowid = is_without_rowid_table_sql(sql);
3028    // Find the parenthesized column list.
3029    let Some(open) = find_unquoted_sql_char(sql, '(') else {
3030        return Vec::new();
3031    };
3032    let Some(close) = find_matching_sql_paren(sql, open) else {
3033        return Vec::new();
3034    };
3035
3036    let body = &sql[open + 1..close];
3037    split_top_level_csv_items(body)
3038        .into_iter()
3039        .filter_map(|col_def| {
3040            if starts_with_unquoted_table_constraint(&col_def) {
3041                return None;
3042            }
3043
3044            let (name, remainder) = parse_column_name_and_remainder(&col_def)?;
3045            let tokens: Vec<&str> = remainder.split_whitespace().collect();
3046            let type_decl = extract_type_declaration(&tokens);
3047            let affinity = type_to_affinity(&type_decl);
3048            let keyword_tokens = unquoted_sql_keyword_tokens(remainder);
3049            let has_primary_key =
3050                unquoted_tokens_contain_phrase(&keyword_tokens, &["PRIMARY", "KEY"]);
3051            let has_primary_key_desc =
3052                unquoted_tokens_contain_phrase(&keyword_tokens, &["PRIMARY", "KEY", "DESC"]);
3053            let has_unique = keyword_tokens
3054                .iter()
3055                .any(|keyword| matches!(keyword.as_str(), "UNIQUE"));
3056            let has_not_null = unquoted_tokens_contain_phrase(&keyword_tokens, &["NOT", "NULL"]);
3057            let is_ipk = !is_without_rowid
3058                && has_primary_key
3059                && !has_primary_key_desc
3060                && type_decl.eq_ignore_ascii_case("INTEGER");
3061            let type_name = if type_decl.is_empty() {
3062                None
3063            } else {
3064                Some(type_decl)
3065            };
3066            let strict_type = if is_strict {
3067                type_name
3068                    .as_deref()
3069                    .and_then(StrictColumnType::from_type_name)
3070            } else {
3071                None
3072            };
3073
3074            let default_value = extract_default_value(remainder);
3075
3076            let collation = extract_collation_name(remainder);
3077            let (generated_expr, generated_stored) = extract_generated_column_clause(remainder);
3078
3079            Some(ColumnInfo {
3080                name,
3081                affinity,
3082                is_ipk,
3083                type_name,
3084                notnull: has_not_null,
3085                unique: has_unique || has_primary_key,
3086                default_value,
3087                strict_type,
3088                generated_expr,
3089                generated_stored,
3090                collation,
3091                conflict_action: None,
3092            })
3093        })
3094        .collect()
3095}
3096
3097/// Extract column metadata from sqlite_master SQL for both ordinary and
3098/// materialized virtual tables.
3099#[must_use]
3100pub fn parse_columns_from_sqlite_master_sql(sql: &str) -> Vec<ColumnInfo> {
3101    if is_virtual_table_sql(sql) {
3102        return parse_virtual_table_columns_from_sql(sql)
3103            .unwrap_or_else(|| parse_columns_from_create_sql(sql));
3104    }
3105    parse_columns_from_create_sql(sql)
3106}
3107
3108pub(crate) fn validate_sqlite_master_root_page(name: &str, root_page_num: i64) -> Result<u32> {
3109    if root_page_num <= 0 {
3110        return Err(FrankenError::DatabaseCorrupt {
3111            detail: format!("sqlite_master entry `{name}` has invalid rootpage {root_page_num}"),
3112        });
3113    }
3114
3115    let root_page_u32 =
3116        u32::try_from(root_page_num).map_err(|_| FrankenError::DatabaseCorrupt {
3117            detail: format!(
3118                "sqlite_master entry `{name}` has out-of-range rootpage {root_page_num}"
3119            ),
3120        })?;
3121    i32::try_from(root_page_u32).map_err(|_| FrankenError::DatabaseCorrupt {
3122        detail: format!(
3123            "sqlite_master entry `{name}` has rootpage {root_page_num} that exceeds supported range"
3124        ),
3125    })?;
3126    Ok(root_page_u32)
3127}
3128
3129fn is_virtual_table_sql(sql: &str) -> bool {
3130    sql.trim_start()
3131        .to_ascii_uppercase()
3132        .starts_with("CREATE VIRTUAL TABLE")
3133}
3134
3135#[must_use]
3136pub fn is_without_rowid_table_sql(sql: &str) -> bool {
3137    if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3138        return create.without_rowid;
3139    }
3140
3141    let Some(close_paren) = sql.rfind(')') else {
3142        return false;
3143    };
3144    let tail = &sql[close_paren + 1..];
3145    unquoted_tokens_contain_phrase(&unquoted_sql_keyword_tokens(tail), &["WITHOUT", "ROWID"])
3146}
3147
3148fn parse_virtual_table_columns_from_sql(sql: &str) -> Option<Vec<ColumnInfo>> {
3149    let mut parser = Parser::from_sql(sql);
3150    let (statements, errors) = parser.parse_all();
3151    if !errors.is_empty() || statements.len() != 1 {
3152        return None;
3153    }
3154    match statements.into_iter().next()? {
3155        Statement::CreateVirtualTable(create) => {
3156            Some(parse_virtual_table_column_infos(&create.args))
3157        }
3158        _ => None,
3159    }
3160}
3161
3162fn parse_virtual_table_column_infos(args: &[String]) -> Vec<ColumnInfo> {
3163    let mut columns = Vec::new();
3164    let mut seen = std::collections::HashSet::<String>::new();
3165
3166    for arg in args {
3167        let trimmed = arg.trim();
3168        if trimmed.is_empty() || trimmed.contains('=') {
3169            continue;
3170        }
3171        let raw_name = trimmed
3172            .split_whitespace()
3173            .next()
3174            .unwrap_or_default()
3175            .trim_matches(|ch| matches!(ch, '"' | '\'' | '`' | '[' | ']'));
3176        if raw_name.is_empty() {
3177            continue;
3178        }
3179        let key = raw_name.to_ascii_lowercase();
3180        if !seen.insert(key) {
3181            continue;
3182        }
3183        columns.push(ColumnInfo {
3184            name: raw_name.to_owned(),
3185            // bd-76k72: a virtual-table column declared without a type (FTS5,
3186            // rtree, table-valued functions are all bare names here) has
3187            // NONE/BLOB affinity per SQLite, not NUMERIC. NUMERIC ('C') broke
3188            // comparison affinity — combine('C', 'D' INTEGER) yields 0 (no
3189            // coercion), so a TEXT vtab value like an FTS5 `message_id` of "7"
3190            // was never coerced to match an INTEGER PK (the FTS5-join-RHS red).
3191            // NONE ('A') combines to NUMERIC against an integer column (coerces
3192            // "7" -> 7) and is a no-op for numeric-vs-numeric (rtree coordinates
3193            // still compare by storage class).
3194            affinity: 'A',
3195            is_ipk: false,
3196            type_name: None,
3197            notnull: false,
3198            unique: false,
3199            default_value: None,
3200            strict_type: None,
3201            generated_expr: None,
3202            generated_stored: None,
3203            collation: None,
3204            conflict_action: None,
3205        });
3206    }
3207
3208    if columns.is_empty() {
3209        columns.push(ColumnInfo {
3210            name: "content".to_owned(),
3211            // bd-76k72: typeless vtab column -> NONE affinity (see above).
3212            affinity: 'A',
3213            is_ipk: false,
3214            type_name: None,
3215            notnull: false,
3216            unique: false,
3217            default_value: None,
3218            strict_type: None,
3219            generated_expr: None,
3220            generated_stored: None,
3221            collation: None,
3222            conflict_action: None,
3223        });
3224    }
3225
3226    columns
3227}
3228
3229/// Return true when CREATE TABLE SQL declares the table as STRICT.
3230#[must_use]
3231pub fn is_strict_table_sql(sql: &str) -> bool {
3232    if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3233        return create.strict;
3234    }
3235
3236    let Some(close_paren) = sql.rfind(')') else {
3237        return false;
3238    };
3239    let tail = &sql[close_paren + 1..];
3240    unquoted_sql_keyword_tokens(tail)
3241        .iter()
3242        .any(|keyword| matches!(keyword.as_str(), "STRICT"))
3243}
3244
3245/// Return true when CREATE TABLE SQL declares AUTOINCREMENT.
3246#[must_use]
3247pub fn is_autoincrement_table_sql(sql: &str) -> bool {
3248    if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3249        return autoincrement_from_create_table_statement(&create);
3250    }
3251
3252    unquoted_sql_keyword_tokens(sql)
3253        .iter()
3254        .any(|keyword| matches!(keyword.as_str(), "AUTOINCREMENT"))
3255}
3256
3257pub(crate) fn autoincrement_from_create_table_statement(create: &CreateTableStatement) -> bool {
3258    let CreateTableBody::Columns { columns, .. } = &create.body else {
3259        return false;
3260    };
3261    columns.iter().any(|col| {
3262        let is_integer = col
3263            .type_name
3264            .as_ref()
3265            .is_some_and(|tn| tn.name.eq_ignore_ascii_case("INTEGER"));
3266        is_integer
3267            && col.constraints.iter().any(|constraint| {
3268                matches!(
3269                    &constraint.kind,
3270                    ColumnConstraintKind::PrimaryKey {
3271                        autoincrement: true,
3272                        direction,
3273                        ..
3274                    } if *direction != Some(SortDirection::Desc)
3275                )
3276            })
3277    })
3278}
3279
3280/// Extract CHECK constraint expressions from a CREATE TABLE SQL string.
3281///
3282/// Finds `CHECK(...)` clauses in the column-def body and returns the
3283/// expression text (inside the parentheses) for each one.
3284#[must_use]
3285pub fn extract_check_constraints_from_sql(sql: &str) -> Vec<String> {
3286    extract_check_constraints_with_owners_from_sql(sql)
3287        .into_iter()
3288        .map(|check| check.expr)
3289        .collect()
3290}
3291
3292pub(crate) fn extract_check_constraints_with_owners_from_sql(sql: &str) -> Vec<CheckConstraint> {
3293    if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3294        return check_constraints_from_create_table_statement(&create);
3295    }
3296
3297    extract_check_constraints_with_owners_sql_fallback(sql)
3298}
3299
3300fn extract_check_constraints_with_owners_sql_fallback(sql: &str) -> Vec<CheckConstraint> {
3301    let Some(open) = find_unquoted_sql_char(sql, '(') else {
3302        return Vec::new();
3303    };
3304    let Some(close) = find_matching_sql_paren(sql, open) else {
3305        return Vec::new();
3306    };
3307    let body = &sql[open + 1..close];
3308    let mut checks = Vec::new();
3309
3310    for definition in split_top_level_csv_items(body) {
3311        let owner_column = if starts_with_unquoted_table_constraint(&definition) {
3312            None
3313        } else {
3314            parse_column_name_and_remainder(&definition).map(|(name, _)| name)
3315        };
3316        let mut search_from = 0_usize;
3317        while let Some(relative_check) =
3318            find_unquoted_sql_keyword(&definition[search_from..], "CHECK")
3319        {
3320            let check_start = search_from + relative_check;
3321            let after_keyword = &definition[check_start + "CHECK".len()..];
3322            let after_space_and_comments = trim_leading_sql_space_and_comments(after_keyword);
3323            let skipped = after_keyword.len() - after_space_and_comments.len();
3324            let open_paren = check_start + "CHECK".len() + skipped;
3325            if !after_space_and_comments.starts_with('(') {
3326                search_from = check_start + "CHECK".len();
3327                continue;
3328            }
3329            let Some(close_paren) = find_matching_sql_paren(&definition, open_paren) else {
3330                break;
3331            };
3332            checks.push(CheckConstraint {
3333                expr: definition[open_paren + 1..close_paren].trim().to_owned(),
3334                owner_column: owner_column.clone(),
3335            });
3336            search_from = close_paren + 1;
3337        }
3338    }
3339    checks
3340}
3341
3342pub(crate) fn check_constraints_from_create_table_statement(
3343    create: &CreateTableStatement,
3344) -> Vec<CheckConstraint> {
3345    let CreateTableBody::Columns {
3346        columns,
3347        constraints,
3348    } = &create.body
3349    else {
3350        return Vec::new();
3351    };
3352    let mut checks = Vec::new();
3353    for column in columns {
3354        for constraint in &column.constraints {
3355            if let ColumnConstraintKind::Check(expr) = &constraint.kind {
3356                checks.push(CheckConstraint {
3357                    expr: expr.to_string(),
3358                    owner_column: Some(column.name.clone()),
3359                });
3360            }
3361        }
3362    }
3363    for constraint in constraints {
3364        if let TableConstraintKind::Check(expr) = &constraint.kind {
3365            checks.push(CheckConstraint {
3366                expr: expr.to_string(),
3367                owner_column: None,
3368            });
3369        }
3370    }
3371    checks
3372}
3373
3374fn parse_column_name_and_remainder(def: &str) -> Option<(String, &str)> {
3375    let trimmed = def.trim_start();
3376    if trimmed.is_empty() {
3377        return None;
3378    }
3379    let bytes = trimmed.as_bytes();
3380    let (name_raw, remainder) = match bytes[0] {
3381        b'"' => parse_quoted_identifier(trimmed, b'"', b'"')?,
3382        b'`' => parse_quoted_identifier(trimmed, b'`', b'`')?,
3383        b'[' => parse_bracket_identifier(trimmed)?,
3384        _ => {
3385            let end = find_unquoted_name_end(trimmed);
3386            (&trimmed[..end], &trimmed[end..])
3387        }
3388    };
3389    Some((
3390        strip_identifier_quotes(name_raw),
3391        trim_leading_sql_space_and_comments(remainder),
3392    ))
3393}
3394
3395fn parse_single_statement(sql: &str) -> Option<Statement> {
3396    let mut parser = Parser::from_sql(sql);
3397    let (statements, errors) = parser.parse_all();
3398    if !errors.is_empty() || statements.len() != 1 {
3399        return None;
3400    }
3401    statements.into_iter().next()
3402}
3403
3404fn format_default_value(dv: &DefaultValue) -> String {
3405    match dv {
3406        DefaultValue::Expr(expr) => expr.to_string(),
3407        DefaultValue::ParenExpr(expr) => format!("({expr})"),
3408    }
3409}
3410
3411fn indexed_column_name(indexed_column: &fsqlite_ast::IndexedColumn) -> Option<&str> {
3412    fn extract(expr: &Expr) -> Option<&str> {
3413        match expr {
3414            Expr::Column(column, _) if column.table.is_none() => Some(&column.column),
3415            // SQLite accepts a legacy single-quoted identifier in table-level
3416            // PRIMARY KEY and UNIQUE constraints.
3417            Expr::Literal(Literal::String(name), _) => Some(name),
3418            Expr::Collate { expr, .. } => extract(expr),
3419            _ => None,
3420        }
3421    }
3422
3423    extract(&indexed_column.expr)
3424}
3425
3426fn strip_wrapping_default_parens(mut default_sql: &str) -> &str {
3427    loop {
3428        let trimmed = default_sql.trim();
3429        let bytes = trimmed.as_bytes();
3430        if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
3431            return trimmed;
3432        }
3433
3434        let mut depth = 0_i32;
3435        let mut idx = 0_usize;
3436        let mut wraps_entire_expr = false;
3437        while idx < bytes.len() {
3438            match bytes[idx] {
3439                quote @ (b'\'' | b'"') => {
3440                    idx += 1;
3441                    while idx < bytes.len() {
3442                        if bytes[idx] == quote {
3443                            if idx + 1 < bytes.len() && bytes[idx + 1] == quote {
3444                                idx += 2;
3445                            } else {
3446                                idx += 1;
3447                                break;
3448                            }
3449                        } else {
3450                            idx += 1;
3451                        }
3452                    }
3453                    continue;
3454                }
3455                b'(' => depth += 1,
3456                b')' => {
3457                    depth -= 1;
3458                    if depth == 0 {
3459                        wraps_entire_expr = idx == bytes.len() - 1;
3460                        break;
3461                    }
3462                    if depth < 0 {
3463                        return trimmed;
3464                    }
3465                }
3466                _ => {}
3467            }
3468            idx += 1;
3469        }
3470
3471        if !wraps_entire_expr || depth != 0 {
3472            return trimmed;
3473        }
3474        default_sql = &trimmed[1..trimmed.len() - 1];
3475    }
3476}
3477
3478fn parse_wrapped_default_text(default_sql: &str, quote: char) -> Option<SqliteValue> {
3479    if !default_sql.starts_with(quote) {
3480        return None;
3481    }
3482    let mut value = String::new();
3483    let body = &default_sql[quote.len_utf8()..];
3484    let mut chars = body.char_indices().peekable();
3485
3486    while let Some((offset, ch)) = chars.next() {
3487        if ch != quote {
3488            value.push(ch);
3489            continue;
3490        }
3491        if let Some((_, next_ch)) = chars.peek()
3492            && *next_ch == quote
3493        {
3494            value.push(quote);
3495            let _ = chars.next();
3496            continue;
3497        }
3498        let absolute_end = quote.len_utf8() + offset + ch.len_utf8();
3499        return (absolute_end == default_sql.len()).then(|| SqliteValue::Text(value.into()));
3500    }
3501
3502    None
3503}
3504
3505fn loaded_default_literal_value(literal: &Literal) -> Option<SqliteValue> {
3506    match literal {
3507        Literal::Integer(value) => Some(SqliteValue::Integer(*value)),
3508        Literal::Float(value) => Some(SqliteValue::Float(*value)),
3509        Literal::String(value) => Some(SqliteValue::Text(value.clone().into())),
3510        Literal::Blob(value) => Some(SqliteValue::from(value.clone())),
3511        Literal::Null => Some(SqliteValue::Null),
3512        Literal::True => Some(SqliteValue::Integer(1)),
3513        Literal::False => Some(SqliteValue::Integer(0)),
3514        Literal::CurrentTime | Literal::CurrentDate | Literal::CurrentTimestamp => None,
3515    }
3516}
3517
3518fn loaded_constant_default_expr_value(expr: &Expr) -> Option<SqliteValue> {
3519    match expr {
3520        Expr::Literal(literal, _) => loaded_default_literal_value(literal),
3521        Expr::UnaryOp {
3522            op: UnaryOp::Plus,
3523            expr,
3524            ..
3525        } => match loaded_constant_default_expr_value(expr)? {
3526            value @ (SqliteValue::Integer(_) | SqliteValue::Float(_)) => Some(value),
3527            _ => None,
3528        },
3529        Expr::UnaryOp {
3530            op: UnaryOp::Negate,
3531            expr,
3532            ..
3533        } => match loaded_constant_default_expr_value(expr)? {
3534            SqliteValue::Integer(value) => Some(
3535                value
3536                    .checked_neg()
3537                    .map_or_else(|| SqliteValue::Float(-(value as f64)), SqliteValue::Integer),
3538            ),
3539            SqliteValue::Float(value) => Some(SqliteValue::Float(-value)),
3540            _ => None,
3541        },
3542        _ => None,
3543    }
3544}
3545
3546fn parse_loaded_column_default_value(default_sql: &str) -> SqliteValue {
3547    let default_sql = strip_wrapping_default_parens(default_sql);
3548    if let Some(value) = parse_wrapped_default_text(default_sql, '\'')
3549        .or_else(|| parse_wrapped_default_text(default_sql, '"'))
3550    {
3551        return value;
3552    }
3553    if let Ok(expr) = fsqlite_parser::expr::parse_expr(default_sql)
3554        && let Some(value) = loaded_constant_default_expr_value(&expr)
3555    {
3556        return value;
3557    }
3558    SqliteValue::Text(default_sql.into())
3559}
3560
3561fn inflate_loaded_table_row_values(
3562    values: &mut Vec<SqliteValue>,
3563    rowid: i64,
3564    columns: &[ColumnInfo],
3565    rowid_alias_col_idx: Option<usize>,
3566    table_name: &str,
3567) -> Result<()> {
3568    let num_columns = columns.len();
3569    if values.len() > num_columns {
3570        return Err(FrankenError::DatabaseCorrupt {
3571            detail: format!(
3572                "table `{table_name}` rowid {rowid} payload has {} columns; expected at most {num_columns}",
3573                values.len()
3574            ),
3575        });
3576    }
3577    if let Some(ipk_idx) = rowid_alias_col_idx
3578        && ipk_idx >= num_columns
3579    {
3580        return Err(FrankenError::DatabaseCorrupt {
3581            detail: format!(
3582                "table `{table_name}` rowid {rowid} has invalid INTEGER PRIMARY KEY alias column index {ipk_idx}"
3583            ),
3584        });
3585    }
3586
3587    let payload_values = std::mem::take(values);
3588    let inflated = inflate_loaded_table_row_values_from_payload(
3589        &payload_values,
3590        rowid,
3591        columns,
3592        rowid_alias_col_idx,
3593        table_name,
3594    )?;
3595    *values = inflated;
3596
3597    Ok(())
3598}
3599
3600fn inflate_loaded_table_row_values_from_payload(
3601    payload_values: &[SqliteValue],
3602    rowid: i64,
3603    columns: &[ColumnInfo],
3604    rowid_alias_col_idx: Option<usize>,
3605    table_name: &str,
3606) -> Result<Vec<SqliteValue>> {
3607    let Some(ipk_idx) = rowid_alias_col_idx else {
3608        return inflate_loaded_table_row_values_with_alias_alignment(
3609            payload_values,
3610            rowid,
3611            columns,
3612            None,
3613            false,
3614            table_name,
3615        );
3616    };
3617
3618    if payload_values.len() == columns.len() {
3619        return inflate_loaded_table_row_values_with_alias_alignment(
3620            payload_values,
3621            rowid,
3622            columns,
3623            Some(ipk_idx),
3624            true,
3625            table_name,
3626        );
3627    }
3628
3629    let Some(value_at_alias_position) = payload_values.get(ipk_idx) else {
3630        return inflate_loaded_table_row_values_with_alias_alignment(
3631            payload_values,
3632            rowid,
3633            columns,
3634            Some(ipk_idx),
3635            false,
3636            table_name,
3637        );
3638    };
3639
3640    let alias_slot_could_be_present = match value_at_alias_position {
3641        SqliteValue::Null => true,
3642        SqliteValue::Integer(encoded_rowid) => *encoded_rowid == rowid,
3643        _ => false,
3644    };
3645    if !alias_slot_could_be_present {
3646        return inflate_loaded_table_row_values_with_alias_alignment(
3647            payload_values,
3648            rowid,
3649            columns,
3650            Some(ipk_idx),
3651            false,
3652            table_name,
3653        );
3654    }
3655
3656    let with_alias = inflate_loaded_table_row_values_with_alias_alignment(
3657        payload_values,
3658        rowid,
3659        columns,
3660        Some(ipk_idx),
3661        true,
3662        table_name,
3663    )?;
3664    let without_alias = inflate_loaded_table_row_values_with_alias_alignment(
3665        payload_values,
3666        rowid,
3667        columns,
3668        Some(ipk_idx),
3669        false,
3670        table_name,
3671    )?;
3672    let with_alias_valid = loaded_row_values_satisfy_notnull(columns, &with_alias);
3673    let without_alias_valid = loaded_row_values_satisfy_notnull(columns, &without_alias);
3674
3675    if !with_alias_valid && !without_alias_valid {
3676        return Err(FrankenError::DatabaseCorrupt {
3677            detail: format!(
3678                "table `{table_name}` rowid {rowid} short payload violates NOT NULL constraints under both rowid-alias alignments"
3679            ),
3680        });
3681    }
3682    if with_alias_valid
3683        && (!without_alias_valid || matches!(value_at_alias_position, SqliteValue::Null))
3684    {
3685        Ok(with_alias)
3686    } else {
3687        Ok(without_alias)
3688    }
3689}
3690
3691fn inflate_loaded_table_row_values_with_alias_alignment(
3692    payload_values: &[SqliteValue],
3693    rowid: i64,
3694    columns: &[ColumnInfo],
3695    rowid_alias_col_idx: Option<usize>,
3696    payload_includes_rowid_alias: bool,
3697    table_name: &str,
3698) -> Result<Vec<SqliteValue>> {
3699    let mut inflated = Vec::with_capacity(columns.len());
3700    let mut payload_idx = 0_usize;
3701
3702    for (col_idx, column) in columns.iter().enumerate() {
3703        if rowid_alias_col_idx == Some(col_idx) && !payload_includes_rowid_alias {
3704            inflated.push(SqliteValue::Integer(rowid));
3705            continue;
3706        }
3707
3708        let value = if let Some(value) = payload_values.get(payload_idx) {
3709            payload_idx += 1;
3710            value.clone()
3711        } else if let Some(default_sql) = column.default_value.as_ref() {
3712            parse_loaded_column_default_value(default_sql)
3713        } else {
3714            SqliteValue::Null
3715        };
3716
3717        if rowid_alias_col_idx == Some(col_idx) {
3718            match &value {
3719                SqliteValue::Null => {
3720                    inflated.push(SqliteValue::Integer(rowid));
3721                    continue;
3722                }
3723                SqliteValue::Integer(encoded_rowid) if *encoded_rowid == rowid => {}
3724                SqliteValue::Integer(encoded_rowid) => {
3725                    return Err(FrankenError::DatabaseCorrupt {
3726                        detail: format!(
3727                            "table `{table_name}` rowid {rowid} stores inconsistent INTEGER PRIMARY KEY alias value {encoded_rowid}"
3728                        ),
3729                    });
3730                }
3731                other => {
3732                    return Err(FrankenError::DatabaseCorrupt {
3733                        detail: format!(
3734                            "table `{table_name}` rowid {rowid} stores non-integer INTEGER PRIMARY KEY alias value {other:?}"
3735                        ),
3736                    });
3737                }
3738            }
3739        }
3740
3741        inflated.push(value);
3742    }
3743
3744    if payload_idx != payload_values.len() {
3745        return Err(FrankenError::DatabaseCorrupt {
3746            detail: format!(
3747                "table `{table_name}` rowid {rowid} left {} payload columns unconsumed after rowid-alias inflation",
3748                payload_values.len() - payload_idx
3749            ),
3750        });
3751    }
3752
3753    Ok(inflated)
3754}
3755
3756fn loaded_row_values_satisfy_notnull(columns: &[ColumnInfo], values: &[SqliteValue]) -> bool {
3757    values.len() == columns.len()
3758        && columns.iter().zip(values.iter()).all(|(column, value)| {
3759            !column.notnull || column.is_ipk || !matches!(value, SqliteValue::Null)
3760        })
3761}
3762
3763fn try_parse_columns_from_create_sql_ast(sql: &str) -> Option<Vec<ColumnInfo>> {
3764    let Statement::CreateTable(create) = parse_single_statement(sql)? else {
3765        return None;
3766    };
3767    columns_from_create_table_statement(&create)
3768}
3769
3770pub(crate) fn columns_from_create_table_statement(
3771    create: &CreateTableStatement,
3772) -> Option<Vec<ColumnInfo>> {
3773    let CreateTableBody::Columns { columns, .. } = &create.body else {
3774        return None;
3775    };
3776
3777    let mut table_pk_rowid = None;
3778
3779    if let CreateTableBody::Columns { constraints, .. } = &create.body {
3780        for constraint in constraints {
3781            match &constraint.kind {
3782                TableConstraintKind::PrimaryKey {
3783                    columns: pk_columns,
3784                    conflict,
3785                } if pk_columns.len() == 1 => {
3786                    let Some(column_name) = indexed_column_name(&pk_columns[0]) else {
3787                        continue;
3788                    };
3789                    let Some(index) = columns
3790                        .iter()
3791                        .position(|col| col.name.eq_ignore_ascii_case(column_name))
3792                    else {
3793                        continue;
3794                    };
3795
3796                    let is_integer = column_def_is_exact_integer(&columns[index]);
3797                    if is_integer && !create.without_rowid {
3798                        table_pk_rowid = Some((index, *conflict));
3799                    }
3800                }
3801                _ => {}
3802            }
3803        }
3804    }
3805
3806    let rowid_col_idx = columns
3807        .iter()
3808        .enumerate()
3809        .find_map(|(index, col)| {
3810            let is_integer = column_def_is_exact_integer(col);
3811            let pk = col.constraints.iter().find_map(|constraint| {
3812                if let ColumnConstraintKind::PrimaryKey { direction, .. } = &constraint.kind {
3813                    if *direction != Some(SortDirection::Desc) {
3814                        Some(())
3815                    } else {
3816                        None
3817                    }
3818                } else {
3819                    None
3820                }
3821            });
3822            if is_integer && pk.is_some() && !create.without_rowid {
3823                Some(index)
3824            } else {
3825                None
3826            }
3827        })
3828        .or_else(|| table_pk_rowid.map(|(index, _)| index));
3829
3830    Some(
3831        columns
3832            .iter()
3833            .enumerate()
3834            .map(|(index, col)| {
3835                let affinity = col
3836                    .type_name
3837                    .as_ref()
3838                    .map_or('A', |type_name| type_to_affinity(&type_name.name));
3839                let type_name = col.type_name.as_ref().map(std::string::ToString::to_string);
3840                let is_ipk = rowid_col_idx.is_some_and(|rowid_index| rowid_index == index);
3841                let notnull = col.constraints.iter().any(|constraint| {
3842                    matches!(&constraint.kind, ColumnConstraintKind::NotNull { .. })
3843                });
3844                let has_primary_key = col.constraints.iter().any(|constraint| {
3845                    matches!(&constraint.kind, ColumnConstraintKind::PrimaryKey { .. })
3846                });
3847                let unique = (!is_ipk && has_primary_key)
3848                    || col.constraints.iter().any(|constraint| {
3849                        matches!(&constraint.kind, ColumnConstraintKind::Unique { .. })
3850                    });
3851                let default_value = col
3852                    .constraints
3853                    .iter()
3854                    .find_map(|constraint| match &constraint.kind {
3855                        ColumnConstraintKind::Default(default_value) => {
3856                            Some(format_default_value(default_value))
3857                        }
3858                        _ => None,
3859                    });
3860                let strict_type = if create.strict {
3861                    type_name
3862                        .as_deref()
3863                        .and_then(StrictColumnType::from_type_name)
3864                } else {
3865                    None
3866                };
3867                let (generated_expr, generated_stored) = col
3868                    .constraints
3869                    .iter()
3870                    .find_map(|constraint| match &constraint.kind {
3871                        ColumnConstraintKind::Generated { expr, storage } => {
3872                            let stored = storage
3873                                .as_ref()
3874                                .is_some_and(|storage| *storage == GeneratedStorage::Stored);
3875                            Some((Some(expr.to_string()), Some(stored)))
3876                        }
3877                        _ => None,
3878                    })
3879                    .unwrap_or((None, None));
3880                let collation = col.constraints.iter().rev().find_map(|constraint| {
3881                    if let ColumnConstraintKind::Collate(name) = &constraint.kind {
3882                        Some(name.clone())
3883                    } else {
3884                        None
3885                    }
3886                });
3887                // Per-constraint ON CONFLICT: PRIMARY KEY clause for the rowid
3888                // alias, NOT NULL clause otherwise (UNIQUE conflicts live on the
3889                // backing index).
3890                let conflict_action = if is_ipk {
3891                    col.constraints
3892                        .iter()
3893                        .find_map(|constraint| match &constraint.kind {
3894                            ColumnConstraintKind::PrimaryKey { conflict, .. } => *conflict,
3895                            _ => None,
3896                        })
3897                        .or_else(|| {
3898                            table_pk_rowid.and_then(|(pk_index, conflict)| {
3899                                (pk_index == index).then_some(conflict).flatten()
3900                            })
3901                        })
3902                } else {
3903                    col.constraints
3904                        .iter()
3905                        .find_map(|constraint| match &constraint.kind {
3906                            ColumnConstraintKind::NotNull { conflict } => *conflict,
3907                            _ => None,
3908                        })
3909                };
3910
3911                ColumnInfo {
3912                    name: col.name.clone(),
3913                    affinity,
3914                    is_ipk,
3915                    type_name,
3916                    notnull,
3917                    unique,
3918                    default_value,
3919                    strict_type,
3920                    generated_expr,
3921                    generated_stored,
3922                    collation,
3923                    conflict_action,
3924                }
3925            })
3926            .collect(),
3927    )
3928}
3929
3930fn parse_quoted_identifier(input: &str, quote: u8, escape: u8) -> Option<(&str, &str)> {
3931    let bytes = input.as_bytes();
3932    let mut i = 1usize;
3933    while i < bytes.len() {
3934        if bytes[i] == quote {
3935            if i + 1 < bytes.len() && bytes[i + 1] == escape {
3936                i += 2;
3937                continue;
3938            }
3939            return Some((&input[..=i], &input[i + 1..]));
3940        }
3941        i += 1;
3942    }
3943    None
3944}
3945
3946fn parse_bracket_identifier(input: &str) -> Option<(&str, &str)> {
3947    let bytes = input.as_bytes();
3948    let mut i = 1usize;
3949    while i < bytes.len() {
3950        if bytes[i] == b']' {
3951            return Some((&input[..=i], &input[i + 1..]));
3952        }
3953        i += 1;
3954    }
3955    None
3956}
3957
3958const COLUMN_CONSTRAINT_KEYWORDS: &[&str] = &[
3959    "CONSTRAINT",
3960    "PRIMARY",
3961    "NOT",
3962    "NULL",
3963    "UNIQUE",
3964    "CHECK",
3965    "DEFAULT",
3966    "COLLATE",
3967    "REFERENCES",
3968    "GENERATED",
3969    "AS",
3970];
3971
3972/// Split a comma-separated SQL list while respecting parentheses, quotes,
3973/// and SQL comments.
3974fn split_top_level_csv_items(input: &str) -> Vec<String> {
3975    let mut chars = input.char_indices().peekable();
3976    let mut out = Vec::new();
3977    let mut current = String::new();
3978    let mut paren_depth = 0usize;
3979    let mut quote: Option<char> = None;
3980    let mut in_brackets = false;
3981
3982    while let Some((_, ch)) = chars.next() {
3983        if let Some(q) = quote {
3984            current.push(ch);
3985            if ch == q {
3986                if let Some(&(_, next_ch)) = chars.peek() {
3987                    if next_ch == q {
3988                        current.push(next_ch);
3989                        chars.next();
3990                    } else {
3991                        quote = None;
3992                    }
3993                } else {
3994                    quote = None;
3995                }
3996            }
3997            continue;
3998        }
3999
4000        if in_brackets {
4001            current.push(ch);
4002            if ch == ']' {
4003                in_brackets = false;
4004            }
4005            continue;
4006        }
4007
4008        match ch {
4009            '\'' | '"' | '`' => {
4010                quote = Some(ch);
4011                current.push(ch);
4012            }
4013            '[' => {
4014                in_brackets = true;
4015                current.push(ch);
4016            }
4017            '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4018                chars.next();
4019                let ends_with_whitespace = current.chars().last().is_some_and(char::is_whitespace);
4020                if !current.trim_end().is_empty() && !ends_with_whitespace {
4021                    current.push(' ');
4022                }
4023
4024                while let Some((_, next_ch)) = chars.next() {
4025                    if next_ch == '\n' {
4026                        break;
4027                    }
4028                    if next_ch == '\r' {
4029                        if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '\n') {
4030                            chars.next();
4031                        }
4032                        break;
4033                    }
4034                }
4035            }
4036            '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4037                chars.next();
4038                let ends_with_whitespace = current.chars().last().is_some_and(char::is_whitespace);
4039                if !current.trim_end().is_empty() && !ends_with_whitespace {
4040                    current.push(' ');
4041                }
4042
4043                let mut previous = '\0';
4044                for (_, next_ch) in chars.by_ref() {
4045                    if previous == '*' && next_ch == '/' {
4046                        break;
4047                    }
4048                    previous = next_ch;
4049                }
4050            }
4051            '(' => {
4052                paren_depth = paren_depth.saturating_add(1);
4053                current.push(ch);
4054            }
4055            ')' => {
4056                paren_depth = paren_depth.saturating_sub(1);
4057                current.push(ch);
4058            }
4059            ',' if paren_depth == 0 => {
4060                let part = current.trim();
4061                if !part.is_empty() {
4062                    out.push(part.to_owned());
4063                }
4064                current.clear();
4065            }
4066            _ => current.push(ch),
4067        }
4068    }
4069
4070    let tail = current.trim();
4071    if !tail.is_empty() {
4072        out.push(tail.to_owned());
4073    }
4074
4075    out
4076}
4077
4078fn find_unquoted_name_end(input: &str) -> usize {
4079    let mut chars = input.char_indices().peekable();
4080    while let Some((idx, ch)) = chars.next() {
4081        if ch.is_whitespace() {
4082            return idx;
4083        }
4084        if ch == '-' && chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') {
4085            return idx;
4086        }
4087        if ch == '/' && chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') {
4088            return idx;
4089        }
4090    }
4091    input.len()
4092}
4093
4094fn starts_with_unquoted_table_constraint(def: &str) -> bool {
4095    let trimmed = trim_leading_sql_space_and_comments(def);
4096    if trimmed.is_empty() {
4097        return false;
4098    }
4099    match trimmed.as_bytes()[0] {
4100        b'"' | b'`' | b'[' => return false,
4101        _ => {}
4102    }
4103    collect_unquoted_sql_keyword_tokens(trimmed)
4104        .first()
4105        .is_some_and(|(token, start)| {
4106            *start == 0
4107                && matches!(
4108                    token.as_str(),
4109                    "CONSTRAINT" | "PRIMARY" | "UNIQUE" | "CHECK" | "FOREIGN"
4110                )
4111        })
4112}
4113
4114type SqlCharIndices<'a> = std::iter::Peekable<std::str::CharIndices<'a>>;
4115
4116fn unquoted_sql_keyword_tokens(input: &str) -> Vec<String> {
4117    collect_unquoted_sql_keyword_tokens(input)
4118        .into_iter()
4119        .map(|(token, _)| token)
4120        .collect()
4121}
4122
4123fn find_unquoted_sql_keyword(input: &str, keyword: &str) -> Option<usize> {
4124    let keyword = keyword.to_ascii_uppercase();
4125    collect_unquoted_sql_keyword_tokens(input)
4126        .into_iter()
4127        .find_map(|(token, start)| (token == keyword).then_some(start))
4128}
4129
4130fn find_top_level_unquoted_sql_keyword(input: &str, keyword: &str) -> Option<usize> {
4131    let mut chars = input.char_indices().peekable();
4132    let mut paren_depth = 0_usize;
4133    let mut token_start = None;
4134
4135    while let Some((idx, ch)) = chars.next() {
4136        let is_token_char = ch.is_ascii_alphanumeric() || ch == '_';
4137        if paren_depth == 0 && is_token_char {
4138            token_start.get_or_insert(idx);
4139            continue;
4140        }
4141        if let Some(start) = token_start.take()
4142            && input[start..idx].eq_ignore_ascii_case(keyword)
4143        {
4144            return Some(start);
4145        }
4146
4147        match ch {
4148            '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4149            '[' => skip_bracket_identifier(&mut chars),
4150            '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4151                let _ = chars.next();
4152                skip_line_comment(&mut chars);
4153            }
4154            '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4155                let _ = chars.next();
4156                skip_block_comment(&mut chars);
4157            }
4158            '(' => paren_depth += 1,
4159            ')' => paren_depth = paren_depth.saturating_sub(1),
4160            _ => {}
4161        }
4162    }
4163
4164    token_start.filter(|start| input[*start..].eq_ignore_ascii_case(keyword))
4165}
4166
4167fn find_unquoted_sql_char(input: &str, target: char) -> Option<usize> {
4168    let mut chars = input.char_indices().peekable();
4169    while let Some((idx, ch)) = chars.next() {
4170        match ch {
4171            '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4172            '[' => skip_bracket_identifier(&mut chars),
4173            '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4174                let _ = chars.next();
4175                skip_line_comment(&mut chars);
4176            }
4177            '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4178                let _ = chars.next();
4179                skip_block_comment(&mut chars);
4180            }
4181            _ if ch == target => return Some(idx),
4182            _ => {}
4183        }
4184    }
4185    None
4186}
4187
4188fn find_matching_sql_paren(input: &str, open_idx: usize) -> Option<usize> {
4189    if input.as_bytes().get(open_idx).copied() != Some(b'(') {
4190        return None;
4191    }
4192
4193    let mut depth = 0_usize;
4194    let mut chars = input[open_idx..].char_indices().peekable();
4195    while let Some((rel_idx, ch)) = chars.next() {
4196        let idx = open_idx + rel_idx;
4197        match ch {
4198            '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4199            '[' => skip_bracket_identifier(&mut chars),
4200            '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4201                let _ = chars.next();
4202                skip_line_comment(&mut chars);
4203            }
4204            '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4205                let _ = chars.next();
4206                skip_block_comment(&mut chars);
4207            }
4208            '(' => depth += 1,
4209            ')' => {
4210                depth = depth.checked_sub(1)?;
4211                if depth == 0 {
4212                    return Some(idx);
4213                }
4214            }
4215            _ => {}
4216        }
4217    }
4218    None
4219}
4220
4221fn trim_leading_sql_space_and_comments(mut input: &str) -> &str {
4222    loop {
4223        let trimmed = input.trim_start();
4224        if let Some(rest) = trimmed.strip_prefix("--") {
4225            let end = rest.find(['\n', '\r']).map_or(rest.len(), |idx| idx + 1);
4226            input = &rest[end..];
4227            continue;
4228        }
4229        if let Some(rest) = trimmed.strip_prefix("/*") {
4230            let Some(end) = rest.find("*/") else {
4231                return "";
4232            };
4233            input = &rest[end + 2..];
4234            continue;
4235        }
4236        return trimmed;
4237    }
4238}
4239
4240fn collect_unquoted_sql_keyword_tokens(input: &str) -> Vec<(String, usize)> {
4241    let mut tokens = Vec::new();
4242    let mut current = String::new();
4243    let mut current_start = 0_usize;
4244    let mut chars = input.char_indices().peekable();
4245
4246    while let Some((idx, ch)) = chars.next() {
4247        match ch {
4248            '\'' | '"' | '`' => {
4249                push_keyword_token(&mut tokens, &mut current, current_start);
4250                skip_quoted_sql(&mut chars, ch);
4251            }
4252            '[' => {
4253                push_keyword_token(&mut tokens, &mut current, current_start);
4254                skip_bracket_identifier(&mut chars);
4255            }
4256            '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4257                let _ = chars.next();
4258                push_keyword_token(&mut tokens, &mut current, current_start);
4259                skip_line_comment(&mut chars);
4260            }
4261            '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4262                let _ = chars.next();
4263                push_keyword_token(&mut tokens, &mut current, current_start);
4264                skip_block_comment(&mut chars);
4265            }
4266            _ if ch.is_ascii_alphanumeric() || matches!(ch, '_') => {
4267                if current.is_empty() {
4268                    current_start = idx;
4269                }
4270                current.push(ch.to_ascii_uppercase());
4271            }
4272            _ => push_keyword_token(&mut tokens, &mut current, current_start),
4273        }
4274    }
4275
4276    push_keyword_token(&mut tokens, &mut current, current_start);
4277    tokens
4278}
4279
4280fn push_keyword_token(
4281    tokens: &mut Vec<(String, usize)>,
4282    current: &mut String,
4283    current_start: usize,
4284) {
4285    if !current.is_empty() {
4286        tokens.push((std::mem::take(current), current_start));
4287    }
4288}
4289
4290fn skip_quoted_sql(chars: &mut SqlCharIndices<'_>, quote: char) {
4291    while let Some((_, ch)) = chars.next() {
4292        if ch != quote {
4293            continue;
4294        }
4295        if chars.peek().is_some_and(|(_, next_ch)| *next_ch == quote) {
4296            let _ = chars.next();
4297        } else {
4298            break;
4299        }
4300    }
4301}
4302
4303fn skip_bracket_identifier(chars: &mut SqlCharIndices<'_>) {
4304    for (_, ch) in chars.by_ref() {
4305        if ch == ']' {
4306            break;
4307        }
4308    }
4309}
4310
4311fn skip_line_comment(chars: &mut SqlCharIndices<'_>) {
4312    for (_, ch) in chars.by_ref() {
4313        if ch == '\n' || ch == '\r' {
4314            break;
4315        }
4316    }
4317}
4318
4319fn skip_block_comment(chars: &mut SqlCharIndices<'_>) {
4320    let mut previous = '\0';
4321    for (_, ch) in chars.by_ref() {
4322        if previous == '*' && ch == '/' {
4323            break;
4324        }
4325        previous = ch;
4326    }
4327}
4328
4329fn unquoted_tokens_contain_phrase(tokens: &[String], phrase: &[&str]) -> bool {
4330    !phrase.is_empty()
4331        && tokens.len() >= phrase.len()
4332        && tokens.windows(phrase.len()).any(|window| {
4333            window
4334                .iter()
4335                .zip(phrase)
4336                .all(|(token, expected)| token.as_str() == *expected)
4337        })
4338}
4339
4340fn extract_collation_name(remainder: &str) -> Option<String> {
4341    let raw_name = remainder.get(find_collation_name_range(remainder)?)?;
4342    let name = strip_sql_name_quotes(raw_name);
4343    (!name.is_empty()).then(|| name.to_ascii_uppercase())
4344}
4345
4346fn find_collation_name_range(remainder: &str) -> Option<std::ops::Range<usize>> {
4347    let pos = find_unquoted_sql_keyword(remainder, "COLLATE")?;
4348    let after = trim_leading_sql_space_and_comments(&remainder[pos + 7..]);
4349    let start = remainder.len().checked_sub(after.len())?;
4350    let bytes = after.as_bytes();
4351    if bytes.is_empty() {
4352        return None;
4353    }
4354
4355    let raw_len = match bytes[0] {
4356        b'\'' => parse_quoted_identifier(after, b'\'', b'\'')?.0,
4357        b'"' => parse_quoted_identifier(after, b'"', b'"')?.0,
4358        b'`' => parse_quoted_identifier(after, b'`', b'`')?.0,
4359        b'[' => parse_bracket_identifier(after)?.0,
4360        _ => {
4361            let end = after
4362                .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
4363                .unwrap_or(after.len());
4364            &after[..end]
4365        }
4366    }
4367    .len();
4368    (raw_len > 0).then_some(start..start + raw_len)
4369}
4370
4371fn strip_sql_name_quotes(token: &str) -> String {
4372    let trimmed = token.trim();
4373    if trimmed.len() >= 2 {
4374        if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
4375            return trimmed[1..trimmed.len() - 1].replace("''", "'");
4376        }
4377        return strip_identifier_quotes(trimmed);
4378    }
4379    trimmed.to_owned()
4380}
4381
4382fn strip_identifier_quotes(token: &str) -> String {
4383    let trimmed = token.trim();
4384    if trimmed.len() >= 2 {
4385        if trimmed.starts_with('"') && trimmed.ends_with('"') {
4386            return trimmed[1..trimmed.len() - 1].replace("\"\"", "\"");
4387        }
4388        if trimmed.starts_with('`') && trimmed.ends_with('`') {
4389            return trimmed[1..trimmed.len() - 1].replace("``", "`");
4390        }
4391        if trimmed.starts_with('[') && trimmed.ends_with(']') {
4392            return trimmed[1..trimmed.len() - 1].to_owned();
4393        }
4394    }
4395    trimmed.to_owned()
4396}
4397
4398fn extract_type_declaration(tokens: &[&str]) -> String {
4399    let mut parts = Vec::new();
4400    let mut paren_depth = 0isize;
4401    for token in tokens {
4402        let token_upper = token
4403            .trim_matches(|c: char| c == ',' || c == ';')
4404            .to_ascii_uppercase();
4405        if paren_depth == 0 && COLUMN_CONSTRAINT_KEYWORDS.contains(&token_upper.as_str()) {
4406            break;
4407        }
4408        parts.push(*token);
4409        for ch in token.chars() {
4410            if ch == '(' {
4411                paren_depth += 1;
4412            } else if ch == ')' && paren_depth > 0 {
4413                paren_depth -= 1;
4414            }
4415        }
4416    }
4417    parts.join(" ")
4418}
4419
4420fn extract_generated_column_clause(remainder: &str) -> (Option<String>, Option<bool>) {
4421    let Some(as_pos) = find_top_level_unquoted_sql_keyword(remainder, "AS") else {
4422        return (None, None);
4423    };
4424    let after_keyword = &remainder[as_pos + "AS".len()..];
4425    let after_space_and_comments = trim_leading_sql_space_and_comments(after_keyword);
4426    if !after_space_and_comments.starts_with('(') {
4427        return (None, None);
4428    }
4429    let skipped = after_keyword.len() - after_space_and_comments.len();
4430    let open_paren = as_pos + "AS".len() + skipped;
4431    let Some(close_paren) = find_matching_sql_paren(remainder, open_paren) else {
4432        return (None, None);
4433    };
4434
4435    let tail = trim_leading_sql_space_and_comments(&remainder[close_paren + 1..]);
4436    let is_stored = collect_unquoted_sql_keyword_tokens(tail)
4437        .first()
4438        .is_some_and(|(token, start)| *start == 0 && token == "STORED");
4439
4440    (
4441        Some(remainder[open_paren + 1..close_paren].trim().to_owned()),
4442        Some(is_stored),
4443    )
4444}
4445
4446/// Extract a DEFAULT value from a column definition remainder (the part after
4447/// the column name).  Handles `DEFAULT literal`, `DEFAULT -number`,
4448/// `DEFAULT 'string'`, `DEFAULT "string"`, and `DEFAULT (expr)`.
4449fn extract_default_value(remainder: &str) -> Option<String> {
4450    let pos = find_unquoted_sql_keyword(remainder, "DEFAULT")?;
4451    let after = trim_leading_sql_space_and_comments(&remainder[pos + 7..]);
4452    if after.is_empty() {
4453        return None;
4454    }
4455    // Parenthesized expression: DEFAULT (...)
4456    if after.starts_with('(') {
4457        let mut depth = 0i32;
4458        let bytes = after.as_bytes();
4459        let mut idx = 0_usize;
4460        while idx < bytes.len() {
4461            match bytes[idx] {
4462                quote @ (b'\'' | b'"') => {
4463                    idx += 1;
4464                    while idx < bytes.len() {
4465                        if bytes[idx] == quote {
4466                            if idx + 1 < bytes.len() && bytes[idx + 1] == quote {
4467                                idx += 2;
4468                            } else {
4469                                idx += 1;
4470                                break;
4471                            }
4472                        } else {
4473                            idx += 1;
4474                        }
4475                    }
4476                    continue;
4477                }
4478                b'(' => depth += 1,
4479                b')' => {
4480                    depth -= 1;
4481                    if depth == 0 {
4482                        return Some(after[..=idx].to_owned());
4483                    }
4484                    if depth < 0 {
4485                        return None;
4486                    }
4487                }
4488                _ => {}
4489            }
4490            idx += 1;
4491        }
4492        return None;
4493    }
4494    // Quoted string: DEFAULT '...' or DEFAULT "..."
4495    if let Some(quote) = after
4496        .as_bytes()
4497        .first()
4498        .copied()
4499        .filter(|quote| matches!(*quote, b'\'' | b'"'))
4500    {
4501        let rest = &after[1..];
4502        let mut i = 0;
4503        let bytes = rest.as_bytes();
4504        while i < bytes.len() {
4505            if bytes[i] == quote {
4506                if i + 1 < bytes.len() && bytes[i + 1] == quote {
4507                    i += 2;
4508                    continue;
4509                }
4510                return Some(after[..i + 2].to_owned());
4511            }
4512            i += 1;
4513        }
4514        return None;
4515    }
4516    // Unquoted token: DEFAULT NULL, DEFAULT 0, DEFAULT -1, DEFAULT CURRENT_TIMESTAMP
4517    let end = after
4518        .find(|c: char| c.is_ascii_whitespace() || c == ',')
4519        .unwrap_or(after.len());
4520    let token = &after[..end];
4521    if token.is_empty() {
4522        None
4523    } else {
4524        Some(token.to_owned())
4525    }
4526}
4527
4528/// Map a SQL type keyword to an affinity character.
4529fn type_to_affinity(type_str: &str) -> char {
4530    // SQLite affinity rules (section 3.1 of datatype3.html):
4531    // Priority: INT > TEXT/CHAR/CLOB > BLOB/empty > REAL/FLOA/DOUB > NUMERIC
4532    let upper = type_str.to_uppercase();
4533    if upper.contains("INT") {
4534        'D' // INTEGER affinity
4535    } else if upper.contains("TEXT") || upper.contains("CHAR") || upper.contains("CLOB") {
4536        'B' // TEXT affinity
4537    } else if upper.contains("BLOB") || upper.is_empty() {
4538        'A' // BLOB (none) affinity
4539    } else if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") {
4540        'E' // REAL affinity
4541    } else {
4542        'C' // NUMERIC affinity
4543    }
4544}
4545
4546// ── Tests ───────────────────────────────────────────────────────────────
4547
4548#[cfg(test)]
4549mod tests {
4550    use super::*;
4551    use std::io::Write;
4552    use std::process::{Command, Stdio};
4553
4554    async fn persist_test_db(
4555        path: &Path,
4556        schema: &[TableSchema],
4557        db: &MemDatabase,
4558        schema_cookie: u32,
4559        change_counter: u32,
4560    ) -> Result<()> {
4561        let cx = Cx::new();
4562        persist_to_sqlite(&cx, path, schema, db, schema_cookie, change_counter).await
4563    }
4564
4565    async fn load_test_db(path: &Path) -> Result<LoadedState> {
4566        let cx = Cx::new();
4567        load_from_sqlite(&cx, path).await
4568    }
4569
4570    fn bare_table_schema(name: &str, columns: &[&str]) -> TableSchema {
4571        TableSchema {
4572            name: name.to_owned(),
4573            root_page: 2,
4574            columns: columns
4575                .iter()
4576                .map(|column| ColumnInfo::basic(*column, 'A', false))
4577                .collect(),
4578            indexes: Vec::new(),
4579            strict: false,
4580            without_rowid: false,
4581            primary_key_constraints: Vec::new(),
4582            foreign_keys: Vec::new(),
4583            check_constraints: Vec::new(),
4584        }
4585    }
4586
4587    #[test]
4588    fn test_parse_loaded_default_text_requires_complete_quoted_literal() {
4589        assert_eq!(
4590            parse_loaded_column_default_value("'can''t'"),
4591            SqliteValue::Text("can't".into()),
4592        );
4593        assert_eq!(
4594            parse_loaded_column_default_value(r#""a""b""#),
4595            SqliteValue::Text("a\"b".into()),
4596        );
4597        assert_eq!(
4598            parse_loaded_column_default_value("'x' || 'y'"),
4599            SqliteValue::Text("'x' || 'y'".into()),
4600        );
4601        assert_eq!(
4602            parse_loaded_column_default_value("('a)b')"),
4603            SqliteValue::Text("a)b".into()),
4604        );
4605        assert_eq!(
4606            parse_loaded_column_default_value(r#"("a)b")"#),
4607            SqliteValue::Text("a)b".into()),
4608        );
4609        assert_eq!(
4610            extract_default_value("TEXT DEFAULT ('a)b')").as_deref(),
4611            Some("('a)b')")
4612        );
4613        assert_eq!(
4614            extract_default_value(r#"TEXT DEFAULT ("a)b")"#).as_deref(),
4615            Some(r#"("a)b")"#)
4616        );
4617        assert_eq!(
4618            extract_default_value("TEXT CHECK (note <> 'DEFAULT bad') DEFAULT 'ok'").as_deref(),
4619            Some("'ok'")
4620        );
4621        assert_eq!(
4622            extract_default_value("TEXT CHECK (note <> 'DEFAULT bad')").as_deref(),
4623            None
4624        );
4625        assert_eq!(
4626            extract_default_value("TEXT /* DEFAULT 'bad' */ DEFAULT 'ok'").as_deref(),
4627            Some("'ok'")
4628        );
4629        assert_eq!(
4630            extract_default_value("TEXT DEFAULT /* comment */ 'ok'").as_deref(),
4631            Some("'ok'")
4632        );
4633        assert_eq!(
4634            extract_default_value("TEXT DEFAULT -- comment\n 'ok'").as_deref(),
4635            Some("'ok'")
4636        );
4637    }
4638
4639    fn make_test_schema_and_db() -> (Vec<TableSchema>, MemDatabase) {
4640        let mut db = MemDatabase::new();
4641        let root = db.create_table(2);
4642        let table = db.tables.get_mut(&root).unwrap();
4643        table.insert_row(
4644            1,
4645            vec![SqliteValue::Integer(42), SqliteValue::Text("hello".into())],
4646        );
4647        table.insert_row(
4648            2,
4649            vec![SqliteValue::Integer(99), SqliteValue::Text("world".into())],
4650        );
4651
4652        let schema = vec![TableSchema {
4653            name: "test_table".to_owned(),
4654            root_page: root,
4655            columns: vec![
4656                ColumnInfo {
4657                    name: "id".to_owned(),
4658                    affinity: 'd',
4659                    is_ipk: false,
4660                    type_name: None,
4661                    notnull: false,
4662                    unique: false,
4663                    default_value: None,
4664                    strict_type: None,
4665                    generated_expr: None,
4666                    generated_stored: None,
4667                    collation: None,
4668                    conflict_action: None,
4669                },
4670                ColumnInfo {
4671                    name: "name".to_owned(),
4672                    affinity: 'C',
4673                    is_ipk: false,
4674                    type_name: None,
4675                    notnull: false,
4676                    unique: false,
4677                    default_value: None,
4678                    strict_type: None,
4679                    generated_expr: None,
4680                    generated_stored: None,
4681                    collation: None,
4682                    conflict_action: None,
4683                },
4684            ],
4685            indexes: Vec::new(),
4686            strict: false,
4687            without_rowid: false,
4688            primary_key_constraints: Vec::new(),
4689            foreign_keys: Vec::new(),
4690            check_constraints: Vec::new(),
4691        }];
4692
4693        (schema, db)
4694    }
4695
4696    #[test]
4697    fn test_roundtrip_persist_and_load() {
4698        asupersync::test_utils::run_test(|| async {
4699            let dir = tempfile::tempdir().unwrap();
4700            let db_path = dir.path().join("test.db");
4701
4702            let (schema, db) = make_test_schema_and_db();
4703            persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4704
4705            assert!(db_path.exists(), "db file should exist");
4706            assert!(is_sqlite_format(&db_path), "should have SQLite magic");
4707
4708            let loaded = load_test_db(&db_path).await.unwrap();
4709            assert_eq!(loaded.schema.len(), 1);
4710            assert_eq!(loaded.schema[0].name, "test_table");
4711            assert_eq!(loaded.schema[0].columns.len(), 2);
4712
4713            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4714            let rows: Vec<_> = table.iter_rows().collect();
4715            assert_eq!(rows.len(), 2);
4716            assert_eq!(rows[0].0, 1); // rowid
4717            assert_eq!(rows[0].1[0], SqliteValue::Integer(42));
4718            assert_eq!(rows[0].1[1], SqliteValue::Text("hello".into()));
4719            assert_eq!(rows[1].0, 2);
4720            assert_eq!(rows[1].1[0], SqliteValue::Integer(99));
4721            assert_eq!(rows[1].1[1], SqliteValue::Text("world".into()));
4722        });
4723    }
4724
4725    #[test]
4726    fn test_empty_database_roundtrip() {
4727        asupersync::test_utils::run_test(|| async {
4728            let dir = tempfile::tempdir().unwrap();
4729            let db_path = dir.path().join("empty.db");
4730
4731            let schema: Vec<TableSchema> = Vec::new();
4732            let db = MemDatabase::new();
4733            persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4734
4735            assert!(is_sqlite_format(&db_path));
4736
4737            let loaded = load_test_db(&db_path).await.unwrap();
4738            assert!(loaded.schema.is_empty());
4739        });
4740    }
4741
4742    #[test]
4743    fn test_persist_creates_sqlite3_readable_file() {
4744        asupersync::test_utils::run_test(|| async {
4745            let dir = tempfile::tempdir().unwrap();
4746            let db_path = dir.path().join("readable.db");
4747
4748            let (schema, db) = make_test_schema_and_db();
4749            persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4750
4751            // Verify with rusqlite (C SQLite) that the file is valid.
4752            let conn = rusqlite::Connection::open(&db_path).unwrap();
4753            let mut stmt = conn
4754                .prepare("SELECT id, name FROM test_table ORDER BY id")
4755                .unwrap();
4756            let rows: Vec<(i64, String)> = stmt
4757                .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
4758                .unwrap()
4759                .collect::<std::result::Result<Vec<_>, _>>()
4760                .unwrap();
4761
4762            assert_eq!(rows.len(), 2);
4763            assert_eq!(rows[0], (42, "hello".to_owned()));
4764            assert_eq!(rows[1], (99, "world".to_owned()));
4765        });
4766    }
4767
4768    #[test]
4769    fn test_parse_virtual_table_columns_from_sql_rejects_trailing_junk() {
4770        assert!(
4771            parse_virtual_table_columns_from_sql("CREATE VIRTUAL TABLE docs USING fts5(a) garbage")
4772                .is_none(),
4773            "trailing tokens must invalidate virtual-table SQL during compat import"
4774        );
4775    }
4776
4777    #[test]
4778    fn test_load_sqlite3_created_file() {
4779        asupersync::test_utils::run_test(|| async {
4780            let dir = tempfile::tempdir().unwrap();
4781            let db_path = dir.path().join("from_c.db");
4782
4783            // Create with C SQLite via rusqlite.
4784            {
4785                let conn = rusqlite::Connection::open(&db_path).unwrap();
4786                conn.execute_batch(
4787                    "CREATE TABLE items (val INTEGER, label TEXT);
4788                 INSERT INTO items VALUES (10, 'alpha');
4789                 INSERT INTO items VALUES (20, 'beta');",
4790                )
4791                .unwrap();
4792            }
4793
4794            // Load with our compat loader.
4795            let loaded = load_test_db(&db_path).await.unwrap();
4796            assert_eq!(loaded.schema.len(), 1);
4797            assert_eq!(loaded.schema[0].name, "items");
4798
4799            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4800            let rows: Vec<_> = table.iter_rows().collect();
4801            assert_eq!(rows.len(), 2);
4802            assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4803            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4804            assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4805            assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4806        });
4807    }
4808
4809    #[test]
4810    fn test_load_sqlite3_created_file_restores_integer_primary_key_alias_values() {
4811        asupersync::test_utils::run_test(|| async {
4812            let dir = tempfile::tempdir().unwrap();
4813            let db_path = dir.path().join("from_c_ipk.db");
4814
4815            {
4816                let conn = rusqlite::Connection::open(&db_path).unwrap();
4817                conn.execute_batch(
4818                    "CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT);
4819                 INSERT INTO items (id, label) VALUES (10, 'alpha');
4820                 INSERT INTO items (id, label) VALUES (20, 'beta');",
4821                )
4822                .unwrap();
4823            }
4824
4825            let loaded = load_test_db(&db_path).await.unwrap();
4826            assert_eq!(loaded.schema.len(), 1);
4827            assert_eq!(loaded.schema[0].name, "items");
4828            assert!(loaded.schema[0].columns[0].is_ipk);
4829            assert!(
4830                loaded.schema[0].indexes.is_empty(),
4831                "table-level INTEGER PRIMARY KEY rowid aliases must not synthesize autoindexes"
4832            );
4833
4834            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4835            let rows: Vec<_> = table.iter_rows().collect();
4836            assert_eq!(rows.len(), 2);
4837            assert_eq!(rows[0].0, 10);
4838            assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4839            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4840            assert_eq!(rows[1].0, 20);
4841            assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4842            assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4843        });
4844    }
4845
4846    #[test]
4847    fn test_load_sqlite3_created_file_restores_table_level_integer_primary_key_alias_values() {
4848        asupersync::test_utils::run_test(|| async {
4849            let dir = tempfile::tempdir().unwrap();
4850            let db_path = dir.path().join("from_c_table_pk.db");
4851
4852            {
4853                let conn = rusqlite::Connection::open(&db_path).unwrap();
4854                conn.execute_batch(
4855                    "CREATE TABLE items (id INTEGER, label TEXT, PRIMARY KEY(id));
4856                 INSERT INTO items (id, label) VALUES (10, 'alpha');
4857                 INSERT INTO items (id, label) VALUES (20, 'beta');",
4858                )
4859                .unwrap();
4860            }
4861
4862            let loaded = load_test_db(&db_path).await.unwrap();
4863            assert_eq!(loaded.schema.len(), 1);
4864            assert_eq!(loaded.schema[0].name, "items");
4865            assert!(loaded.schema[0].columns[0].is_ipk);
4866
4867            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4868            let rows: Vec<_> = table.iter_rows().collect();
4869            assert_eq!(rows.len(), 2);
4870            assert_eq!(rows[0].0, 10);
4871            assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4872            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4873            assert_eq!(rows[1].0, 20);
4874            assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4875            assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4876        });
4877    }
4878
4879    #[test]
4880    fn test_load_sqlite3_rowid_alias_multi_alter_short_rows_preserves_alignment() {
4881        asupersync::test_utils::run_test(|| async {
4882            let dir = tempfile::tempdir().unwrap();
4883            let db_path = dir.path().join("from_c_ipk_multi_alter.db");
4884
4885            {
4886                let conn = rusqlite::Connection::open(&db_path).unwrap();
4887                conn.execute_batch(
4888                    "CREATE TABLE items (
4889                    prefix TEXT,
4890                    id INTEGER PRIMARY KEY,
4891                    nullable TEXT,
4892                    required TEXT NOT NULL
4893                 );
4894                 INSERT INTO items(prefix, id, nullable, required)
4895                 VALUES ('p', 7, NULL, 'keep');
4896                 ALTER TABLE items ADD COLUMN extra TEXT DEFAULT 'x';
4897                 ALTER TABLE items ADD COLUMN note INTEGER DEFAULT 9;",
4898                )
4899                .unwrap();
4900            }
4901
4902            let loaded = load_test_db(&db_path).await.unwrap();
4903            assert_eq!(loaded.schema.len(), 1);
4904            assert_eq!(loaded.schema[0].name, "items");
4905
4906            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4907            let rows: Vec<_> = table.iter_rows().collect();
4908            assert_eq!(rows.len(), 1);
4909            assert_eq!(rows[0].0, 7);
4910            assert_eq!(rows[0].1[0], SqliteValue::Text("p".into()));
4911            assert_eq!(rows[0].1[1], SqliteValue::Integer(7));
4912            assert_eq!(rows[0].1[2], SqliteValue::Null);
4913            assert_eq!(rows[0].1[3], SqliteValue::Text("keep".into()));
4914            assert_eq!(rows[0].1[4], SqliteValue::Text("x".into()));
4915            assert_eq!(rows[0].1[5], SqliteValue::Integer(9));
4916        });
4917    }
4918
4919    #[test]
4920    fn test_load_sqlite3_rowid_alias_parenthesized_added_defaults() {
4921        asupersync::test_utils::run_test(|| async {
4922            let dir = tempfile::tempdir().unwrap();
4923            let db_path = dir.path().join("from_c_ipk_parenthesized_defaults.db");
4924
4925            {
4926                let conn = rusqlite::Connection::open(&db_path).unwrap();
4927                conn.execute_batch(
4928                    "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);
4929                 INSERT INTO items(id, name) VALUES (3, 'alpha');
4930                 ALTER TABLE items ADD COLUMN score INTEGER DEFAULT (9);
4931                 ALTER TABLE items ADD COLUMN tag TEXT DEFAULT ('fallback');",
4932                )
4933                .unwrap();
4934            }
4935
4936            let loaded = load_test_db(&db_path).await.unwrap();
4937            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4938            let rows: Vec<_> = table.iter_rows().collect();
4939            assert_eq!(rows.len(), 1);
4940            assert_eq!(rows[0].0, 3);
4941            assert_eq!(rows[0].1[0], SqliteValue::Integer(3));
4942            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4943            assert_eq!(rows[0].1[2], SqliteValue::Integer(9));
4944            assert_eq!(rows[0].1[3], SqliteValue::Text("fallback".into()));
4945        });
4946    }
4947
4948    #[test]
4949    fn test_load_sqlite3_altered_short_rows_parse_boolean_blob_and_quoted_defaults() {
4950        asupersync::test_utils::run_test(|| async {
4951            let dir = tempfile::tempdir().unwrap();
4952            let db_path = dir.path().join("from_c_ipk_literal_defaults.db");
4953
4954            {
4955                let conn = rusqlite::Connection::open(&db_path).unwrap();
4956                conn.execute_batch(
4957                    r#"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);
4958                 INSERT INTO items(id, name) VALUES (5, 'alpha');
4959                 ALTER TABLE items ADD COLUMN active BOOLEAN DEFAULT TRUE;
4960                 ALTER TABLE items ADD COLUMN disabled BOOLEAN DEFAULT FALSE;
4961                 ALTER TABLE items ADD COLUMN payload BLOB DEFAULT X'6162';
4962                 ALTER TABLE items ADD COLUMN tag TEXT DEFAULT "fallback";"#,
4963                )
4964                .unwrap();
4965            }
4966
4967            let loaded = load_test_db(&db_path).await.unwrap();
4968            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4969            let rows: Vec<_> = table.iter_rows().collect();
4970            assert_eq!(rows.len(), 1);
4971            assert_eq!(rows[0].0, 5);
4972            assert_eq!(rows[0].1[0], SqliteValue::Integer(5));
4973            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4974            assert_eq!(rows[0].1[2], SqliteValue::Integer(1));
4975            assert_eq!(rows[0].1[3], SqliteValue::Integer(0));
4976            assert_eq!(rows[0].1[4], SqliteValue::from(vec![0x61, 0x62]));
4977            assert_eq!(rows[0].1[5], SqliteValue::Text("fallback".into()));
4978        });
4979    }
4980
4981    #[test]
4982    fn test_inflate_loaded_rowid_alias_omitted_slot_keeps_shifted_null_alignment() {
4983        let column = |name: &str, affinity: char, is_ipk: bool| ColumnInfo {
4984            name: name.to_owned(),
4985            affinity,
4986            is_ipk,
4987            type_name: None,
4988            notnull: false,
4989            unique: false,
4990            default_value: None,
4991            strict_type: None,
4992            generated_expr: None,
4993            generated_stored: None,
4994            collation: None,
4995            conflict_action: None,
4996        };
4997        let mut required = column("required", 'B', false);
4998        required.notnull = true;
4999        let mut extra = column("extra", 'B', false);
5000        extra.default_value = Some("'x'".to_owned());
5001        let mut note = column("note", 'D', false);
5002        note.default_value = Some("9".to_owned());
5003        let columns = vec![
5004            column("prefix", 'B', false),
5005            column("id", 'D', true),
5006            column("nullable", 'B', false),
5007            required,
5008            extra,
5009            note,
5010        ];
5011        let mut values = vec![
5012            SqliteValue::Text("p".into()),
5013            SqliteValue::Null,
5014            SqliteValue::Text("keep".into()),
5015        ];
5016
5017        inflate_loaded_table_row_values(&mut values, 7, &columns, Some(1), "items").unwrap();
5018
5019        assert_eq!(values[0], SqliteValue::Text("p".into()));
5020        assert_eq!(values[1], SqliteValue::Integer(7));
5021        assert_eq!(values[2], SqliteValue::Null);
5022        assert_eq!(values[3], SqliteValue::Text("keep".into()));
5023        assert_eq!(values[4], SqliteValue::Text("x".into()));
5024        assert_eq!(values[5], SqliteValue::Integer(9));
5025    }
5026
5027    #[test]
5028    fn test_load_sqlite3_created_file_with_nondefault_page_size_and_reserved_bytes() {
5029        asupersync::test_utils::run_test(|| async {
5030            if Command::new("sqlite3").arg("--version").output().is_err() {
5031                eprintln!("skipping: sqlite3 binary not found");
5032                return;
5033            }
5034
5035            let dir = tempfile::tempdir().unwrap();
5036            let db_path = dir.path().join("from_c_reserved_bytes.db");
5037
5038            let mut child = Command::new("sqlite3")
5039                .arg(&db_path)
5040                .stdin(Stdio::piped())
5041                .stdout(Stdio::piped())
5042                .stderr(Stdio::piped())
5043                .spawn()
5044                .expect("sqlite3 process should start");
5045            {
5046                let mut stdin = child
5047                    .stdin
5048                    .take()
5049                    .expect("sqlite3 stdin should be available");
5050                stdin
5051                    .write_all(
5052                        br"PRAGMA journal_mode=DELETE;
5053PRAGMA page_size=8192;
5054VACUUM;
5055.filectrl reserve_bytes 32
5056VACUUM;
5057CREATE TABLE items (val INTEGER, label TEXT);
5058INSERT INTO items VALUES (10, 'alpha');
5059INSERT INTO items VALUES (20, 'beta');
5060PRAGMA integrity_check;
5061",
5062                    )
5063                    .expect("sqlite3 setup should accept the script");
5064            }
5065            let output = child
5066                .wait_with_output()
5067                .expect("sqlite3 process should finish");
5068            let stdout = String::from_utf8_lossy(&output.stdout);
5069            let stderr = String::from_utf8_lossy(&output.stderr);
5070            if !output.status.success()
5071                && (stdout.contains("unknown")
5072                    || stdout.contains("Usage:")
5073                    || stderr.contains("unknown")
5074                    || stderr.contains("Usage:"))
5075            {
5076                eprintln!(
5077                    "skipping: sqlite3 shell does not support .filectrl reserve_bytes: stdout={stdout} stderr={stderr}"
5078                );
5079                return;
5080            }
5081            assert!(
5082                output.status.success(),
5083                "sqlite3 reserved-byte setup failed: stdout={stdout} stderr={stderr}"
5084            );
5085            assert!(
5086                stdout.lines().any(|line| line.trim() == "ok"),
5087                "sqlite3 should report integrity_check=ok for the reserved-byte database: stdout={stdout} stderr={stderr}"
5088            );
5089
5090            let loaded = load_test_db(&db_path).await.unwrap_or_else(|error| {
5091            panic!(
5092                "compat loader must read valid C SQLite files with non-default page sizes and reserved bytes: {error}"
5093            )
5094        });
5095            assert_eq!(loaded.schema.len(), 1);
5096            assert_eq!(loaded.schema[0].name, "items");
5097
5098            let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
5099            let rows: Vec<_> = table.iter_rows().collect();
5100            assert_eq!(rows.len(), 2);
5101            assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
5102            assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
5103            assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
5104            assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
5105        });
5106    }
5107
5108    #[test]
5109    fn test_is_sqlite_format_text_file() {
5110        let dir = tempfile::tempdir().unwrap();
5111        let path = dir.path().join("text.db");
5112        host_fs::write(&path, b"CREATE TABLE t (x);").unwrap();
5113        assert!(!is_sqlite_format(&path));
5114    }
5115
5116    #[test]
5117    fn test_is_sqlite_format_nonexistent() {
5118        assert!(!is_sqlite_format(Path::new(
5119            "/tmp/nonexistent_compat_test.db"
5120        )));
5121    }
5122
5123    #[test]
5124    fn test_multiple_tables_roundtrip() {
5125        asupersync::test_utils::run_test(|| async {
5126            let dir = tempfile::tempdir().unwrap();
5127            let db_path = dir.path().join("multi.db");
5128
5129            let mut db = MemDatabase::new();
5130            let root_a = db.create_table(1);
5131            db.tables
5132                .get_mut(&root_a)
5133                .unwrap()
5134                .insert_row(1, vec![SqliteValue::Text("row_a".into())]);
5135
5136            let root_b = db.create_table(1);
5137            db.tables
5138                .get_mut(&root_b)
5139                .unwrap()
5140                .insert_row(1, vec![SqliteValue::Integer(777)]);
5141
5142            let schema = vec![
5143                TableSchema {
5144                    name: "alpha".to_owned(),
5145                    root_page: root_a,
5146                    columns: vec![ColumnInfo {
5147                        name: "val".to_owned(),
5148                        affinity: 'C',
5149                        is_ipk: false,
5150                        type_name: None,
5151                        notnull: false,
5152                        unique: false,
5153                        default_value: None,
5154                        strict_type: None,
5155                        generated_expr: None,
5156                        generated_stored: None,
5157                        collation: None,
5158                        conflict_action: None,
5159                    }],
5160                    indexes: Vec::new(),
5161                    strict: false,
5162                    without_rowid: false,
5163                    primary_key_constraints: Vec::new(),
5164                    foreign_keys: Vec::new(),
5165                    check_constraints: Vec::new(),
5166                },
5167                TableSchema {
5168                    name: "beta".to_owned(),
5169                    root_page: root_b,
5170                    columns: vec![ColumnInfo {
5171                        name: "num".to_owned(),
5172                        affinity: 'd',
5173                        is_ipk: false,
5174                        type_name: None,
5175                        notnull: false,
5176                        unique: false,
5177                        default_value: None,
5178                        strict_type: None,
5179                        generated_expr: None,
5180                        generated_stored: None,
5181                        collation: None,
5182                        conflict_action: None,
5183                    }],
5184                    indexes: Vec::new(),
5185                    strict: false,
5186                    without_rowid: false,
5187                    primary_key_constraints: Vec::new(),
5188                    foreign_keys: Vec::new(),
5189                    check_constraints: Vec::new(),
5190                },
5191            ];
5192
5193            persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
5194            let loaded = load_test_db(&db_path).await.unwrap();
5195
5196            assert_eq!(loaded.schema.len(), 2);
5197            assert_eq!(loaded.schema[0].name, "alpha");
5198            assert_eq!(loaded.schema[1].name, "beta");
5199
5200            let tbl_a = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
5201            let rows_a: Vec<_> = tbl_a.iter_rows().collect();
5202            assert_eq!(rows_a[0].1[0], SqliteValue::Text("row_a".into()));
5203
5204            let tbl_b = loaded.db.get_table(loaded.schema[1].root_page).unwrap();
5205            let rows_b: Vec<_> = tbl_b.iter_rows().collect();
5206            assert_eq!(rows_b[0].1[0], SqliteValue::Integer(777));
5207        });
5208    }
5209
5210    #[test]
5211    fn test_parse_columns_from_create_sql() {
5212        let sql = r#"CREATE TABLE "foo" ("id" INTEGER, "name" TEXT, "data" BLOB)"#;
5213        let cols = parse_columns_from_create_sql(sql);
5214        assert_eq!(cols.len(), 3);
5215        assert_eq!(cols[0].name, "id");
5216        assert_eq!(cols[0].affinity, 'D');
5217        assert_eq!(cols[1].name, "name");
5218        assert_eq!(cols[1].affinity, 'B');
5219        assert_eq!(cols[2].name, "data");
5220        assert_eq!(cols[2].affinity, 'A');
5221    }
5222
5223    #[test]
5224    fn test_parse_columns_from_create_sql_handles_nested_commas_and_constraints() {
5225        let sql = r"CREATE TABLE metrics (
5226            id INTEGER PRIMARY KEY,
5227            amount DECIMAL(10,2) NOT NULL,
5228            status TEXT CHECK (status IN ('a,b', 'c')),
5229            CONSTRAINT metrics_pk PRIMARY KEY (id)
5230        )";
5231        let cols = parse_columns_from_create_sql(sql);
5232        assert_eq!(cols.len(), 3);
5233        assert_eq!(cols[0].name, "id");
5234        assert_eq!(cols[0].affinity, 'D');
5235        assert!(cols[0].is_ipk);
5236        assert_eq!(cols[1].name, "amount");
5237        assert_eq!(cols[1].affinity, 'C');
5238        assert_eq!(cols[2].name, "status");
5239        assert_eq!(cols[2].affinity, 'B');
5240    }
5241
5242    #[test]
5243    fn test_parse_columns_from_create_sql_table_level_integer_primary_key_is_ipk() {
5244        let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id))";
5245        let cols = parse_columns_from_create_sql(sql);
5246        assert_eq!(cols.len(), 2);
5247        assert_eq!(cols[0].name, "id");
5248        assert!(cols[0].is_ipk);
5249        assert_eq!(cols[1].name, "body");
5250    }
5251
5252    #[test]
5253    fn test_parse_columns_from_create_sql_legacy_quoted_integer_primary_key_is_ipk() {
5254        let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY('id'))";
5255        let cols = parse_columns_from_create_sql(sql);
5256        assert_eq!(cols.len(), 2);
5257        assert_eq!(cols[0].name, "id");
5258        assert!(cols[0].is_ipk);
5259        assert_eq!(cols[1].name, "body");
5260        assert_eq!(
5261            extract_primary_key_constraints_from_sql(sql),
5262            vec![vec!["id".to_owned()]]
5263        );
5264    }
5265
5266    #[test]
5267    fn test_parse_columns_distinguishes_column_and_table_unique_ownership() {
5268        let column_owned = parse_columns_from_create_sql(
5269            "CREATE TABLE column_owned (id INTEGER UNIQUE, body TEXT)",
5270        );
5271        assert!(column_owned[0].unique);
5272
5273        let table_owned = parse_columns_from_create_sql(
5274            "CREATE TABLE table_owned (id INTEGER, body TEXT, UNIQUE(id))",
5275        );
5276        assert!(!table_owned[0].unique);
5277        let indexes = extract_unique_constraint_indexes_from_sql(
5278            "CREATE TABLE table_owned (id INTEGER, body TEXT, UNIQUE(id))",
5279            "table_owned",
5280        )
5281        .unwrap();
5282        assert_eq!(indexes.len(), 1);
5283        assert_eq!(indexes[0].columns, vec!["id"]);
5284    }
5285
5286    #[test]
5287    fn test_parse_columns_from_create_sql_table_level_integer_primary_key_desc_is_ipk() {
5288        let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id DESC))";
5289        let cols = parse_columns_from_create_sql(sql);
5290        assert_eq!(cols.len(), 2);
5291        assert_eq!(cols[0].name, "id");
5292        assert!(cols[0].is_ipk);
5293        assert_eq!(cols[1].name, "body");
5294    }
5295
5296    #[test]
5297    fn test_parse_columns_from_create_sql_table_level_integer_primary_key_collate_desc_is_ipk() {
5298        let sql =
5299            "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id COLLATE NOCASE DESC))";
5300        let cols = parse_columns_from_create_sql(sql);
5301        assert_eq!(cols.len(), 2);
5302        assert_eq!(cols[0].name, "id");
5303        assert!(cols[0].is_ipk);
5304        assert_eq!(cols[1].name, "body");
5305    }
5306
5307    #[test]
5308    fn test_parse_columns_from_create_sql_without_rowid_integer_pk_is_not_ipk() {
5309        let sql = "CREATE TABLE wr (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID";
5310        let cols = parse_columns_from_create_sql(sql);
5311        assert_eq!(cols.len(), 2);
5312        assert_eq!(cols[0].name, "id");
5313        assert!(!cols[0].is_ipk);
5314        assert!(cols[0].unique);
5315        assert_eq!(cols[1].name, "body");
5316    }
5317
5318    #[test]
5319    fn test_parse_columns_from_create_sql_keeps_quoted_keyword_column_name() {
5320        let sql = r#"CREATE TABLE t ("primary" TEXT, value INTEGER)"#;
5321        let cols = parse_columns_from_create_sql(sql);
5322        assert_eq!(cols.len(), 2);
5323        assert_eq!(cols[0].name, "primary");
5324        assert_eq!(cols[0].affinity, 'B');
5325        assert_eq!(cols[1].name, "value");
5326        assert_eq!(cols[1].affinity, 'D');
5327    }
5328
5329    #[test]
5330    fn test_parse_columns_from_create_sql_handles_quoted_names_with_spaces() {
5331        let sql = r#"CREATE TABLE t ("first name" TEXT, [last name] INTEGER, `role name` NUMERIC)"#;
5332        let cols = parse_columns_from_create_sql(sql);
5333        assert_eq!(cols.len(), 3);
5334        assert_eq!(cols[0].name, "first name");
5335        assert_eq!(cols[0].affinity, 'B');
5336        assert_eq!(cols[1].name, "last name");
5337        assert_eq!(cols[1].affinity, 'D');
5338        assert_eq!(cols[2].name, "role name");
5339        assert_eq!(cols[2].affinity, 'C');
5340    }
5341
5342    #[test]
5343    fn test_parse_columns_from_create_sql_ignores_constraint_keywords_inside_default_literals() {
5344        let sql = r#"CREATE TABLE t (
5345            note TEXT DEFAULT 'NOT NULL UNIQUE PRIMARY KEY',
5346            tag TEXT DEFAULT "fallback"
5347        )"#;
5348        let cols = parse_columns_from_create_sql(sql);
5349        assert_eq!(cols.len(), 2);
5350        assert!(!cols[0].notnull);
5351        assert!(!cols[0].unique);
5352        assert!(!cols[0].is_ipk);
5353        assert_eq!(
5354            cols[0].default_value.as_deref(),
5355            Some("'NOT NULL UNIQUE PRIMARY KEY'")
5356        );
5357        assert_eq!(cols[1].default_value.as_deref(), Some("fallback"));
5358    }
5359
5360    #[test]
5361    fn test_parse_columns_fallback_ignores_constraint_keywords_inside_default_literals() {
5362        let sql = r#"CREATE TABLE t (
5363            note TEXT DEFAULT 'NOT NULL UNIQUE PRIMARY KEY COLLATE bogus',
5364            actual INTEGER DEFAULT "PRIMARY KEY" PRIMARY KEY,
5365            required TEXT DEFAULT "UNIQUE" NOT NULL,
5366            uniq TEXT DEFAULT "NOT NULL" UNIQUE COLLATE nocase
5367        ) trailing"#;
5368        let cols = parse_columns_from_create_sql(sql);
5369
5370        assert_eq!(cols.len(), 4);
5371        assert!(!cols[0].notnull);
5372        assert!(!cols[0].unique);
5373        assert!(!cols[0].is_ipk);
5374        assert_eq!(cols[0].collation, None);
5375        assert!(cols[1].is_ipk);
5376        assert!(cols[1].unique);
5377        assert!(cols[2].notnull);
5378        assert!(!cols[2].unique);
5379        assert!(!cols[3].notnull);
5380        assert!(cols[3].unique);
5381        assert_eq!(cols[3].collation.as_deref(), Some("NOCASE"));
5382    }
5383
5384    #[test]
5385    fn test_parse_columns_fallback_finds_unquoted_default_keyword() {
5386        let sql = r#"CREATE TABLE t (
5387            note TEXT CHECK (note <> 'DEFAULT NOT NULL') DEFAULT 'ok',
5388            other TEXT CHECK (other <> "DEFAULT UNIQUE")
5389        ) trailing"#;
5390        let cols = parse_columns_from_create_sql(sql);
5391
5392        assert_eq!(cols.len(), 2);
5393        assert_eq!(cols[0].default_value.as_deref(), Some("'ok'"));
5394        assert!(!cols[0].notnull);
5395        assert_eq!(cols[1].default_value, None);
5396        assert!(!cols[1].unique);
5397    }
5398
5399    #[test]
5400    fn test_parse_columns_fallback_preserves_generated_column_metadata() {
5401        let sql = "CREATE TABLE t(a, c, b GENERATED ALWAYS AS(a * 2) STORED, CHECK(c > 0) ON CONFLICT FAIL)";
5402        let cols = parse_columns_from_create_sql(sql);
5403
5404        assert_eq!(cols.len(), 3);
5405        assert_eq!(cols[2].name, "b");
5406        assert_eq!(cols[2].generated_expr.as_deref(), Some("a * 2"));
5407        assert_eq!(cols[2].generated_stored, Some(true));
5408
5409        let virtual_cols = parse_columns_from_create_sql(
5410            "CREATE TABLE v(a, b GENERATED ALWAYS AS(a) REFERENCES stored, CHECK(a > 0) ON CONFLICT FAIL)",
5411        );
5412        assert_eq!(virtual_cols[1].generated_expr.as_deref(), Some("a"));
5413        assert_eq!(virtual_cols[1].generated_stored, Some(false));
5414    }
5415
5416    #[test]
5417    fn test_parse_columns_fallback_keeps_quoted_collation_names() {
5418        let sql = r#"CREATE TABLE t (
5419            name TEXT COLLATE "NOCASE",
5420            code TEXT COLLATE [RTRIM],
5421            note TEXT COLLATE 'BINARY',
5422            tag/* name/type comment, comma */TEXT COLLATE/* collation comment, comma */`NOCASE`
5423        ) trailing"#;
5424        let cols = parse_columns_from_create_sql(sql);
5425
5426        assert_eq!(cols.len(), 4);
5427        assert_eq!(cols[0].collation.as_deref(), Some("NOCASE"));
5428        assert_eq!(cols[1].collation.as_deref(), Some("RTRIM"));
5429        assert_eq!(cols[2].collation.as_deref(), Some("BINARY"));
5430        assert_eq!(cols[3].collation.as_deref(), Some("NOCASE"));
5431    }
5432
5433    #[test]
5434    fn test_parse_columns_from_create_sql_preserves_type_arguments() {
5435        let sql = "CREATE TABLE metrics (amount DECIMAL(10, 2), name VARCHAR(255))";
5436        let cols = parse_columns_from_create_sql(sql);
5437        assert_eq!(cols[0].type_name.as_deref(), Some("DECIMAL(10, 2)"));
5438        assert_eq!(cols[1].type_name.as_deref(), Some("VARCHAR(255)"));
5439    }
5440
5441    #[test]
5442    fn test_parse_columns_from_beads_style_multiline_create_table_sql() {
5443        let cases = [
5444            (
5445                "labels",
5446                r"CREATE TABLE labels (
5447                    issue_id TEXT NOT NULL,
5448                    label TEXT NOT NULL,
5449                    PRIMARY KEY (issue_id, label),
5450                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5451                )",
5452                &["issue_id", "label"][..],
5453            ),
5454            (
5455                "comments",
5456                r"CREATE TABLE comments (
5457                    id INTEGER PRIMARY KEY AUTOINCREMENT,
5458                    issue_id TEXT NOT NULL,
5459                    author TEXT NOT NULL,
5460                    text TEXT NOT NULL,
5461                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5462                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5463                )",
5464                &["id", "issue_id", "author", "text", "created_at"][..],
5465            ),
5466            (
5467                "events",
5468                r"CREATE TABLE events (
5469                    id INTEGER PRIMARY KEY AUTOINCREMENT,
5470                    issue_id TEXT NOT NULL,
5471                    event_type TEXT NOT NULL,
5472                    actor TEXT NOT NULL DEFAULT '',
5473                    old_value TEXT,
5474                    new_value TEXT,
5475                    comment TEXT,
5476                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5477                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5478                )",
5479                &[
5480                    "id",
5481                    "issue_id",
5482                    "event_type",
5483                    "actor",
5484                    "old_value",
5485                    "new_value",
5486                    "comment",
5487                    "created_at",
5488                ][..],
5489            ),
5490            (
5491                "config",
5492                r"CREATE TABLE config (
5493                    key TEXT PRIMARY KEY,
5494                    value TEXT NOT NULL
5495                )",
5496                &["key", "value"][..],
5497            ),
5498            (
5499                "blocked_issues_cache",
5500                r"CREATE TABLE blocked_issues_cache (
5501                    issue_id TEXT PRIMARY KEY,
5502                    blocked_by TEXT NOT NULL,  -- JSON array of blocking issue IDs
5503                    blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5504                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5505                )",
5506                &["issue_id", "blocked_by", "blocked_at"][..],
5507            ),
5508            (
5509                "issues",
5510                r"CREATE TABLE issues (
5511                    id TEXT PRIMARY KEY,
5512                    content_hash TEXT,
5513                    title TEXT NOT NULL,
5514                    description TEXT NOT NULL DEFAULT '',
5515                    design TEXT NOT NULL DEFAULT '',
5516                    acceptance_criteria TEXT NOT NULL DEFAULT '',
5517                    notes TEXT NOT NULL DEFAULT '',
5518                    status TEXT NOT NULL DEFAULT 'open',
5519                    priority INTEGER NOT NULL DEFAULT 2,
5520                    issue_type TEXT NOT NULL DEFAULT 'task',
5521                    assignee TEXT,
5522                    owner TEXT DEFAULT '',
5523                    estimated_minutes INTEGER,
5524                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5525                    created_by TEXT DEFAULT '',
5526                    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5527                    closed_at DATETIME,
5528                    close_reason TEXT DEFAULT '',
5529                    closed_by_session TEXT DEFAULT '',
5530                    due_at DATETIME,
5531                    defer_until DATETIME,
5532                    external_ref TEXT,
5533                    source_system TEXT DEFAULT '',
5534                    source_repo TEXT NOT NULL DEFAULT '.',
5535                    deleted_at DATETIME,
5536                    deleted_by TEXT DEFAULT '',
5537                    delete_reason TEXT DEFAULT '',
5538                    original_type TEXT DEFAULT '',
5539                    compaction_level INTEGER DEFAULT 0,
5540                    compacted_at DATETIME,
5541                    compacted_at_commit TEXT,
5542                    original_size INTEGER,
5543                    sender TEXT DEFAULT '',
5544                    ephemeral INTEGER DEFAULT 0,
5545                    pinned INTEGER DEFAULT 0,
5546                    is_template INTEGER DEFAULT 0,
5547                    CHECK(length(title) <= 500),
5548                    CHECK(priority >= 0 AND priority <= 4),
5549                    CHECK((status = 'closed' AND closed_at IS NOT NULL) OR (status != 'closed'))
5550                )",
5551                &[
5552                    "id",
5553                    "content_hash",
5554                    "title",
5555                    "description",
5556                    "design",
5557                    "acceptance_criteria",
5558                    "notes",
5559                    "status",
5560                    "priority",
5561                    "issue_type",
5562                    "assignee",
5563                    "owner",
5564                    "estimated_minutes",
5565                    "created_at",
5566                    "created_by",
5567                    "updated_at",
5568                    "closed_at",
5569                    "close_reason",
5570                    "closed_by_session",
5571                    "due_at",
5572                    "defer_until",
5573                    "external_ref",
5574                    "source_system",
5575                    "source_repo",
5576                    "deleted_at",
5577                    "deleted_by",
5578                    "delete_reason",
5579                    "original_type",
5580                    "compaction_level",
5581                    "compacted_at",
5582                    "compacted_at_commit",
5583                    "original_size",
5584                    "sender",
5585                    "ephemeral",
5586                    "pinned",
5587                    "is_template",
5588                ][..],
5589            ),
5590        ];
5591
5592        for (table_name, sql, expected_columns) in cases {
5593            let cols = parse_columns_from_create_sql(sql);
5594            let actual_names: Vec<&str> = cols.iter().map(|col| col.name.as_str()).collect();
5595            assert_eq!(
5596                actual_names, expected_columns,
5597                "failed to parse Beads-style column list for table {table_name}"
5598            );
5599        }
5600    }
5601
5602    #[test]
5603    fn test_build_create_table_sql_appends_strict_keyword() {
5604        let table = TableSchema {
5605            name: "strict_table".to_owned(),
5606            root_page: 2,
5607            columns: vec![ColumnInfo {
5608                name: "id".to_owned(),
5609                affinity: 'D',
5610                is_ipk: false,
5611                type_name: Some("INTEGER".to_owned()),
5612                notnull: false,
5613                unique: false,
5614                default_value: None,
5615                strict_type: Some(StrictColumnType::Integer),
5616                generated_expr: None,
5617                generated_stored: None,
5618                collation: None,
5619                conflict_action: None,
5620            }],
5621            indexes: Vec::new(),
5622            strict: true,
5623            without_rowid: false,
5624            primary_key_constraints: Vec::new(),
5625            foreign_keys: Vec::new(),
5626            check_constraints: Vec::new(),
5627        };
5628
5629        let sql = build_create_table_sql(&table);
5630        assert!(
5631            sql.ends_with(" STRICT"),
5632            "STRICT tables must round-trip with STRICT suffix: {sql}"
5633        );
5634    }
5635
5636    #[test]
5637    fn test_build_create_table_sql_preserves_declared_type_text() {
5638        let table = TableSchema {
5639            name: "typed_table".to_owned(),
5640            root_page: 2,
5641            columns: vec![
5642                ColumnInfo {
5643                    name: "amount".to_owned(),
5644                    affinity: 'C',
5645                    is_ipk: false,
5646                    type_name: Some("DECIMAL(10, 2)".to_owned()),
5647                    notnull: false,
5648                    unique: false,
5649                    default_value: None,
5650                    strict_type: None,
5651                    generated_expr: None,
5652                    generated_stored: None,
5653                    collation: None,
5654                    conflict_action: None,
5655                },
5656                ColumnInfo {
5657                    name: "name".to_owned(),
5658                    affinity: 'B',
5659                    is_ipk: false,
5660                    type_name: Some("VARCHAR(255)".to_owned()),
5661                    notnull: false,
5662                    unique: false,
5663                    default_value: None,
5664                    strict_type: None,
5665                    generated_expr: None,
5666                    generated_stored: None,
5667                    collation: None,
5668                    conflict_action: None,
5669                },
5670            ],
5671            indexes: Vec::new(),
5672            strict: false,
5673            without_rowid: false,
5674            primary_key_constraints: Vec::new(),
5675            foreign_keys: Vec::new(),
5676            check_constraints: Vec::new(),
5677        };
5678
5679        let sql = build_create_table_sql(&table);
5680        assert!(sql.contains("\"amount\" DECIMAL(10, 2)"), "{sql}");
5681        assert!(sql.contains("\"name\" VARCHAR(255)"), "{sql}");
5682    }
5683
5684    #[test]
5685    fn test_build_create_table_sql_preserves_typeless_columns() {
5686        let table = TableSchema {
5687            name: "typeless_table".to_owned(),
5688            root_page: 2,
5689            columns: vec![ColumnInfo {
5690                name: "payload".to_owned(),
5691                affinity: 'A',
5692                is_ipk: false,
5693                type_name: None,
5694                notnull: false,
5695                unique: false,
5696                default_value: None,
5697                strict_type: None,
5698                generated_expr: None,
5699                generated_stored: None,
5700                collation: None,
5701                conflict_action: None,
5702            }],
5703            indexes: Vec::new(),
5704            strict: false,
5705            without_rowid: false,
5706            primary_key_constraints: Vec::new(),
5707            foreign_keys: Vec::new(),
5708            check_constraints: Vec::new(),
5709        };
5710
5711        let sql = build_create_table_sql(&table);
5712        assert_eq!(sql, "CREATE TABLE \"typeless_table\" (\"payload\")");
5713    }
5714
5715    #[test]
5716    fn test_build_create_table_sql_escapes_embedded_quotes_in_identifiers() {
5717        let table = TableSchema {
5718            name: "ty\"ped_table".to_owned(),
5719            root_page: 2,
5720            columns: vec![
5721                ColumnInfo {
5722                    name: "pay\"load".to_owned(),
5723                    affinity: 'A',
5724                    is_ipk: false,
5725                    type_name: None,
5726                    notnull: false,
5727                    unique: false,
5728                    default_value: None,
5729                    strict_type: None,
5730                    generated_expr: None,
5731                    generated_stored: None,
5732                    collation: Some("noca\"se".to_owned()),
5733                    conflict_action: None,
5734                },
5735                ColumnInfo {
5736                    name: "parent\"id".to_owned(),
5737                    affinity: 'D',
5738                    is_ipk: false,
5739                    type_name: Some("INTEGER".to_owned()),
5740                    notnull: false,
5741                    unique: false,
5742                    default_value: None,
5743                    strict_type: None,
5744                    generated_expr: None,
5745                    generated_stored: None,
5746                    collation: None,
5747                    conflict_action: None,
5748                },
5749            ],
5750            indexes: Vec::new(),
5751            strict: false,
5752            without_rowid: false,
5753            primary_key_constraints: Vec::new(),
5754            foreign_keys: vec![FkDef {
5755                child_columns: vec![1],
5756                owner_column: None,
5757                parent_table: "pa\"rent".to_owned(),
5758                parent_columns: vec!["id\"x".to_owned()],
5759                on_delete: FkActionType::Cascade,
5760                on_update: FkActionType::NoAction,
5761                deferred: false,
5762            }],
5763            check_constraints: Vec::new(),
5764        };
5765
5766        let sql = build_create_table_sql(&table);
5767        assert!(sql.contains("\"ty\"\"ped_table\""), "{sql}");
5768        assert!(
5769            sql.contains("\"pay\"\"load\" COLLATE \"noca\"\"se\""),
5770            "{sql}"
5771        );
5772        assert!(
5773            sql.contains("FOREIGN KEY(\"parent\"\"id\") REFERENCES \"pa\"\"rent\"(\"id\"\"x\")"),
5774            "{sql}"
5775        );
5776    }
5777
5778    #[test]
5779    fn test_build_create_table_sql_preserves_primary_key_constraints() {
5780        let table = TableSchema {
5781            name: "pk_table".to_owned(),
5782            root_page: 2,
5783            columns: vec![
5784                ColumnInfo {
5785                    name: "id".to_owned(),
5786                    affinity: 'B',
5787                    is_ipk: false,
5788                    type_name: Some("TEXT".to_owned()),
5789                    notnull: false,
5790                    unique: true,
5791                    default_value: None,
5792                    strict_type: None,
5793                    generated_expr: None,
5794                    generated_stored: None,
5795                    collation: None,
5796                    conflict_action: None,
5797                },
5798                ColumnInfo {
5799                    name: "body".to_owned(),
5800                    affinity: 'A',
5801                    is_ipk: false,
5802                    type_name: None,
5803                    notnull: false,
5804                    unique: false,
5805                    default_value: None,
5806                    strict_type: None,
5807                    generated_expr: None,
5808                    generated_stored: None,
5809                    collation: None,
5810                    conflict_action: None,
5811                },
5812            ],
5813            indexes: Vec::new(),
5814            strict: false,
5815            without_rowid: false,
5816            primary_key_constraints: vec![vec!["id".to_owned()]],
5817            foreign_keys: Vec::new(),
5818            check_constraints: Vec::new(),
5819        };
5820
5821        let sql = build_create_table_sql(&table);
5822        assert!(sql.contains("PRIMARY KEY"), "{sql}");
5823        assert!(!sql.contains("UNIQUE"), "{sql}");
5824        assert_eq!(
5825            sql,
5826            "CREATE TABLE \"pk_table\" (\"id\" TEXT, \"body\", PRIMARY KEY (\"id\"))"
5827        );
5828    }
5829
5830    #[test]
5831    fn test_build_create_table_sql_appends_without_rowid_and_strict_options() {
5832        let table = TableSchema {
5833            name: "wr_strict".to_owned(),
5834            root_page: 2,
5835            columns: vec![ColumnInfo {
5836                name: "id".to_owned(),
5837                affinity: 'D',
5838                is_ipk: false,
5839                type_name: Some("INTEGER".to_owned()),
5840                notnull: false,
5841                unique: true,
5842                default_value: None,
5843                strict_type: Some(StrictColumnType::Integer),
5844                generated_expr: None,
5845                generated_stored: None,
5846                collation: None,
5847                conflict_action: None,
5848            }],
5849            indexes: Vec::new(),
5850            strict: true,
5851            without_rowid: true,
5852            primary_key_constraints: Vec::new(),
5853            foreign_keys: Vec::new(),
5854            check_constraints: Vec::new(),
5855        };
5856
5857        let sql = build_create_table_sql(&table);
5858        assert!(sql.ends_with(" WITHOUT ROWID, STRICT"), "{sql}");
5859    }
5860
5861    #[test]
5862    fn test_build_create_table_sql_preserves_unique_foreign_key_and_check_constraints() {
5863        let table = TableSchema {
5864            name: "child".to_owned(),
5865            root_page: 2,
5866            columns: vec![
5867                ColumnInfo {
5868                    name: "parent_id".to_owned(),
5869                    affinity: 'D',
5870                    is_ipk: false,
5871                    type_name: Some("INTEGER".to_owned()),
5872                    notnull: true,
5873                    unique: false,
5874                    default_value: None,
5875                    strict_type: None,
5876                    generated_expr: None,
5877                    generated_stored: None,
5878                    collation: None,
5879                    conflict_action: None,
5880                },
5881                ColumnInfo {
5882                    name: "slug".to_owned(),
5883                    affinity: 'B',
5884                    is_ipk: false,
5885                    type_name: Some("TEXT".to_owned()),
5886                    notnull: false,
5887                    unique: false,
5888                    default_value: None,
5889                    strict_type: None,
5890                    generated_expr: None,
5891                    generated_stored: None,
5892                    collation: None,
5893                    conflict_action: None,
5894                },
5895            ],
5896            indexes: vec![IndexSchema {
5897                name: "sqlite_autoindex_child_1".to_owned(),
5898                root_page: 0,
5899                columns: vec!["parent_id".to_owned(), "slug".to_owned()],
5900                key_expressions: Vec::new(),
5901                key_sort_directions: vec![SortDirection::Asc, SortDirection::Asc],
5902                where_clause: None,
5903                is_unique: true,
5904                key_collations: vec![],
5905                conflict_action: None,
5906            }],
5907            strict: false,
5908            without_rowid: false,
5909            primary_key_constraints: Vec::new(),
5910            foreign_keys: vec![FkDef {
5911                child_columns: vec![0],
5912                owner_column: None,
5913                parent_table: "parent".to_owned(),
5914                parent_columns: vec!["id".to_owned()],
5915                on_delete: FkActionType::Cascade,
5916                on_update: FkActionType::Restrict,
5917                deferred: false,
5918            }],
5919            check_constraints: vec![CheckConstraint {
5920                expr: "length(slug) > 0".to_owned(),
5921                owner_column: None,
5922            }],
5923        };
5924
5925        let sql = build_create_table_sql(&table);
5926        assert!(sql.contains("UNIQUE (\"parent_id\", \"slug\")"), "{sql}");
5927        assert!(
5928            sql.contains(
5929                "FOREIGN KEY(\"parent_id\") REFERENCES \"parent\"(\"id\") ON DELETE CASCADE ON UPDATE RESTRICT"
5930            ),
5931            "{sql}"
5932        );
5933        assert!(sql.contains("CHECK(length(slug) > 0)"), "{sql}");
5934    }
5935
5936    #[test]
5937    fn test_extract_unique_constraint_indexes_from_sql_preserves_table_level_unique_constraints() {
5938        let indexes = extract_unique_constraint_indexes_from_sql(
5939            "CREATE TABLE child (tenant TEXT, slug TEXT, UNIQUE(tenant, slug))",
5940            "child",
5941        )
5942        .unwrap();
5943        assert_eq!(indexes.len(), 1);
5944        assert_eq!(indexes[0].columns, vec!["tenant", "slug"]);
5945        assert!(indexes[0].is_unique);
5946    }
5947
5948    #[test]
5949    fn test_extract_unique_constraint_indexes_skips_table_level_integer_primary_key_alias() {
5950        let indexes = extract_unique_constraint_indexes_from_sql(
5951            "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id COLLATE NOCASE DESC))",
5952            "metrics",
5953        )
5954        .unwrap();
5955        assert!(indexes.is_empty(), "{indexes:?}");
5956    }
5957
5958    #[test]
5959    fn test_extract_implicit_autoindexes_preserves_without_rowid_slots_and_exact_integer_rules() {
5960        let indexes = extract_unique_constraint_indexes_from_sql(
5961            "CREATE TABLE wr(
5962                pk TEXT PRIMARY KEY,
5963                u TEXT COLLATE NOCASE COLLATE RTRIM UNIQUE
5964             ) WITHOUT ROWID",
5965            "wr",
5966        )
5967        .unwrap();
5968        assert_eq!(indexes.len(), 1);
5969        assert_eq!(indexes[0].name, "sqlite_autoindex_wr_2");
5970        assert_eq!(indexes[0].columns, ["u"]);
5971        assert_eq!(indexes[0].key_collations, [Some("RTRIM".to_owned())]);
5972
5973        let typed = extract_unique_constraint_indexes_from_sql(
5974            "CREATE TABLE typed(id INTEGER(8) PRIMARY KEY, u TEXT UNIQUE)",
5975            "typed",
5976        )
5977        .unwrap();
5978        assert_eq!(
5979            typed
5980                .iter()
5981                .map(|index| index.name.as_str())
5982                .collect::<Vec<_>>(),
5983            ["sqlite_autoindex_typed_1", "sqlite_autoindex_typed_2"]
5984        );
5985    }
5986
5987    #[test]
5988    fn test_autoindex_followup_explicit_index_uses_final_repeated_collation() {
5989        let Some(Statement::CreateIndex(create)) =
5990            parse_single_statement("CREATE INDEX idx_t_a ON t(a COLLATE NOCASE COLLATE RTRIM)")
5991        else {
5992            panic!("expected CREATE INDEX");
5993        };
5994        let table = bare_table_schema("t", &["a"]);
5995        let index = bind_explicit_index(&create, "idx_t_a", "t", &table)
5996            .expect("authoritative index binder should accept repeated COLLATE syntax")
5997            .into_index_schema(7);
5998        assert_eq!(index.columns, ["a"]);
5999        assert_eq!(index.key_collations, [Some("RTRIM".to_owned())]);
6000    }
6001
6002    #[test]
6003    fn test_is_strict_table_sql_detects_strict_options() {
6004        assert!(is_strict_table_sql(
6005            "CREATE TABLE s (id INTEGER, body TEXT) STRICT"
6006        ));
6007        assert!(is_strict_table_sql(
6008            "CREATE TABLE s (id INTEGER) WITHOUT ROWID, STRICT;"
6009        ));
6010        assert!(!is_strict_table_sql(
6011            "CREATE TABLE s (id INTEGER, body TEXT) WITHOUT ROWID"
6012        ));
6013    }
6014
6015    #[test]
6016    fn test_is_without_rowid_table_sql_detects_option() {
6017        assert!(is_without_rowid_table_sql(
6018            "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID"
6019        ));
6020        assert!(is_without_rowid_table_sql(
6021            "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID, STRICT;"
6022        ));
6023        assert!(!is_without_rowid_table_sql(
6024            "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) STRICT"
6025        ));
6026    }
6027
6028    #[test]
6029    fn test_is_autoincrement_table_sql_detects_keyword() {
6030        assert!(is_autoincrement_table_sql(
6031            "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)"
6032        ));
6033        assert!(!is_autoincrement_table_sql(
6034            "CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)"
6035        ));
6036    }
6037
6038    #[test]
6039    fn test_is_autoincrement_table_sql_ignores_default_literal_keyword() {
6040        assert!(!is_autoincrement_table_sql(
6041            "CREATE TABLE t(id INTEGER PRIMARY KEY, note TEXT DEFAULT 'AUTOINCREMENT')"
6042        ));
6043        assert!(!is_autoincrement_table_sql(
6044            "CREATE TABLE t(id INTEGER PRIMARY KEY, note TEXT DEFAULT 'AUTOINCREMENT') trailing"
6045        ));
6046    }
6047
6048    #[test]
6049    fn test_parse_columns_from_create_sql_populates_strict_types() {
6050        let sql = "CREATE TABLE strict_cols (id INTEGER, score REAL, body TEXT, payload BLOB, any_col ANY) STRICT";
6051        let cols = parse_columns_from_create_sql(sql);
6052        assert_eq!(cols.len(), 5);
6053        assert_eq!(cols[0].strict_type, Some(StrictColumnType::Integer));
6054        assert_eq!(cols[1].strict_type, Some(StrictColumnType::Real));
6055        assert_eq!(cols[2].strict_type, Some(StrictColumnType::Text));
6056        assert_eq!(cols[3].strict_type, Some(StrictColumnType::Blob));
6057        assert_eq!(cols[4].strict_type, Some(StrictColumnType::Any));
6058    }
6059
6060    #[test]
6061    fn test_parse_columns_from_sqlite_master_sql_ignores_virtual_table_options() {
6062        let sql =
6063            "CREATE VIRTUAL TABLE docs USING fts5(subject, body, tokenize='porter', prefix='2 3')";
6064        let cols = parse_columns_from_sqlite_master_sql(sql);
6065        let names: Vec<&str> = cols.iter().map(|column| column.name.as_str()).collect();
6066        assert_eq!(names, vec!["subject", "body"]);
6067    }
6068
6069    #[test]
6070    fn test_extract_check_constraints_from_sql_ignores_literal_check_text() {
6071        let sql = "CREATE TABLE t (note TEXT DEFAULT 'CHECK(fake)', CHECK(length(note) > 0))";
6072        let checks = extract_check_constraints_from_sql(sql);
6073        assert_eq!(checks, vec!["length(note) > 0".to_owned()]);
6074    }
6075
6076    #[test]
6077    fn test_check_constraint_fallback_preserves_column_ownership() {
6078        // SQLite accepts a conflict clause after a table CHECK, while the AST
6079        // parser currently rejects that suffix. Exercise the fallback so a
6080        // neighboring column CHECK does not get flattened into table scope.
6081        let sql = r#"CREATE TABLE t(
6082            "owned col" TEXT DEFAULT 'CHECK(fake)' CHECK(length("owned col") > 0),
6083            b INTEGER,
6084            CONSTRAINT/*name*/ table_check CHECK/*expr*/(b > 0) ON CONFLICT FAIL
6085        )"#;
6086        let checks = extract_check_constraints_with_owners_from_sql(sql);
6087        assert_eq!(
6088            checks,
6089            vec![
6090                CheckConstraint {
6091                    expr: r#"length("owned col") > 0"#.to_owned(),
6092                    owner_column: Some("owned col".to_owned()),
6093                },
6094                CheckConstraint {
6095                    expr: "b > 0".to_owned(),
6096                    owner_column: None,
6097                },
6098            ]
6099        );
6100    }
6101
6102    #[test]
6103    fn test_foreign_key_fallback_preserves_ownership_and_actions() {
6104        // The trailing CHECK conflict clause is accepted by SQLite but is not
6105        // yet accepted by the full AST parser, forcing the schema fallback.
6106        let sql = r#"CREATE TABLE child(
6107            "owned col" INTEGER REFERENCES parent(id)
6108                ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
6109            keep INTEGER,
6110            CONSTRAINT fk_keep FOREIGN KEY(keep) REFERENCES "parent table"("id col")
6111                ON UPDATE RESTRICT NOT DEFERRABLE INITIALLY DEFERRED,
6112            CHECK(keep > 0) ON CONFLICT FAIL
6113        )"#;
6114        let columns = parse_columns_from_create_sql(sql);
6115        let foreign_keys = extract_foreign_keys_from_sql(sql, &columns);
6116
6117        assert_eq!(foreign_keys.len(), 2);
6118        assert_eq!(foreign_keys[0].child_columns, vec![0]);
6119        assert_eq!(foreign_keys[0].owner_column.as_deref(), Some("owned col"));
6120        assert_eq!(foreign_keys[0].parent_table, "parent");
6121        assert_eq!(foreign_keys[0].parent_columns, vec!["id"]);
6122        assert_eq!(foreign_keys[0].on_delete, FkActionType::Cascade);
6123        assert!(foreign_keys[0].deferred);
6124
6125        assert_eq!(foreign_keys[1].child_columns, vec![1]);
6126        assert_eq!(foreign_keys[1].owner_column, None);
6127        assert_eq!(foreign_keys[1].on_update, FkActionType::Restrict);
6128        assert!(!foreign_keys[1].deferred);
6129    }
6130
6131    #[test]
6132    fn test_type_to_affinity_mapping() {
6133        assert_eq!(type_to_affinity("INTEGER"), 'D');
6134        assert_eq!(type_to_affinity("INT"), 'D');
6135        assert_eq!(type_to_affinity("REAL"), 'E');
6136        assert_eq!(type_to_affinity("FLOAT"), 'E');
6137        assert_eq!(type_to_affinity("TEXT"), 'B');
6138        assert_eq!(type_to_affinity("VARCHAR"), 'B');
6139        assert_eq!(type_to_affinity("BLOB"), 'A');
6140        assert_eq!(type_to_affinity("NUMERIC"), 'C');
6141    }
6142
6143    #[test]
6144    fn test_parse_create_index_sql_preserves_quoted_collations_and_comments() {
6145        let sql = r#"CREATE INDEX "idx(words)" ON "items(table)" (
6146            "last, name" COLLATE /* keep comment invisible */ [RTRIM] DESC,
6147            code/* comma, paren ), and COLLATE text stay in comment */COLLATE 'BINARY',
6148            tag COLLATE DESC,
6149            ord COLLATE [DESC] DESC
6150        ) /* index tail */ WHERE active = 1"#;
6151
6152        let Some(Statement::CreateIndex(create)) = parse_single_statement(sql) else {
6153            panic!("expected CREATE INDEX");
6154        };
6155        let table = bare_table_schema(
6156            "items(table)",
6157            &["last, name", "code", "tag", "ord", "active"],
6158        );
6159        let idx = bind_explicit_index(&create, "idx(words)", "items(table)", &table)
6160            .expect("authoritative index binder should preserve quoted metadata")
6161            .into_index_schema(7);
6162
6163        assert_eq!(
6164            idx.columns,
6165            vec![
6166                "last, name".to_owned(),
6167                "code".to_owned(),
6168                "tag".to_owned(),
6169                "ord".to_owned()
6170            ]
6171        );
6172        // Collation names are compared case-insensitively by SQLite
6173        // (`sqlite3_strnicmp`), so `key_collations` carries semantic schema
6174        // state rather than source spelling. Assert the identity of all four
6175        // names — including that `COLLATE DESC` binds a collation *named*
6176        // `DESC` rather than being absorbed as a sort direction — without
6177        // pinning the case a binder happens to emit. The length and `Some`
6178        // checks stay explicit so a dropped or `None` term still fails.
6179        let expected_collations = ["RTRIM", "BINARY", "DESC", "DESC"];
6180        assert_eq!(
6181            idx.key_collations.len(),
6182            expected_collations.len(),
6183            "every key term must bind a collation: {:?}",
6184            idx.key_collations
6185        );
6186        for (term, expected) in expected_collations.iter().enumerate() {
6187            let actual = idx.key_collations[term].as_deref().unwrap_or_else(|| {
6188                panic!("term {term} must bind collation `{expected}`, found None")
6189            });
6190            assert!(
6191                actual.eq_ignore_ascii_case(expected),
6192                "term {term} must bind collation `{expected}` (case-insensitively), found `{actual}`"
6193            );
6194        }
6195        assert_eq!(
6196            idx.key_sort_directions,
6197            vec![
6198                SortDirection::Desc,
6199                SortDirection::Asc,
6200                SortDirection::Asc,
6201                SortDirection::Desc
6202            ]
6203        );
6204        assert_eq!(idx.where_clause.as_deref(), Some("active = 1"));
6205    }
6206
6207    #[test]
6208    fn test_parse_create_index_sql_preserves_expression_terms() {
6209        let sql =
6210            "CREATE UNIQUE INDEX uq_agents_name_ci ON agents(lower(name) DESC) WHERE is_active = 1";
6211
6212        let Some(Statement::CreateIndex(create)) = parse_single_statement(sql) else {
6213            panic!("expected CREATE INDEX");
6214        };
6215        let table = bare_table_schema("agents", &["name", "is_active"]);
6216        let idx = bind_explicit_index(&create, "uq_agents_name_ci", "agents", &table)
6217            .expect("authoritative index binder should preserve expression metadata")
6218            .into_index_schema(7);
6219
6220        assert!(idx.columns.is_empty());
6221        assert_eq!(idx.key_expressions.len(), 1);
6222        assert_eq!(idx.key_expressions[0].to_ascii_lowercase(), "lower(name)");
6223        assert_eq!(idx.key_sort_directions, vec![SortDirection::Desc]);
6224        assert_eq!(idx.where_clause.as_deref(), Some("is_active = 1"));
6225        assert!(idx.is_unique);
6226    }
6227
6228    #[test]
6229    fn test_build_create_index_sql_preserves_unique_collation_and_direction() {
6230        let terms = [
6231            CreateIndexSqlTerm {
6232                column_name: "project_id",
6233                collation: None,
6234                direction: Some(SortDirection::Asc),
6235            },
6236            CreateIndexSqlTerm {
6237                column_name: "name",
6238                collation: Some("NOCASE"),
6239                direction: Some(SortDirection::Desc),
6240            },
6241        ];
6242
6243        let sql = build_create_index_sql(
6244            "idx_agents_project_name_nocase",
6245            "agents",
6246            true,
6247            &terms,
6248            None,
6249        );
6250
6251        assert_eq!(
6252            sql,
6253            "CREATE UNIQUE INDEX \"idx_agents_project_name_nocase\" ON \"agents\" (\"project_id\" ASC, \"name\" COLLATE \"NOCASE\" DESC)"
6254        );
6255    }
6256
6257    #[test]
6258    fn test_build_create_index_sql_escapes_embedded_quotes_in_identifiers() {
6259        let terms = [CreateIndexSqlTerm {
6260            column_name: "na\"me",
6261            collation: Some("NO\"CASE"),
6262            direction: Some(SortDirection::Desc),
6263        }];
6264
6265        let sql = build_create_index_sql("idx\"q", "ta\"ble", true, &terms, None);
6266
6267        assert_eq!(
6268            sql,
6269            "CREATE UNIQUE INDEX \"idx\"\"q\" ON \"ta\"\"ble\" (\"na\"\"me\" COLLATE \"NO\"\"CASE\" DESC)"
6270        );
6271    }
6272
6273    #[test]
6274    fn test_index_sql_preserves_explicit_reserved_prefix_definition() {
6275        let mut original_ddl = HashMap::new();
6276        original_ddl.insert(
6277            "sqlite_autoindex_link_table_v23_1".to_owned(),
6278            "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
6279                .to_owned(),
6280        );
6281
6282        let sql = index_sql_for_persistence(
6283            "sqlite_autoindex_link_table_v23_1",
6284            "link_table",
6285            &original_ddl,
6286            || panic!("preserved explicit DDL must win over prefix classification"),
6287        );
6288
6289        assert_eq!(
6290            sql.as_deref(),
6291            Some(
6292                "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
6293            )
6294        );
6295    }
6296
6297    #[test]
6298    fn test_index_sql_keeps_true_implicit_autoindex_null() {
6299        let original_ddl = HashMap::<String, String>::new();
6300
6301        let sql = index_sql_for_persistence(
6302            "sqlite_autoindex_link_table_1",
6303            "link_table",
6304            &original_ddl,
6305            || panic!("implicit autoindex SQL must not be synthesized"),
6306        );
6307
6308        assert_eq!(sql, None);
6309    }
6310
6311    #[test]
6312    fn test_autoindex_ordinal_requires_canonical_positive_ascii_decimal() {
6313        assert_eq!(
6314            parse_autoindex_ordinal("sqlite_autoindex_link_table_1", "link_table"),
6315            Some(1)
6316        );
6317        for noncanonical in [
6318            "sqlite_autoindex_link_table_0",
6319            "sqlite_autoindex_link_table_01",
6320            "sqlite_autoindex_link_table_+1",
6321            "sqlite_autoindex_link_table_١",
6322            "sqlite_autoindex_other_table_1",
6323        ] {
6324            assert_eq!(
6325                parse_autoindex_ordinal(noncanonical, "link_table"),
6326                None,
6327                "{noncanonical} must not classify as an implicit autoindex"
6328            );
6329        }
6330        let overflowing = format!("sqlite_autoindex_link_table_{}0", usize::MAX);
6331        assert_eq!(parse_autoindex_ordinal(&overflowing, "link_table"), None);
6332    }
6333
6334    fn implicit_autoindex_catalog_row(
6335        entry_type: &str,
6336        name: &str,
6337        table_name: &str,
6338        root_page: i64,
6339        sql: Option<&str>,
6340    ) -> Vec<SqliteValue> {
6341        vec![
6342            SqliteValue::Text(entry_type.into()),
6343            SqliteValue::Text(name.into()),
6344            SqliteValue::Text(table_name.into()),
6345            SqliteValue::Integer(root_page),
6346            sql.map_or(SqliteValue::Null, |value| SqliteValue::Text(value.into())),
6347        ]
6348    }
6349
6350    fn assert_implicit_autoindex_catalog_corrupt(
6351        case_name: &str,
6352        entries: &[Vec<SqliteValue>],
6353        detail_needle: &str,
6354    ) {
6355        assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6356            case_name,
6357            entries,
6358            i32::MAX.unsigned_abs(),
6359            detail_needle,
6360        );
6361    }
6362
6363    fn assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6364        case_name: &str,
6365        entries: &[Vec<SqliteValue>],
6366        max_root_page: u32,
6367        detail_needle: &str,
6368    ) {
6369        let header = DatabaseHeader::default();
6370        assert_implicit_autoindex_catalog_corrupt_with_root_context(
6371            case_name,
6372            entries,
6373            max_root_page,
6374            &header,
6375            &HashSet::new(),
6376            detail_needle,
6377        );
6378    }
6379
6380    fn assert_implicit_autoindex_catalog_corrupt_with_root_context(
6381        case_name: &str,
6382        entries: &[Vec<SqliteValue>],
6383        max_root_page: u32,
6384        header: &DatabaseHeader,
6385        free_pages: &HashSet<PageNumber>,
6386        detail_needle: &str,
6387    ) {
6388        let error = bind_implicit_autoindex_catalog(entries, max_root_page, header, free_pages)
6389            .unwrap_err();
6390        let FrankenError::DatabaseCorrupt { detail } = error else {
6391            panic!("{case_name}: expected DatabaseCorrupt, found {error:?}");
6392        };
6393        assert!(
6394            detail.contains(detail_needle),
6395            "{case_name}: expected `{detail_needle}` in corruption detail, found `{detail}`"
6396        );
6397    }
6398
6399    #[test]
6400    fn virtual_table_catalog_canonical_row_is_order_independent() {
6401        let cases = [
6402            (
6403                "contentless before stale default",
6404                vec![
6405                    implicit_autoindex_catalog_row(
6406                        "table",
6407                        "vt",
6408                        "vt",
6409                        0,
6410                        Some("CREATE VIRTUAL TABLE vt USING fts5(body, content='')"),
6411                    ),
6412                    implicit_autoindex_catalog_row(
6413                        "table",
6414                        "vt",
6415                        "vt",
6416                        0,
6417                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6418                    ),
6419                ],
6420                0,
6421            ),
6422            (
6423                "contentless after stale default",
6424                vec![
6425                    implicit_autoindex_catalog_row(
6426                        "table",
6427                        "vt",
6428                        "vt",
6429                        0,
6430                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6431                    ),
6432                    implicit_autoindex_catalog_row(
6433                        "table",
6434                        "vt",
6435                        "vt",
6436                        0,
6437                        Some("CREATE VIRTUAL TABLE vt USING fts5(body, content='')"),
6438                    ),
6439                ],
6440                1,
6441            ),
6442            (
6443                "equivalent root-zero rows",
6444                vec![
6445                    implicit_autoindex_catalog_row(
6446                        "table",
6447                        "vt",
6448                        "vt",
6449                        0,
6450                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6451                    ),
6452                    implicit_autoindex_catalog_row(
6453                        "table",
6454                        "VT",
6455                        "VT",
6456                        0,
6457                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6458                    ),
6459                ],
6460                0,
6461            ),
6462            (
6463                "positive row before migration row",
6464                vec![
6465                    implicit_autoindex_catalog_row(
6466                        "table",
6467                        "vt",
6468                        "vt",
6469                        2,
6470                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6471                    ),
6472                    implicit_autoindex_catalog_row(
6473                        "table",
6474                        "vt",
6475                        "vt",
6476                        0,
6477                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6478                    ),
6479                ],
6480                0,
6481            ),
6482            (
6483                "positive row after migration row",
6484                vec![
6485                    implicit_autoindex_catalog_row(
6486                        "table",
6487                        "vt",
6488                        "vt",
6489                        0,
6490                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6491                    ),
6492                    implicit_autoindex_catalog_row(
6493                        "table",
6494                        "vt",
6495                        "vt",
6496                        2,
6497                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6498                    ),
6499                ],
6500                1,
6501            ),
6502        ];
6503
6504        for (case_name, entries, expected_row) in cases {
6505            let catalog = bind_implicit_autoindex_catalog(
6506                &entries,
6507                2,
6508                &DatabaseHeader::default(),
6509                &HashSet::new(),
6510            )
6511            .unwrap_or_else(|error| panic!("{case_name}: unexpected bind failure: {error}"));
6512            for row_index in 0..entries.len() {
6513                assert_eq!(
6514                    catalog.is_canonical_virtual_table_row(row_index),
6515                    row_index == expected_row,
6516                    "{case_name}: wrong canonical status for row {row_index}"
6517                );
6518            }
6519        }
6520    }
6521
6522    #[test]
6523    fn implicit_autoindex_catalog_binds_complete_layout_and_real_roots() {
6524        let entries = vec![
6525            implicit_autoindex_catalog_row(
6526                "table",
6527                "t",
6528                "t",
6529                2,
6530                Some("CREATE TABLE t(a TEXT UNIQUE, b TEXT UNIQUE)"),
6531            ),
6532            // Deliberately reverse the physical rows: declaration ordinals,
6533            // not sqlite_master scan order, determine the canonical result.
6534            implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_2", "t", 4, None),
6535            implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_1", "t", 3, None),
6536            implicit_autoindex_catalog_row(
6537                "table",
6538                "wr",
6539                "wr",
6540                5,
6541                Some("CREATE TABLE wr(pk TEXT PRIMARY KEY, u TEXT UNIQUE) WITHOUT ROWID"),
6542            ),
6543            implicit_autoindex_catalog_row("index", "sqlite_autoindex_wr_2", "wr", 6, None),
6544            implicit_autoindex_catalog_row(
6545                "table",
6546                "plain",
6547                "plain",
6548                7,
6549                Some("CREATE TABLE plain(x TEXT)"),
6550            ),
6551            implicit_autoindex_catalog_row(
6552                "index",
6553                "sqlite_autoindex_plain_1",
6554                "plain",
6555                8,
6556                Some("CREATE INDEX sqlite_autoindex_plain_1 ON plain(x)"),
6557            ),
6558        ];
6559
6560        let catalog = bind_implicit_autoindex_catalog(
6561            &entries,
6562            8,
6563            &DatabaseHeader::default(),
6564            &HashSet::new(),
6565        )
6566        .unwrap();
6567        let ordinary = catalog.table("T").unwrap();
6568        let physical = ordinary.physical_index_schemas("t");
6569        assert_eq!(
6570            physical
6571                .iter()
6572                .map(|index| (index.name.as_str(), index.root_page))
6573                .collect::<Vec<_>>(),
6574            vec![("sqlite_autoindex_t_1", 3), ("sqlite_autoindex_t_2", 4)]
6575        );
6576
6577        let without_rowid = catalog.table("wr").unwrap();
6578        assert_eq!(
6579            without_rowid
6580                .slots
6581                .iter()
6582                .map(|bound| bound.storage)
6583                .collect::<Vec<_>>(),
6584            vec![
6585                BoundImplicitAutoindexStorage::TableRoot,
6586                BoundImplicitAutoindexStorage::IndexRoot(6)
6587            ]
6588        );
6589        let wr_physical = without_rowid.physical_index_schemas("wr");
6590        assert_eq!(wr_physical.len(), 1);
6591        assert_eq!(wr_physical[0].name, "sqlite_autoindex_wr_2");
6592        assert_eq!(wr_physical[0].root_page, 6);
6593
6594        let plain = catalog.table("plain").unwrap();
6595        assert_eq!(plain.implicit_slots().count(), 0);
6596        assert!(plain.physical_index_schemas("plain").is_empty());
6597    }
6598
6599    #[test]
6600    fn implicit_autoindex_catalog_accepts_valid_views_triggers_and_supported_fts5_repair() {
6601        let entries = vec![
6602            implicit_autoindex_catalog_row(
6603                "TaBlE",
6604                "plain",
6605                "plain",
6606                2,
6607                Some("CREATE TABLE main.plain(x TEXT)"),
6608            ),
6609            implicit_autoindex_catalog_row(
6610                "ViEw",
6611                "v",
6612                "v",
6613                0,
6614                Some("CREATE VIEW main.v AS SELECT x FROM plain"),
6615            ),
6616            implicit_autoindex_catalog_row(
6617                "TrIgGeR",
6618                "plain",
6619                "plain",
6620                0,
6621                Some("CREATE TRIGGER main.plain AFTER INSERT ON plain BEGIN SELECT 1; END"),
6622            ),
6623            implicit_autoindex_catalog_row(
6624                "trigger",
6625                "v",
6626                "v",
6627                0,
6628                Some("CREATE TRIGGER main.v INSTEAD OF INSERT ON v BEGIN SELECT 1; END"),
6629            ),
6630            implicit_autoindex_catalog_row(
6631                "table",
6632                "docs",
6633                "docs",
6634                0,
6635                Some("CREATE VIRTUAL TABLE main.docs USING fts5(title, body, content='')"),
6636            ),
6637            implicit_autoindex_catalog_row(
6638                "TABLE",
6639                "DOCS",
6640                "DOCS",
6641                0,
6642                Some("CREATE VIRTUAL TABLE docs USING fts5(title, body)"),
6643            ),
6644            implicit_autoindex_catalog_row(
6645                "table",
6646                "legacy_docs",
6647                "legacy_docs",
6648                3,
6649                Some("CREATE VIRTUAL TABLE legacy_docs USING fts5(title, body)"),
6650            ),
6651            implicit_autoindex_catalog_row(
6652                "table",
6653                "LEGACY_DOCS",
6654                "LEGACY_DOCS",
6655                0,
6656                Some("CREATE VIRTUAL TABLE legacy_docs USING fts5(title, body)"),
6657            ),
6658        ];
6659
6660        let catalog = bind_implicit_autoindex_catalog(
6661            &entries,
6662            3,
6663            &DatabaseHeader::default(),
6664            &HashSet::new(),
6665        )
6666        .unwrap();
6667        assert!(catalog.table("PLAIN").is_some());
6668        assert!(catalog.table("docs").is_none());
6669    }
6670
6671    #[test]
6672    fn implicit_autoindex_catalog_rejects_invalid_row_shapes_and_storage_classes() {
6673        let mut short_row =
6674            implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6675        short_row.pop();
6676        let mut non_text_type =
6677            implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6678        non_text_type[0] = SqliteValue::Integer(1);
6679        let mut non_text_name =
6680            implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6681        non_text_name[1] = SqliteValue::Null;
6682        let mut non_text_table_name =
6683            implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6684        non_text_table_name[2] = SqliteValue::Blob(vec![1].into());
6685        let mut invalid_sql_storage =
6686            implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6687        invalid_sql_storage[4] = SqliteValue::Integer(1);
6688
6689        for (case_name, entries, detail_needle) in [
6690            ("short row", vec![short_row], "columns instead of 5"),
6691            (
6692                "non-text type",
6693                vec![non_text_type],
6694                "column `type` must be TEXT",
6695            ),
6696            (
6697                "non-text name",
6698                vec![non_text_name],
6699                "column `name` must be TEXT",
6700            ),
6701            (
6702                "non-text table name",
6703                vec![non_text_table_name],
6704                "column `tbl_name` must be TEXT",
6705            ),
6706            (
6707                "invalid sql storage",
6708                vec![invalid_sql_storage],
6709                "column `sql` must be TEXT or NULL",
6710            ),
6711            (
6712                "table NULL sql",
6713                vec![implicit_autoindex_catalog_row("table", "t", "t", 2, None)],
6714                "has NULL sql",
6715            ),
6716        ] {
6717            assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
6718        }
6719
6720        assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6721            "zero visible bound",
6722            &[],
6723            0,
6724            "without a visible database page",
6725        );
6726    }
6727
6728    #[test]
6729    fn implicit_autoindex_catalog_rejects_incomplete_ambiguous_or_unsafe_catalogs() {
6730        let unique_table = || {
6731            implicit_autoindex_catalog_row(
6732                "table",
6733                "t",
6734                "t",
6735                2,
6736                Some("CREATE TABLE t(a TEXT UNIQUE)"),
6737            )
6738        };
6739        let unique_index = |name: &str, root_page: i64| {
6740            implicit_autoindex_catalog_row("index", name, "t", root_page, None)
6741        };
6742
6743        let mut non_integer_root = unique_table();
6744        non_integer_root[3] = SqliteValue::Text("two".into());
6745        let cases = vec![
6746            (
6747                "missing expected row",
6748                vec![unique_table()],
6749                "missing implicit autoindex",
6750            ),
6751            (
6752                "unexpected ordinal",
6753                vec![unique_table(), unique_index("sqlite_autoindex_t_2", 3)],
6754                "nonexistent declaration slot 2",
6755            ),
6756            (
6757                "duplicate row",
6758                vec![
6759                    unique_table(),
6760                    unique_index("sqlite_autoindex_t_1", 3),
6761                    unique_index("SQLITE_AUTOINDEX_T_1", 4),
6762                ],
6763                "duplicate index entries",
6764            ),
6765            (
6766                "hidden WITHOUT ROWID PK row",
6767                vec![
6768                    implicit_autoindex_catalog_row(
6769                        "table",
6770                        "wr",
6771                        "wr",
6772                        2,
6773                        Some("CREATE TABLE wr(pk TEXT PRIMARY KEY) WITHOUT ROWID"),
6774                    ),
6775                    implicit_autoindex_catalog_row("index", "sqlite_autoindex_wr_1", "wr", 3, None),
6776                ],
6777                "illegally materializes hidden",
6778            ),
6779            (
6780                "missing implicit parent",
6781                vec![implicit_autoindex_catalog_row(
6782                    "index",
6783                    "sqlite_autoindex_absent_1",
6784                    "absent",
6785                    3,
6786                    None,
6787                )],
6788                "missing ordinary table",
6789            ),
6790            (
6791                "missing explicit parent",
6792                vec![implicit_autoindex_catalog_row(
6793                    "index",
6794                    "idx_absent",
6795                    "absent",
6796                    3,
6797                    Some("CREATE INDEX idx_absent ON absent(a)"),
6798                )],
6799                "missing ordinary table",
6800            ),
6801            (
6802                "table name mismatch",
6803                vec![implicit_autoindex_catalog_row(
6804                    "table",
6805                    "t",
6806                    "other",
6807                    2,
6808                    Some("CREATE TABLE t(a)"),
6809                )],
6810                "mismatched tbl_name",
6811            ),
6812            (
6813                "CREATE TABLE name mismatch",
6814                vec![implicit_autoindex_catalog_row(
6815                    "table",
6816                    "t",
6817                    "t",
6818                    2,
6819                    Some("CREATE TABLE other(a)"),
6820                )],
6821                // The CREATE TABLE arm names the rejection class before the
6822                // offending name (compat_persist.rs:709), unlike the virtual
6823                // table arm below, which still renders `declares \`{}\``.
6824                "differently named table `other`",
6825            ),
6826            (
6827                "layout conflict mapping",
6828                vec![implicit_autoindex_catalog_row(
6829                    "table",
6830                    "t",
6831                    "t",
6832                    2,
6833                    Some(
6834                        "CREATE TABLE t(a TEXT UNIQUE ON CONFLICT IGNORE, UNIQUE(a) ON CONFLICT REPLACE)",
6835                    ),
6836                )],
6837                "invalid implicit autoindex layout",
6838            ),
6839            (
6840                "non-integer root",
6841                vec![non_integer_root],
6842                "must be INTEGER",
6843            ),
6844            (
6845                "zero root",
6846                vec![implicit_autoindex_catalog_row(
6847                    "table",
6848                    "t",
6849                    "t",
6850                    0,
6851                    Some("CREATE TABLE t(a)"),
6852                )],
6853                "invalid rootpage 0",
6854            ),
6855            (
6856                "negative root",
6857                vec![implicit_autoindex_catalog_row(
6858                    "table",
6859                    "t",
6860                    "t",
6861                    -2,
6862                    Some("CREATE TABLE t(a)"),
6863                )],
6864                "invalid rootpage -2",
6865            ),
6866            (
6867                "above i32 root",
6868                vec![implicit_autoindex_catalog_row(
6869                    "table",
6870                    "t",
6871                    "t",
6872                    i64::from(i32::MAX) + 1,
6873                    Some("CREATE TABLE t(a)"),
6874                )],
6875                "exceeds supported range",
6876            ),
6877            (
6878                "penultimate i32 root",
6879                vec![implicit_autoindex_catalog_row(
6880                    "table",
6881                    "t",
6882                    "t",
6883                    i64::from(i32::MAX - 1),
6884                    Some("CREATE TABLE t(a)"),
6885                )],
6886                "no safe MemDatabase allocation sentinel",
6887            ),
6888            (
6889                "terminal i32 root",
6890                vec![implicit_autoindex_catalog_row(
6891                    "table",
6892                    "t",
6893                    "t",
6894                    i64::from(i32::MAX),
6895                    Some("CREATE TABLE t(a)"),
6896                )],
6897                "no safe MemDatabase allocation sentinel",
6898            ),
6899            (
6900                "page one collision",
6901                vec![implicit_autoindex_catalog_row(
6902                    "table",
6903                    "t",
6904                    "t",
6905                    1,
6906                    Some("CREATE TABLE t(a)"),
6907                )],
6908                "shared by sqlite_master",
6909            ),
6910            (
6911                "table index root collision",
6912                vec![unique_table(), unique_index("sqlite_autoindex_t_1", 2)],
6913                "rootpage 2 is shared",
6914            ),
6915            (
6916                "two index root collision",
6917                vec![
6918                    implicit_autoindex_catalog_row(
6919                        "table",
6920                        "t",
6921                        "t",
6922                        2,
6923                        Some("CREATE TABLE t(a UNIQUE, b UNIQUE)"),
6924                    ),
6925                    unique_index("sqlite_autoindex_t_1", 3),
6926                    unique_index("sqlite_autoindex_t_2", 3),
6927                ],
6928                "rootpage 3 is shared",
6929            ),
6930            (
6931                "explicit index identity mismatch",
6932                vec![
6933                    implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)")),
6934                    implicit_autoindex_catalog_row(
6935                        "index",
6936                        "idx_t",
6937                        "t",
6938                        3,
6939                        Some("CREATE INDEX idx_other ON t(a)"),
6940                    ),
6941                ],
6942                "declares index `idx_other`",
6943            ),
6944            (
6945                "virtual table identity mismatch",
6946                vec![implicit_autoindex_catalog_row(
6947                    "table",
6948                    "vt",
6949                    "vt",
6950                    0,
6951                    Some("CREATE VIRTUAL TABLE other USING fts5(body)"),
6952                )],
6953                "declares `other`",
6954            ),
6955            (
6956                "unsupported schema type",
6957                vec![implicit_autoindex_catalog_row(
6958                    "bogus",
6959                    "x",
6960                    "x",
6961                    0,
6962                    Some("bogus"),
6963                )],
6964                "unsupported type",
6965            ),
6966            (
6967                "view owns a root",
6968                vec![implicit_autoindex_catalog_row(
6969                    "view",
6970                    "v",
6971                    "v",
6972                    2,
6973                    Some("CREATE VIEW v AS SELECT 1"),
6974                )],
6975                "must have rootpage 0",
6976            ),
6977        ];
6978
6979        for (case_name, entries, detail_needle) in cases {
6980            assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
6981        }
6982
6983        assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6984            "root past visible database",
6985            &[
6986                implicit_autoindex_catalog_row(
6987                    "table",
6988                    "t",
6989                    "t",
6990                    2,
6991                    Some("CREATE TABLE t(a TEXT UNIQUE)"),
6992                ),
6993                implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_1", "t", 4, None),
6994            ],
6995            3,
6996            "exceeds the visible database page count 3",
6997        );
6998    }
6999
7000    #[test]
7001    fn implicit_autoindex_catalog_rejects_schema_identity_and_namespace_corruption() {
7002        let ordinary_table =
7003            || implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a TEXT)"));
7004        let cases = vec![
7005            (
7006                "ordinary CREATE parse failure",
7007                vec![implicit_autoindex_catalog_row(
7008                    "table",
7009                    "t",
7010                    "t",
7011                    2,
7012                    Some("CREATE TABLE t("),
7013                )],
7014                "could not parse CREATE TABLE",
7015            ),
7016            (
7017                "stored CTAS",
7018                vec![implicit_autoindex_catalog_row(
7019                    "table",
7020                    "t",
7021                    "t",
7022                    2,
7023                    Some("CREATE TABLE t AS SELECT 1 AS a"),
7024                )],
7025                "CREATE TABLE AS SELECT",
7026            ),
7027            (
7028                "duplicate ordinary table",
7029                vec![
7030                    ordinary_table(),
7031                    implicit_autoindex_catalog_row(
7032                        "table",
7033                        "T",
7034                        "T",
7035                        3,
7036                        Some("CREATE TABLE T(a TEXT)"),
7037                    ),
7038                ],
7039                "duplicate table entries",
7040            ),
7041            (
7042                "table view namespace collision",
7043                vec![
7044                    ordinary_table(),
7045                    implicit_autoindex_catalog_row(
7046                        "view",
7047                        "T",
7048                        "T",
7049                        0,
7050                        Some("CREATE VIEW T AS SELECT 1"),
7051                    ),
7052                ],
7053                "schema name `T` is shared",
7054            ),
7055            (
7056                "view NULL sql",
7057                vec![implicit_autoindex_catalog_row("view", "v", "v", 0, None)],
7058                "non-NULL sql",
7059            ),
7060            (
7061                "view name mismatch",
7062                vec![implicit_autoindex_catalog_row(
7063                    "view",
7064                    "v",
7065                    "v",
7066                    0,
7067                    Some("CREATE VIEW other AS SELECT 1"),
7068                )],
7069                "differently named view",
7070            ),
7071            (
7072                "temporary view",
7073                vec![implicit_autoindex_catalog_row(
7074                    "view",
7075                    "v",
7076                    "v",
7077                    0,
7078                    Some("CREATE TEMP VIEW v AS SELECT 1"),
7079                )],
7080                "temporary, non-main, or differently named view",
7081            ),
7082            (
7083                "missing trigger target",
7084                vec![implicit_autoindex_catalog_row(
7085                    "trigger",
7086                    "tr",
7087                    "missing",
7088                    0,
7089                    Some("CREATE TRIGGER tr AFTER INSERT ON missing BEGIN SELECT 1; END"),
7090                )],
7091                "missing or incompatible table `missing`",
7092            ),
7093            (
7094                "INSTEAD OF trigger on table",
7095                vec![
7096                    ordinary_table(),
7097                    implicit_autoindex_catalog_row(
7098                        "trigger",
7099                        "tr",
7100                        "t",
7101                        0,
7102                        Some("CREATE TRIGGER tr INSTEAD OF INSERT ON t BEGIN SELECT 1; END"),
7103                    ),
7104                ],
7105                "missing or incompatible view `t`",
7106            ),
7107            (
7108                "AFTER trigger on view",
7109                vec![
7110                    implicit_autoindex_catalog_row(
7111                        "view",
7112                        "v",
7113                        "v",
7114                        0,
7115                        Some("CREATE VIEW v AS SELECT 1"),
7116                    ),
7117                    implicit_autoindex_catalog_row(
7118                        "trigger",
7119                        "tr",
7120                        "v",
7121                        0,
7122                        Some("CREATE TRIGGER tr AFTER INSERT ON v BEGIN SELECT 1; END"),
7123                    ),
7124                ],
7125                "missing or incompatible table `v`",
7126            ),
7127            (
7128                "explicit index parse failure",
7129                vec![
7130                    ordinary_table(),
7131                    implicit_autoindex_catalog_row(
7132                        "index",
7133                        "idx_t",
7134                        "t",
7135                        3,
7136                        Some("CREATE INDEX idx_t ON"),
7137                    ),
7138                ],
7139                "could not parse CREATE INDEX",
7140            ),
7141            (
7142                "explicit index table mismatch",
7143                vec![
7144                    ordinary_table(),
7145                    implicit_autoindex_catalog_row(
7146                        "index",
7147                        "idx_t",
7148                        "t",
7149                        3,
7150                        Some("CREATE INDEX idx_t ON other(a)"),
7151                    ),
7152                ],
7153                "on table `other` instead of `t`",
7154            ),
7155            (
7156                "hidden logical autoindex name claimed explicitly",
7157                vec![
7158                    implicit_autoindex_catalog_row(
7159                        "table",
7160                        "wr",
7161                        "wr",
7162                        2,
7163                        Some("CREATE TABLE wr(pk TEXT PRIMARY KEY, u TEXT) WITHOUT ROWID"),
7164                    ),
7165                    implicit_autoindex_catalog_row(
7166                        "index",
7167                        "sqlite_autoindex_wr_1",
7168                        "wr",
7169                        3,
7170                        Some("CREATE INDEX sqlite_autoindex_wr_1 ON wr(u)"),
7171                    ),
7172                ],
7173                "collides with logical implicit index",
7174            ),
7175            (
7176                "conflicting rootpage-zero virtual tables",
7177                vec![
7178                    implicit_autoindex_catalog_row(
7179                        "table",
7180                        "vt",
7181                        "vt",
7182                        0,
7183                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7184                    ),
7185                    implicit_autoindex_catalog_row(
7186                        "table",
7187                        "VT",
7188                        "VT",
7189                        0,
7190                        Some("CREATE VIRTUAL TABLE vt USING rtree(id, min_x, max_x)"),
7191                    ),
7192                ],
7193                "conflicting virtual-table entries",
7194            ),
7195            (
7196                "third duplicate virtual table",
7197                vec![
7198                    implicit_autoindex_catalog_row(
7199                        "table",
7200                        "vt",
7201                        "vt",
7202                        0,
7203                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7204                    ),
7205                    implicit_autoindex_catalog_row(
7206                        "table",
7207                        "VT",
7208                        "VT",
7209                        0,
7210                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7211                    ),
7212                    implicit_autoindex_catalog_row(
7213                        "table",
7214                        "vt",
7215                        "vt",
7216                        0,
7217                        Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7218                    ),
7219                ],
7220                "conflicting virtual-table entries",
7221            ),
7222        ];
7223
7224        for (case_name, entries, detail_needle) in cases {
7225            assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
7226        }
7227    }
7228
7229    #[test]
7230    fn implicit_autoindex_catalog_rejects_reserved_or_free_root_pages() {
7231        let table_at = |root_page| {
7232            vec![implicit_autoindex_catalog_row(
7233                "table",
7234                "t",
7235                "t",
7236                root_page,
7237                Some("CREATE TABLE t(a)"),
7238            )]
7239        };
7240
7241        let lock_byte_page = fsqlite_pager::lock_byte_page(PageSize::DEFAULT);
7242        assert_implicit_autoindex_catalog_corrupt_with_page_bound(
7243            "lock-byte root",
7244            &table_at(i64::from(lock_byte_page)),
7245            lock_byte_page,
7246            "reserved lock-byte rootpage",
7247        );
7248
7249        let auto_vacuum_header = DatabaseHeader {
7250            largest_root_page: 3,
7251            ..DatabaseHeader::default()
7252        };
7253        assert_implicit_autoindex_catalog_corrupt_with_root_context(
7254            "auto-vacuum pointer-map root",
7255            &table_at(2),
7256            3,
7257            &auto_vacuum_header,
7258            &HashSet::new(),
7259            "pointer-map rootpage 2",
7260        );
7261
7262        let free_page = PageNumber::new(2).unwrap();
7263        assert_implicit_autoindex_catalog_corrupt_with_root_context(
7264            "freelist root",
7265            &table_at(2),
7266            2,
7267            &DatabaseHeader::default(),
7268            &HashSet::from([free_page]),
7269            "uses free rootpage 2",
7270        );
7271    }
7272
7273    #[test]
7274    fn test_index_sql_synthesizes_unrecorded_explicit_index() {
7275        let original_ddl = HashMap::<String, String>::new();
7276
7277        let sql =
7278            index_sql_for_persistence("idx_link_table_a", "link_table", &original_ddl, || {
7279                "CREATE INDEX idx_link_table_a ON link_table(a)".to_owned()
7280            });
7281
7282        assert_eq!(
7283            sql.as_deref(),
7284            Some("CREATE INDEX idx_link_table_a ON link_table(a)")
7285        );
7286    }
7287
7288    #[test]
7289    fn test_index_sql_synthesizes_noncanonical_reserved_prefix_without_ddl() {
7290        let original_ddl = HashMap::<String, String>::new();
7291
7292        let sql = index_sql_for_persistence(
7293            "sqlite_autoindex_link_table_v23_1",
7294            "link_table",
7295            &original_ddl,
7296            || {
7297                "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
7298                    .to_owned()
7299            },
7300        );
7301
7302        assert_eq!(
7303            sql.as_deref(),
7304            Some(
7305                "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
7306            )
7307        );
7308    }
7309
7310    #[test]
7311    fn test_reserved_prefix_explicit_index_survives_without_original_table_ddl() {
7312        asupersync::test_utils::run_test(|| async {
7313            const TABLE_SQL: &str = "CREATE TABLE link_table(\
7314            a INTEGER NOT NULL,\
7315            b INTEGER NOT NULL,\
7316            PRIMARY KEY(a,b)\
7317        )";
7318            const INDEX_NAME: &str = "sqlite_autoindex_link_table_v23_1";
7319            const INDEX_SQL: &str = "CREATE UNIQUE INDEX \
7320            \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)";
7321
7322            let dir = tempfile::tempdir().unwrap();
7323            let source_path = dir.path().join("reserved-prefix-source.db");
7324            let rebuilt_path = dir.path().join("reserved-prefix-rebuilt.db");
7325
7326            {
7327                let sqlite = rusqlite::Connection::open(&source_path).unwrap();
7328                sqlite
7329                    .execute_batch(&format!(
7330                        r"
7331                    {TABLE_SQL};
7332                    CREATE UNIQUE INDEX legacy_unique ON link_table(a,b);
7333                    INSERT INTO link_table VALUES (1,2), (3,4);
7334                    PRAGMA writable_schema=ON;
7335                    UPDATE sqlite_master
7336                       SET name='{INDEX_NAME}', sql='{INDEX_SQL}'
7337                     WHERE type='index' AND name='legacy_unique';
7338                    PRAGMA schema_version=2;
7339                    "
7340                    ))
7341                    .unwrap();
7342            }
7343
7344            {
7345                let sqlite = rusqlite::Connection::open(&source_path).unwrap();
7346                let quick_check: String = sqlite
7347                    .query_row("PRAGMA quick_check;", [], |row| row.get(0))
7348                    .unwrap();
7349                let integrity_check: String = sqlite
7350                    .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7351                    .unwrap();
7352                assert_eq!(quick_check, "ok");
7353                assert_eq!(integrity_check, "ok");
7354            }
7355
7356            let loaded = load_test_db(&source_path).await.unwrap();
7357            let table = loaded
7358                .schema
7359                .iter()
7360                .find(|table| table.name == "link_table")
7361                .unwrap();
7362            assert!(
7363                table.indexes.iter().any(|index| index.name == INDEX_NAME),
7364                "a non-NULL CREATE INDEX entry is explicit even with a reserved-prefix name"
7365            );
7366
7367            let mut original_ddl = HashMap::new();
7368            // Deliberately omit the table DDL so persistence must reconstruct it
7369            // from TableSchema. The explicit reserved-prefix index DDL remains the
7370            // provenance signal that prevents a phantom UNIQUE table constraint.
7371            original_ddl.insert(INDEX_NAME.to_owned(), INDEX_SQL.to_owned());
7372            let header = DatabaseHeader {
7373                page_size: DEFAULT_PAGE_SIZE,
7374                schema_cookie: loaded.schema_cookie,
7375                change_counter: loaded.change_counter,
7376                version_valid_for: loaded.change_counter,
7377                ..DatabaseHeader::default()
7378            };
7379            persist_to_sqlite_with_header_and_master_entries(
7380                &Cx::new(),
7381                &rebuilt_path,
7382                &loaded.schema,
7383                &loaded.db,
7384                &header,
7385                &[],
7386                &original_ddl,
7387            )
7388            .await
7389            .unwrap();
7390
7391            let sqlite = rusqlite::Connection::open(&rebuilt_path).unwrap();
7392            let stored_table_sql: String = sqlite
7393                .query_row(
7394                    "SELECT sql FROM sqlite_master WHERE type='table' AND name='link_table';",
7395                    [],
7396                    |row| row.get(0),
7397                )
7398                .unwrap();
7399            let stored_sql: String = sqlite
7400                .query_row(
7401                    "SELECT sql FROM sqlite_master WHERE type='index' AND name=?1;",
7402                    [INDEX_NAME],
7403                    |row| row.get(0),
7404                )
7405                .unwrap();
7406            let row_count: i64 = sqlite
7407                .query_row("SELECT COUNT(*) FROM link_table;", [], |row| row.get(0))
7408                .unwrap();
7409            let quick_check: String = sqlite
7410                .query_row("PRAGMA quick_check;", [], |row| row.get(0))
7411                .unwrap();
7412            let integrity_check: String = sqlite
7413                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7414                .unwrap();
7415            assert!(
7416                !stored_table_sql.to_ascii_uppercase().contains("UNIQUE"),
7417                "reconstructed table DDL must not duplicate the explicit reserved-prefix index: {stored_table_sql}"
7418            );
7419            assert_eq!(stored_sql, INDEX_SQL);
7420            assert_eq!(row_count, 2);
7421            assert_eq!(quick_check, "ok");
7422            assert_eq!(integrity_check, "ok");
7423            drop(sqlite);
7424
7425            let reopened = load_test_db(&rebuilt_path).await.unwrap();
7426            let table = reopened
7427                .schema
7428                .iter()
7429                .find(|table| table.name == "link_table")
7430                .unwrap();
7431            assert!(table.indexes.iter().any(|index| index.name == INDEX_NAME));
7432        });
7433    }
7434
7435    #[test]
7436    fn test_build_create_expression_index_sql_does_not_duplicate_collation() {
7437        let expressions = vec!["lower(name) COLLATE NOCASE".to_owned()];
7438        let collations = vec![Some("NOCASE".to_owned())];
7439        let directions = vec![SortDirection::Desc];
7440
7441        let sql = build_create_expression_index_sql(
7442            "idx_expr",
7443            "agents",
7444            false,
7445            &expressions,
7446            &collations,
7447            &directions,
7448            Some("is_active = 1"),
7449        );
7450
7451        assert_eq!(
7452            sql,
7453            "CREATE INDEX \"idx_expr\" ON \"agents\" (lower(name) COLLATE NOCASE DESC) WHERE is_active = 1"
7454        );
7455    }
7456
7457    #[test]
7458    fn test_persist_to_sqlite_keeps_expression_index_btree_and_schema() {
7459        asupersync::test_utils::run_test(|| async {
7460            let dir = tempfile::tempdir().unwrap();
7461            let db_path = dir.path().join("expression-index-persist.db");
7462            let cx = Cx::new();
7463
7464            let mut db = MemDatabase::new();
7465            db.create_table_at(2, 3);
7466            let table_data = db.get_table_mut(2).unwrap();
7467            table_data.insert_row(
7468                1,
7469                vec![
7470                    SqliteValue::Integer(1),
7471                    SqliteValue::Text("Alpha".into()),
7472                    SqliteValue::Integer(1),
7473                ],
7474            );
7475            table_data.insert_row(
7476                2,
7477                vec![
7478                    SqliteValue::Integer(2),
7479                    SqliteValue::Text("Dormant".into()),
7480                    SqliteValue::Integer(0),
7481                ],
7482            );
7483
7484            let schema = vec![TableSchema {
7485                name: "agents".to_owned(),
7486                root_page: 2,
7487                columns: vec![
7488                    ColumnInfo {
7489                        name: "id".to_owned(),
7490                        affinity: 'D',
7491                        is_ipk: true,
7492                        type_name: Some("INTEGER".to_owned()),
7493                        notnull: false,
7494                        unique: false,
7495                        default_value: None,
7496                        strict_type: None,
7497                        generated_expr: None,
7498                        generated_stored: None,
7499                        collation: None,
7500                        conflict_action: None,
7501                    },
7502                    ColumnInfo {
7503                        name: "name".to_owned(),
7504                        affinity: 'B',
7505                        is_ipk: false,
7506                        type_name: Some("TEXT".to_owned()),
7507                        notnull: true,
7508                        unique: false,
7509                        default_value: None,
7510                        strict_type: None,
7511                        generated_expr: None,
7512                        generated_stored: None,
7513                        collation: None,
7514                        conflict_action: None,
7515                    },
7516                    ColumnInfo {
7517                        name: "is_active".to_owned(),
7518                        affinity: 'D',
7519                        is_ipk: false,
7520                        type_name: Some("INTEGER".to_owned()),
7521                        notnull: true,
7522                        unique: false,
7523                        default_value: Some("1".to_owned()),
7524                        strict_type: None,
7525                        generated_expr: None,
7526                        generated_stored: None,
7527                        collation: None,
7528                        conflict_action: None,
7529                    },
7530                ],
7531                indexes: vec![IndexSchema {
7532                    name: "uq_agents_name_ci".to_owned(),
7533                    root_page: 3,
7534                    columns: Vec::new(),
7535                    key_expressions: vec!["lower(name)".to_owned()],
7536                    key_sort_directions: vec![SortDirection::Asc],
7537                    where_clause: Some("is_active = 1".to_owned()),
7538                    is_unique: true,
7539                    key_collations: vec![None],
7540                    conflict_action: None,
7541                }],
7542                strict: false,
7543                without_rowid: false,
7544                primary_key_constraints: vec![vec!["id".to_owned()]],
7545                foreign_keys: Vec::new(),
7546                check_constraints: Vec::new(),
7547            }];
7548            let header = DatabaseHeader {
7549                page_size: DEFAULT_PAGE_SIZE,
7550                schema_cookie: 1,
7551                change_counter: 1,
7552                version_valid_for: 1,
7553                ..DatabaseHeader::default()
7554            };
7555            let mut original_ddl = HashMap::new();
7556            original_ddl.insert(
7557            "agents".to_owned(),
7558            "CREATE TABLE agents (id INTEGER PRIMARY KEY, name TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1)"
7559                .to_owned(),
7560        );
7561            original_ddl.insert(
7562                "uq_agents_name_ci".to_owned(),
7563                "CREATE UNIQUE INDEX uq_agents_name_ci ON agents(lower(name)) WHERE is_active = 1"
7564                    .to_owned(),
7565            );
7566
7567            persist_to_sqlite_with_header_and_master_entries(
7568                &cx,
7569                &db_path,
7570                &schema,
7571                &db,
7572                &header,
7573                &[],
7574                &original_ddl,
7575            )
7576            .await
7577            .unwrap();
7578
7579            let conn = rusqlite::Connection::open(&db_path).unwrap();
7580            let integrity: String = conn
7581                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7582                .unwrap();
7583            assert_eq!(integrity, "ok");
7584            let index_sql: String = conn
7585            .query_row(
7586                "SELECT sql FROM sqlite_master WHERE type='index' AND name='uq_agents_name_ci';",
7587                [],
7588                |row| row.get(0),
7589            )
7590            .unwrap();
7591            assert!(
7592                index_sql.to_ascii_lowercase().contains("lower(name)")
7593                    && index_sql
7594                        .to_ascii_lowercase()
7595                        .contains("where is_active = 1"),
7596                "expression index SQL should be preserved: {index_sql}"
7597            );
7598            let duplicate = conn.execute(
7599                "INSERT INTO agents(name, is_active) VALUES ('ALPHA', 1);",
7600                [],
7601            );
7602            assert!(
7603                duplicate.is_err(),
7604                "persisted expression index should still enforce active-name uniqueness"
7605            );
7606        });
7607    }
7608
7609    /// GH #304: the physical index builder must honor declared per-term sort
7610    /// directions, not just echo them back into the `CREATE INDEX` DDL.
7611    ///
7612    /// Before the fix the builder inserted every index record through a plain
7613    /// ascending `BtCursor`, so a `DESC` term produced a b-tree whose physical
7614    /// order contradicted its own `sqlite_master` declaration. Stock SQLite
7615    /// reads the index with `DESC` comparison semantics, so it either reports
7616    /// the image as malformed or silently misses rows on a forced-index scan.
7617    #[test]
7618    fn test_persist_to_sqlite_builds_desc_index_in_declared_key_order() {
7619        asupersync::test_utils::run_test(|| async {
7620            const ROW_COUNT: i64 = 2_000;
7621            const GROUP_COUNT: i64 = 20;
7622            const PROBE_GROUP: i64 = 7;
7623
7624            let dir = tempfile::tempdir().unwrap();
7625            let db_path = dir.path().join("desc-index-persist.db");
7626            let cx = Cx::new();
7627
7628            let mut db = MemDatabase::new();
7629            db.create_table_at(2, 3);
7630            let table_data = db.get_table_mut(2).unwrap();
7631            for id in 1..=ROW_COUNT {
7632                table_data.insert_row(
7633                    id,
7634                    vec![
7635                        SqliteValue::Integer(id),
7636                        SqliteValue::Integer(id % GROUP_COUNT),
7637                        // Wide enough that the table b-tree spans many pages and
7638                        // the index spans multiple leaves under an interior node.
7639                        SqliteValue::Text(format!("payload-{id:0>200}").into()),
7640                    ],
7641                );
7642            }
7643
7644            let schema = vec![TableSchema {
7645                name: "live".to_owned(),
7646                root_page: 2,
7647                columns: vec![
7648                    ColumnInfo {
7649                        name: "id".to_owned(),
7650                        affinity: 'D',
7651                        is_ipk: true,
7652                        type_name: Some("INTEGER".to_owned()),
7653                        notnull: false,
7654                        unique: false,
7655                        default_value: None,
7656                        strict_type: None,
7657                        generated_expr: None,
7658                        generated_stored: None,
7659                        collation: None,
7660                        conflict_action: None,
7661                    },
7662                    ColumnInfo {
7663                        name: "grp".to_owned(),
7664                        affinity: 'D',
7665                        is_ipk: false,
7666                        type_name: Some("INTEGER".to_owned()),
7667                        notnull: true,
7668                        unique: false,
7669                        default_value: None,
7670                        strict_type: None,
7671                        generated_expr: None,
7672                        generated_stored: None,
7673                        collation: None,
7674                        conflict_action: None,
7675                    },
7676                    ColumnInfo {
7677                        name: "payload".to_owned(),
7678                        affinity: 'B',
7679                        is_ipk: false,
7680                        type_name: Some("TEXT".to_owned()),
7681                        notnull: true,
7682                        unique: false,
7683                        default_value: None,
7684                        strict_type: None,
7685                        generated_expr: None,
7686                        generated_stored: None,
7687                        collation: None,
7688                        conflict_action: None,
7689                    },
7690                ],
7691                indexes: vec![IndexSchema {
7692                    name: "idx_live_grp_desc".to_owned(),
7693                    root_page: 3,
7694                    columns: vec!["grp".to_owned(), "id".to_owned()],
7695                    key_expressions: Vec::new(),
7696                    key_sort_directions: vec![SortDirection::Asc, SortDirection::Desc],
7697                    where_clause: None,
7698                    is_unique: false,
7699                    key_collations: vec![None, None],
7700                    conflict_action: None,
7701                }],
7702                strict: false,
7703                without_rowid: false,
7704                primary_key_constraints: vec![vec!["id".to_owned()]],
7705                foreign_keys: Vec::new(),
7706                check_constraints: Vec::new(),
7707            }];
7708            let header = DatabaseHeader {
7709                page_size: DEFAULT_PAGE_SIZE,
7710                schema_cookie: 1,
7711                change_counter: 1,
7712                version_valid_for: 1,
7713                ..DatabaseHeader::default()
7714            };
7715            let mut original_ddl = HashMap::new();
7716            original_ddl.insert(
7717                "live".to_owned(),
7718                "CREATE TABLE live (id INTEGER PRIMARY KEY, grp INTEGER NOT NULL, payload TEXT NOT NULL)"
7719                    .to_owned(),
7720            );
7721            original_ddl.insert(
7722                "idx_live_grp_desc".to_owned(),
7723                "CREATE INDEX idx_live_grp_desc ON live(grp, id DESC)".to_owned(),
7724            );
7725
7726            persist_to_sqlite_with_header_and_master_entries(
7727                &cx,
7728                &db_path,
7729                &schema,
7730                &db,
7731                &header,
7732                &[],
7733                &original_ddl,
7734            )
7735            .await
7736            .unwrap();
7737
7738            // Stock SQLite is the oracle here: it reads the index using the
7739            // DESC semantics declared in sqlite_master.
7740            let conn = rusqlite::Connection::open(&db_path).unwrap();
7741
7742            let integrity: String = conn
7743                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7744                .unwrap();
7745            assert_eq!(
7746                integrity, "ok",
7747                "stock SQLite must accept a persisted DESC index as structurally sound"
7748            );
7749
7750            let declared_sql: String = conn
7751                .query_row(
7752                    "SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_live_grp_desc';",
7753                    [],
7754                    |row| row.get(0),
7755                )
7756                .unwrap();
7757            assert!(
7758                declared_sql.to_ascii_lowercase().contains("id desc"),
7759                "persisted DDL must keep the DESC term: {declared_sql}"
7760            );
7761
7762            let collect = |sql: &str| -> Vec<i64> {
7763                let mut stmt = conn.prepare(sql).unwrap();
7764                let rows = stmt
7765                    .query_map([PROBE_GROUP], |row| row.get::<_, i64>(0))
7766                    .unwrap()
7767                    .collect::<std::result::Result<Vec<_>, _>>()
7768                    .unwrap();
7769                rows
7770            };
7771
7772            let forced = collect(
7773                "SELECT id FROM live INDEXED BY idx_live_grp_desc \
7774                 WHERE grp = ?1 ORDER BY id DESC",
7775            );
7776            let scanned =
7777                collect("SELECT id FROM live NOT INDEXED WHERE grp = ?1 ORDER BY id DESC");
7778
7779            assert!(
7780                !scanned.is_empty(),
7781                "probe group must actually contain rows"
7782            );
7783            assert_eq!(
7784                forced,
7785                scanned,
7786                "forced DESC-index lookup must return the same rows as the table scan \
7787                 (missing {} of {} rows)",
7788                scanned.len().saturating_sub(forced.len()),
7789                scanned.len()
7790            );
7791        });
7792    }
7793
7794    /// GH #304 (collation + partial-index arm): a `DESC` term carrying a
7795    /// non-BINARY built-in collation must also be built in declared order.
7796    ///
7797    /// `NOCASE` is the discriminating case: under BINARY every uppercase
7798    /// prefix sorts before every lowercase one, while under NOCASE they
7799    /// interleave alphabetically. An index declared `COLLATE NOCASE DESC` but
7800    /// physically built BINARY/ASC is therefore doubly out of order, and stock
7801    /// SQLite rejects it. The partial predicate exercises the same builder
7802    /// loop with row filtering active.
7803    #[test]
7804    fn test_persist_to_sqlite_builds_collated_desc_partial_index_in_declared_order() {
7805        asupersync::test_utils::run_test(|| async {
7806            const ROW_COUNT: i64 = 400;
7807
7808            let dir = tempfile::tempdir().unwrap();
7809            let db_path = dir.path().join("collated-desc-partial-index.db");
7810            let cx = Cx::new();
7811
7812            // Case-varying prefixes: BINARY and NOCASE disagree on their order.
7813            let prefixes = ["a", "B", "c", "D"];
7814
7815            let mut db = MemDatabase::new();
7816            db.create_table_at(2, 3);
7817            let table_data = db.get_table_mut(2).unwrap();
7818            for id in 1..=ROW_COUNT {
7819                let prefix = prefixes[usize::try_from(id - 1).unwrap() % prefixes.len()];
7820                table_data.insert_row(
7821                    id,
7822                    vec![
7823                        SqliteValue::Integer(id),
7824                        SqliteValue::Text(format!("{prefix}{id:04}").into()),
7825                        // Only two thirds of the rows satisfy the predicate.
7826                        SqliteValue::Integer(i64::from(id % 3 != 0)),
7827                    ],
7828                );
7829            }
7830
7831            let schema = vec![TableSchema {
7832                name: "docs".to_owned(),
7833                root_page: 2,
7834                columns: vec![
7835                    ColumnInfo {
7836                        name: "id".to_owned(),
7837                        affinity: 'D',
7838                        is_ipk: true,
7839                        type_name: Some("INTEGER".to_owned()),
7840                        notnull: false,
7841                        unique: false,
7842                        default_value: None,
7843                        strict_type: None,
7844                        generated_expr: None,
7845                        generated_stored: None,
7846                        collation: None,
7847                        conflict_action: None,
7848                    },
7849                    ColumnInfo {
7850                        name: "name".to_owned(),
7851                        affinity: 'B',
7852                        is_ipk: false,
7853                        type_name: Some("TEXT".to_owned()),
7854                        notnull: true,
7855                        unique: false,
7856                        default_value: None,
7857                        strict_type: None,
7858                        generated_expr: None,
7859                        generated_stored: None,
7860                        collation: None,
7861                        conflict_action: None,
7862                    },
7863                    ColumnInfo {
7864                        name: "active".to_owned(),
7865                        affinity: 'D',
7866                        is_ipk: false,
7867                        type_name: Some("INTEGER".to_owned()),
7868                        notnull: true,
7869                        unique: false,
7870                        default_value: Some("1".to_owned()),
7871                        strict_type: None,
7872                        generated_expr: None,
7873                        generated_stored: None,
7874                        collation: None,
7875                        conflict_action: None,
7876                    },
7877                ],
7878                indexes: vec![IndexSchema {
7879                    name: "idx_docs_name_ci_desc".to_owned(),
7880                    root_page: 3,
7881                    columns: vec!["name".to_owned()],
7882                    key_expressions: Vec::new(),
7883                    key_sort_directions: vec![SortDirection::Desc],
7884                    where_clause: Some("active = 1".to_owned()),
7885                    is_unique: false,
7886                    key_collations: vec![Some("NOCASE".to_owned())],
7887                    conflict_action: None,
7888                }],
7889                strict: false,
7890                without_rowid: false,
7891                primary_key_constraints: vec![vec!["id".to_owned()]],
7892                foreign_keys: Vec::new(),
7893                check_constraints: Vec::new(),
7894            }];
7895            let header = DatabaseHeader {
7896                page_size: DEFAULT_PAGE_SIZE,
7897                schema_cookie: 1,
7898                change_counter: 1,
7899                version_valid_for: 1,
7900                ..DatabaseHeader::default()
7901            };
7902            let mut original_ddl = HashMap::new();
7903            original_ddl.insert(
7904                "docs".to_owned(),
7905                "CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1)"
7906                    .to_owned(),
7907            );
7908            original_ddl.insert(
7909                "idx_docs_name_ci_desc".to_owned(),
7910                "CREATE INDEX idx_docs_name_ci_desc ON docs(name COLLATE NOCASE DESC) WHERE active = 1"
7911                    .to_owned(),
7912            );
7913
7914            persist_to_sqlite_with_header_and_master_entries(
7915                &cx,
7916                &db_path,
7917                &schema,
7918                &db,
7919                &header,
7920                &[],
7921                &original_ddl,
7922            )
7923            .await
7924            .unwrap();
7925
7926            let conn = rusqlite::Connection::open(&db_path).unwrap();
7927
7928            let integrity: String = conn
7929                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7930                .unwrap();
7931            assert_eq!(
7932                integrity, "ok",
7933                "stock SQLite must accept a persisted COLLATE NOCASE DESC partial index"
7934            );
7935
7936            let collect = |sql: &str| -> Vec<String> {
7937                let mut stmt = conn.prepare(sql).unwrap();
7938                stmt.query_map([], |row| row.get::<_, String>(0))
7939                    .unwrap()
7940                    .collect::<std::result::Result<Vec<_>, _>>()
7941                    .unwrap()
7942            };
7943
7944            let forced = collect(
7945                "SELECT name FROM docs INDEXED BY idx_docs_name_ci_desc \
7946                 WHERE active = 1 ORDER BY name COLLATE NOCASE DESC",
7947            );
7948            let scanned = collect(
7949                "SELECT name FROM docs NOT INDEXED \
7950                 WHERE active = 1 ORDER BY name COLLATE NOCASE DESC",
7951            );
7952
7953            assert!(
7954                !scanned.is_empty(),
7955                "partial predicate must retain some rows"
7956            );
7957            assert!(
7958                scanned.len() < usize::try_from(ROW_COUNT).unwrap(),
7959                "partial predicate must actually exclude some rows"
7960            );
7961            assert_eq!(
7962                forced, scanned,
7963                "forced collated-DESC partial-index scan must match the table scan"
7964            );
7965        });
7966    }
7967
7968    /// GH #304 (UNIQUE + three-term mixed-direction + RTRIM arm): the facets
7969    /// `398bab01` explicitly left unverified.
7970    ///
7971    /// `398bab01` covered rowid tables, built-in collations, and single or
7972    /// composite ASC/DESC terms, but stated that the unique/auto arms,
7973    /// `quick_check`, and source-versus-candidate record parity were not
7974    /// covered. This exercises all of them at once:
7975    ///
7976    /// * a `UNIQUE` index, whose `is_unique` flag reaches only the regenerated
7977    ///   DDL and never the physical builder;
7978    /// * three key terms with mixed directions, so a single shared direction
7979    ///   flag cannot accidentally satisfy the ordering;
7980    /// * `RTRIM`, where `'c0007'` and `'c0007   '` compare equal but sort
7981    ///   differently from BINARY, so a builder that ignored the declared
7982    ///   collation would place trailing-space rows in the wrong leaf;
7983    /// * both `quick_check` and full `integrity_check` under stock SQLite;
7984    /// * index-versus-table row-count parity, which catches entries silently
7985    ///   dropped or duplicated during the rebuild.
7986    #[test]
7987    fn test_persist_to_sqlite_builds_unique_mixed_direction_rtrim_index() {
7988        asupersync::test_utils::run_test(|| async {
7989            const ROW_COUNT: i64 = 1_500;
7990            const CODE_GROUPS: i64 = 300;
7991            const TIER_COUNT: i64 = 7;
7992
7993            let dir = tempfile::tempdir().unwrap();
7994            let db_path = dir.path().join("unique-mixed-rtrim.db");
7995            let cx = Cx::new();
7996
7997            let text_column = |name: &str| ColumnInfo {
7998                name: name.to_owned(),
7999                affinity: 'B',
8000                is_ipk: false,
8001                type_name: Some("TEXT".to_owned()),
8002                notnull: false,
8003                unique: false,
8004                default_value: None,
8005                strict_type: None,
8006                generated_expr: None,
8007                generated_stored: None,
8008                collation: None,
8009                conflict_action: None,
8010            };
8011            let int_column = |name: &str, is_ipk: bool| ColumnInfo {
8012                name: name.to_owned(),
8013                affinity: 'D',
8014                is_ipk,
8015                type_name: Some("INTEGER".to_owned()),
8016                notnull: false,
8017                unique: false,
8018                default_value: None,
8019                strict_type: None,
8020                generated_expr: None,
8021                generated_stored: None,
8022                collation: None,
8023                conflict_action: None,
8024            };
8025
8026            let mut db = MemDatabase::new();
8027            db.create_table_at(2, 4);
8028            let table_data = db.get_table_mut(2).unwrap();
8029            for id in 1..=ROW_COUNT {
8030                // Trailing spaces must alternate across *recurrences of the same
8031                // base*, so the rule keys on the occurrence (the quotient), not
8032                // on `id`. Any rule of the form `id % k` where `k` divides
8033                // `CODE_GROUPS` gives every recurrence of a base an identical
8034                // spelling — the base repeats every `CODE_GROUPS` rows and
8035                // `id % k` is invariant under that step — so RTRIM would never
8036                // diverge from BINARY and a BINARY rebuild would satisfy the
8037                // ordering assertions below. Alternating by occurrence puts both
8038                // `c0007` and `c0007   ` in the table, which RTRIM folds together
8039                // and BINARY orders apart.
8040                let base = format!("c{:0>4}", id % CODE_GROUPS);
8041                let occurrence = id / CODE_GROUPS;
8042                let code = if occurrence % 2 == 1 {
8043                    format!("{base}   ")
8044                } else {
8045                    base
8046                };
8047                table_data.insert_row(
8048                    id,
8049                    vec![
8050                        SqliteValue::Integer(id),
8051                        SqliteValue::Text(code.into()),
8052                        SqliteValue::Integer(id % TIER_COUNT),
8053                        SqliteValue::Text(format!("note-{id:0>200}").into()),
8054                    ],
8055                );
8056            }
8057
8058            let schema = vec![TableSchema {
8059                name: "catalog".to_owned(),
8060                root_page: 2,
8061                columns: vec![
8062                    int_column("id", true),
8063                    text_column("code"),
8064                    int_column("tier", false),
8065                    text_column("note"),
8066                ],
8067                indexes: vec![IndexSchema {
8068                    name: "idx_catalog_mixed".to_owned(),
8069                    root_page: 3,
8070                    columns: vec!["code".to_owned(), "tier".to_owned(), "id".to_owned()],
8071                    key_expressions: Vec::new(),
8072                    key_sort_directions: vec![
8073                        SortDirection::Asc,
8074                        SortDirection::Desc,
8075                        SortDirection::Asc,
8076                    ],
8077                    where_clause: None,
8078                    is_unique: true,
8079                    key_collations: vec![Some("RTRIM".to_owned()), None, None],
8080                    conflict_action: None,
8081                }],
8082                strict: false,
8083                without_rowid: false,
8084                primary_key_constraints: vec![vec!["id".to_owned()]],
8085                foreign_keys: Vec::new(),
8086                check_constraints: Vec::new(),
8087            }];
8088            let header = DatabaseHeader {
8089                page_size: DEFAULT_PAGE_SIZE,
8090                schema_cookie: 1,
8091                change_counter: 1,
8092                version_valid_for: 1,
8093                ..DatabaseHeader::default()
8094            };
8095            let mut original_ddl = HashMap::new();
8096            original_ddl.insert(
8097                "catalog".to_owned(),
8098                "CREATE TABLE catalog (id INTEGER PRIMARY KEY, code TEXT, tier INTEGER, note TEXT)"
8099                    .to_owned(),
8100            );
8101            original_ddl.insert(
8102                "idx_catalog_mixed".to_owned(),
8103                "CREATE UNIQUE INDEX idx_catalog_mixed \
8104                 ON catalog(code COLLATE RTRIM, tier DESC, id)"
8105                    .to_owned(),
8106            );
8107
8108            persist_to_sqlite_with_header_and_master_entries(
8109                &cx,
8110                &db_path,
8111                &schema,
8112                &db,
8113                &header,
8114                &[],
8115                &original_ddl,
8116            )
8117            .await
8118            .unwrap();
8119
8120            let conn = rusqlite::Connection::open(&db_path).unwrap();
8121
8122            let quick: String = conn
8123                .query_row("PRAGMA quick_check;", [], |row| row.get(0))
8124                .unwrap();
8125            assert_eq!(
8126                quick, "ok",
8127                "stock SQLite quick_check must accept the rebuilt UNIQUE index"
8128            );
8129            let integrity: String = conn
8130                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
8131                .unwrap();
8132            assert_eq!(
8133                integrity, "ok",
8134                "stock SQLite integrity_check must accept the rebuilt UNIQUE index"
8135            );
8136
8137            let declared_sql: String = conn
8138                .query_row(
8139                    "SELECT sql FROM sqlite_master \
8140                     WHERE type='index' AND name='idx_catalog_mixed';",
8141                    [],
8142                    |row| row.get(0),
8143                )
8144                .unwrap();
8145            let lowered = declared_sql.to_ascii_lowercase();
8146            assert!(
8147                lowered.contains("unique") && lowered.contains("rtrim") && lowered.contains("desc"),
8148                "persisted DDL must keep UNIQUE, RTRIM and DESC: {declared_sql}"
8149            );
8150
8151            // Every table row must be reachable through the index; a dropped or
8152            // duplicated entry shows up here even when integrity_check passes.
8153            let indexed_count: i64 = conn
8154                .query_row(
8155                    "SELECT count(*) FROM catalog INDEXED BY idx_catalog_mixed;",
8156                    [],
8157                    |row| row.get(0),
8158                )
8159                .unwrap();
8160            let scanned_count: i64 = conn
8161                .query_row("SELECT count(*) FROM catalog NOT INDEXED;", [], |row| {
8162                    row.get(0)
8163                })
8164                .unwrap();
8165            assert_eq!(scanned_count, ROW_COUNT, "fixture must persist every row");
8166            assert_eq!(
8167                indexed_count, scanned_count,
8168                "index must contain exactly one entry per table row"
8169            );
8170
8171            // Compare over ALL rows rather than a `tier` cohort. Same-base rows
8172            // recur every `CODE_GROUPS` ids, and `CODE_GROUPS % TIER_COUNT` is 6,
8173            // so the five occurrences of a base land on five *distinct* tiers:
8174            // any `WHERE tier = ?` cohort therefore contains at most one row per
8175            // base, no trimmed/untrimmed pair survives the filter, and RTRIM and
8176            // BINARY can agree on the filtered set. An unfiltered traversal keeps
8177            // both spellings of every base in the comparison and is also the most
8178            // direct exercise of the whole declared key shape — ASC+RTRIM, then
8179            // DESC, then ASC.
8180            let collect = |sql: &str| -> Vec<i64> {
8181                let mut stmt = conn.prepare(sql).unwrap();
8182                stmt.query_map([], |row| row.get::<_, i64>(0))
8183                    .unwrap()
8184                    .collect::<std::result::Result<Vec<_>, _>>()
8185                    .unwrap()
8186            };
8187
8188            // The fixture must actually be able to tell RTRIM from BINARY,
8189            // otherwise the ordering assertion below passes for an index that
8190            // was rebuilt with the wrong collation. Prove it on the table scan,
8191            // where neither ordering can come from the index under test: if the
8192            // two collations agree on this data the fixture is not
8193            // discriminatory and the rest of this test proves nothing.
8194            let rtrim_scan = collect(
8195                "SELECT id FROM catalog NOT INDEXED \
8196                 ORDER BY code COLLATE RTRIM, tier DESC, id",
8197            );
8198            let binary_scan = collect(
8199                "SELECT id FROM catalog NOT INDEXED \
8200                 ORDER BY code COLLATE BINARY, tier DESC, id",
8201            );
8202            assert_ne!(
8203                rtrim_scan, binary_scan,
8204                "fixture must distinguish RTRIM from BINARY, otherwise a BINARY \
8205                 rebuild would satisfy the index-order assertion below"
8206            );
8207            let both_spellings: i64 = conn
8208                .query_row(
8209                    "SELECT count(*) FROM (SELECT rtrim(code) AS b FROM catalog \
8210                     GROUP BY b HAVING count(DISTINCT code) > 1);",
8211                    [],
8212                    |row| row.get(0),
8213                )
8214                .unwrap();
8215            assert!(
8216                both_spellings > 0,
8217                "at least one base must appear both trimmed and untrimmed"
8218            );
8219
8220            let forced = collect(
8221                "SELECT id FROM catalog INDEXED BY idx_catalog_mixed \
8222                 ORDER BY code COLLATE RTRIM, tier DESC, id",
8223            );
8224            assert_eq!(
8225                forced.len(),
8226                usize::try_from(ROW_COUNT).unwrap(),
8227                "the forced index traversal must visit every row"
8228            );
8229            assert_eq!(
8230                forced, rtrim_scan,
8231                "forced UNIQUE mixed-direction RTRIM index traversal must match the table scan"
8232            );
8233        });
8234    }
8235
8236    /// GH #304 (custom-collation arm): an index whose declared collation is not
8237    /// resolvable by the builder must be refused, not rebuilt under BINARY.
8238    ///
8239    /// The source connection's collation registry is not reachable from the
8240    /// persist path, so a `COLLATE MYCOLL` term previously fell back to BINARY
8241    /// while the regenerated DDL kept saying `MYCOLL`. That produces the same
8242    /// malformed-image class GH #304 was filed for, minus the DESC symptom that
8243    /// made the original report visible — a silently wrong ordering rather than
8244    /// a loud one. Refusing keeps the source image intact and surfaces the gap.
8245    ///
8246    /// Deliberately NOT covered here, and still open on GH #304:
8247    ///
8248    /// * **Built-in name override.** A source connection may register its own
8249    ///   implementation under `BINARY`/`NOCASE`/`RTRIM`. The guard sees the name
8250    ///   present and admits the index, which is then built with the *default*
8251    ///   implementation — silently mis-ordered. No test asserts this, because
8252    ///   provoking it means mutating the process-wide default registry, which
8253    ///   would leak into every other test in this binary (the exact global-state
8254    ///   hazard tracked by GH #299). It needs registry identity, not a name.
8255    /// * **Candidate cleanup.** The `db_path.exists()` assertion below documents
8256    ///   *ownership* only — it pins that this function does not delete its own
8257    ///   output. It is not a cleanup proof. The release evidence still requires a
8258    ///   keeper over the enclosing `VacuumTargetReservation` in `vacuum.rs`
8259    ///   showing the partial candidate is actually removed on this failure path
8260    ///   and that no caller-owned path is touched.
8261    #[test]
8262    fn test_persist_to_sqlite_refuses_unresolvable_index_collation() {
8263        asupersync::test_utils::run_test(|| async {
8264            let dir = tempfile::tempdir().unwrap();
8265            let db_path = dir.path().join("unresolvable-collation.db");
8266            let cx = Cx::new();
8267
8268            let mut db = MemDatabase::new();
8269            db.create_table_at(2, 2);
8270            let table_data = db.get_table_mut(2).unwrap();
8271            for id in 1..=8_i64 {
8272                table_data.insert_row(
8273                    id,
8274                    vec![
8275                        SqliteValue::Integer(id),
8276                        SqliteValue::Text(format!("v{id}").into()),
8277                    ],
8278                );
8279            }
8280
8281            let schema = vec![TableSchema {
8282                name: "t".to_owned(),
8283                root_page: 2,
8284                columns: vec![
8285                    ColumnInfo {
8286                        name: "id".to_owned(),
8287                        affinity: 'D',
8288                        is_ipk: true,
8289                        type_name: Some("INTEGER".to_owned()),
8290                        notnull: false,
8291                        unique: false,
8292                        default_value: None,
8293                        strict_type: None,
8294                        generated_expr: None,
8295                        generated_stored: None,
8296                        collation: None,
8297                        conflict_action: None,
8298                    },
8299                    ColumnInfo {
8300                        name: "label".to_owned(),
8301                        affinity: 'B',
8302                        is_ipk: false,
8303                        type_name: Some("TEXT".to_owned()),
8304                        notnull: false,
8305                        unique: false,
8306                        default_value: None,
8307                        strict_type: None,
8308                        generated_expr: None,
8309                        generated_stored: None,
8310                        collation: None,
8311                        conflict_action: None,
8312                    },
8313                ],
8314                indexes: vec![IndexSchema {
8315                    name: "idx_t_label_custom".to_owned(),
8316                    root_page: 3,
8317                    columns: vec!["label".to_owned()],
8318                    key_expressions: Vec::new(),
8319                    key_sort_directions: vec![SortDirection::Asc],
8320                    where_clause: None,
8321                    is_unique: false,
8322                    key_collations: vec![Some("MYCOLL".to_owned())],
8323                    conflict_action: None,
8324                }],
8325                strict: false,
8326                without_rowid: false,
8327                primary_key_constraints: vec![vec!["id".to_owned()]],
8328                foreign_keys: Vec::new(),
8329                check_constraints: Vec::new(),
8330            }];
8331            let header = DatabaseHeader {
8332                page_size: DEFAULT_PAGE_SIZE,
8333                schema_cookie: 1,
8334                change_counter: 1,
8335                version_valid_for: 1,
8336                ..DatabaseHeader::default()
8337            };
8338            let mut original_ddl = HashMap::new();
8339            original_ddl.insert(
8340                "t".to_owned(),
8341                "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)".to_owned(),
8342            );
8343            original_ddl.insert(
8344                "idx_t_label_custom".to_owned(),
8345                "CREATE INDEX idx_t_label_custom ON t(label COLLATE MYCOLL)".to_owned(),
8346            );
8347
8348            let error = persist_to_sqlite_with_header_and_master_entries(
8349                &cx,
8350                &db_path,
8351                &schema,
8352                &db,
8353                &header,
8354                &[],
8355                &original_ddl,
8356            )
8357            .await
8358            .expect_err("an unresolvable index collation must fail closed");
8359            let rendered = error.to_string();
8360            assert!(
8361                rendered.contains("MYCOLL") && rendered.contains("contradict its own declaration"),
8362                "refusal must name the unresolvable collation and why it is refused: {rendered}"
8363            );
8364            // A legitimate schema this builder cannot honour is a supported-
8365            // schema limitation, not a violated internal invariant.
8366            assert!(
8367                matches!(error, FrankenError::NotImplemented(_)),
8368                "refusal must be typed as NotImplemented, found {error:?}"
8369            );
8370
8371            // Candidate cleanup is deliberately NOT this function's job: it never
8372            // removes its own output on any error path, and the enclosing VACUUM
8373            // caller owns removal through its identity-bound
8374            // `VacuumTargetReservation`. Pin the actual post-failure state so a
8375            // future change to that ownership boundary is caught here rather than
8376            // silently leaking or silently starting to delete caller-owned paths.
8377            let candidate_exists_after_refusal = db_path.exists();
8378
8379            // A built-in collation on the same shape must still succeed, so the
8380            // guard rejects only what it genuinely cannot order. The DDL must be
8381            // regenerated to match: reusing the MYCOLL text would persist an
8382            // index whose declaration contradicts the key metadata actually
8383            // built, which is the very defect this test exists to prevent, and
8384            // stock SQLite would reject the unknown collation on open.
8385            let mut ok_schema = schema;
8386            ok_schema[0].indexes[0].key_collations = vec![Some("NOCASE".to_owned())];
8387            let mut ok_ddl = HashMap::new();
8388            ok_ddl.insert(
8389                "t".to_owned(),
8390                "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)".to_owned(),
8391            );
8392            ok_ddl.insert(
8393                "idx_t_label_custom".to_owned(),
8394                "CREATE INDEX idx_t_label_custom ON t(label COLLATE NOCASE)".to_owned(),
8395            );
8396            let ok_path = dir.path().join("resolvable-collation.db");
8397            persist_to_sqlite_with_header_and_master_entries(
8398                &cx,
8399                &ok_path,
8400                &ok_schema,
8401                &db,
8402                &header,
8403                &[],
8404                &ok_ddl,
8405            )
8406            .await
8407            .expect("a built-in collation must still rebuild");
8408            let conn = rusqlite::Connection::open(&ok_path).unwrap();
8409            let integrity: String = conn
8410                .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
8411                .unwrap();
8412            assert_eq!(integrity, "ok");
8413            let declared: String = conn
8414                .query_row(
8415                    "SELECT sql FROM sqlite_master \
8416                     WHERE type='index' AND name='idx_t_label_custom';",
8417                    [],
8418                    |row| row.get(0),
8419                )
8420                .unwrap();
8421            assert!(
8422                declared.to_ascii_uppercase().contains("NOCASE")
8423                    && !declared.to_ascii_uppercase().contains("MYCOLL"),
8424                "persisted DDL must declare the collation actually built: {declared}"
8425            );
8426
8427            // Reported after the success path so a leak is described precisely
8428            // rather than aborting the more informative assertions above.
8429            assert!(
8430                candidate_exists_after_refusal,
8431                "refusal leaves the partial candidate for the caller's reservation to remove; \
8432                 if this now fails, cleanup ownership moved into the persist path and the \
8433                 comment above it must be updated"
8434            );
8435        });
8436    }
8437
8438    #[test]
8439    fn test_overwrite_existing_file() {
8440        asupersync::test_utils::run_test(|| async {
8441            let dir = tempfile::tempdir().unwrap();
8442            let db_path = dir.path().join("overwrite.db");
8443
8444            // Write once.
8445            let (schema, db) = make_test_schema_and_db();
8446            persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
8447
8448            // Overwrite with empty.
8449            persist_test_db(&db_path, &[], &MemDatabase::new(), 0, 0)
8450                .await
8451                .unwrap();
8452
8453            let loaded = load_test_db(&db_path).await.unwrap();
8454            assert!(loaded.schema.is_empty());
8455        });
8456    }
8457
8458    #[test]
8459    fn test_load_from_sqlite_keeps_materialized_virtual_tables_with_real_root_page() {
8460        asupersync::test_utils::run_test(|| async {
8461            let dir = tempfile::tempdir().unwrap();
8462            let db_path = dir.path().join("materialized_vtab_load.db");
8463            let db_str = db_path.to_string_lossy().to_string();
8464
8465            {
8466                let conn = crate::connection::Connection::open(&db_str).await.unwrap();
8467                conn.execute(
8468                    "CREATE VIRTUAL TABLE docs USING fts5(subject, body, tokenize='porter')",
8469                )
8470                .await
8471                .unwrap();
8472                conn.execute(
8473                    "INSERT INTO docs(rowid, subject, body) VALUES (1, 'Hello', 'Rust world')",
8474                )
8475                .await
8476                .unwrap();
8477                conn.execute(
8478                    "INSERT INTO docs(rowid, subject, body) VALUES (2, 'Other', 'Nothing')",
8479                )
8480                .await
8481                .unwrap();
8482                conn.close().await.unwrap();
8483            }
8484
8485            let loaded = load_test_db(&db_path).await.unwrap();
8486            // FrankenSQLite-created FTS5 tables are now stock-compatible
8487            // rootpage=0 virtual tables. The low-level compat loader deliberately
8488            // skips rootpage=0 virtual-table catalog rows (they have no
8489            // materialized root b-tree of their own; the live vtab is reconstructed
8490            // at the higher connection-reload layer instead), so the `docs` row is
8491            // NOT present here — but the durable document content it persisted DOES
8492            // survive, in the positive-rootpage `docs_content` shadow table.
8493            assert!(
8494                loaded
8495                    .schema
8496                    .iter()
8497                    .all(|table| !table.name.eq_ignore_ascii_case("docs")),
8498                "rootpage=0 FTS5 virtual-table catalog row must be skipped by the low-level loader"
8499            );
8500
8501            // The persisted document content lives in the `docs_content` shadow
8502            // table, laid out as (id INTEGER PRIMARY KEY, c0=subject, c1=body).
8503            let content = loaded
8504                .schema
8505                .iter()
8506                .find(|table| table.name.eq_ignore_ascii_case("docs_content"))
8507                .expect("FTS5 content shadow table should survive direct load");
8508            let content_columns: Vec<&str> = content
8509                .columns
8510                .iter()
8511                .map(|column| column.name.as_str())
8512                .collect();
8513            assert_eq!(content_columns, vec!["id", "c0", "c1"]);
8514            assert!(
8515                content.root_page > 0,
8516                "the _content shadow table is a real positive-rootpage b-tree"
8517            );
8518            let mem_table = loaded
8519                .db
8520                .get_table(content.root_page)
8521                .expect("loaded content shadow table should exist in MemDatabase");
8522            // The _content shadow b-tree stores the full record (id, c0, c1): the
8523            // INTEGER PRIMARY KEY `id` is both the rowid and the first record value,
8524            // followed by the document columns subject (c0) and body (c1).
8525            let rows: Vec<_> = mem_table.iter_rows().collect();
8526            assert_eq!(rows.len(), 2);
8527            assert_eq!(rows[0].0, 1);
8528            assert_eq!(rows[0].1[0], SqliteValue::Integer(1));
8529            assert_eq!(rows[0].1[1], SqliteValue::Text("Hello".into()));
8530            assert_eq!(rows[0].1[2], SqliteValue::Text("Rust world".into()));
8531            assert_eq!(rows[1].0, 2);
8532            assert_eq!(rows[1].1[0], SqliteValue::Integer(2));
8533            assert_eq!(rows[1].1[1], SqliteValue::Text("Other".into()));
8534            assert_eq!(rows[1].1[2], SqliteValue::Text("Nothing".into()));
8535        });
8536    }
8537
8538    #[test]
8539    fn test_load_from_sqlite_rejects_non_virtual_table_with_rootpage_zero() {
8540        asupersync::test_utils::run_test(|| async {
8541            let dir = tempfile::tempdir().unwrap();
8542            let db_path = dir.path().join("compat_corrupt_rootpage_zero.db");
8543
8544            {
8545                let conn = rusqlite::Connection::open(&db_path).unwrap();
8546                conn.execute_batch(
8547                    r"
8548                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8549                INSERT INTO docs VALUES (1, 'hello');
8550                PRAGMA writable_schema = ON;
8551                UPDATE sqlite_master SET rootpage = 0 WHERE name = 'docs';
8552                PRAGMA writable_schema = OFF;
8553                ",
8554                )
8555                .unwrap();
8556            }
8557
8558            let err = match load_test_db(&db_path).await {
8559                Ok(_) => panic!("corrupt rootpage should fail load"),
8560                Err(err) => err,
8561            };
8562            let message = err.to_string();
8563            assert!(
8564                message.contains("rootpage 0") || message.contains("root page"),
8565                "unexpected load error: {message}"
8566            );
8567        });
8568    }
8569
8570    #[test]
8571    fn test_load_from_sqlite_rejects_negative_rootpage() {
8572        asupersync::test_utils::run_test(|| async {
8573            let dir = tempfile::tempdir().unwrap();
8574            let db_path = dir.path().join("compat_corrupt_rootpage_negative.db");
8575
8576            {
8577                let conn = rusqlite::Connection::open(&db_path).unwrap();
8578                conn.execute_batch(
8579                    r"
8580                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8581                INSERT INTO docs VALUES (1, 'hello');
8582                PRAGMA writable_schema = ON;
8583                UPDATE sqlite_master SET rootpage = -7 WHERE name = 'docs';
8584                PRAGMA writable_schema = OFF;
8585                ",
8586                )
8587                .unwrap();
8588            }
8589
8590            let err = match load_test_db(&db_path).await {
8591                Ok(_) => panic!("negative rootpage should fail load"),
8592                Err(err) => err,
8593            };
8594            let message = err.to_string();
8595            assert!(
8596                message.contains("rootpage -7") || message.contains("invalid rootpage"),
8597                "unexpected load error: {message}"
8598            );
8599        });
8600    }
8601
8602    #[test]
8603    fn test_load_from_sqlite_rejects_rootpage_above_supported_range() {
8604        asupersync::test_utils::run_test(|| async {
8605            let dir = tempfile::tempdir().unwrap();
8606            let db_path = dir.path().join("compat_corrupt_rootpage_large.db");
8607
8608            {
8609                let conn = rusqlite::Connection::open(&db_path).unwrap();
8610                conn.execute_batch(
8611                    r"
8612                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8613                INSERT INTO docs VALUES (1, 'hello');
8614                PRAGMA writable_schema = ON;
8615                UPDATE sqlite_master SET rootpage = 2147483648 WHERE name = 'docs';
8616                PRAGMA writable_schema = OFF;
8617                ",
8618                )
8619                .unwrap();
8620            }
8621
8622            let err = match load_test_db(&db_path).await {
8623                Ok(_) => panic!("oversized rootpage should fail load"),
8624                Err(err) => err,
8625            };
8626            let message = err.to_string();
8627            assert!(
8628                message.contains("supported range")
8629                    || message.contains("out-of-range")
8630                    || message.contains("2147483648"),
8631                "unexpected load error: {message}"
8632            );
8633        });
8634    }
8635
8636    #[test]
8637    fn test_load_from_sqlite_rejects_index_rootpage_above_supported_range() {
8638        asupersync::test_utils::run_test(|| async {
8639            let dir = tempfile::tempdir().unwrap();
8640            let db_path = dir.path().join("compat_corrupt_index_rootpage_large.db");
8641
8642            {
8643                let conn = rusqlite::Connection::open(&db_path).unwrap();
8644                conn.execute_batch(
8645                    r"
8646                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8647                CREATE INDEX docs_title_idx ON docs(title);
8648                PRAGMA writable_schema = ON;
8649                UPDATE sqlite_master SET rootpage = 2147483648 WHERE name = 'docs_title_idx';
8650                PRAGMA writable_schema = OFF;
8651                ",
8652                )
8653                .unwrap();
8654            }
8655
8656            let err = match load_test_db(&db_path).await {
8657                Ok(_) => panic!("oversized index rootpage should fail load"),
8658                Err(err) => err,
8659            };
8660            let message = err.to_string();
8661            assert!(
8662                message.contains("docs_title_idx") && message.contains("2147483648"),
8663                "unexpected load error: {message}"
8664            );
8665        });
8666    }
8667
8668    #[test]
8669    fn test_load_from_sqlite_rejects_explicit_index_with_missing_key_column() {
8670        asupersync::test_utils::run_test(|| async {
8671            let dir = tempfile::tempdir().unwrap();
8672            let db_path = dir.path().join("compat_corrupt_index_key_column.db");
8673
8674            {
8675                let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8676                sqlite
8677                    .execute_batch(
8678                        r"
8679                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8680                CREATE INDEX docs_title_idx ON docs(title);
8681                PRAGMA writable_schema = ON;
8682                UPDATE sqlite_master
8683                SET sql = 'CREATE INDEX docs_title_idx ON docs(missing_title)'
8684                WHERE name = 'docs_title_idx';
8685                PRAGMA writable_schema = OFF;
8686                ",
8687                    )
8688                    .unwrap();
8689            }
8690
8691            let error = load_test_db(&db_path)
8692                .await
8693                .expect_err("compat reload must reject an unresolved explicit-index key");
8694            assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8695            let message = error.to_string();
8696            assert!(
8697                message.contains("docs_title_idx") && message.contains("missing_title"),
8698                "unexpected malformed-index compat-load error: {message}"
8699            );
8700        });
8701    }
8702
8703    #[test]
8704    fn test_load_from_sqlite_rejects_schema_qualified_persisted_create_index_sql() {
8705        asupersync::test_utils::run_test(|| async {
8706            let dir = tempfile::tempdir().unwrap();
8707            let db_path = dir.path().join("compat_schema_qualified_index_sql.db");
8708            {
8709                let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8710                sqlite
8711                    .execute_batch(
8712                        r"
8713                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8714                CREATE INDEX docs_title_idx ON docs(title);
8715                PRAGMA writable_schema = ON;
8716                UPDATE sqlite_master
8717                SET sql = 'CREATE INDEX main.docs_title_idx ON docs(title)'
8718                WHERE name = 'docs_title_idx';
8719                PRAGMA writable_schema = OFF;
8720                ",
8721                    )
8722                    .unwrap();
8723            }
8724
8725            let error = load_test_db(&db_path)
8726                .await
8727                .expect_err("compat load must reject schema-qualified index SQL");
8728            assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8729            let message = error.to_string();
8730            assert!(
8731                message.contains("docs_title_idx") && message.contains("schema-qualified"),
8732                "unexpected schema-qualified-index compat-load error: {message}"
8733            );
8734        });
8735    }
8736
8737    #[test]
8738    fn test_load_from_sqlite_rejects_known_invalid_index_functions() {
8739        asupersync::test_utils::run_test(|| async {
8740            let dir = tempfile::tempdir().unwrap();
8741            for (case, create_sql, expected) in [
8742                (
8743                    "random",
8744                    "CREATE INDEX docs_title_idx ON docs(random())",
8745                    "non-deterministic",
8746                ),
8747                (
8748                    "aggregate",
8749                    "CREATE INDEX docs_title_idx ON docs(sum(title))",
8750                    "aggregate",
8751                ),
8752                (
8753                    "wrong_arity",
8754                    "CREATE INDEX docs_title_idx ON docs(lower(title, title))",
8755                    "wrong number of arguments",
8756                ),
8757                (
8758                    "current_timestamp",
8759                    "CREATE INDEX docs_title_idx ON docs(CURRENT_TIMESTAMP)",
8760                    "non-deterministic",
8761                ),
8762            ] {
8763                let db_path = dir
8764                    .path()
8765                    .join(format!("compat_invalid_index_function_{case}.db"));
8766                {
8767                    let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8768                    sqlite
8769                        .execute_batch(
8770                            r"
8771                        CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8772                        CREATE INDEX docs_title_idx ON docs(title);
8773                        PRAGMA writable_schema = ON;
8774                        ",
8775                        )
8776                        .unwrap();
8777                    sqlite
8778                        .execute(
8779                            "UPDATE sqlite_master SET sql = ?1 WHERE name = 'docs_title_idx'",
8780                            [create_sql],
8781                        )
8782                        .unwrap();
8783                    sqlite
8784                        .execute_batch("PRAGMA writable_schema = OFF;")
8785                        .unwrap();
8786                }
8787
8788                let error = load_test_db(&db_path)
8789                    .await
8790                    .expect_err("known-invalid persisted index function must fail compat load");
8791                assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8792                let message = error.to_string().to_ascii_lowercase();
8793                assert!(
8794                    message.contains("docs_title_idx") && message.contains(expected),
8795                    "unexpected compat persisted-function error for `{create_sql}`: {error}"
8796                );
8797            }
8798        });
8799    }
8800
8801    #[test]
8802    fn test_load_from_sqlite_rejects_invalid_utf8_in_sqlite_master_record() {
8803        asupersync::test_utils::run_test(|| async {
8804            let dir = tempfile::tempdir().unwrap();
8805            let db_path = dir.path().join("compat_corrupt_master_utf8.db");
8806
8807            {
8808                let conn = rusqlite::Connection::open(&db_path).unwrap();
8809                conn.execute_batch(
8810                    r"
8811                CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8812                INSERT INTO docs VALUES (1, 'hello');
8813                PRAGMA writable_schema = ON;
8814                UPDATE sqlite_master
8815                SET sql = CAST(x'FF' AS TEXT)
8816                WHERE name = 'docs';
8817                PRAGMA writable_schema = OFF;
8818                ",
8819                )
8820                .unwrap();
8821            }
8822
8823            // Oracle-corrected contract (bd-asupersync-043 triage): after
8824            // 84ebdf4b3 the record TEXT itself loads byte-preserved (stock
8825            // parity), so the failure moves to where stock also fails — the
8826            // schema SQL cannot parse, i.e. the malformed-database-schema
8827            // class. Assert that class rather than the retired record-level
8828            // UTF-8 rejection.
8829            let err = load_test_db(&db_path)
8830                .await
8831                .expect_err("unparseable sqlite_master SQL must fail schema load");
8832            assert!(matches!(&err, FrankenError::DatabaseCorrupt { .. }));
8833            let message = err.to_string();
8834            assert!(
8835                message.contains("could not parse CREATE TABLE SQL")
8836                    || message.to_ascii_lowercase().contains("malformed"),
8837                "unexpected load error class: {message}"
8838            );
8839        });
8840    }
8841
8842    #[test]
8843    fn test_load_from_sqlite_rejects_invalid_utf8_in_table_record() {
8844        asupersync::test_utils::run_test(|| async {
8845            // Oracle-corrected contract (bd-asupersync-043 triage, sqlite3
8846            // 3.46.1 receipt on the release bead): stock SQLite PRESERVES
8847            // invalid-UTF-8 TEXT bytes through the database file with no
8848            // rejection at any stage — `CAST(x'FF' AS TEXT)` round-trips as
8849            // one raw byte. The historical rejection assertion was
8850            // anti-parity once 84ebdf4b3 (byte-preservation) landed. The
8851            // loader must now ACCEPT the record and keep the byte exact.
8852            let dir = tempfile::tempdir().unwrap();
8853            let db_path = dir.path().join("compat_corrupt_table_utf8.db");
8854
8855            {
8856                let conn = rusqlite::Connection::open(&db_path).unwrap();
8857                conn.execute_batch(
8858                    r"
8859                CREATE TABLE docs (title TEXT);
8860                INSERT INTO docs VALUES (CAST(x'FF' AS TEXT));
8861                ",
8862                )
8863                .unwrap();
8864            }
8865
8866            let loaded = load_test_db(&db_path)
8867                .await
8868                .expect("invalid-UTF-8 TEXT must load byte-preserved, matching stock");
8869            let table = loaded
8870                .db
8871                .tables
8872                .values()
8873                .next()
8874                .expect("docs table present");
8875            let (_rowid, values) = table.iter_rows().next().expect("docs row present");
8876            match values.first() {
8877                Some(SqliteValue::Text(text)) => {
8878                    assert_eq!(
8879                        text.as_bytes_direct(),
8880                        &[0xFF],
8881                        "raw TEXT byte must round-trip exactly like stock"
8882                    );
8883                }
8884                other => panic!("expected preserved TEXT value, got {other:?}"),
8885            }
8886        });
8887    }
8888}