bynk_check/worker_bindings.rs
1//! A Worker's binding names must be unique (`bynk.context.duplicate_binding`).
2//!
3//! A Worker's bindings come from derivations that know nothing of each other:
4//! the KV namespace's fixed [`crate::firstparty::KV_BINDING_NAME`], a consumed
5//! context's name (`a.b` → `A_B`), an agent's class name (`OrderEntity` →
6//! `ORDER_ENTITY`) and a derived capability's (`Billing` →
7//! `SERVICE_BILLING`). Two of them can meet: an agent `Kv` in a context that
8//! consumes `bynk.cloudflare { Kv }` is `KV` twice, and an agent
9//! `ServiceBilling` and a `capability Billing = Service` are both
10//! `SERVICE_BILLING`. Wrangler takes such a
11//! config without complaint and one binding hides the other, so the clash is
12//! a compile error. The names themselves are never adjusted to avoid it,
13//! because deployed Workers depend on them.
14//!
15//! The names are the emitter's (`bynk_emit::emitter::wrangler::
16//! worker_bindings`, the list `wrangler.toml` is written from); this module
17//! owns the check over them and the diagnostic.
18
19use std::collections::{BTreeMap, HashMap};
20use std::path::PathBuf;
21
22use bynk_project::ParsedFile;
23use bynk_syntax::ast::{CommonsItem, SourceUnit};
24use bynk_syntax::error::CompileError;
25use bynk_syntax::span::Span;
26
27use crate::firstparty;
28use crate::project_model::ErrorSink;
29
30/// A declaration's name in screaming snake case (`OrderEntity` →
31/// `ORDER_ENTITY`): the casing of an agent's binding and of a derived
32/// capability's. One function, so the two kinds of name the duplicate check
33/// compares cannot drift apart (review of #1968).
34pub fn screaming_snake(name: &str) -> String {
35 let mut out = String::new();
36 for (i, ch) in name.chars().enumerate() {
37 if i > 0 && ch.is_uppercase() {
38 out.push('_');
39 }
40 out.push(ch.to_ascii_uppercase());
41 }
42 out
43}
44
45/// S4a (#1966): the Worker binding name of a derived capability, the kind's
46/// prefix then the capability name in [`screaming_snake`] case (`Sessions` →
47/// `KV_SESSIONS`, `PriceCache` → `KV_PRICE_CACHE`).
48pub fn instance_binding_name(prefix: &str, capability: &str) -> String {
49 format!("{prefix}_{}", screaming_snake(capability))
50}
51
52/// What put a binding into a Worker's `wrangler.toml`: the source a
53/// `bynk.context.duplicate_binding` diagnostic names.
54///
55/// Each variant's doc also says whether a handler's `deps.env` type exposes
56/// it on Workers (#1950, `bynk_emit::emitter::wrangler::handler_bindings`).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum BindingSource {
59 /// A `[[services]]` Service Binding to another context's Worker: a
60 /// consumed context whose services the context calls (#1961 — consuming
61 /// one for its types or events alone binds nothing), or one subscribing to
62 /// an event it publishes. Handlers see a called context's binding; never a
63 /// subscriber's, which only the events fan-out calls.
64 Service { context: String },
65 /// The `[[kv_namespaces]]` binding the cloudflare adapter's `Kv` reads.
66 /// Never seen by handlers: they reach KV through the `WorkersKv` provider,
67 /// which takes the Worker's `env` at compose time.
68 Kv,
69 /// S4a (#1966): the `[[kv_namespaces]]` binding of a derived capability
70 /// (`capability Sessions = Kv`), one per instance. Never seen by handlers:
71 /// they reach it through the instance's `WorkersKv`.
72 KvInstance { capability: String },
73 /// Interop I5 (#1972): the `[[services]]` binding of a `Service`
74 /// instance (`capability Billing = Service @worker("legacy-billing")`), a
75 /// Service Binding to a Worker outside the project. Never seen by
76 /// handlers: they reach it through the instance's `WorkersService`.
77 ForeignService { capability: String, worker: String },
78 /// P5 (#2323): the `[[r2_buckets]]` binding of an `R2` instance
79 /// (`capability Archives = R2`), one bucket per instance, pinned to
80 /// `jurisdiction` when it names one. Never seen by handlers: they reach
81 /// it through the instance's `WorkersR2`.
82 R2Instance {
83 capability: String,
84 jurisdiction: Option<String>,
85 },
86 /// Standard-library P3 (#2342): the `[[ratelimits]]` binding of a
87 /// `RateLimit` instance (`capability LoginLimit = RateLimit @limit(100)
88 /// @period(60.seconds)`), allowing `limit` calls per key every `period`
89 /// seconds. Never seen by handlers: they reach it through the instance's
90 /// `WorkersRateLimit`.
91 RateLimitInstance {
92 capability: String,
93 limit: i64,
94 period: i64,
95 },
96 /// A `[[durable_objects.bindings]]` entry for one of the context's agents.
97 /// A handler that constructs an agent sees every local agent's binding.
98 Agent { class_name: String },
99 /// The `[[durable_objects.bindings]]` entry for the events fan-out class
100 /// of a context whose handlers emit. Never seen by handlers: they reach it
101 /// through `deps.__eventsDispatch`.
102 EventsFanout { class_name: String },
103}
104
105impl BindingSource {
106 /// The source in a diagnostic's words.
107 pub fn describe(&self) -> String {
108 match self {
109 Self::Service { context } => format!("the Service Binding to context `{context}`"),
110 Self::Kv => format!(
111 "the KV namespace binding of `{}`'s `Kv`",
112 firstparty::CLOUDFLARE_UNIT
113 ),
114 Self::KvInstance { capability } => {
115 format!("the KV namespace binding of derived capability `{capability}`")
116 }
117 Self::ForeignService { capability, worker } => format!(
118 "the Service Binding of derived capability `{capability}` (to Worker `{worker}`)"
119 ),
120 Self::R2Instance { capability, .. } => {
121 format!("the R2 bucket binding of derived capability `{capability}`")
122 }
123 Self::RateLimitInstance { capability, .. } => {
124 format!("the rate limiting binding of derived capability `{capability}`")
125 }
126 Self::Agent { class_name } => {
127 format!("the Durable Object binding of agent `{class_name}`")
128 }
129 Self::EventsFanout { .. } => {
130 "the Durable Object binding of the events fan-out".to_string()
131 }
132 }
133 }
134}
135
136/// One binding a Worker declares: the name its `env` field and its
137/// `wrangler.toml` entry share, and where that name came from.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct WorkerBinding {
140 pub name: String,
141 pub source: BindingSource,
142}
143
144/// The bindings in `bindings` whose name an earlier one already took, each
145/// paired with that earlier one, in list order.
146pub fn duplicate_bindings(bindings: &[WorkerBinding]) -> Vec<(&WorkerBinding, &WorkerBinding)> {
147 let mut first: HashMap<&str, &WorkerBinding> = HashMap::new();
148 let mut duplicates = Vec::new();
149 for binding in bindings {
150 match first.get(binding.name.as_str()) {
151 Some(earlier) => duplicates.push((*earlier, binding)),
152 None => {
153 first.insert(&binding.name, binding);
154 }
155 }
156 }
157 duplicates
158}
159
160/// `bynk.context.duplicate_binding`: each context's Worker declares every
161/// binding name once. `bindings` holds each context's bindings as its
162/// `wrangler.toml` would list them. One error per clash, at the declaration
163/// that brought in the second binding (an agent, or a `consumes` clause),
164/// preferring one the author wrote over `consumes bynk.cloudflare`, naming
165/// both sources. A clash between two sources with no declaration of their own
166/// (Service Bindings added for event subscribers) is reported at the
167/// context's name.
168pub fn phase_duplicate_bindings(
169 parsed: &[ParsedFile],
170 groups: &BTreeMap<String, Vec<usize>>,
171 bindings: &BTreeMap<String, Vec<WorkerBinding>>,
172 errors: &mut ErrorSink,
173) {
174 for (ctx, ctx_bindings) in bindings {
175 let indices = groups.get(ctx).map(Vec::as_slice).unwrap_or_default();
176 for (earlier, later) in duplicate_bindings(ctx_bindings) {
177 // At the declaration the author can change: the later source's,
178 // else the earlier's, but never the `bynk.cloudflare` clause
179 // while the other source has a declaration of its own.
180 let mut sources = [later, earlier];
181 sources.sort_by_key(|b| b.source == BindingSource::Kv);
182 let (file, span) = sources
183 .iter()
184 .find_map(|b| declared_at(parsed, indices, b))
185 .or_else(|| context_header(parsed, indices))
186 .map_or((None, Span::default()), |(file, span)| (Some(file), span));
187 errors.push_for(
188 file.as_deref(),
189 CompileError::new(
190 "bynk.context.duplicate_binding",
191 span,
192 format!(
193 "the Worker for `{ctx}` would declare two bindings named `{}`: {} and {}",
194 later.name,
195 earlier.source.describe(),
196 later.source.describe(),
197 ),
198 )
199 .with_note(
200 "a Worker's bindings share one `env`, so each name must be unique; binding names follow agent, context and derived capability names, so rename the declaration behind one of them",
201 ),
202 );
203 }
204 }
205}
206
207/// Where the context declares the source of `binding`: an agent's name, or
208/// the `consumes` clause naming a context (or `bynk.cloudflare`, for the KV
209/// namespace). `None` for a source with no declaration of its own: the events
210/// fan-out, or a Service Binding added for an event subscriber.
211fn declared_at(
212 parsed: &[ParsedFile],
213 indices: &[usize],
214 binding: &WorkerBinding,
215) -> Option<(PathBuf, Span)> {
216 indices.iter().find_map(|&i| {
217 let SourceUnit::Context(c) = parsed[i].unit() else {
218 return None;
219 };
220 let consumed = |target: &str| {
221 c.consumes
222 .iter()
223 .find(|d| d.target.joined() == target)
224 .map(|d| d.span)
225 };
226 let span = match &binding.source {
227 BindingSource::Agent { class_name } => c.items.iter().find_map(|item| {
228 let CommonsItem::Agent(a) = item else {
229 return None;
230 };
231 (a.name.name == *class_name).then_some(a.name.span)
232 }),
233 BindingSource::Service { context } => consumed(context),
234 BindingSource::Kv => consumed(firstparty::CLOUDFLARE_UNIT),
235 BindingSource::KvInstance { capability }
236 | BindingSource::ForeignService { capability, .. }
237 | BindingSource::R2Instance { capability, .. }
238 | BindingSource::RateLimitInstance { capability, .. } => {
239 c.items.iter().find_map(|item| {
240 let CommonsItem::Capability(d) = item else {
241 return None;
242 };
243 (d.name.name == *capability && d.derived.is_some()).then_some(d.span)
244 })
245 }
246 BindingSource::EventsFanout { .. } => None,
247 }?;
248 Some((parsed[i].identity_path(), span))
249 })
250}
251
252/// The context's name in its first file: where a clash between two sources
253/// with no declaration of their own is reported.
254fn context_header(parsed: &[ParsedFile], indices: &[usize]) -> Option<(PathBuf, Span)> {
255 indices.iter().find_map(|&i| {
256 let SourceUnit::Context(c) = parsed[i].unit() else {
257 return None;
258 };
259 Some((parsed[i].identity_path(), c.name.span))
260 })
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn service(name: &str, context: &str) -> WorkerBinding {
268 WorkerBinding {
269 name: name.to_string(),
270 source: BindingSource::Service {
271 context: context.to_string(),
272 },
273 }
274 }
275
276 #[test]
277 fn distinct_names_do_not_clash() {
278 assert!(duplicate_bindings(&[service("A", "a"), service("B", "b")]).is_empty());
279 }
280
281 /// Three bindings on one name are two clashes, each against the first.
282 #[test]
283 fn every_repeat_is_reported_against_the_first() {
284 let bindings = [
285 service("A_B", "a.b"),
286 service("A_B", "a_b"),
287 service("A_B", "x"),
288 ];
289 let duplicates = duplicate_bindings(&bindings);
290 assert_eq!(duplicates.len(), 2);
291 assert!(
292 duplicates
293 .iter()
294 .all(|(earlier, _)| *earlier == &bindings[0])
295 );
296 assert_eq!(duplicates[1].1, &bindings[2]);
297 }
298}