API · SaaS bundle
The SaaS plugin bundle in one domain — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One projects.create mutation exercises billing + analytics + notifications together. Zero-infra boot.
The four cross-cutting SaaS plugins wired turnkey in one cohesive domain — the billing / notifications / analytics / presence slice of the framework end to end, the way api-durable bundles the durable surface. A single projects.create mutation pulls three of them together. Boots zero-infra (store: 'memory'). Template id: api-saas.
Scaffold
voltro create-project acme --api=api-saasWhat ships
apps/acme/api/
├── app.config.ts # billingPlugin + notificationsPlugin + presencePlugin
├── package.json # + the four SaaS plugins
├── database/schema.ts # a tenant-scoped `projects` table
├── queries/projects.list.query.ts # reactive list (+ .server.ts)
└── mutations/projects.create.mutation.ts # the bundle in one handler (+ .server.ts)Each plugin contributes its own routes + tables automatically — you wire them in app.config.ts and consume their services in handlers. The boot banner shows them all: billing.subscription/startCheckout/…, notifications.inbox/markRead/…, presence.heartbeat/list/leave.
The four plugins
// app.config.ts
import { billingPlugin } from '@voltro/plugin-billing'
import { presencePlugin } from '@voltro/plugin-presence'
import { notificationsPlugin, consoleChannel } from '@voltro/plugin-notifications'
plugins: [
billingPlugin({
provider: 'mock', // in-memory; no Stripe key
plans: { // tier → quota, in code
free: { entitlements: { projects: 3 } },
pro: { priceId: 'price_demo_pro', entitlements: { projects: 'unlimited' } },
},
}),
notificationsPlugin({ channels: [consoleChannel()] }), // + a durable in-app inbox
presencePlugin(), // presence.heartbeat / list / leave
]- Billing — subscriptions + entitlements;
plansis the single source of tier→quota truth. Thefreedefault capsprojectsat 3. - Notifications — one
NotificationService.send(...)across channels + an in-app inbox (useInbox()/useUnreadCount()on the client). - Presence — ephemeral "who's online" per channel, plus the
usePresence(channel)hook.
The bundle, in one handler
projects.create is Effect-mode so it can yield* the plugin services the framework provides in the per-request stack:
// mutations/projects.create.mutation.server.ts
import { EffectStore, useAnalytics } from '@voltro/runtime'
import { requireEntitlement } from '@voltro/plugin-billing'
import { NotificationService } from '@voltro/plugin-notifications'
const execute = (input: { name: string }, ctx) =>
Effect.gen(function* () {
yield* requireEntitlement(ctx, 'projects', 1) // 1. BILLING — quota gate
const store = yield* EffectStore
const row = yield* store.insert('projects', { name: input.name }) // tenant() auto-stamps
yield* (yield* useAnalytics()).track({ // 2. ANALYTICS — event
name: 'project_created', subjectId: ctx.request.subject.id, properties: { name: input.name },
})
yield* Effect.promise(() => (yield* NotificationService).send({ // 3. NOTIFICATIONS — inbox + console
to: ctx.request.subject.id ?? 'system', category: 'project',
title: 'Project created', body: `"${input.name}" is live.`,
}))
return { id: row['id'] as string, name: row['name'] as string, tenantId: row['tenantId'] as string }
})The billing plugin auto-merges its typed EntitlementExceeded (+ BillingError) into this procedure's wire-error union — the client decodes the over-quota failure typed, without a manual error: declaration.
Try it (curl)
projects.create and the plugin routes are rpc procedures — call them with useMutation / useSubscription from a web app, or over HTTP via the dev inspect endpoint:
# Create projects — the free plan allows 3
for i in 1 2 3; do
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
-H 'content-type: application/json' \
-d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
done
# → { "ok": true, "result": { "id": "proj_…", … } }
# The 4th trips the billing entitlement
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
-H 'content-type: application/json' \
-d '{"tag":"projects.create","input":{"name":"Project 4"}}'
# → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, "used": 3 } }Each successful create also logs [notify:project] → …: Project created (console channel) and persists a notification_inbox row.
Enable durable analytics
useAnalytics().track(...) is noop-safe with no sink configured (it never crashes), so the event API is reachable on the zero-infra memory store. To actually STORE + query events (aggregate / timeseries / topN), add a sink AND a SQL store — the postgres-lite sink needs a SqlClient:
// app.config.ts
import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
export default {
type: 'api' as const, name: 'AcmeApi',
store: 'postgres' as const, // or 'sqlite' (file:./.voltro/db.sqlite)
analytics: postgresAnalytics(), // events → _voltro_events
plugins: [ /* … */ ],
}Going to production
| Want… | Do |
|---|---|
| Real subscriptions / checkout | billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans }) with real priceIds |
| Email / Slack / SMS notifications | add emailChannel() / webhookChannel() / smsChannel() to channels |
| Durable analytics at scale | postgresAnalytics() on postgres, or swap to @voltro/plugin-clickhouse / @voltro/plugin-duckdb |
| Live presence roster in the UI | subscribe a reactive query over _voltro_presence, or use usePresence(channel) |
Anti-patterns
- Hand-rolling
notifications.*/billing.*/presence.*procedures. The plugins own those tag namespaces — boot fails on a collision. Use the plugin routes + services. - Expecting analytics to persist on
memory. The postgres-lite sink needs aSqlClient.track()is reachable + safe on memory; events land in_voltro_eventsonly once you wire a sink + a SQL store. - Skipping the entitlement gate on a paid action. A scope says "may you call this"; an entitlement says "do you have quota left" — orthogonal. A paid action needs both.
See also
- Plugins overview — the full plugin catalogue + status
api-durable— the durable + reactive bundle (the sibling showcase)- App templates — the full catalogue