@nestarc/feature-flag 0.4.0 → 0.6.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 CHANGED
@@ -1,782 +1,231 @@
1
- # @nestarc/feature-flag
1
+ # NestJS feature flags with Prisma and PostgreSQL
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@nestarc/feature-flag.svg)](https://www.npmjs.com/package/@nestarc/feature-flag)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@nestarc/feature-flag.svg)](https://www.npmjs.com/package/@nestarc/feature-flag)
5
5
  [![CI](https://github.com/nestarc/nestjs-feature-flag/actions/workflows/ci.yml/badge.svg)](https://github.com/nestarc/nestjs-feature-flag/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Docs](https://img.shields.io/badge/docs-nestarc.dev-blue.svg)](https://nestarc.dev/packages/feature-flag/)
8
7
 
9
- DB-backed feature flags for NestJS + Prisma + PostgreSQL -- attribute-targeted overrides, percentage rollouts, and zero external dependencies.
8
+ `@nestarc/feature-flag` stores feature flags in your PostgreSQL database and evaluates them inside your NestJS application. Use route guards, exact attribute targeting, and deterministic percentage rollouts without a separate feature flag service. NestJS, Prisma, and other [peer dependencies](#installation-and-compatibility) are required.
10
9
 
11
- ## Features
10
+ **Version scope:** these docs describe **0.6.0**. It includes targeting-key and registry fixes, custom module provider options, and an SDK-compatible OpenFeature provider that are absent from `0.5.0`. See the [0.6.0 changes and upgrade notes](CHANGELOG.md), and consult the [0.5.0 source](https://github.com/nestarc/nestjs-feature-flag/tree/v0.5.0) when working on that release.
12
11
 
13
- - **Database-backed** -- flags stored in PostgreSQL via Prisma, no external service required
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
- - **Guard decorator** -- `@FeatureFlag()` automatically gates routes and controllers
17
- - **Bypass decorator** -- `@BypassFeatureFlag()` exempts health checks and public endpoints
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
20
- - **Built-in caching** -- configurable TTL with manual invalidation; Redis Pub/Sub for multi-instance
21
- - **Pluggable persistence** -- `FeatureFlagRepository` interface for custom backends (Prisma default)
22
- - **Pluggable tenancy** -- `TenantContextProvider` interface for custom tenant resolution
23
- - **Admin REST API** -- opt-in `FeatureFlagAdminModule` with guard injection and proper error responses
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`
26
- - **Testing utilities** -- drop-in `TestFeatureFlagModule` for unit and integration tests
12
+ ## Contents
27
13
 
28
- ## Installation
14
+ - [Installation and compatibility](#installation-and-compatibility)
15
+ - [Quickstart](#quickstart)
16
+ - [Evaluate a flag](#evaluate-a-flag)
17
+ - [How a flag resolves](#how-a-flag-resolves)
18
+ - [Target users and tenants](#target-users-and-tenants)
19
+ - [Manage flags](#manage-flags)
20
+ - [Caching, events, and integrations](#caching-events-and-integrations)
21
+ - [Documentation and examples](#documentation-and-examples)
22
+ - [For AI agents](#for-ai-agents)
29
23
 
30
- ```bash
31
- npm install @nestarc/feature-flag
32
- ```
24
+ ## Installation and compatibility
33
25
 
34
- ### Peer dependencies
26
+ Install 0.6.0 in an existing NestJS application:
35
27
 
36
28
  ```bash
37
- npm install @nestjs/common @nestjs/core @prisma/client class-transformer class-validator rxjs reflect-metadata
29
+ npm install @nestarc/feature-flag@0.6.0
30
+ npm install @prisma/client@^7 @prisma/adapter-pg@^7 pg class-transformer@^0.5.1 class-validator@^0.15.0
31
+ npm install --save-dev prisma@^7
38
32
  ```
39
33
 
40
- ### Optional
41
-
42
- ```bash
43
- # Required only if you enable emitEvents
44
- npm install @nestjs/event-emitter
34
+ Keep the Prisma CLI, client, and PostgreSQL adapter on matching versions. The quickstart below uses a locally packed archive to verify this repository checkout.
45
35
 
46
- # Required only if you use RedisCacheAdapter
47
- npm install ioredis
36
+ | Requirement | Supported range / purpose |
37
+ | --- | --- |
38
+ | Node.js | `^20.19.0`, `^22.12.0`, or `>=24.0.0` |
39
+ | NestJS | `@nestjs/common` and `@nestjs/core` 10 or 11 |
40
+ | Prisma | `@prisma/client` 7; Prisma CLI and `@prisma/adapter-pg` for PostgreSQL setup |
41
+ | PostgreSQL | Default persistence backend; integration tests use PostgreSQL 16 |
42
+ | Other required peers | `class-transformer` 0.5, `class-validator` 0.14/0.15, `rxjs` 7, `reflect-metadata` 0.1/0.2 |
43
+ | Optional integrations | `ioredis` 5 for Redis; `@nestjs/event-emitter` for events; `@openfeature/server-sdk` ^1.23.0 for OpenFeature |
48
44
 
49
- # Required only if you use the OpenFeature adapter with the SDK
50
- npm install @openfeature/server-sdk
51
- ```
45
+ A standard Nest application already supplies NestJS, RxJS, reflection support, and an HTTP platform adapter. The [consumer guide](docs/usage.md#database-setup) contains the Prisma schema, SQL constraints, and generated-client setup.
52
46
 
53
- ## Redis Cache (Multi-Instance)
47
+ ## Quickstart
54
48
 
55
- For production deployments with multiple instances, use `RedisCacheAdapter` for shared caching and real-time invalidation via Redis Pub/Sub:
56
-
57
- ```typescript
58
- import { FeatureFlagModule, RedisCacheAdapter } from '@nestarc/feature-flag';
59
- import { Redis } from 'ioredis';
60
-
61
- const redisClient = new Redis({ host: 'localhost', port: 6379 });
62
-
63
- FeatureFlagModule.forRoot({
64
- environment: 'production',
65
- prisma,
66
- cacheAdapter: new RedisCacheAdapter({
67
- client: redisClient,
68
- // subscriber is auto-created via client.duplicate()
69
- // keyPrefix: 'feature-flag:', // default
70
- // channel: 'feature-flag:invalidate', // default
71
- }),
72
- })
73
- ```
74
-
75
- 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.
76
-
77
- ## Prisma Schema
78
-
79
- Add the following models to your `schema.prisma`:
80
-
81
- ```prisma
82
- model FeatureFlag {
83
- id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
84
- key String @unique
85
- description String?
86
- enabled Boolean @default(false)
87
- percentage Int @default(0)
88
- metadata Json @default("{}")
89
- archivedAt DateTime? @map("archived_at") @db.Timestamptz()
90
- createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
91
- updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
92
-
93
- overrides FeatureFlagOverride[]
94
-
95
- @@map("feature_flags")
96
- }
97
-
98
- model FeatureFlagOverride {
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()
106
-
107
- flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
108
-
109
- @@index([flagId], map: "idx_override_flag_id")
110
- @@map("feature_flag_overrides")
111
- }
112
- ```
113
-
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:
115
-
116
- ```sql
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
- ```
124
-
125
- ### Migration from 0.2.0 to 0.3.0
126
-
127
- v0.3.0 changes override storage from fixed `tenant_id`, `user_id`, and `environment` columns to an `attributes` `jsonb` object plus `priority`.
128
-
129
- Run your Prisma migrations during deployment:
49
+ The [complete basic guard example](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/basic-guard/README.md) includes its own Prisma schema, migrations, seed data, and startup script. To run this checkout from the repository root, use Node.js from the supported range and a local PostgreSQL database. `npm run docker:up` starts the repository's PostgreSQL and Redis development services if you use Docker.
130
50
 
131
51
  ```bash
132
- npx prisma migrate deploy
133
- ```
52
+ npm ci
53
+ npm run build
54
+ npm pack
55
+ npm run docker:up
56
+ cd examples/basic-guard
57
+ npm install ../../nestarc-feature-flag-0.6.0.tgz
58
+ export DATABASE_URL='postgresql://test:test@localhost:5499/feature_flag_test'
59
+ npm run prisma:generate
60
+ npm run db:migrate
61
+ npm run build
62
+ npm run seed -- on
63
+ npm start
64
+ ```
65
+
66
+ The tarball contains the local checkout, including any local changes. The database URL above is for the repository's development Compose service; substitute your own empty development database if needed. In another terminal:
134
67
 
135
- The migration maps legacy override columns into attributes:
136
-
137
- | v0.2.0 column | v0.3.0 attribute |
138
- | ------------- | ---------------- |
139
- | `tenant_id` | `attributes.tenantId` |
140
- | `user_id` | `attributes.userId` |
141
- | `environment` | `attributes.environment` |
142
-
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.
144
-
145
- Legacy Admin API bodies are rejected:
146
-
147
- ```json
148
- { "tenantId": "tenant-1", "enabled": true }
149
- ```
150
-
151
- Use an `attributes` object instead:
68
+ ```bash
69
+ curl -i http://127.0.0.1:3000/dashboard
70
+ # HTTP 200; {"message":"New dashboard is enabled"}
152
71
 
153
- ```json
154
- { "attributes": { "tenantId": "tenant-1" }, "enabled": true }
72
+ # From examples/basic-guard, with the same DATABASE_URL:
73
+ npm run seed -- off
74
+ curl -i http://127.0.0.1:3000/dashboard
75
+ # HTTP 403
155
76
  ```
156
77
 
157
- ## Module Registration
158
-
159
- ### forRoot (synchronous)
160
-
161
- ```typescript
162
- import { FeatureFlagModule } from '@nestarc/feature-flag';
163
-
164
- @Module({
165
- imports: [
166
- FeatureFlagModule.forRoot({
167
- environment: 'production',
168
- prisma: prismaService,
169
- userIdExtractor: (req) => req.headers['x-user-id'] as string,
170
- emitEvents: true,
171
- cacheTtlMs: 30_000,
172
- }),
173
- ],
174
- })
175
- export class AppModule {}
176
- ```
177
-
178
- ### forRootAsync (with useFactory)
78
+ The example disables caching so direct seed changes are visible on the next request. Its complete module registration is:
179
79
 
80
+ <!-- source: examples/basic-guard/src/app.module.ts -->
180
81
  ```typescript
82
+ import { Module } from '@nestjs/common';
181
83
  import { FeatureFlagModule } from '@nestarc/feature-flag';
84
+ import { DashboardController } from './dashboard.controller';
85
+ import { PrismaModule } from './prisma.module';
86
+ import { PrismaService } from './prisma.service';
182
87
 
183
88
  @Module({
184
89
  imports: [
90
+ PrismaModule,
185
91
  FeatureFlagModule.forRootAsync({
186
- imports: [ConfigModule],
187
- inject: [ConfigService, PrismaService],
188
- useFactory: (config: ConfigService, prisma: PrismaService) => ({
189
- environment: config.get('NODE_ENV'),
92
+ imports: [PrismaModule],
93
+ inject: [PrismaService],
94
+ useFactory: (prisma: PrismaService) => ({
190
95
  prisma,
191
- userIdExtractor: (req) => req.headers['x-user-id'] as string,
96
+ cacheTtlMs: 0, // Let local seed changes appear on the next request.
97
+ environment: process.env.NODE_ENV ?? 'development',
98
+ userIdExtractor: (req) => {
99
+ const userId = req.headers['x-user-id'];
100
+ return Array.isArray(userId) ? (userId[0] ?? null) : (userId ?? null);
101
+ },
192
102
  }),
193
103
  }),
194
104
  ],
105
+ controllers: [DashboardController],
195
106
  })
196
107
  export class AppModule {}
197
108
  ```
198
109
 
199
- ### forRootAsync (with useClass)
110
+ The imported [PrismaModule](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/basic-guard/src/prisma.module.ts), [PrismaService](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/basic-guard/src/prisma.service.ts), and [DashboardController](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/basic-guard/src/dashboard.controller.ts) are included in the example. `PrismaModule` exports `PrismaService`; including that module in `forRootAsync.imports` makes the service available to the factory. Events are disabled in this basic configuration.
200
111
 
201
- ```typescript
202
- @Injectable()
203
- class FeatureFlagConfigService implements FeatureFlagModuleOptionsFactory {
204
- constructor(
205
- private readonly config: ConfigService,
206
- private readonly prisma: PrismaService,
207
- ) {}
208
-
209
- createFeatureFlagOptions() {
210
- return {
211
- environment: this.config.get('NODE_ENV'),
212
- prisma: this.prisma,
213
- };
214
- }
215
- }
112
+ For an existing app, follow the [database setup and complete synchronous registration recipe](docs/usage.md#database-setup), or reuse the example's Prisma module with `forRootAsync`.
216
113
 
217
- @Module({
218
- imports: [
219
- FeatureFlagModule.forRootAsync({
220
- imports: [ConfigModule, PrismaModule],
221
- useClass: FeatureFlagConfigService,
222
- }),
223
- ],
224
- })
225
- export class AppModule {}
226
- ```
114
+ ## Evaluate a flag
227
115
 
228
- ### forRootAsync (with useExisting)
229
-
230
- ```typescript
231
- @Module({
232
- imports: [
233
- FeatureFlagModule.forRootAsync({
234
- useExisting: FeatureFlagConfigService,
235
- }),
236
- ],
237
- })
238
- export class AppModule {}
239
- ```
240
-
241
- ## Feature Flag Guard
242
-
243
- The `@FeatureFlag()` decorator automatically applies `UseGuards(FeatureFlagGuard)`, so you do not need to add `@UseGuards()` yourself.
244
-
245
- ### Method-level
116
+ `@FeatureFlag()` adds its guard automatically. A disabled flag returns HTTP 403 by default:
246
117
 
247
118
  ```typescript
119
+ import { Controller, Get } from '@nestjs/common';
248
120
  import { FeatureFlag } from '@nestarc/feature-flag';
249
121
 
250
122
  @Controller('dashboard')
251
123
  export class DashboardController {
252
- @FeatureFlag('NEW_DASHBOARD')
253
124
  @Get()
125
+ @FeatureFlag('EXAMPLE_DASHBOARD')
254
126
  getDashboard() {
255
127
  return { message: 'Welcome to the new dashboard' };
256
128
  }
257
129
  }
258
130
  ```
259
131
 
260
- ### Class-level
261
-
262
- ```typescript
263
- @FeatureFlag('BETA_API')
264
- @Controller('beta')
265
- export class BetaController {
266
- @Get('feature-a')
267
- featureA() { /* guarded */ }
268
-
269
- @Get('feature-b')
270
- featureB() { /* guarded */ }
271
- }
272
- ```
273
-
274
- ### Custom status code and fallback
275
-
276
- ```typescript
277
- @FeatureFlag('PREMIUM_FEATURE', {
278
- statusCode: 402,
279
- fallback: { message: 'Upgrade required' },
280
- })
281
- @Get('premium')
282
- getPremiumContent() { ... }
283
- ```
284
-
285
- When the flag is disabled, the guard responds with the given `statusCode` (default `403`) and optional `fallback` body.
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
-
295
- ### Bypassing the guard
296
-
297
- Use `@BypassFeatureFlag()` on methods that should always be accessible, even when a class-level flag is applied:
298
-
299
- ```typescript
300
- import { BypassFeatureFlag } from '@nestarc/feature-flag';
301
-
302
- @FeatureFlag('BETA_API')
303
- @Controller('beta')
304
- export class BetaController {
305
- @Get('docs')
306
- betaDocs() { /* guarded by BETA_API */ }
307
-
308
- @BypassFeatureFlag()
309
- @Get('health')
310
- healthCheck() {
311
- return { status: 'ok' };
312
- }
313
- }
314
- ```
315
-
316
- ## Programmatic Evaluation
317
-
318
- Inject `FeatureFlagService` for service-layer checks outside the HTTP request cycle:
132
+ For service logic, inject `FeatureFlagService` and await the result:
319
133
 
320
134
  ```typescript
135
+ import { Injectable } from '@nestjs/common';
321
136
  import { FeatureFlagService } from '@nestarc/feature-flag';
322
137
 
323
138
  @Injectable()
324
- export class PaymentService {
139
+ export class CheckoutService {
325
140
  constructor(private readonly flags: FeatureFlagService) {}
326
141
 
327
- async processPayment(order: Order) {
328
- const useNewGateway = await this.flags.isEnabled('NEW_PAYMENT_GATEWAY');
329
-
330
- if (useNewGateway) {
331
- return this.newGateway.process(order);
332
- }
333
- return this.legacyGateway.process(order);
142
+ async checkoutVersion(tenantId: string): Promise<string> {
143
+ const enabled = await this.flags.isEnabled('NEW_CHECKOUT', { tenantId });
144
+ return enabled ? 'new' : 'classic';
334
145
  }
335
146
  }
336
147
  ```
337
148
 
338
- ### Evaluate all flags at once
339
-
340
- ```typescript
341
- const allFlags = await this.flags.evaluateAll();
342
- // { NEW_DASHBOARD: true, PREMIUM_FEATURE: false, ... }
343
- ```
344
-
345
- ### Explicit evaluation context
346
-
347
- Both `isEnabled()` and `evaluateAll()` accept an optional `EvaluationContext` to override the auto-detected context:
348
-
349
- ```typescript
350
- const enabled = await this.flags.isEnabled('MY_FLAG', {
351
- userId: 'user-123',
352
- tenantId: 'tenant-abc',
353
- environment: 'staging',
354
- });
355
- ```
356
-
357
- Passing `null` explicitly clears that dimension, suppressing any ambient value from the request context:
149
+ Use `evaluateBoolean()` for the value and explanation (`source`, `reason`, `defaultUsed`, and optional bucket details). `evaluateAll()` returns the values of active, stored flags; it does not create entries for registry-only keys, emit evaluation/exposure events, or convert errors to defaults. See the [evaluation reference](docs/usage.md#evaluation-and-defaults).
358
150
 
359
- ```typescript
360
- // Evaluate as if no user is present, even within a request with x-user-id
361
- const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });
362
- ```
151
+ ## How a flag resolves
363
152
 
364
- ### Detailed boolean evaluation
153
+ Evaluation checks **archived status → matching override → percentage rollout → global `enabled`** in that order. In particular, `enabled: false` does not cancel overrides or a percentage rollout.
365
154
 
366
- Use `evaluateBoolean()` when you need to explain why a flag resolved to a value:
155
+ | Condition | Result |
156
+ | --- | --- |
157
+ | Archived | `false`, regardless of other settings |
158
+ | An attribute override matches | The winning override's `enabled` value |
159
+ | No override, `percentage: 100` | `true`, even without a user or tenant |
160
+ | No override, `percentage: 1–99`, usable bucket key | Whether the deterministic bucket is below the percentage; global `enabled` is ignored |
161
+ | No override, `percentage: 1–99`, no usable bucket key | Global `enabled` |
162
+ | No override, `percentage: 0` | Global `enabled` |
163
+ | Missing flag or individual evaluation error | Invocation default → module registry default → `defaultOnMissing` → `false` |
367
164
 
368
- ```typescript
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
- ```
165
+ For a gradual rollout, use `enabled: false` and a percentage between 1 and 99. To make an active flag false for everyone, set `enabled: false`, set `percentage: 0`, and remove any enabling overrides. Archiving also makes evaluation false, and removes the flag from active listings.
388
166
 
389
- Missing flags and evaluation errors return the selected default instead of throwing. Default priority is:
167
+ A non-empty `targetingKey` takes precedence for bucketing. Otherwise the evaluator uses the selected `bucketBy` attribute, then falls back to `userId ?? tenantId`. The [reference](docs/usage.md#percentage-bucketing) defines configuration precedence and missing-attribute behavior. Explicit `targetingKey` handling and consistent registry bucketing are fixed in 0.6.0.
390
168
 
391
- 1. Invocation `defaultValue`
392
- 2. Registry `defaultValue`
393
- 3. Module `defaultOnMissing`
394
- 4. `false`
169
+ ## Target users and tenants
395
170
 
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
- },
411
- });
412
-
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',
443
- tenantId: 'tenant-1',
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:
171
+ Overrides use exact attribute matches. Every attribute in an override must match; string `"1"` and number `1` are different values.
464
172
 
465
173
  ```typescript
174
+ // `flags` is an injected FeatureFlagService.
466
175
  await flags.setOverride('NEW_CHECKOUT', {
467
- attributes: {
468
- tenantId: 'tenant-1',
469
- plan: 'pro',
470
- country: 'KR',
471
- },
176
+ attributes: { tenantId: 'tenant-acme', plan: 'pro' },
472
177
  enabled: true,
473
178
  priority: 10,
474
179
  });
475
- ```
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
-
491
- ## Events
492
-
493
- Enable event emission to observe flag lifecycle changes. Requires installing `@nestjs/event-emitter`.
494
-
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.
496
-
497
- ### Setup
498
-
499
- ```typescript
500
- import { EventEmitterModule } from '@nestjs/event-emitter';
501
-
502
- @Module({
503
- imports: [
504
- EventEmitterModule.forRoot(), // must be imported
505
- FeatureFlagModule.forRoot({
506
- environment: 'production',
507
- prisma: prismaService,
508
- emitEvents: true,
509
- }),
510
- ],
511
- })
512
- export class AppModule {}
513
- ```
514
-
515
- ### Event types
516
-
517
- | Event constant | Event string | Payload type |
518
- | ---------------------------------------- | ---------------------------------- | -------------------- |
519
- | `FeatureFlagEvents.EVALUATED` | `feature-flag.evaluated` | `FlagEvaluatedEvent` |
520
- | `FeatureFlagEvents.EXPOSED` | `feature-flag.exposed` | `FlagExposedEvent` |
521
- | `FeatureFlagEvents.CREATED` | `feature-flag.created` | `FlagMutationEvent` |
522
- | `FeatureFlagEvents.UPDATED` | `feature-flag.updated` | `FlagMutationEvent` |
523
- | `FeatureFlagEvents.ARCHIVED` | `feature-flag.archived` | `FlagMutationEvent` |
524
- | `FeatureFlagEvents.OVERRIDE_SET` | `feature-flag.override.set` | `FlagOverrideEvent` |
525
- | `FeatureFlagEvents.OVERRIDE_REMOVED` | `feature-flag.override.removed` | `FlagOverrideEvent` |
526
- | `FeatureFlagEvents.CACHE_INVALIDATED` | `feature-flag.cache.invalidated` | `{}` |
527
-
528
- ### Listening to events
529
-
530
- ```typescript
531
- import { OnEvent } from '@nestjs/event-emitter';
532
- import { FeatureFlagEvents, FlagEvaluatedEvent } from '@nestarc/feature-flag';
533
-
534
- @Injectable()
535
- export class FlagAuditListener {
536
- @OnEvent(FeatureFlagEvents.EVALUATED)
537
- handleEvaluation(event: FlagEvaluatedEvent) {
538
- console.log(`Flag ${event.flagKey} = ${event.result} (${event.reason})`);
539
- }
540
- }
541
- ```
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
-
545
- ## Testing
546
-
547
- Import `TestFeatureFlagModule` from the `/testing` subpath to stub flag values in tests without a database connection:
548
-
549
- ```typescript
550
- import { TestFeatureFlagModule } from '@nestarc/feature-flag/testing';
551
-
552
- describe('DashboardController', () => {
553
- let app: INestApplication;
554
-
555
- beforeEach(async () => {
556
- const module = await Test.createTestingModule({
557
- imports: [
558
- TestFeatureFlagModule.register({
559
- NEW_DASHBOARD: true,
560
- PREMIUM_FEATURE: false,
561
- }),
562
- ],
563
- controllers: [DashboardController],
564
- }).compile();
565
-
566
- app = module.createNestApplication();
567
- await app.init();
568
- });
569
-
570
- it('should allow access when flag is enabled', () => {
571
- return request(app.getHttpServer())
572
- .get('/dashboard')
573
- .expect(200);
574
- });
575
- });
576
- ```
577
-
578
- `TestFeatureFlagModule.register()` provides a global mock of `FeatureFlagService`:
579
- - `isEnabled(key)` returns the boolean you specified (defaulting to `false` for unregistered keys)
580
- - `evaluateBoolean(key)` returns `BooleanEvaluationDetails`
581
- - `evaluateAll()` returns the full flag map
582
- - `create()`, `update()`, `archive()`, `findByKey()`, `findAll()` return full `FeatureFlagWithOverrides` stub objects
583
- - `findByKey()` throws `NotFoundException` for unknown keys
584
-
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.
603
-
604
- ## Evaluation Priority
605
-
606
- When `isEnabled()` is called, flags are evaluated through the current cascade. The first matching layer wins:
607
-
608
- | Priority | Layer | Description |
609
- | -------- | ---------------------- | ------------------------------------------------------------------ |
610
- | 1 | **Archived** | If the flag has `archivedAt` set, evaluation always returns `false` |
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 |
614
-
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.
616
-
617
- ## Configuration Reference
618
-
619
- ### FeatureFlagModuleOptions
620
-
621
- | Option | Type | Default | Description |
622
- | ------------------- | --------------------------------- | --------- | --------------------------------------------------------------- |
623
- | `environment` | `string` | *required*| Deployment environment (e.g. `'production'`, `'staging'`) |
624
- | `cacheTtlMs` | `number` | `30000` | Cache TTL in ms. Set to `0` to disable caching |
625
- | `userIdExtractor` | `(req: Request) => string \| null`| `undefined`| Extracts user ID from the incoming request |
626
- | `defaultOnMissing` | `boolean` | `false` | Value returned when a flag key does not exist in the database |
627
- | `emitEvents` | `boolean` | `false` | Emit lifecycle events via `@nestjs/event-emitter` |
628
- | `cacheAdapter` | `CacheAdapter` | `MemoryCacheAdapter` | Pluggable cache backend (e.g. `RedisCacheAdapter`) |
629
- | `flags` | `FlagRegistry` | `undefined` | Optional typed registry for defaults, `bucketBy`, and exposure settings |
630
-
631
- ### FeatureFlagModuleRootOptions
632
-
633
- Extends `FeatureFlagModuleOptions` with:
634
-
635
- | Option | Type | Description |
636
- | ------- | ----- | ------------------------------ |
637
- | `prisma`| `any` | Prisma client instance |
638
-
639
- ## CRUD Operations
640
-
641
- `FeatureFlagService` also exposes methods for managing flags programmatically:
642
-
643
- ```typescript
644
- // Create a flag
645
- const flag = await this.flags.create({
646
- key: 'NEW_FEATURE',
647
- description: 'Enables the new feature',
648
- enabled: false,
649
- percentage: 0,
650
- });
651
180
 
652
- // Update a flag
653
- await this.flags.update('NEW_FEATURE', {
654
- enabled: true,
655
- percentage: 50,
181
+ const enabled = await flags.isEnabled('NEW_CHECKOUT', {
182
+ tenantId: 'tenant-acme',
183
+ attributes: { plan: 'pro' },
656
184
  });
657
-
658
- // Archive a flag (soft delete -- evaluations return false)
659
- await this.flags.archive('OLD_FEATURE');
660
-
661
- // List all active (non-archived) flags
662
- const allFlags = await this.flags.findAll();
663
-
664
- // Manually invalidate the cache
665
- this.flags.invalidateCache();
666
185
  ```
667
186
 
668
- ## Admin REST API
187
+ If several overrides match, the winner has more attributes, then higher priority, then earlier creation time, then the lower ID. Top-level `userId`, `tenantId`, and `environment` become targeting attributes and take precedence over same-named entries in `attributes`. Explicit `null` suppresses the corresponding ambient value; it remains a `null` targeting attribute. A provided `tenantId` works without a tenancy package.
669
188
 
670
- `FeatureFlagAdminModule` provides a REST API for managing flags. It requires a guard the module won't register without one:
189
+ The [registry guide](docs/usage.md#typed-registry) covers typed keys and defaults. Registry entries describe evaluation behavior; they do not create database flags or automatically archive expired flags.
671
190
 
672
- ```typescript
673
- import { FeatureFlagAdminModule } from '@nestarc/feature-flag';
674
- import { AdminAuthGuard } from './guards/admin-auth.guard';
675
-
676
- @Module({
677
- imports: [
678
- FeatureFlagModule.forRoot({ ... }),
679
- FeatureFlagAdminModule.register({
680
- guard: AdminAuthGuard,
681
- // path: 'feature-flags', // default
682
- }),
683
- ],
684
- })
685
- export class AppModule {}
686
- ```
687
-
688
- ### Endpoints
689
-
690
- | Method | Route | Description | Error Responses |
691
- |--------|-------|-------------|-----------------|
692
- | POST | `/feature-flags` | Create a flag | 409 duplicate key, 400 invalid percentage |
693
- | GET | `/feature-flags` | List all flags | |
694
- | GET | `/feature-flags/:key` | Get a single flag | 404 not found |
695
- | PATCH | `/feature-flags/:key` | Update a flag | 404 not found, 400 invalid percentage |
696
- | DELETE | `/feature-flags/:key` | Archive a flag | 404 not found |
697
- | POST | `/feature-flags/:key/evaluate` | Evaluate a flag without mutating it | |
698
- | POST | `/feature-flags/:key/overrides` | Set an override | 404 flag not found |
699
- | DELETE | `/feature-flags/:key/overrides` | Remove an override | 404 flag not found |
700
-
701
- Percentage values must be between 0 and 100 (inclusive). Invalid values return 400 Bad Request.
702
-
703
- ## Custom Persistence (Advanced)
704
-
705
- The default `PrismaFeatureFlagRepository` can be replaced with any implementation of `FeatureFlagRepository`:
191
+ ## Manage flags
706
192
 
707
193
  ```typescript
708
- import {
709
- FeatureFlagModule,
710
- FEATURE_FLAG_REPOSITORY,
711
- FeatureFlagRepository,
712
- } from '@nestarc/feature-flag';
713
-
714
- @Module({
715
- imports: [
716
- FeatureFlagModule.forRoot({
717
- environment: 'production',
718
- prisma, // still required for module init, but unused if you override the repository
719
- }),
720
- ],
721
- providers: [
722
- {
723
- provide: FEATURE_FLAG_REPOSITORY,
724
- useClass: MyCustomRepository, // implements FeatureFlagRepository
725
- },
726
- ],
727
- })
728
- export class AppModule {}
194
+ // `flags` is an injected FeatureFlagService.
195
+ await flags.create({ key: 'NEW_CHECKOUT', enabled: false, percentage: 0 });
196
+ await flags.update('NEW_CHECKOUT', { percentage: 20 });
197
+ const activeFlags = await flags.findAll();
198
+ await flags.archive('OLD_CHECKOUT');
199
+ await flags.invalidateCache();
729
200
  ```
730
201
 
731
- ## Custom Tenant Resolution (Advanced)
732
-
733
- Override the default `@nestarc/tenancy` integration with your own `TenantContextProvider`:
734
-
735
- ```typescript
736
- import {
737
- FeatureFlagModule,
738
- TENANT_CONTEXT_PROVIDER,
739
- TenantContextProvider,
740
- } from '@nestarc/feature-flag';
741
-
742
- @Injectable()
743
- class MyTenantProvider implements TenantContextProvider {
744
- getCurrentTenantId(): string | null {
745
- // your custom tenant resolution logic
746
- return 'tenant-from-custom-source';
747
- }
748
- }
202
+ The optional [Admin REST API](docs/usage.md#admin-rest-api) exposes creation, updates, active listings, evaluation, and overrides. Registration requires an application-supplied authentication/authorization guard. Request bodies, response examples, status codes, and errors are documented in the guide.
749
203
 
750
- @Module({
751
- imports: [FeatureFlagModule.forRoot({ ... })],
752
- providers: [
753
- { provide: TENANT_CONTEXT_PROVIDER, useClass: MyTenantProvider },
754
- ],
755
- })
756
- export class AppModule {}
757
- ```
204
+ ## Caching, events, and integrations
758
205
 
759
- ## Examples
206
+ The default cache is in memory with a 30,000 ms TTL. Set `cacheTtlMs: 0` to disable writes to the cache, or use Redis for shared storage and Pub/Sub invalidation across instances. Mutation invalidation is best effort: a database write can succeed while cache invalidation fails. TTL expiry limits how long an existing stale entry remains; concurrent reads and failures mean this is not an immediate-consistency guarantee. Choose TTL based on your acceptable staleness and measured workload.
760
207
 
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
208
+ - [Redis and cache lifecycle](docs/usage.md#caching): adapter setup, invalidation, and connection ownership.
209
+ - [Events](docs/usage.md#events): import `EventEmitterModule.forRoot()` **and** set `emitEvents: true`; exposure tracking also needs an opt-in setting.
210
+ - [Custom persistence and tenancy](docs/usage.md#custom-persistence-and-tenancy): use module options and Nest factories; these options are new in 0.6.0.
211
+ - [OpenFeature](docs/usage.md#openfeature): boolean evaluation through the optional SDK integration; no string, numeric, or object flag values.
212
+ - [Testing utilities](docs/usage.md#testing): `/testing` provides controlled boolean stubs; targeting behavior should be tested with the actual evaluator.
764
213
 
765
- ## Performance
214
+ ## Documentation and examples
766
215
 
767
- Measured with PostgreSQL 16, Prisma 6, 500 iterations on Apple Silicon:
216
+ - [Consumer guide](docs/usage.md): schema, complete registration, API semantics, integrations, and troubleshooting.
217
+ - [Basic route guard](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/basic-guard/README.md), [tenant and plan targeting](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/multi-tenant-targeting/README.md), and [Redis with events](https://github.com/nestarc/nestjs-feature-flag/blob/main/examples/redis-events/README.md): runnable applications with seed data and expected HTTP results.
218
+ - [Changelog and 0.5 upgrade notes](CHANGELOG.md): Prisma 7 adapter, generated import path, and configuration changes.
219
+ - [Benchmark method](https://github.com/nestarc/nestjs-feature-flag/blob/main/benchmarks/README.md): reproducible commands and measurement limits; latency depends on your workload and environment.
220
+ - [Documentation index](https://github.com/nestarc/nestjs-feature-flag/blob/main/docs/README.md): current guides versus historical designs and validation reports.
221
+ - [Website](https://nestarc.dev/packages/feature-flag/) and [issue tracker](https://github.com/nestarc/nestjs-feature-flag/issues).
768
222
 
769
- | Scenario | Avg | P50 | P95 | P99 |
770
- |----------|-----|-----|-----|-----|
771
- | **isEnabled() — cache hit** | **0.04ms** | **0.03ms** | **0.05ms** | **0.07ms** |
772
- | isEnabled() — cache miss (DB lookup) | 1.30ms | 1.14ms | 2.54ms | 3.69ms |
773
- | isEnabled() — override cascade (cold) | 1.07ms | 1.02ms | 1.43ms | 2.11ms |
774
- | **evaluateAll() — 50 flags (mixed)** | **0.19ms** | **0.04ms** | **1.55ms** | **1.71ms** |
223
+ ## For AI agents
775
224
 
776
- Cache speedup: **32.5x** (hit vs miss). Keep the default 30s cache TTL for optimal performance.
225
+ Check the installed package version and its `.d.ts` exports first, then use [docs/usage.md](docs/usage.md) for consumer implementation. It identifies the 0.6.0 APIs, version boundaries, prerequisites, context/default semantics, and executable examples. The guide and changelog are included in the package so installed-package workflows can read them without relying on search results. Use the documented `/testing` and `/openfeature` entry points instead of importing internal `dist` paths.
777
226
 
778
- > Reproduce: `docker compose up -d && dotenv -e .env.test -- npx ts-node benchmarks/evaluation-overhead.ts`
227
+ For changes to this repository, follow [AGENTS.md](https://github.com/nestarc/nestjs-feature-flag/blob/main/AGENTS.md). Historical design documents are not the current API contract.
779
228
 
780
229
  ## License
781
230
 
782
- MIT
231
+ [MIT](LICENSE)