Skip to main content

dynoxide/
storage.rs

1#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2use crate::errors::{DynoxideError, Result};
3#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
4use crate::storage_backend::clock::{Clock, SystemClock};
5#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
6use crate::storage_backend::sql_builders::{self, escape_table_name};
7use crate::types::AttributeValue;
8#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
9use rusqlite::{Connection, params};
10#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
11use std::{cell::RefCell, collections::HashMap, sync::Arc};
12
13// The schema version this build writes. It lives in `sql_builders` so the
14// wasm backend records the same version on open.
15#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
16use crate::storage_backend::sql_builders::SCHEMA_VERSION;
17
18/// How long a connection to a database file waits, while it opens, for a lock
19/// another process holds before it reports the database locked. Another
20/// Dynoxide opening the same file holds its write lock while it brings the
21/// file up to date, which can take longer than five seconds on a large file.
22/// Once the file is open the connection waits [`REQUEST_BUSY_TIMEOUT`]
23/// instead. An in-memory database has no other process to wait for and keeps
24/// the default throughout.
25#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
26const OPEN_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
27
28/// How long an open connection waits for a lock another process holds:
29/// rusqlite's default. Every request waits behind the one holding the
30/// connection, so a request held up by another process gives up after this
31/// rather than holding the rest up for [`OPEN_BUSY_TIMEOUT`].
32#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
33const REQUEST_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
34
35#[cfg(test)]
36thread_local! {
37    /// The busy timeout a connection on this thread had when it finished
38    /// opening: its migrations ran under it. For tests of the two phases.
39    static OPENED_WITH_BUSY_TIMEOUT_MS: std::cell::Cell<Option<i64>> =
40        const { std::cell::Cell::new(None) };
41}
42
43/// Number of hash buckets used for parallel scan segment assignment.
44/// Matches dynalite's implementation.
45pub(crate) const HASH_BUCKETS: u32 = 4096;
46
47// ---------------------------------------------------------------------------
48// MD5-based hash prefix for parallel scan (dynalite-compatible)
49// ---------------------------------------------------------------------------
50
51/// Compute the 6-character hex hash prefix for a partition key value.
52///
53/// Uses `MD5("Outliers" + key_bytes)` where `key_bytes` depends on the
54/// attribute type:
55/// - `S`: UTF-8 bytes of the string
56/// - `N`: Oracle packed BCD encoding via `num_to_buffer`
57/// - `B`: raw binary bytes
58///
59/// This matches dynalite's `hashPrefix` function.
60pub fn compute_hash_prefix(pk_value: &AttributeValue) -> String {
61    let key_bytes = match pk_value {
62        AttributeValue::S(s) => s.as_bytes().to_vec(),
63        AttributeValue::N(n) => num_to_buffer(n),
64        AttributeValue::B(b) => b.clone(),
65        _ => vec![], // Should not happen for valid keys
66    };
67
68    let digest = md5::compute([b"Outliers" as &[u8], &key_bytes].concat());
69    format!("{:032x}", digest)[..6].to_string()
70}
71
72/// Compute the hash bucket (0..4095) from a 6-character hex hash prefix.
73pub fn hash_bucket(hash_prefix: &str) -> u32 {
74    let prefix_3 = &hash_prefix[..3.min(hash_prefix.len())];
75    u32::from_str_radix(prefix_3, 16).unwrap_or(0)
76}
77
78/// Convert a DynamoDB number string to Oracle-style packed BCD bytes.
79///
80/// Faithfully ports dynalite's `numToBuffer` function from `db/index.js`.
81/// Uses Big.js semantics: `num.s` (sign), `num.c` (coefficient digits),
82/// `num.e` (exponent = position of first digit relative to decimal point - 1).
83fn num_to_buffer(num_str: &str) -> Vec<u8> {
84    let trimmed = num_str.trim();
85    if trimmed.is_empty() {
86        return vec![0x80];
87    }
88
89    use bigdecimal::BigDecimal;
90    use std::str::FromStr;
91
92    let bd = match BigDecimal::from_str(trimmed) {
93        Ok(v) => v,
94        Err(_) => return vec![0x80],
95    };
96
97    if bd.sign() == bigdecimal::num_bigint::Sign::NoSign {
98        return vec![0x80];
99    }
100
101    let is_negative = bd.sign() == bigdecimal::num_bigint::Sign::Minus;
102    let bd_abs = if is_negative { -&bd } else { bd.clone() };
103
104    let (mantissa, exponent) = extract_mantissa_and_exponent(&bd_abs);
105    if mantissa.is_empty() {
106        return vec![0x80];
107    }
108
109    // JS: scale = num.s (-1 for negative, 1 for positive)
110    // JS: appendZero = exponent % 2 ? 1 : 0
111    let append_zero: i64 = if exponent % 2 != 0 { 1 } else { 0 };
112    let byte_len_no_exp = ((mantissa.len() as i64 + append_zero + 1) / 2) as usize;
113
114    let mut byte_array: Vec<u8>;
115    if byte_len_no_exp < 20 && is_negative {
116        byte_array = vec![0u8; byte_len_no_exp + 2];
117        byte_array[byte_len_no_exp + 1] = 102;
118    } else {
119        byte_array = vec![0u8; byte_len_no_exp + 1];
120    }
121
122    // byteArray[0] = Math.floor((exponent + appendZero) / 2) - 64
123    // For negative exponents, JS Math.floor(-3/2) = -2, matching Rust integer division
124    // for negative values we need floor division, not truncation.
125    let exp_sum = exponent + append_zero;
126    let exp_byte_val = floor_div(exp_sum, 2) - 64;
127    if is_negative {
128        // byteArray[0] ^= 0xffffffff - JS bitwise XOR on a number
129        // This effectively does a bitwise NOT on the low byte
130        byte_array[0] = (exp_byte_val ^ !0i64) as u8;
131    } else {
132        byte_array[0] = exp_byte_val as u8;
133    }
134
135    // The main loop faithfully mirrors the JS for loop.
136    // JS uses mantissaIndex as a signed int that can be decremented to -1.
137    let mut mi: i64 = 0; // mantissaIndex (signed to allow -1)
138    let mlen = mantissa.len() as i64;
139    let mut appended_zero = false;
140
141    while mi < mlen {
142        let bai = ((mi + append_zero) / 2 + 1) as usize; // byteArrayIndex
143        if append_zero != 0 && mi == 0 && !appended_zero {
144            byte_array[bai] = 0;
145            appended_zero = true;
146            mi -= 1; // JS: mantissaIndex--
147        } else if (mi + append_zero) % 2 == 0 {
148            byte_array[bai] = mantissa[mi as usize] * 10;
149        } else {
150            byte_array[bai] += mantissa[mi as usize];
151        }
152
153        // Finalise byte: if odd position or last mantissa digit
154        if ((mi + append_zero) % 2 != 0) || (mi == mlen - 1) {
155            if is_negative {
156                byte_array[bai] = 101u8.wrapping_sub(byte_array[bai]);
157            } else {
158                byte_array[bai] = byte_array[bai].wrapping_add(1);
159            }
160        }
161
162        mi += 1; // JS: for loop increment
163    }
164
165    byte_array
166}
167
168/// Floor division for signed integers (matching JS Math.floor for division).
169fn floor_div(a: i64, b: i64) -> i64 {
170    let d = a / b;
171    let r = a % b;
172    if (r != 0) && ((r ^ b) < 0) { d - 1 } else { d }
173}
174
175/// Extract mantissa digits and exponent from a BigDecimal.
176///
177/// Returns (digits, exponent) matching Big.js semantics where:
178/// - digits: array of individual digits [2, 5, 1] for "251"
179/// - exponent: number of digits before the decimal point
180///   e.g., "251" → exponent=3, "0.012345" → exponent=-1
181fn extract_mantissa_and_exponent(bd: &bigdecimal::BigDecimal) -> (Vec<u8>, i64) {
182    // Normalize to remove trailing zeros
183    let normalized = bd.normalized();
184
185    // Get the string representation without scientific notation
186    // We need the digits and the position of the decimal point
187    let (bigint, scale) = normalized.as_bigint_and_exponent();
188    let digits_str = bigint.to_string();
189    let digits_str = digits_str.trim_start_matches('-');
190
191    let digits: Vec<u8> = digits_str
192        .chars()
193        .map(|c| c.to_digit(10).unwrap() as u8)
194        .collect();
195
196    // scale = number of digits after decimal point in the representation
197    // exponent (Big.js style) = number_of_digits - scale = digits.len() as i64 - scale
198    let exponent = digits.len() as i64 - scale;
199
200    (digits, exponent)
201}
202
203/// Check whether a hash_prefix falls within the range for a given segment.
204///
205/// Uses dynalite's 4096-bucket scheme:
206/// - bucket = parseInt(hash_prefix[0..3], 16)
207/// - segment owns buckets from ceil(4096 * segment / total) to
208///   ceil(4096 * (segment+1) / total) - 1
209pub fn hash_in_segment(hash_prefix: &str, segment: u32, total_segments: u32) -> bool {
210    let bucket = hash_bucket(hash_prefix);
211    let start = ceiling_div(HASH_BUCKETS * segment, total_segments);
212    let end = ceiling_div(HASH_BUCKETS * (segment + 1), total_segments) - 1;
213    bucket >= start && bucket <= end
214}
215
216pub(crate) fn ceiling_div(a: u32, b: u32) -> u32 {
217    a.div_ceil(b)
218}
219
220/// Parameters for scan operations (base table or GSI).
221#[derive(Debug, Default)]
222pub struct ScanParams<'a> {
223    pub limit: Option<usize>,
224    pub exclusive_start_pk: Option<&'a str>,
225    pub exclusive_start_sk: Option<&'a str>,
226    pub segment: Option<u32>,
227    pub total_segments: Option<u32>,
228    /// For LSI pagination: base table PK for composite cursor.
229    pub exclusive_start_base_pk: Option<&'a str>,
230    /// For LSI pagination: base table SK for composite cursor.
231    pub exclusive_start_base_sk: Option<&'a str>,
232}
233
234/// Parameters for inserting table metadata.
235#[derive(Debug, Default)]
236pub struct CreateTableMetadata<'a> {
237    pub table_name: &'a str,
238    pub key_schema: &'a str,
239    pub attribute_definitions: &'a str,
240    pub gsi_definitions: Option<&'a str>,
241    pub lsi_definitions: Option<&'a str>,
242    pub provisioned_throughput: Option<&'a str>,
243    pub created_at: i64,
244    pub sse_specification: Option<&'a str>,
245    pub table_class: Option<&'a str>,
246    pub deletion_protection_enabled: bool,
247    pub billing_mode: Option<&'a str>,
248    pub on_demand_throughput: Option<&'a str>,
249    pub vector_index_definitions: Option<&'a str>,
250}
251
252/// Parameters for query operations (base table or GSI).
253#[derive(Debug, Default)]
254pub struct QueryParams<'a> {
255    pub sk_condition: Option<&'a str>,
256    pub sk_params: &'a [&'a str],
257    pub forward: bool,
258    pub limit: Option<usize>,
259    pub exclusive_start_sk: Option<&'a str>,
260    /// For LSI pagination: base table PK for composite cursor.
261    pub exclusive_start_base_pk: Option<&'a str>,
262    /// For LSI pagination: base table SK for composite cursor.
263    pub exclusive_start_base_sk: Option<&'a str>,
264}
265
266/// Low-level SQLite storage layer.
267///
268/// Manages the SQLite connection, metadata tables, and per-DynamoDB-table
269/// data tables. All SQL lives here: higher layers work with Rust types.
270///
271/// Native-only: this type is the rusqlite-backed backend and is compiled out
272/// of backend-neutral builds (for example `wasm-sqlite`).
273#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
274pub struct Storage {
275    conn: Connection,
276    /// In-memory cache of table metadata to avoid repeated SQLite reads.
277    /// Safe to use `RefCell` because `Storage` is always behind `Arc<Mutex<>>`.
278    metadata_cache: RefCell<HashMap<String, TableMetadata>>,
279    /// Wall-clock used by stream / TTL paths. Defaults to [`SystemClock`].
280    clock: Arc<dyn Clock>,
281    /// Countdown to a forced GSI insert failure, for tests that need a write
282    /// to fail part way through its index fan-out. `None` never fires. Compiled
283    /// out of every build but the crate's own unit tests.
284    #[cfg(test)]
285    gsi_insert_fault: RefCell<Option<u32>>,
286    /// Countdown to a forced failure rewriting one row's stored size, for
287    /// tests that need a size recompute to fail part way through. `None` never
288    /// fires. Compiled out of every build but the crate's own unit tests.
289    #[cfg(test)]
290    item_size_fault: RefCell<Option<u32>>,
291}
292
293#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
294impl Storage {
295    /// Open a persistent database at the given path.
296    pub fn new(path: &str) -> Result<Self> {
297        let conn = Connection::open(path)?;
298        conn.busy_timeout(OPEN_BUSY_TIMEOUT)?;
299        let mut storage = Self {
300            conn,
301            metadata_cache: RefCell::new(HashMap::new()),
302            #[cfg(test)]
303            gsi_insert_fault: RefCell::new(None),
304            #[cfg(test)]
305            item_size_fault: RefCell::new(None),
306            clock: Arc::new(SystemClock),
307        };
308        storage.initialize().map_err(Self::maybe_encrypted_error)?;
309        storage.conn.busy_timeout(REQUEST_BUSY_TIMEOUT)?;
310        Ok(storage)
311    }
312
313    /// Let `successes` GSI inserts through, then fail every one after that, so
314    /// a write fails part way through its index fan-out. Nothing else can
315    /// produce that: a fan-out either runs to completion or the whole write
316    /// was rejected before it started. Pass 0 to fail the first insert.
317    #[cfg(test)]
318    pub(crate) fn fail_gsi_insert_after(&self, successes: u32) {
319        *self.gsi_insert_fault.borrow_mut() = Some(successes);
320    }
321
322    /// Consume one step of the armed countdown, erroring when it reaches zero.
323    #[cfg(test)]
324    pub(crate) fn check_gsi_insert_fault(&self) -> Result<()> {
325        Self::count_down(&self.gsi_insert_fault, "injected GSI insert failure")
326    }
327
328    #[cfg(test)]
329    fn count_down(fault: &RefCell<Option<u32>>, message: &str) -> Result<()> {
330        let mut fault = fault.borrow_mut();
331        match fault.as_mut() {
332            Some(0) => Err(crate::errors::DynoxideError::InternalServerError(
333                message.to_string(),
334            )),
335            Some(remaining) => {
336                *remaining -= 1;
337                Ok(())
338            }
339            None => Ok(()),
340        }
341    }
342
343    /// Replace the [`Clock`] used by the stream and TTL paths. Returns `self`
344    /// so callers can chain construction (`Storage::memory()?.with_clock(c)`).
345    ///
346    /// Default for every constructor is [`SystemClock`]. Tests use this to
347    /// inject a `ManualClock`.
348    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
349        self.clock = clock;
350        self
351    }
352
353    /// Borrow the active [`Clock`]. Used by stream and TTL paths.
354    pub(crate) fn clock(&self) -> &dyn Clock {
355        self.clock.as_ref()
356    }
357
358    /// If a SQLite error is SQLITE_NOTADB, return a clearer error message
359    /// suggesting the database may be encrypted.
360    fn maybe_encrypted_error(err: DynoxideError) -> DynoxideError {
361        if let DynoxideError::SqliteError(ref sqlite_err) = err
362            && let Some(rusqlite::ErrorCode::NotADatabase) = sqlite_err.sqlite_error_code()
363        {
364            return DynoxideError::InternalServerError(
365                "Database file is encrypted or not a valid SQLite database. \
366                     If encrypted, enable the `encryption` or `encryption-cc` feature \
367                     and use Database::new_encrypted() with the correct key."
368                    .to_string(),
369            );
370        }
371        err
372    }
373
374    /// Open or create an encrypted persistent database at the given path.
375    ///
376    /// The key is passed to SQLCipher via `PRAGMA key`. The database file is
377    /// encrypted at rest using AES-256-CBC. A database opened without calling
378    /// `PRAGMA key` is treated as a normal unencrypted database by SQLCipher.
379    #[cfg(feature = "_has-encryption")]
380    pub fn new_encrypted(path: &str, key: &str) -> Result<Self> {
381        use zeroize::Zeroize;
382
383        let conn = Connection::open(path)?;
384        conn.busy_timeout(OPEN_BUSY_TIMEOUT)?;
385        // Safety: key is validated to be exactly 64 hex characters [0-9a-fA-F]
386        // by Database::new_encrypted(), so no injection is possible in the
387        // x'...' hex literal format. Note: pragma_update does NOT use parameter
388        // binding for the PRAGMA value: hex validation is the sole injection defense.
389        let mut pragma_val = format!("x'{key}'");
390        conn.pragma_update(None, "key", &pragma_val)?;
391        pragma_val.zeroize();
392        // Verify the key works by reading from the database
393        conn.execute_batch("SELECT count(*) FROM sqlite_master;")?;
394        let mut storage = Self {
395            conn,
396            metadata_cache: RefCell::new(HashMap::new()),
397            #[cfg(test)]
398            gsi_insert_fault: RefCell::new(None),
399            #[cfg(test)]
400            item_size_fault: RefCell::new(None),
401            clock: Arc::new(SystemClock),
402        };
403        storage.initialize()?;
404        storage.conn.busy_timeout(REQUEST_BUSY_TIMEOUT)?;
405        Ok(storage)
406    }
407
408    /// Open an in-memory database (for tests and ephemeral use).
409    pub fn memory() -> Result<Self> {
410        let conn = Connection::open_in_memory()?;
411        let mut storage = Self {
412            conn,
413            metadata_cache: RefCell::new(HashMap::new()),
414            #[cfg(test)]
415            gsi_insert_fault: RefCell::new(None),
416            #[cfg(test)]
417            item_size_fault: RefCell::new(None),
418            clock: Arc::new(SystemClock),
419        };
420        storage.initialize()?;
421        Ok(storage)
422    }
423
424    /// Initialize the database: WAL mode, metadata tables, config.
425    fn initialize(&mut self) -> Result<()> {
426        // Ahead of everything here that writes, the journal mode included, so
427        // a file a newer build wrote is refused as it was found.
428        refuse_newer_schema(&self.conn)?;
429        // Enable WAL mode for better concurrency
430        self.conn.pragma_update(None, "journal_mode", "WAL")?;
431        Self::register_functions(&self.conn)?;
432        self.migrate_to_current()?;
433        #[cfg(test)]
434        OPENED_WITH_BUSY_TIMEOUT_MS.set(Some(self.conn.query_row(
435            "PRAGMA busy_timeout",
436            [],
437            |r| r.get(0),
438        )?));
439        Ok(())
440    }
441
442    /// Register the SQL functions every statement this layer runs may call.
443    fn register_functions(conn: &Connection) -> Result<()> {
444        // Register FNV-1a hash function for parallel scan segment assignment.
445        // Uses get_raw() to borrow directly from SQLite's buffer (zero-copy).
446        conn.create_scalar_function(
447            "fnv1a_hash",
448            1,
449            rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC
450                | rusqlite::functions::FunctionFlags::SQLITE_UTF8,
451            |ctx: &rusqlite::functions::Context| -> rusqlite::Result<i64> {
452                let pk_ref = ctx.get_raw(0);
453                let pk_bytes = match pk_ref {
454                    rusqlite::types::ValueRef::Text(bytes) => bytes,
455                    _ => {
456                        return Err(rusqlite::Error::InvalidFunctionParameterType(
457                            0,
458                            rusqlite::types::Type::Text,
459                        ));
460                    }
461                };
462                let mut hash: u32 = 2166136261;
463                for &byte in pk_bytes {
464                    hash ^= byte as u32;
465                    hash = hash.wrapping_mul(16777619);
466                }
467                Ok(hash as i64)
468            },
469        )?;
470        Ok(())
471    }
472
473    /// Create the metadata tables if they are missing and run every migration
474    /// the recorded schema version calls for. Runs on open, and on the scratch
475    /// copy a restore brings up to date before it replaces this database.
476    fn migrate_to_current(&self) -> Result<()> {
477        // A database with tables and no recorded version, which the browser
478        // build wrote from Dynoxide 0.10 to 0.13, or one whose version isn't a
479        // number, runs every step. Each is safe on a database that already
480        // has what it adds: a column already there is left alone, an index is
481        // created only where it is missing, only a table without an id is
482        // given one, and the steps from version 10 on rebuild from the base
483        // table.
484        let version = self.create_metadata_tables()?.unwrap_or(1);
485
486        if version < 2 {
487            self.migrate_v1_to_v2()?;
488        }
489        if version < 3 {
490            self.migrate_v2_to_v3()?;
491        }
492        if version < 4 {
493            self.migrate_v3_to_v4()?;
494        }
495        if version < 5 {
496            self.migrate_v4_to_v5()?;
497        }
498        if version < 6 {
499            self.migrate_v5_to_v6()?;
500        }
501        if version < 7 {
502            self.migrate_v6_to_v7()?;
503        }
504        if version < 8 {
505            self.migrate_v7_to_v8()?;
506        }
507        if version < 9 {
508            self.migrate_v8_to_v9()?;
509        }
510        // The steps from version 10 on are shared with the wasm backend and run
511        // each in its own write transaction. The native futures never suspend,
512        // so `block_on` returns as soon as they do.
513        pollster::block_on(crate::actions::schema_migrations::run_open_migrations(
514            self,
515            crate::storage_backend::schema::open_migrations(Some(version)),
516        ))
517    }
518
519    /// Create the metadata tables if they are missing and, on a new database,
520    /// record this build's version, in one write transaction, so another
521    /// process opening the same file never finds the tables without the
522    /// version. Returns the version recorded once that is done.
523    fn create_metadata_tables(&self) -> Result<Option<u32>> {
524        let transaction = rusqlite::Transaction::new_unchecked(
525            &self.conn,
526            rusqlite::TransactionBehavior::Immediate,
527        )?;
528        let new_database = !table_exists(&self.conn, "_tables")?;
529        // The metadata tables, shared with the wasm backend.
530        self.conn.execute_batch(sql_builders::INIT_SCHEMA)?;
531        // A stream record table made before records carried a user identity
532        // gains the column; one that has it fails the ALTER harmlessly.
533        let _ = self
534            .conn
535            .execute_batch("ALTER TABLE _stream_records ADD COLUMN user_identity TEXT");
536        if new_database {
537            let (sql, params) = sql_builders::init_schema_version(SCHEMA_VERSION);
538            self.conn
539                .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
540        }
541        let recorded = self.recorded_schema_version()?;
542        transaction.commit()?;
543        Ok(recorded)
544    }
545
546    /// Migrate from schema v1 to v2: add `cached_at REAL` column to all data tables.
547    fn migrate_v1_to_v2(&self) -> Result<()> {
548        let mut stmt = self.conn.prepare("SELECT table_name FROM _tables")?;
549        let table_names: Vec<String> = stmt
550            .query_map([], |row| row.get(0))?
551            .collect::<std::result::Result<Vec<_>, _>>()?;
552
553        for table_name in &table_names {
554            let escaped = format!("\"{}\"", table_name.replace('"', "\"\""));
555            let _ = self.conn.execute(
556                &format!("ALTER TABLE {escaped} ADD COLUMN cached_at REAL"),
557                [],
558            );
559        }
560
561        self.conn.execute(
562            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '2')",
563            [],
564        )?;
565
566        Ok(())
567    }
568
569    /// Migrate from schema v2 to v3: add `tags TEXT` column to `_tables`.
570    fn migrate_v2_to_v3(&self) -> Result<()> {
571        let _ = self
572            .conn
573            .execute("ALTER TABLE _tables ADD COLUMN tags TEXT", []);
574
575        self.conn.execute(
576            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '3')",
577            [],
578        )?;
579
580        Ok(())
581    }
582
583    /// Migrate from schema v3 to v4: add SSE, table class, and deletion protection columns.
584    fn migrate_v3_to_v4(&self) -> Result<()> {
585        let _ = self
586            .conn
587            .execute("ALTER TABLE _tables ADD COLUMN sse_specification TEXT", []);
588        let _ = self
589            .conn
590            .execute("ALTER TABLE _tables ADD COLUMN table_class TEXT", []);
591        let _ = self.conn.execute(
592            "ALTER TABLE _tables ADD COLUMN deletion_protection_enabled INTEGER DEFAULT 0",
593            [],
594        );
595
596        self.conn.execute(
597            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '4')",
598            [],
599        )?;
600
601        Ok(())
602    }
603
604    /// Migrate from schema v4 to v5: add secondary indexes on GSI and LSI base-key columns.
605    fn migrate_v4_to_v5(&self) -> Result<()> {
606        let mut stmt = self
607            .conn
608            .prepare("SELECT table_name, gsi_definitions, lsi_definitions FROM _tables")?;
609        let tables: Vec<(String, Option<String>, Option<String>)> = stmt
610            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
611            .collect::<std::result::Result<Vec<_>, _>>()?;
612
613        for (table_name, gsi_json, lsi_json) in &tables {
614            if let Some(json) = gsi_json
615                && let Ok(gsis) = serde_json::from_str::<Vec<serde_json::Value>>(json)
616            {
617                for gsi in &gsis {
618                    if let Some(idx) = gsi.get("IndexName").and_then(|v| v.as_str()) {
619                        let gsi_table = escape_table_name(&format!("{table_name}::gsi::{idx}"));
620                        let idx_name =
621                            escape_table_name(&format!("{table_name}::gsi::{idx}::base_key"));
622                        let _ = self.conn.execute_batch(&format!(
623                                "CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{gsi_table}\" (table_pk, table_sk)"
624                            ));
625                    }
626                }
627            }
628            if let Some(json) = lsi_json
629                && let Ok(lsis) = serde_json::from_str::<Vec<serde_json::Value>>(json)
630            {
631                for lsi in &lsis {
632                    if let Some(idx) = lsi.get("IndexName").and_then(|v| v.as_str()) {
633                        let lsi_table = escape_table_name(&format!("{table_name}::lsi::{idx}"));
634                        let idx_name =
635                            escape_table_name(&format!("{table_name}::lsi::{idx}::base_key"));
636                        let _ = self.conn.execute_batch(&format!(
637                                "CREATE INDEX IF NOT EXISTS \"{idx_name}\" ON \"{lsi_table}\" (base_pk, base_sk)"
638                            ));
639                    }
640                }
641            }
642        }
643
644        self.conn.execute(
645            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '5')",
646            [],
647        )?;
648
649        Ok(())
650    }
651
652    /// Migrate from schema v5 to v6: add `hash_prefix TEXT` column to all data tables.
653    fn migrate_v5_to_v6(&self) -> Result<()> {
654        let mut stmt = self.conn.prepare("SELECT table_name FROM _tables")?;
655        let table_names: Vec<String> = stmt
656            .query_map([], |row| row.get(0))?
657            .collect::<std::result::Result<Vec<_>, _>>()?;
658
659        for table_name in &table_names {
660            let escaped = escape_table_name(table_name);
661            let _ = self.conn.execute(
662                &format!(
663                    "ALTER TABLE \"{escaped}\" ADD COLUMN hash_prefix TEXT NOT NULL DEFAULT ''"
664                ),
665                [],
666            );
667        }
668
669        self.conn.execute(
670            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '6')",
671            [],
672        )?;
673
674        Ok(())
675    }
676
677    /// Migrate from schema v6 to v7: add `on_demand_throughput TEXT` column to `_tables`.
678    fn migrate_v6_to_v7(&self) -> Result<()> {
679        let _ = self.conn.execute(
680            "ALTER TABLE _tables ADD COLUMN on_demand_throughput TEXT",
681            [],
682        );
683
684        self.conn.execute(
685            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '7')",
686            [],
687        )?;
688
689        Ok(())
690    }
691
692    /// Migrate from schema v7 to v8: add a `table_id TEXT` column to `_tables`
693    /// and backfill existing tables with a one-time random UUID.
694    ///
695    /// TableId must stay stable between reads of the same table (#55). New
696    /// tables get their id at insert time; tables created before this column
697    /// existed are assigned a persisted id here, once. The backfill `SELECT`
698    /// references `table_id`, so if the `ALTER` did not actually add the column
699    /// (a genuine failure, swallowed below for the duplicate-column re-run case)
700    /// this step errors out before the version is stamped, rather than stamping
701    /// a half-applied migration.
702    fn migrate_v7_to_v8(&self) -> Result<()> {
703        let _ = self
704            .conn
705            .execute("ALTER TABLE _tables ADD COLUMN table_id TEXT", []);
706
707        let names: Vec<String> = {
708            let mut stmt = self
709                .conn
710                .prepare("SELECT table_name FROM _tables WHERE table_id IS NULL")?;
711            let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
712            rows.collect::<rusqlite::Result<Vec<String>>>()?
713        };
714        for name in names {
715            let id = uuid::Uuid::new_v4().to_string();
716            self.conn.execute(
717                "UPDATE _tables SET table_id = ?1 WHERE table_name = ?2",
718                params![id, name],
719            )?;
720        }
721
722        self.conn.execute(
723            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '8')",
724            [],
725        )?;
726
727        Ok(())
728    }
729
730    /// Migrate from schema v8 to v9: add a `vector_index_definitions TEXT`
731    /// column to `_tables`.
732    ///
733    /// Only the duplicate-column error (a re-run over a half-applied
734    /// migration whose version stamp did not land) is tolerated; any other
735    /// failure propagates rather than stamping a version whose column is
736    /// silently absent.
737    fn migrate_v8_to_v9(&self) -> Result<()> {
738        if let Err(e) = self
739            .conn
740            .execute(sql_builders::ADD_VECTOR_INDEX_DEFINITIONS_COLUMN, [])
741            && !sql_builders::is_duplicate_column_error(&e.to_string())
742        {
743            return Err(e.into());
744        }
745
746        self.conn.execute(
747            "INSERT OR REPLACE INTO _config (key, value) VALUES ('schema_version', '9')",
748            [],
749        )?;
750
751        Ok(())
752    }
753
754    /// Get a reference to the underlying connection (for transactions, etc.).
755    pub fn conn(&self) -> &Connection {
756        &self.conn
757    }
758
759    /// Get a mutable reference to the underlying connection.
760    pub fn conn_mut(&mut self) -> &mut Connection {
761        &mut self.conn
762    }
763
764    // -----------------------------------------------------------------------
765    // Table metadata
766    // -----------------------------------------------------------------------
767
768    /// Insert a row into the `_tables` metadata table.
769    pub fn insert_table_metadata(&self, m: &CreateTableMetadata) -> Result<()> {
770        let table_name = m.table_name;
771        let (sql, params) = sql_builders::insert_table_metadata(m);
772        self.conn
773            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
774        self.metadata_cache.borrow_mut().remove(table_name);
775        Ok(())
776    }
777
778    /// Get metadata for a table. Returns None if the table doesn't exist.
779    ///
780    /// Results are cached in memory. The cache is invalidated when metadata
781    /// is modified via `insert_table_metadata`, `delete_table_metadata`,
782    /// `enable_stream`, or `update_ttl_config`.
783    pub fn get_table_metadata(&self, table_name: &str) -> Result<Option<TableMetadata>> {
784        // Check cache first
785        if let Some(cached) = self.metadata_cache.borrow().get(table_name) {
786            return Ok(Some(cached.clone()));
787        }
788
789        let (sql, params) = sql_builders::get_table_metadata(table_name);
790        let mut stmt = self.conn.prepare(&sql)?;
791
792        let result = stmt.query_row(rusqlite::params_from_iter(params.iter()), row_to_metadata);
793
794        match result {
795            Ok(meta) => {
796                self.metadata_cache
797                    .borrow_mut()
798                    .insert(table_name.to_string(), meta.clone());
799                Ok(Some(meta))
800            }
801            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
802            Err(e) => Err(DynoxideError::from(e)),
803        }
804    }
805
806    /// Delete metadata for a table.
807    pub fn delete_table_metadata(&self, table_name: &str) -> Result<bool> {
808        let (sql, params) = sql_builders::delete_table_metadata(table_name);
809        let affected = self
810            .conn
811            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
812        self.metadata_cache.borrow_mut().remove(table_name);
813        Ok(affected > 0)
814    }
815
816    /// Update attribute definitions and GSI definitions for a table.
817    pub fn update_table_metadata(
818        &self,
819        table_name: &str,
820        attribute_definitions: &str,
821        gsi_definitions: Option<&str>,
822    ) -> Result<()> {
823        let (sql, params) =
824            sql_builders::update_table_metadata(table_name, attribute_definitions, gsi_definitions);
825        self.conn
826            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
827        self.metadata_cache.borrow_mut().remove(table_name);
828        Ok(())
829    }
830
831    /// Update vector index definitions for a table; `None` clears the column.
832    pub fn update_vector_index_definitions(
833        &self,
834        table_name: &str,
835        vector_index_definitions: Option<&str>,
836    ) -> Result<()> {
837        let (sql, params) =
838            sql_builders::update_vector_index_definitions(table_name, vector_index_definitions);
839        self.conn
840            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
841        self.metadata_cache.borrow_mut().remove(table_name);
842        Ok(())
843    }
844
845    /// Update provisioned throughput for a table.
846    pub fn update_provisioned_throughput(
847        &self,
848        table_name: &str,
849        provisioned_throughput: &str,
850    ) -> Result<()> {
851        let (sql, params) =
852            sql_builders::update_provisioned_throughput(table_name, provisioned_throughput);
853        self.conn
854            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
855        self.metadata_cache.borrow_mut().remove(table_name);
856        Ok(())
857    }
858
859    /// Clear provisioned throughput for a table (sets to SQL NULL).
860    pub fn clear_provisioned_throughput(&self, table_name: &str) -> Result<()> {
861        let (sql, params) = sql_builders::clear_provisioned_throughput(table_name);
862        self.conn
863            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
864        self.metadata_cache.borrow_mut().remove(table_name);
865        Ok(())
866    }
867
868    /// Clear on-demand throughput ceilings for a table (sets to SQL NULL).
869    pub fn clear_on_demand_throughput(&self, table_name: &str) -> Result<()> {
870        let (sql, params) = sql_builders::clear_on_demand_throughput(table_name);
871        self.conn
872            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
873        self.metadata_cache.borrow_mut().remove(table_name);
874        Ok(())
875    }
876
877    /// Update billing mode for a table.
878    pub fn update_billing_mode(&self, table_name: &str, billing_mode: &str) -> Result<()> {
879        let (sql, params) = sql_builders::update_billing_mode(table_name, billing_mode);
880        self.conn
881            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
882        self.metadata_cache.borrow_mut().remove(table_name);
883        Ok(())
884    }
885
886    /// Update the table class for a table.
887    pub fn update_table_class(&self, table_name: &str, table_class: &str) -> Result<()> {
888        let (sql, params) = sql_builders::update_table_class(table_name, table_class);
889        self.conn
890            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
891        self.metadata_cache.borrow_mut().remove(table_name);
892        Ok(())
893    }
894
895    /// Update the on-demand throughput (stored as JSON) for a table.
896    pub fn update_on_demand_throughput(
897        &self,
898        table_name: &str,
899        on_demand_throughput: &str,
900    ) -> Result<()> {
901        let (sql, params) =
902            sql_builders::update_on_demand_throughput(table_name, on_demand_throughput);
903        self.conn
904            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
905        self.metadata_cache.borrow_mut().remove(table_name);
906        Ok(())
907    }
908
909    // -----------------------------------------------------------------------
910    // Tag operations
911    // -----------------------------------------------------------------------
912
913    /// Get tags for a table.
914    pub fn get_tags(&self, table_name: &str) -> Result<Vec<crate::types::Tag>> {
915        let tags_json: Option<String> = self.conn.query_row(
916            "SELECT tags FROM _tables WHERE table_name = ?1",
917            params![table_name],
918            |row| row.get(0),
919        )?;
920
921        match tags_json {
922            Some(json) => serde_json::from_str(&json)
923                .map_err(|e| DynoxideError::InternalServerError(format!("Bad tags JSON: {e}"))),
924            None => Ok(Vec::new()),
925        }
926    }
927
928    /// Set (merge) tags on a table. New keys overwrite existing keys.
929    pub fn set_tags(&self, table_name: &str, new_tags: &[crate::types::Tag]) -> Result<()> {
930        use std::collections::BTreeMap;
931
932        let existing = self.get_tags(table_name)?;
933        let mut tag_map: BTreeMap<String, String> =
934            existing.into_iter().map(|t| (t.key, t.value)).collect();
935
936        for tag in new_tags {
937            tag_map.insert(tag.key.clone(), tag.value.clone());
938        }
939
940        if tag_map.len() > 50 {
941            return Err(DynoxideError::ValidationException(
942                "One or more parameter values were invalid: \
943                 Too many tags: tag limit is 50"
944                    .to_string(),
945            ));
946        }
947
948        let merged: Vec<crate::types::Tag> = tag_map
949            .into_iter()
950            .map(|(k, v)| crate::types::Tag { key: k, value: v })
951            .collect();
952
953        let json = serde_json::to_string(&merged)
954            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
955
956        self.conn.execute(
957            "UPDATE _tables SET tags = ?1 WHERE table_name = ?2",
958            params![json, table_name],
959        )?;
960        Ok(())
961    }
962
963    /// Update the deletion protection setting for a table.
964    pub fn update_deletion_protection(&self, table_name: &str, enabled: bool) -> Result<()> {
965        let (sql, params) = sql_builders::update_deletion_protection(table_name, enabled);
966        self.conn
967            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
968        self.metadata_cache.borrow_mut().remove(table_name);
969        Ok(())
970    }
971
972    /// Remove tags by key from a table.
973    pub fn remove_tags(&self, table_name: &str, keys: &[String]) -> Result<()> {
974        let mut tags = self.get_tags(table_name)?;
975        tags.retain(|t| !keys.contains(&t.key));
976
977        let json = if tags.is_empty() {
978            None
979        } else {
980            Some(
981                serde_json::to_string(&tags)
982                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?,
983            )
984        };
985
986        self.conn.execute(
987            "UPDATE _tables SET tags = ?1 WHERE table_name = ?2",
988            params![json, table_name],
989        )?;
990        Ok(())
991    }
992
993    /// List all table names.
994    pub fn list_table_names(&self) -> Result<Vec<String>> {
995        let (sql, params) = sql_builders::list_table_names();
996        let mut stmt = self.conn.prepare(&sql)?;
997        let names = stmt
998            .query_map(rusqlite::params_from_iter(params.iter()), |row| row.get(0))?
999            .collect::<std::result::Result<Vec<String>, _>>()?;
1000        Ok(names)
1001    }
1002
1003    /// Check if a table exists in metadata.
1004    pub fn table_exists(&self, table_name: &str) -> Result<bool> {
1005        let (sql, params) = sql_builders::table_exists(table_name);
1006        let count: i32 =
1007            self.conn
1008                .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1009                    row.get(0)
1010                })?;
1011        Ok(count > 0)
1012    }
1013
1014    /// Invalidate the cached metadata for a specific table.
1015    #[allow(dead_code)]
1016    pub(crate) fn invalidate_metadata_cache(&self, table_name: &str) {
1017        self.metadata_cache.borrow_mut().remove(table_name);
1018    }
1019
1020    // -----------------------------------------------------------------------
1021    // Dynamic data tables
1022    // -----------------------------------------------------------------------
1023
1024    /// Create a data table for a DynamoDB table.
1025    pub fn create_data_table(&self, table_name: &str) -> Result<()> {
1026        let (sql, params) = sql_builders::create_data_table(table_name);
1027        self.conn
1028            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1029        Ok(())
1030    }
1031
1032    /// Drop a data table.
1033    pub fn drop_data_table(&self, table_name: &str) -> Result<()> {
1034        let (sql, params) = sql_builders::drop_data_table(table_name);
1035        self.conn
1036            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1037        Ok(())
1038    }
1039
1040    /// Create a GSI table.
1041    pub fn create_gsi_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1042        let (sql, _) = sql_builders::create_gsi_table(table_name, index_name);
1043        self.conn.execute_batch(&sql)?;
1044        Ok(())
1045    }
1046
1047    /// Drop a GSI table.
1048    pub fn drop_gsi_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1049        let (sql, params) = sql_builders::drop_gsi_table(table_name, index_name);
1050        self.conn
1051            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1052        Ok(())
1053    }
1054
1055    // -----------------------------------------------------------------------
1056    // GSI item operations
1057    // -----------------------------------------------------------------------
1058
1059    /// Insert an item into a GSI table.
1060    #[allow(clippy::too_many_arguments)]
1061    pub fn insert_gsi_item(
1062        &self,
1063        table_name: &str,
1064        index_name: &str,
1065        gsi_pk: &str,
1066        gsi_sk: &str,
1067        table_pk: &str,
1068        table_sk: &str,
1069        item_json: &str,
1070    ) -> Result<()> {
1071        let sql = sql_builders::gsi_insert_sql(table_name, index_name);
1072        let params = sql_builders::gsi_insert_params(gsi_pk, gsi_sk, table_pk, table_sk, item_json);
1073        self.conn
1074            .prepare_cached(&sql)?
1075            .execute(rusqlite::params_from_iter(params.iter()))?;
1076        Ok(())
1077    }
1078
1079    /// Bulk-insert many rows into one GSI table using a single cached
1080    /// prepared statement. Equivalent to calling [`Self::insert_gsi_item`]
1081    /// once per row, but reuses the statement across the batch.
1082    pub fn insert_gsi_items(
1083        &self,
1084        table_name: &str,
1085        index_name: &str,
1086        rows: &[crate::storage_backend::GsiItemRow],
1087    ) -> Result<()> {
1088        let sql = sql_builders::gsi_insert_sql(table_name, index_name);
1089        let mut stmt = self.conn.prepare_cached(&sql)?;
1090        for row in rows {
1091            let params = sql_builders::gsi_insert_params(
1092                &row.gsi_pk,
1093                &row.gsi_sk,
1094                &row.table_pk,
1095                &row.table_sk,
1096                &row.item_json,
1097            );
1098            stmt.execute(rusqlite::params_from_iter(params.iter()))?;
1099        }
1100        Ok(())
1101    }
1102
1103    /// Delete an item from a GSI table by base table primary key.
1104    pub fn delete_gsi_item(
1105        &self,
1106        table_name: &str,
1107        index_name: &str,
1108        table_pk: &str,
1109        table_sk: &str,
1110    ) -> Result<()> {
1111        let (sql, params) =
1112            sql_builders::delete_gsi_item(table_name, index_name, table_pk, table_sk);
1113        self.conn
1114            .prepare_cached(&sql)?
1115            .execute(rusqlite::params_from_iter(params.iter()))?;
1116        Ok(())
1117    }
1118
1119    /// Query items from a GSI table.
1120    pub fn query_gsi_items(
1121        &self,
1122        table_name: &str,
1123        index_name: &str,
1124        gsi_pk: &str,
1125        params: &QueryParams,
1126    ) -> Result<Vec<(String, String, String)>> {
1127        let (sql, params_vec) =
1128            sql_builders::query_gsi_items(table_name, index_name, gsi_pk, params);
1129        let mut stmt = self.conn.prepare(&sql)?;
1130        let rows = stmt
1131            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1132                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1133            })?
1134            .collect::<std::result::Result<Vec<_>, _>>()?;
1135
1136        Ok(rows)
1137    }
1138
1139    /// Scan all items from a GSI table.
1140    pub fn scan_gsi_items(
1141        &self,
1142        table_name: &str,
1143        index_name: &str,
1144        params: &ScanParams,
1145    ) -> Result<Vec<(String, String, String)>> {
1146        let (sql, params_vec) = sql_builders::scan_gsi_items(table_name, index_name, params);
1147        let mut stmt = self.conn.prepare(&sql)?;
1148        let rows = stmt
1149            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1150                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1151            })?
1152            .collect::<std::result::Result<Vec<_>, _>>()?;
1153
1154        Ok(rows)
1155    }
1156
1157    // -----------------------------------------------------------------------
1158    // LSI table operations
1159    // -----------------------------------------------------------------------
1160
1161    /// Create an LSI table for a given base table and index name.
1162    pub fn create_lsi_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1163        let (sql, _) = sql_builders::create_lsi_table(table_name, index_name);
1164        self.conn.execute_batch(&sql)?;
1165        Ok(())
1166    }
1167
1168    /// Create a vector index shadow table.
1169    pub fn create_vector_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1170        let (sql, _) = sql_builders::create_vector_table(table_name, index_name);
1171        self.conn.execute_batch(&sql)?;
1172        Ok(())
1173    }
1174
1175    /// Bulk-insert many rows into one vector shadow table using a single
1176    /// cached prepared statement, mirroring [`Self::insert_gsi_items`].
1177    pub fn insert_vector_items(
1178        &self,
1179        table_name: &str,
1180        index_name: &str,
1181        rows: &[crate::storage_backend::VectorItemRow],
1182    ) -> Result<()> {
1183        let sql = sql_builders::vector_insert_sql(table_name, index_name);
1184        let mut stmt = self.conn.prepare_cached(&sql)?;
1185        for row in rows {
1186            let params = sql_builders::vector_insert_params(
1187                &row.table_pk,
1188                &row.table_sk,
1189                &row.hash_value,
1190                &row.vector_json,
1191                &row.filter_json,
1192                &row.item_json,
1193                row.entry_bytes,
1194            );
1195            stmt.execute(rusqlite::params_from_iter(params.iter()))?;
1196        }
1197        Ok(())
1198    }
1199
1200    /// Delete a vector shadow-table row by base table primary key, mirroring
1201    /// [`Self::delete_gsi_item`].
1202    pub fn delete_vector_item(
1203        &self,
1204        table_name: &str,
1205        index_name: &str,
1206        table_pk: &str,
1207        table_sk: &str,
1208    ) -> Result<()> {
1209        let (sql, params) =
1210            sql_builders::delete_vector_item(table_name, index_name, table_pk, table_sk);
1211        self.conn
1212            .prepare_cached(&sql)?
1213            .execute(rusqlite::params_from_iter(params.iter()))?;
1214        Ok(())
1215    }
1216
1217    /// Load vector shadow-table candidate rows for a search, optionally
1218    /// scoped to one SearchSchema HASH value. Ordered by base-table primary
1219    /// key (see the builder) so equal scores tie-break deterministically.
1220    pub fn query_vector_candidates(
1221        &self,
1222        table_name: &str,
1223        index_name: &str,
1224        hash_value: Option<&str>,
1225    ) -> Result<Vec<crate::storage_backend::VectorCandidateRow>> {
1226        let (sql, params) =
1227            sql_builders::query_vector_candidates(table_name, index_name, hash_value);
1228        let mut stmt = self.conn.prepare(&sql)?;
1229        let rows = stmt
1230            .query_map(rusqlite::params_from_iter(params.iter()), |row| {
1231                Ok(crate::storage_backend::VectorCandidateRow {
1232                    table_pk: row.get(0)?,
1233                    table_sk: row.get(1)?,
1234                    vector_json: row.get(2)?,
1235                    filter_json: row.get(3)?,
1236                    entry_bytes: row.get(4)?,
1237                })
1238            })?
1239            .collect::<std::result::Result<Vec<_>, _>>()?;
1240        Ok(rows)
1241    }
1242
1243    /// Load the projected entries for the keys a search returns.
1244    pub fn vector_items_for_keys(
1245        &self,
1246        table_name: &str,
1247        index_name: &str,
1248        keys: &[(String, String)],
1249    ) -> Result<Vec<(String, String, String)>> {
1250        if keys.is_empty() {
1251            return Ok(Vec::new());
1252        }
1253        let (sql, params) = sql_builders::vector_items_for_keys(table_name, index_name, keys);
1254        let mut stmt = self.conn.prepare(&sql)?;
1255        let rows = stmt
1256            .query_map(rusqlite::params_from_iter(params.iter()), |row| {
1257                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1258            })?
1259            .collect::<std::result::Result<Vec<_>, _>>()?;
1260        Ok(rows)
1261    }
1262
1263    /// Drop a vector index shadow table.
1264    pub fn drop_vector_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1265        let (sql, params) = sql_builders::drop_vector_table(table_name, index_name);
1266        self.conn
1267            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1268        Ok(())
1269    }
1270
1271    /// Drop an LSI table.
1272    pub fn drop_lsi_table(&self, table_name: &str, index_name: &str) -> Result<()> {
1273        let (sql, params) = sql_builders::drop_lsi_table(table_name, index_name);
1274        self.conn
1275            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1276        Ok(())
1277    }
1278
1279    // -----------------------------------------------------------------------
1280    // LSI item operations
1281    // -----------------------------------------------------------------------
1282
1283    /// Insert an item into an LSI table.
1284    #[allow(clippy::too_many_arguments)]
1285    pub fn insert_lsi_item(
1286        &self,
1287        table_name: &str,
1288        index_name: &str,
1289        pk: &str,
1290        sk: &str,
1291        base_pk: &str,
1292        base_sk: &str,
1293        item_json: &str,
1294    ) -> Result<()> {
1295        let sql = sql_builders::lsi_insert_sql(table_name, index_name);
1296        let params = sql_builders::lsi_insert_params(pk, sk, base_pk, base_sk, item_json);
1297        self.conn
1298            .prepare_cached(&sql)?
1299            .execute(rusqlite::params_from_iter(params.iter()))?;
1300        Ok(())
1301    }
1302
1303    /// Delete an item from an LSI table by base table primary key.
1304    pub fn delete_lsi_item(
1305        &self,
1306        table_name: &str,
1307        index_name: &str,
1308        base_pk: &str,
1309        base_sk: &str,
1310    ) -> Result<()> {
1311        let (sql, params) = sql_builders::delete_lsi_item(table_name, index_name, base_pk, base_sk);
1312        self.conn
1313            .prepare_cached(&sql)?
1314            .execute(rusqlite::params_from_iter(params.iter()))?;
1315        Ok(())
1316    }
1317
1318    /// Query items from an LSI table.
1319    pub fn query_lsi_items(
1320        &self,
1321        table_name: &str,
1322        index_name: &str,
1323        pk: &str,
1324        params: &QueryParams,
1325    ) -> Result<Vec<(String, String, String)>> {
1326        let (sql, params_vec) = sql_builders::query_lsi_items(table_name, index_name, pk, params);
1327        let mut stmt = self.conn.prepare(&sql)?;
1328        let rows = stmt
1329            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1330                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1331            })?
1332            .collect::<std::result::Result<Vec<_>, _>>()?;
1333
1334        Ok(rows)
1335    }
1336
1337    /// Scan all items from an LSI table.
1338    pub fn scan_lsi_items(
1339        &self,
1340        table_name: &str,
1341        index_name: &str,
1342        params: &ScanParams,
1343    ) -> Result<Vec<(String, String, String)>> {
1344        let (sql, params_vec) = sql_builders::scan_lsi_items(table_name, index_name, params);
1345        let mut stmt = self.conn.prepare(&sql)?;
1346        let rows = stmt
1347            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1348                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1349            })?
1350            .collect::<std::result::Result<Vec<_>, _>>()?;
1351
1352        Ok(rows)
1353    }
1354
1355    // -----------------------------------------------------------------------
1356    // Transaction support
1357    // -----------------------------------------------------------------------
1358
1359    /// Begin an immediate SQLite transaction.
1360    pub fn begin_transaction(&self) -> Result<()> {
1361        self.conn.execute_batch(sql_builders::BEGIN)?;
1362        Ok(())
1363    }
1364
1365    /// Commit the current transaction.
1366    pub fn commit(&self) -> Result<()> {
1367        self.conn.execute_batch(sql_builders::COMMIT)?;
1368        Ok(())
1369    }
1370
1371    /// Rollback the current transaction.
1372    pub fn rollback(&self) -> Result<()> {
1373        self.conn.execute_batch(sql_builders::ROLLBACK)?;
1374        Ok(())
1375    }
1376
1377    /// The schema version the database records, or `None` when it records
1378    /// none, or one that isn't a number.
1379    pub(crate) fn recorded_schema_version(&self) -> Result<Option<u32>> {
1380        use rusqlite::OptionalExtension;
1381        let value: Option<String> = self
1382            .conn
1383            .query_row(sql_builders::GET_SCHEMA_VERSION, [], |r| r.get(0))
1384            .optional()?;
1385        Ok(crate::storage_backend::schema::parse_recorded(
1386            value.as_deref(),
1387        ))
1388    }
1389
1390    /// Record `version` as the database's schema version, replacing any
1391    /// recorded one.
1392    pub(crate) fn record_schema_version(&self, version: u32) -> Result<()> {
1393        let (sql, params) = sql_builders::set_schema_version(version);
1394        self.conn
1395            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1396        Ok(())
1397    }
1398
1399    /// Set aggressive PRAGMAs for bulk loading.
1400    ///
1401    /// Disables fsync, increases cache, and enables memory-mapped I/O.
1402    /// Only safe when data loss on crash is acceptable (e.g., fresh import
1403    /// that can be re-run).
1404    pub fn enable_bulk_loading(&self) -> Result<()> {
1405        self.conn.execute_batch(
1406            "PRAGMA synchronous = OFF;
1407             PRAGMA cache_size = -64000;
1408             PRAGMA temp_store = MEMORY;
1409             PRAGMA mmap_size = 268435456;",
1410        )?;
1411        Ok(())
1412    }
1413
1414    /// Restore normal PRAGMAs after bulk loading.
1415    pub fn disable_bulk_loading(&self) -> Result<()> {
1416        self.conn.execute_batch(
1417            "PRAGMA synchronous = NORMAL;
1418             PRAGMA cache_size = -2000;
1419             PRAGMA temp_store = DEFAULT;
1420             PRAGMA mmap_size = 0;",
1421        )?;
1422        Ok(())
1423    }
1424
1425    // -----------------------------------------------------------------------
1426    // Item CRUD
1427    // -----------------------------------------------------------------------
1428
1429    /// Insert or replace an item.
1430    pub fn put_item(
1431        &self,
1432        table_name: &str,
1433        pk: &str,
1434        sk: &str,
1435        item_json: &str,
1436        item_size: usize,
1437    ) -> Result<Option<String>> {
1438        self.put_item_with_hash(table_name, pk, sk, item_json, item_size, "")
1439    }
1440
1441    /// Put an item with an explicit hash prefix for parallel scan ordering.
1442    pub fn put_item_with_hash(
1443        &self,
1444        table_name: &str,
1445        pk: &str,
1446        sk: &str,
1447        item_json: &str,
1448        item_size: usize,
1449        hash_prefix: &str,
1450    ) -> Result<Option<String>> {
1451        // First, try to get the old item for return value
1452        let old_item = self.get_item(table_name, pk, sk)?;
1453
1454        let (sql, params) =
1455            sql_builders::put_item_with_hash(table_name, pk, sk, item_json, item_size, hash_prefix);
1456        self.conn
1457            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1458
1459        Ok(old_item)
1460    }
1461
1462    /// Bulk-insert many base-table rows using a single cached prepared
1463    /// statement (`INSERT OR REPLACE`). Unlike [`Self::put_item_with_hash`],
1464    /// which preserves any existing `cached_at`, this writes `cached_at`
1465    /// verbatim from each row, matching the import flow's semantics.
1466    pub fn put_base_items(
1467        &self,
1468        table_name: &str,
1469        rows: &[crate::storage_backend::BaseItemRow],
1470    ) -> Result<()> {
1471        let escaped = escape_table_name(table_name);
1472        let sql = format!(
1473            "INSERT OR REPLACE INTO \"{escaped}\" (pk, sk, item_json, item_size, cached_at, hash_prefix) \
1474             VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1475        );
1476        let mut stmt = self.conn.prepare_cached(&sql)?;
1477        for row in rows {
1478            stmt.execute(params![
1479                row.pk,
1480                row.sk,
1481                row.item_json,
1482                row.item_size as i64,
1483                row.cached_at,
1484                row.hash_prefix
1485            ])?;
1486        }
1487        Ok(())
1488    }
1489
1490    /// Get a single item by primary key.
1491    pub fn get_item(&self, table_name: &str, pk: &str, sk: &str) -> Result<Option<String>> {
1492        let (sql, params) = sql_builders::get_item(table_name, pk, sk);
1493        let result = self
1494            .conn
1495            .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1496                row.get(0)
1497            });
1498
1499        match result {
1500            Ok(json) => Ok(Some(json)),
1501            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1502            Err(e) => Err(DynoxideError::from(e)),
1503        }
1504    }
1505
1506    /// Return the total item_size for all items sharing the given partition key.
1507    pub fn get_partition_size(&self, table_name: &str, pk: &str) -> Result<i64> {
1508        let (sql, params) = sql_builders::get_partition_size(table_name, pk);
1509        let size: i64 =
1510            self.conn
1511                .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1512                    row.get(0)
1513                })?;
1514        Ok(size)
1515    }
1516
1517    /// Return the total size of LSI items for a given partition key.
1518    /// LSI items are stored as JSON text, so we use length(item_json).
1519    pub fn get_lsi_partition_size(
1520        &self,
1521        table_name: &str,
1522        index_name: &str,
1523        pk: &str,
1524    ) -> Result<i64> {
1525        let (sql, params) = sql_builders::get_lsi_partition_size(table_name, index_name, pk);
1526        let size: i64 =
1527            self.conn
1528                .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1529                    row.get(0)
1530                })?;
1531        Ok(size)
1532    }
1533
1534    /// Delete an item by primary key. Returns the old item_json if it existed.
1535    pub fn delete_item(&self, table_name: &str, pk: &str, sk: &str) -> Result<Option<String>> {
1536        let old_item = self.get_item(table_name, pk, sk)?;
1537
1538        let (sql, params) = sql_builders::delete_item(table_name, pk, sk);
1539        self.conn
1540            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
1541
1542        Ok(old_item)
1543    }
1544
1545    /// Query items by partition key with optional sort key condition.
1546    ///
1547    /// `sk_condition` is a SQL fragment like `AND sk > ?` with `sk_params` providing values.
1548    /// Returns `(items, has_more)` where items is a vec of `(pk, sk, item_json)`.
1549    pub fn query_items(
1550        &self,
1551        table_name: &str,
1552        pk: &str,
1553        params: &QueryParams,
1554    ) -> Result<Vec<(String, String, String)>> {
1555        let (sql, params_vec) = sql_builders::query_items(table_name, pk, params);
1556        let mut stmt = self.conn.prepare(&sql)?;
1557        let rows = stmt
1558            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1559                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1560            })?
1561            .collect::<std::result::Result<Vec<_>, _>>()?;
1562
1563        Ok(rows)
1564    }
1565
1566    /// Scan items from a table with pagination.
1567    ///
1568    /// Returns `(pk, sk, item_json)` tuples ordered by hash_prefix for
1569    /// dynalite-compatible parallel scan behaviour.
1570    pub fn scan_items(
1571        &self,
1572        table_name: &str,
1573        params: &ScanParams,
1574    ) -> Result<Vec<(String, String, String)>> {
1575        let (sql, params_vec) = sql_builders::scan_items(table_name, params);
1576        let mut stmt = self.conn.prepare(&sql)?;
1577        let rows = stmt
1578            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1579                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1580            })?
1581            .collect::<std::result::Result<Vec<_>, _>>()?;
1582
1583        Ok(rows)
1584    }
1585
1586    /// Count items in a table.
1587    pub fn count_items(&self, table_name: &str) -> Result<i64> {
1588        let (sql, params) = sql_builders::count_items(table_name);
1589        let count: i64 =
1590            self.conn
1591                .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1592                    row.get(0)
1593                })?;
1594        Ok(count)
1595    }
1596
1597    /// Count items in a table and total their stored sizes, in one query.
1598    pub fn item_count_and_size(&self, table_name: &str) -> Result<(i64, i64)> {
1599        let (sql, params) = sql_builders::item_count_and_size(table_name);
1600        let stats: (i64, i64) =
1601            self.conn
1602                .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1603                    Ok((row.get(0)?, row.get(1)?))
1604                })?;
1605        Ok(stats)
1606    }
1607
1608    /// Count a GSI's entries and total their projected sizes. See
1609    /// [`StorageBackend::gsi_item_count_and_size`](crate::storage_backend::StorageBackend::gsi_item_count_and_size).
1610    pub fn gsi_item_count_and_size(
1611        &self,
1612        table_name: &str,
1613        index_name: &str,
1614        whole_items: bool,
1615    ) -> Result<(i64, i64)> {
1616        if whole_items {
1617            self.count_and_size(sql_builders::gsi_count_and_size_from_base(
1618                table_name, index_name,
1619            ))
1620        } else {
1621            self.count_and_size_entries(sql_builders::gsi_entries_item_json(table_name, index_name))
1622        }
1623    }
1624
1625    /// Count an LSI's entries and total their projected sizes. See
1626    /// [`StorageBackend::lsi_item_count_and_size`](crate::storage_backend::StorageBackend::lsi_item_count_and_size).
1627    pub fn lsi_item_count_and_size(
1628        &self,
1629        table_name: &str,
1630        index_name: &str,
1631        whole_items: bool,
1632    ) -> Result<(i64, i64)> {
1633        if whole_items {
1634            self.count_and_size(sql_builders::lsi_count_and_size_from_base(
1635                table_name, index_name,
1636            ))
1637        } else {
1638            self.count_and_size_entries(sql_builders::lsi_entries_item_json(table_name, index_name))
1639        }
1640    }
1641
1642    /// Count a vector index's entries and total their stored billable sizes.
1643    /// See [`StorageBackend::vector_item_count_and_size`](crate::storage_backend::StorageBackend::vector_item_count_and_size).
1644    pub fn vector_item_count_and_size(
1645        &self,
1646        table_name: &str,
1647        index_name: &str,
1648    ) -> Result<(i64, i64)> {
1649        self.count_and_size(sql_builders::vector_count_and_size(table_name, index_name))
1650    }
1651
1652    /// Rewrite the stored `item_size` of the given rows. See
1653    /// [`StorageBackend::set_item_sizes`](crate::storage_backend::StorageBackend::set_item_sizes).
1654    pub fn set_item_sizes(
1655        &self,
1656        table_name: &str,
1657        sizes: &[(String, String, usize)],
1658    ) -> Result<()> {
1659        for (pk, sk, size) in sizes {
1660            #[cfg(test)]
1661            Self::count_down(&self.item_size_fault, "injected item size write failure")?;
1662            let (sql, params) = sql_builders::set_item_size(table_name, pk, sk, *size);
1663            self.conn
1664                .prepare_cached(&sql)?
1665                .execute(rusqlite::params_from_iter(params.iter()))?;
1666        }
1667        Ok(())
1668    }
1669
1670    fn count_and_size(
1671        &self,
1672        (sql, params): (String, Vec<sql_builders::SqlParam<'static>>),
1673    ) -> Result<(i64, i64)> {
1674        Ok(self
1675            .conn
1676            .query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
1677                Ok((row.get(0)?, row.get(1)?))
1678            })?)
1679    }
1680
1681    fn count_and_size_entries(
1682        &self,
1683        (sql, params): (String, Vec<sql_builders::SqlParam<'static>>),
1684    ) -> Result<(i64, i64)> {
1685        let mut stmt = self.conn.prepare(&sql)?;
1686        let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?;
1687        let (mut count, mut bytes) = (0i64, 0i64);
1688        while let Some(row) = rows.next()? {
1689            let json: String = row.get(0)?;
1690            count += 1;
1691            bytes += crate::storage_backend::projected_entry_size(&json)
1692                .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
1693        }
1694        Ok((count, bytes))
1695    }
1696
1697    // -----------------------------------------------------------------------
1698    // Introspection
1699    // -----------------------------------------------------------------------
1700
1701    /// Get the database file path, or `None` for in-memory databases.
1702    pub fn db_path(&self) -> Option<String> {
1703        self.conn
1704            .path()
1705            .filter(|p| !p.is_empty())
1706            .map(|p| p.to_owned())
1707    }
1708
1709    /// Get the total database size in bytes.
1710    pub fn db_size_bytes(&self) -> Result<u64> {
1711        let size: i64 = self.conn.query_row(
1712            "SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()",
1713            [],
1714            |row| row.get(0),
1715        )?;
1716        Ok(size as u64)
1717    }
1718
1719    /// Count the number of DynamoDB tables.
1720    pub fn table_count(&self) -> Result<usize> {
1721        let count: i64 = self
1722            .conn
1723            .query_row("SELECT COUNT(*) FROM _tables", [], |row| row.get(0))?;
1724        Ok(count as usize)
1725    }
1726
1727    /// Get per-table statistics: name, item count, and approximate size in bytes.
1728    ///
1729    /// Uses a single query per table (COUNT + SUM combined) instead of separate queries.
1730    pub fn table_stats(&self) -> Result<Vec<TableStats>> {
1731        let table_names = self.list_table_names()?;
1732        let mut stats = Vec::with_capacity(table_names.len());
1733        for name in table_names {
1734            let sql = format!(
1735                "SELECT COUNT(*), COALESCE(SUM(item_size), 0) FROM \"{}\"",
1736                escape_table_name(&name)
1737            );
1738            let (item_count, size_bytes): (i64, i64) = self
1739                .conn
1740                .query_row(&sql, [], |row| Ok((row.get(0)?, row.get(1)?)))?;
1741            stats.push(TableStats {
1742                table_name: name,
1743                item_count,
1744                size_bytes: size_bytes as u64,
1745            });
1746        }
1747        Ok(stats)
1748    }
1749
1750    /// Get combined database info in a single call for atomic consistency.
1751    ///
1752    /// Returns all introspection data that `get_database_info` tools need
1753    /// without releasing the lock between queries.
1754    pub fn database_info(&self) -> Result<DatabaseInfo> {
1755        let path = self.db_path();
1756        let size_bytes = self.db_size_bytes()?;
1757        let table_count = self.table_count()?;
1758        let stats = self.table_stats()?;
1759
1760        let mut table_details = Vec::with_capacity(stats.len());
1761        for s in stats {
1762            let metadata = self.get_table_metadata(&s.table_name)?;
1763            table_details.push(TableInfoEntry { stats: s, metadata });
1764        }
1765
1766        Ok(DatabaseInfo {
1767            path,
1768            size_bytes,
1769            table_count,
1770            tables: table_details,
1771        })
1772    }
1773
1774    // -----------------------------------------------------------------------
1775    // Snapshot operations
1776    // -----------------------------------------------------------------------
1777
1778    /// Create a snapshot of the database by copying it to the given path.
1779    /// Uses `VACUUM INTO` which works for both in-memory and file-backed databases.
1780    pub fn vacuum_into(&self, path: &str) -> Result<()> {
1781        if path.contains('\0') {
1782            return Err(DynoxideError::ValidationException(
1783                "path contains null byte".to_string(),
1784            ));
1785        }
1786        self.conn
1787            .execute_batch(&format!("VACUUM INTO '{}'", path.replace('\'', "''")))?;
1788        Ok(())
1789    }
1790
1791    /// Run VACUUM to compact the database file in place.
1792    pub fn vacuum(&self) -> Result<()> {
1793        self.conn.execute_batch("VACUUM")?;
1794        Ok(())
1795    }
1796
1797    /// Restore the database from a snapshot file using SQLite's backup API.
1798    /// This replaces the current database contents with the snapshot contents.
1799    /// Works for both in-memory and file-backed databases.
1800    ///
1801    /// The snapshot is opened read-only, so it is never written to. A snapshot
1802    /// in WAL mode (every file Dynoxide creates is) may be left with its
1803    /// `-wal` and `-shm` files beside it afterwards, as reading a WAL database
1804    /// creates them and a read-only connection can't remove them on close.
1805    pub fn restore_from(&mut self, path: &str) -> Result<()> {
1806        // Read-only and without CREATE: a mistyped path is an error, rather
1807        // than a new, empty file backed up over this database, and the
1808        // snapshot itself is never written to.
1809        let source = Connection::open_with_flags(
1810            path,
1811            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
1812                | rusqlite::OpenFlags::SQLITE_OPEN_URI
1813                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1814        )?;
1815        self.restore_from_connection(&source)
1816    }
1817
1818    /// Backup the current database to a new in-memory SQLite connection.
1819    ///
1820    /// Used for in-memory snapshot storage: the returned connection holds
1821    /// a complete copy of the database without touching the filesystem.
1822    pub fn backup_to_memory(&self) -> Result<Connection> {
1823        let mut dest = Connection::open_in_memory()?;
1824        {
1825            let backup = rusqlite::backup::Backup::new(&self.conn, &mut dest)?;
1826            run_backup(&backup)?;
1827        }
1828        Ok(dest)
1829    }
1830
1831    /// Restore the database from another SQLite connection using the backup API.
1832    ///
1833    /// A source with no Dynoxide metadata (an empty or zero-byte file,
1834    /// `:memory:`, or another program's database) is refused as not a
1835    /// snapshot. Otherwise the source is copied into a private temporary
1836    /// database and brought up to date there, as an open would bring a file
1837    /// an earlier build wrote; only then does the copy replace this database's
1838    /// contents and the metadata cache get cleared. A copy or migration that
1839    /// fails leaves this database as it was. SQLite keeps the temporary copy
1840    /// in its page cache and spills it to a temporary file as it grows, so a
1841    /// large snapshot doesn't need its own size in memory. Builds with
1842    /// encryption are the exception: SQLCipher is compiled to keep temporary
1843    /// databases in memory (`SQLITE_TEMP_STORE=2`), so there the copy does take
1844    /// its own size in memory, and never reaches the disk unencrypted.
1845    pub fn restore_from_connection(&mut self, source: &Connection) -> Result<()> {
1846        ensure_snapshot(source)?;
1847        refuse_newer_schema(source)?;
1848        let staged = self.staged_copy(source)?;
1849        {
1850            let backup = rusqlite::backup::Backup::new(&staged.conn, &mut self.conn)?;
1851            run_backup(&backup)?;
1852        }
1853        self.metadata_cache.borrow_mut().clear();
1854        Ok(())
1855    }
1856
1857    /// Copy `source` into a fresh private temporary database and run every
1858    /// migration its recorded schema version calls for, leaving this database
1859    /// untouched.
1860    fn staged_copy(&self, source: &Connection) -> Result<Storage> {
1861        let mut staged = Storage {
1862            // An empty path opens a private temporary database: on disk once
1863            // it outgrows the page cache (in memory throughout on encryption
1864            // builds, whose SQLCipher keeps temporary databases there), and
1865            // deleted when it is closed.
1866            conn: Connection::open("")?,
1867            metadata_cache: RefCell::new(HashMap::new()),
1868            // A countdown armed on this database fires in the migration that
1869            // stands in for it.
1870            #[cfg(test)]
1871            gsi_insert_fault: RefCell::new(self.gsi_insert_fault.take()),
1872            #[cfg(test)]
1873            item_size_fault: RefCell::new(self.item_size_fault.take()),
1874            clock: Arc::clone(&self.clock),
1875        };
1876        let copied = match rusqlite::backup::Backup::new(source, &mut staged.conn) {
1877            Ok(backup) => run_backup(&backup),
1878            Err(e) => Err(e.into()),
1879        };
1880        let migrated = copied
1881            .and_then(|()| Self::register_functions(&staged.conn))
1882            .and_then(|()| staged.migrate_to_current());
1883        #[cfg(test)]
1884        {
1885            *self.gsi_insert_fault.borrow_mut() = staged.gsi_insert_fault.take();
1886            *self.item_size_fault.borrow_mut() = staged.item_size_fault.take();
1887        }
1888        migrated.map(|()| staged)
1889    }
1890
1891    /// Get the database size in bytes for an arbitrary connection.
1892    pub fn connection_size_bytes(conn: &Connection) -> Result<u64> {
1893        let size: i64 = conn.query_row(
1894            "SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()",
1895            [],
1896            |row| row.get(0),
1897        )?;
1898        Ok(size as u64)
1899    }
1900
1901    // -----------------------------------------------------------------------
1902    // Stream operations
1903    // -----------------------------------------------------------------------
1904
1905    /// Enable streams on a table.
1906    pub fn enable_stream(&self, table_name: &str, view_type: &str, label: &str) -> Result<()> {
1907        self.conn.execute(
1908            "UPDATE _tables SET stream_enabled = 1, stream_view_type = ?1, stream_label = ?2 WHERE table_name = ?3",
1909            params![view_type, label, table_name],
1910        )?;
1911        self.metadata_cache.borrow_mut().remove(table_name);
1912        Ok(())
1913    }
1914
1915    /// Disable streams on a table.
1916    pub fn disable_stream(&self, table_name: &str) -> Result<()> {
1917        self.conn.execute(
1918            "UPDATE _tables SET stream_enabled = 0 WHERE table_name = ?1",
1919            params![table_name],
1920        )?;
1921        self.metadata_cache.borrow_mut().remove(table_name);
1922        Ok(())
1923    }
1924
1925    /// Insert a stream record.
1926    #[allow(clippy::too_many_arguments)]
1927    pub fn insert_stream_record(
1928        &self,
1929        table_name: &str,
1930        event_name: &str,
1931        keys_json: &str,
1932        new_image: Option<&str>,
1933        old_image: Option<&str>,
1934        sequence_number: &str,
1935        shard_id: &str,
1936        created_at: i64,
1937    ) -> Result<()> {
1938        self.insert_stream_record_with_identity(
1939            table_name,
1940            event_name,
1941            keys_json,
1942            new_image,
1943            old_image,
1944            sequence_number,
1945            shard_id,
1946            created_at,
1947            None,
1948        )
1949    }
1950
1951    /// Insert a stream record with optional user identity (for TTL deletions).
1952    #[allow(clippy::too_many_arguments)]
1953    pub fn insert_stream_record_with_identity(
1954        &self,
1955        table_name: &str,
1956        event_name: &str,
1957        keys_json: &str,
1958        new_image: Option<&str>,
1959        old_image: Option<&str>,
1960        sequence_number: &str,
1961        shard_id: &str,
1962        created_at: i64,
1963        user_identity: Option<&str>,
1964    ) -> Result<()> {
1965        self.conn.execute(
1966            "INSERT INTO _stream_records (table_name, event_name, keys_json, new_image, old_image, sequence_number, shard_id, created_at, user_identity)
1967             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1968            params![table_name, event_name, keys_json, new_image, old_image, sequence_number, shard_id, created_at, user_identity],
1969        )?;
1970        Ok(())
1971    }
1972
1973    /// Get the next sequence number for a table's stream.
1974    pub fn next_stream_sequence_number(&self, table_name: &str) -> Result<i64> {
1975        let result: std::result::Result<i64, _> = self.conn.query_row(
1976            "SELECT COALESCE(MAX(CAST(sequence_number AS INTEGER)), 0) + 1 FROM _stream_records WHERE table_name = ?1",
1977            params![table_name],
1978            |row| row.get(0),
1979        );
1980        match result {
1981            Ok(n) => Ok(n),
1982            Err(_) => Ok(1),
1983        }
1984    }
1985
1986    /// Get stream records for a shard starting after a given sequence number.
1987    pub fn get_stream_records(
1988        &self,
1989        table_name: &str,
1990        shard_id: &str,
1991        after_sequence: i64,
1992        limit: usize,
1993    ) -> Result<Vec<StreamRecord>> {
1994        let mut stmt = self.conn.prepare(
1995            "SELECT event_name, keys_json, new_image, old_image, sequence_number, created_at, user_identity
1996             FROM _stream_records
1997             WHERE table_name = ?1 AND shard_id = ?2 AND CAST(sequence_number AS INTEGER) > ?3
1998             ORDER BY CAST(sequence_number AS INTEGER) ASC
1999             LIMIT ?4",
2000        )?;
2001        let rows = stmt
2002            .query_map(
2003                params![table_name, shard_id, after_sequence, limit as i64],
2004                |row| {
2005                    Ok(StreamRecord {
2006                        event_name: row.get(0)?,
2007                        keys_json: row.get(1)?,
2008                        new_image: row.get(2)?,
2009                        old_image: row.get(3)?,
2010                        sequence_number: row.get(4)?,
2011                        created_at: row.get(5)?,
2012                        user_identity: row.get(6)?,
2013                    })
2014                },
2015            )?
2016            .collect::<std::result::Result<Vec<_>, _>>()?;
2017        Ok(rows)
2018    }
2019
2020    /// List tables that have streams enabled.
2021    pub fn list_stream_enabled_tables(&self) -> Result<Vec<TableMetadata>> {
2022        let sql = format!(
2023            "SELECT {} FROM _tables WHERE stream_enabled = 1 ORDER BY table_name",
2024            sql_builders::TABLE_METADATA_COLUMNS
2025        );
2026        let mut stmt = self.conn.prepare(&sql)?;
2027        let rows = stmt
2028            .query_map([], row_to_metadata)?
2029            .collect::<std::result::Result<Vec<_>, _>>()?;
2030        Ok(rows)
2031    }
2032
2033    // -----------------------------------------------------------------------
2034    // TTL operations
2035    // -----------------------------------------------------------------------
2036
2037    /// Update TTL configuration for a table.
2038    pub fn update_ttl_config(
2039        &self,
2040        table_name: &str,
2041        attribute_name: Option<&str>,
2042        enabled: bool,
2043    ) -> Result<()> {
2044        self.conn.execute(
2045            "UPDATE _tables SET ttl_attribute = ?1, ttl_enabled = ?2 WHERE table_name = ?3",
2046            params![attribute_name, enabled as i32, table_name],
2047        )?;
2048        self.metadata_cache.borrow_mut().remove(table_name);
2049        Ok(())
2050    }
2051
2052    /// List tables that have TTL enabled.
2053    pub fn list_ttl_enabled_tables(&self) -> Result<Vec<TableMetadata>> {
2054        let sql = format!(
2055            "SELECT {} FROM _tables WHERE ttl_enabled = 1 ORDER BY table_name",
2056            sql_builders::TABLE_METADATA_COLUMNS
2057        );
2058        let mut stmt = self.conn.prepare(&sql)?;
2059        let rows = stmt
2060            .query_map([], row_to_metadata)?
2061            .collect::<std::result::Result<Vec<_>, _>>()?;
2062        Ok(rows)
2063    }
2064
2065    /// Get the min and max sequence numbers for a shard.
2066    pub fn get_shard_sequence_range(
2067        &self,
2068        table_name: &str,
2069        shard_id: &str,
2070    ) -> Result<(Option<String>, Option<String>)> {
2071        let result: std::result::Result<(Option<String>, Option<String>), _> = self.conn.query_row(
2072            "SELECT MIN(sequence_number), MAX(sequence_number) FROM _stream_records WHERE table_name = ?1 AND shard_id = ?2",
2073            params![table_name, shard_id],
2074            |row| Ok((row.get(0)?, row.get(1)?)),
2075        );
2076        match result {
2077            Ok(range) => Ok(range),
2078            Err(_) => Ok((None, None)),
2079        }
2080    }
2081
2082    // -----------------------------------------------------------------------
2083    // Cache tracking (cached_at)
2084    // -----------------------------------------------------------------------
2085
2086    /// Update the `cached_at` timestamp for a single item.
2087    pub fn touch_cached_at(
2088        &self,
2089        table_name: &str,
2090        pk: &str,
2091        sk: &str,
2092        timestamp: f64,
2093    ) -> Result<()> {
2094        let sql = format!(
2095            "UPDATE \"{}\" SET cached_at = ?1 WHERE pk = ?2 AND sk = ?3",
2096            escape_table_name(table_name)
2097        );
2098        self.conn.execute(&sql, params![timestamp, pk, sk])?;
2099        Ok(())
2100    }
2101
2102    /// Get items ordered by `cached_at` (oldest first) for LRU eviction.
2103    ///
2104    /// Returns `(pk, sk, item_size)` tuples. Items with NULL `cached_at`
2105    /// are excluded (they were never cached from a remote source).
2106    pub fn get_lru_items(
2107        &self,
2108        table_name: &str,
2109        limit: usize,
2110    ) -> Result<Vec<(String, String, i64)>> {
2111        let sql = format!(
2112            "SELECT pk, sk, item_size FROM \"{}\" WHERE cached_at IS NOT NULL ORDER BY cached_at ASC LIMIT ?1",
2113            escape_table_name(table_name)
2114        );
2115        let mut stmt = self.conn.prepare(&sql)?;
2116        let rows = stmt
2117            .query_map(params![limit as i64], |row| {
2118                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
2119            })?
2120            .collect::<std::result::Result<Vec<_>, _>>()?;
2121        Ok(rows)
2122    }
2123}
2124
2125/// A stream record from the `_stream_records` table.
2126#[derive(Debug, Clone)]
2127pub struct StreamRecord {
2128    pub event_name: String,
2129    pub keys_json: String,
2130    pub new_image: Option<String>,
2131    pub old_image: Option<String>,
2132    pub sequence_number: String,
2133    pub created_at: i64,
2134    pub user_identity: Option<String>,
2135}
2136
2137/// Per-table statistics returned by `Storage::table_stats()`.
2138#[derive(Debug, Clone)]
2139pub struct TableStats {
2140    pub table_name: String,
2141    pub item_count: i64,
2142    pub size_bytes: u64,
2143}
2144
2145/// Combined database introspection info returned by `Storage::database_info()`.
2146#[derive(Debug, Clone)]
2147pub struct DatabaseInfo {
2148    pub path: Option<String>,
2149    pub size_bytes: u64,
2150    pub table_count: usize,
2151    pub tables: Vec<TableInfoEntry>,
2152}
2153
2154/// Per-table stats + metadata for `DatabaseInfo`.
2155#[derive(Debug, Clone)]
2156pub struct TableInfoEntry {
2157    pub stats: TableStats,
2158    pub metadata: Option<TableMetadata>,
2159}
2160
2161/// Metadata row from the `_tables` table.
2162///
2163/// Note: The `tags` column is intentionally excluded. Tags are not on the hot
2164/// path for item operations and are accessed via separate `get_tags`/`set_tags`
2165/// methods to keep the metadata cache lean.
2166#[derive(Debug, Clone)]
2167pub struct TableMetadata {
2168    pub table_name: String,
2169    pub key_schema: String,
2170    pub attribute_definitions: String,
2171    pub gsi_definitions: Option<String>,
2172    pub lsi_definitions: Option<String>,
2173    pub stream_enabled: bool,
2174    pub stream_view_type: Option<String>,
2175    pub stream_label: Option<String>,
2176    pub ttl_attribute: Option<String>,
2177    pub ttl_enabled: bool,
2178    pub created_at: i64,
2179    pub table_status: String,
2180    pub billing_mode: Option<String>,
2181    pub provisioned_throughput: Option<String>,
2182    pub sse_specification: Option<String>,
2183    pub table_class: Option<String>,
2184    pub deletion_protection_enabled: bool,
2185    pub on_demand_throughput: Option<String>,
2186    /// Stable per-table identifier assigned at create time (#55). `None` only
2187    /// for rows that predate the v8 migration's backfill.
2188    pub table_id: Option<String>,
2189    /// Vector index definitions as request-shaped JSON, following the
2190    /// `gsi_definitions` convention. `None` when the table has none.
2191    pub vector_index_definitions: Option<String>,
2192}
2193
2194/// Map a row from the _tables SELECT to a TableMetadata struct.
2195#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2196fn row_to_metadata(row: &rusqlite::Row) -> rusqlite::Result<TableMetadata> {
2197    Ok(TableMetadata {
2198        table_name: row.get(0)?,
2199        key_schema: row.get(1)?,
2200        attribute_definitions: row.get(2)?,
2201        gsi_definitions: row.get(3)?,
2202        lsi_definitions: row.get(4)?,
2203        stream_enabled: row.get::<_, i32>(5)? != 0,
2204        stream_view_type: row.get(6)?,
2205        stream_label: row.get(7)?,
2206        ttl_attribute: row.get(8)?,
2207        ttl_enabled: row.get::<_, i32>(9)? != 0,
2208        created_at: row.get(10)?,
2209        table_status: row.get(11)?,
2210        billing_mode: row.get(12)?,
2211        provisioned_throughput: row.get(13)?,
2212        sse_specification: row.get(14)?,
2213        table_class: row.get(15)?,
2214        deletion_protection_enabled: row.get::<_, i32>(16).unwrap_or(0) != 0,
2215        on_demand_throughput: row.get(17)?,
2216        table_id: row.get(18)?,
2217        vector_index_definitions: row.get(19)?,
2218    })
2219}
2220
2221/// How long a backup waits between attempts on a database another
2222/// connection has busy or locked, and after the copy starts over.
2223#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2224const BACKUP_LOCK_PAUSE: std::time::Duration = std::time::Duration::from_millis(10);
2225
2226/// How long a backup keeps retrying a busy or locked database before giving
2227/// up, by the clock: the same patience as an open connection's busy timeout,
2228/// [`REQUEST_BUSY_TIMEOUT`]. Tests use it unchanged, so a test that waits out
2229/// a brief lock has a wide margin on a slow machine.
2230#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2231const BACKUP_LOCK_PATIENCE: std::time::Duration = REQUEST_BUSY_TIMEOUT;
2232
2233/// How many times a backup starts over, because another connection changed
2234/// the database being copied, before it gives up.
2235#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2236const BACKUP_RESTART_LIMIT: u32 = 50;
2237
2238/// What one backup step reported, as far as [`BackupWatch`] cares.
2239#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2240#[derive(Clone, Copy, Debug, PartialEq)]
2241enum BackupStep {
2242    Done,
2243    /// Pages copied and more to go; `remaining` is what's left to copy.
2244    More {
2245        remaining: i32,
2246    },
2247    /// The database was busy or locked.
2248    Busy,
2249}
2250
2251/// What [`run_backup`] does next.
2252#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2253#[derive(Clone, Copy, Debug, PartialEq)]
2254enum BackupNext {
2255    Finish,
2256    Step,
2257    Pause,
2258    GiveUpLocked,
2259    GiveUpRestarting,
2260}
2261
2262/// The bookkeeping behind [`run_backup`]: how long the database has been
2263/// busy, by the clock, and how often the copy has started over.
2264#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2265#[derive(Default)]
2266struct BackupWatch {
2267    busy_since: Option<std::time::Instant>,
2268    last_remaining: Option<i32>,
2269    restarts: u32,
2270}
2271
2272#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2273impl BackupWatch {
2274    /// Decide what follows a step that started at `started` and ended at `now`.
2275    ///
2276    /// A busy spell is timed from the start of the first step that found the
2277    /// database busy, so the time SQLite's own busy handler spends inside that
2278    /// step counts towards the patience. Progress ends the spell. A copy whose
2279    /// remaining page count doesn't fall has started over, which SQLite does
2280    /// when another connection changes the database being copied: the pages it
2281    /// copies again leave the same count remaining as the step before, or more
2282    /// if the database grew. A restart pauses before the next step, so a short
2283    /// burst of writes to the source doesn't use the limit up at once.
2284    fn observe(
2285        &mut self,
2286        step: BackupStep,
2287        started: std::time::Instant,
2288        now: std::time::Instant,
2289        patience: std::time::Duration,
2290    ) -> BackupNext {
2291        match step {
2292            BackupStep::Done => BackupNext::Finish,
2293            BackupStep::More { remaining } => {
2294                self.busy_since = None;
2295                let restarted = self.last_remaining.is_some_and(|last| remaining >= last);
2296                self.last_remaining = Some(remaining);
2297                if !restarted {
2298                    return BackupNext::Step;
2299                }
2300                self.restarts += 1;
2301                if self.restarts > BACKUP_RESTART_LIMIT {
2302                    BackupNext::GiveUpRestarting
2303                } else {
2304                    BackupNext::Pause
2305                }
2306            }
2307            BackupStep::Busy => {
2308                let since = *self.busy_since.get_or_insert(started);
2309                if now.saturating_duration_since(since) >= patience {
2310                    BackupNext::GiveUpLocked
2311                } else {
2312                    BackupNext::Pause
2313                }
2314            }
2315        }
2316    }
2317}
2318
2319/// Copy every remaining page through `backup`.
2320///
2321/// Steps run back to back while they make progress. A busy or locked database
2322/// is retried every [`BACKUP_LOCK_PAUSE`] until it has been busy for
2323/// [`BACKUP_LOCK_PATIENCE`] by the clock, then reported as locked. A step can
2324/// overrun that by a busy-timeout wait of its own inside SQLite, on each
2325/// database it found locked. A copy that keeps starting over because the
2326/// database being copied keeps changing waits the same pause after each
2327/// restart and gives up after [`BACKUP_RESTART_LIMIT`] of them.
2328#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2329fn run_backup(backup: &rusqlite::backup::Backup<'_, '_>) -> Result<()> {
2330    let mut watch = BackupWatch::default();
2331    loop {
2332        let started = std::time::Instant::now();
2333        let step = backup_step(backup)?;
2334        match watch.observe(
2335            step,
2336            started,
2337            std::time::Instant::now(),
2338            BACKUP_LOCK_PATIENCE,
2339        ) {
2340            BackupNext::Finish => return Ok(()),
2341            BackupNext::Step => {}
2342            BackupNext::Pause => std::thread::sleep(BACKUP_LOCK_PAUSE),
2343            BackupNext::GiveUpLocked => {
2344                return Err(rusqlite::Error::SqliteFailure(
2345                    rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
2346                    Some("database is locked".to_string()),
2347                )
2348                .into());
2349            }
2350            BackupNext::GiveUpRestarting => {
2351                return Err(rusqlite::Error::SqliteFailure(
2352                    rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
2353                    Some(format!(
2354                        "the database being copied kept changing; gave up after {BACKUP_RESTART_LIMIT} restarts"
2355                    )),
2356                )
2357                .into());
2358            }
2359        }
2360    }
2361}
2362
2363/// Copy the next hundred pages through `backup` and say what happened.
2364#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2365fn backup_step(backup: &rusqlite::backup::Backup<'_, '_>) -> Result<BackupStep> {
2366    use rusqlite::backup::StepResult;
2367    Ok(match backup.step(100)? {
2368        StepResult::Done => BackupStep::Done,
2369        StepResult::More => BackupStep::More {
2370            remaining: backup.progress().remaining,
2371        },
2372        // Busy, Locked and any later variant: wait and retry, within limits.
2373        _ => BackupStep::Busy,
2374    })
2375}
2376
2377/// Refuse a restore source that holds no Dynoxide metadata.
2378///
2379/// An empty or zero-byte file, `:memory:` or another program's database
2380/// opens without complaint, and restoring one would replace this database
2381/// with an empty one.
2382#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2383fn ensure_snapshot(source: &Connection) -> Result<()> {
2384    if table_exists(source, "_tables")? {
2385        Ok(())
2386    } else {
2387        Err(DynoxideError::ValidationException(
2388            "not a Dynoxide snapshot: it has no table metadata".to_string(),
2389        ))
2390    }
2391}
2392
2393/// Whether the database at `conn` holds a table called `name`.
2394#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2395fn table_exists(conn: &Connection, name: &str) -> Result<bool> {
2396    Ok(conn.query_row(sql_builders::TABLE_EXISTS, [name], |row| row.get(0))?)
2397}
2398
2399/// Refuse a database a newer build wrote, reading it and nothing more: a
2400/// version it records must be one this build knows. An open and a restore
2401/// check this before they write anything, so a refused file, or a refused
2402/// snapshot and the database it would have replaced, is left as it was.
2403#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
2404fn refuse_newer_schema(conn: &Connection) -> Result<()> {
2405    use rusqlite::OptionalExtension;
2406    if !table_exists(conn, "_config")? {
2407        return Ok(());
2408    }
2409    let value: Option<String> = conn
2410        .query_row(sql_builders::GET_SCHEMA_VERSION, [], |row| row.get(0))
2411        .optional()?;
2412    crate::storage_backend::schema::refuse_newer(crate::storage_backend::schema::parse_recorded(
2413        value.as_deref(),
2414    ))
2415    .map_err(DynoxideError::InternalServerError)
2416}
2417
2418#[cfg(all(test, any(feature = "native-sqlite", feature = "_has-encryption")))]
2419mod tests {
2420    use super::*;
2421
2422    fn test_storage() -> Storage {
2423        Storage::memory().expect("Failed to create in-memory storage")
2424    }
2425
2426    #[test]
2427    fn test_initialize_creates_metadata_tables() {
2428        let storage = test_storage();
2429        // _config and _tables should exist
2430        let version: String = storage
2431            .conn()
2432            .query_row(
2433                "SELECT value FROM _config WHERE key = 'schema_version'",
2434                [],
2435                |row| row.get(0),
2436            )
2437            .unwrap();
2438        assert_eq!(version, SCHEMA_VERSION.to_string());
2439    }
2440
2441    #[test]
2442    fn test_migrate_v6_to_v7_adds_on_demand_throughput_column() {
2443        // Issue #44: the on_demand_throughput column must be added to existing
2444        // on-disk databases through the versioned migration chain, not just the
2445        // fresh CREATE: otherwise DescribeTable on a pre-existing table errors.
2446        let tmp = tempfile::NamedTempFile::new().unwrap();
2447        let path = tmp.path().to_str().unwrap().to_string();
2448
2449        // Build a v6-shape database by hand: _tables without on_demand_throughput,
2450        // schema_version pinned at 6, and one pre-existing table row.
2451        {
2452            let conn = Connection::open(&path).unwrap();
2453            conn.execute_batch(
2454                "CREATE TABLE _config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2455                 CREATE TABLE _tables (
2456                    table_name TEXT PRIMARY KEY,
2457                    key_schema TEXT NOT NULL,
2458                    attribute_definitions TEXT NOT NULL,
2459                    gsi_definitions TEXT,
2460                    lsi_definitions TEXT,
2461                    stream_enabled INTEGER DEFAULT 0,
2462                    stream_view_type TEXT,
2463                    stream_label TEXT,
2464                    ttl_attribute TEXT,
2465                    ttl_enabled INTEGER DEFAULT 0,
2466                    created_at INTEGER NOT NULL,
2467                    table_status TEXT NOT NULL DEFAULT 'ACTIVE',
2468                    billing_mode TEXT DEFAULT 'PAY_PER_REQUEST',
2469                    provisioned_throughput TEXT,
2470                    tags TEXT,
2471                    sse_specification TEXT,
2472                    table_class TEXT,
2473                    deletion_protection_enabled INTEGER DEFAULT 0
2474                 );",
2475            )
2476            .unwrap();
2477            conn.execute(
2478                "INSERT INTO _config (key, value) VALUES ('schema_version', '6')",
2479                [],
2480            )
2481            .unwrap();
2482            conn.execute(
2483                "INSERT INTO _tables (table_name, key_schema, attribute_definitions, created_at) \
2484                 VALUES ('LegacyTable', ?1, ?2, 0)",
2485                params![
2486                    r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
2487                    r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
2488                ],
2489            )
2490            .unwrap();
2491        }
2492
2493        // Reopening runs the migration chain; v6 -> v7 adds the column, and the
2494        // chain continues to the current SCHEMA_VERSION.
2495        let storage = Storage::new(&path).unwrap();
2496        let version: String = storage
2497            .conn()
2498            .query_row(
2499                "SELECT value FROM _config WHERE key = 'schema_version'",
2500                [],
2501                |r| r.get(0),
2502            )
2503            .unwrap();
2504        assert_eq!(version, SCHEMA_VERSION.to_string());
2505
2506        // The pre-existing row survives and reads back through row_to_metadata,
2507        // with the new column present and NULL.
2508        let meta = storage.get_table_metadata("LegacyTable").unwrap().unwrap();
2509        assert_eq!(meta.table_name, "LegacyTable");
2510        assert!(meta.on_demand_throughput.is_none());
2511
2512        // A bare SELECT of the new column would error if the ALTER had not run.
2513        let col: Option<String> = storage
2514            .conn()
2515            .query_row(
2516                "SELECT on_demand_throughput FROM _tables WHERE table_name = 'LegacyTable'",
2517                [],
2518                |r| r.get(0),
2519            )
2520            .unwrap();
2521        assert!(col.is_none());
2522    }
2523
2524    /// #55: the v7 -> v8 migration adds the `table_id` column and backfills any
2525    /// pre-existing table with a one-time random id, so a legacy table reports a
2526    /// stable TableId from then on.
2527    #[test]
2528    fn test_migrate_v7_to_v8_backfills_table_id() {
2529        let tmp = tempfile::NamedTempFile::new().unwrap();
2530        let path = tmp.path().to_str().unwrap().to_string();
2531
2532        // Build a v7-shape database by hand: _tables with on_demand_throughput
2533        // but no table_id, schema_version pinned at 7, one pre-existing row.
2534        {
2535            let conn = Connection::open(&path).unwrap();
2536            conn.execute_batch(
2537                "CREATE TABLE _config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2538                 CREATE TABLE _tables (
2539                    table_name TEXT PRIMARY KEY,
2540                    key_schema TEXT NOT NULL,
2541                    attribute_definitions TEXT NOT NULL,
2542                    gsi_definitions TEXT,
2543                    lsi_definitions TEXT,
2544                    stream_enabled INTEGER DEFAULT 0,
2545                    stream_view_type TEXT,
2546                    stream_label TEXT,
2547                    ttl_attribute TEXT,
2548                    ttl_enabled INTEGER DEFAULT 0,
2549                    created_at INTEGER NOT NULL,
2550                    table_status TEXT NOT NULL DEFAULT 'ACTIVE',
2551                    billing_mode TEXT DEFAULT 'PAY_PER_REQUEST',
2552                    provisioned_throughput TEXT,
2553                    tags TEXT,
2554                    sse_specification TEXT,
2555                    table_class TEXT,
2556                    deletion_protection_enabled INTEGER DEFAULT 0,
2557                    on_demand_throughput TEXT
2558                 );",
2559            )
2560            .unwrap();
2561            conn.execute(
2562                "INSERT INTO _config (key, value) VALUES ('schema_version', '7')",
2563                [],
2564            )
2565            .unwrap();
2566            conn.execute(
2567                "INSERT INTO _tables (table_name, key_schema, attribute_definitions, created_at) \
2568                 VALUES ('LegacyTable', ?1, ?2, 0)",
2569                params![
2570                    r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
2571                    r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
2572                ],
2573            )
2574            .unwrap();
2575        }
2576
2577        // Reopening runs the migration chain through v8.
2578        let storage = Storage::new(&path).unwrap();
2579        let version: String = storage
2580            .conn()
2581            .query_row(
2582                "SELECT value FROM _config WHERE key = 'schema_version'",
2583                [],
2584                |r| r.get(0),
2585            )
2586            .unwrap();
2587        assert_eq!(version, SCHEMA_VERSION.to_string());
2588
2589        // The legacy row was backfilled with a non-empty table_id, and it reads
2590        // back through row_to_metadata.
2591        let meta = storage.get_table_metadata("LegacyTable").unwrap().unwrap();
2592        let id = meta.table_id.expect("legacy table should be backfilled");
2593        assert!(!id.is_empty());
2594
2595        // Reopening again keeps the same id (stable, not regenerated).
2596        drop(storage);
2597        let storage2 = Storage::new(&path).unwrap();
2598        let meta2 = storage2.get_table_metadata("LegacyTable").unwrap().unwrap();
2599        assert_eq!(meta2.table_id.as_deref(), Some(id.as_str()));
2600    }
2601
2602    /// The v8 -> v9 migration adds the `vector_index_definitions` column, so a
2603    /// pre-upgrade database keeps working and its rows read back through
2604    /// row_to_metadata with the new column present and NULL.
2605    #[test]
2606    fn test_migrate_v8_to_v9_adds_vector_index_definitions_column() {
2607        let tmp = tempfile::NamedTempFile::new().unwrap();
2608        let path = tmp.path().to_str().unwrap().to_string();
2609
2610        // Build a v8-shape database by hand: _tables with table_id but no
2611        // vector_index_definitions, schema_version pinned at 8, one row.
2612        {
2613            let conn = Connection::open(&path).unwrap();
2614            conn.execute_batch(
2615                "CREATE TABLE _config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2616                 CREATE TABLE _tables (
2617                    table_name TEXT PRIMARY KEY,
2618                    key_schema TEXT NOT NULL,
2619                    attribute_definitions TEXT NOT NULL,
2620                    gsi_definitions TEXT,
2621                    lsi_definitions TEXT,
2622                    stream_enabled INTEGER DEFAULT 0,
2623                    stream_view_type TEXT,
2624                    stream_label TEXT,
2625                    ttl_attribute TEXT,
2626                    ttl_enabled INTEGER DEFAULT 0,
2627                    created_at INTEGER NOT NULL,
2628                    table_status TEXT NOT NULL DEFAULT 'ACTIVE',
2629                    billing_mode TEXT DEFAULT 'PAY_PER_REQUEST',
2630                    provisioned_throughput TEXT,
2631                    tags TEXT,
2632                    sse_specification TEXT,
2633                    table_class TEXT,
2634                    deletion_protection_enabled INTEGER DEFAULT 0,
2635                    on_demand_throughput TEXT,
2636                    table_id TEXT
2637                 );",
2638            )
2639            .unwrap();
2640            conn.execute(
2641                "INSERT INTO _config (key, value) VALUES ('schema_version', '8')",
2642                [],
2643            )
2644            .unwrap();
2645            conn.execute(
2646                "INSERT INTO _tables (table_name, key_schema, attribute_definitions, created_at, table_id) \
2647                 VALUES ('LegacyTable', ?1, ?2, 0, 'fixed-id')",
2648                params![
2649                    r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
2650                    r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
2651                ],
2652            )
2653            .unwrap();
2654        }
2655
2656        // Reopening runs the migration chain; v8 -> v9 adds the column.
2657        let storage = Storage::new(&path).unwrap();
2658        let version: String = storage
2659            .conn()
2660            .query_row(
2661                "SELECT value FROM _config WHERE key = 'schema_version'",
2662                [],
2663                |r| r.get(0),
2664            )
2665            .unwrap();
2666        assert_eq!(version, SCHEMA_VERSION.to_string());
2667
2668        // The pre-existing row survives and reads back with the new column
2669        // present and NULL.
2670        let meta = storage.get_table_metadata("LegacyTable").unwrap().unwrap();
2671        assert_eq!(meta.table_name, "LegacyTable");
2672        assert!(meta.vector_index_definitions.is_none());
2673
2674        // A bare SELECT of the new column would error if the ALTER had not run.
2675        let col: Option<String> = storage
2676            .conn()
2677            .query_row(
2678                "SELECT vector_index_definitions FROM _tables WHERE table_name = 'LegacyTable'",
2679                [],
2680                |r| r.get(0),
2681            )
2682            .unwrap();
2683        assert!(col.is_none());
2684    }
2685
2686    #[test]
2687    fn test_migrate_v9_to_v10_rebuilds_a_multi_attribute_gsi() {
2688        let tmp = tempfile::NamedTempFile::new().unwrap();
2689        let path = tmp.path().to_str().unwrap().to_string();
2690
2691        // A table with a one-plus-two-attribute GSI key, written by this build.
2692        {
2693            let db = crate::Database::new(&path).unwrap();
2694            db.create_table(
2695                serde_json::from_value(serde_json::json!({
2696                    "TableName": "Legacy",
2697                    "BillingMode": "PAY_PER_REQUEST",
2698                    "AttributeDefinitions": [
2699                        {"AttributeName": "pk", "AttributeType": "S"},
2700                        {"AttributeName": "h", "AttributeType": "S"},
2701                        {"AttributeName": "a", "AttributeType": "S"},
2702                        {"AttributeName": "b", "AttributeType": "S"}
2703                    ],
2704                    "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
2705                    "GlobalSecondaryIndexes": [{
2706                        "IndexName": "byKey",
2707                        "KeySchema": [
2708                            {"AttributeName": "h", "KeyType": "HASH"},
2709                            {"AttributeName": "a", "KeyType": "RANGE"},
2710                            {"AttributeName": "b", "KeyType": "RANGE"}
2711                        ],
2712                        "Projection": {"ProjectionType": "ALL"}
2713                    }]
2714                }))
2715                .unwrap(),
2716            )
2717            .unwrap();
2718            for item in [
2719                serde_json::json!({"pk": {"S": "1"}, "h": {"S": "x"}, "a": {"S": "m"}, "b": {"S": "2"}}),
2720                serde_json::json!({"pk": {"S": "2"}, "h": {"S": "x"}, "a": {"S": "m"}, "b": {"S": "1"}}),
2721                serde_json::json!({"pk": {"S": "3"}, "h": {"S": "x"}, "a": {"S": "m"}}),
2722            ] {
2723                db.put_item(
2724                    serde_json::from_value(
2725                        serde_json::json!({"TableName": "Legacy", "Item": item}),
2726                    )
2727                    .unwrap(),
2728                )
2729                .unwrap();
2730            }
2731        }
2732
2733        // Put the index back the way a v9 build left it: keyed on the first
2734        // sort key attribute alone, and holding the item that lacks `b`.
2735        {
2736            let conn = Connection::open(&path).unwrap();
2737            conn.execute_batch(
2738                "DELETE FROM \"Legacy::gsi::byKey\";
2739                 INSERT INTO \"Legacy::gsi::byKey\" (gsi_pk, gsi_sk, table_pk, table_sk, item_json)
2740                    SELECT 'S:x', 'S:m', pk, sk, item_json FROM \"Legacy\";
2741                 UPDATE _config SET value = '9' WHERE key = 'schema_version';",
2742            )
2743            .unwrap();
2744        }
2745
2746        let storage = Storage::new(&path).unwrap();
2747        let version: String = storage
2748            .conn()
2749            .query_row(
2750                "SELECT value FROM _config WHERE key = 'schema_version'",
2751                [],
2752                |r| r.get(0),
2753            )
2754            .unwrap();
2755        assert_eq!(version, SCHEMA_VERSION.to_string());
2756        let rows: Vec<(String, String)> = {
2757            let mut stmt = storage
2758                .conn()
2759                .prepare(
2760                    "SELECT table_pk, gsi_sk FROM \"Legacy::gsi::byKey\" ORDER BY gsi_sk, table_pk",
2761                )
2762                .unwrap();
2763            stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2764                .unwrap()
2765                .collect::<rusqlite::Result<_>>()
2766                .unwrap()
2767        };
2768        let expected_key = |b: &str| {
2769            crate::actions::gsi::composite_key_string(&[
2770                &AttributeValue::S("m".into()),
2771                &AttributeValue::S(b.into()),
2772            ])
2773            .unwrap()
2774        };
2775        assert_eq!(
2776            rows,
2777            vec![
2778                ("S:2".to_string(), expected_key("1")),
2779                ("S:1".to_string(), expected_key("2")),
2780            ]
2781        );
2782    }
2783
2784    /// A path for a test database inside a fresh temporary directory, so the
2785    /// `-wal` and `-shm` files a WAL database (or a read-only restore from
2786    /// one) leaves beside it are removed with the directory.
2787    fn v9_snapshot_path() -> (tempfile::TempDir, String) {
2788        let dir = tempfile::tempdir().unwrap();
2789        let path = dir.path().join("legacy.db").to_str().unwrap().to_string();
2790        (dir, path)
2791    }
2792
2793    /// Write a database the way a v9 build left it at `path`. Table `Legacy`
2794    /// has three GSIs:
2795    ///
2796    /// - `byKey`, one HASH and two RANGE attributes, keyed on the first of
2797    ///   each kind and holding the item that lacks its second RANGE attribute;
2798    /// - `byTwoHash`, two HASH and one RANGE attribute, keyed on the first
2799    ///   HASH attribute alone;
2800    /// - `bySingle`, one attribute of each kind, stored as every version
2801    ///   stores it.
2802    ///
2803    /// Three named items come first, then `extra` more carrying every key
2804    /// attribute. Table `Vec` has a vector index and one item whose stored size
2805    /// is deliberately stale, the billed size plus an arbitrary amount, so the
2806    /// size recompute has a row to rewrite.
2807    fn write_v9_database(path: &str, extra: usize) {
2808        {
2809            let db = crate::Database::new(path).unwrap();
2810            db.create_table(
2811                serde_json::from_value(serde_json::json!({
2812                    "TableName": "Legacy",
2813                    "BillingMode": "PAY_PER_REQUEST",
2814                    "AttributeDefinitions": [
2815                        {"AttributeName": "pk", "AttributeType": "S"},
2816                        {"AttributeName": "h", "AttributeType": "S"},
2817                        {"AttributeName": "h2", "AttributeType": "S"},
2818                        {"AttributeName": "a", "AttributeType": "S"},
2819                        {"AttributeName": "b", "AttributeType": "S"}
2820                    ],
2821                    "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
2822                    "GlobalSecondaryIndexes": [
2823                        {
2824                            "IndexName": "byKey",
2825                            "KeySchema": [
2826                                {"AttributeName": "h", "KeyType": "HASH"},
2827                                {"AttributeName": "a", "KeyType": "RANGE"},
2828                                {"AttributeName": "b", "KeyType": "RANGE"}
2829                            ],
2830                            "Projection": {"ProjectionType": "ALL"}
2831                        },
2832                        {
2833                            "IndexName": "byTwoHash",
2834                            "KeySchema": [
2835                                {"AttributeName": "h", "KeyType": "HASH"},
2836                                {"AttributeName": "h2", "KeyType": "HASH"},
2837                                {"AttributeName": "a", "KeyType": "RANGE"}
2838                            ],
2839                            "Projection": {"ProjectionType": "ALL"}
2840                        },
2841                        {
2842                            "IndexName": "bySingle",
2843                            "KeySchema": [
2844                                {"AttributeName": "h", "KeyType": "HASH"},
2845                                {"AttributeName": "a", "KeyType": "RANGE"}
2846                            ],
2847                            "Projection": {"ProjectionType": "KEYS_ONLY"}
2848                        }
2849                    ]
2850                }))
2851                .unwrap(),
2852            )
2853            .unwrap();
2854            let named = [
2855                serde_json::json!({"pk": {"S": "1"}, "h": {"S": "x"}, "h2": {"S": "y"}, "a": {"S": "m"}, "b": {"S": "2"}}),
2856                serde_json::json!({"pk": {"S": "2"}, "h": {"S": "x"}, "h2": {"S": "y"}, "a": {"S": "m"}, "b": {"S": "1"}}),
2857                serde_json::json!({"pk": {"S": "3"}, "h": {"S": "x"}, "h2": {"S": "y"}, "a": {"S": "m"}}),
2858            ];
2859            let extras = (0..extra).map(|i| {
2860                serde_json::json!({
2861                    "pk": {"S": format!("e{i:05}")},
2862                    "h": {"S": "x"}, "h2": {"S": "y"}, "a": {"S": "m"},
2863                    "b": {"S": format!("{i:05}")}
2864                })
2865            });
2866            for item in named.into_iter().chain(extras) {
2867                db.put_item(
2868                    serde_json::from_value(
2869                        serde_json::json!({"TableName": "Legacy", "Item": item}),
2870                    )
2871                    .unwrap(),
2872                )
2873                .unwrap();
2874            }
2875            db.create_table(
2876                serde_json::from_value(serde_json::json!({
2877                    "TableName": "Vec",
2878                    "BillingMode": "PAY_PER_REQUEST",
2879                    "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
2880                    "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
2881                    "VectorIndexes": [{
2882                        "IndexName": "vix",
2883                        "VectorAttribute": {"AttributeName": "e"},
2884                        "Dimensions": 3,
2885                        "DistanceFunction": "COSINE",
2886                        "Projection": {"ProjectionType": "KEYS_ONLY"}
2887                    }]
2888                }))
2889                .unwrap(),
2890            )
2891            .unwrap();
2892            db.put_item(
2893                serde_json::from_value(serde_json::json!({
2894                    "TableName": "Vec",
2895                    "Item": {
2896                        "pk": {"S": "v"},
2897                        "e": {"L": [{"N": "0.125"}, {"N": "0.125"}, {"N": "0.125"}]}
2898                    }
2899                }))
2900                .unwrap(),
2901            )
2902            .unwrap();
2903        }
2904        let conn = Connection::open(path).unwrap();
2905        conn.execute_batch(
2906            "UPDATE \"Vec\" SET item_size = item_size + 7;
2907             DELETE FROM \"Legacy::gsi::byKey\";
2908             INSERT INTO \"Legacy::gsi::byKey\" (gsi_pk, gsi_sk, table_pk, table_sk, item_json)
2909                SELECT 'S:x', 'S:m', pk, sk, item_json FROM \"Legacy\";
2910             DELETE FROM \"Legacy::gsi::byTwoHash\";
2911             INSERT INTO \"Legacy::gsi::byTwoHash\" (gsi_pk, gsi_sk, table_pk, table_sk, item_json)
2912                SELECT 'S:x', 'S:m', pk, sk, item_json FROM \"Legacy\";
2913             UPDATE _config SET value = '9' WHERE key = 'schema_version';",
2914        )
2915        .unwrap();
2916    }
2917
2918    /// Every column of every row of a GSI table, in base key order.
2919    fn gsi_rows(conn: &Connection, index: &str) -> Vec<[String; 5]> {
2920        let mut stmt = conn
2921            .prepare(&format!(
2922                "SELECT gsi_pk, gsi_sk, table_pk, table_sk, item_json \
2923                 FROM \"Legacy::gsi::{index}\" ORDER BY table_pk, table_sk"
2924            ))
2925            .unwrap();
2926        stmt.query_map([], |r| {
2927            Ok([r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?])
2928        })
2929        .unwrap()
2930        .collect::<rusqlite::Result<_>>()
2931        .unwrap()
2932    }
2933
2934    fn recorded_version(conn: &Connection) -> String {
2935        conn.query_row(
2936            "SELECT value FROM _config WHERE key = 'schema_version'",
2937            [],
2938            |r| r.get(0),
2939        )
2940        .unwrap()
2941    }
2942
2943    /// Open `path` as [`Storage::new`] does, with the GSI insert and item
2944    /// size countdowns armed before the open's migrations run.
2945    fn open_with_faults(
2946        path: &str,
2947        gsi_insert: Option<u32>,
2948        item_size: Option<u32>,
2949    ) -> Result<Storage> {
2950        let mut storage = Storage {
2951            conn: Connection::open(path)?,
2952            metadata_cache: RefCell::new(HashMap::new()),
2953            gsi_insert_fault: RefCell::new(gsi_insert),
2954            item_size_fault: RefCell::new(item_size),
2955            clock: Arc::new(SystemClock),
2956        };
2957        storage.initialize()?;
2958        Ok(storage)
2959    }
2960
2961    #[test]
2962    fn an_interrupted_gsi_rebuild_leaves_the_database_as_it_was() {
2963        let (_dir, path) = v9_snapshot_path();
2964        write_v9_database(&path, 0);
2965        let before = {
2966            let conn = Connection::open(&path).unwrap();
2967            (gsi_rows(&conn, "byKey"), gsi_rows(&conn, "byTwoHash"))
2968        };
2969
2970        // The first index insert of the rebuild fails, after the rebuild has
2971        // already dropped and recreated the index it was filling.
2972        assert!(open_with_faults(&path, Some(0), None).is_err());
2973
2974        let conn = Connection::open(&path).unwrap();
2975        assert_eq!(recorded_version(&conn), "9");
2976        assert_eq!(
2977            (gsi_rows(&conn, "byKey"), gsi_rows(&conn, "byTwoHash")),
2978            before,
2979            "an interrupted rebuild must leave every index as it found it"
2980        );
2981        drop(conn);
2982
2983        // The next open runs the rebuild from the start.
2984        let storage = Storage::new(&path).unwrap();
2985        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
2986    }
2987
2988    #[test]
2989    fn an_interrupted_size_recompute_leaves_the_database_as_it_was() {
2990        let (_dir, path) = v9_snapshot_path();
2991        {
2992            let db = crate::Database::new(&path).unwrap();
2993            db.create_table(
2994                serde_json::from_value(serde_json::json!({
2995                    "TableName": "Vec",
2996                    "BillingMode": "PAY_PER_REQUEST",
2997                    "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
2998                    "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
2999                    "VectorIndexes": [{
3000                        "IndexName": "vix",
3001                        "VectorAttribute": {"AttributeName": "e"},
3002                        "Dimensions": 3,
3003                        "DistanceFunction": "COSINE",
3004                        "Projection": {"ProjectionType": "KEYS_ONLY"}
3005                    }]
3006                }))
3007                .unwrap(),
3008            )
3009            .unwrap();
3010            for pk in ["a", "b", "c"] {
3011                db.put_item(
3012                    serde_json::from_value(serde_json::json!({
3013                        "TableName": "Vec",
3014                        "Item": {
3015                            "pk": {"S": pk},
3016                            "e": {"L": [{"N": "0.125"}, {"N": "0.125"}, {"N": "0.125"}]}
3017                        }
3018                    }))
3019                    .unwrap(),
3020                )
3021                .unwrap();
3022            }
3023        }
3024        // Back to how a v10 build stored them: a different size per row.
3025        let sizes = |conn: &Connection| -> Vec<(String, i64)> {
3026            let mut stmt = conn
3027                .prepare("SELECT pk, item_size FROM \"Vec\" ORDER BY pk")
3028                .unwrap();
3029            stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3030                .unwrap()
3031                .collect::<rusqlite::Result<_>>()
3032                .unwrap()
3033        };
3034        let before = {
3035            let conn = Connection::open(&path).unwrap();
3036            conn.execute_batch(
3037                "UPDATE \"Vec\" SET item_size = item_size + 7;
3038                 UPDATE _config SET value = '10' WHERE key = 'schema_version';",
3039            )
3040            .unwrap();
3041            sizes(&conn)
3042        };
3043
3044        // One row is rewritten, then the second rewrite fails.
3045        assert!(open_with_faults(&path, None, Some(1)).is_err());
3046
3047        let conn = Connection::open(&path).unwrap();
3048        assert_eq!(recorded_version(&conn), "10");
3049        assert_eq!(
3050            sizes(&conn),
3051            before,
3052            "an interrupted recompute must leave every stored size as it found it"
3053        );
3054    }
3055
3056    fn vec_size(conn: &Connection) -> i64 {
3057        conn.query_row("SELECT item_size FROM \"Vec\" WHERE pk = 'S:v'", [], |r| {
3058            r.get(0)
3059        })
3060        .unwrap()
3061    }
3062
3063    #[test]
3064    fn a_second_open_runs_no_migration() {
3065        let (_dir, path) = v9_snapshot_path();
3066        write_v9_database(&path, 0);
3067        drop(Storage::new(&path).unwrap());
3068        // The fixture gives each migration a row to write, and either would
3069        // fail at its first write, so an open that ran one fails.
3070        let storage = open_with_faults(&path, Some(0), Some(0)).unwrap();
3071        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3072    }
3073
3074    #[test]
3075    fn a_recompute_interrupted_after_the_rebuild_leaves_version_10() {
3076        let (_dir, path) = v9_snapshot_path();
3077        write_v9_database(&path, 0);
3078        let stale_size = vec_size(&Connection::open(&path).unwrap());
3079
3080        // The GSI rebuild runs and commits; the size recompute then fails at
3081        // its first row.
3082        assert!(open_with_faults(&path, None, Some(0)).is_err());
3083
3084        let conn = Connection::open(&path).unwrap();
3085        assert_eq!(
3086            recorded_version(&conn),
3087            "10",
3088            "the rebuild's step is kept and the recompute's is not"
3089        );
3090        assert_eq!(vec_size(&conn), stale_size, "the recompute is rolled back");
3091        let composite = crate::actions::gsi::composite_key_string(&[
3092            &AttributeValue::S("m".into()),
3093            &AttributeValue::S("2".into()),
3094        ])
3095        .unwrap();
3096        assert!(
3097            gsi_rows(&conn, "byKey")
3098                .iter()
3099                .any(|[_, gsi_sk, _, _, _]| *gsi_sk == composite),
3100            "the rebuilt index is kept"
3101        );
3102        drop(conn);
3103
3104        // The next open runs only the recompute.
3105        let storage = open_with_faults(&path, Some(0), None).unwrap();
3106        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3107        assert_eq!(vec_size(storage.conn()), stale_size - 7);
3108    }
3109
3110    #[test]
3111    fn the_rebuild_keeps_single_attribute_indexes_and_keys_every_hash_attribute() {
3112        let (_dir, path) = v9_snapshot_path();
3113        write_v9_database(&path, 0);
3114        let single_before = gsi_rows(&Connection::open(&path).unwrap(), "bySingle");
3115
3116        let storage = Storage::new(&path).unwrap();
3117        assert_eq!(
3118            gsi_rows(storage.conn(), "bySingle"),
3119            single_before,
3120            "a single-attribute index is stored the same way and is left alone"
3121        );
3122        let two_hash = crate::actions::gsi::composite_key_string(&[
3123            &AttributeValue::S("x".into()),
3124            &AttributeValue::S("y".into()),
3125        ])
3126        .unwrap();
3127        let keys: Vec<(String, String)> = gsi_rows(storage.conn(), "byTwoHash")
3128            .into_iter()
3129            .map(|[gsi_pk, _, table_pk, _, _]| (gsi_pk, table_pk))
3130            .collect();
3131        assert_eq!(
3132            keys,
3133            ["S:1", "S:2", "S:3"]
3134                .map(|pk| (two_hash.clone(), pk.to_string()))
3135                .to_vec()
3136        );
3137    }
3138
3139    #[test]
3140    fn the_rebuild_runs_past_the_first_backfill_batch() {
3141        let tmp = tempfile::NamedTempFile::new().unwrap();
3142        let path = tmp.path().to_str().unwrap().to_string();
3143        // The backfill reads the base table 1,000 rows at a time.
3144        write_v9_database(&path, 1_005);
3145
3146        let storage = Storage::new(&path).unwrap();
3147        // Every item but the one lacking `b` is in `byKey`; every item is in
3148        // `byTwoHash`.
3149        assert_eq!(gsi_rows(storage.conn(), "byKey").len(), 2 + 1_005);
3150        assert_eq!(gsi_rows(storage.conn(), "byTwoHash").len(), 3 + 1_005);
3151    }
3152
3153    #[test]
3154    fn a_restored_snapshot_from_an_earlier_version_is_brought_up_to_date() {
3155        let (_dir, path) = v9_snapshot_path();
3156        write_v9_database(&path, 0);
3157
3158        let mut storage = Storage::memory().unwrap();
3159        storage.restore_from(&path).unwrap();
3160
3161        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3162        let sort_keys: Vec<String> = gsi_rows(storage.conn(), "byKey")
3163            .into_iter()
3164            .map(|[_, gsi_sk, _, _, _]| gsi_sk)
3165            .collect();
3166        let composite = |b: &str| {
3167            crate::actions::gsi::composite_key_string(&[
3168                &AttributeValue::S("m".into()),
3169                &AttributeValue::S(b.into()),
3170            ])
3171            .unwrap()
3172        };
3173        assert_eq!(sort_keys, vec![composite("2"), composite("1")]);
3174    }
3175
3176    /// A table of this database's own, so a restore that should have left the
3177    /// database alone can be seen to have done so.
3178    fn mark_live(storage: &Storage) {
3179        storage
3180            .conn()
3181            .execute_batch(
3182                "CREATE TABLE live_marker (v TEXT); INSERT INTO live_marker VALUES ('live');",
3183            )
3184            .unwrap();
3185    }
3186
3187    fn still_live(storage: &Storage) -> bool {
3188        storage
3189            .conn()
3190            .query_row("SELECT v FROM live_marker", [], |r| r.get::<_, String>(0))
3191            .map(|v| v == "live")
3192            .unwrap_or(false)
3193    }
3194
3195    #[test]
3196    fn restoring_from_a_missing_file_errors_and_leaves_the_database_alone() {
3197        let dir = tempfile::tempdir().unwrap();
3198        let missing = dir.path().join("no-such-snapshot.db");
3199        let missing = missing.to_str().unwrap();
3200        let mut storage = Storage::memory().unwrap();
3201        mark_live(&storage);
3202
3203        assert!(storage.restore_from(missing).is_err());
3204        assert!(
3205            !std::path::Path::new(missing).exists(),
3206            "a mistyped path must not be created"
3207        );
3208        assert!(still_live(&storage), "the database must be left as it was");
3209    }
3210
3211    #[test]
3212    fn a_restore_whose_migration_fails_leaves_the_database_alone() {
3213        let (_dir, path) = v9_snapshot_path();
3214        write_v9_database(&path, 0);
3215
3216        let mut storage = Storage::memory().unwrap();
3217        mark_live(&storage);
3218        // The restored snapshot's GSI rebuild fails at its first insert.
3219        storage.fail_gsi_insert_after(0);
3220        let err = storage.restore_from(&path).unwrap_err().to_string();
3221        assert!(
3222            err.contains("injected GSI insert failure"),
3223            "the restore must fail in the migration, not elsewhere: {err}"
3224        );
3225
3226        assert!(still_live(&storage), "the database must be left as it was");
3227        let legacy: i64 = storage
3228            .conn()
3229            .query_row(
3230                "SELECT count(*) FROM sqlite_master WHERE name = 'Legacy'",
3231                [],
3232                |r| r.get(0),
3233            )
3234            .unwrap();
3235        assert_eq!(legacy, 0, "none of the snapshot may be left behind");
3236        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3237    }
3238
3239    #[test]
3240    fn restoring_from_something_that_is_not_a_snapshot_errors_and_leaves_the_database_alone() {
3241        let dir = tempfile::tempdir().unwrap();
3242        let empty = dir.path().join("zero-bytes.db");
3243        std::fs::write(&empty, b"").unwrap();
3244        let foreign = dir.path().join("foreign.db");
3245        Connection::open(&foreign)
3246            .unwrap()
3247            .execute_batch("CREATE TABLE notes (body TEXT); INSERT INTO notes VALUES ('x');")
3248            .unwrap();
3249
3250        for source in [
3251            empty.to_str().unwrap(),
3252            foreign.to_str().unwrap(),
3253            ":memory:",
3254        ] {
3255            let mut storage = Storage::memory().unwrap();
3256            mark_live(&storage);
3257            let err = storage.restore_from(source).unwrap_err().to_string();
3258            assert!(
3259                err.contains("not a Dynoxide snapshot"),
3260                "{source}: refused for the wrong reason: {err}"
3261            );
3262            assert!(
3263                still_live(&storage),
3264                "{source}: the database must be left as it was"
3265            );
3266        }
3267    }
3268
3269    #[test]
3270    fn a_backup_gives_up_on_a_source_that_stays_locked() {
3271        let dir = tempfile::tempdir().unwrap();
3272        let path = dir.path().join("locked.db");
3273        let path = path.to_str().unwrap().to_string();
3274        {
3275            let conn = Connection::open(&path).unwrap();
3276            conn.execute_batch(
3277                "PRAGMA journal_mode = DELETE; CREATE TABLE t (v); INSERT INTO t VALUES (1);",
3278            )
3279            .unwrap();
3280        }
3281        // Another connection holds the file exclusively for the whole copy.
3282        let holder = Connection::open(&path).unwrap();
3283        holder
3284            .execute_batch("BEGIN EXCLUSIVE; INSERT INTO t VALUES (2);")
3285            .unwrap();
3286
3287        // The copier keeps the connection's default busy timeout, so a busy
3288        // step waits inside SQLite too, and the patience counts that time.
3289        let (done, outcome) = std::sync::mpsc::channel();
3290        let copier_path = path.clone();
3291        std::thread::spawn(move || {
3292            let source = Connection::open(&copier_path).unwrap();
3293            let mut dest = Connection::open_in_memory().unwrap();
3294            let result = rusqlite::backup::Backup::new(&source, &mut dest)
3295                .map_err(DynoxideError::from)
3296                .and_then(|backup| run_backup(&backup));
3297            let _ = done.send(result.map_err(|e| e.to_string()));
3298        });
3299
3300        // The first busy step waits out SQLite's busy timeout (5 s), which
3301        // uses up the whole patience, so the copy gives up after about five
3302        // seconds.
3303        let result = outcome
3304            .recv_timeout(std::time::Duration::from_secs(15))
3305            .expect("a backup of a locked source must give up within its patience");
3306        let err = result.expect_err("the source stayed locked, so the copy must fail");
3307        assert!(err.contains("locked") || err.contains("busy"), "{err}");
3308        drop(holder);
3309    }
3310
3311    /// A source locked briefly by another connection, released after `hold`.
3312    fn briefly_locked_source(hold: std::time::Duration) -> (tempfile::TempDir, String) {
3313        let dir = tempfile::tempdir().unwrap();
3314        let path = dir.path().join("briefly-locked.db");
3315        let path = path.to_str().unwrap().to_string();
3316        {
3317            let conn = Connection::open(&path).unwrap();
3318            conn.execute_batch(
3319                "PRAGMA journal_mode = DELETE; CREATE TABLE t (v); INSERT INTO t VALUES (1);",
3320            )
3321            .unwrap();
3322        }
3323        let (locked, is_locked) = std::sync::mpsc::channel();
3324        let holder_path = path.clone();
3325        std::thread::spawn(move || {
3326            let holder = Connection::open(&holder_path).unwrap();
3327            holder
3328                .execute_batch("BEGIN EXCLUSIVE; INSERT INTO t VALUES (2);")
3329                .unwrap();
3330            let _ = locked.send(());
3331            std::thread::sleep(hold);
3332            holder.execute_batch("COMMIT").unwrap();
3333        });
3334        is_locked.recv().unwrap();
3335        (dir, path)
3336    }
3337
3338    fn copy_rows(source: &Connection) -> Result<i64> {
3339        let mut dest = Connection::open_in_memory().unwrap();
3340        {
3341            let backup = rusqlite::backup::Backup::new(source, &mut dest)?;
3342            run_backup(&backup)?;
3343        }
3344        Ok(dest.query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0))?)
3345    }
3346
3347    #[test]
3348    fn a_backup_waits_out_a_short_lock() {
3349        let (_dir, path) = briefly_locked_source(std::time::Duration::from_millis(50));
3350        // SQLite's own busy wait covers a short lock.
3351        let source = Connection::open(&path).unwrap();
3352        assert_eq!(
3353            copy_rows(&source).unwrap(),
3354            2,
3355            "the copy sees the committed row"
3356        );
3357    }
3358
3359    #[test]
3360    fn a_backup_retries_a_lock_that_clears() {
3361        let (_dir, path) = briefly_locked_source(std::time::Duration::from_millis(50));
3362        // With no busy wait inside SQLite, the retry loop is what waits.
3363        let source = Connection::open(&path).unwrap();
3364        source.busy_timeout(std::time::Duration::ZERO).unwrap();
3365        assert_eq!(
3366            copy_rows(&source).unwrap(),
3367            2,
3368            "the copy sees the committed row"
3369        );
3370    }
3371
3372    #[test]
3373    fn a_backup_watch_times_a_busy_spell_from_the_step_that_found_it() {
3374        let patience = std::time::Duration::from_secs(5);
3375        let t0 = std::time::Instant::now();
3376        let at = |ms: u64| t0 + std::time::Duration::from_millis(ms);
3377        let mut watch = BackupWatch::default();
3378        // The first busy step spent 4.9 s waiting inside SQLite: still patient.
3379        assert_eq!(
3380            watch.observe(BackupStep::Busy, at(0), at(4_900), patience),
3381            BackupNext::Pause
3382        );
3383        // The next busy step ends past five seconds from the first one's start.
3384        assert_eq!(
3385            watch.observe(BackupStep::Busy, at(4_910), at(5_000), patience),
3386            BackupNext::GiveUpLocked
3387        );
3388        // Progress ends a spell, so the next busy spell is timed afresh.
3389        let mut watch = BackupWatch::default();
3390        assert_eq!(
3391            watch.observe(BackupStep::Busy, at(0), at(4_000), patience),
3392            BackupNext::Pause
3393        );
3394        assert_eq!(
3395            watch.observe(
3396                BackupStep::More { remaining: 10 },
3397                at(4_010),
3398                at(4_020),
3399                patience
3400            ),
3401            BackupNext::Step
3402        );
3403        assert_eq!(
3404            watch.observe(BackupStep::Busy, at(4_030), at(8_000), patience),
3405            BackupNext::Pause
3406        );
3407        assert_eq!(
3408            watch.observe(BackupStep::Done, at(8_010), at(8_020), patience),
3409            BackupNext::Finish
3410        );
3411    }
3412
3413    #[test]
3414    fn a_backup_watch_gives_up_on_a_copy_that_keeps_starting_over() {
3415        let patience = std::time::Duration::from_secs(5);
3416        let t = std::time::Instant::now();
3417        let mut watch = BackupWatch::default();
3418        assert_eq!(
3419            watch.observe(BackupStep::More { remaining: 100 }, t, t, patience),
3420            BackupNext::Step
3421        );
3422        // Steady progress isn't a restart.
3423        assert_eq!(
3424            watch.observe(BackupStep::More { remaining: 50 }, t, t, patience),
3425            BackupNext::Step
3426        );
3427        // Each jump back up is a restart; the limit's worth are tolerated,
3428        // each followed by a pause.
3429        for _ in 0..BACKUP_RESTART_LIMIT {
3430            assert_eq!(
3431                watch.observe(BackupStep::More { remaining: 100 }, t, t, patience),
3432                BackupNext::Pause
3433            );
3434            assert_eq!(
3435                watch.observe(BackupStep::More { remaining: 50 }, t, t, patience),
3436                BackupNext::Step
3437            );
3438        }
3439        assert_eq!(
3440            watch.observe(BackupStep::More { remaining: 100 }, t, t, patience),
3441            BackupNext::GiveUpRestarting
3442        );
3443    }
3444
3445    #[test]
3446    fn a_backup_watch_counts_a_copy_that_starts_over_at_the_same_place() {
3447        // A source changed between every step starts over each time, and the
3448        // first hundred pages copied again leave the same count remaining as
3449        // the step before: SQLite reports 302, 302, 302... for a 402-page
3450        // source, so a restart is a count that doesn't fall.
3451        let patience = std::time::Duration::from_secs(5);
3452        let t = std::time::Instant::now();
3453        let mut watch = BackupWatch::default();
3454        assert_eq!(
3455            watch.observe(BackupStep::More { remaining: 302 }, t, t, patience),
3456            BackupNext::Step
3457        );
3458        // Each restart within the limit pauses before the next step, so a
3459        // short burst of writes to the source doesn't use the limit up at once.
3460        for restart in 1..=BACKUP_RESTART_LIMIT {
3461            assert_eq!(
3462                watch.observe(BackupStep::More { remaining: 302 }, t, t, patience),
3463                BackupNext::Pause,
3464                "restart {restart}"
3465            );
3466        }
3467        assert_eq!(
3468            watch.observe(BackupStep::More { remaining: 302 }, t, t, patience),
3469            BackupNext::GiveUpRestarting
3470        );
3471    }
3472
3473    #[test]
3474    fn a_backup_of_a_source_changed_between_steps_gives_up() {
3475        let dir = tempfile::tempdir().unwrap();
3476        let path = dir.path().join("changing.db");
3477        let path = path.to_str().unwrap().to_string();
3478        let writer = Connection::open(&path).unwrap();
3479        writer
3480            .execute_batch(
3481                "PRAGMA journal_mode = DELETE; CREATE TABLE t (v); \
3482                 WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 400) \
3483                 INSERT INTO t SELECT zeroblob(3000) FROM n;",
3484            )
3485            .unwrap();
3486        let source = Connection::open(&path).unwrap();
3487        let mut dest = Connection::open_in_memory().unwrap();
3488        let backup = rusqlite::backup::Backup::new(&source, &mut dest).unwrap();
3489        let patience = std::time::Duration::from_secs(5);
3490        let mut watch = BackupWatch::default();
3491        let mut outcome = None;
3492        // Another connection changes the source between every pair of steps,
3493        // which is what makes SQLite start the copy over. The new value must
3494        // differ from the old: SQLite leaves a page alone when a write would
3495        // put back the bytes already there, and the copy then carries on.
3496        for step in 0..(2 * BACKUP_RESTART_LIMIT + 10) {
3497            let t = std::time::Instant::now();
3498            match watch.observe(backup_step(&backup).unwrap(), t, t, patience) {
3499                BackupNext::Step | BackupNext::Pause => {}
3500                other => {
3501                    outcome = Some((step, other));
3502                    break;
3503                }
3504            }
3505            writer
3506                .execute("UPDATE t SET v = randomblob(3000) WHERE rowid = 1", [])
3507                .unwrap();
3508        }
3509        let (step, next) = outcome.expect("a copy that keeps starting over must give up");
3510        assert_eq!(next, BackupNext::GiveUpRestarting);
3511        assert!(
3512            step <= BACKUP_RESTART_LIMIT + 1,
3513            "gave up after {step} steps, not at the restart limit"
3514        );
3515    }
3516
3517    #[test]
3518    fn a_restore_leaves_its_source_file_unchanged() {
3519        let (_dir, path) = v9_snapshot_path();
3520        write_v9_database(&path, 0);
3521        let before = std::fs::read(&path).unwrap();
3522
3523        let mut storage = Storage::memory().unwrap();
3524        storage.restore_from(&path).unwrap();
3525        drop(storage);
3526
3527        assert_eq!(std::fs::read(&path).unwrap(), before);
3528        assert_eq!(recorded_version(&Connection::open(&path).unwrap()), "9");
3529    }
3530
3531    fn busy_timeout_ms(conn: &Connection) -> i64 {
3532        conn.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
3533            .unwrap()
3534    }
3535
3536    /// The busy timeout the last open on this thread ran its migrations
3537    /// under, and the one the connection it handed out has.
3538    fn open_and_after(storage: &Storage) -> (Option<i64>, i64) {
3539        (
3540            OPENED_WITH_BUSY_TIMEOUT_MS.take(),
3541            busy_timeout_ms(storage.conn()),
3542        )
3543    }
3544
3545    /// A database file waits 30 seconds for a lock another process holds
3546    /// while it opens and brings the file up to date, and five once it is
3547    /// open, so a request held up by another process gives up after five
3548    /// seconds rather than holding every other request up behind it.
3549    #[test]
3550    fn a_database_file_waits_thirty_seconds_for_a_lock_while_opening_and_five_after() {
3551        let (_dir, path) = v9_snapshot_path();
3552        let storage = Storage::new(&path).unwrap();
3553        assert_eq!(open_and_after(&storage), (Some(30_000), 5_000));
3554        // An in-memory database has no other process to wait for, and keeps
3555        // the connection's default throughout.
3556        let memory = Storage::memory().unwrap();
3557        assert_eq!(open_and_after(&memory), (Some(5_000), 5_000));
3558    }
3559
3560    #[cfg(feature = "_has-encryption")]
3561    #[test]
3562    fn an_encrypted_database_file_waits_thirty_seconds_for_a_lock_while_opening_and_five_after() {
3563        const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
3564        let (_dir, path) = v9_snapshot_path();
3565        let storage = Storage::new_encrypted(&path, KEY).unwrap();
3566        assert_eq!(open_and_after(&storage), (Some(30_000), 5_000));
3567    }
3568
3569    /// A second process opening a file while the first holds its write lock
3570    /// part way through a migration waits for the lock, then finds the
3571    /// migration done and leaves it alone. The pause before the first commits
3572    /// only makes it likely the second is already waiting; the outcome is the
3573    /// same either way, and how long the second will wait is the test above.
3574    #[test]
3575    fn a_second_open_waits_for_a_migration_another_holds_then_finds_it_done() {
3576        let (_dir, path) = v9_snapshot_path();
3577        write_v9_database(&path, 0);
3578        let stale_index = gsi_rows(&Connection::open(&path).unwrap(), "byKey");
3579
3580        // The first open, part way through: it holds the write lock and has
3581        // finished the work and recorded the version, but not yet committed.
3582        // Its work leaves a size no recompute would give, so a second
3583        // recompute would show.
3584        let first = Connection::open(&path).unwrap();
3585        first.execute_batch("BEGIN IMMEDIATE").unwrap();
3586        first
3587            .execute_batch(&format!(
3588                "UPDATE \"Vec\" SET item_size = 1;
3589                 UPDATE _config SET value = '{SCHEMA_VERSION}' WHERE key = 'schema_version';"
3590            ))
3591            .unwrap();
3592
3593        let (started, starting) = std::sync::mpsc::channel();
3594        let second_path = path.clone();
3595        let second = std::thread::spawn(move || {
3596            started.send(()).unwrap();
3597            let storage = Storage::new(&second_path)?;
3598            Ok::<_, DynoxideError>((
3599                recorded_version(storage.conn()),
3600                vec_size(storage.conn()),
3601                gsi_rows(storage.conn(), "byKey"),
3602            ))
3603        });
3604        starting.recv().unwrap();
3605        std::thread::sleep(std::time::Duration::from_millis(200));
3606        first.execute_batch("COMMIT").unwrap();
3607
3608        let (version, size, index) = second.join().unwrap().unwrap();
3609        assert_eq!(version, SCHEMA_VERSION.to_string());
3610        assert_eq!(
3611            size, 1,
3612            "the second open must not recompute the sizes again"
3613        );
3614        assert_eq!(
3615            index, stale_index,
3616            "the second open must not rebuild the index the first left"
3617        );
3618    }
3619
3620    /// A database that records no version, as the browser build left one
3621    /// from Dynoxide 0.10 to 0.13, is taken through every step on open rather
3622    /// than taken to be current. It already has every column and index the
3623    /// earlier steps add, so this also shows each of them leaves those alone.
3624    #[test]
3625    fn a_database_that_records_no_version_is_brought_up_to_date() {
3626        let (_dir, path) = v9_snapshot_path();
3627        write_v9_database(&path, 0);
3628        let table_id = |conn: &Connection| -> String {
3629            conn.query_row(
3630                "SELECT table_id FROM _tables WHERE table_name = 'Legacy'",
3631                [],
3632                |r| r.get(0),
3633            )
3634            .unwrap()
3635        };
3636        let (stale_size, id_before) = {
3637            let conn = Connection::open(&path).unwrap();
3638            conn.execute("DELETE FROM _config WHERE key = 'schema_version'", [])
3639                .unwrap();
3640            (vec_size(&conn), table_id(&conn))
3641        };
3642
3643        let storage = Storage::new(&path).unwrap();
3644        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3645        assert_eq!(
3646            vec_size(storage.conn()),
3647            stale_size - 7,
3648            "vector table sizes are recomputed"
3649        );
3650        let composite = crate::actions::gsi::composite_key_string(&[
3651            &AttributeValue::S("m".into()),
3652            &AttributeValue::S("2".into()),
3653        ])
3654        .unwrap();
3655        assert!(
3656            gsi_rows(storage.conn(), "byKey")
3657                .iter()
3658                .any(|[_, gsi_sk, _, _, _]| *gsi_sk == composite),
3659            "multi-attribute indexes are rebuilt"
3660        );
3661        assert_eq!(table_id(storage.conn()), id_before, "a table keeps its id");
3662        drop(storage);
3663
3664        let db = crate::Database::new(&path).unwrap();
3665        let item = db
3666            .get_item(
3667                serde_json::from_value(serde_json::json!({
3668                    "TableName": "Legacy",
3669                    "Key": {"pk": {"S": "1"}}
3670                }))
3671                .unwrap(),
3672            )
3673            .unwrap()
3674            .item;
3675        assert!(item.is_some(), "the items read back");
3676    }
3677
3678    /// Record a version one past this build's in the database at `conn`, as a
3679    /// newer build would have, and fold the WAL into the file, so the file's
3680    /// bytes are the whole database.
3681    fn stamp_newer(conn: &Connection) {
3682        conn.execute(
3683            "UPDATE _config SET value = ?1 WHERE key = 'schema_version'",
3684            [(SCHEMA_VERSION + 1).to_string()],
3685        )
3686        .unwrap();
3687        conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
3688            .unwrap();
3689    }
3690
3691    /// A database at `path` as a newer build would have left it.
3692    fn write_newer_database(path: &str) {
3693        drop(crate::Database::new(path).unwrap());
3694        stamp_newer(&Connection::open(path).unwrap());
3695    }
3696
3697    fn assert_refused_as_newer(err: DynoxideError) {
3698        let message = err.to_string();
3699        assert_eq!(
3700            message,
3701            crate::storage_backend::schema::newer_than_this_build(SCHEMA_VERSION + 1)
3702        );
3703        assert!(
3704            message.contains(&format!("schema version {}", SCHEMA_VERSION + 1))
3705                && message.contains(&format!("up to schema version {SCHEMA_VERSION}")),
3706            "{message}"
3707        );
3708    }
3709
3710    #[test]
3711    fn a_database_a_newer_build_wrote_is_refused_and_left_as_it_was() {
3712        let (_dir, path) = v9_snapshot_path();
3713        write_newer_database(&path);
3714        let before = std::fs::read(&path).unwrap();
3715
3716        assert_refused_as_newer(Storage::new(&path).map(drop).unwrap_err());
3717        assert_refused_as_newer(crate::Database::new(&path).map(drop).unwrap_err());
3718
3719        assert_eq!(std::fs::read(&path).unwrap(), before);
3720        assert_eq!(
3721            recorded_version(&Connection::open(&path).unwrap()),
3722            (SCHEMA_VERSION + 1).to_string()
3723        );
3724    }
3725
3726    #[cfg(feature = "_has-encryption")]
3727    #[test]
3728    fn an_encrypted_database_a_newer_build_wrote_is_refused_and_left_as_it_was() {
3729        const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
3730        let (_dir, path) = v9_snapshot_path();
3731        drop(crate::Database::new_encrypted(&path, KEY).unwrap());
3732        {
3733            let conn = Connection::open(&path).unwrap();
3734            conn.pragma_update(None, "key", format!("x'{KEY}'"))
3735                .unwrap();
3736            stamp_newer(&conn);
3737        }
3738        let before = std::fs::read(&path).unwrap();
3739
3740        assert_refused_as_newer(
3741            crate::Database::new_encrypted(&path, KEY)
3742                .map(drop)
3743                .unwrap_err(),
3744        );
3745
3746        assert_eq!(std::fs::read(&path).unwrap(), before);
3747    }
3748
3749    #[test]
3750    fn a_restore_refuses_a_snapshot_a_newer_build_wrote() {
3751        let (_dir, path) = v9_snapshot_path();
3752        write_newer_database(&path);
3753        let before = std::fs::read(&path).unwrap();
3754
3755        let mut storage = Storage::memory().unwrap();
3756        mark_live(&storage);
3757        assert_refused_as_newer(storage.restore_from(&path).unwrap_err());
3758        assert!(still_live(&storage), "the database must be left as it was");
3759        assert_eq!(recorded_version(storage.conn()), SCHEMA_VERSION.to_string());
3760        drop(storage);
3761
3762        assert_eq!(std::fs::read(&path).unwrap(), before);
3763    }
3764
3765    #[test]
3766    fn test_wal_mode_enabled() {
3767        let storage = test_storage();
3768        let mode: String = storage
3769            .conn()
3770            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
3771            .unwrap();
3772        // In-memory databases may report "memory" instead of "wal"
3773        assert!(mode == "wal" || mode == "memory", "Got mode: {mode}");
3774    }
3775
3776    // FNV-1a parity contract. The wasm bridge hashes through js/fnv1a.js, which
3777    // parallel-scan segment assignment relies on agreeing with this native
3778    // scalar byte-for-byte. These vectors are the shared truth: the JS unit test
3779    // js/fnv1a.test.js asserts the same inputs hash to the same values, pinning
3780    // the two implementations together.
3781    #[test]
3782    fn fnv1a_hash_matches_known_vectors() {
3783        let storage = test_storage();
3784        let cases: [(&str, i64); 6] = [
3785            ("", 2166136261),
3786            ("a", 3826002220),
3787            ("u#1", 2199603432),
3788            ("artist#42", 2385694177),
3789            ("café", 2821410889),
3790            ("tenant#9007199254740993", 2022216178),
3791        ];
3792        for (input, expected) in cases {
3793            let got: i64 = storage
3794                .conn()
3795                .query_row("SELECT fnv1a_hash(?1)", [input], |row| row.get(0))
3796                .unwrap();
3797            assert_eq!(got, expected, "fnv1a_hash({input:?})");
3798        }
3799    }
3800
3801    #[test]
3802    fn test_table_metadata_crud() {
3803        let storage = test_storage();
3804
3805        // Initially no tables
3806        assert!(!storage.table_exists("TestTable").unwrap());
3807        assert!(storage.list_table_names().unwrap().is_empty());
3808
3809        // Insert metadata
3810        storage
3811            .insert_table_metadata(&CreateTableMetadata {
3812                table_name: "TestTable",
3813                key_schema: r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
3814                attribute_definitions: r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
3815                created_at: 1000000,
3816                ..Default::default()
3817            })
3818            .unwrap();
3819
3820        assert!(storage.table_exists("TestTable").unwrap());
3821        assert_eq!(storage.list_table_names().unwrap(), vec!["TestTable"]);
3822
3823        // Get metadata
3824        let meta = storage.get_table_metadata("TestTable").unwrap().unwrap();
3825        assert_eq!(meta.table_name, "TestTable");
3826        assert_eq!(meta.table_status, "ACTIVE");
3827        assert_eq!(meta.created_at, 1000000);
3828
3829        // Delete metadata
3830        assert!(storage.delete_table_metadata("TestTable").unwrap());
3831        assert!(!storage.table_exists("TestTable").unwrap());
3832    }
3833
3834    #[test]
3835    fn test_create_and_drop_data_table() {
3836        let storage = test_storage();
3837        storage.create_data_table("MyTable").unwrap();
3838
3839        // Should be able to insert into it
3840        storage
3841            .put_item("MyTable", "pk1", "", r#"{"pk":{"S":"pk1"}}"#, 10)
3842            .unwrap();
3843
3844        let item = storage.get_item("MyTable", "pk1", "").unwrap();
3845        assert!(item.is_some());
3846
3847        storage.drop_data_table("MyTable").unwrap();
3848    }
3849
3850    #[test]
3851    fn test_item_crud() {
3852        let storage = test_storage();
3853        storage.create_data_table("Items").unwrap();
3854
3855        // Put item
3856        let old = storage
3857            .put_item(
3858                "Items",
3859                "user#1",
3860                "profile",
3861                r#"{"name":{"S":"Alice"}}"#,
3862                20,
3863            )
3864            .unwrap();
3865        assert!(old.is_none()); // No previous item
3866
3867        // Get item
3868        let item = storage.get_item("Items", "user#1", "profile").unwrap();
3869        assert_eq!(item.unwrap(), r#"{"name":{"S":"Alice"}}"#);
3870
3871        // Replace item (returns old)
3872        let old = storage
3873            .put_item("Items", "user#1", "profile", r#"{"name":{"S":"Bob"}}"#, 18)
3874            .unwrap();
3875        assert_eq!(old.unwrap(), r#"{"name":{"S":"Alice"}}"#);
3876
3877        // Delete item
3878        let deleted = storage.delete_item("Items", "user#1", "profile").unwrap();
3879        assert_eq!(deleted.unwrap(), r#"{"name":{"S":"Bob"}}"#);
3880
3881        // Item should be gone
3882        assert!(
3883            storage
3884                .get_item("Items", "user#1", "profile")
3885                .unwrap()
3886                .is_none()
3887        );
3888    }
3889
3890    #[test]
3891    fn test_query_items() {
3892        let storage = test_storage();
3893        storage.create_data_table("Orders").unwrap();
3894
3895        // Insert several items for the same partition key
3896        for i in 1..=5 {
3897            let sk = format!("order#{i:03}");
3898            let json = format!(r#"{{"id":{{"N":"{i}"}}}}"#);
3899            storage
3900                .put_item("Orders", "user#1", &sk, &json, 10)
3901                .unwrap();
3902        }
3903
3904        // Query all for partition key
3905        let results = storage
3906            .query_items(
3907                "Orders",
3908                "user#1",
3909                &QueryParams {
3910                    forward: true,
3911                    ..Default::default()
3912                },
3913            )
3914            .unwrap();
3915        assert_eq!(results.len(), 5);
3916        assert_eq!(results[0].1, "order#001"); // Sorted ascending
3917
3918        // Query with limit
3919        let results = storage
3920            .query_items(
3921                "Orders",
3922                "user#1",
3923                &QueryParams {
3924                    forward: true,
3925                    limit: Some(2),
3926                    ..Default::default()
3927                },
3928            )
3929            .unwrap();
3930        assert_eq!(results.len(), 2);
3931
3932        // Query reverse
3933        let results = storage
3934            .query_items(
3935                "Orders",
3936                "user#1",
3937                &QueryParams {
3938                    forward: false,
3939                    limit: Some(2),
3940                    ..Default::default()
3941                },
3942            )
3943            .unwrap();
3944        assert_eq!(results.len(), 2);
3945        assert_eq!(results[0].1, "order#005"); // Sorted descending
3946    }
3947
3948    #[test]
3949    fn test_scan_items() {
3950        let storage = test_storage();
3951        storage.create_data_table("ScanTest").unwrap();
3952
3953        storage.put_item("ScanTest", "a", "1", r#"{}"#, 2).unwrap();
3954        storage.put_item("ScanTest", "b", "2", r#"{}"#, 2).unwrap();
3955        storage.put_item("ScanTest", "c", "3", r#"{}"#, 2).unwrap();
3956
3957        let results = storage.scan_items("ScanTest", &Default::default()).unwrap();
3958        assert_eq!(results.len(), 3);
3959
3960        // Scan with limit
3961        let results = storage
3962            .scan_items(
3963                "ScanTest",
3964                &ScanParams {
3965                    limit: Some(2),
3966                    ..Default::default()
3967                },
3968            )
3969            .unwrap();
3970        assert_eq!(results.len(), 2);
3971
3972        // Scan with pagination
3973        let results = storage
3974            .scan_items(
3975                "ScanTest",
3976                &ScanParams {
3977                    limit: Some(2),
3978                    exclusive_start_pk: Some("a"),
3979                    exclusive_start_sk: Some("1"),
3980                    ..Default::default()
3981                },
3982            )
3983            .unwrap();
3984        assert_eq!(results.len(), 2);
3985        assert_eq!(results[0].0, "b"); // Skipped "a"
3986    }
3987
3988    #[test]
3989    fn test_count_items() {
3990        let storage = test_storage();
3991        storage.create_data_table("CountTest").unwrap();
3992
3993        assert_eq!(storage.count_items("CountTest").unwrap(), 0);
3994
3995        storage.put_item("CountTest", "a", "", r#"{}"#, 2).unwrap();
3996        storage.put_item("CountTest", "b", "", r#"{}"#, 2).unwrap();
3997
3998        assert_eq!(storage.count_items("CountTest").unwrap(), 2);
3999    }
4000
4001    #[test]
4002    fn test_gsi_table_lifecycle() {
4003        let storage = test_storage();
4004        storage.create_gsi_table("Orders", "ByDate").unwrap();
4005
4006        // Should be able to write to the GSI table via raw SQL
4007        let gsi_name = "Orders::gsi::ByDate";
4008        let sql = format!(
4009            "INSERT INTO \"{}\" (gsi_pk, gsi_sk, table_pk, table_sk, item_json) VALUES (?1, ?2, ?3, ?4, ?5)",
4010            gsi_name.replace('"', "\"\"")
4011        );
4012        storage
4013            .conn()
4014            .execute(
4015                &sql,
4016                params!["2024-01-01", "001", "user#1", "order#001", r#"{}"#],
4017            )
4018            .unwrap();
4019
4020        storage.drop_gsi_table("Orders", "ByDate").unwrap();
4021    }
4022
4023    #[test]
4024    fn test_nonexistent_table_metadata() {
4025        let storage = test_storage();
4026        assert!(storage.get_table_metadata("Nonexistent").unwrap().is_none());
4027        assert!(!storage.delete_table_metadata("Nonexistent").unwrap());
4028    }
4029
4030    #[test]
4031    fn test_metadata_cache_hit() {
4032        let storage = test_storage();
4033        storage
4034            .insert_table_metadata(&CreateTableMetadata {
4035                table_name: "CacheTest",
4036                key_schema: r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
4037                attribute_definitions: r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
4038                created_at: 1000000,
4039                ..Default::default()
4040            })
4041            .unwrap();
4042
4043        // First call populates cache
4044        let meta1 = storage.get_table_metadata("CacheTest").unwrap().unwrap();
4045        assert_eq!(meta1.table_name, "CacheTest");
4046
4047        // Second call should hit cache (same result)
4048        let meta2 = storage.get_table_metadata("CacheTest").unwrap().unwrap();
4049        assert_eq!(meta2.table_name, "CacheTest");
4050        assert_eq!(meta1.created_at, meta2.created_at);
4051
4052        // Cache should have the entry
4053        assert!(storage.metadata_cache.borrow().contains_key("CacheTest"));
4054    }
4055
4056    #[test]
4057    fn test_metadata_cache_invalidated_on_delete() {
4058        let storage = test_storage();
4059        storage
4060            .insert_table_metadata(&CreateTableMetadata {
4061                table_name: "DelCache",
4062                key_schema: r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
4063                attribute_definitions: r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
4064                created_at: 1000000,
4065                ..Default::default()
4066            })
4067            .unwrap();
4068
4069        // Populate cache
4070        storage.get_table_metadata("DelCache").unwrap();
4071        assert!(storage.metadata_cache.borrow().contains_key("DelCache"));
4072
4073        // Delete should invalidate cache
4074        storage.delete_table_metadata("DelCache").unwrap();
4075        assert!(!storage.metadata_cache.borrow().contains_key("DelCache"));
4076    }
4077
4078    #[test]
4079    fn test_metadata_cache_invalidated_on_stream_enable() {
4080        let storage = test_storage();
4081        storage
4082            .insert_table_metadata(&CreateTableMetadata {
4083                table_name: "StreamCache",
4084                key_schema: r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
4085                attribute_definitions: r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
4086                created_at: 1000000,
4087                ..Default::default()
4088            })
4089            .unwrap();
4090
4091        // Populate cache
4092        let meta = storage.get_table_metadata("StreamCache").unwrap().unwrap();
4093        assert!(!meta.stream_enabled);
4094
4095        // Enable stream should invalidate cache
4096        storage
4097            .enable_stream("StreamCache", "NEW_AND_OLD_IMAGES", "2024-01-01T00:00:00")
4098            .unwrap();
4099        assert!(!storage.metadata_cache.borrow().contains_key("StreamCache"));
4100
4101        // Next get should reflect the change
4102        let meta = storage.get_table_metadata("StreamCache").unwrap().unwrap();
4103        assert!(meta.stream_enabled);
4104    }
4105
4106    #[test]
4107    fn test_metadata_cache_invalidated_on_ttl_update() {
4108        let storage = test_storage();
4109        storage
4110            .insert_table_metadata(&CreateTableMetadata {
4111                table_name: "TtlCache",
4112                key_schema: r#"[{"AttributeName":"pk","KeyType":"HASH"}]"#,
4113                attribute_definitions: r#"[{"AttributeName":"pk","AttributeType":"S"}]"#,
4114                created_at: 1000000,
4115                ..Default::default()
4116            })
4117            .unwrap();
4118
4119        // Populate cache
4120        let meta = storage.get_table_metadata("TtlCache").unwrap().unwrap();
4121        assert!(!meta.ttl_enabled);
4122
4123        // Update TTL should invalidate cache
4124        storage
4125            .update_ttl_config("TtlCache", Some("expires_at"), true)
4126            .unwrap();
4127        assert!(!storage.metadata_cache.borrow().contains_key("TtlCache"));
4128
4129        // Next get should reflect the change
4130        let meta = storage.get_table_metadata("TtlCache").unwrap().unwrap();
4131        assert!(meta.ttl_enabled);
4132        assert_eq!(meta.ttl_attribute, Some("expires_at".to_string()));
4133    }
4134
4135    #[test]
4136    fn test_num_to_buffer_zero() {
4137        // numToBuffer("0") → [0x80]
4138        assert_eq!(num_to_buffer("0"), vec![0x80]);
4139        assert_eq!(num_to_buffer("-0"), vec![0x80]);
4140    }
4141
4142    #[test]
4143    fn test_hash_prefix_string_keys() {
4144        // Verify known hash prefixes for string keys used in scan tests.
4145        // These specific values determine which segment items land in.
4146        let h1 = compute_hash_prefix(&AttributeValue::S("3635".into()));
4147        let h2 = compute_hash_prefix(&AttributeValue::S("228".into()));
4148        let h3 = compute_hash_prefix(&AttributeValue::S("1668".into()));
4149        let h4 = compute_hash_prefix(&AttributeValue::S("3435".into()));
4150
4151        // With TotalSegments=4096, segment 0 owns bucket 0 only.
4152        // Items "3635" and "228" must be in segment 0 (bucket 0).
4153        assert_eq!(
4154            hash_bucket(&h1),
4155            0,
4156            "3635 should be bucket 0, got hash {h1}"
4157        );
4158        assert_eq!(hash_bucket(&h2), 0, "228 should be bucket 0, got hash {h2}");
4159
4160        // "1668" must be in segment 1 (bucket 1)
4161        assert_eq!(
4162            hash_bucket(&h3),
4163            1,
4164            "1668 should be bucket 1, got hash {h3}"
4165        );
4166
4167        // "3435" must be in segment 4 (bucket 4)
4168        assert_eq!(
4169            hash_bucket(&h4),
4170            4,
4171            "3435 should be bucket 4, got hash {h4}"
4172        );
4173    }
4174
4175    #[test]
4176    fn test_hash_prefix_number_keys() {
4177        // Verify number key hash prefixes from scan tests.
4178        // "251" must be in segment 1 (bucket 1) with TotalSegments=4096
4179        let h1 = compute_hash_prefix(&AttributeValue::N("251".into()));
4180        assert_eq!(hash_bucket(&h1), 1, "251 should be bucket 1, got hash {h1}");
4181
4182        // "2388" must be in segment 4095 (bucket 4095)
4183        let h2 = compute_hash_prefix(&AttributeValue::N("2388".into()));
4184        assert_eq!(
4185            hash_bucket(&h2),
4186            4095,
4187            "2388 should be bucket 4095, got hash {h2}"
4188        );
4189    }
4190
4191    #[test]
4192    fn test_hash_in_segment() {
4193        // bucket 0 should be in segment 0 of 4096
4194        assert!(hash_in_segment("000000", 0, 4096));
4195        assert!(!hash_in_segment("000000", 1, 4096));
4196
4197        // bucket 1 should be in segment 1 of 4096
4198        assert!(hash_in_segment("001000", 1, 4096));
4199        assert!(!hash_in_segment("001000", 0, 4096));
4200
4201        // bucket 4095 should be in segment 4095 of 4096
4202        assert!(hash_in_segment("fff000", 4095, 4096));
4203        assert!(!hash_in_segment("fff000", 0, 4096));
4204
4205        // With 2 segments: buckets 0-2047 in segment 0, 2048-4095 in segment 1
4206        assert!(hash_in_segment("000000", 0, 2));
4207        assert!(hash_in_segment("7ff000", 0, 2));
4208        assert!(hash_in_segment("800000", 1, 2));
4209        assert!(hash_in_segment("fff000", 1, 2));
4210    }
4211}