Skip to main content

autumn_admin_plugin/
experiments.rs

1//! Admin panel model for `autumn_experiments`.
2//!
3//! Registers an experiment management page at `/admin/experiments/` with:
4//! - List view: name, state, variants, winner
5//! - Edit view: update description, exclusion group, state, variants
6//! - History tab: per-experiment audit trail from `autumn_experiment_changes`
7
8use serde_json::Value;
9
10use crate::{
11    AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryPage, AdminModel, ListParams,
12    ListResult, SelectOption,
13};
14
15// ── Grouped-aggregate roll-up over the audit trail (#1364) ──────────────────
16//
17// A typed `#[repository]` over `autumn_experiment_changes` so the per-experiment
18// audit-trail size is computed with the grouped-aggregate API
19// (`count_grouped_by_experiment().filter_eq(name)`) instead of a hand-written
20// `SELECT COUNT(*) … WHERE experiment = $1`. The repo is built here via
21// `with_pool_untracked`, which is pool-aware but pins `ReadRoute::Primary` (it
22// carries no request/`AppState` read-route context) — so this read runs on the
23// primary, NOT a replica. A request-scoped constructor would let the same query
24// route to a replica for free.
25
26// ── Backend portability (issue #2108) ────────────────────────────────────────
27//
28// `changed_at` is a `timestamptz` column. The table and the model declare the
29// portable `Timestamp` type with a `NaiveDateTime` field. Do not write
30// `Timestamptz` here. Diesel implements `HasSqlType<Timestamptz>` for `Pg`
31// only, so the generated DSL stops compiling under `autumn-web/sqlite`.
32//
33// This does not change what Postgres returns. Postgres sends `timestamp` and
34// `timestamptz` in the same binary form: microseconds from 2000-01-01 UTC. A
35// `Timestamp` read of a `timestamptz` column gives the UTC wall clock. The
36// session time zone has no effect. `and_utc()` adds the offset again at the
37// JSON boundary. `tests/experiment_admin_db.rs` asserts this on a non-UTC
38// session.
39//
40// The write direction is safe too. `#[default]` keeps `changed_at` out of
41// `NewExperimentChange` and `UpdateExperimentChange`, so the generated CRUD
42// cannot write it, and every writer in the workspace is raw SQL that names
43// `(experiment, mutation, actor)` only. One vector remains: a hand-written
44// `insert_into(...).values(&ExperimentChange)` binds `changed_at` as
45// `timestamp`, and Postgres then coerces it with the SESSION time zone.
46// diesel-async sets every new connection to UTC (`set_config_options` in
47// `diesel_async::pg`), so this is correct unless the app overrides that.
48diesel::table! {
49    autumn_experiment_changes (id) {
50        id -> diesel::sql_types::Int8,
51        experiment -> diesel::sql_types::Text,
52        mutation -> diesel::sql_types::Text,
53        actor -> diesel::sql_types::Nullable<diesel::sql_types::Text>,
54        changed_at -> diesel::sql_types::Timestamp,
55    }
56}
57
58#[autumn_web::model(table = "autumn_experiment_changes")]
59pub struct ExperimentChange {
60    #[id]
61    pub id: i64,
62    #[indexed]
63    pub experiment: String,
64    pub mutation: String,
65    pub actor: Option<String>,
66    #[default]
67    pub changed_at: chrono::NaiveDateTime,
68}
69
70#[autumn_web::repository(ExperimentChange, table = "autumn_experiment_changes")]
71pub trait ExperimentChangeRepository {
72    /// COUNT(*) GROUP BY experiment → one `(experiment, count)` pair per
73    /// experiment; `.filter_eq(name)` narrows it to a single experiment's
74    /// audit-trail size.
75    fn count_grouped_by_experiment() -> Vec<(String, i64)>;
76}
77
78/// Admin panel model for A/B experiments.
79///
80/// # Postgres only
81///
82/// This model reads and writes `autumn_experiments`. That table is Postgres-only.
83/// Its SQL uses `ILIKE`, `::type` casts and writable CTEs, which `SQLite` does
84/// not have. On `SQLite` every method refuses with an error that names this
85/// model. The plugin core is backend-agnostic: register your own
86/// [`AdminModel`](crate::AdminModel)s there instead. See the crate README.
87///
88/// Register this model with the admin plugin to get an experiment management UI
89/// at `/admin/experiments/`:
90///
91/// ```rust,ignore
92/// use autumn_admin_plugin::{prelude::*, AdminPlugin};
93/// use autumn_admin_plugin::experiments::ExperimentAdminModel;
94///
95/// autumn_web::app()
96///     .plugin(
97///         AdminPlugin::new()
98///             .register(ExperimentAdminModel::default()),
99///     )
100///     .run()
101///     .await;
102/// ```
103#[derive(Debug, Default, Clone)]
104pub struct ExperimentAdminModel;
105
106impl AdminModel for ExperimentAdminModel {
107    fn slug(&self) -> &'static str {
108        "experiments"
109    }
110
111    fn display_name(&self) -> &'static str {
112        "Experiment"
113    }
114
115    fn display_name_plural(&self) -> &'static str {
116        "Experiments"
117    }
118
119    fn record_display(&self, record: &Value) -> String {
120        record
121            .get("name")
122            .and_then(|v| v.as_str())
123            .map_or_else(|| "Experiment".to_owned(), |n| format!("Experiment: {n}"))
124    }
125
126    fn fields(&self) -> Vec<AdminField> {
127        vec![
128            AdminField::new("name", AdminFieldKind::Text)
129                .label("Experiment Name")
130                .searchable(),
131            AdminField::new("description", AdminFieldKind::TextArea)
132                .label("Description")
133                .optional()
134                .searchable(),
135            AdminField::new(
136                "state",
137                AdminFieldKind::Select(vec![
138                    SelectOption {
139                        value: "draft".into(),
140                        label: "Draft".into(),
141                    },
142                    SelectOption {
143                        value: "running".into(),
144                        label: "Running".into(),
145                    },
146                    SelectOption {
147                        value: "concluded".into(),
148                        label: "Concluded".into(),
149                    },
150                    SelectOption {
151                        value: "archived".into(),
152                        label: "Archived".into(),
153                    },
154                ]),
155            )
156            .label("State"),
157            AdminField::new("variants", AdminFieldKind::Json)
158                .label("Variants (JSON)")
159                .optional()
160                .hide_from_list(),
161            AdminField::new("winner", AdminFieldKind::Text)
162                .label("Winner")
163                .optional(),
164            AdminField::new("exclusion_group", AdminFieldKind::Text)
165                .label("Exclusion Group")
166                .optional()
167                .hide_from_list(),
168            AdminField::new("updated_at", AdminFieldKind::DateTime)
169                .label("Last Updated")
170                .readonly()
171                .optional(),
172        ]
173    }
174
175    fn has_history(&self) -> bool {
176        true
177    }
178
179    fn list(
180        &self,
181        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
182        params: ListParams,
183    ) -> AdminFuture<'_, ListResult> {
184        use diesel_async::RunQueryDsl;
185
186        let pool = pool.clone();
187        Box::pin(async move {
188            crate::traits::require_postgres("ExperimentAdminModel")?;
189            let mut conn = pool
190                .get()
191                .await
192                .map_err(|e| AdminError::Database(e.to_string()))?;
193
194            let per_page = params.per_page;
195            let (offset, limit) = params.sql_offset_limit();
196            let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
197
198            let total: i64 = diesel::sql_query(
199                "SELECT COUNT(*) FROM autumn_experiments \
200                 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
201            )
202            .bind::<diesel::sql_types::Text, _>(&search_pattern)
203            .get_result::<CountRow>(&mut conn)
204            .await
205            .map_or(0, |r| r.count);
206
207            let records: Vec<Value> = diesel::sql_query(
208                "SELECT id, name, description, state::text AS state, \
209                        variants::text AS variants, winner, updated_at \
210                 FROM autumn_experiments \
211                 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
212                 ORDER BY name \
213                 LIMIT $2 OFFSET $3",
214            )
215            .bind::<diesel::sql_types::Text, _>(&search_pattern)
216            .bind::<diesel::sql_types::BigInt, _>(limit)
217            .bind::<diesel::sql_types::BigInt, _>(offset)
218            .load::<ExperimentRow>(&mut conn)
219            .await
220            .map(|rows| rows.into_iter().map(ExperimentRow::into_json).collect())
221            .map_err(|e| AdminError::Database(e.to_string()))?;
222
223            Ok(ListResult {
224                total: u64::try_from(total).unwrap_or(0),
225                page: params.page,
226                per_page,
227                records,
228            })
229        })
230    }
231
232    fn get(
233        &self,
234        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
235        id: i64,
236    ) -> AdminFuture<'_, Option<Value>> {
237        use diesel::prelude::*;
238        use diesel_async::RunQueryDsl;
239
240        let pool = pool.clone();
241        Box::pin(async move {
242            crate::traits::require_postgres("ExperimentAdminModel")?;
243            let mut conn = pool
244                .get()
245                .await
246                .map_err(|e| AdminError::Database(e.to_string()))?;
247
248            diesel::sql_query(
249                "SELECT id, name, description, state::text AS state, \
250                        variants::text AS variants, winner, exclusion_group, updated_at \
251                 FROM autumn_experiments WHERE id = $1",
252            )
253            .bind::<diesel::sql_types::BigInt, _>(id)
254            .get_result::<ExperimentDetailRow>(&mut conn)
255            .await
256            .optional()
257            .map(|r| r.map(ExperimentDetailRow::into_json))
258            .map_err(|e| AdminError::Database(e.to_string()))
259        })
260    }
261
262    fn create(
263        &self,
264        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
265        data: Value,
266    ) -> AdminFuture<'_, Value> {
267        use diesel_async::RunQueryDsl;
268
269        let pool = pool.clone();
270        Box::pin(async move {
271            crate::traits::require_postgres("ExperimentAdminModel")?;
272            let mut conn = pool
273                .get()
274                .await
275                .map_err(|e| AdminError::Database(e.to_string()))?;
276
277            let name = data
278                .get("name")
279                .and_then(Value::as_str)
280                .ok_or_else(|| AdminError::Validation("'name' is required".into()))?;
281            let description = data.get("description").and_then(Value::as_str);
282            let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
283            let variants = validate_variants_json(&extract_variants_str(&data))?;
284            let winner = data.get("winner").and_then(Value::as_str);
285            let exclusion_group = data
286                .get("exclusion_group")
287                .and_then(Value::as_str)
288                .filter(|s| !s.trim().is_empty());
289
290            // Concluding requires a non-empty winner that is a configured variant.
291            if state == "concluded" {
292                let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
293                    AdminError::Validation(
294                        "a concluded experiment requires a non-empty winner".into(),
295                    )
296                })?;
297                let arr: Vec<serde_json::Value> =
298                    serde_json::from_str(&variants).unwrap_or_default();
299                if !arr
300                    .iter()
301                    .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
302                {
303                    return Err(AdminError::Validation(format!(
304                        "winner '{w}' is not a configured variant"
305                    )));
306                }
307            }
308
309            let row = diesel::sql_query(
310                "WITH inserted AS ( \
311                     INSERT INTO autumn_experiments \
312                         (name, description, state, variants, winner, exclusion_group) \
313                     VALUES ($1, $2, $3::autumn_experiment_state, $4::jsonb, $5, $6) \
314                     RETURNING id, name, description, state::text AS state, \
315                               variants::text AS variants, winner, updated_at \
316                 ), \
317                 _audit AS ( \
318                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
319                     SELECT name, 'created', NULL FROM inserted \
320                 ) \
321                 SELECT id, name, description, state, variants, winner, updated_at \
322                 FROM inserted",
323            )
324            .bind::<diesel::sql_types::Text, _>(name)
325            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
326                description.map(str::to_owned),
327            )
328            .bind::<diesel::sql_types::Text, _>(state)
329            .bind::<diesel::sql_types::Text, _>(variants)
330            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
331                winner.map(str::to_owned),
332            )
333            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
334                exclusion_group.map(str::to_owned),
335            )
336            .get_result::<ExperimentRow>(&mut conn)
337            .await
338            .map_err(|e| {
339                if matches!(
340                    e,
341                    diesel::result::Error::DatabaseError(
342                        diesel::result::DatabaseErrorKind::UniqueViolation,
343                        _
344                    )
345                ) {
346                    AdminError::Validation(format!("an experiment named '{name}' already exists"))
347                } else {
348                    AdminError::Database(e.to_string())
349                }
350            })?;
351
352            Ok(ExperimentRow::into_json(row))
353        })
354    }
355
356    #[allow(clippy::too_many_lines)]
357    fn update(
358        &self,
359        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
360        id: i64,
361        data: Value,
362    ) -> AdminFuture<'_, Value> {
363        use diesel::prelude::*;
364        use diesel_async::RunQueryDsl;
365
366        let pool = pool.clone();
367        Box::pin(async move {
368            crate::traits::require_postgres("ExperimentAdminModel")?;
369            let mut conn = pool
370                .get()
371                .await
372                .map_err(|e| AdminError::Database(e.to_string()))?;
373
374            let current = diesel::sql_query(
375                "SELECT name, state::text AS state FROM autumn_experiments WHERE id = $1",
376            )
377            .bind::<diesel::sql_types::BigInt, _>(id)
378            .get_result::<NameStateRow>(&mut conn)
379            .await
380            .optional()
381            .map_err(|e| AdminError::Database(e.to_string()))?;
382
383            let Some(NameStateRow {
384                name,
385                state: state_str,
386            }) = current
387            else {
388                return Err(AdminError::Validation("experiment not found".into()));
389            };
390
391            if state_str == "archived" {
392                return Err(AdminError::Validation(
393                    "archived experiments cannot be edited".into(),
394                ));
395            }
396
397            // name is read-only after creation; we read it only to satisfy field validation
398            // and for display — the SQL does not allow renaming an experiment.
399            let description = data.get("description").and_then(Value::as_str);
400            let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
401            let variants = validate_variants_json(&extract_variants_str(&data))?;
402            let winner = data.get("winner").and_then(Value::as_str);
403
404            let parsed_new_variants: Vec<serde_json::Value> = serde_json::from_str(&variants)
405                .map_err(|e| AdminError::Validation(format!("invalid variants JSON: {e}")))?;
406            let new_variant_names: std::collections::HashSet<&str> = parsed_new_variants
407                .iter()
408                .filter_map(|v| v.get("name").and_then(Value::as_str))
409                .collect();
410
411            let active_variants = diesel::sql_query(
412                "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
413            )
414            .bind::<diesel::sql_types::Text, _>(&name)
415            .load::<VariantNameRow>(&mut conn)
416            .await
417            .map_err(|e| AdminError::Database(e.to_string()))?;
418
419            for row in active_variants {
420                if !new_variant_names.contains(row.variant.as_str()) {
421                    return Err(AdminError::Validation(format!(
422                        "cannot delete variant '{}' because it has active assignments",
423                        row.variant
424                    )));
425                }
426            }
427
428            // Concluding requires a non-empty winner that is a configured variant.
429            if state == "concluded" {
430                let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
431                    AdminError::Validation(
432                        "a concluded experiment requires a non-empty winner".into(),
433                    )
434                })?;
435                let arr: Vec<serde_json::Value> =
436                    serde_json::from_str(&variants).unwrap_or_default();
437                if !arr
438                    .iter()
439                    .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
440                {
441                    return Err(AdminError::Validation(format!(
442                        "winner '{w}' is not a configured variant"
443                    )));
444                }
445            }
446            let exclusion_group = data
447                .get("exclusion_group")
448                .and_then(Value::as_str)
449                .filter(|s| !s.trim().is_empty());
450
451            let row = diesel::sql_query(
452                "WITH updated AS ( \
453                     UPDATE autumn_experiments \
454                     SET description = $2, \
455                         state = $3::autumn_experiment_state, \
456                         variants = $4::jsonb, winner = $5, \
457                         exclusion_group = $6, updated_at = NOW() \
458                     WHERE id = $1 \
459                     RETURNING id, name, description, state::text AS state, \
460                               variants::text AS variants, winner, updated_at \
461                 ), \
462                 _audit AS ( \
463                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
464                     SELECT name, 'updated', NULL FROM updated \
465                 ) \
466                 SELECT id, name, description, state, variants, winner, updated_at \
467                 FROM updated",
468            )
469            .bind::<diesel::sql_types::BigInt, _>(id)
470            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
471                description.map(str::to_owned),
472            )
473            .bind::<diesel::sql_types::Text, _>(state)
474            .bind::<diesel::sql_types::Text, _>(variants)
475            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
476                winner.map(str::to_owned),
477            )
478            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
479                exclusion_group.map(str::to_owned),
480            )
481            .get_result::<ExperimentRow>(&mut conn)
482            .await
483            .map_err(|e| AdminError::Database(e.to_string()))?;
484
485            Ok(ExperimentRow::into_json(row))
486        })
487    }
488
489    fn delete(
490        &self,
491        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
492        id: i64,
493    ) -> AdminFuture<'_, ()> {
494        use diesel_async::RunQueryDsl;
495
496        let pool = pool.clone();
497        Box::pin(async move {
498            crate::traits::require_postgres("ExperimentAdminModel")?;
499            let mut conn = pool
500                .get()
501                .await
502                .map_err(|e| AdminError::Database(e.to_string()))?;
503
504            diesel::sql_query(
505                "WITH deleted AS ( \
506                     DELETE FROM autumn_experiments WHERE id = $1 RETURNING name \
507                 ), \
508                 _del_assignments AS ( \
509                     DELETE FROM autumn_experiment_assignments \
510                     WHERE experiment IN (SELECT name FROM deleted) \
511                 ), \
512                 _del_overrides AS ( \
513                     DELETE FROM autumn_experiment_overrides \
514                     WHERE experiment IN (SELECT name FROM deleted) \
515                 ), \
516                 _audit AS ( \
517                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
518                     SELECT name, 'deleted', NULL FROM deleted \
519                 ) \
520                 SELECT COUNT(*) AS count FROM deleted",
521            )
522            .bind::<diesel::sql_types::BigInt, _>(id)
523            .get_result::<CountRow>(&mut conn)
524            .await
525            .map_err(|e| AdminError::Database(e.to_string()))?;
526
527            Ok(())
528        })
529    }
530
531    fn execute_action(
532        &self,
533        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
534        action: &str,
535        ids: Vec<i64>,
536    ) -> AdminFuture<'_, u64> {
537        // `ExperimentAdminModel` never declares soft delete
538        // (`supports_soft_delete()` is the trait default, `false`), so
539        // `actions()` (traits.rs) only ever offers `"delete"` — the admin UI
540        // can't reach `"restore"` or `"purge"` for this model. Those two
541        // branches below, and the unhandled-action branch, are unchanged
542        // copies of the trait default's per-id loop: kept only so a direct
543        // or out-of-band `execute_action` call gets the exact same "does not
544        // support soft delete" (or "unhandled action") error it always did,
545        // not because they need batching — `self.restore`/`self.purge` are
546        // the trait's default methods, which return `Err` on the very first
547        // id regardless of loop shape, so there is no N+1 to eliminate there.
548        if action == "delete" {
549            let pool = pool.clone();
550            return Box::pin(async move {
551                // The batched form binds a Postgres array. SQLite has no array
552                // bind type. `backend_select!` keeps one arm and drops the
553                // other, so the array never reaches the SQLite type-checker
554                // (issue #2108).
555                //
556                // The SQLite arm keeps the crate compiling, and refuses.
557                // ExperimentAdminModel is Postgres-only, so there is no
558                // correct SQLite statement to fall back to.
559                ::autumn_web::backend_select! {
560                    pg => {{
561                        use diesel_async::RunQueryDsl;
562
563                        // Batch every id into ONE round trip instead of the trait
564                        // default's one-CTE-per-id loop (an operator selecting
565                        // hundreds of concluded/archived experiments in the admin
566                        // list and clicking "Delete selected" otherwise costs one
567                        // statement, and one connection checkout, per experiment).
568                        // Same CTE shape as the single-row `delete()`: the cascading
569                        // assignment/override deletes and the audit INSERT's
570                        // `SELECT name, 'deleted', NULL FROM deleted` already fan out
571                        // to one row per id the `DELETE ... RETURNING name` actually
572                        // removed, so widening the predicate to `id = ANY($1)` is
573                        // enough — an id that doesn't exist contributes no row to
574                        // `deleted` and so no cascading delete or audit row either,
575                        // exactly like the loop it replaces.
576                        //
577                        // The returned count matches the *ids submitted*, not rows
578                        // actually deleted, exactly like the loop this replaces
579                        // (which incremented its counter once per id regardless of
580                        // whether that id matched a row).
581                        let mut conn = pool
582                            .get()
583                            .await
584                            .map_err(|e| AdminError::Database(e.to_string()))?;
585                        diesel::sql_query(
586                            "WITH deleted AS ( \
587                                 DELETE FROM autumn_experiments WHERE id = ANY($1) RETURNING name \
588                             ), \
589                             _del_assignments AS ( \
590                                 DELETE FROM autumn_experiment_assignments \
591                                 WHERE experiment IN (SELECT name FROM deleted) \
592                             ), \
593                             _del_overrides AS ( \
594                                 DELETE FROM autumn_experiment_overrides \
595                                 WHERE experiment IN (SELECT name FROM deleted) \
596                             ), \
597                             _audit AS ( \
598                                 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
599                                 SELECT name, 'deleted', NULL FROM deleted \
600                             ) \
601                             SELECT COUNT(*) AS count FROM deleted",
602                        )
603                        .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
604                        .get_result::<CountRow>(&mut conn)
605                        .await
606                        .map_err(|e| AdminError::Database(e.to_string()))?;
607                        Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
608                    }},
609                    sqlite => {{
610                        let _ = (&pool, &ids);
611                        crate::traits::require_postgres("ExperimentAdminModel").map(|()| 0)
612                    }},
613                }
614            });
615        }
616
617        let action = action.to_owned();
618        let pool = pool.clone();
619        Box::pin(async move {
620            crate::traits::require_postgres("ExperimentAdminModel")?;
621            match action.as_str() {
622                "restore" => {
623                    let mut count: u64 = 0;
624                    for id in ids {
625                        self.restore(&pool, id).await?;
626                        count += 1;
627                    }
628                    Ok(count)
629                }
630                "purge" => {
631                    let mut count: u64 = 0;
632                    for id in ids {
633                        self.purge(&pool, id).await?;
634                        count += 1;
635                    }
636                    Ok(count)
637                }
638                other => Err(AdminError::Other(format!(
639                    "unhandled bulk action '{other}'; \
640                     override AdminModel::execute_action to support it"
641                ))),
642            }
643        })
644    }
645
646    fn get_history<'a>(
647        &'a self,
648        pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
649        record_id: i64,
650        page: u64,
651        per_page: u64,
652    ) -> AdminFuture<'a, AdminHistoryPage> {
653        use diesel::prelude::*;
654        use diesel_async::RunQueryDsl;
655
656        let pool = pool.clone();
657        Box::pin(async move {
658            crate::traits::require_postgres("ExperimentAdminModel")?;
659            let mut conn = pool
660                .get()
661                .await
662                .map_err(|e| AdminError::Database(e.to_string()))?;
663
664            let name: Option<String> =
665                diesel::sql_query("SELECT name FROM autumn_experiments WHERE id = $1")
666                    .bind::<diesel::sql_types::BigInt, _>(record_id)
667                    .get_result::<NameRow>(&mut conn)
668                    .await
669                    .optional()
670                    .unwrap_or(None)
671                    .map(|r| r.name);
672
673            let Some(name) = name else {
674                return Ok(AdminHistoryPage {
675                    entries: vec![],
676                    total: 0,
677                    page,
678                    per_page,
679                });
680            };
681
682            let offset = (page.saturating_sub(1)) * per_page;
683            let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(
684                "SELECT id, mutation AS op, actor, changed_at \
685                 FROM autumn_experiment_changes \
686                 WHERE experiment = $1 \
687                 ORDER BY changed_at DESC \
688                 LIMIT $2 OFFSET $3",
689            )
690            .bind::<diesel::sql_types::Text, _>(&name)
691            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
692            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
693            .load::<HistoryRow>(&mut conn)
694            .await
695            .unwrap_or_default()
696            .into_iter()
697            .map(|r| crate::AdminHistoryEntry {
698                id: r.id,
699                actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
700                op: r.op,
701                request_id: None,
702                changes: vec![],
703                recorded_at: r.changed_at.and_utc(),
704            })
705            .collect();
706
707            // Release this handler's connection before the grouped-aggregate
708            // count checks out its own. `conn` is held for the name + entries
709            // lookups above, and on a single-connection pool (max_size = 1, no
710            // read replica) a second concurrent checkout would block until the
711            // pool timeout — deadlocking the history page. The count does not
712            // depend on `entries`, so running it last, after `conn` is dropped,
713            // keeps the two checkouts from overlapping.
714            drop(conn);
715
716            // Audit-trail size via the typed grouped-aggregate API (#1364):
717            // COUNT(*) GROUP BY experiment, scoped to this experiment. Grouping
718            // on the filtered column yields a single `(name, count)` row (or
719            // none when the experiment has no history). Errors degrade to 0,
720            // matching the previous raw-SQL `map_or(0, …)`.
721            let count: i64 = PgExperimentChangeRepository::with_pool_untracked(pool.clone())
722                .count_grouped_by_experiment()
723                .filter_eq(name.clone())
724                .load()
725                .await
726                .ok()
727                .and_then(|rows| rows.into_iter().next())
728                .map_or(0, |(_, c)| c);
729
730            Ok(AdminHistoryPage {
731                entries,
732                total: u64::try_from(count).unwrap_or(0),
733                page,
734                per_page,
735            })
736        })
737    }
738}
739
740// ── Helpers ───────────────────────────────────────────────────────────────────
741
742/// Extract `variants` from admin form data, handling both pre-parsed JSON arrays
743/// (normalized by the admin route) and raw JSON strings submitted by the form.
744fn extract_variants_str(data: &Value) -> String {
745    match data.get("variants") {
746        Some(Value::String(s)) => s.clone(),
747        Some(v) if !v.is_null() => serde_json::to_string(v).unwrap_or_else(|_| "[]".to_owned()),
748        _ => "[]".to_owned(),
749    }
750}
751
752/// Validate `raw` as a JSON array of `{"name": string, "weight": integer}` objects.
753fn validate_variants_json(raw: &str) -> Result<String, AdminError> {
754    let trimmed = raw.trim();
755    if trimmed.is_empty() {
756        return Ok("[]".to_owned());
757    }
758    match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
759        Ok(arr) => {
760            let mut seen_names = std::collections::HashSet::new();
761            for (i, v) in arr.iter().enumerate() {
762                let name_str = match v.get("name").and_then(|n| n.as_str()) {
763                    None => {
764                        return Err(AdminError::Validation(format!(
765                            "variants[{i}].name must be a string"
766                        )));
767                    }
768                    Some(n) if n.trim().is_empty() => {
769                        return Err(AdminError::Validation(format!(
770                            "variants[{i}].name must not be empty"
771                        )));
772                    }
773                    Some(n) => n,
774                };
775                if !seen_names.insert(name_str) {
776                    return Err(AdminError::Validation(format!(
777                        "duplicate variant name '{name_str}' at variants[{i}]"
778                    )));
779                }
780                match v.get("weight").and_then(Value::as_u64) {
781                    None => {
782                        return Err(AdminError::Validation(format!(
783                            "variants[{i}].weight must be a non-negative integer"
784                        )));
785                    }
786                    Some(w) if w > u64::from(u32::MAX) => {
787                        return Err(AdminError::Validation(format!(
788                            "variants[{i}].weight must not exceed {} (u32::MAX)",
789                            u32::MAX
790                        )));
791                    }
792                    _ => {}
793                }
794            }
795            Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
796        }
797        Err(_) => Err(AdminError::Validation(
798            "'variants' must be a valid JSON array".into(),
799        )),
800    }
801}
802
803// ── Row types ─────────────────────────────────────────────────────────────────
804
805#[derive(diesel::QueryableByName)]
806struct CountRow {
807    #[diesel(sql_type = diesel::sql_types::BigInt)]
808    count: i64,
809}
810
811#[derive(diesel::QueryableByName)]
812struct NameRow {
813    #[diesel(sql_type = diesel::sql_types::Text)]
814    name: String,
815}
816
817#[derive(diesel::QueryableByName)]
818struct NameStateRow {
819    #[diesel(sql_type = diesel::sql_types::Text)]
820    name: String,
821    #[diesel(sql_type = diesel::sql_types::Text)]
822    state: String,
823}
824
825#[derive(diesel::QueryableByName)]
826struct VariantNameRow {
827    #[diesel(sql_type = diesel::sql_types::Text)]
828    variant: String,
829}
830
831/// Row returned from list queries (no `exclusion_group` for brevity in list view).
832#[derive(diesel::QueryableByName)]
833struct ExperimentRow {
834    #[diesel(sql_type = diesel::sql_types::BigInt)]
835    id: i64,
836    #[diesel(sql_type = diesel::sql_types::Text)]
837    name: String,
838    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
839    description: Option<String>,
840    #[diesel(sql_type = diesel::sql_types::Text)]
841    state: String,
842    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
843    variants: Option<String>,
844    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
845    winner: Option<String>,
846    #[diesel(sql_type = diesel::sql_types::Timestamp)]
847    updated_at: chrono::NaiveDateTime,
848}
849
850impl ExperimentRow {
851    fn into_json(self) -> Value {
852        serde_json::json!({
853            "id": self.id,
854            "name": self.name,
855            "description": self.description,
856            "state": self.state,
857            "variants": self.variants.map(decode_variants),
858            "winner": self.winner,
859            "updated_at": self.updated_at.and_utc().to_rfc3339(),
860        })
861    }
862}
863
864/// Decode a `variants` `TEXT` column's raw content (JSON-serialized on write
865/// — see the admin's update/create handling) into the real `serde_json::Value`
866/// array it represents, rather than embedding the raw serialized text as an
867/// opaque `Value::String`. `render_form_widget`'s `AdminFieldKind::Json` arm
868/// re-serializes whatever `Value` it's given as JSON syntax, so wrapping
869/// already-JSON text in `Value::String` would get it double-encoded into a
870/// quoted, escaped blob instead of the editable array the form used to show
871/// (Codex review finding on #1341). A parse failure — unreachable in
872/// practice, since this column is always written as `serde_json::to_string`
873/// output — falls back to the raw text rather than panicking.
874fn decode_variants(raw: String) -> Value {
875    serde_json::from_str(&raw).unwrap_or(Value::String(raw))
876}
877
878/// Row returned from detail (get) queries — includes `exclusion_group`.
879#[derive(diesel::QueryableByName)]
880struct ExperimentDetailRow {
881    #[diesel(sql_type = diesel::sql_types::BigInt)]
882    id: i64,
883    #[diesel(sql_type = diesel::sql_types::Text)]
884    name: String,
885    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
886    description: Option<String>,
887    #[diesel(sql_type = diesel::sql_types::Text)]
888    state: String,
889    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
890    variants: Option<String>,
891    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
892    winner: Option<String>,
893    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
894    exclusion_group: Option<String>,
895    #[diesel(sql_type = diesel::sql_types::Timestamp)]
896    updated_at: chrono::NaiveDateTime,
897}
898
899impl ExperimentDetailRow {
900    fn into_json(self) -> Value {
901        serde_json::json!({
902            "id": self.id,
903            "name": self.name,
904            "description": self.description,
905            "state": self.state,
906            "variants": self.variants.map(decode_variants),
907            "winner": self.winner,
908            "exclusion_group": self.exclusion_group,
909            "updated_at": self.updated_at.and_utc().to_rfc3339(),
910        })
911    }
912}
913
914#[derive(diesel::QueryableByName)]
915struct HistoryRow {
916    #[diesel(sql_type = diesel::sql_types::BigInt)]
917    id: i64,
918    #[diesel(sql_type = diesel::sql_types::Text)]
919    op: String,
920    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
921    actor: Option<String>,
922    #[diesel(sql_type = diesel::sql_types::Timestamp)]
923    changed_at: chrono::NaiveDateTime,
924}
925
926// ── Tests ─────────────────────────────────────────────────────────────────────
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    #[test]
933    fn experiment_admin_model_slug() {
934        let model = ExperimentAdminModel;
935        assert_eq!(model.slug(), "experiments");
936    }
937
938    #[test]
939    fn experiment_admin_model_display_names() {
940        let model = ExperimentAdminModel;
941        assert_eq!(model.display_name(), "Experiment");
942        assert_eq!(model.display_name_plural(), "Experiments");
943    }
944
945    #[test]
946    fn experiment_admin_model_has_history() {
947        let model = ExperimentAdminModel;
948        assert!(model.has_history(), "experiment admin must expose history");
949    }
950
951    #[test]
952    fn experiment_admin_model_has_expected_fields() {
953        let model = ExperimentAdminModel;
954        let fields = model.fields();
955        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
956        assert!(names.contains(&"name"), "must have 'name' field");
957        assert!(names.contains(&"state"), "must have 'state' field");
958        assert!(names.contains(&"variants"), "must have 'variants' field");
959        assert!(names.contains(&"winner"), "must have 'winner' field");
960        assert!(
961            names.contains(&"exclusion_group"),
962            "must have 'exclusion_group' field"
963        );
964    }
965
966    #[test]
967    fn experiment_admin_model_state_field_has_all_lifecycle_states() {
968        let model = ExperimentAdminModel;
969        let state_field = model
970            .fields()
971            .into_iter()
972            .find(|f| f.name == "state")
973            .expect("state field must exist");
974        let AdminFieldKind::Select(options) = state_field.kind else {
975            panic!("state field must be Select");
976        };
977        let values: Vec<&str> = options.iter().map(|o| o.value.as_str()).collect();
978        assert!(values.contains(&"draft"));
979        assert!(values.contains(&"running"));
980        assert!(values.contains(&"concluded"));
981        assert!(values.contains(&"archived"));
982    }
983
984    #[test]
985    fn record_display_uses_experiment_name() {
986        let model = ExperimentAdminModel;
987        let record = serde_json::json!({"name": "checkout_v2", "state": "running"});
988        assert_eq!(model.record_display(&record), "Experiment: checkout_v2");
989    }
990
991    #[test]
992    fn record_display_fallback_when_no_name() {
993        let model = ExperimentAdminModel;
994        let record = serde_json::json!({});
995        assert_eq!(model.record_display(&record), "Experiment");
996    }
997
998    #[test]
999    fn validate_variants_json_accepts_valid_array() {
1000        let json = validate_variants_json(
1001            r#"[{"name":"control","weight":50},{"name":"treatment","weight":50}]"#,
1002        )
1003        .unwrap();
1004        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1005        assert_eq!(v.as_array().unwrap().len(), 2);
1006    }
1007
1008    #[test]
1009    fn validate_variants_json_accepts_empty_string() {
1010        assert_eq!(validate_variants_json("").unwrap(), "[]");
1011    }
1012
1013    #[test]
1014    fn validate_variants_json_rejects_missing_name() {
1015        let err = validate_variants_json(r#"[{"weight":50}]"#).unwrap_err();
1016        assert!(err.to_string().contains("name"));
1017    }
1018
1019    #[test]
1020    fn validate_variants_json_rejects_missing_weight() {
1021        let err = validate_variants_json(r#"[{"name":"control"}]"#).unwrap_err();
1022        assert!(err.to_string().contains("weight"));
1023    }
1024
1025    #[test]
1026    fn validate_variants_json_rejects_invalid_json() {
1027        let err = validate_variants_json("{not json}").unwrap_err();
1028        assert!(err.to_string().contains("JSON"));
1029    }
1030
1031    #[test]
1032    fn validate_variants_json_rejects_empty_name() {
1033        let err = validate_variants_json(r#"[{"name":"","weight":100}]"#).unwrap_err();
1034        assert!(
1035            err.to_string().contains("empty"),
1036            "expected empty-name error, got: {err}"
1037        );
1038    }
1039
1040    #[test]
1041    fn validate_variants_json_rejects_whitespace_only_name() {
1042        let err = validate_variants_json(r#"[{"name":"   ","weight":100}]"#).unwrap_err();
1043        assert!(
1044            err.to_string().contains("empty"),
1045            "expected empty-name error, got: {err}"
1046        );
1047    }
1048
1049    #[test]
1050    fn decode_variants_parses_stored_json_text_into_a_real_value() {
1051        // Codex review finding on #1341: `variants` is a TEXT column storing
1052        // serialized JSON. Embedding that raw text as `Value::String` (the
1053        // old behavior) made `render_form_widget`'s `AdminFieldKind::Json` arm
1054        // double-encode it into a quoted, escaped blob once that arm started
1055        // re-serializing `Value::String` as JSON syntax (to correctly
1056        // round-trip a GENUINE top-level JSON string scalar). Decoding here
1057        // means the edit form sees the real array and displays it correctly.
1058        let decoded = decode_variants(r#"[{"name":"control","weight":50}]"#.to_owned());
1059        assert_eq!(
1060            decoded,
1061            serde_json::json!([{"name": "control", "weight": 50}])
1062        );
1063        assert!(matches!(decoded, Value::Array(_)));
1064    }
1065
1066    #[test]
1067    fn decode_variants_falls_back_to_raw_text_on_unparseable_input() {
1068        // Unreachable through normal use (this column is always written as
1069        // `serde_json::to_string` output), but must not panic on legacy or
1070        // hand-edited data.
1071        let decoded = decode_variants("not json".to_owned());
1072        assert_eq!(decoded, Value::String("not json".to_owned()));
1073    }
1074}