From 55cf995eda335455ba7ea8a025c98c1439ea35d7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 23 Jun 2026 19:36:40 +0200 Subject: [PATCH] feat(enabled): scripts/routes enabled column + declarative data path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers already carried `enabled`; this adds it to scripts and routes and threads it through the declarative project tool. Runtime honoring (disabled route 404 / script non-invocable / dispatcher fire-time re-check) is the next commit — this change only stores and reconciles the flag. - Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts + routes. Default true ⇒ no behavior change on migrate. - `Script`/`Route` (shared) gain `enabled` (serde default true via the new `picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it. - Apply diff treats `enabled` as a declarative field (omitted ⇒ active): `script_update_reason` + `diff_routes` detect a toggle as an Update, and the create/update/insert paths persist it. The bound-plan `state_token` re-includes script/route `enabled` (removed in the earlier review fix precisely because the diff didn't key on it yet — now it does). - CLI manifest model + `build_bundle` + `pull` round-trip `enabled` (serialized only when false; omitted ⇒ active). `pic init` scaffold and the interactive script/route create paths default active. Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) + cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0045_scripts_routes_enabled.sql | 8 +++ crates/manager-core/src/api.rs | 10 +-- crates/manager-core/src/app_bootstrap.rs | 2 + crates/manager-core/src/apply_service.rs | 62 ++++++++++++++++++- crates/manager-core/src/invoke_service.rs | 1 + crates/manager-core/src/repo.rs | 16 ++++- crates/manager-core/src/route_admin.rs | 3 + crates/manager-core/src/route_repo.rs | 19 +++--- crates/manager-core/src/triggers_api.rs | 1 + crates/picloud-cli/src/cmds/init.rs | 2 + crates/picloud-cli/src/cmds/plan.rs | 1 + crates/picloud-cli/src/cmds/pull.rs | 2 + crates/picloud-cli/src/manifest.rs | 32 ++++++++++ crates/shared/src/lib.rs | 8 +++ crates/shared/src/route.rs | 6 ++ crates/shared/src/script.rs | 6 ++ 16 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 crates/manager-core/migrations/0045_scripts_routes_enabled.sql diff --git a/crates/manager-core/migrations/0045_scripts_routes_enabled.sql b/crates/manager-core/migrations/0045_scripts_routes_enabled.sql new file mode 100644 index 0000000..6025ee2 --- /dev/null +++ b/crates/manager-core/migrations/0045_scripts_routes_enabled.sql @@ -0,0 +1,8 @@ +-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half. +-- Triggers already carry `enabled` (0008) honored at match/schedule time; +-- this adds the same toggle to scripts and routes. A disabled script is not +-- invocable; a disabled route 404s (indistinguishable from absent). Default +-- TRUE so every existing row stays active — no behavior change on migrate. + +ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index ff15f33..fd38f23 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -246,6 +246,8 @@ async fn create_script( } else { Some(input.sandbox) }, + // Scripts are created active; toggling is a dedicated path. + enabled: true, imports: validated.imports, }) .await?; @@ -345,6 +347,7 @@ async fn update_script( memory_limit_mb: input.memory_limit_mb, sandbox: input.sandbox, kind: input.kind, + enabled: None, imports: imports_for_patch, }, ) @@ -476,10 +479,9 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match &self { Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::AppNotFound(_) - | Self::BadRequest(_) - | Self::Invalid(_) - | Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), + Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), Self::AuthzRepo(e) => { diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 187c912..77f2dd5 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -68,6 +68,7 @@ async fn seed_into( timeout_seconds: Some(5), memory_limit_mb: None, sandbox: None, + enabled: true, imports: Vec::new(), }) .await?; @@ -85,6 +86,7 @@ async fn seed_into( // `curl -d '{"name":"X"}' /hello` work out of the box. method: None, dispatch_mode: picloud_shared::DispatchMode::Sync, + enabled: true, }) .await?; diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index f904be9..3ef2c14 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -90,6 +90,10 @@ pub struct BundleScript { pub memory_limit_mb: Option, #[serde(default)] pub sandbox: Option, + /// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not + /// leave-as-is): a script absent the key is `enabled = true`. + #[serde(default = "picloud_shared::default_true")] + pub enabled: bool, } #[derive(Debug, Clone, Deserialize)] @@ -107,6 +111,9 @@ pub struct BundleRoute { pub path: String, #[serde(default)] pub dispatch_mode: DispatchMode, + /// Three-state lifecycle (§4.3); omitted ⇒ active. + #[serde(default = "picloud_shared::default_true")] + pub enabled: bool, } /// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`). @@ -495,6 +502,7 @@ impl ApplyService { timeout_seconds: bs.timeout_seconds, memory_limit_mb: bs.memory_limit_mb, sandbox: bs.sandbox, + enabled: bs.enabled, imports: self.script_imports(bs)?, }; let created = insert_script_tx(&mut tx, &new).await.map_err(map_repo)?; @@ -515,6 +523,8 @@ impl ApplyService { memory_limit_mb: bs.memory_limit_mb, sandbox: bs.sandbox, kind: Some(bs.kind), + // Declarative: always reconcile to the manifest's value. + enabled: Some(bs.enabled), imports: Some(self.script_imports(bs)?), }; update_script_tx(&mut tx, cur.id, &patch) @@ -1080,6 +1090,14 @@ fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option if cur.kind != desired.kind { return Some("kind changed".into()); } + // Declarative (default true): always reconcile to the manifest's value. + if cur.enabled != desired.enabled { + return Some(if desired.enabled { + "enabled".into() + } else { + "disabled".into() + }); + } // Sparse like the other optional fields: an omitted (`None`) description // means "leave as-is", so only an explicitly-set value that differs // forces an update. (Clearing a description is done in the dashboard.) @@ -1161,6 +1179,7 @@ fn diff_routes( if cur_script != Some(r.script.as_str()) || cur.dispatch_mode != r.dispatch_mode || cur.host_param_name != r.host_param_name + || cur.enabled != r.enabled { out.push(ResourceChange { op: Op::Update, @@ -1527,6 +1546,7 @@ async fn insert_bundle_route( path: br.path.clone(), method: br.method.clone(), dispatch_mode: br.dispatch_mode, + enabled: br.enabled, }; insert_route_tx(tx, &new).await.map_err(map_repo)?; Ok(()) @@ -1622,11 +1642,16 @@ pub fn state_token(current: &CurrentState) -> String { + current.secret_names.len(), ); for s in ¤t.scripts { - parts.push(format!("s|{}|{}", s.name.to_lowercase(), s.version)); + parts.push(format!( + "s|{}|{}|{}", + s.name.to_lowercase(), + s.version, + s.enabled + )); } for r in ¤t.routes { parts.push(format!( - "r|{}|{:?}|{:?}|{}", + "r|{}|{:?}|{:?}|{}|{}", route_key( r.method.as_deref(), r.host_kind, @@ -1637,6 +1662,7 @@ pub fn state_token(current: &CurrentState) -> String { r.script_id, r.dispatch_mode, r.host_param_name.as_deref().unwrap_or(""), + r.enabled, )); } // Mirror exactly what the trigger diff keys on: `current_trigger_identity` @@ -1698,6 +1724,7 @@ mod tests { timeout_seconds: 30, sandbox: ScriptSandbox::empty(), memory_limit_mb: 256, + enabled: true, created_at: Utc::now(), updated_at: Utc::now(), } @@ -1712,6 +1739,7 @@ mod tests { timeout_seconds: None, memory_limit_mb: None, sandbox: None, + enabled: true, } } @@ -1798,6 +1826,7 @@ mod tests { path: "/p".into(), method: Some("POST".into()), dispatch_mode: DispatchMode::Sync, + enabled: true, created_at: Utc::now(), }; let current = CurrentState { @@ -1817,6 +1846,7 @@ mod tests { path_kind: PathKind::Exact, path: "/p".into(), dispatch_mode: DispatchMode::Sync, + enabled: true, }], triggers: vec![], secrets: vec![], @@ -2047,6 +2077,34 @@ mod tests { ); } + #[test] + fn enabled_is_declarative_and_diffed() { + // A live-active script the manifest marks disabled → Update; the token + // is sensitive to the flag so an out-of-band toggle is detectable. + let cur = script("a", "let x = 1;"); // enabled: true + let base = CurrentState { + scripts: vec![cur.clone()], + ..CurrentState::default() + }; + let mut disable = empty_bundle(); + let mut bs = bundle_script("a", "let x = 1;"); + bs.enabled = false; + disable.scripts = vec![bs]; + assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update); + + let mut flipped = cur; + flipped.enabled = false; + let toggled = CurrentState { + scripts: vec![flipped], + ..CurrentState::default() + }; + assert_ne!( + state_token(&base), + state_token(&toggled), + "token must flip on an enabled change" + ); + } + #[test] fn trigger_diff_create_noop_delete() { let s = script("h", "x"); diff --git a/crates/manager-core/src/invoke_service.rs b/crates/manager-core/src/invoke_service.rs index faa979a..b0fe4ba 100644 --- a/crates/manager-core/src/invoke_service.rs +++ b/crates/manager-core/src/invoke_service.rs @@ -313,6 +313,7 @@ mod tests { timeout_seconds: 30, memory_limit_mb: 64, sandbox: ScriptSandbox::default(), + enabled: true, created_at: Utc::now(), updated_at: Utc::now(), } diff --git a/crates/manager-core/src/repo.rs b/crates/manager-core/src/repo.rs index 7777188..b2c5f65 100644 --- a/crates/manager-core/src/repo.rs +++ b/crates/manager-core/src/repo.rs @@ -154,6 +154,8 @@ pub struct NewScript { /// Sandbox overrides; `None` means store an empty object (use /// platform defaults at exec time). pub sandbox: Option, + /// Three-state lifecycle (§4.3). Create active by default. + pub enabled: bool, /// v1.1.3: literal-path `import ""` declarations extracted /// from the source. The repo writes these into `script_imports` /// transactionally with the script row. Empty when validation @@ -176,6 +178,8 @@ pub struct ScriptPatch { /// rejects unsafe transitions (e.g. endpoint→module when routes /// or triggers reference the script). pub kind: Option, + /// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it. + pub enabled: Option, /// v1.1.3: when `source` is also `Some`, the repo replaces the /// `script_imports` edges for this script with these names. /// `None` keeps the existing edges untouched (a name/description @@ -203,7 +207,8 @@ impl PostgresScriptRepository { /// adding `kind` (v1.1.3) and future columns can't accidentally skip /// one query. const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \ - timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at"; + timeout_seconds, memory_limit_mb, sandbox, enabled, \ + created_at, updated_at"; #[async_trait] impl ScriptRepository for PostgresScriptRepository { @@ -393,8 +398,8 @@ pub(crate) async fn insert_script_tx( let res = sqlx::query_as::<_, ScriptRow>(&format!( "INSERT INTO scripts ( \ app_id, name, description, source, kind, \ - timeout_seconds, memory_limit_mb, sandbox \ - ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \ + timeout_seconds, memory_limit_mb, sandbox, enabled \ + ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \ RETURNING {SCRIPT_SELECT_COLS}" )) .bind(input.app_id.into_inner()) @@ -405,6 +410,7 @@ pub(crate) async fn insert_script_tx( .bind(input.timeout_seconds) .bind(input.memory_limit_mb) .bind(sandbox_json) + .bind(input.enabled) .fetch_one(&mut **tx) .await; let script: Script = match res { @@ -441,6 +447,7 @@ pub(crate) async fn update_script_tx( memory_limit_mb = COALESCE($7, memory_limit_mb), \ sandbox = COALESCE($8, sandbox), \ kind = COALESCE($9, kind), \ + enabled = COALESCE($10, enabled), \ version = version + 1, \ updated_at = NOW() \ WHERE id = $1 \ @@ -455,6 +462,7 @@ pub(crate) async fn update_script_tx( .bind(patch.memory_limit_mb) .bind(sandbox_json) .bind(patch.kind.map(ScriptKind::as_str)) + .bind(patch.enabled) .fetch_optional(&mut **tx) .await; let script: Script = match res { @@ -505,6 +513,7 @@ struct ScriptRow { timeout_seconds: i32, memory_limit_mb: i32, sandbox: serde_json::Value, + enabled: bool, created_at: chrono::DateTime, updated_at: chrono::DateTime, } @@ -531,6 +540,7 @@ impl From for Script { timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30), memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256), sandbox, + enabled: r.enabled, created_at: r.created_at, updated_at: r.updated_at, } diff --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs index f2d04f8..6edd32b 100644 --- a/crates/manager-core/src/route_admin.rs +++ b/crates/manager-core/src/route_admin.rs @@ -229,6 +229,8 @@ async fn create_route( path: normalized_path, method: input.method, dispatch_mode: input.dispatch_mode, + // Routes are created active; toggling is a dedicated path. + enabled: true, }) .await?; refresh_table(&state).await?; @@ -625,6 +627,7 @@ mod tests { path: path.to_string(), method: None, dispatch_mode: DispatchMode::default(), + enabled: true, created_at: chrono::Utc::now(), } } diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs index 4a80f29..e5ea465 100644 --- a/crates/manager-core/src/route_repo.rs +++ b/crates/manager-core/src/route_repo.rs @@ -21,6 +21,8 @@ pub struct NewRoute { pub path: String, pub method: Option, pub dispatch_mode: DispatchMode, + /// Three-state lifecycle (§4.3). Create active by default. + pub enabled: bool, } #[async_trait] @@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository { async fn list_all(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, created_at \ + path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes ORDER BY created_at", ) .fetch_all(&self.pool) @@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository { async fn get(&self, route_id: Uuid) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, created_at \ + path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE id = $1", ) .bind(route_id) @@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository { async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, created_at \ + path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE app_id = $1 ORDER BY created_at", ) .bind(app_id.into_inner()) @@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository { ) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, created_at \ + path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE script_id = $1 ORDER BY created_at", ) .bind(script_id.into_inner()) @@ -173,10 +175,10 @@ pub(crate) async fn insert_route_tx( let res = sqlx::query_as::<_, RouteRow>( "INSERT INTO routes ( \ app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode \ - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ + path_kind, path, method, dispatch_mode, enabled \ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ RETURNING id, app_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, created_at", + path_kind, path, method, dispatch_mode, enabled, created_at", ) .bind(input.app_id.into_inner()) .bind(input.script_id.into_inner()) @@ -187,6 +189,7 @@ pub(crate) async fn insert_route_tx( .bind(&input.path) .bind(input.method.as_deref()) .bind(input.dispatch_mode.as_str()) + .bind(input.enabled) .fetch_one(&mut **tx) .await; match res { @@ -231,6 +234,7 @@ struct RouteRow { path: String, method: Option, dispatch_mode: String, + enabled: bool, created_at: chrono::DateTime, } @@ -255,6 +259,7 @@ impl From for Route { path: r.path, method: r.method, dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync), + enabled: r.enabled, created_at: r.created_at, } } diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index ecb1803..a65d5c5 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -1347,6 +1347,7 @@ mod tests { timeout_seconds: 30, sandbox: picloud_shared::ScriptSandbox::default(), memory_limit_mb: 256, + enabled: true, created_at: now, updated_at: now, }, diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index ee96e3c..11f702a 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -122,6 +122,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { timeout_seconds: None, memory_limit_mb: None, sandbox: None, + enabled: true, }], routes: vec![ManifestRoute { script: "hello".into(), @@ -132,6 +133,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { path_kind: PathKind::Exact, path: "/hello".into(), dispatch_mode: DispatchMode::Sync, + enabled: true, }], triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 2ebdde2..c0c5290 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -61,6 +61,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { if let Some(sb) = &s.sandbox { obj.insert("sandbox".into(), serde_json::to_value(sb)?); } + obj.insert("enabled".into(), json!(s.enabled)); scripts.push(Value::Object(obj)); } diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 7e7269e..11f3b2d 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -49,6 +49,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> { path_kind: r.path_kind, path: r.path, dispatch_mode: r.dispatch_mode, + enabled: r.enabled, }); } } @@ -90,6 +91,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> { } else { Some(s.sandbox) }, + enabled: s.enabled, }); } diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 02008b4..d45f7aa 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -84,6 +84,13 @@ pub struct ManifestScript { /// Per-script sandbox overrides; omitted entirely when no knob is set. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, + /// Three-state lifecycle (§4.3): `false` deploys the script inert (not + /// invocable). Omitted ⇒ active; only serialized when disabled. + #[serde( + default = "picloud_shared::default_true", + skip_serializing_if = "is_true" + )] + pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -102,6 +109,13 @@ pub struct ManifestRoute { pub path: String, #[serde(default, skip_serializing_if = "is_sync")] pub dispatch_mode: DispatchMode, + /// Three-state lifecycle (§4.3): `false` deploys the route inert (404). + /// Omitted ⇒ active; only serialized when disabled. + #[serde( + default = "picloud_shared::default_true", + skip_serializing_if = "is_true" + )] + pub enabled: bool, } /// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …). @@ -244,6 +258,11 @@ fn is_sync(mode: &DispatchMode) -> bool { *mode == DispatchMode::Sync } +/// Skip-serialize helper: `enabled` defaults true, so only emit it when false. +fn is_true(b: &bool) -> bool { + *b +} + fn default_timezone() -> String { "UTC".to_string() } @@ -268,6 +287,7 @@ mod tests { timeout_seconds: Some(10), memory_limit_mb: Some(256), sandbox: None, + enabled: true, }, ManifestScript { name: "lib".into(), @@ -280,6 +300,7 @@ mod tests { max_operations: Some(5_000_000), ..ScriptSandbox::empty() }), + enabled: false, }, ], routes: vec![ManifestRoute { @@ -291,6 +312,7 @@ mod tests { path_kind: PathKind::Exact, path: "/posts".into(), dispatch_mode: DispatchMode::Sync, + enabled: true, }], triggers: ManifestTriggers { cron: vec![CronTriggerSpec { @@ -337,6 +359,16 @@ mod tests { ); // Module kind IS non-default → emitted. assert!(text.contains("kind = \"module\""), "got:\n{text}"); + // `enabled` defaults true → omitted for the active script/route, but + // the disabled `lib` script emits `enabled = false`. + assert!( + text.contains("enabled = false"), + "a disabled entity must emit enabled:\n{text}" + ); + assert!( + !text.contains("enabled = true"), + "active entities must omit the default:\n{text}" + ); } #[test] diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 3eb6e76..d70ddf6 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -39,6 +39,14 @@ pub mod users; pub mod validator; pub mod version; +/// serde `default` for `enabled`-style boolean fields that should default to +/// `true` when absent from the wire (bool's own `Default` is `false`). Shared +/// by `Script`/`Route`, the apply `Bundle` types, and the CLI manifest. +#[must_use] +pub fn default_true() -> bool { + true +} + pub use app::{App, AppDomain, DomainShape}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError}; diff --git a/crates/shared/src/route.rs b/crates/shared/src/route.rs index ee0e751..b0106b4 100644 --- a/crates/shared/src/route.rs +++ b/crates/shared/src/route.rs @@ -99,5 +99,11 @@ pub struct Route { #[serde(default)] pub dispatch_mode: DispatchMode, + /// Three-state lifecycle (§4.3): `false` means the route is deployed but + /// inert — it is dropped from the compiled match table, so a request 404s + /// indistinguishably from an absent route. Defaults `true`. + #[serde(default = "crate::default_true")] + pub enabled: bool, + pub created_at: DateTime, } diff --git a/crates/shared/src/script.rs b/crates/shared/src/script.rs index 5e7103a..7fd72d3 100644 --- a/crates/shared/src/script.rs +++ b/crates/shared/src/script.rs @@ -122,6 +122,12 @@ pub struct Script { /// have to add it back when that's built. pub memory_limit_mb: u32, + /// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the + /// script is not invocable (route 404s, trigger doesn't fire) but stays + /// as desired state (not pruned). Defaults `true`. + #[serde(default = "crate::default_true")] + pub enabled: bool, + pub created_at: DateTime, pub updated_at: DateTime, }