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