Skip to main content

mini_app_core/
alias_storage.rs

1//! Global alias storage — single-source-of-truth for named queries that
2//! span [`SourceSpec::Single`] / [`SourceSpec::Multi`] / [`SourceSpec::Pattern`]
3//! table sources.
4//!
5//! # Phase 2 Storage Layout
6//!
7//! - **Project scope**: `<project_dir>/_global.db` (created on demand)
8//! - **User scope**:    `<user_dir>/_global.db`    (created on demand)
9//! - **Lookup precedence**: Project → User. A Project alias with the same
10//!   `name` overrides the User alias of the same name.
11//! - Both scopes are independent SQLite files sharing the
12//!   [`CREATE_GLOBAL_ALIASES_SQL`] schema.
13//!
14//! `_global.db` is intentionally separate from per-table `<table>.db`
15//! files so that:
16//! 1. one alias can reference multiple tables (Multi / Pattern sources)
17//!    without owning a per-table SoT;
18//! 2. user-wide BP aliases survive project deletion;
19//! 3. project-specific aliases override user defaults transparently.
20//!
21//! # Migration from Per-Table `_aliases`
22//!
23//! [`GlobalAliasStorage::migrate_from_per_table`] performs a lossless,
24//! idempotent transfer of legacy 5-field rows from per-table `_aliases`
25//! into the project-scope `_global_aliases`, with `sources` filled in as
26//! `Single(<table_name>)` and `aggregator = None`. Rows already present
27//! in project storage are skipped (`INSERT OR IGNORE` semantics) so the
28//! migration may safely run on every registry open.
29//!
30//! See `crates/core/src/aggregator.rs` for the [`SourceSpec`] /
31//! [`AliasAggregator`] primitives this storage persists.
32
33use crate::aggregator::{AliasAggregator, SourceSpec};
34use crate::error::MiniAppError;
35use rusqlite::OptionalExtension;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38
39/// Scope determines which `_global.db` (project or user) is written to.
40/// Lookup (`alias_get` / `alias_list`) always reads both with project
41/// taking precedence on name collisions.
42///
43/// `Deserialize` / `Serialize` / `JsonSchema` are derived so callers
44/// (e.g. the MCP `alias_create` tool) can accept this enum as a JSON
45/// parameter. The wire representation is `"project"` / `"user"`
46/// (lowercase) for natural caller ergonomics.
47#[derive(
48    Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
49)]
50#[serde(rename_all = "lowercase")]
51pub enum AliasScope {
52    /// Project-local `_global.db` (default for new aliases when the
53    /// Project scope is mounted).
54    Project,
55    /// User-wide `_global.db` (shared across projects). Used as the
56    /// fallback when Project scope is unmounted, or when the caller
57    /// explicitly opts in via the `scope` parameter.
58    User,
59}
60
61/// A single global alias record.
62///
63/// `filter` is stored verbatim — either a serialised
64/// [`crate::filter::ListFilter`] JSON string, or a MiniJinja template
65/// string when `params_schema` is `Some`. `sources` / `aggregator` are
66/// serialised via `serde_json` at the storage boundary.
67#[derive(Debug, Clone)]
68pub struct AliasRecord {
69    /// Alias name (PRIMARY KEY within each scope's `_global_aliases`).
70    pub name: String,
71    /// Source-table specifier (Single / Multi / Pattern).
72    pub sources: SourceSpec,
73    /// Optional aggregator (None means a plain `Rows` alias).
74    pub aggregator: Option<AliasAggregator>,
75    /// Serialised filter JSON or MiniJinja template string.
76    pub filter: String,
77    /// Optional default limit to apply when `alias_run` does not supply one.
78    pub default_limit: Option<u32>,
79    /// Optional human-readable description.
80    pub description: Option<String>,
81    /// Optional JSON array of parameter name strings (e.g. `["project","owner"]`).
82    /// `None` means the alias takes no parameters.
83    pub params_schema: Option<String>,
84    /// Optional default field projection stored as a serialised
85    /// [`crate::materialize::FieldSelector`] JSON string.
86    /// `None` means no stored default — all fields are returned (Crux #3: must
87    /// never be coerced to an empty projection list).
88    pub fields: Option<String>,
89    /// Optional default order-by stored as a serialised
90    /// `Vec<`[`crate::order_by::OrderByItem`]`>` JSON string.
91    /// `None` means no stored default — falls back to the store's default
92    /// `ORDER BY created_at DESC`.
93    pub order_by: Option<String>,
94    /// Origin scope of this record after `alias_get` / `alias_list`.
95    /// `None` for newly-constructed records that have not yet been
96    /// persisted or loaded.
97    pub scope: Option<AliasScope>,
98}
99
100impl AliasRecord {
101    /// Construct a new record with `scope = None`. Persistence assigns
102    /// the scope via [`GlobalAliasStorage::alias_create`].
103    ///
104    /// # Arguments
105    /// - `name` — alias name (PRIMARY KEY within scope).
106    /// - `sources` — source-table specifier.
107    /// - `aggregator` — optional aggregator; `None` for plain Rows aliases.
108    /// - `filter` — serialised filter JSON or MiniJinja template string.
109    /// - `default_limit` — optional row-count cap applied when `alias_run` omits a limit.
110    /// - `description` — optional human-readable description.
111    /// - `params_schema` — optional JSON array of parameter names.
112    /// - `fields` — optional serialised [`crate::materialize::FieldSelector`]; `None`
113    ///   means no stored default projection (all fields returned).
114    /// - `order_by` — optional serialised `Vec<OrderByItem>`; `None` means no stored
115    ///   default sort order (store default `ORDER BY created_at DESC` applies).
116    #[allow(clippy::too_many_arguments)]
117    pub fn new(
118        name: impl Into<String>,
119        sources: SourceSpec,
120        aggregator: Option<AliasAggregator>,
121        filter: impl Into<String>,
122        default_limit: Option<u32>,
123        description: Option<String>,
124        params_schema: Option<String>,
125        fields: Option<String>,
126        order_by: Option<String>,
127    ) -> Self {
128        Self {
129            name: name.into(),
130            sources,
131            aggregator,
132            filter: filter.into(),
133            default_limit,
134            description,
135            params_schema,
136            fields,
137            order_by,
138            scope: None,
139        }
140    }
141}
142
143/// SQLite DDL for the global alias table. Stored once per `_global.db`
144/// (project + user).
145const CREATE_GLOBAL_ALIASES_SQL: &str = "
146    CREATE TABLE IF NOT EXISTS _global_aliases (
147        name            TEXT    PRIMARY KEY,
148        sources_json    TEXT    NOT NULL,
149        aggregator_json TEXT,
150        filter          TEXT    NOT NULL,
151        default_limit   INTEGER,
152        description     TEXT,
153        params_schema   TEXT,
154        fields          TEXT,
155        order_by        TEXT
156    )
157";
158
159/// Per-table legacy DDL — exposed for migration sites that re-create the
160/// `_aliases` table on a fresh connection in tests.
161pub const LEGACY_PER_TABLE_ALIASES_SQL: &str = "
162    CREATE TABLE IF NOT EXISTS _aliases (
163        name           TEXT    PRIMARY KEY,
164        filter         TEXT    NOT NULL,
165        default_limit  INTEGER,
166        description    TEXT,
167        params_schema  TEXT
168    )
169";
170
171/// Tuple shape returned when scanning a per-table `_aliases` table during
172/// [`GlobalAliasStorage::migrate_from_per_table`]. The columns map 1:1 to
173/// the legacy 5-field schema (`name`, `filter`, `default_limit`,
174/// `description`, `params_schema`).
175type LegacyAliasRow = (String, String, Option<u32>, Option<String>, Option<String>);
176
177/// Global alias storage handle. Holds at most two SQLite connections
178/// (project + user), both wrapped in [`Arc<Mutex<_>>`] so the
179/// `spawn_blocking` body can lock + execute without violating
180/// [`Send`] / [`Sync`] bounds (rusqlite::Connection is `!Send`).
181pub struct GlobalAliasStorage {
182    project_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
183    user_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
184    project_path: Option<PathBuf>,
185    user_path: Option<PathBuf>,
186}
187
188impl std::fmt::Debug for GlobalAliasStorage {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        // rusqlite::Connection is !Debug, so we project only the visible
191        // metadata (path + scope presence) — sufficient for assert-output
192        // diagnostics.
193        f.debug_struct("GlobalAliasStorage")
194            .field("project_path", &self.project_path)
195            .field("user_path", &self.user_path)
196            .field("project_mounted", &self.project_conn.is_some())
197            .field("user_mounted", &self.user_conn.is_some())
198            .finish()
199    }
200}
201
202impl GlobalAliasStorage {
203    /// Open both project and user `_global.db` files. Either argument may
204    /// be `None` (scope skipped). At least one MUST be `Some`, otherwise
205    /// the resulting storage has nothing to read or write.
206    ///
207    /// Each provided directory is created on demand. The `_global.db`
208    /// file is created with the [`CREATE_GLOBAL_ALIASES_SQL`] schema if
209    /// absent. Existing files are opened as-is (no DDL migration).
210    ///
211    /// # Errors
212    /// - [`MiniAppError::Config`] when both arguments are `None`.
213    /// - [`MiniAppError::Io`] when directory creation fails.
214    /// - [`MiniAppError::Storage`] when SQLite open / DDL execute fails.
215    pub fn open(project_dir: Option<&Path>, user_dir: Option<&Path>) -> Result<Self, MiniAppError> {
216        if project_dir.is_none() && user_dir.is_none() {
217            return Err(MiniAppError::Config(
218                "GlobalAliasStorage::open requires at least one of project_dir / user_dir".into(),
219            ));
220        }
221        let project = project_dir.map(open_scope_db).transpose()?;
222        let user = user_dir.map(open_scope_db).transpose()?;
223        Ok(Self {
224            project_conn: project.as_ref().map(|(c, _)| Arc::clone(c)),
225            user_conn: user.as_ref().map(|(c, _)| Arc::clone(c)),
226            project_path: project.map(|(_, p)| p),
227            user_path: user.map(|(_, p)| p),
228        })
229    }
230
231    /// Open an in-memory storage for tests. Project scope only.
232    #[cfg(test)]
233    pub fn open_in_memory() -> Result<Self, MiniAppError> {
234        let conn = rusqlite::Connection::open_in_memory()?;
235        conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
236        Ok(Self {
237            project_conn: Some(Arc::new(Mutex::new(conn))),
238            user_conn: None,
239            project_path: None,
240            user_path: None,
241        })
242    }
243
244    /// Returns the resolved `_global.db` path for a scope, or `None` if
245    /// that scope is unmounted (or the storage was opened in-memory).
246    pub fn path_for_scope(&self, scope: AliasScope) -> Option<&Path> {
247        match scope {
248            AliasScope::Project => self.project_path.as_deref(),
249            AliasScope::User => self.user_path.as_deref(),
250        }
251    }
252
253    fn conn_for_scope(
254        &self,
255        scope: AliasScope,
256    ) -> Result<Arc<Mutex<rusqlite::Connection>>, MiniAppError> {
257        let opt = match scope {
258            AliasScope::Project => self.project_conn.as_ref(),
259            AliasScope::User => self.user_conn.as_ref(),
260        };
261        opt.map(Arc::clone).ok_or_else(|| {
262            MiniAppError::Config(format!("GlobalAliasStorage scope {scope:?} is not mounted"))
263        })
264    }
265
266    /// Insert a new alias into the specified scope's storage.
267    ///
268    /// # Errors
269    /// - [`MiniAppError::AliasAlreadyExists`] when an alias with the same
270    ///   `name` already exists *in that scope* (cross-scope collisions are
271    ///   permitted — project overrides user at lookup time).
272    /// - [`MiniAppError::Config`] when the scope is unmounted.
273    /// - [`MiniAppError::Storage`] on rusqlite failure.
274    pub async fn alias_create(
275        &self,
276        scope: AliasScope,
277        record: AliasRecord,
278    ) -> Result<(), MiniAppError> {
279        let conn = self.conn_for_scope(scope)?;
280        let sources_json = serde_json::to_string(&record.sources).map_err(|e| {
281            MiniAppError::Schema(format!(
282                "serialise sources for alias '{}': {e}",
283                record.name
284            ))
285        })?;
286        let aggregator_json = match &record.aggregator {
287            Some(agg) => Some(serde_json::to_string(agg).map_err(|e| {
288                MiniAppError::Schema(format!(
289                    "serialise aggregator for alias '{}': {e}",
290                    record.name
291                ))
292            })?),
293            None => None,
294        };
295        let name = record.name.clone();
296        let filter = record.filter.clone();
297        let default_limit = record.default_limit;
298        let description = record.description.clone();
299        let params_schema = record.params_schema.clone();
300        let fields = record.fields.clone();
301        let order_by = record.order_by.clone();
302        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
303            let conn = conn
304                .lock()
305                .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
306            conn.execute(
307                "INSERT OR IGNORE INTO _global_aliases \
308                 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by) \
309                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
310                rusqlite::params![
311                    name,
312                    sources_json,
313                    aggregator_json,
314                    filter,
315                    default_limit,
316                    description,
317                    params_schema,
318                    fields,
319                    order_by,
320                ],
321            )?;
322            if conn.changes() == 0 {
323                return Err(MiniAppError::AliasAlreadyExists { name });
324            }
325            Ok(())
326        })
327        .await
328        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
329    }
330
331    /// Get an alias by name. Project storage is consulted first; on miss
332    /// the user storage is consulted. Returns [`MiniAppError::AliasNotFound`]
333    /// when neither scope has the alias.
334    pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
335        if let Some(rec) = self.alias_get_scope(AliasScope::Project, name).await? {
336            return Ok(rec);
337        }
338        if let Some(rec) = self.alias_get_scope(AliasScope::User, name).await? {
339            return Ok(rec);
340        }
341        Err(MiniAppError::AliasNotFound {
342            name: name.to_string(),
343        })
344    }
345
346    /// Get an alias from a *specific* scope. Returns `Ok(None)` when the
347    /// alias is absent (so the merged [`Self::alias_get`] can fall back
348    /// to the next scope without distinguishing missing scope from
349    /// missing row).
350    pub async fn alias_get_scope(
351        &self,
352        scope: AliasScope,
353        name: &str,
354    ) -> Result<Option<AliasRecord>, MiniAppError> {
355        let conn = match scope {
356            AliasScope::Project => self.project_conn.as_ref(),
357            AliasScope::User => self.user_conn.as_ref(),
358        };
359        let Some(conn) = conn.map(Arc::clone) else {
360            return Ok(None);
361        };
362        let name_owned = name.to_string();
363        tokio::task::spawn_blocking(move || -> Result<Option<AliasRecord>, MiniAppError> {
364            let conn = conn
365                .lock()
366                .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
367            let mut stmt = conn.prepare(
368                "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by \
369                 FROM _global_aliases WHERE name = ?1",
370            )?;
371            let row = stmt
372                .query_row(rusqlite::params![name_owned], extract_row)
373                .optional()?;
374            match row {
375                Some(mut rec) => {
376                    rec.scope = Some(scope);
377                    Ok(Some(rec))
378                }
379                None => Ok(None),
380            }
381        })
382        .await
383        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
384    }
385
386    /// List all aliases across both scopes, sorted ascending by name.
387    /// On name collision the Project entry is retained; the User entry
388    /// is silently discarded (precedence rule).
389    pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
390        let project = match self.project_conn.as_ref() {
391            Some(c) => list_scope(Arc::clone(c), AliasScope::Project).await?,
392            None => Vec::new(),
393        };
394        let user = match self.user_conn.as_ref() {
395            Some(c) => list_scope(Arc::clone(c), AliasScope::User).await?,
396            None => Vec::new(),
397        };
398        let mut merged: std::collections::BTreeMap<String, AliasRecord> =
399            std::collections::BTreeMap::new();
400        // Insert user first, then project — project entries overwrite on
401        // collision, satisfying the precedence rule.
402        for rec in user {
403            merged.insert(rec.name.clone(), rec);
404        }
405        for rec in project {
406            merged.insert(rec.name.clone(), rec);
407        }
408        Ok(merged.into_values().collect())
409    }
410
411    /// Delete an alias from a specific scope.
412    ///
413    /// # Errors
414    /// - [`MiniAppError::AliasNotFound`] when the alias is absent in that
415    ///   scope.
416    /// - [`MiniAppError::Config`] when the scope is unmounted.
417    /// - [`MiniAppError::Storage`] on rusqlite failure.
418    pub async fn alias_delete(&self, scope: AliasScope, name: &str) -> Result<(), MiniAppError> {
419        let conn = self.conn_for_scope(scope)?;
420        let name_owned = name.to_string();
421        tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
422            let conn = conn
423                .lock()
424                .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
425            let affected = conn.execute(
426                "DELETE FROM _global_aliases WHERE name = ?1",
427                rusqlite::params![name_owned],
428            )?;
429            if affected == 0 {
430                return Err(MiniAppError::AliasNotFound { name: name_owned });
431            }
432            Ok(())
433        })
434        .await
435        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
436    }
437
438    /// Lossless, idempotent migration of legacy per-table `_aliases` rows
439    /// into a chosen scope's `_global_aliases`.
440    ///
441    /// For each `(table_name, per_table_conn)` pair, every row from
442    /// the per-table `_aliases` table is loaded and inserted into
443    /// `target_scope` storage with `sources = Single(<table_name>)` and
444    /// `aggregator = None`. Rows whose `name` already exists in that
445    /// scope are skipped (`INSERT OR IGNORE`), so the migration may
446    /// safely run on every registry open.
447    ///
448    /// Returns the number of rows newly written (skipped collisions are
449    /// not counted).
450    ///
451    /// # Errors
452    /// - [`MiniAppError::Config`] when `target_scope` is unmounted.
453    /// - [`MiniAppError::Storage`] on rusqlite failure (per-table read
454    ///   or destination insert).
455    pub async fn migrate_from_per_table(
456        &self,
457        target_scope: AliasScope,
458        per_table: Vec<(String, Arc<Mutex<rusqlite::Connection>>)>,
459    ) -> Result<usize, MiniAppError> {
460        let dest = self.conn_for_scope(target_scope).map_err(|_| {
461            MiniAppError::Config(format!(
462                "GlobalAliasStorage::migrate_from_per_table requires {target_scope:?} scope to be mounted"
463            ))
464        })?;
465        tokio::task::spawn_blocking(move || -> Result<usize, MiniAppError> {
466            let mut migrated = 0usize;
467            for (table_name, src_conn) in per_table {
468                let rows: Vec<LegacyAliasRow> = {
469                    let src = src_conn
470                        .lock()
471                        .map_err(|_| MiniAppError::Schema("source mutex poisoned".into()))?;
472                    let mut stmt = src.prepare(
473                        "SELECT name, filter, default_limit, description, params_schema \
474                         FROM _aliases ORDER BY name ASC",
475                    )?;
476                    stmt.query_map([], |row| {
477                        Ok((
478                            row.get::<_, String>(0)?,
479                            row.get::<_, String>(1)?,
480                            row.get::<_, Option<u32>>(2)?,
481                            row.get::<_, Option<String>>(3)?,
482                            row.get::<_, Option<String>>(4)?,
483                        ))
484                    })?
485                    .collect::<Result<Vec<_>, _>>()?
486                };
487                if rows.is_empty() {
488                    continue;
489                }
490                let sources_json = serde_json::to_string(&SourceSpec::Single(table_name.clone()))
491                    .map_err(|e| {
492                    MiniAppError::Schema(format!(
493                        "serialise Single source for table '{table_name}' during migration: {e}"
494                    ))
495                })?;
496                let dst = dest
497                    .lock()
498                    .map_err(|_| MiniAppError::Schema("dest mutex poisoned".into()))?;
499                for (name, filter, default_limit, description, params_schema) in rows {
500                    dst.execute(
501                        "INSERT OR IGNORE INTO _global_aliases \
502                         (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by) \
503                         VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL)",
504                        rusqlite::params![
505                            name,
506                            sources_json,
507                            filter,
508                            default_limit,
509                            description,
510                            params_schema,
511                        ],
512                    )?;
513                    if dst.changes() > 0 {
514                        migrated += 1;
515                    }
516                }
517            }
518            Ok(migrated)
519        })
520        .await
521        .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
522    }
523}
524
525fn open_scope_db(dir: &Path) -> Result<(Arc<Mutex<rusqlite::Connection>>, PathBuf), MiniAppError> {
526    std::fs::create_dir_all(dir)?;
527    let db_path = dir.join("_global.db");
528    let conn = rusqlite::Connection::open(&db_path)?;
529    // Enable WAL journal mode so concurrent connections to the same
530    // `_global.db` file (e.g. across `rebuild_registry` ArcSwap windows
531    // when the previous storage handle is still held by in-flight
532    // tasks) do not serialise on SQLITE_BUSY. Mirrors `Store::open`'s
533    // WAL setup for dual-registry safety. The PRAGMA returns a single
534    // "wal" row; rusqlite errors are propagated as `MiniAppError::Storage`.
535    conn.pragma_update(None, "journal_mode", "WAL")?;
536    conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
537    // Idempotent migration: add `fields` column to existing databases that
538    // were created before this column was introduced.  PRAGMA table_info
539    // returns one row per column; we check for a row whose second field
540    // (the column name) equals "fields".  If absent, ALTER TABLE appends it.
541    // This is safe to run on every open because the column either already
542    // exists (no-op branch) or is added exactly once.
543    let has_fields: bool = {
544        let mut stmt = conn.prepare(
545            "SELECT COUNT(*) FROM pragma_table_info('_global_aliases') WHERE name = 'fields'",
546        )?;
547        stmt.query_row([], |row| row.get::<_, i64>(0))
548            .map(|n| n > 0)?
549    };
550    if !has_fields {
551        conn.execute_batch("ALTER TABLE _global_aliases ADD COLUMN fields TEXT")?;
552    }
553    // Idempotent migration: add `order_by` column to existing databases that
554    // were created before this column was introduced.  Same pattern as the
555    // `fields` migration above.
556    let has_order_by: bool = {
557        let mut stmt = conn.prepare(
558            "SELECT COUNT(*) FROM pragma_table_info('_global_aliases') WHERE name = 'order_by'",
559        )?;
560        stmt.query_row([], |row| row.get::<_, i64>(0))
561            .map(|n| n > 0)?
562    };
563    if !has_order_by {
564        conn.execute_batch("ALTER TABLE _global_aliases ADD COLUMN order_by TEXT")?;
565    }
566    Ok((Arc::new(Mutex::new(conn)), db_path))
567}
568
569fn extract_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AliasRecord> {
570    let name: String = row.get(0)?;
571    let sources_json: String = row.get(1)?;
572    let aggregator_json: Option<String> = row.get(2)?;
573    let filter: String = row.get(3)?;
574    let default_limit: Option<u32> = row.get(4)?;
575    let description: Option<String> = row.get(5)?;
576    let params_schema: Option<String> = row.get(6)?;
577    let fields: Option<String> = row.get(7)?;
578    let order_by: Option<String> = row.get(8)?;
579    let sources: SourceSpec = serde_json::from_str(&sources_json).map_err(|e| {
580        rusqlite::Error::FromSqlConversionFailure(
581            1,
582            rusqlite::types::Type::Text,
583            Box::new(std::io::Error::other(format!(
584                "deserialise sources_json: {e}"
585            ))),
586        )
587    })?;
588    let aggregator: Option<AliasAggregator> = match aggregator_json {
589        Some(s) => Some(serde_json::from_str(&s).map_err(|e| {
590            rusqlite::Error::FromSqlConversionFailure(
591                2,
592                rusqlite::types::Type::Text,
593                Box::new(std::io::Error::other(format!(
594                    "deserialise aggregator_json: {e}"
595                ))),
596            )
597        })?),
598        None => None,
599    };
600    Ok(AliasRecord {
601        name,
602        sources,
603        aggregator,
604        filter,
605        default_limit,
606        description,
607        params_schema,
608        fields,
609        order_by,
610        scope: None,
611    })
612}
613
614async fn list_scope(
615    conn: Arc<Mutex<rusqlite::Connection>>,
616    scope: AliasScope,
617) -> Result<Vec<AliasRecord>, MiniAppError> {
618    tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
619        let conn = conn
620            .lock()
621            .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
622        let mut stmt = conn.prepare(
623            "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by \
624             FROM _global_aliases ORDER BY name ASC",
625        )?;
626        let rows = stmt
627            .query_map([], extract_row)?
628            .collect::<Result<Vec<_>, _>>()?;
629        Ok(rows
630            .into_iter()
631            .map(|mut r| {
632                r.scope = Some(scope);
633                r
634            })
635            .collect())
636    })
637    .await
638    .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
639}
640
641// =============================================================================
642// Tests
643// =============================================================================
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::aggregator::AliasAggregator;
649    use tempfile::TempDir;
650
651    fn sample_record(name: &str) -> AliasRecord {
652        AliasRecord::new(
653            name,
654            SourceSpec::Single("rows".into()),
655            None,
656            r#"{"type":"eq","field":"status","value":"open"}"#,
657            Some(20),
658            Some("sample".into()),
659            None,
660            None,
661            None,
662        )
663    }
664
665    #[tokio::test]
666    async fn create_get_roundtrip_in_memory() {
667        let storage = GlobalAliasStorage::open_in_memory().unwrap();
668        storage
669            .alias_create(AliasScope::Project, sample_record("foo"))
670            .await
671            .unwrap();
672        let got = storage.alias_get("foo").await.unwrap();
673        assert_eq!(got.name, "foo");
674        assert!(matches!(got.sources, SourceSpec::Single(ref t) if t == "rows"));
675        assert!(got.aggregator.is_none());
676        assert_eq!(got.default_limit, Some(20));
677        assert_eq!(got.description.as_deref(), Some("sample"));
678        assert_eq!(got.scope, Some(AliasScope::Project));
679    }
680
681    #[tokio::test]
682    async fn create_persists_sources_multi_and_aggregator_groupby() {
683        let storage = GlobalAliasStorage::open_in_memory().unwrap();
684        let rec = AliasRecord::new(
685            "by_tag",
686            SourceSpec::Multi(vec!["a".into(), "b".into()]),
687            Some(AliasAggregator::GroupBy {
688                by_field: "tag".into(),
689                having: None,
690                inner: Some(Box::new(AliasAggregator::Sum {
691                    field: "value".into(),
692                })),
693            }),
694            "{}".to_string(),
695            None,
696            None,
697            None,
698            None,
699            None,
700        );
701        storage
702            .alias_create(AliasScope::Project, rec)
703            .await
704            .unwrap();
705        let got = storage.alias_get("by_tag").await.unwrap();
706        match got.sources {
707            SourceSpec::Multi(v) => assert_eq!(v, vec!["a".to_string(), "b".to_string()]),
708            other => panic!("expected Multi, got {other:?}"),
709        }
710        match got.aggregator {
711            Some(AliasAggregator::GroupBy {
712                by_field,
713                inner: Some(inner),
714                ..
715            }) => {
716                assert_eq!(by_field, "tag");
717                assert!(matches!(*inner, AliasAggregator::Sum { ref field } if field == "value"));
718            }
719            other => panic!("expected GroupBy+Sum, got {other:?}"),
720        }
721    }
722
723    #[tokio::test]
724    async fn create_persists_pattern_source() {
725        let storage = GlobalAliasStorage::open_in_memory().unwrap();
726        let rec = AliasRecord::new(
727            "shi_all",
728            SourceSpec::Pattern("shi_*".into()),
729            Some(AliasAggregator::Count),
730            "{}".to_string(),
731            None,
732            None,
733            None,
734            None,
735            None,
736        );
737        storage
738            .alias_create(AliasScope::Project, rec)
739            .await
740            .unwrap();
741        let got = storage.alias_get("shi_all").await.unwrap();
742        match got.sources {
743            SourceSpec::Pattern(p) => assert_eq!(p, "shi_*"),
744            other => panic!("expected Pattern, got {other:?}"),
745        }
746        assert!(matches!(got.aggregator, Some(AliasAggregator::Count)));
747    }
748
749    #[tokio::test]
750    async fn create_duplicate_returns_already_exists() {
751        let storage = GlobalAliasStorage::open_in_memory().unwrap();
752        storage
753            .alias_create(AliasScope::Project, sample_record("dup"))
754            .await
755            .unwrap();
756        let err = storage
757            .alias_create(AliasScope::Project, sample_record("dup"))
758            .await
759            .expect_err("expected AliasAlreadyExists");
760        assert_eq!(err.code(), crate::error::codes::ALIAS_ALREADY_EXISTS);
761    }
762
763    #[tokio::test]
764    async fn get_unknown_returns_not_found() {
765        let storage = GlobalAliasStorage::open_in_memory().unwrap();
766        let err = storage
767            .alias_get("nope")
768            .await
769            .expect_err("expected AliasNotFound");
770        assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
771    }
772
773    #[tokio::test]
774    async fn delete_round_trip_then_not_found() {
775        let storage = GlobalAliasStorage::open_in_memory().unwrap();
776        storage
777            .alias_create(AliasScope::Project, sample_record("to_delete"))
778            .await
779            .unwrap();
780        storage
781            .alias_delete(AliasScope::Project, "to_delete")
782            .await
783            .unwrap();
784        let err = storage
785            .alias_delete(AliasScope::Project, "to_delete")
786            .await
787            .expect_err("second delete should fail");
788        assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
789    }
790
791    #[tokio::test]
792    async fn list_returns_sorted_ascending_by_name() {
793        let storage = GlobalAliasStorage::open_in_memory().unwrap();
794        for n in ["c", "a", "b"] {
795            storage
796                .alias_create(AliasScope::Project, sample_record(n))
797                .await
798                .unwrap();
799        }
800        let names: Vec<String> = storage
801            .alias_list()
802            .await
803            .unwrap()
804            .into_iter()
805            .map(|r| r.name)
806            .collect();
807        assert_eq!(names, vec!["a", "b", "c"]);
808    }
809
810    #[tokio::test]
811    async fn project_overrides_user_on_name_collision() {
812        let project_dir = TempDir::new().unwrap();
813        let user_dir = TempDir::new().unwrap();
814        let storage =
815            GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
816        let mut user_rec = sample_record("shared");
817        user_rec.description = Some("user-version".into());
818        storage
819            .alias_create(AliasScope::User, user_rec)
820            .await
821            .unwrap();
822        let mut project_rec = sample_record("shared");
823        project_rec.description = Some("project-version".into());
824        storage
825            .alias_create(AliasScope::Project, project_rec)
826            .await
827            .unwrap();
828
829        // alias_get → project takes precedence
830        let got = storage.alias_get("shared").await.unwrap();
831        assert_eq!(got.description.as_deref(), Some("project-version"));
832        assert_eq!(got.scope, Some(AliasScope::Project));
833
834        // alias_list → merged, project wins for collisions, 1 row
835        let all = storage.alias_list().await.unwrap();
836        assert_eq!(all.len(), 1);
837        assert_eq!(all[0].description.as_deref(), Some("project-version"));
838        assert_eq!(all[0].scope, Some(AliasScope::Project));
839    }
840
841    #[tokio::test]
842    async fn user_only_alias_returned_when_no_project_collision() {
843        let project_dir = TempDir::new().unwrap();
844        let user_dir = TempDir::new().unwrap();
845        let storage =
846            GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
847        let user_only = sample_record("user_only");
848        storage
849            .alias_create(AliasScope::User, user_only)
850            .await
851            .unwrap();
852        let got = storage.alias_get("user_only").await.unwrap();
853        assert_eq!(got.scope, Some(AliasScope::User));
854    }
855
856    #[tokio::test]
857    async fn open_persists_across_reopen() {
858        let project_dir = TempDir::new().unwrap();
859        {
860            let storage = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
861            storage
862                .alias_create(AliasScope::Project, sample_record("persisted"))
863                .await
864                .unwrap();
865        }
866        let reopened = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
867        let got = reopened.alias_get("persisted").await.unwrap();
868        assert_eq!(got.name, "persisted");
869    }
870
871    #[tokio::test]
872    async fn open_requires_at_least_one_scope() {
873        let err = GlobalAliasStorage::open(None, None)
874            .expect_err("expected Config error when both dirs are None");
875        assert_eq!(err.code(), crate::error::codes::CONFIG_ERROR);
876    }
877
878    #[tokio::test]
879    async fn migrate_from_per_table_lossless_roundtrip() {
880        // Set up two per-table _aliases tables (in-memory) with 2 + 1 rows.
881        let conn_a = rusqlite::Connection::open_in_memory().unwrap();
882        conn_a.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
883        conn_a
884            .execute(
885                "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
886                 VALUES (?1, ?2, ?3, ?4, ?5)",
887                rusqlite::params!["a_open", "{}", 50i64, "alpha", Option::<String>::None],
888            )
889            .unwrap();
890        conn_a
891            .execute(
892                "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
893                 VALUES (?1, ?2, ?3, ?4, ?5)",
894                rusqlite::params![
895                    "a_closed",
896                    "{}",
897                    Option::<i64>::None,
898                    Option::<String>::None,
899                    Some("[\"x\"]".to_string())
900                ],
901            )
902            .unwrap();
903
904        let conn_b = rusqlite::Connection::open_in_memory().unwrap();
905        conn_b.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
906        conn_b
907            .execute(
908                "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
909                 VALUES (?1, ?2, ?3, ?4, ?5)",
910                rusqlite::params!["b_recent", "{}", 10i64, "bravo", Option::<String>::None],
911            )
912            .unwrap();
913
914        let storage = GlobalAliasStorage::open_in_memory().unwrap();
915        let migrated = storage
916            .migrate_from_per_table(
917                AliasScope::Project,
918                vec![
919                    ("table_a".to_string(), Arc::new(Mutex::new(conn_a))),
920                    ("table_b".to_string(), Arc::new(Mutex::new(conn_b))),
921                ],
922            )
923            .await
924            .unwrap();
925        assert_eq!(migrated, 3);
926
927        // Verify lossless: all 3 rows present in project storage, with
928        // sources=Single(<table>) embedded.
929        let all = storage.alias_list().await.unwrap();
930        assert_eq!(all.len(), 3);
931        let a_open = all.iter().find(|r| r.name == "a_open").unwrap();
932        assert!(matches!(a_open.sources, SourceSpec::Single(ref t) if t == "table_a"));
933        assert!(a_open.aggregator.is_none());
934        assert_eq!(a_open.default_limit, Some(50));
935        assert_eq!(a_open.description.as_deref(), Some("alpha"));
936        assert_eq!(a_open.params_schema, None);
937
938        let a_closed = all.iter().find(|r| r.name == "a_closed").unwrap();
939        assert!(matches!(a_closed.sources, SourceSpec::Single(ref t) if t == "table_a"));
940        assert_eq!(a_closed.params_schema.as_deref(), Some("[\"x\"]"));
941
942        let b_recent = all.iter().find(|r| r.name == "b_recent").unwrap();
943        assert!(matches!(b_recent.sources, SourceSpec::Single(ref t) if t == "table_b"));
944        assert_eq!(b_recent.default_limit, Some(10));
945    }
946
947    #[tokio::test]
948    async fn migrate_from_per_table_idempotent_on_second_run() {
949        let conn = rusqlite::Connection::open_in_memory().unwrap();
950        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
951        conn.execute(
952            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
953             VALUES (?1, ?2, ?3, ?4, ?5)",
954            rusqlite::params![
955                "x",
956                "{}",
957                Option::<i64>::None,
958                Option::<String>::None,
959                Option::<String>::None
960            ],
961        )
962        .unwrap();
963        let conn_arc = Arc::new(Mutex::new(conn));
964
965        let storage = GlobalAliasStorage::open_in_memory().unwrap();
966        let first = storage
967            .migrate_from_per_table(
968                AliasScope::Project,
969                vec![("t".to_string(), Arc::clone(&conn_arc))],
970            )
971            .await
972            .unwrap();
973        let second = storage
974            .migrate_from_per_table(
975                AliasScope::Project,
976                vec![("t".to_string(), Arc::clone(&conn_arc))],
977            )
978            .await
979            .unwrap();
980        assert_eq!(first, 1);
981        assert_eq!(second, 0);
982        let all = storage.alias_list().await.unwrap();
983        assert_eq!(all.len(), 1);
984    }
985
986    #[tokio::test]
987    async fn migrate_from_per_table_skips_collision_with_existing_global() {
988        // Project storage already has an alias named "shared" — migration
989        // must not overwrite it even when a per-table row of the same
990        // name appears in the migration input.
991        let storage = GlobalAliasStorage::open_in_memory().unwrap();
992        let mut existing = sample_record("shared");
993        existing.description = Some("existing-global".into());
994        storage
995            .alias_create(AliasScope::Project, existing)
996            .await
997            .unwrap();
998
999        let conn = rusqlite::Connection::open_in_memory().unwrap();
1000        conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1001        conn.execute(
1002            "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1003             VALUES (?1, ?2, ?3, ?4, ?5)",
1004            rusqlite::params![
1005                "shared",
1006                "{}",
1007                Option::<i64>::None,
1008                Some("legacy-per-table".to_string()),
1009                Option::<String>::None
1010            ],
1011        )
1012        .unwrap();
1013        let migrated = storage
1014            .migrate_from_per_table(
1015                AliasScope::Project,
1016                vec![("ignored_table".to_string(), Arc::new(Mutex::new(conn)))],
1017            )
1018            .await
1019            .unwrap();
1020        assert_eq!(migrated, 0);
1021        let got = storage.alias_get("shared").await.unwrap();
1022        assert_eq!(got.description.as_deref(), Some("existing-global"));
1023    }
1024
1025    #[tokio::test]
1026    async fn order_by_roundtrip() {
1027        let storage = GlobalAliasStorage::open_in_memory().unwrap();
1028        let order_by_json =
1029            r#"[{"field":"priority","direction":"asc"},{"field":"due","direction":"asc"}]"#;
1030        let rec = AliasRecord::new(
1031            "sorted_alias",
1032            SourceSpec::Single("todo".into()),
1033            None,
1034            "{}",
1035            None,
1036            None,
1037            None,
1038            None,
1039            Some(order_by_json.to_string()),
1040        );
1041        storage
1042            .alias_create(AliasScope::Project, rec)
1043            .await
1044            .unwrap();
1045        let got = storage.alias_get("sorted_alias").await.unwrap();
1046        assert_eq!(got.order_by.as_deref(), Some(order_by_json));
1047    }
1048
1049    #[tokio::test]
1050    async fn order_by_none_roundtrip() {
1051        let storage = GlobalAliasStorage::open_in_memory().unwrap();
1052        let rec = AliasRecord::new(
1053            "unsorted_alias",
1054            SourceSpec::Single("todo".into()),
1055            None,
1056            "{}",
1057            None,
1058            None,
1059            None,
1060            None,
1061            None,
1062        );
1063        storage
1064            .alias_create(AliasScope::Project, rec)
1065            .await
1066            .unwrap();
1067        let got = storage.alias_get("unsorted_alias").await.unwrap();
1068        assert!(got.order_by.is_none());
1069    }
1070}