@happyvertical/smrt-features
Code-first feature flag registry with layered resolution: tenant hierarchy, global scope, and the definition default.
Overview
smrt-features is a code-first feature-flag system. Features are declared on @smrt()-decorated classes (their default state lives in code), then optionally
overridden at the global or tenant (hierarchical) scope at runtime. A boot-time sync service
mirrors the registered definitions into the database so admin tooling can list and configure
them. Resolution returns a boolean.
Installation
npm install @happyvertical/smrt-featuresOptional peer: @happyvertical/smrt-users enables tenant-hierarchy walking. Without
it, tenant resolution is single-level.
Quick Start
import {
FeatureResolver,
FeatureSyncService,
FeatureOverrideCollection,
FeatureOverrideEffect,
createFeatureKey,
} from '@happyvertical/smrt-features';
// 1. Sync definitions to DB at boot.
// Definitions are read from the SMRT ObjectRegistry (features declared on
// @smrt()-decorated classes), not from a literal array. With no filter,
// syncDefinitions() reconciles every registered feature.
const sync = new FeatureSyncService({ db });
const result = await sync.syncDefinitions();
// result: { total, created, updated, unchanged, deleted, featureKeys }
// 2. Resolve a feature for the current context (returns a boolean).
// Feature keys are "<qualifiedClassName>#<localId>".
const resolver = new FeatureResolver({ db });
const featureKey = createFeatureKey('@acme/commerce:Invoice', 'draftMode');
const enabled = await resolver.isEnabled(featureKey, {
tenantId: 'tenant-123',
});
// Or resolve directly from a class/instance + localId:
// await resolver.isEnabledFor(Invoice, 'draftMode', { tenantId });
// 3. Write a tenant-level override via FeatureOverrideCollection.
const overrides = await FeatureOverrideCollection.create({ db });
await overrides.setOverride(
featureKey,
'tenant',
'tenant-123',
FeatureOverrideEffect.ENABLE,
);Resolution Chain
isEnabled() starts from the definition default and applies overrides from least to
most specific, returning the resulting boolean:
Resolution order
1. Definition default (defaultEnabled on the code-registered FeatureDefinition)
2. Global override scopeType: 'global', scopeId: GLOBAL_FEATURE_SCOPE_ID ('*')
3. Tenant override scopeType: 'tenant', scopeId: tenantId
(walks root -> ... -> tenantId via smrt-users, applying
each override down the chain)
Override effects: 'enable' | 'disable' | 'inherit' (FeatureOverrideEffect).
'inherit' leaves the inherited state unchanged.
There is no per-user scope: scopeType is 'global' or 'tenant' only.
The tenant-hierarchy walk requires the @happyvertical/smrt-users peer.
Without it (or without a tenantId), tenant resolution uses the single
direct tenant override.Core Models
FeatureDefinition
class FeatureDefinition extends SmrtObject {
featureKey: string // '<qualifiedClassName>#<localId>'
packageName: string // owning package
qualifiedClassName: string // e.g. '@acme/commerce:Invoice'
className: string
localId: string // the feature id within the class
defaultEnabled: boolean // default state when no override matches
label: string
description: string
metadata: string // JSON metadata stored as text (get/setMetadata)
visibility: string // default 'public'
// System table: _smrt_feature_definitions (conflictColumns: ['feature_key'])
// Owned by code via FeatureSyncService — do not write directly
}FeatureOverride
enum FeatureOverrideEffect {
INHERIT = 'inherit',
ENABLE = 'enable',
DISABLE = 'disable',
}
class FeatureOverride extends SmrtObject {
featureKey: string
scopeType: 'global' | 'tenant' // no 'user' scope
scopeId: string // tenantId, or GLOBAL_FEATURE_SCOPE_ID ('*')
effect: FeatureOverrideEffect // default INHERIT
// Helpers: isInherit(), isEnabled(), isDisabled()
// System table: _smrt_feature_overrides
// conflictColumns: ['feature_key', 'scope_type', 'scope_id']
}FeatureSyncService
Keeps _smrt_feature_definitions in sync with the features declared on @smrt()-decorated classes (read from the SMRT ObjectRegistry) at
boot. Calling syncDefinitions() with no options reconciles every registered
feature; pass classNames or constructors to scope the sync to
specific classes. A full (unfiltered) sync prunes stale definitions by default (pruneStale, on for full syncs, off for filtered ones).
const sync = new FeatureSyncService({ db });
// Full reconcile of every registered feature
const result = await sync.syncDefinitions();
// Or scope to specific classes (pruneStale defaults to false here)
await sync.syncDefinitions({ classNames: ['Invoice', 'Order'] });
// result: { total, created, updated, unchanged, deleted, featureKeys }
// - Upserts definitions for registered features (created / updated / unchanged)
// - On a full sync, deletes definitions whose feature keys are no longer registered
// You can also sync directly from a manifest:
// await sync.syncManifest(manifest, { pruneStale: true });Integration with smrt-users
When @happyvertical/smrt-users is present, FeatureResolver walks the
tenant hierarchy (root down to the target tenant) so a feature can be turned on for a parent
tenant and inherited by descendants. The default loader pulls the chain from smrt-users automatically; pass a custom tenantHierarchyLoader via
the resolver's second constructor argument (FeatureResolverOptions) to supply
your own FeatureTenantHierarchyProvider.
Gotchas
DOs
- Declare features on
@smrt()-decorated classes so they register automatically - Run a full
syncDefinitions()at boot — filtered syncs skip stale-pruning and can leave drift - Build feature keys with
createFeatureKey(qualifiedClassName, localId)rather than hand-writing them - Use
FeatureOverrideEffect.INHERITto clear an override without deleting the row
DON'Ts
- Don't write
FeatureDefinitionrows directly — useFeatureSyncService - Don't assume tenant-hierarchy walking works without the
smrt-userspeer - Don't expect a per-user scope — overrides are
'global'or'tenant'only - Don't use feature flags as long-term config — promote stable flags to
smrt-config