Skip to main content

mini_app_core/
store.rs

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