1use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use crate::alias_storage::GlobalAliasStorage;
31use crate::error::MiniAppError;
32use crate::schema::{self, SchemaConfig};
33use crate::store::Store;
34
35pub struct TableEntry {
44 pub store: Arc<Store>,
46 pub schema: Arc<SchemaConfig>,
48 pub schema_path: Arc<PathBuf>,
50}
51
52pub struct TableRegistry {
58 entries: HashMap<String, TableEntry>,
60 default_table: Option<String>,
62 global_aliases: Option<Arc<GlobalAliasStorage>>,
67}
68
69impl TableRegistry {
70 pub async fn mount_from_dirs(
101 user_dir: Option<&Path>,
102 project_dir: Option<&Path>,
103 ) -> Result<Self, MiniAppError> {
104 let user_dir = user_dir.filter(|p| p.exists());
110 let project_dir = project_dir.filter(|p| p.exists());
111
112 let mut entries: HashMap<String, TableEntry> = HashMap::new();
113
114 let global_aliases = if user_dir.is_some() || project_dir.is_some() {
120 Some(Arc::new(GlobalAliasStorage::open(project_dir, user_dir)?))
121 } else {
122 None
123 };
124
125 if let Some(dir) = user_dir {
133 scan_and_mount(dir, &mut entries).await?;
134 if let Some(g) = global_aliases.as_ref() {
135 migrate_per_dir_subset(g, crate::alias_storage::AliasScope::User, dir, &entries)
136 .await?;
137 }
138 }
139
140 if let Some(dir) = project_dir {
146 scan_and_mount(dir, &mut entries).await?;
147 if let Some(g) = global_aliases.as_ref() {
148 migrate_per_dir_subset(g, crate::alias_storage::AliasScope::Project, dir, &entries)
149 .await?;
150 }
151 }
152
153 Ok(TableRegistry {
154 entries,
155 default_table: None,
156 global_aliases,
157 })
158 }
159
160 pub async fn mount_legacy(schema_path: &Path, db_path: &Path) -> Result<Self, MiniAppError> {
182 let schema = schema::load_from_path(schema_path)?;
183 let table_name = schema.table.clone();
184 let store = Store::open(db_path, schema.clone()).await?;
185
186 let entry = TableEntry {
187 store: Arc::new(store),
188 schema: Arc::new(schema),
189 schema_path: Arc::new(schema_path.to_path_buf()),
190 };
191
192 let mut entries = HashMap::new();
193 entries.insert(table_name.clone(), entry);
194
195 Ok(TableRegistry {
196 entries,
197 default_table: Some(table_name),
198 global_aliases: None,
199 })
200 }
201
202 pub fn resolve(&self, name: Option<&str>) -> Result<&TableEntry, MiniAppError> {
225 let key = match name {
226 Some(n) => n,
227 None => match &self.default_table {
228 Some(d) => d.as_str(),
229 None => return Err(MiniAppError::TableRequired),
230 },
231 };
232
233 self.entries
234 .get(key)
235 .ok_or_else(|| MiniAppError::TableNotFound {
236 table: key.to_string(),
237 })
238 }
239
240 pub fn default_table(&self) -> Option<&str> {
251 self.default_table.as_deref()
252 }
253
254 pub fn table_count(&self) -> usize {
260 self.entries.len()
261 }
262
263 pub fn table_names(&self) -> impl Iterator<Item = &str> {
271 self.entries.keys().map(|k| k.as_str())
272 }
273
274 pub fn global_aliases(&self) -> Option<&Arc<GlobalAliasStorage>> {
283 self.global_aliases.as_ref()
284 }
285
286 pub fn entries(&self) -> &HashMap<String, TableEntry> {
296 &self.entries
297 }
298
299 pub fn from_entries(
310 entries: HashMap<String, TableEntry>,
311 default_table: Option<String>,
312 ) -> Self {
313 TableRegistry {
314 entries,
315 default_table,
316 global_aliases: None,
317 }
318 }
319
320 pub fn from_single(
332 store: Store,
333 schema: SchemaConfig,
334 schema_path: PathBuf,
335 table_name: String,
336 ) -> Self {
337 let entry = TableEntry {
338 store: Arc::new(store),
339 schema: Arc::new(schema),
340 schema_path: Arc::new(schema_path),
341 };
342 let mut entries = HashMap::new();
343 entries.insert(table_name.clone(), entry);
344 TableRegistry {
345 entries,
346 default_table: Some(table_name),
347 global_aliases: None,
348 }
349 }
350
351 pub async fn mount_legacy_into(
372 mut registry: TableRegistry,
373 schema_path: &Path,
374 db_path: &Path,
375 ) -> Result<TableRegistry, MiniAppError> {
376 let schema = schema::load_from_path(schema_path)?;
377 let table_name = schema.table.clone();
378
379 if registry.entries.contains_key(&table_name) {
380 tracing::warn!(
381 table = %table_name,
382 "legacy table name conflicts with a dir-scanned table; legacy env takes precedence"
383 );
384 }
385
386 let store = Store::open(db_path, schema.clone()).await?;
387 let entry = TableEntry {
388 store: Arc::new(store),
389 schema: Arc::new(schema),
390 schema_path: Arc::new(schema_path.to_path_buf()),
391 };
392 registry.entries.insert(table_name.clone(), entry);
393 registry.default_table = Some(table_name);
394 Ok(registry)
395 }
396}
397
398async fn migrate_per_dir_subset(
418 storage: &Arc<GlobalAliasStorage>,
419 scope: crate::alias_storage::AliasScope,
420 dir: &Path,
421 entries: &HashMap<String, TableEntry>,
422) -> Result<(), MiniAppError> {
423 let mut subset_names: Vec<String> = Vec::new();
424 let read_dir = match std::fs::read_dir(dir) {
425 Ok(rd) => rd,
426 Err(e) => {
427 tracing::warn!(
432 dir = %dir.display(),
433 error = %e,
434 "migrate_per_dir_subset could not read dir; skipping"
435 );
436 return Ok(());
437 }
438 };
439 for dir_entry in read_dir.flatten() {
440 let Ok(meta) = dir_entry.metadata() else {
441 continue;
442 };
443 if !meta.is_dir() {
444 continue;
445 }
446 let Some(name) = dir_entry.file_name().to_str().map(str::to_owned) else {
447 continue;
448 };
449 if entries.contains_key(&name) {
450 subset_names.push(name);
451 }
452 }
453 let per_table: Vec<(String, Arc<std::sync::Mutex<rusqlite::Connection>>)> = subset_names
454 .iter()
455 .filter_map(|name| entries.get(name).map(|e| (name.clone(), e.store.conn())))
456 .collect();
457 if per_table.is_empty() {
458 return Ok(());
459 }
460 let migrated = storage.migrate_from_per_table(scope, per_table).await?;
461 if migrated > 0 {
462 tracing::info!(
463 migrated_rows = migrated,
464 scope = ?scope,
465 "migrated legacy per-table _aliases rows into _global_aliases"
466 );
467 }
468 Ok(())
469}
470
471async fn scan_and_mount(
494 dir: &Path,
495 entries: &mut HashMap<String, TableEntry>,
496) -> Result<(), MiniAppError> {
497 if !dir.exists() {
498 tracing::warn!(
499 dir = %dir.display(),
500 "directory does not exist, skipping"
501 );
502 return Ok(());
503 }
504
505 let read_dir = std::fs::read_dir(dir)?;
506
507 for dir_entry_result in read_dir {
508 let dir_entry = dir_entry_result?;
509 let metadata = dir_entry.metadata()?;
510
511 if !metadata.is_dir() {
512 continue;
513 }
514
515 let table_dir = dir_entry.path();
516 let table_name = match table_dir.file_name().and_then(|n| n.to_str()) {
517 Some(n) => n.to_string(),
518 None => {
519 tracing::warn!(
520 path = %table_dir.display(),
521 "skipping subdirectory with non-UTF-8 name"
522 );
523 continue;
524 }
525 };
526
527 let schema_path = table_dir.join("schema.yaml");
528 let db_path = table_dir.join(format!("{table_name}.db"));
529
530 if !schema_path.exists() {
531 tracing::warn!(
532 table = %table_name,
533 path = %schema_path.display(),
534 "skipping table: schema.yaml not found"
535 );
536 continue;
537 }
538
539 if !db_path.exists() {
540 tracing::debug!(
543 table = %table_name,
544 path = %db_path.display(),
545 "db file absent, will be created by Store::open"
546 );
547 }
548
549 let schema = match schema::load_from_path(&schema_path) {
550 Ok(s) => s,
551 Err(e) => {
552 tracing::warn!(
553 table = %table_name,
554 error = %e,
555 "skipping table: failed to parse schema.yaml"
556 );
557 continue;
558 }
559 };
560
561 let store = match Store::open(&db_path, schema.clone()).await {
562 Ok(s) => s,
563 Err(e) => {
564 tracing::warn!(
565 table = %table_name,
566 error = %e,
567 "skipping table: failed to open store"
568 );
569 continue;
570 }
571 };
572
573 tracing::debug!(
574 table = %table_name,
575 schema_path = %schema_path.display(),
576 db_path = %db_path.display(),
577 "mounted table"
578 );
579
580 entries.insert(
581 table_name,
582 TableEntry {
583 store: Arc::new(store),
584 schema: Arc::new(schema),
585 schema_path: Arc::new(schema_path),
586 },
587 );
588 }
589
590 Ok(())
591}
592
593#[cfg(test)]
598mod tests {
599 use super::*;
600 use std::io::Write;
601 use tempfile::TempDir;
602
603 fn create_table_dir(parent: &TempDir, table_name: &str, fields_yaml: &str) -> PathBuf {
607 let table_dir = parent.path().join(table_name);
608 std::fs::create_dir_all(&table_dir).expect("create table dir");
609 let schema_path = table_dir.join("schema.yaml");
610 let yaml = format!("table: {table_name}\nfields:\n{fields_yaml}\n");
611 let mut f = std::fs::File::create(&schema_path).expect("create schema.yaml");
612 f.write_all(yaml.as_bytes()).expect("write schema.yaml");
613 table_dir
614 }
615
616 #[tokio::test]
620 async fn user_scope_only_mounts_two_tables() {
621 let user_dir = TempDir::new().expect("tempdir");
622 create_table_dir(
623 &user_dir,
624 "notes",
625 " - name: title\n type: string\n required: true\n",
626 );
627 create_table_dir(
628 &user_dir,
629 "tasks",
630 " - name: body\n type: string\n required: false\n",
631 );
632
633 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
634 .await
635 .expect("mount must succeed");
636
637 assert_eq!(registry.table_count(), 2);
638 assert!(registry.resolve(Some("notes")).is_ok());
639 assert!(registry.resolve(Some("tasks")).is_ok());
640 assert_eq!(registry.default_table(), None);
641 }
642
643 #[tokio::test]
645 async fn user_and_project_scopes_merge_with_project_override() {
646 let user_dir = TempDir::new().expect("tempdir");
647 let project_dir = TempDir::new().expect("tempdir");
648
649 create_table_dir(
651 &user_dir,
652 "table_a",
653 " - name: f\n type: string\n required: false\n",
654 );
655 create_table_dir(
656 &user_dir,
657 "table_b",
658 " - name: user_field\n type: string\n required: false\n",
659 );
660
661 create_table_dir(
663 &project_dir,
664 "table_b",
665 " - name: project_field\n type: string\n required: true\n",
666 );
667 create_table_dir(
668 &project_dir,
669 "table_c",
670 " - name: g\n type: number\n required: false\n",
671 );
672
673 let registry =
674 TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
675 .await
676 .expect("mount must succeed");
677
678 assert_eq!(registry.table_count(), 3);
679
680 let entry_a = registry
682 .resolve(Some("table_a"))
683 .expect("table_a must exist");
684 assert_eq!(entry_a.schema.table, "table_a");
685
686 let entry_b = registry
688 .resolve(Some("table_b"))
689 .expect("table_b must exist");
690 assert!(
692 entry_b
693 .schema
694 .fields
695 .iter()
696 .any(|f| f.name == "project_field"),
697 "table_b should use Project schema (project_field), not User schema (user_field)"
698 );
699 assert!(
700 !entry_b.schema.fields.iter().any(|f| f.name == "user_field"),
701 "table_b must not retain User's user_field after Project override"
702 );
703
704 assert!(registry.resolve(Some("table_c")).is_ok());
706 }
707
708 #[tokio::test]
710 async fn project_override_is_file_level_swap_not_field_merge() {
711 let user_dir = TempDir::new().expect("tempdir");
712 let project_dir = TempDir::new().expect("tempdir");
713
714 create_table_dir(
716 &user_dir,
717 "same_table",
718 " - name: field_a\n type: string\n required: false\n - name: field_b\n type: string\n required: false\n",
719 );
720 create_table_dir(
722 &project_dir,
723 "same_table",
724 " - name: field_c\n type: number\n required: true\n",
725 );
726
727 let registry =
728 TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
729 .await
730 .expect("mount must succeed");
731
732 let entry = registry
733 .resolve(Some("same_table"))
734 .expect("same_table must exist");
735 assert_eq!(entry.schema.fields.len(), 1);
737 assert_eq!(entry.schema.fields[0].name, "field_c");
738 }
739
740 #[tokio::test]
742 async fn legacy_mode_mounts_one_table_with_default() {
743 let dir = TempDir::new().expect("tempdir");
744 let schema_path = dir.path().join("schema.yaml");
745 let db_path = dir.path().join("legacy.db");
746
747 let yaml =
748 "table: legacy_table\nfields:\n - name: title\n type: string\n required: true\n";
749 std::fs::write(&schema_path, yaml).expect("write schema.yaml");
750
751 let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
752 .await
753 .expect("mount_legacy must succeed");
754
755 assert_eq!(registry.table_count(), 1);
756 assert_eq!(registry.default_table(), Some("legacy_table"));
757
758 let entry = registry
760 .resolve(None)
761 .expect("default resolve must succeed");
762 assert_eq!(entry.schema.table, "legacy_table");
763
764 let entry2 = registry
766 .resolve(Some("legacy_table"))
767 .expect("explicit resolve must succeed");
768 assert_eq!(entry2.schema.table, "legacy_table");
769 }
770
771 #[tokio::test]
775 async fn empty_dirs_mount_zero_tables() {
776 let user_dir = TempDir::new().expect("tempdir");
777 let project_dir = TempDir::new().expect("tempdir");
778
779 let registry =
780 TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
781 .await
782 .expect("mount must not fail for empty dirs");
783
784 assert_eq!(registry.table_count(), 0);
785 }
786
787 #[tokio::test]
789 async fn both_dirs_none_mounts_zero_tables() {
790 let registry = TableRegistry::mount_from_dirs(None, None)
791 .await
792 .expect("mount must not fail when both dirs are None");
793
794 assert_eq!(registry.table_count(), 0);
795 }
796
797 #[tokio::test]
799 async fn nonexistent_dir_is_skipped_not_fatal() {
800 let user_dir = TempDir::new().expect("tempdir");
801 create_table_dir(
802 &user_dir,
803 "table_a",
804 " - name: f\n type: string\n required: false\n",
805 );
806
807 let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
808 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(&nonexistent))
809 .await
810 .expect("mount must succeed even when project_dir does not exist");
811
812 assert_eq!(registry.table_count(), 1);
814 assert!(registry.resolve(Some("table_a")).is_ok());
815 }
816
817 #[tokio::test]
819 async fn subdir_without_schema_yaml_is_skipped() {
820 let user_dir = TempDir::new().expect("tempdir");
821 std::fs::create_dir(user_dir.path().join("no_schema")).expect("create dir");
823 create_table_dir(
825 &user_dir,
826 "valid_table",
827 " - name: f\n type: string\n required: false\n",
828 );
829
830 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
831 .await
832 .expect("mount must succeed");
833
834 assert_eq!(registry.table_count(), 1);
835 assert!(registry.resolve(Some("valid_table")).is_ok());
836 }
837
838 #[tokio::test]
842 async fn resolve_none_without_default_returns_table_required() {
843 let user_dir = TempDir::new().expect("tempdir");
844 create_table_dir(
845 &user_dir,
846 "table_a",
847 " - name: f\n type: string\n required: false\n",
848 );
849 create_table_dir(
850 &user_dir,
851 "table_b",
852 " - name: g\n type: string\n required: false\n",
853 );
854
855 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
856 .await
857 .expect("mount must succeed");
858
859 let result = registry.resolve(None);
860 assert!(
861 result.is_err(),
862 "resolve(None) must fail with no default table"
863 );
864 if let Err(err) = result {
866 assert!(
867 matches!(err, MiniAppError::TableRequired),
868 "expected TableRequired, got: {err:?}"
869 );
870 }
871 }
872
873 #[tokio::test]
875 async fn resolve_unknown_table_returns_table_not_found() {
876 let user_dir = TempDir::new().expect("tempdir");
877 create_table_dir(
878 &user_dir,
879 "table_a",
880 " - name: f\n type: string\n required: false\n",
881 );
882
883 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
884 .await
885 .expect("mount must succeed");
886
887 let result = registry.resolve(Some("nonexistent"));
888 assert!(result.is_err(), "resolve(nonexistent) must fail");
889 if let Err(err) = result {
891 match err {
892 MiniAppError::TableNotFound { table } => {
893 assert_eq!(table, "nonexistent");
894 }
895 other => panic!("expected TableNotFound, got: {other:?}"),
896 }
897 }
898 }
899
900 #[tokio::test]
909 async fn mount_from_dirs_attaches_global_alias_storage() {
910 let user_dir = TempDir::new().expect("tempdir");
911 create_table_dir(
912 &user_dir,
913 "rows",
914 " - name: f\n type: string\n required: false\n",
915 );
916 let registry = TableRegistry::mount_from_dirs(Some(user_dir.path()), None)
917 .await
918 .expect("mount must succeed");
919 assert!(
920 registry.global_aliases().is_some(),
921 "mount_from_dirs with user_dir must attach GlobalAliasStorage"
922 );
923 }
924
925 #[tokio::test]
927 async fn mount_from_dirs_without_any_dir_has_no_global_alias_storage() {
928 let registry = TableRegistry::mount_from_dirs(None, None)
929 .await
930 .expect("mount must succeed even with no dirs");
931 assert!(
932 registry.global_aliases().is_none(),
933 "mount_from_dirs(None, None) must not attach GlobalAliasStorage"
934 );
935 }
936
937 #[tokio::test]
939 async fn mount_legacy_has_no_global_alias_storage() {
940 let dir = TempDir::new().expect("tempdir");
941 let schema_path = dir.path().join("schema.yaml");
942 std::fs::write(
943 &schema_path,
944 "table: notes\nfields:\n - name: title\n type: string\n required: true\n",
945 )
946 .expect("write schema");
947 let db_path = dir.path().join("notes.db");
948 let registry = TableRegistry::mount_legacy(&schema_path, &db_path)
949 .await
950 .expect("mount_legacy must succeed");
951 assert!(
952 registry.global_aliases().is_none(),
953 "mount_legacy must not attach GlobalAliasStorage"
954 );
955 }
956
957 #[tokio::test]
962 async fn mount_from_dirs_routes_per_table_aliases_to_origin_scope() {
963 use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
964
965 let user_dir = TempDir::new().expect("tempdir");
966 let project_dir = TempDir::new().expect("tempdir");
967
968 let user_table = create_table_dir(
970 &user_dir,
971 "user_only",
972 " - name: f\n type: string\n required: false\n",
973 );
974 let user_db = user_table.join("user_only.db");
975 let conn = rusqlite::Connection::open(&user_db).expect("open user db");
976 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
977 conn.execute(
978 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
979 VALUES (?1, ?2, ?3, ?4, ?5)",
980 rusqlite::params![
981 "from_user",
982 "{}",
983 Some(7i64),
984 Some("user-scope alias".to_string()),
985 Option::<String>::None
986 ],
987 )
988 .unwrap();
989 drop(conn);
990
991 let proj_table = create_table_dir(
993 &project_dir,
994 "proj_only",
995 " - name: f\n type: string\n required: false\n",
996 );
997 let proj_db = proj_table.join("proj_only.db");
998 let conn = rusqlite::Connection::open(&proj_db).expect("open proj db");
999 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1000 conn.execute(
1001 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1002 VALUES (?1, ?2, ?3, ?4, ?5)",
1003 rusqlite::params![
1004 "from_project",
1005 "{}",
1006 Some(11i64),
1007 Some("project-scope alias".to_string()),
1008 Option::<String>::None
1009 ],
1010 )
1011 .unwrap();
1012 drop(conn);
1013
1014 let registry =
1015 TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
1016 .await
1017 .expect("mount must succeed");
1018 let global = registry
1019 .global_aliases()
1020 .expect("global storage must be attached");
1021
1022 let user_alias = global
1024 .alias_get_scope(AliasScope::User, "from_user")
1025 .await
1026 .expect("user alias_get_scope ok")
1027 .expect("user alias must be present in User scope");
1028 assert_eq!(user_alias.description.as_deref(), Some("user-scope alias"));
1029 let user_in_project = global
1031 .alias_get_scope(AliasScope::Project, "from_user")
1032 .await
1033 .expect("project alias_get_scope ok");
1034 assert!(
1035 user_in_project.is_none(),
1036 "user-origin alias must NOT leak into Project scope (silent inversion fix)"
1037 );
1038
1039 let proj_alias = global
1041 .alias_get_scope(AliasScope::Project, "from_project")
1042 .await
1043 .expect("project alias_get_scope ok")
1044 .expect("project alias must be present in Project scope");
1045 assert_eq!(
1046 proj_alias.description.as_deref(),
1047 Some("project-scope alias")
1048 );
1049 let proj_in_user = global
1051 .alias_get_scope(AliasScope::User, "from_project")
1052 .await
1053 .expect("user alias_get_scope ok");
1054 assert!(
1055 proj_in_user.is_none(),
1056 "project-origin alias must NOT leak into User scope"
1057 );
1058 }
1059
1060 #[tokio::test]
1065 async fn mount_from_dirs_preserves_user_alias_when_project_overrides_table() {
1066 use crate::alias_storage::{AliasScope, LEGACY_PER_TABLE_ALIASES_SQL};
1067
1068 let user_dir = TempDir::new().expect("tempdir");
1069 let project_dir = TempDir::new().expect("tempdir");
1070
1071 let user_table = create_table_dir(
1073 &user_dir,
1074 "foo",
1075 " - name: f\n type: string\n required: false\n",
1076 );
1077 let user_db = user_table.join("foo.db");
1078 let conn = rusqlite::Connection::open(&user_db).expect("open user foo db");
1079 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1080 conn.execute(
1081 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1082 VALUES (?1, ?2, ?3, ?4, ?5)",
1083 rusqlite::params![
1084 "shared",
1085 "{}",
1086 Option::<i64>::None,
1087 Some("user".to_string()),
1088 Option::<String>::None
1089 ],
1090 )
1091 .unwrap();
1092 drop(conn);
1093
1094 let proj_table = create_table_dir(
1096 &project_dir,
1097 "foo",
1098 " - name: f\n type: string\n required: false\n",
1099 );
1100 let proj_db = proj_table.join("foo.db");
1101 let conn = rusqlite::Connection::open(&proj_db).expect("open project foo db");
1102 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1103 conn.execute(
1104 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1105 VALUES (?1, ?2, ?3, ?4, ?5)",
1106 rusqlite::params![
1107 "shared",
1108 "{}",
1109 Option::<i64>::None,
1110 Some("project".to_string()),
1111 Option::<String>::None
1112 ],
1113 )
1114 .unwrap();
1115 drop(conn);
1116
1117 let registry =
1118 TableRegistry::mount_from_dirs(Some(user_dir.path()), Some(project_dir.path()))
1119 .await
1120 .expect("mount must succeed");
1121 let global = registry
1122 .global_aliases()
1123 .expect("global storage must be attached");
1124
1125 let user_row = global
1127 .alias_get_scope(AliasScope::User, "shared")
1128 .await
1129 .unwrap()
1130 .expect("user shared alias preserved");
1131 assert_eq!(user_row.description.as_deref(), Some("user"));
1132
1133 let project_row = global
1135 .alias_get_scope(AliasScope::Project, "shared")
1136 .await
1137 .unwrap()
1138 .expect("project shared alias present");
1139 assert_eq!(project_row.description.as_deref(), Some("project"));
1140
1141 let merged = global.alias_get("shared").await.unwrap();
1143 assert_eq!(merged.description.as_deref(), Some("project"));
1144 assert_eq!(merged.scope, Some(AliasScope::Project));
1145 }
1146
1147 #[tokio::test]
1151 async fn mount_from_dirs_auto_migrates_per_table_aliases() {
1152 use crate::alias_storage::LEGACY_PER_TABLE_ALIASES_SQL;
1153
1154 let project_dir = TempDir::new().expect("tempdir");
1155 let table_dir = create_table_dir(
1156 &project_dir,
1157 "rows",
1158 " - name: f\n type: string\n required: false\n",
1159 );
1160
1161 let db_path = table_dir.join("rows.db");
1164 let conn = rusqlite::Connection::open(&db_path).expect("open per-table db");
1165 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL)
1166 .expect("create _aliases");
1167 conn.execute(
1168 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1169 VALUES (?1, ?2, ?3, ?4, ?5)",
1170 rusqlite::params![
1171 "legacy_alias",
1172 "{}",
1173 Some(10i64),
1174 Some("preserved".to_string()),
1175 Option::<String>::None
1176 ],
1177 )
1178 .expect("seed legacy alias");
1179 drop(conn);
1180
1181 let registry = TableRegistry::mount_from_dirs(None, Some(project_dir.path()))
1182 .await
1183 .expect("mount must succeed");
1184 let global = registry
1185 .global_aliases()
1186 .expect("global storage must be attached");
1187 let all = global.alias_list().await.expect("alias_list");
1188 assert_eq!(all.len(), 1, "exactly one alias should be migrated");
1189 let rec = &all[0];
1190 assert_eq!(rec.name, "legacy_alias");
1191 assert!(
1192 matches!(&rec.sources, crate::aggregator::SourceSpec::Single(t) if t == "rows"),
1193 "migrated row must have sources=Single(rows), got {:?}",
1194 rec.sources
1195 );
1196 assert!(rec.aggregator.is_none());
1197 assert_eq!(rec.default_limit, Some(10));
1198 assert_eq!(rec.description.as_deref(), Some("preserved"));
1199 }
1200}