From 137103a4290e8d35013fda358cdcded6c6462534 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 2 Jul 2026 21:30:32 +0200 Subject: [PATCH] feat(triggers): show a `materialized` column in `pic triggers ls --app` (D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A materialized copy of an M5 group stateful template (cron/queue/email) now reads as `materialized = true` — inherited, read-only — distinct from a hand-authored trigger. Threads a derived `materialized` bool (= `materialized_from IS NOT NULL`) row → domain → API → CLI, mirroring the `sealed`/`shared` columns; `list_for_app` SELECTs `materialized_from`. No migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_service.rs | 1 + crates/manager-core/src/trigger_repo.rs | 26 +++++++++++++++++-- crates/manager-core/src/triggers_api.rs | 8 ++++++ crates/picloud-cli/src/client.rs | 5 ++++ crates/picloud-cli/src/cmds/triggers.rs | 2 ++ .../picloud-cli/tests/stateful_templates.rs | 23 ++++++++++++++++ crates/picloud-cli/tests/triggers.rs | 6 +++++ 7 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 9316554..c06c38d 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -4121,6 +4121,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 3, retry_backoff: BackoffShape::Exponential, diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index a51216c..a9f01c5 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -50,6 +50,9 @@ pub enum TriggerRepoError { /// Parent-table row plus the per-kind detail merged in. Serialized /// back to admin clients via the JSON API. +// enabled + sealed + shared + materialized are distinct trigger facets, not a +// bit-packed config — a struct is the right shape. +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Trigger { pub id: TriggerId, @@ -71,6 +74,11 @@ pub struct Trigger { /// collection, not per-app ones). Also part of the apply diff identity. #[serde(default)] pub shared: bool, + /// §4.5 M5: `true` for a materialized copy of a group stateful template + /// (`materialized_from` is set). Read-only — inherited, not app-authored. + /// Derived, not stored directly (see `TriggerRow::materialized_from`). + #[serde(default)] + pub materialized: bool, pub dispatch_mode: TriggerDispatchMode, pub retry_max_attempts: u32, pub retry_backoff: BackoffShape, @@ -907,6 +915,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -976,6 +985,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1040,6 +1050,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1110,6 +1121,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1180,6 +1192,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1245,6 +1258,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1308,6 +1322,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1352,8 +1367,8 @@ impl TriggerRepo for PostgresTriggerRepo { async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError> { let parents: Vec = sqlx::query_as( - "SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \ - retry_max_attempts, retry_backoff, retry_base_ms, \ + "SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, materialized_from, \ + dispatch_mode, retry_max_attempts, retry_backoff, retry_base_ms, \ registered_by_principal, created_at, updated_at \ FROM triggers WHERE app_id = $1 ORDER BY created_at DESC", ) @@ -1756,6 +1771,7 @@ impl TriggerRepo for PostgresTriggerRepo { enabled: parent.enabled, sealed: parent.sealed, shared: parent.shared, + materialized: parent.materialized_from.is_some(), dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1968,6 +1984,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result, dispatch_mode: String, retry_max_attempts: i32, retry_backoff: String, diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index 06816e6..a6e98d7 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -910,6 +910,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, @@ -942,6 +943,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, @@ -974,6 +976,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 1, retry_backoff: BackoffShape::Constant, @@ -1007,6 +1010,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 3, retry_backoff: BackoffShape::Exponential, @@ -1058,6 +1062,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, @@ -1091,6 +1096,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, @@ -1123,6 +1129,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, @@ -1203,6 +1210,7 @@ mod tests { enabled: true, sealed: false, shared: false, + materialized: false, dispatch_mode: req.dispatch_mode, retry_max_attempts: req.retry_max_attempts, retry_backoff: req.retry_backoff, diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 78dd1f5..791ff47 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1760,6 +1760,11 @@ pub struct TriggerDto { pub enabled: bool, pub dispatch_mode: String, pub retry_max_attempts: u32, + /// §4.5 M5: true for a materialized copy of a group stateful template + /// (inherited, read-only). `default` so an older server without the field + /// still deserializes. + #[serde(default)] + pub materialized: bool, pub created_at: DateTime, #[serde(default)] pub details: Value, diff --git a/crates/picloud-cli/src/cmds/triggers.rs b/crates/picloud-cli/src/cmds/triggers.rs index b973b08..043ca5e 100644 --- a/crates/picloud-cli/src/cmds/triggers.rs +++ b/crates/picloud-cli/src/cmds/triggers.rs @@ -23,6 +23,7 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { "enabled", "dispatch", "retry_max", + "materialized", "created_at", ]); for t in resp.triggers { @@ -33,6 +34,7 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { t.enabled.to_string(), t.dispatch_mode, t.retry_max_attempts.to_string(), + t.materialized.to_string(), t.created_at.to_rfc3339(), ]); } diff --git a/crates/picloud-cli/tests/stateful_templates.rs b/crates/picloud-cli/tests/stateful_templates.rs index 3b85d47..16fca99 100644 --- a/crates/picloud-cli/tests/stateful_templates.rs +++ b/crates/picloud-cli/tests/stateful_templates.rs @@ -52,6 +52,22 @@ fn app_cron_count(env: &common::TestEnv, app: &str) -> usize { app_trigger_count(env, app, "cron") } +/// The `materialized` cell (index 6) of the first row of `kind` in +/// `pic triggers ls --app`. Columns: id kind script_id enabled dispatch +/// retry_max materialized created_at. +fn app_trigger_materialized(env: &common::TestEnv, app: &str, kind: &str) -> Option { + let out = common::pic_as(env) + .args(["triggers", "ls", "--app", app]) + .output() + .expect("triggers ls"); + String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(common::cells) + .find(|c| c.get(1) == Some(&kind)) + .and_then(|c| c.get(6).map(|s| (*s).to_string())) +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn group_cron_template_materializes_for_descendant_apps() { @@ -110,6 +126,13 @@ fn group_cron_template_materializes_for_descendant_apps() { 1, "a descendant app must have a materialized cron trigger" ); + // D1: the materialized copy reads `materialized = true` (inherited, + // read-only) — distinct from a hand-authored trigger. + assert_eq!( + app_trigger_materialized(&env, &app, "cron").as_deref(), + Some("true"), + "a materialized cron copy must show materialized = true" + ); // Re-apply of the group is a NoOp for materialization (no duplicate copy). common::pic_as(&env) diff --git a/crates/picloud-cli/tests/triggers.rs b/crates/picloud-cli/tests/triggers.rs index 98e8ee1..427806b 100644 --- a/crates/picloud-cli/tests/triggers.rs +++ b/crates/picloud-cli/tests/triggers.rs @@ -57,6 +57,7 @@ fn create_kv_and_show_in_ls() { "enabled", "dispatch", "retry_max", + "materialized", "created_at" ] ); @@ -67,6 +68,11 @@ fn create_kv_and_show_in_ls() { .find(|c| c.get(1).copied() == Some("kv")) .unwrap_or_else(|| panic!("kv trigger missing from ls: {stdout}")); assert_eq!(row[2], script_id); + // D1: a hand-authored trigger is not materialized. + assert_eq!( + row[6], "false", + "an app-authored trigger shows materialized = false" + ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"]