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::schema::SchemaConfig;
25
26// ---------------------------------------------------------------------------
27// Public types
28// ---------------------------------------------------------------------------
29
30/// A single stored row returned by CRUD operations.
31///
32/// The `data` field contains the raw JSON object that was supplied at
33/// creation / update time.  `created_at` and `updated_at` are Unix epoch
34/// seconds.
35#[derive(Debug, Clone, Serialize)]
36pub struct RowRecord {
37    /// Unique row identifier (UUID v4 string).
38    pub id: String,
39    /// The validated JSON payload stored for this row.
40    pub data: serde_json::Value,
41    /// Unix epoch seconds at the time the row was created.
42    pub created_at: i64,
43    /// Unix epoch seconds at the time the row was last updated.
44    pub updated_at: i64,
45}
46
47/// Update semantics for [`Store::update`].
48///
49/// - `Merge` (default): RFC 7396 shallow merge. Absent fields are preserved
50///   from the stored row. A `null` patch value deletes the field when
51///   `required = false`; it returns a [`MiniAppError::Validation`] error when
52///   `required = true`. A full schema validation runs on the merged result
53///   before persisting.
54/// - `Replace`: Full replacement — identical to the pre-breaking-change default
55///   behavior. The stored row is overwritten byte-for-byte with the supplied
56///   `value` after schema validation.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema)]
58#[serde(rename_all = "lowercase")]
59pub enum UpdateMode {
60    /// RFC 7396 shallow merge (default).
61    #[default]
62    Merge,
63    /// Full replacement (legacy behavior).
64    Replace,
65}
66
67/// Async CRUD store backed by a single SQLite table.
68///
69/// The store wraps a `rusqlite::Connection` in an `Arc<Mutex<_>>` so it can
70/// be cloned and shared across async tasks.  [`rusqlite::Connection`] is
71/// `Send` but `!Sync`; the `Mutex` provides the required exclusive access.
72///
73/// All database operations execute inside `tokio::task::spawn_blocking` so
74/// the tokio runtime thread-pool is never blocked.
75pub struct Store {
76    conn: Arc<Mutex<rusqlite::Connection>>,
77    schema: SchemaConfig,
78    /// Filesystem path of the SQLite database file backing this store.
79    ///
80    /// Captured at [`Store::open`] time and exposed via
81    /// [`Store::db_path`] for the multi-table aggregator path, which
82    /// uses `ATTACH DATABASE` to mount per-table `.db` files into a
83    /// shared in-memory connection.
84    db_path: PathBuf,
85}
86
87// ---------------------------------------------------------------------------
88// DDL
89// ---------------------------------------------------------------------------
90
91/// Fixed DDL.  Schema-yaml columns are **never** added here (Crux #1).
92const CREATE_TABLE_SQL: &str = "
93    CREATE TABLE IF NOT EXISTS rows (
94        id          TEXT    PRIMARY KEY,
95        data        TEXT    NOT NULL,
96        created_at  INTEGER NOT NULL,
97        updated_at  INTEGER NOT NULL
98    )
99";
100
101/// DDL for the per-table named query alias store.
102///
103/// `_aliases` lives inside the same `.db` file as `rows`, ensuring per-table
104/// namespace isolation: each [`Store`] instance only ever accesses the
105/// `_aliases` table in its own database connection.
106///
107/// `name` is the PRIMARY KEY — UNIQUE constraint is implicit.
108/// `filter` stores the serialized [`crate::filter::ListFilter`] JSON, or a
109/// MiniJinja template string when `params_schema` is set.
110/// `default_limit` is optional and may be overridden at `alias_run` call time.
111/// `params_schema` stores an optional JSON array of parameter name strings
112/// (e.g. `["project","owner"]`); `NULL` means the alias takes no parameters.
113const CREATE_ALIASES_TABLE_SQL: &str = "
114    CREATE TABLE IF NOT EXISTS _aliases (
115        name           TEXT    PRIMARY KEY,
116        filter         TEXT    NOT NULL,
117        default_limit  INTEGER,
118        description    TEXT,
119        params_schema  TEXT
120    )
121";
122
123/// A row returned from the `_aliases` table.
124///
125/// `filter` is stored as raw JSON text or a MiniJinja template string;
126/// callers (`alias_run` in server.rs) are responsible for rendering and
127/// deserialising it back to a [`crate::filter::ListFilter`].
128#[derive(Debug, Clone)]
129pub struct AliasRecord {
130    /// Alias name (PRIMARY KEY in `_aliases`).
131    pub name: String,
132    /// Serialised [`crate::filter::ListFilter`] JSON string, or a MiniJinja
133    /// template string when `params_schema` is `Some`.
134    pub filter: String,
135    /// Optional default limit to apply when `alias_run` does not supply one.
136    pub default_limit: Option<u32>,
137    /// Optional human-readable description.
138    pub description: Option<String>,
139    /// Optional JSON array of parameter name strings (e.g. `["project","owner"]`).
140    /// `None` means the alias takes no parameters and the filter text is plain JSON.
141    pub params_schema: Option<String>,
142}
143
144// ---------------------------------------------------------------------------
145// Helpers
146// ---------------------------------------------------------------------------
147
148/// Returns the current time as Unix epoch seconds.
149fn now_secs() -> i64 {
150    SystemTime::now()
151        .duration_since(UNIX_EPOCH)
152        .unwrap_or_default()
153        .as_secs() as i64
154}
155
156/// Parse a JSON text column back into `serde_json::Value`.
157fn parse_data(json_str: &str) -> Result<serde_json::Value, MiniAppError> {
158    serde_json::from_str(json_str).map_err(|e| MiniAppError::Schema(format!("data column: {e}")))
159}
160
161/// Resolves a possibly-shortened id prefix to the full UUID stored in `rows`.
162///
163/// - If `id.len() == 36`: full UUID bypass — returns `Ok(id.to_string())`
164///   immediately without querying the database.
165/// - Otherwise: executes `SELECT id FROM rows WHERE id LIKE ?1` with param
166///   `format!("{}%", id)`.
167///   - 0 results  → `Err(MiniAppError::NotFound { id: id.to_string() })`
168///   - 1 result   → `Ok(candidates[0].clone())`
169///   - 2+ results → `Err(MiniAppError::AmbiguousId { id_prefix, candidates })`
170///
171/// # Security
172/// The `%` wildcard is appended to the *parameter value*, not to the SQL
173/// template, so this is safe against SQL injection (rusqlite parameterized
174/// query).  UUID character set (0-9, a-f, hyphens) contains no LIKE metachar
175/// (`%` or `_`), so no LIKE escaping is needed in practice.
176fn resolve_id(conn: &rusqlite::Connection, id: &str) -> Result<String, MiniAppError> {
177    if id.len() == 36 {
178        // Full UUID bypass: skip LIKE query entirely (Crux constraint).
179        return Ok(id.to_string());
180    }
181    let mut stmt = conn.prepare("SELECT id FROM rows WHERE id LIKE ?1")?;
182    let candidates: Vec<String> = stmt
183        .query_map(rusqlite::params![format!("{}%", id)], |row| {
184            row.get::<_, String>(0)
185        })?
186        .collect::<Result<Vec<_>, _>>()?;
187    match candidates.len() {
188        0 => Err(MiniAppError::NotFound { id: id.to_string() }),
189        1 => {
190            // SAFETY: len == 1 guarantees next() returns Some.
191            Ok(candidates.into_iter().next().unwrap())
192        }
193        _ => Err(MiniAppError::AmbiguousId {
194            id_prefix: id.to_string(),
195            candidates,
196        }),
197    }
198}
199
200/// RFC 7396 shallow merge: apply `patch` on top of `current`, consulting
201/// `schema` for required-field null-deletion checks.
202///
203/// Rules:
204/// - `patch` must be a JSON object; otherwise `Err(Validation { field: "(root)", .. })`.
205/// - For each `(key, value)` in `patch`:
206///   - If `value` is `null`: look up the field in `schema`.
207///     - `required = true` → `Err(Validation { field: key, reason: "required field cannot be deleted via null" })`.
208///     - Otherwise → remove the key from `current` (physical deletion from the Map).
209///   - If `value` is non-null: overwrite `current[key]` with `value` (nested
210///     objects are replaced wholesale — no deep merge).
211/// - Fields not mentioned in `patch` are untouched in `current`.
212/// - Returns the merged `serde_json::Value` (always an Object).
213///
214/// The caller is responsible for running `schema.validate(&merged)` after this
215/// call to enforce post-merge type/required constraints.
216fn shallow_merge(
217    mut current: serde_json::Value,
218    patch: serde_json::Value,
219    schema: &SchemaConfig,
220) -> Result<serde_json::Value, MiniAppError> {
221    let patch_map = patch.as_object().ok_or_else(|| MiniAppError::Validation {
222        field: "(root)".to_string(),
223        reason: "patch must be a JSON object".to_string(),
224    })?;
225
226    let current_map = current
227        .as_object_mut()
228        .ok_or_else(|| MiniAppError::Validation {
229            field: "(root)".to_string(),
230            reason: "stored row is not a JSON object".to_string(),
231        })?;
232
233    for (key, value) in patch_map {
234        if value.is_null() {
235            // Null means "delete this field" per RFC 7396.
236            let is_required = schema
237                .fields
238                .iter()
239                .find(|f| &f.name == key)
240                .map(|f| f.required)
241                .unwrap_or(false);
242
243            if is_required {
244                return Err(MiniAppError::Validation {
245                    field: key.clone(),
246                    reason: "required field cannot be deleted via null".to_string(),
247                });
248            }
249            current_map.remove(key);
250        } else {
251            current_map.insert(key.clone(), value.clone());
252        }
253    }
254
255    Ok(current)
256}
257
258// ---------------------------------------------------------------------------
259// Store impl
260// ---------------------------------------------------------------------------
261
262impl Store {
263    /// Open the SQLite database at `db_path` and run `CREATE TABLE IF NOT EXISTS rows`.
264    ///
265    /// # WAL journal mode
266    /// The connection is opened with `PRAGMA journal_mode = WAL` to enable safe
267    /// coexistence of old and new [`Store`] instances during schema hot-reload
268    /// (see `crux-card.md` Crux #1). WAL mode allows one writer and many readers
269    /// concurrently, preventing lock conflicts when dual registries are held.
270    /// Sidecar files `<db>.db-wal` and `<db>.db-shm` are created next to the
271    /// main DB file; this is expected and safe.
272    ///
273    /// # Concurrency
274    /// Returns a [`Store`] that wraps `Arc<Mutex<rusqlite::Connection>>` and is
275    /// `Send + Sync`. [`rusqlite::Connection`] is `Send` but `!Sync`; the
276    /// `std::sync::Mutex` provides exclusive access. All subsequent CRUD calls
277    /// acquire the lock inside `spawn_blocking` closures and drop it before any
278    /// `.await` point — holding a `MutexGuard` across `.await` is never permitted.
279    ///
280    /// If `schema.dump.sync` is `Some(SyncMode::Bidirectional)`, a
281    /// `tracing::warn!` is emitted once here; the store behaves as write-only
282    /// until bidirectional sync is implemented.
283    ///
284    /// # Cancel Safety
285    /// Not cancel-safe. Once the `spawn_blocking` closure has started (DDL
286    /// execution), calling `abort` on the `JoinHandle` or dropping the returned
287    /// `Future` has no effect — the DDL completes on the blocking thread pool.
288    ///
289    /// # Errors
290    /// - [`MiniAppError::Storage`] — `Connection::open`, WAL pragma, or DDL execute failure.
291    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
292    ///
293    /// # Panic
294    /// Does not panic.
295    pub async fn open(db_path: &Path, schema: SchemaConfig) -> Result<Self, MiniAppError> {
296        // Warn if bidirectional sync is configured but not yet implemented.
297        if let Some(crate::dump::SyncMode::Bidirectional) =
298            schema.dump.as_ref().and_then(|d| d.sync.as_ref())
299        {
300            tracing::warn!(
301                target: "mini_app_mcp::dump",
302                "sync=bidirectional configured but not implemented yet; behaving as write-only"
303            );
304        }
305
306        let stored_db_path = db_path.to_path_buf();
307        let db_path = db_path.to_path_buf();
308        let conn =
309            tokio::task::spawn_blocking(move || -> Result<rusqlite::Connection, MiniAppError> {
310                let c = rusqlite::Connection::open(&db_path)?;
311                // Enable WAL journal mode before DDL. WAL allows concurrent readers
312                // and one writer, which is essential for Crux #1 dual-registry safety.
313                c.pragma_update(None, "journal_mode", "WAL")?;
314                // Read back the actual mode: SQLite silently falls back to non-WAL
315                // on `:memory:`, NFS, or read-only filesystems.  A mismatch does not
316                // prevent startup but means concurrent reload may hit SQLITE_BUSY.
317                let actual_mode: String = c.query_row("PRAGMA journal_mode", [], |r| r.get(0))?;
318                if actual_mode.to_lowercase() != "wal" {
319                    tracing::warn!(
320                        actual_mode = %actual_mode,
321                        "PRAGMA journal_mode=WAL fell back to non-WAL mode; \
322                         concurrent reload may hit SQLITE_BUSY"
323                    );
324                }
325                c.execute_batch(CREATE_TABLE_SQL)?;
326                c.execute_batch(CREATE_ALIASES_TABLE_SQL)?;
327                // Idempotent migration: add params_schema column if absent (K-1 st1-entries).
328                // SQLite does not support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, so we
329                // use PRAGMA table_info to check for the column first.
330                let has_params_schema = c
331                    .prepare("PRAGMA table_info(_aliases)")?
332                    .query_map([], |row| row.get::<_, String>(1))?
333                    .collect::<Result<Vec<_>, _>>()?
334                    .iter()
335                    .any(|name| name == "params_schema");
336                if !has_params_schema {
337                    c.execute_batch("ALTER TABLE _aliases ADD COLUMN params_schema TEXT")?;
338                }
339                Ok(c)
340            })
341            .await
342            .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
343
344        Ok(Store {
345            conn: Arc::new(Mutex::new(conn)),
346            schema,
347            db_path: stored_db_path,
348        })
349    }
350
351    /// Returns the filesystem path of the SQLite database file backing
352    /// this store, as captured at [`Store::open`] time.
353    ///
354    /// Used by `mini_app_core::aggregator::execute_aggregate` to mount
355    /// each per-table `.db` file via `ATTACH DATABASE` for the
356    /// multi-table `UNION ALL` aggregation path (Crux #3).
357    pub fn db_path(&self) -> &Path {
358        &self.db_path
359    }
360
361    /// Returns a clone of the [`Arc<Mutex<rusqlite::Connection>>`]
362    /// handle backing this store. Used by
363    /// [`crate::alias_storage::GlobalAliasStorage::migrate_from_per_table`]
364    /// to read the legacy per-table `_aliases` rows on registry mount.
365    ///
366    /// The connection is shared (no copy); callers MUST acquire the
367    /// `Mutex` lock inside a `spawn_blocking` body to avoid blocking the
368    /// async runtime.
369    pub fn conn(&self) -> Arc<Mutex<rusqlite::Connection>> {
370        Arc::clone(&self.conn)
371    }
372
373    /// Validate `value` against the schema and insert a new row with a
374    /// generated UUID primary key.
375    ///
376    /// # Concurrency
377    /// The rusqlite `INSERT` executes inside `tokio::task::spawn_blocking`.
378    /// `Arc<Mutex<Connection>>` is cloned before entering the blocking closure;
379    /// the [`std::sync::MutexGuard`] is acquired and dropped entirely within the
380    /// blocking closure — never held across an `.await` point.
381    ///
382    /// After the `spawn_blocking` future resolves, `dump::on_change` is called
383    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
384    /// If `dump::on_change` fails (e.g. disk full), the error is propagated via
385    /// `?` and the caller receives `Err(MiniAppError::Io(_))`; the row remains
386    /// in the database (DB and file may be transiently inconsistent until the
387    /// next successful write).
388    ///
389    /// # Cancel Safety
390    /// Not cancel-safe. Once the `spawn_blocking` closure has started, the
391    /// `INSERT` completes regardless of `Future` cancellation. If the caller
392    /// drops this `Future` after the INSERT but before `dump::on_change`
393    /// completes, the file may not be materialized while the DB row exists.
394    ///
395    /// # Errors
396    /// - [`MiniAppError::Validation`] — required field absent or type mismatch.
397    /// - [`MiniAppError::Storage`] — rusqlite error (constraint violation, I/O).
398    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
399    /// - [`MiniAppError::Io`] — dump file write failure (only when `dump` is configured).
400    ///
401    /// # Panic
402    /// Does not panic. Mutex poisoning is propagated as `Err(MiniAppError::Storage(_))`.
403    pub async fn create(&self, value: serde_json::Value) -> Result<RowRecord, MiniAppError> {
404        self.schema.validate(&value)?;
405
406        let id = uuid::Uuid::new_v4().to_string();
407        let now = now_secs();
408        let data_str =
409            serde_json::to_string(&value).expect("serde_json::Value serialization is infallible");
410
411        let conn = self.conn.clone();
412        let id_inner = id.clone();
413        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
414            let conn = conn
415                .lock()
416                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
417            conn.execute(
418                "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
419                rusqlite::params![id_inner, data_str, now, now],
420            )?;
421            Ok(RowRecord {
422                id: id_inner,
423                data: value,
424                created_at: now,
425                updated_at: now,
426            })
427        })
428        .await
429        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
430
431        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
432        crate::dump::on_change(&self.schema, &record).await?;
433
434        Ok(record)
435    }
436
437    /// Fetch the row with the given `id`.
438    ///
439    /// # Concurrency
440    /// The `SELECT` executes inside `tokio::task::spawn_blocking`. The
441    /// [`std::sync::MutexGuard`] is acquired and released within the blocking
442    /// closure; no lock is held across `.await`.
443    ///
444    /// # Cancel Safety
445    /// Once the blocking closure has started the `SELECT` will complete
446    /// regardless of `Future` cancellation.
447    ///
448    /// # Errors
449    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
450    /// - [`MiniAppError::Storage`] — rusqlite error.
451    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
452    ///
453    /// # Panic
454    /// Does not panic.
455    pub async fn get(&self, id: &str) -> Result<RowRecord, MiniAppError> {
456        let conn = self.conn.clone();
457        let id = id.to_string();
458
459        tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
460            let conn = conn
461                .lock()
462                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
463            let id = resolve_id(&conn, &id)?;
464            let mut stmt =
465                conn.prepare("SELECT id, data, created_at, updated_at FROM rows WHERE id = ?1")?;
466            let row = stmt
467                .query_row(rusqlite::params![id], |row| {
468                    Ok((
469                        row.get::<_, String>(0)?,
470                        row.get::<_, String>(1)?,
471                        row.get::<_, i64>(2)?,
472                        row.get::<_, i64>(3)?,
473                    ))
474                })
475                .optional()?
476                .ok_or_else(|| MiniAppError::NotFound { id: id.clone() })?;
477
478            let data = parse_data(&row.1)?;
479            Ok(RowRecord {
480                id: row.0,
481                data,
482                created_at: row.2,
483                updated_at: row.3,
484            })
485        })
486        .await
487        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
488    }
489
490    /// Return rows ordered by `created_at DESC`.
491    ///
492    /// `limit` defaults to `100` (max `1000`). `offset` defaults to `0`.
493    ///
494    /// # Concurrency
495    /// The `SELECT` executes inside `tokio::task::spawn_blocking`. The
496    /// [`std::sync::MutexGuard`] is held only within the blocking closure.
497    ///
498    /// # Cancel Safety
499    /// Once the blocking closure has started the query runs to completion
500    /// regardless of `Future` cancellation.
501    ///
502    /// # Errors
503    /// - [`MiniAppError::Storage`] — rusqlite error.
504    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
505    /// - [`MiniAppError::Validation`] — `build_sql` on `filter` fails
506    ///   (defensive; callers should call `filter.validate()` first).
507    ///
508    /// # Panic
509    /// Does not panic.
510    pub async fn list(
511        &self,
512        limit: Option<u32>,
513        offset: Option<u32>,
514        filter: Option<ListFilter>,
515    ) -> Result<Vec<RowRecord>, MiniAppError> {
516        let conn = self.conn.clone();
517        let limit = limit.unwrap_or(100).min(1000) as i64;
518        let offset = offset.unwrap_or(0) as i64;
519
520        // Build WHERE clause + params from filter (before spawning the blocking task).
521        let (where_clause, filter_params) = match filter {
522            None => (String::new(), Vec::new()),
523            Some(f) => {
524                let (fragment, params) = f.build_sql()?;
525                (format!(" WHERE {fragment}"), params)
526            }
527        };
528
529        tokio::task::spawn_blocking(move || -> Result<Vec<RowRecord>, MiniAppError> {
530            let conn = conn
531                .lock()
532                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
533            let sql = format!(
534                "SELECT id, data, created_at, updated_at FROM rows{where_clause} \
535                 ORDER BY created_at DESC LIMIT ? OFFSET ?"
536            );
537            // Combine filter params with LIMIT/OFFSET params in order.
538            let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = filter_params
539                .into_iter()
540                .map(|p| -> Box<dyn rusqlite::ToSql> { Box::new(p) })
541                .collect();
542            all_params.push(Box::new(limit));
543            all_params.push(Box::new(offset));
544
545            let mut stmt = conn.prepare(&sql)?;
546            let rows = stmt
547                .query_map(
548                    params_from_iter(all_params.iter().map(|p| p.as_ref())),
549                    |row| {
550                        Ok((
551                            row.get::<_, String>(0)?,
552                            row.get::<_, String>(1)?,
553                            row.get::<_, i64>(2)?,
554                            row.get::<_, i64>(3)?,
555                        ))
556                    },
557                )?
558                .map(|r| {
559                    r.map_err(MiniAppError::Storage).and_then(|row| {
560                        let data = parse_data(&row.1)?;
561                        Ok(RowRecord {
562                            id: row.0,
563                            data,
564                            created_at: row.2,
565                            updated_at: row.3,
566                        })
567                    })
568                })
569                .collect::<Result<Vec<_>, _>>()?;
570            Ok(rows)
571        })
572        .await
573        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
574    }
575
576    /// Count all rows in the table.
577    ///
578    /// Used by `schema_delete` in `dry_run` mode to report how many rows
579    /// would be orphaned when the schema is removed.
580    ///
581    /// # Returns
582    /// The total row count as `u64`.
583    ///
584    /// # Errors
585    /// - [`MiniAppError::Schema`] — if the mutex is poisoned or the blocking
586    ///   task panics.
587    /// - [`MiniAppError::Storage`] — if the SQL query fails.
588    pub async fn row_count(&self) -> Result<u64, MiniAppError> {
589        let conn = self.conn.clone();
590        tokio::task::spawn_blocking(move || -> Result<u64, MiniAppError> {
591            let conn = conn
592                .lock()
593                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
594            let count: i64 = conn.query_row("SELECT COUNT(*) FROM rows", [], |row| row.get(0))?;
595            Ok(count.max(0) as u64)
596        })
597        .await
598        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
599    }
600
601    /// Validate `value` and update the row identified by `id`.
602    /// `updated_at` is refreshed; `created_at` is unchanged.
603    ///
604    /// # Concurrency
605    /// The `UPDATE` executes inside `tokio::task::spawn_blocking`. The
606    /// [`std::sync::MutexGuard`] is held only within the blocking closure and is
607    /// dropped before any `.await` point. Concurrent calls with the same `id`
608    /// are serialized by the `Mutex`.
609    ///
610    /// After the `spawn_blocking` future resolves, `dump::on_change` is called
611    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
612    /// If `dump::on_change` fails (e.g. disk full), the error is propagated via
613    /// `?` and the caller receives `Err(MiniAppError::Io(_))`; the row update
614    /// remains in the database (DB and file may be transiently inconsistent
615    /// until the next successful write).
616    ///
617    /// **Same-id concurrent update is not order-preserving with respect to
618    /// file content.** The DB `UPDATE` is serialised by the connection
619    /// `Mutex`, but `dump::on_change` runs *outside* the lock. Two concurrent
620    /// `update(id, A)` / `update(id, B)` calls may finalise the DB row as B
621    /// while the dump file ends up holding A's content (whichever
622    /// `spawn_blocking` write completes last wins on disk). Callers that
623    /// require strict file-DB ordering must serialise updates by `id` at the
624    /// caller side.
625    ///
626    /// # Cancel Safety
627    /// Not cancel-safe. Once the blocking closure has started the `UPDATE` will
628    /// complete regardless of `Future` cancellation. Idempotent at the SQL
629    /// level: calling with the same `id` and `value` results in the same final
630    /// DB state. If the caller drops this `Future` after the UPDATE but before
631    /// `dump::on_change` completes, the file may not be re-materialized while
632    /// the DB row already reflects the new value.
633    ///
634    /// # Errors
635    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
636    /// - [`MiniAppError::Validation`] — required field absent or type mismatch, or
637    ///   a null patch value targets a required field (Merge mode only).
638    /// - [`MiniAppError::Storage`] — rusqlite error.
639    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
640    /// - [`MiniAppError::Io`] — dump file write failure (only when `dump` is configured).
641    ///
642    /// # Panic
643    /// Does not panic.
644    pub async fn update(
645        &self,
646        id: &str,
647        value: serde_json::Value,
648        mode: UpdateMode,
649    ) -> Result<RowRecord, MiniAppError> {
650        let now = now_secs();
651        let conn = self.conn.clone();
652        let id_str = id.to_string();
653        let schema = self.schema.clone();
654
655        let record = tokio::task::spawn_blocking(move || -> Result<RowRecord, MiniAppError> {
656            let conn = conn
657                .lock()
658                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
659            let id_str = resolve_id(&conn, &id_str)?;
660
661            // Fetch both data and created_at in one query.
662            // For Replace mode, the data column is read but unused; this keeps
663            // the SQL identical across modes and avoids a second lock acquisition.
664            let row_data: Option<(String, i64)> = conn
665                .query_row(
666                    "SELECT data, created_at FROM rows WHERE id = ?1",
667                    rusqlite::params![id_str],
668                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
669                )
670                .optional()?;
671
672            let (current_data_str, created_at) =
673                row_data.ok_or_else(|| MiniAppError::NotFound { id: id_str.clone() })?;
674
675            let merged = match mode {
676                UpdateMode::Merge => {
677                    let current: serde_json::Value = parse_data(&current_data_str)?;
678                    let merged = shallow_merge(current, value, &schema)?;
679                    // Post-merge full schema validation (Crux #1: must run after merge).
680                    schema.validate(&merged)?;
681                    merged
682                }
683                UpdateMode::Replace => {
684                    // Replace: validate first, then store as-is (byte-for-byte identical
685                    // to pre-breaking-change behavior — Crux #2).
686                    schema.validate(&value)?;
687                    value
688                }
689            };
690
691            let merged_str = serde_json::to_string(&merged)
692                .expect("serde_json::Value serialization is infallible");
693
694            conn.execute(
695                "UPDATE rows SET data = ?1, updated_at = ?2 WHERE id = ?3",
696                rusqlite::params![merged_str, now, id_str],
697            )?;
698
699            Ok(RowRecord {
700                id: id_str,
701                data: merged,
702                created_at,
703                updated_at: now,
704            })
705        })
706        .await
707        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
708
709        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
710        crate::dump::on_change(&self.schema, &record).await?;
711
712        Ok(record)
713    }
714
715    /// Execute a closure under a SQLite SAVEPOINT for all-or-nothing semantics.
716    ///
717    /// The closure receives `&mut rusqlite::Savepoint<'_>` and may run arbitrary
718    /// SQL inside the SAVEPOINT.  On success, the SAVEPOINT is committed.  On
719    /// failure, the SAVEPOINT is rolled back automatically when it is dropped
720    /// (enforced via `set_drop_behavior(DropBehavior::Rollback)`).
721    ///
722    /// # Crux compliance
723    /// This method is the implementation backing `schema_batch`'s
724    /// `schema_batch SAVEPOINT atomicity` Crux constraint.  All ops inside a
725    /// batch share the same SAVEPOINT; any failure causes the SAVEPOINT to
726    /// roll back, leaving the DB unchanged.
727    ///
728    /// # Concurrency
729    /// The Mutex is acquired and the entire SAVEPOINT + ops execute inside a
730    /// single `tokio::task::spawn_blocking` closure.  `Savepoint<'_>` borrows
731    /// the `Connection`, so both must remain in the same closure scope — they
732    /// cannot straddle an `.await` point (K-103, K-110).
733    ///
734    /// # Cancel Safety
735    /// Not cancel-safe.  Once the `spawn_blocking` closure has started, the
736    /// SAVEPOINT runs to completion (commit or rollback) regardless of `Future`
737    /// cancellation.
738    ///
739    /// # Type Parameters
740    /// - `F`: closure `FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static`.
741    /// - `R`: return value, must be `Send + 'static`.
742    ///
743    /// # Errors
744    /// - [`MiniAppError::Schema`] — Mutex poisoned or blocking thread panicked.
745    /// - [`MiniAppError::Storage`] — rusqlite SAVEPOINT creation or commit failed.
746    /// - Any error returned by the closure `f`.
747    ///
748    /// # Panic
749    /// Does not panic.
750    pub async fn execute_under_savepoint<F, R>(&self, f: F) -> Result<R, MiniAppError>
751    where
752        F: FnOnce(&mut rusqlite::Savepoint<'_>) -> Result<R, MiniAppError> + Send + 'static,
753        R: Send + 'static,
754    {
755        let conn = self.conn.clone();
756        tokio::task::spawn_blocking(move || -> Result<R, MiniAppError> {
757            let mut guard = conn
758                .lock()
759                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
760            let mut sp = guard.savepoint()?;
761            // Ensure rollback on Drop so any early-return via `?` cleans up.
762            sp.set_drop_behavior(rusqlite::DropBehavior::Rollback);
763            let result = f(&mut sp)?;
764            sp.commit()?;
765            Ok(result)
766        })
767        .await
768        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
769    }
770
771    /// Delete the row identified by `id`.
772    ///
773    /// # Concurrency
774    /// The `DELETE` executes inside `tokio::task::spawn_blocking`. The
775    /// [`std::sync::MutexGuard`] is held only within the blocking closure and
776    /// is dropped before any `.await` point. Idempotent at the SQL level:
777    /// deleting a non-existent `id` returns [`MiniAppError::NotFound`], so
778    /// calling twice with the same `id` returns `Err(MiniAppError::NotFound)`
779    /// on the second call.
780    ///
781    /// After the `spawn_blocking` future resolves, `dump::on_delete` is called
782    /// at the `.await` point. The `MutexGuard` is already dropped at this stage.
783    /// In the current implementation `on_delete` is a no-op (`Ok(())`) and the
784    /// dump file is preserved on disk by default. The `Result<(), MiniAppError>`
785    /// signature is retained because a future schema flag (e.g.
786    /// `dump.on_delete: keep | remove`) may switch this to an actual file
787    /// removal that can fail with [`MiniAppError::Io`]. Today the value-level
788    /// behaviour is infallible, but the type-level contract (and the
789    /// `?`-propagation site in `Store::delete`) is preserved so that flipping
790    /// the future flag does not require changing the call site.
791    ///
792    /// # Cancel Safety
793    /// Not cancel-safe with respect to the `spawn_blocking` portion: once the
794    /// blocking closure has started the `DELETE` runs to completion regardless
795    /// of `Future` cancellation. The current `on_delete` no-op is itself
796    /// cancel-safe (no `.await`, no I/O), so dropping this `Future` after the
797    /// DELETE has no observable file-system effect today. When `on_delete`
798    /// gains real file removal in the future, this paragraph must be updated
799    /// in lockstep with the new contract.
800    ///
801    /// # Errors
802    /// - [`MiniAppError::NotFound`] — no row with the given `id`.
803    /// - [`MiniAppError::Storage`] — rusqlite error.
804    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
805    /// - [`MiniAppError::Io`] — reserved for a future `on_delete` implementation
806    ///   that performs file removal (currently never returned, but the variant
807    ///   is part of the public contract so the call site does not need to
808    ///   change when the flag is added).
809    ///
810    /// # Panic
811    /// Does not panic.
812    pub async fn delete(&self, id: &str) -> Result<(), MiniAppError> {
813        let conn = self.conn.clone();
814        let id = id.to_string();
815
816        // The closure returns the resolved (full) UUID so that on_delete
817        // receives a complete UUID rather than a prefix string (CF-1).
818        let resolved_id = tokio::task::spawn_blocking(move || -> Result<String, MiniAppError> {
819            let conn = conn
820                .lock()
821                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
822            let resolved = resolve_id(&conn, &id)?;
823            let n = conn.execute(
824                "DELETE FROM rows WHERE id = ?1",
825                rusqlite::params![resolved],
826            )?;
827            if n == 0 {
828                return Err(MiniAppError::NotFound { id: resolved });
829            }
830            Ok(resolved)
831        })
832        .await
833        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))??;
834
835        // MutexGuard is already dropped (held only inside the spawn_blocking closure above).
836        crate::dump::on_delete(&self.schema, &resolved_id).await?;
837
838        Ok(())
839    }
840
841    // -----------------------------------------------------------------------
842    // Alias CRUD
843    // -----------------------------------------------------------------------
844
845    /// Register a named query alias in `_aliases`.
846    ///
847    /// The `filter_json` value is stored verbatim (serialize before calling).
848    /// `default_limit`, `description`, and `params_schema` are optional.
849    /// `params_schema` is a JSON array of parameter name strings
850    /// (e.g. `["project","owner"]`); pass `None` for parameter-free aliases.
851    ///
852    /// # Errors
853    /// - [`MiniAppError::AliasAlreadyExists`] — an alias with `name` already
854    ///   exists.  Delete it first or choose a different name.
855    /// - [`MiniAppError::Storage`] — rusqlite error.
856    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
857    ///
858    /// # Panic
859    /// Does not panic.
860    pub async fn alias_create(
861        &self,
862        name: &str,
863        filter_json: &str,
864        default_limit: Option<u32>,
865        description: Option<String>,
866        params_schema: Option<String>,
867    ) -> Result<(), MiniAppError> {
868        let conn = self.conn.clone();
869        let name = name.to_string();
870        let filter_json = filter_json.to_string();
871
872        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
873            let conn = conn
874                .lock()
875                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
876            conn.execute(
877                "INSERT OR IGNORE INTO _aliases \
878                 (name, filter, default_limit, description, params_schema) \
879                 VALUES (?1, ?2, ?3, ?4, ?5)",
880                rusqlite::params![name, filter_json, default_limit, description, params_schema],
881            )?;
882            if conn.changes() == 0 {
883                return Err(MiniAppError::AliasAlreadyExists { name });
884            }
885            Ok(())
886        })
887        .await
888        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
889    }
890
891    /// Retrieve a single alias by name.
892    ///
893    /// # Errors
894    /// - [`MiniAppError::AliasNotFound`] — no alias with `name` exists.
895    /// - [`MiniAppError::Storage`] — rusqlite error.
896    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
897    ///
898    /// # Panic
899    /// Does not panic.
900    pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
901        let conn = self.conn.clone();
902        let name = name.to_string();
903
904        tokio::task::spawn_blocking(move || -> Result<AliasRecord, MiniAppError> {
905            let conn = conn
906                .lock()
907                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
908            let mut stmt = conn.prepare(
909                "SELECT name, filter, default_limit, description, params_schema \
910                 FROM _aliases WHERE name = ?1",
911            )?;
912            let record = stmt
913                .query_row(rusqlite::params![name], |row| {
914                    Ok((
915                        row.get::<_, String>(0)?,
916                        row.get::<_, String>(1)?,
917                        row.get::<_, Option<u32>>(2)?,
918                        row.get::<_, Option<String>>(3)?,
919                        row.get::<_, Option<String>>(4)?,
920                    ))
921                })
922                .optional()?
923                .ok_or_else(|| MiniAppError::AliasNotFound { name: name.clone() })?;
924
925            Ok(AliasRecord {
926                name: record.0,
927                filter: record.1,
928                default_limit: record.2,
929                description: record.3,
930                params_schema: record.4,
931            })
932        })
933        .await
934        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
935    }
936
937    /// List all aliases registered for this table, ordered by name.
938    ///
939    /// Returns an empty `Vec` when no aliases exist.
940    ///
941    /// # Errors
942    /// - [`MiniAppError::Storage`] — rusqlite error.
943    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
944    ///
945    /// # Panic
946    /// Does not panic.
947    pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
948        let conn = self.conn.clone();
949
950        tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
951            let conn = conn
952                .lock()
953                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
954            let mut stmt = conn.prepare(
955                "SELECT name, filter, default_limit, description, params_schema \
956                 FROM _aliases ORDER BY name ASC",
957            )?;
958            let records = stmt
959                .query_map([], |row| {
960                    Ok((
961                        row.get::<_, String>(0)?,
962                        row.get::<_, String>(1)?,
963                        row.get::<_, Option<u32>>(2)?,
964                        row.get::<_, Option<String>>(3)?,
965                        row.get::<_, Option<String>>(4)?,
966                    ))
967                })?
968                .collect::<Result<Vec<_>, _>>()?;
969
970            Ok(records
971                .into_iter()
972                .map(
973                    |(name, filter, default_limit, description, params_schema)| AliasRecord {
974                        name,
975                        filter,
976                        default_limit,
977                        description,
978                        params_schema,
979                    },
980                )
981                .collect())
982        })
983        .await
984        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
985    }
986
987    /// Delete the alias with the given `name`.
988    ///
989    /// # Errors
990    /// - [`MiniAppError::AliasNotFound`] — no alias with `name` exists.
991    /// - [`MiniAppError::Storage`] — rusqlite error.
992    /// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
993    ///
994    /// # Panic
995    /// Does not panic.
996    pub async fn alias_delete(&self, name: &str) -> Result<(), MiniAppError> {
997        let conn = self.conn.clone();
998        let name = name.to_string();
999
1000        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
1001            let conn = conn
1002                .lock()
1003                .map_err(|_| MiniAppError::Schema("mutex poisoned".to_string()))?;
1004            let n = conn.execute(
1005                "DELETE FROM _aliases WHERE name = ?1",
1006                rusqlite::params![name],
1007            )?;
1008            if n == 0 {
1009                return Err(MiniAppError::AliasNotFound { name });
1010            }
1011            Ok(())
1012        })
1013        .await
1014        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
1015    }
1016}
1017
1018// ---------------------------------------------------------------------------
1019// Tests
1020// ---------------------------------------------------------------------------
1021
1022#[cfg(test)]
1023mod tests {
1024    use std::sync::Arc;
1025
1026    use super::*;
1027    use crate::schema::{FieldDef, FieldType};
1028
1029    async fn make_test_store() -> Store {
1030        let schema = SchemaConfig {
1031            table: "test".into(),
1032            title: None,
1033            description: None,
1034            fields: vec![
1035                FieldDef {
1036                    name: "title".into(),
1037                    ty: FieldType::String,
1038                    required: true,
1039                    description: None,
1040                },
1041                FieldDef {
1042                    name: "state".into(),
1043                    ty: FieldType::String,
1044                    required: false,
1045                    description: None,
1046                },
1047            ],
1048            dump: None,
1049        };
1050        Store::open(Path::new(":memory:"), schema).await.unwrap()
1051    }
1052
1053    /// Build a test store with dump directed to `dir`.
1054    async fn make_test_store_with_dump(dir: &Path) -> Store {
1055        use crate::dump::{DumpConfig, SyncMode};
1056        let schema = SchemaConfig {
1057            table: "test".into(),
1058            title: None,
1059            description: None,
1060            fields: vec![
1061                FieldDef {
1062                    name: "title".into(),
1063                    ty: FieldType::String,
1064                    required: true,
1065                    description: None,
1066                },
1067                FieldDef {
1068                    name: "body".into(),
1069                    ty: FieldType::String,
1070                    required: false,
1071                    description: None,
1072                },
1073            ],
1074            dump: Some(DumpConfig {
1075                dir: Some(dir.to_path_buf()),
1076                title_field: None,
1077                body_field: None,
1078                sync: Some(SyncMode::WriteOnly),
1079            }),
1080        };
1081        Store::open(Path::new(":memory:"), schema).await.unwrap()
1082    }
1083
1084    // --- Basic CRUD ---
1085
1086    #[tokio::test]
1087    async fn test_create_and_get_roundtrip() {
1088        let store = make_test_store().await;
1089        let value = serde_json::json!({"title": "hello", "state": "open"});
1090        let row = store.create(value.clone()).await.unwrap();
1091        let fetched = store.get(&row.id).await.unwrap();
1092        assert_eq!(fetched.id, row.id);
1093        assert_eq!(fetched.data, value);
1094    }
1095
1096    #[tokio::test]
1097    async fn test_create_then_list() {
1098        let store = make_test_store().await;
1099        store
1100            .create(serde_json::json!({"title": "t1"}))
1101            .await
1102            .unwrap();
1103        let rows = store.list(None, None, None).await.unwrap();
1104        assert_eq!(rows.len(), 1);
1105    }
1106
1107    #[tokio::test]
1108    async fn test_list_limit_offset() {
1109        let store = make_test_store().await;
1110        for i in 0..5 {
1111            store
1112                .create(serde_json::json!({"title": format!("item-{i}")}))
1113                .await
1114                .unwrap();
1115        }
1116        let page1 = store.list(Some(2), Some(0), None).await.unwrap();
1117        assert_eq!(page1.len(), 2);
1118        let page2 = store.list(Some(2), Some(2), None).await.unwrap();
1119        assert_eq!(page2.len(), 2);
1120        let page3 = store.list(Some(2), Some(4), None).await.unwrap();
1121        assert_eq!(page3.len(), 1);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_update_timestamps() {
1126        let store = make_test_store().await;
1127        let row = store
1128            .create(serde_json::json!({"title": "original"}))
1129            .await
1130            .unwrap();
1131        // Sleep a tiny bit so updated_at can differ from created_at.
1132        // (In practice both are epoch seconds, so same-second updates produce
1133        //  the same value — the test verifies created_at is preserved.)
1134        let updated = store
1135            .update(
1136                &row.id,
1137                serde_json::json!({"title": "changed"}),
1138                UpdateMode::Replace,
1139            )
1140            .await
1141            .unwrap();
1142        assert_eq!(updated.created_at, row.created_at);
1143        assert_eq!(updated.id, row.id);
1144        assert_eq!(updated.data["title"], "changed");
1145    }
1146
1147    #[tokio::test]
1148    async fn test_create_delete_get_not_found() {
1149        let store = make_test_store().await;
1150        let row = store
1151            .create(serde_json::json!({"title": "to-delete"}))
1152            .await
1153            .unwrap();
1154        store.delete(&row.id).await.unwrap();
1155        let err = store.get(&row.id).await.unwrap_err();
1156        assert!(matches!(err, MiniAppError::NotFound { .. }));
1157    }
1158
1159    #[tokio::test]
1160    async fn test_get_unknown_id_not_found() {
1161        let store = make_test_store().await;
1162        let err = store.get("nonexistent-id").await.unwrap_err();
1163        assert!(matches!(err, MiniAppError::NotFound { .. }));
1164    }
1165
1166    #[tokio::test]
1167    async fn test_update_unknown_id_not_found() {
1168        let store = make_test_store().await;
1169        let err = store
1170            .update(
1171                "nonexistent-id",
1172                serde_json::json!({"title": "x"}),
1173                UpdateMode::Replace,
1174            )
1175            .await
1176            .unwrap_err();
1177        assert!(matches!(err, MiniAppError::NotFound { .. }));
1178    }
1179
1180    #[tokio::test]
1181    async fn test_delete_unknown_id_not_found() {
1182        let store = make_test_store().await;
1183        let err = store.delete("nonexistent-id").await.unwrap_err();
1184        assert!(matches!(err, MiniAppError::NotFound { .. }));
1185    }
1186
1187    #[tokio::test]
1188    async fn test_create_missing_required_field_validation_error() {
1189        let store = make_test_store().await;
1190        // `title` is required but absent.
1191        let err = store
1192            .create(serde_json::json!({"state": "open"}))
1193            .await
1194            .unwrap_err();
1195        assert!(
1196            matches!(err, MiniAppError::Validation { .. }),
1197            "expected Validation, got: {err:?}"
1198        );
1199    }
1200
1201    #[tokio::test]
1202    async fn test_create_type_mismatch_validation_error() {
1203        let store = make_test_store().await;
1204        // `title` must be a string; passing a number should fail.
1205        let err = store
1206            .create(serde_json::json!({"title": 42}))
1207            .await
1208            .unwrap_err();
1209        assert!(
1210            matches!(err, MiniAppError::Validation { .. }),
1211            "expected Validation, got: {err:?}"
1212        );
1213    }
1214
1215    // --- Concurrency tests (from concurrency-analysis.md §2) ---
1216
1217    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1218    async fn test_store_create_concurrent() {
1219        let store = Arc::new(make_test_store().await);
1220        let handles: Vec<_> = (0..4)
1221            .map(|i| {
1222                let s = store.clone();
1223                tokio::spawn(async move {
1224                    s.create(serde_json::json!({"title": format!("task-{i}"), "state": "open"}))
1225                        .await
1226                })
1227            })
1228            .collect();
1229        let results: Vec<_> = futures::future::join_all(handles).await;
1230        assert!(results.iter().all(|r| r.as_ref().unwrap().is_ok()));
1231        let rows = store.list(None, None, None).await.unwrap();
1232        assert_eq!(rows.len(), 4);
1233    }
1234
1235    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1236    async fn test_store_mutex_no_await_holding_lock() {
1237        let store = Arc::new(make_test_store().await);
1238        let id = store
1239            .create(serde_json::json!({"title": "init", "state": "open"}))
1240            .await
1241            .unwrap()
1242            .id;
1243        let s1 = store.clone();
1244        let id1 = id.clone();
1245        let h1 = tokio::spawn(async move { s1.get(&id1).await });
1246        let s2 = store.clone();
1247        let id2 = id.clone();
1248        let h2 = tokio::spawn(async move {
1249            s2.update(
1250                &id2,
1251                serde_json::json!({"title": "updated", "state": "closed"}),
1252                UpdateMode::Replace,
1253            )
1254            .await
1255        });
1256        let (r1, r2) = tokio::join!(h1, h2);
1257        assert!(r1.unwrap().is_ok());
1258        assert!(r2.unwrap().is_ok());
1259    }
1260
1261    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1262    async fn test_store_arc_clone_across_tasks() {
1263        let store = Arc::new(make_test_store().await);
1264        let handles: Vec<_> = (0..8)
1265            .map(|i| {
1266                let s = Arc::clone(&store);
1267                tokio::spawn(async move {
1268                    s.create(serde_json::json!({"title": format!("row-{i}"), "state": "open"}))
1269                        .await
1270                })
1271            })
1272            .collect();
1273        futures::future::join_all(handles).await;
1274        assert_eq!(store.list(None, None, None).await.unwrap().len(), 8);
1275    }
1276
1277    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1278    async fn test_spawn_blocking_join_error_propagation() {
1279        let result: Result<(), _> = tokio::task::spawn_blocking(|| panic!("intentional"))
1280            .await
1281            .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")));
1282        assert!(matches!(result, Err(MiniAppError::Schema(_))));
1283    }
1284
1285    // --- Dump integration tests ---
1286
1287    #[tokio::test]
1288    async fn create_triggers_dump_when_configured() {
1289        let tmp = tempfile::tempdir().expect("tempdir");
1290        let store = make_test_store_with_dump(tmp.path()).await;
1291        let row = store
1292            .create(serde_json::json!({"title": "My Issue", "body": "Details"}))
1293            .await
1294            .expect("create ok");
1295        let dump_file = tmp.path().join(format!("{}.md", row.id));
1296        assert!(dump_file.exists(), "dump file must be created after create");
1297        let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1298        assert!(content.starts_with("# My Issue\n"));
1299        assert!(content.contains("Details"));
1300    }
1301
1302    #[tokio::test]
1303    async fn update_overwrites_dump_file() {
1304        let tmp = tempfile::tempdir().expect("tempdir");
1305        let store = make_test_store_with_dump(tmp.path()).await;
1306        let row = store
1307            .create(serde_json::json!({"title": "Original", "body": "v1"}))
1308            .await
1309            .expect("create ok");
1310
1311        store
1312            .update(
1313                &row.id,
1314                serde_json::json!({"title": "Updated", "body": "v2"}),
1315                UpdateMode::Replace,
1316            )
1317            .await
1318            .expect("update ok");
1319
1320        let dump_file = tmp.path().join(format!("{}.md", row.id));
1321        let content = std::fs::read_to_string(&dump_file).expect("read dump file");
1322        assert!(
1323            content.starts_with("# Updated\n"),
1324            "dump file must reflect updated title"
1325        );
1326        assert!(
1327            content.contains("v2"),
1328            "dump file must reflect updated body"
1329        );
1330    }
1331
1332    #[tokio::test]
1333    async fn delete_keeps_dump_file_by_default() {
1334        let tmp = tempfile::tempdir().expect("tempdir");
1335        let store = make_test_store_with_dump(tmp.path()).await;
1336        let row = store
1337            .create(serde_json::json!({"title": "Keep Me", "body": ""}))
1338            .await
1339            .expect("create ok");
1340
1341        let dump_file = tmp.path().join(format!("{}.md", row.id));
1342        assert!(dump_file.exists(), "dump file must exist after create");
1343
1344        store.delete(&row.id).await.expect("delete ok");
1345        assert!(
1346            dump_file.exists(),
1347            "dump file must remain after delete (default: keep)"
1348        );
1349    }
1350
1351    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1352    async fn test_store_create_concurrent_dump_writes_all_files() {
1353        let tmp = tempfile::tempdir().expect("tempdir");
1354        let store = Arc::new(make_test_store_with_dump(tmp.path()).await);
1355
1356        let handles: Vec<_> = (0..4)
1357            .map(|i| {
1358                let s = store.clone();
1359                tokio::spawn(async move {
1360                    s.create(serde_json::json!({
1361                        "title": format!("concurrent-{i}"),
1362                        "body": format!("body-{i}"),
1363                    }))
1364                    .await
1365                })
1366            })
1367            .collect();
1368
1369        let results: Vec<_> = futures::future::join_all(handles).await;
1370        // All creates must succeed
1371        let rows: Vec<_> = results
1372            .into_iter()
1373            .map(|r| r.expect("spawn ok").expect("create ok"))
1374            .collect();
1375
1376        // Each row must have a corresponding dump file
1377        for row in &rows {
1378            let path = tmp.path().join(format!("{}.md", row.id));
1379            assert!(path.exists(), "dump file must exist for row {}", row.id);
1380        }
1381        assert_eq!(rows.len(), 4);
1382    }
1383
1384    #[tokio::test]
1385    async fn store_open_with_bidirectional_sync_returns_ok() {
1386        use crate::dump::{DumpConfig, SyncMode};
1387        // Store::open must succeed and emit warn (we verify Ok return here;
1388        // warn log capture is out of scope per Acceptance Criteria §7).
1389        let schema = SchemaConfig {
1390            table: "test".into(),
1391            title: None,
1392            description: None,
1393            fields: vec![FieldDef {
1394                name: "title".into(),
1395                ty: FieldType::String,
1396                required: false,
1397                description: None,
1398            }],
1399            dump: Some(DumpConfig {
1400                dir: None,
1401                title_field: None,
1402                body_field: None,
1403                sync: Some(SyncMode::Bidirectional),
1404            }),
1405        };
1406        let store = Store::open(Path::new(":memory:"), schema).await;
1407        assert!(
1408            store.is_ok(),
1409            "Store::open must succeed even with bidirectional sync configured"
1410        );
1411    }
1412
1413    // --- SAVEPOINT / concurrency tests (ST3 additions) ---
1414
1415    /// Test that execute_under_savepoint rolls back all ops on failure.
1416    /// Crux must_not_simplify 1: single SAVEPOINT, all-or-nothing semantics.
1417    ///
1418    /// Sequence: INSERT via SAVEPOINT → force error inside SAVEPOINT →
1419    /// assert SAVEPOINT rolled back → DB row count = 0.
1420    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1421    async fn test_savepoint_atomic_rollback_on_op_failure() {
1422        let store = make_test_store().await;
1423
1424        // A closure that does one INSERT then returns an error.
1425        let result: Result<(), MiniAppError> = store
1426            .execute_under_savepoint(|sp| {
1427                sp.execute(
1428                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1429                    rusqlite::params!["sp-test-id", r#"{"title":"t"}"#, 1000_i64, 1000_i64],
1430                )?;
1431                // Force failure after the INSERT — SAVEPOINT must roll back.
1432                Err(MiniAppError::Validation {
1433                    field: "test".into(),
1434                    reason: "forced rollback".into(),
1435                })
1436            })
1437            .await;
1438
1439        assert!(
1440            result.is_err(),
1441            "execute_under_savepoint must propagate the closure error"
1442        );
1443        assert!(
1444            matches!(result.unwrap_err(), MiniAppError::Validation { .. }),
1445            "error variant must be preserved"
1446        );
1447
1448        // After rollback: the row must not exist.
1449        let rows = store.list(Some(1000), None, None).await.unwrap();
1450        assert_eq!(
1451            rows.len(),
1452            0,
1453            "SAVEPOINT rollback must revert the INSERT (Crux: SAVEPOINT atomicity)"
1454        );
1455
1456        // Verify the SAVEPOINT is gone and normal ops still work.
1457        store
1458            .create(serde_json::json!({"title": "after-rollback"}))
1459            .await
1460            .expect("store must be usable after SAVEPOINT rollback");
1461        assert_eq!(store.list(None, None, None).await.unwrap().len(), 1);
1462    }
1463
1464    /// Test that execute_under_savepoint commits on success.
1465    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1466    async fn test_savepoint_commit_on_success() {
1467        let store = make_test_store().await;
1468
1469        let result = store
1470            .execute_under_savepoint(|sp| {
1471                sp.execute(
1472                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
1473                    rusqlite::params!["sp-ok-id", r#"{"title":"committed"}"#, 1000_i64, 1000_i64],
1474                )?;
1475                Ok(42_u32)
1476            })
1477            .await;
1478
1479        assert_eq!(
1480            result.unwrap(),
1481            42_u32,
1482            "successful SAVEPOINT must return value"
1483        );
1484
1485        // The INSERT must be committed.
1486        let rows = store.list(Some(10), None, None).await.unwrap();
1487        assert_eq!(rows.len(), 1, "committed INSERT must persist");
1488    }
1489
1490    /// Concurrency regression: 8 tasks × 100 creates on same Store,
1491    /// total 800 rows expected, no deadlock or panic.
1492    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1493    async fn test_store_concurrent_create() {
1494        let store = Arc::new(make_test_store().await);
1495        let task_count = 8_usize;
1496        let rows_per_task = 100_usize;
1497
1498        let handles: Vec<_> = (0..task_count)
1499            .map(|task_id| {
1500                let s = Arc::clone(&store);
1501                tokio::spawn(async move {
1502                    for i in 0..rows_per_task {
1503                        s.create(serde_json::json!({"title": format!("task-{task_id}-row-{i}")}))
1504                            .await
1505                            .expect("concurrent create must succeed");
1506                    }
1507                })
1508            })
1509            .collect();
1510
1511        futures::future::join_all(handles)
1512            .await
1513            .into_iter()
1514            .for_each(|r| r.expect("task must not panic"));
1515
1516        let total = store.list(Some(1000), None, None).await.unwrap().len();
1517        assert_eq!(
1518            total,
1519            task_count * rows_per_task,
1520            "all {total} rows must be present; expected {}",
1521            task_count * rows_per_task
1522        );
1523    }
1524
1525    /// Concurrency regression: 4 tasks × 50 same-id updates, no deadlock.
1526    /// Final DB row must be one of the valid values; no Mutex poison.
1527    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1528    async fn test_store_concurrent_update_same_id() {
1529        let store = Arc::new(make_test_store().await);
1530
1531        // Insert the row to update.
1532        let row = store
1533            .create(serde_json::json!({"title": "initial"}))
1534            .await
1535            .unwrap();
1536        let id = row.id.clone();
1537
1538        let task_count = 4_usize;
1539        let updates_per_task = 50_usize;
1540
1541        let handles: Vec<_> = (0..task_count)
1542            .map(|task_id| {
1543                let s = Arc::clone(&store);
1544                let row_id = id.clone();
1545                tokio::spawn(async move {
1546                    for i in 0..updates_per_task {
1547                        s.update(
1548                            &row_id,
1549                            serde_json::json!({"title": format!("task-{task_id}-update-{i}")}),
1550                            UpdateMode::Replace,
1551                        )
1552                        .await
1553                        .expect("concurrent update must succeed");
1554                    }
1555                })
1556            })
1557            .collect();
1558
1559        futures::future::join_all(handles)
1560            .await
1561            .into_iter()
1562            .for_each(|r| r.expect("task must not panic"));
1563
1564        // Final state: exactly 1 row, title is one of the last writes.
1565        let rows = store.list(None, None, None).await.unwrap();
1566        assert_eq!(rows.len(), 1, "update must not insert extra rows");
1567        assert!(
1568            rows[0].data["title"].is_string(),
1569            "title must be a string after concurrent updates"
1570        );
1571    }
1572
1573    /// Concurrency: Mutex poison propagated as MiniAppError::Schema("mutex poisoned").
1574    ///
1575    /// Spawns a blocking task that acquires the Mutex and panics (poisoning it),
1576    /// then asserts that the next store.get() call returns Schema("mutex poisoned").
1577    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1578    async fn test_store_mutex_poison_propagated_as_error() {
1579        let store = Arc::new(make_test_store().await);
1580
1581        // Poison the Mutex by panicking inside spawn_blocking while holding the lock.
1582        let conn = store.conn.clone();
1583        let _ = tokio::task::spawn_blocking(move || {
1584            let _guard = conn.lock().unwrap(); // acquire lock
1585            panic!("intentional poison"); // poison the Mutex
1586        })
1587        .await; // JoinError expected — ignore it
1588
1589        // The Mutex is now poisoned. Any Store operation must return Schema("mutex poisoned").
1590        let err = store.get("any-id").await.unwrap_err();
1591        assert!(
1592            matches!(&err, MiniAppError::Schema(msg) if msg.contains("mutex poisoned")),
1593            "expected Schema(\"mutex poisoned\"), got: {err:?}"
1594        );
1595    }
1596
1597    /// Crux #1 verification: Store::open must set journal_mode to WAL on a real
1598    /// file-based database. `:memory:` databases do not support WAL; this test
1599    /// uses a tempdir to open an actual file and asserts the pragma value.
1600    #[tokio::test]
1601    async fn store_open_sets_wal_journal_mode() {
1602        let tmp = tempfile::tempdir().expect("tempdir");
1603        let db_path = tmp.path().join("test.db");
1604
1605        let schema = SchemaConfig {
1606            table: "test".into(),
1607            title: None,
1608            description: None,
1609            fields: vec![FieldDef {
1610                name: "title".into(),
1611                ty: FieldType::String,
1612                required: false,
1613                description: None,
1614            }],
1615            dump: None,
1616        };
1617        let store = Store::open(&db_path, schema)
1618            .await
1619            .expect("Store::open should succeed");
1620
1621        // Query journal_mode through the Store connection to verify WAL was set.
1622        let mode = {
1623            let conn = store.conn.lock().expect("lock");
1624            conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
1625                .expect("PRAGMA journal_mode query")
1626        };
1627        assert_eq!(
1628            mode.to_lowercase(),
1629            "wal",
1630            "Store::open must set journal_mode = WAL for dual-registry safety (Crux #1)"
1631        );
1632    }
1633
1634    // --- shallow_merge unit tests (Subtask 1, Crux #1) ---
1635
1636    /// Helper: build a minimal SchemaConfig with the given fields.
1637    fn make_schema(fields: Vec<FieldDef>) -> SchemaConfig {
1638        SchemaConfig {
1639            table: "test".into(),
1640            title: None,
1641            description: None,
1642            fields,
1643            dump: None,
1644        }
1645    }
1646
1647    /// AC #3-a: absent fields in the patch are preserved from current.
1648    #[test]
1649    fn shallow_merge_preserves_absent_fields() {
1650        let schema = make_schema(vec![
1651            FieldDef {
1652                name: "a".into(),
1653                ty: FieldType::Number,
1654                required: true,
1655                description: None,
1656            },
1657            FieldDef {
1658                name: "b".into(),
1659                ty: FieldType::Number,
1660                required: false,
1661                description: None,
1662            },
1663        ]);
1664        let current = serde_json::json!({"a": 1, "b": 2});
1665        let patch = serde_json::json!({"a": 9});
1666        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1667        assert_eq!(merged["a"], 9, "patched field must be updated");
1668        assert_eq!(
1669            merged["b"], 2,
1670            "absent patch field must be preserved from current"
1671        );
1672    }
1673
1674    /// AC #3-b: null value for an optional field physically removes it from the merged object.
1675    #[test]
1676    fn shallow_merge_deletes_null_for_optional_field() {
1677        let schema = make_schema(vec![
1678            FieldDef {
1679                name: "a".into(),
1680                ty: FieldType::Number,
1681                required: true,
1682                description: None,
1683            },
1684            FieldDef {
1685                name: "b".into(),
1686                ty: FieldType::Number,
1687                required: false,
1688                description: None,
1689            },
1690        ]);
1691        let current = serde_json::json!({"a": 1, "b": 2});
1692        let patch = serde_json::json!({"b": null});
1693        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1694        assert_eq!(merged["a"], 1);
1695        assert!(
1696            merged.get("b").is_none(),
1697            "null-patched optional field must be physically removed (not set to null)"
1698        );
1699    }
1700
1701    /// AC #3-c: null value for a required field returns a Validation error.
1702    #[test]
1703    fn shallow_merge_errors_on_null_for_required_field() {
1704        let schema = make_schema(vec![FieldDef {
1705            name: "title".into(),
1706            ty: FieldType::String,
1707            required: true,
1708            description: None,
1709        }]);
1710        let current = serde_json::json!({"title": "hello"});
1711        let patch = serde_json::json!({"title": null});
1712        let err = shallow_merge(current, patch, &schema).expect_err("must error");
1713        match err {
1714            MiniAppError::Validation { field, reason } => {
1715                assert_eq!(field, "title");
1716                assert!(
1717                    reason.contains("required field cannot be deleted via null"),
1718                    "unexpected reason: {reason}"
1719                );
1720            }
1721            other => panic!("expected Validation error, got: {other:?}"),
1722        }
1723    }
1724
1725    /// AC #3-d: nested objects are replaced wholesale, not deep-merged.
1726    #[test]
1727    fn shallow_merge_replaces_nested_object_wholesale() {
1728        let schema = make_schema(vec![FieldDef {
1729            name: "cfg".into(),
1730            ty: FieldType::Object,
1731            required: false,
1732            description: None,
1733        }]);
1734        let current = serde_json::json!({"cfg": {"x": 1, "y": 2}});
1735        let patch = serde_json::json!({"cfg": {"x": 9}});
1736        let merged = shallow_merge(current, patch, &schema).expect("merge ok");
1737        assert_eq!(merged["cfg"]["x"], 9, "x must be updated");
1738        assert!(
1739            merged["cfg"].get("y").is_none(),
1740            "y must be absent (nested object replaced wholesale, not deep-merged)"
1741        );
1742    }
1743
1744    /// AC #3-e: non-object patch (array / number / string) returns Validation error.
1745    #[test]
1746    fn shallow_merge_rejects_non_object_patch() {
1747        let schema = make_schema(vec![]);
1748        let current = serde_json::json!({"a": 1});
1749
1750        for bad_patch in [
1751            serde_json::json!([1, 2, 3]),
1752            serde_json::json!(42),
1753            serde_json::json!("string"),
1754        ] {
1755            let err = shallow_merge(current.clone(), bad_patch, &schema)
1756                .expect_err("non-object patch must be rejected");
1757            match err {
1758                MiniAppError::Validation { field, .. } => {
1759                    assert_eq!(field, "(root)", "error field must be '(root)'");
1760                }
1761                other => panic!("expected Validation error, got: {other:?}"),
1762            }
1763        }
1764    }
1765
1766    /// AC #3-f: post-merge schema validation catches type mismatches in the merged result.
1767    /// Tests the full Store::update Merge path (not just shallow_merge in isolation).
1768    #[tokio::test]
1769    async fn store_update_merge_runs_post_merge_validation() {
1770        let store = make_test_store().await;
1771        let row = store
1772            .create(serde_json::json!({"title": "x", "state": "open"}))
1773            .await
1774            .unwrap();
1775
1776        // Patch `state` with a number — type mismatch must be caught by post-merge validate.
1777        let err = store
1778            .update(&row.id, serde_json::json!({"state": 42}), UpdateMode::Merge)
1779            .await
1780            .expect_err("type mismatch must fail post-merge validation");
1781
1782        assert!(
1783            matches!(err, MiniAppError::Validation { .. }),
1784            "expected Validation error, got: {err:?}"
1785        );
1786    }
1787
1788    // -----------------------------------------------------------------------
1789    // Alias CRUD tests
1790    // -----------------------------------------------------------------------
1791
1792    use crate::filter::ListFilter;
1793
1794    /// Build a trivial ListFilter suitable for alias tests.
1795    fn make_filter() -> ListFilter {
1796        ListFilter::Eq {
1797            field: "state".to_string(),
1798            value: serde_json::json!("open"),
1799        }
1800    }
1801
1802    /// AC#5: alias_create → alias_get round-trip preserves all fields.
1803    #[tokio::test]
1804    async fn alias_create_and_get_round_trip() {
1805        let store = make_test_store().await;
1806        let filter = make_filter();
1807        let filter_json = serde_json::to_string(&filter).unwrap();
1808
1809        store
1810            .alias_create(
1811                "recent_open",
1812                &filter_json,
1813                Some(20),
1814                Some("desc".to_string()),
1815                None,
1816            )
1817            .await
1818            .expect("alias_create must succeed");
1819
1820        let record = store
1821            .alias_get("recent_open")
1822            .await
1823            .expect("alias_get must succeed");
1824
1825        assert_eq!(record.name, "recent_open");
1826        assert_eq!(record.default_limit, Some(20));
1827        assert_eq!(record.description.as_deref(), Some("desc"));
1828
1829        // filter round-trip: deserialise from the stored JSON text
1830        let restored: ListFilter =
1831            serde_json::from_str(&record.filter).expect("filter must deserialise");
1832        let stored_back = serde_json::to_string(&filter).unwrap();
1833        let stored_back2 = serde_json::to_string(&restored).unwrap();
1834        assert_eq!(
1835            stored_back, stored_back2,
1836            "filter must survive a JSON round-trip"
1837        );
1838    }
1839
1840    /// AC#5 (nulls): alias_create with None default_limit and None description.
1841    #[tokio::test]
1842    async fn alias_create_with_optional_nulls() {
1843        let store = make_test_store().await;
1844        let filter = make_filter();
1845        let filter_json = serde_json::to_string(&filter).unwrap();
1846
1847        store
1848            .alias_create("no_opts", &filter_json, None, None, None)
1849            .await
1850            .expect("alias_create must succeed with None optionals");
1851
1852        let record = store
1853            .alias_get("no_opts")
1854            .await
1855            .expect("alias_get must succeed");
1856        assert_eq!(record.name, "no_opts");
1857        assert!(record.default_limit.is_none());
1858        assert!(record.description.is_none());
1859    }
1860
1861    /// AC#6: alias_list returns all registered aliases.
1862    #[tokio::test]
1863    async fn alias_list_returns_all() {
1864        let store = make_test_store().await;
1865        let filter = make_filter();
1866
1867        // Initially empty.
1868        let list = store
1869            .alias_list()
1870            .await
1871            .expect("alias_list must succeed on empty store");
1872        assert!(list.is_empty(), "empty store should return empty list");
1873
1874        let filter_json = serde_json::to_string(&filter).unwrap();
1875        store
1876            .alias_create("b_alias", &filter_json, None, None, None)
1877            .await
1878            .unwrap();
1879        store
1880            .alias_create("a_alias", &filter_json, None, None, None)
1881            .await
1882            .unwrap();
1883
1884        let list = store.alias_list().await.expect("alias_list must succeed");
1885        assert_eq!(list.len(), 2, "must return 2 aliases");
1886        // Ordered by name ASC.
1887        assert_eq!(list[0].name, "a_alias");
1888        assert_eq!(list[1].name, "b_alias");
1889    }
1890
1891    /// AC#7: alias_delete removes the alias, subsequent alias_get returns AliasNotFound.
1892    #[tokio::test]
1893    async fn alias_delete_removes_alias() {
1894        let store = make_test_store().await;
1895        let filter = make_filter();
1896        let filter_json = serde_json::to_string(&filter).unwrap();
1897
1898        store
1899            .alias_create("to_delete", &filter_json, None, None, None)
1900            .await
1901            .unwrap();
1902
1903        store
1904            .alias_delete("to_delete")
1905            .await
1906            .expect("alias_delete must succeed");
1907
1908        let err = store
1909            .alias_get("to_delete")
1910            .await
1911            .expect_err("alias_get after delete must fail");
1912
1913        assert!(
1914            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "to_delete"),
1915            "expected AliasNotFound, got: {err:?}"
1916        );
1917    }
1918
1919    /// AC#8: duplicate alias_create returns AliasAlreadyExists.
1920    #[tokio::test]
1921    async fn alias_create_duplicate_returns_already_exists() {
1922        let store = make_test_store().await;
1923        let filter = make_filter();
1924        let filter_json = serde_json::to_string(&filter).unwrap();
1925
1926        store
1927            .alias_create("dup", &filter_json, None, None, None)
1928            .await
1929            .expect("first alias_create must succeed");
1930
1931        let err = store
1932            .alias_create("dup", &filter_json, None, None, None)
1933            .await
1934            .expect_err("second alias_create must fail");
1935
1936        assert!(
1937            matches!(err, MiniAppError::AliasAlreadyExists { ref name } if name == "dup"),
1938            "expected AliasAlreadyExists, got: {err:?}"
1939        );
1940    }
1941
1942    /// AC#9: alias_get for non-existent name returns AliasNotFound.
1943    #[tokio::test]
1944    async fn alias_get_missing_returns_not_found() {
1945        let store = make_test_store().await;
1946
1947        let err = store
1948            .alias_get("nonexistent")
1949            .await
1950            .expect_err("alias_get on missing alias must fail");
1951
1952        assert!(
1953            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1954            "expected AliasNotFound, got: {err:?}"
1955        );
1956    }
1957
1958    /// AC#9: alias_delete for non-existent name returns AliasNotFound.
1959    #[tokio::test]
1960    async fn alias_delete_missing_returns_not_found() {
1961        let store = make_test_store().await;
1962
1963        let err = store
1964            .alias_delete("nonexistent")
1965            .await
1966            .expect_err("alias_delete on missing alias must fail");
1967
1968        assert!(
1969            matches!(err, MiniAppError::AliasNotFound { ref name } if name == "nonexistent"),
1970            "expected AliasNotFound, got: {err:?}"
1971        );
1972    }
1973
1974    /// Verify per-table isolation: two separate Store instances (each with their
1975    /// own :memory: DB) have independent _aliases tables.
1976    #[tokio::test]
1977    async fn alias_namespace_isolation_between_stores() {
1978        let store_a = make_test_store().await;
1979        let store_b = make_test_store().await;
1980        let filter = make_filter();
1981
1982        let filter_json = serde_json::to_string(&filter).unwrap();
1983        store_a
1984            .alias_create("shared_name", &filter_json, None, None, None)
1985            .await
1986            .expect("store_a alias_create must succeed");
1987
1988        // store_b has a completely separate _aliases table — the alias must not be visible.
1989        let err = store_b
1990            .alias_get("shared_name")
1991            .await
1992            .expect_err("alias created in store_a must not be visible in store_b");
1993
1994        assert!(
1995            matches!(err, MiniAppError::AliasNotFound { .. }),
1996            "expected AliasNotFound in store_b, got: {err:?}"
1997        );
1998    }
1999
2000    // --- UUID prefix match tests ---
2001
2002    /// prefix match: single hit → returns that row
2003    #[tokio::test]
2004    async fn test_get_prefix_match_single() {
2005        let store = make_test_store().await;
2006        let row = store
2007            .create(serde_json::json!({"title": "prefix-test"}))
2008            .await
2009            .unwrap();
2010        // Use first 8 characters as prefix (UUID v4 is sufficiently random).
2011        let prefix = &row.id[..8];
2012        let fetched = store.get(prefix).await.unwrap();
2013        assert_eq!(fetched.id, row.id);
2014        assert_eq!(fetched.data["title"], "prefix-test");
2015    }
2016
2017    /// prefix match: 0 hits → NotFound
2018    #[tokio::test]
2019    async fn test_get_prefix_match_not_found() {
2020        let store = make_test_store().await;
2021        // "zzzzzzzz" is not a valid UUID hex prefix, will match nothing.
2022        let err = store.get("zzzzzzzz").await.unwrap_err();
2023        assert!(
2024            matches!(err, MiniAppError::NotFound { .. }),
2025            "expected NotFound, got: {err:?}"
2026        );
2027    }
2028
2029    /// prefix match: 2+ hits → AmbiguousId with candidate list
2030    #[tokio::test]
2031    async fn test_get_prefix_match_ambiguous() {
2032        let store = make_test_store().await;
2033        // Insert two rows whose IDs start with a known prefix by manipulating
2034        // the DB directly.  We use the internal connection via execute_under_savepoint.
2035        let id1 = "aaaaaaaa-0000-4000-8000-000000000001".to_string();
2036        let id2 = "aaaaaaaa-0000-4000-8000-000000000002".to_string();
2037        let id1_clone = id1.clone();
2038        let id2_clone = id2.clone();
2039        store
2040            .execute_under_savepoint(move |sp| {
2041                sp.execute(
2042                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2043                    rusqlite::params![id1_clone, r#"{"title":"a1"}"#],
2044                )?;
2045                sp.execute(
2046                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2047                    rusqlite::params![id2_clone, r#"{"title":"a2"}"#],
2048                )?;
2049                Ok(())
2050            })
2051            .await
2052            .unwrap();
2053
2054        let err = store.get("aaaaaaaa").await.unwrap_err();
2055        match err {
2056            MiniAppError::AmbiguousId {
2057                ref id_prefix,
2058                ref candidates,
2059            } => {
2060                assert_eq!(id_prefix, "aaaaaaaa");
2061                assert_eq!(candidates.len(), 2);
2062                let mut sorted = candidates.clone();
2063                sorted.sort();
2064                assert_eq!(sorted[0], id1);
2065                assert_eq!(sorted[1], id2);
2066            }
2067            other => panic!("expected AmbiguousId, got: {other:?}"),
2068        }
2069    }
2070
2071    /// full UUID (36 chars) bypasses prefix match and uses exact query
2072    #[tokio::test]
2073    async fn test_get_full_uuid_bypass() {
2074        let store = make_test_store().await;
2075        let row = store
2076            .create(serde_json::json!({"title": "bypass-test"}))
2077            .await
2078            .unwrap();
2079        assert_eq!(row.id.len(), 36, "UUID must be 36 chars");
2080        // Pass the full UUID — must resolve via exact match, not LIKE.
2081        let fetched = store.get(&row.id).await.unwrap();
2082        assert_eq!(fetched.id, row.id);
2083    }
2084
2085    /// update with prefix match: single hit → update succeeds
2086    #[tokio::test]
2087    async fn test_update_prefix_match_single() {
2088        let store = make_test_store().await;
2089        let row = store
2090            .create(serde_json::json!({"title": "before"}))
2091            .await
2092            .unwrap();
2093        let prefix = &row.id[..8];
2094        let updated = store
2095            .update(
2096                prefix,
2097                serde_json::json!({"title": "after"}),
2098                UpdateMode::Replace,
2099            )
2100            .await
2101            .unwrap();
2102        assert_eq!(updated.id, row.id);
2103        assert_eq!(updated.data["title"], "after");
2104    }
2105
2106    /// update with prefix match: 2+ hits → AmbiguousId
2107    #[tokio::test]
2108    async fn test_update_prefix_match_ambiguous() {
2109        let store = make_test_store().await;
2110        let id1 = "bbbbbbbb-0000-4000-8000-000000000001".to_string();
2111        let id2 = "bbbbbbbb-0000-4000-8000-000000000002".to_string();
2112        store
2113            .execute_under_savepoint(move |sp| {
2114                sp.execute(
2115                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2116                    rusqlite::params![id1, r#"{"title":"b1"}"#],
2117                )?;
2118                sp.execute(
2119                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2120                    rusqlite::params![id2, r#"{"title":"b2"}"#],
2121                )?;
2122                Ok(())
2123            })
2124            .await
2125            .unwrap();
2126
2127        let err = store
2128            .update(
2129                "bbbbbbbb",
2130                serde_json::json!({"title": "x"}),
2131                UpdateMode::Replace,
2132            )
2133            .await
2134            .unwrap_err();
2135        assert!(
2136            matches!(err, MiniAppError::AmbiguousId { .. }),
2137            "expected AmbiguousId, got: {err:?}"
2138        );
2139    }
2140
2141    /// delete with prefix match: single hit → delete succeeds
2142    #[tokio::test]
2143    async fn test_delete_prefix_match_single() {
2144        let store = make_test_store().await;
2145        let row = store
2146            .create(serde_json::json!({"title": "to-delete-prefix"}))
2147            .await
2148            .unwrap();
2149        let prefix = &row.id[..8];
2150        store.delete(prefix).await.unwrap();
2151        // Confirm deletion via full UUID
2152        let err = store.get(&row.id).await.unwrap_err();
2153        assert!(
2154            matches!(err, MiniAppError::NotFound { .. }),
2155            "expected NotFound after delete, got: {err:?}"
2156        );
2157    }
2158
2159    /// delete with prefix match: 2+ hits → AmbiguousId
2160    #[tokio::test]
2161    async fn test_delete_prefix_match_ambiguous() {
2162        let store = make_test_store().await;
2163        let id1 = "cccccccc-0000-4000-8000-000000000001".to_string();
2164        let id2 = "cccccccc-0000-4000-8000-000000000002".to_string();
2165        store
2166            .execute_under_savepoint(move |sp| {
2167                sp.execute(
2168                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2169                    rusqlite::params![id1, r#"{"title":"c1"}"#],
2170                )?;
2171                sp.execute(
2172                    "INSERT INTO rows (id, data, created_at, updated_at) VALUES (?1, ?2, 0, 0)",
2173                    rusqlite::params![id2, r#"{"title":"c2"}"#],
2174                )?;
2175                Ok(())
2176            })
2177            .await
2178            .unwrap();
2179
2180        let err = store.delete("cccccccc").await.unwrap_err();
2181        assert!(
2182            matches!(err, MiniAppError::AmbiguousId { .. }),
2183            "expected AmbiguousId, got: {err:?}"
2184        );
2185    }
2186}