diff --git a/crates/outer/src/deploy/manifest.rs b/crates/outer/src/deploy/manifest.rs new file mode 100644 index 0000000..6c9ccdd --- /dev/null +++ b/crates/outer/src/deploy/manifest.rs @@ -0,0 +1,204 @@ +//! The live manifest celld publishes per upload: `deploy/current.json` +//! carries the versioned `prefix`, and the authoritative deployed config is +//! the manifest at `fleets/{slug}/{prefix}/manifest.json`. There is no +//! `fleets/{slug}/wrangler.json` in the bucket, and the worktree +//! `dist/wrangler.json` is a wipeable build artifact — so the manifest is +//! the only source of truth. This is the ONE manifest reader for every +//! consumer: the console data panels parse bindings out of it, and the +//! deploy pipeline reads the live version's declared D1 databases out of it +//! to decide the migrate/upload order. `vars` — which may carry injected +//! secret values — is never read here and never lands in a response. +//! +//! A missing pointer or manifest means never-deployed (`None`); malformed +//! JSON or an unsafe prefix is an error. + +use crate::deploy::migrate::{self, D1Database}; +use crate::store::Store; + +/// The deploy pointer's versioned prefix must stay a relative deploy path: +/// it becomes a store key suffix, so anything absolute or escaping fails the +/// read instead of probing an unintended key. +pub fn validate_prefix(prefix: &str) -> anyhow::Result<()> { + if prefix.is_empty() + || prefix.starts_with('/') + || prefix.contains("..") + || prefix.contains('\\') + || prefix.contains('\0') + { + anyhow::bail!("deploy pointer prefix {prefix:?} is not a relative deploy path"); + } + Ok(()) +} + +/// The live manifest for `slug`, via the scoped store. A missing pointer or +/// manifest means never-deployed (`None`); malformed JSON or an unsafe +/// prefix is an error. +pub async fn deployed_manifest( + store: &Store, + slug: &str, +) -> anyhow::Result> { + let pointer_key = format!("fleets/{slug}/deploy/current.json"); + let Some(pointer) = store.get(&pointer_key).await? else { + return Ok(None); + }; + let pointer: serde_json::Value = serde_json::from_slice(&pointer) + .map_err(|e| anyhow::anyhow!("parsing deploy pointer: {e:#}"))?; + let prefix = pointer + .get("prefix") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("deploy pointer has no prefix"))?; + validate_prefix(prefix)?; + let key = format!("fleets/{slug}/{prefix}/manifest.json"); + let Some(manifest) = store.get(&key).await? else { + return Ok(None); + }; + let manifest: serde_json::Value = serde_json::from_slice(&manifest) + .map_err(|e| anyhow::anyhow!("parsing deployed manifest: {e:#}"))?; + Ok(Some(manifest)) +} + +/// The live manifest's declared D1 databases, from `raw_metadata.bindings`: +/// `type: "d1"` entries map `name` → binding and +/// `database_name`/`database_id` → identity. Other binding types are ignored. +/// `vars` — which may carry injected secret values — is never read here and +/// never lands in the synthesized cwd config, so secrets can't leak through +/// the materialized project dir or the fake-celld-style logs that read it. +/// +/// An asset-only live version (no `main_module`) declares nothing: celld +/// serves it without a worker, so there is no live code that could reach a +/// database, and migrations must not wait on it. +pub fn manifest_dbs(manifest: &serde_json::Value) -> anyhow::Result> { + if manifest + .get("main_module") + .and_then(|v| v.as_str()) + .is_none() + { + return Ok(Vec::new()); + } + let Some(bindings) = manifest.pointer("/raw_metadata/bindings") else { + return Ok(Vec::new()); + }; + let bindings = bindings.as_array().ok_or_else(|| { + anyhow::anyhow!("deployed manifest raw_metadata.bindings must be an array") + })?; + let mut dbs = Vec::new(); + for (i, entry) in bindings.iter().enumerate() { + let obj = entry + .as_object() + .ok_or_else(|| anyhow::anyhow!("deployed manifest bindings[{i}] must be an object"))?; + let kind = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| { + anyhow::anyhow!("deployed manifest bindings[{i}] needs a string type") + })?; + if kind != "d1" { + continue; + } + let binding = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| { + anyhow::anyhow!("deployed manifest bindings[{i}] needs a string name") + })?; + let database_name = obj + .get("database_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow::anyhow!("deployed manifest bindings[{i}] needs a string database_name") + })?; + let database_id = match obj.get("database_id") { + None => None, + Some(v) => { + let id = v.as_str().ok_or_else(|| { + anyhow::anyhow!("deployed manifest bindings[{i}] needs a string database_id") + })?; + migrate::validate_database_id(id) + .map_err(|e| anyhow::anyhow!("deployed manifest bindings[{i}]: {e:#}"))?; + Some(id.to_string()) + } + }; + migrate::validate_database_name(database_name) + .map_err(|e| anyhow::anyhow!("deployed manifest bindings[{i}]: {e:#}"))?; + dbs.push(D1Database { + binding: binding.to_string(), + database_name: database_name.to_string(), + database_id, + migrations_dir: None, + }); + } + Ok(dbs) +} + +/// The `database_name`s in `wanted` the live version does not declare, by +/// name. The pipeline migrates by name (that is what reaches celld's argv), +/// so the order decision is by name too: any name here means the live fleet +/// cannot reach that database and the upload must go first. +pub fn missing_from_live(live: &[D1Database], wanted: &[String]) -> Vec { + wanted + .iter() + .filter(|name| !live.iter().any(|db| &db.database_name == *name)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest_with(main: Option<&str>, bindings: serde_json::Value) -> serde_json::Value { + let mut manifest = serde_json::json!({ + "schema_version": 2, + "script_name": "outer-app", + "raw_metadata": { + "bindings": bindings, + "compatibility_date": "2026-09-01", + } + }); + if let Some(main) = main { + manifest["main_module"] = serde_json::Value::String(main.to_string()); + } + manifest + } + + #[test] + fn asset_only_live_manifest_declares_nothing() { + // A static site's manifest has no worker: even with d1-shaped + // bindings present, there is no live code that could reach a + // database, so the pipeline must treat every database as missing. + let dbs = manifest_dbs(&manifest_with( + None, + serde_json::json!([{"type": "d1", "name": "DB", "database_name": "site-db"}]), + )) + .unwrap(); + assert!(dbs.is_empty()); + let bare = serde_json::json!({"schema_version": 2, "script_name": "x"}); + assert!(manifest_dbs(&bare).unwrap().is_empty()); + } + + #[test] + fn worker_manifest_maps_d1_bindings() { + let dbs = manifest_dbs(&manifest_with( + Some("index.js"), + serde_json::json!([ + {"type": "d1", "name": "DB", "database_name": "app-db", "database_id": "abc-123"}, + {"type": "kv_namespace", "name": "CACHE", "namespace_id": "kv-1"}, + ]), + )) + .unwrap(); + assert_eq!(dbs.len(), 1); + assert_eq!(dbs[0].binding, "DB"); + assert_eq!(dbs[0].effective_identity(), "abc-123"); + } + + #[test] + fn missing_from_live_reports_undeclared_names() { + let live = manifest_dbs(&manifest_with( + Some("index.js"), + serde_json::json!([{"type": "d1", "name": "DB", "database_name": "app-db"}]), + )) + .unwrap(); + assert!( + missing_from_live(&live, &["app-db".to_string()]).is_empty(), + "a declared database needs no upload-first" + ); + assert_eq!( + missing_from_live(&live, &["app-db".to_string(), "site-db".to_string()]), + vec!["site-db".to_string()] + ); + } +} diff --git a/crates/outer/src/deploy/migrate.rs b/crates/outer/src/deploy/migrate.rs index cf3c818..9d0648a 100644 --- a/crates/outer/src/deploy/migrate.rs +++ b/crates/outer/src/deploy/migrate.rs @@ -2,24 +2,34 @@ //! //! After the gate passes, the pipeline applies pending //! `NNNN_description.sql` files with `celld d1 migrations apply` under the -//! app's scoped credentials. For an app with a live version the apply runs -//! BEFORE the `celld deploy` upload: celld offers no staged upload (the -//! upload publishes `deploy/current.json` and every fleet adopts it on its -//! own poll), so ordering is the only gate — when the new pointer becomes -//! visible the schema is already ready, and a migrate failure uploads -//! nothing, so no fleet can ever serve new code against an unmigrated -//! schema. For a first deploy the order reverses: the apply needs a running -//! node (found through the bucket's node leases) and nothing is published -//! yet, so the upload goes first, the fleet wakes, and the migrations apply -//! after — and only a migrate success marks the app live. The proxy refuses -//! to forward for an app with no successful deploy, so the uploaded-but- -//! unmigrated code is never served. celld reads the files relative to the -//! deploy root, orders them by numeric prefix, and records applied -//! files by name in the app's own `d1_migrations` table (the same table and -//! columns `wrangler d1 migrations` uses, so the two never re-apply each -//! other's files). Outer additionally pins the sha256 it saw in -//! `d1_migration`, so a file edited after it was applied fails the deploy -//! instead of silently drifting from what the database runs. +//! app's scoped credentials. When the live version declares every database +//! being migrated, the apply runs BEFORE the `celld deploy` upload: celld +//! offers no staged upload (the upload publishes `deploy/current.json` and +//! every fleet adopts it on its own poll), so ordering is the only gate — +//! when the new pointer becomes visible the schema is already ready, and a +//! migrate failure uploads nothing, so no fleet can ever serve new code +//! against an unmigrated schema. The old code keeps serving during the +//! migration (expand-first for destructive changes). +//! +//! Otherwise the order reverses: the upload goes first, then the wake, then +//! the migrations. That is the first-deploy case (the apply needs a running +//! node, found through the bucket's node leases, and nothing is published +//! yet, so the fleet must be started by the upload) and the +//! live-lacks-the-database case (the live manifest declares no D1 database +//! being migrated — an asset-only live version, a first D1, a renamed +//! database — so the woken live fleet cannot run the migration at all). The +//! proxy refuses to forward for an app with no successful deploy, so an +//! uploaded-but-unmigrated first deploy is never served; an uploaded-but- +//! unmigrated existing app IS served (the pointer moved), so the deploy +//! fails at `migrate` with an error that says the new version is live +//! without the migrations, and the app's live pointer moves anyway. +//! celld reads the files relative to the deploy root, orders them by +//! numeric prefix, and records applied files by name in the app's own +//! `d1_migrations` table (the same table and columns `wrangler d1 +//! migrations` uses, so the two never re-apply each other's files). Outer +//! additionally pins the sha256 it saw in `d1_migration`, so a file edited +//! after it was applied fails the deploy instead of silently drifting from +//! what the database runs. //! //! Security model: everything celld reads or Outer copies here is //! attacker-influenced (the app's built config and build output are written diff --git a/crates/outer/src/deploy/mod.rs b/crates/outer/src/deploy/mod.rs index 848fa75..7341fc5 100644 --- a/crates/outer/src/deploy/mod.rs +++ b/crates/outer/src/deploy/mod.rs @@ -5,6 +5,7 @@ //! module owns everything under `src/deploy/`. pub mod check; +pub mod manifest; pub mod migrate; pub mod pipeline; diff --git a/crates/outer/src/deploy/pipeline.rs b/crates/outer/src/deploy/pipeline.rs index 3bc70b9..67e1d20 100644 --- a/crates/outer/src/deploy/pipeline.rs +++ b/crates/outer/src/deploy/pipeline.rs @@ -12,11 +12,15 @@ //! Concurrency: a global semaphore bounds simultaneous builds and a per-app //! mutex serializes deploys of the same app. The `deploy` row is inserted as //! are held, and to `deploying` before the migrate/upload steps run. The -//! migrate step runs under the same `deploying` status, before the upload -//! for an existing app and after it for a first deploy: a blocked deploy -//! (gate) never touches app data, a failed migration on an existing app -//! leaves the previous version serving, and a failed first-deploy migration -//! leaves the app not-live. +//! migrate step runs under the same `deploying` status: before the upload +//! when the live version declares every database being migrated (so a +//! migrate failure uploads nothing and the previous version keeps serving), +//! after the upload when there is no live version yet or the live version +//! does not declare a database being migrated (the old code cannot reach +//! that database, so nothing old depends on it). A failed migration after +//! the upload means the new version is already published: the deploy still +//! fails at `migrate`, with an error that says so, but the app's live +//! pointer moves to the new version. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -30,6 +34,7 @@ use tokio::sync::{Mutex as AsyncMutex, Semaphore}; use crate::apps::{self, App}; use crate::cron; +use crate::deploy::manifest as live_manifest; use crate::deploy::migrate; use crate::fleet::cgroups; use crate::fleet::manager::FleetStorage; @@ -37,6 +42,7 @@ use crate::fleet::sandbox::{self, Sandbox}; use crate::git::manifest::{self, GitConfig}; use crate::moderation::gate; use crate::moderation::jev::JevClient; +use crate::store::Store; /// What the pipeline needs: dirs, tool paths, storage creds, timeouts. #[derive(Clone)] @@ -352,6 +358,27 @@ impl DeployKind { matches!(self, DeployKind::Preview) } } +/// Where the D1 migrate step runs relative to the `celld deploy` upload. +/// For an app with a live version the default is before the upload: when +/// the new pointer becomes visible the schema is already ready, and a +/// migrate failure uploads nothing. The upload goes first when there is no +/// live fleet that could apply the migrations — no live version yet, or a +/// live version that does not declare a database being migrated (asset-only +/// sites, a first D1, a renamed database): the old code cannot reach that +/// database anyway. A migrate failure after the upload still fails the +/// deploy, but the new version is already published (see `StepFailed`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MigrateOrder { + /// Migrate before uploading; a failure uploads nothing. + BeforeUpload, + /// First deploy: upload, wake the fleet, then migrate. Only a migrate + /// success marks the app live. + AfterUploadFirstDeploy, + /// The live version does not declare a database being migrated: upload, + /// wake, then migrate. A migrate failure still publishes the new + /// version, without the migrations. + AfterUploadLiveLacksDb, +} pub struct Deployer { settings: DeploySettings, semaphore: Arc, @@ -657,6 +684,7 @@ impl Deployer { pub async fn deploy( &self, pool: &SqlitePool, + store: &Store, git: &GitConfig, app: &App, sha: &str, @@ -666,7 +694,7 @@ impl Deployer { self.egress() .gate(&format!("build {} for {}", sha, app.slug))?; let slug = app.slug.clone(); - self.deploy_inner(pool, git, app, &slug, DeployKind::Main, sha) + self.deploy_inner(pool, store, git, app, &slug, DeployKind::Main, sha) .await } @@ -679,6 +707,7 @@ impl Deployer { pub async fn deploy_preview( &self, pool: &SqlitePool, + store: &Store, git: &GitConfig, app: &App, sha: &str, @@ -690,13 +719,26 @@ impl Deployer { let Some(preview) = crate::preview::preview_app(app) else { anyhow::bail!("no preview fleet for {}", app.slug); }; - self.deploy_inner(pool, git, &preview, &app.slug, DeployKind::Preview, sha) - .await + self.deploy_inner( + pool, + store, + git, + &preview, + &app.slug, + DeployKind::Preview, + sha, + ) + .await } + // Eight params: the run identity (store, fleet app, mirror slug, kind) + // rides alongside the deploy identity. Splitting the struct would + // obscure the step order below, so the count stays. + #[allow(clippy::too_many_arguments)] async fn deploy_inner( &self, pool: &SqlitePool, + store: &Store, git: &GitConfig, app: &App, git_slug: &str, @@ -723,6 +765,7 @@ impl Deployer { let result = self .run_steps( pool, + store, &deploy_id, git, app, @@ -803,6 +846,42 @@ impl Deployer { let log_tail = tail_cap(&log); let steps_json = steps_json(&steps); finish_failed(pool, &deploy_id, &failed.error, &steps_json, &log_tail).await?; + // The migrate step ran after the upload and failed: the new + // version is already published, so the app's live state moves + // exactly as on success — `last_deploy_sha` names what the + // fleet actually serves (the proxy forwards on it), the new + // version's crons go live, and the edge cache purges — while + // the deploy row (and `app_status`, which reads the latest + // row) still reports the migrate failure so the fix happens. + // A retry then sees a live version that declares the + // database and migrates before uploading again. + let reloaded = if failed.uploaded { + // Same validated config the success path syncs: the + // worktree still holds this sha's tree (only a failed + // build wipes it, which never uploads first). + let crons = self + .deploy_root(app) + .map(|(_, crons, _)| crons) + .unwrap_or_default(); + match kind { + DeployKind::Main => { + apps::set_last_deploy(pool, &app.id, sha).await?; + cron::sync_app_crons(pool, &app.id, &crons, apps::now_ms()).await?; + let reloaded = reload_if_awake(app).await; + self.spawn_edge_purge(pool, &app.id, &app.slug, kind); + reloaded + } + DeployKind::Preview => { + let _ = + crate::preview::touch_preview(pool, &app.id, apps::now_ms()).await; + let reloaded = reload_if_awake(app).await; + self.spawn_edge_purge(pool, &app.id, &app.slug, kind); + reloaded + } + } + } else { + false + }; Ok(DeployOutcome { deploy_id, sha: sha.to_string(), @@ -815,7 +894,7 @@ impl Deployer { steps, log_tail, version: None, - reloaded: false, + reloaded, }) } } @@ -870,17 +949,20 @@ impl Deployer { /// the celld version and the validated cron schedules on success. The /// review runs before `deploy-root`, and before migrations, so a blocked /// deploy never touches the app's data or uploads anything. For an app - /// with a live version migrations run before the upload, so no fleet can - /// ever serve new code against an unmigrated schema; for a first deploy - /// (no successful deploy yet) the upload runs first because `celld d1` - /// needs a running fleet and there is nothing to start one from yet. - // Nine params: the run identity (fleet app, mirror slug, kind) rides + /// whose live version declares every database being migrated, migrations + /// run before the upload, so no fleet can ever serve new code against an + /// unmigrated schema; otherwise the upload runs first because `celld d1` + /// needs a running fleet and there is nothing to start one from yet + /// (first deploy) or the live fleet cannot reach the database (the live + /// version does not declare it). + // Ten params: the run identity (fleet app, mirror slug, kind, store) rides // alongside the deploy identity. Splitting the struct would obscure // the step order below, so the count stays. #[allow(clippy::too_many_arguments)] async fn run_steps( &self, pool: &SqlitePool, + store: &Store, deploy_id: &str, git: &GitConfig, app: &App, @@ -1005,84 +1087,142 @@ impl Deployer { } }; - // 6+7. D1 migrations and the `celld deploy` upload. For an app with - // a live version the migrate step runs before the upload: celld - // offers no staged or invisible upload (the upload publishes - // `deploy/current.json` and every fleet adopts it on its own poll), - // so ordering is the only gate: when the new pointer becomes visible - // the schema is already ready, and a migrate failure uploads - // nothing, so the previous version keeps serving. The old code keeps - // serving during the migration (expand-first for destructive - // changes). + // 6+7. D1 migrations and the `celld deploy` upload. For an app whose + // live version declares every database being migrated, the migrate + // step runs before the upload: celld offers no staged or invisible + // upload (the upload publishes `deploy/current.json` and every fleet + // adopts it on its own poll), so ordering is the only gate: when the + // new pointer becomes visible the schema is already ready, and a + // migrate failure uploads nothing, so the previous version keeps + // serving. The old code keeps serving during the migration + // (expand-first for destructive changes). // - // A first deploy (`last_deploy_sha` is NULL: no successful deploy - // yet) reverses the order. `celld d1 migrations apply` finds its - // node through the bucket's node leases, and a first deploy has - // published nothing yet, so no fleet can start and the wake before - // the migrate step times out. The upload goes first, then the wake - // (now possible), then the migrations — and only a migrate success - // marks the deploy successful. Nothing is served before that: the - // proxy refuses to forward for an app with no successful deploy, so - // no fleet can serve the new code unmigrated. + // Otherwise the upload goes first, then the wake (now possible), + // then the migrations. That is the first-deploy case + // (`last_deploy_sha` is NULL: no successful deploy yet, so + // `celld d1 migrations apply` has no fleet to reach through the + // bucket's node leases), and the live-lacks-the-database case: the + // live manifest does not declare a database this deploy migrates + // (an asset-only live version, a first D1, a renamed database), so + // the woken live fleet cannot run the migration — retrying would + // fail forever and the site could never gain the database. The old + // live code cannot reach that database anyway, so nothing old + // depends on the new schema. // // The live-version signal comes from the row, not the caller's // snapshot: a previous deploy may have succeeded after the caller // loaded the app (the per-app lock serializes deploys, so the row // is current). `last_deploy_sha` NULL means no successful deploy yet. - // A preview deploy reads the preview signal instead: the first - // preview deploy (no successful preview run yet) uploads before + // A preview deploy reads the preview signal (and the preview + // manifest) instead: the first preview deploy uploads before // migrating for the same lease reason, against the preview fleet. - let is_first_deploy = match kind { - DeployKind::Main => last_deploy_sha(pool, &app.id) - .await - .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))? - .is_none(), - DeployKind::Preview => last_preview_success(pool, &app.id) - .await - .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))? - .is_none(), - }; + // A missing live manifest keeps the old order (migrate first): with + // no signal about the live version, the deploy fails exactly where + // it used to instead of guessing. + let order = self + .migrate_order(pool, store, app, kind, &deploy_root) + .await?; set_status_blocking(pool, deploy_id) .await .map_err(StepEnd::Failed)?; - if is_first_deploy { + if order == MigrateOrder::BeforeUpload { + self.migrate_step(pool, app, deploy_id, &deploy_root, order, kind, journal) + .await?; let version = self .deploy_step(pool, app, deploy_id, &deploy_root, journal) .await?; - self.migrate_step( - pool, - app, - deploy_id, - &deploy_root, - is_first_deploy, - kind, - journal, - ) - .await?; Ok((version, crons)) } else { - self.migrate_step( - pool, - app, - deploy_id, - &deploy_root, - is_first_deploy, - kind, - journal, - ) - .await?; let version = self .deploy_step(pool, app, deploy_id, &deploy_root, journal) .await?; + // A first deploy that fails here leaves the app not-live exactly + // as before: the proxy still refuses to forward (it gates on + // `last_deploy_sha`), so only a live app's upload-first failure + // marks the error published — the fleet already serves the new + // version, and the failure path moves the live pointer to it. + self.migrate_step(pool, app, deploy_id, &deploy_root, order, kind, journal) + .await + .map_err(|e| match e { + StepEnd::Failed(mut failed) => { + if order == MigrateOrder::AfterUploadLiveLacksDb { + failed.uploaded = true; + } + StepEnd::Failed(failed) + } + StepEnd::Blocked(blocked) => StepEnd::Blocked(blocked), + })?; Ok((version, crons)) } } - /// The `celld deploy ... --json` upload step; returns the version. + /// Where the migrate step runs: before the upload when the live version + /// declares every database the new config migrates, after the upload + /// when there is no live fleet that could apply them. Reads the live + /// manifest through the shared `deploy::manifest` reader (the same + /// source the console data panels use); for a preview deploy `app` is + /// the preview identity, so this reads the preview fleet's manifest. A + /// missing manifest keeps `BeforeUpload`; an unreadable store or + /// manifest fails the deploy at the migrate step before anything + /// uploads. + async fn migrate_order( + &self, + pool: &SqlitePool, + store: &Store, + app: &App, + kind: DeployKind, + deploy_root: &DeployRoot, + ) -> Result { + let is_first_deploy = match kind { + DeployKind::Main => last_deploy_sha(pool, &app.id) + .await + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))? + .is_none(), + DeployKind::Preview => last_preview_success(pool, &app.id) + .await + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))? + .is_none(), + }; + if is_first_deploy { + return Ok(MigrateOrder::AfterUploadFirstDeploy); + } + // The new config this deploy migrates: the same file the migrate + // step reads, so the order and the apply can never disagree. A + // config that fails to parse here fails again in the migrate step, + // before any upload. + let parsed = read_config_file(&deploy_root.config) + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))?; + let databases = migrate::d1_databases(&parsed) + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))?; + if databases.is_empty() { + return Ok(MigrateOrder::BeforeUpload); + } + let wanted: Vec = databases + .iter() + .map(|db| db.database_name.clone()) + .collect(); + let live = live_manifest::deployed_manifest(store, &app.slug) + .await + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))?; + let Some(live) = live else { + return Ok(MigrateOrder::BeforeUpload); + }; + let live_dbs = live_manifest::manifest_dbs(&live) + .map_err(|e| StepEnd::Failed(StepFailed::plain("migrate", &e)))?; + if live_manifest::missing_from_live(&live_dbs, &wanted).is_empty() { + Ok(MigrateOrder::BeforeUpload) + } else { + Ok(MigrateOrder::AfterUploadLiveLacksDb) + } + } + + /// The `celld deploy ... --json` upload; returns the version. /// The upload publishes `deploy/current.json`, which every fleet adopts - /// on its own poll — so for an existing app it runs after the migrate - /// step (a migrate failure uploads nothing at all), while for a first - /// deploy it runs first (nothing to start a fleet from otherwise). + /// on its own poll — so it runs after the migrate step when the live + /// version declares every database being migrated (a migrate failure + /// uploads nothing at all), and first when no live fleet could apply the + /// migrations (first deploy, or a live version that lacks a database + /// being migrated). async fn deploy_step( &self, pool: &SqlitePool, @@ -1121,10 +1261,11 @@ impl Deployer { /// The D1 migrations step: pending `NNNN_*.sql` files for the built /// config's `d1_databases`, applied with the app's scoped credentials. - /// For an existing app this runs before the upload makes the new code - /// visible to any fleet; for a first deploy it runs after the upload - /// (which is what lets the woken fleet exist at all). No files (or no - /// D1) skips the step without waking a sleeping fleet. + /// This runs before the upload makes the new code visible to any fleet + /// when the live version declares every database being migrated; after + /// the upload when there is no live fleet that could apply them (first + /// deploy, or a live version that lacks a database being migrated). + /// No files (or no D1) skips the step without waking a sleeping fleet. // The kind rides along for the wake target and the pin-record switch. #[allow(clippy::too_many_arguments)] async fn migrate_step( @@ -1133,14 +1274,14 @@ impl Deployer { app: &App, deploy_id: &str, deploy_root: &DeployRoot, - is_first_deploy: bool, + order: MigrateOrder, kind: DeployKind, journal: &mut Journal, ) -> Result<(), StepEnd> { let Journal { steps, log } = journal; let started = Instant::now(); match self - .migrate(pool, app, deploy_id, deploy_root, is_first_deploy, kind) + .migrate(pool, app, deploy_id, deploy_root, order, kind) .await { Ok((skipped, out)) => { @@ -1502,18 +1643,19 @@ impl Deployer { /// credentials, and every file present is recorded in `d1_migration` so /// a later edit of an applied file fails the deploy instead of drifting. /// A failure names the migration file when celld's output identifies it - /// and carries an agent-facing line; the fleet is never reloaded, so an - /// existing app keeps serving its previous version while a first deploy - /// leaves the app not-live (the proxy forwards only after a success). - /// `is_first_deploy` selects that wording and the operator-CLI hint - /// below; the caller derives it from `last_deploy_sha` being NULL. + /// and carries an agent-facing line. When the step runs before the + /// upload, the fleet is never reloaded and an existing app keeps serving + /// its previous version; when it runs after the upload, the new version + /// is already published, so the error says the new version is live + /// without the migrations. `order` selects that wording and the + /// operator-CLI hint below (which only applies to a first deploy). async fn migrate( &self, pool: &SqlitePool, app: &App, deploy_id: &str, deploy_root: &DeployRoot, - is_first_deploy: bool, + order: MigrateOrder, kind: DeployKind, ) -> Result<(bool, String), StepFailed> { let root = &deploy_root.root; @@ -1566,9 +1708,17 @@ impl Deployer { // to wake a sleeping fleet. // A first deploy that never went live has no previous version: say // the app isn't live yet instead of naming one that keeps serving. + // After the upload, the migrate failure does not roll the publish + // back, so the message must not claim the previous version keeps + // serving: it names the new version as live without the migrations. + // (Preview keeps its own line: the live app is untouched in every + // preview case, matching the first-preview-deploy failure.) + let published = kind == DeployKind::Main && order == MigrateOrder::AfterUploadLiveLacksDb; let live_line = if kind.is_preview() { "Your live app is untouched; only the preview failed to update." - } else if is_first_deploy { + } else if published { + "The new version is live without them — fix the migration and commit again." + } else if order == MigrateOrder::AfterUploadFirstDeploy { "Your app isn't live yet." } else { "Your app still shows the previous version." @@ -1582,17 +1732,40 @@ impl Deployer { .map_err(|e| StepFailed::plain("migrate", &e))?; match recorded { Some(sha) if sha != file.sha256 => { - let error = format!( - "migration {} for database {} was edited after it was applied; \ - write a new migration instead", - file.name, db.database_name - ); - let user_message = format!( - "Outer couldn't publish this change because migration {} was edited \ - after it was already applied to database {}. {live_line} Write a \ - new migration file instead of editing an applied one.", - file.name, db.database_name - ); + // After the upload the refusal still means the new + // version is live: celld never ran, so it serves + // without this deploy's migration content. + let (error, user_message) = if published { + ( + format!( + "migration {} for database {} was edited after it was applied; \ + the new version is live without it — write a new migration \ + instead and commit again", + file.name, db.database_name + ), + format!( + "Outer published this change, but migration {} was edited \ + after it was already applied to database {}. The new version \ + is live without it — write a new migration instead and \ + commit again.", + file.name, db.database_name + ), + ) + } else { + ( + format!( + "migration {} for database {} was edited after it was applied; \ + write a new migration instead", + file.name, db.database_name + ), + format!( + "Outer couldn't publish this change because migration {} was edited \ + after it was already applied to database {}. {live_line} Write a \ + new migration file instead of editing an applied one.", + file.name, db.database_name + ), + ) + }; return Err(StepFailed::migration(error, user_message)); } Some(_) => {} @@ -1643,7 +1816,10 @@ impl Deployer { // instead of surfacing celld's lease internals. (With a // fleet manager the wake above runs first, so a lease // error there is a real failure, not this hint.) - if is_first_deploy && self.settings.wake.is_none() && text.contains("no node leases") { + if order == MigrateOrder::AfterUploadFirstDeploy + && self.settings.wake.is_none() + && text.contains("no node leases") + { return StepFailed::migration( format!( "first deploy with D1 migrations needs the server; deploy through MCP \ @@ -1658,6 +1834,10 @@ impl Deployer { ); } let failed = migrate::failing_file(&text); + // After the upload the failure still means the new + // version is live, so the message pairs the file with + // that fact instead of claiming the previous version + // keeps serving. let error = match &failed { Some(name) => format!( "migration {name} for database {} failed: {}", @@ -1670,7 +1850,25 @@ impl Deployer { first_output_line(&text) ), }; + let error = if published { + format!("{error}; the new version is live without them — fix the migration and commit again") + } else { + error + }; let user_message = match &failed { + Some(name) if published => format!( + "Outer published this change, but migration {name} failed to apply \ + to database {}: {}. The new version is live without them — fix \ + the migration and commit again.", + db.database_name, + error_detail(&text, Some(name)) + ), + None if published => format!( + "Outer published this change, but the database migrations failed \ + to apply: {}. The new version is live without them — fix the \ + migration and commit again.", + first_output_line(&text) + ), Some(name) => format!( "Outer couldn't publish this change because migration {name} failed \ to apply to database {}: {}. {live_line}", @@ -1954,11 +2152,17 @@ struct BlockedInfo { /// A step that failed: its name, the first meaningful output line, and an /// optional agent-facing line (the gate sets one for `review_unavailable`, -/// migrate for file-specific failures). +/// migrate for file-specific failures). `uploaded` is true when the failure +/// came after an upload a live fleet already serves (an existing app's +/// upload-first migrate failure): the failure path still moves the app's +/// live pointer even though the deploy row records the failure. A first +/// deploy's upload is never served (the proxy gates on `last_deploy_sha`), +/// so its migrate failures leave `uploaded` false and the app not-live. struct StepFailed { step: &'static str, error: String, user_message: Option, + uploaded: bool, } impl StepFailed { @@ -1976,6 +2180,7 @@ impl StepFailed { step, error: cap_error(error), user_message: None, + uploaded: false, } } @@ -1991,6 +2196,7 @@ impl StepFailed { step, error: cap_error(error), user_message: None, + uploaded: false, } } @@ -2001,18 +2207,22 @@ impl StepFailed { step, error: "review_unavailable".to_string(), user_message: Some(user_message), + uploaded: false, } } /// For a failed migration: names the file when identifiable, with an - /// agent-facing line. The fleet is never reloaded, so an existing app - /// keeps serving its previous version while a first deploy stays - /// not-live. + /// agent-facing line. When the migrate step runs before the upload, the + /// fleet is never reloaded and an existing app keeps serving its + /// previous version; when it runs after the upload (`uploaded` is set + /// by the caller), the new version is already published and the error + /// says so. fn migration(error: String, user_message: String) -> Self { Self { step: "migrate", error: cap_error(&error), user_message: Some(user_message), + uploaded: false, } } @@ -2023,6 +2233,7 @@ impl StepFailed { step, error: cap_error(&user_message), user_message: Some(user_message), + uploaded: false, } } } @@ -3510,7 +3721,7 @@ mod tests { #[tokio::test] async fn failed_deploy_writes_row_and_leaves_last_deploy_sha() { - let (_mdir, cfg, _store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; + let (_mdir, cfg, store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; let (_ddir, pool) = test_pool().await; let app = test_app(10001); sqlx::query( @@ -3529,7 +3740,10 @@ mod tests { // No package.json, no wrangler config: fails at deploy-root. let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 2); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("deploy-root")); assert!( @@ -3639,7 +3853,7 @@ mod tests { #[tokio::test] async fn failed_deploy_issues_no_purge() { - let (_mdir, cfg, _store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; + let (_mdir, cfg, store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; let (_ddir, pool) = test_pool().await; let app = test_app(10001); sqlx::query( @@ -3657,7 +3871,10 @@ mod tests { let purge = crate::cloudflare::fake::FakePurge::start().await; let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(purge_settings(&data, &purge), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); // No spawn happens on the failure path at all; the settle wait only // guards against a stray detached task. @@ -3734,7 +3951,10 @@ mod tests { settings.celld_bin = "/nonexistent/celld-binary".to_string(); settings.jev = Some(jev_client(&fake.base_url)); let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "blocked"); tokio::time::sleep(std::time::Duration::from_millis(300)).await; assert_eq!( @@ -3816,7 +4036,10 @@ mod tests { settings.celld_bin = "/nonexistent/celld-binary".to_string(); settings.jev = Some(jev_client(&fake.base_url)); let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "blocked"); assert_eq!(outcome.failed_step.as_deref(), Some("gate")); assert_eq!(outcome.reason.as_deref(), Some("profanity")); @@ -3936,7 +4159,10 @@ mod tests { settings.celld_bin = "/nonexistent/celld-binary".to_string(); settings.jev = Some(jev_client(&fake.base_url)); let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("gate")); assert_eq!(outcome.error.as_deref(), Some("review_unavailable")); @@ -4225,7 +4451,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); // A first deploy (`last_deploy_sha` NULL) uploads before it // migrates: `celld d1` needs a running fleet and nothing is @@ -4343,7 +4572,10 @@ exit 0 let mut settings = migrate_settings(&data, state.path(), &script); settings.bun_bin = bun.to_string_lossy().into_owned(); let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); // The uploaded config carries the decrypted values: the fresh var, // the overwrite winning over code, and the untouched code var. @@ -4680,7 +4912,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let first = deployer.deploy(&pool, &cfg, &app, &sha_a).await.unwrap(); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(first.status, "success", "error: {:?}", first.error); // The first deploy has no live version yet, so it uploads before it // migrates. @@ -4723,7 +4958,10 @@ exit 0 ) .await .unwrap(); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); assert_eq!( @@ -4781,7 +5019,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let first = deployer.deploy(&pool, &cfg, &app, &sha_a).await.unwrap(); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(first.status, "success", "error: {:?}", first.error); assert_eq!(upload_calls(state.path()), 1); @@ -4810,7 +5051,10 @@ exit 0 ) .await .unwrap(); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("migrate")); assert_eq!( @@ -4849,8 +5093,38 @@ exit 0 assert_eq!(count, 1, "a failed apply records nothing new"); } + /// The live manifest celld published for `slug`: `deploy/current.json` + /// pointing at a versioned prefix plus the manifest there, in the layout + /// `deploy::manifest::deployed_manifest` reads. The fake celld never + /// writes these (it has no store handle), so tests seed them directly. + async fn seed_live_manifest(store: &Store, slug: &str, manifest: serde_json::Value) { + let prefix = "deploy/outer-app/abcd1234"; + store + .put( + &format!("fleets/{slug}/deploy/current.json"), + format!( + "{{\"script_name\":\"{slug}\",\"version\":\"abcd1234\",\"prefix\":\"{prefix}\"}}" + ) + .into_bytes(), + ) + .await + .unwrap(); + store + .put( + &format!("fleets/{slug}/{prefix}/manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .await + .unwrap(); + } + #[tokio::test] - async fn migrate_skips_when_everything_is_already_applied() { + async fn migrate_uploads_first_when_live_manifest_lacks_the_database() { + // The live repro: a static site deploys first (no worker, no D1), + // then a tree adds the worker, the `d1_databases` binding and its + // first migration. The live manifest declares nothing, so the woken + // live fleet could never run the apply — retrying would fail forever + // and the site could never gain the database. The upload goes first. let dir = tempfile::tempdir().unwrap(); let cfg = GitConfig::new(dir.path().join("git"), None); let store = Store::memory(); @@ -4858,48 +5132,128 @@ exit 0 manifest::init_repo(&store, &cfg, &sync, "grocery") .await .unwrap(); - let sha = commit_tree( - &cfg, + let sha_a = commit::commit( &store, + &cfg, &sync, "grocery", - vec![write( - "db/migrations/0001_init.sql", - "CREATE TABLE item (id INTEGER);", - )], + CommitRequest { + message: "static site".to_string(), + base: None, + changes: commit::CommitChanges { + writes: vec![ + write( + "dist/wrangler.json", + r#"{"name":"t","compatibility_date":"2026-01-01"}"#, + ), + write("pages/index.tsx", "export default () =>

hi

;"), + ], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, ) - .await; + .await + .unwrap(); let (_ddir, pool) = test_pool().await; - let app = test_app(10102); + let app = test_app(10120); insert_test_app(&pool, &app).await; let state = tempfile::tempdir().unwrap(); let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let first = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(first.status, "success", "error: {:?}", first.error); - assert_eq!(apply_calls(state.path()), 1); - // Same tree again: nothing pending, no second apply, no wake. - let second = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); - assert_eq!(second.status, "success", "error: {:?}", second.error); - let migrate = second - .steps - .iter() - .find(|s| s.name == "migrate") - .expect("the migrate step is journaled"); - assert!(migrate.skipped, "all files recorded: {migrate:?}"); + // The live version celld published: an asset-only manifest with no + // worker module and no bindings declares nothing. + seed_live_manifest( + &store, + "grocery", + serde_json::json!({ + "schema_version": 2, + "script_name": "grocery", + "raw_metadata": {"bindings": []}, + }), + ) + .await; + + // The newsletter copy: an API route makes it a worker, the binding + // appears, and the first migration lands. + let sha_b = commit::commit( + &store, + &cfg, + &sync, + "grocery", + CommitRequest { + message: "add newsletter".to_string(), + base: Some(sha_a.clone()), + changes: commit::CommitChanges { + writes: vec![ + write( + "dist/wrangler.json", + r#"{"name":"t","main":"index.js","compatibility_date":"2026-01-01","d1_databases":[{"binding":"DB","database_name":"site-db"}]}"#, + ), + write( + "db/migrations/0001_newsletter.sql", + "CREATE TABLE subscriber (id INTEGER);", + ), + ], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, + ) + .await + .unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); + assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); + let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); assert_eq!( - apply_calls(state.path()), - 1, - "no celld call when nothing is pending" + names, + [ + "worktree", + "install", + "build", + "gate", + "deploy-root", + "deploy", + "migrate" + ] ); + assert_eq!( + call_kinds(state.path()), + ["deploy", "deploy", "apply"], + "the first deploy uploads, then the D1 deploy uploads before it migrates" + ); + let (last_sha,): (Option,) = + sqlx::query_as("SELECT last_deploy_sha FROM app WHERE id = ?1") + .bind(&app.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_sha.as_deref(), Some(sha_b.as_str())); } #[tokio::test] - async fn migrate_skips_without_d1_or_files() { - // No `d1_databases` declared: the step skips without touching celld. + async fn migrate_failure_after_upload_first_is_failed_but_live() { + // Same setup, but the first migration is broken: the deploy fails at + // `migrate` with an honest error, yet the new version is already + // published — so `last_deploy_sha` moves to it instead of pinning + // the old version while the fleet serves the new one. let dir = tempfile::tempdir().unwrap(); let cfg = GitConfig::new(dir.path().join("git"), None); let store = Store::memory(); @@ -4907,19 +5261,19 @@ exit 0 manifest::init_repo(&store, &cfg, &sync, "grocery") .await .unwrap(); - let sha = commit::commit( + let sha_a = commit::commit( &store, &cfg, &sync, "grocery", CommitRequest { - message: "no d1".to_string(), + message: "static site".to_string(), base: None, - changes: crate::git::commit::CommitChanges { + changes: commit::CommitChanges { writes: vec![ write( "dist/wrangler.json", - r#"{"name":"t","main":"index.js","compatibility_date":"2026-01-01"}"#, + r#"{"name":"t","compatibility_date":"2026-01-01"}"#, ), write("pages/index.tsx", "export default () =>

hi

;"), ], @@ -4935,50 +5289,474 @@ exit 0 .unwrap(); let (_ddir, pool) = test_pool().await; - let app = test_app(10103); + let app = test_app(10121); insert_test_app(&pool, &app).await; let state = tempfile::tempdir().unwrap(); let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); - assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); - let migrate = outcome - .steps - .iter() - .find(|s| s.name == "migrate") - .expect("the migrate step is journaled"); - assert!(migrate.skipped); - assert!( - outcome.log_tail.contains("no d1_databases"), - "the journal says why: {}", - outcome.log_tail - ); - assert_eq!(apply_calls(state.path()), 0, "celld never runs"); - } - - #[tokio::test] - async fn migrate_skips_with_d1_but_no_files() { - let dir = tempfile::tempdir().unwrap(); - let cfg = GitConfig::new(dir.path().join("git"), None); - let store = Store::memory(); - let sync = GitSync::default(); - manifest::init_repo(&store, &cfg, &sync, "grocery") + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) .await .unwrap(); - let sha = commit_tree(&cfg, &store, &sync, "grocery", vec![]).await; + assert_eq!(first.status, "success", "error: {:?}", first.error); + seed_live_manifest( + &store, + "grocery", + serde_json::json!({ + "schema_version": 2, + "script_name": "grocery", + "raw_metadata": {"bindings": []}, + }), + ) + .await; - let (_ddir, pool) = test_pool().await; - let app = test_app(10104); - insert_test_app(&pool, &app).await; - let state = tempfile::tempdir().unwrap(); - let script = install_fake_celld(state.path()); - let data = tempfile::tempdir().unwrap(); - let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); - assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); - let migrate = outcome - .steps + let sha_b = commit::commit( + &store, + &cfg, + &sync, + "grocery", + CommitRequest { + message: "broken newsletter migration".to_string(), + base: Some(sha_a.clone()), + changes: commit::CommitChanges { + writes: vec![ + write( + "dist/wrangler.json", + r#"{"name":"t","main":"index.js","compatibility_date":"2026-01-01","d1_databases":[{"binding":"DB","database_name":"site-db"}]}"#, + ), + write( + "db/migrations/0001_newsletter.sql", + "CREATE TABLE oops (;", + ), + write("dist/.fake-fail", "0001_newsletter.sql"), + ], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, + ) + .await + .unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); + assert_eq!(outcome.status, "failed"); + assert_eq!(outcome.failed_step.as_deref(), Some("migrate")); + let error = outcome.error.as_deref().unwrap_or(""); + assert!( + error.contains("0001_newsletter.sql") && error.contains("live without them"), + "the error names the file and says the new version is live: {error}" + ); + let message = outcome.user_message.as_deref().unwrap_or(""); + assert!( + message.contains("published") && message.contains("live without them"), + "the agent learns the version published and the migration did not: {message}" + ); + assert!( + outcome.log_tail.contains("0001_newsletter.sql"), + "the migration error is in the log tail" + ); + let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); + assert_eq!( + names, + [ + "worktree", + "install", + "build", + "gate", + "deploy-root", + "deploy", + "migrate" + ], + "the upload ran before the failing migrate" + ); + assert_eq!( + call_kinds(state.path()), + ["deploy", "deploy", "apply"], + "the second deploy uploaded before its migrate failed" + ); + assert_eq!(upload_calls(state.path()), 2); + // The new version is what the fleet serves, so the live pointer + // moves to it even though the deploy failed; a retry then sees a + // live version that declares the database and migrates first. + let (last_sha,): (Option,) = + sqlx::query_as("SELECT last_deploy_sha FROM app WHERE id = ?1") + .bind(&app.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_sha.as_deref(), Some(sha_b.as_str())); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM d1_migration") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "a failed apply records nothing"); + } + + #[tokio::test] + async fn migrate_stays_first_when_live_manifest_declares_the_database() { + // The positive path, pinned explicitly: the live manifest declares + // the database being migrated, so the migrate step still runs before + // the upload — and a migrate failure would upload nothing. + let dir = tempfile::tempdir().unwrap(); + let cfg = GitConfig::new(dir.path().join("git"), None); + let store = Store::memory(); + let sync = GitSync::default(); + manifest::init_repo(&store, &cfg, &sync, "grocery") + .await + .unwrap(); + let sha_a = commit_tree( + &cfg, + &store, + &sync, + "grocery", + vec![write( + "db/migrations/0001_init.sql", + "CREATE TABLE item (id INTEGER);", + )], + ) + .await; + + let (_ddir, pool) = test_pool().await; + let app = test_app(10122); + insert_test_app(&pool, &app).await; + let state = tempfile::tempdir().unwrap(); + let script = install_fake_celld(state.path()); + let data = tempfile::tempdir().unwrap(); + let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); + assert_eq!(first.status, "success", "error: {:?}", first.error); + seed_live_manifest( + &store, + "grocery", + serde_json::json!({ + "schema_version": 2, + "script_name": "grocery", + "main_module": "index.js", + "raw_metadata": {"bindings": [ + {"type": "d1", "name": "DB", "database_name": "app-db"}, + ]}, + }), + ) + .await; + + let sha_b = commit::commit( + &store, + &cfg, + &sync, + "grocery", + CommitRequest { + message: "second migration".to_string(), + base: Some(sha_a.clone()), + changes: commit::CommitChanges { + writes: vec![write( + "db/migrations/0002_add_owner.sql", + "ALTER TABLE item ADD COLUMN owner TEXT;", + )], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, + ) + .await + .unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); + assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); + let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); + assert_eq!( + names, + [ + "worktree", + "install", + "build", + "gate", + "deploy-root", + "migrate", + "deploy" + ] + ); + assert_eq!( + call_kinds(state.path()), + ["deploy", "apply", "apply", "deploy"], + "a live version that declares the database migrates before it uploads" + ); + let (last_sha,): (Option,) = + sqlx::query_as("SELECT last_deploy_sha FROM app WHERE id = ?1") + .bind(&app.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_sha.as_deref(), Some(sha_b.as_str())); + } + + #[tokio::test] + async fn migrate_uploads_first_when_the_database_is_renamed() { + // The live version declares `app-db`; the new tree renames it to + // `site-db`. By name the live version lacks the database being + // migrated, so the upload goes first: the old fleet only knows the + // old name. + let dir = tempfile::tempdir().unwrap(); + let cfg = GitConfig::new(dir.path().join("git"), None); + let store = Store::memory(); + let sync = GitSync::default(); + manifest::init_repo(&store, &cfg, &sync, "grocery") + .await + .unwrap(); + let sha_a = commit_tree( + &cfg, + &store, + &sync, + "grocery", + vec![write( + "db/migrations/0001_init.sql", + "CREATE TABLE item (id INTEGER);", + )], + ) + .await; + + let (_ddir, pool) = test_pool().await; + let app = test_app(10123); + insert_test_app(&pool, &app).await; + let state = tempfile::tempdir().unwrap(); + let script = install_fake_celld(state.path()); + let data = tempfile::tempdir().unwrap(); + let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); + assert_eq!(first.status, "success", "error: {:?}", first.error); + seed_live_manifest( + &store, + "grocery", + serde_json::json!({ + "schema_version": 2, + "script_name": "grocery", + "main_module": "index.js", + "raw_metadata": {"bindings": [ + {"type": "d1", "name": "DB", "database_name": "app-db"}, + ]}, + }), + ) + .await; + + let sha_b = commit::commit( + &store, + &cfg, + &sync, + "grocery", + CommitRequest { + message: "rename database".to_string(), + base: Some(sha_a.clone()), + changes: commit::CommitChanges { + writes: vec![ + write( + "dist/wrangler.json", + r#"{"name":"t","main":"index.js","compatibility_date":"2026-01-01","d1_databases":[{"binding":"DB","database_name":"site-db"}]}"#, + ), + write( + "db/migrations/0002_add_owner.sql", + "ALTER TABLE item ADD COLUMN owner TEXT;", + ), + ], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, + ) + .await + .unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); + assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); + let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); + assert_eq!( + names, + [ + "worktree", + "install", + "build", + "gate", + "deploy-root", + "deploy", + "migrate" + ] + ); + assert_eq!( + call_kinds(state.path()), + ["deploy", "apply", "deploy", "apply"], + "a renamed database uploads before it migrates" + ); + let (last_sha,): (Option,) = + sqlx::query_as("SELECT last_deploy_sha FROM app WHERE id = ?1") + .bind(&app.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(last_sha.as_deref(), Some(sha_b.as_str())); + } + + #[tokio::test] + async fn migrate_skips_when_everything_is_already_applied() { + let dir = tempfile::tempdir().unwrap(); + let cfg = GitConfig::new(dir.path().join("git"), None); + let store = Store::memory(); + let sync = GitSync::default(); + manifest::init_repo(&store, &cfg, &sync, "grocery") + .await + .unwrap(); + let sha = commit_tree( + &cfg, + &store, + &sync, + "grocery", + vec![write( + "db/migrations/0001_init.sql", + "CREATE TABLE item (id INTEGER);", + )], + ) + .await; + + let (_ddir, pool) = test_pool().await; + let app = test_app(10102); + insert_test_app(&pool, &app).await; + let state = tempfile::tempdir().unwrap(); + let script = install_fake_celld(state.path()); + let data = tempfile::tempdir().unwrap(); + let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); + assert_eq!(first.status, "success", "error: {:?}", first.error); + assert_eq!(apply_calls(state.path()), 1); + + // Same tree again: nothing pending, no second apply, no wake. + let second = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); + assert_eq!(second.status, "success", "error: {:?}", second.error); + let migrate = second + .steps + .iter() + .find(|s| s.name == "migrate") + .expect("the migrate step is journaled"); + assert!(migrate.skipped, "all files recorded: {migrate:?}"); + assert_eq!( + apply_calls(state.path()), + 1, + "no celld call when nothing is pending" + ); + } + + #[tokio::test] + async fn migrate_skips_without_d1_or_files() { + // No `d1_databases` declared: the step skips without touching celld. + let dir = tempfile::tempdir().unwrap(); + let cfg = GitConfig::new(dir.path().join("git"), None); + let store = Store::memory(); + let sync = GitSync::default(); + manifest::init_repo(&store, &cfg, &sync, "grocery") + .await + .unwrap(); + let sha = commit::commit( + &store, + &cfg, + &sync, + "grocery", + CommitRequest { + message: "no d1".to_string(), + base: None, + changes: crate::git::commit::CommitChanges { + writes: vec![ + write( + "dist/wrangler.json", + r#"{"name":"t","main":"index.js","compatibility_date":"2026-01-01"}"#, + ), + write("pages/index.tsx", "export default () =>

hi

;"), + ], + edits: vec![], + deletes: vec![], + moves: vec![], + ..Default::default() + }, + author: author(), + }, + ) + .await + .unwrap(); + + let (_ddir, pool) = test_pool().await; + let app = test_app(10103); + insert_test_app(&pool, &app).await; + let state = tempfile::tempdir().unwrap(); + let script = install_fake_celld(state.path()); + let data = tempfile::tempdir().unwrap(); + let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); + assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); + let migrate = outcome + .steps + .iter() + .find(|s| s.name == "migrate") + .expect("the migrate step is journaled"); + assert!(migrate.skipped); + assert!( + outcome.log_tail.contains("no d1_databases"), + "the journal says why: {}", + outcome.log_tail + ); + assert_eq!(apply_calls(state.path()), 0, "celld never runs"); + } + + #[tokio::test] + async fn migrate_skips_with_d1_but_no_files() { + let dir = tempfile::tempdir().unwrap(); + let cfg = GitConfig::new(dir.path().join("git"), None); + let store = Store::memory(); + let sync = GitSync::default(); + manifest::init_repo(&store, &cfg, &sync, "grocery") + .await + .unwrap(); + let sha = commit_tree(&cfg, &store, &sync, "grocery", vec![]).await; + + let (_ddir, pool) = test_pool().await; + let app = test_app(10104); + insert_test_app(&pool, &app).await; + let state = tempfile::tempdir().unwrap(); + let script = install_fake_celld(state.path()); + let data = tempfile::tempdir().unwrap(); + let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); + assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); + let migrate = outcome + .steps .iter() .find(|s| s.name == "migrate") .expect("the migrate step is journaled"); @@ -5018,7 +5796,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("migrate")); let error = outcome.error.as_deref().unwrap_or(""); @@ -5103,7 +5884,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let failed = deployer.deploy(&pool, &cfg, &app, &sha_a).await.unwrap(); + let failed = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(failed.status, "failed"); assert_eq!(failed.failed_step.as_deref(), Some("migrate")); // Still no successful deploy, so the retry is a first deploy too: @@ -5136,7 +5920,10 @@ exit 0 ) .await .unwrap(); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); let names: Vec<&str> = outcome.steps.iter().map(|s| s.name.as_str()).collect(); assert_eq!( @@ -5193,7 +5980,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let first = deployer.deploy(&pool, &cfg, &app, &sha_a).await.unwrap(); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(first.status, "success", "error: {:?}", first.error); assert_eq!(apply_calls(state.path()), 1); @@ -5224,7 +6014,10 @@ exit 0 ) .await .unwrap(); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("migrate")); let error = outcome.error.as_deref().unwrap_or(""); @@ -5296,7 +6089,10 @@ exit 0 settings.bun_bin = "/bin/true".to_string(); settings.jev = Some(jev_client(&fake.base_url)); let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "blocked"); assert_eq!( apply_calls(state.path()), @@ -5346,7 +6142,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); assert_eq!(apply_calls(state.path()), 1, "one apply for the database"); let calls = std::fs::read_to_string(state.path().join("calls.log")).unwrap(); @@ -5416,7 +6215,10 @@ exit 0 let script = install_fake_celld(state.path()); let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(migrate_settings(&data, state.path(), &script), 1); - let first = deployer.deploy(&pool, &cfg, &app, &sha_a).await.unwrap(); + let first = deployer + .deploy(&pool, &store, &cfg, &app, &sha_a) + .await + .unwrap(); assert_eq!(first.status, "success", "error: {:?}", first.error); assert_eq!(apply_calls(state.path()), 1); @@ -5442,7 +6244,10 @@ exit 0 ) .await .unwrap(); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha_b).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha_b) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); assert_eq!(apply_calls(state.path()), 2, "the new identity re-applies"); let mut identities: Vec<(String,)> = @@ -5618,7 +6423,10 @@ exit 0 // bad cron stops the deploy at deploy-root, before celld runs. let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("deploy-root")); assert!( @@ -5682,7 +6490,10 @@ exit 0 // message, before migrations or celld run. let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("deploy-root")); assert_eq!(outcome.error.as_deref(), Some(BINDING_CAP_MESSAGE)); @@ -5730,7 +6541,10 @@ exit 0 let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "failed"); assert_eq!(outcome.failed_step.as_deref(), Some("deploy-root")); assert_eq!(outcome.error.as_deref(), Some(BINDING_CAP_MESSAGE)); @@ -5739,7 +6553,7 @@ exit 0 #[tokio::test] async fn queued_row_is_visible_while_waiting_for_permits() { - let (_mdir, cfg, _store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; + let (_mdir, cfg, store, _sync, _sha_a, sha_b) = seed_mirror("grocery").await; let (_ddir, pool) = test_pool().await; let app = test_app(10002); sqlx::query( @@ -5754,7 +6568,9 @@ exit 0 let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 0); let pool2 = pool.clone(); - let handle = tokio::spawn(async move { deployer.deploy(&pool2, &cfg, &app, &sha_b).await }); + let store2 = store.clone(); + let handle = + tokio::spawn(async move { deployer.deploy(&pool2, &store2, &cfg, &app, &sha_b).await }); tokio::time::sleep(Duration::from_millis(300)).await; let (status,): (String,) = sqlx::query_as("SELECT status FROM deploy WHERE app_id = 'app1'") @@ -5870,7 +6686,10 @@ exit 0 secrets_key: crate::secrets::SecretsKey::test_key(), }; let deployer = Deployer::new(settings, 1); - let outcome = deployer.deploy(&pool, &cfg, &app, &sha).await.unwrap(); + let outcome = deployer + .deploy(&pool, &store, &cfg, &app, &sha) + .await + .unwrap(); assert_eq!(outcome.status, "success", "error: {:?}", outcome.error); assert!( outcome.version.is_some(), diff --git a/crates/outer/src/examples.rs b/crates/outer/src/examples.rs new file mode 100644 index 0000000..f1482c7 --- /dev/null +++ b/crates/outer/src/examples.rs @@ -0,0 +1,801 @@ +//! Copy-paste patterns served over the public `examples` MCP tool. +//! +//! The patterns live in `examples/` in the `outer-templates` repo, vendored +//! into the store as a git bundle at the pinned sha (see [`crate::templates`]). +//! [`list`] and [`get`] extract only `examples/` into a cache dir under +//! Outer's data dir, once per sha, and serve from there. A pinned sha with no +//! `examples/` yields an empty list. A bad example is skipped with a warn and +//! never fails the whole list. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::store::Store; +use crate::templates::Template; + +/// One file's listing entry: path plus its line count. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FileRef { + pub path: String, + pub lines: usize, +} + +/// One row of the `examples` list: metadata plus the file listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Summary { + pub name: String, + pub title: String, + pub summary: String, + pub template: String, + pub dependencies: BTreeMap, + pub files: Vec, +} + +/// One full example: the README plus every file's text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExampleFile { + pub path: String, + pub text: String, +} + +/// One full example: the README plus every file's text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Example { + pub name: String, + pub title: String, + pub summary: String, + pub template: String, + pub dependencies: BTreeMap, + pub readme: String, + pub files: Vec, +} + +/// An example dir name: `^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$`. +pub fn is_valid_name(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.len() < 2 || bytes.len() > 40 { + return false; + } + let edge = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + if !edge(bytes[0]) || !edge(bytes[bytes.len() - 1]) { + return false; + } + bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +/// The `example.json` contract: name, title, summary, template, plus optional +/// dependencies. Unknown fields are ignored. +#[derive(Debug, Deserialize)] +struct ExampleJson { + title: String, + summary: String, + template: String, + #[serde(default)] + dependencies: BTreeMap, +} + +/// Largest single file an example may carry (contract: 64 KiB). +const MAX_EXAMPLE_FILE_BYTES: u64 = 64 * 1024; +/// Most files one example may carry (contract: 30). +const MAX_EXAMPLE_FILES: usize = 30; + +/// Marker written after a sha's cache dir is fully extracted. +const READY: &str = "READY"; +/// How long a racing caller waits for the winner's `READY`. +const EXTRACT_WAIT: Duration = Duration::from_secs(30); +/// A non-ready cache dir older than this is a crashed extraction: take over. +const STALE_AFTER: Duration = Duration::from_secs(600); + +/// The cache root: one dir per vendored sha under Outer's data dir. +fn cache_root(data_dir: &Path) -> PathBuf { + data_dir.join("examples") +} + +/// List the valid examples at the current pin, optionally filtered to one +/// template. Sorted by name. No pin, or a pin with no `examples/`, is an +/// empty list. +pub async fn list( + store: &Store, + data_dir: &Path, + template: Option