Skip to main content

fsqlite_core/
compat_persist.rs

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