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