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