npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nestarc/feature-flag

v0.2.0

Published

DB-backed feature flags for NestJS + Prisma + PostgreSQL with tenant-aware overrides

Readme

@nestarc/feature-flag

npm version npm downloads CI License: MIT Docs

DB-backed feature flags for NestJS + Prisma + PostgreSQL -- tenant-aware overrides, percentage rollouts, and zero external dependencies.

Features

  • Database-backed -- flags stored in PostgreSQL via Prisma, no external service required
  • Tenant / user / environment overrides -- granular control per tenant, user, or deployment environment
  • Percentage rollouts -- deterministic hashing (murmurhash3) for consistent per-user bucketing
  • Guard decorator -- @FeatureFlag() automatically gates routes and controllers
  • Bypass decorator -- @BypassFeatureFlag() exempts health checks and public endpoints
  • Programmatic evaluation -- isEnabled() and evaluateAll() for service-layer logic
  • Built-in caching -- configurable TTL with manual invalidation; Redis Pub/Sub for multi-instance
  • Pluggable persistence -- FeatureFlagRepository interface for custom backends (Prisma default)
  • Pluggable tenancy -- TenantContextProvider interface for custom tenant resolution
  • Admin REST API -- opt-in FeatureFlagAdminModule with guard injection and proper error responses
  • Event system -- optional integration with @nestjs/event-emitter for audit and observability
  • Testing utilities -- drop-in TestFeatureFlagModule for unit and integration tests

Installation

npm install @nestarc/feature-flag

Peer dependencies

npm install @nestjs/common @nestjs/core @prisma/client rxjs reflect-metadata

Optional

# Required only if you enable emitEvents
npm install @nestjs/event-emitter

# Required only if you use RedisCacheAdapter
npm install ioredis

Redis Cache (Multi-Instance)

For production deployments with multiple instances, use RedisCacheAdapter for shared caching and real-time invalidation via Redis Pub/Sub:

import { FeatureFlagModule, RedisCacheAdapter } from '@nestarc/feature-flag';
import { Redis } from 'ioredis';

const redisClient = new Redis({ host: 'localhost', port: 6379 });

FeatureFlagModule.forRoot({
  environment: 'production',
  prisma,
  cacheAdapter: new RedisCacheAdapter({
    client: redisClient,
    // subscriber is auto-created via client.duplicate()
    // keyPrefix: 'feature-flag:',   // default
    // channel: 'feature-flag:invalidate',  // default
  }),
})

When a flag is updated on any instance, all other instances are notified via Pub/Sub and invalidate their cache immediately — eliminating the stale-cache window.

Prisma Schema

Add the following models to your schema.prisma:

model FeatureFlag {
  id          String    @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  key         String    @unique
  description String?
  enabled     Boolean   @default(false)
  percentage  Int       @default(0)
  metadata    Json      @default("{}")
  archivedAt  DateTime? @map("archived_at") @db.Timestamptz()
  createdAt   DateTime  @default(now()) @map("created_at") @db.Timestamptz()
  updatedAt   DateTime  @updatedAt @map("updated_at") @db.Timestamptz()

  overrides FeatureFlagOverride[]

  @@map("feature_flags")
}

model FeatureFlagOverride {
  id          String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  flagId      String   @map("flag_id") @db.Uuid
  tenantId    String?  @map("tenant_id")
  userId      String?  @map("user_id")
  environment String?
  enabled     Boolean
  createdAt   DateTime @default(now()) @map("created_at") @db.Timestamptz()
  updatedAt   DateTime @updatedAt @map("updated_at") @db.Timestamptz()

  flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

  @@index([flagId], map: "idx_override_flag_id")
  @@map("feature_flag_overrides")
}

Partial unique indexes for overrides

PostgreSQL treats NULL != NULL in standard unique constraints, which means a simple UNIQUE(flag_id, tenant_id, user_id, environment) would allow duplicate rows when any nullable column is NULL. To enforce true uniqueness across all combinations, apply the following migration that creates one partial index per NULL/NOT-NULL pattern:

-- Drop the old unique constraint that does not handle NULLs correctly
ALTER TABLE feature_flag_overrides
  DROP CONSTRAINT IF EXISTS uq_override_context;

-- Global override (all nullable columns NULL)
CREATE UNIQUE INDEX uq_override_000
  ON feature_flag_overrides (flag_id)
  WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NULL;

-- Only environment is NOT NULL
CREATE UNIQUE INDEX uq_override_001
  ON feature_flag_overrides (flag_id, environment)
  WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NOT NULL;

-- Only user_id is NOT NULL
CREATE UNIQUE INDEX uq_override_010
  ON feature_flag_overrides (flag_id, user_id)
  WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NULL;

-- user_id + environment
CREATE UNIQUE INDEX uq_override_011
  ON feature_flag_overrides (flag_id, user_id, environment)
  WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NOT NULL;

-- Only tenant_id is NOT NULL
CREATE UNIQUE INDEX uq_override_100
  ON feature_flag_overrides (flag_id, tenant_id)
  WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NULL;

-- tenant_id + environment
CREATE UNIQUE INDEX uq_override_101
  ON feature_flag_overrides (flag_id, tenant_id, environment)
  WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NOT NULL;

-- tenant_id + user_id
CREATE UNIQUE INDEX uq_override_110
  ON feature_flag_overrides (flag_id, tenant_id, user_id)
  WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NULL;

-- All three NOT NULL
CREATE UNIQUE INDEX uq_override_111
  ON feature_flag_overrides (flag_id, tenant_id, user_id, environment)
  WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NOT NULL;

This SQL is included in the initial migration at prisma/migrations/20260405000000_init/migration.sql.

Module Registration

forRoot (synchronous)

import { FeatureFlagModule } from '@nestarc/feature-flag';

@Module({
  imports: [
    FeatureFlagModule.forRoot({
      environment: 'production',
      prisma: prismaService,
      userIdExtractor: (req) => req.headers['x-user-id'] as string,
      emitEvents: true,
      cacheTtlMs: 30_000,
    }),
  ],
})
export class AppModule {}

forRootAsync (with useFactory)

import { FeatureFlagModule } from '@nestarc/feature-flag';

@Module({
  imports: [
    FeatureFlagModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService, PrismaService],
      useFactory: (config: ConfigService, prisma: PrismaService) => ({
        environment: config.get('NODE_ENV'),
        prisma,
        userIdExtractor: (req) => req.headers['x-user-id'] as string,
      }),
    }),
  ],
})
export class AppModule {}

forRootAsync (with useClass)

@Injectable()
class FeatureFlagConfigService implements FeatureFlagModuleOptionsFactory {
  constructor(
    private readonly config: ConfigService,
    private readonly prisma: PrismaService,
  ) {}

  createFeatureFlagOptions() {
    return {
      environment: this.config.get('NODE_ENV'),
      prisma: this.prisma,
    };
  }
}

@Module({
  imports: [
    FeatureFlagModule.forRootAsync({
      imports: [ConfigModule, PrismaModule],
      useClass: FeatureFlagConfigService,
    }),
  ],
})
export class AppModule {}

forRootAsync (with useExisting)

@Module({
  imports: [
    FeatureFlagModule.forRootAsync({
      useExisting: FeatureFlagConfigService,
    }),
  ],
})
export class AppModule {}

Feature Flag Guard

The @FeatureFlag() decorator automatically applies UseGuards(FeatureFlagGuard), so you do not need to add @UseGuards() yourself.

Method-level

import { FeatureFlag } from '@nestarc/feature-flag';

@Controller('dashboard')
export class DashboardController {
  @FeatureFlag('NEW_DASHBOARD')
  @Get()
  getDashboard() {
    return { message: 'Welcome to the new dashboard' };
  }
}

Class-level

@FeatureFlag('BETA_API')
@Controller('beta')
export class BetaController {
  @Get('feature-a')
  featureA() { /* guarded */ }

  @Get('feature-b')
  featureB() { /* guarded */ }
}

Custom status code and fallback

@FeatureFlag('PREMIUM_FEATURE', {
  statusCode: 402,
  fallback: { message: 'Upgrade required' },
})
@Get('premium')
getPremiumContent() { ... }

When the flag is disabled, the guard responds with the given statusCode (default 403) and optional fallback body.

Bypassing the guard

Use @BypassFeatureFlag() on methods that should always be accessible, even when a class-level flag is applied:

import { BypassFeatureFlag } from '@nestarc/feature-flag';

@FeatureFlag('BETA_API')
@Controller('beta')
export class BetaController {
  @Get('docs')
  betaDocs() { /* guarded by BETA_API */ }

  @BypassFeatureFlag()
  @Get('health')
  healthCheck() {
    return { status: 'ok' };
  }
}

Programmatic Evaluation

Inject FeatureFlagService for service-layer checks outside the HTTP request cycle:

import { FeatureFlagService } from '@nestarc/feature-flag';

@Injectable()
export class PaymentService {
  constructor(private readonly flags: FeatureFlagService) {}

  async processPayment(order: Order) {
    const useNewGateway = await this.flags.isEnabled('NEW_PAYMENT_GATEWAY');

    if (useNewGateway) {
      return this.newGateway.process(order);
    }
    return this.legacyGateway.process(order);
  }
}

Evaluate all flags at once

const allFlags = await this.flags.evaluateAll();
// { NEW_DASHBOARD: true, PREMIUM_FEATURE: false, ... }

Explicit evaluation context

Both isEnabled() and evaluateAll() accept an optional EvaluationContext to override the auto-detected context:

const enabled = await this.flags.isEnabled('MY_FLAG', {
  userId: 'user-123',
  tenantId: 'tenant-abc',
  environment: 'staging',
});

Passing null explicitly clears that dimension, suppressing any ambient value from the request context:

// Evaluate as if no user is present, even within a request with x-user-id
const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });

Overrides

Set context-specific overrides that take precedence over the global flag value:

// Enable for a specific tenant
await this.flags.setOverride('MY_FLAG', {
  tenantId: 'tenant-1',
  enabled: true,
});

// Disable for a specific user
await this.flags.setOverride('MY_FLAG', {
  userId: 'user-42',
  enabled: false,
});

// Enable only in staging
await this.flags.setOverride('MY_FLAG', {
  environment: 'staging',
  enabled: true,
});

// Combine dimensions
await this.flags.setOverride('MY_FLAG', {
  tenantId: 'tenant-1',
  userId: 'user-42',
  environment: 'production',
  enabled: true,
});

Events

Enable event emission to observe flag lifecycle changes. Requires @nestjs/event-emitter as an optional peer dependency.

Important: You must import EventEmitterModule.forRoot() in your app module. The feature-flag module reuses the same EventEmitter2 singleton that NestJS manages, so @OnEvent() listeners work out of the box.

Setup

import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
  imports: [
    EventEmitterModule.forRoot(),   // must be imported
    FeatureFlagModule.forRoot({
      environment: 'production',
      prisma: prismaService,
      emitEvents: true,
    }),
  ],
})
export class AppModule {}

Event types

| Event constant | Event string | Payload type | | ---------------------------------------- | ---------------------------------- | -------------------- | | FeatureFlagEvents.EVALUATED | feature-flag.evaluated | FlagEvaluatedEvent | | FeatureFlagEvents.CREATED | feature-flag.created | FlagMutationEvent | | FeatureFlagEvents.UPDATED | feature-flag.updated | FlagMutationEvent | | FeatureFlagEvents.ARCHIVED | feature-flag.archived | FlagMutationEvent | | FeatureFlagEvents.OVERRIDE_SET | feature-flag.override.set | FlagOverrideEvent | | FeatureFlagEvents.OVERRIDE_REMOVED | feature-flag.override.removed | FlagOverrideEvent | | FeatureFlagEvents.CACHE_INVALIDATED | feature-flag.cache.invalidated | {} |

Listening to events

import { OnEvent } from '@nestjs/event-emitter';
import { FeatureFlagEvents, FlagEvaluatedEvent } from '@nestarc/feature-flag';

@Injectable()
export class FlagAuditListener {
  @OnEvent(FeatureFlagEvents.EVALUATED)
  handleEvaluation(event: FlagEvaluatedEvent) {
    console.log(`Flag ${event.flagKey} = ${event.result} (source: ${event.source})`);
  }
}

Testing

Import TestFeatureFlagModule from the /testing subpath to stub flag values in tests without a database connection:

import { TestFeatureFlagModule } from '@nestarc/feature-flag/testing';

describe('DashboardController', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TestFeatureFlagModule.register({
          NEW_DASHBOARD: true,
          PREMIUM_FEATURE: false,
        }),
      ],
      controllers: [DashboardController],
    }).compile();

    app = module.createNestApplication();
    await app.init();
  });

  it('should allow access when flag is enabled', () => {
    return request(app.getHttpServer())
      .get('/dashboard')
      .expect(200);
  });
});

TestFeatureFlagModule.register() provides a global mock of FeatureFlagService:

  • isEnabled(key) returns the boolean you specified (defaulting to false for unregistered keys)
  • evaluateAll() returns the full flag map
  • create(), update(), archive(), findByKey(), findAll() return full FeatureFlagWithOverrides stub objects
  • findByKey() throws NotFoundException for unknown keys

This is a stateless boolean stub -- write operations do not persist state across calls. For stateful test doubles, use your own mock implementation.

Evaluation Priority

When isEnabled() is called, flags are evaluated through a 6-layer cascade. The first matching layer wins:

| Priority | Layer | Description | | -------- | ---------------------- | ------------------------------------------------------------------ | | 1 | Archived | If the flag has archivedAt set, evaluation always returns false | | 2 | User override | Override matching the current userId (most specific) | | 3 | Tenant override | Override matching the current tenantId | | 4 | Environment override| Override matching the current environment | | 5 | Percentage rollout | Deterministic hash of flagKey + userId (or tenantId) mod 100 | | 6 | Global default | The flag's enabled field |

Percentage rollout uses murmurhash3 for deterministic bucketing: the same user always gets the same result for a given flag, ensuring a consistent experience across requests.

Configuration Reference

FeatureFlagModuleOptions

| Option | Type | Default | Description | | ------------------- | --------------------------------- | --------- | --------------------------------------------------------------- | | environment | string | required| Deployment environment (e.g. 'production', 'staging') | | cacheTtlMs | number | 30000 | Cache TTL in ms. Set to 0 to disable caching | | userIdExtractor | (req: Request) => string \| null| undefined| Extracts user ID from the incoming request | | defaultOnMissing | boolean | false | Value returned when a flag key does not exist in the database | | emitEvents | boolean | false | Emit lifecycle events via @nestjs/event-emitter | | cacheAdapter | CacheAdapter | MemoryCacheAdapter | Pluggable cache backend (e.g. RedisCacheAdapter) |

FeatureFlagModuleRootOptions

Extends FeatureFlagModuleOptions with:

| Option | Type | Description | | ------- | ----- | ------------------------------ | | prisma| any | Prisma client instance |

CRUD Operations

FeatureFlagService also exposes methods for managing flags programmatically:

// Create a flag
const flag = await this.flags.create({
  key: 'NEW_FEATURE',
  description: 'Enables the new feature',
  enabled: false,
  percentage: 0,
});

// Update a flag
await this.flags.update('NEW_FEATURE', {
  enabled: true,
  percentage: 50,
});

// Archive a flag (soft delete -- evaluations return false)
await this.flags.archive('OLD_FEATURE');

// List all active (non-archived) flags
const allFlags = await this.flags.findAll();

// Manually invalidate the cache
this.flags.invalidateCache();

Admin REST API

FeatureFlagAdminModule provides a REST API for managing flags. It requires a guard — the module won't register without one:

import { FeatureFlagAdminModule } from '@nestarc/feature-flag';
import { AdminAuthGuard } from './guards/admin-auth.guard';

@Module({
  imports: [
    FeatureFlagModule.forRoot({ ... }),
    FeatureFlagAdminModule.register({
      guard: AdminAuthGuard,
      // path: 'feature-flags',  // default
    }),
  ],
})
export class AppModule {}

Endpoints

| Method | Route | Description | Error Responses | |--------|-------|-------------|-----------------| | POST | /feature-flags | Create a flag | 409 duplicate key, 400 invalid percentage | | GET | /feature-flags | List all flags | | | GET | /feature-flags/:key | Get a single flag | 404 not found | | PATCH | /feature-flags/:key | Update a flag | 404 not found, 400 invalid percentage | | DELETE | /feature-flags/:key | Archive a flag | 404 not found | | POST | /feature-flags/:key/overrides | Set an override | 404 flag not found | | DELETE | /feature-flags/:key/overrides | Remove an override | 404 flag not found |

Percentage values must be between 0 and 100 (inclusive). Invalid values return 400 Bad Request.

Custom Persistence (Advanced)

The default PrismaFeatureFlagRepository can be replaced with any implementation of FeatureFlagRepository:

import {
  FeatureFlagModule,
  FEATURE_FLAG_REPOSITORY,
  FeatureFlagRepository,
} from '@nestarc/feature-flag';

@Module({
  imports: [
    FeatureFlagModule.forRoot({
      environment: 'production',
      prisma, // still required for module init, but unused if you override the repository
    }),
  ],
  providers: [
    {
      provide: FEATURE_FLAG_REPOSITORY,
      useClass: MyCustomRepository, // implements FeatureFlagRepository
    },
  ],
})
export class AppModule {}

Custom Tenant Resolution (Advanced)

Override the default @nestarc/tenancy integration with your own TenantContextProvider:

import {
  FeatureFlagModule,
  TENANT_CONTEXT_PROVIDER,
  TenantContextProvider,
} from '@nestarc/feature-flag';

@Injectable()
class MyTenantProvider implements TenantContextProvider {
  getCurrentTenantId(): string | null {
    // your custom tenant resolution logic
    return 'tenant-from-custom-source';
  }
}

@Module({
  imports: [FeatureFlagModule.forRoot({ ... })],
  providers: [
    { provide: TENANT_CONTEXT_PROVIDER, useClass: MyTenantProvider },
  ],
})
export class AppModule {}

Performance

Measured with PostgreSQL 16, Prisma 6, 500 iterations on Apple Silicon:

| Scenario | Avg | P50 | P95 | P99 | |----------|-----|-----|-----|-----| | isEnabled() — cache hit | 0.04ms | 0.03ms | 0.05ms | 0.07ms | | isEnabled() — cache miss (DB lookup) | 1.30ms | 1.14ms | 2.54ms | 3.69ms | | isEnabled() — override cascade (cold) | 1.07ms | 1.02ms | 1.43ms | 2.11ms | | evaluateAll() — 50 flags (mixed) | 0.19ms | 0.04ms | 1.55ms | 1.71ms |

Cache speedup: 32.5x (hit vs miss). Keep the default 30s cache TTL for optimal performance.

Reproduce: docker compose up -d && dotenv -e .env.test -- npx ts-node benchmarks/evaluation-overhead.ts

License

MIT