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