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(history_row_to_entry)
698            .collect();
699
700            // Release this handler's connection before the grouped-aggregate
701            // count checks out its own. `conn` is held for the name + entries
702            // lookups above, and on a single-connection pool (max_size = 1, no
703            // read replica) a second concurrent checkout would block until the
704            // pool timeout — deadlocking the history page. The count does not
705            // depend on `entries`, so running it last, after `conn` is dropped,
706            // keeps the two checkouts from overlapping.
707            drop(conn);
708
709            // Audit-trail size via the typed grouped-aggregate API (#1364):
710            // COUNT(*) GROUP BY experiment, scoped to this experiment. Grouping
711            // on the filtered column yields a single `(name, count)` row (or
712            // none when the experiment has no history). Errors degrade to 0,
713            // matching the previous raw-SQL `map_or(0, …)`.
714            let count: i64 = PgExperimentChangeRepository::with_pool_untracked(pool.clone())
715                .count_grouped_by_experiment()
716                .filter_eq(name.clone())
717                .load()
718                .await
719                .ok()
720                .and_then(|rows| rows.into_iter().next())
721                .map_or(0, |(_, c)| c);
722
723            Ok(AdminHistoryPage {
724                entries,
725                total: u64::try_from(count).unwrap_or(0),
726                page,
727                per_page,
728            })
729        })
730    }
731}
732
733// ── Helpers ───────────────────────────────────────────────────────────────────
734
735/// Extract `variants` from admin form data, handling both pre-parsed JSON arrays
736/// (normalized by the admin route) and raw JSON strings submitted by the form.
737fn extract_variants_str(data: &Value) -> String {
738    match data.get("variants") {
739        Some(Value::String(s)) => s.clone(),
740        Some(v) if !v.is_null() => serde_json::to_string(v).unwrap_or_else(|_| "[]".to_owned()),
741        _ => "[]".to_owned(),
742    }
743}
744
745/// Validate `raw` as a JSON array of `{"name": string, "weight": integer}` objects.
746fn validate_variants_json(raw: &str) -> Result<String, AdminError> {
747    let trimmed = raw.trim();
748    if trimmed.is_empty() {
749        return Ok("[]".to_owned());
750    }
751    match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
752        Ok(arr) => {
753            let mut seen_names = std::collections::HashSet::new();
754            for (i, v) in arr.iter().enumerate() {
755                let name_str = match v.get("name").and_then(|n| n.as_str()) {
756                    None => {
757                        return Err(AdminError::Validation(format!(
758                            "variants[{i}].name must be a string"
759                        )));
760                    }
761                    Some(n) if n.trim().is_empty() => {
762                        return Err(AdminError::Validation(format!(
763                            "variants[{i}].name must not be empty"
764                        )));
765                    }
766                    Some(n) => n,
767                };
768                if !seen_names.insert(name_str) {
769                    return Err(AdminError::Validation(format!(
770                        "duplicate variant name '{name_str}' at variants[{i}]"
771                    )));
772                }
773                match v.get("weight").and_then(Value::as_u64) {
774                    None => {
775                        return Err(AdminError::Validation(format!(
776                            "variants[{i}].weight must be a non-negative integer"
777                        )));
778                    }
779                    Some(w) if w > u64::from(u32::MAX) => {
780                        return Err(AdminError::Validation(format!(
781                            "variants[{i}].weight must not exceed {} (u32::MAX)",
782                            u32::MAX
783                        )));
784                    }
785                    _ => {}
786                }
787            }
788            Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
789        }
790        Err(_) => Err(AdminError::Validation(
791            "'variants' must be a valid JSON array".into(),
792        )),
793    }
794}
795
796// ── Row types ─────────────────────────────────────────────────────────────────
797
798#[derive(diesel::QueryableByName)]
799struct CountRow {
800    #[diesel(sql_type = diesel::sql_types::BigInt)]
801    count: i64,
802}
803
804#[derive(diesel::QueryableByName)]
805struct NameRow {
806    #[diesel(sql_type = diesel::sql_types::Text)]
807    name: String,
808}
809
810#[derive(diesel::QueryableByName)]
811struct NameStateRow {
812    #[diesel(sql_type = diesel::sql_types::Text)]
813    name: String,
814    #[diesel(sql_type = diesel::sql_types::Text)]
815    state: String,
816}
817
818#[derive(diesel::QueryableByName)]
819struct VariantNameRow {
820    #[diesel(sql_type = diesel::sql_types::Text)]
821    variant: String,
822}
823
824/// Row returned from list queries (no `exclusion_group` for brevity in list view).
825#[derive(diesel::QueryableByName)]
826struct ExperimentRow {
827    #[diesel(sql_type = diesel::sql_types::BigInt)]
828    id: i64,
829    #[diesel(sql_type = diesel::sql_types::Text)]
830    name: String,
831    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
832    description: Option<String>,
833    #[diesel(sql_type = diesel::sql_types::Text)]
834    state: String,
835    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
836    variants: Option<String>,
837    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
838    winner: Option<String>,
839    #[diesel(sql_type = diesel::sql_types::Timestamp)]
840    updated_at: chrono::NaiveDateTime,
841}
842
843impl ExperimentRow {
844    fn into_json(self) -> Value {
845        serde_json::json!({
846            "id": self.id,
847            "name": self.name,
848            "description": self.description,
849            "state": self.state,
850            "variants": self.variants.map(decode_variants),
851            "winner": self.winner,
852            "updated_at": self.updated_at.and_utc().to_rfc3339(),
853        })
854    }
855}
856
857/// Decode a `variants` `TEXT` column's raw content (JSON-serialized on write
858/// — see the admin's update/create handling) into the real `serde_json::Value`
859/// array it represents, rather than embedding the raw serialized text as an
860/// opaque `Value::String`. `render_form_widget`'s `AdminFieldKind::Json` arm
861/// re-serializes whatever `Value` it's given as JSON syntax, so wrapping
862/// already-JSON text in `Value::String` would get it double-encoded into a
863/// quoted, escaped blob instead of the editable array the form used to show
864/// (Codex review finding on #1341). A parse failure — unreachable in
865/// practice, since this column is always written as `serde_json::to_string`
866/// output — falls back to the raw text rather than panicking.
867fn decode_variants(raw: String) -> Value {
868    serde_json::from_str(&raw).unwrap_or(Value::String(raw))
869}
870
871/// Row returned from detail (get) queries — includes `exclusion_group`.
872#[derive(diesel::QueryableByName)]
873struct ExperimentDetailRow {
874    #[diesel(sql_type = diesel::sql_types::BigInt)]
875    id: i64,
876    #[diesel(sql_type = diesel::sql_types::Text)]
877    name: String,
878    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
879    description: Option<String>,
880    #[diesel(sql_type = diesel::sql_types::Text)]
881    state: String,
882    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
883    variants: Option<String>,
884    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
885    winner: Option<String>,
886    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
887    exclusion_group: Option<String>,
888    #[diesel(sql_type = diesel::sql_types::Timestamp)]
889    updated_at: chrono::NaiveDateTime,
890}
891
892impl ExperimentDetailRow {
893    fn into_json(self) -> Value {
894        serde_json::json!({
895            "id": self.id,
896            "name": self.name,
897            "description": self.description,
898            "state": self.state,
899            "variants": self.variants.map(decode_variants),
900            "winner": self.winner,
901            "exclusion_group": self.exclusion_group,
902            "updated_at": self.updated_at.and_utc().to_rfc3339(),
903        })
904    }
905}
906
907// Echo clone class (autumn-admin-plugin::history_row_shape): `HistoryRow` and
908// `history_row_to_entry` below are structurally identical to their namesakes
909// in `feature_flags.rs` — same columns, same `actor` default. They have
910// co-changed twice (commit 3d318b55: `changed_at` Timestamptz→Timestamp +
911// `.and_utc()`; commit 10e9538e: pool type), but the surrounding query is not
912// a clone: `feature_flags.rs::get_history` walks a rename-ancestry recursive
913// CTE this model has no equivalent for, so the two `get_history` bodies are a
914// real, documented divergence, not an oversight. Two instances with no
915// missed-fix defect is below Echo's merge bar (rule of three), so this is
916// linked, not merged — keep the "cli" default and the column set in sync by
917// hand; `history_row_to_entry_defaults_actor_to_cli` below pins the one
918// invariant that matters.
919#[derive(diesel::QueryableByName)]
920struct HistoryRow {
921    #[diesel(sql_type = diesel::sql_types::BigInt)]
922    id: i64,
923    #[diesel(sql_type = diesel::sql_types::Text)]
924    op: String,
925    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
926    actor: Option<String>,
927    #[diesel(sql_type = diesel::sql_types::Timestamp)]
928    changed_at: chrono::NaiveDateTime,
929}
930
931fn history_row_to_entry(r: HistoryRow) -> crate::AdminHistoryEntry {
932    crate::AdminHistoryEntry {
933        id: r.id,
934        actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
935        op: r.op,
936        request_id: None,
937        changes: vec![],
938        recorded_at: r.changed_at.and_utc(),
939    }
940}
941
942// ── Tests ─────────────────────────────────────────────────────────────────────
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    #[test]
949    fn experiment_admin_model_slug() {
950        let model = ExperimentAdminModel;
951        assert_eq!(model.slug(), "experiments");
952    }
953
954    // Pins the one invariant `history_row_to_entry` shares with its
955    // `feature_flags.rs` namesake (see the clone-class comment on
956    // `HistoryRow` above): a NULL `actor` column reads as "cli" in the admin
957    // history tab. If this drifts from `feature_flags.rs`'s copy, the two
958    // models' history tabs would show different authorship for the same kind
959    // of unattributed mutation.
960    #[test]
961    fn history_row_to_entry_defaults_actor_to_cli() {
962        let row = HistoryRow {
963            id: 1,
964            op: "update".to_owned(),
965            actor: None,
966            changed_at: chrono::Utc::now().naive_utc(),
967        };
968        assert_eq!(history_row_to_entry(row).actor, "cli");
969    }
970
971    #[test]
972    fn experiment_admin_model_display_names() {
973        let model = ExperimentAdminModel;
974        assert_eq!(model.display_name(), "Experiment");
975        assert_eq!(model.display_name_plural(), "Experiments");
976    }
977
978    #[test]
979    fn experiment_admin_model_has_history() {
980        let model = ExperimentAdminModel;
981        assert!(model.has_history(), "experiment admin must expose history");
982    }
983
984    #[test]
985    fn experiment_admin_model_has_expected_fields() {
986        let model = ExperimentAdminModel;
987        let fields = model.fields();
988        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
989        assert!(names.contains(&"name"), "must have 'name' field");
990        assert!(names.contains(&"state"), "must have 'state' field");
991        assert!(names.contains(&"variants"), "must have 'variants' field");
992        assert!(names.contains(&"winner"), "must have 'winner' field");
993        assert!(
994            names.contains(&"exclusion_group"),
995            "must have 'exclusion_group' field"
996        );
997    }
998
999    #[test]
1000    fn experiment_admin_model_state_field_has_all_lifecycle_states() {
1001        let model = ExperimentAdminModel;
1002        let state_field = model
1003            .fields()
1004            .into_iter()
1005            .find(|f| f.name == "state")
1006            .expect("state field must exist");
1007        let AdminFieldKind::Select(options) = state_field.kind else {
1008            panic!("state field must be Select");
1009        };
1010        let values: Vec<&str> = options.iter().map(|o| o.value.as_str()).collect();
1011        assert!(values.contains(&"draft"));
1012        assert!(values.contains(&"running"));
1013        assert!(values.contains(&"concluded"));
1014        assert!(values.contains(&"archived"));
1015    }
1016
1017    #[test]
1018    fn record_display_uses_experiment_name() {
1019        let model = ExperimentAdminModel;
1020        let record = serde_json::json!({"name": "checkout_v2", "state": "running"});
1021        assert_eq!(model.record_display(&record), "Experiment: checkout_v2");
1022    }
1023
1024    #[test]
1025    fn record_display_fallback_when_no_name() {
1026        let model = ExperimentAdminModel;
1027        let record = serde_json::json!({});
1028        assert_eq!(model.record_display(&record), "Experiment");
1029    }
1030
1031    #[test]
1032    fn validate_variants_json_accepts_valid_array() {
1033        let json = validate_variants_json(
1034            r#"[{"name":"control","weight":50},{"name":"treatment","weight":50}]"#,
1035        )
1036        .unwrap();
1037        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1038        assert_eq!(v.as_array().unwrap().len(), 2);
1039    }
1040
1041    #[test]
1042    fn validate_variants_json_accepts_empty_string() {
1043        assert_eq!(validate_variants_json("").unwrap(), "[]");
1044    }
1045
1046    #[test]
1047    fn validate_variants_json_rejects_missing_name() {
1048        let err = validate_variants_json(r#"[{"weight":50}]"#).unwrap_err();
1049        assert!(err.to_string().contains("name"));
1050    }
1051
1052    #[test]
1053    fn validate_variants_json_rejects_missing_weight() {
1054        let err = validate_variants_json(r#"[{"name":"control"}]"#).unwrap_err();
1055        assert!(err.to_string().contains("weight"));
1056    }
1057
1058    #[test]
1059    fn validate_variants_json_rejects_invalid_json() {
1060        let err = validate_variants_json("{not json}").unwrap_err();
1061        assert!(err.to_string().contains("JSON"));
1062    }
1063
1064    #[test]
1065    fn validate_variants_json_rejects_empty_name() {
1066        let err = validate_variants_json(r#"[{"name":"","weight":100}]"#).unwrap_err();
1067        assert!(
1068            err.to_string().contains("empty"),
1069            "expected empty-name error, got: {err}"
1070        );
1071    }
1072
1073    #[test]
1074    fn validate_variants_json_rejects_whitespace_only_name() {
1075        let err = validate_variants_json(r#"[{"name":"   ","weight":100}]"#).unwrap_err();
1076        assert!(
1077            err.to_string().contains("empty"),
1078            "expected empty-name error, got: {err}"
1079        );
1080    }
1081
1082    #[test]
1083    fn decode_variants_parses_stored_json_text_into_a_real_value() {
1084        // Codex review finding on #1341: `variants` is a TEXT column storing
1085        // serialized JSON. Embedding that raw text as `Value::String` (the
1086        // old behavior) made `render_form_widget`'s `AdminFieldKind::Json` arm
1087        // double-encode it into a quoted, escaped blob once that arm started
1088        // re-serializing `Value::String` as JSON syntax (to correctly
1089        // round-trip a GENUINE top-level JSON string scalar). Decoding here
1090        // means the edit form sees the real array and displays it correctly.
1091        let decoded = decode_variants(r#"[{"name":"control","weight":50}]"#.to_owned());
1092        assert_eq!(
1093            decoded,
1094            serde_json::json!([{"name": "control", "weight": 50}])
1095        );
1096        assert!(matches!(decoded, Value::Array(_)));
1097    }
1098
1099    #[test]
1100    fn decode_variants_falls_back_to_raw_text_on_unparseable_input() {
1101        // Unreachable through normal use (this column is always written as
1102        // `serde_json::to_string` output), but must not panic on legacy or
1103        // hand-edited data.
1104        let decoded = decode_variants("not json".to_owned());
1105        assert_eq!(decoded, Value::String("not json".to_owned()));
1106    }
1107}