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