1use chrono::{DateTime, Utc};
16use sea_orm::entity::prelude::*;
17use sea_orm::sea_query::{
18 Alias, ColumnDef, Index, IndexCreateStatement, Table, TableCreateStatement, TableDropStatement,
19};
20use sea_orm::{
21 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseBackend, DatabaseConnection,
22 DbBackend, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
23 TryInsertResult,
24};
25
26use crate::DbError;
27use aster_forge_config::{
28 ConfigDefinition, ConfigRegistry, ConfigSeedRecord, ConfigSource, ConfigValue, ConfigValueType,
29 ConfigVisibility, RuntimeConfigRecord, present_config_value,
30};
31
32pub const SYSTEM_CONFIG_TABLE: &str = "system_config";
34pub const SYSTEM_CONFIG_ID_COLUMN: &str = "id";
36pub const SYSTEM_CONFIG_KEY_COLUMN: &str = "key";
38pub const SYSTEM_CONFIG_VALUE_COLUMN: &str = "value";
40pub const SYSTEM_CONFIG_VALUE_TYPE_COLUMN: &str = "value_type";
42pub const SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN: &str = "requires_restart";
44pub const SYSTEM_CONFIG_IS_SENSITIVE_COLUMN: &str = "is_sensitive";
46pub const SYSTEM_CONFIG_SOURCE_COLUMN: &str = "source";
48pub const SYSTEM_CONFIG_VISIBILITY_COLUMN: &str = "visibility";
50pub const SYSTEM_CONFIG_NAMESPACE_COLUMN: &str = "namespace";
52pub const SYSTEM_CONFIG_CATEGORY_COLUMN: &str = "category";
54pub const SYSTEM_CONFIG_DESCRIPTION_COLUMN: &str = "description";
56pub const SYSTEM_CONFIG_UPDATED_AT_COLUMN: &str = "updated_at";
58pub const SYSTEM_CONFIG_UPDATED_BY_COLUMN: &str = "updated_by";
60pub const SYSTEM_CONFIG_KEY_UNIQUE_INDEX: &str = "idx_system_config_key_unique";
62
63#[must_use]
65pub fn create_system_config_table(backend: DatabaseBackend) -> TableCreateStatement {
66 Table::create()
67 .table(system_config_table())
68 .if_not_exists()
69 .col(
70 ColumnDef::new(system_config_id())
71 .big_integer()
72 .not_null()
73 .auto_increment()
74 .primary_key(),
75 )
76 .col(
77 ColumnDef::new(system_config_key())
78 .string_len(128)
79 .not_null(),
80 )
81 .col(ColumnDef::new(system_config_value()).text().not_null())
82 .col(
83 ColumnDef::new(system_config_value_type())
84 .string_len(32)
85 .not_null()
86 .default(ConfigValueType::String.as_str()),
87 )
88 .col(
89 ColumnDef::new(system_config_requires_restart())
90 .boolean()
91 .not_null()
92 .default(false),
93 )
94 .col(
95 ColumnDef::new(system_config_is_sensitive())
96 .boolean()
97 .not_null()
98 .default(false),
99 )
100 .col(
101 ColumnDef::new(system_config_source())
102 .string_len(16)
103 .not_null()
104 .default(ConfigSource::System.as_str()),
105 )
106 .col(
107 ColumnDef::new(system_config_visibility())
108 .string_len(16)
109 .not_null()
110 .default(ConfigVisibility::Private.as_str()),
111 )
112 .col(
113 ColumnDef::new(system_config_namespace())
114 .string_len(64)
115 .not_null()
116 .default(""),
117 )
118 .col(
119 ColumnDef::new(system_config_category())
120 .string_len(64)
121 .not_null(),
122 )
123 .col(
124 ColumnDef::new(system_config_description())
125 .string_len(512)
126 .not_null(),
127 )
128 .col(utc_datetime_column(backend, system_config_updated_at()).not_null())
129 .col(
130 ColumnDef::new(system_config_updated_by())
131 .big_integer()
132 .null(),
133 )
134 .to_owned()
135}
136
137#[must_use]
139pub fn drop_system_config_table() -> TableDropStatement {
140 Table::drop()
141 .table(system_config_table())
142 .if_exists()
143 .to_owned()
144}
145
146#[must_use]
148pub fn create_system_config_key_unique_index() -> IndexCreateStatement {
149 Index::create()
150 .name(SYSTEM_CONFIG_KEY_UNIQUE_INDEX)
151 .table(system_config_table())
152 .col(system_config_key())
153 .unique()
154 .if_not_exists()
155 .to_owned()
156}
157
158fn system_config_table() -> Alias {
159 Alias::new(SYSTEM_CONFIG_TABLE)
160}
161
162fn system_config_id() -> Alias {
163 Alias::new(SYSTEM_CONFIG_ID_COLUMN)
164}
165
166fn system_config_key() -> Alias {
167 Alias::new(SYSTEM_CONFIG_KEY_COLUMN)
168}
169
170fn system_config_value() -> Alias {
171 Alias::new(SYSTEM_CONFIG_VALUE_COLUMN)
172}
173
174fn system_config_value_type() -> Alias {
175 Alias::new(SYSTEM_CONFIG_VALUE_TYPE_COLUMN)
176}
177
178fn system_config_requires_restart() -> Alias {
179 Alias::new(SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN)
180}
181
182fn system_config_is_sensitive() -> Alias {
183 Alias::new(SYSTEM_CONFIG_IS_SENSITIVE_COLUMN)
184}
185
186fn system_config_source() -> Alias {
187 Alias::new(SYSTEM_CONFIG_SOURCE_COLUMN)
188}
189
190fn system_config_visibility() -> Alias {
191 Alias::new(SYSTEM_CONFIG_VISIBILITY_COLUMN)
192}
193
194fn system_config_namespace() -> Alias {
195 Alias::new(SYSTEM_CONFIG_NAMESPACE_COLUMN)
196}
197
198fn system_config_category() -> Alias {
199 Alias::new(SYSTEM_CONFIG_CATEGORY_COLUMN)
200}
201
202fn system_config_description() -> Alias {
203 Alias::new(SYSTEM_CONFIG_DESCRIPTION_COLUMN)
204}
205
206fn system_config_updated_at() -> Alias {
207 Alias::new(SYSTEM_CONFIG_UPDATED_AT_COLUMN)
208}
209
210fn system_config_updated_by() -> Alias {
211 Alias::new(SYSTEM_CONFIG_UPDATED_BY_COLUMN)
212}
213
214fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
215 let mut definition = ColumnDef::new(column);
216 match backend {
217 DatabaseBackend::MySql => {
218 definition.custom(Alias::new("datetime(6)"));
219 }
220 _ => {
221 definition.timestamp_with_time_zone();
222 }
223 }
224 definition
225}
226
227#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
229#[sea_orm(table_name = "system_config")]
230pub struct Model {
231 #[sea_orm(primary_key)]
233 pub id: i64,
234 #[sea_orm(unique)]
236 pub key: String,
237 pub value: String,
239 pub value_type: ConfigValueType,
241 pub requires_restart: bool,
243 pub is_sensitive: bool,
245 pub source: ConfigSource,
247 pub visibility: ConfigVisibility,
249 pub namespace: String,
251 pub category: String,
253 pub description: String,
255 pub updated_at: DateTimeUtc,
257 pub updated_by: Option<i64>,
259}
260
261#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
262pub enum Relation {}
263
264impl ActiveModelBehavior for ActiveModel {}
265
266impl RuntimeConfigRecord for Model {
267 fn config_key(&self) -> &str {
268 &self.key
269 }
270
271 fn config_value(&self) -> &str {
272 &self.value
273 }
274
275 fn config_requires_restart(&self) -> bool {
276 self.requires_restart
277 }
278}
279
280#[derive(Clone, Debug, PartialEq)]
286pub struct PresentedSystemConfig {
287 pub id: i64,
289 pub key: String,
291 pub value: ConfigValue,
293 pub value_type: ConfigValueType,
295 pub requires_restart: bool,
297 pub is_sensitive: bool,
299 pub source: ConfigSource,
301 pub visibility: ConfigVisibility,
303 pub namespace: String,
305 pub category: String,
307 pub description: String,
309 pub updated_at: DateTimeUtc,
311 pub updated_by: Option<i64>,
313}
314
315impl PresentedSystemConfig {
316 pub fn from_model(
318 model: Model,
319 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
320 ) -> Self {
321 let value = present_config_value(
322 model.value_type,
323 model.value,
324 model.is_sensitive,
325 on_invalid,
326 );
327 Self {
328 id: model.id,
329 key: model.key,
330 value,
331 value_type: model.value_type,
332 requires_restart: model.requires_restart,
333 is_sensitive: model.is_sensitive,
334 source: model.source,
335 visibility: model.visibility,
336 namespace: model.namespace,
337 category: model.category,
338 description: model.description,
339 updated_at: model.updated_at,
340 updated_by: model.updated_by,
341 }
342 }
343}
344
345pub fn present_system_config(
347 model: Model,
348 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
349) -> PresentedSystemConfig {
350 PresentedSystemConfig::from_model(model, on_invalid)
351}
352
353#[derive(Debug, Clone, PartialEq)]
355pub struct SystemConfigCursorSlice {
356 pub items: Vec<Model>,
358 pub total: u64,
360 pub has_more: bool,
362}
363
364impl SystemConfigCursorSlice {
365 fn empty(total: u64) -> Self {
366 Self {
367 items: Vec::new(),
368 total,
369 has_more: false,
370 }
371 }
372
373 fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
374 let item_count = u64::try_from(items.len())
375 .map_err(|_| DbError::non_retryable("system config page item count is too large"))?;
376 let has_more = item_count > limit;
377 if has_more {
378 let limit = usize::try_from(limit)
379 .map_err(|_| DbError::non_retryable("system config page limit is too large"))?;
380 items.truncate(limit);
381 }
382 Ok(Self {
383 items,
384 total,
385 has_more,
386 })
387 }
388}
389
390#[derive(Clone, Copy)]
397pub struct SystemConfigDbBinding {
398 registry: &'static ConfigRegistry,
399 deprecated_keys: &'static [&'static str],
400}
401
402impl SystemConfigDbBinding {
403 #[must_use]
405 pub const fn new(
406 registry: &'static ConfigRegistry,
407 deprecated_keys: &'static [&'static str],
408 ) -> Self {
409 Self {
410 registry,
411 deprecated_keys,
412 }
413 }
414
415 pub async fn find_all<C: ConnectionTrait>(&self, db: &C) -> crate::Result<Vec<Model>> {
421 find_all(db).await
422 }
423
424 pub async fn find_cursor<C: ConnectionTrait>(
430 &self,
431 db: &C,
432 limit: u64,
433 after_id: Option<i64>,
434 ) -> crate::Result<SystemConfigCursorSlice> {
435 find_cursor(db, limit, after_id).await
436 }
437
438 pub async fn find_by_key<C: ConnectionTrait>(
444 &self,
445 db: &C,
446 key: &str,
447 ) -> crate::Result<Option<Model>> {
448 find_by_key(db, key).await
449 }
450
451 pub async fn find_visible_custom<C: ConnectionTrait>(
457 &self,
458 db: &C,
459 include_authenticated: bool,
460 ) -> crate::Result<Vec<Model>> {
461 find_visible_custom(db, include_authenticated).await
462 }
463
464 pub async fn lock_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
470 lock_by_key(db, key).await
471 }
472
473 pub async fn upsert_prevalidated<C: ConnectionTrait>(
487 &self,
488 db: &C,
489 request: SystemConfigUpsert<'_>,
490 ) -> crate::Result<Model> {
491 upsert_prevalidated(db, self.registry, request).await
492 }
493
494 pub async fn delete_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
500 delete_by_key(db, key).await
501 }
502
503 pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
512 &self,
513 db: &C,
514 key: &str,
515 value: &str,
516 ) -> crate::Result<bool> {
517 ensure_system_value_if_missing(db, self.registry, key, value).await
518 }
519
520 pub async fn delete_deprecated_keys<C: ConnectionTrait>(&self, db: &C) -> crate::Result<u64> {
526 delete_deprecated_keys(db, self.deprecated_keys).await
527 }
528
529 pub async fn ensure_defaults<C: ConnectionTrait>(&self, db: &C) -> crate::Result<usize> {
535 ensure_defaults(db, self.registry, self.deprecated_keys).await
536 }
537}
538
539#[derive(Debug, Clone, Copy)]
544pub struct SystemConfigUpsert<'a> {
545 pub key: &'a str,
547 pub value: &'a str,
550 pub visibility: Option<ConfigVisibility>,
552 pub updated_by: Option<i64>,
554}
555
556#[derive(Clone)]
560pub struct SystemConfigDbStore {
561 db: DatabaseConnection,
562 registry: &'static ConfigRegistry,
563 deprecated_keys: &'static [&'static str],
564}
565
566impl SystemConfigDbStore {
567 #[must_use]
569 pub const fn new(
570 db: DatabaseConnection,
571 registry: &'static ConfigRegistry,
572 deprecated_keys: &'static [&'static str],
573 ) -> Self {
574 Self {
575 db,
576 registry,
577 deprecated_keys,
578 }
579 }
580
581 pub async fn find_all(&self) -> crate::Result<Vec<Model>> {
587 find_all(&self.db).await
588 }
589
590 pub async fn find_cursor(
596 &self,
597 limit: u64,
598 after_id: Option<i64>,
599 ) -> crate::Result<SystemConfigCursorSlice> {
600 find_cursor(&self.db, limit, after_id).await
601 }
602
603 pub async fn find_by_key(&self, key: &str) -> crate::Result<Option<Model>> {
609 find_by_key(&self.db, key).await
610 }
611
612 pub async fn find_visible_custom(
618 &self,
619 include_authenticated: bool,
620 ) -> crate::Result<Vec<Model>> {
621 find_visible_custom(&self.db, include_authenticated).await
622 }
623
624 pub async fn lock_by_key(&self, key: &str) -> crate::Result<()> {
630 lock_by_key(&self.db, key).await
631 }
632
633 pub async fn upsert_prevalidated(
648 &self,
649 request: SystemConfigUpsert<'_>,
650 ) -> crate::Result<Model> {
651 upsert_prevalidated(&self.db, self.registry, request).await
652 }
653
654 pub async fn delete_by_key(&self, key: &str) -> crate::Result<()> {
660 delete_by_key(&self.db, key).await
661 }
662
663 pub async fn ensure_system_value_if_missing(
672 &self,
673 key: &str,
674 value: &str,
675 ) -> crate::Result<bool> {
676 ensure_system_value_if_missing(&self.db, self.registry, key, value).await
677 }
678
679 pub async fn delete_deprecated_keys(&self) -> crate::Result<u64> {
685 delete_deprecated_keys(&self.db, self.deprecated_keys).await
686 }
687
688 pub async fn ensure_defaults(&self) -> crate::Result<usize> {
694 ensure_defaults(&self.db, self.registry, self.deprecated_keys).await
695 }
696}
697
698pub async fn find_all<C: ConnectionTrait>(db: &C) -> crate::Result<Vec<Model>> {
704 Entity::find()
705 .order_by_asc(Column::Id)
706 .all(db)
707 .await
708 .map_err(DbError::from)
709}
710
711pub async fn find_cursor<C: ConnectionTrait>(
717 db: &C,
718 limit: u64,
719 after_id: Option<i64>,
720) -> crate::Result<SystemConfigCursorSlice> {
721 let limit = limit.clamp(1, 100);
722 let base = Entity::find();
723 let total = base.clone().count(db).await.map_err(DbError::from)?;
724 if total == 0 {
725 return Ok(SystemConfigCursorSlice::empty(total));
726 }
727
728 let mut query = base;
729 if let Some(after_id) = after_id {
730 query = query.filter(Column::Id.gt(after_id));
731 }
732
733 let items = query
734 .order_by_asc(Column::Id)
735 .limit(limit.saturating_add(1))
736 .all(db)
737 .await
738 .map_err(DbError::from)?;
739 SystemConfigCursorSlice::from_overfetch(items, total, limit)
740}
741
742pub async fn find_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<Option<Model>> {
748 Entity::find()
749 .filter(Column::Key.eq(key))
750 .one(db)
751 .await
752 .map_err(DbError::from)
753}
754
755pub async fn find_visible_custom<C: ConnectionTrait>(
761 db: &C,
762 include_authenticated: bool,
763) -> crate::Result<Vec<Model>> {
764 let mut visibility_filter =
765 Condition::any().add(Column::Visibility.eq(ConfigVisibility::Public));
766 if include_authenticated {
767 visibility_filter =
768 visibility_filter.add(Column::Visibility.eq(ConfigVisibility::Authenticated));
769 }
770
771 Entity::find()
772 .filter(Column::Source.eq(ConfigSource::Custom))
773 .filter(visibility_filter)
774 .order_by_asc(Column::Key)
775 .all(db)
776 .await
777 .map_err(DbError::from)
778}
779
780pub async fn lock_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
786 let query = Entity::find().filter(Column::Key.eq(key));
787 let config = match db.get_database_backend() {
788 DbBackend::Postgres | DbBackend::MySql => query
789 .lock_exclusive()
790 .one(db)
791 .await
792 .map_err(DbError::from)?,
793 _ => query.one(db).await.map_err(DbError::from)?,
794 };
795
796 config
797 .map(|_| ())
798 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))
799}
800
801pub async fn upsert_prevalidated<C: ConnectionTrait>(
820 db: &C,
821 registry: &'static ConfigRegistry,
822 request: SystemConfigUpsert<'_>,
823) -> crate::Result<Model> {
824 let now = Utc::now();
825 let definition = registry.get(request.key);
826 let is_custom_key = definition.is_none();
827 let active = definition.map_or_else(
828 || {
829 build_custom_active_model(
830 request.key,
831 request.value.to_string(),
832 request.visibility.unwrap_or_default(),
833 now,
834 request.updated_by,
835 )
836 },
837 |def| build_system_active_model(def, request.value.to_string(), now, request.updated_by),
838 );
839 let inserted = insert_do_nothing(active, db, "system config upsert").await?;
840
841 if !inserted {
842 let existing = find_by_key(db, request.key).await?.ok_or_else(|| {
843 DbError::non_retryable(format!("config key '{}' not found", request.key))
844 })?;
845 let mut active: ActiveModel = existing.into();
846 active.value = Set(request.value.to_string());
847 if is_custom_key && let Some(visibility) = request.visibility {
848 active.visibility = Set(visibility);
849 }
850 active.updated_at = Set(now);
851 active.updated_by = Set(request.updated_by);
852 active.update(db).await.map_err(DbError::from)?;
853 }
854
855 find_by_key(db, request.key)
856 .await?
857 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", request.key)))
858}
859
860pub async fn delete_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
866 let existing = find_by_key(db, key)
867 .await?
868 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
869
870 if existing.source == ConfigSource::System {
871 return Err(DbError::non_retryable("cannot delete system configuration"));
872 }
873
874 Entity::delete_by_id(existing.id)
875 .exec(db)
876 .await
877 .map_err(DbError::from)?;
878 Ok(())
879}
880
881pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
891 db: &C,
892 registry: &'static ConfigRegistry,
893 key: &str,
894 value: &str,
895) -> crate::Result<bool> {
896 let def = registry
897 .get(key)
898 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
899 let now = Utc::now();
900 insert_do_nothing(
901 build_system_active_model(def, value.to_string(), now, None),
902 db,
903 "ensure_system_value_if_missing",
904 )
905 .await
906}
907
908pub async fn delete_deprecated_keys<C: ConnectionTrait>(
914 db: &C,
915 deprecated_keys: &'static [&'static str],
916) -> crate::Result<u64> {
917 if deprecated_keys.is_empty() {
918 return Ok(0);
919 }
920
921 let result = Entity::delete_many()
922 .filter(Column::Key.is_in(deprecated_keys.iter().copied()))
923 .exec(db)
924 .await
925 .map_err(DbError::from)?;
926
927 if result.rows_affected > 0 {
928 tracing::info!(
929 count = result.rows_affected,
930 keys = ?deprecated_keys,
931 "deleted deprecated system config keys"
932 );
933 }
934
935 Ok(result.rows_affected)
936}
937
938pub async fn ensure_defaults<C: ConnectionTrait>(
944 db: &C,
945 registry: &'static ConfigRegistry,
946 deprecated_keys: &'static [&'static str],
947) -> crate::Result<usize> {
948 let mut count = 0;
949
950 delete_deprecated_keys(db, deprecated_keys).await?;
951
952 for seed in registry
953 .default_seed_records()
954 .map_err(DbError::non_retryable)?
955 {
956 let now = Utc::now();
957 let key = seed.key.clone();
958 let inserted = insert_do_nothing(
959 build_system_active_model_from_seed(seed, now, None),
960 db,
961 "ensure_defaults",
962 )
963 .await?;
964
965 if inserted {
966 count += 1;
967 continue;
968 }
969
970 let def = registry.require(&key).map_err(DbError::non_retryable)?;
971 let existing = find_by_key(db, def.key)
972 .await?
973 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", def.key)))?;
974 let mut active: ActiveModel = existing.into();
975 active.source = Set(ConfigSource::System);
976 active.value_type = Set(def.value_type);
977 active.requires_restart = Set(def.requires_restart);
978 active.is_sensitive = Set(def.is_sensitive);
979 active.visibility = Set(def.visibility);
980 active.category = Set(def.category.to_string());
981 active.description = Set(def.description.to_string());
982 active.update(db).await.map_err(DbError::from)?;
983 }
984
985 if count > 0 {
986 tracing::info!("initialized {count} default configuration items");
987 }
988
989 Ok(count)
990}
991
992async fn insert_do_nothing<C: ConnectionTrait>(
993 active: ActiveModel,
994 db: &C,
995 operation: &'static str,
996) -> crate::Result<bool> {
997 match Entity::insert(active)
998 .on_conflict_do_nothing_on([Column::Key])
999 .exec(db)
1000 .await
1001 .map_err(DbError::from)?
1002 {
1003 TryInsertResult::Inserted(_) => Ok(true),
1004 TryInsertResult::Conflicted => Ok(false),
1005 TryInsertResult::Empty => Err(DbError::database_operation(format!(
1006 "{operation} produced empty insert result"
1007 ))),
1008 }
1009}
1010
1011fn build_system_active_model(
1012 def: &ConfigDefinition,
1013 value: String,
1014 now: DateTime<Utc>,
1015 updated_by: Option<i64>,
1016) -> ActiveModel {
1017 ActiveModel {
1018 key: Set(def.key.to_string()),
1019 value: Set(value),
1020 value_type: Set(def.value_type),
1021 requires_restart: Set(def.requires_restart),
1022 is_sensitive: Set(def.is_sensitive),
1023 source: Set(ConfigSource::System),
1024 visibility: Set(def.visibility),
1025 namespace: Set(String::new()),
1026 category: Set(def.category.to_string()),
1027 description: Set(def.description.to_string()),
1028 updated_at: Set(now),
1029 updated_by: Set(updated_by),
1030 ..Default::default()
1031 }
1032}
1033
1034fn build_system_active_model_from_seed(
1035 seed: ConfigSeedRecord,
1036 now: DateTime<Utc>,
1037 updated_by: Option<i64>,
1038) -> ActiveModel {
1039 ActiveModel {
1040 key: Set(seed.key),
1041 value: Set(seed.value),
1042 value_type: Set(seed.value_type),
1043 requires_restart: Set(seed.requires_restart),
1044 is_sensitive: Set(seed.is_sensitive),
1045 source: Set(seed.source),
1046 visibility: Set(seed.visibility),
1047 namespace: Set(String::new()),
1048 category: Set(seed.category),
1049 description: Set(seed.description),
1050 updated_at: Set(now),
1051 updated_by: Set(updated_by),
1052 ..Default::default()
1053 }
1054}
1055
1056fn build_custom_active_model(
1057 key: &str,
1058 value: String,
1059 visibility: ConfigVisibility,
1060 now: DateTime<Utc>,
1061 updated_by: Option<i64>,
1062) -> ActiveModel {
1063 ActiveModel {
1064 key: Set(key.to_string()),
1065 value: Set(value),
1066 value_type: Set(ConfigValueType::String),
1067 requires_restart: Set(false),
1068 is_sensitive: Set(false),
1069 source: Set(ConfigSource::Custom),
1070 visibility: Set(visibility),
1071 namespace: Set(String::new()),
1072 category: Set(String::new()),
1073 description: Set(String::new()),
1074 updated_at: Set(now),
1075 updated_by: Set(updated_by),
1076 ..Default::default()
1077 }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::{
1083 ActiveModel, Column, Entity, Model, SystemConfigDbBinding, SystemConfigDbStore,
1084 SystemConfigUpsert, create_system_config_key_unique_index, create_system_config_table,
1085 present_system_config,
1086 };
1087 use aster_forge_config::{
1088 ConfigDefinition, ConfigRegistry, ConfigSource, ConfigValue, ConfigValueType,
1089 ConfigVisibility, config_value_audit_string,
1090 };
1091 use chrono::Utc;
1092 use sea_orm::sea_query::{MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder};
1093 use sea_orm::{
1094 ActiveModelTrait, ColumnTrait, ConnectionTrait, Database, DatabaseBackend, EntityTrait,
1095 PaginatorTrait, QueryFilter, Set,
1096 };
1097
1098 const PRIMARY_KEY: &str = "primary_key";
1099 const ARRAY_KEY: &str = "array_key";
1100 const DEPRECATED_KEY: &str = "deprecated_key";
1101
1102 fn primary_default() -> String {
1103 "primary default".to_string()
1104 }
1105
1106 fn array_default() -> String {
1107 "[\"https://example.com\"]".to_string()
1108 }
1109
1110 const PRIMARY: ConfigDefinition = ConfigDefinition {
1111 key: PRIMARY_KEY,
1112 default_fn: primary_default,
1113 value_type: ConfigValueType::String,
1114 category: "site.branding",
1115 description: "Primary config",
1116 ..ConfigDefinition::private_system()
1117 };
1118
1119 const ARRAY: ConfigDefinition = ConfigDefinition {
1120 key: ARRAY_KEY,
1121 default_fn: array_default,
1122 value_type: ConfigValueType::StringArray,
1123 category: "site.public",
1124 description: "Array config",
1125 visibility: ConfigVisibility::Public,
1126 ..ConfigDefinition::private_system()
1127 };
1128
1129 const DUPLICATE: ConfigDefinition = ConfigDefinition {
1130 key: PRIMARY_KEY,
1131 ..PRIMARY
1132 };
1133
1134 static REGISTRY: ConfigRegistry = ConfigRegistry::new(&[PRIMARY, ARRAY]);
1135 static DUPLICATE_REGISTRY: ConfigRegistry = ConfigRegistry::new(&[PRIMARY, DUPLICATE]);
1136 static DEPRECATED: &[&str] = &[DEPRECATED_KEY];
1137 static BINDING: SystemConfigDbBinding = SystemConfigDbBinding::new(®ISTRY, DEPRECATED);
1138
1139 async fn sqlite_store() -> SystemConfigDbStore {
1140 let db = Database::connect("sqlite::memory:")
1141 .await
1142 .expect("sqlite memory database should connect");
1143 let backend = db.get_database_backend();
1144 db.execute(&create_system_config_table(backend))
1145 .await
1146 .expect("system_config table builder should execute");
1147 db.execute(&create_system_config_key_unique_index())
1148 .await
1149 .expect("system_config key index builder should execute");
1150 SystemConfigDbStore::new(db, ®ISTRY, DEPRECATED)
1151 }
1152
1153 async fn sqlite_db_from_builders() -> sea_orm::DatabaseConnection {
1154 let db = Database::connect("sqlite::memory:")
1155 .await
1156 .expect("sqlite memory database should connect");
1157 let backend = db.get_database_backend();
1158 db.execute(&create_system_config_table(backend))
1159 .await
1160 .expect("system_config table builder should execute");
1161 db.execute(&create_system_config_key_unique_index())
1162 .await
1163 .expect("system_config key index builder should execute");
1164 db
1165 }
1166
1167 fn create_table_sql(backend: DatabaseBackend) -> String {
1168 let table = create_system_config_table(backend);
1169 match backend {
1170 DatabaseBackend::MySql => table.to_string(MysqlQueryBuilder),
1171 DatabaseBackend::Postgres => table.to_string(PostgresQueryBuilder),
1172 DatabaseBackend::Sqlite => table.to_string(SqliteQueryBuilder),
1173 _ => unreachable!("unsupported backend in system config table test"),
1174 }
1175 }
1176
1177 #[test]
1178 fn create_system_config_table_uses_stable_shape() {
1179 let sqlite_sql = create_table_sql(DatabaseBackend::Sqlite);
1180 assert!(sqlite_sql.contains("CREATE TABLE IF NOT EXISTS \"system_config\""));
1181 assert!(sqlite_sql.contains("\"key\" varchar(128) NOT NULL"));
1182 assert!(sqlite_sql.contains("\"value_type\" varchar(32) NOT NULL DEFAULT 'string'"));
1183 assert!(sqlite_sql.contains("\"source\" varchar(16) NOT NULL DEFAULT 'system'"));
1184 assert!(sqlite_sql.contains("\"visibility\" varchar(16) NOT NULL DEFAULT 'private'"));
1185 assert!(sqlite_sql.contains("\"category\" varchar(64) NOT NULL"));
1186 assert!(sqlite_sql.contains("\"description\" varchar(512) NOT NULL"));
1187 assert!(sqlite_sql.contains("\"updated_at\" timestamp_with_timezone_text NOT NULL"));
1188
1189 let key_index = create_system_config_key_unique_index().to_string(SqliteQueryBuilder);
1190 assert!(key_index.contains("idx_system_config_key_unique"));
1191 assert!(key_index.contains("\"key\""));
1192
1193 let mysql_sql = create_table_sql(DatabaseBackend::MySql);
1194 assert!(mysql_sql.contains("`updated_at` datetime(6) NOT NULL"));
1195
1196 let postgres_sql = create_table_sql(DatabaseBackend::Postgres);
1197 assert!(postgres_sql.contains("\"updated_at\" timestamp with time zone NOT NULL"));
1198 }
1199
1200 #[tokio::test]
1201 async fn ensure_defaults_inserts_once_and_repairs_metadata() {
1202 let store = sqlite_store().await;
1203
1204 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1205 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1206
1207 let mut active: ActiveModel = store
1208 .find_by_key(PRIMARY_KEY)
1209 .await
1210 .unwrap()
1211 .unwrap()
1212 .into();
1213 active.source = Set(ConfigSource::Custom);
1214 active.value_type = Set(ConfigValueType::Number);
1215 active.requires_restart = Set(true);
1216 active.is_sensitive = Set(true);
1217 active.visibility = Set(ConfigVisibility::Authenticated);
1218 active.category = Set("wrong".to_string());
1219 active.description = Set("wrong".to_string());
1220 active.update(&store.db).await.unwrap();
1221
1222 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1223 let repaired = store.find_by_key(PRIMARY_KEY).await.unwrap().unwrap();
1224 assert_eq!(repaired.source, ConfigSource::System);
1225 assert_eq!(repaired.value_type, ConfigValueType::String);
1226 assert!(!repaired.requires_restart);
1227 assert!(!repaired.is_sensitive);
1228 assert_eq!(repaired.visibility, ConfigVisibility::Private);
1229 assert_eq!(repaired.category, "site.branding");
1230 assert_eq!(repaired.description, "Primary config");
1231 }
1232
1233 #[tokio::test]
1234 async fn ensure_defaults_rejects_duplicate_registry_without_inserting_rows() {
1235 let db = sqlite_db_from_builders().await;
1236
1237 assert!(
1238 super::ensure_defaults(&db, &DUPLICATE_REGISTRY, &[])
1239 .await
1240 .is_err()
1241 );
1242 assert_eq!(Entity::find().count(&db).await.unwrap(), 0);
1243 }
1244
1245 #[tokio::test]
1246 async fn system_config_unique_index_rejects_duplicate_keys() {
1247 let db = sqlite_db_from_builders().await;
1248 let now = Utc::now();
1249
1250 Entity::insert(super::build_system_active_model(
1251 &PRIMARY,
1252 "first".to_string(),
1253 now,
1254 None,
1255 ))
1256 .exec(&db)
1257 .await
1258 .unwrap();
1259
1260 let duplicate = Entity::insert(super::build_system_active_model(
1261 &PRIMARY,
1262 "second".to_string(),
1263 now,
1264 None,
1265 ))
1266 .exec(&db)
1267 .await;
1268 assert!(duplicate.is_err());
1269 assert_eq!(Entity::find().count(&db).await.unwrap(), 1);
1270 }
1271
1272 #[tokio::test]
1273 async fn ensure_defaults_deletes_deprecated_keys() {
1274 let store = sqlite_store().await;
1275
1276 store
1277 .upsert_prevalidated(SystemConfigUpsert {
1278 key: DEPRECATED_KEY,
1279 value: "old",
1280 visibility: None,
1281 updated_by: None,
1282 })
1283 .await
1284 .unwrap();
1285
1286 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1287 assert!(store.find_by_key(DEPRECATED_KEY).await.unwrap().is_none());
1288 assert_eq!(store.delete_deprecated_keys().await.unwrap(), 0);
1289 }
1290
1291 #[tokio::test]
1292 async fn binding_uses_product_registry_and_deprecated_keys() {
1293 let db = sqlite_db_from_builders().await;
1294
1295 BINDING
1296 .upsert_prevalidated(
1297 &db,
1298 SystemConfigUpsert {
1299 key: DEPRECATED_KEY,
1300 value: "old",
1301 visibility: None,
1302 updated_by: None,
1303 },
1304 )
1305 .await
1306 .unwrap();
1307
1308 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1309 assert!(
1310 BINDING
1311 .find_by_key(&db, DEPRECATED_KEY)
1312 .await
1313 .unwrap()
1314 .is_none()
1315 );
1316 assert_eq!(BINDING.delete_deprecated_keys(&db).await.unwrap(), 0);
1317
1318 let known = BINDING
1319 .find_by_key(&db, PRIMARY_KEY)
1320 .await
1321 .unwrap()
1322 .unwrap();
1323 assert_eq!(known.source, ConfigSource::System);
1324 assert_eq!(known.value, "primary default");
1325 }
1326
1327 #[tokio::test]
1328 async fn product_integration_flow_uses_builders_store_and_presentation_helpers() {
1329 let db = sqlite_db_from_builders().await;
1330
1331 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1332 let system = BINDING
1333 .upsert_prevalidated(
1334 &db,
1335 SystemConfigUpsert {
1336 key: PRIMARY_KEY,
1337 value: "operator title",
1338 visibility: None,
1339 updated_by: Some(42),
1340 },
1341 )
1342 .await
1343 .unwrap();
1344 assert_eq!(system.source, ConfigSource::System);
1345 assert_eq!(system.updated_by, Some(42));
1346
1347 let custom = BINDING
1348 .upsert_prevalidated(
1349 &db,
1350 SystemConfigUpsert {
1351 key: "custom.banner",
1352 value: "hello",
1353 visibility: Some(ConfigVisibility::Public),
1354 updated_by: Some(7),
1355 },
1356 )
1357 .await
1358 .unwrap();
1359 assert_eq!(custom.source, ConfigSource::Custom);
1360
1361 let visible = BINDING.find_visible_custom(&db, true).await.unwrap();
1362 assert_eq!(
1363 visible
1364 .iter()
1365 .map(|config| config.key.as_str())
1366 .collect::<Vec<_>>(),
1367 vec!["custom.banner"]
1368 );
1369
1370 let presented = present_system_config(custom, |_| {
1371 unreachable!("valid scalar config should not report invalid storage")
1372 });
1373 assert_eq!(presented.value, ConfigValue::String("hello".to_string()));
1374
1375 let sensitive = Model {
1376 is_sensitive: true,
1377 value: "secret".to_string(),
1378 ..system
1379 };
1380 let presented_sensitive = present_system_config(sensitive.clone(), |_| {
1381 unreachable!("sensitive config should not parse storage")
1382 });
1383 assert_eq!(presented_sensitive.value, ConfigValue::redacted());
1384
1385 let audit_value = config_value_audit_string(
1386 sensitive.value_type,
1387 sensitive.value,
1388 sensitive.is_sensitive,
1389 |_| unreachable!("sensitive config should not parse storage"),
1390 );
1391 assert_eq!(audit_value, ConfigValue::REDACTED);
1392 }
1393
1394 #[tokio::test]
1395 async fn upsert_system_and_custom_config_preserves_metadata() {
1396 let store = sqlite_store().await;
1397
1398 let system = store
1399 .upsert_prevalidated(SystemConfigUpsert {
1400 key: PRIMARY_KEY,
1401 value: "Custom Title",
1402 visibility: None,
1403 updated_by: Some(42),
1404 })
1405 .await
1406 .unwrap();
1407 assert_eq!(system.value, "Custom Title");
1408 assert_eq!(system.updated_by, Some(42));
1409 assert_eq!(system.source, ConfigSource::System);
1410 assert_eq!(system.visibility, ConfigVisibility::Private);
1411 assert_eq!(system.value_type, ConfigValueType::String);
1412
1413 let custom = store
1414 .upsert_prevalidated(SystemConfigUpsert {
1415 key: "custom_public_banner",
1416 value: "hello",
1417 visibility: Some(ConfigVisibility::Public),
1418 updated_by: Some(7),
1419 })
1420 .await
1421 .unwrap();
1422 assert_eq!(custom.source, ConfigSource::Custom);
1423 assert_eq!(custom.visibility, ConfigVisibility::Public);
1424 assert_eq!(custom.value_type, ConfigValueType::String);
1425 assert_eq!(custom.updated_by, Some(7));
1426
1427 let updated_custom = store
1428 .upsert_prevalidated(SystemConfigUpsert {
1429 key: "custom_public_banner",
1430 value: "hello again",
1431 visibility: Some(ConfigVisibility::Authenticated),
1432 updated_by: None,
1433 })
1434 .await
1435 .unwrap();
1436 assert_eq!(updated_custom.id, custom.id);
1437 assert_eq!(updated_custom.value, "hello again");
1438 assert_eq!(updated_custom.visibility, ConfigVisibility::Authenticated);
1439 assert_eq!(updated_custom.updated_by, None);
1440 }
1441
1442 #[tokio::test]
1443 async fn find_visible_custom_filters_visibility_and_orders_by_key() {
1444 let store = sqlite_store().await;
1445 store.ensure_defaults().await.unwrap();
1446 for (key, visibility) in [
1447 ("visible_public", ConfigVisibility::Public),
1448 ("visible_authenticated", ConfigVisibility::Authenticated),
1449 ("visible_private", ConfigVisibility::Private),
1450 ] {
1451 store
1452 .upsert_prevalidated(SystemConfigUpsert {
1453 key,
1454 value: key,
1455 visibility: Some(visibility),
1456 updated_by: None,
1457 })
1458 .await
1459 .unwrap();
1460 }
1461
1462 let public_only = store.find_visible_custom(false).await.unwrap();
1463 assert_eq!(
1464 public_only
1465 .iter()
1466 .map(|config| config.key.as_str())
1467 .collect::<Vec<_>>(),
1468 vec!["visible_public"]
1469 );
1470
1471 let public_and_authenticated = store.find_visible_custom(true).await.unwrap();
1472 assert_eq!(
1473 public_and_authenticated
1474 .iter()
1475 .map(|config| config.key.as_str())
1476 .collect::<Vec<_>>(),
1477 vec!["visible_authenticated", "visible_public"]
1478 );
1479 }
1480
1481 #[tokio::test]
1482 async fn delete_rejects_system_config_and_removes_custom_config() {
1483 let store = sqlite_store().await;
1484 store.ensure_defaults().await.unwrap();
1485 store
1486 .upsert_prevalidated(SystemConfigUpsert {
1487 key: "custom_delete_me",
1488 value: "value",
1489 visibility: None,
1490 updated_by: None,
1491 })
1492 .await
1493 .unwrap();
1494
1495 let system_error = store.delete_by_key(PRIMARY_KEY).await.unwrap_err();
1496 assert!(
1497 system_error
1498 .to_string()
1499 .contains("cannot delete system configuration")
1500 );
1501
1502 store.delete_by_key("custom_delete_me").await.unwrap();
1503 assert!(
1504 store
1505 .find_by_key("custom_delete_me")
1506 .await
1507 .unwrap()
1508 .is_none()
1509 );
1510
1511 let missing_error = store.delete_by_key("missing_custom").await.unwrap_err();
1512 assert!(missing_error.to_string().contains("missing_custom"));
1513 }
1514
1515 #[tokio::test]
1516 async fn find_cursor_and_lock_by_key_follow_repository_contract() {
1517 let store = sqlite_store().await;
1518 store.ensure_defaults().await.unwrap();
1519
1520 let all = store.find_all().await.unwrap();
1521 assert_eq!(all.len(), 2);
1522
1523 let page = store.find_cursor(1, Some(all[0].id)).await.unwrap();
1524 assert_eq!(page.total, 2);
1525 assert!(!page.has_more);
1526 assert_eq!(page.items.len(), 1);
1527 assert_eq!(page.items[0].id, all[1].id);
1528
1529 store.lock_by_key(PRIMARY_KEY).await.unwrap();
1530 let missing = store.lock_by_key("missing_lock_key").await.unwrap_err();
1531 assert!(missing.to_string().contains("missing_lock_key"));
1532 }
1533
1534 #[tokio::test]
1535 async fn ensure_system_value_if_missing_inserts_known_keys_only() {
1536 let store = sqlite_store().await;
1537
1538 assert!(
1539 store
1540 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://example.com"]"#)
1541 .await
1542 .unwrap()
1543 );
1544 assert!(
1545 !store
1546 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://ignored.com"]"#)
1547 .await
1548 .unwrap()
1549 );
1550 let stored = store.find_by_key(ARRAY_KEY).await.unwrap().unwrap();
1551 assert_eq!(stored.value, r#"["https://example.com"]"#);
1552 assert_eq!(stored.value_type, ConfigValueType::StringArray);
1553
1554 let unknown = store
1555 .ensure_system_value_if_missing("unknown_config_key", "value")
1556 .await
1557 .unwrap_err();
1558 assert!(unknown.to_string().contains("unknown_config_key"));
1559 }
1560
1561 #[tokio::test]
1562 async fn free_functions_work_with_any_connection() {
1563 let store = sqlite_store().await;
1564 super::upsert_prevalidated(
1565 &store.db,
1566 ®ISTRY,
1567 SystemConfigUpsert {
1568 key: PRIMARY_KEY,
1569 value: "direct",
1570 visibility: None,
1571 updated_by: None,
1572 },
1573 )
1574 .await
1575 .unwrap();
1576
1577 let stored = Entity::find()
1578 .filter(Column::Key.eq(PRIMARY_KEY))
1579 .one(&store.db)
1580 .await
1581 .unwrap()
1582 .unwrap();
1583 assert_eq!(stored.value, "direct");
1584 assert!(stored.updated_at <= Utc::now());
1585 }
1586}