feat(triggers): show a materialized column in pic triggers ls --app (D1)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -4121,6 +4121,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 3,
|
retry_max_attempts: 3,
|
||||||
retry_backoff: BackoffShape::Exponential,
|
retry_backoff: BackoffShape::Exponential,
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ pub enum TriggerRepoError {
|
|||||||
|
|
||||||
/// Parent-table row plus the per-kind detail merged in. Serialized
|
/// Parent-table row plus the per-kind detail merged in. Serialized
|
||||||
/// back to admin clients via the JSON API.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Trigger {
|
pub struct Trigger {
|
||||||
pub id: TriggerId,
|
pub id: TriggerId,
|
||||||
@@ -71,6 +74,11 @@ pub struct Trigger {
|
|||||||
/// collection, not per-app ones). Also part of the apply diff identity.
|
/// collection, not per-app ones). Also part of the apply diff identity.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shared: bool,
|
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 dispatch_mode: TriggerDispatchMode,
|
||||||
pub retry_max_attempts: u32,
|
pub retry_max_attempts: u32,
|
||||||
pub retry_backoff: BackoffShape,
|
pub retry_backoff: BackoffShape,
|
||||||
@@ -907,6 +915,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -976,6 +985,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1040,6 +1050,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1110,6 +1121,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1180,6 +1192,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1245,6 +1258,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1308,6 +1322,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
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<Vec<Trigger>, TriggerRepoError> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||||
"SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
|
"SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, materialized_from, \
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
dispatch_mode, retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal, created_at, updated_at \
|
registered_by_principal, created_at, updated_at \
|
||||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||||
)
|
)
|
||||||
@@ -1756,6 +1771,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1968,6 +1984,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
|||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
sealed: parent.sealed,
|
sealed: parent.sealed,
|
||||||
shared: parent.shared,
|
shared: parent.shared,
|
||||||
|
materialized: parent.materialized_from.is_some(),
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -2024,6 +2041,11 @@ struct TriggerRow {
|
|||||||
// §11.6: same `default` treatment as `sealed`.
|
// §11.6: same `default` treatment as `sealed`.
|
||||||
#[sqlx(default)]
|
#[sqlx(default)]
|
||||||
shared: bool,
|
shared: bool,
|
||||||
|
// §4.5 M5: set on a materialized copy of a group stateful template; NULL on
|
||||||
|
// a hand-authored trigger. `default` like the flags above — only the app/
|
||||||
|
// group list queries SELECT it.
|
||||||
|
#[sqlx(default)]
|
||||||
|
materialized_from: Option<Uuid>,
|
||||||
dispatch_mode: String,
|
dispatch_mode: String,
|
||||||
retry_max_attempts: i32,
|
retry_max_attempts: i32,
|
||||||
retry_backoff: String,
|
retry_backoff: String,
|
||||||
|
|||||||
@@ -910,6 +910,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -942,6 +943,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -974,6 +976,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 1,
|
retry_max_attempts: 1,
|
||||||
retry_backoff: BackoffShape::Constant,
|
retry_backoff: BackoffShape::Constant,
|
||||||
@@ -1007,6 +1010,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 3,
|
retry_max_attempts: 3,
|
||||||
retry_backoff: BackoffShape::Exponential,
|
retry_backoff: BackoffShape::Exponential,
|
||||||
@@ -1058,6 +1062,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1091,6 +1096,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1123,6 +1129,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1203,6 +1210,7 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
sealed: false,
|
sealed: false,
|
||||||
shared: false,
|
shared: false,
|
||||||
|
materialized: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
|
|||||||
@@ -1760,6 +1760,11 @@ pub struct TriggerDto {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub dispatch_mode: String,
|
pub dispatch_mode: String,
|
||||||
pub retry_max_attempts: u32,
|
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<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub details: Value,
|
pub details: Value,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
|||||||
"enabled",
|
"enabled",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"retry_max",
|
"retry_max",
|
||||||
|
"materialized",
|
||||||
"created_at",
|
"created_at",
|
||||||
]);
|
]);
|
||||||
for t in resp.triggers {
|
for t in resp.triggers {
|
||||||
@@ -33,6 +34,7 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
|||||||
t.enabled.to_string(),
|
t.enabled.to_string(),
|
||||||
t.dispatch_mode,
|
t.dispatch_mode,
|
||||||
t.retry_max_attempts.to_string(),
|
t.retry_max_attempts.to_string(),
|
||||||
|
t.materialized.to_string(),
|
||||||
t.created_at.to_rfc3339(),
|
t.created_at.to_rfc3339(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ fn app_cron_count(env: &common::TestEnv, app: &str) -> usize {
|
|||||||
app_trigger_count(env, app, "cron")
|
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<String> {
|
||||||
|
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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[test]
|
#[test]
|
||||||
fn group_cron_template_materializes_for_descendant_apps() {
|
fn group_cron_template_materializes_for_descendant_apps() {
|
||||||
@@ -110,6 +126,13 @@ fn group_cron_template_materializes_for_descendant_apps() {
|
|||||||
1,
|
1,
|
||||||
"a descendant app must have a materialized cron trigger"
|
"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).
|
// Re-apply of the group is a NoOp for materialization (no duplicate copy).
|
||||||
common::pic_as(&env)
|
common::pic_as(&env)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ fn create_kv_and_show_in_ls() {
|
|||||||
"enabled",
|
"enabled",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"retry_max",
|
"retry_max",
|
||||||
|
"materialized",
|
||||||
"created_at"
|
"created_at"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -67,6 +68,11 @@ fn create_kv_and_show_in_ls() {
|
|||||||
.find(|c| c.get(1).copied() == Some("kv"))
|
.find(|c| c.get(1).copied() == Some("kv"))
|
||||||
.unwrap_or_else(|| panic!("kv trigger missing from ls: {stdout}"));
|
.unwrap_or_else(|| panic!("kv trigger missing from ls: {stdout}"));
|
||||||
assert_eq!(row[2], script_id);
|
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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
|||||||
Reference in New Issue
Block a user