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::{UnitTable, worker_dir_name};
9
10/// Compile-time pinned compatibility date. Cloudflare uses this to lock
11/// Workers runtime behaviour. When to bump it, and the gate a bump must pass,
12/// is the policy in `design/bynk-release-discipline.md` ("Part 3 — The Workers
13/// compatibility date", #1677): reviewed at each release, and moved only after
14/// the workerd smokes pass on the new date.
15pub const COMPATIBILITY_DATE: &str = "2026-07-01";
16
17/// #1732: the oldest wrangler whose bundled `workerd` serves
18/// [`COMPATIBILITY_DATE`]. An older `workerd` refuses a newer date outright
19/// (`wrangler dev` exits: "This Worker requires compatibility date …"), so
20/// `bynk doctor` warns about a wrangler below this. Set by the same
21/// compatibility-date review that sets the date (Part 3's lag rule keeps it at
22/// least three months old). `bynk-emit/tests/compat_date_policy.rs` checks the
23/// policy doc names both.
24pub const WRANGLER_MIN: &str = "4.107.0";
25
26/// Events track, slice 0 (spine #936, ADR 0284): the class name of a
27/// publishing context's fan-out Durable Object (`emitter::events_fanout`).
28/// Shared with `emitter::workers` (the `Env` field name + `deps.
29/// __eventsDispatch` call it drives) and `emitter::events_fanout` (the class
30/// this name must actually export) so the three can never drift apart.
31///
32/// Double-underscore-prefixed, matching every other compiler-synthesised
33/// identifier in emitted output (`__events`, `__eventsDispatch`,
34/// `__makeLedger`, …) — a Bynk `agent` name can never start with `_` (a
35/// parse error, checked directly: `agent _Foo { … }` fails with
36/// `expected identifier after \`agent\`, found \`_\``), so an agent
37/// coincidentally named the same as this synthetic class is structurally
38/// impossible, not merely unlikely.
39pub(crate) const EVENTS_FANOUT_CLASS_NAME: &str = "__EventsFanout";
40
41/// Deploy-time sentinel in generated Worker configuration. The driver replaces
42/// this with the persistent Cloudflare KV namespace id immediately before a
43/// remote Wrangler command runs.
44pub const KV_NAMESPACE_ID_PLACEHOLDER: &str = "<KV_NAMESPACE_ID>";
45
46/// P6.x cutover slice 2 (#1191, narrowed from #1187): `crons`/`queues` arrive
47/// pre-collected, sorted and deduped by the caller (`project.rs`'s own
48/// `emit_wrangler_toml` call site) rather than being walked here off
49/// `table.services`. Both used to match a handler's cron-schedule kind and a
50/// service's queue-binding protocol directly, straight off the syntax tree —
51/// this file's entire raw-syntax footprint, per #1191's own grounding.
52/// `table` already only needs `agents`/`unit_table_uses_emit` (neither
53/// syntax-typed from this file's perspective), so relocating just these two
54/// matches removes `wrangler.rs` from the `ast_importers` probe outright — no
55/// `bynk_ir` equivalent exists to route through instead: nothing builds a
56/// project-wide service IR at the call site (#1191's Framing), and after
57/// #1542's Slice D2 nothing builds one anywhere.
58///
59/// P7.3 (#1303): builds a [`TomlDocument`] rather than writing text directly
60/// — the caller (`project.rs`) prints it via `emitter::toml_doc::
61/// print_toml_document`. What gets built, in what order, under what gates,
62/// is unchanged; only the construction shape moved from direct `writeln!`
63/// into pushing typed blocks. String-escaping moved with it: every value
64/// below is escaped unconditionally by the printer now, not selectively by
65/// this function (`emitter/toml_doc.rs`'s own module doc has the reasoning).
66pub(crate) fn emit_wrangler_toml(
67 context: &str,
68 table: &UnitTable,
69 consumes: &[String],
70 // v0.19 (C1): this Worker's closure reaches bynk.cloudflare — declare the
71 // KV namespace binding (the `id` is a deploy-time placeholder).
72 needs_kv: bool,
73 // v0.10a: every `on cron "expr"` schedule in the context, sorted+deduped.
74 // Sorting is load-bearing, not cosmetic (review of #1192): the caller
75 // walks `table.services`, a `HashMap`, so unsorted input makes
76 // `wrangler.toml` non-reproducible across runs.
77 crons: &[String],
78 // v0.10b/v0.44: every `from queue("name")` service's bound queue name,
79 // sorted+deduped (same reproducibility requirement as `crons`).
80 queues: &[String],
81 // #1187's slice 6 plumbing: `unit_table_uses_emit(table, callees)`,
82 // precomputed by the caller — passing a bare `bool` rather than the
83 // `Callee` map itself keeps this file's own hard-won zero `bynk_syntax::
84 // ast` footprint (#1191) intact; the map's own element type would have
85 // reintroduced exactly the literal spelling that slice removed.
86 uses_emit: bool,
87) -> TomlDocument {
88 let name = worker_dir_name(context);
89 let mut doc = TomlDocument::new(
90 "Generated by bynkc — do not edit by hand.",
91 vec![
92 TomlEntry::kv("name", TomlValue::str(name)),
93 TomlEntry::kv("main", TomlValue::str("index.ts")),
94 TomlEntry::kv("compatibility_date", TomlValue::str(COMPATIBILITY_DATE)),
95 ],
96 );
97
98 let mut sorted_consumes: Vec<&String> = consumes.iter().collect();
99 sorted_consumes.sort();
100 for target in &sorted_consumes {
101 let binding = consumed_binding_name(target);
102 let service = worker_dir_name(target);
103 doc.push_block(TomlBlock::array_table(
104 "services",
105 vec![
106 TomlEntry::kv("binding", TomlValue::str(binding)),
107 TomlEntry::kv("service", TomlValue::str(service)),
108 ],
109 ));
110 }
111
112 if needs_kv {
113 doc.push_block(TomlBlock::array_table(
114 "kv_namespaces",
115 vec![
116 TomlEntry::kv(
117 "binding",
118 TomlValue::str(bynk_check::firstparty::KV_BINDING_NAME),
119 ),
120 TomlEntry::with_comment(
121 "id",
122 TomlValue::str(KV_NAMESPACE_ID_PLACEHOLDER),
123 "set at deploy time",
124 ),
125 ],
126 ));
127 }
128
129 // Agents → Durable Object bindings + exports. Events track, slice 0
130 // (spine #936, ADR 0284): a context whose handlers emit gets its own
131 // fan-out DO folded into the same bindings/exports blocks — Cloudflare
132 // only cares that `index.ts` (this Worker's `main`) exports a class with
133 // this name, not which generated file it came from.
134 let mut class_names: Vec<String> = table.agents.keys().cloned().collect();
135 if uses_emit {
136 class_names.push(EVENTS_FANOUT_CLASS_NAME.to_string());
137 }
138 class_names.sort();
139 for class_name in &class_names {
140 let binding = agent_binding_name(class_name);
141 doc.push_block(TomlBlock::array_table(
142 "durable_objects.bindings",
143 vec![
144 TomlEntry::kv("name", TomlValue::str(binding)),
145 TomlEntry::kv("class_name", TomlValue::str(class_name.clone())),
146 ],
147 ));
148 }
149 // #1796: each class is declared in Cloudflare's declarative `exports`
150 // map, not a `[[migrations]]` list. Migrations are picked by tag: Wrangler
151 // uploads only the ones after the Worker's applied tag, so the single
152 // fixed `v1` this used to emit registered the classes of the *first*
153 // deploy and no class added after it (a new agent, or the fan-out class
154 // arriving with a context's first `emit`). `exports` has no tag. On every
155 // deploy Cloudflare compares the declared set with the Worker's
156 // namespaces and creates what's missing, so the config stays a pure
157 // function of the source and the ledger still records nothing (ADR 0194
158 // D1's principle). Wrangler reads `exports` from 4.107.0, which is
159 // [`WRANGLER_MIN`], and `wrangler dev` takes each class's backend from
160 // it too.
161 //
162 // Every class is SQLite-backed (#1779, ADR 0438): the emitted agent uses
163 // only `storage.get`/`storage.put` on its single `"state"` key, which a
164 // SQLite-backed class supports unchanged, and the fan-out class uses no
165 // storage at all. A Worker whose classes an older Bynk created
166 // key-value-backed (`new_classes`, ≤ 0.309.10) is refused
167 // (`storage_type_mismatch`, per Cloudflare's `exports` docs), because
168 // Cloudflare can't change a backend in place. That is a documented
169 // pre-1.0 break: such a Worker is torn down and redeployed. No
170 // `deleted`/`renamed` tombstone is ever emitted — destroying or moving a
171 // class's data is #539's decision. Per Cloudflare's docs, removing an
172 // agent then fails the deploy (`orphaned_provisioned_namespace`), which
173 // is the loud outcome wanted; that is not yet confirmed against a live
174 // account (#1796).
175 for class_name in &class_names {
176 doc.push_block(TomlBlock::keyed_table(
177 "exports",
178 class_name.clone(),
179 vec![
180 TomlEntry::kv("type", TomlValue::str("durable-object")),
181 TomlEntry::kv("storage", TomlValue::str("sqlite")),
182 ],
183 ));
184 }
185
186 // v0.10a: cron triggers. Cloudflare uses a single `[triggers]` table with a
187 // `crons` array aggregating every `on cron` schedule in the context.
188 // Already sorted+deduped by the caller (#1191).
189 if !crons.is_empty() {
190 let quoted = crons.iter().map(TomlValue::str).collect();
191 doc.push_block(TomlBlock::table(
192 "triggers",
193 vec![TomlEntry::kv("crons", TomlValue::Array(quoted))],
194 ));
195 }
196
197 // v0.10b: queue consumers. Each `on queue "name"` becomes a
198 // `[[queues.consumers]]` binding. Already sorted+deduped by the caller
199 // (#1191).
200 for name in queues {
201 doc.push_block(TomlBlock::array_table(
202 "queues.consumers",
203 vec![
204 TomlEntry::kv("queue", TomlValue::str(name.clone())),
205 TomlEntry::kv("max_batch_size", TomlValue::Int(10)),
206 ],
207 ));
208 }
209
210 doc
211}
212
213/// Service Binding identifier for a consumed context: uppercase with
214/// underscores. `commerce.payment` → `COMMERCE_PAYMENT`.
215pub(crate) fn consumed_binding_name(target: &str) -> String {
216 target.replace('.', "_").to_uppercase()
217}
218
219/// Durable Object binding identifier for an agent class. We use the
220/// class name in screaming snake case so handlers can grab it by a
221/// predictable name (`OrderEntity` → `ORDER_ENTITY`).
222pub(crate) fn agent_binding_name(class_name: &str) -> String {
223 let mut out = String::new();
224 for (i, ch) in class_name.chars().enumerate() {
225 if i > 0 && ch.is_uppercase() {
226 out.push('_');
227 }
228 out.push(ch.to_ascii_uppercase());
229 }
230 out
231}
232
233// ---------------------------------------------------------------------------
234// P7.4 (#1305): structural post-emission patches — closes R7.6 ("downstream
235// consumers couple to nodes, never to emitted text") and R8.20 ("deploy-time
236// placeholders are typed, not textual"). `bynk deploy`/`bynk dev --remote`
237// and `--emit js` both need to rewrite one field of an *already-emitted*
238// `wrangler.toml` after compilation — not build one from scratch (that's
239// `emit_wrangler_toml`/`TomlDocument`, P7.3's own construction-side tree).
240//
241// This is the read-an-existing-document side, and needs a different tool
242// than `TomlDocument`'s own from-scratch builder or a plain `toml::Table`
243// parse: both discard everything they don't model (`toml::Table` round-trips
244// through a `BTreeMap` with no comment/order trivia at all — a first attempt
245// using it here silently dropped the "Generated by bynkc" banner and every
246// inline comment, and re-sorted the whole document alphabetically, on every
247// patch). `toml_edit::DocumentMut` is format-preserving: parse, mutate the
248// one value that actually changed, re-serialise, and everything else survives
249// byte-for-byte. That's the real shape "immune to a reformat, scoped to the
250// actual field" needs — not just "found the right field," but "touched
251// nothing else."
252// ---------------------------------------------------------------------------
253
254/// Set `item`'s value to `s`, preserving whatever leading/trailing
255/// whitespace and comments already decorated it — e.g. the KV namespace
256/// id's own `# set at deploy time` note survives materialisation (stale
257/// once set, but that's the same text a caller would have seen under the
258/// old substring-replace behaviour too; not this slice's concern to change).
259fn set_string_preserving_decor(item: &mut toml_edit::Item, s: &str) {
260 let decor = item
261 .as_value()
262 .map(|v| v.decor().clone())
263 .unwrap_or_default();
264 let mut new_value = toml_edit::Value::from(s);
265 *new_value.decor_mut() = decor;
266 *item = toml_edit::Item::Value(new_value);
267}
268
269/// Whether a generated `wrangler.toml`'s KV namespace id is still the
270/// deploy-time placeholder — the same condition [`materialise_kv_namespace_id`]
271/// itself checks internally before writing, exposed separately so a caller
272/// can decide whether materialising is needed at all *before* doing the
273/// (possibly fallible) work of finding the real id to substitute — e.g.
274/// `bynk dev --remote` skipping a lock-file lookup entirely for a project
275/// that either has no KV binding or is already materialised.
276pub fn wrangler_needs_kv_materialisation(text: &str) -> Result<bool, String> {
277 let doc: toml_edit::DocumentMut = text
278 .parse()
279 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
280 Ok(current_kv_namespace_id(&doc) == Some(KV_NAMESPACE_ID_PLACEHOLDER))
281}
282
283/// Materialise the deploy-time KV namespace id placeholder in a generated
284/// `wrangler.toml`'s text, structurally. Parses `text` and, if (and only
285/// if) the first `[[kv_namespaces]]` entry's `id` is currently *exactly*
286/// [`KV_NAMESPACE_ID_PLACEHOLDER`], replaces it with `id`.
287///
288/// Any other state — no `[[kv_namespaces]]` section at all, or an `id`
289/// that's already been materialised to a real value — is a **no-op**,
290/// returning `text` unchanged (byte-for-byte: the document is never
291/// re-serialised in this branch, so there's nothing for a caller to
292/// mistakenly treat as a real write). This mirrors the exact gate the old
293/// `text.contains(KV_NAMESPACE_ID_PLACEHOLDER)` check enforced; it is not a
294/// general "always overwrite with the given id" operation. Widening it into
295/// a re-provisioning operation is a different feature, out of scope here
296/// (#1305's own Decision B).
297pub fn materialise_kv_namespace_id(text: &str, id: &str) -> Result<String, String> {
298 let mut doc: toml_edit::DocumentMut = text
299 .parse()
300 .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
301 if current_kv_namespace_id(&doc) != Some(KV_NAMESPACE_ID_PLACEHOLDER) {
302 return Ok(text.to_string());
303 }
304 let item = doc
305 .get_mut("kv_namespaces")
306 .and_then(|i| i.as_array_of_tables_mut())
307 .and_then(|arr| arr.get_mut(0))
308 .and_then(|t| t.get_mut("id"))
309 .expect("current_kv_namespace_id returned Some, so this navigation cannot fail");
310 set_string_preserving_decor(item, id);
311 Ok(doc.to_string())
312}
313
314/// The first `[[kv_namespaces]]` entry's `id`, if the stanza exists at all.
315fn current_kv_namespace_id(doc: &toml_edit::DocumentMut) -> Option<&str> {
316 doc.get("kv_namespaces")?
317 .as_array_of_tables()?
318 .get(0)?
319 .get("id")?
320 .as_str()
321}
322
323#[cfg(test)]
324mod patch_tests {
325 use super::*;
326
327 /// A full, realistic `wrangler.toml` — every stanza `emit_wrangler_toml`
328 /// can produce, in its real order, with its real banner and inline
329 /// comment (mirrors `bynkc/tests/fixtures/positive/
330 /// 372_kv_agent_queue_workers/expected/workers/ops-hub/wrangler.toml`
331 /// byte-for-byte). Review of #1305, finding 2: every test below asserts
332 /// **exact** patched text against this fixture, not just "the changed
333 /// field has the right value after a re-parse" — a re-parse is blind by
334 /// construction to whatever the printer discarded, which is exactly how
335 /// finding 1 (the whole-document alphabetical re-sort, dropped banner)
336 /// survived a green suite the first time around.
337 const REPRESENTATIVE: &str = "\
338# Generated by bynkc — do not edit by hand.
339name = \"ops-hub\"
340main = \"index.ts\"
341compatibility_date = \"2026-07-01\"
342
343[[services]]
344binding = \"PAYMENT\"
345service = \"payment\"
346
347[[kv_namespaces]]
348binding = \"BYNK_KV\"
349id = \"<KV_NAMESPACE_ID>\" # set at deploy time
350
351[[durable_objects.bindings]]
352name = \"JOB_LEDGER\"
353class_name = \"JobLedger\"
354
355[exports.JobLedger]
356type = \"durable-object\"
357storage = \"sqlite\"
358
359[[queues.consumers]]
360queue = \"job-intake\"
361max_batch_size = 10
362";
363
364 #[test]
365 fn materialise_kv_namespace_id_changes_only_the_id() {
366 let expected = REPRESENTATIVE.replacen("<KV_NAMESPACE_ID>", "abc123", 1);
367 assert_eq!(
368 materialise_kv_namespace_id(REPRESENTATIVE, "abc123").unwrap(),
369 expected
370 );
371 // The `# set at deploy time` note survives — the old text-replace
372 // behaviour left it in place too (it only replaced the placeholder
373 // substring, not the whole line), and this slice isn't the one
374 // deciding whether that note should still be there post-materialisation.
375 assert!(expected.contains("id = \"abc123\" # set at deploy time"));
376 }
377
378 #[test]
379 fn materialise_kv_namespace_id_is_immune_to_reformatting() {
380 let text = "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"<KV_NAMESPACE_ID>\" # set at deploy time\n";
381 let patched = materialise_kv_namespace_id(text, "abc123").unwrap();
382 assert_eq!(
383 patched,
384 "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"abc123\" # set at deploy time\n"
385 );
386 }
387
388 #[test]
389 fn materialise_kv_namespace_id_is_a_no_op_without_a_kv_namespaces_section() {
390 let text = "name = \"api\"\nmain = \"index.ts\"\n";
391 let patched = materialise_kv_namespace_id(text, "abc123").unwrap();
392 assert_eq!(patched, text);
393 }
394
395 #[test]
396 fn materialise_kv_namespace_id_is_a_no_op_once_already_materialised() {
397 let text = "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"already-real\"\n";
398 let patched = materialise_kv_namespace_id(text, "abc123").unwrap();
399 assert_eq!(patched, text);
400 }
401
402 #[test]
403 fn wrangler_needs_kv_materialisation_matches_materialise_kv_namespace_ids_own_gate() {
404 let placeholder = "[[kv_namespaces]]\nid = \"<KV_NAMESPACE_ID>\"\n";
405 assert!(wrangler_needs_kv_materialisation(placeholder).unwrap());
406
407 let no_kv = "name = \"api\"\n";
408 assert!(!wrangler_needs_kv_materialisation(no_kv).unwrap());
409
410 let already_real = "[[kv_namespaces]]\nid = \"already-real\"\n";
411 assert!(!wrangler_needs_kv_materialisation(already_real).unwrap());
412 }
413
414 #[test]
415 fn wrangler_needs_kv_materialisation_rejects_invalid_toml() {
416 assert!(wrangler_needs_kv_materialisation("not = valid = toml = at = all").is_err());
417 }
418
419 #[test]
420 fn materialise_kv_namespace_id_rejects_invalid_toml() {
421 assert!(materialise_kv_namespace_id("not = valid = toml = at = all", "abc123").is_err());
422 }
423}