Skip to main content

aster_forge_db/
system_config.rs

1//! Database-backed runtime system configuration store.
2//!
3//! Aster products share the same `system_config` table shape and persistence
4//! rules: system definitions are seeded from a product registry, custom values
5//! are stored as scalar strings, public/authenticated custom values can be
6//! exposed to clients, and startup repairs system metadata without overwriting
7//! user-provided values. Product crates still own their configuration
8//! definitions, validation callbacks, audit records, and API presentation.
9
10use 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
27/// Shared system configuration table name.
28pub const SYSTEM_CONFIG_TABLE: &str = "system_config";
29/// Stable row id column.
30pub const SYSTEM_CONFIG_ID_COLUMN: &str = "id";
31/// Stable configuration key column.
32pub const SYSTEM_CONFIG_KEY_COLUMN: &str = "key";
33/// Storage value column.
34pub const SYSTEM_CONFIG_VALUE_COLUMN: &str = "value";
35/// Storage value type column.
36pub const SYSTEM_CONFIG_VALUE_TYPE_COLUMN: &str = "value_type";
37/// Restart-required marker column.
38pub const SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN: &str = "requires_restart";
39/// Sensitive-value marker column.
40pub const SYSTEM_CONFIG_IS_SENSITIVE_COLUMN: &str = "is_sensitive";
41/// System/custom source column.
42pub const SYSTEM_CONFIG_SOURCE_COLUMN: &str = "source";
43/// Consumer visibility column.
44pub const SYSTEM_CONFIG_VISIBILITY_COLUMN: &str = "visibility";
45/// Optional product namespace column.
46pub const SYSTEM_CONFIG_NAMESPACE_COLUMN: &str = "namespace";
47/// Product category column.
48pub const SYSTEM_CONFIG_CATEGORY_COLUMN: &str = "category";
49/// Product description column.
50pub const SYSTEM_CONFIG_DESCRIPTION_COLUMN: &str = "description";
51/// Last update timestamp column.
52pub const SYSTEM_CONFIG_UPDATED_AT_COLUMN: &str = "updated_at";
53/// Optional actor user id column.
54pub const SYSTEM_CONFIG_UPDATED_BY_COLUMN: &str = "updated_by";
55/// Unique index name for configuration keys.
56pub const SYSTEM_CONFIG_KEY_UNIQUE_INDEX: &str = "idx_system_config_key_unique";
57
58/// Builds the shared `system_config` table creation statement.
59pub 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
131/// Builds the shared `system_config` table drop statement.
132pub fn drop_system_config_table() -> TableDropStatement {
133    Table::drop()
134        .table(system_config_table())
135        .if_exists()
136        .to_owned()
137}
138
139/// Builds the unique index for stable configuration keys.
140pub 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/// Runtime system configuration SeaORM model.
220#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
221#[sea_orm(table_name = "system_config")]
222pub struct Model {
223    /// Stable row id.
224    #[sea_orm(primary_key)]
225    pub id: i64,
226    /// Stable configuration key.
227    #[sea_orm(unique)]
228    pub key: String,
229    /// Storage value. List-like values are JSON strings.
230    pub value: String,
231    /// Storage value type.
232    pub value_type: ConfigValueType,
233    /// Whether changes require process restart to take effect.
234    pub requires_restart: bool,
235    /// Whether APIs and audit logs should redact this value.
236    pub is_sensitive: bool,
237    /// System-defined or custom user-defined source.
238    pub source: ConfigSource,
239    /// Consumer visibility.
240    pub visibility: ConfigVisibility,
241    /// Optional product namespace. Existing Aster services use an empty namespace.
242    pub namespace: String,
243    /// Product category for UI grouping.
244    pub category: String,
245    /// Product description for admin UIs.
246    pub description: String,
247    /// Last update timestamp.
248    pub updated_at: DateTimeUtc,
249    /// Optional actor user id.
250    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/// API-facing representation of a stored system configuration row.
273///
274/// This keeps the product-neutral field mapping, sensitive-value redaction, and lossy historical
275/// value parsing in Forge while leaving product API envelopes, permissions, warning calculation,
276/// and OpenAPI schema ownership in each service.
277#[derive(Clone, Debug, PartialEq)]
278pub struct PresentedSystemConfig {
279    /// Stable row id.
280    pub id: i64,
281    /// Stable configuration key.
282    pub key: String,
283    /// API-facing value with sensitive rows redacted.
284    pub value: ConfigValue,
285    /// Storage value type.
286    pub value_type: ConfigValueType,
287    /// Whether changes require process restart to take effect.
288    pub requires_restart: bool,
289    /// Whether APIs and audit logs should redact this value.
290    pub is_sensitive: bool,
291    /// System-defined or custom user-defined source.
292    pub source: ConfigSource,
293    /// Consumer visibility.
294    pub visibility: ConfigVisibility,
295    /// Optional product namespace.
296    pub namespace: String,
297    /// Product category for UI grouping.
298    pub category: String,
299    /// Product description for admin UIs.
300    pub description: String,
301    /// Last update timestamp.
302    pub updated_at: DateTimeUtc,
303    /// Optional actor user id.
304    pub updated_by: Option<i64>,
305}
306
307impl PresentedSystemConfig {
308    /// Converts a stored model into an API-facing row.
309    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
337/// Converts a stored system config row into an API-facing presentation row.
338pub 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/// Page slice returned by cursor-style repository queries.
346#[derive(Debug, Clone, PartialEq)]
347pub struct SystemConfigCursorSlice {
348    /// Items to expose after overfetch trimming.
349    pub items: Vec<Model>,
350    /// Total number of matching rows.
351    pub total: u64,
352    /// Whether one more row was found beyond the requested limit.
353    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/// Product binding for the shared system config store.
383///
384/// A product normally has one static binding that supplies its registry and deprecated-key list.
385/// Repository code can then focus on product error mapping, authorization, and API cursor shape
386/// instead of repeatedly passing the same registry values into every call.
387#[derive(Clone, Copy)]
388pub struct SystemConfigDbBinding {
389    registry: &'static ConfigRegistry,
390    deprecated_keys: &'static [&'static str],
391}
392
393impl SystemConfigDbBinding {
394    /// Creates a product binding from a config registry and deprecated key list.
395    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    /// Lists all rows by stable id order.
406    pub async fn find_all<C: ConnectionTrait>(&self, db: &C) -> crate::Result<Vec<Model>> {
407        find_all(db).await
408    }
409
410    /// Lists one id-cursor page by stable id order.
411    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    /// Finds one row by key.
421    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    /// Lists visible custom rows ordered by key.
430    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    /// Locks one row by key where the database supports row locks.
439    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    /// Upserts one row using this binding's registry metadata for known system keys.
444    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    /// Deletes a custom row.
453    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    /// Inserts one system value if no row exists.
458    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    /// Deletes deprecated system keys configured by the product.
468    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    /// Ensures default rows exist and repairs metadata for existing system rows.
473    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/// Product request to upsert one system or custom configuration value.
479#[derive(Debug, Clone, Copy)]
480pub struct SystemConfigUpsert<'a> {
481    /// Config key.
482    pub key: &'a str,
483    /// New storage value.
484    pub value: &'a str,
485    /// Visibility override for custom keys. System visibility comes from the registry.
486    pub visibility: Option<ConfigVisibility>,
487    /// Optional actor user id.
488    pub updated_by: Option<i64>,
489}
490
491/// SeaORM-backed system configuration store.
492#[derive(Clone)]
493pub struct SystemConfigDbStore {
494    db: DatabaseConnection,
495    registry: &'static ConfigRegistry,
496    deprecated_keys: &'static [&'static str],
497}
498
499impl SystemConfigDbStore {
500    /// Creates a store from a database connection and product config registry.
501    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    /// Lists all rows by stable id order.
514    pub async fn find_all(&self) -> crate::Result<Vec<Model>> {
515        find_all(&self.db).await
516    }
517
518    /// Lists one id-cursor page by stable id order.
519    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    /// Finds one row by key.
528    pub async fn find_by_key(&self, key: &str) -> crate::Result<Option<Model>> {
529        find_by_key(&self.db, key).await
530    }
531
532    /// Lists visible custom rows ordered by key.
533    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    /// Locks one row by key where the database supports row locks.
541    pub async fn lock_by_key(&self, key: &str) -> crate::Result<()> {
542        lock_by_key(&self.db, key).await
543    }
544
545    /// Upserts one row using registry metadata for known system keys.
546    pub async fn upsert(&self, request: SystemConfigUpsert<'_>) -> crate::Result<Model> {
547        upsert(&self.db, self.registry, request).await
548    }
549
550    /// Deletes a custom row.
551    pub async fn delete_by_key(&self, key: &str) -> crate::Result<()> {
552        delete_by_key(&self.db, key).await
553    }
554
555    /// Inserts one system value if no row exists.
556    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    /// Deletes deprecated system keys configured by the product.
565    pub async fn delete_deprecated_keys(&self) -> crate::Result<u64> {
566        delete_deprecated_keys(&self.db, self.deprecated_keys).await
567    }
568
569    /// Ensures default rows exist and repairs metadata for existing system rows.
570    pub async fn ensure_defaults(&self) -> crate::Result<usize> {
571        ensure_defaults(&self.db, self.registry, self.deprecated_keys).await
572    }
573}
574
575/// Lists all rows by stable id order.
576pub 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
584/// Lists one id-cursor page by stable id order.
585pub 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
611/// Finds one row by key.
612pub 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
620/// Lists visible custom rows ordered by key.
621pub 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
641/// Locks one row by key where the database supports row locks.
642pub 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
658/// Upserts one row using registry metadata for known system keys.
659pub 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
701/// Deletes a custom row.
702pub 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
718/// Inserts one system value if no row exists.
719pub 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
737/// Deletes deprecated system keys configured by the product.
738pub 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
763/// Ensures default rows exist and repairs metadata for existing system rows.
764pub 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(&REGISTRY, 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, &REGISTRY, 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            &REGISTRY,
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}