@happyvertical/smrt-features

Code-first feature flag registry with layered resolution: tenant hierarchy, global scope, and the definition default.

v0.29.34Feature FlagsBoolean ResolutionTenant Hierarchy

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

bash
npm install @happyvertical/smrt-features

Optional peer: @happyvertical/smrt-users enables tenant-hierarchy walking. Without it, tenant resolution is single-level.

Quick Start

typescript
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:

text
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

typescript
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

typescript
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).

typescript
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.INHERIT to clear an override without deleting the row

DON'Ts

  • Don't write FeatureDefinition rows directly — use FeatureSyncService
  • Don't assume tenant-hierarchy walking works without the smrt-users peer
  • 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

Related Modules