@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.
- package/CHANGELOG.md +532 -0
- package/dist/apiBuild-DgBS9ayv.js +2 -0
- package/dist/{apiBuild-BtrTyKnF.js → apiBuild-eUM32r1u.js} +2 -2
- package/dist/bin.js +2 -2
- package/dist/{commands-D-9iYF2D.js → commands-CSkrUI1h.js} +2430 -2238
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-CpYgmSw4.js} +1 -1
- package/dist/dbCommand-DvguqlzF.js +2 -0
- package/dist/{dev-Dbm6SWtn.js → dev-CEZwJhmb.js} +2825 -2540
- package/dist/dev-DlBWWnJQ.js +3 -0
- package/dist/index.js +1 -1
- package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-ZTn-dPFa.js +1425 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/data.md +219 -0
- package/templates/agent-docs/deployment.md +56 -0
- package/templates/agent-docs/plugins.md +28 -0
- package/templates/agent-docs/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +157 -286
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-DDJ0It4j.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-BnWq4jeA.js +0 -3
- package/dist/serveCommand-XBXuwJty.js +0 -1294
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Z as e } from "./inspectMetrics-BU90mvJN.js";
|
|
2
|
-
import { c as t } from "./seedRunner-
|
|
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-
|
|
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.
|
|
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.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "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",
|
package/templates/AGENTS.md
CHANGED
|
@@ -602,7 +602,7 @@ each plugin's own README.
|
|
|
602
602
|
|
|
603
603
|
| Topic | Open | Summary |
|
|
604
604
|
|---|---|---|
|
|
605
|
-
| **What's new in 0.
|
|
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.
|
|
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.
|