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