Skip to main content

faucet_cli/templates/
store.rs

1//! Registration + materialization of pipeline templates (#444).
2//!
3//! Pure orchestration over [`crate::serve::history::RunHistory`]'s template
4//! methods and [`crate::params`]; no HTTP, no clap, no MCP shapes — the three
5//! front-ends are thin adapters over the two entry points here.
6
7use crate::error::{CliError, CliResult};
8use crate::params::{self, BindMode, SuppliedParams};
9use crate::serve::config::HistoryBackendSpec;
10use crate::serve::history::templates::{
11    DeprecationRecord, TemplateDraft, TemplateId, TemplateRecord, TemplateState, TemplateStatus,
12    TemplateSummary, VersionChannel, VersionSelector,
13};
14use crate::serve::history::{self, RunHistory};
15use crate::serve::load::ConfigFormat;
16use serde_json::Value;
17use std::collections::BTreeMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21/// The registry handle. Any `RunHistory` backend will do — `faucet serve` passes
22/// its own `--history` store so templates live beside run records; the CLI
23/// connects one from `--store` / the config's `catalog:` block.
24pub type TemplateStore = Arc<dyn RunHistory>;
25
26/// A registration, before validation.
27#[derive(Debug, Clone)]
28pub struct RegisterRequest {
29    /// Explicit id. When `None` the id is derived from the config's `name:`.
30    pub id: Option<String>,
31    /// The config document, stored verbatim.
32    pub body: String,
33    pub format: ConfigFormat,
34    /// Free-text description (falls back to nothing).
35    pub description: Option<String>,
36    /// Named environment channels to point at the newly registered version. The
37    /// version number itself always auto-increments; these are the human-facing
38    /// pointers (`dev`, `pre-prod`, …) moved onto it in the same step. Derived
39    /// channels are rejected.
40    pub tags: Vec<VersionChannel>,
41    /// Launch the newly registered version immediately, making it `stable`.
42    /// Without this a register is inert — a new build never moves existing
43    /// callers, which is the point of the model — so this is the explicit
44    /// "register and go live" shortcut.
45    pub launch: bool,
46    /// Principal performing the registration, for provenance.
47    pub created_by: Option<String>,
48}
49
50/// A template rendered for one trigger: a config document with every
51/// `${param.*}` bound, ready to hand to the ordinary run path.
52#[derive(Debug, Clone)]
53pub struct MaterializedConfig {
54    pub template_id: String,
55    pub version: u32,
56    /// The config's own `name:`, for the run record.
57    pub name: Option<String>,
58    /// JSON config document (params bound). JSON regardless of how the template
59    /// was registered — one canonical hand-off shape for the run path.
60    pub body: String,
61    /// Bound param values with `secret: true` entries replaced by `"***"` — the
62    /// only form safe to echo, audit, or persist.
63    pub params_redacted: BTreeMap<String, Value>,
64    /// True when at least one bound param was declared `secret: true`.
65    pub used_secret_params: bool,
66}
67
68impl MaterializedConfig {
69    /// Wire format of [`Self::body`]. Always JSON.
70    pub fn format(&self) -> ConfigFormat {
71        ConfigFormat::Json
72    }
73}
74
75/// Parse a config document by declared format into an untyped value.
76fn parse_body(body: &str, format: ConfigFormat) -> CliResult<Value> {
77    match format {
78        ConfigFormat::Yaml => {
79            serde_yaml::from_str(body).map_err(|e| CliError::Config(format!("invalid YAML: {e}")))
80        }
81        ConfigFormat::Json => {
82            serde_json::from_str(body).map_err(|e| CliError::Config(format!("invalid JSON: {e}")))
83        }
84    }
85}
86
87/// Validate a submitted config and append it as a new template version.
88///
89/// Validation deliberately runs against a **placeholder binding**: required
90/// params have no value at registration time, so each is filled with a
91/// type-shaped stand-in and the config is then taken through the real
92/// `PipelineConfig` parse plus `expand` (matrix mode) or
93/// [`crate::topology::validate_topology_spec`] (topology mode). That checks
94/// everything structural — grammar, named templates, the matrix graph
95/// (parent/`depends_on` cycles, duplicate state keys), the exactly-once and
96/// write-mode gates, edge endpoints — without resolving a single secret or
97/// constructing a single connector. Node arity in topology mode is validated
98/// when the graph is built, i.e. at trigger time, because building it requires
99/// live connectors that a placeholder-bound config must not create.
100pub async fn register(store: &TemplateStore, req: RegisterRequest) -> CliResult<TemplateRecord> {
101    let mut doc = parse_body(&req.body, req.format)?;
102    if !doc.is_object() {
103        return Err(CliError::Config(
104            "a pipeline template must be a config document (a YAML/JSON mapping)".into(),
105        ));
106    }
107
108    // The declared trigger surface, validated and stored alongside the body so
109    // callers can discover it without re-parsing.
110    let declared = params::declared(&doc)?;
111
112    // Structural validation on a placeholder-bound copy. `${env:…}` and secret
113    // directives are left untouched — registration must never read the server's
114    // secrets, and the body we persist is the one that was submitted.
115    let mut probe = doc.clone();
116    params::bind_document(&mut probe, &SuppliedParams::new(), BindMode::Placeholder)?;
117    let cfg = crate::config::PipelineConfig::from_value(probe)?;
118    if crate::topology::is_topology(&cfg) {
119        crate::topology::validate_topology_spec(&cfg)?;
120    } else {
121        // Compile each row's transform chain too — `expand` only checks an entry's
122        // shape, so without this a template with a misspelled transform field
123        // registers cleanly and fails at trigger time instead.
124        for node in crate::expand::expand(&cfg)? {
125            if node.transforms.is_empty() {
126                continue;
127            }
128            crate::transforms::compile_transforms(&node.transforms)
129                .map_err(|e| CliError::Config(format!("row '{}': {e}", node.id)))?;
130        }
131    }
132
133    let id = match &req.id {
134        Some(raw) => TemplateId::parse(raw)?,
135        None => {
136            let name = cfg.name.as_deref().ok_or_else(|| {
137                CliError::Config(
138                    "no template id given and the config has no `name:` to derive one from — \
139                     pass an explicit id"
140                        .into(),
141                )
142            })?;
143            TemplateId::from_config_name(name)?
144        }
145    };
146
147    // Keep the persisted body byte-identical to what was submitted.
148    let _ = &mut doc;
149
150    // Reject a derived channel before writing anything, so a bad request never
151    // leaves a half-registered version behind.
152    for tag in &req.tags {
153        reject_derived(*tag)?;
154    }
155
156    // A description describes the *template*, not the build, so carry the previous
157    // version's forward when the caller omits one. Without this, a deploy that
158    // re-registers without `--description` blanks the listing for everybody.
159    let description = match &req.description {
160        Some(d) => Some(d.clone()),
161        None => store
162            .template_get(id.as_str(), None)
163            .await
164            .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
165            .and_then(|prev| prev.description),
166    };
167
168    let draft = TemplateDraft {
169        id,
170        name: cfg.name.clone(),
171        description,
172        body: req.body.clone(),
173        format: req.format,
174        params: declared,
175        created_by: req.created_by.clone(),
176    };
177    let record = store
178        .template_register(&draft)
179        .await
180        .map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
181
182    // Point the requested channels at the version just created.
183    for tag in &req.tags {
184        store
185            .template_set_tag(&record.id, tag.as_str(), record.version)
186            .await
187            .map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
188    }
189    // `--launch` is the only way a register makes a version live.
190    if req.launch {
191        store
192            .template_launch(&record.id, record.version, req.created_by.as_deref())
193            .await
194            .map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
195    }
196    Ok(record)
197}
198
199/// `latest` is computed from the version list, so promoting or deleting it makes
200/// no sense — say so instead of silently no-oping.
201fn reject_derived(tag: VersionChannel) -> CliResult<()> {
202    if tag.is_derived() {
203        let how = match tag {
204            VersionChannel::Stable => " — move it with `faucet template launch` instead",
205            VersionChannel::Previous => " — it is whatever was launched before the current version",
206            _ => " — it is always the highest version number",
207        };
208        return Err(CliError::Config(format!(
209            "`{tag}` is a derived channel and cannot be promoted{how}. Promotable channels: {}",
210            VersionChannel::ASSIGNABLE
211                .iter()
212                .map(|c| c.as_str())
213                .collect::<Vec<_>>()
214                .join(", ")
215        )));
216    }
217    Ok(())
218}
219
220/// Resolve a [`VersionSelector`] to the exact version to act on.
221///
222/// **Every** channel — derived or assigned — is looked up here; nothing falls back
223/// to "the newest build". A selector that names an unset channel is an error
224/// listing what *is* set, because silently substituting another version is how a
225/// caller ends up running code they did not ask for.
226pub async fn resolve_version(
227    store: &TemplateStore,
228    id: &str,
229    selector: VersionSelector,
230) -> CliResult<u32> {
231    if let VersionSelector::Pinned(n) = selector {
232        return Ok(n);
233    }
234    let channel = selector
235        .channel()
236        .expect("non-pinned selector names a channel");
237    let state = template_state(store, id).await?;
238    if state.versions.is_empty() {
239        return Err(CliError::UnknownPipelineTemplate {
240            id: id.to_string(),
241            version: None,
242        });
243    }
244    state
245        .derived(channel)
246        .ok_or_else(|| unresolved_channel(id, channel, &state))
247}
248
249/// The error for a selector that names a channel with nothing behind it. Phrased
250/// per channel, because the fix differs: `stable` needs a *launch*, `previous`
251/// needs a second launch, an environment channel needs a *promote*.
252fn unresolved_channel(id: &str, channel: VersionChannel, state: &TemplateState) -> CliError {
253    let newest = state
254        .newest
255        .map(|v| v.to_string())
256        .unwrap_or_else(|| "1".into());
257    match channel {
258        // Phrased for both audiences — the same error surfaces on the CLI, over
259        // HTTP, and in the console's versions page.
260        VersionChannel::Stable => CliError::Config(format!(
261            "template '{id}' has no launched version (status: {}). Launch one first \
262             (`faucet template launch {id} --version {newest}`, or \
263             `POST /v1/templates/{id}/launch`), or select a specific build with \
264             `newest` / a version number",
265            state.status
266        )),
267        VersionChannel::Previous => CliError::Config(format!(
268            "template '{id}' has no previous version — {}. `previous` is the version launched \
269             before the current one, so it only exists after a second launch",
270            match state.stable {
271                Some(v) => format!("v{v} is the first and only launched version"),
272                None => "nothing has been launched yet".to_string(),
273            }
274        )),
275        // `newest` is unreachable here (a template with versions always has one),
276        // so this arm only guards a future channel gaining derived status.
277        VersionChannel::Newest => {
278            CliError::Config(format!("template '{id}' has no versions registered"))
279        }
280        assigned => CliError::Config(format!(
281            "template '{id}' has no `{assigned}` version. Channels currently set: {}. Promote one \
282             with `faucet template promote {id} --tag {assigned} --version <n>`",
283            if state.tags.is_empty() {
284                String::from("(none)")
285            } else {
286                state
287                    .tags
288                    .iter()
289                    .map(|(t, v)| format!("{t}=v{v}"))
290                    .collect::<Vec<_>>()
291                    .join(", ")
292            }
293        )),
294    }
295}
296
297/// Every registered template's latest version, each carrying its release state.
298///
299/// One extra read per template — the registry is a small, human-curated set, and
300/// assembling the state per row keeps the list and detail views consistent by
301/// construction rather than by convention.
302pub async fn list_with_state(store: &TemplateStore) -> CliResult<Vec<TemplateSummary>> {
303    let mut out = store
304        .template_list()
305        .await
306        .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?;
307    for summary in &mut out {
308        summary.state = Some(template_state(store, &summary.id).await?);
309    }
310    Ok(out)
311}
312
313/// The template's full release state (status, `stable` / `previous` / `newest`,
314/// channel pointers). Errors only if the registry itself is unreadable.
315pub async fn template_state(store: &TemplateStore, id: &str) -> CliResult<TemplateState> {
316    store
317        .template_state(id)
318        .await
319        .map_err(|e| CliError::Internal(format!("template registry read: {e}")))
320}
321
322/// Confirm a version exists, returning a typed error naming it if not.
323async fn require_version(store: &TemplateStore, id: &str, version: u32) -> CliResult<()> {
324    if store
325        .template_get(id, Some(version))
326        .await
327        .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
328        .is_none()
329    {
330        return Err(CliError::UnknownPipelineTemplate {
331            id: id.to_string(),
332            version: Some(version),
333        });
334    }
335    Ok(())
336}
337
338/// Point a named environment channel at a version, moving it if already set.
339///
340/// The target is itself a selector, so `--tag prod --version stable` promotes
341/// whatever is currently launched — the "use the version I already blessed" case —
342/// and `--version 3` pins an exact build. Derived channels (`stable`, `previous`,
343/// `newest`) are not valid *targets*: `stable` moves via [`launch`], and the other
344/// two are computed.
345pub async fn promote(
346    store: &TemplateStore,
347    id: &str,
348    tag: VersionChannel,
349    target: VersionSelector,
350) -> CliResult<u32> {
351    reject_derived(tag)?;
352    let version = resolve_version(store, id, target).await?;
353    require_version(store, id, version).await?;
354    store
355        .template_set_tag(id, tag.as_str(), version)
356        .await
357        .map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
358    Ok(version)
359}
360
361/// The outcome of a [`launch`]: which version is now live, and what it replaced.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct LaunchOutcome {
364    /// The version now launched (`stable`).
365    pub version: u32,
366    /// The version it replaced — the new `previous`. `None` on a first launch.
367    pub replaced: Option<u32>,
368    /// True when the requested version was already launched, so nothing changed.
369    pub already_launched: bool,
370    /// Whether this launch flipped the template out of `draft`.
371    pub first_launch: bool,
372}
373
374/// **Launch** a version: make it `stable`, so unpinned callers start using it.
375///
376/// This is the one deliberate act that moves consumers. Registering a build never
377/// does — that is the whole point of the model, so a nightly can land without
378/// dragging anyone along.
379///
380/// Refuses to launch while the template is deprecated: reviving a retired template
381/// by moving its live pointer is almost certainly a mistake, and `--undo` makes the
382/// intent explicit.
383pub async fn launch(
384    store: &TemplateStore,
385    id: &str,
386    target: VersionSelector,
387    launched_by: Option<&str>,
388) -> CliResult<LaunchOutcome> {
389    let version = resolve_version(store, id, target).await?;
390    require_version(store, id, version).await?;
391    let before = template_state(store, id).await?;
392    if before.status == TemplateStatus::Deprecated {
393        return Err(CliError::Config(format!(
394            "template '{id}' is deprecated — un-deprecate it first with \
395             `faucet template deprecate {id} --undo`, then launch"
396        )));
397    }
398    let seq = store
399        .template_launch(id, version, launched_by)
400        .await
401        .map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
402    Ok(LaunchOutcome {
403        version,
404        replaced: before.stable,
405        already_launched: seq.is_none(),
406        first_launch: before.stable.is_none(),
407    })
408}
409
410/// Roll back to the previously launched version — `launch` of `previous`, named
411/// for the thing you actually want to find under pressure.
412pub async fn rollback(
413    store: &TemplateStore,
414    id: &str,
415    launched_by: Option<&str>,
416) -> CliResult<LaunchOutcome> {
417    launch(
418        store,
419        id,
420        VersionSelector::Channel(VersionChannel::Previous),
421        launched_by,
422    )
423    .await
424}
425
426/// Retire (`Some`) or revive (`None`) a template.
427///
428/// Deprecation is **template-wide**, not per version: a build that should not be
429/// used simply never gets launched (or gets deleted). A deprecated template keeps
430/// serving callers who pin or ride `stable` — retiring must not hard-break
431/// them — but every trigger warns and listings mark it. Returns the resulting
432/// status.
433pub async fn set_deprecated(
434    store: &TemplateStore,
435    id: &str,
436    reason: Option<String>,
437    by: Option<&str>,
438    deprecated: bool,
439) -> CliResult<TemplateStatus> {
440    let state = template_state(store, id).await?;
441    if state.versions.is_empty() {
442        return Err(CliError::UnknownPipelineTemplate {
443            id: id.to_string(),
444            version: None,
445        });
446    }
447    let record = deprecated.then(|| DeprecationRecord {
448        deprecated_at: chrono::Utc::now(),
449        deprecated_by: by.map(str::to_string),
450        reason,
451    });
452    store
453        .template_set_deprecation(id, record.as_ref())
454        .await
455        .map_err(|e| CliError::Internal(format!("template deprecation write: {e}")))?;
456    Ok(TemplateStatus::derive(state.stable.is_some(), deprecated))
457}
458
459/// Where the materialized config is going — which decides whether load-time
460/// directives may be resolved here.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum Materialize {
463    /// The body is executed by *this* process and never stored. Load-time
464    /// directives (`${env:}` / `${file:}` / `${secret:}`) are resolved here, so a
465    /// caller-supplied `env` overlay takes effect.
466    Local,
467    /// The body will be **persisted** for another instance to execute (a
468    /// clustered submit). Load-time directives are left as tokens for the
469    /// executing instance to resolve, so a resolved credential is never written
470    /// to the shared run-history database (#456 C5).
471    Persisted,
472}
473
474/// Fetch a template version and bind the supplied params into a runnable config
475/// document.
476///
477/// Ordering mirrors the file-load path: `${env:}` / `${file:}` / `${secret:}`
478/// resolve **first** (with `env_overrides` taking precedence over the process
479/// environment), then `${param.*}` binds. A supplied param value is therefore
480/// never itself scanned for directives, so a caller cannot use a param to read
481/// the server's environment or secret store.
482///
483/// Under [`Materialize::Persisted`] the first step is **skipped**: the directives
484/// stay as tokens and are resolved later by `load_submission` on whichever
485/// instance runs the job. Resolving them here would serialise the *values* into
486/// the body that gets stored in the shared database — which is how a
487/// `${env:DB_PASSWORD}` in a template body ended up in plaintext there. The
488/// trade-off is that a typed (`int`/`float`/`bool`) param whose `default` is
489/// itself a directive cannot be coerced in this mode; it fails loudly at trigger
490/// time naming the param, rather than silently.
491pub async fn materialize(
492    store: &TemplateStore,
493    id: &str,
494    version: u32,
495    supplied: &SuppliedParams,
496    env_overrides: &BTreeMap<String, String>,
497    mode: Materialize,
498) -> CliResult<MaterializedConfig> {
499    // Takes a concrete version, never an `Option`: "no version given" is resolved
500    // by `resolve_version` against the registry, so there is no code path where a
501    // `None` here could quietly mean "the newest build".
502    let record = store
503        .template_get(id, Some(version))
504        .await
505        .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
506        .ok_or_else(|| CliError::UnknownPipelineTemplate {
507            id: id.to_string(),
508            version: Some(version),
509        })?;
510
511    let mut doc = parse_body(&record.body, record.format)?;
512    if mode == Materialize::Local {
513        let overlay: crate::interpolate::EnvOverlay = env_overrides
514            .iter()
515            .map(|(k, v)| (k.clone(), v.clone()))
516            .collect();
517        crate::interpolate::interpolate_value_with_env(&mut doc, &overlay)?;
518    }
519    let bound = params::bind_document(&mut doc, supplied, BindMode::Strict)?;
520    // Drop the declaration block: materialization is the moment params cease to
521    // exist. Leaving it would make any later load re-run the bind pass with no
522    // supplied values and reject the config for a "missing" required param —
523    // and the param surface is already recorded on the template and echoed to
524    // the caller, so nothing is lost.
525    if let Some(map) = doc.as_object_mut() {
526        map.remove(params::PARAMS_KEY);
527    }
528
529    let body = serde_json::to_string(&doc)
530        .map_err(|e| CliError::Internal(format!("re-serializing template body: {e}")))?;
531    Ok(MaterializedConfig {
532        template_id: record.id.clone(),
533        version: record.version,
534        name: record.name.clone(),
535        body,
536        params_redacted: bound.redacted(),
537        used_secret_params: bound.has_secrets(),
538    })
539}
540
541/// Connect a template store from a URL: `memory`, `sqlite:<path>`, or a
542/// `postgres://…` URL. Same grammar (and same build-feature requirements) as
543/// `catalog.url` and `faucet serve --history`, so one store can hold run
544/// history, the dataset catalog, and the template registry together.
545pub async fn resolve_store_url(url: &str) -> CliResult<TemplateStore> {
546    let backend = match url {
547        "memory" => HistoryBackendSpec::Memory,
548        u if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
549            HistoryBackendSpec::Postgres(u.to_string())
550        }
551        u if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u.to_string()),
552        other => {
553            return Err(CliError::Config(format!(
554                "template store '{other}' is not recognised — expected 'memory', \
555                 'sqlite:<path>', or a 'postgres://…' URL"
556            )));
557        }
558    };
559    history::connect(
560        &backend,
561        // Idempotency claims and run leases are run-history concerns; a
562        // template-only connection never uses them.
563        Duration::from_secs(3600),
564        Duration::from_secs(30),
565        &uuid::Uuid::now_v7().to_string(),
566    )
567    .await
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::serve::history::memory::MemoryHistory;
574    use serde_json::json;
575
576    fn store() -> TemplateStore {
577        Arc::new(MemoryHistory::new(Duration::from_secs(60))) as TemplateStore
578    }
579
580    const PARAMETERIZED: &str = "\
581version: 1
582name: tenant-sync
583params:
584  tenant_id: { required: true, description: Tenant to sync }
585  since: { default: \"1970-01-01\" }
586  page: { type: int, default: 100 }
587pipeline:
588  source:
589    type: rest
590    config:
591      url: \"https://api.example.com/${param.tenant_id}/events?since=${param.since}\"
592  sink:
593    type: jsonl
594    config:
595      path: ./out.jsonl
596";
597
598    fn req(body: &str) -> RegisterRequest {
599        RegisterRequest {
600            id: None,
601            body: body.to_string(),
602            format: ConfigFormat::Yaml,
603            description: Some("test".into()),
604            tags: Vec::new(),
605            launch: false,
606            created_by: Some("tester".into()),
607        }
608    }
609
610    /// Register + launch in one step, for tests that only care about the result.
611    fn req_launched(body: &str) -> RegisterRequest {
612        RegisterRequest {
613            launch: true,
614            ..req(body)
615        }
616    }
617
618    #[tokio::test]
619    async fn registers_and_versions() {
620        let s = store();
621        let first = register(&s, req(PARAMETERIZED)).await.unwrap();
622        assert_eq!(first.id, "tenant-sync");
623        assert_eq!(first.version, 1);
624        assert_eq!(first.created_by.as_deref(), Some("tester"));
625        assert!(first.params["tenant_id"].required);
626        assert_eq!(first.params["page"].default, Some(json!(100)));
627        assert_eq!(first.body, PARAMETERIZED, "body stored verbatim");
628
629        let second = register(&s, req(PARAMETERIZED)).await.unwrap();
630        assert_eq!(second.version, 2);
631        assert_eq!(
632            s.template_versions("tenant-sync").await.unwrap(),
633            vec![2, 1]
634        );
635        let listed = list_with_state(&s).await.unwrap();
636        assert_eq!(listed.len(), 1, "list folds to one row per id");
637        // A register is inert: two versions exist, nothing is live.
638        let st = listed[0].state.as_ref().unwrap();
639        assert_eq!(st.status, TemplateStatus::Draft);
640        assert_eq!(st.newest, Some(2));
641        assert_eq!(st.stable, None);
642    }
643
644    #[tokio::test]
645    async fn register_compiles_transforms_not_just_their_shape() {
646        let s = store();
647        // `set` takes `values:`; `fields:` is a plausible typo that used to
648        // register cleanly and then fail at trigger time.
649        let mut bad = req(r#"
650version: 1
651name: tenant-sync
652pipeline:
653  source: { type: rest, config: {} }
654  transforms:
655    - type: set
656      config: { fields: { a: 1 } }
657  sink: { type: jsonl, config: { path: ./o.jsonl } }
658"#);
659        bad.description = None;
660        let err = register(&s, bad).await.unwrap_err().to_string();
661        assert!(err.contains("values"), "names the missing field: {err}");
662        assert!(
663            s.template_versions("tenant-sync").await.unwrap().is_empty(),
664            "nothing is persisted when validation fails"
665        );
666    }
667
668    #[tokio::test]
669    async fn a_description_carries_forward_across_registers() {
670        let s = store();
671        let first = register(&s, req(PARAMETERIZED)).await.unwrap();
672        assert_eq!(first.description.as_deref(), Some("test"));
673
674        // A deploy that re-registers without `--description` must not blank it.
675        let mut bare = req(PARAMETERIZED);
676        bare.description = None;
677        let second = register(&s, bare).await.unwrap();
678        assert_eq!(second.description.as_deref(), Some("test"));
679
680        // An explicit description still wins.
681        let mut changed = req(PARAMETERIZED);
682        changed.description = Some("now something else".into());
683        let third = register(&s, changed).await.unwrap();
684        assert_eq!(third.description.as_deref(), Some("now something else"));
685
686        // …and the new one is what the next bare register inherits.
687        let mut bare2 = req(PARAMETERIZED);
688        bare2.description = None;
689        let fourth = register(&s, bare2).await.unwrap();
690        assert_eq!(fourth.description.as_deref(), Some("now something else"));
691    }
692
693    #[tokio::test]
694    async fn a_register_never_moves_existing_callers() {
695        let s = store();
696        register(&s, req_launched(PARAMETERIZED)).await.unwrap(); // v1, launched
697        assert_eq!(
698            resolve_version(&s, "tenant-sync", VersionSelector::stable())
699                .await
700                .unwrap(),
701            1
702        );
703
704        // A nightly lands as v2 — `stable` must not budge. This is the property
705        // the whole model exists for.
706        register(&s, req(PARAMETERIZED)).await.unwrap();
707        assert_eq!(
708            resolve_version(&s, "tenant-sync", VersionSelector::stable())
709                .await
710                .unwrap(),
711            1,
712            "registering a build must not move the launched version"
713        );
714        assert_eq!(
715            resolve_version(&s, "tenant-sync", VersionSelector::newest())
716                .await
717                .unwrap(),
718            2,
719            "`newest` is how you reach the un-launched build"
720        );
721
722        // Launching is the deliberate act that moves them.
723        let out = launch(&s, "tenant-sync", VersionSelector::newest(), Some("alice"))
724            .await
725            .unwrap();
726        assert_eq!((out.version, out.replaced), (2, Some(1)));
727        assert!(!out.first_launch);
728        assert_eq!(
729            resolve_version(&s, "tenant-sync", VersionSelector::stable())
730                .await
731                .unwrap(),
732            2
733        );
734        // `previous` is now the version launched before it.
735        assert_eq!(
736            resolve_version(
737                &s,
738                "tenant-sync",
739                VersionSelector::Channel(VersionChannel::Previous)
740            )
741            .await
742            .unwrap(),
743            1
744        );
745    }
746
747    #[tokio::test]
748    async fn draft_template_has_no_stable_and_says_how_to_fix_it() {
749        let s = store();
750        register(&s, req(PARAMETERIZED)).await.unwrap();
751        let state = template_state(&s, "tenant-sync").await.unwrap();
752        assert_eq!(state.status, TemplateStatus::Draft);
753
754        // Unpinned resolution fails with the exact command to run — never a
755        // silent fallback to the newest build.
756        let err = resolve_version(&s, "tenant-sync", VersionSelector::stable())
757            .await
758            .unwrap_err()
759            .to_string();
760        assert!(err.contains("no launched version"), "{err}");
761        assert!(err.contains("faucet template launch"), "{err}");
762        // But explicit selectors work, so a draft is fully testable.
763        assert_eq!(
764            resolve_version(&s, "tenant-sync", VersionSelector::newest())
765                .await
766                .unwrap(),
767            1
768        );
769        assert_eq!(
770            resolve_version(&s, "tenant-sync", VersionSelector::Pinned(1))
771                .await
772                .unwrap(),
773            1
774        );
775    }
776
777    #[tokio::test]
778    async fn first_launch_flips_status_and_relaunch_is_a_noop() {
779        let s = store();
780        register(&s, req(PARAMETERIZED)).await.unwrap();
781        let out = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
782            .await
783            .unwrap();
784        assert!(out.first_launch);
785        assert_eq!(out.replaced, None);
786        assert_eq!(
787            template_state(&s, "tenant-sync").await.unwrap().status,
788            TemplateStatus::Launched
789        );
790
791        // Re-launching what is already live changes nothing — and crucially does
792        // not append, which would make `previous` a duplicate of `stable`.
793        let again = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
794            .await
795            .unwrap();
796        assert!(again.already_launched);
797        assert_eq!(s.template_launches("tenant-sync").await.unwrap().len(), 1);
798        let err = resolve_version(
799            &s,
800            "tenant-sync",
801            VersionSelector::Channel(VersionChannel::Previous),
802        )
803        .await
804        .unwrap_err()
805        .to_string();
806        assert!(err.contains("no previous version"), "{err}");
807    }
808
809    #[tokio::test]
810    async fn rollback_returns_to_the_prior_launch() {
811        let s = store();
812        for _ in 0..3 {
813            register(&s, req(PARAMETERIZED)).await.unwrap();
814        }
815        launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
816            .await
817            .unwrap();
818        launch(&s, "tenant-sync", VersionSelector::Pinned(3), None)
819            .await
820            .unwrap();
821
822        let out = rollback(&s, "tenant-sync", Some("oncall")).await.unwrap();
823        assert_eq!(out.version, 1, "rollback re-launches `previous`");
824        assert_eq!(out.replaced, Some(3));
825        let state = template_state(&s, "tenant-sync").await.unwrap();
826        assert_eq!(state.stable, Some(1));
827        assert_eq!(
828            state.previous,
829            Some(3),
830            "previous now points at what we left"
831        );
832
833        // The launch log is the audit trail: v1, v3, v1, newest first.
834        let log = s.template_launches("tenant-sync").await.unwrap();
835        assert_eq!(
836            log.iter().map(|l| l.version).collect::<Vec<_>>(),
837            vec![1, 3, 1]
838        );
839        assert_eq!(log[0].launched_by.as_deref(), Some("oncall"));
840    }
841
842    #[tokio::test]
843    async fn deprecation_is_template_wide_and_reversible() {
844        let s = store();
845        register(&s, req_launched(PARAMETERIZED)).await.unwrap();
846
847        let status = set_deprecated(
848            &s,
849            "tenant-sync",
850            Some("superseded".into()),
851            Some("bob"),
852            true,
853        )
854        .await
855        .unwrap();
856        assert_eq!(status, TemplateStatus::Deprecated);
857        let state = template_state(&s, "tenant-sync").await.unwrap();
858        assert_eq!(state.status, TemplateStatus::Deprecated);
859        assert_eq!(
860            state.deprecation.as_ref().unwrap().reason.as_deref(),
861            Some("superseded")
862        );
863        // Retiring must not break existing callers: `stable` still resolves.
864        assert_eq!(
865            resolve_version(&s, "tenant-sync", VersionSelector::stable())
866                .await
867                .unwrap(),
868            1
869        );
870        // But launching into a retired template is refused — reviving it that way
871        // is almost certainly a mistake.
872        let err = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
873            .await
874            .unwrap_err()
875            .to_string();
876        assert!(err.contains("deprecated"), "{err}");
877
878        // `--undo` restores the prior status, derived rather than remembered.
879        let status = set_deprecated(&s, "tenant-sync", None, None, false)
880            .await
881            .unwrap();
882        assert_eq!(status, TemplateStatus::Launched);
883        assert!(
884            template_state(&s, "tenant-sync")
885                .await
886                .unwrap()
887                .deprecation
888                .is_none()
889        );
890        // Deprecating a template that does not exist is a typed error.
891        assert!(matches!(
892            set_deprecated(&s, "nope", None, None, true)
893                .await
894                .unwrap_err(),
895            CliError::UnknownPipelineTemplate { .. }
896        ));
897    }
898
899    #[tokio::test]
900    async fn explicit_id_wins_and_is_validated() {
901        let s = store();
902        let mut r = req(PARAMETERIZED);
903        r.id = Some("my-template".into());
904        assert_eq!(register(&s, r).await.unwrap().id, "my-template");
905
906        let mut bad = req(PARAMETERIZED);
907        bad.id = Some("Bad Id".into());
908        assert!(register(&s, bad).await.is_err());
909    }
910
911    #[tokio::test]
912    async fn register_requires_an_id_source() {
913        let s = store();
914        let body = "version: 1\npipeline:\n  source: { type: csv, config: { path: a.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n";
915        let err = register(&s, req(body)).await.unwrap_err().to_string();
916        assert!(err.contains("no template id"), "{err}");
917    }
918
919    #[tokio::test]
920    async fn register_rejects_a_structurally_invalid_config() {
921        let s = store();
922        let err = register(&s, req("version: 1\nname: x\nnope: 1\npipeline: {}\n"))
923            .await
924            .unwrap_err()
925            .to_string();
926        assert!(err.contains("nope") || err.contains("pipeline"), "{err}");
927    }
928
929    #[tokio::test]
930    async fn register_rejects_an_invalid_params_block() {
931        let s = store();
932        let body = "version: 1\nname: x\nparams:\n  a: { required: true, default: 1 }\npipeline:\n  source: { type: csv, config: { path: a.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n";
933        let err = register(&s, req(body)).await.unwrap_err().to_string();
934        assert!(err.contains("required"), "{err}");
935    }
936
937    #[tokio::test]
938    async fn register_rejects_a_non_mapping_body() {
939        let s = store();
940        let err = register(&s, req("- a\n- b\n"))
941            .await
942            .unwrap_err()
943            .to_string();
944        assert!(err.contains("mapping"), "{err}");
945        let err = register(&s, req(": :\n")).await.unwrap_err().to_string();
946        assert!(err.contains("YAML"), "{err}");
947    }
948
949    #[tokio::test]
950    async fn materialize_binds_params_and_defaults() {
951        let s = store();
952        register(&s, req_launched(PARAMETERIZED)).await.unwrap();
953        let supplied: SuppliedParams = [("tenant_id".to_string(), json!("acme"))].into();
954        let want = resolve_version(&s, "tenant-sync", VersionSelector::stable())
955            .await
956            .unwrap();
957        let out = materialize(
958            &s,
959            "tenant-sync",
960            want,
961            &supplied,
962            &BTreeMap::new(),
963            Materialize::Local,
964        )
965        .await
966        .unwrap();
967        assert_eq!(out.version, 1);
968        assert_eq!(out.name.as_deref(), Some("tenant-sync"));
969        assert_eq!(out.format(), ConfigFormat::Json);
970        let doc: Value = serde_json::from_str(&out.body).unwrap();
971        assert_eq!(
972            doc["pipeline"]["source"]["config"]["url"],
973            "https://api.example.com/acme/events?since=1970-01-01"
974        );
975        assert_eq!(out.params_redacted["tenant_id"], json!("acme"));
976        assert_eq!(out.params_redacted["page"], json!(100));
977        assert!(!out.used_secret_params);
978    }
979
980    #[tokio::test]
981    async fn materialize_reports_missing_and_unknown_params() {
982        let s = store();
983        register(&s, req_launched(PARAMETERIZED)).await.unwrap();
984        let err = materialize(
985            &s,
986            "tenant-sync",
987            1,
988            &SuppliedParams::new(),
989            &BTreeMap::new(),
990            Materialize::Local,
991        )
992        .await
993        .unwrap_err();
994        assert!(matches!(err, CliError::MissingParam { .. }), "{err:?}");
995
996        let supplied: SuppliedParams = [
997            ("tenant_id".to_string(), json!("a")),
998            ("bogus".to_string(), json!("b")),
999        ]
1000        .into();
1001        let err = materialize(
1002            &s,
1003            "tenant-sync",
1004            1,
1005            &supplied,
1006            &BTreeMap::new(),
1007            Materialize::Local,
1008        )
1009        .await
1010        .unwrap_err();
1011        assert!(matches!(err, CliError::UnknownParam { .. }), "{err:?}");
1012    }
1013
1014    #[tokio::test]
1015    async fn unknown_template_and_version_are_typed_errors() {
1016        let s = store();
1017        register(&s, req_launched(PARAMETERIZED)).await.unwrap();
1018        let err = resolve_version(&s, "nope", VersionSelector::stable())
1019            .await
1020            .unwrap_err();
1021        assert!(
1022            matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
1023            "{err:?}"
1024        );
1025        let supplied: SuppliedParams = [("tenant_id".to_string(), json!("a"))].into();
1026        let err = materialize(
1027            &s,
1028            "tenant-sync",
1029            9,
1030            &supplied,
1031            &BTreeMap::new(),
1032            Materialize::Local,
1033        )
1034        .await
1035        .unwrap_err();
1036        assert!(
1037            matches!(
1038                err,
1039                CliError::UnknownPipelineTemplate {
1040                    version: Some(9),
1041                    ..
1042                }
1043            ),
1044            "{err:?}"
1045        );
1046    }
1047
1048    #[tokio::test]
1049    async fn env_overrides_win_over_the_process_environment() {
1050        let s = store();
1051        let body = "\
1052version: 1
1053name: env-template
1054pipeline:
1055  source: { type: rest, config: { url: \"https://x/${env:FAUCET_TPL_REGION}\" } }
1056  sink: { type: jsonl, config: { path: ./o.jsonl } }
1057";
1058        unsafe { std::env::set_var("FAUCET_TPL_REGION", "from-process") };
1059        register(&s, req_launched(body)).await.unwrap();
1060
1061        let out = materialize(
1062            &s,
1063            "env-template",
1064            1,
1065            &SuppliedParams::new(),
1066            &BTreeMap::new(),
1067            Materialize::Local,
1068        )
1069        .await
1070        .unwrap();
1071        let doc: Value = serde_json::from_str(&out.body).unwrap();
1072        assert_eq!(
1073            doc["pipeline"]["source"]["config"]["url"],
1074            "https://x/from-process"
1075        );
1076
1077        let overrides: BTreeMap<String, String> =
1078            [("FAUCET_TPL_REGION".to_string(), "from-request".to_string())].into();
1079        let out = materialize(
1080            &s,
1081            "env-template",
1082            1,
1083            &SuppliedParams::new(),
1084            &overrides,
1085            Materialize::Local,
1086        )
1087        .await
1088        .unwrap();
1089        let doc: Value = serde_json::from_str(&out.body).unwrap();
1090        assert_eq!(
1091            doc["pipeline"]["source"]["config"]["url"],
1092            "https://x/from-request"
1093        );
1094        assert_eq!(std::env::var("FAUCET_TPL_REGION").unwrap(), "from-process");
1095        unsafe { std::env::remove_var("FAUCET_TPL_REGION") };
1096    }
1097
1098    #[tokio::test]
1099    async fn secret_params_are_flagged_and_redacted() {
1100        let s = store();
1101        let body = "\
1102version: 1
1103name: secret-template
1104params:
1105  api_token: { required: true, secret: true }
1106pipeline:
1107  source:
1108    type: rest
1109    config:
1110      url: https://api.example.com/events
1111      auth: { type: bearer, config: { token: \"${param.api_token}\" } }
1112  sink: { type: jsonl, config: { path: ./o.jsonl } }
1113";
1114        register(&s, req_launched(body)).await.unwrap();
1115        let supplied: SuppliedParams =
1116            [("api_token".to_string(), json!("tok-abcdefghijklmnop"))].into();
1117        let out = materialize(
1118            &s,
1119            "secret-template",
1120            1,
1121            &supplied,
1122            &BTreeMap::new(),
1123            Materialize::Local,
1124        )
1125        .await
1126        .unwrap();
1127        assert!(out.used_secret_params);
1128        assert_eq!(out.params_redacted["api_token"], json!("***"));
1129        assert!(out.body.contains("tok-abcdefghijklmnop"));
1130        assert_eq!(
1131            crate::secrets::registry::redact("token=tok-abcdefghijklmnop"),
1132            "token=***"
1133        );
1134    }
1135
1136    #[tokio::test]
1137    async fn channels_are_promoted_independently_of_launching() {
1138        let s = store();
1139        register(&s, req_launched(PARAMETERIZED)).await.unwrap(); // v1 live
1140        register(&s, req(PARAMETERIZED)).await.unwrap(); // v2 draft build
1141        let mut tagged = req(PARAMETERIZED);
1142        tagged.tags = vec![VersionChannel::Dev];
1143        register(&s, tagged).await.unwrap(); // v3, dev=v3
1144
1145        assert_eq!(
1146            resolve_version(
1147                &s,
1148                "tenant-sync",
1149                VersionSelector::Channel(VersionChannel::Dev)
1150            )
1151            .await
1152            .unwrap(),
1153            3
1154        );
1155        // Promoting an environment channel never touches what is live.
1156        assert_eq!(
1157            promote(
1158                &s,
1159                "tenant-sync",
1160                VersionChannel::PreProd,
1161                VersionSelector::Channel(VersionChannel::Dev)
1162            )
1163            .await
1164            .unwrap(),
1165            3
1166        );
1167        let state = template_state(&s, "tenant-sync").await.unwrap();
1168        assert_eq!(state.stable, Some(1), "promote must not move `stable`");
1169        assert_eq!(state.tags["dev"], 3);
1170        assert_eq!(state.tags["pre-prod"], 3);
1171        assert!(!state.tags.contains_key("stable"), "derived, never stored");
1172
1173        // Launching *from* a channel is the promotion pipeline's last step.
1174        let out = launch(
1175            &s,
1176            "tenant-sync",
1177            VersionSelector::Channel(VersionChannel::PreProd),
1178            None,
1179        )
1180        .await
1181        .unwrap();
1182        assert_eq!((out.version, out.replaced), (3, Some(1)));
1183    }
1184
1185    #[tokio::test]
1186    async fn derived_channels_cannot_be_promoted() {
1187        let s = store();
1188        register(&s, req_launched(PARAMETERIZED)).await.unwrap();
1189        for (tag, needle) in [
1190            (VersionChannel::Stable, "launch"),
1191            (VersionChannel::Previous, "launched before"),
1192            (VersionChannel::Newest, "highest version"),
1193        ] {
1194            let err = promote(&s, "tenant-sync", tag, VersionSelector::Pinned(1))
1195                .await
1196                .unwrap_err()
1197                .to_string();
1198            assert!(err.contains("derived"), "{tag}: {err}");
1199            assert!(err.contains(needle), "{tag}: {err}");
1200        }
1201        // Same guard on the register path, before anything is written.
1202        let mut bad = req(PARAMETERIZED);
1203        bad.tags = vec![VersionChannel::Stable];
1204        assert!(register(&s, bad).await.is_err());
1205        assert_eq!(
1206            s.template_versions("tenant-sync").await.unwrap(),
1207            vec![1],
1208            "the rejected register must not have appended a version"
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn promoting_to_a_missing_version_is_rejected() {
1214        let s = store();
1215        register(&s, req(PARAMETERIZED)).await.unwrap();
1216        let err = promote(
1217            &s,
1218            "tenant-sync",
1219            VersionChannel::Prod,
1220            VersionSelector::Pinned(9),
1221        )
1222        .await
1223        .unwrap_err();
1224        assert!(
1225            matches!(
1226                err,
1227                CliError::UnknownPipelineTemplate {
1228                    version: Some(9),
1229                    ..
1230                }
1231            ),
1232            "{err:?}"
1233        );
1234        assert!(matches!(
1235            promote(&s, "nope", VersionChannel::Prod, VersionSelector::Pinned(1))
1236                .await
1237                .unwrap_err(),
1238            CliError::UnknownPipelineTemplate { .. }
1239        ));
1240    }
1241
1242    #[tokio::test]
1243    async fn deleting_a_version_drops_pointers_aimed_at_it() {
1244        let s = store();
1245        register(&s, req(PARAMETERIZED)).await.unwrap();
1246        register(&s, req(PARAMETERIZED)).await.unwrap();
1247        launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
1248            .await
1249            .unwrap();
1250        launch(&s, "tenant-sync", VersionSelector::Pinned(2), None)
1251            .await
1252            .unwrap();
1253        promote(
1254            &s,
1255            "tenant-sync",
1256            VersionChannel::Prod,
1257            VersionSelector::Pinned(1),
1258        )
1259        .await
1260        .unwrap();
1261
1262        // Deleting v1 must leave neither a channel nor a launch entry pointing at
1263        // it — otherwise `previous` or `prod` would resolve to a missing version.
1264        assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
1265        let state = template_state(&s, "tenant-sync").await.unwrap();
1266        assert!(!state.tags.contains_key("prod"), "{:?}", state.tags);
1267        assert_eq!(state.stable, Some(2));
1268        assert_eq!(state.previous, None, "v1's launch entry went with it");
1269
1270        s.template_delete("tenant-sync", None).await.unwrap();
1271        assert!(s.template_launches("tenant-sync").await.unwrap().is_empty());
1272        assert!(s.template_tags("tenant-sync").await.unwrap().is_empty());
1273    }
1274
1275    #[tokio::test]
1276    async fn delete_removes_one_version_or_all() {
1277        let s = store();
1278        register(&s, req(PARAMETERIZED)).await.unwrap();
1279        register(&s, req(PARAMETERIZED)).await.unwrap();
1280        assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
1281        assert_eq!(s.template_versions("tenant-sync").await.unwrap(), vec![2]);
1282        assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 1);
1283        assert!(s.template_list().await.unwrap().is_empty());
1284        assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 0);
1285        assert_eq!(s.template_delete("tenant-sync", Some(3)).await.unwrap(), 0);
1286    }
1287
1288    #[tokio::test]
1289    async fn registers_a_topology_config() {
1290        let s = store();
1291        let body = "\
1292version: 1
1293name: topo-template
1294params:
1295  path: { default: ./in.csv }
1296pipeline:
1297  sources:
1298    s: { type: csv, config: { path: \"${param.path}\" } }
1299  sinks:
1300    o: { type: jsonl, config: { path: ./out.jsonl } }
1301  nodes:
1302    src: { kind: source, ref: s }
1303    w: { kind: sink, ref: o }
1304  edges:
1305    - { from: src, to: w }
1306";
1307        let rec = register(&s, req_launched(body)).await.unwrap();
1308        assert_eq!(rec.id, "topo-template");
1309        let out = materialize(
1310            &s,
1311            "topo-template",
1312            1,
1313            &SuppliedParams::new(),
1314            &BTreeMap::new(),
1315            Materialize::Local,
1316        )
1317        .await
1318        .unwrap();
1319        let doc: Value = serde_json::from_str(&out.body).unwrap();
1320        assert_eq!(
1321            doc["pipeline"]["sources"]["s"]["config"]["path"],
1322            "./in.csv"
1323        );
1324    }
1325
1326    #[tokio::test]
1327    async fn version_history_is_bounded() {
1328        use crate::serve::history::templates::VERSION_RETAIN;
1329        let s = store();
1330        for _ in 0..(VERSION_RETAIN + 3) {
1331            register(&s, req(PARAMETERIZED)).await.unwrap();
1332        }
1333        let versions = s.template_versions("tenant-sync").await.unwrap();
1334        assert_eq!(versions.len(), VERSION_RETAIN);
1335        assert_eq!(versions[0], (VERSION_RETAIN + 3) as u32, "newest kept");
1336        assert!(!versions.contains(&1), "oldest pruned");
1337    }
1338
1339    #[tokio::test]
1340    async fn store_url_grammar() {
1341        assert!(resolve_store_url("memory").await.is_ok());
1342        // `RunHistory` is not Debug, so match rather than `unwrap_err`.
1343        match resolve_store_url("mysql://nope").await {
1344            Ok(_) => panic!("an unrecognised scheme must be rejected"),
1345            Err(e) => assert!(e.to_string().contains("template store"), "{e}"),
1346        }
1347        // SQL schemes are recognised even without the build feature — the error
1348        // then names the missing feature rather than the URL grammar.
1349        let dir = tempfile::tempdir().unwrap();
1350        let url = format!("sqlite:{}", dir.path().join("t.db").display());
1351        if let Err(e) = resolve_store_url(&url).await {
1352            assert!(e.to_string().contains("serve-history-sqlite"), "{e}");
1353        }
1354    }
1355}