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