Skip to main content

boatramp_server/
function_api.rs

1//! The function management API (FA-1/FA-2): list function summaries and
2//! deploy, version, alias, roll back, and remove function definitions. This is
3//! the always-on control surface (no wasm engine required); the runtime that
4//! invokes functions lives in `function_runtime`. Pulls the serve-pipeline
5//! scope in via `use super::*`.
6
7use super::*;
8
9use boatramp_core::function::FunctionSummary;
10
11/// `?site=` filter for the functions view.
12#[derive(serde::Deserialize)]
13pub(super) struct FunctionQuery {
14    site: Option<String>,
15}
16
17/// `GET /api/functions[?site=…]` — the derived, **read-only** site-scoped function
18/// view (FA-1): desugar each site's active manifest into functions + triggers and
19/// resolve component paths to their blob-hash version ids. A pure projection of the
20/// manifests — the serve path is untouched, so a site's handlers are unchanged.
21/// `system·read`.
22pub(super) async fn list_functions(
23    State(deploy): State<DeployStore>,
24    Extension(project): axum::extract::Extension<ProjectContext>,
25    axum::extract::Query(query): axum::extract::Query<FunctionQuery>,
26) -> Response {
27    use boatramp_core::function;
28    let sites = match &query.site {
29        Some(s) => vec![s.clone()],
30        None => match deploy.all_sites(project.as_ref()).await {
31            Ok(s) => s,
32            Err(err) => return deploy_error_response(err),
33        },
34    };
35    let mut out: Vec<FunctionSummary> = Vec::new();
36    for site in sites {
37        let manifest = match deploy.current_manifest(project.as_ref(), &site).await {
38            Ok(Some(m)) => m,
39            Ok(None) => continue,
40            Err(err) => return deploy_error_response(err),
41        };
42        let (specs, triggers) = function::desugar(&manifest.config);
43        for f in function::materialize(&specs, &site, &manifest.files, 0) {
44            let trigs = triggers
45                .iter()
46                .filter(|t| t.target.as_ref().map(|r| r.name.as_str()) == Some(f.name.as_str()))
47                .map(std::string::ToString::to_string)
48                .collect();
49            out.push(FunctionSummary {
50                name: format!("{site}/{}", f.name),
51                owner: format!("site:{site}"),
52                runtime: f.config.runtime.as_str().to_string(),
53                version: f.active,
54                triggers: trigs,
55            });
56        }
57    }
58    // Top-level (independently-stored) functions — FA-2. A `?site=` filter is
59    // site-scoped only, so it excludes these.
60    if query.site.is_none() {
61        match deploy.list_stored_functions(project.as_ref()).await {
62            Ok(stored) => {
63                for f in stored {
64                    out.push(FunctionSummary {
65                        name: f.name.clone(),
66                        owner: f.owner.to_string(),
67                        runtime: f.config.runtime.as_str().to_string(),
68                        version: f.active,
69                        // A top-level function has a stable invoke URL (FA-3).
70                        triggers: vec![format!("invoke {}", f.name)],
71                    });
72                }
73            }
74            Err(err) => return deploy_error_response(err),
75        }
76    }
77    Json(out).into_response()
78}
79
80/// Body of `PUT /api/functions/:name` — deploy a version of a top-level function.
81#[derive(serde::Deserialize)]
82pub(super) struct FunctionUpsert {
83    /// The component blob hash (uploaded first via `PUT /api/blobs/<hash>`).
84    pub(super) component: String,
85    /// Binding/capability config.
86    #[serde(default)]
87    pub(super) config: boatramp_core::function::FunctionConfig,
88    /// Version lifecycle (defaults to `deploy-pinned`; top-level functions choose
89    /// `independent`).
90    #[serde(default)]
91    pub(super) lifecycle: boatramp_core::function::Lifecycle,
92}
93
94/// Query for `PUT /api/functions/:name`.
95#[derive(serde::Deserialize, Default)]
96pub(super) struct DeployFunctionQuery {
97    /// When this function is an **already-registered** federation subgraph, whether to refresh
98    /// its registered SDL from the new version and block the deploy if the new schema no longer
99    /// composes (default `true`). Set `false` to deploy without touching the registry — the
100    /// escape hatch for a coordinated multi-subgraph migration.
101    #[serde(default)]
102    register_subgraph: Option<bool>,
103}
104
105/// Invoke `f` with the bytes of every `boatramp:function-manifest` custom section in a
106/// component — descending into its embedded core module(s), where the guest's `#[link_section]`
107/// manifest lives. Best-effort: a malformed component just yields nothing.
108#[cfg(feature = "handlers")]
109fn scan_manifest_sections(bytes: &[u8], f: &mut impl FnMut(&[u8])) {
110    use wasmparser::{Parser, Payload};
111    for payload in Parser::new(0).parse_all(bytes) {
112        match payload {
113            Ok(Payload::CustomSection(reader)) if reader.name() == "boatramp:function-manifest" => {
114                f(reader.data());
115            }
116            Ok(Payload::ModuleSection {
117                unchecked_range, ..
118            }) => scan_manifest_sections(&bytes[unchecked_range], f),
119            Ok(_) => {}
120            Err(_) => return,
121        }
122    }
123}
124
125/// Whether a component **self-declares** a GraphQL federation subgraph: any NDJSON line of its
126/// `boatramp:function-manifest` section carries `"subgraph": true` (the shim's
127/// `#[graphql_subgraph]` emits this). This is the intent signal that lets a first deploy
128/// auto-register the subgraph — a function that does not declare it is never touched.
129#[cfg(feature = "handlers")]
130fn component_declares_subgraph(component: &[u8]) -> bool {
131    let mut declared = false;
132    scan_manifest_sections(component, &mut |data| {
133        for line in data.split(|&b| b == b'\n') {
134            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
135                if v.get("subgraph").and_then(serde_json::Value::as_bool) == Some(true) {
136                    declared = true;
137                }
138            }
139        }
140    });
141    declared
142}
143
144/// Whether a component's `boatramp:function-manifest` self-declares that the route with request
145/// path `route` is a **streaming** handler (`#[handler(stream)]` emits `"streaming": true` on that
146/// function's manifest line). Matches by the http trigger's path (the part after an optional
147/// `METHOD `), so a multi-route component reports per route. Returns `false` when no line matches
148/// or none declares streaming — including for a guest built before the flag existed.
149#[cfg(feature = "handlers")]
150pub(crate) fn component_declares_streaming_route(component: &[u8], route: &str) -> bool {
151    let mut declared = false;
152    scan_manifest_sections(component, &mut |data| {
153        for line in data.split(|&b| b == b'\n') {
154            let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) else {
155                continue;
156            };
157            if v.get("streaming").and_then(serde_json::Value::as_bool) != Some(true) {
158                continue;
159            }
160            // This line is streaming — does one of its http triggers target `route`?
161            let Some(triggers) = v.get("triggers").and_then(serde_json::Value::as_array) else {
162                continue;
163            };
164            for t in triggers {
165                if t.get("on").and_then(serde_json::Value::as_str) != Some("http") {
166                    continue;
167                }
168                if let Some(r) = t.get("route").and_then(serde_json::Value::as_str) {
169                    // `route` is `"METHOD /path"` or `"/path"`; compare the path portion.
170                    let path = r.rsplit(char::is_whitespace).next().unwrap_or(r);
171                    if path == route {
172                        declared = true;
173                    }
174                }
175            }
176        }
177    });
178    declared
179}
180
181/// The stability of a capability feature — an honest, present-tense signal to operators and
182/// guest authors, and the seam the future deprecation clock extends (a deprecated capability is
183/// just this with a removal target). `Experimental` = new or off-by-default surface whose shape
184/// or behaviour may still change; `Stable` = safe to build on. Not a quality judgement — a
185/// stable capability isn't "better", it's *settled*.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
187#[serde(rename_all = "lowercase")]
188pub enum Lifecycle {
189    Stable,
190    Experimental,
191}
192
193impl Lifecycle {
194    /// Lowercase label for human output (matches the JSON serialization).
195    #[must_use]
196    pub fn as_str(self) -> &'static str {
197        match self {
198            Self::Stable => "stable",
199            Self::Experimental => "experimental",
200        }
201    }
202}
203
204/// A capability feature this host implements, paired with its stability. The `name` is the token
205/// a guest names in its manifest `requires`; admission checks the name, and `boatramp
206/// capabilities` shows the name + lifecycle so operators can see what's settled vs. provisional.
207#[derive(Debug, Clone, serde::Serialize)]
208pub struct CapabilityFeature {
209    pub name: &'static str,
210    pub lifecycle: Lifecycle,
211}
212
213/// The capability **features** this host build implements, with lifecycle — what a guest's
214/// manifest `requires` is checked against. The base grantable capabilities are always present
215/// with the handler engine (the `KNOWN_IMPORTS` tokens, all settled); the granular surface
216/// features are always-on in 0.3.0 except the correlated roll-up, which is cargo-gated. This is
217/// the admission side of the contract: availability lives in metadata, not the linkable WIT
218/// (PLAN v2).
219#[cfg(feature = "handlers")]
220pub fn host_capability_features_detailed() -> Vec<CapabilityFeature> {
221    use Lifecycle::{Experimental, Stable};
222    // Base grantable capabilities (the KNOWN_IMPORTS tokens) — the established interface grants.
223    let mut f: Vec<CapabilityFeature> = boatramp_core::config::known_imports()
224        .iter()
225        .map(|&name| CapabilityFeature {
226            name,
227            lifecycle: Stable,
228        })
229        .collect();
230    // Surface features beyond the base grants.
231    f.push(CapabilityFeature {
232        name: "sql-json",
233        lifecycle: Stable,
234    });
235    // pgvector `distance` is new and Postgres-only (fail-closed elsewhere); the vector surface is
236    // the most likely to grow more operators/index hints — flagged experimental until it settles.
237    f.push(CapabilityFeature {
238        name: "orm-vector",
239        lifecycle: Experimental,
240    });
241    f.push(CapabilityFeature {
242        name: "streaming",
243        lifecycle: Stable,
244    });
245    if cfg!(feature = "orm-subquery") {
246        // Correlated roll-ups ship off-by-default (the riskiest query surface) — experimental
247        // until the shape settles.
248        f.push(CapabilityFeature {
249            name: "orm-subquery",
250            lifecycle: Experimental,
251        });
252    }
253    f
254}
255
256/// The capability feature **names** this host implements — what a guest manifest `requires` is
257/// checked against. Names only; [`host_capability_features_detailed`] pairs them with lifecycle.
258#[cfg(feature = "handlers")]
259pub fn host_capability_features() -> Vec<&'static str> {
260    host_capability_features_detailed()
261        .into_iter()
262        .map(|c| c.name)
263        .collect()
264}
265
266/// The capability features a component's `boatramp:function-manifest` **requires** (any NDJSON
267/// line's `"requires": [...]`), deduped. A guest that uses a capability whose availability varies
268/// by host build declares it here so the deploy can fail loud on a host that lacks it.
269#[cfg(feature = "handlers")]
270pub fn component_requires(component: &[u8]) -> Vec<String> {
271    let mut reqs = Vec::new();
272    scan_manifest_sections(component, &mut |data| {
273        for line in data.split(|&b| b == b'\n') {
274            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
275                if let Some(arr) = v.get("requires").and_then(serde_json::Value::as_array) {
276                    reqs.extend(arr.iter().filter_map(|r| r.as_str().map(str::to_string)));
277                }
278            }
279        }
280    });
281    reqs.sort();
282    reqs.dedup();
283    reqs
284}
285
286/// The capability features a component `requires` that this host does **not** implement — empty
287/// when the host can satisfy the component. A non-empty result is a fail-loud deploy rejection.
288#[cfg(feature = "handlers")]
289pub fn unmet_requires(component: &[u8]) -> Vec<String> {
290    let host = host_capability_features();
291    component_requires(component)
292        .into_iter()
293        .filter(|r| !host.contains(&r.as_str()))
294        .collect()
295}
296
297/// Keep the project's GraphQL supergraph correct across a function deploy. The function is a
298/// subgraph to (re)register when it is **already registered** *or* its component
299/// **self-declares** one (the shim marker); in either case introspect the **pending** version's
300/// `_service { sdl }` before its activation flips and **block** the deploy (an error response) if
301/// the new schema does not compose. So a subgraph auto-registers on first deploy, refreshes on
302/// each later deploy, and can never leave the supergraph stale or broken. An ordinary function
303/// (no registry entry, no marker) is untouched, `?register_subgraph=false` opts out, and a node
304/// with no engine degrades to a skip. `Ok(())` ⇒ proceed with the deploy.
305#[cfg(feature = "handlers")]
306async fn maybe_register_subgraph(
307    deploy: &DeployStore,
308    handlers: &HandlerRuntime,
309    project: boatramp_core::project::ProjectRef<'_>,
310    name: &str,
311    function: &boatramp_core::function::Function,
312    component: &str,
313    register: Option<bool>,
314) -> Result<(), Response> {
315    if register == Some(false) {
316        return Ok(()); // explicit opt-out (the coordinated-migration escape hatch)
317    }
318    let kv = deploy.kv().as_ref();
319    if !crate::graphql_registry::is_registered_subgraph(kv, project.as_str(), name).await {
320        // First deploy: only auto-register a component that declares itself a subgraph; any
321        // ordinary function (or an unreadable blob) deploys untouched.
322        match crate::handler_dispatch::read_blob_fully(deploy, component).await {
323            Ok(blob) if component_declares_subgraph(&blob) => {}
324            _ => return Ok(()),
325        }
326    }
327    let sdl = match handlers
328        .introspect_subgraph_sdl(deploy, project, function, component)
329        .await
330    {
331        Ok(sdl) => sdl,
332        // No engine on this node — skip rather than block; the SDL simply isn't (re)published.
333        Err(crate::function_runtime::SubgraphSdlError::Unavailable) => return Ok(()),
334        Err(crate::function_runtime::SubgraphSdlError::NotASubgraph) => {
335            return Err((
336                StatusCode::UNPROCESSABLE_ENTITY,
337                format!(
338                    "subgraph `{name}` does not answer `{{ _service {{ sdl }} }}`; deploy with \
339                     `?register_subgraph=false` to skip subgraph registration\n"
340                ),
341            )
342                .into_response())
343        }
344        Err(crate::function_runtime::SubgraphSdlError::InvokeFailed(msg)) => {
345            return Err((
346                StatusCode::BAD_GATEWAY,
347                format!("could not introspect subgraph `{name}`: {msg}\n"),
348            )
349                .into_response())
350        }
351    };
352    match crate::graphql_registry::publish(kv, project.as_str(), name, &sdl).await {
353        Ok(_) => Ok(()),
354        Err(crate::graphql_registry::PublishError::Composition(e)) => Err((
355            StatusCode::BAD_REQUEST,
356            format!(
357                "subgraph `{name}` does not compose: {e}\n(deploy with `?register_subgraph=false` \
358                 to skip, or unregister a conflicting subgraph first)\n"
359            ),
360        )
361            .into_response()),
362        Err(crate::graphql_registry::PublishError::Store(e)) => Err((
363            StatusCode::INTERNAL_SERVER_ERROR,
364            format!("registry store error: {e}\n"),
365        )
366            .into_response()),
367    }
368}
369
370/// No wasm engine in this build → no subgraph registry to maintain; the deploy proceeds unchanged.
371#[cfg(not(feature = "handlers"))]
372async fn maybe_register_subgraph(
373    _deploy: &DeployStore,
374    _handlers: &HandlerRuntime,
375    _project: boatramp_core::project::ProjectRef<'_>,
376    _name: &str,
377    _function: &boatramp_core::function::Function,
378    _component: &str,
379    _register: Option<bool>,
380) -> Result<(), Response> {
381    Ok(())
382}
383
384/// `PUT /api/functions/:name` (FA-2) — deploy a version of a top-level function.
385/// The component blob must already be uploaded. Creates the function if new;
386/// otherwise appends + activates the version (idempotent per component hash).
387/// `system·admin`.
388pub(super) async fn deploy_function(
389    State(deploy): State<DeployStore>,
390    Extension(project): axum::extract::Extension<ProjectContext>,
391    Extension(handlers): Extension<Arc<HandlerRuntime>>,
392    axum::extract::Query(q): axum::extract::Query<DeployFunctionQuery>,
393    Path(name): Path<String>,
394    Json(body): Json<FunctionUpsert>,
395) -> Response {
396    use boatramp_core::function::{Function, Owner};
397    if let Some(resp) = reject_invalid_name("function", &name) {
398        return resp;
399    }
400    // Fail loud at deploy on a `secrets` map the posture forbids: under the
401    // multi-tenant posture a bare / `env:` ref reads the operator's environment
402    // (cross-tenant host-env exfiltration). Refuse here with the same message the
403    // resolution-time backstop raises, so the tenant sees it now, not at first
404    // invocation. (Falls back to the fail-closed `false` on a no-op runtime.)
405    // Handlers-gated: without a wasm engine a function can't run, so there is no
406    // resolution-time path to guard at deploy.
407    #[cfg(feature = "handlers")]
408    if let Err(err) = crate::handler_dispatch::admit_secret_refs(
409        &body.config.secrets,
410        handlers.allow_env_secret_refs(),
411    ) {
412        return (
413            StatusCode::BAD_REQUEST,
414            format!("function secrets: {err}\n"),
415        )
416            .into_response();
417    }
418    match deploy.has_blob(&body.component).await {
419        Ok(true) => {}
420        Ok(false) => {
421            return (
422                StatusCode::BAD_REQUEST,
423                format!("component blob {} not uploaded\n", body.component),
424            )
425                .into_response()
426        }
427        Err(err) => return deploy_error_response(err),
428    }
429    let now = now_unix();
430    let f = match deploy.get_function(project.as_ref(), &name).await {
431        Ok(Some(mut existing)) => {
432            existing.config = body.config;
433            existing.upsert_version(&body.component, body.lifecycle, now);
434            existing
435        }
436        // A brand-new top-level function is owned by the (single, for now) default
437        // project; per-tenant ownership arrives with FA-4.
438        Ok(None) => Function::new(
439            name.clone(),
440            Owner::Project("default".to_string()),
441            &body.component,
442            body.config,
443            body.lifecycle,
444            now,
445        ),
446        Err(err) => return deploy_error_response(err),
447    };
448    // Before the new version becomes active: if this function is (or self-declares) a federation
449    // subgraph, (re)register its SDL and refuse the deploy if the new schema does not compose with
450    // the project's supergraph (a deploy must never leave the supergraph invalid or stale).
451    if let Err(resp) = maybe_register_subgraph(
452        &deploy,
453        &handlers,
454        project.as_ref(),
455        &name,
456        &f,
457        &body.component,
458        q.register_subgraph,
459    )
460    .await
461    {
462        return resp;
463    }
464    if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
465        return deploy_error_response(err);
466    }
467    Json(f).into_response()
468}
469
470/// Body of `POST /api/functions/:name/rollback`.
471#[derive(serde::Deserialize)]
472pub(super) struct RollbackBody {
473    pub(super) to: String,
474}
475
476/// `POST /api/functions/:name/rollback` (FA-2) — point active at a prior version.
477pub(super) async fn rollback_function(
478    State(deploy): State<DeployStore>,
479    Extension(project): axum::extract::Extension<ProjectContext>,
480    Path(name): Path<String>,
481    Json(body): Json<RollbackBody>,
482) -> Response {
483    match deploy.get_function(project.as_ref(), &name).await {
484        Ok(Some(mut f)) => match f.rollback(&body.to) {
485            Ok(()) => {
486                if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
487                    return deploy_error_response(err);
488                }
489                Json(f).into_response()
490            }
491            Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
492        },
493        Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
494        Err(err) => deploy_error_response(err),
495    }
496}
497
498/// Body of `PUT /api/functions/:name/aliases/:label`.
499#[derive(serde::Deserialize)]
500pub(super) struct AliasBody {
501    pub(super) version: String,
502}
503
504/// `PUT /api/functions/:name/aliases/:label` (FA-2) — point a label at a version.
505pub(super) async fn alias_function(
506    State(deploy): State<DeployStore>,
507    Extension(project): axum::extract::Extension<ProjectContext>,
508    Path((name, label)): Path<(String, String)>,
509    Json(body): Json<AliasBody>,
510) -> Response {
511    match deploy.get_function(project.as_ref(), &name).await {
512        Ok(Some(mut f)) => match f.set_alias(&label, &body.version) {
513            Ok(()) => {
514                if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
515                    return deploy_error_response(err);
516                }
517                Json(f).into_response()
518            }
519            Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
520        },
521        Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
522        Err(err) => deploy_error_response(err),
523    }
524}
525
526/// `DELETE /api/functions/:name` (FA-2) — remove a top-level function (idempotent).
527/// Content-addressed component blobs are shared and left to `prune`.
528pub(super) async fn remove_function(
529    State(deploy): State<DeployStore>,
530    Extension(project): axum::extract::Extension<ProjectContext>,
531    Path(name): Path<String>,
532) -> Response {
533    match deploy.delete_function(project.as_ref(), &name).await {
534        Ok(_) => StatusCode::NO_CONTENT.into_response(),
535        Err(err) => deploy_error_response(err),
536    }
537}
538
539#[cfg(all(test, feature = "handlers"))]
540mod tests {
541    use super::*;
542    use boatramp_core::function::{Function, FunctionConfig, Lifecycle, Owner};
543    use boatramp_core::kv::MemoryKv;
544    use std::sync::Arc;
545
546    #[test]
547    fn host_capability_features_registry_and_requires_filter() {
548        let host = host_capability_features();
549        // Base grantable capabilities (KNOWN_IMPORTS) + always-on surface features are advertised.
550        assert!(host.contains(&"sql"), "base capability token present");
551        assert!(
552            host.contains(&"streaming"),
553            "always-on surface feature present"
554        );
555        assert!(host.contains(&"orm-vector"));
556        // The correlated roll-up is cargo-gated: advertised iff this build enabled it.
557        assert_eq!(
558            host.contains(&"orm-subquery"),
559            cfg!(feature = "orm-subquery")
560        );
561        // The detailed registry pairs each name with a lifecycle, and its names are exactly the
562        // flat list (one source of truth — the flat list derives from the detailed one).
563        let detailed = host_capability_features_detailed();
564        let detailed_names: Vec<&str> = detailed.iter().map(|c| c.name).collect();
565        assert_eq!(
566            detailed_names, host,
567            "detailed names match the flat registry"
568        );
569        let lifecycle = |name: &str| {
570            detailed
571                .iter()
572                .find(|c| c.name == name)
573                .map(|c| c.lifecycle)
574        };
575        assert_eq!(lifecycle("sql"), Some(super::Lifecycle::Stable));
576        assert_eq!(lifecycle("sql-json"), Some(super::Lifecycle::Stable));
577        assert_eq!(
578            lifecycle("orm-vector"),
579            Some(super::Lifecycle::Experimental)
580        );
581        // The admission filter keeps only requirements the host cannot satisfy.
582        let unmet: Vec<&str> = ["sql", "streaming", "quantum-teleport", "warp-drive"]
583            .into_iter()
584            .filter(|r| !host.contains(r))
585            .collect();
586        assert_eq!(unmet, vec!["quantum-teleport", "warp-drive"]);
587    }
588
589    /// A blob store the subgraph-refresh guard never reaches (it returns before touching blobs).
590    struct NullStorage;
591    #[async_trait::async_trait]
592    impl boatramp_core::Storage for NullStorage {
593        async fn get(
594            &self,
595            _: &str,
596        ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
597            Err(boatramp_core::StorageError::NotFound(String::new()))
598        }
599        async fn get_range(
600            &self,
601            _: &str,
602            _: u64,
603            _: Option<u64>,
604        ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
605            Err(boatramp_core::StorageError::NotFound(String::new()))
606        }
607        async fn put(
608            &self,
609            _: &str,
610            _: boatramp_core::ByteStream,
611            _: boatramp_core::PutMeta,
612        ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
613            Err(boatramp_core::StorageError::unsupported("null"))
614        }
615        async fn head(
616            &self,
617            _: &str,
618        ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
619            Err(boatramp_core::StorageError::NotFound(String::new()))
620        }
621        async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
622            Ok(())
623        }
624        async fn list(
625            &self,
626            _: &str,
627        ) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
628            Ok(Vec::new())
629        }
630    }
631
632    fn a_function() -> Function {
633        Function::new(
634            "accounts",
635            Owner::Project("default".to_string()),
636            "component-hash",
637            FunctionConfig::default(),
638            Lifecycle::default(),
639            0,
640        )
641    }
642
643    /// The subgraph-refresh hook must never block or touch the registry for a function that is
644    /// not a registered subgraph, on an explicit opt-out, or on a node with no wasm engine — so
645    /// an ordinary function deploy (and the coordinated-migration escape hatch) is unaffected.
646    #[tokio::test]
647    async fn refresh_is_a_noop_unless_the_function_is_a_registered_subgraph() {
648        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
649        let handlers = HandlerRuntime::disabled();
650        let project = boatramp_core::project::ProjectRef::new("default");
651        let f = a_function();
652
653        // Not a registered subgraph → no-op; nothing is published.
654        maybe_register_subgraph(
655            &deploy,
656            &handlers,
657            project,
658            "accounts",
659            &f,
660            "component-hash",
661            None,
662        )
663        .await
664        .expect("an unregistered function deploys freely");
665        assert!(
666            !crate::graphql_registry::is_registered_subgraph(
667                deploy.kv().as_ref(),
668                "default",
669                "accounts"
670            )
671            .await
672        );
673
674        // Now registered: `?register_subgraph=false` opts out (the migration escape hatch).
675        crate::graphql_registry::publish(
676            deploy.kv().as_ref(),
677            "default",
678            "accounts",
679            "type Query { x: Int }",
680        )
681        .await
682        .unwrap();
683        maybe_register_subgraph(
684            &deploy,
685            &handlers,
686            project,
687            "accounts",
688            &f,
689            "component-hash",
690            Some(false),
691        )
692        .await
693        .expect("opt-out never blocks");
694
695        // Registered, no opt-out, but this node has no engine → Unavailable degrades to a skip
696        // rather than blocking the deploy.
697        maybe_register_subgraph(
698            &deploy,
699            &handlers,
700            project,
701            "accounts",
702            &f,
703            "component-hash",
704            None,
705        )
706        .await
707        .expect("a node with no engine skips the refresh, it does not block");
708    }
709
710    /// A minimal core wasm module carrying one `boatramp:function-manifest` custom section — the
711    /// shape the guest's `#[link_section]` manifest takes (here at the top level; a real
712    /// component nests it in an embedded core module, exercised end-to-end elsewhere).
713    fn leb128(mut n: usize, out: &mut Vec<u8>) {
714        loop {
715            let mut byte = (n & 0x7f) as u8;
716            n >>= 7;
717            if n != 0 {
718                byte |= 0x80;
719            }
720            out.push(byte);
721            if n == 0 {
722                break;
723            }
724        }
725    }
726
727    fn module_with_manifest(manifest: &[u8]) -> Vec<u8> {
728        let name = b"boatramp:function-manifest";
729        let mut payload = Vec::new();
730        leb128(name.len(), &mut payload);
731        payload.extend_from_slice(name);
732        payload.extend_from_slice(manifest);
733        let mut module = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; // \0asm + version 1
734        module.push(0x00); // custom section id
735        leb128(payload.len(), &mut module);
736        module.extend_from_slice(&payload);
737        module
738    }
739
740    #[test]
741    fn a_subgraph_marker_in_the_manifest_is_detected() {
742        assert!(component_declares_subgraph(&module_with_manifest(
743            br#"{"name":"schema","triggers":[{"on":"http","route":"POST /graphql"}],"authorize":"public","subgraph":true}"#
744        )));
745        // An ordinary managed handler manifest is not a subgraph.
746        assert!(!component_declares_subgraph(&module_with_manifest(
747            br#"{"name":"orders","authorize":"tenant"}"#
748        )));
749        // No manifest section at all → not a subgraph.
750        assert!(!component_declares_subgraph(&[
751            0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00
752        ]));
753        // Garbage bytes are handled gracefully (best-effort parse).
754        assert!(!component_declares_subgraph(b"not a wasm module"));
755    }
756}