@voltro/cli 0.26.0 → 0.28.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +532 -0
  2. package/dist/apiBuild-DgBS9ayv.js +2 -0
  3. package/dist/{apiBuild-BtrTyKnF.js → apiBuild-eUM32r1u.js} +2 -2
  4. package/dist/bin.js +2 -2
  5. package/dist/{commands-D-9iYF2D.js → commands-CSkrUI1h.js} +2430 -2238
  6. package/dist/{dbCommand-uuNCrFAb.js → dbCommand-CpYgmSw4.js} +1 -1
  7. package/dist/dbCommand-DvguqlzF.js +2 -0
  8. package/dist/{dev-Dbm6SWtn.js → dev-CEZwJhmb.js} +2825 -2540
  9. package/dist/dev-DlBWWnJQ.js +3 -0
  10. package/dist/index.js +1 -1
  11. package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
  12. package/dist/serveCommand-ZTn-dPFa.js +1425 -0
  13. package/dist/serveEntry.js +2 -2
  14. package/package.json +17 -17
  15. package/templates/AGENTS.md +1 -1
  16. package/templates/agent-docs/_index.md +1 -1
  17. package/templates/agent-docs/data.md +219 -0
  18. package/templates/agent-docs/deployment.md +56 -0
  19. package/templates/agent-docs/plugins.md +28 -0
  20. package/templates/agent-docs/testing.md +42 -0
  21. package/templates/agent-docs/whats-new.md +157 -286
  22. package/templates/apps/api-ai/package.json +7 -7
  23. package/templates/apps/api-auth/package.json +8 -8
  24. package/templates/apps/api-backend/package.json +7 -7
  25. package/templates/apps/api-backend-deactivation/package.json +7 -7
  26. package/templates/apps/api-backend-mail/package.json +8 -8
  27. package/templates/apps/api-backend-mariadb/package.json +9 -9
  28. package/templates/apps/api-backend-storage/package.json +8 -8
  29. package/templates/apps/api-data-advanced/package.json +8 -8
  30. package/templates/apps/api-durable/package.json +8 -8
  31. package/templates/apps/api-feature-flags/package.json +9 -9
  32. package/templates/apps/api-governance/package.json +8 -8
  33. package/templates/apps/api-kv/package.json +8 -8
  34. package/templates/apps/api-moderation/package.json +8 -8
  35. package/templates/apps/api-observability/package.json +8 -8
  36. package/templates/apps/api-ratelimit/package.json +8 -8
  37. package/templates/apps/api-rbac/package.json +8 -8
  38. package/templates/apps/api-rest/package.json +7 -7
  39. package/templates/apps/api-saas/package.json +11 -11
  40. package/templates/apps/api-search/package.json +8 -8
  41. package/templates/apps/api-versioning/package.json +8 -8
  42. package/templates/apps/api-webhooks/package.json +9 -9
  43. package/templates/apps/changelog/package.json +6 -6
  44. package/templates/apps/edge-functions/package.json +2 -2
  45. package/templates/apps/frontend-admin/package.json +8 -8
  46. package/templates/apps/frontend-app/package.json +8 -8
  47. package/templates/apps/frontend-blank/package.json +7 -7
  48. package/templates/apps/frontend-contact/package.json +7 -7
  49. package/templates/apps/frontend-dashboard/package.json +7 -7
  50. package/templates/apps/frontend-docs/package.json +7 -7
  51. package/templates/apps/frontend-i18n/package.json +6 -6
  52. package/templates/apps/frontend-landing/package.json +7 -7
  53. package/templates/apps/frontend-spa/package.json +7 -7
  54. package/templates/apps/frontend-ssr/package.json +7 -7
  55. package/templates/apps/frontend-ssr-api/package.json +8 -8
  56. package/templates/apps/frontend-static-blog/package.json +6 -6
  57. package/dist/apiBuild-DDJ0It4j.js +0 -2
  58. package/dist/dbCommand-DrzXimKf.js +0 -2
  59. package/dist/dev-BnWq4jeA.js +0 -3
  60. package/dist/serveCommand-XBXuwJty.js +0 -1294
@@ -1,5 +1,5 @@
1
1
  import { Z as e } from "./inspectMetrics-BU90mvJN.js";
2
- import { c as t } from "./seedRunner-D6eu-u5U.js";
2
+ import { c as t } from "./seedRunner-Bqxgp7HZ.js";
3
3
  import { r as n } from "./appModuleLoader-C9r9mxZt.js";
4
- import { t as r } from "./serveCommand-XBXuwJty.js";
4
+ import { t as r } from "./serveCommand-ZTn-dPFa.js";
5
5
  export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.108.0",
63
63
  "@effect/sql": "^0.52.0",
64
64
  "@effect/workflow": "^0.19.0",
65
- "@voltro/ai": "0.26.0",
66
- "@voltro/cache": "0.26.0",
67
- "@voltro/data-transfer": "0.26.0",
68
- "@voltro/database": "0.26.0",
69
- "@voltro/env": "0.26.0",
70
- "@voltro/kv": "0.26.0",
71
- "@voltro/logger": "0.26.0",
72
- "@voltro/plugin-auth": "0.26.0",
73
- "@voltro/plugin-broadcast": "0.26.0",
74
- "@voltro/plugin-mail": "0.26.0",
75
- "@voltro/plugin-storage": "0.26.0",
76
- "@voltro/plugin-webhooks": "0.26.0",
77
- "@voltro/protocol": "0.26.0",
78
- "@voltro/runtime": "0.26.0",
79
- "@voltro/serverless": "0.26.0",
80
- "@voltro/workflow": "0.26.0",
65
+ "@voltro/ai": "0.28.0",
66
+ "@voltro/cache": "0.28.0",
67
+ "@voltro/data-transfer": "0.28.0",
68
+ "@voltro/database": "0.28.0",
69
+ "@voltro/env": "0.28.0",
70
+ "@voltro/kv": "0.28.0",
71
+ "@voltro/logger": "0.28.0",
72
+ "@voltro/plugin-auth": "0.28.0",
73
+ "@voltro/plugin-broadcast": "0.28.0",
74
+ "@voltro/plugin-mail": "0.28.0",
75
+ "@voltro/plugin-storage": "0.28.0",
76
+ "@voltro/plugin-webhooks": "0.28.0",
77
+ "@voltro/protocol": "0.28.0",
78
+ "@voltro/runtime": "0.28.0",
79
+ "@voltro/serverless": "0.28.0",
80
+ "@voltro/workflow": "0.28.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -602,7 +602,7 @@ each plugin's own README.
602
602
 
603
603
  | Topic | Open | Summary |
604
604
  |---|---|---|
605
- | **What's new in 0.25.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
605
+ | **What's new in 0.27.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
606
606
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
607
607
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
608
608
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.25.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.27.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -536,6 +536,32 @@ Queries are streaming RPCs whose elements are **subscription events**: an initia
536
536
  - **Using a stream for durable data.** Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
537
537
 
538
538
 
539
+ ## Contradictions refused at declaration
540
+
541
+ ```ts
542
+ defineQuery({ name: 'q', guards: [], … }) // ✗ enforces nothing
543
+ defineQuery({ name: 'q', source: '', … }) // ✗ reactive, subscribed to nothing
544
+ defineQuery({ name: 'q', internal: true, overridesPlugin: true, … }) // ✗ removes, replaces nothing
545
+ ```
546
+
547
+ The same three shapes `defineEvent` refuses, for the same reasons — a rule that
548
+ holds for one primitive and not another is worse than no rule, because the
549
+ answer then depends on which file you happened to open.
550
+
551
+ **`guards: []`** reads at the call site as if the procedure were protected and
552
+ enforces nothing; the check runs only for a non-empty list. Omit the field.
553
+
554
+ **An empty `source`** declares reactivity and subscribes to nothing: the query
555
+ serves one snapshot and never updates, which is indistinguishable from "nothing
556
+ changed". Worse than a *stale* source, which the boot warning can at least name
557
+ — this one names no table at all, so nothing can report it.
558
+
559
+ **`internal: true` + `overridesPlugin`** removes the plugin's route and puts
560
+ something unreachable in its place: callers get a 404 for something that used to
561
+ work, with no diff that says so. Joins the existing refusals of `internal` with
562
+ `publicApi` or `exposeAsTool`.
563
+
564
+
539
565
  ## Loading vs empty — don't conflate them
540
566
 
541
567
  `useSubscription` returns `loading` and `isEmpty` alongside `data`. They are
@@ -1695,6 +1721,13 @@ export default (input, ctx) => Effect.gen(function* () {
1695
1721
 
1696
1722
  Three typed errors reach the **producer**, so a mismatch is one failing call rather than every consumer's handler breaking on a field that is not there: `EventPayloadInvalid`, `EventKeyInvalid`, `EventPayloadTooLarge`.
1697
1723
 
1724
+ `EventPayloadTooLarge` fires at **7,500 bytes** for the encoded envelope (event
1725
+ name + key + payload, as JSON). The ceiling is not arbitrary and it is not a
1726
+ transport limit to tune around: an event says that something *happened*, so
1727
+ `photo.added` carries a photo REFERENCE and the consumer fetches the photo
1728
+ through a route that can stream, cache and authorize it. A payload approaching
1729
+ this size is usually a read that has been pushed into a notification.
1730
+
1698
1731
  <Callout>
1699
1732
  **Both handler styles publish.** `ctx.events.publish` returns an Effect, so the
1700
1733
  `Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)`
@@ -1721,6 +1754,30 @@ Everything you would otherwise hand-roll is gone, and each of these was a real b
1721
1754
  - **A key change is a clean switch** — the old subscription ends before the new one starts.
1722
1755
  - **`key: null` means "not yet"**: no subscription, `status: 'idle'`. You never need a placeholder key.
1723
1756
 
1757
+ ## Showing that it is showing stale
1758
+
1759
+ `useEvent` returns `{ status, missed, lastMiss }` — `status` is
1760
+ `'idle' | 'connecting' | 'live'`, so `status === 'live'` is your connected flag
1761
+ and needs no extra plumbing.
1762
+
1763
+ ```tsx
1764
+ const { status, missed } = useEvent(gameStarted, { arenaId }, onStart)
1765
+
1766
+ // A screen nobody is standing at should say when it stopped being current.
1767
+ {status !== 'live' && <Badge>reconnecting…</Badge>}
1768
+ {missed > 0 && <Badge>{missed} missed — refreshing</Badge>}
1769
+ ```
1770
+
1771
+ This matters most where nobody is watching the tab. A wall display that loses
1772
+ its connection keeps rendering the last thing it received, and from across the
1773
+ room "stale" and "current" look identical. The difference between *showing old
1774
+ data* and *showing that it is showing old data* is one badge.
1775
+
1776
+ `missed` is the other half: it is COMPUTED, never estimated, so a non-zero value
1777
+ means deliveries provably did not arrive — worth surfacing rather than hiding,
1778
+ because the refresh that follows is visible anyway.
1779
+
1780
+
1724
1781
  ## What it guarantees — read this before you build on it
1725
1782
 
1726
1783
  - **At-most-once, best-effort, live.** No persistence, no retry, no redelivery. For guarantees use the [outbox](/docs/data/outbox); this is the other axis.
@@ -1796,6 +1853,144 @@ consequence: during a **rolling deploy** replicas on different framework version
1796
1853
  use different channel names, so cross-replica delivery is degraded for the length
1797
1854
  of the rollout. Local delivery on each replica is unaffected throughout.
1798
1855
 
1856
+ ## Measured against socket.io
1857
+
1858
+ Same machine, same Redis, same topology, back to back:
1859
+
1860
+ | | p50 (median of 5) | p50 range | p99 (median of 5) | p99 range |
1861
+ | --- | --- | --- | --- | --- |
1862
+ | **Voltro**, publisher → Redis → subscriber | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 |
1863
+ | **socket.io + @socket.io/redis-adapter** | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |
1864
+
1865
+ Five runs each, alternating, on one machine. **The p50 ranges do not overlap —
1866
+ that is a real ~2x advantage.** The p99 ranges DO overlap, so the tail difference
1867
+ is weaker evidence than the medians.
1868
+
1869
+ **An earlier version of this table reported one run each** and claimed "32%
1870
+ faster at the median, 44% worse at the tail". Both numbers were noise: the
1871
+ median advantage is nearer 2x and the tail gap is inside the overlap. A single
1872
+ measurement presented as a fact is the defect this framework spends its time
1873
+ removing, and it was committed here.
1874
+
1875
+ **Where our tail comes from, measured rather than guessed.** Splitting the
1876
+ publish path: our own code — building the envelope, the Effect fiber, handing
1877
+ off — costs **p50 0.056 ms / p99 0.444 ms**. Waiting for Redis to acknowledge
1878
+ costs **p50 1.17 ms / p99 6.43 ms**. So roughly 0.4 ms of a 7 ms tail is ours;
1879
+ the rest is the broker round-trip, which socket.io pays too. There is no
1880
+ code-level tail defect to fix here — on this machine the number is dominated by
1881
+ Docker's network stack.
1882
+
1883
+ The script is in the repo (`scripts/bench/socketio-cross-replica.mjs`) so the
1884
+ number can be re-taken rather than believed. It is not a test: keeping a
1885
+ competitor in the dependency tree to hold a number green is the wrong trade.
1886
+
1887
+ **Topology is what makes this a comparison at all.** Two server instances share
1888
+ one Redis; the client hangs off instance B and every emit is issued on instance
1889
+ A. The first version measured socket.io on a plain localhost websocket and came
1890
+ out 3x faster — which proved nothing, because that is one hop and this is two
1891
+ through a broker.
1892
+
1893
+ Hosted products (Firebase, Pusher, Ably) are deliberately absent. Measuring them
1894
+ honestly needs their accounts, regions and tiers, and a wrong number about
1895
+ someone else's product is worse than no number.
1896
+
1897
+
1898
+ ## The hard questions, and our answers
1899
+
1900
+ | the question | the answer here |
1901
+ | --- | --- |
1902
+ | Does a client learn that deliveries were missed? | Yes — `missed` is COMPUTED from per-origin serials against a watermark, never estimated |
1903
+ | Can a late arrival tell "nothing happened" from "I was not listening"? | Yes — every delivery carries `prior`, the watermark before it was accepted |
1904
+ | Is a subscription authorized, or only the connection? | Per subscription, on the routing key, re-checked per delivery |
1905
+ | Does a subscription outlive the credential that authorized it? | No — bounded by the credential's verified expiry, cookie AND bearer |
1906
+ | Does the link heal itself after a broker outage? | Yes — no restart, no app-side retry, no resubscribe |
1907
+ | Does a degraded network lose messages or only slow them? | Only slows them — 7x the median latency, zero loss |
1908
+ | Does fan-out cost grow with subscriber count? | No — 0.027 µs per delivery, flat from 1 to 100 |
1909
+ | Are channels typed, or strings? | Typed — a rename is a compile error |
1910
+ | Is a declared event nothing publishes reported? | Yes, at boot, reading sibling apps in the workspace |
1911
+ | Is cross-replica fan-out separated per app by default? | Yes — the namespace derives from the app name |
1912
+
1913
+ **Every row is enforced by a test** (`realtimeProperties.test.ts`) that fails if
1914
+ the proof behind it disappears. A property may not be claimed here without
1915
+ something in the repository that demonstrates it.
1916
+
1917
+ **Why this is not a benchmark against other products.** A table of our measured
1918
+ numbers beside someone else's published ones is not a comparison — it is two
1919
+ things in a row. Benchmarking a hosted competitor honestly needs their accounts,
1920
+ regions, tiers and retry policies, and a wrong number about someone else's
1921
+ product is worse than no number. What decides a choice anyway is not the
1922
+ microseconds; it is whether the system answers these questions at all. Each
1923
+ answer above is checkable against this repository by anyone, which is the
1924
+ opposite of a claim.
1925
+
1926
+
1927
+ ## What you can build — and what to reach for
1928
+
1929
+ | what you want to build | reach for |
1930
+ | --- | --- |
1931
+ | a list that updates as rows change | `useSubscription` |
1932
+ | a huge list without loading all of it | `useWindowedSubscription` |
1933
+ | an edit that appears before the server confirms | `useMutation` (optimistic is derived) |
1934
+ | a signal with no row behind it — a game start, a trigger | `defineEvent` |
1935
+ | a 60 Hz value where only the newest matters | `defineEvent` + `delivery: 'latest'` |
1936
+ | knowing a delivery was provably missed | `useEvent` → `missed` |
1937
+ | who is online in a room | `usePresence` |
1938
+ | who is typing right now | `useTyping` |
1939
+ | showing that the screen went stale | `useEvent` → `status`, or `useConnectionStatus` |
1940
+ | fan-out across replicas | `broadcastPlugin()` |
1941
+ | durable work with progress a client can watch | `useWorkflow` |
1942
+ | an in-app inbox | `useInbox` |
1943
+ | delivering an event to a third party | `@voltro/plugin-webhooks` |
1944
+ | gating who may subscribe | `guards:` on the event |
1945
+ | an upload whose progress the UI follows | `useUpload` |
1946
+
1947
+ This table is a TEST (`realtimeCapabilities.test.ts`), not a claim: each row
1948
+ asserts its primitive is still exported, so a capability that loses its
1949
+ primitive to a rename goes red in CI rather than being discovered by whoever
1950
+ tries to build it.
1951
+
1952
+ **Three of these are the ones people usually reach for wrongly.** A value that
1953
+ changes many times a second is an EVENT, not a row — writing it to a table wakes
1954
+ every subscriber of every query reading that table, and each pays a full re-diff.
1955
+ "Who is online" is presence rather than a table, because the answer is ephemeral
1956
+ and per-connection. And "did anything get lost" has a real answer (`missed`),
1957
+ computed rather than estimated, so you do not have to build a heartbeat of your
1958
+ own to find out.
1959
+
1960
+
1961
+ ## The four costs side by side
1962
+
1963
+ Every number below is asserted by a test in the repo, not quoted from a report —
1964
+ each surface has a `*.perf.test.ts` that prints what it measured.
1965
+
1966
+ | primitive | operation | cost | scales with |
1967
+ | --- | --- | --- | --- |
1968
+ | **Events** | `ctx.events.publish` | **4.3 µs** (~232 k/s) | nothing — flat |
1969
+ | **Events** | delivery to a subscriber | **0.027 µs** | subscribers, linearly and cheaply |
1970
+ | **Presence** | a heartbeat | **0.16 µs** | nothing — flat |
1971
+ | **Presence** | a roster read, 10 k members | **547 µs** | the ROOM |
1972
+ | **Records** | a live query re-diff, 5 000 rows | **3 062 µs** | the RESULT SET |
1973
+ | **Broadcast** | cross-replica, real Redis | **p50 1.1 ms · p99 11.2 ms** | the network |
1974
+
1975
+ **The comparison is the useful part.** Publishing an event costs about a
1976
+ thousandth of what re-diffing a large live query does, and that ratio — not
1977
+ either number — is what should decide between them. A value that changes at 60 Hz
1978
+ belongs in an event; the same value written to a table wakes every subscriber of
1979
+ every query reading it, and each pays the full walk.
1980
+
1981
+ **Two of the four are flat and two are not.** A publish and a heartbeat cost the
1982
+ same whatever the load, so they scale by adding replicas. A roster read is
1983
+ linear in the room and a live-query diff is linear in the RESULT SET — including
1984
+ when one column of one row changed, because the cost is the walk rather than the
1985
+ patch. Those are the two numbers to keep an eye on as an app grows.
1986
+
1987
+ **Cross-replica adds milliseconds, not microseconds**, and that is a network
1988
+ crossing rather than framework overhead. Under an injected 20 ms ± 10 jitter it
1989
+ becomes p50 26 ms / p99 89 ms; adding a 50 KB/s ceiling makes it p50 188 ms — and
1990
+ in every one of those runs, all 200 messages arrive. Degradation costs latency,
1991
+ never messages.
1992
+
1993
+
1799
1994
  ## Throughput — the numbers, and where this is the wrong primitive
1800
1995
 
1801
1996
  Measured on one core, publish path only:
@@ -1924,6 +2119,30 @@ Three bugs in nine lines, and every consumer has to get all three right: the `se
1924
2119
 
1925
2120
  Migrating is mechanical: declare the event, replace the insert with `ctx.events.publish`, replace the hook with `useEvent`, and drop the table.
1926
2121
 
2122
+ ## What `defineEvent` refuses, and why
2123
+
2124
+ ```ts
2125
+ defineEvent({ name: 'orders paid', … }) // ✗ whitespace — see below
2126
+ defineEvent({ name: 'orders.paid', guards: [], … }) // ✗ enforces nothing
2127
+ defineEvent({ name: 'x', webhook: { rateLimit: { perMinute: 0 } } }) // ✗ never delivers
2128
+ ```
2129
+
2130
+ **Whitespace in a name is a broker-level failure, not a style rule.** The name
2131
+ becomes a broker SUBJECT segment. NATS refuses a subject containing whitespace
2132
+ and delivers nothing — with no error on the publishing side. An app that works
2133
+ on Redis therefore stops working when the transport changes, silently and in
2134
+ production only. Use a dot to namespace: `orders.paid`.
2135
+
2136
+ **`guards: []` is refused** because it reads at the call site as if the event
2137
+ were protected and enforces nothing — the empty list never reaches the check.
2138
+ Omit the field for an unguarded event.
2139
+
2140
+ **`rateLimit: { perMinute: 0 }`** defers every delivery forever. There is no
2141
+ "unlimited" spelling — omit `rateLimit` for no ceiling. **`version: 0`** would
2142
+ make a subscriber pinned to 1 read the event as *behind*, the opposite of what a
2143
+ bump is for.
2144
+
2145
+
1927
2146
  ## See also
1928
2147
 
1929
2148
  [Subscriptions](/docs/data/subscriptions) · [Outbox](/docs/data/outbox) · [Subscribers](/docs/data/subscribers) · [Streams](/docs/data/streams)
@@ -1150,6 +1150,59 @@ KV_BACKEND=redis
1150
1150
 
1151
1151
  Schedules and aggregates auto-coordinate via an advisory lock on SQL stores — no extra config to keep them from double-firing across replicas.
1152
1152
 
1153
+ ### The prerequisites you learn at the SECOND pod
1154
+
1155
+ Every item below is invisible on one replica and breaks on two. They are
1156
+ collected here because an operator reported each of them separately, each found
1157
+ the same way: the first pod proved the configuration worked.
1158
+
1159
+ **The connection pool multiplies, the database limit does not.**
1160
+
1161
+ ```sh
1162
+ DB_MAX_CONNECTIONS=10 # per replica — the fleet opens up to this × replicaCount
1163
+ ```
1164
+
1165
+ The framework opens ONE pool per process. At 4 replicas a pool of 10 is 40
1166
+ connections against a database that still allows whatever it allowed before you
1167
+ scaled. An operator's second pod died on `Connection timed out` for exactly
1168
+ this. `voltro serve` now prints the number and the arithmetic at boot:
1169
+
1170
+ ```
1171
+ db pool: max=10 per replica (DB_MAX_CONNECTIONS) × 4 replicas = up to 40 connections.
1172
+ ```
1173
+
1174
+ Set `REPLICA_COUNT` from your deployment (Helm: `{{ .Values.replicaCount }}`) and
1175
+ the line does the multiplication for you; without it the line still names the
1176
+ formula. `voltro dev` deliberately does not print it — one process, no replicas.
1177
+
1178
+ **`POD_IP` is each replica's identity, not only a workflow setting.**
1179
+
1180
+ ```yaml
1181
+ env:
1182
+ - name: POD_IP
1183
+ valueFrom:
1184
+ fieldRef:
1185
+ fieldPath: status.podIP
1186
+ ```
1187
+
1188
+ Without it every replica registers under the same host, so they are one runner
1189
+ as far as the cluster is concerned. The boot warning for it fires only on SQL
1190
+ cluster storage, so a deployment that has not adopted durable workflows yet gets
1191
+ no signal at all — inject it as a matter of course.
1192
+
1193
+ **Derive the broadcast namespace from something that cannot be forgotten.**
1194
+
1195
+ ```yaml
1196
+ - name: VOLTRO_BROADCAST_NAMESPACE
1197
+ value: {{ .Release.Namespace }}
1198
+ ```
1199
+
1200
+ Staging and production of the same app share a name, code and fingerprint, so
1201
+ the auto-derived namespace does NOT separate them — only this variable does. An
1202
+ operator's own guidance, and better than ours was: a value taken from the
1203
+ release namespace cannot be left out of one environment's config file, because
1204
+ there is no file to forget.
1205
+
1153
1206
  ### Workflow failover across replicas
1154
1207
 
1155
1208
  On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:
@@ -1181,6 +1234,9 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1181
1234
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
1182
1235
  - [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
1183
1236
  - [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
1237
+ - [ ] `DB_MAX_CONNECTIONS` set so `pool × replicaCount` fits your database's limit — read the `db pool:` boot line before raising `replicaCount`
1238
+ - [ ] `POD_IP` injected via the downward API on EVERY multi-replica deployment, not only for durable workflows
1239
+ - [ ] `VOLTRO_BROADCAST_NAMESPACE` derived from the release namespace — staging and production do not separate themselves
1184
1240
  - [ ] For durable workflows on >1 replica: SQL store + `POD_IP` injected; tune `VOLTRO_WORKFLOW_FAILOVER_LEASE` if a 35s takeover is too slow; step side effects idempotent
1185
1241
 
1186
1242
 
@@ -196,6 +196,34 @@ registers refuses at boot rather than sitting there looking enforced.
196
196
  orphan rows are cleaned up *before* the FK constraint is added — and has no
197
197
  runtime semantics. Runtime behaviour comes from `onDelete`.
198
198
 
199
+ ## Composing with a plugin's namespace
200
+
201
+ Sharing a namespace with a plugin already works: the collision check compares
202
+ FULL tags, so `notifications.list` of yours beside the plugin's
203
+ `notifications.inbox` is not a clash. Only an identical name is — two handlers
204
+ behind one tag is not something a caller can reason about.
205
+
206
+ To REPLACE one deliberately, declare it:
207
+
208
+ ```ts
209
+ // Adopt the plugin's namespace, add your own leaves beside it…
210
+ defineQuery({ name: 'notifications.archive', … })
211
+
212
+ // …and REPLACE just the one you need to behave differently.
213
+ defineMutation({ name: 'notifications.markRead', overridesPlugin: true, … })
214
+ ```
215
+
216
+ The plugin's route is dropped, not merely permitted alongside yours — permitting
217
+ both would leave two handlers bound, which is the state the check exists to
218
+ prevent. The boot logs which routes were replaced.
219
+
220
+ **Explicit, never inferred.** Letting your route win silently would mean a
221
+ plugin upgrade that adds a route could shadow one of yours with no diff to read.
222
+ It is also why the two obvious alternatives are worse: renaming your procedure,
223
+ or `alias`ing the whole plugin away, both move the split from a domain boundary
224
+ to "who built it" — for whoever calls the api, the worst possible partition.
225
+
226
+
199
227
  ## When NOT to write a plugin
200
228
 
201
229
  - **One-off side effect** — just call it from the mutation directly.
@@ -372,6 +372,48 @@ What it fills and what it leaves alone:
372
372
 
373
373
  There is no flag to turn the validation off — a test store that accepts rows production rejects is a fake testing itself. `fixtureRow` is a runtime filler for the loose `store.insert(name, row)` path. For **compile-time** payload typing (a missing required column caught as a type error at the call), use [`insertRow` / `upsertRow`](/docs/data/mutations) from `@voltro/database`, which check against `InferInsertRow<T>`.
374
374
 
375
+ ## Subscribers — `makeSubscribeContext` + the change constructors
376
+
377
+ A `*.subscribe.ts` handler does not receive an `AppContext`. It receives a
378
+ change EVENT and a `SubscribeContext`, and both have constructors:
379
+
380
+ ```ts
381
+ import { changeInsert, changeUpdate, changeDelete, changeSoftDelete, makeSubscribeContext, makeTestContext } from '@voltro/testing'
382
+ import subscriber from '../src/attendance.subscribe'
383
+
384
+ const app = makeTestContext({ store: { attendance: [{ id: 'a1', employeeId: 'e1' }] } })
385
+ const ctx = makeSubscribeContext({ id: 'attendance', store: app.store })
386
+
387
+ await subscriber.handler(changeUpdate('attendance', { id: 'a1', state: 'in' }, { id: 'a1', state: 'out' }), ctx)
388
+ ```
389
+
390
+ **Build the event with a constructor, not with an object literal.** The
391
+ constructors put the semantics in the name, which is the half a literal cannot
392
+ give you:
393
+
394
+ | | `op` | `old` | `new` |
395
+ | --- | --- | --- | --- |
396
+ | `changeInsert(t, row)` | `insert` | `null` | the row |
397
+ | `changeUpdate(t, before, after)` | `update` | before | after |
398
+ | `changeDelete(t, row)` | `delete` | the row | `null` |
399
+ | `changeSoftDelete(t, row)` | **`update`** | `deletedAt: null` | `deletedAt` set |
400
+
401
+ `changeSoftDelete` is the reason this exists. There is no `op: 'softDelete'` and
402
+ there never will be — a soft delete is an ordinary update that sets `deletedAt`
403
+ — so a test author who does not know that writes a delete, and the test passes
404
+ against a stream the framework never emits. We shipped exactly that defect: a
405
+ feature that keys off soft deletes was inert in production while its own tests
406
+ were green, because they asserted against an event shape that does not exist.
407
+
408
+ `ctx.store` has **no default and throws when touched**. Pass
409
+ `makeTestContext().store` so the subscriber and the code under test share one; a
410
+ silent empty store would let a subscriber reading the wrong table pass its test,
411
+ which is the same silent-nothing the constructors exist to remove.
412
+
413
+ `ctx.publish` is absent unless you pass one, mirroring the real context — where
414
+ it is optional precisely so that reaching for it in an app that declares no
415
+ event is a type error.
416
+
375
417
  ## Subject + tenant re-scoping
376
418
 
377
419
  `withSubject` and `withTenant` re-scope to a different principal for one block, sharing the **same** underlying data — so cross-subject reads exercise real tenant scoping, not a closure stub. This is how you prove isolation: write as one tenant, then assert another tenant can't see the row.