@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.
- package/CHANGELOG.md +163 -0
- package/dist/apiBuild-BOqL1dlg.js +2 -0
- package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
- package/dist/appGraph-CvQCte0z.js +2 -0
- package/dist/appGraph-gQ_6GkQQ.js +110 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-ClRvH_SN.js +2 -0
- package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
- package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
- package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
- package/dist/dbCommand-BwQ_Jl98.js +2 -0
- package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
- package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
- package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
- package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
- package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
- package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
- package/dist/serveEntry.js +2 -2
- package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +43 -43
- package/templates/agent-docs/_index.md +43 -43
- package/templates/agent-docs/_manifest.json +84 -42
- package/templates/agent-docs/cli.md +4 -2
- package/templates/agent-docs/database/migrations.md +14 -1
- package/templates/agent-docs/internationalization.md +54 -3
- package/templates/agent-docs/plugins/ai-flows.md +148 -0
- package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
- package/templates/agent-docs/plugins/atlassian.md +378 -0
- package/templates/agent-docs/plugins/audit.md +324 -0
- package/templates/agent-docs/plugins/auth-auth0.md +92 -0
- package/templates/agent-docs/plugins/auth-clerk.md +90 -0
- package/templates/agent-docs/plugins/auth-kinde.md +88 -0
- package/templates/agent-docs/plugins/auth-oidc.md +95 -0
- package/templates/agent-docs/plugins/auth-supabase.md +103 -0
- package/templates/agent-docs/plugins/auth-workos.md +105 -0
- package/templates/agent-docs/plugins/auth.md +227 -0
- package/templates/agent-docs/plugins/billing.md +324 -0
- package/templates/agent-docs/plugins/broadcast.md +144 -0
- package/templates/agent-docs/plugins/cdc-out.md +116 -0
- package/templates/agent-docs/plugins/clickhouse.md +91 -0
- package/templates/agent-docs/plugins/datadog.md +105 -0
- package/templates/agent-docs/plugins/deactivation.md +114 -0
- package/templates/agent-docs/plugins/duckdb.md +82 -0
- package/templates/agent-docs/plugins/flags.md +146 -0
- package/templates/agent-docs/plugins/governance.md +143 -0
- package/templates/agent-docs/plugins/licensing.md +74 -0
- package/templates/agent-docs/plugins/logship.md +56 -0
- package/templates/agent-docs/plugins/mail.md +334 -0
- package/templates/agent-docs/plugins/moderation.md +65 -0
- package/templates/agent-docs/plugins/multitenancy.md +99 -0
- package/templates/agent-docs/plugins/notifications.md +252 -0
- package/templates/agent-docs/plugins/openapi.md +94 -0
- package/templates/agent-docs/plugins/postgis.md +140 -0
- package/templates/agent-docs/plugins/posthog.md +91 -0
- package/templates/agent-docs/plugins/presence.md +115 -0
- package/templates/agent-docs/plugins/prometheus.md +124 -0
- package/templates/agent-docs/plugins/ratelimit.md +274 -0
- package/templates/agent-docs/plugins/rbac.md +270 -0
- package/templates/agent-docs/plugins/scim.md +63 -0
- package/templates/agent-docs/plugins/search.md +116 -0
- package/templates/agent-docs/plugins/sentry.md +192 -0
- package/templates/agent-docs/plugins/soft-delete.md +72 -0
- package/templates/agent-docs/plugins/sso-saml.md +132 -0
- package/templates/agent-docs/plugins/storage.md +709 -0
- package/templates/agent-docs/plugins/tinybird.md +92 -0
- package/templates/agent-docs/plugins/versioning.md +226 -0
- package/templates/agent-docs/plugins/webhooks.md +585 -0
- package/templates/agent-docs/security.md +12 -1
- package/templates/agent-docs/whats-new.md +81 -37
- 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-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/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-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/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-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +9 -9
- 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-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- 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/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +2 -2
- package/dist/apiBuild-Ca6Sq5jy.js +0 -2
- package/dist/checkCommand-DHD0aBeR.js +0 -2
- package/dist/dbCommand-wfGi3FgG.js +0 -2
- 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
|