1use chrono::{DateTime, Utc};
11use sea_orm::entity::prelude::*;
12use sea_orm::sea_query::{
13 Alias, ColumnDef, Index, IndexCreateStatement, Table, TableCreateStatement, TableDropStatement,
14};
15use sea_orm::{
16 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseBackend, DatabaseConnection,
17 DbBackend, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
18 TryInsertResult,
19};
20
21use crate::DbError;
22use aster_forge_config::{
23 ConfigDefinition, ConfigRegistry, ConfigSeedRecord, ConfigSource, ConfigValue, ConfigValueType,
24 ConfigVisibility, RuntimeConfigRecord, present_config_value,
25};
26
27pub const SYSTEM_CONFIG_TABLE: &str = "system_config";
29pub const SYSTEM_CONFIG_ID_COLUMN: &str = "id";
31pub const SYSTEM_CONFIG_KEY_COLUMN: &str = "key";
33pub const SYSTEM_CONFIG_VALUE_COLUMN: &str = "value";
35pub const SYSTEM_CONFIG_VALUE_TYPE_COLUMN: &str = "value_type";
37pub const SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN: &str = "requires_restart";
39pub const SYSTEM_CONFIG_IS_SENSITIVE_COLUMN: &str = "is_sensitive";
41pub const SYSTEM_CONFIG_SOURCE_COLUMN: &str = "source";
43pub const SYSTEM_CONFIG_VISIBILITY_COLUMN: &str = "visibility";
45pub const SYSTEM_CONFIG_NAMESPACE_COLUMN: &str = "namespace";
47pub const SYSTEM_CONFIG_CATEGORY_COLUMN: &str = "category";
49pub const SYSTEM_CONFIG_DESCRIPTION_COLUMN: &str = "description";
51pub const SYSTEM_CONFIG_UPDATED_AT_COLUMN: &str = "updated_at";
53pub const SYSTEM_CONFIG_UPDATED_BY_COLUMN: &str = "updated_by";
55pub const SYSTEM_CONFIG_KEY_UNIQUE_INDEX: &str = "idx_system_config_key_unique";
57
58#[must_use]
60pub fn create_system_config_table(backend: DatabaseBackend) -> TableCreateStatement {
61 Table::create()
62 .table(system_config_table())
63 .if_not_exists()
64 .col(
65 ColumnDef::new(system_config_id())
66 .big_integer()
67 .not_null()
68 .auto_increment()
69 .primary_key(),
70 )
71 .col(
72 ColumnDef::new(system_config_key())
73 .string_len(128)
74 .not_null(),
75 )
76 .col(ColumnDef::new(system_config_value()).text().not_null())
77 .col(
78 ColumnDef::new(system_config_value_type())
79 .string_len(32)
80 .not_null()
81 .default(ConfigValueType::String.as_str()),
82 )
83 .col(
84 ColumnDef::new(system_config_requires_restart())
85 .boolean()
86 .not_null()
87 .default(false),
88 )
89 .col(
90 ColumnDef::new(system_config_is_sensitive())
91 .boolean()
92 .not_null()
93 .default(false),
94 )
95 .col(
96 ColumnDef::new(system_config_source())
97 .string_len(16)
98 .not_null()
99 .default(ConfigSource::System.as_str()),
100 )
101 .col(
102 ColumnDef::new(system_config_visibility())
103 .string_len(16)
104 .not_null()
105 .default(ConfigVisibility::Private.as_str()),
106 )
107 .col(
108 ColumnDef::new(system_config_namespace())
109 .string_len(64)
110 .not_null()
111 .default(""),
112 )
113 .col(
114 ColumnDef::new(system_config_category())
115 .string_len(64)
116 .not_null(),
117 )
118 .col(
119 ColumnDef::new(system_config_description())
120 .string_len(512)
121 .not_null(),
122 )
123 .col(utc_datetime_column(backend, system_config_updated_at()).not_null())
124 .col(
125 ColumnDef::new(system_config_updated_by())
126 .big_integer()
127 .null(),
128 )
129 .to_owned()
130}
131
132#[must_use]
134pub fn drop_system_config_table() -> TableDropStatement {
135 Table::drop()
136 .table(system_config_table())
137 .if_exists()
138 .to_owned()
139}
140
141#[must_use]
143pub fn create_system_config_key_unique_index() -> IndexCreateStatement {
144 Index::create()
145 .name(SYSTEM_CONFIG_KEY_UNIQUE_INDEX)
146 .table(system_config_table())
147 .col(system_config_key())
148 .unique()
149 .if_not_exists()
150 .to_owned()
151}
152
153fn system_config_table() -> Alias {
154 Alias::new(SYSTEM_CONFIG_TABLE)
155}
156
157fn system_config_id() -> Alias {
158 Alias::new(SYSTEM_CONFIG_ID_COLUMN)
159}
160
161fn system_config_key() -> Alias {
162 Alias::new(SYSTEM_CONFIG_KEY_COLUMN)
163}
164
165fn system_config_value() -> Alias {
166 Alias::new(SYSTEM_CONFIG_VALUE_COLUMN)
167}
168
169fn system_config_value_type() -> Alias {
170 Alias::new(SYSTEM_CONFIG_VALUE_TYPE_COLUMN)
171}
172
173fn system_config_requires_restart() -> Alias {
174 Alias::new(SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN)
175}
176
177fn system_config_is_sensitive() -> Alias {
178 Alias::new(SYSTEM_CONFIG_IS_SENSITIVE_COLUMN)
179}
180
181fn system_config_source() -> Alias {
182 Alias::new(SYSTEM_CONFIG_SOURCE_COLUMN)
183}
184
185fn system_config_visibility() -> Alias {
186 Alias::new(SYSTEM_CONFIG_VISIBILITY_COLUMN)
187}
188
189fn system_config_namespace() -> Alias {
190 Alias::new(SYSTEM_CONFIG_NAMESPACE_COLUMN)
191}
192
193fn system_config_category() -> Alias {
194 Alias::new(SYSTEM_CONFIG_CATEGORY_COLUMN)
195}
196
197fn system_config_description() -> Alias {
198 Alias::new(SYSTEM_CONFIG_DESCRIPTION_COLUMN)
199}
200
201fn system_config_updated_at() -> Alias {
202 Alias::new(SYSTEM_CONFIG_UPDATED_AT_COLUMN)
203}
204
205fn system_config_updated_by() -> Alias {
206 Alias::new(SYSTEM_CONFIG_UPDATED_BY_COLUMN)
207}
208
209fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
210 let mut definition = ColumnDef::new(column);
211 match backend {
212 DatabaseBackend::MySql => {
213 definition.custom(Alias::new("datetime(6)"));
214 }
215 _ => {
216 definition.timestamp_with_time_zone();
217 }
218 }
219 definition
220}
221
222#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
224#[sea_orm(table_name = "system_config")]
225pub struct Model {
226 #[sea_orm(primary_key)]
228 pub id: i64,
229 #[sea_orm(unique)]
231 pub key: String,
232 pub value: String,
234 pub value_type: ConfigValueType,
236 pub requires_restart: bool,
238 pub is_sensitive: bool,
240 pub source: ConfigSource,
242 pub visibility: ConfigVisibility,
244 pub namespace: String,
246 pub category: String,
248 pub description: String,
250 pub updated_at: DateTimeUtc,
252 pub updated_by: Option<i64>,
254}
255
256#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
257pub enum Relation {}
258
259impl ActiveModelBehavior for ActiveModel {}
260
261impl RuntimeConfigRecord for Model {
262 fn config_key(&self) -> &str {
263 &self.key
264 }
265
266 fn config_value(&self) -> &str {
267 &self.value
268 }
269
270 fn config_requires_restart(&self) -> bool {
271 self.requires_restart
272 }
273}
274
275#[derive(Clone, Debug, PartialEq)]
281pub struct PresentedSystemConfig {
282 pub id: i64,
284 pub key: String,
286 pub value: ConfigValue,
288 pub value_type: ConfigValueType,
290 pub requires_restart: bool,
292 pub is_sensitive: bool,
294 pub source: ConfigSource,
296 pub visibility: ConfigVisibility,
298 pub namespace: String,
300 pub category: String,
302 pub description: String,
304 pub updated_at: DateTimeUtc,
306 pub updated_by: Option<i64>,
308}
309
310impl PresentedSystemConfig {
311 pub fn from_model(
313 model: Model,
314 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
315 ) -> Self {
316 let value = present_config_value(
317 model.value_type,
318 model.value,
319 model.is_sensitive,
320 on_invalid,
321 );
322 Self {
323 id: model.id,
324 key: model.key,
325 value,
326 value_type: model.value_type,
327 requires_restart: model.requires_restart,
328 is_sensitive: model.is_sensitive,
329 source: model.source,
330 visibility: model.visibility,
331 namespace: model.namespace,
332 category: model.category,
333 description: model.description,
334 updated_at: model.updated_at,
335 updated_by: model.updated_by,
336 }
337 }
338}
339
340pub fn present_system_config(
342 model: Model,
343 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
344) -> PresentedSystemConfig {
345 PresentedSystemConfig::from_model(model, on_invalid)
346}
347
348#[derive(Debug, Clone, PartialEq)]
350pub struct SystemConfigCursorSlice {
351 pub items: Vec<Model>,
353 pub total: u64,
355 pub has_more: bool,
357}
358
359impl SystemConfigCursorSlice {
360 fn empty(total: u64) -> Self {
361 Self {
362 items: Vec::new(),
363 total,
364 has_more: false,
365 }
366 }
367
368 fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
369 let item_count = u64::try_from(items.len())
370 .map_err(|_| DbError::non_retryable("system config page item count is too large"))?;
371 let has_more = item_count > limit;
372 if has_more {
373 let limit = usize::try_from(limit)
374 .map_err(|_| DbError::non_retryable("system config page limit is too large"))?;
375 items.truncate(limit);
376 }
377 Ok(Self {
378 items,
379 total,
380 has_more,
381 })
382 }
383}
384
385#[derive(Clone, Copy)]
391pub struct SystemConfigDbBinding {
392 registry: &'static ConfigRegistry,
393 deprecated_keys: &'static [&'static str],
394}
395
396impl SystemConfigDbBinding {
397 #[must_use]
399 pub const fn new(
400 registry: &'static ConfigRegistry,
401 deprecated_keys: &'static [&'static str],
402 ) -> Self {
403 Self {
404 registry,
405 deprecated_keys,
406 }
407 }
408
409 pub async fn find_all<C: ConnectionTrait>(&self, db: &C) -> crate::Result<Vec<Model>> {
415 find_all(db).await
416 }
417
418 pub async fn find_cursor<C: ConnectionTrait>(
424 &self,
425 db: &C,
426 limit: u64,
427 after_id: Option<i64>,
428 ) -> crate::Result<SystemConfigCursorSlice> {
429 find_cursor(db, limit, after_id).await
430 }
431
432 pub async fn find_by_key<C: ConnectionTrait>(
438 &self,
439 db: &C,
440 key: &str,
441 ) -> crate::Result<Option<Model>> {
442 find_by_key(db, key).await
443 }
444
445 pub async fn find_visible_custom<C: ConnectionTrait>(
451 &self,
452 db: &C,
453 include_authenticated: bool,
454 ) -> crate::Result<Vec<Model>> {
455 find_visible_custom(db, include_authenticated).await
456 }
457
458 pub async fn lock_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
464 lock_by_key(db, key).await
465 }
466
467 pub async fn upsert<C: ConnectionTrait>(
473 &self,
474 db: &C,
475 request: SystemConfigUpsert<'_>,
476 ) -> crate::Result<Model> {
477 upsert(db, self.registry, request).await
478 }
479
480 pub async fn delete_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
486 delete_by_key(db, key).await
487 }
488
489 pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
495 &self,
496 db: &C,
497 key: &str,
498 value: &str,
499 ) -> crate::Result<bool> {
500 ensure_system_value_if_missing(db, self.registry, key, value).await
501 }
502
503 pub async fn delete_deprecated_keys<C: ConnectionTrait>(&self, db: &C) -> crate::Result<u64> {
509 delete_deprecated_keys(db, self.deprecated_keys).await
510 }
511
512 pub async fn ensure_defaults<C: ConnectionTrait>(&self, db: &C) -> crate::Result<usize> {
518 ensure_defaults(db, self.registry, self.deprecated_keys).await
519 }
520}
521
522#[derive(Debug, Clone, Copy)]
524pub struct SystemConfigUpsert<'a> {
525 pub key: &'a str,
527 pub value: &'a str,
529 pub visibility: Option<ConfigVisibility>,
531 pub updated_by: Option<i64>,
533}
534
535#[derive(Clone)]
537pub struct SystemConfigDbStore {
538 db: DatabaseConnection,
539 registry: &'static ConfigRegistry,
540 deprecated_keys: &'static [&'static str],
541}
542
543impl SystemConfigDbStore {
544 #[must_use]
546 pub const fn new(
547 db: DatabaseConnection,
548 registry: &'static ConfigRegistry,
549 deprecated_keys: &'static [&'static str],
550 ) -> Self {
551 Self {
552 db,
553 registry,
554 deprecated_keys,
555 }
556 }
557
558 pub async fn find_all(&self) -> crate::Result<Vec<Model>> {
564 find_all(&self.db).await
565 }
566
567 pub async fn find_cursor(
573 &self,
574 limit: u64,
575 after_id: Option<i64>,
576 ) -> crate::Result<SystemConfigCursorSlice> {
577 find_cursor(&self.db, limit, after_id).await
578 }
579
580 pub async fn find_by_key(&self, key: &str) -> crate::Result<Option<Model>> {
586 find_by_key(&self.db, key).await
587 }
588
589 pub async fn find_visible_custom(
595 &self,
596 include_authenticated: bool,
597 ) -> crate::Result<Vec<Model>> {
598 find_visible_custom(&self.db, include_authenticated).await
599 }
600
601 pub async fn lock_by_key(&self, key: &str) -> crate::Result<()> {
607 lock_by_key(&self.db, key).await
608 }
609
610 pub async fn upsert(&self, request: SystemConfigUpsert<'_>) -> crate::Result<Model> {
616 upsert(&self.db, self.registry, request).await
617 }
618
619 pub async fn delete_by_key(&self, key: &str) -> crate::Result<()> {
625 delete_by_key(&self.db, key).await
626 }
627
628 pub async fn ensure_system_value_if_missing(
634 &self,
635 key: &str,
636 value: &str,
637 ) -> crate::Result<bool> {
638 ensure_system_value_if_missing(&self.db, self.registry, key, value).await
639 }
640
641 pub async fn delete_deprecated_keys(&self) -> crate::Result<u64> {
647 delete_deprecated_keys(&self.db, self.deprecated_keys).await
648 }
649
650 pub async fn ensure_defaults(&self) -> crate::Result<usize> {
656 ensure_defaults(&self.db, self.registry, self.deprecated_keys).await
657 }
658}
659
660pub async fn find_all<C: ConnectionTrait>(db: &C) -> crate::Result<Vec<Model>> {
666 Entity::find()
667 .order_by_asc(Column::Id)
668 .all(db)
669 .await
670 .map_err(DbError::from)
671}
672
673pub async fn find_cursor<C: ConnectionTrait>(
679 db: &C,
680 limit: u64,
681 after_id: Option<i64>,
682) -> crate::Result<SystemConfigCursorSlice> {
683 let limit = limit.clamp(1, 100);
684 let base = Entity::find();
685 let total = base.clone().count(db).await.map_err(DbError::from)?;
686 if total == 0 {
687 return Ok(SystemConfigCursorSlice::empty(total));
688 }
689
690 let mut query = base;
691 if let Some(after_id) = after_id {
692 query = query.filter(Column::Id.gt(after_id));
693 }
694
695 let items = query
696 .order_by_asc(Column::Id)
697 .limit(limit.saturating_add(1))
698 .all(db)
699 .await
700 .map_err(DbError::from)?;
701 SystemConfigCursorSlice::from_overfetch(items, total, limit)
702}
703
704pub async fn find_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<Option<Model>> {
710 Entity::find()
711 .filter(Column::Key.eq(key))
712 .one(db)
713 .await
714 .map_err(DbError::from)
715}
716
717pub async fn find_visible_custom<C: ConnectionTrait>(
723 db: &C,
724 include_authenticated: bool,
725) -> crate::Result<Vec<Model>> {
726 let mut visibility_filter =
727 Condition::any().add(Column::Visibility.eq(ConfigVisibility::Public));
728 if include_authenticated {
729 visibility_filter =
730 visibility_filter.add(Column::Visibility.eq(ConfigVisibility::Authenticated));
731 }
732
733 Entity::find()
734 .filter(Column::Source.eq(ConfigSource::Custom))
735 .filter(visibility_filter)
736 .order_by_asc(Column::Key)
737 .all(db)
738 .await
739 .map_err(DbError::from)
740}
741
742pub async fn lock_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
748 let query = Entity::find().filter(Column::Key.eq(key));
749 let config = match db.get_database_backend() {
750 DbBackend::Postgres | DbBackend::MySql => query
751 .lock_exclusive()
752 .one(db)
753 .await
754 .map_err(DbError::from)?,
755 _ => query.one(db).await.map_err(DbError::from)?,
756 };
757
758 config
759 .map(|_| ())
760 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))
761}
762
763pub async fn upsert<C: ConnectionTrait>(
769 db: &C,
770 registry: &'static ConfigRegistry,
771 request: SystemConfigUpsert<'_>,
772) -> crate::Result<Model> {
773 let now = Utc::now();
774 let definition = registry.get(request.key);
775 let is_custom_key = definition.is_none();
776 let active = definition.map_or_else(
777 || {
778 build_custom_active_model(
779 request.key,
780 request.value.to_string(),
781 request.visibility.unwrap_or_default(),
782 now,
783 request.updated_by,
784 )
785 },
786 |def| build_system_active_model(def, request.value.to_string(), now, request.updated_by),
787 );
788 let inserted = insert_do_nothing(active, db, "system config upsert").await?;
789
790 if !inserted {
791 let existing = find_by_key(db, request.key).await?.ok_or_else(|| {
792 DbError::non_retryable(format!("config key '{}' not found", request.key))
793 })?;
794 let mut active: ActiveModel = existing.into();
795 active.value = Set(request.value.to_string());
796 if is_custom_key && let Some(visibility) = request.visibility {
797 active.visibility = Set(visibility);
798 }
799 active.updated_at = Set(now);
800 active.updated_by = Set(request.updated_by);
801 active.update(db).await.map_err(DbError::from)?;
802 }
803
804 find_by_key(db, request.key)
805 .await?
806 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", request.key)))
807}
808
809pub async fn delete_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
815 let existing = find_by_key(db, key)
816 .await?
817 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
818
819 if existing.source == ConfigSource::System {
820 return Err(DbError::non_retryable("cannot delete system configuration"));
821 }
822
823 Entity::delete_by_id(existing.id)
824 .exec(db)
825 .await
826 .map_err(DbError::from)?;
827 Ok(())
828}
829
830pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
836 db: &C,
837 registry: &'static ConfigRegistry,
838 key: &str,
839 value: &str,
840) -> crate::Result<bool> {
841 let def = registry
842 .get(key)
843 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
844 let now = Utc::now();
845 insert_do_nothing(
846 build_system_active_model(def, value.to_string(), now, None),
847 db,
848 "ensure_system_value_if_missing",
849 )
850 .await
851}
852
853pub async fn delete_deprecated_keys<C: ConnectionTrait>(
859 db: &C,
860 deprecated_keys: &'static [&'static str],
861) -> crate::Result<u64> {
862 if deprecated_keys.is_empty() {
863 return Ok(0);
864 }
865
866 let result = Entity::delete_many()
867 .filter(Column::Key.is_in(deprecated_keys.iter().copied()))
868 .exec(db)
869 .await
870 .map_err(DbError::from)?;
871
872 if result.rows_affected > 0 {
873 tracing::info!(
874 count = result.rows_affected,
875 keys = ?deprecated_keys,
876 "deleted deprecated system config keys"
877 );
878 }
879
880 Ok(result.rows_affected)
881}
882
883pub async fn ensure_defaults<C: ConnectionTrait>(
889 db: &C,
890 registry: &'static ConfigRegistry,
891 deprecated_keys: &'static [&'static str],
892) -> crate::Result<usize> {
893 let mut count = 0;
894
895 delete_deprecated_keys(db, deprecated_keys).await?;
896
897 for seed in registry
898 .default_seed_records()
899 .map_err(DbError::non_retryable)?
900 {
901 let now = Utc::now();
902 let key = seed.key.clone();
903 let inserted = insert_do_nothing(
904 build_system_active_model_from_seed(seed, now, None),
905 db,
906 "ensure_defaults",
907 )
908 .await?;
909
910 if inserted {
911 count += 1;
912 continue;
913 }
914
915 let def = registry.require(&key).map_err(DbError::non_retryable)?;
916 let existing = find_by_key(db, def.key)
917 .await?
918 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", def.key)))?;
919 let mut active: ActiveModel = existing.into();
920 active.source = Set(ConfigSource::System);
921 active.value_type = Set(def.value_type);
922 active.requires_restart = Set(def.requires_restart);
923 active.is_sensitive = Set(def.is_sensitive);
924 active.visibility = Set(def.visibility);
925 active.category = Set(def.category.to_string());
926 active.description = Set(def.description.to_string());
927 active.update(db).await.map_err(DbError::from)?;
928 }
929
930 if count > 0 {
931 tracing::info!("initialized {count} default configuration items");
932 }
933
934 Ok(count)
935}
936
937async fn insert_do_nothing<C: ConnectionTrait>(
938 active: ActiveModel,
939 db: &C,
940 operation: &'static str,
941) -> crate::Result<bool> {
942 match Entity::insert(active)
943 .on_conflict_do_nothing_on([Column::Key])
944 .exec(db)
945 .await
946 .map_err(DbError::from)?
947 {
948 TryInsertResult::Inserted(_) => Ok(true),
949 TryInsertResult::Conflicted => Ok(false),
950 TryInsertResult::Empty => Err(DbError::database_operation(format!(
951 "{operation} produced empty insert result"
952 ))),
953 }
954}
955
956fn build_system_active_model(
957 def: &ConfigDefinition,
958 value: String,
959 now: DateTime<Utc>,
960 updated_by: Option<i64>,
961) -> ActiveModel {
962 ActiveModel {
963 key: Set(def.key.to_string()),
964 value: Set(value),
965 value_type: Set(def.value_type),
966 requires_restart: Set(def.requires_restart),
967 is_sensitive: Set(def.is_sensitive),
968 source: Set(ConfigSource::System),
969 visibility: Set(def.visibility),
970 namespace: Set(String::new()),
971 category: Set(def.category.to_string()),
972 description: Set(def.description.to_string()),
973 updated_at: Set(now),
974 updated_by: Set(updated_by),
975 ..Default::default()
976 }
977}
978
979fn build_system_active_model_from_seed(
980 seed: ConfigSeedRecord,
981 now: DateTime<Utc>,
982 updated_by: Option<i64>,
983) -> ActiveModel {
984 ActiveModel {
985 key: Set(seed.key),
986 value: Set(seed.value),
987 value_type: Set(seed.value_type),
988 requires_restart: Set(seed.requires_restart),
989 is_sensitive: Set(seed.is_sensitive),
990 source: Set(seed.source),
991 visibility: Set(seed.visibility),
992 namespace: Set(String::new()),
993 category: Set(seed.category),
994 description: Set(seed.description),
995 updated_at: Set(now),
996 updated_by: Set(updated_by),
997 ..Default::default()
998 }
999}
1000
1001fn build_custom_active_model(
1002 key: &str,
1003 value: String,
1004 visibility: ConfigVisibility,
1005 now: DateTime<Utc>,
1006 updated_by: Option<i64>,
1007) -> ActiveModel {
1008 ActiveModel {
1009 key: Set(key.to_string()),
1010 value: Set(value),
1011 value_type: Set(ConfigValueType::String),
1012 requires_restart: Set(false),
1013 is_sensitive: Set(false),
1014 source: Set(ConfigSource::Custom),
1015 visibility: Set(visibility),
1016 namespace: Set(String::new()),
1017 category: Set(String::new()),
1018 description: Set(String::new()),
1019 updated_at: Set(now),
1020 updated_by: Set(updated_by),
1021 ..Default::default()
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::{
1028 ActiveModel, Column, Entity, Model, SystemConfigDbBinding, SystemConfigDbStore,
1029 SystemConfigUpsert, create_system_config_key_unique_index, create_system_config_table,
1030 present_system_config,
1031 };
1032 use aster_forge_config::{
1033 ConfigDefinition, ConfigRegistry, ConfigSource, ConfigValue, ConfigValueType,
1034 ConfigVisibility, config_value_audit_string,
1035 };
1036 use chrono::Utc;
1037 use sea_orm::sea_query::{MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder};
1038 use sea_orm::{
1039 ActiveModelTrait, ColumnTrait, ConnectionTrait, Database, DatabaseBackend, EntityTrait,
1040 QueryFilter, Set,
1041 };
1042
1043 const PRIMARY_KEY: &str = "primary_key";
1044 const ARRAY_KEY: &str = "array_key";
1045 const DEPRECATED_KEY: &str = "deprecated_key";
1046
1047 fn primary_default() -> String {
1048 "primary default".to_string()
1049 }
1050
1051 fn array_default() -> String {
1052 "[\"https://example.com\"]".to_string()
1053 }
1054
1055 const PRIMARY: ConfigDefinition = ConfigDefinition {
1056 key: PRIMARY_KEY,
1057 default_fn: primary_default,
1058 value_type: ConfigValueType::String,
1059 category: "site.branding",
1060 description: "Primary config",
1061 ..ConfigDefinition::private_system()
1062 };
1063
1064 const ARRAY: ConfigDefinition = ConfigDefinition {
1065 key: ARRAY_KEY,
1066 default_fn: array_default,
1067 value_type: ConfigValueType::StringArray,
1068 category: "site.public",
1069 description: "Array config",
1070 visibility: ConfigVisibility::Public,
1071 ..ConfigDefinition::private_system()
1072 };
1073
1074 static REGISTRY: ConfigRegistry = ConfigRegistry::new(&[PRIMARY, ARRAY]);
1075 static DEPRECATED: &[&str] = &[DEPRECATED_KEY];
1076 static BINDING: SystemConfigDbBinding = SystemConfigDbBinding::new(®ISTRY, DEPRECATED);
1077
1078 async fn sqlite_store() -> SystemConfigDbStore {
1079 let db = Database::connect("sqlite::memory:")
1080 .await
1081 .expect("sqlite memory database should connect");
1082 let backend = db.get_database_backend();
1083 db.execute(&create_system_config_table(backend))
1084 .await
1085 .expect("system_config table builder should execute");
1086 db.execute(&create_system_config_key_unique_index())
1087 .await
1088 .expect("system_config key index builder should execute");
1089 SystemConfigDbStore::new(db, ®ISTRY, DEPRECATED)
1090 }
1091
1092 async fn sqlite_db_from_builders() -> sea_orm::DatabaseConnection {
1093 let db = Database::connect("sqlite::memory:")
1094 .await
1095 .expect("sqlite memory database should connect");
1096 let backend = db.get_database_backend();
1097 db.execute(&create_system_config_table(backend))
1098 .await
1099 .expect("system_config table builder should execute");
1100 db.execute(&create_system_config_key_unique_index())
1101 .await
1102 .expect("system_config key index builder should execute");
1103 db
1104 }
1105
1106 fn create_table_sql(backend: DatabaseBackend) -> String {
1107 let table = create_system_config_table(backend);
1108 match backend {
1109 DatabaseBackend::MySql => table.to_string(MysqlQueryBuilder),
1110 DatabaseBackend::Postgres => table.to_string(PostgresQueryBuilder),
1111 DatabaseBackend::Sqlite => table.to_string(SqliteQueryBuilder),
1112 _ => unreachable!("unsupported backend in system config table test"),
1113 }
1114 }
1115
1116 #[test]
1117 fn create_system_config_table_uses_stable_shape() {
1118 let sqlite_sql = create_table_sql(DatabaseBackend::Sqlite);
1119 assert!(sqlite_sql.contains("CREATE TABLE IF NOT EXISTS \"system_config\""));
1120 assert!(sqlite_sql.contains("\"key\" varchar(128) NOT NULL"));
1121 assert!(sqlite_sql.contains("\"value_type\" varchar(32) NOT NULL DEFAULT 'string'"));
1122 assert!(sqlite_sql.contains("\"source\" varchar(16) NOT NULL DEFAULT 'system'"));
1123 assert!(sqlite_sql.contains("\"visibility\" varchar(16) NOT NULL DEFAULT 'private'"));
1124 assert!(sqlite_sql.contains("\"category\" varchar(64) NOT NULL"));
1125 assert!(sqlite_sql.contains("\"description\" varchar(512) NOT NULL"));
1126 assert!(sqlite_sql.contains("\"updated_at\" timestamp_with_timezone_text NOT NULL"));
1127
1128 let key_index = create_system_config_key_unique_index().to_string(SqliteQueryBuilder);
1129 assert!(key_index.contains("idx_system_config_key_unique"));
1130 assert!(key_index.contains("\"key\""));
1131
1132 let mysql_sql = create_table_sql(DatabaseBackend::MySql);
1133 assert!(mysql_sql.contains("`updated_at` datetime(6) NOT NULL"));
1134
1135 let postgres_sql = create_table_sql(DatabaseBackend::Postgres);
1136 assert!(postgres_sql.contains("\"updated_at\" timestamp with time zone NOT NULL"));
1137 }
1138
1139 #[tokio::test]
1140 async fn ensure_defaults_inserts_once_and_repairs_metadata() {
1141 let store = sqlite_store().await;
1142
1143 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1144 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1145
1146 let mut active: ActiveModel = store
1147 .find_by_key(PRIMARY_KEY)
1148 .await
1149 .unwrap()
1150 .unwrap()
1151 .into();
1152 active.source = Set(ConfigSource::Custom);
1153 active.value_type = Set(ConfigValueType::Number);
1154 active.requires_restart = Set(true);
1155 active.is_sensitive = Set(true);
1156 active.visibility = Set(ConfigVisibility::Authenticated);
1157 active.category = Set("wrong".to_string());
1158 active.description = Set("wrong".to_string());
1159 active.update(&store.db).await.unwrap();
1160
1161 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1162 let repaired = store.find_by_key(PRIMARY_KEY).await.unwrap().unwrap();
1163 assert_eq!(repaired.source, ConfigSource::System);
1164 assert_eq!(repaired.value_type, ConfigValueType::String);
1165 assert!(!repaired.requires_restart);
1166 assert!(!repaired.is_sensitive);
1167 assert_eq!(repaired.visibility, ConfigVisibility::Private);
1168 assert_eq!(repaired.category, "site.branding");
1169 assert_eq!(repaired.description, "Primary config");
1170 }
1171
1172 #[tokio::test]
1173 async fn ensure_defaults_deletes_deprecated_keys() {
1174 let store = sqlite_store().await;
1175
1176 store
1177 .upsert(SystemConfigUpsert {
1178 key: DEPRECATED_KEY,
1179 value: "old",
1180 visibility: None,
1181 updated_by: None,
1182 })
1183 .await
1184 .unwrap();
1185
1186 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1187 assert!(store.find_by_key(DEPRECATED_KEY).await.unwrap().is_none());
1188 assert_eq!(store.delete_deprecated_keys().await.unwrap(), 0);
1189 }
1190
1191 #[tokio::test]
1192 async fn binding_uses_product_registry_and_deprecated_keys() {
1193 let db = sqlite_db_from_builders().await;
1194
1195 BINDING
1196 .upsert(
1197 &db,
1198 SystemConfigUpsert {
1199 key: DEPRECATED_KEY,
1200 value: "old",
1201 visibility: None,
1202 updated_by: None,
1203 },
1204 )
1205 .await
1206 .unwrap();
1207
1208 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1209 assert!(
1210 BINDING
1211 .find_by_key(&db, DEPRECATED_KEY)
1212 .await
1213 .unwrap()
1214 .is_none()
1215 );
1216 assert_eq!(BINDING.delete_deprecated_keys(&db).await.unwrap(), 0);
1217
1218 let known = BINDING
1219 .find_by_key(&db, PRIMARY_KEY)
1220 .await
1221 .unwrap()
1222 .unwrap();
1223 assert_eq!(known.source, ConfigSource::System);
1224 assert_eq!(known.value, "primary default");
1225 }
1226
1227 #[tokio::test]
1228 async fn product_integration_flow_uses_builders_store_and_presentation_helpers() {
1229 let db = sqlite_db_from_builders().await;
1230
1231 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1232 let system = BINDING
1233 .upsert(
1234 &db,
1235 SystemConfigUpsert {
1236 key: PRIMARY_KEY,
1237 value: "operator title",
1238 visibility: None,
1239 updated_by: Some(42),
1240 },
1241 )
1242 .await
1243 .unwrap();
1244 assert_eq!(system.source, ConfigSource::System);
1245 assert_eq!(system.updated_by, Some(42));
1246
1247 let custom = BINDING
1248 .upsert(
1249 &db,
1250 SystemConfigUpsert {
1251 key: "custom.banner",
1252 value: "hello",
1253 visibility: Some(ConfigVisibility::Public),
1254 updated_by: Some(7),
1255 },
1256 )
1257 .await
1258 .unwrap();
1259 assert_eq!(custom.source, ConfigSource::Custom);
1260
1261 let visible = BINDING.find_visible_custom(&db, true).await.unwrap();
1262 assert_eq!(
1263 visible
1264 .iter()
1265 .map(|config| config.key.as_str())
1266 .collect::<Vec<_>>(),
1267 vec!["custom.banner"]
1268 );
1269
1270 let presented = present_system_config(custom, |_| {
1271 unreachable!("valid scalar config should not report invalid storage")
1272 });
1273 assert_eq!(presented.value, ConfigValue::String("hello".to_string()));
1274
1275 let sensitive = Model {
1276 is_sensitive: true,
1277 value: "secret".to_string(),
1278 ..system
1279 };
1280 let presented_sensitive = present_system_config(sensitive.clone(), |_| {
1281 unreachable!("sensitive config should not parse storage")
1282 });
1283 assert_eq!(presented_sensitive.value, ConfigValue::redacted());
1284
1285 let audit_value = config_value_audit_string(
1286 sensitive.value_type,
1287 sensitive.value,
1288 sensitive.is_sensitive,
1289 |_| unreachable!("sensitive config should not parse storage"),
1290 );
1291 assert_eq!(audit_value, ConfigValue::REDACTED);
1292 }
1293
1294 #[tokio::test]
1295 async fn upsert_system_and_custom_config_preserves_metadata() {
1296 let store = sqlite_store().await;
1297
1298 let system = store
1299 .upsert(SystemConfigUpsert {
1300 key: PRIMARY_KEY,
1301 value: "Custom Title",
1302 visibility: None,
1303 updated_by: Some(42),
1304 })
1305 .await
1306 .unwrap();
1307 assert_eq!(system.value, "Custom Title");
1308 assert_eq!(system.updated_by, Some(42));
1309 assert_eq!(system.source, ConfigSource::System);
1310 assert_eq!(system.visibility, ConfigVisibility::Private);
1311 assert_eq!(system.value_type, ConfigValueType::String);
1312
1313 let custom = store
1314 .upsert(SystemConfigUpsert {
1315 key: "custom_public_banner",
1316 value: "hello",
1317 visibility: Some(ConfigVisibility::Public),
1318 updated_by: Some(7),
1319 })
1320 .await
1321 .unwrap();
1322 assert_eq!(custom.source, ConfigSource::Custom);
1323 assert_eq!(custom.visibility, ConfigVisibility::Public);
1324 assert_eq!(custom.value_type, ConfigValueType::String);
1325 assert_eq!(custom.updated_by, Some(7));
1326
1327 let updated_custom = store
1328 .upsert(SystemConfigUpsert {
1329 key: "custom_public_banner",
1330 value: "hello again",
1331 visibility: Some(ConfigVisibility::Authenticated),
1332 updated_by: None,
1333 })
1334 .await
1335 .unwrap();
1336 assert_eq!(updated_custom.id, custom.id);
1337 assert_eq!(updated_custom.value, "hello again");
1338 assert_eq!(updated_custom.visibility, ConfigVisibility::Authenticated);
1339 assert_eq!(updated_custom.updated_by, None);
1340 }
1341
1342 #[tokio::test]
1343 async fn find_visible_custom_filters_visibility_and_orders_by_key() {
1344 let store = sqlite_store().await;
1345 store.ensure_defaults().await.unwrap();
1346 for (key, visibility) in [
1347 ("visible_public", ConfigVisibility::Public),
1348 ("visible_authenticated", ConfigVisibility::Authenticated),
1349 ("visible_private", ConfigVisibility::Private),
1350 ] {
1351 store
1352 .upsert(SystemConfigUpsert {
1353 key,
1354 value: key,
1355 visibility: Some(visibility),
1356 updated_by: None,
1357 })
1358 .await
1359 .unwrap();
1360 }
1361
1362 let public_only = store.find_visible_custom(false).await.unwrap();
1363 assert_eq!(
1364 public_only
1365 .iter()
1366 .map(|config| config.key.as_str())
1367 .collect::<Vec<_>>(),
1368 vec!["visible_public"]
1369 );
1370
1371 let public_and_authenticated = store.find_visible_custom(true).await.unwrap();
1372 assert_eq!(
1373 public_and_authenticated
1374 .iter()
1375 .map(|config| config.key.as_str())
1376 .collect::<Vec<_>>(),
1377 vec!["visible_authenticated", "visible_public"]
1378 );
1379 }
1380
1381 #[tokio::test]
1382 async fn delete_rejects_system_config_and_removes_custom_config() {
1383 let store = sqlite_store().await;
1384 store.ensure_defaults().await.unwrap();
1385 store
1386 .upsert(SystemConfigUpsert {
1387 key: "custom_delete_me",
1388 value: "value",
1389 visibility: None,
1390 updated_by: None,
1391 })
1392 .await
1393 .unwrap();
1394
1395 let system_error = store.delete_by_key(PRIMARY_KEY).await.unwrap_err();
1396 assert!(
1397 system_error
1398 .to_string()
1399 .contains("cannot delete system configuration")
1400 );
1401
1402 store.delete_by_key("custom_delete_me").await.unwrap();
1403 assert!(
1404 store
1405 .find_by_key("custom_delete_me")
1406 .await
1407 .unwrap()
1408 .is_none()
1409 );
1410
1411 let missing_error = store.delete_by_key("missing_custom").await.unwrap_err();
1412 assert!(missing_error.to_string().contains("missing_custom"));
1413 }
1414
1415 #[tokio::test]
1416 async fn find_cursor_and_lock_by_key_follow_repository_contract() {
1417 let store = sqlite_store().await;
1418 store.ensure_defaults().await.unwrap();
1419
1420 let all = store.find_all().await.unwrap();
1421 assert_eq!(all.len(), 2);
1422
1423 let page = store.find_cursor(1, Some(all[0].id)).await.unwrap();
1424 assert_eq!(page.total, 2);
1425 assert!(!page.has_more);
1426 assert_eq!(page.items.len(), 1);
1427 assert_eq!(page.items[0].id, all[1].id);
1428
1429 store.lock_by_key(PRIMARY_KEY).await.unwrap();
1430 let missing = store.lock_by_key("missing_lock_key").await.unwrap_err();
1431 assert!(missing.to_string().contains("missing_lock_key"));
1432 }
1433
1434 #[tokio::test]
1435 async fn ensure_system_value_if_missing_inserts_known_keys_only() {
1436 let store = sqlite_store().await;
1437
1438 assert!(
1439 store
1440 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://example.com"]"#)
1441 .await
1442 .unwrap()
1443 );
1444 assert!(
1445 !store
1446 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://ignored.com"]"#)
1447 .await
1448 .unwrap()
1449 );
1450 let stored = store.find_by_key(ARRAY_KEY).await.unwrap().unwrap();
1451 assert_eq!(stored.value, r#"["https://example.com"]"#);
1452 assert_eq!(stored.value_type, ConfigValueType::StringArray);
1453
1454 let unknown = store
1455 .ensure_system_value_if_missing("unknown_config_key", "value")
1456 .await
1457 .unwrap_err();
1458 assert!(unknown.to_string().contains("unknown_config_key"));
1459 }
1460
1461 #[tokio::test]
1462 async fn free_functions_work_with_any_connection() {
1463 let store = sqlite_store().await;
1464 super::upsert(
1465 &store.db,
1466 ®ISTRY,
1467 SystemConfigUpsert {
1468 key: PRIMARY_KEY,
1469 value: "direct",
1470 visibility: None,
1471 updated_by: None,
1472 },
1473 )
1474 .await
1475 .unwrap();
1476
1477 let stored = Entity::find()
1478 .filter(Column::Key.eq(PRIMARY_KEY))
1479 .one(&store.db)
1480 .await
1481 .unwrap()
1482 .unwrap();
1483 assert_eq!(stored.value, "direct");
1484 assert!(stored.updated_at <= Utc::now());
1485 }
1486}