@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,324 @@
|
|
|
1
|
+
# Billing
|
|
2
|
+
|
|
3
|
+
> Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/billing.md -->
|
|
10
|
+
## Billing
|
|
11
|
+
|
|
12
|
+
_Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-billing` wires a billing provider into Voltro's runtime. Subscriptions become rows in your DB; provider webhooks update them; entitlements gate features by quota. It rides `@voltro/plugin-webhooks` for inbound event signature verification + idempotency, so it never re-implements that machinery.
|
|
15
|
+
|
|
16
|
+
All monetary amounts are **integer minor units** (cents for USD/EUR, pence for GBP) paired with a `currency` string — never a float, never a decimal column.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// app.config.ts
|
|
22
|
+
import { billingPlugin } from '@voltro/plugin-billing'
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
type: 'api' as const,
|
|
26
|
+
plugins: [
|
|
27
|
+
billingPlugin({
|
|
28
|
+
provider: 'stripe', // | 'mock' | a BillingProvider
|
|
29
|
+
apiKey: process.env.STRIPE_SECRET_KEY, // server-only; default STRIPE_SECRET_KEY
|
|
30
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, // default STRIPE_WEBHOOK_SECRET
|
|
31
|
+
plans: {
|
|
32
|
+
free: { entitlements: { aiCalls: 100, storageBytes: 1_000_000_000 } },
|
|
33
|
+
pro: { priceId: 'price_…', entitlements: { aiCalls: 5000, storageBytes: 50_000_000_000 } },
|
|
34
|
+
ent: { priceId: 'price_…', entitlements: { aiCalls: 'unlimited', storageBytes: 1_000_000_000_000 } },
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`plans` is the single source of tier→limit truth — it lives in code, not the DB, so limits change by redeploy, not migration. Entitlement values are `number | 'unlimited'`. With no provider configured (and no `STRIPE_SECRET_KEY`), the plugin uses an in-memory **mock provider** — zero-config for dev and tests.
|
|
42
|
+
|
|
43
|
+
What it provides:
|
|
44
|
+
|
|
45
|
+
- The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims) via `extendSchema.tables`.
|
|
46
|
+
- The `BillingService` Context.Tag — yield it in any handler.
|
|
47
|
+
- The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
|
|
48
|
+
- A webhook receiver at `POST /billing/webhook`.
|
|
49
|
+
- The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
|
|
50
|
+
- **Seat-based billing**; proration and failed-payment retries are Stripe's.
|
|
51
|
+
- The typed `BillingError` + `EntitlementExceeded` errors, merged into every procedure's wire error union.
|
|
52
|
+
- A browser-safe `useStartCheckout()` hook on the `/web` subpath.
|
|
53
|
+
|
|
54
|
+
## The BillingService
|
|
55
|
+
|
|
56
|
+
Yield the service in any handler:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { Effect } from 'effect'
|
|
60
|
+
import { BillingService } from '@voltro/plugin-billing'
|
|
61
|
+
|
|
62
|
+
export default (input: { tenantId: string }, _ctx) =>
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const billing = yield* BillingService
|
|
65
|
+
const sub = yield* billing.subscription(input.tenantId) // Subscription | null
|
|
66
|
+
const plan = yield* billing.plan(input.tenantId) // resolves tier, defaults 'free'
|
|
67
|
+
return { plan, status: sub?.status ?? 'none' }
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns `'free'` when there is no active (or trialing) subscription.
|
|
72
|
+
|
|
73
|
+
## Entitlement checks
|
|
74
|
+
|
|
75
|
+
There is **no `guards:` field** on `defineMutation` — procedures carry only `name / input / output / error / target`. Quota enforcement happens one of two real ways.
|
|
76
|
+
|
|
77
|
+
### In-handler — `requireEntitlement`
|
|
78
|
+
|
|
79
|
+
The Effect-native guard, mirroring `requireScope` / `permission()`. It resolves the caller's `tenantId` from the subject, atomically checks-and-decrements the quota, and fails with the typed `EntitlementExceeded` when exhausted:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { Effect } from 'effect'
|
|
83
|
+
import { requireEntitlement } from '@voltro/plugin-billing'
|
|
84
|
+
|
|
85
|
+
export default (input: { tokens: number }, ctx) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
yield* requireEntitlement(ctx, 'aiCalls', Math.ceil(input.tokens / 1000))
|
|
88
|
+
// … the work the quota gates …
|
|
89
|
+
return { ok: true }
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`requireEntitlement` declares `BillingService` in its Effect requirements — the plugin's `services` layer provides it automatically. Compute the cost however you like; pass `1` for a flat per-call charge or a derived integer for metered work.
|
|
94
|
+
|
|
95
|
+
### Declarative — the `enforce` map
|
|
96
|
+
|
|
97
|
+
For per-tag enforcement without touching the handler body, pass an `enforce` map (the rate-limit-plugin pattern). It installs an interceptor that consumes the entitlement BEFORE the executor runs — an over-quota call never reaches your code:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
billingPlugin({
|
|
101
|
+
plans: { /* … */ },
|
|
102
|
+
enforce: {
|
|
103
|
+
'ai.heavy': { entitlement: 'aiCalls', cost: 1 },
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Either form fails with `EntitlementExceeded` (a `Schema.TaggedError` carrying `{ entitlement, limit, used, cost }`), decoded typed on the client. An `'unlimited'` plan limit short-circuits without touching the counter.
|
|
109
|
+
|
|
110
|
+
Quota windows are per calendar month (`YYYY-MM`) per `(tenantId, entitlementKey)`. The check + decrement is a store-level atomic consume — a compare-and-set on the usage row (`UNIQUE(tenant, key, period)`), safe across replicas — that runs OUTSIDE the mutation's transaction: a handler that fails after consuming does not refund the quota.
|
|
111
|
+
|
|
112
|
+
Entitlements are orthogonal to RBAC scopes: a scope answers "may you call this proc"; an entitlement answers "do you have quota left". A procedure can require both.
|
|
113
|
+
|
|
114
|
+
## Webhook handling
|
|
115
|
+
|
|
116
|
+
Provider events land at `POST /billing/webhook`. The plugin rides `@voltro/plugin-webhooks`' Stripe provider preset, so signature verification (the `Stripe-Signature` `t=…,v1=…` scheme, 5-minute replay window) and idempotency (Stripe event `id`, 30-day TTL) come for free. The handler maps the verified payload to a provider-agnostic `BillingEvent`, applies it to the DB rows, then runs any `onEvent` side effect.
|
|
117
|
+
|
|
118
|
+
Events handled:
|
|
119
|
+
|
|
120
|
+
- `customer.subscription.created` / `updated` / `deleted`
|
|
121
|
+
- `invoice.paid` / `invoice.payment_failed`
|
|
122
|
+
- `customer.created` (links the tenant via Stripe metadata)
|
|
123
|
+
|
|
124
|
+
`applyEvent` upserts by provider id, so it's idempotent even under a cross-process replay where the LRU idempotency cache wouldn't catch the duplicate. The webhook needs `tenantId` (and, for subscriptions, `plan`) in the Stripe object's `metadata` — `startCheckout` sets it automatically.
|
|
125
|
+
|
|
126
|
+
Per-event side effects run AFTER the row is updated:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { Effect } from 'effect'
|
|
130
|
+
|
|
131
|
+
billingPlugin({
|
|
132
|
+
plans: { /* … */ },
|
|
133
|
+
onEvent: {
|
|
134
|
+
invoicePaymentFailed: (event) =>
|
|
135
|
+
Effect.sync(() => {
|
|
136
|
+
// event: { _tag: 'invoicePaymentFailed', tenantId, providerInvoiceId, amountMinor, currency }
|
|
137
|
+
console.warn('payment failed for', event.tenantId, event.amountMinor, event.currency)
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`onEvent` keys are the normalized `BillingEvent` tags (`subscriptionUpserted`, `invoicePaid`, `invoicePaymentFailed`, `customerLinked`, `subscriptionCanceled`), not raw Stripe types.
|
|
144
|
+
|
|
145
|
+
## Checkout + upgrade flows
|
|
146
|
+
|
|
147
|
+
The `/web` subpath ships a browser-safe hook. It imports nothing from the server module — no secret, no node-only lib ever reaches the browser bundle. Pass it the generated `billing.startCheckout` rpc binding:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { useStartCheckout } from '@voltro/plugin-billing/web'
|
|
151
|
+
import { useAppClient } from '@voltro/web'
|
|
152
|
+
|
|
153
|
+
const UpgradeButton = () => {
|
|
154
|
+
const app = useAppClient('app')
|
|
155
|
+
const { startCheckout, pending } = useStartCheckout((input) => app.billing.startCheckout(input))
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
disabled={pending}
|
|
159
|
+
onClick={() => startCheckout({ plan: 'pro', successUrl: location.href, cancelUrl: location.href })}
|
|
160
|
+
>
|
|
161
|
+
Upgrade to Pro
|
|
162
|
+
</button>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The rpc calls the provider, returns a hosted checkout URL, and the hook redirects. The provider captures the card; on success it redirects back to your `successUrl` and the webhook updates the subscription row. `startCheckout` fails with `BillingError` for a plan that has no `priceId` (free plans aren't paid checkouts).
|
|
168
|
+
|
|
169
|
+
## Customer portal
|
|
170
|
+
|
|
171
|
+
For self-service plan changes, payment-method updates, and invoice download, mint a provider-hosted portal URL. The tenant must already have a linked provider customer (created on first checkout / `customer.created`):
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { Effect } from 'effect'
|
|
175
|
+
import { BillingService } from '@voltro/plugin-billing'
|
|
176
|
+
|
|
177
|
+
export default (input: { tenantId: string; returnUrl: string }, _ctx) =>
|
|
178
|
+
Effect.gen(function* () {
|
|
179
|
+
const billing = yield* BillingService
|
|
180
|
+
return yield* billing.portalUrl(input.tenantId, input.returnUrl) // { url }
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Usage reporting
|
|
185
|
+
|
|
186
|
+
For metered billing, record usage locally; the service flushes the aggregate to the provider in one batched push per `(tenant, key)`:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const billing = yield* BillingService
|
|
190
|
+
yield* billing.reportUsage(tenantId, 'aiCalls', count)
|
|
191
|
+
// later — typically from a schedule:
|
|
192
|
+
yield* billing.flushUsage()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`flushUsage` is a no-op on providers that report `supportsMeteredUsage: false`. Counters that have already been pushed are not re-sent.
|
|
196
|
+
|
|
197
|
+
## Tables
|
|
198
|
+
|
|
199
|
+
All five are `_voltro_`-prefixed and built from the cross-dialect schema DSL (no raw SQL, no pg-only types, no `TEXT` defaults). Money is `integer` minor units + a `currency` text column:
|
|
200
|
+
|
|
201
|
+
- `_voltro_billing_customers` — tenant ↔ provider customer link.
|
|
202
|
+
- `_voltro_billing_subscriptions` — one subscription per tenant (plan, status, seat `quantity`, period start + end, cancel-at).
|
|
203
|
+
- `_voltro_billing_invoices` — invoice history (`amountMinor` integer + `currency`).
|
|
204
|
+
- `_voltro_billing_usage` — per-tenant metered counters keyed by `(tenantId, entitlementKey, period)`.
|
|
205
|
+
- `_voltro_billing_flush_claims` — INSERT-wins flush-window claims (multi-instance autopilot coordination); short-lived, retention defaults to 1 hour via `VOLTRO_BILLING_FLUSH_CLAIM_TTL_HOURS`.
|
|
206
|
+
|
|
207
|
+
`_voltro_billing_usage` is append-only — one upserted counter row per `(tenant, key, period)` — so a closed period's row would otherwise live forever. The plugin registers a retention sweep on the row's `updatedAt`: a row is only touched while its window is current, so once a period closes it ages out, while the live period's row stays fresh and survives regardless. The bound defaults to ~400 days (a conservative window with headroom for end-of-period flush + back-dated reads) and is tunable via the `VOLTRO_BILLING_USAGE_TTL_HOURS` env var; the boot retention sweep drains rows past the TTL.
|
|
208
|
+
|
|
209
|
+
Card data never touches the DB — the provider's hosted portal owns it, so there is no payment-methods table.
|
|
210
|
+
|
|
211
|
+
## Usage-based billing autopilot
|
|
212
|
+
|
|
213
|
+
Make the app meter + bill its **own** per-tenant usage with one declaration — no hand-wired counters. Pass `metering` and the plugin derives usage from the graph's own telemetry, then flushes it to the provider on a schedule:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
billingPlugin({
|
|
217
|
+
provider: 'stripe',
|
|
218
|
+
plans: { pro: { priceId: 'price_…', entitlements: { apiCalls: 100_000 } } },
|
|
219
|
+
metering: {
|
|
220
|
+
apiCalls: { from: 'rpc', match: /^orders\./ }, // count SUCCESSFUL rpc calls (match: string | RegExp; optional kind)
|
|
221
|
+
rows: { from: 'cdc', table: 'orders' }, // count row writes (default op: insert)
|
|
222
|
+
aiTokens: { from: 'ai' }, // sum the _voltro_ai_usage ledger (metric: 'tokens' | 'costMicroUsd')
|
|
223
|
+
},
|
|
224
|
+
// flushIntervalMs: 60_000, // 0 disables the self-scheduled flush — call billing.flushUsage() from your own *.cron.tsx
|
|
225
|
+
})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Each source taps something the framework **already tracks**:
|
|
229
|
+
|
|
230
|
+
- **`rpc`** — the rpc interceptor counts matching calls per tenant, AFTER they succeed (a quota-rejected or failed call isn't counted).
|
|
231
|
+
- **`cdc`** — the post-commit ChangeEvent stream counts row writes to `table`; the tenant comes from the row's `tenantId`.
|
|
232
|
+
- **`ai`** — at flush time the autopilot sums the `_voltro_ai_usage` ledger (written by `@voltro/ai`'s `recordAiUsage`) per tenant for the current period and reports the **delta** vs. what's already accrued — so re-running is idempotent.
|
|
233
|
+
|
|
234
|
+
All three route through the existing `BillingService.reportUsage` (a local per-period counter) + `flushUsage` (the batched provider push). Anonymous (tenant-less) calls aren't metered.
|
|
235
|
+
|
|
236
|
+
**Idempotency + multi-instance.** The self-scheduled flush is per-period idempotent (`markReported` makes a re-flush a no-op) AND **cluster-coordinated by default**: each replica self-schedules, but an INSERT-wins claim on the flush window (`_voltro_billing_flush_claims`) means exactly one replica flushes a given window — so two replicas never double-push it, with no extra wiring. Set `flushIntervalMs: 0` to disable the timer entirely and drive `billing.flushUsage()` from your own `*.cron.tsx` instead.
|
|
237
|
+
|
|
238
|
+
**Boundaries (v1).** Metering captures writes the framework observes through `ctx.store` — the bulk helpers (`updateMany` / `deleteMany`) emit per-row ChangeEvents that ARE counted, but a single bulk SQL escape-hatch write isn't. The meter is best-effort post-commit telemetry, not a financial ledger of record.
|
|
239
|
+
|
|
240
|
+
## Plans, seats & the billed amount
|
|
241
|
+
|
|
242
|
+
A plan carries an optional per-seat **unit amount** (integer minor units) and a `currency`, alongside its entitlement limits:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
billingPlugin({
|
|
246
|
+
plans: {
|
|
247
|
+
free: { entitlements: { seats: 3 } },
|
|
248
|
+
starter: { priceId: 'price_starter', unitAmountMinor: 1000, currency: 'usd', entitlements: { seats: 10 } },
|
|
249
|
+
pro: { priceId: 'price_pro', unitAmountMinor: 3000, currency: 'usd', entitlements: { seats: 'unlimited' } },
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
A subscription carries a **seat `quantity`** (default 1). The billed amount is `plan.unitAmountMinor × quantity` — all integer minor units, no float. The quantity comes off the provider event (Stripe's first line-item quantity) and round-trips through the subscription row.
|
|
255
|
+
|
|
256
|
+
## Proration — Stripe's, and it is actually billed
|
|
257
|
+
|
|
258
|
+
A mid-cycle change (plan upgrade/downgrade, seat change) settles the difference for the unused remainder of the period. **Stripe computes it and invoices it** — the framework does not do this arithmetic, because a number we computed ourselves would differ from the charge by Stripe's rounding, its tax calculation, and any credit balance on the customer, and every one of those differences is a support ticket.
|
|
259
|
+
|
|
260
|
+
`changePlan` / `changeSeats` apply the change AT Stripe with `proration_behavior: create_prorations` and return what Stripe booked:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import { Effect } from 'effect'
|
|
264
|
+
import { BillingService } from '@voltro/plugin-billing'
|
|
265
|
+
|
|
266
|
+
export default (input: { tenantId: string }, _ctx) =>
|
|
267
|
+
Effect.gen(function* () {
|
|
268
|
+
const billing = yield* BillingService
|
|
269
|
+
const change = yield* billing.changePlan(input.tenantId, 'pro')
|
|
270
|
+
const seats = yield* billing.changeSeats(input.tenantId, 5)
|
|
271
|
+
return { prorationMinor: change.prorationMinor, seatsProration: seats.prorationMinor }
|
|
272
|
+
})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Each returns `{ plan, quantity, prorationMinor, currency }`, where `prorationMinor` is the sum of Stripe's proration lines on the upcoming invoice — positive is a charge, negative a credit. The local subscription row is written from Stripe's answer, not from what was requested, so the two cannot drift.
|
|
276
|
+
|
|
277
|
+
### Quote before you charge
|
|
278
|
+
|
|
279
|
+
To show a figure in a confirmation dialog, use `previewChange` — it reads Stripe's invoice preview without applying anything:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
|
|
283
|
+
// → { plan, quantity, prorationMinor, currency } nothing has changed yet
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Never quote a locally estimated number. The one Stripe previews is the one it charges.
|
|
287
|
+
|
|
288
|
+
## Failed payments — Stripe retries, you read the status
|
|
289
|
+
|
|
290
|
+
There is no dunning subsystem here. Stripe Smart Retries runs the retry schedule (configured in the Stripe Dashboard, where it can use Stripe's own timing models) and reports the outcome as a subscription status change:
|
|
291
|
+
|
|
292
|
+
| From | On | To |
|
|
293
|
+
| --- | --- | --- |
|
|
294
|
+
| `active` | payment fails | `pastDue` |
|
|
295
|
+
| `pastDue` | payment recovers | `active` |
|
|
296
|
+
| `pastDue` | Stripe gives up | `canceled` |
|
|
297
|
+
|
|
298
|
+
Those transitions arrive as `customer.subscription.updated` webhooks and land on the subscription row. **Key your UI on `subscription.status`**, not on a retry record:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
const sub = yield* billing.subscription(tenantId)
|
|
302
|
+
if (sub?.status === 'pastDue') {
|
|
303
|
+
// Show a "update your payment method" banner + a link to the billing portal.
|
|
304
|
+
const { url } = yield* billing.portalUrl(tenantId, returnUrl)
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`pastDue` deliberately keeps the customer's entitlements: a bounced card is a payment problem, and locking an organisation out of its own data over a bank decline is a support incident, not enforcement. Stripe cancels when it has genuinely given up, and `canceled` is what removes access.
|
|
309
|
+
|
|
310
|
+
## Provider portability
|
|
311
|
+
|
|
312
|
+
The surface (`BillingService`, the entitlement engine, the DB rows) is provider-agnostic. A `BillingProvider` is a dumb adapter: checkout/portal URL minting, usage push, and a pure `normalizeEvent` mapping the provider's payload to a `BillingEvent`. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { billingPlugin } from '@voltro/plugin-billing'
|
|
316
|
+
import { myProvider } from './my-provider'
|
|
317
|
+
|
|
318
|
+
billingPlugin({ provider: myProvider(), plans: { /* … */ } })
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## See also
|
|
322
|
+
|
|
323
|
+
- [plugin-webhooks](/docs/plugins/webhooks) — the inbound signature + idempotency machinery billing rides
|
|
324
|
+
- [plugin-rbac](/docs/plugins/rbac) — scopes (the orthogonal "may you call this" axis)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Broadcast
|
|
2
|
+
|
|
3
|
+
> Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/broadcast.md -->
|
|
10
|
+
## Broadcast
|
|
11
|
+
|
|
12
|
+
_Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-broadcast` closes the cross-replica reactivity gap for dialects that have no native cross-instance change feed. Postgres fans out natively via `LISTEN/NOTIFY` and mariadb via binlog CDC; every other dialect (mysql / mssql / cockroach / planetscale / azure) emits change events **in-process on the writing instance only**. Without a bus, a write on one pod never wakes a subscription on another pod.
|
|
15
|
+
|
|
16
|
+
**Status:** ✓ shipped.
|
|
17
|
+
|
|
18
|
+
> For the full cross-replica reactivity story — when you need it, the dialect matrix, and the boot-log signals — see [Database → Multi-replica](/docs/database/multi-replica). This page documents the plugin itself.
|
|
19
|
+
|
|
20
|
+
## Wiring
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// app.config.ts
|
|
24
|
+
import { broadcastPlugin } from '@voltro/plugin-broadcast'
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
type: 'api' as const,
|
|
28
|
+
name: 'api',
|
|
29
|
+
plugins: [broadcastPlugin()],
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Broadcast is opt-in — it never turns on from a shared `REDIS_URL` alone.** The bus enables only on an explicit signal: `BROADCAST_URL`, `BROADCAST_REDIS_URL`, `BROADCAST_PROVIDER`, or the `connection` / `provider` / `url` option. So a deployment running the cache on `REDIS_URL` keeps broadcast on the single-process memory bus until you deliberately switch it on. This differs from `ctx.cache` / `ctx.kv`, which are gated by their own `CACHE_BACKEND` / `KV_BACKEND` selectors — same principle, explicit per concern.
|
|
34
|
+
|
|
35
|
+
Once enabled on redis, the connection resolves own → shared: an explicit `url` / broker-agnostic `BROADCAST_URL` → `BROADCAST_REDIS_URL` (this connection's own) → the shared `REDIS_URL`. A `redis://` / `rediss://` URL selects Redis; a `nats://` URL selects NATS.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
broadcastPlugin({ connection: 'broadcast' }) // opt in + share the app's redis: BROADCAST_REDIS_URL → REDIS_URL
|
|
39
|
+
broadcastPlugin({ provider: 'redis', url: process.env.BROADCAST_URL }) // or an explicit url / provider
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The `connection` option follows the same `<NAME>_REDIS_URL` → `REDIS_URL` convention as the [cache, KV and rate limiter](/docs/caching/key-value) — but setting it (or another explicit signal) is the deliberate opt-in; the shared `REDIS_URL` is only the connection detail. NATS stays broker-agnostic via `BROADCAST_URL`.
|
|
43
|
+
|
|
44
|
+
`BroadcastPluginOptions`: `provider` (`'redis' | 'nats' | 'memory'` or a `BroadcastProvider`), `url`, `connection` (named redis connection, default `'broadcast'`), and `name` (disambiguates multiple instances of the plugin in one app — the second instance's name becomes `@voltro/plugin-broadcast#<name>`).
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
The plugin is mostly a **carrier**: it resolves a `BroadcastProvider` at construction and exposes it so the serve pipeline can attach the bus to the live `DataStore` **after** the store is built (the bus needs the store's `onChange` + `injectExternalChange` seam, which doesn't exist at plugin-activation time).
|
|
49
|
+
|
|
50
|
+
The bus is **additive** to the inline emit path:
|
|
51
|
+
|
|
52
|
+
- It publishes `{ origin, event }` on the `<namespace>:changes` channel.
|
|
53
|
+
- It injects remote events into every other replica's store, skipping its own origin so there's no double-emit.
|
|
54
|
+
- A broker outage degrades cross-replica fan-out only — local reactivity keeps working.
|
|
55
|
+
|
|
56
|
+
The plugin declares the `network:outbound:*` permission. The boot banner names the resolved tier (cross-instance via redis/nats, or off for the dialect when no broker is configured).
|
|
57
|
+
|
|
58
|
+
## A dropped message cannot leave a client stale
|
|
59
|
+
|
|
60
|
+
Pub/sub has no retention. If a replica's broker connection blips it simply never
|
|
61
|
+
learns that a change happened — and its clients keep their sockets, so the
|
|
62
|
+
client-side reconnect never fires and their live queries never re-run. They would
|
|
63
|
+
show stale rows until something else touched the same table, which on a quiet
|
|
64
|
+
table can be never.
|
|
65
|
+
|
|
66
|
+
Every change carries a **per-origin serial**, so a receiving replica can tell
|
|
67
|
+
exactly how many it missed — a count, not an estimate. On a gap it re-runs every
|
|
68
|
+
live subscription.
|
|
69
|
+
|
|
70
|
+
That works because **a live query is idempotent**: re-running one always lands on
|
|
71
|
+
the truth, so a proven loss is repaired by refreshing rather than by replaying
|
|
72
|
+
something nobody kept. The refresh goes through each subscription's own
|
|
73
|
+
descriptor, so guards, row filters and tenant predicates apply unchanged — and it
|
|
74
|
+
is a re-query, not a push: if the snapshot has not moved, the subscriber sees
|
|
75
|
+
nothing.
|
|
76
|
+
|
|
77
|
+
A replica that just started reports no gap however high a peer's serial is. It
|
|
78
|
+
missed nothing; it was not there.
|
|
79
|
+
|
|
80
|
+
Nothing to configure. It follows from having a broker.
|
|
81
|
+
|
|
82
|
+
## Sharing one broker between apps — the namespace
|
|
83
|
+
|
|
84
|
+
Every framework channel on the broker hangs off **one namespace**: `<ns>:changes`,
|
|
85
|
+
`<ns>:events:<name>`, `<ns>:members`, `<ns>:presence`.
|
|
86
|
+
|
|
87
|
+
It defaults to your app's name, so **two different apps pointed at one Redis or
|
|
88
|
+
NATS separate on their own** — nothing to configure, and nothing to forget.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
broadcast({ provider: 'redis', namespace: 'shop-prod' })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Set it explicitly for the one case the default cannot see: **several deployments
|
|
95
|
+
of the same app on one broker.** Staging and production share a name, share the
|
|
96
|
+
code and share every fingerprint, so nothing derivable tells them apart. There,
|
|
97
|
+
this option — or `VOLTRO_BROADCAST_NAMESPACE` — is the only thing that works.
|
|
98
|
+
|
|
99
|
+
<Callout type="warn">
|
|
100
|
+
If you are upgrading and used `broadcast({ channel })`, note what the codemod
|
|
101
|
+
tells you: **that option never took effect.** It was declared, and documented as
|
|
102
|
+
the fix for exactly this, and nothing read it — your deployments were sharing
|
|
103
|
+
channels regardless of what you set. `voltro update` rewrites it to `namespace`
|
|
104
|
+
and strips the trailing `:changes`, since the framework appends the channel kind
|
|
105
|
+
itself.
|
|
106
|
+
</Callout>
|
|
107
|
+
|
|
108
|
+
Resolution order: `broadcast({ namespace })` → `VOLTRO_BROADCAST_NAMESPACE` →
|
|
109
|
+
your app's name. The value is lowercased and reduced to `a-z0-9_-`, which closes
|
|
110
|
+
three things NATS punishes (measured against nats:2 — Redis is indifferent):
|
|
111
|
+
|
|
112
|
+
| In a name | What NATS does |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| a `.` beside a `>` (`shop.>`) | matches `shop.other` — wildcards are token-level, and tokens are dot-separated |
|
|
115
|
+
| a name that IS `>` or `*` | matches **every** subject on the server |
|
|
116
|
+
| whitespace (`My App`) | rejects the subject outright — the app receives nothing at all |
|
|
117
|
+
|
|
118
|
+
A wildcard *inside* a token is harmless (`shop>:changes` does not match
|
|
119
|
+
`other:changes`), so the dangerous inputs are narrower than they look — and a
|
|
120
|
+
name that reduces to nothing falls through to the next candidate rather than
|
|
121
|
+
becoming an empty prefix.
|
|
122
|
+
|
|
123
|
+
## The namespace is a broker subject — and it is normalised
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
VOLTRO_BROADCAST_NAMESPACE="prod env" # → resolves to "prod-env"
|
|
127
|
+
VOLTRO_BROADCAST_NAMESPACE="prod.env" # → ALSO "prod-env" — same channel
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Whitespace, dots and wildcards are folded to `-` and the result is lowercased,
|
|
131
|
+
because the value becomes a broker subject: NATS refuses a subject containing
|
|
132
|
+
whitespace outright and delivers nothing, with no error on the publishing side.
|
|
133
|
+
|
|
134
|
+
**Two differently-configured deployments can therefore collapse onto one
|
|
135
|
+
channel** — which is what this option exists to prevent. Nothing refuses (the
|
|
136
|
+
resolved value is safe either way, and failing a boot over a dot would be worse
|
|
137
|
+
than the collapse), but the boot logs the substitution whenever it changes what
|
|
138
|
+
you wrote. If you see it, check that the RESOLVED names differ, not the ones you
|
|
139
|
+
typed.
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
## Caveat — app-mutation changes only
|
|
143
|
+
|
|
144
|
+
The bus carries changes written through `ctx.store` (app mutations). It does **not** capture out-of-band DB writes (a `psql` session, another service) — only postgres `LISTEN/NOTIFY` and mariadb binlog observe those. Without a broker URL the plugin falls back to the in-process memory bus (single-process only) and warns at boot.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# CDC-out (reverse-ETL)
|
|
2
|
+
|
|
3
|
+
> Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/cdc-out.md -->
|
|
10
|
+
## CDC-out (reverse-ETL)
|
|
11
|
+
|
|
12
|
+
_Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
|
|
13
|
+
|
|
14
|
+
# CDC-out — declarative reverse-ETL
|
|
15
|
+
|
|
16
|
+
`@voltro/plugin-cdc-out` streams your table changes OUTWARD to an external
|
|
17
|
+
sink. Declare `{ table → sink, map }` and the plugin mirrors every change
|
|
18
|
+
through a **durable outbox** in your app's own database — no separate
|
|
19
|
+
Debezium / Fivetran pipeline. The data-team wedge: app data in the warehouse,
|
|
20
|
+
declared in code, composed onto the change stream you already have.
|
|
21
|
+
|
|
22
|
+
## Wiring
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// app.config.ts
|
|
26
|
+
import { cdcOutPlugin, webhookSink } from '@voltro/plugin-cdc-out'
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
type: 'api' as const,
|
|
30
|
+
name: 'api',
|
|
31
|
+
plugins: [
|
|
32
|
+
cdcOutPlugin({
|
|
33
|
+
sinks: [
|
|
34
|
+
{
|
|
35
|
+
table: 'orders',
|
|
36
|
+
sink: webhookSink('https://warehouse.example.com/ingest'),
|
|
37
|
+
// Map a row → the outbound record body (default: the row unchanged).
|
|
38
|
+
map: (row) => ({ id: row.id, total: row.total, status: row.status }),
|
|
39
|
+
// Optional: only mirror some changes.
|
|
40
|
+
filter: (event) => event.op !== 'delete',
|
|
41
|
+
batchSize: 500, // max records per delivered batch (default 100)
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
maxAttempts: 5, // per-record delivery attempts before dead-letter
|
|
45
|
+
backoffBaseMs: 200, // first retry delay; doubles per attempt, jittered
|
|
46
|
+
deliveryTimeoutMs: 10_000, // per-attempt timeout — aborts the sink call
|
|
47
|
+
}),
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
One sink config per table per instance — a duplicate `table` is a **loud boot
|
|
53
|
+
error**. For multiple sinks on one table, wire a second instance with a
|
|
54
|
+
`name` (`cdcOutPlugin({ name: 'analytics', sinks: […] })`); it suffixes the
|
|
55
|
+
plugin name (`@voltro/plugin-cdc-out#analytics`) and the inspect mount
|
|
56
|
+
(`/_voltro/inspect/plugins/cdc-out--analytics/…`).
|
|
57
|
+
|
|
58
|
+
## Sinks
|
|
59
|
+
|
|
60
|
+
- **`memorySink()`** — in-process, records every delivered batch. Dev + tests.
|
|
61
|
+
- **`webhookSink(url, { headers? })`** — POSTs each batch as
|
|
62
|
+
`{ records: [...] }` JSON, honoring the engine's per-attempt abort signal.
|
|
63
|
+
Its host is declared as a `network:outbound:<host>` permission automatically.
|
|
64
|
+
- **Warehouse / Kafka** — implement the `CdcSink` interface
|
|
65
|
+
(`{ name, deliver(batch, ctx), outboundHost? }`). `deliver` may return a
|
|
66
|
+
**Promise or an Effect** — both compose without wrapping. The engine is
|
|
67
|
+
connector-agnostic; the sink is the only thing that changes.
|
|
68
|
+
|
|
69
|
+
## Delivery guarantees — exactly what holds
|
|
70
|
+
|
|
71
|
+
Every mirrored change becomes a row in `_voltro_cdcout_outbox` (contributed
|
|
72
|
+
via `extendSchema`, migrated by `voltro dev`). The row's TypeID id **is** the
|
|
73
|
+
record's `deliveryKey` — unique across replicas, stable across restarts and
|
|
74
|
+
retries.
|
|
75
|
+
|
|
76
|
+
- **Exactly-once ENQUEUE, fleet-wide.** On `changeScope: 'local'` stores each
|
|
77
|
+
replica enqueues only its OWN commits (injected cross-replica events are
|
|
78
|
+
skipped). On `'fleet'` stores (postgres `changeStrategy: 'cdc'`
|
|
79
|
+
LISTEN/NOTIFY, mysql binlog) only the holder of the leader lease
|
|
80
|
+
(`_voltro_cdcout_leases`, TTL-heartbeat) enqueues. Fail closed: no lease,
|
|
81
|
+
no enqueue.
|
|
82
|
+
- **At-least-once FROM ENQUEUE.** The tap is post-commit — a crash in the
|
|
83
|
+
narrow window between commit and the outbox insert loses that one event;
|
|
84
|
+
the plugin does not claim better. From enqueue on, delivery survives
|
|
85
|
+
restarts, retries with exponential backoff + jitter, and re-sends the SAME
|
|
86
|
+
`deliveryKey`s — a sink that upserts on them dedupes safely.
|
|
87
|
+
- **Ordered per pipe.** One in-flight batch per pipe, consumed strictly in
|
|
88
|
+
commit (id) order; the queue is head-blocking, so a batch waiting out its
|
|
89
|
+
retry backoff is never overtaken.
|
|
90
|
+
- **Dead-letter, never silent drop.** A record that exhausts `maxAttempts`
|
|
91
|
+
moves to status `dead` with its last error — queryable at
|
|
92
|
+
`GET /_voltro/inspect/plugins/cdc-out/dead-letter` — and unblocks the pipe.
|
|
93
|
+
- **Bounded storage.** Delivered/dead rows are purged by the framework
|
|
94
|
+
retention sweep after `retentionHours` (default 72, env
|
|
95
|
+
`CDCOUT_RETENTION_HOURS`); pending rows are never purged.
|
|
96
|
+
|
|
97
|
+
## Multi-tenancy
|
|
98
|
+
|
|
99
|
+
The change tap fires for **every tenant's rows**, and a sink is app-global,
|
|
100
|
+
developer-authored config: whatever `map` returns (default: the raw row,
|
|
101
|
+
including `tenantId`) is mirrored to ONE external endpoint. Use `filter` /
|
|
102
|
+
`map` to scope or strip tenant data, and only ever point sinks at
|
|
103
|
+
**operator-controlled** URLs — never at a tenant-supplied one.
|
|
104
|
+
|
|
105
|
+
## Backfill
|
|
106
|
+
|
|
107
|
+
Seed a pipe with existing rows once — they flow through the SAME durable
|
|
108
|
+
outbox as live changes (ordered, retried, dead-lettered, stable per-row
|
|
109
|
+
`deliveryKey`s). Call from a `*.startup.tsx` or a CLI with the app's store:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { enqueueBackfill } from '@voltro/plugin-cdc-out'
|
|
113
|
+
import { sinkConfig } from './cdc'
|
|
114
|
+
|
|
115
|
+
const n = await enqueueBackfill(store, sinkConfig, rows) // every current row, enqueued as an insert
|
|
116
|
+
```
|