Flag
Feature flags with a unified API, scoped per-user or per-team, and optional rich values for A/B testing. Built-in drivers for PostgreSQL (persistent) and in-memory (testing). Custom drivers can be added viaextend().
Installation
bun add @strav/flag
bun strav install flag
The install command copies config/flag.ts into your project. The file is yours to edit.
Setup
1. Register FlagManager
Using a service provider (recommended)
import { FlagProvider } from '@strav/flag'
app.use(new FlagProvider())
The FlagProvider registers FlagManager as a singleton. It depends on the config and database providers, and auto-creates the _strav_features table on boot.
To skip auto-creation (e.g. when using migrations):
app.use(new FlagProvider({ ensureTables: false }))
Manual setup
import FlagManager from '@strav/flag'
app.singleton(FlagManager)
app.resolve(FlagManager)
await FlagManager.ensureTables()
2. Configure drivers
Editconfig/flag.ts:
import { env } from '@strav/kernel'
export default {
default: env('FLAG_DRIVER', 'database'),
drivers: {
database: {
driver: 'database',
},
array: {
driver: 'array',
},
},
}
Defining features
Features must be defined before they can be checked. Define them during app bootstrap, after the provider boots.
Closure-based
import { flag } from '@strav/flag'
// Boolean flag
flag.define('new-checkout', true)
// Dynamic — receives the serialized scope key (e.g. "User:42")
flag.define('beta-ui', (scope) => {
if (scope === '__global__') return false
const id = parseInt(scope.split(':')[1])
return id % 10 === 0 // 10% rollout
})
// Rich value for A/B testing
flag.define('checkout-variant', (scope) => {
const id = parseInt(scope.split(':')[1] ?? '0')
return id % 2 === 0 ? 'variant-a' : 'variant-b'
})
Class-based
import type { FeatureClass } from '@strav/flag'
class NewBillingExperience implements FeatureClass {
static readonly key = 'new-billing'
resolve(scope: string) {
return scope !== '__global__'
}
}
flag.defineClass(NewBillingExperience)
If key is not set, the class name is converted to kebab-case (NewBillingExperience → new-billing-experience).
Checking features
import { flag } from '@strav/flag'
if (await flag.active('new-checkout')) {
// feature is on
}
if (await flag.inactive('new-checkout')) {
// feature is off
}
// Rich value
const variant = await flag.value('checkout-variant') as string
Conditional execution
const html = await flag.when(
'new-checkout',
(value) => renderNewCheckout(value),
() => renderOldCheckout(),
)
The onActive callback receives the resolved value. The onInactive callback receives no arguments.
Batch check
const values = await flag.values(['new-checkout', 'beta-ui', 'checkout-variant'])
// Map { 'new-checkout' => true, 'beta-ui' => false, 'checkout-variant' => 'variant-a' }
Scoping
By default, features resolve at the global scope. Use.for() to scope to a specific entity:
const user = ctx.get<User>('user')
await flag.for(user).active('beta-ui')
await flag.for(user).value('checkout-variant')
await flag.for(user).values(['feat-a', 'feat-b'])
Any object with an id property works as a scope. The scope is serialized as ClassName:id (e.g. User:42, Team:7).
To customize the type prefix, implement featureScope():
class Workspace {
id = 5
featureScope() { return 'Workspace' }
}
// Serializes to "Workspace:5"
Global scope
When no scope is passed, features resolve against the__global__ scope. This is useful for system-wide flags like maintenance mode.
Manual activation
Override the resolver and store a specific value:
// Activate for the global scope
await flag.activate('maintenance-mode')
// Activate for a specific user
await flag.for(user).activate('beta-ui')
// Activate with a rich value
await flag.activate('checkout-variant', 'variant-c')
// Deactivate
await flag.deactivate('maintenance-mode')
await flag.for(user).deactivate('beta-ui')
Activate/deactivate for everyone
Shorthand for the global scope:
await flag.activateForEveryone('maintenance-mode')
await flag.deactivateForEveryone('maintenance-mode')
// With a rich value
await flag.activateForEveryone('checkout-variant', 'variant-b')
Resolution flow
Whenflag.value('feature', scope) is called:
- In-memory cache — return immediately if cached
- Store lookup — check the database for a persisted value
- Resolver — call the closure/class
resolve()method - Persist — store the resolved value in the database
- Cache — cache in memory for the rest of the request
forget() to force re-resolution.
Eager loading
When rendering a list where you check features per-item, eager-load to avoid N+1 queries:
const users = await User.all()
await flag.load(['beta-ui', 'checkout-variant'], users)
for (const user of users) {
// These are now cached — no additional DB queries
if (await flag.for(user).active('beta-ui')) {
// ...
}
}
Cleanup
// Remove stored value for the current scope — forces re-resolution on next check
await flag.forget('beta-ui')
await flag.for(user).forget('beta-ui')
// Remove all stored values for a feature (all scopes)
await flag.purge('beta-ui')
// Remove everything
await flag.purgeAll()
Cache flushing
The in-memory cache lives for the duration of the process. Flush it to force a re-read from the database:
flag.flushCache()
Middleware
Gate routes behind a feature flag withensureFeature(). Returns a 403 JSON response when the feature is not active.
import { ensureFeature } from '@strav/flag'
router.group({ middleware: [auth(), ensureFeature('beta-ui')] }, (r) => {
r.get('/beta/dashboard', betaDashboard)
})
The middleware uses ctx.get('user') as the scope by default. Pass a custom scope extractor for other entities:
import { compose } from '@strav/http'
r.get('/team/:id/analytics', compose(
[ensureFeature('team-analytics', (ctx) => ctx.get('team'))],
analyticsHandler,
))
Events
Feature operations emit events through theEmitter:
| Event | Payload | When |
|---|---|---|
flag:resolved |
{ feature, scope, value } |
Value resolved for the first time |
flag:updated |
{ feature, scope, value } |
Value changed via activate/deactivate |
flag:deleted |
{ feature, scope } |
Value forgotten or purged |
import Emitter from '@strav/kernel'
Emitter.on('flag:resolved', ({ feature, scope, value }) => {
console.log(`Feature "${feature}" resolved to ${value} for ${scope}`)
})
Custom driver
Register a custom storage driver withextend():
import { flag } from '@strav/flag'
import type { FeatureStore } from '@strav/flag'
flag.extend('redis', (config) => {
return new RedisFeatureStore(config)
})
The factory receives the driver's config object from config/flag.ts. The returned object must implement the FeatureStore interface.
Then set it as the driver in your config:
export default {
default: 'redis',
drivers: {
redis: {
driver: 'redis',
url: env('REDIS_URL', 'redis://localhost:6379'),
},
},
}
CLI commands
The flag package provides three CLI commands (auto-discovered by the framework):
flag:setup
Create the_strav_features table:
bun strav flag:setup
flag:list
List all stored feature flags with their scopes and values:
bun strav flag:list
flag:purge
Purge stored values for a specific feature or all features:
bun strav flag:purge beta-ui
bun strav flag:purge --all
Testing
Use thearray driver in tests to avoid hitting the database:
# .env.test
FLAG_DRIVER=array
Call FlagManager.reset() in test teardown to clear definitions and stored values:
import { beforeEach } from 'bun:test'
import FlagManager from '@strav/flag'
beforeEach(() => {
FlagManager.reset()
})
Controller example
import { flag } from '@strav/flag'
export default class CheckoutController {
async show(ctx: Context) {
const user = ctx.get<User>('user')
const variant = await flag.for(user).value('checkout-variant') as string
return await flag.for(user).when(
'new-checkout',
() => ctx.json({ variant, layout: 'new' }),
() => ctx.json({ layout: 'classic' }),
)
}
async toggleBeta(ctx: Context) {
const user = ctx.get<User>('user')
const { enabled } = await ctx.body<{ enabled: boolean }>()
if (enabled) {
await flag.for(user).activate('beta-ui')
} else {
await flag.for(user).deactivate('beta-ui')
}
return ctx.json({ ok: true })
}
}