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