@zerotal/arch 1.11.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/changelog.md CHANGED
@@ -27,6 +27,79 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.11.1 — 2026-08-31
31
+
32
+ Two things the framework could not do, both reported by teams who had already
33
+ worked around them.
34
+
35
+ A patch, not a minor: nothing here breaks. Under
36
+ [the versioning scheme](/docs/upgrade#versioning) a minor is reserved for a
37
+ breaking change and a patch carries everything else, features included — so this
38
+ is safe to take from any 1.11.x.
39
+
40
+ ### Added
41
+
42
+ - **`zt version`** — which Zerotal, which Bun, which app.
43
+
44
+ ```
45
+ Zerotal 1.11.1
46
+ Bun 1.3.14
47
+ App my-app 0.1.0
48
+ ```
49
+
50
+ It was an unknown command, so the version had to be dug out of `package.json` or
51
+ `node_modules` — both of which report what is _installed_ rather than what is
52
+ _running_, and those differ for any process that has been up since before an
53
+ upgrade. It reports the running one.
54
+
55
+ `--version` and `-v` answer earlier still, ahead of the runtime check, the config
56
+ load and the app import, because those are the things someone is asking the version
57
+ _about_: a config that no longer validates and an app that will not boot are the two
58
+ moments the question stops being idle. A version flag that only works when
59
+ everything else already works answers a question nobody has.
60
+
61
+ Add `--json` for a script, and prefer `zt --version --json` over
62
+ `zt version --json` there — the application's boot log is written to stdout, so the
63
+ second form puts a log line ahead of the JSON while the first never boots at all.
64
+ The output carries no colour, unlike every other command's, because it gets pasted
65
+ into bug reports and piped into parsers more than it is read on a terminal.
66
+
67
+ - **`MailMessage.header()` and `MailPayload.headers`** — set a header the mail driver
68
+ does not build itself.
69
+
70
+ ```ts
71
+ new MailMessage()
72
+ .subject("Your weekly digest")
73
+ .header("List-Unsubscribe", `<https://app.test/unsubscribe/${token}>`)
74
+ .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
75
+ ```
76
+
77
+ `MailPayload` had `to`, `from`, `subject`, `text`, `html`, `cc`, `bcc`, `replyTo`
78
+ and `attachments`, and no way to add anything else — so a team that wanted
79
+ `List-Unsubscribe` had to patch a vendored copy of the package, and shipped a footer
80
+ link instead.
81
+
82
+ Those are not substitutes for one another. Gmail and Yahoo draw their native
83
+ unsubscribe control from the header, and a recipient who cannot find a control marks
84
+ the message as spam instead — a judgement that attaches to the sending domain and
85
+ degrades delivery of everything else it sends, including the mail people asked for.
86
+ Send `List-Unsubscribe-Post` alongside it: alone, the first leaves a link to follow,
87
+ and only the pair produces the one-click control both providers now expect.
88
+
89
+ Wired through all three drivers. SMTP writes them into the message, Resend sends
90
+ them as the API's `headers` object, and the log driver prints them — that last one
91
+ deliberately, because the reason to set a header is that a mail client does
92
+ something with it, and the log driver is where that gets checked before anything is
93
+ sent for real.
94
+
95
+ Names the drivers build themselves are refused rather than sent twice: a second
96
+ `Subject` is an ambiguous message, not an override, and which copy a client believes
97
+ is its own business. The list is exported as `RESERVED_MAIL_HEADERS`, with
98
+ `resolveHeaders()` beside it for anyone writing a custom transport. CR and LF in a
99
+ value are folded to a space — left raw they end the header and let the remainder be
100
+ read as further headers, which is how a `Bcc` arrives courtesy of whoever supplied a
101
+ tracking ID.
102
+
30
103
  ## 1.11.0 — 2026-08-30
31
104
 
32
105
  Two production reports, from teams taking apps live on 1.9.0 — one shipping a
package/docs/commands.md CHANGED
@@ -310,6 +310,7 @@ Packages register their own — see
310
310
 
311
311
  | Command | Description |
312
312
  | ---------------------- | ------------------------------------------------------ |
313
+ | `bun zt version` | Show the Zerotal, Bun and app versions |
313
314
  | `bun zt route:list` | List all registered routes with methods and middleware |
314
315
  | `bun zt route:types` | Write `types/routes.generated.ts` (`--check` in CI) |
315
316
  | `bun zt doctor` | Check the app for silent misconfigurations |
@@ -317,6 +318,36 @@ Packages register their own — see
317
318
  | `bun zt lint:packages` | Check every workspace package against convention rules |
318
319
  | `bun zt upgrade` | Apply the codemods for a version upgrade |
319
320
 
321
+ #### Which version am I on?
322
+
323
+ `bun zt version` prints the framework, the runtime and the app:
324
+
325
+ ```
326
+ Zerotal 1.11.0
327
+ Bun 1.3.14
328
+ App my-app 0.1.0
329
+ ```
330
+
331
+ It reports the version that is **running**, which is not always the version that is
332
+ installed — a long-running server holds the code it booted with, so an upgrade lands
333
+ on disk without reaching it.
334
+
335
+ `--version` and `-v` answer the same question without booting the application:
336
+
337
+ ```bash
338
+ bun zt --version
339
+ bun zt --version --json # for a script
340
+ ```
341
+
342
+ Prefer the flag form in scripts, for two reasons. It still answers when the app does
343
+ not boot — a config that no longer validates is exactly when you want to know which
344
+ version you are on. And it is the form whose output can be piped: the application's
345
+ boot log is written to stdout, so `bun zt version --json` carries a log line ahead of
346
+ the JSON, while `bun zt --version --json` never boots and emits nothing else.
347
+
348
+ If `node_modules` contains a second Bun, the report names it. Nothing executes that
349
+ copy — it arrives as a peer dependency — but it is the one an install would use.
350
+
320
351
  ### Upgrading between versions
321
352
 
322
353
  `bun zt upgrade --to <version>` applies the codemods a version gap calls for —
@@ -390,6 +390,47 @@ async toMail(_n: Notifiable): Promise<MailMessage> {
390
390
  }
391
391
  ```
392
392
 
393
+ #### Custom headers, and the unsubscribe button
394
+
395
+ `header(name, value)` sets a header the driver does not build itself. The one this
396
+ exists for is `List-Unsubscribe`:
397
+
398
+ ```ts fragment
399
+ // in a Notification
400
+ toMail(n: Notifiable): MailMessage {
401
+ return new MailMessage()
402
+ .subject("Your weekly digest")
403
+ .line("Here is what happened this week.")
404
+ .header("List-Unsubscribe", `<https://app.test/unsubscribe/${n.unsubscribeToken}>`)
405
+ .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
406
+ }
407
+ ```
408
+
409
+ Gmail and Yahoo draw their native unsubscribe control — the one beside the sender's
410
+ name, not the one in your footer — from that header, and there is no other way to ask
411
+ for it. Send both headers, not just the first: alone, `List-Unsubscribe` leaves the
412
+ recipient a link to follow, and only the pair produces a control that resolves in one
413
+ press. The URL must accept a `POST` with no body and unsubscribe on the spot, with no
414
+ confirmation page.
415
+
416
+ It is worth more than the footer link it duplicates. A recipient who cannot find the
417
+ control marks the message as spam instead, and that judgement attaches to the sending
418
+ domain and affects delivery of everything else it sends — including the mail people do
419
+ want. The two paths out of a mailing list are not equivalent for the sender.
420
+
421
+ Header names the drivers build themselves are refused rather than sent twice, because
422
+ a second `Subject` is an ambiguous message rather than an override. Those names are
423
+ `RESERVED_MAIL_HEADERS`, and the error names the `MailMessage` method to use instead
424
+ where there is one. The check runs when you set the header, so the throw carries the
425
+ stack of the code that wrote it rather than of a queue worker three hops away — and
426
+ again in the driver, since a `MailPayload` can be built without ever passing through
427
+ `MailMessage`.
428
+
429
+ Values are folded to a single line: a CR or LF in a header value ends the header and
430
+ lets the rest be read as further headers, which is how a `Bcc` gets added by someone
431
+ who was only supposed to be supplying a tracking ID. Writing a custom transport?
432
+ Call `resolveHeaders()` on the payload's headers and it does both checks for you.
433
+
393
434
  ### database
394
435
 
395
436
  Implement `toDatabase(notifiable)` returning a plain object. The notification is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.11.0"
38
+ "@zerotal/core": "1.11.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.11.0"
42
+ "@zerotal/orm": "1.11.1"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [