@nestarc/feature-flag 0.1.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.
- package/LICENSE +21 -0
- package/README.md +509 -0
- package/dist/decorators/bypass-feature-flag.decorator.d.ts +1 -0
- package/dist/decorators/bypass-feature-flag.decorator.js +8 -0
- package/dist/decorators/bypass-feature-flag.decorator.js.map +1 -0
- package/dist/decorators/feature-flag.decorator.d.ts +2 -0
- package/dist/decorators/feature-flag.decorator.js +10 -0
- package/dist/decorators/feature-flag.decorator.js.map +1 -0
- package/dist/events/feature-flag.events.d.ts +29 -0
- package/dist/events/feature-flag.events.js +13 -0
- package/dist/events/feature-flag.events.js.map +1 -0
- package/dist/feature-flag.constants.d.ts +5 -0
- package/dist/feature-flag.constants.js +9 -0
- package/dist/feature-flag.constants.js.map +1 -0
- package/dist/feature-flag.module.d.ts +14 -0
- package/dist/feature-flag.module.js +132 -0
- package/dist/feature-flag.module.js.map +1 -0
- package/dist/guards/feature-flag.guard.d.ts +9 -0
- package/dist/guards/feature-flag.guard.js +53 -0
- package/dist/guards/feature-flag.guard.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/evaluation-context.interface.d.ts +8 -0
- package/dist/interfaces/evaluation-context.interface.js +3 -0
- package/dist/interfaces/evaluation-context.interface.js.map +1 -0
- package/dist/interfaces/feature-flag-options.interface.d.ts +27 -0
- package/dist/interfaces/feature-flag-options.interface.js +3 -0
- package/dist/interfaces/feature-flag-options.interface.js.map +1 -0
- package/dist/interfaces/feature-flag.interface.d.ts +45 -0
- package/dist/interfaces/feature-flag.interface.js +3 -0
- package/dist/interfaces/feature-flag.interface.js.map +1 -0
- package/dist/middleware/flag-context.middleware.d.ts +10 -0
- package/dist/middleware/flag-context.middleware.js +35 -0
- package/dist/middleware/flag-context.middleware.js.map +1 -0
- package/dist/services/feature-flag.service.d.ts +29 -0
- package/dist/services/feature-flag.service.js +208 -0
- package/dist/services/feature-flag.service.js.map +1 -0
- package/dist/services/flag-cache.service.d.ts +13 -0
- package/dist/services/flag-cache.service.js +72 -0
- package/dist/services/flag-cache.service.js.map +1 -0
- package/dist/services/flag-context.d.ts +9 -0
- package/dist/services/flag-context.js +26 -0
- package/dist/services/flag-context.js.map +1 -0
- package/dist/services/flag-evaluator.service.d.ts +12 -0
- package/dist/services/flag-evaluator.service.js +63 -0
- package/dist/services/flag-evaluator.service.js.map +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-feature-flag.module.d.ts +4 -0
- package/dist/testing/test-feature-flag.module.js +41 -0
- package/dist/testing/test-feature-flag.module.js.map +1 -0
- package/dist/utils/hash.d.ts +6 -0
- package/dist/utils/hash.js +28 -0
- package/dist/utils/hash.js.map +1 -0
- package/package.json +88 -0
- package/prisma/migrations/20260405000000_init/migration.sql +76 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nestarc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# @nestarc/feature-flag
|
|
2
|
+
|
|
3
|
+
DB-backed feature flags for NestJS + Prisma + PostgreSQL -- tenant-aware overrides, percentage rollouts, and zero external dependencies.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **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
|
|
9
|
+
- **Percentage rollouts** -- deterministic hashing (murmurhash3) for consistent per-user bucketing
|
|
10
|
+
- **Guard decorator** -- `@FeatureFlag()` automatically gates routes and controllers
|
|
11
|
+
- **Bypass decorator** -- `@BypassFeatureFlag()` exempts health checks and public endpoints
|
|
12
|
+
- **Programmatic evaluation** -- `isEnabled()` and `evaluateAll()` for service-layer logic
|
|
13
|
+
- **Built-in caching** -- configurable TTL with manual invalidation
|
|
14
|
+
- **Event system** -- optional integration with `@nestjs/event-emitter` for audit and observability
|
|
15
|
+
- **Testing utilities** -- drop-in `TestFeatureFlagModule` for unit and integration tests
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @nestarc/feature-flag
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Peer dependencies
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @nestjs/common @nestjs/core @prisma/client rxjs reflect-metadata
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Optional
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Required only if you enable emitEvents
|
|
33
|
+
npm install @nestjs/event-emitter
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Prisma Schema
|
|
37
|
+
|
|
38
|
+
Add the following models to your `schema.prisma`:
|
|
39
|
+
|
|
40
|
+
```prisma
|
|
41
|
+
model FeatureFlag {
|
|
42
|
+
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
43
|
+
key String @unique
|
|
44
|
+
description String?
|
|
45
|
+
enabled Boolean @default(false)
|
|
46
|
+
percentage Int @default(0)
|
|
47
|
+
metadata Json @default("{}")
|
|
48
|
+
archivedAt DateTime? @map("archived_at") @db.Timestamptz()
|
|
49
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
|
50
|
+
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
|
51
|
+
|
|
52
|
+
overrides FeatureFlagOverride[]
|
|
53
|
+
|
|
54
|
+
@@map("feature_flags")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
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()
|
|
66
|
+
|
|
67
|
+
flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
|
|
68
|
+
|
|
69
|
+
@@index([flagId], map: "idx_override_flag_id")
|
|
70
|
+
@@map("feature_flag_overrides")
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
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:
|
|
77
|
+
|
|
78
|
+
```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;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This SQL is included in the initial migration at `prisma/migrations/20260405000000_init/migration.sql`.
|
|
125
|
+
|
|
126
|
+
## Module Registration
|
|
127
|
+
|
|
128
|
+
### forRoot (synchronous)
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { FeatureFlagModule } from '@nestarc/feature-flag';
|
|
132
|
+
|
|
133
|
+
@Module({
|
|
134
|
+
imports: [
|
|
135
|
+
FeatureFlagModule.forRoot({
|
|
136
|
+
environment: 'production',
|
|
137
|
+
prisma: prismaService,
|
|
138
|
+
userIdExtractor: (req) => req.headers['x-user-id'] as string,
|
|
139
|
+
emitEvents: true,
|
|
140
|
+
cacheTtlMs: 30_000,
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
export class AppModule {}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### forRootAsync (with useFactory)
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { FeatureFlagModule } from '@nestarc/feature-flag';
|
|
151
|
+
|
|
152
|
+
@Module({
|
|
153
|
+
imports: [
|
|
154
|
+
FeatureFlagModule.forRootAsync({
|
|
155
|
+
imports: [ConfigModule],
|
|
156
|
+
inject: [ConfigService, PrismaService],
|
|
157
|
+
useFactory: (config: ConfigService, prisma: PrismaService) => ({
|
|
158
|
+
environment: config.get('NODE_ENV'),
|
|
159
|
+
prisma,
|
|
160
|
+
userIdExtractor: (req) => req.headers['x-user-id'] as string,
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
],
|
|
164
|
+
})
|
|
165
|
+
export class AppModule {}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### forRootAsync (with useClass)
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
@Injectable()
|
|
172
|
+
class FeatureFlagConfigService implements FeatureFlagModuleOptionsFactory {
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly config: ConfigService,
|
|
175
|
+
private readonly prisma: PrismaService,
|
|
176
|
+
) {}
|
|
177
|
+
|
|
178
|
+
createFeatureFlagOptions() {
|
|
179
|
+
return {
|
|
180
|
+
environment: this.config.get('NODE_ENV'),
|
|
181
|
+
prisma: this.prisma,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Module({
|
|
187
|
+
imports: [
|
|
188
|
+
FeatureFlagModule.forRootAsync({
|
|
189
|
+
imports: [ConfigModule, PrismaModule],
|
|
190
|
+
useClass: FeatureFlagConfigService,
|
|
191
|
+
}),
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
export class AppModule {}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### forRootAsync (with useExisting)
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
@Module({
|
|
201
|
+
imports: [
|
|
202
|
+
FeatureFlagModule.forRootAsync({
|
|
203
|
+
useExisting: FeatureFlagConfigService,
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
})
|
|
207
|
+
export class AppModule {}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Feature Flag Guard
|
|
211
|
+
|
|
212
|
+
The `@FeatureFlag()` decorator automatically applies `UseGuards(FeatureFlagGuard)`, so you do not need to add `@UseGuards()` yourself.
|
|
213
|
+
|
|
214
|
+
### Method-level
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { FeatureFlag } from '@nestarc/feature-flag';
|
|
218
|
+
|
|
219
|
+
@Controller('dashboard')
|
|
220
|
+
export class DashboardController {
|
|
221
|
+
@FeatureFlag('NEW_DASHBOARD')
|
|
222
|
+
@Get()
|
|
223
|
+
getDashboard() {
|
|
224
|
+
return { message: 'Welcome to the new dashboard' };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Class-level
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
@FeatureFlag('BETA_API')
|
|
233
|
+
@Controller('beta')
|
|
234
|
+
export class BetaController {
|
|
235
|
+
@Get('feature-a')
|
|
236
|
+
featureA() { /* guarded */ }
|
|
237
|
+
|
|
238
|
+
@Get('feature-b')
|
|
239
|
+
featureB() { /* guarded */ }
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Custom status code and fallback
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
@FeatureFlag('PREMIUM_FEATURE', {
|
|
247
|
+
statusCode: 402,
|
|
248
|
+
fallback: { message: 'Upgrade required' },
|
|
249
|
+
})
|
|
250
|
+
@Get('premium')
|
|
251
|
+
getPremiumContent() { ... }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
When the flag is disabled, the guard responds with the given `statusCode` (default `403`) and optional `fallback` body.
|
|
255
|
+
|
|
256
|
+
### Bypassing the guard
|
|
257
|
+
|
|
258
|
+
Use `@BypassFeatureFlag()` on methods that should always be accessible, even when a class-level flag is applied:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import { BypassFeatureFlag } from '@nestarc/feature-flag';
|
|
262
|
+
|
|
263
|
+
@FeatureFlag('BETA_API')
|
|
264
|
+
@Controller('beta')
|
|
265
|
+
export class BetaController {
|
|
266
|
+
@Get('docs')
|
|
267
|
+
betaDocs() { /* guarded by BETA_API */ }
|
|
268
|
+
|
|
269
|
+
@BypassFeatureFlag()
|
|
270
|
+
@Get('health')
|
|
271
|
+
healthCheck() {
|
|
272
|
+
return { status: 'ok' };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Programmatic Evaluation
|
|
278
|
+
|
|
279
|
+
Inject `FeatureFlagService` for service-layer checks outside the HTTP request cycle:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
import { FeatureFlagService } from '@nestarc/feature-flag';
|
|
283
|
+
|
|
284
|
+
@Injectable()
|
|
285
|
+
export class PaymentService {
|
|
286
|
+
constructor(private readonly flags: FeatureFlagService) {}
|
|
287
|
+
|
|
288
|
+
async processPayment(order: Order) {
|
|
289
|
+
const useNewGateway = await this.flags.isEnabled('NEW_PAYMENT_GATEWAY');
|
|
290
|
+
|
|
291
|
+
if (useNewGateway) {
|
|
292
|
+
return this.newGateway.process(order);
|
|
293
|
+
}
|
|
294
|
+
return this.legacyGateway.process(order);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Evaluate all flags at once
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
const allFlags = await this.flags.evaluateAll();
|
|
303
|
+
// { NEW_DASHBOARD: true, PREMIUM_FEATURE: false, ... }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Explicit evaluation context
|
|
307
|
+
|
|
308
|
+
Both `isEnabled()` and `evaluateAll()` accept an optional `EvaluationContext` to override the auto-detected context:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const enabled = await this.flags.isEnabled('MY_FLAG', {
|
|
312
|
+
userId: 'user-123',
|
|
313
|
+
tenantId: 'tenant-abc',
|
|
314
|
+
environment: 'staging',
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Passing `null` explicitly clears that dimension, suppressing any ambient value from the request context:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// Evaluate as if no user is present, even within a request with x-user-id
|
|
322
|
+
const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Overrides
|
|
326
|
+
|
|
327
|
+
Set context-specific overrides that take precedence over the global flag value:
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// Enable for a specific tenant
|
|
331
|
+
await this.flags.setOverride('MY_FLAG', {
|
|
332
|
+
tenantId: 'tenant-1',
|
|
333
|
+
enabled: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Disable for a specific user
|
|
337
|
+
await this.flags.setOverride('MY_FLAG', {
|
|
338
|
+
userId: 'user-42',
|
|
339
|
+
enabled: false,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Enable only in staging
|
|
343
|
+
await this.flags.setOverride('MY_FLAG', {
|
|
344
|
+
environment: 'staging',
|
|
345
|
+
enabled: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Combine dimensions
|
|
349
|
+
await this.flags.setOverride('MY_FLAG', {
|
|
350
|
+
tenantId: 'tenant-1',
|
|
351
|
+
userId: 'user-42',
|
|
352
|
+
environment: 'production',
|
|
353
|
+
enabled: true,
|
|
354
|
+
});
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Events
|
|
358
|
+
|
|
359
|
+
Enable event emission to observe flag lifecycle changes. Requires `@nestjs/event-emitter` as an optional peer dependency.
|
|
360
|
+
|
|
361
|
+
**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
|
+
|
|
363
|
+
### Setup
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
367
|
+
|
|
368
|
+
@Module({
|
|
369
|
+
imports: [
|
|
370
|
+
EventEmitterModule.forRoot(), // must be imported
|
|
371
|
+
FeatureFlagModule.forRoot({
|
|
372
|
+
environment: 'production',
|
|
373
|
+
prisma: prismaService,
|
|
374
|
+
emitEvents: true,
|
|
375
|
+
}),
|
|
376
|
+
],
|
|
377
|
+
})
|
|
378
|
+
export class AppModule {}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Event types
|
|
382
|
+
|
|
383
|
+
| Event constant | Event string | Payload type |
|
|
384
|
+
| ---------------------------------------- | ---------------------------------- | -------------------- |
|
|
385
|
+
| `FeatureFlagEvents.EVALUATED` | `feature-flag.evaluated` | `FlagEvaluatedEvent` |
|
|
386
|
+
| `FeatureFlagEvents.CREATED` | `feature-flag.created` | `FlagMutationEvent` |
|
|
387
|
+
| `FeatureFlagEvents.UPDATED` | `feature-flag.updated` | `FlagMutationEvent` |
|
|
388
|
+
| `FeatureFlagEvents.ARCHIVED` | `feature-flag.archived` | `FlagMutationEvent` |
|
|
389
|
+
| `FeatureFlagEvents.OVERRIDE_SET` | `feature-flag.override.set` | `FlagOverrideEvent` |
|
|
390
|
+
| `FeatureFlagEvents.OVERRIDE_REMOVED` | `feature-flag.override.removed` | `FlagOverrideEvent` |
|
|
391
|
+
| `FeatureFlagEvents.CACHE_INVALIDATED` | `feature-flag.cache.invalidated` | `{}` |
|
|
392
|
+
|
|
393
|
+
### Listening to events
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
import { OnEvent } from '@nestjs/event-emitter';
|
|
397
|
+
import { FeatureFlagEvents, FlagEvaluatedEvent } from '@nestarc/feature-flag';
|
|
398
|
+
|
|
399
|
+
@Injectable()
|
|
400
|
+
export class FlagAuditListener {
|
|
401
|
+
@OnEvent(FeatureFlagEvents.EVALUATED)
|
|
402
|
+
handleEvaluation(event: FlagEvaluatedEvent) {
|
|
403
|
+
console.log(`Flag ${event.flagKey} = ${event.result} (source: ${event.source})`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## Testing
|
|
409
|
+
|
|
410
|
+
Import `TestFeatureFlagModule` from the `/testing` subpath to stub flag values in tests without a database connection:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import { TestFeatureFlagModule } from '@nestarc/feature-flag/testing';
|
|
414
|
+
|
|
415
|
+
describe('DashboardController', () => {
|
|
416
|
+
let app: INestApplication;
|
|
417
|
+
|
|
418
|
+
beforeEach(async () => {
|
|
419
|
+
const module = await Test.createTestingModule({
|
|
420
|
+
imports: [
|
|
421
|
+
TestFeatureFlagModule.register({
|
|
422
|
+
NEW_DASHBOARD: true,
|
|
423
|
+
PREMIUM_FEATURE: false,
|
|
424
|
+
}),
|
|
425
|
+
],
|
|
426
|
+
controllers: [DashboardController],
|
|
427
|
+
}).compile();
|
|
428
|
+
|
|
429
|
+
app = module.createNestApplication();
|
|
430
|
+
await app.init();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should allow access when flag is enabled', () => {
|
|
434
|
+
return request(app.getHttpServer())
|
|
435
|
+
.get('/dashboard')
|
|
436
|
+
.expect(200);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
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.
|
|
442
|
+
|
|
443
|
+
## Evaluation Priority
|
|
444
|
+
|
|
445
|
+
When `isEnabled()` is called, flags are evaluated through a 6-layer cascade. The first matching layer wins:
|
|
446
|
+
|
|
447
|
+
| Priority | Layer | Description |
|
|
448
|
+
| -------- | ---------------------- | ------------------------------------------------------------------ |
|
|
449
|
+
| 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 |
|
|
455
|
+
|
|
456
|
+
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
|
+
|
|
458
|
+
## Configuration Reference
|
|
459
|
+
|
|
460
|
+
### FeatureFlagModuleOptions
|
|
461
|
+
|
|
462
|
+
| Option | Type | Default | Description |
|
|
463
|
+
| ------------------- | --------------------------------- | --------- | --------------------------------------------------------------- |
|
|
464
|
+
| `environment` | `string` | *required*| Deployment environment (e.g. `'production'`, `'staging'`) |
|
|
465
|
+
| `cacheTtlMs` | `number` | `30000` | Cache TTL in ms. Set to `0` to disable caching |
|
|
466
|
+
| `userIdExtractor` | `(req: Request) => string \| null`| `undefined`| Extracts user ID from the incoming request |
|
|
467
|
+
| `defaultOnMissing` | `boolean` | `false` | Value returned when a flag key does not exist in the database |
|
|
468
|
+
| `emitEvents` | `boolean` | `false` | Emit lifecycle events via `@nestjs/event-emitter` |
|
|
469
|
+
|
|
470
|
+
### FeatureFlagModuleRootOptions
|
|
471
|
+
|
|
472
|
+
Extends `FeatureFlagModuleOptions` with:
|
|
473
|
+
|
|
474
|
+
| Option | Type | Description |
|
|
475
|
+
| ------- | ----- | ------------------------------ |
|
|
476
|
+
| `prisma`| `any` | Prisma client instance |
|
|
477
|
+
|
|
478
|
+
## CRUD Operations
|
|
479
|
+
|
|
480
|
+
`FeatureFlagService` also exposes methods for managing flags programmatically:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
// Create a flag
|
|
484
|
+
const flag = await this.flags.create({
|
|
485
|
+
key: 'NEW_FEATURE',
|
|
486
|
+
description: 'Enables the new feature',
|
|
487
|
+
enabled: false,
|
|
488
|
+
percentage: 0,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Update a flag
|
|
492
|
+
await this.flags.update('NEW_FEATURE', {
|
|
493
|
+
enabled: true,
|
|
494
|
+
percentage: 50,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Archive a flag (soft delete -- evaluations return false)
|
|
498
|
+
await this.flags.archive('OLD_FEATURE');
|
|
499
|
+
|
|
500
|
+
// List all active (non-archived) flags
|
|
501
|
+
const allFlags = await this.flags.findAll();
|
|
502
|
+
|
|
503
|
+
// Manually invalidate the cache
|
|
504
|
+
this.flags.invalidateCache();
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## License
|
|
508
|
+
|
|
509
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const BypassFeatureFlag: () => import("@nestjs/common").CustomDecorator<string>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BypassFeatureFlag = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
const feature_flag_constants_1 = require("../feature-flag.constants");
|
|
6
|
+
const BypassFeatureFlag = () => (0, common_1.SetMetadata)(feature_flag_constants_1.BYPASS_FEATURE_FLAG_KEY, true);
|
|
7
|
+
exports.BypassFeatureFlag = BypassFeatureFlag;
|
|
8
|
+
//# sourceMappingURL=bypass-feature-flag.decorator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bypass-feature-flag.decorator.js","sourceRoot":"","sources":["../../src/decorators/bypass-feature-flag.decorator.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAC7C,sEAAoE;AAE7D,MAAM,iBAAiB,GAAG,GAAG,EAAE,CAAC,IAAA,oBAAW,EAAC,gDAAuB,EAAE,IAAI,CAAC,CAAC;AAArE,QAAA,iBAAiB,qBAAoD"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FeatureFlag = FeatureFlag;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
const feature_flag_constants_1 = require("../feature-flag.constants");
|
|
6
|
+
const feature_flag_guard_1 = require("../guards/feature-flag.guard");
|
|
7
|
+
function FeatureFlag(flagKey, options = {}) {
|
|
8
|
+
return (0, common_1.applyDecorators)((0, common_1.SetMetadata)(feature_flag_constants_1.FEATURE_FLAG_KEY, flagKey), (0, common_1.SetMetadata)(feature_flag_constants_1.FEATURE_FLAG_OPTIONS_KEY, options), (0, common_1.UseGuards)(feature_flag_guard_1.FeatureFlagGuard));
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=feature-flag.decorator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feature-flag.decorator.js","sourceRoot":"","sources":["../../src/decorators/feature-flag.decorator.ts"],"names":[],"mappings":";;AAKA,kCASC;AAdD,2CAAyE;AACzE,sEAAuF;AAEvF,qEAAgE;AAEhE,SAAgB,WAAW,CACzB,OAAe,EACf,UAAmC,EAAE;IAErC,OAAO,IAAA,wBAAe,EACpB,IAAA,oBAAW,EAAC,yCAAgB,EAAE,OAAO,CAAC,EACtC,IAAA,oBAAW,EAAC,iDAAwB,EAAE,OAAO,CAAC,EAC9C,IAAA,kBAAS,EAAC,qCAAgB,CAAC,CAC5B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EvaluationContext } from '../interfaces/evaluation-context.interface';
|
|
2
|
+
export declare const FeatureFlagEvents: {
|
|
3
|
+
readonly EVALUATED: "feature-flag.evaluated";
|
|
4
|
+
readonly CREATED: "feature-flag.created";
|
|
5
|
+
readonly UPDATED: "feature-flag.updated";
|
|
6
|
+
readonly ARCHIVED: "feature-flag.archived";
|
|
7
|
+
readonly OVERRIDE_SET: "feature-flag.override.set";
|
|
8
|
+
readonly OVERRIDE_REMOVED: "feature-flag.override.removed";
|
|
9
|
+
readonly CACHE_INVALIDATED: "feature-flag.cache.invalidated";
|
|
10
|
+
};
|
|
11
|
+
export interface FlagEvaluatedEvent {
|
|
12
|
+
flagKey: string;
|
|
13
|
+
result: boolean;
|
|
14
|
+
context: EvaluationContext;
|
|
15
|
+
source: 'user_override' | 'tenant_override' | 'env_override' | 'percentage' | 'global';
|
|
16
|
+
evaluationTimeMs: number;
|
|
17
|
+
}
|
|
18
|
+
export interface FlagMutationEvent {
|
|
19
|
+
flagKey: string;
|
|
20
|
+
action: 'created' | 'updated' | 'archived';
|
|
21
|
+
}
|
|
22
|
+
export interface FlagOverrideEvent {
|
|
23
|
+
flagKey: string;
|
|
24
|
+
tenantId?: string | null;
|
|
25
|
+
userId?: string | null;
|
|
26
|
+
environment?: string | null;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
action: 'set' | 'removed';
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FeatureFlagEvents = void 0;
|
|
4
|
+
exports.FeatureFlagEvents = {
|
|
5
|
+
EVALUATED: 'feature-flag.evaluated',
|
|
6
|
+
CREATED: 'feature-flag.created',
|
|
7
|
+
UPDATED: 'feature-flag.updated',
|
|
8
|
+
ARCHIVED: 'feature-flag.archived',
|
|
9
|
+
OVERRIDE_SET: 'feature-flag.override.set',
|
|
10
|
+
OVERRIDE_REMOVED: 'feature-flag.override.removed',
|
|
11
|
+
CACHE_INVALIDATED: 'feature-flag.cache.invalidated',
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=feature-flag.events.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feature-flag.events.js","sourceRoot":"","sources":["../../src/events/feature-flag.events.ts"],"names":[],"mappings":";;;AAEa,QAAA,iBAAiB,GAAG;IAC/B,SAAS,EAAE,wBAAwB;IACnC,OAAO,EAAE,sBAAsB;IAC/B,OAAO,EAAE,sBAAsB;IAC/B,QAAQ,EAAE,uBAAuB;IACjC,YAAY,EAAE,2BAA2B;IACzC,gBAAgB,EAAE,+BAA+B;IACjD,iBAAiB,EAAE,gCAAgC;CAC3C,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const FEATURE_FLAG_MODULE_OPTIONS: unique symbol;
|
|
2
|
+
export declare const DEFAULT_CACHE_TTL_MS = 30000;
|
|
3
|
+
export declare const FEATURE_FLAG_KEY = "FEATURE_FLAG_KEY";
|
|
4
|
+
export declare const FEATURE_FLAG_OPTIONS_KEY = "FEATURE_FLAG_OPTIONS_KEY";
|
|
5
|
+
export declare const BYPASS_FEATURE_FLAG_KEY = "BYPASS_FEATURE_FLAG_KEY";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BYPASS_FEATURE_FLAG_KEY = exports.FEATURE_FLAG_OPTIONS_KEY = exports.FEATURE_FLAG_KEY = exports.DEFAULT_CACHE_TTL_MS = exports.FEATURE_FLAG_MODULE_OPTIONS = void 0;
|
|
4
|
+
exports.FEATURE_FLAG_MODULE_OPTIONS = Symbol('FEATURE_FLAG_MODULE_OPTIONS');
|
|
5
|
+
exports.DEFAULT_CACHE_TTL_MS = 30_000;
|
|
6
|
+
exports.FEATURE_FLAG_KEY = 'FEATURE_FLAG_KEY';
|
|
7
|
+
exports.FEATURE_FLAG_OPTIONS_KEY = 'FEATURE_FLAG_OPTIONS_KEY';
|
|
8
|
+
exports.BYPASS_FEATURE_FLAG_KEY = 'BYPASS_FEATURE_FLAG_KEY';
|
|
9
|
+
//# sourceMappingURL=feature-flag.constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feature-flag.constants.js","sourceRoot":"","sources":["../src/feature-flag.constants.ts"],"names":[],"mappings":";;;AAAa,QAAA,2BAA2B,GAAG,MAAM,CAAC,6BAA6B,CAAC,CAAC;AAEpE,QAAA,oBAAoB,GAAG,MAAM,CAAC;AAE9B,QAAA,gBAAgB,GAAG,kBAAkB,CAAC;AACtC,QAAA,wBAAwB,GAAG,0BAA0B,CAAC;AACtD,QAAA,uBAAuB,GAAG,yBAAyB,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DynamicModule, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|
2
|
+
import { FeatureFlagModuleAsyncOptions, FeatureFlagModuleOptions } from './interfaces/feature-flag-options.interface';
|
|
3
|
+
export interface FeatureFlagModuleRootOptions extends FeatureFlagModuleOptions {
|
|
4
|
+
prisma: any;
|
|
5
|
+
}
|
|
6
|
+
export interface FeatureFlagModuleRootAsyncOptions extends FeatureFlagModuleAsyncOptions {
|
|
7
|
+
useFactory?: (...args: any[]) => Promise<FeatureFlagModuleRootOptions> | FeatureFlagModuleRootOptions;
|
|
8
|
+
}
|
|
9
|
+
export declare class FeatureFlagModule implements NestModule {
|
|
10
|
+
configure(consumer: MiddlewareConsumer): void;
|
|
11
|
+
static forRoot(options: FeatureFlagModuleRootOptions): DynamicModule;
|
|
12
|
+
static forRootAsync(options: FeatureFlagModuleRootAsyncOptions): DynamicModule;
|
|
13
|
+
private static createAsyncProviders;
|
|
14
|
+
}
|