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