@voltro/cli 0.30.1 → 0.31.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 (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,91 @@
1
+ # PostHog
2
+
3
+ > Track-only AnalyticsSink that forwards events to PostHog's /capture endpoint — compose it with a second sink that owns reads.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/posthog.md -->
10
+ ## PostHog
11
+
12
+ _Track-only AnalyticsSink that forwards events to PostHog's /capture endpoint — compose it with a second sink that owns reads._
13
+
14
+ `@voltro/plugin-posthog` is a **track-only** `AnalyticsSink` — it forwards every
15
+ `track()` to PostHog's `/capture` endpoint so your events show up in PostHog's
16
+ product-analytics UI (funnels, cohorts, sessions, feature flags). PostHog's
17
+ query model is too PostHog-specific to fit the cross-provider contract, so
18
+ `aggregate` / `timeseries` / `topN` return `AnalyticsCapabilityNotSupported`.
19
+ The intended wiring is therefore `composeAnalytics([...])`: a primary sink owns
20
+ the typed reads, PostHog additionally mirrors every event. For the shared
21
+ `AnalyticsSink` API and the `useAnalytics()` read methods, see
22
+ [Analytics & warehouse sinks](/docs/plugins/analytics).
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pnpm add @voltro/plugin-posthog
28
+ ```
29
+
30
+ ## Wiring — compose with a read-capable sink
31
+
32
+ PostHog can't serve reads, so pair it with a primary sink via `composeAnalytics`
33
+ (`track()` fans out to both; reads route to the first sink that supports them):
34
+
35
+ ```ts
36
+ // app.config.ts
37
+ import { composeAnalytics } from '@voltro/runtime'
38
+ import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
39
+ import { posthogAnalytics } from '@voltro/plugin-posthog'
40
+
41
+ export default {
42
+ type: 'api' as const,
43
+ name: 'myApi',
44
+ store: 'postgres' as const,
45
+ analytics: composeAnalytics([
46
+ postgresAnalytics(), // primary — handles reads
47
+ posthogAnalytics({ apiKey: process.env.POSTHOG_KEY! }), // also forwards every track()
48
+ ]),
49
+ }
50
+ ```
51
+
52
+ ## Options
53
+
54
+ `posthogAnalytics(options)`:
55
+
56
+ | Option | Type | Default | Notes |
57
+ |---|---|---|---|
58
+ | `apiKey` | `string` | — (**required**) | PostHog project API key. |
59
+ | `host` | `string` | `'https://app.posthog.com'` | Use `'https://eu.posthog.com'` for EU cloud, or your self-hosted URL. |
60
+ | `batch` | `{ maxSize?, flushIntervalMs? }` | unset (immediate mode) | Opt into client-side batching to PostHog's `/batch/` endpoint. |
61
+ | `batch.maxSize` | `number` | `20` | Flush once the buffer reaches this many events. |
62
+ | `batch.flushIntervalMs` | `number` | `5000` | Flush a non-empty buffer this many ms after the first event lands. |
63
+
64
+ > **Connection details come from `options`, not env.** The plugin reads no
65
+ > `POSTHOG_*` variables itself — the example wires `process.env` into the
66
+ > options. Use `@voltro/env`'s `defineEnv` to declare the key as a secret.
67
+
68
+ ## Delivery semantics
69
+
70
+ - **Immediate (default, `batch` unset):** every `track()` is one `/capture/`
71
+ POST, so a successful `track()` means the event was delivered.
72
+ - **Batched (`batch: {...}`):** events buffer and flush by size, on a timer,
73
+ and on graceful shutdown (the `dispose` hook). A successful `track()` then
74
+ means "buffered" — far fewer round-trips under load, but a flush failure is
75
+ logged + the batch dropped (best-effort; `/batch/` carries no idempotency
76
+ key). A hard crash loses whatever is still buffered. Leave `batch` unset when
77
+ you need per-event delivery confirmation.
78
+
79
+ ## Capability limits
80
+
81
+ `aggregate`, `timeseries`, and `topN` always return
82
+ `AnalyticsCapabilityNotSupported` (provider `posthog`) — catch it with
83
+ `Effect.catchTag` for graceful fallback, or rely on `composeAnalytics` routing
84
+ reads to a read-capable sink. For PostHog-specific endpoints (capture batches,
85
+ decide, person properties) import the `PostHog` Tag and use the raw HTTP API.
86
+
87
+ ## See also
88
+
89
+ - [Analytics & warehouse sinks](/docs/plugins/analytics) — the shared
90
+ `AnalyticsSink` contract, the `useAnalytics()` read API, `composeAnalytics`,
91
+ and capability-not-supported error handling.
@@ -0,0 +1,115 @@
1
+ # Presence
2
+
3
+ > Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/presence.md -->
10
+ ## Presence
11
+
12
+ _Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance._
13
+
14
+ `@voltro/plugin-presence` answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Held **in memory**, owner-partitioned: every member belongs to exactly the replica holding its WebSocket, so concurrent writes to one key are impossible by construction and there is no table, no CRDT and no coordinator.
15
+
16
+ <Callout type="warn">
17
+ Cross-instance presence needs a broker. With [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) every replica sees the whole room; **without one, each replica sees only the clients connected to it** — a correct roster of a fraction of the room. Nothing errors, and a single-replica staging box looks perfect, so the boot logs a warning rather than leaving you to discover it in production.
18
+ </Callout>
19
+
20
+ ## Wiring
21
+
22
+ ```ts
23
+ // app.config.ts
24
+ import { presencePlugin } from '@voltro/plugin-presence'
25
+
26
+ export default {
27
+ type: 'api' as const, name: 'api',
28
+ plugins: [presencePlugin({ timeoutMs: 30_000 })], // online window after the last heartbeat
29
+ }
30
+ ```
31
+
32
+ Contributes three routes: `presence.heartbeat`, `presence.list`, `presence.leave`. No table — a member is held by the replica that owns its connection.
33
+
34
+ A client that vanishes without calling `leave` (a closed laptop, a dropped network, a crashed tab) is removed by a sweep after `timeoutMs`, and the removal is broadcast so every other replica drops it too. Each replica sweeps only its OWN members: another owner's timestamps are on another clock, and a replica that has gone is dropped whole by instance membership rather than guessed at. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window.
35
+
36
+ ## Client hook
37
+
38
+ ```tsx
39
+ import { usePresence } from '@voltro/plugin-presence/web'
40
+
41
+ const Room = ({ channel }: { channel: string }) => {
42
+ // Heartbeats while mounted, leaves on unmount, returns the live roster.
43
+ const members = usePresence(channel, { meta: { status: 'typing' } })
44
+ return <div>{members.length} online: {members.map((m) => m.key).join(', ')}</div>
45
+ }
46
+ ```
47
+
48
+ `usePresence(channel, opts)` heartbeats on an interval (`heartbeatMs`, default 15s) and publishes per-member `meta` (status, cursor position, …). The roster is **push-driven** — `presence.list` is a reactive plugin query (`source: '_voltro_presence'`), so the framework pushes a fresh roster over the subscription transport on every roster change (heartbeat / leave / sweep / a peer departing) with NO client polling. `key` defaults to the subject id; pass an explicit `key` for anonymous members.
49
+
50
+ ## Typing indicator
51
+
52
+ `useTyping` is a typing indicator built on the SAME presence primitive — no new transport. While `isTyping`, the client heartbeats into a short-TTL lane `typing:<channel>`; `stop()` / unmount leaves it, so a typer drops off within the heartbeat window.
53
+
54
+ ```tsx
55
+ import { useTyping } from '@voltro/plugin-presence/web'
56
+
57
+ const Composer = ({ channel, myKey }: { channel: string; myKey: string }) => {
58
+ const typing = useTyping(channel, { selfKey: myKey })
59
+ return (
60
+ <>
61
+ <textarea onFocus={typing.start} onBlur={typing.stop} />
62
+ {typing.active.length > 0 && <em>{typing.active.length} typing…</em>}
63
+ </>
64
+ )
65
+ }
66
+ ```
67
+
68
+ `useTyping(channel, opts)` → `{ active, isTyping, start, stop }`: `active` is the other members currently typing (self excluded via `selfKey`), `start()`/`stop()` toggle broadcasting. A shorter heartbeat than the roster (`heartbeatMs` default 3s) so a typer clears quickly; the lane roster is push-driven like `usePresence` (no poll). `activeTypers(members, selfKey)` is the pure self-exclusion helper it uses.
69
+
70
+ ## Cost
71
+
72
+ Measured on the in-memory tracker:
73
+
74
+ | | |
75
+ | --- | --- |
76
+ | a heartbeat (`track`), 1k members in the room | 0.10 µs |
77
+ | `roster`, 1k members | 27 µs |
78
+ | `roster`, 10k members | 283 µs |
79
+ | a full sweep, 10k members | 77 µs |
80
+
81
+ A heartbeat is a map write and costs nothing; reading the roster is linear in
82
+ the room and is the number to watch. 10k in one channel is 283 µs per read —
83
+ fine for a roster panel, wrong for a per-frame cursor overlay, which belongs in
84
+ an event declared `delivery: 'latest'` rather than in presence metadata.
85
+
86
+ ## Notes
87
+
88
+ - The roster is push-driven: `presence.list` is a reactive plugin query (`source: '_voltro_presence'`), so the framework re-runs it and pushes deltas over the subscription transport on every roster change — no client polling. There is no such TABLE any more; the name survives purely as the reactivity key the framework routes on, which is why the roster push needed no second mechanism when presence moved into memory. Any plugin query route that names a `source` table becomes push-driven the same way.
89
+ - A member counts as online for `timeoutMs` after its last heartbeat. The sweep runs every `timeoutMs / 3`, so a vanished member is gone within roughly 1.3× the window.
90
+ - The sweep needs **no cluster coordination**, and that follows from the design rather than being a shortcut: every member is owned by exactly one replica and nobody else may touch it, so each replica sweeps its own and there is nothing to contend over. (The table version *did* need coordination — its rows were shared.)
91
+
92
+ ## `timeoutMs` and `heartbeatMs` are one contract
93
+
94
+ ```ts
95
+ presencePlugin({ timeoutMs: 30_000 }) // server: online for 30s after a beat
96
+ usePresence(channel, { heartbeatMs: 15_000 }) // client: beats every 15s
97
+ ```
98
+
99
+ A member counts as online for `timeoutMs` after its last heartbeat, and the
100
+ client decides how often that heartbeat is. **The server value must comfortably
101
+ outlast the client's** — the shipped default pair is 30s / 15s, a factor of two.
102
+
103
+ Set it below the heartbeat and every member expires between beats: the roster
104
+ flaps empty, and nothing reports it, because an empty roster is also what
105
+ "nobody is here" looks like. A value under a second is refused at declaration
106
+ for that reason; there is no "never expire" spelling, so omit `timeoutMs` for
107
+ the default.
108
+
109
+ A long window is fine — a signage terminal beating once a minute is a real
110
+ deployment. The rule is a floor, not a range.
111
+
112
+
113
+ ## Permissions
114
+
115
+ None for the roster itself — presence is in memory, so a heartbeat writes no rows.
@@ -0,0 +1,124 @@
1
+ # Prometheus
2
+
3
+ > Prometheus exporter — scrapes the unified Metrics-API at GET /metrics in text exposition format.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/prometheus.md -->
10
+ ## Prometheus
11
+
12
+ _Prometheus exporter — scrapes the unified Metrics-API at GET /metrics in text exposition format._
13
+
14
+ `@voltro/plugin-prometheus` exposes the framework's metrics to Prometheus / Grafana at a scrape endpoint. It only READS — the framework emits the metrics automatically; the plugin formats them.
15
+
16
+ ## The Metrics-API (single source of truth)
17
+
18
+ The framework records metrics into Effect's global `MetricRegistry`. One snapshot sees everything:
19
+
20
+ - **Core RPC** — `voltro_rpc_requests_total{tag,status}`, `voltro_rpc_errors_total{tag}`, `voltro_rpc_duration_seconds{tag}` (histogram). Emitted at the mutation / action handler boundary.
21
+ - **HTTP routes** — `voltro_http_requests_total{route,status}`, `voltro_http_duration_seconds{route}`.
22
+ - **Plugin interceptors** — `voltro_plugin_hook_duration_seconds{hook}`, `voltro_plugin_hook_errors_total{hook}`.
23
+ - **Subscriptions** — `voltro_subscriptions_active{tag}` (gauge of currently-open subscriptions), `voltro_subscription_deliveries_total{tag,kind}` + `voltro_subscription_delivery_seconds{tag,kind}` (per-delivery produce→push latency; `kind` = `snapshot` | `delta`).
24
+ - **Schedules (crons)** — `voltro_schedule_runs_total{schedule,status}` (firings by name + outcome — `status` = `succeeded` | `failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a **gauge holding the UNIX time of the last SUCCESS**). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on: `time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>` fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge.
25
+ - **Workflows (durable execution)** — `voltro_workflow_runs_total{workflow,status}` (terminal outcomes — `status` = `succeeded` | `failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, a `failed` run is **terminal** — it is the dead-letter state — so `voltro_workflow_runs_total{status="failed"}` **is** the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge.
26
+ - **`@voltro/cache`** counters, Effect's own `effect_fiber_*` runtime metrics, and **any custom metric** you or another plugin defines.
27
+
28
+ Two consumers read the SAME snapshot, so they never disagree:
29
+
30
+ - **`GET /metrics`** (this plugin) → Prometheus text exposition format, consumed by Prometheus / Grafana.
31
+ - **`GET /_voltro/inspect/metrics`** → JSON `MetricSample[]`, consumed by the dashboard's Metrics panel (quantiles derived from the histogram buckets, `histogram_quantile`-style).
32
+
33
+ ### Custom metrics
34
+
35
+ Define your own counter / gauge / histogram and it shows up in both consumers automatically:
36
+
37
+ ```ts
38
+ import { counter, gauge, histogramMetric } from '@voltro/runtime'
39
+ import { Effect, Metric } from 'effect'
40
+
41
+ const signups = counter('app_signups_total', 'User sign-ups.')
42
+
43
+ export default (input, _ctx) =>
44
+ Effect.gen(function* () {
45
+ // … create the user …
46
+ yield* Metric.increment(signups)
47
+ return { ok: true }
48
+ })
49
+ ```
50
+
51
+ ## Wiring
52
+
53
+ ```ts
54
+ // app.config.ts
55
+ import { prometheusPlugin } from '@voltro/plugin-prometheus'
56
+
57
+ export default {
58
+ type: 'api' as const,
59
+ name: 'api',
60
+ plugins: [prometheusPlugin()],
61
+ }
62
+ ```
63
+
64
+ Scrape config — one target **per replica**, never a single load-balanced hostname:
65
+
66
+ ```yaml
67
+ scrape_configs:
68
+ - job_name: voltro
69
+ metrics_path: /metrics
70
+ static_configs:
71
+ # one target per replica — NOT one LB-fronted hostname
72
+ - targets: ['api-0.internal:4000', 'api-1.internal:4000']
73
+ ```
74
+
75
+ ### Multi-replica scraping
76
+
77
+ The registry is **per-process**: each replica serves its own counters, and Prometheus keeps the series apart via the per-target `instance` label — exactly the pull model. But that only works when every replica is scraped **directly**. Behind a load-balancer, successive scrapes hit different processes and the per-process counters interleave into one incoherent series. On Kubernetes, use pod-level discovery so every pod becomes its own target:
78
+
79
+ ```yaml
80
+ scrape_configs:
81
+ - job_name: voltro
82
+ kubernetes_sd_configs:
83
+ - role: pod
84
+ relabel_configs:
85
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
86
+ action: keep
87
+ regex: 'true'
88
+ ```
89
+
90
+ The plugin's `name` option disambiguates multiple plugin instances **within one process** (it suffixes the plugin name) — it does not identify replicas; replica identity is the scrape target's `instance` label.
91
+
92
+ ## Options
93
+
94
+ ```ts
95
+ prometheusPlugin({
96
+ path: '/metrics', // scrape path (default /metrics)
97
+ token: process.env.PROMETHEUS_TOKEN, // require `Authorization: Bearer <token>`
98
+ processMetrics: true, // include node process_* metrics (default true)
99
+ version: '1.4.2', // sets voltro_build_info{version} = 1
100
+ })
101
+ ```
102
+
103
+ - **`token`** — defaults to the `PROMETHEUS_TOKEN` env. Unset → the endpoint is open; gate it at the network layer (Prometheus uses `bearer_token` to send it).
104
+ - **`processMetrics`** — emits `process_resident_memory_bytes`, `process_heap_bytes`, `process_cpu_seconds_total`.
105
+ - The response is `text/plain; version=0.0.4; charset=utf-8` with `cache-control: no-store`.
106
+
107
+ ## Output shape
108
+
109
+ Counters / gauges render as `name{labels} value`. Histograms render as cumulative `name_bucket{labels,le="…"}` series plus `name_sum` + `name_count` + the `+Inf` bucket — exactly what `histogram_quantile()` expects in Grafana.
110
+
111
+ ```
112
+ # HELP voltro_rpc_requests_total Total RPC handler invocations.
113
+ # TYPE voltro_rpc_requests_total counter
114
+ voltro_rpc_requests_total{status="ok",tag="mutation.todos.create"} 42
115
+ # TYPE voltro_rpc_duration_seconds histogram
116
+ voltro_rpc_duration_seconds_bucket{tag="mutation.todos.create",le="0.001"} 3
117
+ voltro_rpc_duration_seconds_bucket{tag="mutation.todos.create",le="+Inf"} 42
118
+ voltro_rpc_duration_seconds_sum{tag="mutation.todos.create"} 0.21
119
+ voltro_rpc_duration_seconds_count{tag="mutation.todos.create"} 42
120
+ ```
121
+
122
+ ## Scope
123
+
124
+ Mutation / action / HTTP / plugin metrics, subscription metrics (active gauge + per-delivery latency histogram, split snapshot vs delta), schedule/cron metrics AND workflow metrics (run counter + duration + last-success gauge each) are all covered. OTLP metrics export reads the same registry: the `OTEL_EXPORTER_OTLP_ENDPOINT` that enables trace export also exports metrics (`@effect/opentelemetry` bridges the registry into the OTel MeterProvider) — that is the path for the Datadog Agent / any OTLP collector; agentless Datadog is `@voltro/plugin-datadog`. This plugin is specifically the Prometheus **pull** (scrape) surface.
@@ -0,0 +1,274 @@
1
+ # Rate limiting
2
+
3
+ > Per-endpoint, per-subject and per-tenant request limits via the rpc interceptors. Sliding-window / fixed-window / token-bucket, memory / postgres / redis stores.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/ratelimit.md -->
10
+ ## Rate limiting
11
+
12
+ _Per-endpoint, per-subject and per-tenant request limits via the rpc interceptors. Sliding-window / fixed-window / token-bucket, memory / postgres / redis stores._
13
+
14
+ `@voltro/plugin-ratelimit` enforces request limits at the **rpc interceptor**
15
+ level — per endpoint, per subject, per tenant. Highly configurable: ordered
16
+ rules, three algorithms, composite keying, static + dynamic per-tenant
17
+ overrides, and an optional coarse pre-auth IP shield.
18
+
19
+ Why the rpc interceptors and not an HTTP middleware: rpc traffic rides one
20
+ long-lived WebSocket, so an HTTP-level limiter only ever sees the single
21
+ upgrade — never the individual calls. The Effect-native rpc interceptors are
22
+ the only surface that sees each call **and** the resolved subject (needed for
23
+ per-tenant limits).
24
+
25
+ Live — `demo.limited` is capped at 5/min per visitor; the 6th call within a
26
+ minute fails with the typed `RateLimited` error (carrying `retryAfterMs`):
27
+
28
+ ```tsx
29
+ const call = useAction('app', 'demo.limited') // 5/min → typed RateLimited
30
+ ```
31
+
32
+ ## Quick start
33
+
34
+ ```ts
35
+ // app.config.ts
36
+ import { rateLimitPlugin } from '@voltro/plugin-ratelimit'
37
+
38
+ export default {
39
+ type: 'api' as const,
40
+ name: 'myApi',
41
+ plugins: [
42
+ rateLimitPlugin({
43
+ // global fallback: 100 calls / minute / subject
44
+ default: { limit: 100, window: '1m' },
45
+ rules: [
46
+ // tighter, per-endpoint, token-bucket with burst
47
+ { match: 'todos.create', limit: 10, window: '1m', algorithm: 'token-bucket', burst: 15 },
48
+ // every admin.* mutation, one shared bucket per tenant
49
+ { match: /^admin\./, kind: 'mutation', limit: 1000, window: '1h', by: 'tenant', scope: 'rule' },
50
+ ],
51
+ // per-tenant limits from the subject's plan claim
52
+ resolve: (ctx) =>
53
+ ctx.subject.type !== 'anonymous' && ctx.subject.metadata?.plan === 'enterprise'
54
+ ? { limit: 10_000, window: '1m' }
55
+ : undefined,
56
+ store: 'memory', // 'memory' (default) | 'postgres' | 'redis' (any RESP server)
57
+ }),
58
+ ],
59
+ }
60
+ ```
61
+
62
+ ## Rules — per endpoint
63
+
64
+ Rules are evaluated top-to-bottom; the **first** whose `match` + `kind` accept
65
+ the call wins. If none match, `default` applies; if there's no `default`, the
66
+ call is unlimited.
67
+
68
+ ```ts
69
+ rules: [
70
+ { match: 'todos.create', limit: 10, window: '1m' }, // exact rpc tag
71
+ { match: ['posts.create', 'posts.update'], limit: 30, window: '1m' }, // tag list
72
+ { match: /^admin\./, limit: 1000, window: '1h' }, // RegExp over tags
73
+ ]
74
+ ```
75
+
76
+ | Field | Meaning |
77
+ |---|---|
78
+ | `match` | `string` (exact tag) · `string[]` (any of) · `RegExp` (pattern). Omitted → every tag. |
79
+ | `kind` | `'mutation' \| 'query' \| 'action'` or an array. Omitted → all kinds. Limit writes but not reads. |
80
+ | `limit` | allowance per window. `number`, or `(ctx) => number` for dynamic per-tenant/plan logic. |
81
+ | `window` | `'1m'`, `'10s'`, `'500ms'`, `'2h'`, `'1d'`, or raw milliseconds. Static or `(ctx) => …`. |
82
+ | `algorithm` | `'sliding-window'` (default), `'fixed-window'`, `'token-bucket'`. |
83
+ | `burst` | token-bucket capacity; defaults to `limit`. Ignored by the window algorithms. |
84
+ | `by` | keying dimension(s) — see below. Default `'subject'`. |
85
+ | `scope` | `'tag'` (default: a bucket per endpoint) vs `'rule'` (one bucket across all tags the rule matches). |
86
+ | `tenants` | static per-tenant overrides — see below. |
87
+
88
+ `default` is the same shape minus `match` (it covers whatever no rule matched).
89
+
90
+ ## Algorithms
91
+
92
+ - **`sliding-window`** (default) — weighted-counter approximation; smooth, no
93
+ edge bursts, two integers of state per key.
94
+ - **`fixed-window`** — cheapest; can allow up to 2× at window edges.
95
+ - **`token-bucket`** — smooth refill with a configurable `burst` capacity.
96
+ Good for "N steady-state, but allow a batch of `burst` now and then".
97
+
98
+ ## Keying — who shares a bucket
99
+
100
+ `by` controls what the bucket is keyed on. Composite arrays join into one key.
101
+
102
+ | `by` | Bucket scope |
103
+ |---|---|
104
+ | `'subject'` *(default)* | per authenticated principal (already tenant-scoped) |
105
+ | `'tenant'` | one shared bucket for the whole tenant |
106
+ | `'apiKey'` | per api key (falls back to subject for non-apiKey subjects) |
107
+ | `'global'` | one bucket for the rule across everything |
108
+ | `['tenant','subject']` | composite — one bucket per (tenant, user) |
109
+ | `(ctx) => string` | custom key |
110
+
111
+ `scope` is orthogonal: `'tag'` (default) gives each matched endpoint its own
112
+ bucket; `'rule'` shares one bucket across every tag the rule matches (e.g.
113
+ "200 writes/min total across all mutations").
114
+
115
+ ## Per-tenant limits
116
+
117
+ Two ways, and they compose — static first, then the dynamic hook wins:
118
+
119
+ **Static `tenants` override** on a rule — different limit (or exemption) per tenant id:
120
+
121
+ ```ts
122
+ {
123
+ match: 'ai.chat', limit: 50, window: '1m',
124
+ tenants: {
125
+ free: { limit: 10 },
126
+ ent: { limit: 5_000 },
127
+ internal: { disabled: true }, // exempt this tenant entirely
128
+ },
129
+ }
130
+ ```
131
+
132
+ **Dynamic `resolve` hook** — fully programmatic, sync / Promise / Effect.
133
+ Runs after a rule + its static tenant override resolve; whatever it returns
134
+ wins. Return a limit override, `'exempt'`, or `undefined` to keep the rule's:
135
+
136
+ ```ts
137
+ resolve: (ctx) =>
138
+ ctx.subject.type !== 'anonymous' && ctx.subject.metadata?.plan === 'enterprise'
139
+ ? { limit: 10_000, window: '1m' }
140
+ : undefined,
141
+ ```
142
+
143
+ The `ctx` is `{ tag, kind, subject, tenantId, traceId }`.
144
+
145
+ ## Master gate — `enabled`
146
+
147
+ `false` disables all limiting; a function gates per-call (e.g. exempt a tenant
148
+ or a maintenance bypass). Returning `false` means "no limit for this call".
149
+
150
+ ```ts
151
+ rateLimitPlugin({
152
+ default: { limit: 100, window: '1m' },
153
+ enabled: (ctx) => ctx.tenantId !== 'vip', // VIP tenant is never limited
154
+ })
155
+ ```
156
+
157
+ ## Coarse pre-auth IP shield — `http`
158
+
159
+ Optional front-door guard keyed by remote IP, **before** any subject is
160
+ resolved. It fires on every HTTP request — inspect, webhooks, AND the rpc
161
+ WebSocket upgrade. Use as a cheap DoS shield in addition to the per-subject
162
+ rpc rules. Adds the `http:intercept` permission automatically.
163
+
164
+ ```ts
165
+ rateLimitPlugin({
166
+ default: { limit: 100, window: '1m' },
167
+ http: { limit: 1000, window: '1m' }, // 1000 connections/min/IP at the door
168
+ })
169
+ ```
170
+
171
+ When the shield trips it returns `429` with a `Retry-After` header plus the IETF draft `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers (`RateLimit-Reset` is seconds-until-reset), so the caller can read its quota position.
172
+
173
+ ## Rejected calls — the `RateLimited` error
174
+
175
+ When a call exceeds its limit the interceptor fails with a typed error on the
176
+ rpc error channel. The plugin merges `RateLimited` into **every** procedure's
177
+ wire error union (server group + generated client group stay in lockstep), so
178
+ the client decodes it **typed** — not as an opaque defect:
179
+
180
+ ```ts
181
+ class RateLimited extends Schema.TaggedError('RateLimited')({
182
+ tag: Schema.String, // the rpc tag that was limited
183
+ limit: Schema.Number, // the effective limit hit
184
+ windowMs: Schema.Number, // the window it applies over
185
+ retryAfterMs: Schema.Number, // suggested wait before retry
186
+ resetAtMs: Schema.Number, // epoch-ms when the bucket replenishes
187
+ })
188
+ ```
189
+
190
+ Client narrows on `_tag` and shows a retry timer:
191
+
192
+ ```tsx
193
+ const result = await create.mutate(input).catch((e) => e)
194
+ if (result?._tag === 'RateLimited') {
195
+ toast.error(`Slow down — retry in ${Math.ceil(result.retryAfterMs / 1000)}s`)
196
+ }
197
+ ```
198
+
199
+ For observability, pass `onLimited(info)` — a best-effort callback (swallowed
200
+ on throw) fired on every rejection with `{ tag, kind, key, tenantId, limit, windowMs, retryAfterMs }`.
201
+
202
+ ## Storage backends
203
+
204
+ A rate limiter needs an **atomic** counter (an increment that can't lose
205
+ updates under concurrency), so it can't reuse a plain get/set cache. Three
206
+ stores ship, all fail OPEN on backend errors (degrade to no-limit, never to 500s):
207
+
208
+ - **Memory** *(default)* — single-process, zero-config. Perfect for dev and
209
+ single-instance deploys; not shared across replicas.
210
+ - **Postgres** — multi-node correct. Runs on the framework's **already-open
211
+ `SqlClient`** — the same connection pool the app itself uses — so it reads no
212
+ `DB_*`/`PG_*` env of its own and never opens a second pool. The plugin binds
213
+ that client for you when `store: 'postgres'` is selected. It mutates counter
214
+ state under a `SELECT … FOR UPDATE` row lock plus a transaction-scoped
215
+ advisory lock (cold-key safe). The pg driver (`@effect/sql`) is loaded lazily,
216
+ only on this path — a memory- or redis-only app never bundles it.
217
+ - **Redis (RESP family)** — multi-node, fastest. Runs the whole decision in
218
+ one atomic Lua script. Works against **any RESP-compatible server, not just
219
+ Redis itself**:
220
+
221
+ | Server | Notes |
222
+ |---|---|
223
+ | **Redis** | the reference RESP server |
224
+ | **Valkey** | the Linux-Foundation fork of Redis 7.2 |
225
+ | **KeyDB** | multithreaded, Redis-compatible |
226
+ | **Dragonfly** | modern multi-core drop-in |
227
+ | **Upstash** | serverless Redis over REST (HTTP driver) |
228
+
229
+ `'redis'` is the umbrella for the **whole RESP family** — the server brand
230
+ (Valkey, KeyDB, Dragonfly, …) is just the url; you never pick a per-server
231
+ type. The one real choice is the **driver**, mirroring `@voltro/cache`:
232
+
233
+ - `'resp'` *(default)* — `ioredis` over TCP. Covers Redis, Valkey, KeyDB,
234
+ Dragonfly, and Upstash's TCP endpoint.
235
+ - `'http'` — `@upstash/redis` over REST, for serverless/edge where TCP isn't
236
+ available.
237
+
238
+ Both are optional dependencies, loaded only when the redis store is used.
239
+
240
+ ```ts
241
+ rateLimitPlugin({
242
+ default: { limit: 100, window: '1m' },
243
+ store: 'redis', // 'memory' (default) | 'postgres' | 'redis' (any RESP server)
244
+ })
245
+ ```
246
+
247
+ `store: 'redis'` reads the same env as the cache plugin — `CACHE_REDIS_URL`
248
+ (falling back to `REDIS_URL`), `CACHE_REDIS_DRIVER` (`resp` default | `http`),
249
+ and `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` for the http driver. An app
250
+ already pointed at Valkey/Upstash for caching gets rate-limiting on the same
251
+ backend for free.
252
+
253
+ For a custom client/url/driver — or to share an existing connection — use the factory:
254
+
255
+ ```ts
256
+ import { redisStore } from '@voltro/plugin-ratelimit/redis'
257
+
258
+ // Valkey / KeyDB / Dragonfly / Redis over TCP — just the url:
259
+ store: redisStore({ url: 'rediss://valkey.internal:6379' })
260
+
261
+ // Upstash over REST (serverless/edge):
262
+ store: redisStore({ driver: 'http', url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! })
263
+
264
+ // or share an existing client (ioredis or @upstash/redis):
265
+ store: redisStore({ client: myClient })
266
+ ```
267
+
268
+ Bring your own backend entirely by passing any object that satisfies the
269
+ `RateLimitStore` interface to `store:`.
270
+
271
+ ## See also
272
+
273
+ - [Mutations](/docs/data/mutations) — the interceptor chain rate limiting hooks into
274
+ - [Multi-tenancy](/docs/multi-tenancy) — where `subject.tenantId` comes from