Skip to main content

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}