Skip to main content

boatramp_types/
function.rs

1//! The FaaS **function** model — PLAN-faas FA-1.
2//!
3//! A **function** is the compute *artifact*: a versioned WASI component + its
4//! binding/capability config. It is the one primitive the engine runs (decision 1
5//! — "one primitive, two views"). A **handler** is *not* a resource — it is a
6//! function reached by an HTTP **route** trigger; likewise a *consumer* / *cron* is
7//! a function reached by a queue / timer trigger (decision 5). Triggers are their
8//! own objects, and many can point at one function version (decision 2).
9//!
10//! [`desugar`] lowers a site's deploy-scoped `handlers/consumers/crons/streams`
11//! into functions + triggers with **no behavioural change** — the mandatory
12//! non-breaking gate: a site's handlers must run identically before and after.
13//! It is a pure config→shape transform; the content hash / version id of each
14//! function is assigned later, at `sync`, when its component blob is uploaded.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
21use crate::file::FileEntry;
22
23/// A function's owner — a **site** or a **project/tenant**. Drives the KV/blob/sql
24/// binding prefix and the inherited RBAC (a site-scoped function gains no privilege
25/// over its site).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Owner {
29    /// Owned by a site: `fn/<site>/<name>`, binding prefix + RBAC of the site.
30    Site(String),
31    /// A top-level function owned by a project/tenant: `fn/<name>`.
32    Project(String),
33}
34
35impl std::fmt::Display for Owner {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Site(s) => write!(f, "site:{s}"),
39            Self::Project(p) => write!(f, "project:{p}"),
40        }
41    }
42}
43
44/// A function version's lifecycle (decision 3: `DeployPinned` is the default).
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Lifecycle {
48    /// Versions + rolls back atomically with the owning site's deploy.
49    #[default]
50    DeployPinned,
51    /// Its own version / alias / rollback, independent of any deploy.
52    Independent,
53}
54
55/// The execution substrate (decision 1: a per-function knob; `wasm` is the default
56/// and scales to zero by instantiation; the stronger-isolation substrates are the
57/// compute backends — see PLAN-compute-backends).
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Runtime {
61    #[default]
62    Wasm,
63    Microvm,
64    Container,
65}
66
67impl Runtime {
68    /// The snake_case wire term (matches the serde `rename_all`).
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Wasm => "wasm",
72            Self::Microvm => "microvm",
73            Self::Container => "container",
74        }
75    }
76}
77
78impl std::fmt::Display for Runtime {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// A function's binding/capability config — the `HandlerConfig` capability fields
85/// (imports, resource limits, static env) *minus its trigger* (route/methods, which
86/// become a [`Trigger`]), plus the [`Runtime`] knob. A `HandlerConfig` *is* a
87/// function's config, so the engine has one path.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct FunctionConfig {
91    /// Requested host capabilities (interface names).
92    #[serde(skip_serializing_if = "Vec::is_empty")]
93    pub imports: Vec<String>,
94    /// Optional resource limits (mem / timeout / fuel).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub limits: Option<HandlerLimits>,
97    /// Static, non-secret environment.
98    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
99    pub env: BTreeMap<String, String>,
100    /// Execution substrate (default `wasm`).
101    pub runtime: Runtime,
102    /// Usage quota (FA-4). Absent / all-`None` ⇒ unlimited.
103    #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
104    pub quota: FunctionQuota,
105    /// Signed inbound-webhook ingress (FA-5). Absent ⇒ no webhook endpoint.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub webhook: Option<WebhookConfig>,
108    /// Function-to-function invoke allowlist (FI): the target names this function
109    /// may call through the `invoke` capability. Each entry may use `*` wildcards
110    /// (`*` = any sibling, `img-*` = a family, `resize` = one literal). Deny by
111    /// default: empty ⇒ the function cannot invoke anything, even if it imports
112    /// `invoke`. Only consulted when `imports` contains `invoke`.
113    #[serde(skip_serializing_if = "Vec::is_empty")]
114    pub invoke_targets: Vec<String>,
115}
116
117/// The signature scheme a webhook is verified under (FA-5).
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum WebhookAlgorithm {
121    /// `HMAC-SHA256(body, secret)`, hex — the GitHub/Stripe-style scheme.
122    #[default]
123    HmacSha256,
124}
125
126/// Signed inbound-webhook config for a function (FA-5). The verifying secret is a
127/// **reference to a host env var** (never stored plaintext — mirrors site
128/// secrets); the endpoint verifies the request signature over the raw body,
129/// constant-time, **before** the guest runs.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct WebhookConfig {
133    /// The host env var holding the shared secret.
134    pub secret_env: String,
135    /// Signature scheme (default HMAC-SHA256).
136    #[serde(default)]
137    pub algorithm: WebhookAlgorithm,
138    /// The header carrying the hex signature (default `x-boatramp-signature`; a
139    /// leading `sha256=` is accepted and stripped).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub signature_header: Option<String>,
142    /// Max request body accepted before verifying/dispatching (default 1 MiB).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub max_body_bytes: Option<u64>,
145    /// If set, a **verified** webhook publishes its body onto the project bus at
146    /// this topic (a consumer subscribes with `bus:<topic>`) and returns `202`,
147    /// **without running any component** — the "external event → the fabric"
148    /// ingress, so a quick webhook needs no dedicated `wasi:http` function. Unset
149    /// ⇒ the verified webhook invokes the function as before.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub publish: Option<String>,
152}
153
154impl WebhookConfig {
155    /// The signature header name (defaulted).
156    pub fn header(&self) -> &str {
157        self.signature_header
158            .as_deref()
159            .unwrap_or("x-boatramp-signature")
160    }
161    /// The body cap (defaulted to 1 MiB).
162    pub fn body_cap(&self) -> u64 {
163        self.max_body_bytes.unwrap_or(1024 * 1024)
164    }
165}
166
167impl FunctionConfig {
168    fn from_handler(h: &HandlerConfig) -> Self {
169        Self {
170            imports: h.imports.clone(),
171            limits: h.limits.clone(),
172            env: h.env.clone(),
173            runtime: Runtime::default(),
174            quota: FunctionQuota::default(),
175            webhook: None,
176            invoke_targets: Vec::new(),
177        }
178    }
179    fn from_consumer(c: &ConsumerConfig) -> Self {
180        Self {
181            imports: c.imports.clone(),
182            ..Default::default()
183        }
184    }
185}
186
187/// A reference to a function (a `None` version = the function's active version).
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct FunctionRef {
191    /// The function name (site-scoped names are `<site>/<name>`).
192    pub name: String,
193    /// A specific version id, or `None` for the active version.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub version: Option<String>,
196}
197
198/// A **trigger** — a separate object (decision 2). Many triggers may point at the
199/// same function version (e.g. a route *and* a cron). A `target` of `None` is a
200/// host-native trigger (a stream fan-out has no component).
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct Trigger {
204    /// What fires the trigger.
205    pub kind: TriggerKind,
206    /// The function it invokes, or `None` for a host-native trigger.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub target: Option<FunctionRef>,
209}
210
211/// The event that fires a trigger. The role words (route/queue/cron/stream) are the
212/// familiar site-view names; each is just *a way to reach a function*.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum TriggerKind {
216    /// An HTTP route — the "handler" shape. `host` scopes it to a virtualhost.
217    Route {
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        host: Option<String>,
220        path: String,
221        #[serde(default, skip_serializing_if = "Vec::is_empty")]
222        methods: Vec<String>,
223    },
224    /// A stable invoke name — `/api/functions/<name>` (the FaaS verb, FA-3).
225    Invoke { name: String },
226    /// A message topic — the "consumer" shape. An empty `group` is the default
227    /// work-queue; a non-empty `group` is a durable fan-out subscriber (every
228    /// message, its own cursor) starting at `start` on first subscription.
229    Queue {
230        topic: String,
231        #[serde(default, skip_serializing_if = "String::is_empty")]
232        group: String,
233        #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
234        start: crate::config::StartPosition,
235    },
236    /// A cron schedule — the "cron" shape.
237    Cron {
238        schedule: String,
239        #[serde(default)]
240        overlap: Overlap,
241    },
242    /// An object-storage change under a prefix (FA-5).
243    Blob { prefix: String },
244    /// A signed inbound webhook (FA-5). `secret_env` names the host env var
245    /// holding the verifying secret (never the secret itself).
246    Webhook { path: String, secret_env: String },
247    /// Host-native SSE / WebSocket topic fan-out — the "stream" shape (no component,
248    /// so a stream trigger's `target` is `None`).
249    Stream {
250        topics: Vec<String>,
251        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
252        websocket: bool,
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        publish_topic: Option<String>,
255    },
256}
257
258impl std::fmt::Display for Trigger {
259    /// A short one-line label for the functions view (`route GET /x`, `queue t`, …).
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match &self.kind {
262            TriggerKind::Route { path, methods, .. } => {
263                let m = if methods.is_empty() {
264                    "*".to_string()
265                } else {
266                    methods.join(",")
267                };
268                write!(f, "route {m} {path}")
269            }
270            TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
271            TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
272            TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
273            TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
274            TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
275            TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
276        }
277    }
278}
279
280/// A **stored trigger** bound to a top-level function — the durable form the
281/// server dispatches (FA-3 *scheduled* + FA-5 *event sources*). Its owning
282/// function is the key context, so there is no separate `target`. Keyed under
283/// [`keys::trigger`].
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct FunctionTrigger {
287    /// Unique id within the function (the key suffix).
288    pub id: String,
289    /// What fires it.
290    pub kind: TriggerKind,
291    /// Cron dedup: the minute-stamp (`hour*60 + minute` within the day, or a
292    /// monotonic per-fire stamp) this trigger last fired at — durable so a fire
293    /// isn't repeated across a restart or (in a cluster) a leader change.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub last_fired_minute: Option<i64>,
296}
297
298/// A stored, content-addressed function resource (the FA-1/FA-2 keyspace form).
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct Function {
302    /// The function name (unique within its owner).
303    pub name: String,
304    /// Who owns it (drives binding prefix + RBAC).
305    pub owner: Owner,
306    /// Immutable versions, newest last.
307    pub versions: Vec<FunctionVersion>,
308    /// The active version's id.
309    pub active: String,
310    /// Named aliases → version id (staging/previews; mirrors deploy aliases).
311    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
312    pub aliases: BTreeMap<String, String>,
313    /// Binding/capability config.
314    pub config: FunctionConfig,
315}
316
317/// A [`Function`] operation named a version id the function has no record of
318/// (`rollback`/`set_alias`). A typed error so a caller can distinguish it (e.g.
319/// map it to a 404) instead of string-matching a message.
320#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
321#[error("no version {id:?} in function {function:?}")]
322pub struct UnknownVersion {
323    /// The function the operation was on.
324    pub function: String,
325    /// The version id that was not found.
326    pub id: String,
327}
328
329impl Function {
330    /// A new top-level function with a single active version (the component blob).
331    /// The version id **is** the component's content hash (content-addressed).
332    pub fn new(
333        name: impl Into<String>,
334        owner: Owner,
335        component_hash: impl Into<String>,
336        config: FunctionConfig,
337        lifecycle: Lifecycle,
338        created: u64,
339    ) -> Self {
340        let hash = component_hash.into();
341        Self {
342            name: name.into(),
343            owner,
344            versions: vec![FunctionVersion {
345                id: hash.clone(),
346                component: hash.clone(),
347                created,
348                lifecycle,
349            }],
350            active: hash,
351            aliases: BTreeMap::new(),
352            config,
353        }
354    }
355
356    /// Add a version for `component_hash` (id = the hash) and make it active. If a
357    /// version with that hash already exists it is just re-activated (idempotent).
358    /// Returns the (active) version id.
359    pub fn upsert_version(
360        &mut self,
361        component_hash: impl Into<String>,
362        lifecycle: Lifecycle,
363        created: u64,
364    ) -> String {
365        let hash = component_hash.into();
366        if !self.versions.iter().any(|v| v.id == hash) {
367            self.versions.push(FunctionVersion {
368                id: hash.clone(),
369                component: hash.clone(),
370                created,
371                lifecycle,
372            });
373        }
374        self.active = hash.clone();
375        hash
376    }
377
378    /// Point `active` at an existing version id. `Err` if the version is unknown.
379    pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
380        if self.versions.iter().any(|v| v.id == to) {
381            self.active = to.to_string();
382            Ok(())
383        } else {
384            Err(UnknownVersion {
385                function: self.name.clone(),
386                id: to.to_string(),
387            })
388        }
389    }
390
391    /// Set `label` → an existing version id. `Err` if the version is unknown.
392    pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
393        if self.versions.iter().any(|v| v.id == version) {
394            self.aliases.insert(label.to_string(), version.to_string());
395            Ok(())
396        } else {
397            Err(UnknownVersion {
398                function: self.name.clone(),
399                id: version.to_string(),
400            })
401        }
402    }
403
404    /// Resolve a **reference** — an alias label or a version id — to the component
405    /// blob hash that backs it, if known. An alias label is resolved first, then a
406    /// version id (so a label named like an id still wins as a label).
407    pub fn resolve(&self, reference: &str) -> Option<&str> {
408        let id = self
409            .aliases
410            .get(reference)
411            .map(String::as_str)
412            .unwrap_or(reference);
413        self.versions
414            .iter()
415            .find(|v| v.id == id)
416            .map(|v| v.component.as_str())
417    }
418}
419
420/// One immutable version of a function.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct FunctionVersion {
424    /// Immutable content-hash id.
425    pub id: String,
426    /// The component blob hash.
427    pub component: String,
428    /// Unix creation time.
429    pub created: u64,
430    /// This version's lifecycle.
431    #[serde(default)]
432    pub lifecycle: Lifecycle,
433}
434
435/// The desugared shape of a function derived from a site's `DeployConfig`. The
436/// `component` is still a **path** within the deploy — its content hash / version
437/// id is assigned at `sync`, when the blob is uploaded (so this stays a pure
438/// config→shape transform, testable without a blob store).
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct FunctionSpec {
441    /// Site-scoped function name (see [`handler_name`] / [`consumer_name`]).
442    pub name: String,
443    /// The component path within the deploy.
444    pub component: String,
445    /// Binding/capability config.
446    pub config: FunctionConfig,
447    /// Version lifecycle (deploy-scoped functions are `DeployPinned`).
448    pub lifecycle: Lifecycle,
449}
450
451/// How an invocation is delivered (FA-3). `Sync` runs inline and returns the
452/// function's response; `Async` durably enqueues the call, returns `202` with an
453/// id, and a drain worker runs it later (retried, then dead-lettered).
454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "snake_case")]
456pub enum InvokeMode {
457    /// Run inline; the caller blocks on the function's response.
458    #[default]
459    Sync,
460    /// Durably enqueue; the caller gets an id to poll.
461    Async,
462}
463
464/// The lifecycle of a durable (async) invocation.
465#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "snake_case")]
467pub enum InvocationStatus {
468    /// Enqueued, not yet claimed by a drain worker.
469    #[default]
470    Queued,
471    /// Claimed and executing.
472    Running,
473    /// Completed (the function returned a response — any HTTP status).
474    Succeeded,
475    /// Exhausted its attempts and was dead-lettered.
476    Failed,
477}
478
479/// The captured response of a completed invocation (sync idempotency replay +
480/// async poll both read this). The body is base64 so the record is plain JSON.
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(deny_unknown_fields)]
483pub struct InvocationResult {
484    /// The HTTP status the function returned.
485    pub status: u16,
486    /// The response content type, if any.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub content_type: Option<String>,
489    /// The response body, base64 (standard, no padding stripped).
490    pub body_b64: String,
491}
492
493/// A durable invocation record — the unit of the async queue and the receipt an
494/// idempotency key replays. Keyed under [`keys::invocation`].
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct Invocation {
498    /// Opaque invocation id (also the queue key suffix).
499    pub id: String,
500    /// The function invoked.
501    pub function: String,
502    /// The function version that ran (or will run) — pinned at enqueue so a
503    /// later deploy can't silently change an in-flight async call.
504    pub version: String,
505    /// Delivery mode.
506    pub mode: InvokeMode,
507    /// Current status.
508    pub status: InvocationStatus,
509    /// The idempotency key that created it, if any.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub idempotency_key: Option<String>,
512    /// Delivery attempts so far (async).
513    #[serde(default)]
514    pub attempts: u32,
515    /// Lease expiry (unix seconds) while an async invocation is `Running`: the
516    /// drain that claims it stamps `now + ceiling + margin`, and the drain
517    /// treats a `Running` record whose lease has elapsed as reclaimable — so a
518    /// node that crashes mid-invocation never orphans the job. `None` once the
519    /// invocation settles (or while still `Queued`).
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub lease_expires: Option<u64>,
522    /// The request body the function receives, base64.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub request_b64: Option<String>,
525    /// The request content type forwarded to the function.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub request_content_type: Option<String>,
528    /// The captured result once complete.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub result: Option<InvocationResult>,
531    /// Unix create time.
532    pub created: u64,
533    /// Unix last-update time.
534    pub updated: u64,
535}
536
537impl Invocation {
538    /// Whether the invocation reached a terminal state.
539    pub fn is_terminal(&self) -> bool {
540        matches!(
541            self.status,
542            InvocationStatus::Succeeded | InvocationStatus::Failed
543        )
544    }
545}
546
547/// Per-function usage quota (FA-4) — `require`-style knobs enforced host-side,
548/// **fail-closed** (over the limit ⇒ `429`). Accounting, not billing: this bounds
549/// abuse, it does not charge. All limits are per node today (a cluster-wide token
550/// bucket is future work); `None` on a field means that dimension is unlimited.
551#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(default, deny_unknown_fields)]
553pub struct FunctionQuota {
554    /// Max invocations admitted within [`window_secs`](Self::window_secs) (a
555    /// fixed window: the counter resets when the window rolls over).
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub max_invocations: Option<u64>,
558    /// The rate-limit window length in seconds (defaults to 60 when a
559    /// `max_invocations` cap is set).
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub window_secs: Option<u64>,
562    /// Max concurrent in-flight invocations for this function (per node).
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub max_concurrent: Option<u32>,
565}
566
567impl FunctionQuota {
568    /// Whether any dimension is set (an all-`None` quota is a no-op the caller can
569    /// skip entirely).
570    pub fn is_unset(&self) -> bool {
571        self.max_invocations.is_none() && self.max_concurrent.is_none()
572    }
573    /// The effective rate-limit window (defaults to 60s).
574    pub fn window(&self) -> u64 {
575        self.window_secs.unwrap_or(60)
576    }
577}
578
579/// One invocation's measured cost, folded into a [`Metering`] aggregate.
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581pub struct MeteringSample {
582    /// Whether the invocation succeeded (the guest produced a response).
583    pub success: bool,
584    /// Wall-clock duration in milliseconds (the server-side CPU-time proxy; true
585    /// fuel accounting needs the engine to surface post-completion cost).
586    pub duration_ms: u64,
587    /// Request bytes delivered to the function.
588    pub bytes_in: u64,
589    /// Response bytes the function produced.
590    pub bytes_out: u64,
591}
592
593/// Host-side usage aggregate for one function (FA-4), tenant-isolated under
594/// [`keys::metering`]. It also carries the fixed-window rate-limit counter so
595/// metering + quota share a single read-modify-write.
596#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(default, deny_unknown_fields)]
598pub struct Metering {
599    /// The function these counters belong to.
600    pub function: String,
601    /// Total invocations metered (sync + drained async).
602    pub invocations: u64,
603    /// Invocations whose guest produced a response.
604    pub successes: u64,
605    /// Invocations that failed to deliver (engine error / dead-lettered).
606    pub failures: u64,
607    /// Summed wall-clock duration, milliseconds.
608    pub duration_ms_total: u64,
609    /// Summed request bytes.
610    pub bytes_in_total: u64,
611    /// Summed response bytes.
612    pub bytes_out_total: u64,
613    /// The current rate-limit window's start (unix seconds).
614    pub window_start: u64,
615    /// Invocations admitted in the current window.
616    pub window_count: u64,
617    /// Last update (unix seconds).
618    pub updated: u64,
619}
620
621impl Metering {
622    /// A fresh aggregate for `function`.
623    pub fn new(function: impl Into<String>) -> Self {
624        Self {
625            function: function.into(),
626            ..Default::default()
627        }
628    }
629
630    /// Fold one invocation's cost into the usage counters.
631    pub fn record(&mut self, sample: &MeteringSample, now: u64) {
632        self.invocations += 1;
633        if sample.success {
634            self.successes += 1;
635        } else {
636            self.failures += 1;
637        }
638        self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
639        self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
640        self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
641        self.updated = now;
642    }
643
644    /// Admit one invocation against the fixed-window rate limit, rolling the
645    /// window over when it has elapsed. Returns `true` if admitted (and records
646    /// the admission), `false` if the window is already at `max` (⇒ the caller
647    /// fails closed with `429`). An unset cap always admits.
648    pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
649        let Some(max) = quota.max_invocations else {
650            return true;
651        };
652        let window = quota.window();
653        if now.saturating_sub(self.window_start) >= window {
654            self.window_start = now;
655            self.window_count = 0;
656        }
657        if self.window_count >= max {
658            return false;
659        }
660        self.window_count += 1;
661        self.updated = now;
662        true
663    }
664}
665
666pub mod keys {
667    //! KV keyspace for a function (mirrors the deploy/alias immutability model), all
668    //! **project-scoped** under `project/<proj>/…` (0.2.0). `project` is passed as a
669    //! bare `&str` (this crate is wasm-clean and has no `ProjectRef`); callers in
670    //! `boatramp-core` pass `ProjectRef::as_str()`.
671
672    /// Function metadata.
673    pub fn meta(project: &str, name: &str) -> String {
674        format!("project/{project}/functions/{name}")
675    }
676    /// An immutable version.
677    pub fn version(project: &str, name: &str, id: &str) -> String {
678        format!("project/{project}/functions/{name}/versions/{id}")
679    }
680    /// A named alias → version id.
681    pub fn alias(project: &str, name: &str, label: &str) -> String {
682        format!("project/{project}/functions/{name}/alias/{label}")
683    }
684    /// A trigger bound to the function.
685    pub fn trigger(project: &str, name: &str, id: &str) -> String {
686        format!("project/{project}/functions/{name}/triggers/{id}")
687    }
688    /// A durable invocation record.
689    pub fn invocation(project: &str, name: &str, id: &str) -> String {
690        format!("project/{project}/functions/{name}/invocations/{id}")
691    }
692    /// The prefix under which all of a function's invocations live (queue scan).
693    pub fn invocations_prefix(project: &str, name: &str) -> String {
694        format!("project/{project}/functions/{name}/invocations/")
695    }
696    /// An idempotency key → invocation id pointer.
697    pub fn idempotency(project: &str, name: &str, key: &str) -> String {
698        format!("project/{project}/functions/{name}/idem/{key}")
699    }
700    /// The function's usage-metering aggregate (FA-4).
701    pub fn metering(project: &str, name: &str) -> String {
702        format!("project/{project}/metering/{name}")
703    }
704
705    /// The prefix listing every function record in a project (`meta` keys). A
706    /// function record's key has no further `/` after its name, so a lister filters
707    /// on that to skip the per-function sub-records.
708    pub fn functions_prefix(project: &str) -> String {
709        format!("project/{project}/functions/")
710    }
711    /// The prefix listing a function's triggers.
712    pub fn triggers_prefix(project: &str, name: &str) -> String {
713        format!("project/{project}/functions/{name}/triggers/")
714    }
715    /// The prefix listing every metering aggregate in a project.
716    pub fn metering_prefix(project: &str) -> String {
717        format!("project/{project}/metering/")
718    }
719}
720
721/// The site-scoped function name for an HTTP route handler — a slug of the route
722/// (`/api/hello` → `api-hello`, `/` → `root`).
723pub fn handler_name(route: &str) -> String {
724    let s = slug(route);
725    if s.is_empty() {
726        "root".to_string()
727    } else {
728        s
729    }
730}
731
732/// The site-scoped function name for a topic consumer (`orders` → `consumer-orders`).
733pub fn consumer_name(topic: &str) -> String {
734    format!("consumer-{}", slug(topic))
735}
736
737/// Lower-case alphanumeric slug, non-alnum runs collapsed to single `-`, trimmed.
738fn slug(s: &str) -> String {
739    let mut out = String::new();
740    let mut dash = false;
741    for c in s.chars() {
742        if c.is_ascii_alphanumeric() {
743            out.push(c.to_ascii_lowercase());
744            dash = false;
745        } else if !out.is_empty() && !dash {
746            out.push('-');
747            dash = true;
748        }
749    }
750    out.trim_matches('-').to_string()
751}
752
753/// Lower a site's deploy-scoped compute config into **functions + triggers**
754/// (decision 2), preserving behaviour exactly (the non-breaking gate):
755///
756/// - each `handler` → a `DeployPinned` [`FunctionSpec`] + a `Route` [`Trigger`];
757/// - each `consumer` → a [`FunctionSpec`] + a `Queue` trigger;
758/// - each `cron` → a `Cron` trigger onto the **function serving its route** (a
759///   second trigger on one function — N triggers → one function);
760/// - each `stream` → a host-native `Stream` trigger (`target: None`, no component).
761///
762/// Pure: no I/O, no hashing. The site handler surface (`routing.handlers`) is
763/// unchanged — this is the internal lowering that FA-1..FA-3 build on.
764pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
765    let mut functions = Vec::new();
766    let mut triggers = Vec::new();
767
768    for h in &cfg.handlers {
769        let name = handler_name(&h.route);
770        functions.push(FunctionSpec {
771            name: name.clone(),
772            component: h.component.clone(),
773            config: FunctionConfig::from_handler(h),
774            lifecycle: Lifecycle::DeployPinned,
775        });
776        triggers.push(Trigger {
777            kind: TriggerKind::Route {
778                host: None,
779                path: h.route.clone(),
780                methods: h.methods.clone(),
781            },
782            target: Some(FunctionRef {
783                name,
784                version: None,
785            }),
786        });
787    }
788
789    for c in &cfg.consumers {
790        let name = consumer_name(&c.topic);
791        functions.push(FunctionSpec {
792            name: name.clone(),
793            component: c.component.clone(),
794            config: FunctionConfig::from_consumer(c),
795            lifecycle: Lifecycle::DeployPinned,
796        });
797        triggers.push(Trigger {
798            kind: TriggerKind::Queue {
799                topic: c.topic.clone(),
800                group: c.group.clone(),
801                start: c.start,
802            },
803            target: Some(FunctionRef {
804                name,
805                version: None,
806            }),
807        });
808    }
809
810    for cr in &cfg.crons {
811        // A cron fires an existing handler-function, addressed by its route.
812        let target = cfg
813            .handlers
814            .iter()
815            .find(|h| h.route == cr.route)
816            .map(|h| FunctionRef {
817                name: handler_name(&h.route),
818                version: None,
819            });
820        triggers.push(Trigger {
821            kind: TriggerKind::Cron {
822                schedule: cr.schedule.clone(),
823                overlap: cr.overlap,
824            },
825            target,
826        });
827    }
828
829    for s in &cfg.streams {
830        triggers.push(Trigger {
831            kind: TriggerKind::Stream {
832                topics: s.topics.clone(),
833                websocket: s.websocket,
834                publish_topic: s.publish_topic.clone(),
835            },
836            target: None,
837        });
838    }
839
840    (functions, triggers)
841}
842
843/// Materialize desugared specs into stored [`Function`]s for a site, resolving each
844/// component **path** to its blob hash via the deploy's file map (the blob hash is
845/// the content-addressed version id). `created` is the deploy's activation time.
846/// Specs whose component isn't in the file map are dropped (a validated deploy
847/// always has them). This is the derived, read-only view of a site's functions
848/// (FA-1); independently-stored top-level functions come with FA-2.
849pub fn materialize(
850    specs: &[FunctionSpec],
851    site: &str,
852    files: &BTreeMap<String, FileEntry>,
853    created: u64,
854) -> Vec<Function> {
855    specs
856        .iter()
857        .filter_map(|s| {
858            let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
859            Some(Function {
860                name: s.name.clone(),
861                owner: Owner::Site(site.to_string()),
862                versions: vec![FunctionVersion {
863                    id: hash.clone(),
864                    component: hash.clone(),
865                    created,
866                    lifecycle: s.lifecycle,
867                }],
868                active: hash,
869                aliases: BTreeMap::new(),
870                config: s.config.clone(),
871            })
872        })
873        .collect()
874}
875
876/// One entry in the `GET /api/functions` view: a function plus its resolved
877/// active version and the triggers that reach it. The read-only projection the
878/// server returns and the CLI/console render.
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct FunctionSummary {
881    /// Function name (`<site>/<name>` for site-scoped; bare for top-level).
882    pub name: String,
883    /// Owner (`site:<site>` or `project:<project>`).
884    pub owner: String,
885    /// Execution substrate.
886    pub runtime: String,
887    /// Active version id (the component blob hash).
888    pub version: String,
889    /// Rendered triggers that reach this function.
890    pub triggers: Vec<String>,
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
897
898    fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
899        HandlerConfig {
900            route: route.into(),
901            methods: methods
902                .iter()
903                .map(std::string::ToString::to_string)
904                .collect(),
905            component: component.into(),
906            imports: imports
907                .iter()
908                .map(std::string::ToString::to_string)
909                .collect(),
910            limits: None,
911            env: BTreeMap::new(),
912            invoke_targets: Vec::new(),
913        }
914    }
915
916    #[test]
917    fn slugs_and_names() {
918        assert_eq!(handler_name("/api/hello"), "api-hello");
919        assert_eq!(handler_name("/"), "root");
920        assert_eq!(handler_name("/a/b/*"), "a-b");
921        assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
922    }
923
924    /// The mandatory **non-breaking gate**: desugaring preserves every handler,
925    /// consumer, cron, and stream's fields exactly — the same component, imports,
926    /// routes/methods, topics, and the cron→handler binding.
927    #[test]
928    fn desugar_preserves_all_compute_config() {
929        let cfg = DeployConfig {
930            handlers: vec![
931                handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
932                handler("/api/report", "report.wasm", &[], &[]),
933            ],
934            consumers: vec![ConsumerConfig {
935                topic: "orders".into(),
936                component: "orders.wasm".into(),
937                imports: vec!["sql".into()],
938                group: String::new(),
939                start: Default::default(),
940            }],
941            crons: vec![CronConfig {
942                schedule: "0 * * * *".into(),
943                route: "/api/report".into(),
944                overlap: Overlap::Skip,
945            }],
946            streams: vec![StreamConfig {
947                route: "/live".into(),
948                topics: vec!["ticks".into()],
949                websocket: false,
950                publish_topic: None,
951            }],
952            ..Default::default()
953        };
954
955        let (functions, triggers) = desugar(&cfg);
956
957        // Two handlers + one consumer → three functions.
958        assert_eq!(functions.len(), 3);
959        let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
960        assert_eq!(hello.component, "hello.wasm");
961        assert_eq!(hello.config.imports, vec!["kv".to_string()]);
962        assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
963        assert_eq!(hello.config.runtime, Runtime::Wasm);
964        let consumer = functions
965            .iter()
966            .find(|f| f.name == "consumer-orders")
967            .unwrap();
968        assert_eq!(consumer.component, "orders.wasm");
969        assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
970
971        // Triggers: 2 routes + 1 queue + 1 cron + 1 stream = 5.
972        assert_eq!(triggers.len(), 5);
973
974        // The route trigger carries the exact path + methods and targets its function.
975        let route = triggers
976            .iter()
977            .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
978            .unwrap();
979        match &route.kind {
980            TriggerKind::Route { methods, host, .. } => {
981                assert_eq!(methods, &["GET".to_string()]);
982                assert!(host.is_none());
983            }
984            _ => unreachable!(),
985        }
986        assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
987
988        // The queue trigger.
989        let queue = triggers
990            .iter()
991            .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
992            .unwrap();
993        assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
994
995        // The cron is a SECOND trigger on the /api/report function (N triggers → 1 fn).
996        let cron = triggers
997            .iter()
998            .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
999            .unwrap();
1000        assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1001
1002        // The stream is host-native: a trigger with no function target.
1003        let stream = triggers
1004            .iter()
1005            .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1006            .unwrap();
1007        assert!(stream.target.is_none());
1008        match &stream.kind {
1009            TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1010            _ => unreachable!(),
1011        }
1012    }
1013
1014    #[test]
1015    fn materialize_resolves_paths_to_blob_hashes() {
1016        let cfg = DeployConfig {
1017            handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1018            ..Default::default()
1019        };
1020        let (specs, _) = desugar(&cfg);
1021        let files = BTreeMap::from([(
1022            "hello.wasm".to_string(),
1023            FileEntry {
1024                hash: "sha256:abc".into(),
1025                size: 10,
1026                content_type: None,
1027                variants: BTreeMap::new(),
1028            },
1029        )]);
1030        let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1031        assert_eq!(funcs.len(), 1);
1032        assert_eq!(funcs[0].name, "api-hello");
1033        assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1034        assert_eq!(funcs[0].active, "sha256:abc");
1035        assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1036        assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1037        // A spec whose component blob is absent is dropped (no phantom function).
1038        assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1039    }
1040
1041    #[test]
1042    fn empty_config_desugars_to_nothing() {
1043        let (functions, triggers) = desugar(&DeployConfig::default());
1044        assert!(functions.is_empty() && triggers.is_empty());
1045    }
1046
1047    #[test]
1048    fn model_serde_round_trips() {
1049        let f = Function {
1050            name: "resize".into(),
1051            owner: Owner::Project("acme".into()),
1052            versions: vec![FunctionVersion {
1053                id: "v1abc".into(),
1054                component: "blob:deadbeef".into(),
1055                created: 1_800_000_000,
1056                lifecycle: Lifecycle::Independent,
1057            }],
1058            active: "v1abc".into(),
1059            aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1060            config: FunctionConfig {
1061                imports: vec!["blobstore".into()],
1062                runtime: Runtime::Microvm,
1063                ..Default::default()
1064            },
1065        };
1066        let json = serde_json::to_string(&f).unwrap();
1067        assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1068
1069        // A trigger with a data-carrying kind + host-native (no target) both round-trip.
1070        for t in [
1071            Trigger {
1072                kind: TriggerKind::Route {
1073                    host: Some("example.com".into()),
1074                    path: "/x".into(),
1075                    methods: vec!["POST".into()],
1076                },
1077                target: Some(FunctionRef {
1078                    name: "resize".into(),
1079                    version: None,
1080                }),
1081            },
1082            Trigger {
1083                kind: TriggerKind::Stream {
1084                    topics: vec!["t".into()],
1085                    websocket: true,
1086                    publish_topic: Some("up".into()),
1087                },
1088                target: None,
1089            },
1090        ] {
1091            let j = serde_json::to_string(&t).unwrap();
1092            assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1093        }
1094    }
1095
1096    #[test]
1097    fn versioning_alias_and_rollback() {
1098        let mut f = Function::new(
1099            "resize",
1100            Owner::Project("acme".into()),
1101            "hashA",
1102            FunctionConfig::default(),
1103            Lifecycle::Independent,
1104            1,
1105        );
1106        assert_eq!(f.active, "hashA");
1107        assert_eq!(f.versions.len(), 1);
1108
1109        // A new component → a new active version.
1110        f.upsert_version("hashB", Lifecycle::Independent, 2);
1111        assert_eq!(f.active, "hashB");
1112        assert_eq!(f.versions.len(), 2);
1113        // Re-deploying the same hash is idempotent (re-activates, no dup version).
1114        f.upsert_version("hashA", Lifecycle::Independent, 3);
1115        assert_eq!(f.active, "hashA");
1116        assert_eq!(f.versions.len(), 2);
1117
1118        // Alias to a known version; unknown is rejected.
1119        f.set_alias("prod", "hashB").unwrap();
1120        assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1121        assert!(f.set_alias("prod", "ghost").is_err());
1122
1123        // Rollback to a known version; unknown is rejected.
1124        f.rollback("hashB").unwrap();
1125        assert_eq!(f.active, "hashB");
1126        assert!(f.rollback("ghost").is_err());
1127
1128        // `resolve` maps a version id OR an alias label to the component hash;
1129        // an unknown reference resolves to nothing.
1130        assert_eq!(f.resolve("hashA"), Some("hashA"));
1131        assert_eq!(f.resolve("prod"), Some("hashB")); // alias → version → component
1132        assert_eq!(f.resolve("ghost"), None);
1133    }
1134
1135    #[test]
1136    fn invocation_model_round_trips_and_reports_terminal() {
1137        let inv = Invocation {
1138            id: "inv-1".into(),
1139            function: "greeter".into(),
1140            version: "hashA".into(),
1141            mode: InvokeMode::Async,
1142            status: InvocationStatus::Queued,
1143            idempotency_key: Some("k".into()),
1144            attempts: 0,
1145            lease_expires: None,
1146            request_b64: Some("aGk=".into()),
1147            request_content_type: Some("text/plain".into()),
1148            result: None,
1149            created: 1,
1150            updated: 1,
1151        };
1152        assert!(!inv.is_terminal());
1153        let json = serde_json::to_string(&inv).unwrap();
1154        let back: Invocation = serde_json::from_str(&json).unwrap();
1155        assert_eq!(back, inv);
1156        // Enum wire forms are snake_case + stable.
1157        assert!(json.contains("\"mode\":\"async\""));
1158        assert!(json.contains("\"status\":\"queued\""));
1159
1160        let done = Invocation {
1161            status: InvocationStatus::Succeeded,
1162            result: Some(InvocationResult {
1163                status: 200,
1164                content_type: None,
1165                body_b64: "b2s=".into(),
1166            }),
1167            ..inv
1168        };
1169        assert!(done.is_terminal());
1170    }
1171
1172    #[test]
1173    fn metering_records_and_rate_limits() {
1174        let mut m = Metering::new("greeter");
1175        m.record(
1176            &MeteringSample {
1177                success: true,
1178                duration_ms: 5,
1179                bytes_in: 3,
1180                bytes_out: 7,
1181            },
1182            100,
1183        );
1184        m.record(
1185            &MeteringSample {
1186                success: false,
1187                duration_ms: 2,
1188                bytes_in: 0,
1189                bytes_out: 0,
1190            },
1191            101,
1192        );
1193        assert_eq!(m.invocations, 2);
1194        assert_eq!(m.successes, 1);
1195        assert_eq!(m.failures, 1);
1196        assert_eq!(m.duration_ms_total, 7);
1197        assert_eq!(m.bytes_out_total, 7);
1198        assert_eq!(m.updated, 101);
1199
1200        // Fixed-window rate limit: 2 per 10s window.
1201        let quota = FunctionQuota {
1202            max_invocations: Some(2),
1203            window_secs: Some(10),
1204            max_concurrent: None,
1205        };
1206        let mut r = Metering::new("greeter");
1207        assert!(r.admit(&quota, 1000)); // 1st in window
1208        assert!(r.admit(&quota, 1001)); // 2nd
1209        assert!(!r.admit(&quota, 1002)); // 3rd → rejected
1210        assert_eq!(r.window_count, 2);
1211        // The window rolls over after `window_secs`, resetting the counter.
1212        assert!(r.admit(&quota, 1011));
1213        assert_eq!(r.window_count, 1);
1214
1215        // An unset cap always admits and never touches the counter.
1216        let unset = FunctionQuota::default();
1217        let mut u = Metering::new("greeter");
1218        assert!(u.admit(&unset, 1));
1219        assert_eq!(u.window_count, 0);
1220        assert!(unset.is_unset());
1221    }
1222
1223    #[test]
1224    fn webhook_config_defaults_and_round_trips() {
1225        // Defaults: header + body cap.
1226        let w = WebhookConfig {
1227            secret_env: "HOOK_SECRET".into(),
1228            algorithm: WebhookAlgorithm::HmacSha256,
1229            signature_header: None,
1230            max_body_bytes: None,
1231            publish: None,
1232        };
1233        assert_eq!(w.header(), "x-boatramp-signature");
1234        assert_eq!(w.body_cap(), 1024 * 1024);
1235
1236        // A config carrying a webhook round-trips and the secret is an env *ref*.
1237        let cfg = FunctionConfig {
1238            webhook: Some(w),
1239            ..Default::default()
1240        };
1241        let json = serde_json::to_string(&cfg).unwrap();
1242        assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1243        assert!(json.contains("\"hmac_sha256\""));
1244        let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1245        assert_eq!(back, cfg);
1246
1247        // Custom header + cap are honoured.
1248        let custom = WebhookConfig {
1249            secret_env: "S".into(),
1250            algorithm: WebhookAlgorithm::HmacSha256,
1251            signature_header: Some("x-hub-signature-256".into()),
1252            max_body_bytes: Some(4096),
1253            publish: None,
1254        };
1255        assert_eq!(custom.header(), "x-hub-signature-256");
1256        assert_eq!(custom.body_cap(), 4096);
1257    }
1258
1259    #[test]
1260    fn keyspace_is_stable() {
1261        assert_eq!(
1262            keys::meta("default", "resize"),
1263            "project/default/functions/resize"
1264        );
1265        assert_eq!(
1266            keys::version("default", "resize", "v1"),
1267            "project/default/functions/resize/versions/v1"
1268        );
1269        assert_eq!(
1270            keys::alias("default", "resize", "prod"),
1271            "project/default/functions/resize/alias/prod"
1272        );
1273        assert_eq!(
1274            keys::trigger("default", "resize", "t1"),
1275            "project/default/functions/resize/triggers/t1"
1276        );
1277        assert_eq!(
1278            keys::invocation("default", "resize", "inv-1"),
1279            "project/default/functions/resize/invocations/inv-1"
1280        );
1281        assert_eq!(
1282            keys::invocations_prefix("default", "resize"),
1283            "project/default/functions/resize/invocations/"
1284        );
1285        assert_eq!(
1286            keys::idempotency("default", "resize", "k-1"),
1287            "project/default/functions/resize/idem/k-1"
1288        );
1289        assert_eq!(
1290            keys::metering("default", "resize"),
1291            "project/default/metering/resize"
1292        );
1293        // A project other than the default keys under its own segment.
1294        assert_eq!(
1295            keys::meta("acme", "resize"),
1296            "project/acme/functions/resize"
1297        );
1298    }
1299}