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
58pub fn create_system_config_table(backend: DatabaseBackend) -> TableCreateStatement {
60 Table::create()
61 .table(system_config_table())
62 .if_not_exists()
63 .col(
64 ColumnDef::new(system_config_id())
65 .big_integer()
66 .not_null()
67 .auto_increment()
68 .primary_key(),
69 )
70 .col(
71 ColumnDef::new(system_config_key())
72 .string_len(128)
73 .not_null(),
74 )
75 .col(ColumnDef::new(system_config_value()).text().not_null())
76 .col(
77 ColumnDef::new(system_config_value_type())
78 .string_len(32)
79 .not_null()
80 .default(ConfigValueType::String.as_str()),
81 )
82 .col(
83 ColumnDef::new(system_config_requires_restart())
84 .boolean()
85 .not_null()
86 .default(false),
87 )
88 .col(
89 ColumnDef::new(system_config_is_sensitive())
90 .boolean()
91 .not_null()
92 .default(false),
93 )
94 .col(
95 ColumnDef::new(system_config_source())
96 .string_len(16)
97 .not_null()
98 .default(ConfigSource::System.as_str()),
99 )
100 .col(
101 ColumnDef::new(system_config_visibility())
102 .string_len(16)
103 .not_null()
104 .default(ConfigVisibility::Private.as_str()),
105 )
106 .col(
107 ColumnDef::new(system_config_namespace())
108 .string_len(64)
109 .not_null()
110 .default(""),
111 )
112 .col(
113 ColumnDef::new(system_config_category())
114 .string_len(64)
115 .not_null(),
116 )
117 .col(
118 ColumnDef::new(system_config_description())
119 .string_len(512)
120 .not_null(),
121 )
122 .col(utc_datetime_column(backend, system_config_updated_at()).not_null())
123 .col(
124 ColumnDef::new(system_config_updated_by())
125 .big_integer()
126 .null(),
127 )
128 .to_owned()
129}
130
131pub fn drop_system_config_table() -> TableDropStatement {
133 Table::drop()
134 .table(system_config_table())
135 .if_exists()
136 .to_owned()
137}
138
139pub fn create_system_config_key_unique_index() -> IndexCreateStatement {
141 Index::create()
142 .name(SYSTEM_CONFIG_KEY_UNIQUE_INDEX)
143 .table(system_config_table())
144 .col(system_config_key())
145 .unique()
146 .if_not_exists()
147 .to_owned()
148}
149
150fn system_config_table() -> Alias {
151 Alias::new(SYSTEM_CONFIG_TABLE)
152}
153
154fn system_config_id() -> Alias {
155 Alias::new(SYSTEM_CONFIG_ID_COLUMN)
156}
157
158fn system_config_key() -> Alias {
159 Alias::new(SYSTEM_CONFIG_KEY_COLUMN)
160}
161
162fn system_config_value() -> Alias {
163 Alias::new(SYSTEM_CONFIG_VALUE_COLUMN)
164}
165
166fn system_config_value_type() -> Alias {
167 Alias::new(SYSTEM_CONFIG_VALUE_TYPE_COLUMN)
168}
169
170fn system_config_requires_restart() -> Alias {
171 Alias::new(SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN)
172}
173
174fn system_config_is_sensitive() -> Alias {
175 Alias::new(SYSTEM_CONFIG_IS_SENSITIVE_COLUMN)
176}
177
178fn system_config_source() -> Alias {
179 Alias::new(SYSTEM_CONFIG_SOURCE_COLUMN)
180}
181
182fn system_config_visibility() -> Alias {
183 Alias::new(SYSTEM_CONFIG_VISIBILITY_COLUMN)
184}
185
186fn system_config_namespace() -> Alias {
187 Alias::new(SYSTEM_CONFIG_NAMESPACE_COLUMN)
188}
189
190fn system_config_category() -> Alias {
191 Alias::new(SYSTEM_CONFIG_CATEGORY_COLUMN)
192}
193
194fn system_config_description() -> Alias {
195 Alias::new(SYSTEM_CONFIG_DESCRIPTION_COLUMN)
196}
197
198fn system_config_updated_at() -> Alias {
199 Alias::new(SYSTEM_CONFIG_UPDATED_AT_COLUMN)
200}
201
202fn system_config_updated_by() -> Alias {
203 Alias::new(SYSTEM_CONFIG_UPDATED_BY_COLUMN)
204}
205
206fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
207 let mut definition = ColumnDef::new(column);
208 match backend {
209 DatabaseBackend::MySql => {
210 definition.custom(Alias::new("datetime(6)"));
211 }
212 _ => {
213 definition.timestamp_with_time_zone();
214 }
215 }
216 definition
217}
218
219#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
221#[sea_orm(table_name = "system_config")]
222pub struct Model {
223 #[sea_orm(primary_key)]
225 pub id: i64,
226 #[sea_orm(unique)]
228 pub key: String,
229 pub value: String,
231 pub value_type: ConfigValueType,
233 pub requires_restart: bool,
235 pub is_sensitive: bool,
237 pub source: ConfigSource,
239 pub visibility: ConfigVisibility,
241 pub namespace: String,
243 pub category: String,
245 pub description: String,
247 pub updated_at: DateTimeUtc,
249 pub updated_by: Option<i64>,
251}
252
253#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
254pub enum Relation {}
255
256impl ActiveModelBehavior for ActiveModel {}
257
258impl RuntimeConfigRecord for Model {
259 fn config_key(&self) -> &str {
260 &self.key
261 }
262
263 fn config_value(&self) -> &str {
264 &self.value
265 }
266
267 fn config_requires_restart(&self) -> bool {
268 self.requires_restart
269 }
270}
271
272#[derive(Clone, Debug, PartialEq)]
278pub struct PresentedSystemConfig {
279 pub id: i64,
281 pub key: String,
283 pub value: ConfigValue,
285 pub value_type: ConfigValueType,
287 pub requires_restart: bool,
289 pub is_sensitive: bool,
291 pub source: ConfigSource,
293 pub visibility: ConfigVisibility,
295 pub namespace: String,
297 pub category: String,
299 pub description: String,
301 pub updated_at: DateTimeUtc,
303 pub updated_by: Option<i64>,
305}
306
307impl PresentedSystemConfig {
308 pub fn from_model(
310 model: Model,
311 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
312 ) -> Self {
313 let value = present_config_value(
314 model.value_type,
315 model.value,
316 model.is_sensitive,
317 on_invalid,
318 );
319 Self {
320 id: model.id,
321 key: model.key,
322 value,
323 value_type: model.value_type,
324 requires_restart: model.requires_restart,
325 is_sensitive: model.is_sensitive,
326 source: model.source,
327 visibility: model.visibility,
328 namespace: model.namespace,
329 category: model.category,
330 description: model.description,
331 updated_at: model.updated_at,
332 updated_by: model.updated_by,
333 }
334 }
335}
336
337pub fn present_system_config(
339 model: Model,
340 on_invalid: impl FnOnce(&aster_forge_config::ConfigCoreError),
341) -> PresentedSystemConfig {
342 PresentedSystemConfig::from_model(model, on_invalid)
343}
344
345#[derive(Debug, Clone, PartialEq)]
347pub struct SystemConfigCursorSlice {
348 pub items: Vec<Model>,
350 pub total: u64,
352 pub has_more: bool,
354}
355
356impl SystemConfigCursorSlice {
357 fn empty(total: u64) -> Self {
358 Self {
359 items: Vec::new(),
360 total,
361 has_more: false,
362 }
363 }
364
365 fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
366 let item_count = u64::try_from(items.len())
367 .map_err(|_| DbError::non_retryable("system config page item count is too large"))?;
368 let has_more = item_count > limit;
369 if has_more {
370 let limit = usize::try_from(limit)
371 .map_err(|_| DbError::non_retryable("system config page limit is too large"))?;
372 items.truncate(limit);
373 }
374 Ok(Self {
375 items,
376 total,
377 has_more,
378 })
379 }
380}
381
382#[derive(Clone, Copy)]
388pub struct SystemConfigDbBinding {
389 registry: &'static ConfigRegistry,
390 deprecated_keys: &'static [&'static str],
391}
392
393impl SystemConfigDbBinding {
394 pub const fn new(
396 registry: &'static ConfigRegistry,
397 deprecated_keys: &'static [&'static str],
398 ) -> Self {
399 Self {
400 registry,
401 deprecated_keys,
402 }
403 }
404
405 pub async fn find_all<C: ConnectionTrait>(&self, db: &C) -> crate::Result<Vec<Model>> {
407 find_all(db).await
408 }
409
410 pub async fn find_cursor<C: ConnectionTrait>(
412 &self,
413 db: &C,
414 limit: u64,
415 after_id: Option<i64>,
416 ) -> crate::Result<SystemConfigCursorSlice> {
417 find_cursor(db, limit, after_id).await
418 }
419
420 pub async fn find_by_key<C: ConnectionTrait>(
422 &self,
423 db: &C,
424 key: &str,
425 ) -> crate::Result<Option<Model>> {
426 find_by_key(db, key).await
427 }
428
429 pub async fn find_visible_custom<C: ConnectionTrait>(
431 &self,
432 db: &C,
433 include_authenticated: bool,
434 ) -> crate::Result<Vec<Model>> {
435 find_visible_custom(db, include_authenticated).await
436 }
437
438 pub async fn lock_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
440 lock_by_key(db, key).await
441 }
442
443 pub async fn upsert<C: ConnectionTrait>(
445 &self,
446 db: &C,
447 request: SystemConfigUpsert<'_>,
448 ) -> crate::Result<Model> {
449 upsert(db, self.registry, request).await
450 }
451
452 pub async fn delete_by_key<C: ConnectionTrait>(&self, db: &C, key: &str) -> crate::Result<()> {
454 delete_by_key(db, key).await
455 }
456
457 pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
459 &self,
460 db: &C,
461 key: &str,
462 value: &str,
463 ) -> crate::Result<bool> {
464 ensure_system_value_if_missing(db, self.registry, key, value).await
465 }
466
467 pub async fn delete_deprecated_keys<C: ConnectionTrait>(&self, db: &C) -> crate::Result<u64> {
469 delete_deprecated_keys(db, self.deprecated_keys).await
470 }
471
472 pub async fn ensure_defaults<C: ConnectionTrait>(&self, db: &C) -> crate::Result<usize> {
474 ensure_defaults(db, self.registry, self.deprecated_keys).await
475 }
476}
477
478#[derive(Debug, Clone, Copy)]
480pub struct SystemConfigUpsert<'a> {
481 pub key: &'a str,
483 pub value: &'a str,
485 pub visibility: Option<ConfigVisibility>,
487 pub updated_by: Option<i64>,
489}
490
491#[derive(Clone)]
493pub struct SystemConfigDbStore {
494 db: DatabaseConnection,
495 registry: &'static ConfigRegistry,
496 deprecated_keys: &'static [&'static str],
497}
498
499impl SystemConfigDbStore {
500 pub const fn new(
502 db: DatabaseConnection,
503 registry: &'static ConfigRegistry,
504 deprecated_keys: &'static [&'static str],
505 ) -> Self {
506 Self {
507 db,
508 registry,
509 deprecated_keys,
510 }
511 }
512
513 pub async fn find_all(&self) -> crate::Result<Vec<Model>> {
515 find_all(&self.db).await
516 }
517
518 pub async fn find_cursor(
520 &self,
521 limit: u64,
522 after_id: Option<i64>,
523 ) -> crate::Result<SystemConfigCursorSlice> {
524 find_cursor(&self.db, limit, after_id).await
525 }
526
527 pub async fn find_by_key(&self, key: &str) -> crate::Result<Option<Model>> {
529 find_by_key(&self.db, key).await
530 }
531
532 pub async fn find_visible_custom(
534 &self,
535 include_authenticated: bool,
536 ) -> crate::Result<Vec<Model>> {
537 find_visible_custom(&self.db, include_authenticated).await
538 }
539
540 pub async fn lock_by_key(&self, key: &str) -> crate::Result<()> {
542 lock_by_key(&self.db, key).await
543 }
544
545 pub async fn upsert(&self, request: SystemConfigUpsert<'_>) -> crate::Result<Model> {
547 upsert(&self.db, self.registry, request).await
548 }
549
550 pub async fn delete_by_key(&self, key: &str) -> crate::Result<()> {
552 delete_by_key(&self.db, key).await
553 }
554
555 pub async fn ensure_system_value_if_missing(
557 &self,
558 key: &str,
559 value: &str,
560 ) -> crate::Result<bool> {
561 ensure_system_value_if_missing(&self.db, self.registry, key, value).await
562 }
563
564 pub async fn delete_deprecated_keys(&self) -> crate::Result<u64> {
566 delete_deprecated_keys(&self.db, self.deprecated_keys).await
567 }
568
569 pub async fn ensure_defaults(&self) -> crate::Result<usize> {
571 ensure_defaults(&self.db, self.registry, self.deprecated_keys).await
572 }
573}
574
575pub async fn find_all<C: ConnectionTrait>(db: &C) -> crate::Result<Vec<Model>> {
577 Entity::find()
578 .order_by_asc(Column::Id)
579 .all(db)
580 .await
581 .map_err(DbError::from)
582}
583
584pub async fn find_cursor<C: ConnectionTrait>(
586 db: &C,
587 limit: u64,
588 after_id: Option<i64>,
589) -> crate::Result<SystemConfigCursorSlice> {
590 let limit = limit.clamp(1, 100);
591 let base = Entity::find();
592 let total = base.clone().count(db).await.map_err(DbError::from)?;
593 if total == 0 {
594 return Ok(SystemConfigCursorSlice::empty(total));
595 }
596
597 let mut query = base;
598 if let Some(after_id) = after_id {
599 query = query.filter(Column::Id.gt(after_id));
600 }
601
602 let items = query
603 .order_by_asc(Column::Id)
604 .limit(limit.saturating_add(1))
605 .all(db)
606 .await
607 .map_err(DbError::from)?;
608 SystemConfigCursorSlice::from_overfetch(items, total, limit)
609}
610
611pub async fn find_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<Option<Model>> {
613 Entity::find()
614 .filter(Column::Key.eq(key))
615 .one(db)
616 .await
617 .map_err(DbError::from)
618}
619
620pub async fn find_visible_custom<C: ConnectionTrait>(
622 db: &C,
623 include_authenticated: bool,
624) -> crate::Result<Vec<Model>> {
625 let mut visibility_filter =
626 Condition::any().add(Column::Visibility.eq(ConfigVisibility::Public));
627 if include_authenticated {
628 visibility_filter =
629 visibility_filter.add(Column::Visibility.eq(ConfigVisibility::Authenticated));
630 }
631
632 Entity::find()
633 .filter(Column::Source.eq(ConfigSource::Custom))
634 .filter(visibility_filter)
635 .order_by_asc(Column::Key)
636 .all(db)
637 .await
638 .map_err(DbError::from)
639}
640
641pub async fn lock_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
643 let query = Entity::find().filter(Column::Key.eq(key));
644 let config = match db.get_database_backend() {
645 DbBackend::Postgres | DbBackend::MySql => query
646 .lock_exclusive()
647 .one(db)
648 .await
649 .map_err(DbError::from)?,
650 _ => query.one(db).await.map_err(DbError::from)?,
651 };
652
653 config
654 .map(|_| ())
655 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))
656}
657
658pub async fn upsert<C: ConnectionTrait>(
660 db: &C,
661 registry: &'static ConfigRegistry,
662 request: SystemConfigUpsert<'_>,
663) -> crate::Result<Model> {
664 let now = Utc::now();
665 let definition = registry.get(request.key);
666 let is_custom_key = definition.is_none();
667 let active = definition
668 .map(|def| {
669 build_system_active_model(def, request.value.to_string(), now, request.updated_by)
670 })
671 .unwrap_or_else(|| {
672 build_custom_active_model(
673 request.key,
674 request.value.to_string(),
675 request.visibility.unwrap_or_default(),
676 now,
677 request.updated_by,
678 )
679 });
680 let inserted = insert_do_nothing(active, db, "system config upsert").await?;
681
682 if !inserted {
683 let existing = find_by_key(db, request.key).await?.ok_or_else(|| {
684 DbError::non_retryable(format!("config key '{}' not found", request.key))
685 })?;
686 let mut active: ActiveModel = existing.into();
687 active.value = Set(request.value.to_string());
688 if is_custom_key && let Some(visibility) = request.visibility {
689 active.visibility = Set(visibility);
690 }
691 active.updated_at = Set(now);
692 active.updated_by = Set(request.updated_by);
693 active.update(db).await.map_err(DbError::from)?;
694 }
695
696 find_by_key(db, request.key)
697 .await?
698 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", request.key)))
699}
700
701pub async fn delete_by_key<C: ConnectionTrait>(db: &C, key: &str) -> crate::Result<()> {
703 let existing = find_by_key(db, key)
704 .await?
705 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
706
707 if existing.source == ConfigSource::System {
708 return Err(DbError::non_retryable("cannot delete system configuration"));
709 }
710
711 Entity::delete_by_id(existing.id)
712 .exec(db)
713 .await
714 .map_err(DbError::from)?;
715 Ok(())
716}
717
718pub async fn ensure_system_value_if_missing<C: ConnectionTrait>(
720 db: &C,
721 registry: &'static ConfigRegistry,
722 key: &str,
723 value: &str,
724) -> crate::Result<bool> {
725 let def = registry
726 .get(key)
727 .ok_or_else(|| DbError::non_retryable(format!("config key '{key}' not found")))?;
728 let now = Utc::now();
729 insert_do_nothing(
730 build_system_active_model(def, value.to_string(), now, None),
731 db,
732 "ensure_system_value_if_missing",
733 )
734 .await
735}
736
737pub async fn delete_deprecated_keys<C: ConnectionTrait>(
739 db: &C,
740 deprecated_keys: &'static [&'static str],
741) -> crate::Result<u64> {
742 if deprecated_keys.is_empty() {
743 return Ok(0);
744 }
745
746 let result = Entity::delete_many()
747 .filter(Column::Key.is_in(deprecated_keys.iter().copied()))
748 .exec(db)
749 .await
750 .map_err(DbError::from)?;
751
752 if result.rows_affected > 0 {
753 tracing::info!(
754 count = result.rows_affected,
755 keys = ?deprecated_keys,
756 "deleted deprecated system config keys"
757 );
758 }
759
760 Ok(result.rows_affected)
761}
762
763pub async fn ensure_defaults<C: ConnectionTrait>(
765 db: &C,
766 registry: &'static ConfigRegistry,
767 deprecated_keys: &'static [&'static str],
768) -> crate::Result<usize> {
769 let mut count = 0;
770
771 delete_deprecated_keys(db, deprecated_keys).await?;
772
773 for seed in registry
774 .default_seed_records()
775 .map_err(DbError::non_retryable)?
776 {
777 let now = Utc::now();
778 let key = seed.key.clone();
779 let inserted = insert_do_nothing(
780 build_system_active_model_from_seed(seed, now, None),
781 db,
782 "ensure_defaults",
783 )
784 .await?;
785
786 if inserted {
787 count += 1;
788 continue;
789 }
790
791 let def = registry.require(&key).map_err(DbError::non_retryable)?;
792 let existing = find_by_key(db, def.key)
793 .await?
794 .ok_or_else(|| DbError::non_retryable(format!("config key '{}' not found", def.key)))?;
795 let mut active: ActiveModel = existing.into();
796 active.source = Set(ConfigSource::System);
797 active.value_type = Set(def.value_type);
798 active.requires_restart = Set(def.requires_restart);
799 active.is_sensitive = Set(def.is_sensitive);
800 active.visibility = Set(def.visibility);
801 active.category = Set(def.category.to_string());
802 active.description = Set(def.description.to_string());
803 active.update(db).await.map_err(DbError::from)?;
804 }
805
806 if count > 0 {
807 tracing::info!("initialized {count} default configuration items");
808 }
809
810 Ok(count)
811}
812
813async fn insert_do_nothing<C: ConnectionTrait>(
814 active: ActiveModel,
815 db: &C,
816 operation: &'static str,
817) -> crate::Result<bool> {
818 match Entity::insert(active)
819 .on_conflict_do_nothing_on([Column::Key])
820 .exec(db)
821 .await
822 .map_err(DbError::from)?
823 {
824 TryInsertResult::Inserted(_) => Ok(true),
825 TryInsertResult::Conflicted => Ok(false),
826 TryInsertResult::Empty => Err(DbError::database_operation(format!(
827 "{operation} produced empty insert result"
828 ))),
829 }
830}
831
832fn build_system_active_model(
833 def: &ConfigDefinition,
834 value: String,
835 now: DateTime<Utc>,
836 updated_by: Option<i64>,
837) -> ActiveModel {
838 ActiveModel {
839 key: Set(def.key.to_string()),
840 value: Set(value),
841 value_type: Set(def.value_type),
842 requires_restart: Set(def.requires_restart),
843 is_sensitive: Set(def.is_sensitive),
844 source: Set(ConfigSource::System),
845 visibility: Set(def.visibility),
846 namespace: Set(String::new()),
847 category: Set(def.category.to_string()),
848 description: Set(def.description.to_string()),
849 updated_at: Set(now),
850 updated_by: Set(updated_by),
851 ..Default::default()
852 }
853}
854
855fn build_system_active_model_from_seed(
856 seed: ConfigSeedRecord,
857 now: DateTime<Utc>,
858 updated_by: Option<i64>,
859) -> ActiveModel {
860 ActiveModel {
861 key: Set(seed.key),
862 value: Set(seed.value),
863 value_type: Set(seed.value_type),
864 requires_restart: Set(seed.requires_restart),
865 is_sensitive: Set(seed.is_sensitive),
866 source: Set(seed.source),
867 visibility: Set(seed.visibility),
868 namespace: Set(String::new()),
869 category: Set(seed.category),
870 description: Set(seed.description),
871 updated_at: Set(now),
872 updated_by: Set(updated_by),
873 ..Default::default()
874 }
875}
876
877fn build_custom_active_model(
878 key: &str,
879 value: String,
880 visibility: ConfigVisibility,
881 now: DateTime<Utc>,
882 updated_by: Option<i64>,
883) -> ActiveModel {
884 ActiveModel {
885 key: Set(key.to_string()),
886 value: Set(value),
887 value_type: Set(ConfigValueType::String),
888 requires_restart: Set(false),
889 is_sensitive: Set(false),
890 source: Set(ConfigSource::Custom),
891 visibility: Set(visibility),
892 namespace: Set(String::new()),
893 category: Set(String::new()),
894 description: Set(String::new()),
895 updated_at: Set(now),
896 updated_by: Set(updated_by),
897 ..Default::default()
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::{
904 ActiveModel, Column, Entity, Model, SystemConfigDbBinding, SystemConfigDbStore,
905 SystemConfigUpsert, create_system_config_key_unique_index, create_system_config_table,
906 present_system_config,
907 };
908 use aster_forge_config::{
909 ConfigDefinition, ConfigRegistry, ConfigSource, ConfigValue, ConfigValueType,
910 ConfigVisibility, config_value_audit_string,
911 };
912 use chrono::Utc;
913 use sea_orm::sea_query::{MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder};
914 use sea_orm::{
915 ActiveModelTrait, ColumnTrait, ConnectionTrait, Database, DatabaseBackend, EntityTrait,
916 QueryFilter, Set,
917 };
918
919 const PRIMARY_KEY: &str = "primary_key";
920 const ARRAY_KEY: &str = "array_key";
921 const DEPRECATED_KEY: &str = "deprecated_key";
922
923 fn primary_default() -> String {
924 "primary default".to_string()
925 }
926
927 fn array_default() -> String {
928 "[\"https://example.com\"]".to_string()
929 }
930
931 const PRIMARY: ConfigDefinition = ConfigDefinition {
932 key: PRIMARY_KEY,
933 default_fn: primary_default,
934 value_type: ConfigValueType::String,
935 category: "site.branding",
936 description: "Primary config",
937 ..ConfigDefinition::private_system()
938 };
939
940 const ARRAY: ConfigDefinition = ConfigDefinition {
941 key: ARRAY_KEY,
942 default_fn: array_default,
943 value_type: ConfigValueType::StringArray,
944 category: "site.public",
945 description: "Array config",
946 visibility: ConfigVisibility::Public,
947 ..ConfigDefinition::private_system()
948 };
949
950 static REGISTRY: ConfigRegistry = ConfigRegistry::new(&[PRIMARY, ARRAY]);
951 static DEPRECATED: &[&str] = &[DEPRECATED_KEY];
952 static BINDING: SystemConfigDbBinding = SystemConfigDbBinding::new(®ISTRY, DEPRECATED);
953
954 async fn sqlite_store() -> SystemConfigDbStore {
955 let db = Database::connect("sqlite::memory:")
956 .await
957 .expect("sqlite memory database should connect");
958 let backend = db.get_database_backend();
959 db.execute(&create_system_config_table(backend))
960 .await
961 .expect("system_config table builder should execute");
962 db.execute(&create_system_config_key_unique_index())
963 .await
964 .expect("system_config key index builder should execute");
965 SystemConfigDbStore::new(db, ®ISTRY, DEPRECATED)
966 }
967
968 async fn sqlite_db_from_builders() -> sea_orm::DatabaseConnection {
969 let db = Database::connect("sqlite::memory:")
970 .await
971 .expect("sqlite memory database should connect");
972 let backend = db.get_database_backend();
973 db.execute(&create_system_config_table(backend))
974 .await
975 .expect("system_config table builder should execute");
976 db.execute(&create_system_config_key_unique_index())
977 .await
978 .expect("system_config key index builder should execute");
979 db
980 }
981
982 fn create_table_sql(backend: DatabaseBackend) -> String {
983 let table = create_system_config_table(backend);
984 match backend {
985 DatabaseBackend::MySql => table.to_string(MysqlQueryBuilder),
986 DatabaseBackend::Postgres => table.to_string(PostgresQueryBuilder),
987 DatabaseBackend::Sqlite => table.to_string(SqliteQueryBuilder),
988 _ => unreachable!("unsupported backend in system config table test"),
989 }
990 }
991
992 #[test]
993 fn create_system_config_table_uses_stable_shape() {
994 let sqlite_sql = create_table_sql(DatabaseBackend::Sqlite);
995 assert!(sqlite_sql.contains("CREATE TABLE IF NOT EXISTS \"system_config\""));
996 assert!(sqlite_sql.contains("\"key\" varchar(128) NOT NULL"));
997 assert!(sqlite_sql.contains("\"value_type\" varchar(32) NOT NULL DEFAULT 'string'"));
998 assert!(sqlite_sql.contains("\"source\" varchar(16) NOT NULL DEFAULT 'system'"));
999 assert!(sqlite_sql.contains("\"visibility\" varchar(16) NOT NULL DEFAULT 'private'"));
1000 assert!(sqlite_sql.contains("\"category\" varchar(64) NOT NULL"));
1001 assert!(sqlite_sql.contains("\"description\" varchar(512) NOT NULL"));
1002 assert!(sqlite_sql.contains("\"updated_at\" timestamp_with_timezone_text NOT NULL"));
1003
1004 let key_index = create_system_config_key_unique_index().to_string(SqliteQueryBuilder);
1005 assert!(key_index.contains("idx_system_config_key_unique"));
1006 assert!(key_index.contains("\"key\""));
1007
1008 let mysql_sql = create_table_sql(DatabaseBackend::MySql);
1009 assert!(mysql_sql.contains("`updated_at` datetime(6) NOT NULL"));
1010
1011 let postgres_sql = create_table_sql(DatabaseBackend::Postgres);
1012 assert!(postgres_sql.contains("\"updated_at\" timestamp with time zone NOT NULL"));
1013 }
1014
1015 #[tokio::test]
1016 async fn ensure_defaults_inserts_once_and_repairs_metadata() {
1017 let store = sqlite_store().await;
1018
1019 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1020 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1021
1022 let mut active: ActiveModel = store
1023 .find_by_key(PRIMARY_KEY)
1024 .await
1025 .unwrap()
1026 .unwrap()
1027 .into();
1028 active.source = Set(ConfigSource::Custom);
1029 active.value_type = Set(ConfigValueType::Number);
1030 active.requires_restart = Set(true);
1031 active.is_sensitive = Set(true);
1032 active.visibility = Set(ConfigVisibility::Authenticated);
1033 active.category = Set("wrong".to_string());
1034 active.description = Set("wrong".to_string());
1035 active.update(&store.db).await.unwrap();
1036
1037 assert_eq!(store.ensure_defaults().await.unwrap(), 0);
1038 let repaired = store.find_by_key(PRIMARY_KEY).await.unwrap().unwrap();
1039 assert_eq!(repaired.source, ConfigSource::System);
1040 assert_eq!(repaired.value_type, ConfigValueType::String);
1041 assert!(!repaired.requires_restart);
1042 assert!(!repaired.is_sensitive);
1043 assert_eq!(repaired.visibility, ConfigVisibility::Private);
1044 assert_eq!(repaired.category, "site.branding");
1045 assert_eq!(repaired.description, "Primary config");
1046 }
1047
1048 #[tokio::test]
1049 async fn ensure_defaults_deletes_deprecated_keys() {
1050 let store = sqlite_store().await;
1051
1052 store
1053 .upsert(SystemConfigUpsert {
1054 key: DEPRECATED_KEY,
1055 value: "old",
1056 visibility: None,
1057 updated_by: None,
1058 })
1059 .await
1060 .unwrap();
1061
1062 assert_eq!(store.ensure_defaults().await.unwrap(), 2);
1063 assert!(store.find_by_key(DEPRECATED_KEY).await.unwrap().is_none());
1064 assert_eq!(store.delete_deprecated_keys().await.unwrap(), 0);
1065 }
1066
1067 #[tokio::test]
1068 async fn binding_uses_product_registry_and_deprecated_keys() {
1069 let db = sqlite_db_from_builders().await;
1070
1071 BINDING
1072 .upsert(
1073 &db,
1074 SystemConfigUpsert {
1075 key: DEPRECATED_KEY,
1076 value: "old",
1077 visibility: None,
1078 updated_by: None,
1079 },
1080 )
1081 .await
1082 .unwrap();
1083
1084 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1085 assert!(
1086 BINDING
1087 .find_by_key(&db, DEPRECATED_KEY)
1088 .await
1089 .unwrap()
1090 .is_none()
1091 );
1092 assert_eq!(BINDING.delete_deprecated_keys(&db).await.unwrap(), 0);
1093
1094 let known = BINDING
1095 .find_by_key(&db, PRIMARY_KEY)
1096 .await
1097 .unwrap()
1098 .unwrap();
1099 assert_eq!(known.source, ConfigSource::System);
1100 assert_eq!(known.value, "primary default");
1101 }
1102
1103 #[tokio::test]
1104 async fn product_integration_flow_uses_builders_store_and_presentation_helpers() {
1105 let db = sqlite_db_from_builders().await;
1106
1107 assert_eq!(BINDING.ensure_defaults(&db).await.unwrap(), 2);
1108 let system = BINDING
1109 .upsert(
1110 &db,
1111 SystemConfigUpsert {
1112 key: PRIMARY_KEY,
1113 value: "operator title",
1114 visibility: None,
1115 updated_by: Some(42),
1116 },
1117 )
1118 .await
1119 .unwrap();
1120 assert_eq!(system.source, ConfigSource::System);
1121 assert_eq!(system.updated_by, Some(42));
1122
1123 let custom = BINDING
1124 .upsert(
1125 &db,
1126 SystemConfigUpsert {
1127 key: "custom.banner",
1128 value: "hello",
1129 visibility: Some(ConfigVisibility::Public),
1130 updated_by: Some(7),
1131 },
1132 )
1133 .await
1134 .unwrap();
1135 assert_eq!(custom.source, ConfigSource::Custom);
1136
1137 let visible = BINDING.find_visible_custom(&db, true).await.unwrap();
1138 assert_eq!(
1139 visible
1140 .iter()
1141 .map(|config| config.key.as_str())
1142 .collect::<Vec<_>>(),
1143 vec!["custom.banner"]
1144 );
1145
1146 let presented = present_system_config(custom, |_| {
1147 unreachable!("valid scalar config should not report invalid storage")
1148 });
1149 assert_eq!(presented.value, ConfigValue::String("hello".to_string()));
1150
1151 let sensitive = Model {
1152 is_sensitive: true,
1153 value: "secret".to_string(),
1154 ..system
1155 };
1156 let presented_sensitive = present_system_config(sensitive.clone(), |_| {
1157 unreachable!("sensitive config should not parse storage")
1158 });
1159 assert_eq!(presented_sensitive.value, ConfigValue::redacted());
1160
1161 let audit_value = config_value_audit_string(
1162 sensitive.value_type,
1163 sensitive.value,
1164 sensitive.is_sensitive,
1165 |_| unreachable!("sensitive config should not parse storage"),
1166 );
1167 assert_eq!(audit_value, ConfigValue::REDACTED);
1168 }
1169
1170 #[tokio::test]
1171 async fn upsert_system_and_custom_config_preserves_metadata() {
1172 let store = sqlite_store().await;
1173
1174 let system = store
1175 .upsert(SystemConfigUpsert {
1176 key: PRIMARY_KEY,
1177 value: "Custom Title",
1178 visibility: None,
1179 updated_by: Some(42),
1180 })
1181 .await
1182 .unwrap();
1183 assert_eq!(system.value, "Custom Title");
1184 assert_eq!(system.updated_by, Some(42));
1185 assert_eq!(system.source, ConfigSource::System);
1186 assert_eq!(system.visibility, ConfigVisibility::Private);
1187 assert_eq!(system.value_type, ConfigValueType::String);
1188
1189 let custom = store
1190 .upsert(SystemConfigUpsert {
1191 key: "custom_public_banner",
1192 value: "hello",
1193 visibility: Some(ConfigVisibility::Public),
1194 updated_by: Some(7),
1195 })
1196 .await
1197 .unwrap();
1198 assert_eq!(custom.source, ConfigSource::Custom);
1199 assert_eq!(custom.visibility, ConfigVisibility::Public);
1200 assert_eq!(custom.value_type, ConfigValueType::String);
1201 assert_eq!(custom.updated_by, Some(7));
1202
1203 let updated_custom = store
1204 .upsert(SystemConfigUpsert {
1205 key: "custom_public_banner",
1206 value: "hello again",
1207 visibility: Some(ConfigVisibility::Authenticated),
1208 updated_by: None,
1209 })
1210 .await
1211 .unwrap();
1212 assert_eq!(updated_custom.id, custom.id);
1213 assert_eq!(updated_custom.value, "hello again");
1214 assert_eq!(updated_custom.visibility, ConfigVisibility::Authenticated);
1215 assert_eq!(updated_custom.updated_by, None);
1216 }
1217
1218 #[tokio::test]
1219 async fn find_visible_custom_filters_visibility_and_orders_by_key() {
1220 let store = sqlite_store().await;
1221 store.ensure_defaults().await.unwrap();
1222 for (key, visibility) in [
1223 ("visible_public", ConfigVisibility::Public),
1224 ("visible_authenticated", ConfigVisibility::Authenticated),
1225 ("visible_private", ConfigVisibility::Private),
1226 ] {
1227 store
1228 .upsert(SystemConfigUpsert {
1229 key,
1230 value: key,
1231 visibility: Some(visibility),
1232 updated_by: None,
1233 })
1234 .await
1235 .unwrap();
1236 }
1237
1238 let public_only = store.find_visible_custom(false).await.unwrap();
1239 assert_eq!(
1240 public_only
1241 .iter()
1242 .map(|config| config.key.as_str())
1243 .collect::<Vec<_>>(),
1244 vec!["visible_public"]
1245 );
1246
1247 let public_and_authenticated = store.find_visible_custom(true).await.unwrap();
1248 assert_eq!(
1249 public_and_authenticated
1250 .iter()
1251 .map(|config| config.key.as_str())
1252 .collect::<Vec<_>>(),
1253 vec!["visible_authenticated", "visible_public"]
1254 );
1255 }
1256
1257 #[tokio::test]
1258 async fn delete_rejects_system_config_and_removes_custom_config() {
1259 let store = sqlite_store().await;
1260 store.ensure_defaults().await.unwrap();
1261 store
1262 .upsert(SystemConfigUpsert {
1263 key: "custom_delete_me",
1264 value: "value",
1265 visibility: None,
1266 updated_by: None,
1267 })
1268 .await
1269 .unwrap();
1270
1271 let system_error = store.delete_by_key(PRIMARY_KEY).await.unwrap_err();
1272 assert!(
1273 system_error
1274 .to_string()
1275 .contains("cannot delete system configuration")
1276 );
1277
1278 store.delete_by_key("custom_delete_me").await.unwrap();
1279 assert!(
1280 store
1281 .find_by_key("custom_delete_me")
1282 .await
1283 .unwrap()
1284 .is_none()
1285 );
1286
1287 let missing_error = store.delete_by_key("missing_custom").await.unwrap_err();
1288 assert!(missing_error.to_string().contains("missing_custom"));
1289 }
1290
1291 #[tokio::test]
1292 async fn find_cursor_and_lock_by_key_follow_repository_contract() {
1293 let store = sqlite_store().await;
1294 store.ensure_defaults().await.unwrap();
1295
1296 let all = store.find_all().await.unwrap();
1297 assert_eq!(all.len(), 2);
1298
1299 let page = store.find_cursor(1, Some(all[0].id)).await.unwrap();
1300 assert_eq!(page.total, 2);
1301 assert!(!page.has_more);
1302 assert_eq!(page.items.len(), 1);
1303 assert_eq!(page.items[0].id, all[1].id);
1304
1305 store.lock_by_key(PRIMARY_KEY).await.unwrap();
1306 let missing = store.lock_by_key("missing_lock_key").await.unwrap_err();
1307 assert!(missing.to_string().contains("missing_lock_key"));
1308 }
1309
1310 #[tokio::test]
1311 async fn ensure_system_value_if_missing_inserts_known_keys_only() {
1312 let store = sqlite_store().await;
1313
1314 assert!(
1315 store
1316 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://example.com"]"#)
1317 .await
1318 .unwrap()
1319 );
1320 assert!(
1321 !store
1322 .ensure_system_value_if_missing(ARRAY_KEY, r#"["https://ignored.com"]"#)
1323 .await
1324 .unwrap()
1325 );
1326 let stored = store.find_by_key(ARRAY_KEY).await.unwrap().unwrap();
1327 assert_eq!(stored.value, r#"["https://example.com"]"#);
1328 assert_eq!(stored.value_type, ConfigValueType::StringArray);
1329
1330 let unknown = store
1331 .ensure_system_value_if_missing("unknown_config_key", "value")
1332 .await
1333 .unwrap_err();
1334 assert!(unknown.to_string().contains("unknown_config_key"));
1335 }
1336
1337 #[tokio::test]
1338 async fn free_functions_work_with_any_connection() {
1339 let store = sqlite_store().await;
1340 super::upsert(
1341 &store.db,
1342 ®ISTRY,
1343 SystemConfigUpsert {
1344 key: PRIMARY_KEY,
1345 value: "direct",
1346 visibility: None,
1347 updated_by: None,
1348 },
1349 )
1350 .await
1351 .unwrap();
1352
1353 let stored = Entity::find()
1354 .filter(Column::Key.eq(PRIMARY_KEY))
1355 .one(&store.db)
1356 .await
1357 .unwrap()
1358 .unwrap();
1359 assert_eq!(stored.value, "direct");
1360 assert!(stored.updated_at <= Utc::now());
1361 }
1362}