@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.
- package/README.md +206 -81
- package/dist/admin/feature-flag-admin.controller.d.ts +8 -6
- package/dist/admin/feature-flag-admin.controller.js +22 -4
- package/dist/admin/feature-flag-admin.controller.js.map +1 -1
- package/dist/admin/feature-flag-admin.dto.d.ts +30 -0
- package/dist/admin/feature-flag-admin.dto.js +116 -0
- package/dist/admin/feature-flag-admin.dto.js.map +1 -0
- package/dist/admin/targeting-attributes.validator.d.ts +6 -0
- package/dist/admin/targeting-attributes.validator.js +36 -0
- package/dist/admin/targeting-attributes.validator.js.map +1 -0
- package/dist/events/feature-flag.events.d.ts +38 -6
- package/dist/events/feature-flag.events.js +1 -0
- package/dist/events/feature-flag.events.js.map +1 -1
- package/dist/flag-registry.d.ts +24 -0
- package/dist/flag-registry.js +62 -0
- package/dist/flag-registry.js.map +1 -0
- package/dist/guards/feature-flag.guard.js +5 -1
- package/dist/guards/feature-flag.guard.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/evaluation-context.interface.d.ts +8 -3
- package/dist/interfaces/evaluation-details.interface.d.ts +29 -0
- package/dist/interfaces/evaluation-details.interface.js +3 -0
- package/dist/interfaces/evaluation-details.interface.js.map +1 -0
- package/dist/interfaces/feature-flag-options.interface.d.ts +3 -0
- package/dist/interfaces/feature-flag-repository.interface.d.ts +8 -6
- package/dist/interfaces/feature-flag.interface.d.ts +18 -9
- package/dist/interfaces/flag-registry.interface.d.ts +17 -0
- package/dist/interfaces/flag-registry.interface.js +3 -0
- package/dist/interfaces/flag-registry.interface.js.map +1 -0
- package/dist/openfeature.d.ts +26 -0
- package/dist/openfeature.js +96 -0
- package/dist/openfeature.js.map +1 -0
- package/dist/repositories/prisma-feature-flag.repository.d.ts +3 -3
- package/dist/repositories/prisma-feature-flag.repository.js +18 -14
- package/dist/repositories/prisma-feature-flag.repository.js.map +1 -1
- package/dist/services/feature-flag.service.d.ts +15 -7
- package/dist/services/feature-flag.service.js +126 -38
- package/dist/services/feature-flag.service.js.map +1 -1
- package/dist/services/flag-context-resolver.js +14 -3
- package/dist/services/flag-context-resolver.js.map +1 -1
- package/dist/services/flag-evaluator.service.d.ts +8 -8
- package/dist/services/flag-evaluator.service.js +90 -36
- package/dist/services/flag-evaluator.service.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/test-feature-flag.module.d.ts +21 -0
- package/dist/testing/test-feature-flag.module.js +78 -19
- package/dist/testing/test-feature-flag.module.js.map +1 -1
- package/dist/utils/targeting-attributes.d.ts +9 -0
- package/dist/utils/targeting-attributes.js +44 -0
- package/dist/utils/targeting-attributes.js.map +1 -0
- package/package.json +18 -2
- package/prisma/migrations/20260512000000_attribute_targeting/migration.sql +59 -0
- package/prisma/schema.prisma +7 -9
package/README.md
CHANGED
|
@@ -6,21 +6,23 @@
|
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://nestarc.dev/packages/feature-flag/)
|
|
8
8
|
|
|
9
|
-
DB-backed feature flags for NestJS + Prisma + PostgreSQL --
|
|
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
|
-
- **
|
|
15
|
-
- **Percentage rollouts** -- deterministic hashing (murmurhash3)
|
|
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
|
|
95
|
-
flagId
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
Legacy Admin API bodies are rejected:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "tenantId": "tenant-1", "enabled": true }
|
|
158
149
|
```
|
|
159
150
|
|
|
160
|
-
|
|
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
|
-
|
|
364
|
+
### Detailed boolean evaluation
|
|
362
365
|
|
|
363
|
-
|
|
366
|
+
Use `evaluateBoolean()` when you need to explain why a flag resolved to a value:
|
|
364
367
|
|
|
365
368
|
```typescript
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
tenantId: 'tenant-1',
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
await
|
|
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
|
|
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} (
|
|
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
|
-
|
|
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
|
|
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 | **
|
|
493
|
-
| 3 | **
|
|
494
|
-
| 4 | **
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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:
|
|
8
|
+
create(input: CreateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
|
|
8
9
|
findAll(): Promise<FeatureFlagWithOverrides[]>;
|
|
9
10
|
findByKey(key: string): Promise<FeatureFlagWithOverrides>;
|
|
10
|
-
update(key: string, input:
|
|
11
|
+
update(key: string, input: UpdateFeatureFlagDto): Promise<FeatureFlagWithOverrides>;
|
|
11
12
|
archive(key: string): Promise<FeatureFlagWithOverrides>;
|
|
12
|
-
|
|
13
|
-
|
|
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", [
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|