bynk_emit/emitter/wrangler.rs
1//! `wrangler.toml` generation per Worker (v0.8 §4.4 / §4.5).
2//!
3//! Each context becomes a Cloudflare Worker with its own wrangler config.
4//! Service Bindings are declared for every consumed context. Durable
5//! Object bindings + `exports` entries are declared for every agent.
6
7use crate::emitter::toml_doc::{TomlBlock, TomlDocument, TomlEntry, TomlValue};
8use crate::project::worker_dir_name;
9pub(crate) use bynk_check::worker_bindings::{BindingSource, WorkerBinding};
10
11/// Compile-time pinned compatibility date. Cloudflare uses this to lock
12/// Workers runtime behaviour. When to bump it, and the gate a bump must pass,
13/// is the policy in `design/bynk-release-discipline.md` ("Part 3 — The Workers
14/// compatibility date", #1677): reviewed at each release, and moved only after
15/// the workerd smokes pass on the new date.
16pub const COMPATIBILITY_DATE: &str = "2026-07-01";
17
18/// #1890: the compatibility flags every emitted Worker carries, ahead of the
19/// project's own (`bynk.toml`'s `[workers] compatibility_flags`).
20///
21/// `global_fetch_strictly_public` makes `fetch()` to a URL go out over the
22/// public internet even when the URL is another Worker on the same account
23/// or zone. Without it Cloudflare refuses such a fetch (error 1042, or a
24/// fast `404` from a `workers.dev` sibling), so a Bynk `Fetch` to a sibling
25/// service's public URL failed where the same URL worked from outside. In
26/// Bynk, `Fetch` to a URL means over the public internet, which is exactly
27/// this flag's behaviour. Worker-to-Worker calls inside one project use
28/// Service Bindings, which the flag leaves alone.
29///
30/// Reviewed with [`COMPATIBILITY_DATE`] (`design/bynk-release-discipline.md`,
31/// Part 3): a date bump can turn a flag on by default, which makes it
32/// redundant here.
33pub const DEFAULT_COMPATIBILITY_FLAGS: &[&str] = &["global_fetch_strictly_public"];
34
35/// #1890: the flags a Worker's `wrangler.toml` carries, and the duplicates
36/// dropped on the way. [`DEFAULT_COMPATIBILITY_FLAGS`] come first, then
37/// `extra` (the manifest's list) in order. A name already present, whether a
38/// default or earlier in `extra`, is dropped and returned in the second list,
39/// once per extra occurrence, for the caller to warn about. Names are not
40/// otherwise checked.
41pub fn compatibility_flags(extra: &[String]) -> (Vec<String>, Vec<String>) {
42 let mut flags: Vec<String> = DEFAULT_COMPATIBILITY_FLAGS
43 .iter()
44 .map(|f| f.to_string())
45 .collect();
46 let mut duplicates = Vec::new();
47 for flag in extra {
48 if flags.contains(flag) {
49 duplicates.push(flag.clone());
50 } else {
51 flags.push(flag.clone());
52 }
53 }
54 (flags, duplicates)
55}
56
57/// #1732: the oldest wrangler whose bundled `workerd` serves
58/// [`COMPATIBILITY_DATE`]. An older `workerd` refuses a newer date outright
59/// (`wrangler dev` exits: "This Worker requires compatibility date …"), so
60/// `bynk doctor` warns about a wrangler below this. Set by the same
61/// compatibility-date review that sets the date (Part 3's lag rule keeps it at
62/// least three months old). `bynk-emit/tests/compat_date_policy.rs` checks the
63/// policy doc names both.
64pub const WRANGLER_MIN: &str = "4.107.0";
65
66/// Events track, slice 0 (spine #936, ADR 0284): the class name of a
67/// publishing context's fan-out Durable Object (`emitter::events_fanout`).
68/// Shared with `emitter::workers` (the `Env` field name + `deps.
69/// __eventsDispatch` call it drives) and `emitter::events_fanout` (the class
70/// this name must actually export) so the three can never drift apart.
71///
72/// Double-underscore-prefixed, matching every other compiler-synthesised
73/// identifier in emitted output (`__events`, `__eventsDispatch`,
74/// `__makeLedger`, …) — a Bynk `agent` name can never start with `_` (a
75/// parse error, checked directly: `agent _Foo { … }` fails with
76/// `expected identifier after \`agent\`, found \`_\``), so an agent
77/// coincidentally named the same as this synthetic class is structurally
78/// impossible, not merely unlikely.
79pub const EVENTS_FANOUT_CLASS_NAME: &str = "__EventsFanout";
80
81/// Deploy-time sentinel in generated Worker configuration. The driver replaces
82/// this with the persistent Cloudflare KV namespace id immediately before a
83/// remote Wrangler command runs.
84pub const KV_NAMESPACE_ID_PLACEHOLDER: &str = "<KV_NAMESPACE_ID>";
85
86/// The bare `Kv`'s binding (`KV`), re-exported for `bynk deploy`, which reads
87/// generated configs and tells the bare namespace from derived instances by
88/// it (S4b, #1967).
89pub use bynk_check::firstparty::KV_BINDING_NAME;
90
91/// #1883, slice 1b: the `[assets]` block for a Worker that ships static files.
92/// `directory` is the Worker's own assets directory
93/// ([`crate::project::WORKER_ASSETS_DIR`]), beside — never containing — its
94/// compiled code. The three routing settings are set explicitly rather than
95/// inherited (#1883 [DECISION C], checked against the wrangler reference):
96///
97/// - `run_worker_first = false`: a matching asset is served without running
98/// the Worker, which is what makes an asset public by construction.
99/// - `not_found_handling = "none"`: a request no asset matches falls through
100/// to the Worker, so every handler stays reachable.
101/// - `html_handling = "none"`: an asset is served only at its exact path (the
102/// default would also serve `foo.html` at `/foo`), so the route-conflict
103/// check can compare paths exactly.
104pub(crate) fn assets_block() -> TomlBlock {
105 TomlBlock::table(
106 "assets",
107 vec![
108 TomlEntry::kv(
109 "directory",
110 TomlValue::str(format!("./{}", crate::project::WORKER_ASSETS_DIR)),
111 ),
112 TomlEntry::kv("run_worker_first", TomlValue::Bool(false)),
113 TomlEntry::kv("not_found_handling", TomlValue::str("none")),
114 TomlEntry::kv("html_handling", TomlValue::str("none")),
115 ],
116 )
117}
118
119/// L7 (#2414): `[cache]` turning Workers Cache on for a Worker whose context
120/// declares an `EdgeCache` instance. Cloudflare then answers a `GET` or `HEAD`
121/// from its edge cache when it holds a fresh response, without running the
122/// Worker, and stores what the Worker sends as RFC 9111 allows: which is why
123/// such a Worker sends `no-store` on every response but a `@cache` route's
124/// `2xx` (`workers_entry`). A Worker without an instance gets no stanza.
125pub(crate) fn edge_cache_block() -> TomlBlock {
126 TomlBlock::table(
127 "cache",
128 vec![TomlEntry::kv("enabled", TomlValue::Bool(true))],
129 )
130}
131
132/// #2321: the explicit module rule for a Worker whose adapter closure reaches
133/// an included WebAssembly module: `**/*.wasm` is compiled by Wrangler to a
134/// `WebAssembly.Module` (the shim's `import module from "./x.wasm"`). Wrangler's
135/// default rules already say this; stating it keeps the dependency visible and
136/// stops it changing under us. `fallthrough = true` leaves the other default
137/// rules in force.
138pub(crate) fn wasm_rule_block() -> TomlBlock {
139 TomlBlock::array_table(
140 "rules",
141 vec![
142 TomlEntry::kv("type", TomlValue::str("CompiledWasm")),
143 TomlEntry::kv("globs", TomlValue::Array(vec![TomlValue::str("**/*.wasm")])),
144 TomlEntry::kv("fallthrough", TomlValue::Bool(true)),
145 ],
146 )
147}
148
149/// #1883, slice 1b: the `_headers` file written into a Worker's assets
150/// directory beside its files. Every served path carries its content hash, so
151/// a file under [`bynk_check::assets::SERVED_PREFIX`] never changes and may
152/// be cached for a year. Without it the platform's default is `max-age=0,
153/// must-revalidate`. The glob is built from the prefix, so the two cannot
154/// drift (review of #1904).
155pub(crate) fn asset_headers() -> String {
156 format!(
157 "{}*\n Cache-Control: public, max-age=31536000, immutable\n",
158 bynk_check::assets::SERVED_PREFIX
159 )
160}
161
162/// State-migrations S3 (#2248): one context's agent class renames
163/// (`(old, new)`) and removals, as `exports` tombstones.
164#[derive(Debug, Default)]
165pub(crate) struct ClassTombstones {
166 pub renamed: Vec<(String, String)>,
167 pub deleted: Vec<String>,
168}
169
170impl ClassTombstones {
171 /// From the context's declarations: each agent's `replaces`, and each
172 /// `removed agent`, skipping an old name still declared live (the checker
173 /// already refuses that).
174 pub(crate) fn of(table: &bynk_check::symbols::UnitTable) -> Self {
175 let mut renamed: Vec<(String, String)> = table
176 .agents
177 .values()
178 .filter_map(|a| {
179 let old = a.replaces.as_ref()?;
180 (!table.agents.contains_key(&old.name))
181 .then(|| (old.name.clone(), a.name.name.clone()))
182 })
183 .collect();
184 renamed.sort();
185 let mut deleted: Vec<String> = table
186 .removed_agents
187 .iter()
188 .filter(|r| !table.agents.contains_key(&r.name.name))
189 .map(|r| r.name.name.clone())
190 .collect();
191 deleted.sort();
192 deleted.dedup();
193 ClassTombstones { renamed, deleted }
194 }
195}
196
197/// P6.x cutover slice 2 (#1191, narrowed from #1187): `crons`/`queues` arrive
198/// pre-collected, sorted and deduped by the caller (`project.rs`'s own
199/// `emit_wrangler_toml` call site) rather than being walked here off
200/// `table.services`. Both used to match a handler's cron-schedule kind and a
201/// service's queue-binding protocol directly, straight off the syntax tree —
202/// this file's entire raw-syntax footprint, per #1191's own grounding.
203/// Relocating just these two matches removed `wrangler.rs` from the
204/// `ast_importers` probe outright — no `bynk_ir` equivalent exists to route
205/// through instead: nothing builds a project-wide service IR at the call site
206/// (#1191's Framing), and after #1542's Slice D2 nothing builds one anywhere.
207///
208/// P7.3 (#1303): builds a [`TomlDocument`] rather than writing text directly
209/// — the caller (`project.rs`) prints it via `emitter::toml_doc::
210/// print_toml_document`. What gets built, in what order, under what gates,
211/// is unchanged; only the construction shape moved from direct `writeln!`
212/// into pushing typed blocks. String-escaping moved with it: every value
213/// below is escaped unconditionally by the printer now, not selectively by
214/// this function (`emitter/toml_doc.rs`'s own module doc has the reasoning).
215pub(crate) fn emit_wrangler_toml(
216 context: &str,
217 // #1944: every binding this Worker declares, [`worker_bindings`]' list —
218 // the same one its `Env` interface and the duplicate check read.
219 bindings: &[WorkerBinding],
220 // v0.10a: every `on cron "expr"` schedule in the context, sorted+deduped.
221 // Sorting is load-bearing, not cosmetic (review of #1192): the caller
222 // walks `table.services`, a `HashMap`, so unsorted input makes
223 // `wrangler.toml` non-reproducible across runs.
224 crons: &[String],
225 // v0.10b/v0.44: every `from queue("name")` service's bound queue name,
226 // sorted+deduped (same reproducibility requirement as `crons`).
227 queues: &[String],
228 // #1890: the merged flag list, [`compatibility_flags`]' first result.
229 // The same for every Worker in the project, so the caller merges (and
230 // warns about duplicates) once, not per Worker.
231 flags: &[String],
232 // State-migrations S3 (#2248): the context's declared class renames and
233 // removals, each emitted as an `exports` tombstone.
234 tombstones: &ClassTombstones,
235) -> TomlDocument {
236 let name = worker_dir_name(context);
237 let mut doc = TomlDocument::new(
238 "Generated by bynkc — do not edit by hand.",
239 vec![
240 TomlEntry::kv("name", TomlValue::str(name)),
241 TomlEntry::kv("main", TomlValue::str("index.ts")),
242 TomlEntry::kv("compatibility_date", TomlValue::str(COMPATIBILITY_DATE)),
243 TomlEntry::kv(
244 "compatibility_flags",
245 TomlValue::Array(flags.iter().map(TomlValue::str).collect()),
246 ),
247 ],
248 );
249
250 let mut class_names: Vec<&str> = Vec::new();
251 for binding in bindings {
252 let name = TomlValue::str(binding.name.clone());
253 let block = match &binding.source {
254 BindingSource::Service { context } => TomlBlock::array_table(
255 "services",
256 vec![
257 TomlEntry::kv("binding", name),
258 TomlEntry::kv("service", TomlValue::str(worker_dir_name(context))),
259 ],
260 ),
261 BindingSource::Kv => TomlBlock::array_table(
262 "kv_namespaces",
263 vec![
264 TomlEntry::kv("binding", name),
265 TomlEntry::with_comment(
266 "id",
267 TomlValue::str(KV_NAMESPACE_ID_PLACEHOLDER),
268 "set at deploy time",
269 ),
270 ],
271 ),
272 // S4a (#1966): an instance's own placeholder, named for its binding,
273 // so deploy (#1967) can mint and materialise each one apart.
274 BindingSource::KvInstance { .. } => TomlBlock::array_table(
275 "kv_namespaces",
276 vec![
277 TomlEntry::kv("binding", name),
278 TomlEntry::with_comment(
279 "id",
280 TomlValue::str(kv_instance_id_placeholder(&binding.name)),
281 "set at deploy time",
282 ),
283 ],
284 ),
285 // P5 (#2323): the bucket's derived name, which `bynk deploy`
286 // environment-qualifies; R2 bucket names are scoped to the
287 // account, so the name is a function of the Worker and the
288 // capability alone.
289 BindingSource::R2Instance { jurisdiction, .. } => {
290 let mut entries = vec![
291 TomlEntry::kv("binding", name),
292 TomlEntry::kv(
293 "bucket_name",
294 TomlValue::str(r2_bucket_name(&worker_dir_name(context), &binding.name)),
295 ),
296 ];
297 if let Some(jurisdiction) = jurisdiction {
298 entries.push(TomlEntry::kv(
299 "jurisdiction",
300 TomlValue::str(jurisdiction.clone()),
301 ));
302 }
303 TomlBlock::array_table("r2_buckets", entries)
304 }
305 // P3 (#2342): the limiter's own placeholder `namespace_id`, which
306 // `bynk deploy` allocates in the ledger and materialises; the
307 // limit and the window are configuration, not identity.
308 BindingSource::RateLimitInstance { limit, period, .. } => TomlBlock::array_table(
309 "ratelimits",
310 vec![
311 TomlEntry::kv("name", name),
312 TomlEntry::with_comment(
313 "namespace_id",
314 TomlValue::str(ratelimit_namespace_placeholder(&binding.name)),
315 "set at deploy time",
316 ),
317 TomlEntry::kv(
318 "simple",
319 TomlValue::InlineTable(vec![
320 ("limit", TomlValue::Int(*limit)),
321 ("period", TomlValue::Int(*period)),
322 ]),
323 ),
324 ],
325 ),
326 // Interop I5 (#1972): the Worker as the declaration names it,
327 // not a project context's `worker_dir_name`.
328 BindingSource::ForeignService { worker, .. } => TomlBlock::array_table(
329 "services",
330 vec![
331 TomlEntry::kv("binding", name),
332 TomlEntry::kv("service", TomlValue::str(worker.clone())),
333 ],
334 ),
335 BindingSource::Agent { class_name } | BindingSource::EventsFanout { class_name } => {
336 class_names.push(class_name);
337 TomlBlock::array_table(
338 "durable_objects.bindings",
339 vec![
340 TomlEntry::kv("name", name),
341 TomlEntry::kv("class_name", TomlValue::str(class_name.clone())),
342 ],
343 )
344 }
345 };
346 doc.push_block(block);
347 }
348 // #1796: each class is declared in Cloudflare's declarative `exports`
349 // map, not a `[[migrations]]` list. Migrations are picked by tag: Wrangler
350 // uploads only the ones after the Worker's applied tag, so the single
351 // fixed `v1` this used to emit registered the classes of the *first*
352 // deploy and no class added after it (a new agent, or the fan-out class
353 // arriving with a context's first `emit`). `exports` has no tag. On every
354 // deploy Cloudflare compares the declared set with the Worker's
355 // namespaces and creates what's missing, so the config stays a pure
356 // function of the source and the ledger still records nothing (ADR 0194
357 // D1's principle). Wrangler reads `exports` from 4.107.0, which is
358 // [`WRANGLER_MIN`], and `wrangler dev` takes each class's backend from
359 // it too.
360 //
361 // Every class is SQLite-backed (#1779, ADR 0438): the emitted agent uses
362 // only `storage.get`/`storage.put` on its single `"state"` key, which a
363 // SQLite-backed class supports unchanged, and the fan-out class uses no
364 // storage at all. A Worker whose classes an older Bynk created
365 // key-value-backed (`new_classes`, ≤ 0.309.10, Workers Paid only: the
366 // Free plan refuses that backend with code 10097) is refused
367 // (`storage_type_mismatch`, per Cloudflare's `exports` docs), because
368 // Cloudflare can't change a backend in place. That is a documented
369 // pre-1.0 break: such a Worker is torn down and redeployed. A Worker
370 // whose classes the `new_sqlite_classes` migration created takes them
371 // over into `exports` with their state (#1809, live).
372 //
373 // State-migrations S3 (#2248): a class leaves only through a declaration.
374 // `agent New replaces Old` emits a `renamed` tombstone (Cloudflare moves
375 // Old's stored data to New) and `removed agent Old` a `deleted` one (the
376 // data is destroyed, after a named confirmation at deploy). A class that
377 // simply disappears from source still fails the deploy with Cloudflare's
378 // `orphaned_provisioned_namespace` (confirmed live in #1809) — and, before
379 // that, `bynk check` with `bynk.agent.class_removed`. A tombstoned class
380 // gets no binding.
381 for class_name in class_names {
382 doc.push_block(TomlBlock::keyed_table(
383 "exports",
384 class_name.to_string(),
385 vec![
386 TomlEntry::kv("type", TomlValue::str("durable-object")),
387 TomlEntry::kv("storage", TomlValue::str("sqlite")),
388 ],
389 ));
390 }
391 let mut tombstones_sorted: Vec<(&str, Option<&str>)> = tombstones
392 .renamed
393 .iter()
394 .map(|(old, new)| (old.as_str(), Some(new.as_str())))
395 .chain(tombstones.deleted.iter().map(|old| (old.as_str(), None)))
396 .collect();
397 tombstones_sorted.sort();
398 for (old, renamed_to) in tombstones_sorted {
399 let mut entries = vec![TomlEntry::kv("type", TomlValue::str("durable-object"))];
400 match renamed_to {
401 Some(new) => {
402 entries.push(TomlEntry::kv("state", TomlValue::str("renamed")));
403 entries.push(TomlEntry::kv("renamed_to", TomlValue::str(new)));
404 }
405 None => entries.push(TomlEntry::kv("state", TomlValue::str("deleted"))),
406 }
407 doc.push_block(TomlBlock::keyed_table("exports", old.to_string(), entries));
408 }
409
410 // v0.10a: cron triggers. Cloudflare uses a single `[triggers]` table with a
411 // `crons` array aggregating every `on cron` schedule in the context.
412 // Already sorted+deduped by the caller (#1191).
413 if !crons.is_empty() {
414 let quoted = crons.iter().map(TomlValue::str).collect();
415 doc.push_block(TomlBlock::table(
416 "triggers",
417 vec![TomlEntry::kv("crons", TomlValue::Array(quoted))],
418 ));
419 }
420
421 // v0.10b: queue consumers. Each `on queue "name"` becomes a
422 // `[[queues.consumers]]` binding. Already sorted+deduped by the caller
423 // (#1191).
424 for name in queues {
425 doc.push_block(TomlBlock::array_table(
426 "queues.consumers",
427 vec![
428 TomlEntry::kv("queue", TomlValue::str(name.clone())),
429 TomlEntry::kv("max_batch_size", TomlValue::Int(10)),
430 ],
431 ));
432 }
433
434 doc
435}
436
437/// Every binding a Worker's `wrangler.toml` declares, in emission order:
438/// Service Bindings by target, then those to foreign Workers by capability,
439/// then the KV namespaces, then the Durable Objects by class name. [`emit_wrangler_toml`] writes exactly this list,
440/// the Worker's `Env` interface (`emitter::workers`) types it, and the
441/// duplicate check (`bynk_check::worker_bindings`) reads it, so a new kind of
442/// binding is declared, typed and checked from one place (#1944). Each kind
443/// derives its names independently, so two can meet (an agent `Kv` and the KV
444/// namespace are both `KV`); names are never adjusted to avoid that, because
445/// deployed Workers depend on them. Two members with one name would also be
446/// invalid TypeScript in `Env`, so the duplicate check must run first.
447pub(crate) fn worker_bindings(
448 // The context's agents (`UnitTable::agents`' keys), in any order.
449 agents: &[String],
450 consumes: &[String],
451 needs_kv: bool,
452 // The context's derived instances, each its binding with a
453 // `KvInstance` (S4a, #1966) or `ForeignService` (interop I5, #1972)
454 // source, in capability order.
455 instances: &[WorkerBinding],
456 uses_emit: bool,
457) -> Vec<WorkerBinding> {
458 let mut bindings = Vec::new();
459 let mut sorted_consumes: Vec<&String> = consumes.iter().collect();
460 sorted_consumes.sort();
461 bindings.extend(sorted_consumes.into_iter().map(|t| service_binding(t)));
462 // Interop I5 (#1972): Service Bindings to foreign Workers, beside the
463 // ones to project contexts.
464 bindings.extend(
465 instances
466 .iter()
467 .filter(|b| matches!(b.source, BindingSource::ForeignService { .. }))
468 .cloned(),
469 );
470 if needs_kv {
471 bindings.push(kv_binding());
472 }
473 bindings.extend(
474 instances
475 .iter()
476 .filter(|b| matches!(b.source, BindingSource::KvInstance { .. }))
477 .cloned(),
478 );
479 // P5 (#2323): the R2 buckets, after the KV namespaces.
480 bindings.extend(
481 instances
482 .iter()
483 .filter(|b| matches!(b.source, BindingSource::R2Instance { .. }))
484 .cloned(),
485 );
486 // P3 (#2342): the rate limiters, after the buckets.
487 bindings.extend(
488 instances
489 .iter()
490 .filter(|b| matches!(b.source, BindingSource::RateLimitInstance { .. }))
491 .cloned(),
492 );
493 // Events track, slice 0 (spine #936, ADR 0284): a context whose handlers
494 // emit gets its own fan-out DO among the agents' — Cloudflare only cares
495 // that `index.ts` (this Worker's `main`) exports a class with this name,
496 // not which generated file it came from. Sources by provenance, not by
497 // name: an agent that happened to share the fan-out's class name is
498 // still an agent.
499 let mut objects: Vec<(&str, WorkerBinding)> = agents
500 .iter()
501 .map(|class_name| (class_name.as_str(), agent_binding(class_name)))
502 .collect();
503 if uses_emit {
504 objects.push((EVENTS_FANOUT_CLASS_NAME, events_fanout_binding()));
505 }
506 objects.sort_by(|a, b| a.0.cmp(b.0));
507 bindings.extend(objects.into_iter().map(|(_, binding)| binding));
508 bindings
509}
510
511/// #1950: the bindings a handler's `deps.env` type exposes on Workers — the
512/// projection of [`worker_bindings`] handlers reach directly, which
513/// `BindingSource`'s variant docs state per kind. `consumed` is the consumed
514/// contexts the handler calls (#1961), `agents` every local agent when the
515/// handler constructs one (else empty); order as in [`worker_bindings`].
516///
517/// Built by the same constructors, so each name and source is the Worker's
518/// own: a called context is never an adapter (an adapter may not declare a
519/// `service`, `bynk.adapter.disallowed_item`), and the Worker binds every
520/// context any of its bodies calls, so it has a Service Binding; every local
521/// agent has a Durable Object binding. The
522/// KV namespace, the events fan-out and subscribers' Service Bindings never
523/// reach handlers.
524pub(crate) fn handler_bindings(consumed: &[String], agents: &[String]) -> Vec<WorkerBinding> {
525 let mut consumed: Vec<&String> = consumed.iter().collect();
526 consumed.sort();
527 let mut agents: Vec<&String> = agents.iter().collect();
528 agents.sort();
529 consumed
530 .into_iter()
531 .map(|t| service_binding(t))
532 .chain(agents.into_iter().map(|a| agent_binding(a)))
533 .collect()
534}
535
536/// The TypeScript type a binding has on the Worker's `env`, for both its
537/// `Env` interface and a handler's `deps.env`.
538pub(crate) fn binding_ts_type(source: &BindingSource) -> &'static str {
539 match source {
540 BindingSource::Service { .. } | BindingSource::ForeignService { .. } => "__ServiceBinding",
541 BindingSource::Kv | BindingSource::KvInstance { .. } => "__KVNamespace",
542 BindingSource::R2Instance { .. } => "__R2Bucket",
543 BindingSource::RateLimitInstance { .. } => "__RateLimiter",
544 BindingSource::Agent { .. } | BindingSource::EventsFanout { .. } => {
545 "__DurableObjectNamespace"
546 }
547 }
548}
549
550// #1958: the constructors below are the only callers of the two name
551// functions, so every place that declares, types or reads a binding (the
552// emitted `env.X`, the fan-out's routing table, the integration harness)
553// gets its name from the one constructor for its kind. A constructor is
554// `pub(crate)` exactly when something outside this file reads its name.
555
556/// The Service Binding to context `target`'s Worker.
557pub(crate) fn service_binding(target: &str) -> WorkerBinding {
558 WorkerBinding {
559 name: consumed_binding_name(target),
560 source: BindingSource::Service {
561 context: target.to_string(),
562 },
563 }
564}
565
566/// Interop I5 (#1972): the status of the stand-in for a foreign Worker that
567/// `bynk dev` binds by default and the integration test harness binds always.
568/// 503, not a 2xx: the stand-in served nothing, and code that ignores the
569/// status should not mistake it for the Worker's answer.
570pub const FOREIGN_SERVICE_DOUBLE_STATUS: u16 = 503;
571
572/// Interop I5 (#1972): the body of that stand-in's every response, saying
573/// what it is. `bynk dev` adds how to reach the real Worker instead.
574pub fn foreign_service_double_body(binding: &str, worker: &str) -> String {
575 format!(
576 "bynk: `{binding}` is bound to a stand-in for Worker `{worker}`, which is not part of this project."
577 )
578}
579
580/// S4a (#1966): the deploy-time id placeholder of a derived KV instance's
581/// `[[kv_namespaces]]` entry: `KV_SESSIONS` → `<KV_SESSIONS_ID>`.
582pub fn kv_instance_id_placeholder(binding: &str) -> String {
583 format!("<{binding}_ID>")
584}
585
586/// P3 (#2342): the placeholder `namespace_id` of rate limiter `binding`,
587/// which `bynk deploy` replaces with the id the ledger allocates. Miniflare
588/// takes it as it is, so `bynk dev` runs with it.
589pub fn ratelimit_namespace_placeholder(binding: &str) -> String {
590 format!("<{binding}_NAMESPACE>")
591}
592
593/// The bare KV namespace binding the cloudflare adapter's `Kv` reads. Its one
594/// TypeScript read is the default of `WorkersKv`'s `binding` parameter in
595/// `bynk-std`'s `cloudflare.binding.ts`, pinned to `KV_BINDING_NAME` by a test.
596fn kv_binding() -> WorkerBinding {
597 WorkerBinding {
598 name: bynk_check::firstparty::KV_BINDING_NAME.to_string(),
599 source: BindingSource::Kv,
600 }
601}
602
603/// The Durable Object binding of agent `class_name`.
604pub(crate) fn agent_binding(class_name: &str) -> WorkerBinding {
605 WorkerBinding {
606 name: agent_binding_name(class_name),
607 source: BindingSource::Agent {
608 class_name: class_name.to_string(),
609 },
610 }
611}
612
613/// The Durable Object binding of the events fan-out class.
614pub(crate) fn events_fanout_binding() -> WorkerBinding {
615 WorkerBinding {
616 name: agent_binding_name(EVENTS_FANOUT_CLASS_NAME),
617 source: BindingSource::EventsFanout {
618 class_name: EVENTS_FANOUT_CLASS_NAME.to_string(),
619 },
620 }
621}
622
623/// Service Binding identifier for a consumed context: uppercase with
624/// underscores. `commerce.payment` → `COMMERCE_PAYMENT`.
625fn consumed_binding_name(target: &str) -> String {
626 target.replace('.', "_").to_uppercase()
627}
628
629/// Durable Object binding identifier for an agent class. We use the
630/// class name in screaming snake case so handlers can grab it by a
631/// predictable name (`OrderEntity` → `ORDER_ENTITY`), the casing derived
632/// capabilities' bindings share.
633fn agent_binding_name(class_name: &str) -> String {
634 bynk_check::worker_bindings::screaming_snake(class_name)
635}
636
637// ---------------------------------------------------------------------------
638// P7.4 (#1305): structural post-emission patches — closes R7.6 ("downstream
639// consumers couple to nodes, never to emitted text") and R8.20 ("deploy-time
640// placeholders are typed, not textual"). `bynk deploy`/`bynk dev --remote`
641// and `--emit js` both need to rewrite one field of an *already-emitted*
642// `wrangler.toml` after compilation — not build one from scratch (that's
643// `emit_wrangler_toml`/`TomlDocument`, P7.3's own construction-side tree).
644//
645// This is the read-an-existing-document side, and needs a different tool
646// than `TomlDocument`'s own from-scratch builder or a plain `toml::Table`
647// parse: both discard everything they don't model (`toml::Table` round-trips
648// through a `BTreeMap` with no comment/order trivia at all — a first attempt
649// using it here silently dropped the "Generated by bynkc" banner and every
650// inline comment, and re-sorted the whole document alphabetically, on every
651// patch). `toml_edit::DocumentMut` is format-preserving: parse, mutate the
652// one value that actually changed, re-serialise, and everything else survives
653// byte-for-byte. That's the real shape "immune to a reformat, scoped to the
654// actual field" needs — not just "found the right field," but "touched
655// nothing else."
656// ---------------------------------------------------------------------------
657
658/// Set `item`'s value to `s`, preserving whatever leading/trailing
659/// whitespace and comments already decorated it — e.g. the KV namespace
660/// id's own `# set at deploy time` note survives materialisation (stale
661/// once set, but that's the same text a caller would have seen under the
662/// old substring-replace behaviour too; not this slice's concern to change).
663fn set_string_preserving_decor(item: &mut toml_edit::Item, s: &str) {
664 let decor = item
665 .as_value()
666 .map(|v| v.decor().clone())
667 .unwrap_or_default();
668 let mut new_value = toml_edit::Value::from(s);
669 *new_value.decor_mut() = decor;
670 *item = toml_edit::Item::Value(new_value);
671}
672
673/// The deploy-time placeholder a `[[kv_namespaces]]` entry for `binding`
674/// carries until `bynk deploy` materialises it: [`KV_NAMESPACE_ID_PLACEHOLDER`]
675/// for the bare `KV`, [`kv_instance_id_placeholder`] for a derived instance
676/// (S4b, #1967).
677pub fn kv_id_placeholder(binding: &str) -> String {
678 if binding == bynk_check::firstparty::KV_BINDING_NAME {
679 KV_NAMESPACE_ID_PLACEHOLDER.to_string()
680 } else {
681 kv_instance_id_placeholder(binding)
682 }
683}
684
685/// S4b (#1967): the account-facing title stem of a derived instance's KV
686/// namespace, `<worker>-<capability, kebab>`: `shop-cart` and `KV_SESSIONS`
687/// give `shop-cart-sessions`, `KV_PRICE_CACHE` gives `shop-cart-price-cache`.
688/// Read off the binding, which is a function of the capability's name, so a
689/// renamed capability is a new title. `bynk deploy` environment-qualifies it.
690pub fn kv_instance_title(worker: &str, binding: &str) -> String {
691 let prefix = format!("{}_", bynk_check::firstparty::KV_BINDING_NAME);
692 let stem = binding.strip_prefix(&prefix).unwrap_or(binding);
693 format!("{worker}-{}", stem.to_ascii_lowercase().replace('_', "-"))
694}
695
696/// P5 (#2323): the account-facing name of an `R2` instance's bucket,
697/// `<worker>-<capability, kebab>`: `shop-cart` and `R2_ARCHIVES` give
698/// `shop-cart-archives`. R2 bucket names are scoped to the account (not the
699/// Worker), so the Worker's name carries the uniqueness. `bynk deploy`
700/// environment-qualifies it.
701pub fn r2_bucket_name(worker: &str, binding: &str) -> String {
702 let prefix = format!("{}_", bynk_check::firstparty::R2_BINDING_PREFIX);
703 let stem = binding.strip_prefix(&prefix).unwrap_or(binding);
704 format!("{worker}-{}", stem.to_ascii_lowercase().replace('_', "-"))
705}
706
707/// A generated `wrangler.toml`'s `[[r2_buckets]]` entries as `(binding,
708/// bucket_name, jurisdiction)`, in file order; an entry missing a binding or
709/// a name is skipped.
710pub fn r2_bucket_entries(text: &str) -> Result<Vec<(String, String, Option<String>)>, String> {
711 let doc: toml_edit::DocumentMut = text
712 .parse()
713 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
714 Ok(doc
715 .get("r2_buckets")
716 .and_then(|i| i.as_array_of_tables())
717 .into_iter()
718 .flatten()
719 .filter_map(|t| {
720 Some((
721 t.get("binding")?.as_str()?.to_string(),
722 t.get("bucket_name")?.as_str()?.to_string(),
723 t.get("jurisdiction")
724 .and_then(|j| j.as_str())
725 .map(str::to_string),
726 ))
727 })
728 .collect())
729}
730
731/// P5 (#2323): point `[[r2_buckets]]` entries at their deploy-time bucket
732/// names, structurally, by binding: each entry whose binding `names` lists gets
733/// that `bucket_name`. The generated name is the unqualified derived one
734/// ([`r2_bucket_name`]); an environment qualifies it. Unchanged text when
735/// nothing differs, as [`materialise_kv_namespace_ids`].
736pub fn materialise_r2_bucket_names(
737 text: &str,
738 names: &std::collections::BTreeMap<String, String>,
739) -> Result<String, String> {
740 let mut doc: toml_edit::DocumentMut = text
741 .parse()
742 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
743 let mut changed = false;
744 if let Some(entries) = doc
745 .get_mut("r2_buckets")
746 .and_then(|i| i.as_array_of_tables_mut())
747 {
748 for entry in entries.iter_mut() {
749 let Some(name) = entry
750 .get("binding")
751 .and_then(|b| b.as_str())
752 .and_then(|b| names.get(b))
753 .cloned()
754 else {
755 continue;
756 };
757 if let Some(item) = entry.get_mut("bucket_name")
758 && item.as_str() != Some(name.as_str())
759 {
760 set_string_preserving_decor(item, &name);
761 changed = true;
762 }
763 }
764 }
765 Ok(if changed {
766 doc.to_string()
767 } else {
768 text.to_string()
769 })
770}
771
772/// The bindings of a generated `wrangler.toml`'s `[[kv_namespaces]]` entries
773/// whose `id` is still that binding's deploy-time placeholder
774/// ([`kv_id_placeholder`]), in file order: what [`materialise_kv_namespace_ids`]
775/// would patch. Exposed so a caller can learn which ids it needs before doing
776/// the (possibly fallible) work of finding them, as `bynk dev --remote` does
777/// before it reads the ledger. Empty for a config with no KV, or one already
778/// materialised.
779pub fn kv_bindings_needing_ids(text: &str) -> Result<Vec<String>, String> {
780 let doc: toml_edit::DocumentMut = text
781 .parse()
782 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
783 Ok(kv_entries(&doc)
784 .filter(|(binding, id)| *id == kv_id_placeholder(binding))
785 .map(|(binding, _)| binding.to_string())
786 .collect())
787}
788
789/// Materialise deploy-time KV namespace ids in a generated `wrangler.toml`'s
790/// text, structurally, by binding (S4b, #1967). Each `[[kv_namespaces]]` entry
791/// whose binding has an id in `ids`, and whose `id` is currently *exactly* that
792/// binding's placeholder ([`kv_id_placeholder`]), gets the id; the decor around
793/// the value (the `# set at deploy time` note) is kept.
794///
795/// Any other state — no `[[kv_namespaces]]` section, an entry already
796/// materialised, a binding `ids` doesn't name — is left alone, and when nothing
797/// changes `text` is returned unchanged (byte-for-byte: the document is never
798/// re-serialised in that case, so there's nothing for a caller to mistake for
799/// a real write). It is not a general "overwrite with the given id" operation:
800/// re-provisioning is a different feature (#1305's Decision B).
801pub fn materialise_kv_namespace_ids(
802 text: &str,
803 ids: &std::collections::BTreeMap<String, String>,
804) -> Result<String, String> {
805 let mut doc: toml_edit::DocumentMut = text
806 .parse()
807 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
808 let mut changed = false;
809 if let Some(entries) = doc
810 .get_mut("kv_namespaces")
811 .and_then(|i| i.as_array_of_tables_mut())
812 {
813 for entry in entries.iter_mut() {
814 let Some(binding) = entry.get("binding").and_then(|b| b.as_str()) else {
815 continue;
816 };
817 let Some(id) = ids.get(binding) else {
818 continue;
819 };
820 let placeholder = kv_id_placeholder(binding);
821 if let Some(item) = entry.get_mut("id")
822 && item.as_str() == Some(placeholder.as_str())
823 {
824 set_string_preserving_decor(item, id);
825 changed = true;
826 }
827 }
828 }
829 Ok(if changed {
830 doc.to_string()
831 } else {
832 text.to_string()
833 })
834}
835
836/// P3 (#2342): the rate limiters whose `namespace_id` is still its
837/// placeholder, by `name`, in file order.
838pub fn ratelimits_needing_ids(text: &str) -> Result<Vec<String>, String> {
839 let doc: toml_edit::DocumentMut = text
840 .parse()
841 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
842 let mut out = Vec::new();
843 if let Some(entries) = doc.get("ratelimits").and_then(|i| i.as_array_of_tables()) {
844 for entry in entries.iter() {
845 let Some(name) = entry.get("name").and_then(|b| b.as_str()) else {
846 continue;
847 };
848 if entry.get("namespace_id").and_then(|i| i.as_str())
849 == Some(ratelimit_namespace_placeholder(name).as_str())
850 {
851 out.push(name.to_string());
852 }
853 }
854 }
855 Ok(out)
856}
857
858/// P3 (#2342): write each rate limiter's allocated `namespace_id` over its
859/// placeholder, structurally, by `name`, leaving its `simple = { … }` and
860/// comment as they are. Unchanged text when nothing matches, as
861/// [`materialise_kv_namespace_ids`].
862pub fn materialise_ratelimit_namespace_ids(
863 text: &str,
864 ids: &std::collections::BTreeMap<String, String>,
865) -> Result<String, String> {
866 let mut doc: toml_edit::DocumentMut = text
867 .parse()
868 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
869 let mut changed = false;
870 if let Some(entries) = doc
871 .get_mut("ratelimits")
872 .and_then(|i| i.as_array_of_tables_mut())
873 {
874 for entry in entries.iter_mut() {
875 let Some(name) = entry.get("name").and_then(|b| b.as_str()) else {
876 continue;
877 };
878 let Some(id) = ids.get(name) else {
879 continue;
880 };
881 let placeholder = ratelimit_namespace_placeholder(name);
882 if let Some(item) = entry.get_mut("namespace_id")
883 && item.as_str() == Some(placeholder.as_str())
884 {
885 set_string_preserving_decor(item, id);
886 changed = true;
887 }
888 }
889 }
890 Ok(if changed {
891 doc.to_string()
892 } else {
893 text.to_string()
894 })
895}
896
897/// Interop I5 (#1972): point Service Bindings at other Workers, structurally,
898/// by binding: each `[[services]]` entry whose binding `targets` names gets
899/// that `service`. `bynk dev` uses it to bind a foreign Worker to its local
900/// stand-in, which runs under a name of its own so that it never answers for
901/// the real Worker in wrangler's shared dev registry. Unchanged text when
902/// nothing matches, as [`materialise_kv_namespace_ids`].
903pub fn retarget_services(
904 text: &str,
905 targets: &std::collections::BTreeMap<String, String>,
906) -> Result<String, String> {
907 let mut doc: toml_edit::DocumentMut = text
908 .parse()
909 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
910 let mut changed = false;
911 if let Some(entries) = doc
912 .get_mut("services")
913 .and_then(|i| i.as_array_of_tables_mut())
914 {
915 for entry in entries.iter_mut() {
916 let Some(target) = entry
917 .get("binding")
918 .and_then(|b| b.as_str())
919 .and_then(|b| targets.get(b))
920 .cloned()
921 else {
922 continue;
923 };
924 if let Some(item) = entry.get_mut("service")
925 && item.as_str() != Some(target.as_str())
926 {
927 set_string_preserving_decor(item, &target);
928 changed = true;
929 }
930 }
931 }
932 Ok(if changed {
933 doc.to_string()
934 } else {
935 text.to_string()
936 })
937}
938
939/// Each `[[kv_namespaces]]` entry's `(binding, id)`, in file order; an entry
940/// missing either is skipped.
941fn kv_entries(doc: &toml_edit::DocumentMut) -> impl Iterator<Item = (&str, &str)> {
942 doc.get("kv_namespaces")
943 .and_then(|i| i.as_array_of_tables())
944 .into_iter()
945 .flatten()
946 .filter_map(|t| Some((t.get("binding")?.as_str()?, t.get("id")?.as_str()?)))
947}
948
949#[cfg(test)]
950mod patch_tests {
951 use super::*;
952
953 /// A full, realistic `wrangler.toml` — every stanza `emit_wrangler_toml`
954 /// can produce, in its real order, with its real banner and inline
955 /// comment (mirrors `bynkc/tests/fixtures/positive/
956 /// 372_kv_agent_queue_workers/expected/workers/ops-hub/wrangler.toml`
957 /// byte-for-byte). Review of #1305, finding 2: every test below asserts
958 /// **exact** patched text against this fixture, not just "the changed
959 /// field has the right value after a re-parse" — a re-parse is blind by
960 /// construction to whatever the printer discarded, which is exactly how
961 /// finding 1 (the whole-document alphabetical re-sort, dropped banner)
962 /// survived a green suite the first time around.
963 const REPRESENTATIVE: &str = "\
964# Generated by bynkc — do not edit by hand.
965name = \"ops-hub\"
966main = \"index.ts\"
967compatibility_date = \"2026-07-01\"
968compatibility_flags = [\"global_fetch_strictly_public\"]
969
970[[services]]
971binding = \"PAYMENT\"
972service = \"payment\"
973
974[[kv_namespaces]]
975binding = \"KV\"
976id = \"<KV_NAMESPACE_ID>\" # set at deploy time
977
978[[durable_objects.bindings]]
979name = \"JOB_LEDGER\"
980class_name = \"JobLedger\"
981
982[exports.JobLedger]
983type = \"durable-object\"
984storage = \"sqlite\"
985
986[[queues.consumers]]
987queue = \"job-intake\"
988max_batch_size = 10
989";
990
991 fn ids(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
992 pairs
993 .iter()
994 .map(|(b, i)| (b.to_string(), i.to_string()))
995 .collect()
996 }
997
998 /// A Worker with the bare namespace and two derived instances (S4b,
999 /// #1967), as the emitter writes it (fixture `1966_derived_kv_workers`
1000 /// plus the bare `KV`).
1001 const INSTANCES: &str = "\
1002[[kv_namespaces]]
1003binding = \"KV\"
1004id = \"<KV_NAMESPACE_ID>\" # set at deploy time
1005
1006[[kv_namespaces]]
1007binding = \"KV_PRICE_CACHE\"
1008id = \"<KV_PRICE_CACHE_ID>\" # set at deploy time
1009
1010[[kv_namespaces]]
1011binding = \"KV_SESSIONS\"
1012id = \"<KV_SESSIONS_ID>\" # set at deploy time
1013";
1014
1015 /// Interop I5 (#1972): only the named bindings move; the rest of the
1016 /// file, comments included, is untouched, and a no-op is byte-identical.
1017 #[test]
1018 fn retarget_services_moves_only_the_named_bindings() {
1019 let text = "name = \"shop-billing\"\n\n[[services]]\nbinding = \"SHOP_FRONT\"\nservice = \"shop-front\"\n\n[[services]]\nbinding = \"SERVICE_BILLING\"\nservice = \"legacy-billing\" # foreign\n";
1020 let targets = std::collections::BTreeMap::from([(
1021 "SERVICE_BILLING".to_string(),
1022 "bynk-stand-in-shop-billing-legacy-billing".to_string(),
1023 )]);
1024 let out = retarget_services(text, &targets).unwrap();
1025 assert_eq!(
1026 out,
1027 text.replace(
1028 "service = \"legacy-billing\"",
1029 "service = \"bynk-stand-in-shop-billing-legacy-billing\""
1030 )
1031 );
1032 assert_eq!(retarget_services(&out, &targets).unwrap(), out);
1033 assert_eq!(
1034 retarget_services(text, &std::collections::BTreeMap::new()).unwrap(),
1035 text
1036 );
1037 assert!(retarget_services("not [ toml", &targets).is_err());
1038 }
1039
1040 #[test]
1041 fn materialise_kv_namespace_ids_changes_only_the_bare_id() {
1042 let expected = REPRESENTATIVE.replacen("<KV_NAMESPACE_ID>", "abc123", 1);
1043 assert_eq!(
1044 materialise_kv_namespace_ids(REPRESENTATIVE, &ids(&[("KV", "abc123")])).unwrap(),
1045 expected
1046 );
1047 // The `# set at deploy time` note survives — the old text-replace
1048 // behaviour left it in place too (it only replaced the placeholder
1049 // substring, not the whole line), and this slice isn't the one
1050 // deciding whether that note should still be there post-materialisation.
1051 assert!(expected.contains("id = \"abc123\" # set at deploy time"));
1052 }
1053
1054 /// S4b (#1967): each instance's placeholder gets its own id, and only its
1055 /// own: an id for one binding never lands in another's entry.
1056 #[test]
1057 fn materialise_kv_namespace_ids_patches_each_binding_apart() {
1058 let all = materialise_kv_namespace_ids(
1059 INSTANCES,
1060 &ids(&[
1061 ("KV", "bare"),
1062 ("KV_SESSIONS", "sess"),
1063 ("KV_PRICE_CACHE", "price"),
1064 ]),
1065 )
1066 .unwrap();
1067 assert_eq!(
1068 all,
1069 INSTANCES
1070 .replacen("<KV_NAMESPACE_ID>", "bare", 1)
1071 .replacen("<KV_PRICE_CACHE_ID>", "price", 1)
1072 .replacen("<KV_SESSIONS_ID>", "sess", 1)
1073 );
1074 let one =
1075 materialise_kv_namespace_ids(INSTANCES, &ids(&[("KV_SESSIONS", "sess")])).unwrap();
1076 assert_eq!(one, INSTANCES.replacen("<KV_SESSIONS_ID>", "sess", 1));
1077 assert_eq!(
1078 kv_bindings_needing_ids(&one).unwrap(),
1079 ["KV", "KV_PRICE_CACHE"]
1080 );
1081 }
1082
1083 /// A placeholder belongs to its binding: the bare placeholder under an
1084 /// instance's binding is not that instance's to patch.
1085 #[test]
1086 fn materialise_kv_namespace_ids_matches_each_bindings_own_placeholder() {
1087 let text = "[[kv_namespaces]]\nbinding = \"KV_SESSIONS\"\nid = \"<KV_NAMESPACE_ID>\"\n";
1088 assert_eq!(
1089 materialise_kv_namespace_ids(text, &ids(&[("KV_SESSIONS", "sess")])).unwrap(),
1090 text
1091 );
1092 assert!(kv_bindings_needing_ids(text).unwrap().is_empty());
1093 }
1094
1095 #[test]
1096 fn materialise_kv_namespace_ids_is_immune_to_reformatting() {
1097 let text = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"<KV_NAMESPACE_ID>\" # set at deploy time\n";
1098 let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1099 assert_eq!(
1100 patched,
1101 "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"abc123\" # set at deploy time\n"
1102 );
1103 }
1104
1105 #[test]
1106 fn materialise_kv_namespace_ids_is_a_no_op_without_a_kv_namespaces_section() {
1107 let text = "name = \"api\"\nmain = \"index.ts\"\n";
1108 let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1109 assert_eq!(patched, text);
1110 }
1111
1112 #[test]
1113 fn materialise_kv_namespace_ids_is_a_no_op_once_already_materialised() {
1114 let text = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"already-real\"\n";
1115 let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1116 assert_eq!(patched, text);
1117 }
1118
1119 #[test]
1120 fn kv_bindings_needing_ids_matches_materialise_kv_namespace_ids_own_gate() {
1121 assert_eq!(
1122 kv_bindings_needing_ids(INSTANCES).unwrap(),
1123 ["KV", "KV_PRICE_CACHE", "KV_SESSIONS"]
1124 );
1125 assert!(
1126 kv_bindings_needing_ids("name = \"api\"\n")
1127 .unwrap()
1128 .is_empty()
1129 );
1130 let already_real = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"already-real\"\n";
1131 assert!(kv_bindings_needing_ids(already_real).unwrap().is_empty());
1132 }
1133
1134 #[test]
1135 fn kv_instance_title_is_the_worker_then_the_capability_in_kebab_case() {
1136 assert_eq!(
1137 kv_instance_title("shop-cart", "KV_SESSIONS"),
1138 "shop-cart-sessions"
1139 );
1140 assert_eq!(
1141 kv_instance_title("shop-cart", "KV_PRICE_CACHE"),
1142 "shop-cart-price-cache"
1143 );
1144 }
1145
1146 #[test]
1147 fn kv_bindings_needing_ids_rejects_invalid_toml() {
1148 assert!(kv_bindings_needing_ids("not = valid = toml = at = all").is_err());
1149 }
1150
1151 #[test]
1152 fn materialise_kv_namespace_ids_rejects_invalid_toml() {
1153 assert!(
1154 materialise_kv_namespace_ids("not = valid = toml = at = all", &ids(&[("KV", "x")]))
1155 .is_err()
1156 );
1157 }
1158}
1159
1160#[cfg(test)]
1161mod compatibility_flags_tests {
1162 use super::*;
1163
1164 fn strings(names: &[&str]) -> Vec<String> {
1165 names.iter().map(|s| s.to_string()).collect()
1166 }
1167
1168 /// #1890: the default set alone, when the manifest adds nothing.
1169 #[test]
1170 fn the_default_is_global_fetch_strictly_public() {
1171 assert_eq!(
1172 DEFAULT_COMPATIBILITY_FLAGS,
1173 ["global_fetch_strictly_public"]
1174 );
1175 let (flags, duplicates) = compatibility_flags(&[]);
1176 assert_eq!(flags, ["global_fetch_strictly_public"]);
1177 assert!(duplicates.is_empty());
1178 }
1179
1180 /// #1890: the manifest's flags follow the default, in manifest order;
1181 /// names are not checked.
1182 #[test]
1183 fn extra_flags_follow_the_default_in_order() {
1184 let (flags, duplicates) =
1185 compatibility_flags(&strings(&["nodejs_compat", "a_future_flag"]));
1186 assert_eq!(
1187 flags,
1188 [
1189 "global_fetch_strictly_public",
1190 "nodejs_compat",
1191 "a_future_flag"
1192 ]
1193 );
1194 assert!(duplicates.is_empty());
1195 }
1196
1197 /// #1890: a repeat, of a default or of an earlier extra, is dropped and
1198 /// reported once per repeat.
1199 #[test]
1200 fn duplicates_are_dropped_and_reported() {
1201 let (flags, duplicates) = compatibility_flags(&strings(&[
1202 "nodejs_compat",
1203 "global_fetch_strictly_public",
1204 "nodejs_compat",
1205 "nodejs_compat",
1206 ]));
1207 assert_eq!(flags, ["global_fetch_strictly_public", "nodejs_compat"]);
1208 assert_eq!(
1209 duplicates,
1210 [
1211 "global_fetch_strictly_public",
1212 "nodejs_compat",
1213 "nodejs_compat"
1214 ]
1215 );
1216 }
1217}
1218
1219#[cfg(test)]
1220mod binding_tests {
1221 use super::*;
1222 use bynk_check::worker_bindings::duplicate_bindings;
1223
1224 fn names(bindings: &[WorkerBinding]) -> Vec<&str> {
1225 bindings.iter().map(|b| b.name.as_str()).collect()
1226 }
1227
1228 /// Emission order: Service Bindings by target, the KV namespace, then
1229 /// the Durable Objects by class name, the fan-out among them.
1230 #[test]
1231 fn worker_bindings_lists_every_kind_in_emission_order() {
1232 let bindings = worker_bindings(
1233 &[],
1234 &["shop.payment".to_string(), "audit".to_string()],
1235 true,
1236 &[],
1237 true,
1238 );
1239 assert_eq!(
1240 names(&bindings),
1241 ["AUDIT", "SHOP_PAYMENT", "KV", "___EVENTS_FANOUT"]
1242 );
1243 assert!(duplicate_bindings(&bindings).is_empty());
1244 }
1245
1246 /// #1950: the handler projection is drawn from the Worker's own list —
1247 /// each binding identical, name and source, a Service Binding and an
1248 /// agent's alike — and leaves out the KV namespace and the events fan-out.
1249 #[test]
1250 fn handler_bindings_are_a_subset_of_the_worker_bindings() {
1251 let worker = worker_bindings(
1252 &["OrderEntity".to_string()],
1253 &["shop.payment".to_string(), "audit".to_string()],
1254 true,
1255 &[],
1256 true,
1257 );
1258 let handler = handler_bindings(&["shop.payment".to_string()], &["OrderEntity".to_string()]);
1259 assert_eq!(names(&handler), ["SHOP_PAYMENT", "ORDER_ENTITY"]);
1260 for binding in &handler {
1261 assert!(worker.contains(binding), "{binding:?} not in {worker:?}");
1262 }
1263 }
1264
1265 /// A consumed context `kv` and the KV namespace are both `KV`: the
1266 /// later one is paired with the earlier.
1267 #[test]
1268 fn a_shared_name_pairs_the_later_binding_with_the_earlier() {
1269 let bindings = worker_bindings(&[], &["kv".to_string()], true, &[], false);
1270 let duplicates = duplicate_bindings(&bindings);
1271 assert_eq!(duplicates.len(), 1);
1272 let (earlier, later) = duplicates[0];
1273 assert_eq!(
1274 earlier.source,
1275 BindingSource::Service {
1276 context: "kv".to_string()
1277 }
1278 );
1279 assert_eq!(later.source, BindingSource::Kv);
1280 }
1281}