API · Feature flags
Feature flags wired turnkey with @voltro/plugin-flags — flags as code (kill-switch / % rollout / targeting), a declarative gatedBy map that fails typed FlagDisabled before the handler, an in-handler requireFlag guard, and flags.evaluate for useFlag() on the web. Memory store, zero infra.
Feature flags without a service. @voltro/plugin-flags declares flags as code and gates rpc calls two ways — declaratively (a gatedBy map fails FlagDisabled before the handler) and in-handler (requireFlag(ctx, key)). A flag is a bare boolean (kill-switch) or a rich definition with a deterministic % rollout and OR-of-rules targeting. The client gets the same evaluation via useFlag(). Memory store is zero-infra; postgres adds runtime toggles. Template id: api-feature-flags.
Scaffold
voltro create-project acme --api=api-feature-flagsFlags as code
// app.config.ts
import { flagsPlugin } from '@voltro/plugin-flags'
flagsPlugin({
flags: {
newEditor: true, // kill-switch (on)
betaExport: false, // kill-switch (off)
proDashboard: { targeting: [{ metadata: { plan: 'pro' } }] }, // targeting
gradualRollout: { rollout: 25 }, // deterministic % rollout
},
gatedBy: { 'notes.export': 'betaExport' }, // tag → flag
store: 'memory',
})Two ways to gate
- Declarative — the
gatedBymap pointsnotes.exportatbetaExport. The framework fails typedFlagDisabledbefore the handler when the flag is off. No code in the handler. - In-handler —
notes.createcalls the Effect-native guard:
// notes.create.mutation.server.ts
import { requireFlag } from '@voltro/plugin-flags'
export default (input, ctx) => Effect.gen(function* () {
yield* requireFlag(ctx, 'newEditor') // fails typed FlagDisabled when off
const store = yield* EffectStore
return yield* store.insert('notes', { title: input.title, body: input.body })
})Declare error: FlagDisabled (from the browser-safe @voltro/plugin-flags/errors) on the procedure so the client decodes it typed.
Try it
# newEditor ON → requireFlag passes → created:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d '{"tag":"notes.create","input":{"tenantId":"acme","title":"hi","body":"x"}}'
# betaExport OFF → gatedBy blocks → typed FlagDisabled:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d '{"tag":"notes.export","input":{}}'
# → { ok:false, error:{ _tag:"FlagDisabled", flag:"betaExport" } }On the web side
import { useFlag } from '@voltro/plugin-flags/web'
const showBeta = useFlag('betaExport') // same targeting + rollout as the server
{showBeta && <BetaExportButton />}Runtime toggles
store: 'postgres' overlays runtime-toggleable flags (_voltro_feature_flags) on the code baseline — flip a kill-switch without a deploy. The dashboard ships a Flags panel for it.
Anti-patterns
- Trusting
useFlagfor enforcement. It's a UI affordance; the server guard (gatedBy/requireFlag) is the real gate. Never gate a write on the client alone. - A
% rolloutkeyed on a non-stable id. Rollout buckets deterministically per subject (or tenant viarolloutBy: 'tenant') — an anonymous/idless caller buckets on'anon', so don't expect per-call variation.