@nestarc/feature-flag 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +206 -81
  2. package/dist/admin/feature-flag-admin.controller.d.ts +8 -6
  3. package/dist/admin/feature-flag-admin.controller.js +22 -4
  4. package/dist/admin/feature-flag-admin.controller.js.map +1 -1
  5. package/dist/admin/feature-flag-admin.dto.d.ts +30 -0
  6. package/dist/admin/feature-flag-admin.dto.js +116 -0
  7. package/dist/admin/feature-flag-admin.dto.js.map +1 -0
  8. package/dist/admin/targeting-attributes.validator.d.ts +6 -0
  9. package/dist/admin/targeting-attributes.validator.js +36 -0
  10. package/dist/admin/targeting-attributes.validator.js.map +1 -0
  11. package/dist/events/feature-flag.events.d.ts +38 -6
  12. package/dist/events/feature-flag.events.js +1 -0
  13. package/dist/events/feature-flag.events.js.map +1 -1
  14. package/dist/flag-registry.d.ts +24 -0
  15. package/dist/flag-registry.js +62 -0
  16. package/dist/flag-registry.js.map +1 -0
  17. package/dist/guards/feature-flag.guard.js +5 -1
  18. package/dist/guards/feature-flag.guard.js.map +1 -1
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.js +7 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/interfaces/evaluation-context.interface.d.ts +8 -3
  23. package/dist/interfaces/evaluation-details.interface.d.ts +29 -0
  24. package/dist/interfaces/evaluation-details.interface.js +3 -0
  25. package/dist/interfaces/evaluation-details.interface.js.map +1 -0
  26. package/dist/interfaces/feature-flag-options.interface.d.ts +3 -0
  27. package/dist/interfaces/feature-flag-repository.interface.d.ts +8 -6
  28. package/dist/interfaces/feature-flag.interface.d.ts +18 -9
  29. package/dist/interfaces/flag-registry.interface.d.ts +17 -0
  30. package/dist/interfaces/flag-registry.interface.js +3 -0
  31. package/dist/interfaces/flag-registry.interface.js.map +1 -0
  32. package/dist/openfeature.d.ts +26 -0
  33. package/dist/openfeature.js +96 -0
  34. package/dist/openfeature.js.map +1 -0
  35. package/dist/repositories/prisma-feature-flag.repository.d.ts +3 -3
  36. package/dist/repositories/prisma-feature-flag.repository.js +18 -14
  37. package/dist/repositories/prisma-feature-flag.repository.js.map +1 -1
  38. package/dist/services/feature-flag.service.d.ts +15 -7
  39. package/dist/services/feature-flag.service.js +126 -38
  40. package/dist/services/feature-flag.service.js.map +1 -1
  41. package/dist/services/flag-context-resolver.js +14 -3
  42. package/dist/services/flag-context-resolver.js.map +1 -1
  43. package/dist/services/flag-evaluator.service.d.ts +8 -8
  44. package/dist/services/flag-evaluator.service.js +90 -36
  45. package/dist/services/flag-evaluator.service.js.map +1 -1
  46. package/dist/testing/index.d.ts +1 -1
  47. package/dist/testing/index.js +2 -1
  48. package/dist/testing/index.js.map +1 -1
  49. package/dist/testing/test-feature-flag.module.d.ts +21 -0
  50. package/dist/testing/test-feature-flag.module.js +78 -19
  51. package/dist/testing/test-feature-flag.module.js.map +1 -1
  52. package/dist/utils/targeting-attributes.d.ts +9 -0
  53. package/dist/utils/targeting-attributes.js +44 -0
  54. package/dist/utils/targeting-attributes.js.map +1 -0
  55. package/package.json +18 -2
  56. package/prisma/migrations/20260512000000_attribute_targeting/migration.sql +59 -0
  57. package/prisma/schema.prisma +7 -9
package/README.md CHANGED
@@ -6,21 +6,23 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![Docs](https://img.shields.io/badge/docs-nestarc.dev-blue.svg)](https://nestarc.dev/packages/feature-flag/)
8
8
 
9
- DB-backed feature flags for NestJS + Prisma + PostgreSQL -- tenant-aware overrides, percentage rollouts, and zero external dependencies.
9
+ DB-backed feature flags for NestJS + Prisma + PostgreSQL -- attribute-targeted overrides, percentage rollouts, and zero external dependencies.
10
10
 
11
11
  ## Features
12
12
 
13
13
  - **Database-backed** -- flags stored in PostgreSQL via Prisma, no external service required
14
- - **Tenant / user / environment overrides** -- granular control per tenant, user, or deployment environment
15
- - **Percentage rollouts** -- deterministic hashing (murmurhash3) for consistent per-user bucketing
14
+ - **Attribute-targeted overrides** -- exact-match targeting for tenants, users, environments, plans, regions, or custom dimensions
15
+ - **Percentage rollouts** -- deterministic hashing (murmurhash3) with explicit `targetingKey` / `bucketBy`
16
16
  - **Guard decorator** -- `@FeatureFlag()` automatically gates routes and controllers
17
17
  - **Bypass decorator** -- `@BypassFeatureFlag()` exempts health checks and public endpoints
18
- - **Programmatic evaluation** -- `isEnabled()` and `evaluateAll()` for service-layer logic
18
+ - **Programmatic evaluation** -- `isEnabled()`, `evaluateBoolean()`, and `evaluateAll()` for service-layer logic
19
+ - **Type-safe registry helpers** -- define flag keys, defaults, rollout bucket keys, exposure tracking, and lifecycle metadata in code
19
20
  - **Built-in caching** -- configurable TTL with manual invalidation; Redis Pub/Sub for multi-instance
20
21
  - **Pluggable persistence** -- `FeatureFlagRepository` interface for custom backends (Prisma default)
21
22
  - **Pluggable tenancy** -- `TenantContextProvider` interface for custom tenant resolution
22
23
  - **Admin REST API** -- opt-in `FeatureFlagAdminModule` with guard injection and proper error responses
23
24
  - **Event system** -- optional integration with `@nestjs/event-emitter` for audit and observability
25
+ - **OpenFeature adapter** -- optional boolean-only provider at `@nestarc/feature-flag/openfeature`
24
26
  - **Testing utilities** -- drop-in `TestFeatureFlagModule` for unit and integration tests
25
27
 
26
28
  ## Installation
@@ -32,7 +34,7 @@ npm install @nestarc/feature-flag
32
34
  ### Peer dependencies
33
35
 
34
36
  ```bash
35
- npm install @nestjs/common @nestjs/core @prisma/client rxjs reflect-metadata
37
+ npm install @nestjs/common @nestjs/core @prisma/client class-transformer class-validator rxjs reflect-metadata
36
38
  ```
37
39
 
38
40
  ### Optional
@@ -43,6 +45,9 @@ npm install @nestjs/event-emitter
43
45
 
44
46
  # Required only if you use RedisCacheAdapter
45
47
  npm install ioredis
48
+
49
+ # Required only if you use the OpenFeature adapter with the SDK
50
+ npm install @openfeature/server-sdk
46
51
  ```
47
52
 
48
53
  ## Redis Cache (Multi-Instance)
@@ -91,14 +96,13 @@ model FeatureFlag {
91
96
  }
92
97
 
93
98
  model FeatureFlagOverride {
94
- id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
95
- flagId String @map("flag_id") @db.Uuid
96
- tenantId String? @map("tenant_id")
97
- userId String? @map("user_id")
98
- environment String?
99
- enabled Boolean
100
- createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
101
- updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
99
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
100
+ flagId String @map("flag_id") @db.Uuid
101
+ attributes Json
102
+ priority Int @default(0)
103
+ enabled Boolean
104
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
105
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
102
106
 
103
107
  flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
104
108
 
@@ -107,57 +111,48 @@ model FeatureFlagOverride {
107
111
  }
108
112
  ```
109
113
 
110
- ### Partial unique indexes for overrides
111
-
112
- 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:
114
+ The v0.3.0 migration uses an `{}` default only while backfilling legacy rows, then drops that default. It also creates a unique index on `(flag_id, attributes)` and a check constraint requiring override attributes to be a non-empty JSON object. If you copy this schema into a greenfield app instead of running the included migrations, add an equivalent raw SQL migration because Prisma schema cannot express these PostgreSQL constraints:
113
115
 
114
116
  ```sql
115
- -- Drop the old unique constraint that does not handle NULLs correctly
116
- ALTER TABLE feature_flag_overrides
117
- DROP CONSTRAINT IF EXISTS uq_override_context;
117
+ CREATE UNIQUE INDEX "uq_feature_flag_override_attributes"
118
+ ON "feature_flag_overrides"("flag_id", "attributes");
119
+
120
+ ALTER TABLE "feature_flag_overrides"
121
+ ADD CONSTRAINT "chk_feature_flag_override_attributes_non_empty"
122
+ CHECK (jsonb_typeof("attributes") = 'object' AND "attributes" <> '{}'::jsonb);
123
+ ```
118
124
 
119
- -- Global override (all nullable columns NULL)
120
- CREATE UNIQUE INDEX uq_override_000
121
- ON feature_flag_overrides (flag_id)
122
- WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NULL;
125
+ ### Migration from 0.2.0 to 0.3.0
123
126
 
124
- -- Only environment is NOT NULL
125
- CREATE UNIQUE INDEX uq_override_001
126
- ON feature_flag_overrides (flag_id, environment)
127
- WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NOT NULL;
127
+ v0.3.0 changes override storage from fixed `tenant_id`, `user_id`, and `environment` columns to an `attributes` `jsonb` object plus `priority`.
128
128
 
129
- -- Only user_id is NOT NULL
130
- CREATE UNIQUE INDEX uq_override_010
131
- ON feature_flag_overrides (flag_id, user_id)
132
- WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NULL;
129
+ Run your Prisma migrations during deployment:
133
130
 
134
- -- user_id + environment
135
- CREATE UNIQUE INDEX uq_override_011
136
- ON feature_flag_overrides (flag_id, user_id, environment)
137
- WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NOT NULL;
131
+ ```bash
132
+ npx prisma migrate deploy
133
+ ```
138
134
 
139
- -- Only tenant_id is NOT NULL
140
- CREATE UNIQUE INDEX uq_override_100
141
- ON feature_flag_overrides (flag_id, tenant_id)
142
- WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NULL;
135
+ The migration maps legacy override columns into attributes:
143
136
 
144
- -- tenant_id + environment
145
- CREATE UNIQUE INDEX uq_override_101
146
- ON feature_flag_overrides (flag_id, tenant_id, environment)
147
- WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NOT NULL;
137
+ | v0.2.0 column | v0.3.0 attribute |
138
+ | ------------- | ---------------- |
139
+ | `tenant_id` | `attributes.tenantId` |
140
+ | `user_id` | `attributes.userId` |
141
+ | `environment` | `attributes.environment` |
148
142
 
149
- -- tenant_id + user_id
150
- CREATE UNIQUE INDEX uq_override_110
151
- ON feature_flag_overrides (flag_id, tenant_id, user_id)
152
- WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NULL;
143
+ Rows with all three legacy columns set to `NULL` are deleted because empty override attributes are not valid in v0.3.0. If multiple legacy rows backfill to the same `(flag_id, attributes)`, the migration keeps the row with the latest `updated_at`, then latest `created_at`, then highest `id`, and deletes the other duplicates before creating the unique index.
153
144
 
154
- -- All three NOT NULL
155
- CREATE UNIQUE INDEX uq_override_111
156
- ON feature_flag_overrides (flag_id, tenant_id, user_id, environment)
157
- WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NOT NULL;
145
+ Legacy Admin API bodies are rejected:
146
+
147
+ ```json
148
+ { "tenantId": "tenant-1", "enabled": true }
158
149
  ```
159
150
 
160
- This SQL is included in the initial migration at `prisma/migrations/20260405000000_init/migration.sql`.
151
+ Use an `attributes` object instead:
152
+
153
+ ```json
154
+ { "attributes": { "tenantId": "tenant-1" }, "enabled": true }
155
+ ```
161
156
 
162
157
  ## Module Registration
163
158
 
@@ -289,6 +284,14 @@ getPremiumContent() { ... }
289
284
 
290
285
  When the flag is disabled, the guard responds with the given `statusCode` (default `403`) and optional `fallback` body.
291
286
 
287
+ Use `defaultValue` when a route should choose an invocation-specific fallback if a flag is missing or evaluation fails:
288
+
289
+ ```typescript
290
+ @FeatureFlag('OPTIONAL_PREVIEW', { defaultValue: true })
291
+ @Get('preview')
292
+ getPreview() { ... }
293
+ ```
294
+
292
295
  ### Bypassing the guard
293
296
 
294
297
  Use `@BypassFeatureFlag()` on methods that should always be accessible, even when a class-level flag is applied:
@@ -358,41 +361,136 @@ Passing `null` explicitly clears that dimension, suppressing any ambient value f
358
361
  const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });
359
362
  ```
360
363
 
361
- ## Overrides
364
+ ### Detailed boolean evaluation
362
365
 
363
- Set context-specific overrides that take precedence over the global flag value:
366
+ Use `evaluateBoolean()` when you need to explain why a flag resolved to a value:
364
367
 
365
368
  ```typescript
366
- // Enable for a specific tenant
367
- await this.flags.setOverride('MY_FLAG', {
368
- tenantId: 'tenant-1',
369
- enabled: true,
370
- });
369
+ const details = await this.flags.evaluateBoolean(
370
+ 'NEW_CHECKOUT',
371
+ { targetingKey: 'tenant-1', tenantId: 'tenant-1' },
372
+ { defaultValue: false, trackExposure: true },
373
+ );
374
+
375
+ console.log(details);
376
+ // {
377
+ // flagKey: 'NEW_CHECKOUT',
378
+ // value: true,
379
+ // result: true,
380
+ // source: 'percentage',
381
+ // reason: 'PERCENTAGE_MATCH',
382
+ // defaultUsed: false,
383
+ // bucket: 17,
384
+ // targetingKey: 'tenant-1',
385
+ // evaluationTimeMs: 1
386
+ // }
387
+ ```
371
388
 
372
- // Disable for a specific user
373
- await this.flags.setOverride('MY_FLAG', {
374
- userId: 'user-42',
375
- enabled: false,
376
- });
389
+ Missing flags and evaluation errors return the selected default instead of throwing. Default priority is:
377
390
 
378
- // Enable only in staging
379
- await this.flags.setOverride('MY_FLAG', {
380
- environment: 'staging',
381
- enabled: true,
391
+ 1. Invocation `defaultValue`
392
+ 2. Registry `defaultValue`
393
+ 3. Module `defaultOnMissing`
394
+ 4. `false`
395
+
396
+ ### Type-safe flag registry
397
+
398
+ ```typescript
399
+ import { defineFlags, createFeatureFlagClient } from '@nestarc/feature-flag';
400
+
401
+ export const flags = defineFlags({
402
+ NEW_CHECKOUT: {
403
+ defaultValue: false,
404
+ bucketBy: 'tenantId',
405
+ trackExposure: true,
406
+ owner: 'payments',
407
+ tags: ['checkout'],
408
+ staleAt: '2026-09-01',
409
+ expiresAt: '2026-12-01',
410
+ },
382
411
  });
383
412
 
384
- // Combine dimensions
385
- await this.flags.setOverride('MY_FLAG', {
413
+ const flagClient = createFeatureFlagClient(featureFlagService, flags);
414
+ const enabled = await flagClient.isEnabled('NEW_CHECKOUT', { tenantId: 'tenant-1' });
415
+ ```
416
+
417
+ You can also pass the registry to `FeatureFlagModule.forRoot({ flags })` so service-level fallback and `bucketBy` defaults apply to direct `FeatureFlagService` calls.
418
+
419
+ ### OpenFeature boolean adapter
420
+
421
+ The optional adapter lives on a separate subpath and delegates boolean resolution to `FeatureFlagService`:
422
+
423
+ ```typescript
424
+ import { createOpenFeatureBooleanProvider } from '@nestarc/feature-flag/openfeature';
425
+
426
+ const provider = createOpenFeatureBooleanProvider(featureFlagService);
427
+ const result = await provider.resolveBooleanEvaluation(
428
+ 'NEW_CHECKOUT',
429
+ false,
430
+ { targetingKey: 'tenant-1', tenantId: 'tenant-1', plan: 'pro' },
431
+ );
432
+ ```
433
+
434
+ Only boolean evaluation is supported in v0.4.0. Variant flags and string/number/json remote config remain out of scope.
435
+
436
+ ## Attribute Targeting
437
+
438
+ Overrides match exact attributes. Every key/value in an override's `attributes` object must exist in the evaluation context attributes for the override to apply.
439
+
440
+ ```typescript
441
+ const enabled = await this.flags.isEnabled('NEW_CHECKOUT', {
442
+ userId: 'user-123',
386
443
  tenantId: 'tenant-1',
387
- userId: 'user-42',
388
444
  environment: 'production',
445
+ attributes: {
446
+ plan: 'pro',
447
+ country: 'KR',
448
+ },
449
+ });
450
+ ```
451
+
452
+ Top-level `userId`, `tenantId`, and `environment` are merged into targeting attributes. If the same key also appears in `attributes`, the top-level value wins.
453
+
454
+ When multiple overrides match, the evaluator chooses the winner by:
455
+
456
+ 1. More attributes
457
+ 2. Higher `priority`
458
+ 3. Earlier `createdAt`
459
+ 4. Lower `id`
460
+
461
+ ## Overrides
462
+
463
+ Set attribute-based overrides that take precedence over the global flag value:
464
+
465
+ ```typescript
466
+ await flags.setOverride('NEW_CHECKOUT', {
467
+ attributes: {
468
+ tenantId: 'tenant-1',
469
+ plan: 'pro',
470
+ country: 'KR',
471
+ },
389
472
  enabled: true,
473
+ priority: 10,
390
474
  });
391
475
  ```
392
476
 
477
+ REST Admin API body:
478
+
479
+ ```json
480
+ {
481
+ "attributes": {
482
+ "tenantId": "tenant-1",
483
+ "plan": "pro",
484
+ "country": "KR"
485
+ },
486
+ "enabled": true,
487
+ "priority": 10
488
+ }
489
+ ```
490
+
393
491
  ## Events
394
492
 
395
- Enable event emission to observe flag lifecycle changes. Requires `@nestjs/event-emitter` as an optional peer dependency.
493
+ Enable event emission to observe flag lifecycle changes. Requires installing `@nestjs/event-emitter`.
396
494
 
397
495
  **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.
398
496
 
@@ -419,6 +517,7 @@ export class AppModule {}
419
517
  | Event constant | Event string | Payload type |
420
518
  | ---------------------------------------- | ---------------------------------- | -------------------- |
421
519
  | `FeatureFlagEvents.EVALUATED` | `feature-flag.evaluated` | `FlagEvaluatedEvent` |
520
+ | `FeatureFlagEvents.EXPOSED` | `feature-flag.exposed` | `FlagExposedEvent` |
422
521
  | `FeatureFlagEvents.CREATED` | `feature-flag.created` | `FlagMutationEvent` |
423
522
  | `FeatureFlagEvents.UPDATED` | `feature-flag.updated` | `FlagMutationEvent` |
424
523
  | `FeatureFlagEvents.ARCHIVED` | `feature-flag.archived` | `FlagMutationEvent` |
@@ -436,11 +535,13 @@ import { FeatureFlagEvents, FlagEvaluatedEvent } from '@nestarc/feature-flag';
436
535
  export class FlagAuditListener {
437
536
  @OnEvent(FeatureFlagEvents.EVALUATED)
438
537
  handleEvaluation(event: FlagEvaluatedEvent) {
439
- console.log(`Flag ${event.flagKey} = ${event.result} (source: ${event.source})`);
538
+ console.log(`Flag ${event.flagKey} = ${event.result} (${event.reason})`);
440
539
  }
441
540
  }
442
541
  ```
443
542
 
543
+ Exposure events are opt-in per call, registry entry, or flag metadata via `trackExposure`. They do not persist analytics; attach your own listener if you need sampling, batching, or storage.
544
+
444
545
  ## Testing
445
546
 
446
547
  Import `TestFeatureFlagModule` from the `/testing` subpath to stub flag values in tests without a database connection:
@@ -476,26 +577,42 @@ describe('DashboardController', () => {
476
577
 
477
578
  `TestFeatureFlagModule.register()` provides a global mock of `FeatureFlagService`:
478
579
  - `isEnabled(key)` returns the boolean you specified (defaulting to `false` for unregistered keys)
580
+ - `evaluateBoolean(key)` returns `BooleanEvaluationDetails`
479
581
  - `evaluateAll()` returns the full flag map
480
582
  - `create()`, `update()`, `archive()`, `findByKey()`, `findAll()` return full `FeatureFlagWithOverrides` stub objects
481
583
  - `findByKey()` throws `NotFoundException` for unknown keys
482
584
 
483
- This is a **stateless boolean stub** -- write operations do not persist state across calls. For stateful test doubles, use your own mock implementation.
585
+ For registry-based tests, use `registerRegistry()` and the injected controller:
586
+
587
+ ```typescript
588
+ import {
589
+ TestFeatureFlagController,
590
+ TestFeatureFlagModule,
591
+ } from '@nestarc/feature-flag/testing';
592
+
593
+ const module = await Test.createTestingModule({
594
+ imports: [TestFeatureFlagModule.registerRegistry(flags)],
595
+ }).compile();
596
+
597
+ const testFlags = module.get(TestFeatureFlagController);
598
+ testFlags.set('NEW_CHECKOUT', true);
599
+ testFlags.reset();
600
+ ```
601
+
602
+ The testing controller keeps state inside the compiled testing module. CRUD-style write methods on the mocked service still return stub objects and do not persist database rows.
484
603
 
485
604
  ## Evaluation Priority
486
605
 
487
- When `isEnabled()` is called, flags are evaluated through a 6-layer cascade. The first matching layer wins:
606
+ When `isEnabled()` is called, flags are evaluated through the current cascade. The first matching layer wins:
488
607
 
489
608
  | Priority | Layer | Description |
490
609
  | -------- | ---------------------- | ------------------------------------------------------------------ |
491
610
  | 1 | **Archived** | If the flag has `archivedAt` set, evaluation always returns `false` |
492
- | 2 | **User override** | Override matching the current `userId` (most specific) |
493
- | 3 | **Tenant override** | Override matching the current `tenantId` |
494
- | 4 | **Environment override**| Override matching the current `environment` |
495
- | 5 | **Percentage rollout** | Deterministic hash of `flagKey + userId` (or `tenantId`) mod 100 |
496
- | 6 | **Global default** | The flag's `enabled` field |
611
+ | 2 | **Attribute override** | Best override whose attributes are all present in the evaluation context |
612
+ | 3 | **Percentage rollout** | Deterministic hash of `flagKey + targetingKey` mod 100 |
613
+ | 4 | **Global default** | The flag's `enabled` field |
497
614
 
498
- 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.
615
+ Percentage rollout uses murmurhash3 for deterministic bucketing. The targeting key is resolved in this order: explicit `context.targetingKey`, registry or metadata `bucketBy`, then the legacy `userId ?? tenantId` fallback.
499
616
 
500
617
  ## Configuration Reference
501
618
 
@@ -509,6 +626,7 @@ Percentage rollout uses murmurhash3 for deterministic bucketing: the same user a
509
626
  | `defaultOnMissing` | `boolean` | `false` | Value returned when a flag key does not exist in the database |
510
627
  | `emitEvents` | `boolean` | `false` | Emit lifecycle events via `@nestjs/event-emitter` |
511
628
  | `cacheAdapter` | `CacheAdapter` | `MemoryCacheAdapter` | Pluggable cache backend (e.g. `RedisCacheAdapter`) |
629
+ | `flags` | `FlagRegistry` | `undefined` | Optional typed registry for defaults, `bucketBy`, and exposure settings |
512
630
 
513
631
  ### FeatureFlagModuleRootOptions
514
632
 
@@ -576,6 +694,7 @@ export class AppModule {}
576
694
  | GET | `/feature-flags/:key` | Get a single flag | 404 not found |
577
695
  | PATCH | `/feature-flags/:key` | Update a flag | 404 not found, 400 invalid percentage |
578
696
  | DELETE | `/feature-flags/:key` | Archive a flag | 404 not found |
697
+ | POST | `/feature-flags/:key/evaluate` | Evaluate a flag without mutating it | |
579
698
  | POST | `/feature-flags/:key/overrides` | Set an override | 404 flag not found |
580
699
  | DELETE | `/feature-flags/:key/overrides` | Remove an override | 404 flag not found |
581
700
 
@@ -637,6 +756,12 @@ class MyTenantProvider implements TenantContextProvider {
637
756
  export class AppModule {}
638
757
  ```
639
758
 
759
+ ## Examples
760
+
761
+ - [examples/basic-guard](examples/basic-guard) - route gating with `@FeatureFlag()`
762
+ - [examples/multi-tenant-targeting](examples/multi-tenant-targeting) - tenant and plan targeting with attributes
763
+ - [examples/redis-events](examples/redis-events) - Redis cache invalidation and feature flag events
764
+
640
765
  ## Performance
641
766
 
642
767
  Measured with PostgreSQL 16, Prisma 6, 500 iterations on Apple Silicon:
@@ -1,14 +1,16 @@
1
1
  import { FeatureFlagService } from '../services/feature-flag.service';
2
- import { CreateFeatureFlagInput, UpdateFeatureFlagInput, SetOverrideInput, FeatureFlagWithOverrides } from '../interfaces/feature-flag.interface';
3
- import { RemoveOverrideInput } from '../interfaces/feature-flag.interface';
2
+ import { FeatureFlagWithOverrides } from '../interfaces/feature-flag.interface';
3
+ import { BooleanEvaluationDetails } from '../interfaces/evaluation-details.interface';
4
+ import { CreateFeatureFlagDto, EvaluateFeatureFlagDto, RemoveOverrideDto, SetOverrideDto, UpdateFeatureFlagDto } from './feature-flag-admin.dto';
4
5
  export declare class FeatureFlagAdminController {
5
6
  private readonly service;
6
7
  constructor(service: FeatureFlagService);
7
- create(input: CreateFeatureFlagInput): Promise<FeatureFlagWithOverrides>;
8
+ create(input: CreateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
8
9
  findAll(): Promise<FeatureFlagWithOverrides[]>;
9
10
  findByKey(key: string): Promise<FeatureFlagWithOverrides>;
10
- update(key: string, input: UpdateFeatureFlagInput): Promise<FeatureFlagWithOverrides>;
11
+ update(key: string, input: UpdateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
11
12
  archive(key: string): Promise<FeatureFlagWithOverrides>;
12
- setOverride(key: string, input: SetOverrideInput): Promise<void>;
13
- removeOverride(key: string, input: RemoveOverrideInput): Promise<void>;
13
+ evaluate(key: string, input: EvaluateFeatureFlagDto): Promise<BooleanEvaluationDetails>;
14
+ setOverride(key: string, input: SetOverrideDto): Promise<void>;
15
+ removeOverride(key: string, input: RemoveOverrideDto): Promise<void>;
14
16
  }
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.FeatureFlagAdminController = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const feature_flag_service_1 = require("../services/feature-flag.service");
18
+ const feature_flag_admin_dto_1 = require("./feature-flag-admin.dto");
18
19
  let FeatureFlagAdminController = class FeatureFlagAdminController {
19
20
  constructor(service) {
20
21
  this.service = service;
@@ -34,6 +35,10 @@ let FeatureFlagAdminController = class FeatureFlagAdminController {
34
35
  archive(key) {
35
36
  return this.service.archive(key);
36
37
  }
38
+ evaluate(key, input) {
39
+ const { context, ...options } = input;
40
+ return this.service.evaluateBoolean(key, context, options);
41
+ }
37
42
  setOverride(key, input) {
38
43
  return this.service.setOverride(key, input);
39
44
  }
@@ -46,7 +51,7 @@ __decorate([
46
51
  (0, common_1.Post)(),
47
52
  __param(0, (0, common_1.Body)()),
48
53
  __metadata("design:type", Function),
49
- __metadata("design:paramtypes", [Object]),
54
+ __metadata("design:paramtypes", [feature_flag_admin_dto_1.CreateFeatureFlagDto]),
50
55
  __metadata("design:returntype", Promise)
51
56
  ], FeatureFlagAdminController.prototype, "create", null);
52
57
  __decorate([
@@ -67,7 +72,7 @@ __decorate([
67
72
  __param(0, (0, common_1.Param)('key')),
68
73
  __param(1, (0, common_1.Body)()),
69
74
  __metadata("design:type", Function),
70
- __metadata("design:paramtypes", [String, Object]),
75
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.UpdateFeatureFlagDto]),
71
76
  __metadata("design:returntype", Promise)
72
77
  ], FeatureFlagAdminController.prototype, "update", null);
73
78
  __decorate([
@@ -77,12 +82,20 @@ __decorate([
77
82
  __metadata("design:paramtypes", [String]),
78
83
  __metadata("design:returntype", Promise)
79
84
  ], FeatureFlagAdminController.prototype, "archive", null);
85
+ __decorate([
86
+ (0, common_1.Post)(':key/evaluate'),
87
+ __param(0, (0, common_1.Param)('key')),
88
+ __param(1, (0, common_1.Body)()),
89
+ __metadata("design:type", Function),
90
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.EvaluateFeatureFlagDto]),
91
+ __metadata("design:returntype", Promise)
92
+ ], FeatureFlagAdminController.prototype, "evaluate", null);
80
93
  __decorate([
81
94
  (0, common_1.Post)(':key/overrides'),
82
95
  __param(0, (0, common_1.Param)('key')),
83
96
  __param(1, (0, common_1.Body)()),
84
97
  __metadata("design:type", Function),
85
- __metadata("design:paramtypes", [String, Object]),
98
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.SetOverrideDto]),
86
99
  __metadata("design:returntype", Promise)
87
100
  ], FeatureFlagAdminController.prototype, "setOverride", null);
88
101
  __decorate([
@@ -90,11 +103,16 @@ __decorate([
90
103
  __param(0, (0, common_1.Param)('key')),
91
104
  __param(1, (0, common_1.Body)()),
92
105
  __metadata("design:type", Function),
93
- __metadata("design:paramtypes", [String, Object]),
106
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.RemoveOverrideDto]),
94
107
  __metadata("design:returntype", Promise)
95
108
  ], FeatureFlagAdminController.prototype, "removeOverride", null);
96
109
  exports.FeatureFlagAdminController = FeatureFlagAdminController = __decorate([
97
110
  (0, common_1.Controller)(),
111
+ (0, common_1.UsePipes)(new common_1.ValidationPipe({
112
+ whitelist: true,
113
+ forbidNonWhitelisted: true,
114
+ transform: true,
115
+ })),
98
116
  __metadata("design:paramtypes", [feature_flag_service_1.FeatureFlagService])
99
117
  ], FeatureFlagAdminController);
100
118
  //# sourceMappingURL=feature-flag-admin.controller.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"feature-flag-admin.controller.js","sourceRoot":"","sources":["../../src/admin/feature-flag-admin.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAQwB;AACxB,2EAAsE;AAU/D,IAAM,0BAA0B,GAAhC,MAAM,0BAA0B;IACrC,YAA6B,OAA2B;QAA3B,YAAO,GAAP,OAAO,CAAoB;IAAG,CAAC;IAG5D,MAAM,CAAS,KAA6B;QAC1C,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpC,CAAC;IAGD,OAAO;QACL,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;IAChC,CAAC;IAGD,SAAS,CAAe,GAAW;QACjC,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACrC,CAAC;IAGD,MAAM,CACU,GAAW,EACjB,KAA6B;QAErC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;IAGD,OAAO,CAAe,GAAW;QAC/B,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IAGD,WAAW,CACK,GAAW,EACjB,KAAuB;QAE/B,OAAO,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC9C,CAAC;IAGD,cAAc,CACE,GAAW,EACjB,KAA0B;QAElC,OAAO,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACjD,CAAC;CACF,CAAA;AA9CY,gEAA0B;AAIrC;IADC,IAAA,aAAI,GAAE;IACC,WAAA,IAAA,aAAI,GAAE,CAAA;;;;wDAEb;AAGD;IADC,IAAA,YAAG,GAAE;;;;yDAGL;AAGD;IADC,IAAA,YAAG,EAAC,MAAM,CAAC;IACD,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;;;;2DAEtB;AAGD;IADC,IAAA,cAAK,EAAC,MAAM,CAAC;IAEX,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IACZ,WAAA,IAAA,aAAI,GAAE,CAAA;;;;wDAGR;AAGD;IADC,IAAA,eAAM,EAAC,MAAM,CAAC;IACN,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;;;;yDAEpB;AAGD;IADC,IAAA,aAAI,EAAC,gBAAgB,CAAC;IAEpB,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IACZ,WAAA,IAAA,aAAI,GAAE,CAAA;;;;6DAGR;AAGD;IADC,IAAA,eAAM,EAAC,gBAAgB,CAAC;IAEtB,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IACZ,WAAA,IAAA,aAAI,GAAE,CAAA;;;;gEAGR;qCA7CU,0BAA0B;IADtC,IAAA,mBAAU,GAAE;qCAE2B,yCAAkB;GAD7C,0BAA0B,CA8CtC"}
1
+ {"version":3,"file":"feature-flag-admin.controller.js","sourceRoot":"","sources":["../../src/admin/feature-flag-admin.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAUwB;AACxB,2EAAsE;AAGtE,qEAMkC;AAU3B,IAAM,0BAA0B,GAAhC,MAAM,0BAA0B;IACrC,YAA6B,OAA2B;QAA3B,YAAO,GAAP,OAAO,CAAoB;IAAG,CAAC;IAG5D,MAAM,CAAS,KAA2B;QACxC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpC,CAAC;IAGD,OAAO;QACL,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;IAChC,CAAC;IAGD,SAAS,CAAe,GAAW;QACjC,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACrC,CAAC;IAGD,MAAM,CACU,GAAW,EACjB,KAA2B;QAEnC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;IAGD,OAAO,CAAe,GAAW;QAC/B,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IAGD,QAAQ,CACQ,GAAW,EACjB,KAA6B;QAErC,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,GAAG,KAAK,CAAC;QACtC,OAAO,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D,CAAC;IAGD,WAAW,CAAe,GAAW,EAAU,KAAqB;QAClE,OAAO,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC9C,CAAC;IAGD,cAAc,CAAe,GAAW,EAAU,KAAwB;QACxE,OAAO,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACjD,CAAC;CACF,CAAA;AAjDY,gEAA0B;AAIrC;IADC,IAAA,aAAI,GAAE;IACC,WAAA,IAAA,aAAI,GAAE,CAAA;;qCAAQ,6CAAoB;;wDAEzC;AAGD;IADC,IAAA,YAAG,GAAE;;;;yDAGL;AAGD;IADC,IAAA,YAAG,EAAC,MAAM,CAAC;IACD,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;;;;2DAEtB;AAGD;IADC,IAAA,cAAK,EAAC,MAAM,CAAC;IAEX,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IACZ,WAAA,IAAA,aAAI,GAAE,CAAA;;6CAAQ,6CAAoB;;wDAGpC;AAGD;IADC,IAAA,eAAM,EAAC,MAAM,CAAC;IACN,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;;;;yDAEpB;AAGD;IADC,IAAA,aAAI,EAAC,eAAe,CAAC;IAEnB,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IACZ,WAAA,IAAA,aAAI,GAAE,CAAA;;6CAAQ,+CAAsB;;0DAItC;AAGD;IADC,IAAA,aAAI,EAAC,gBAAgB,CAAC;IACV,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IAAe,WAAA,IAAA,aAAI,GAAE,CAAA;;6CAAQ,uCAAc;;6DAEnE;AAGD;IADC,IAAA,eAAM,EAAC,gBAAgB,CAAC;IACT,WAAA,IAAA,cAAK,EAAC,KAAK,CAAC,CAAA;IAAe,WAAA,IAAA,aAAI,GAAE,CAAA;;6CAAQ,0CAAiB;;gEAEzE;qCAhDU,0BAA0B;IARtC,IAAA,mBAAU,GAAE;IACZ,IAAA,iBAAQ,EACP,IAAI,uBAAc,CAAC;QACjB,SAAS,EAAE,IAAI;QACf,oBAAoB,EAAE,IAAI;QAC1B,SAAS,EAAE,IAAI;KAChB,CAAC,CACH;qCAEuC,yCAAkB;GAD7C,0BAA0B,CAiDtC"}
@@ -0,0 +1,30 @@
1
+ import { EvaluationContext } from '../interfaces/evaluation-context.interface';
2
+ import { EvaluateBooleanOptions } from '../interfaces/evaluation-details.interface';
3
+ import { TargetingAttributes } from '../interfaces/feature-flag.interface';
4
+ export declare class CreateFeatureFlagDto {
5
+ key: string;
6
+ description?: string;
7
+ enabled?: boolean;
8
+ percentage?: number;
9
+ metadata?: Record<string, unknown>;
10
+ }
11
+ export declare class UpdateFeatureFlagDto {
12
+ description?: string;
13
+ enabled?: boolean;
14
+ percentage?: number;
15
+ metadata?: Record<string, unknown>;
16
+ }
17
+ export declare class SetOverrideDto {
18
+ attributes: TargetingAttributes;
19
+ enabled: boolean;
20
+ priority?: number;
21
+ }
22
+ export declare class RemoveOverrideDto {
23
+ attributes: TargetingAttributes;
24
+ }
25
+ export declare class EvaluateFeatureFlagDto implements EvaluateBooleanOptions {
26
+ context?: EvaluationContext;
27
+ defaultValue?: boolean;
28
+ trackExposure?: boolean;
29
+ includeContextInEvent?: boolean;
30
+ }