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