Skip to main content

mini_app_core/
store.rs

1/// SQLite-backed row store for mini-app-mcp.
2///
3/// The [`Store`] type provides async CRUD operations over a single SQLite
4/// table.  All field-level semantics (required fields, type coercion) are
5/// delegated to [`crate::schema::SchemaConfig::validate`]; the store layer
6/// is deliberately schema-agnostic at the DDL level.
7///
8/// # Crux #1 compliance
9/// The `CREATE TABLE` DDL is a static string literal — no column is derived
10/// from `schema.yaml` at the SQL level.  The `data` column stores a JSON
11/// blob; all field validation happens in application code via
12/// [`SchemaConfig::validate`].
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use rusqlite::{OptionalExtension, params_from_iter};
21
22use crate::error::MiniAppError;
23use crate::filter::ListFilter;
24use crate::order_by::OrderByItem;
25use crate::schema::{FieldType, SchemaConfig};
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31/// A single stored row returned by CRUD operations.
32///
33/// The `data` field contains the raw JSON object that was supplied at
34/// creation / update time.  `created_at` and `updated_at` are Unix epoch
35/// seconds.
36#[derive(Debug, Clone, Serialize)]
37pub struct RowRecord {
38    /// Unique row identifier (UUID v4 string).
39    pub id: String,
40    /// The validated JSON payload stored for this row.
41    pub data: serde_json::Value,
42    /// Unix epoch seconds at the time the row was created.
43    pub created_at: i64,
44    /// Unix epoch seconds at the time the row was last updated.
45    pub updated_at: i64,
46}
47
48/// Update semantics for [`Store::update`].
49///
50/// - `Merge` (default): RFC 7396 shallow merge. Absent fields are preserved
51///   from the stored row. A `null` patch value deletes the field when
52///   `required = false`; it returns a [`MiniAppError::Validation`] error when
53///   `required = true`. A full schema validation runs on the merged result
54///   before persisting.
55/// - `Replace`: Full replacement — identical to the pre-breaking-change default
56///   behavior. The stored row is overwritten byte-for-byte with the supplied
57///   `value` after schema validation.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
59#[serde(rename_all = "lowercase")]
60pub enum UpdateMode {
61    /// RFC 7396 shallow merge (default).
62    #[default]
63    Merge,
64    /// Full replacement (legacy behavior).
65    Replace,
66}
67
68/// Async CRUD store backed by a single SQLite table.
69///
70/// The store wraps a `rusqlite::Connection` in an `Arc<Mutex<_>>` so it can
71/// be cloned and shared across async tasks.  [`rusqlite::Connection`] is
72/// `Send` but `!Sync`; the `Mutex` provides the required exclusive access.
73///
74/// All database operations execute inside `tokio::task::spawn_blocking` so
75/// the tokio runtime thread-pool is never blocked.
76pub struct Store {
77    conn: Arc<Mutex<rusqlite::Connection>>,
78    schema: SchemaConfig,
79    /// Filesystem path of the SQLite database file backing this store.
80    ///
81    /// Captured at [`Store::open`] time and exposed via
82    /// [`Store::db_path`] for the multi-table aggregator path, which
83    /// uses `ATTACH DATABASE` to mount per-table `.db` files into a
84    /// shared in-memory connection.
85    db_path: PathBuf,
86}
87
88// ---------------------------------------------------------------------------
89// DDL
90// ---------------------------------------------------------------------------
91
92/// Fixed DDL.  Schema-yaml columns are **never** added here (Crux #1).
93const CREATE_TABLE_SQL: &str = "
94    CREATE TABLE IF NOT EXISTS rows (
95        id          TEXT    PRIMARY KEY,
96        data        TEXT    NOT NULL,
97        created_at  INTEGER NOT NULL,
98        updated_at  INTEGER NOT NULL
99    )
100";
101
102/// DDL for the per-table named query alias store.
103///
104/// `_aliases` lives inside the same `.db` file as `rows`, ensuring per-table
105/// namespace isolation: each [`Store`] instance only ever accesses the
106/// `_aliases` table in its own database connection.
107///
108/// `name` is the PRIMARY KEY — UNIQUE constraint is implicit.
109/// `filter` stores the serialized [`crate::filter::ListFilter`] JSON, or a
110/// MiniJinja template string when `params_schema` is set.
111/// `default_limit` is optional and may be overridden at `alias_run` call time.
112/// `params_schema` stores an optional JSON array of parameter name strings
113/// (e.g. `["project","owner"]`); `NULL` means the alias takes no parameters.
114const CREATE_ALIASES_TABLE_SQL: &str = "
115    CREATE TABLE IF NOT EXISTS _aliases (
116        name           TEXT    PRIMARY KEY,
117        filter         TEXT    NOT NULL,
118        default_limit  INTEGER,
119        description    TEXT,
120        params_schema  TEXT
121    )
122";
123
124/// A row returned from the `_aliases` table.
125///
126/// `filter` is stored as raw JSON text or a MiniJinja template string;
127/// callers (`alias_run` in server.rs) are responsible for rendering and
128/// deserialising it back to a [`crate::filter::ListFilter`].
129#[derive(Debug, Clone)]
130pub struct AliasRecord {
131    /// Alias name (PRIMARY KEY in `_aliases`).
132    pub name: String,
133    /// Serialised [`crate::filter::ListFilter`] JSON string, or a MiniJinja
134    /// template string when `params_schema` is `Some`.
135    pub filter: String,
136    /// Optional default limit to apply when `alias_run` does not supply one.
137    pub default_limit: Option<u32>,
138    /// Optional human-readable description.
139    pub description: Option<String>,
140    /// Optional JSON array of parameter name strings (e.g. `["project","owner"]`).
141    /// `None` means the alias takes no parameters and the filter text is plain JSON.
142    pub params_schema: Option<String>,
143}
144
145/// Result returned by [`Store::replace_string_field`].
146#[derive(Debug, serde::Serialize)]
147pub struct ReplaceResult {
148    /// Number of replacements performed.
149    pub matches: u32,
150}
151
152// ---------------------------------------------------------------------------
153// Helpers
154// ---------------------------------------------------------------------------
155
156/// Returns the current time as Unix epoch seconds.
157fn now_secs() -> i64 {
158    SystemTime::now()
159        .duration_since(UNIX_EPOCH)
160        .unwrap_or_default()
161        .as_secs() as i64
162}
163
164/// Parse a JSON text column back into `serde_json::Value`.
165fn parse_data(json_str: &str) -> Result<serde_json::Value, MiniAppError> {
166    serde_json::from_str(json_str).map_err(|e| MiniAppError::Schema(format!("data column: {e}")))
167}
168
169/// Resolves a possibly-shortened id prefix to the full UUID stored in `rows`.
170///
171/// - If `id.len() == 36`: full UUID bypass — returns `Ok(id.to_string())`
172///   immediately without querying the database.
173/// - Otherwise: executes `SELECT id FROM rows WHERE id LIKE ?1` with param
174///   `format!("{}%", id)`.
175///   - 0 results  → `Err(MiniAppError::NotFound { id: id.to_string() })`
176///   - 1 result   → `Ok(candidates[0].clone())`
177///   - 2+ results → `Err(MiniAppError::AmbiguousId { id_prefix, candidates })`
178///
179/// # Security
180/// The `%` wildcard is appended to the *parameter value*, not to the SQL
181/// template, so this is safe against SQL injection (rusqlite parameterized
182/// query).  UUID character set (0-9, a-f, hyphens) contains no LIKE metachar
183/// (`%` or `_`), so no LIKE escaping is needed in practice.
184fn resolve_id(conn: &rusqlite::Connection, id: &str) -> Result<String, MiniAppError> {
185    if id.len() == 36 {
186        // Full UUID bypass: skip LIKE query entirely (Crux constraint).
187        return Ok(id.to_string());
188    }
189    let mut stmt = conn.prepare("SELECT id FROM rows WHERE id LIKE ?1")?;
190    let candidates: Vec<String> = stmt
191        .query_map(rusqlite::params![format!("{}%", id)], |row| {
192            row.get::<_, String>(0)
193        })?
194        .collect::<Result<Vec<_>, _>>()?;
195    match candidates.len() {
196        0 => Err(MiniAppError::NotFound { id: id.to_string() }),
197        1 => {
198            // SAFETY: len == 1 guarantees next() returns Some.
199            Ok(candidates.into_iter().next().unwrap())
200        }
201        _ => Err(MiniAppError::AmbiguousId {
202            id_prefix: id.to_string(),
203            candidates,
204        }),
205    }
206}
207
208/// RFC 7396 shallow merge: apply `patch` on top of `current`, consulting
209/// `schema` for required-field null-deletion checks.
210///
211/// Rules:
212/// - `patch` must be a JSON object; otherwise `Err(Validation { field: "(root)", .. })`.
213/// - For each `(key, value)` in `patch`:
214///   - If `value` is `null`: look up the field in `schema`.
215///     - `required = true` → `Err(Validation { field: key, reason: "required field cannot be deleted via null" })`.
216///     - Otherwise → remove the key from `current` (physical deletion from the Map).
217///   - If `value` is non-null: overwrite `current[key]` with `value` (nested
218///     objects are replaced wholesale — no deep merge).
219/// - Fields not mentioned in `patch` are untouched in `current`.
220/// - Returns the merged `serde_json::Value` (always an Object).
221///
222/// The caller is responsible for running `schema.validate(&merged)` after this
223/// call to enforce post-merge type/required constraints.
224fn shallow_merge(
225    mut current: serde_json::Value,
226    patch: serde_json::Value,
227    schema: &SchemaConfig,
228) -> Result<serde_json::Value, MiniAppError> {
229    let patch_map = patch.as_object().ok_or_else(|| MiniAppError::Validation {
230        field: "(root)".to_string(),
231        reason: "patch must be a JSON object".to_string(),
232    })?;
233
234    let current_map = current
235        .as_object_mut()
236        .ok_or_else(|| MiniAppError::Validation {
237            field: "(root)".to_string(),
238            reason: "stored row is not a JSON object".to_string(),
239        })?;
240
241    for (key, value) in patch_map {
242        if value.is_null() {
243            // Null means "delete this field" per RFC 7396.
244            let is_required = schema
245                .fields
246                .iter()
247                .find(|f| &f.name == key)
248                .map(|f| f.required)
249                .unwrap_or(false);
250
251            if is_required {
252                return Err(MiniAppError::Validation {
253                    field: key.clone(),
254                    reason: "required field cannot be deleted via null".to_string(),
255                });
256            }
257            current_map.remove(key);
258        } else {
259            current_map.insert(key.clone(), value.clone());
260        }
261    }
262
263    Ok(current)
264}
265
266// ---------------------------------------------------------------------------
267// Store impl
268// ---------------------------------------------------------------------------
269
270impl Store {
271    /// Open the SQLite database at `db_path` and run `CREATE TABLE IF NOT EXISTS rows`.
272    ///
273    /// # WAL journal mode
274    /// The connection is opened with `PRAGMA journal_mode = WAL` to enable safe
275    /// coexistence of old and new [`Store`] instances during schema hot-reload
276    /// (see `crux-card.md` Crux #1). WAL mode allows one writer and many readers
277    /// concurrently, preventing lock conflicts when dual registries are held.
278    /// Sidecar files `<db>.db-wal` and `<db>.db-shm` are created next to the
279    /// main DB file; this is expected and safe.
280    ///
281    /// # Concurrency
282    /// Returns a [`Store`] that wraps `Arc<Mutex<rusqlite::Connection>>` and is
283    /// `Send + Sync`. [`rusqlite::Connection`] is `Send` but `!Sync`; the
284    /// `std::sync::Mutex` provides exclusive access. All subsequent CRUD calls
285    /// acquire the lock inside `spawn_blocking` closures and drop it before any
286    /// `.await` point — holding a `MutexGuard` across `.await` is never permitted.
287    ///
288    /// If `schema.dump.sync` is `Some(SyncMode::Bidirectional)`, a
289    /// `tracing::warn!` is emitted once here; the store behaves as write-only
290    /// until bidirectional sync is implemented.
291    ///
292    /// # Cancel Safety
293    /// Not cancel-safe. Once the `spawn_blocking` closure has started (DDL
294    /// execution), calling `abort` on the `JoinHandle` or dropping the returned
295    /// `Future` has no effect — the DDL completes on the blocking thread pool.
296    ///
297    /// # Errors
298    /// - [`MiniAppError::Storage`] — `Connection::open`, WAL pragma, or DDL execute failure.
299    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
300    ///
301    /// # Panic
302    /// Does not panic.
303    pub async fn open(db_path: &Path, schema: SchemaConfig) -> Result<Self, MiniAppError> {
304        // Warn if bidirectional sync is configured but not yet implemented.
305        if let Some(crate::dump::SyncMode::Bidirectional) =
306            schema.dump.as_ref().and_then(|d| d.sync.as_ref())
307        {
308            tracing::warn!(
309                target: "mini_app_mcp::dump",
310                "sync=bidirectional configured but not implemented yet; behaving as write-only"
311            );
312        }
313
314        let stored_db_path = db_path.to_path_buf();
315        let db_path = db_path.to_path_buf();
316        let conn =
317            tokio::task::spawn_blocking(move || -> Result<rusqlite::Connection, MiniAppError> {
318                let c = rusqlite::Connection::open(&db_path)?;
319                // Enable WAL journal mode before DDL. WAL allows concurrent readers
320                // and one writer, which is essential for Crux #1 dual-registry safety.
321                c.pragma_update(None, "journal_mode", "WAL")?;
322                // Read back the actual mode: SQLite silently falls back to non-WAL
323                // on `:memory:`, NFS, or read-only filesystems.  A mismatch does not
324                // prevent startup but means concurrent reload may hit SQLITE_BUSY.
325                let actual_mode: String = c.query_row("PRAGMA journal_mode", [], |r| r.get(0))?;
326                if actual_mode.to_lowercase() != "wal" {
327                    tracing::warn!(
328                        actual_mode = %actual_mode,
329                        "PRAGMA journal_mode=WAL fell back to non-WAL mode; \
330                         concurrent reload may hit SQLITE_BUSY"
331                    );
332                }
333                c.execute_batch(CREATE_TABLE_SQL)?;
334                c.execute_batch(CREATE_ALIASES_TABLE_SQL)?;
335                crate::row_history::ensure_history_table(&c)?;
336                // Idempotent migration: add params_schema column if absent (K-1 st1-entries).
337                // SQLite does not support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, so we
338                // use PRAGMA table_info to check for the column first.
339                let has_params_schema = c
340                    .prepare("PRAGMA table_info(_aliases)")?
341                    .query_map([], |row| row.get::<_, String>(1))?
342                    .collect::<Result<Vec<_>, _>>()?
343                    .iter()
344                    .any(|name| name == "params_schema");
345                if !has_params_schema {
346                    c.execute_batch("ALTER TABLE _aliases ADD COLUMN params_schema TEXT")?;
347                }
348                Ok(c)
349            })
350            .await
351            .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
352
353        Ok(Store {
354            conn: Arc::new(Mutex::new(conn)),
355            schema,
356            db_path: stored_db_path,
357        })
358    }
359
360    /// Returns the filesystem path of the SQLite database file backing
361    /// this store, as captured at [`Store::open`] time.
362    ///
363    /// Used by `mini_app_core::aggregator::execute_aggregate` to mount
364    /// each per-table `.db` file via `ATTACH DATABASE` for the
365    /// multi-table `UNION ALL` aggregation path (Crux #3).
366    pub fn db_path(&self) -> &Path {
367        &self.db_path
368    }
369
370    /// Returns a clone of the [`Arc<Mutex<rusqlite::Connection>>`]
371    /// handle backing this store. Used by
372    /// [`crate::alias_storage::GlobalAliasStorage::migrate_from_per_table`]
373    /// to read the legacy per-table `_aliases` rows on registry mount.
374    ///
375    /// The connection is shared (no copy); callers MUST acquire the
376    /// `Mutex` lock inside a `spawn_blocking` body to avoid blocking the
377    /// async runtime.
378    pub fn conn(&self) -> Arc<Mutex<rusqlite::Connection>> {
379        Arc::clone(&self.conn)
380    }
381
382    /// Return a raw `MutexGuard` for test-only low-level SQL access.
383    ///
384    /// The guard MUST be dropped before calling any `async` methods on this
385    /// `Store` to avoid deadlocks.
386    pub fn conn_for_test(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
387        self.conn.lock().expect("not poisoned")
388    }
389
390    /// Validate `value` against the schema and insert a new row with a
391    /// generated UUID primary key.
392    ///
393    /// # Concurrency
394    /// The rusqlite `INSERT` executes inside `tokio::task::spawn_blocking`.
395    /// `Arc<Mutex<Connection>>` is cloned before entering the blocking closure;
396    /// the [`std::sync::MutexGuard`] is acquired and dropped entirely within the
397    /// blocking closure — never held across an `.await` point.
398    ///
399    /// After the `spawn_blocking` future resolves, `dump::on_change` is called
400    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
401    /// If `dump::on_change` fails (e.g. disk full), the error is propagated via
402    /// `?` and the caller receives `Err(MiniAppError::Io(_))`; the row remains
403    /// in the database (DB and file may be transiently inconsistent until the
404    /// next successful write).
405    ///
406    /// # Cancel Safety
407    /// Not cancel-safe. Once the `spawn_blocking` closure has started, the
408    /// `INSERT` completes regardless of `Future` cancellation. If the caller
409    /// drops this `Future` after the INSERT but before `dump::on_change`
410    /// completes, the file may not be materialized while the DB row exists.
411    ///
412    /// # Errors
413    /// - [`MiniAppError::Validation`] — required field absent or type mismatch.
414    /// - [`MiniAppError::Storage`] — rusqlite error (constraint violation, I/O).
415    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
416    /// - [`MiniAppError::Io`] — dump file write failure (only when `dump` is configured).
417    ///
418    /// # Panic
419    /// Does not panic. Mutex poisoning is propagated as `Err(MiniAppError::Storage(_))`.
420    pub async fn create(&self, value: serde_json::Value) -> Result<RowRecord, MiniAppError> {
421        self.schema.validate(&value)?;
422
423        let id = uuid::Uuid::new_v4().to_string();
424        let now = now_secs();
425        let data_str =
426            serde_json::to_string(&value).expect("serde_json::Value serialization is infallible");
427
428        let table_name = self.schema.table.clone();
429        let history_enabled = self.schema.history.enabled();
430        let conn = self.conn.clone();
431        let id_inner = id.clone();
432        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
433            let mut conn = conn
434                .lock()
435                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
436            let tx = conn.transaction()?;
437            tx.execute(
438                "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
439                rusqlite::params![id_inner, data_str, now, now],
440            )?;
441            if history_enabled {
442                crate::row_history::record_in_tx(
443                    &tx,
444                    &table_name,
445                    &id_inner,
446                    crate::row_history::HistoryOp::Create,
447                    Some(&serde_json::to_string(&value).expect("infallible")),
448                    now,
449                )?;
450            }
451            tx.commit()?;
452            Ok(RowRecord {
453                id: id_inner,
454                data: value,
455                created_at: now,
456                updated_at: now,
457            })
458        })
459        .await
460        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
461
462        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
463        crate::dump::on_change(&self.schema, &record).await?;
464
465        Ok(record)
466    }
467
468    /// Fetch the row with the given `id`.
469    ///
470    /// # Concurrency
471    /// The `SELECT` executes inside `tokio::task::spawn_blocking`. The
472    /// [`std::sync::MutexGuard`] is acquired and released within the blocking
473    /// closure; no lock is held across `.await`.
474    ///
475    /// # Cancel Safety
476    /// Once the blocking closure has started the `SELECT` will complete
477    /// regardless of `Future` cancellation.
478    ///
479    /// # Errors
480    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
481    /// - [`MiniAppError::Storage`] — rusqlite error.
482    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
483    ///
484    /// # Panic
485    /// Does not panic.
486    pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
487        let conn = self.conn.clone();
488        let id = id.to_string();
489
490        tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
491            let conn = conn
492                .lock()
493                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
494            let id = resolve_id(&conn, &id)?;
495            let mut stmt =
496                conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
497            let row = stmt
498                .query_row(rusqlite::params![id], |row| {
499                    Ok((
500                        row.get::<_, String>(0)?,
501                        row.get::<_, String>(1)?,
502                        row.get::<_, i64>(2)?,
503                        row.get::<_, i64>(3)?,
504                    ))
505                })
506                .optional()?
507                .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
508
509            let data = parse_data(&row.1)?;
510            Ok(RowRecord {
511                id: row.0,
512                data,
513                created_at: row.2,
514                updated_at: row.3,
515            })
516        })
517        .await
518        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
519    }
520
521    /// Return rows ordered by `created_at DESC`.
522    ///
523    /// `limit` defaults to `100` (max `1000`). `offset` defaults to `0`.
524    ///
525    /// # Concurrency
526    /// The `SELECT` executes inside `tokio::task::spawn_blocking`. The
527    /// [`std::sync::MutexGuard`] is held only within the blocking closure.
528    ///
529    /// # Cancel Safety
530    /// Once the blocking closure has started the query runs to completion
531    /// regardless of `Future` cancellation.
532    ///
533    /// # Errors
534    /// - [`MiniAppError::Storage`] — rusqlite error.
535    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
536    /// - [`MiniAppError::Validation`] — `build_sql` on `filter` fails
537    ///   (defensive; callers should call `filter.validate()` first).
538    ///
539    /// # Panic
540    /// Does not panic.
541    pub async fn list(
542        &self,
543        limit: Option<u32>,
544        offset: Option<u32>,
545        filter: Option<ListFilter>,
546        order_by: Option<Vec<OrderByItem>>,
547    ) -> Result<Vec<RowRecord>, MiniAppError> {
548        let conn = self.conn.clone();
549        let limit = limit.unwrap_or(100).min(1000) as i64;
550        let offset = offset.unwrap_or(0) as i64;
551
552        // Build WHERE clause + params from filter (before spawning the blocking task).
553        let (where_clause, filter_params) = match filter {
554            None => (String::new(), Vec::new()),
555            Some(f) => {
556                let (fragment, params) = f.build_sql()?;
557                (format!(" WHERE {fragment}"), params)
558            }
559        };
560
561        // Build ORDER BY clause from order_by items. Falls back to the legacy
562        // `created_at DESC` default when not supplied or when an empty slice is
563        // provided (callers should reject empty via validate_order_by first, but
564        // the store layer is defensive here for backward-compat).
565        let order_by_clause = match &order_by {
566            None => " ORDER BY created_at DESC".to_string(),
567            Some(items) if items.is_empty() => " ORDER BY created_at DESC".to_string(),
568            Some(items) => format!(" ORDER BY {}", crate::order_by::build_order_by_sql(items)),
569        };
570
571        tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
572            let conn = conn
573                .lock()
574                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
575            let sql = format!(
576                "SELECT id, data, created_at, updated_at FROM rows{where_clause}{order_by_clause} LIMIT ? OFFSET ?"
577            );
578            // Combine filter params with LIMIT/OFFSET params in order.
579            let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
580                .into_iter()
581                .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
582                .collect();
583            all_params.push(Box::new(limit));
584            all_params.push(Box::new(offset));
585
586            let mut stmt = conn.prepare(&sql)?;
587            let rows = stmt
588                .query_map(
589                    params_from_iter(all_params.iter().map(|p| p.as_ref())),
590                    |row| {
591                        Ok((
592                            row.get::<_, String>(0)?,
593                            row.get::<_, String>(1)?,
594                            row.get::<_, i64>(2)?,
595                            row.get::<_, i64>(3)?,
596                        ))
597                    },
598                )?
599                .map(|r| {
600                    r.map_err(MiniAppError::Storage).and_then(|row| {
601                        let data = parse_data(&row.1)?;
602                        Ok(RowRecord {
603                            id: row.0,
604                            data,
605                            created_at: row.2,
606                            updated_at: row.3,
607                        })
608                    })
609                })
610                .collect::<Result<Vec<_>, _>>()?;
611            Ok(rows)
612        })
613        .await
614        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
615    }
616
617    /// Count all rows in the table.
618    ///
619    /// Used by `schema_delete` in `dry_run` mode to report how many rows
620    /// would be orphaned when the schema is removed.
621    ///
622    /// # Returns
623    /// The total row count as `u64`.
624    ///
625    /// # Errors
626    /// - [`MiniAppError::Schema`] — if the mutex is poisoned or the blocking
627    ///   task panics.
628    /// - [`MiniAppError::Storage`] — if the SQL query fails.
629    pub async fn row_count(&self) -> Result<u64, MiniAppError> {
630        let conn = self.conn.clone();
631        tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
632            let conn = conn
633                .lock()
634                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
635            let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
636            Ok(count.max(0) as u64)
637        })
638        .await
639        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
640    }
641
642    /// Validate `value` and update the row identified by `id`.
643    /// `updated_at` is refreshed; `created_at` is unchanged.
644    ///
645    /// # Concurrency
646    /// The `UPDATE` executes inside `tokio::task::spawn_blocking`. The
647    /// [`std::sync::MutexGuard`] is held only within the blocking closure and is
648    /// dropped before any `.await` point. Concurrent calls with the same `id`
649    /// are serialized by the `Mutex`.
650    ///
651    /// After the `spawn_blocking` future resolves, `dump::on_change` is called
652    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
653    /// If `dump::on_change` fails (e.g. disk full), the error is propagated via
654    /// `?` and the caller receives `Err(MiniAppError::Io(_))`; the row update
655    /// remains in the database (DB and file may be transiently inconsistent
656    /// until the next successful write).
657    ///
658    /// **Same-id concurrent update is not order-preserving with respect to
659    /// file content.** The DB `UPDATE` is serialised by the connection
660    /// `Mutex`, but `dump::on_change` runs *outside* the lock. Two concurrent
661    /// `update(id, A)` / `update(id, B)` calls may finalise the DB row as B
662    /// while the dump file ends up holding A's content (whichever
663    /// `spawn_blocking` write completes last wins on disk). Callers that
664    /// require strict file-DB ordering must serialise updates by `id` at the
665    /// caller side.
666    ///
667    /// # Cancel Safety
668    /// Not cancel-safe. Once the blocking closure has started the `UPDATE` will
669    /// complete regardless of `Future` cancellation. Idempotent at the SQL
670    /// level: calling with the same `id` and `value` results in the same final
671    /// DB state. If the caller drops this `Future` after the UPDATE but before
672    /// `dump::on_change` completes, the file may not be re-materialized while
673    /// the DB row already reflects the new value.
674    ///
675    /// # Errors
676    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
677    /// - [`MiniAppError::Validation`] — required field absent or type mismatch, or
678    ///   a null patch value targets a required field (Merge mode only).
679    /// - [`MiniAppError::Storage`] — rusqlite error.
680    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
681    /// - [`MiniAppError::Io`] — dump file write failure (only when `dump` is configured).
682    ///
683    /// # Panic
684    /// Does not panic.
685    pub async fn update(
686        &self,
687        id: &str,
688        value: serde_json::Value,
689        mode: UpdateMode,
690    ) -> Result<RowRecord, MiniAppError> {
691        let now = now_secs();
692        let conn = self.conn.clone();
693        let id_str = id.to_string();
694        let schema = self.schema.clone();
695        let table_name = self.schema.table.clone();
696
697        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
698            let mut conn = conn
699                .lock()
700                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
701            let id_str = resolve_id(&conn, &id_str)?;
702
703            // Fetch both data and created_at in one query.
704            // For Replace mode, the data column is read but unused; this keeps
705            // the SQL identical across modes and avoids a second lock acquisition.
706            let row_data: Option<(String, i64)> = conn
707                .query_row(
708                    "SELECT data, created_at FROM rows WHERE id = ?1",
709                    rusqlite::params![id_str],
710                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
711                )
712                .optional()?;
713
714            let (current_data_str, created_at) =
715                row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
716
717            let merged = match mode {
718                UpdateMode::Merge => {
719                    let current: serde_json::Value = parse_data(&current_data_str)?;
720                    let merged = shallow_merge(current, value, &schema)?;
721                    // Post-merge full schema validation (Crux #1: must run after merge).
722                    schema.validate(&merged)?;
723                    merged
724                }
725                UpdateMode::Replace => {
726                    // Replace: validate first, then store as-is (byte-for-byte identical
727                    // to pre-breaking-change behavior — Crux #2).
728                    schema.validate(&value)?;
729                    value
730                }
731            };
732
733            let merged_str = serde_json::to_string(&merged)
734                .expect("serde_json::Value serialization is infallible");
735
736            let tx = conn.transaction()?;
737            tx.execute(
738                "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
739                rusqlite::params![merged_str, now, id_str],
740            )?;
741            if schema.history.enabled() {
742                crate::row_history::record_in_tx(
743                    &tx,
744                    &table_name,
745                    &id_str,
746                    crate::row_history::HistoryOp::Update,
747                    Some(&merged_str),
748                    now,
749                )?;
750            }
751            tx.commit()?;
752
753            Ok(RowRecord {
754                id: id_str,
755                data: merged,
756                created_at,
757                updated_at: now,
758            })
759        })
760        .await
761        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
762
763        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
764        crate::dump::on_change(&self.schema, &record).await?;
765
766        Ok(record)
767    }
768
769    /// Execute a closure under a SQLite SAVEPOINT for all-or-nothing semantics.
770    ///
771    /// The closure receives `&mut rusqlite::Savepoint<'_>` and may run arbitrary
772    /// SQL inside the SAVEPOINT.  On success, the SAVEPOINT is committed.  On
773    /// failure, the SAVEPOINT is rolled back automatically when it is dropped
774    /// (enforced via `set_drop_behavior(DropBehavior::Rollback)`).
775    ///
776    /// # Crux compliance
777    /// This method is the implementation backing `schema_batch`'s
778    /// `schema_batch SAVEPOINT atomicity` Crux constraint.  All ops inside a
779    /// batch share the same SAVEPOINT; any failure causes the SAVEPOINT to
780    /// roll back, leaving the DB unchanged.
781    ///
782    /// # Concurrency
783    /// The Mutex is acquired and the entire SAVEPOINT + ops execute inside a
784    /// single `tokio::task::spawn_blocking` closure.  `Savepoint<'_>` borrows
785    /// the `Connection`, so both must remain in the same closure scope — they
786    /// cannot straddle an `.await` point (K-103, K-110).
787    ///
788    /// # Cancel Safety
789    /// Not cancel-safe.  Once the `spawn_blocking` closure has started, the
790    /// SAVEPOINT runs to completion (commit or rollback) regardless of `Future`
791    /// cancellation.
792    ///
793    /// # Type Parameters
794    /// - `F`: closure `FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static`.
795    /// - `R`: return value, must be `Send + 'static`.
796    ///
797    /// # Errors
798    /// - [`MiniAppError::Schema`] — Mutex poisoned or blocking thread panicked.
799    /// - [`MiniAppError::Storage`] — rusqlite SAVEPOINT creation or commit failed.
800    /// - Any error returned by the closure `f`.
801    ///
802    /// # Panic
803    /// Does not panic.
804    pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
805    where
806        F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
807        R: Send + 'static,
808    {
809        let conn = self.conn.clone();
810        tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
811            let mut guard = conn
812                .lock()
813                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
814            let mut sp = guard.savepoint()?;
815            // Ensure rollback on Drop so any early-return via `?` cleans up.
816            sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
817            let result = f(&mut sp)?;
818            sp.commit()?;
819            Ok(result)
820        })
821        .await
822        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
823    }
824
825    /// Delete the row identified by `id`.
826    ///
827    /// # Concurrency
828    /// The `DELETE` executes inside `tokio::task::spawn_blocking`. The
829    /// [`std::sync::MutexGuard`] is held only within the blocking closure and
830    /// is dropped before any `.await` point. Idempotent at the SQL level:
831    /// deleting a non-existent `id` returns [`MiniAppError::NotFound`], so
832    /// calling twice with the same `id` returns `Err(MiniAppError::NotFound)`
833    /// on the second call.
834    ///
835    /// After the `spawn_blocking` future resolves, `dump::on_delete` is called
836    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
837    /// In the current implementation `on_delete` is a no-op (`Ok(())`) and the
838    /// dump file is preserved on disk by default. The `Result<(), MiniAppError>`
839    /// signature is retained because a future schema flag (e.g.
840    /// `dump.on_delete: keep | remove`) may switch this to an actual file
841    /// removal that can fail with [`MiniAppError::Io`]. Today the value-level
842    /// behaviour is infallible, but the type-level contract (and the
843    /// `?`-propagation site in `Store::delete`) is preserved so that flipping
844    /// the future flag does not require changing the call site.
845    ///
846    /// # Cancel Safety
847    /// Not cancel-safe with respect to the `spawn_blocking` portion: once the
848    /// blocking closure has started the `DELETE` runs to completion regardless
849    /// of `Future` cancellation. The current `on_delete` no-op is itself
850    /// cancel-safe (no `.await`, no I/O), so dropping this `Future` after the
851    /// DELETE has no observable file-system effect today. When `on_delete`
852    /// gains real file removal in the future, this paragraph must be updated
853    /// in lockstep with the new contract.
854    ///
855    /// # Errors
856    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
857    /// - [`MiniAppError::Storage`] — rusqlite error.
858    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
859    /// - [`MiniAppError::Io`] — reserved for a future `on_delete` implementation
860    ///   that performs file removal (currently never returned, but the variant
861    ///   is part of the public contract so the call site does not need to
862    ///   change when the flag is added).
863    ///
864    /// # Panic
865    /// Does not panic.
866    pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
867        let conn = self.conn.clone();
868        let id = id.to_string();
869        let table_name = self.schema.table.clone();
870        let history_enabled = self.schema.history.enabled();
871        let now = now_secs();
872
873        // The closure returns the resolved (full) UUID so that on_delete
874        // receives a complete UUID rather than a prefix string (CF-1).
875        let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
876            let mut conn = conn
877                .lock()
878                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
879            let resolved = resolve_id(&conn, &id)?;
880            // Fetch row data before deletion for history recording.
881            let data_str: Option<String> = conn
882                .query_row(
883                    "SELECT data FROM rows WHERE id = ?1",
884                    rusqlite::params![resolved],
885                    |row| row.get::<_, String>(0),
886                )
887                .optional()?;
888            let data_str = data_str.ok_or_else(|| MiniAppError::NotFound {
889                id: resolved.clone(),
890            })?;
891            let tx = conn.transaction()?;
892            let n = tx.execute(
893                "DELETE FROM rows WHERE id = ?1",
894                rusqlite::params![resolved],
895            )?;
896            if n == 0 {
897                // Row disappeared between SELECT and DELETE (race); roll back naturally.
898                return Err(MiniAppError::NotFound { id: resolved });
899            }
900            if history_enabled {
901                crate::row_history::record_in_tx(
902                    &tx,
903                    &table_name,
904                    &resolved,
905                    crate::row_history::HistoryOp::Delete,
906                    Some(&data_str),
907                    now,
908                )?;
909            }
910            tx.commit()?;
911            Ok(resolved)
912        })
913        .await
914        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
915
916        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
917        crate::dump::on_delete(&self.schema, &resolved_id).await?;
918
919        Ok(())
920    }
921
922    /// Re-insert a previously-deleted row with the same `id` and the given `data`.
923    ///
924    /// This is the internal path for point-in-time restore of a deleted row.
925    /// The row is inserted with `created_at = updated_at = now_secs()` and
926    /// a `HistoryOp::Create` history entry is appended atomically.
927    ///
928    /// For restoring a *live* (non-deleted) row to an earlier snapshot, use
929    /// [`Store::update`] with [`UpdateMode::Replace`] instead, which records
930    /// `HistoryOp::Update`.
931    ///
932    /// # Errors
933    /// - [`MiniAppError::Schema`] — `data` fails schema validation, or mutex poisoned,
934    ///   or blocking thread panicked.
935    /// - [`MiniAppError::Storage`] — rusqlite error (e.g. UNIQUE constraint on `id`
936    ///   if the row still exists).
937    pub async fn restore_row(
938        &self,
939        id: &str,
940        data: serde_json::Value,
941    ) -> Result<RowRecord, MiniAppError> {
942        self.schema.validate(&data)?;
943        let now = now_secs();
944        let id_str = id.to_string();
945        let data_str =
946            serde_json::to_string(&data).expect("serde_json::Value serialization is infallible");
947        let table_name = self.schema.table.clone();
948        let history_enabled = self.schema.history.enabled();
949        let conn = self.conn.clone();
950
951        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
952            let mut conn = conn
953                .lock()
954                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
955            let tx = conn.transaction()?;
956            tx.execute(
957                "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
958                rusqlite::params![id_str, data_str, now, now],
959            )?;
960            if history_enabled {
961                crate::row_history::record_in_tx(
962                    &tx,
963                    &table_name,
964                    &id_str,
965                    crate::row_history::HistoryOp::Create,
966                    Some(&data_str),
967                    now,
968                )?;
969            }
970            tx.commit()?;
971            Ok(RowRecord {
972                id: id_str,
973                data,
974                created_at: now,
975                updated_at: now,
976            })
977        })
978        .await
979        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
980
981        crate::dump::on_change(&self.schema, &record).await?;
982
983        Ok(record)
984    }
985
986    // -----------------------------------------------------------------------
987    // Row history
988    // -----------------------------------------------------------------------
989
990    /// Return the row-history snapshot at or before `at_unix_secs`.
991    ///
992    /// Returns `Ok(Some(record))` when a history entry exists for `id` with
993    /// `recorded_at <= at_unix_secs`, `Ok(None)` when no such entry exists.
994    ///
995    /// # Errors
996    /// - [`MiniAppError::Storage`] — rusqlite error.
997    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
998    pub async fn fetch_history_at(
999        &self,
1000        id: &str,
1001        at_unix_secs: i64,
1002    ) -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
1003        // With `history: off` this table has no restore surface. Entries from
1004        // before a full→off cutover may still exist in the DB, but serving
1005        // them here would let row_restore silently overwrite live data with a
1006        // pre-cutover snapshot (and the overwrite itself would leave no
1007        // history entry to undo) — so the switch also disables reads.
1008        if !self.schema.history.enabled() {
1009            return Ok(None);
1010        }
1011        let conn = self.conn.clone();
1012        let table_name = self.schema.table.clone();
1013        let id_str = id.to_string();
1014        tokio::task::spawn_blocking(
1015            move || -> Result<Option<crate::row_history::HistoryRecord>, MiniAppError> {
1016                let conn = conn
1017                    .lock()
1018                    .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1019                crate::row_history::fetch_at(&conn, &table_name, &id_str, at_unix_secs)
1020                    .map_err(MiniAppError::Storage)
1021            },
1022        )
1023        .await
1024        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1025    }
1026
1027    // -----------------------------------------------------------------------
1028    // Alias CRUD
1029    // -----------------------------------------------------------------------
1030
1031    /// Register a named query alias in `_aliases`.
1032    ///
1033    /// The `filter_json` value is stored verbatim (serialize before calling).
1034    /// `default_limit`, `description`, and `params_schema` are optional.
1035    /// `params_schema` is a JSON array of parameter name strings
1036    /// (e.g. `["project","owner"]`); pass `None` for parameter-free aliases.
1037    ///
1038    /// # Errors
1039    /// - [`MiniAppError::AliasAlreadyExists`] — an alias with `name` already
1040    ///   exists.  Delete it first or choose a different name.
1041    /// - [`MiniAppError::Storage`] — rusqlite error.
1042    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
1043    ///
1044    /// # Panic
1045    /// Does not panic.
1046    pub async fn alias_create(
1047        &self,
1048        name: &str,
1049        filter_json: &str,
1050        default_limit: Option<u32>,
1051        description: Option<String>,
1052        params_schema: Option<String>,
1053    ) -> Result<(), MiniAppError> {
1054        let conn = self.conn.clone();
1055        let name = name.to_string();
1056        let filter_json = filter_json.to_string();
1057
1058        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1059            let conn = conn
1060                .lock()
1061                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1062            conn.execute(
1063                "INSERT OR IGNORE INTO _aliases \
1064                 (name, filter, default_limit, description, params_schema) \
1065                 VALUES (?1, ?2, ?3, ?4, ?5)",
1066                rusqlite::params![name, filter_json, default_limit, description, params_schema],
1067            )?;
1068            if conn.changes() == 0 {
1069                return Err(MiniAppError::AliasAlreadyExists { name });
1070            }
1071            Ok(())
1072        })
1073        .await
1074        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1075    }
1076
1077    /// Retrieve a single alias by name.
1078    ///
1079    /// # Errors
1080    /// - [`MiniAppError::AliasNotFound`] — no alias with `name` exists.
1081    /// - [`MiniAppError::Storage`] — rusqlite error.
1082    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
1083    ///
1084    /// # Panic
1085    /// Does not panic.
1086    pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
1087        let conn = self.conn.clone();
1088        let name = name.to_string();
1089
1090        tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
1091            let conn = conn
1092                .lock()
1093                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1094            let mut stmt = conn.prepare(
1095                "SELECT name, filter, default_limit, description, params_schema \
1096                 FROM _aliases WHERE name = ?1",
1097            )?;
1098            let record = stmt
1099                .query_row(rusqlite::params![name], |row| {
1100                    Ok((
1101                        row.get::<_, String>(0)?,
1102                        row.get::<_, String>(1)?,
1103                        row.get::<_, Option<u32>>(2)?,
1104                        row.get::<_, Option<String>>(3)?,
1105                        row.get::<_, Option<String>>(4)?,
1106                    ))
1107                })
1108                .optional()?
1109                .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
1110
1111            Ok(AliasRecord {
1112                name: record.0,
1113                filter: record.1,
1114                default_limit: record.2,
1115                description: record.3,
1116                params_schema: record.4,
1117            })
1118        })
1119        .await
1120        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1121    }
1122
1123    /// List all aliases registered for this table, ordered by name.
1124    ///
1125    /// Returns an empty `Vec` when no aliases exist.
1126    ///
1127    /// # Errors
1128    /// - [`MiniAppError::Storage`] — rusqlite error.
1129    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
1130    ///
1131    /// # Panic
1132    /// Does not panic.
1133    pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
1134        let conn = self.conn.clone();
1135
1136        tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
1137            let conn = conn
1138                .lock()
1139                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1140            let mut stmt = conn.prepare(
1141                "SELECT name, filter, default_limit, description, params_schema \
1142                 FROM _aliases ORDER BY name ASC",
1143            )?;
1144            let records = stmt
1145                .query_map([], |row| {
1146                    Ok((
1147                        row.get::<_, String>(0)?,
1148                        row.get::<_, String>(1)?,
1149                        row.get::<_, Option<u32>>(2)?,
1150                        row.get::<_, Option<String>>(3)?,
1151                        row.get::<_, Option<String>>(4)?,
1152                    ))
1153                })?
1154                .collect::<Result<Vec<_>, _>>()?;
1155
1156            Ok(records
1157                .into_iter()
1158                .map(
1159                    |(name, filter, default_limit, description, params_schema)| AliasRecord {
1160                        name,
1161                        filter,
1162                        default_limit,
1163                        description,
1164                        params_schema,
1165                    },
1166                )
1167                .collect())
1168        })
1169        .await
1170        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1171    }
1172
1173    /// Delete the alias with the given `name`.
1174    ///
1175    /// # Errors
1176    /// - [`MiniAppError::AliasNotFound`] — no alias with `name` exists.
1177    /// - [`MiniAppError::Storage`] — rusqlite error.
1178    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
1179    ///
1180    /// # Panic
1181    /// Does not panic.
1182    pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
1183        let conn = self.conn.clone();
1184        let name = name.to_string();
1185
1186        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1187            let conn = conn
1188                .lock()
1189                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1190            let n = conn.execute(
1191                "DELETE FROM _aliases WHERE name = ?1",
1192                rusqlite::params![name],
1193            )?;
1194            if n == 0 {
1195                return Err(MiniAppError::AliasNotFound { name });
1196            }
1197            Ok(())
1198        })
1199        .await
1200        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1201    }
1202
1203    // -----------------------------------------------------------------------
1204    // Partial-edit helpers
1205    // -----------------------------------------------------------------------
1206
1207    /// Return the contents of a string field with `cat -n` style line numbers.
1208    ///
1209    /// Line numbers are 1-indexed. `view_range` is an inclusive `[start, end]`
1210    /// bound; `None` shows all lines.
1211    ///
1212    /// # Errors
1213    /// - [`MiniAppError::NotFound`] — row does not exist.
1214    /// - [`MiniAppError::FieldTypeError`] — field is not `FieldType::String`.
1215    /// - [`MiniAppError::Schema`] — field absent from schema, or mutex poisoned.
1216    pub async fn view_string_field(
1217        &self,
1218        id: &str,
1219        field: &str,
1220        view_range: Option<(u32, u32)>,
1221    ) -> Result<String, MiniAppError> {
1222        let conn = self.conn.clone();
1223        let id_str = id.to_string();
1224        let field = field.to_string();
1225        let schema = self.schema.clone();
1226
1227        tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
1228            let conn = conn
1229                .lock()
1230                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1231            let id_str = resolve_id(&conn, &id_str)?;
1232
1233            let field_def = schema
1234                .fields
1235                .iter()
1236                .find(|f| f.name == field)
1237                .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1238            if field_def.ty != FieldType::String {
1239                return Err(MiniAppError::FieldTypeError {
1240                    field: field.clone(),
1241                    actual_type: field_def.ty.as_str().to_string(),
1242                });
1243            }
1244
1245            let row_data: Option<String> = conn
1246                .query_row(
1247                    "SELECT data FROM rows WHERE id = ?1",
1248                    rusqlite::params![id_str],
1249                    |row| row.get::<_, String>(0),
1250                )
1251                .optional()?;
1252            let data_str = row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1253
1254            let data = parse_data(&data_str)?;
1255            let text = data
1256                .get(&field)
1257                .and_then(|v| v.as_str())
1258                .unwrap_or("")
1259                .to_string();
1260
1261            let lines: Vec<&str> = text.split('\n').collect();
1262            let total = lines.len() as u32;
1263            let (start, end) = match view_range {
1264                Some((s, e)) => (s.max(1), e.min(total)),
1265                None => (1, total),
1266            };
1267
1268            let mut out = String::new();
1269            for (i, line) in lines.iter().enumerate() {
1270                let lineno = i as u32 + 1;
1271                if lineno >= start && lineno <= end {
1272                    out.push_str(&format!("{:6}  {}\n", lineno, line));
1273                }
1274            }
1275            Ok(out)
1276        })
1277        .await
1278        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1279    }
1280
1281    /// Replace occurrences of `old_str` in a string field.
1282    ///
1283    /// Default mode (`replace_all = false`): exactly one occurrence is
1284    /// required. Zero matches → [`MiniAppError::StringNotFound`]; two or more
1285    /// → [`MiniAppError::AmbiguousMatch`]. Pass `replace_all = true` to batch
1286    /// all occurrences and get back the total replacement count.
1287    ///
1288    /// The uniqueness check and the write are performed inside a single SQLite
1289    /// transaction (atomic). History is recorded via `row_history::record_in_tx`.
1290    ///
1291    /// # Errors
1292    /// - [`MiniAppError::NotFound`] — row does not exist.
1293    /// - [`MiniAppError::FieldTypeError`] — field is not `FieldType::String`.
1294    /// - [`MiniAppError::StringNotFound`] — no occurrence of `old_str` found.
1295    /// - [`MiniAppError::AmbiguousMatch`] — ≥2 occurrences when `replace_all` is false.
1296    /// - [`MiniAppError::Schema`] — field absent from schema, or mutex poisoned.
1297    pub async fn replace_string_field(
1298        &self,
1299        id: &str,
1300        field: &str,
1301        old_str: &str,
1302        new_str: &str,
1303        view_range: Option<(u32, u32)>,
1304        replace_all: bool,
1305    ) -> Result<ReplaceResult, MiniAppError> {
1306        let now = now_secs();
1307        let conn = self.conn.clone();
1308        let id_str = id.to_string();
1309        let field = field.to_string();
1310        let old_str = old_str.to_string();
1311        let new_str = new_str.to_string();
1312        let schema = self.schema.clone();
1313        let table_name = self.schema.table.clone();
1314
1315        let (record, matches) =
1316            tokio::task::spawn_blocking(move || -> Result<(RowRecord, u32), MiniAppError> {
1317                let mut conn = conn
1318                    .lock()
1319                    .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1320                let id_str = resolve_id(&conn, &id_str)?;
1321
1322                let field_def =
1323                    schema
1324                        .fields
1325                        .iter()
1326                        .find(|f| f.name == field)
1327                        .ok_or_else(|| {
1328                            MiniAppError::Schema(format!("field `{field}` not in schema"))
1329                        })?;
1330                if field_def.ty != FieldType::String {
1331                    return Err(MiniAppError::FieldTypeError {
1332                        field: field.clone(),
1333                        actual_type: field_def.ty.as_str().to_string(),
1334                    });
1335                }
1336
1337                // Fix 4: reject empty old_str before touching the DB.
1338                if old_str.is_empty() {
1339                    return Err(MiniAppError::Validation {
1340                        field: field.clone(),
1341                        reason: "old_str must not be empty".to_string(),
1342                    });
1343                }
1344
1345                let row_data: Option<(String, i64)> = conn
1346                    .query_row(
1347                        "SELECT data, created_at FROM rows WHERE id = ?1",
1348                        rusqlite::params![id_str],
1349                        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1350                    )
1351                    .optional()?;
1352                let (data_str, created_at) =
1353                    row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1354
1355                let mut data = parse_data(&data_str)?;
1356                let text = data
1357                    .get(&field)
1358                    .and_then(|v| v.as_str())
1359                    .unwrap_or("")
1360                    .to_string();
1361
1362                // Fix 1: validate view_range before constructing the search scope.
1363                if let Some((s, e)) = view_range {
1364                    if s == 0 || s > e {
1365                        return Err(MiniAppError::Validation {
1366                            field: field.clone(),
1367                            reason: format!(
1368                                "view_range [{s}, {e}] is invalid: start must be ≥ 1 and ≤ end"
1369                            ),
1370                        });
1371                    }
1372                }
1373
1374                // Determine the search scope (optionally restricted to view_range lines).
1375                let search_scope = if let Some((s, e)) = view_range {
1376                    let lines: Vec<&str> = text.split('\n').collect();
1377                    let total = lines.len() as u32;
1378                    let si = (s.max(1).saturating_sub(1)) as usize;
1379                    let ei = (e.min(total) as usize).min(lines.len());
1380                    lines[si..ei].join("\n")
1381                } else {
1382                    text.clone()
1383                };
1384
1385                // Find all non-overlapping occurrences in the search scope.
1386                // Fix 3: cap candidates at CAND_CAP to prevent DoS via huge allocations;
1387                //        match_count tracks the true total across all occurrences.
1388                // Fix 5: expand snippet window to ≈30 bytes and respect UTF-8 char
1389                //        boundaries to avoid panics on multibyte characters.
1390                const CAND_CAP: usize = 20;
1391                let mut candidates: Vec<crate::error::MatchCandidate> = Vec::new();
1392                let mut match_count = 0u32;
1393                let mut pos = 0usize;
1394                let old_len = old_str.len().max(1);
1395                while let Some(rel) = search_scope[pos..].find(old_str.as_str()) {
1396                    let abs = pos + rel;
1397                    match_count += 1;
1398                    if candidates.len() < CAND_CAP {
1399                        let before = &search_scope[..abs];
1400                        let line_no = (before.matches('\n').count() as u32) + 1;
1401                        let col = (abs - before.rfind('\n').map(|p| p + 1).unwrap_or(0)) as u32 + 1;
1402                        let mut snip_start = abs.saturating_sub(30);
1403                        while snip_start < abs && !search_scope.is_char_boundary(snip_start) {
1404                            snip_start += 1;
1405                        }
1406                        let mut snip_end = (abs + old_str.len() + 30).min(search_scope.len());
1407                        while snip_end > abs + old_str.len()
1408                            && !search_scope.is_char_boundary(snip_end)
1409                        {
1410                            snip_end -= 1;
1411                        }
1412                        candidates.push(crate::error::MatchCandidate {
1413                            line: line_no,
1414                            col,
1415                            snippet: search_scope[snip_start..snip_end].to_string(),
1416                        });
1417                    }
1418                    pos = abs + old_len;
1419                }
1420                if match_count == 0 {
1421                    return Err(MiniAppError::StringNotFound {
1422                        field: field.clone(),
1423                    });
1424                }
1425                if !replace_all && match_count >= 2 {
1426                    return Err(MiniAppError::AmbiguousMatch {
1427                        field: field.clone(),
1428                        matches: match_count,
1429                        candidates,
1430                    });
1431                }
1432
1433                // Perform the replacement within the correct scope.
1434                let new_text = if let Some((s, e)) = view_range {
1435                    let lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1436                    let total = lines.len() as u32;
1437                    let si = (s.max(1).saturating_sub(1)) as usize;
1438                    let ei = (e.min(total) as usize).min(lines.len());
1439                    let scoped = lines[si..ei].join("\n");
1440                    let replaced_scoped = if replace_all {
1441                        scoped.replace(old_str.as_str(), new_str.as_str())
1442                    } else {
1443                        scoped.replacen(old_str.as_str(), new_str.as_str(), 1)
1444                    };
1445                    let mut all_lines: Vec<String> = lines[..si].to_vec();
1446                    all_lines.extend(replaced_scoped.split('\n').map(str::to_string));
1447                    all_lines.extend_from_slice(&lines[ei..]);
1448                    all_lines.join("\n")
1449                } else if replace_all {
1450                    text.replace(old_str.as_str(), new_str.as_str())
1451                } else {
1452                    text.replacen(old_str.as_str(), new_str.as_str(), 1)
1453                };
1454
1455                // Update the field value and validate the full object.
1456                data.as_object_mut()
1457                    .expect("data is always a JSON object")
1458                    .insert(field.clone(), serde_json::Value::String(new_text));
1459                schema.validate(&data)?;
1460
1461                let new_data_str =
1462                    serde_json::to_string(&data).expect("serialization is infallible");
1463
1464                // Atomic write + history inside a single transaction.
1465                let tx = conn.transaction()?;
1466                tx.execute(
1467                    "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1468                    rusqlite::params![new_data_str, now, id_str],
1469                )?;
1470                if schema.history.enabled() {
1471                    crate::row_history::record_in_tx(
1472                        &tx,
1473                        &table_name,
1474                        &id_str,
1475                        crate::row_history::HistoryOp::Update,
1476                        Some(&new_data_str),
1477                        now,
1478                    )?;
1479                }
1480                tx.commit()?;
1481
1482                Ok((
1483                    RowRecord {
1484                        id: id_str,
1485                        data,
1486                        created_at,
1487                        updated_at: now,
1488                    },
1489                    match_count,
1490                ))
1491            })
1492            .await
1493            .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1494
1495        crate::dump::on_change(&self.schema, &record).await?;
1496        Ok(ReplaceResult { matches })
1497    }
1498
1499    /// Insert `content` into a string field at the given 1-indexed line
1500    /// position.
1501    ///
1502    /// - `line = 0` → prepend (insert before the first line).
1503    /// - `line = N` → insert after line N (1-indexed).
1504    /// - `line > total_lines + 1` → [`MiniAppError::LineOutOfRange`].
1505    ///
1506    /// # Errors
1507    /// - [`MiniAppError::NotFound`] — row does not exist.
1508    /// - [`MiniAppError::FieldTypeError`] — field is not `FieldType::String`.
1509    /// - [`MiniAppError::LineOutOfRange`] — `line` is out of bounds.
1510    /// - [`MiniAppError::Schema`] — field absent from schema, or mutex poisoned.
1511    pub async fn insert_into_string_field(
1512        &self,
1513        id: &str,
1514        field: &str,
1515        line: u32,
1516        content: &str,
1517    ) -> Result<(), MiniAppError> {
1518        let now = now_secs();
1519        let conn = self.conn.clone();
1520        let id_str = id.to_string();
1521        let field = field.to_string();
1522        let content = content.to_string();
1523        let schema = self.schema.clone();
1524        let table_name = self.schema.table.clone();
1525
1526        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
1527            let mut conn = conn
1528                .lock()
1529                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1530            let id_str = resolve_id(&conn, &id_str)?;
1531
1532            let field_def = schema
1533                .fields
1534                .iter()
1535                .find(|f| f.name == field)
1536                .ok_or_else(|| MiniAppError::Schema(format!("field `{field}` not in schema")))?;
1537            if field_def.ty != FieldType::String {
1538                return Err(MiniAppError::FieldTypeError {
1539                    field: field.clone(),
1540                    actual_type: field_def.ty.as_str().to_string(),
1541                });
1542            }
1543
1544            let row_data: Option<(String, i64)> = conn
1545                .query_row(
1546                    "SELECT data, created_at FROM rows WHERE id = ?1",
1547                    rusqlite::params![id_str],
1548                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
1549                )
1550                .optional()?;
1551            let (data_str, created_at) =
1552                row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
1553
1554            let mut data = parse_data(&data_str)?;
1555            let text = data
1556                .get(&field)
1557                .and_then(|v| v.as_str())
1558                .unwrap_or("")
1559                .to_string();
1560
1561            let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
1562            let total = lines.len() as u32;
1563
1564            // Fix 2: line > total is out of range (Vec::insert panics at index > len).
1565            if line > total {
1566                return Err(MiniAppError::LineOutOfRange {
1567                    line,
1568                    total_lines: total,
1569                });
1570            }
1571            // line = 0 → prepend; line = N → insert after line N (1-indexed).
1572            lines.insert(line as usize, content.clone());
1573
1574            let new_text = lines.join("\n");
1575            data.as_object_mut()
1576                .expect("data is always a JSON object")
1577                .insert(field.clone(), serde_json::Value::String(new_text));
1578            schema.validate(&data)?;
1579
1580            let new_data_str = serde_json::to_string(&data).expect("serialization is infallible");
1581
1582            let tx = conn.transaction()?;
1583            tx.execute(
1584                "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
1585                rusqlite::params![new_data_str, now, id_str],
1586            )?;
1587            if schema.history.enabled() {
1588                crate::row_history::record_in_tx(
1589                    &tx,
1590                    &table_name,
1591                    &id_str,
1592                    crate::row_history::HistoryOp::Update,
1593                    Some(&new_data_str),
1594                    now,
1595                )?;
1596            }
1597            tx.commit()?;
1598
1599            Ok(RowRecord {
1600                id: id_str,
1601                data,
1602                created_at,
1603                updated_at: now,
1604            })
1605        })
1606        .await
1607        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
1608
1609        crate::dump::on_change(&self.schema, &record).await?;
1610        Ok(())
1611    }
1612}
1613
1614// ---------------------------------------------------------------------------
1615// Tests
1616// ---------------------------------------------------------------------------
1617
1618#[cfg(test)]
1619mod tests {
1620    use std::sync::Arc;
1621
1622    use super::*;
1623    use crate::schema::{FieldDef, FieldType};
1624
1625    async fn make_test_store() -> Store {
1626        let schema = SchemaConfig {
1627            table: "test".into(),
1628            title: None,
1629            description: None,
1630            fields: vec![
1631                FieldDef {
1632                    name: "title".into(),
1633                    ty: FieldType::String,
1634                    required: true,
1635                    description: None,
1636                },
1637                FieldDef {
1638                    name: "state".into(),
1639                    ty: FieldType::String,
1640                    required: false,
1641                    description: None,
1642                },
1643            ],
1644            dump: None,
1645            history: Default::default(),
1646        };
1647        Store::open(Path::new(":memory:"), schema).await.unwrap()
1648    }
1649
1650    /// Build a test store with dump directed to `dir`.
1651    async fn make_test_store_with_dump(dir: &Path) -> Store {
1652        use crate::dump::{DumpConfig, SyncMode};
1653        let schema = SchemaConfig {
1654            table: "test".into(),
1655            title: None,
1656            description: None,
1657            fields: vec![
1658                FieldDef {
1659                    name: "title".into(),
1660                    ty: FieldType::String,
1661                    required: true,
1662                    description: None,
1663                },
1664                FieldDef {
1665                    name: "body".into(),
1666                    ty: FieldType::String,
1667                    required: false,
1668                    description: None,
1669                },
1670            ],
1671            history: Default::default(),
1672            dump: Some(DumpConfig {
1673                dir: Some(dir.to_path_buf()),
1674                title_field: None,
1675                body_field: None,
1676                sync: Some(SyncMode::WriteOnly),
1677            }),
1678        };
1679        Store::open(Path::new(":memory:"), schema).await.unwrap()
1680    }
1681
1682    // --- Basic CRUD ---
1683
1684    #[tokio::test]
1685    async fn test_history_off_records_nothing() {
1686        let schema = SchemaConfig {
1687            table: "test".into(),
1688            title: None,
1689            description: None,
1690            fields: vec![FieldDef {
1691                name: "title".into(),
1692                ty: FieldType::String,
1693                required: true,
1694                description: None,
1695            }],
1696            dump: None,
1697            history: crate::schema::HistoryMode::Off,
1698        };
1699        let store = Store::open(Path::new(":memory:"), schema).await.unwrap();
1700        let row = store
1701            .create(serde_json::json!({"title": "a"}))
1702            .await
1703            .unwrap();
1704        store
1705            .update(
1706                &row.id,
1707                serde_json::json!({"title": "b"}),
1708                UpdateMode::Merge,
1709            )
1710            .await
1711            .unwrap();
1712        store.delete(&row.id).await.unwrap();
1713
1714        let n: i64 = store
1715            .conn_for_test()
1716            .query_row("SELECT COUNT(*) FROM _row_history", [], |r| r.get(0))
1717            .unwrap();
1718        assert_eq!(n, 0, "history: off must not record any entry");
1719
1720        // The restore surface is disabled too: even if pre-cutover entries
1721        // existed, fetch_history_at must not serve them (stale-restore guard).
1722        assert!(
1723            store
1724                .fetch_history_at(&row.id, i64::MAX)
1725                .await
1726                .unwrap()
1727                .is_none()
1728        );
1729    }
1730
1731    #[tokio::test]
1732    async fn test_history_full_still_records() {
1733        let store = make_test_store().await;
1734        let row = store
1735            .create(serde_json::json!({"title": "a"}))
1736            .await
1737            .unwrap();
1738        store
1739            .update(
1740                &row.id,
1741                serde_json::json!({"title": "b"}),
1742                UpdateMode::Merge,
1743            )
1744            .await
1745            .unwrap();
1746        let versions = store.conn_for_test();
1747        let n: i64 = versions
1748            .query_row("SELECT COUNT(*) FROM _row_history", [], |r| r.get(0))
1749            .unwrap();
1750        assert_eq!(n, 2);
1751        let prev_n: i64 = versions
1752            .query_row(
1753                "SELECT COUNT(*) FROM _row_history WHERE prev_data_json IS NOT NULL",
1754                [],
1755                |r| r.get(0),
1756            )
1757            .unwrap();
1758        assert_eq!(prev_n, 0, "prev_data_json must no longer be written");
1759    }
1760
1761    #[tokio::test]
1762    async fn test_create_and_get_roundtrip() {
1763        let store = make_test_store().await;
1764        let value = serde_json::json!({"title": "hello", "state": "open"});
1765        let row = store.create(value.clone()).await.unwrap();
1766        let fetched = store.get(&row.id).await.unwrap();
1767        assert_eq!(fetched.id, row.id);
1768        assert_eq!(fetched.data, value);
1769    }
1770
1771    #[tokio::test]
1772    async fn test_create_then_list() {
1773        let store = make_test_store().await;
1774        store
1775            .create(serde_json::json!({"title": "t1"}))
1776            .await
1777            .unwrap();
1778        let rows = store.list(None, None, None, None).await.unwrap();
1779        assert_eq!(rows.len(), 1);
1780    }
1781
1782    #[tokio::test]
1783    async fn test_list_limit_offset() {
1784        let store = make_test_store().await;
1785        for i in 0..5 {
1786            store
1787                .create(serde_json::json!({"title": format!("item-{i}")}))
1788                .await
1789                .unwrap();
1790        }
1791        let page1 = store.list(Some(2), Some(0), None, None).await.unwrap();
1792        assert_eq!(page1.len(), 2);
1793        let page2 = store.list(Some(2), Some(2), None, None).await.unwrap();
1794        assert_eq!(page2.len(), 2);
1795        let page3 = store.list(Some(2), Some(4), None, None).await.unwrap();
1796        assert_eq!(page3.len(), 1);
1797    }
1798
1799    #[tokio::test]
1800    async fn test_update_timestamps() {
1801        let store = make_test_store().await;
1802        let row = store
1803            .create(serde_json::json!({"title": "original"}))
1804            .await
1805            .unwrap();
1806        // Sleep a tiny bit so updated_at can differ from created_at.
1807        // (In practice both are epoch seconds, so same-second updates produce
1808        //  the same value — the test verifies created_at is preserved.)
1809        let updated = store
1810            .update(
1811                &row.id,
1812                serde_json::json!({"title": "changed"}),
1813                UpdateMode::Replace,
1814            )
1815            .await
1816            .unwrap();
1817        assert_eq!(updated.created_at, row.created_at);
1818        assert_eq!(updated.id, row.id);
1819        assert_eq!(updated.data["title"], "changed");
1820    }
1821
1822    #[tokio::test]
1823    async fn test_create_delete_get_not_found() {
1824        let store = make_test_store().await;
1825        let row = store
1826            .create(serde_json::json!({"title": "to-delete"}))
1827            .await
1828            .unwrap();
1829        store.delete(&row.id).await.unwrap();
1830        let err = store.get(&row.id).await.unwrap_err();
1831        assert!(matches!(err, MiniAppError::NotFound { .. }));
1832    }
1833
1834    #[tokio::test]
1835    async fn test_get_unknown_id_not_found() {
1836        let store = make_test_store().await;
1837        let err = store.get("nonexistent-id").await.unwrap_err();
1838        assert!(matches!(err, MiniAppError::NotFound { .. }));
1839    }
1840
1841    #[tokio::test]
1842    async fn test_update_unknown_id_not_found() {
1843        let store = make_test_store().await;
1844        let err = store
1845            .update(
1846                "nonexistent-id",
1847                serde_json::json!({"title": "x"}),
1848                UpdateMode::Replace,
1849            )
1850            .await
1851            .unwrap_err();
1852        assert!(matches!(err, MiniAppError::NotFound { .. }));
1853    }
1854
1855    #[tokio::test]
1856    async fn test_delete_unknown_id_not_found() {
1857        let store = make_test_store().await;
1858        let err = store.delete("nonexistent-id").await.unwrap_err();
1859        assert!(matches!(err, MiniAppError::NotFound { .. }));
1860    }
1861
1862    #[tokio::test]
1863    async fn test_create_missing_required_field_validation_error() {
1864        let store = make_test_store().await;
1865        // `title` is required but absent.
1866        let err = store
1867            .create(serde_json::json!({"state": "open"}))
1868            .await
1869            .unwrap_err();
1870        assert!(
1871            matches!(err, MiniAppError::Validation { .. }),
1872            "expected Validation, got: {err:?}"
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn test_create_type_mismatch_validation_error() {
1878        let store = make_test_store().await;
1879        // `title` must be a string; passing a number should fail.
1880        let err = store
1881            .create(serde_json::json!({"title": 42}))
1882            .await
1883            .unwrap_err();
1884        assert!(
1885            matches!(err, MiniAppError::Validation { .. }),
1886            "expected Validation, got: {err:?}"
1887        );
1888    }
1889
1890    // --- Concurrency tests (from concurrency-analysis.md §2) ---
1891
1892    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1893    async fn test_store_create_concurrent() {
1894        let store = Arc::new(make_test_store().await);
1895        let handles: Vec<_> = (0..4)
1896            .map(|i| {
1897                let s = store.clone();
1898                tokio::spawn(async move {
1899                    s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1900                        .await
1901                })
1902            })
1903            .collect();
1904        let results: Vec<_> = futures::future::join_all(handles).await;
1905        assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1906        let rows = store.list(None, None, None, None).await.unwrap();
1907        assert_eq!(rows.len(), 4);
1908    }
1909
1910    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1911    async fn test_store_mutex_no_await_holding_lock() {
1912        let store = Arc::new(make_test_store().await);
1913        let id = store
1914            .create(serde_json::json!({"title": "init", "state": "open"}))
1915            .await
1916            .unwrap()
1917            .id;
1918        let s1 = store.clone();
1919        let id1 = id.clone();
1920        let h1 = tokio::spawn(async move { s1.get(&id1).await });
1921        let s2 = store.clone();
1922        let id2 = id.clone();
1923        let h2 = tokio::spawn(async move {
1924            s2.update(
1925                &id2,
1926                serde_json::json!({"title": "updated", "state": "closed"}),
1927                UpdateMode::Replace,
1928            )
1929            .await
1930        });
1931        let (r1, r2) = tokio::join!(h1, h2);
1932        assert!(r1.unwrap().is_ok());
1933        assert!(r2.unwrap().is_ok());
1934    }
1935
1936    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1937    async fn test_store_arc_clone_across_tasks() {
1938        let store = Arc::new(make_test_store().await);
1939        let handles: Vec<_> = (0..8)
1940            .map(|i| {
1941                let s = Arc::clone(&store);
1942                tokio::spawn(async move {
1943                    s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1944                        .await
1945                })
1946            })
1947            .collect();
1948        futures::future::join_all(handles).await;
1949        assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 8);
1950    }
1951
1952    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1953    async fn test_spawn_blocking_join_error_propagation() {
1954        let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1955            .await
1956            .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1957        assert!(matches!(result, Err(MiniAppError::Schema(_))));
1958    }
1959
1960    // --- Dump integration tests ---
1961
1962    #[tokio::test]
1963    async fn create_triggers_dump_when_configured() {
1964        let tmp = tempfile::tempdir().expect("tempdir");
1965        let store = make_test_store_with_dump(tmp.path()).await;
1966        let row = store
1967            .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1968            .await
1969            .expect("create ok");
1970        let dump_file = tmp.path().join(format!("{}.md", row.id));
1971        assert!(dump_file.exists(), "dump file must be created after create");
1972        let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1973        assert!(content.starts_with("# My Issue\n"));
1974        assert!(content.contains("Details"));
1975    }
1976
1977    #[tokio::test]
1978    async fn update_overwrites_dump_file() {
1979        let tmp = tempfile::tempdir().expect("tempdir");
1980        let store = make_test_store_with_dump(tmp.path()).await;
1981        let row = store
1982            .create(serde_json::json!({"title": "Original", "body": "v1"}))
1983            .await
1984            .expect("create ok");
1985
1986        store
1987            .update(
1988                &row.id,
1989                serde_json::json!({"title": "Updated", "body": "v2"}),
1990                UpdateMode::Replace,
1991            )
1992            .await
1993            .expect("update ok");
1994
1995        let dump_file = tmp.path().join(format!("{}.md", row.id));
1996        let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1997        assert!(
1998            content.starts_with("# Updated\n"),
1999            "dump file must reflect updated title"
2000        );
2001        assert!(
2002            content.contains("v2"),
2003            "dump file must reflect updated body"
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn delete_keeps_dump_file_by_default() {
2009        let tmp = tempfile::tempdir().expect("tempdir");
2010        let store = make_test_store_with_dump(tmp.path()).await;
2011        let row = store
2012            .create(serde_json::json!({"title": "Keep Me", "body": ""}))
2013            .await
2014            .expect("create ok");
2015
2016        let dump_file = tmp.path().join(format!("{}.md", row.id));
2017        assert!(dump_file.exists(), "dump file must exist after create");
2018
2019        store.delete(&row.id).await.expect("delete ok");
2020        assert!(
2021            dump_file.exists(),
2022            "dump file must remain after delete (default: keep)"
2023        );
2024    }
2025
2026    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2027    async fn test_store_create_concurrent_dump_writes_all_files() {
2028        let tmp = tempfile::tempdir().expect("tempdir");
2029        let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
2030
2031        let handles: Vec<_> = (0..4)
2032            .map(|i| {
2033                let s = store.clone();
2034                tokio::spawn(async move {
2035                    s.create(serde_json::json!({
2036                        "title": format!("concurrent-{i}"),
2037                        "body": format!("body-{i}"),
2038                    }))
2039                    .await
2040                })
2041            })
2042            .collect();
2043
2044        let results: Vec<_> = futures::future::join_all(handles).await;
2045        // All creates must succeed
2046        let rows: Vec<_> = results
2047            .into_iter()
2048            .map(|r| r.expect("spawn ok").expect("create ok"))
2049            .collect();
2050
2051        // Each row must have a corresponding dump file
2052        for row in &rows {
2053            let path = tmp.path().join(format!("{}.md", row.id));
2054            assert!(path.exists(), "dump file must exist for row {}", row.id);
2055        }
2056        assert_eq!(rows.len(), 4);
2057    }
2058
2059    #[tokio::test]
2060    async fn store_open_with_bidirectional_sync_returns_ok() {
2061        use crate::dump::{DumpConfig, SyncMode};
2062        // Store::open must succeed and emit warn (we verify Ok return here;
2063        // warn log capture is out of scope per Acceptance Criteria §7).
2064        let schema = SchemaConfig {
2065            table: "test".into(),
2066            title: None,
2067            description: None,
2068            fields: vec![FieldDef {
2069                name: "title".into(),
2070                ty: FieldType::String,
2071                required: false,
2072                description: None,
2073            }],
2074            history: Default::default(),
2075            dump: Some(DumpConfig {
2076                dir: None,
2077                title_field: None,
2078                body_field: None,
2079                sync: Some(SyncMode::Bidirectional),
2080            }),
2081        };
2082        let store = Store::open(Path::new(":memory:"), schema).await;
2083        assert!(
2084            store.is_ok(),
2085            "Store::open must succeed even with bidirectional sync configured"
2086        );
2087    }
2088
2089    // --- SAVEPOINT / concurrency tests (ST3 additions) ---
2090
2091    /// Test that execute_under_savepoint rolls back all ops on failure.
2092    /// Crux must_not_simplify 1: single SAVEPOINT, all-or-nothing semantics.
2093    ///
2094    /// Sequence: INSERT via SAVEPOINT → force error inside SAVEPOINT →
2095    /// assert SAVEPOINT rolled back → DB row count = 0.
2096    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2097    async fn test_savepoint_atomic_rollback_on_op_failure() {
2098        let store = make_test_store().await;
2099
2100        // A closure that does one INSERT then returns an error.
2101        let result: Result<(), MiniAppError> = store
2102            .execute_under_savepoint(|sp| {
2103                sp.execute(
2104                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2105                    rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
2106                )?;
2107                // Force failure after the INSERT — SAVEPOINT must roll back.
2108                Err(MiniAppError::Validation {
2109                    field: "test".into(),
2110                    reason: "forced rollback".into(),
2111                })
2112            })
2113            .await;
2114
2115        assert!(
2116            result.is_err(),
2117            "execute_under_savepoint must propagate the closure error"
2118        );
2119        assert!(
2120            matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
2121            "error variant must be preserved"
2122        );
2123
2124        // After rollback: the row must not exist.
2125        let rows = store.list(Some(1000), None, None, None).await.unwrap();
2126        assert_eq!(
2127            rows.len(),
2128            0,
2129            "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
2130        );
2131
2132        // Verify the SAVEPOINT is gone and normal ops still work.
2133        store
2134            .create(serde_json::json!({"title": "after-rollback"}))
2135            .await
2136            .expect("store must be usable after SAVEPOINT rollback");
2137        assert_eq!(store.list(None, None, None, None).await.unwrap().len(), 1);
2138    }
2139
2140    /// Test that execute_under_savepoint commits on success.
2141    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2142    async fn test_savepoint_commit_on_success() {
2143        let store = make_test_store().await;
2144
2145        let result = store
2146            .execute_under_savepoint(|sp| {
2147                sp.execute(
2148                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
2149                    rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
2150                )?;
2151                Ok(42_u32)
2152            })
2153            .await;
2154
2155        assert_eq!(
2156            result.unwrap(),
2157            42_u32,
2158            "successful SAVEPOINT must return value"
2159        );
2160
2161        // The INSERT must be committed.
2162        let rows = store.list(Some(10), None, None, None).await.unwrap();
2163        assert_eq!(rows.len(), 1, "committed INSERT must persist");
2164    }
2165
2166    /// Concurrency regression: 8 tasks × 100 creates on same Store,
2167    /// total 800 rows expected, no deadlock or panic.
2168    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2169    async fn test_store_concurrent_create() {
2170        let store = Arc::new(make_test_store().await);
2171        let task_count = 8_usize;
2172        let rows_per_task = 100_usize;
2173
2174        let handles: Vec<_> = (0..task_count)
2175            .map(|task_id| {
2176                let s = Arc::clone(&store);
2177                tokio::spawn(async move {
2178                    for i in 0..rows_per_task {
2179                        s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
2180                            .await
2181                            .expect("concurrent create must succeed");
2182                    }
2183                })
2184            })
2185            .collect();
2186
2187        futures::future::join_all(handles)
2188            .await
2189            .into_iter()
2190            .for_each(|r| r.expect("task must not panic"));
2191
2192        let total = store
2193            .list(Some(1000), None, None, None)
2194            .await
2195            .unwrap()
2196            .len();
2197        assert_eq!(
2198            total,
2199            task_count * rows_per_task,
2200            "all {total} rows must be present; expected {}",
2201            task_count * rows_per_task
2202        );
2203    }
2204
2205    /// Concurrency regression: 4 tasks × 50 same-id updates, no deadlock.
2206    /// Final DB row must be one of the valid values; no Mutex poison.
2207    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2208    async fn test_store_concurrent_update_same_id() {
2209        let store = Arc::new(make_test_store().await);
2210
2211        // Insert the row to update.
2212        let row = store
2213            .create(serde_json::json!({"title": "initial"}))
2214            .await
2215            .unwrap();
2216        let id = row.id.clone();
2217
2218        let task_count = 4_usize;
2219        let updates_per_task = 50_usize;
2220
2221        let handles: Vec<_> = (0..task_count)
2222            .map(|task_id| {
2223                let s = Arc::clone(&store);
2224                let row_id = id.clone();
2225                tokio::spawn(async move {
2226                    for i in 0..updates_per_task {
2227                        s.update(
2228                            &row_id,
2229                            serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
2230                            UpdateMode::Replace,
2231                        )
2232                        .await
2233                        .expect("concurrent update must succeed");
2234                    }
2235                })
2236            })
2237            .collect();
2238
2239        futures::future::join_all(handles)
2240            .await
2241            .into_iter()
2242            .for_each(|r| r.expect("task must not panic"));
2243
2244        // Final state: exactly 1 row, title is one of the last writes.
2245        let rows = store.list(None, None, None, None).await.unwrap();
2246        assert_eq!(rows.len(), 1, "update must not insert extra rows");
2247        assert!(
2248            rows[0].data["title"].is_string(),
2249            "title must be a string after concurrent updates"
2250        );
2251    }
2252
2253    /// Concurrency: Mutex poison propagated as MiniAppError::Schema("mutex poisoned").
2254    ///
2255    /// Spawns a blocking task that acquires the Mutex and panics (poisoning it),
2256    /// then asserts that the next store.get() call returns Schema("mutex poisoned").
2257    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2258    async fn test_store_mutex_poison_propagated_as_error() {
2259        let store = Arc::new(make_test_store().await);
2260
2261        // Poison the Mutex by panicking inside spawn_blocking while holding the lock.
2262        let conn = store.conn.clone();
2263        let _ = tokio::task::spawn_blocking(move || {
2264            let _guard = conn.lock().unwrap(); // acquire lock
2265            panic!("intentional poison"); // poison the Mutex
2266        })
2267        .await; // JoinError expected — ignore it
2268
2269        // The Mutex is now poisoned. Any Store operation must return Schema("mutex poisoned").
2270        let err = store.get("any-id").await.unwrap_err();
2271        assert!(
2272            matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
2273            "expected Schema(\"mutex poisoned\"), got: {err:?}"
2274        );
2275    }
2276
2277    /// Crux #1 verification: Store::open must set journal_mode to WAL on a real
2278    /// file-based database. `:memory:` databases do not support WAL; this test
2279    /// uses a tempdir to open an actual file and asserts the pragma value.
2280    #[tokio::test]
2281    async fn store_open_sets_wal_journal_mode() {
2282        let tmp = tempfile::tempdir().expect("tempdir");
2283        let db_path = tmp.path().join("test.db");
2284
2285        let schema = SchemaConfig {
2286            table: "test".into(),
2287            title: None,
2288            description: None,
2289            fields: vec![FieldDef {
2290                name: "title".into(),
2291                ty: FieldType::String,
2292                required: false,
2293                description: None,
2294            }],
2295            dump: None,
2296            history: Default::default(),
2297        };
2298        let store = Store::open(&db_path, schema)
2299            .await
2300            .expect("Store::open should succeed");
2301
2302        // Query journal_mode through the Store connection to verify WAL was set.
2303        let mode = {
2304            let conn = store.conn.lock().expect("lock");
2305            conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
2306                .expect("PRAGMA journal_mode query")
2307        };
2308        assert_eq!(
2309            mode.to_lowercase(),
2310            "wal",
2311            "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
2312        );
2313    }
2314
2315    // --- shallow_merge unit tests (Subtask 1, Crux #1) ---
2316
2317    /// Helper: build a minimal SchemaConfig with the given fields.
2318    fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
2319        SchemaConfig {
2320            table: "test".into(),
2321            title: None,
2322            description: None,
2323            fields,
2324            dump: None,
2325            history: Default::default(),
2326        }
2327    }
2328
2329    /// AC #3-a: absent fields in the patch are preserved from current.
2330    #[test]
2331    fn shallow_merge_preserves_absent_fields() {
2332        let schema = make_schema(vec![
2333            FieldDef {
2334                name: "a".into(),
2335                ty: FieldType::Number,
2336                required: true,
2337                description: None,
2338            },
2339            FieldDef {
2340                name: "b".into(),
2341                ty: FieldType::Number,
2342                required: false,
2343                description: None,
2344            },
2345        ]);
2346        let current = serde_json::json!({"a": 1, "b": 2});
2347        let patch = serde_json::json!({"a": 9});
2348        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2349        assert_eq!(merged["a"], 9, "patched field must be updated");
2350        assert_eq!(
2351            merged["b"], 2,
2352            "absent patch field must be preserved from current"
2353        );
2354    }
2355
2356    /// AC #3-b: null value for an optional field physically removes it from the merged object.
2357    #[test]
2358    fn shallow_merge_deletes_null_for_optional_field() {
2359        let schema = make_schema(vec![
2360            FieldDef {
2361                name: "a".into(),
2362                ty: FieldType::Number,
2363                required: true,
2364                description: None,
2365            },
2366            FieldDef {
2367                name: "b".into(),
2368                ty: FieldType::Number,
2369                required: false,
2370                description: None,
2371            },
2372        ]);
2373        let current = serde_json::json!({"a": 1, "b": 2});
2374        let patch = serde_json::json!({"b": null});
2375        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2376        assert_eq!(merged["a"], 1);
2377        assert!(
2378            merged.get("b").is_none(),
2379            "null-patched optional field must be physically removed (not set to null)"
2380        );
2381    }
2382
2383    /// AC #3-c: null value for a required field returns a Validation error.
2384    #[test]
2385    fn shallow_merge_errors_on_null_for_required_field() {
2386        let schema = make_schema(vec![FieldDef {
2387            name: "title".into(),
2388            ty: FieldType::String,
2389            required: true,
2390            description: None,
2391        }]);
2392        let current = serde_json::json!({"title": "hello"});
2393        let patch = serde_json::json!({"title": null});
2394        let err = shallow_merge(current, patch, &schema).expect_err("must error");
2395        match err {
2396            MiniAppError::Validation { field, reason } => {
2397                assert_eq!(field, "title");
2398                assert!(
2399                    reason.contains("required field cannot be deleted via null"),
2400                    "unexpected reason: {reason}"
2401                );
2402            }
2403            other => panic!("expected Validation error, got: {other:?}"),
2404        }
2405    }
2406
2407    /// AC #3-d: nested objects are replaced wholesale, not deep-merged.
2408    #[test]
2409    fn shallow_merge_replaces_nested_object_wholesale() {
2410        let schema = make_schema(vec![FieldDef {
2411            name: "cfg".into(),
2412            ty: FieldType::Object,
2413            required: false,
2414            description: None,
2415        }]);
2416        let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
2417        let patch = serde_json::json!({"cfg": {"x": 9}});
2418        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
2419        assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
2420        assert!(
2421            merged["cfg"].get("y").is_none(),
2422            "y must be absent (nested object replaced wholesale, not deep-merged)"
2423        );
2424    }
2425
2426    /// AC #3-e: non-object patch (array / number / string) returns Validation error.
2427    #[test]
2428    fn shallow_merge_rejects_non_object_patch() {
2429        let schema = make_schema(vec![]);
2430        let current = serde_json::json!({"a": 1});
2431
2432        for bad_patch in [
2433            serde_json::json!([1, 2, 3]),
2434            serde_json::json!(42),
2435            serde_json::json!("string"),
2436        ] {
2437            let err = shallow_merge(current.clone(), bad_patch, &schema)
2438                .expect_err("non-object patch must be rejected");
2439            match err {
2440                MiniAppError::Validation { field, .. } => {
2441                    assert_eq!(field, "(root)", "error field must be '(root)'");
2442                }
2443                other => panic!("expected Validation error, got: {other:?}"),
2444            }
2445        }
2446    }
2447
2448    /// AC #3-f: post-merge schema validation catches type mismatches in the merged result.
2449    /// Tests the full Store::update Merge path (not just shallow_merge in isolation).
2450    #[tokio::test]
2451    async fn store_update_merge_runs_post_merge_validation() {
2452        let store = make_test_store().await;
2453        let row = store
2454            .create(serde_json::json!({"title": "x", "state": "open"}))
2455            .await
2456            .unwrap();
2457
2458        // Patch `state` with a number — type mismatch must be caught by post-merge validate.
2459        let err = store
2460            .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
2461            .await
2462            .expect_err("type mismatch must fail post-merge validation");
2463
2464        assert!(
2465            matches!(err, MiniAppError::Validation { .. }),
2466            "expected Validation error, got: {err:?}"
2467        );
2468    }
2469
2470    // -----------------------------------------------------------------------
2471    // Alias CRUD tests
2472    // -----------------------------------------------------------------------
2473
2474    use crate::filter::ListFilter;
2475
2476    /// Build a trivial ListFilter suitable for alias tests.
2477    fn make_filter() -> ListFilter {
2478        ListFilter::Eq {
2479            field: "state".to_string(),
2480            value: serde_json::json!("open"),
2481        }
2482    }
2483
2484    /// AC#5: alias_create → alias_get round-trip preserves all fields.
2485    #[tokio::test]
2486    async fn alias_create_and_get_round_trip() {
2487        let store = make_test_store().await;
2488        let filter = make_filter();
2489        let filter_json = serde_json::to_string(&filter).unwrap();
2490
2491        store
2492            .alias_create(
2493                "recent_open",
2494                &filter_json,
2495                Some(20),
2496                Some("desc".to_string()),
2497                None,
2498            )
2499            .await
2500            .expect("alias_create must succeed");
2501
2502        let record = store
2503            .alias_get("recent_open")
2504            .await
2505            .expect("alias_get must succeed");
2506
2507        assert_eq!(record.name, "recent_open");
2508        assert_eq!(record.default_limit, Some(20));
2509        assert_eq!(record.description.as_deref(), Some("desc"));
2510
2511        // filter round-trip: deserialise from the stored JSON text
2512        let restored: ListFilter =
2513            serde_json::from_str(&record.filter).expect("filter must deserialise");
2514        let stored_back = serde_json::to_string(&filter).unwrap();
2515        let stored_back2 = serde_json::to_string(&restored).unwrap();
2516        assert_eq!(
2517            stored_back, stored_back2,
2518            "filter must survive a JSON round-trip"
2519        );
2520    }
2521
2522    /// AC#5 (nulls): alias_create with None default_limit and None description.
2523    #[tokio::test]
2524    async fn alias_create_with_optional_nulls() {
2525        let store = make_test_store().await;
2526        let filter = make_filter();
2527        let filter_json = serde_json::to_string(&filter).unwrap();
2528
2529        store
2530            .alias_create("no_opts", &filter_json, None, None, None)
2531            .await
2532            .expect("alias_create must succeed with None optionals");
2533
2534        let record = store
2535            .alias_get("no_opts")
2536            .await
2537            .expect("alias_get must succeed");
2538        assert_eq!(record.name, "no_opts");
2539        assert!(record.default_limit.is_none());
2540        assert!(record.description.is_none());
2541    }
2542
2543    /// AC#6: alias_list returns all registered aliases.
2544    #[tokio::test]
2545    async fn alias_list_returns_all() {
2546        let store = make_test_store().await;
2547        let filter = make_filter();
2548
2549        // Initially empty.
2550        let list = store
2551            .alias_list()
2552            .await
2553            .expect("alias_list must succeed on empty store");
2554        assert!(list.is_empty(), "empty store should return empty list");
2555
2556        let filter_json = serde_json::to_string(&filter).unwrap();
2557        store
2558            .alias_create("b_alias", &filter_json, None, None, None)
2559            .await
2560            .unwrap();
2561        store
2562            .alias_create("a_alias", &filter_json, None, None, None)
2563            .await
2564            .unwrap();
2565
2566        let list = store.alias_list().await.expect("alias_list must succeed");
2567        assert_eq!(list.len(), 2, "must return 2 aliases");
2568        // Ordered by name ASC.
2569        assert_eq!(list[0].name, "a_alias");
2570        assert_eq!(list[1].name, "b_alias");
2571    }
2572
2573    /// AC#7: alias_delete removes the alias, subsequent alias_get returns AliasNotFound.
2574    #[tokio::test]
2575    async fn alias_delete_removes_alias() {
2576        let store = make_test_store().await;
2577        let filter = make_filter();
2578        let filter_json = serde_json::to_string(&filter).unwrap();
2579
2580        store
2581            .alias_create("to_delete", &filter_json, None, None, None)
2582            .await
2583            .unwrap();
2584
2585        store
2586            .alias_delete("to_delete")
2587            .await
2588            .expect("alias_delete must succeed");
2589
2590        let err = store
2591            .alias_get("to_delete")
2592            .await
2593            .expect_err("alias_get after delete must fail");
2594
2595        assert!(
2596            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
2597            "expected AliasNotFound, got: {err:?}"
2598        );
2599    }
2600
2601    /// AC#8: duplicate alias_create returns AliasAlreadyExists.
2602    #[tokio::test]
2603    async fn alias_create_duplicate_returns_already_exists() {
2604        let store = make_test_store().await;
2605        let filter = make_filter();
2606        let filter_json = serde_json::to_string(&filter).unwrap();
2607
2608        store
2609            .alias_create("dup", &filter_json, None, None, None)
2610            .await
2611            .expect("first alias_create must succeed");
2612
2613        let err = store
2614            .alias_create("dup", &filter_json, None, None, None)
2615            .await
2616            .expect_err("second alias_create must fail");
2617
2618        assert!(
2619            matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
2620            "expected AliasAlreadyExists, got: {err:?}"
2621        );
2622    }
2623
2624    /// AC#9: alias_get for non-existent name returns AliasNotFound.
2625    #[tokio::test]
2626    async fn alias_get_missing_returns_not_found() {
2627        let store = make_test_store().await;
2628
2629        let err = store
2630            .alias_get("nonexistent")
2631            .await
2632            .expect_err("alias_get on missing alias must fail");
2633
2634        assert!(
2635            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2636            "expected AliasNotFound, got: {err:?}"
2637        );
2638    }
2639
2640    /// AC#9: alias_delete for non-existent name returns AliasNotFound.
2641    #[tokio::test]
2642    async fn alias_delete_missing_returns_not_found() {
2643        let store = make_test_store().await;
2644
2645        let err = store
2646            .alias_delete("nonexistent")
2647            .await
2648            .expect_err("alias_delete on missing alias must fail");
2649
2650        assert!(
2651            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
2652            "expected AliasNotFound, got: {err:?}"
2653        );
2654    }
2655
2656    /// Verify per-table isolation: two separate Store instances (each with their
2657    /// own :memory: DB) have independent _aliases tables.
2658    #[tokio::test]
2659    async fn alias_namespace_isolation_between_stores() {
2660        let store_a = make_test_store().await;
2661        let store_b = make_test_store().await;
2662        let filter = make_filter();
2663
2664        let filter_json = serde_json::to_string(&filter).unwrap();
2665        store_a
2666            .alias_create("shared_name", &filter_json, None, None, None)
2667            .await
2668            .expect("store_a alias_create must succeed");
2669
2670        // store_b has a completely separate _aliases table — the alias must not be visible.
2671        let err = store_b
2672            .alias_get("shared_name")
2673            .await
2674            .expect_err("alias created in store_a must not be visible in store_b");
2675
2676        assert!(
2677            matches!(err, MiniAppError::AliasNotFound { .. }),
2678            "expected AliasNotFound in store_b, got: {err:?}"
2679        );
2680    }
2681
2682    // --- UUID prefix match tests ---
2683
2684    /// prefix match: single hit → returns that row
2685    #[tokio::test]
2686    async fn test_get_prefix_match_single() {
2687        let store = make_test_store().await;
2688        let row = store
2689            .create(serde_json::json!({"title": "prefix-test"}))
2690            .await
2691            .unwrap();
2692        // Use first 8 characters as prefix (UUID v4 is sufficiently random).
2693        let prefix = &row.id[..8];
2694        let fetched = store.get(prefix).await.unwrap();
2695        assert_eq!(fetched.id, row.id);
2696        assert_eq!(fetched.data["title"], "prefix-test");
2697    }
2698
2699    /// prefix match: 0 hits → NotFound
2700    #[tokio::test]
2701    async fn test_get_prefix_match_not_found() {
2702        let store = make_test_store().await;
2703        // "zzzzzzzz" is not a valid UUID hex prefix, will match nothing.
2704        let err = store.get("zzzzzzzz").await.unwrap_err();
2705        assert!(
2706            matches!(err, MiniAppError::NotFound { .. }),
2707            "expected NotFound, got: {err:?}"
2708        );
2709    }
2710
2711    /// prefix match: 2+ hits → AmbiguousId with candidate list
2712    #[tokio::test]
2713    async fn test_get_prefix_match_ambiguous() {
2714        let store = make_test_store().await;
2715        // Insert two rows whose IDs start with a known prefix by manipulating
2716        // the DB directly.  We use the internal connection via execute_under_savepoint.
2717        let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2718        let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2719        let id1_clone = id1.clone();
2720        let id2_clone = id2.clone();
2721        store
2722            .execute_under_savepoint(move |sp| {
2723                sp.execute(
2724                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2725                    rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2726                )?;
2727                sp.execute(
2728                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2729                    rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2730                )?;
2731                Ok(())
2732            })
2733            .await
2734            .unwrap();
2735
2736        let err = store.get("aaaaaaaa").await.unwrap_err();
2737        match err {
2738            MiniAppError::AmbiguousId {
2739                ref id_prefix,
2740                ref candidates,
2741            } => {
2742                assert_eq!(id_prefix, "aaaaaaaa");
2743                assert_eq!(candidates.len(), 2);
2744                let mut sorted = candidates.clone();
2745                sorted.sort();
2746                assert_eq!(sorted[0], id1);
2747                assert_eq!(sorted[1], id2);
2748            }
2749            other => panic!("expected AmbiguousId, got: {other:?}"),
2750        }
2751    }
2752
2753    /// full UUID (36 chars) bypasses prefix match and uses exact query
2754    #[tokio::test]
2755    async fn test_get_full_uuid_bypass() {
2756        let store = make_test_store().await;
2757        let row = store
2758            .create(serde_json::json!({"title": "bypass-test"}))
2759            .await
2760            .unwrap();
2761        assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2762        // Pass the full UUID — must resolve via exact match, not LIKE.
2763        let fetched = store.get(&row.id).await.unwrap();
2764        assert_eq!(fetched.id, row.id);
2765    }
2766
2767    /// update with prefix match: single hit → update succeeds
2768    #[tokio::test]
2769    async fn test_update_prefix_match_single() {
2770        let store = make_test_store().await;
2771        let row = store
2772            .create(serde_json::json!({"title": "before"}))
2773            .await
2774            .unwrap();
2775        let prefix = &row.id[..8];
2776        let updated = store
2777            .update(
2778                prefix,
2779                serde_json::json!({"title": "after"}),
2780                UpdateMode::Replace,
2781            )
2782            .await
2783            .unwrap();
2784        assert_eq!(updated.id, row.id);
2785        assert_eq!(updated.data["title"], "after");
2786    }
2787
2788    /// update with prefix match: 2+ hits → AmbiguousId
2789    #[tokio::test]
2790    async fn test_update_prefix_match_ambiguous() {
2791        let store = make_test_store().await;
2792        let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2793        let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2794        store
2795            .execute_under_savepoint(move |sp| {
2796                sp.execute(
2797                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2798                    rusqlite::params![id1, r#"{"title":"b1"}"#],
2799                )?;
2800                sp.execute(
2801                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2802                    rusqlite::params![id2, r#"{"title":"b2"}"#],
2803                )?;
2804                Ok(())
2805            })
2806            .await
2807            .unwrap();
2808
2809        let err = store
2810            .update(
2811                "bbbbbbbb",
2812                serde_json::json!({"title": "x"}),
2813                UpdateMode::Replace,
2814            )
2815            .await
2816            .unwrap_err();
2817        assert!(
2818            matches!(err, MiniAppError::AmbiguousId { .. }),
2819            "expected AmbiguousId, got: {err:?}"
2820        );
2821    }
2822
2823    /// delete with prefix match: single hit → delete succeeds
2824    #[tokio::test]
2825    async fn test_delete_prefix_match_single() {
2826        let store = make_test_store().await;
2827        let row = store
2828            .create(serde_json::json!({"title": "to-delete-prefix"}))
2829            .await
2830            .unwrap();
2831        let prefix = &row.id[..8];
2832        store.delete(prefix).await.unwrap();
2833        // Confirm deletion via full UUID
2834        let err = store.get(&row.id).await.unwrap_err();
2835        assert!(
2836            matches!(err, MiniAppError::NotFound { .. }),
2837            "expected NotFound after delete, got: {err:?}"
2838        );
2839    }
2840
2841    /// delete with prefix match: 2+ hits → AmbiguousId
2842    #[tokio::test]
2843    async fn test_delete_prefix_match_ambiguous() {
2844        let store = make_test_store().await;
2845        let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2846        let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2847        store
2848            .execute_under_savepoint(move |sp| {
2849                sp.execute(
2850                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2851                    rusqlite::params![id1, r#"{"title":"c1"}"#],
2852                )?;
2853                sp.execute(
2854                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2855                    rusqlite::params![id2, r#"{"title":"c2"}"#],
2856                )?;
2857                Ok(())
2858            })
2859            .await
2860            .unwrap();
2861
2862        let err = store.delete("cccccccc").await.unwrap_err();
2863        assert!(
2864            matches!(err, MiniAppError::AmbiguousId { .. }),
2865            "expected AmbiguousId, got: {err:?}"
2866        );
2867    }
2868}