@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.
- package/README.md +252 -87
- package/dist/admin/admin-options.interface.d.ts +5 -0
- package/dist/admin/admin-options.interface.js +3 -0
- package/dist/admin/admin-options.interface.js.map +1 -0
- package/dist/admin/feature-flag-admin.controller.d.ts +14 -0
- package/dist/admin/feature-flag-admin.controller.js +106 -0
- package/dist/admin/feature-flag-admin.controller.js.map +1 -0
- package/dist/admin/feature-flag-admin.dto.d.ts +22 -0
- package/dist/admin/feature-flag-admin.dto.js +93 -0
- package/dist/admin/feature-flag-admin.dto.js.map +1 -0
- package/dist/admin/feature-flag-admin.module.d.ts +5 -0
- package/dist/admin/feature-flag-admin.module.js +33 -0
- package/dist/admin/feature-flag-admin.module.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/cache/memory-cache.adapter.d.ts +11 -0
- package/dist/{services/flag-cache.service.js → cache/memory-cache.adapter.js} +18 -31
- package/dist/cache/memory-cache.adapter.js.map +1 -0
- package/dist/cache/redis-cache.adapter.d.ts +26 -0
- package/dist/cache/redis-cache.adapter.js +102 -0
- package/dist/cache/redis-cache.adapter.js.map +1 -0
- package/dist/events/feature-flag.events.d.ts +4 -5
- package/dist/feature-flag.constants.d.ts +3 -0
- package/dist/feature-flag.constants.js +4 -1
- package/dist/feature-flag.constants.js.map +1 -1
- package/dist/feature-flag.module.js +49 -10
- package/dist/feature-flag.module.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/cache-adapter.interface.d.ts +9 -0
- package/dist/interfaces/cache-adapter.interface.js +3 -0
- package/dist/interfaces/cache-adapter.interface.js.map +1 -0
- package/dist/interfaces/evaluation-context.interface.d.ts +6 -3
- package/dist/interfaces/feature-flag-options.interface.d.ts +3 -0
- package/dist/interfaces/feature-flag-repository.interface.d.ts +22 -0
- package/dist/interfaces/feature-flag-repository.interface.js +3 -0
- package/dist/interfaces/feature-flag-repository.interface.js.map +1 -0
- package/dist/interfaces/feature-flag.interface.d.ts +11 -6
- package/dist/interfaces/tenant-context-provider.interface.d.ts +3 -0
- package/dist/interfaces/tenant-context-provider.interface.js +3 -0
- package/dist/interfaces/tenant-context-provider.interface.js.map +1 -0
- package/dist/repositories/prisma-feature-flag.repository.d.ts +18 -0
- package/dist/repositories/prisma-feature-flag.repository.js +161 -0
- package/dist/repositories/prisma-feature-flag.repository.js.map +1 -0
- package/dist/services/default-tenant-context-provider.d.ts +7 -0
- package/dist/services/default-tenant-context-provider.js +35 -0
- package/dist/services/default-tenant-context-provider.js.map +1 -0
- package/dist/services/feature-flag.service.d.ts +21 -13
- package/dist/services/feature-flag.service.js +111 -126
- package/dist/services/feature-flag.service.js.map +1 -1
- package/dist/services/flag-context-resolver.d.ts +11 -0
- package/dist/services/flag-context-resolver.js +51 -0
- package/dist/services/flag-context-resolver.js.map +1 -0
- package/dist/services/flag-evaluator.service.d.ts +1 -0
- package/dist/services/flag-evaluator.service.js +29 -27
- package/dist/services/flag-evaluator.service.js.map +1 -1
- package/dist/services/flag-event-publisher.d.ts +7 -0
- package/dist/services/flag-event-publisher.js +37 -0
- package/dist/services/flag-event-publisher.js.map +1 -0
- package/dist/testing/test-feature-flag.module.js +26 -5
- 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 +20 -3
- package/prisma/migrations/20260512000000_attribute_targeting/migration.sql +59 -0
- package/prisma/schema.prisma +7 -9
- package/dist/services/flag-cache.service.d.ts +0 -13
- 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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@nestarc/feature-flag)
|
|
4
|
+
[](https://www.npmjs.com/package/@nestarc/feature-flag)
|
|
5
|
+
[](https://github.com/nestarc/nestjs-feature-flag/actions/workflows/ci.yml)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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
|
-
- **
|
|
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
|
|
59
|
-
flagId
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
351
|
+
## Attribute Targeting
|
|
326
352
|
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
356
|
+
const enabled = await this.flags.isEnabled('NEW_CHECKOUT', {
|
|
357
|
+
userId: 'user-123',
|
|
332
358
|
tenantId: 'tenant-1',
|
|
333
|
-
|
|
359
|
+
environment: 'production',
|
|
360
|
+
attributes: {
|
|
361
|
+
plan: 'pro',
|
|
362
|
+
country: 'KR',
|
|
363
|
+
},
|
|
334
364
|
});
|
|
365
|
+
```
|
|
335
366
|
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
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
|
|
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
|
|
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 | **
|
|
451
|
-
| 3 | **
|
|
452
|
-
| 4 | **
|
|
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 @@
|
|
|
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
|
+
}
|