@nestarc/feature-flag 0.1.0 → 0.3.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 (71) hide show
  1. package/README.md +252 -87
  2. package/dist/admin/admin-options.interface.d.ts +5 -0
  3. package/dist/admin/admin-options.interface.js +3 -0
  4. package/dist/admin/admin-options.interface.js.map +1 -0
  5. package/dist/admin/feature-flag-admin.controller.d.ts +14 -0
  6. package/dist/admin/feature-flag-admin.controller.js +106 -0
  7. package/dist/admin/feature-flag-admin.controller.js.map +1 -0
  8. package/dist/admin/feature-flag-admin.dto.d.ts +22 -0
  9. package/dist/admin/feature-flag-admin.dto.js +93 -0
  10. package/dist/admin/feature-flag-admin.dto.js.map +1 -0
  11. package/dist/admin/feature-flag-admin.module.d.ts +5 -0
  12. package/dist/admin/feature-flag-admin.module.js +33 -0
  13. package/dist/admin/feature-flag-admin.module.js.map +1 -0
  14. package/dist/admin/targeting-attributes.validator.d.ts +6 -0
  15. package/dist/admin/targeting-attributes.validator.js +36 -0
  16. package/dist/admin/targeting-attributes.validator.js.map +1 -0
  17. package/dist/cache/memory-cache.adapter.d.ts +11 -0
  18. package/dist/{services/flag-cache.service.js → cache/memory-cache.adapter.js} +18 -31
  19. package/dist/cache/memory-cache.adapter.js.map +1 -0
  20. package/dist/cache/redis-cache.adapter.d.ts +26 -0
  21. package/dist/cache/redis-cache.adapter.js +102 -0
  22. package/dist/cache/redis-cache.adapter.js.map +1 -0
  23. package/dist/events/feature-flag.events.d.ts +4 -5
  24. package/dist/feature-flag.constants.d.ts +3 -0
  25. package/dist/feature-flag.constants.js +4 -1
  26. package/dist/feature-flag.constants.js.map +1 -1
  27. package/dist/feature-flag.module.js +49 -10
  28. package/dist/feature-flag.module.js.map +1 -1
  29. package/dist/index.d.ts +10 -2
  30. package/dist/index.js +13 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/interfaces/cache-adapter.interface.d.ts +9 -0
  33. package/dist/interfaces/cache-adapter.interface.js +3 -0
  34. package/dist/interfaces/cache-adapter.interface.js.map +1 -0
  35. package/dist/interfaces/evaluation-context.interface.d.ts +6 -3
  36. package/dist/interfaces/feature-flag-options.interface.d.ts +3 -0
  37. package/dist/interfaces/feature-flag-repository.interface.d.ts +22 -0
  38. package/dist/interfaces/feature-flag-repository.interface.js +3 -0
  39. package/dist/interfaces/feature-flag-repository.interface.js.map +1 -0
  40. package/dist/interfaces/feature-flag.interface.d.ts +11 -6
  41. package/dist/interfaces/tenant-context-provider.interface.d.ts +3 -0
  42. package/dist/interfaces/tenant-context-provider.interface.js +3 -0
  43. package/dist/interfaces/tenant-context-provider.interface.js.map +1 -0
  44. package/dist/repositories/prisma-feature-flag.repository.d.ts +18 -0
  45. package/dist/repositories/prisma-feature-flag.repository.js +161 -0
  46. package/dist/repositories/prisma-feature-flag.repository.js.map +1 -0
  47. package/dist/services/default-tenant-context-provider.d.ts +7 -0
  48. package/dist/services/default-tenant-context-provider.js +35 -0
  49. package/dist/services/default-tenant-context-provider.js.map +1 -0
  50. package/dist/services/feature-flag.service.d.ts +21 -13
  51. package/dist/services/feature-flag.service.js +111 -126
  52. package/dist/services/feature-flag.service.js.map +1 -1
  53. package/dist/services/flag-context-resolver.d.ts +11 -0
  54. package/dist/services/flag-context-resolver.js +51 -0
  55. package/dist/services/flag-context-resolver.js.map +1 -0
  56. package/dist/services/flag-evaluator.service.d.ts +1 -0
  57. package/dist/services/flag-evaluator.service.js +29 -27
  58. package/dist/services/flag-evaluator.service.js.map +1 -1
  59. package/dist/services/flag-event-publisher.d.ts +7 -0
  60. package/dist/services/flag-event-publisher.js +37 -0
  61. package/dist/services/flag-event-publisher.js.map +1 -0
  62. package/dist/testing/test-feature-flag.module.js +26 -5
  63. package/dist/testing/test-feature-flag.module.js.map +1 -1
  64. package/dist/utils/targeting-attributes.d.ts +9 -0
  65. package/dist/utils/targeting-attributes.js +44 -0
  66. package/dist/utils/targeting-attributes.js.map +1 -0
  67. package/package.json +20 -3
  68. package/prisma/migrations/20260512000000_attribute_targeting/migration.sql +59 -0
  69. package/prisma/schema.prisma +7 -9
  70. package/dist/services/flag-cache.service.d.ts +0 -13
  71. package/dist/services/flag-cache.service.js.map +0 -1
package/README.md CHANGED
@@ -1,16 +1,25 @@
1
1
  # @nestarc/feature-flag
2
2
 
3
- DB-backed feature flags for NestJS + Prisma + PostgreSQL -- tenant-aware overrides, percentage rollouts, and zero external dependencies.
3
+ [![npm version](https://img.shields.io/npm/v/@nestarc/feature-flag.svg)](https://www.npmjs.com/package/@nestarc/feature-flag)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@nestarc/feature-flag.svg)](https://www.npmjs.com/package/@nestarc/feature-flag)
5
+ [![CI](https://github.com/nestarc/nestjs-feature-flag/actions/workflows/ci.yml/badge.svg)](https://github.com/nestarc/nestjs-feature-flag/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Docs](https://img.shields.io/badge/docs-nestarc.dev-blue.svg)](https://nestarc.dev/packages/feature-flag/)
8
+
9
+ DB-backed feature flags for NestJS + Prisma + PostgreSQL -- attribute-targeted overrides, percentage rollouts, and zero external dependencies.
4
10
 
5
11
  ## Features
6
12
 
7
13
  - **Database-backed** -- flags stored in PostgreSQL via Prisma, no external service required
8
- - **Tenant / user / environment overrides** -- granular control per tenant, user, or deployment environment
14
+ - **Attribute-targeted overrides** -- exact-match targeting for tenants, users, environments, plans, regions, or custom dimensions
9
15
  - **Percentage rollouts** -- deterministic hashing (murmurhash3) for consistent per-user bucketing
10
16
  - **Guard decorator** -- `@FeatureFlag()` automatically gates routes and controllers
11
17
  - **Bypass decorator** -- `@BypassFeatureFlag()` exempts health checks and public endpoints
12
18
  - **Programmatic evaluation** -- `isEnabled()` and `evaluateAll()` for service-layer logic
13
- - **Built-in caching** -- configurable TTL with manual invalidation
19
+ - **Built-in caching** -- configurable TTL with manual invalidation; Redis Pub/Sub for multi-instance
20
+ - **Pluggable persistence** -- `FeatureFlagRepository` interface for custom backends (Prisma default)
21
+ - **Pluggable tenancy** -- `TenantContextProvider` interface for custom tenant resolution
22
+ - **Admin REST API** -- opt-in `FeatureFlagAdminModule` with guard injection and proper error responses
14
23
  - **Event system** -- optional integration with `@nestjs/event-emitter` for audit and observability
15
24
  - **Testing utilities** -- drop-in `TestFeatureFlagModule` for unit and integration tests
16
25
 
@@ -23,7 +32,7 @@ npm install @nestarc/feature-flag
23
32
  ### Peer dependencies
24
33
 
25
34
  ```bash
26
- npm install @nestjs/common @nestjs/core @prisma/client rxjs reflect-metadata
35
+ npm install @nestjs/common @nestjs/core @prisma/client class-transformer class-validator rxjs reflect-metadata
27
36
  ```
28
37
 
29
38
  ### Optional
@@ -31,8 +40,35 @@ npm install @nestjs/common @nestjs/core @prisma/client rxjs reflect-metadata
31
40
  ```bash
32
41
  # Required only if you enable emitEvents
33
42
  npm install @nestjs/event-emitter
43
+
44
+ # Required only if you use RedisCacheAdapter
45
+ npm install ioredis
34
46
  ```
35
47
 
48
+ ## Redis Cache (Multi-Instance)
49
+
50
+ For production deployments with multiple instances, use `RedisCacheAdapter` for shared caching and real-time invalidation via Redis Pub/Sub:
51
+
52
+ ```typescript
53
+ import { FeatureFlagModule, RedisCacheAdapter } from '@nestarc/feature-flag';
54
+ import { Redis } from 'ioredis';
55
+
56
+ const redisClient = new Redis({ host: 'localhost', port: 6379 });
57
+
58
+ FeatureFlagModule.forRoot({
59
+ environment: 'production',
60
+ prisma,
61
+ cacheAdapter: new RedisCacheAdapter({
62
+ client: redisClient,
63
+ // subscriber is auto-created via client.duplicate()
64
+ // keyPrefix: 'feature-flag:', // default
65
+ // channel: 'feature-flag:invalidate', // default
66
+ }),
67
+ })
68
+ ```
69
+
70
+ 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.
71
+
36
72
  ## Prisma Schema
37
73
 
38
74
  Add the following models to your `schema.prisma`:
@@ -55,14 +91,13 @@ model FeatureFlag {
55
91
  }
56
92
 
57
93
  model FeatureFlagOverride {
58
- id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
59
- flagId String @map("flag_id") @db.Uuid
60
- tenantId String? @map("tenant_id")
61
- userId String? @map("user_id")
62
- environment String?
63
- enabled Boolean
64
- createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
65
- updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
94
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
95
+ flagId String @map("flag_id") @db.Uuid
96
+ attributes Json
97
+ priority Int @default(0)
98
+ enabled Boolean
99
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
100
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
66
101
 
67
102
  flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
68
103
 
@@ -71,57 +106,48 @@ model FeatureFlagOverride {
71
106
  }
72
107
  ```
73
108
 
74
- ### Partial unique indexes for overrides
75
-
76
- 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:
109
+ 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:
77
110
 
78
111
  ```sql
79
- -- Drop the old unique constraint that does not handle NULLs correctly
80
- ALTER TABLE feature_flag_overrides
81
- DROP CONSTRAINT IF EXISTS uq_override_context;
82
-
83
- -- Global override (all nullable columns NULL)
84
- CREATE UNIQUE INDEX uq_override_000
85
- ON feature_flag_overrides (flag_id)
86
- WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NULL;
87
-
88
- -- Only environment is NOT NULL
89
- CREATE UNIQUE INDEX uq_override_001
90
- ON feature_flag_overrides (flag_id, environment)
91
- WHERE tenant_id IS NULL AND user_id IS NULL AND environment IS NOT NULL;
92
-
93
- -- Only user_id is NOT NULL
94
- CREATE UNIQUE INDEX uq_override_010
95
- ON feature_flag_overrides (flag_id, user_id)
96
- WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NULL;
97
-
98
- -- user_id + environment
99
- CREATE UNIQUE INDEX uq_override_011
100
- ON feature_flag_overrides (flag_id, user_id, environment)
101
- WHERE tenant_id IS NULL AND user_id IS NOT NULL AND environment IS NOT NULL;
102
-
103
- -- Only tenant_id is NOT NULL
104
- CREATE UNIQUE INDEX uq_override_100
105
- ON feature_flag_overrides (flag_id, tenant_id)
106
- WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NULL;
107
-
108
- -- tenant_id + environment
109
- CREATE UNIQUE INDEX uq_override_101
110
- ON feature_flag_overrides (flag_id, tenant_id, environment)
111
- WHERE tenant_id IS NOT NULL AND user_id IS NULL AND environment IS NOT NULL;
112
-
113
- -- tenant_id + user_id
114
- CREATE UNIQUE INDEX uq_override_110
115
- ON feature_flag_overrides (flag_id, tenant_id, user_id)
116
- WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NULL;
117
-
118
- -- All three NOT NULL
119
- CREATE UNIQUE INDEX uq_override_111
120
- ON feature_flag_overrides (flag_id, tenant_id, user_id, environment)
121
- WHERE tenant_id IS NOT NULL AND user_id IS NOT NULL AND environment IS NOT NULL;
112
+ CREATE UNIQUE INDEX "uq_feature_flag_override_attributes"
113
+ ON "feature_flag_overrides"("flag_id", "attributes");
114
+
115
+ ALTER TABLE "feature_flag_overrides"
116
+ ADD CONSTRAINT "chk_feature_flag_override_attributes_non_empty"
117
+ CHECK (jsonb_typeof("attributes") = 'object' AND "attributes" <> '{}'::jsonb);
118
+ ```
119
+
120
+ ### Migration from 0.2.0 to 0.3.0
121
+
122
+ v0.3.0 changes override storage from fixed `tenant_id`, `user_id`, and `environment` columns to an `attributes` `jsonb` object plus `priority`.
123
+
124
+ Run your Prisma migrations during deployment:
125
+
126
+ ```bash
127
+ npx prisma migrate deploy
128
+ ```
129
+
130
+ The migration maps legacy override columns into attributes:
131
+
132
+ | v0.2.0 column | v0.3.0 attribute |
133
+ | ------------- | ---------------- |
134
+ | `tenant_id` | `attributes.tenantId` |
135
+ | `user_id` | `attributes.userId` |
136
+ | `environment` | `attributes.environment` |
137
+
138
+ 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.
139
+
140
+ Legacy Admin API bodies are rejected:
141
+
142
+ ```json
143
+ { "tenantId": "tenant-1", "enabled": true }
122
144
  ```
123
145
 
124
- This SQL is included in the initial migration at `prisma/migrations/20260405000000_init/migration.sql`.
146
+ Use an `attributes` object instead:
147
+
148
+ ```json
149
+ { "attributes": { "tenantId": "tenant-1" }, "enabled": true }
150
+ ```
125
151
 
126
152
  ## Module Registration
127
153
 
@@ -322,41 +348,64 @@ Passing `null` explicitly clears that dimension, suppressing any ambient value f
322
348
  const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });
323
349
  ```
324
350
 
325
- ## Overrides
351
+ ## Attribute Targeting
326
352
 
327
- Set context-specific overrides that take precedence over the global flag value:
353
+ 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.
328
354
 
329
355
  ```typescript
330
- // Enable for a specific tenant
331
- await this.flags.setOverride('MY_FLAG', {
356
+ const enabled = await this.flags.isEnabled('NEW_CHECKOUT', {
357
+ userId: 'user-123',
332
358
  tenantId: 'tenant-1',
333
- enabled: true,
359
+ environment: 'production',
360
+ attributes: {
361
+ plan: 'pro',
362
+ country: 'KR',
363
+ },
334
364
  });
365
+ ```
335
366
 
336
- // Disable for a specific user
337
- await this.flags.setOverride('MY_FLAG', {
338
- userId: 'user-42',
339
- enabled: false,
340
- });
367
+ Top-level `userId`, `tenantId`, and `environment` are merged into targeting attributes. If the same key also appears in `attributes`, the top-level value wins.
341
368
 
342
- // Enable only in staging
343
- await this.flags.setOverride('MY_FLAG', {
344
- environment: 'staging',
345
- enabled: true,
346
- });
369
+ When multiple overrides match, the evaluator chooses the winner by:
347
370
 
348
- // Combine dimensions
349
- await this.flags.setOverride('MY_FLAG', {
350
- tenantId: 'tenant-1',
351
- userId: 'user-42',
352
- environment: 'production',
371
+ 1. More attributes
372
+ 2. Higher `priority`
373
+ 3. Earlier `createdAt`
374
+ 4. Lower `id`
375
+
376
+ ## Overrides
377
+
378
+ Set attribute-based overrides that take precedence over the global flag value:
379
+
380
+ ```typescript
381
+ await flags.setOverride('NEW_CHECKOUT', {
382
+ attributes: {
383
+ tenantId: 'tenant-1',
384
+ plan: 'pro',
385
+ country: 'KR',
386
+ },
353
387
  enabled: true,
388
+ priority: 10,
354
389
  });
355
390
  ```
356
391
 
392
+ REST Admin API body:
393
+
394
+ ```json
395
+ {
396
+ "attributes": {
397
+ "tenantId": "tenant-1",
398
+ "plan": "pro",
399
+ "country": "KR"
400
+ },
401
+ "enabled": true,
402
+ "priority": 10
403
+ }
404
+ ```
405
+
357
406
  ## Events
358
407
 
359
- Enable event emission to observe flag lifecycle changes. Requires `@nestjs/event-emitter` as an optional peer dependency.
408
+ Enable event emission to observe flag lifecycle changes. Requires installing `@nestjs/event-emitter`.
360
409
 
361
410
  **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.
362
411
 
@@ -438,20 +487,24 @@ describe('DashboardController', () => {
438
487
  });
439
488
  ```
440
489
 
441
- `TestFeatureFlagModule.register()` provides a global mock of `FeatureFlagService` where `isEnabled(key)` returns the boolean you specified (defaulting to `false` for unregistered keys) and `evaluateAll()` returns the full map.
490
+ `TestFeatureFlagModule.register()` provides a global mock of `FeatureFlagService`:
491
+ - `isEnabled(key)` returns the boolean you specified (defaulting to `false` for unregistered keys)
492
+ - `evaluateAll()` returns the full flag map
493
+ - `create()`, `update()`, `archive()`, `findByKey()`, `findAll()` return full `FeatureFlagWithOverrides` stub objects
494
+ - `findByKey()` throws `NotFoundException` for unknown keys
495
+
496
+ This is a **stateless boolean stub** -- write operations do not persist state across calls. For stateful test doubles, use your own mock implementation.
442
497
 
443
498
  ## Evaluation Priority
444
499
 
445
- When `isEnabled()` is called, flags are evaluated through a 6-layer cascade. The first matching layer wins:
500
+ When `isEnabled()` is called, flags are evaluated through the current cascade. The first matching layer wins:
446
501
 
447
502
  | Priority | Layer | Description |
448
503
  | -------- | ---------------------- | ------------------------------------------------------------------ |
449
504
  | 1 | **Archived** | If the flag has `archivedAt` set, evaluation always returns `false` |
450
- | 2 | **User override** | Override matching the current `userId` (most specific) |
451
- | 3 | **Tenant override** | Override matching the current `tenantId` |
452
- | 4 | **Environment override**| Override matching the current `environment` |
453
- | 5 | **Percentage rollout** | Deterministic hash of `flagKey + userId` (or `tenantId`) mod 100 |
454
- | 6 | **Global default** | The flag's `enabled` field |
505
+ | 2 | **Attribute override** | Best override whose attributes are all present in the evaluation context |
506
+ | 3 | **Percentage rollout** | Deterministic hash of `flagKey + userId` (or `tenantId`) mod 100 |
507
+ | 4 | **Global default** | The flag's `enabled` field |
455
508
 
456
509
  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.
457
510
 
@@ -466,6 +519,7 @@ Percentage rollout uses murmurhash3 for deterministic bucketing: the same user a
466
519
  | `userIdExtractor` | `(req: Request) => string \| null`| `undefined`| Extracts user ID from the incoming request |
467
520
  | `defaultOnMissing` | `boolean` | `false` | Value returned when a flag key does not exist in the database |
468
521
  | `emitEvents` | `boolean` | `false` | Emit lifecycle events via `@nestjs/event-emitter` |
522
+ | `cacheAdapter` | `CacheAdapter` | `MemoryCacheAdapter` | Pluggable cache backend (e.g. `RedisCacheAdapter`) |
469
523
 
470
524
  ### FeatureFlagModuleRootOptions
471
525
 
@@ -504,6 +558,117 @@ const allFlags = await this.flags.findAll();
504
558
  this.flags.invalidateCache();
505
559
  ```
506
560
 
561
+ ## Admin REST API
562
+
563
+ `FeatureFlagAdminModule` provides a REST API for managing flags. It requires a guard — the module won't register without one:
564
+
565
+ ```typescript
566
+ import { FeatureFlagAdminModule } from '@nestarc/feature-flag';
567
+ import { AdminAuthGuard } from './guards/admin-auth.guard';
568
+
569
+ @Module({
570
+ imports: [
571
+ FeatureFlagModule.forRoot({ ... }),
572
+ FeatureFlagAdminModule.register({
573
+ guard: AdminAuthGuard,
574
+ // path: 'feature-flags', // default
575
+ }),
576
+ ],
577
+ })
578
+ export class AppModule {}
579
+ ```
580
+
581
+ ### Endpoints
582
+
583
+ | Method | Route | Description | Error Responses |
584
+ |--------|-------|-------------|-----------------|
585
+ | POST | `/feature-flags` | Create a flag | 409 duplicate key, 400 invalid percentage |
586
+ | GET | `/feature-flags` | List all flags | |
587
+ | GET | `/feature-flags/:key` | Get a single flag | 404 not found |
588
+ | PATCH | `/feature-flags/:key` | Update a flag | 404 not found, 400 invalid percentage |
589
+ | DELETE | `/feature-flags/:key` | Archive a flag | 404 not found |
590
+ | POST | `/feature-flags/:key/overrides` | Set an override | 404 flag not found |
591
+ | DELETE | `/feature-flags/:key/overrides` | Remove an override | 404 flag not found |
592
+
593
+ Percentage values must be between 0 and 100 (inclusive). Invalid values return 400 Bad Request.
594
+
595
+ ## Custom Persistence (Advanced)
596
+
597
+ The default `PrismaFeatureFlagRepository` can be replaced with any implementation of `FeatureFlagRepository`:
598
+
599
+ ```typescript
600
+ import {
601
+ FeatureFlagModule,
602
+ FEATURE_FLAG_REPOSITORY,
603
+ FeatureFlagRepository,
604
+ } from '@nestarc/feature-flag';
605
+
606
+ @Module({
607
+ imports: [
608
+ FeatureFlagModule.forRoot({
609
+ environment: 'production',
610
+ prisma, // still required for module init, but unused if you override the repository
611
+ }),
612
+ ],
613
+ providers: [
614
+ {
615
+ provide: FEATURE_FLAG_REPOSITORY,
616
+ useClass: MyCustomRepository, // implements FeatureFlagRepository
617
+ },
618
+ ],
619
+ })
620
+ export class AppModule {}
621
+ ```
622
+
623
+ ## Custom Tenant Resolution (Advanced)
624
+
625
+ Override the default `@nestarc/tenancy` integration with your own `TenantContextProvider`:
626
+
627
+ ```typescript
628
+ import {
629
+ FeatureFlagModule,
630
+ TENANT_CONTEXT_PROVIDER,
631
+ TenantContextProvider,
632
+ } from '@nestarc/feature-flag';
633
+
634
+ @Injectable()
635
+ class MyTenantProvider implements TenantContextProvider {
636
+ getCurrentTenantId(): string | null {
637
+ // your custom tenant resolution logic
638
+ return 'tenant-from-custom-source';
639
+ }
640
+ }
641
+
642
+ @Module({
643
+ imports: [FeatureFlagModule.forRoot({ ... })],
644
+ providers: [
645
+ { provide: TENANT_CONTEXT_PROVIDER, useClass: MyTenantProvider },
646
+ ],
647
+ })
648
+ export class AppModule {}
649
+ ```
650
+
651
+ ## Examples
652
+
653
+ - [examples/basic-guard](examples/basic-guard) - route gating with `@FeatureFlag()`
654
+ - [examples/multi-tenant-targeting](examples/multi-tenant-targeting) - tenant and plan targeting with attributes
655
+ - [examples/redis-events](examples/redis-events) - Redis cache invalidation and feature flag events
656
+
657
+ ## Performance
658
+
659
+ Measured with PostgreSQL 16, Prisma 6, 500 iterations on Apple Silicon:
660
+
661
+ | Scenario | Avg | P50 | P95 | P99 |
662
+ |----------|-----|-----|-----|-----|
663
+ | **isEnabled() — cache hit** | **0.04ms** | **0.03ms** | **0.05ms** | **0.07ms** |
664
+ | isEnabled() — cache miss (DB lookup) | 1.30ms | 1.14ms | 2.54ms | 3.69ms |
665
+ | isEnabled() — override cascade (cold) | 1.07ms | 1.02ms | 1.43ms | 2.11ms |
666
+ | **evaluateAll() — 50 flags (mixed)** | **0.19ms** | **0.04ms** | **1.55ms** | **1.71ms** |
667
+
668
+ Cache speedup: **32.5x** (hit vs miss). Keep the default 30s cache TTL for optimal performance.
669
+
670
+ > Reproduce: `docker compose up -d && dotenv -e .env.test -- npx ts-node benchmarks/evaluation-overhead.ts`
671
+
507
672
  ## License
508
673
 
509
674
  MIT
@@ -0,0 +1,5 @@
1
+ import { CanActivate, Type } from '@nestjs/common';
2
+ export interface FeatureFlagAdminOptions {
3
+ guard: Type<CanActivate>;
4
+ path?: string;
5
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=admin-options.interface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-options.interface.js","sourceRoot":"","sources":["../../src/admin/admin-options.interface.ts"],"names":[],"mappings":""}
@@ -0,0 +1,14 @@
1
+ import { FeatureFlagService } from '../services/feature-flag.service';
2
+ import { FeatureFlagWithOverrides } from '../interfaces/feature-flag.interface';
3
+ import { CreateFeatureFlagDto, RemoveOverrideDto, SetOverrideDto, UpdateFeatureFlagDto } from './feature-flag-admin.dto';
4
+ export declare class FeatureFlagAdminController {
5
+ private readonly service;
6
+ constructor(service: FeatureFlagService);
7
+ create(input: CreateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
8
+ findAll(): Promise<FeatureFlagWithOverrides[]>;
9
+ findByKey(key: string): Promise<FeatureFlagWithOverrides>;
10
+ update(key: string, input: UpdateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
11
+ archive(key: string): Promise<FeatureFlagWithOverrides>;
12
+ setOverride(key: string, input: SetOverrideDto): Promise<void>;
13
+ removeOverride(key: string, input: RemoveOverrideDto): Promise<void>;
14
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.FeatureFlagAdminController = void 0;
16
+ const common_1 = require("@nestjs/common");
17
+ const feature_flag_service_1 = require("../services/feature-flag.service");
18
+ const feature_flag_admin_dto_1 = require("./feature-flag-admin.dto");
19
+ let FeatureFlagAdminController = class FeatureFlagAdminController {
20
+ constructor(service) {
21
+ this.service = service;
22
+ }
23
+ create(input) {
24
+ return this.service.create(input);
25
+ }
26
+ findAll() {
27
+ return this.service.findAll();
28
+ }
29
+ findByKey(key) {
30
+ return this.service.findByKey(key);
31
+ }
32
+ update(key, input) {
33
+ return this.service.update(key, input);
34
+ }
35
+ archive(key) {
36
+ return this.service.archive(key);
37
+ }
38
+ setOverride(key, input) {
39
+ return this.service.setOverride(key, input);
40
+ }
41
+ removeOverride(key, input) {
42
+ return this.service.removeOverride(key, input);
43
+ }
44
+ };
45
+ exports.FeatureFlagAdminController = FeatureFlagAdminController;
46
+ __decorate([
47
+ (0, common_1.Post)(),
48
+ __param(0, (0, common_1.Body)()),
49
+ __metadata("design:type", Function),
50
+ __metadata("design:paramtypes", [feature_flag_admin_dto_1.CreateFeatureFlagDto]),
51
+ __metadata("design:returntype", Promise)
52
+ ], FeatureFlagAdminController.prototype, "create", null);
53
+ __decorate([
54
+ (0, common_1.Get)(),
55
+ __metadata("design:type", Function),
56
+ __metadata("design:paramtypes", []),
57
+ __metadata("design:returntype", Promise)
58
+ ], FeatureFlagAdminController.prototype, "findAll", null);
59
+ __decorate([
60
+ (0, common_1.Get)(':key'),
61
+ __param(0, (0, common_1.Param)('key')),
62
+ __metadata("design:type", Function),
63
+ __metadata("design:paramtypes", [String]),
64
+ __metadata("design:returntype", Promise)
65
+ ], FeatureFlagAdminController.prototype, "findByKey", null);
66
+ __decorate([
67
+ (0, common_1.Patch)(':key'),
68
+ __param(0, (0, common_1.Param)('key')),
69
+ __param(1, (0, common_1.Body)()),
70
+ __metadata("design:type", Function),
71
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.UpdateFeatureFlagDto]),
72
+ __metadata("design:returntype", Promise)
73
+ ], FeatureFlagAdminController.prototype, "update", null);
74
+ __decorate([
75
+ (0, common_1.Delete)(':key'),
76
+ __param(0, (0, common_1.Param)('key')),
77
+ __metadata("design:type", Function),
78
+ __metadata("design:paramtypes", [String]),
79
+ __metadata("design:returntype", Promise)
80
+ ], FeatureFlagAdminController.prototype, "archive", null);
81
+ __decorate([
82
+ (0, common_1.Post)(':key/overrides'),
83
+ __param(0, (0, common_1.Param)('key')),
84
+ __param(1, (0, common_1.Body)()),
85
+ __metadata("design:type", Function),
86
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.SetOverrideDto]),
87
+ __metadata("design:returntype", Promise)
88
+ ], FeatureFlagAdminController.prototype, "setOverride", null);
89
+ __decorate([
90
+ (0, common_1.Delete)(':key/overrides'),
91
+ __param(0, (0, common_1.Param)('key')),
92
+ __param(1, (0, common_1.Body)()),
93
+ __metadata("design:type", Function),
94
+ __metadata("design:paramtypes", [String, feature_flag_admin_dto_1.RemoveOverrideDto]),
95
+ __metadata("design:returntype", Promise)
96
+ ], FeatureFlagAdminController.prototype, "removeOverride", null);
97
+ exports.FeatureFlagAdminController = FeatureFlagAdminController = __decorate([
98
+ (0, common_1.Controller)(),
99
+ (0, common_1.UsePipes)(new common_1.ValidationPipe({
100
+ whitelist: true,
101
+ forbidNonWhitelisted: true,
102
+ transform: true,
103
+ })),
104
+ __metadata("design:paramtypes", [feature_flag_service_1.FeatureFlagService])
105
+ ], FeatureFlagAdminController);
106
+ //# sourceMappingURL=feature-flag-admin.controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feature-flag-admin.controller.js","sourceRoot":"","sources":["../../src/admin/feature-flag-admin.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAUwB;AACxB,2EAAsE;AAEtE,qEAKkC;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,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;AAxCY,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,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;qCAvCU,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,CAwCtC"}
@@ -0,0 +1,22 @@
1
+ import { TargetingAttributes } from '../interfaces/feature-flag.interface';
2
+ export declare class CreateFeatureFlagDto {
3
+ key: string;
4
+ description?: string;
5
+ enabled?: boolean;
6
+ percentage?: number;
7
+ metadata?: Record<string, unknown>;
8
+ }
9
+ export declare class UpdateFeatureFlagDto {
10
+ description?: string;
11
+ enabled?: boolean;
12
+ percentage?: number;
13
+ metadata?: Record<string, unknown>;
14
+ }
15
+ export declare class SetOverrideDto {
16
+ attributes: TargetingAttributes;
17
+ enabled: boolean;
18
+ priority?: number;
19
+ }
20
+ export declare class RemoveOverrideDto {
21
+ attributes: TargetingAttributes;
22
+ }