@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/docs/usage.md ADDED
@@ -0,0 +1,720 @@
1
+ # @nestarc/feature-flag consumer guide
2
+
3
+ This guide is for application developers and AI agents consuming the package. Start with the [README quickstart](../README.md#quickstart) for a complete running app.
4
+
5
+ **Version scope:** this guide describes **0.6.0**. It adds `repository` / `tenantContextProvider` module options, invocation `bucketBy`, explicit targeting-key propagation, consistent registry bucketing, Admin null-percentage validation, and full OpenFeature SDK registration. These changes are absent from `0.5.0`. See the [changelog](../CHANGELOG.md) for version boundaries. When using 0.5.0, inspect its declarations and [tagged source](https://github.com/nestarc/nestjs-feature-flag/tree/v0.5.0).
6
+
7
+ ## Contents
8
+
9
+ - [Database setup](#database-setup)
10
+ - [Module registration](#module-registration)
11
+ - [Evaluation and defaults](#evaluation-and-defaults)
12
+ - [Context and targeting](#context-and-targeting)
13
+ - [Percentage bucketing](#percentage-bucketing)
14
+ - [Typed registry](#typed-registry)
15
+ - [Route guards](#route-guards)
16
+ - [Flag management](#flag-management)
17
+ - [Admin REST API](#admin-rest-api)
18
+ - [Caching](#caching)
19
+ - [Events](#events)
20
+ - [Custom persistence and tenancy](#custom-persistence-and-tenancy)
21
+ - [OpenFeature](#openfeature)
22
+ - [Testing](#testing)
23
+ - [Upgrades and troubleshooting](#upgrades-and-troubleshooting)
24
+ - [Agent implementation checklist](#agent-implementation-checklist)
25
+
26
+ ## Database setup
27
+
28
+ Use Node.js `^20.19.0 || ^22.12.0 || >=24.0.0` with NestJS 10/11 and Prisma 7. The default repository needs PostgreSQL and a Prisma client with `featureFlag` and `featureFlagOverride` models. Required package peers are listed in `package.json`; `@prisma/adapter-pg`, `pg`, the Prisma CLI, and `dotenv` below are application setup dependencies.
29
+
30
+ Install 0.6.0 in an existing Nest application with matching versions of Prisma CLI, client, and adapter:
31
+
32
+ ```bash
33
+ npm install @nestarc/feature-flag@0.6.0
34
+ npm install @prisma/client@^7 @prisma/adapter-pg@^7 pg dotenv class-transformer@^0.5.1 class-validator@^0.15.0
35
+ npm install --save-dev prisma@^7
36
+ ```
37
+
38
+ To verify a local checkout, build this repository with `npm ci` and `npm run build`, run `npm pack`, then install the resulting `nestarc-feature-flag-0.6.0.tgz` in your app with `npm install /path/to/nestarc-feature-flag-0.6.0.tgz`. The [standalone examples](https://github.com/nestarc/nestjs-feature-flag/tree/main/examples) document this path.
39
+
40
+ For a new development database with no existing Prisma migrations:
41
+
42
+ ```bash
43
+ mkdir -p prisma
44
+ cp node_modules/@nestarc/feature-flag/prisma/schema.prisma prisma/schema.prisma
45
+ cp -R node_modules/@nestarc/feature-flag/prisma/migrations prisma/migrations
46
+ ```
47
+
48
+ In the copied schema, set the generator output for an application whose TypeScript sources live in `src`:
49
+
50
+ ```prisma
51
+ generator client {
52
+ provider = "prisma-client"
53
+ output = "../src/generated/prisma"
54
+ moduleFormat = "cjs"
55
+ }
56
+
57
+ datasource db {
58
+ provider = "postgresql"
59
+ }
60
+ ```
61
+
62
+ Keep both copied models, `FeatureFlag` and `FeatureFlagOverride`. Configure the connection URL in `prisma.config.ts`:
63
+
64
+ <!-- source: examples/basic-guard/prisma.config.ts -->
65
+ ```typescript
66
+ import 'dotenv/config';
67
+ import { defineConfig, env } from 'prisma/config';
68
+
69
+ export default defineConfig({
70
+ schema: 'prisma/schema.prisma',
71
+ migrations: { path: 'prisma/migrations' },
72
+ datasource: { url: env('DATABASE_URL') },
73
+ });
74
+ ```
75
+
76
+ Set `DATABASE_URL` to your development PostgreSQL database, then run:
77
+
78
+ ```bash
79
+ npx prisma generate
80
+ npx prisma migrate deploy
81
+ ```
82
+
83
+ In an existing Prisma app, merge the two models and create a migration in your own migration history instead of replacing its schema or migration directory. The included SQL migrations also define constraints that Prisma schema alone does not express:
84
+
85
+ ```sql
86
+ CREATE UNIQUE INDEX "uq_feature_flag_override_attributes"
87
+ ON "feature_flag_overrides"("flag_id", "attributes");
88
+
89
+ ALTER TABLE "feature_flag_overrides"
90
+ ADD CONSTRAINT "chk_feature_flag_override_attributes_non_empty"
91
+ CHECK (jsonb_typeof("attributes") = 'object' AND "attributes" <> '{}'::jsonb);
92
+ ```
93
+
94
+ Add these only if your database does not already have the equivalent constraints. They require unique override attributes per flag and reject empty/non-object override attributes. The existing migrations apply them for you.
95
+
96
+ ### Complete synchronous registration
97
+
98
+ After the preceding client generation, this `src/main.ts` defines its imports, client, lifecycle cleanup, module, and one guarded route. It uses the default Express Nest platform, so the app also needs `@nestjs/platform-express`. Enable `experimentalDecorators`, `emitDecoratorMetadata`, and `esModuleInterop` in TypeScript, as in a standard Nest project.
99
+
100
+ <!-- typecheck: main.ts -->
101
+ ```typescript
102
+ import 'reflect-metadata';
103
+ import 'dotenv/config';
104
+ import { Controller, Get, Injectable, Module, OnModuleDestroy } from '@nestjs/common';
105
+ import { NestFactory } from '@nestjs/core';
106
+ import { PrismaPg } from '@prisma/adapter-pg';
107
+ import { FeatureFlag, FeatureFlagModule } from '@nestarc/feature-flag';
108
+ import { PrismaClient } from './generated/prisma/client';
109
+
110
+ const connectionString = process.env.DATABASE_URL;
111
+ if (!connectionString) throw new Error('DATABASE_URL is required');
112
+ const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
113
+
114
+ @Injectable()
115
+ class PrismaLifecycle implements OnModuleDestroy {
116
+ async onModuleDestroy(): Promise<void> {
117
+ await prisma.$disconnect();
118
+ }
119
+ }
120
+
121
+ @Controller('dashboard')
122
+ class DashboardController {
123
+ @Get()
124
+ @FeatureFlag('EXAMPLE_DASHBOARD')
125
+ getDashboard() {
126
+ return { message: 'New dashboard is enabled' };
127
+ }
128
+ }
129
+
130
+ @Module({
131
+ imports: [FeatureFlagModule.forRoot({ prisma, environment: 'development' })],
132
+ controllers: [DashboardController],
133
+ providers: [PrismaLifecycle],
134
+ })
135
+ class AppModule {}
136
+
137
+ async function bootstrap(): Promise<void> {
138
+ await prisma.$connect();
139
+ const app = await NestFactory.create(AppModule);
140
+ app.enableShutdownHooks();
141
+ await app.listen(3000);
142
+ }
143
+
144
+ void bootstrap();
145
+ ```
146
+
147
+ Until the database contains an enabled `EXAMPLE_DASHBOARD`, `GET /dashboard` returns 403. Create it through a trusted application seed script or the guarded Admin API. The [basic example](https://github.com/nestarc/nestjs-feature-flag/tree/main/examples/basic-guard) provides an idempotent seed and executable `on` / `off` flow.
148
+
149
+ ## Module registration
150
+
151
+ Register `FeatureFlagModule` once in the application; it exports `FeatureFlagService` globally. The application owns the Prisma connection lifecycle.
152
+
153
+ | Option | Default | Meaning |
154
+ | --- | --- | --- |
155
+ | `environment` | Required | Ambient environment used for targeting |
156
+ | `prisma` | Required unless `repository` supplied | Prisma client for the default repository |
157
+ | `cacheTtlMs` | `30000` | TTL in milliseconds; `0` skips writes to cache |
158
+ | `userIdExtractor` | None | `(req) => string \| null`; reads the request before the route guard |
159
+ | `defaultOnMissing` | `false` | Fallback for individual missing/error evaluations |
160
+ | `emitEvents` | `false` | Enables publishing when Nest's event emitter is configured |
161
+ | `cacheAdapter` | `MemoryCacheAdapter` | Cache implementation |
162
+ | `flags` | None | Registry defaults, bucket selection, and exposure settings |
163
+ | `repository` | Prisma repository | Custom repository instance; added in 0.6.0 |
164
+ | `tenantContextProvider` | Default tenant provider | Custom ambient tenant resolver instance; added in 0.6.0 |
165
+
166
+ ### Asynchronous factories and reusable option providers
167
+
168
+ Every dependency used in `inject` or a factory class constructor must be exported by a module included in `forRootAsync.imports`, unless it is already globally available. Registering a provider only in the parent `AppModule.providers` does not make it visible inside this dynamic module.
169
+
170
+ The following complete module file defines the Prisma service and exports needed by all three async forms. It assumes the generated client path from [database setup](#database-setup).
171
+
172
+ <!-- typecheck: database.module.ts -->
173
+ ```typescript
174
+ import 'dotenv/config';
175
+ import { Injectable, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
176
+ import { PrismaPg } from '@prisma/adapter-pg';
177
+ import {
178
+ FeatureFlagModule,
179
+ FeatureFlagModuleOptionsFactory,
180
+ FeatureFlagModuleRootOptions,
181
+ } from '@nestarc/feature-flag';
182
+ import { PrismaClient } from './generated/prisma/client';
183
+
184
+ @Injectable()
185
+ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
186
+ constructor() {
187
+ const connectionString = process.env.DATABASE_URL;
188
+ if (!connectionString) throw new Error('DATABASE_URL is required');
189
+ super({ adapter: new PrismaPg({ connectionString }) });
190
+ }
191
+ async onModuleInit(): Promise<void> { await this.$connect(); }
192
+ async onModuleDestroy(): Promise<void> { await this.$disconnect(); }
193
+ }
194
+
195
+ @Module({ providers: [PrismaService], exports: [PrismaService] })
196
+ export class PrismaModule {}
197
+
198
+ @Injectable()
199
+ export class FeatureFlagConfigService implements FeatureFlagModuleOptionsFactory {
200
+ constructor(private readonly prisma: PrismaService) {}
201
+ createFeatureFlagOptions(): FeatureFlagModuleRootOptions {
202
+ return { prisma: this.prisma, environment: process.env.NODE_ENV ?? 'development' };
203
+ }
204
+ }
205
+
206
+ @Module({
207
+ imports: [PrismaModule],
208
+ providers: [FeatureFlagConfigService],
209
+ exports: [FeatureFlagConfigService],
210
+ })
211
+ export class FlagConfigurationModule {}
212
+
213
+ @Module({
214
+ imports: [
215
+ FeatureFlagModule.forRootAsync({
216
+ imports: [PrismaModule],
217
+ inject: [PrismaService],
218
+ useFactory: (prisma: PrismaService) => ({
219
+ prisma,
220
+ environment: process.env.NODE_ENV ?? 'development',
221
+ }),
222
+ }),
223
+ ],
224
+ })
225
+ export class AppModule {}
226
+ ```
227
+
228
+ Choose exactly one registration form. In that file, the alternatives replace the `FeatureFlagModule.forRootAsync(...)` entry in `AppModule.imports`:
229
+
230
+ ```typescript
231
+ // useClass: FeatureFlagModule constructs this class with an imported PrismaService.
232
+ FeatureFlagModule.forRootAsync({
233
+ imports: [PrismaModule],
234
+ useClass: FeatureFlagConfigService,
235
+ });
236
+
237
+ // useExisting: reuse the instance exported by FlagConfigurationModule.
238
+ FeatureFlagModule.forRootAsync({
239
+ imports: [FlagConfigurationModule],
240
+ useExisting: FeatureFlagConfigService,
241
+ });
242
+ ```
243
+
244
+ An asynchronous `useFactory` or `createFeatureFlagOptions()` can return a promise. The options factory is invoked once per module registration. To enable events, also configure the separate [event emitter module](#events).
245
+
246
+ ## Evaluation and defaults
247
+
248
+ The service methods have different contracts:
249
+
250
+ | Method | Return / error behavior | Evaluation events |
251
+ | --- | --- | --- |
252
+ | `isEnabled(key, context?, options?)` | `Promise<boolean>`; individual missing/error fallback | Same as `evaluateBoolean()` |
253
+ | `evaluateBoolean(key, context?, options?)` | `Promise<BooleanEvaluationDetails>`; individual missing/error fallback | Evaluated; exposed if opted in and event emission is enabled |
254
+ | `evaluateAll(context?)` | `Promise<Record<string, boolean>>` of active stored flags; read/context/evaluation errors reject | None |
255
+
256
+ A boolean detail result includes `flagKey`, `value`, `result` (alias of `value`), `source`, `reason`, `defaultUsed`, and `evaluationTimeMs`. `matchedOverrideId`, `bucket`, and `targetingKey` appear when applicable. Error defaults include `errorCode` and `errorMessage`.
257
+
258
+ ```typescript
259
+ // `flags` is an injected FeatureFlagService.
260
+ const details = await flags.evaluateBoolean(
261
+ 'NEW_CHECKOUT',
262
+ { tenantId: 'tenant-acme', targetingKey: 'tenant-acme' },
263
+ { defaultValue: false, trackExposure: true, includeContextInEvent: false },
264
+ );
265
+ console.log(details.value, details.source, details.reason);
266
+ ```
267
+
268
+ Evaluation follows archived status, matching override, percentage, then global `enabled`. See the [README decision table](../README.md#how-a-flag-resolves). An archived flag returns false with `ARCHIVED`, not a fallback. A missing flag uses `FLAG_NOT_FOUND`; a caught evaluation failure uses `ERROR`.
269
+
270
+ For individual evaluation the selected default is the first defined value:
271
+
272
+ 1. Invocation `options.defaultValue` (including the value passed by a typed client or decorator).
273
+ 2. The module registry entry's `defaultValue`.
274
+ 3. Module `defaultOnMissing`.
275
+ 4. `false`.
276
+
277
+ Defaults do not override a stored flag's normal result. Registry-only flags are absent from `evaluateAll()`. Bulk evaluation applies the module registry's `bucketBy` in 0.6.0, but it has no invocation options or per-key fallback on failure.
278
+
279
+ ## Context and targeting
280
+
281
+ ```typescript
282
+ // `flags` is an injected FeatureFlagService.
283
+ await flags.isEnabled('NEW_CHECKOUT', {
284
+ userId: 'user-123',
285
+ tenantId: 'tenant-acme',
286
+ environment: 'production',
287
+ attributes: { plan: 'pro', country: 'KR' },
288
+ });
289
+ ```
290
+
291
+ The resolver obtains `userId` from request middleware, `tenantId` from the tenant provider, and `environment` from module options. Explicit top-level values override them. `undefined` allows ambient resolution; explicit `null` suppresses that ambient value and remains a null-valued targeting attribute. For example, `{ userId: null }` can match an override with `{ userId: null }`; it does not remove the attribute altogether.
292
+
293
+ Top-level resolved `userId`, `tenantId`, and `environment` overwrite same-named entries in `attributes`, including when the resolved value is null. Supply these dimensions at the top level. A `tenantId` passed explicitly works even when `@nestarc/tenancy` is not installed. The default provider attempts to obtain the current tenant from that integration and otherwise returns null.
294
+
295
+ Targeting attributes are scalar strings, finite numbers, booleans, or null. Overrides require a non-empty object. All attributes in an override must match exactly; there is no substring, regex, range, or segment-rule evaluation. Nested objects and arrays are not override values.
296
+
297
+ When more than one override matches, order is: more attributes, higher `priority`, earlier `createdAt`, then lower ID. An empty override is invalid; use the flag's `enabled` value for a global fallback.
298
+
299
+ ## Percentage bucketing
300
+
301
+ Percentage must be an integer from 0 through 100. A percentage of 100 returns true after override handling without requiring a bucket key. A percentage of 0 uses global `enabled`. Between 1 and 99, a usable key produces `murmurhash3(flag.key + targetingKey) % 100`; the flag is true if the result is below `percentage`. This is a deterministic distribution, not a guarantee that exactly that fraction of a small population is enabled.
302
+
303
+ Key selection in 0.6.0 is:
304
+
305
+ 1. Non-empty explicit `context.targetingKey`.
306
+ 2. Read the attribute named by the chosen `bucketBy`: invocation option, then module registry, then flag `metadata.bucketBy`.
307
+ 3. If no configured key value is usable, use `context.userId ?? context.tenantId ?? ''`.
308
+
309
+ A typed client passes its own registry `bucketBy` as an invocation option; explicit call options override that value. If the selected attribute is absent, the evaluator goes directly to the legacy user/tenant fallback; it does not retry a lower-priority `bucketBy` configuration. Custom scalar attribute values are converted to strings. Null or an empty `targetingKey` allows normal fallback. Avoid empty user IDs: legacy nullish fallback preserves an empty `userId` rather than moving on to `tenantId`.
310
+
311
+ When no usable key remains, the result is global `enabled` with `PERCENTAGE_NO_TARGETING_KEY`. Use a stable, non-empty key for consistent allocation. Changing the key, chosen bucket attribute, or flag key can change allocation.
312
+
313
+ ```typescript
314
+ // Invocation bucketBy is new in 0.6.0; `flags` is an injected FeatureFlagService.
315
+ await flags.evaluateBoolean(
316
+ 'NEW_CHECKOUT',
317
+ { tenantId: 'tenant-acme', userId: 'user-123' },
318
+ { bucketBy: 'tenantId' },
319
+ );
320
+ ```
321
+
322
+ ## Typed registry
323
+
324
+ ```typescript
325
+ import { createFeatureFlagClient, defineFlags } from '@nestarc/feature-flag';
326
+
327
+ export const flagDefinitions = defineFlags({
328
+ NEW_CHECKOUT: {
329
+ defaultValue: false,
330
+ bucketBy: 'tenantId',
331
+ trackExposure: true,
332
+ owner: 'payments',
333
+ type: 'release',
334
+ tags: ['checkout'],
335
+ staleAt: '2026-12-01',
336
+ expiresAt: '2027-01-01',
337
+ },
338
+ });
339
+
340
+ // `service` is the injected FeatureFlagService.
341
+ const client = createFeatureFlagClient(service, flagDefinitions);
342
+ const enabled = await client.isEnabled('NEW_CHECKOUT', { tenantId: 'tenant-acme' });
343
+ ```
344
+
345
+ Pass `flags: flagDefinitions` alongside `environment` and persistence options at module registration to apply registry settings to direct service calls. The typed client's own registry is local to that client and does not register module-wide settings or affect separate `evaluateAll()` calls. Registry bucket propagation in the typed client and bulk evaluation is fixed in 0.6.0.
346
+
347
+ `defineFlags()` retains typed keys; it does not seed or synchronize database records. Lifecycle metadata (`owner`, `type`, `tags`, `staleAt`, `expiresAt`) is descriptive. `getFlagLifecycleStatus()` calculates active/stale/expired status; it does not archive records, prevent evaluation, or schedule cleanup. `createFeatureFlagDecorators(registry)` constrains keys and supplies registry defaults to decorators; use the module registry for guard bucketing/exposure defaults.
348
+
349
+ ## Route guards
350
+
351
+ `@FeatureFlag(key, options?)` applies `FeatureFlagGuard` automatically to a method or controller. A method's flag configuration takes precedence over its controller's flag configuration. `@BypassFeatureFlag()` exempts a method from a controller-level flag.
352
+
353
+ <!-- typecheck: route-guard.ts -->
354
+ ```typescript
355
+ import { Controller, Get } from '@nestjs/common';
356
+ import { BypassFeatureFlag, FeatureFlag } from '@nestarc/feature-flag';
357
+
358
+ @FeatureFlag('BETA_API')
359
+ @Controller('beta')
360
+ export class BetaController {
361
+ @Get('preview')
362
+ @FeatureFlag('OPTIONAL_PREVIEW', { defaultValue: true })
363
+ preview() { return { preview: true }; }
364
+
365
+ @Get('health')
366
+ @BypassFeatureFlag()
367
+ health() { return { status: 'ok' }; }
368
+ }
369
+ ```
370
+
371
+ Guard options are `statusCode` (default 403), `fallback` (optional response object), and `defaultValue` (individual missing/error fallback). For example, `{ statusCode: 402, fallback: { message: 'Upgrade required' } }` customizes a disabled response. Feature flags make rollout decisions; the application still supplies authentication and authorization.
372
+
373
+ ## Flag management
374
+
375
+ `create`, `update`, `archive`, and `findByKey` return `FeatureFlagWithOverrides`; `findAll()` returns active flags only. Archived flags remain available by key and always evaluate false. Their keys remain reserved; archiving is a soft delete. `setOverride()` and `removeOverride()` return `Promise<void>`.
376
+
377
+ ```typescript
378
+ // `flags` is an injected FeatureFlagService.
379
+ await flags.create({ key: 'NEW_CHECKOUT', enabled: false, percentage: 0 });
380
+ await flags.update('NEW_CHECKOUT', { percentage: 20 }, {
381
+ actorId: 'operator-1', actorType: 'user', reason: 'Start rollout',
382
+ });
383
+ await flags.setOverride('NEW_CHECKOUT', {
384
+ attributes: { tenantId: 'tenant-acme', plan: 'pro' },
385
+ enabled: true,
386
+ priority: 10,
387
+ });
388
+ await flags.removeOverride('NEW_CHECKOUT', {
389
+ attributes: { tenantId: 'tenant-acme', plan: 'pro' },
390
+ });
391
+ await flags.invalidateCache();
392
+ ```
393
+
394
+ An override's entire attributes object identifies it within a flag. Setting the same attributes updates its value and priority; removing uses the same complete attributes object. Omitted priority is 0, including when updating an existing override. Removing a nonexistent override is idempotent if the flag exists.
395
+
396
+ Optional mutation metadata fields are `actorId`, `actorType`, `reason`, `requestId`, and `correlationId`. They are emitted in lifecycle events when enabled; the package does not persist an audit log. Descriptions accept a string or null (null clears an existing description). Direct service calls rely on TypeScript input contracts and repository validation; HTTP DTO validation is specific to the Admin controller.
397
+
398
+ ## Admin REST API
399
+
400
+ Import `FeatureFlagAdminModule.register({ guard: AdminAuthGuard })` in the application that already registers `FeatureFlagModule`. A guard class is mandatory; its authentication policy is supplied by your application. `path` defaults to `feature-flags`. The module registers the guard class internally; any injected guard dependencies must be visible there (for example through an application-global authentication module).
401
+
402
+ The following guard reads an authenticated user established by your application's authentication middleware:
403
+
404
+ <!-- typecheck: admin.ts -->
405
+ ```typescript
406
+ import { CanActivate, ExecutionContext, Injectable, Module } from '@nestjs/common';
407
+ import { FeatureFlagAdminModule } from '@nestarc/feature-flag';
408
+
409
+ @Injectable()
410
+ class AdminAuthGuard implements CanActivate {
411
+ canActivate(context: ExecutionContext): boolean {
412
+ const request = context.switchToHttp().getRequest<{ user?: { isAdmin?: boolean } }>();
413
+ return request.user?.isAdmin === true;
414
+ }
415
+ }
416
+
417
+ @Module({
418
+ imports: [FeatureFlagAdminModule.register({ guard: AdminAuthGuard })],
419
+ })
420
+ export class FlagAdminModule {}
421
+ ```
422
+
423
+ Import `FlagAdminModule` into the root app alongside its feature flag registration. Without authenticated `request.user.isAdmin === true`, this sample denies access. The controller uses a validation pipe that rejects unknown top-level body fields.
424
+
425
+ | Method / route | Body | Success response | Application errors |
426
+ | --- | --- | --- | --- |
427
+ | `POST /feature-flags` | `key`, optional `description`, `enabled`, `percentage`, `metadata` | 201, flag object | 400 invalid body/percentage; 409 duplicate key |
428
+ | `GET /feature-flags` | None | 200, active flag array | Guard may deny access |
429
+ | `GET /feature-flags/:key` | None | 200, flag object (including archived) | 404 missing flag |
430
+ | `PATCH /feature-flags/:key` | Optional `description`, `enabled`, `percentage`, `metadata` | 200, updated flag | 400 invalid body/percentage; 404 missing flag |
431
+ | `DELETE /feature-flags/:key` | None | 200, archived flag object | 404 missing flag |
432
+ | `POST /feature-flags/:key/evaluate` | Optional `context`, `defaultValue`, `bucketBy`, `trackExposure`, `includeContextInEvent` | 201, evaluation details | 400 invalid body; missing flag is a default result |
433
+ | `POST /feature-flags/:key/overrides` | `attributes`, `enabled`, optional `priority` | 201, empty body | 400 invalid attributes/body; 404 missing flag |
434
+ | `DELETE /feature-flags/:key/overrides` | `attributes` | 200, empty body | 400 invalid attributes/body; 404 missing flag |
435
+
436
+ These are Nest's default success status codes; override endpoints do not return a flag object or 204. Authentication errors depend on the supplied guard. Infrastructure errors may still return 500. The evaluation endpoint shares the service's event behavior, so `trackExposure: true` can emit an event without a database mutation. The `bucketBy` option added in 0.6.0 also works through this endpoint and must be a non-empty string; null, empty, and non-string values return 400.
437
+
438
+ Create request:
439
+
440
+ ```http
441
+ POST /feature-flags
442
+ Content-Type: application/json
443
+
444
+ {"key":"NEW_CHECKOUT","enabled":false,"percentage":0}
445
+ ```
446
+
447
+ Example 201 response (IDs and timestamps vary):
448
+
449
+ ```json
450
+ {
451
+ "id": "1b4b788f-13eb-470e-a690-c34e8796f528",
452
+ "key": "NEW_CHECKOUT",
453
+ "description": null,
454
+ "enabled": false,
455
+ "percentage": 0,
456
+ "metadata": {},
457
+ "archivedAt": null,
458
+ "createdAt": "2026-09-10T00:00:00.000Z",
459
+ "updatedAt": "2026-09-10T00:00:00.000Z",
460
+ "overrides": []
461
+ }
462
+ ```
463
+
464
+ Evaluate that flag:
465
+
466
+ ```http
467
+ POST /feature-flags/NEW_CHECKOUT/evaluate
468
+ Content-Type: application/json
469
+
470
+ {"context":{"tenantId":"tenant-acme","attributes":{"plan":"pro"}},"defaultValue":true}
471
+ ```
472
+
473
+ ```json
474
+ {
475
+ "flagKey": "NEW_CHECKOUT",
476
+ "value": false,
477
+ "result": false,
478
+ "source": "global",
479
+ "reason": "GLOBAL",
480
+ "defaultUsed": false,
481
+ "evaluationTimeMs": 0
482
+ }
483
+ ```
484
+
485
+ The example timing is illustrative. `defaultValue: true` does not replace a stored false result. A missing key produces `value: true`, `source: "default"`, `reason: "FLAG_NOT_FOUND"`, and `defaultUsed: true` with this request.
486
+
487
+ Percentages must be integers 0–100. The 0.6.0 validation fix rejects explicit `null` as well as strings, fractions, and out-of-range values with 400; omission uses the create default or leaves an update unchanged. Legacy override bodies such as `{"tenantId":"tenant-acme","enabled":true}` are rejected: use `{"attributes":{"tenantId":"tenant-acme"},"enabled":true}`. Evaluation `context` is checked as an object, not deeply validated as a nested DTO; follow the [context contract](#context-and-targeting).
488
+
489
+ ## Caching
490
+
491
+ The default `MemoryCacheAdapter` stores definitions per process. Evaluations, not final boolean decisions, are recomputed from those definitions and the current context. The default TTL is 30,000 ms. A TTL of 0 skips writes to the built-in caches; it does not clear entries another instance already populated in a shared Redis cache.
492
+
493
+ Successful mutations attempt invalidation before returning. Those invalidation errors are caught because the database write has already succeeded. An existing stale entry can remain until its TTL expires; write/read races and unavailable infrastructure prevent an immediate-consistency guarantee. A direct database edit does not trigger library invalidation. Use the service mutation APIs, await `invalidateCache()`, or accept TTL-based refresh when making external edits.
494
+
495
+ `await flags.invalidateCache()` clears the cache through the adapter and propagates an invalidation error to its caller, unlike mutation-path best effort. Independent in-memory instances do not notify one another. Redis provides shared cache entries and Pub/Sub invalidation:
496
+
497
+ <!-- typecheck: cache.ts -->
498
+ ```typescript
499
+ import { RedisCacheAdapter } from '@nestarc/feature-flag';
500
+ import { Redis } from 'ioredis';
501
+
502
+ const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
503
+ const cacheAdapter = new RedisCacheAdapter({
504
+ client: redis,
505
+ keyPrefix: 'my-app:production:feature-flag:',
506
+ channel: 'my-app:production:feature-flag:invalidate',
507
+ });
508
+
509
+ // Add cacheAdapter to the root options alongside prisma and environment.
510
+ // On app shutdown, close the app (which destroys the adapter), then redis.quit().
511
+ ```
512
+
513
+ Install `ioredis` separately. Use the same prefix and channel for instances sharing flags, and separate namespaces for unrelated applications/databases. The adapter creates a subscriber using `client.duplicate()` unless one is supplied. It unsubscribes and closes its owned subscriber on module destruction; it does not close the supplied client or a caller-owned subscriber. Arrange application cleanup for those connections and call `app.enableShutdownHooks()` if using process signals. The [Redis example](https://github.com/nestarc/nestjs-feature-flag/tree/main/examples/redis-events) includes lifecycle cleanup and two running instances.
514
+
515
+ Choose TTL from acceptable staleness and measured load. The [benchmark method](https://github.com/nestarc/nestjs-feature-flag/blob/main/benchmarks/README.md) describes a reproducible local measurement; it does not establish a universally optimal TTL or production latency.
516
+
517
+ ## Events
518
+
519
+ Install `@nestjs/event-emitter`, register `EventEmitterModule.forRoot()`, and set `emitEvents: true`. The module uses Nest's emitter instance. The following changes apply to the PrismaModule/PrismaService from the async registration recipe:
520
+
521
+ <!-- typecheck: events.ts -->
522
+ ```typescript
523
+ import { Injectable, Module } from '@nestjs/common';
524
+ import { EventEmitterModule, OnEvent } from '@nestjs/event-emitter';
525
+ import {
526
+ FeatureFlagEvents,
527
+ FeatureFlagModule,
528
+ FlagEvaluatedEvent,
529
+ } from '@nestarc/feature-flag';
530
+ import { PrismaModule, PrismaService } from './database.module';
531
+
532
+ @Injectable()
533
+ class FlagAuditListener {
534
+ @OnEvent(FeatureFlagEvents.EVALUATED)
535
+ onEvaluated(event: FlagEvaluatedEvent): void {
536
+ console.log(event.flagKey, event.value, event.reason);
537
+ }
538
+ }
539
+
540
+ @Module({
541
+ imports: [
542
+ EventEmitterModule.forRoot(),
543
+ FeatureFlagModule.forRootAsync({
544
+ imports: [PrismaModule],
545
+ inject: [PrismaService],
546
+ useFactory: (prisma: PrismaService) => ({
547
+ prisma,
548
+ environment: 'production',
549
+ emitEvents: true,
550
+ }),
551
+ }),
552
+ ],
553
+ providers: [FlagAuditListener],
554
+ })
555
+ export class AppModule {}
556
+ ```
557
+
558
+ Save the reusable Prisma service/module definitions as `database.module.ts` for that import, or use the separate files in the [Redis events example](https://github.com/nestarc/nestjs-feature-flag/tree/main/examples/redis-events). For synchronous registration, include `EventEmitterModule.forRoot()` alongside `FeatureFlagModule.forRoot({ prisma, environment, emitEvents: true })` using your constructed client.
559
+
560
+ | Constant | Event string |
561
+ | --- | --- |
562
+ | `EVALUATED` | `feature-flag.evaluated` |
563
+ | `EXPOSED` | `feature-flag.exposed` |
564
+ | `CREATED` / `UPDATED` / `ARCHIVED` | `feature-flag.created` / `feature-flag.updated` / `feature-flag.archived` |
565
+ | `OVERRIDE_SET` / `OVERRIDE_REMOVED` | `feature-flag.override.set` / `feature-flag.override.removed` |
566
+ | `CACHE_INVALIDATED` | `feature-flag.cache.invalidated` |
567
+
568
+ All constants are properties of `FeatureFlagEvents`. `CACHE_INVALIDATED` is emitted by explicit `invalidateCache()`; mutation events accompany mutation-path invalidation. Failed mutation invalidation uses the string `feature-flag.cache.invalidation-failed`, with `key` and `error`, when events are enabled.
569
+
570
+ Exposure opt-in precedence is invocation `trackExposure`, module registry `trackExposure`, flag metadata `trackExposure`, then false. A typed client forwards its registry setting as an invocation option. These settings only request an event; `emitEvents: true` and the emitter setup are still needed. Error fallback can use invocation/module registry exposure settings; flag metadata cannot be relied on when fetching the flag failed. `evaluateAll()` emits neither event.
571
+
572
+ | `includeContextInEvent` | Evaluated event context | Exposed event context |
573
+ | --- | --- | --- |
574
+ | Omitted | Included | Omitted |
575
+ | `true` | Included | Included |
576
+ | `false` | Omitted | Omitted |
577
+
578
+ Event listeners decide sampling, persistence, and analytics. Lifecycle and exposure metadata are not automatically stored as audit logs or analytics records.
579
+
580
+ ## Custom persistence and tenancy
581
+
582
+ **Added in 0.6.0:** pass implementation instances through module options. A custom repository removes the need for a Prisma instance at module initialization; if both are supplied, `repository` takes precedence. Omitting both throws a configuration error. This does not change npm's declared peer dependencies. Your application owns custom instance lifecycle: Nest manages injected providers in their declaring module; initialize and close manually constructed instances yourself. Exported repository/tenant tokens expose interface delegates, so do not rely on identity with your supplied object.
583
+
584
+ For implementations already constructed by your app:
585
+
586
+ ```typescript
587
+ // repository implements FeatureFlagRepository; tenantContextProvider implements
588
+ // TenantContextProvider. Both are application-owned instances.
589
+ FeatureFlagModule.forRoot({
590
+ environment: 'production',
591
+ repository,
592
+ tenantContextProvider,
593
+ });
594
+ ```
595
+
596
+ For injectable implementations, export them from their defining module and return their injected instances from the root-options factory:
597
+
598
+ ```typescript
599
+ import { Module } from '@nestjs/common';
600
+ import { FeatureFlagModule } from '@nestarc/feature-flag';
601
+ import { MyFlagRepository } from './my-flag.repository';
602
+ import { MyTenantProvider } from './my-tenant.provider';
603
+
604
+ @Module({
605
+ providers: [MyFlagRepository, MyTenantProvider],
606
+ exports: [MyFlagRepository, MyTenantProvider],
607
+ })
608
+ class FlagInfrastructureModule {}
609
+
610
+ @Module({
611
+ imports: [
612
+ FeatureFlagModule.forRootAsync({
613
+ imports: [FlagInfrastructureModule],
614
+ inject: [MyFlagRepository, MyTenantProvider],
615
+ useFactory: (repository: MyFlagRepository, tenantContextProvider: MyTenantProvider) => ({
616
+ environment: 'production',
617
+ repository,
618
+ tenantContextProvider,
619
+ }),
620
+ }),
621
+ ],
622
+ })
623
+ export class AppModule {}
624
+ ```
625
+
626
+ The two imported classes are application implementations, not exports from this package. `TenantContextProvider` has one synchronous method: `getCurrentTenantId(): string | null`. Resolve the current request's tenant through your application's context mechanism. `FeatureFlagRepository` specifies flag CRUD, active-only listing, and override lookup/create/update/delete methods; implement its exported interface and preserve the error/uniqueness contracts your application requires. The injected Prisma repository is the reference implementation.
627
+
628
+ Do not attempt to replace these internal providers by placing an identical token in a parent module's `providers` array: Nest module encapsulation prevents that from overriding the provider inside `FeatureFlagModule`. Test overrides through Nest's testing builder are a separate testing mechanism.
629
+
630
+ ## OpenFeature
631
+
632
+ **SDK integration in 0.6.0:** install `@openfeature/server-sdk@^1.23.0` and register the provider through the SDK. The adapter has no SDK runtime import, but its public TypeScript provider declaration references SDK types, so install the optional SDK when using this entry point.
633
+
634
+ ```typescript
635
+ import { OpenFeature } from '@openfeature/server-sdk';
636
+ import { FeatureFlagService } from '@nestarc/feature-flag';
637
+ import { createOpenFeatureBooleanProvider } from '@nestarc/feature-flag/openfeature';
638
+
639
+ // `app` is your initialized Nest application with FeatureFlagModule registered.
640
+ await OpenFeature.setProviderAndWait(
641
+ createOpenFeatureBooleanProvider(app.get(FeatureFlagService)),
642
+ );
643
+ const client = OpenFeature.getClient();
644
+ const enabled = await client.getBooleanValue('NEW_CHECKOUT', false, {
645
+ targetingKey: 'user-123',
646
+ tenantId: 'tenant-acme',
647
+ plan: 'pro',
648
+ });
649
+ ```
650
+
651
+ The SDK's invocation default is forwarded to individual service evaluation. Known top-level keys map to library context; extra scalar context keys such as `plan` become targeting attributes. Object/array values are ignored. The provider supports booleans only. String, numeric, and object SDK getters return the caller's default with reason `ERROR` and `TYPE_MISMATCH`; they do not implement variant flags or remote configuration.
652
+
653
+ | Library result | SDK result reason / error |
654
+ | --- | --- |
655
+ | Override | `TARGETING_MATCH` |
656
+ | Percentage match or miss | `SPLIT` |
657
+ | Global value | `STATIC` |
658
+ | Archived | `DISABLED` |
659
+ | Percentage without a usable key | `DEFAULT`, global enabled fallback, no error |
660
+ | Missing flag | `ERROR`, `FLAG_NOT_FOUND`, caller default |
661
+ | Evaluation failure | `ERROR`, `GENERAL`, caller default |
662
+
663
+ The 0.5.0 adapter does not provide the SDK compatibility described here; upgrade to 0.6.0 for this integration. For exposure event controls, use the library's evaluation API or module registry.
664
+
665
+ ## Testing
666
+
667
+ The `/testing` entry point supplies a database-free service stub for code that consumes booleans:
668
+
669
+ ```typescript
670
+ import { Test } from '@nestjs/testing';
671
+ import { FeatureFlagService } from '@nestarc/feature-flag';
672
+ import { TestFeatureFlagController, TestFeatureFlagModule } from '@nestarc/feature-flag/testing';
673
+
674
+ const moduleRef = await Test.createTestingModule({
675
+ imports: [TestFeatureFlagModule.register({ NEW_CHECKOUT: false })],
676
+ }).compile();
677
+ const flags = moduleRef.get(FeatureFlagService);
678
+ const controls = moduleRef.get(TestFeatureFlagController);
679
+
680
+ controls.set('NEW_CHECKOUT', true);
681
+ expect(await flags.isEnabled('NEW_CHECKOUT')).toBe(true);
682
+ controls.reset();
683
+ expect(await flags.isEnabled('NEW_CHECKOUT')).toBe(false);
684
+ await moduleRef.close();
685
+ ```
686
+
687
+ `registerRegistry(registry, { overrides })` starts from registry defaults with optional test overrides. `reset()` restores registry/default values, not the initially supplied overrides. Unknown keys default to the invocation default or false.
688
+
689
+ The stub does not evaluate context, percentage rollouts, override precedence, or emit events. Its CRUD methods return stub objects; they do not simulate persistence or mutate the controller's values. Use `TestFeatureFlagController.set()` to change a test decision. Test actual targeting and infrastructure behavior with the real module/evaluator and an appropriate repository. Close test apps/modules and owned connections after each test.
690
+
691
+ ## Upgrades and troubleshooting
692
+
693
+ For 0.5.0 → 0.6.0, no Prisma schema migration is required. Review the [0.6.0 migration notes](../CHANGELOG.md) for custom provider registration and OpenFeature SDK types. Corrected `targetingKey` and registry `bucketBy` handling can change existing partial-rollout assignments when those settings were previously ignored.
694
+
695
+ For 0.5.0, follow the [changelog's migration notes](../CHANGELOG.md): Prisma 7 needs `@prisma/adapter-pg`, a generated-client output import, and the URL in `prisma.config.ts`. This upgrade does not require a feature-flag database migration.
696
+
697
+ For 0.2 → 0.3, the SQL migration moves legacy `tenant_id`, `user_id`, and `environment` into override `attributes`. It deletes rows whose legacy columns are all null. Rows that collide after conversion are deduplicated by latest `updated_at`, then latest `created_at`, then highest ID. Review that data transformation before deploying it. Replace legacy override request fields with a non-empty `attributes` object. Later runtime fixes do not undo this migration's data transformations.
698
+
699
+ | Symptom | Check |
700
+ | --- | --- |
701
+ | Nest cannot resolve `PrismaService` or a factory dependency | Export it from its module and include that module in `forRootAsync.imports` |
702
+ | `useExisting` cannot resolve the options factory | Import a module that provides and exports that factory class |
703
+ | An event listener receives nothing | Install the event package, import `EventEmitterModule.forRoot()`, register the listener, and set `emitEvents: true`; exposure also requires opt-in |
704
+ | A false flag still enables a route | Check matching overrides and nonzero percentage before global `enabled` |
705
+ | Percentage rollout does not use a supplied targeting key | Check installed version; explicit propagation is fixed in 0.6.0 |
706
+ | A seeded change is not visible | Direct DB changes do not invalidate library caches; await manual invalidation or wait for TTL |
707
+ | A tenant override fails without the tenancy package | Supply top-level `tenantId` explicitly; no tenancy dependency is needed for explicit context |
708
+ | A custom provider in `AppModule.providers` is ignored | Use the 0.6.0 module options with imported/injected implementations |
709
+ | `evaluateAll()` omits a registry key | Bulk results contain active database flags only; seed the record explicitly |
710
+ | Prisma client import is missing | Generate the client and match its configured output path; 0.5 uses Prisma 7's generated import |
711
+
712
+ ## Agent implementation checklist
713
+
714
+ 1. Inspect the installed `package.json`, changelog, and public `.d.ts` files. Confirm that 0.6.0 is installed before using the options and fixes added in that version.
715
+ 2. Use `@nestarc/feature-flag`, `@nestarc/feature-flag/testing`, and `@nestarc/feature-flag/openfeature`. Avoid private `dist/*` or repository `src/*` imports.
716
+ 3. Choose the complete basic example or the registration recipe above. Supply database schema/migrations, a generated Prisma client, exported Nest dependencies, environment values, and a seeded flag.
717
+ 4. Use top-level user/tenant/environment context, explicit stable bucket identity, and an appropriate missing/error default. Do not treat registry declarations as database creation.
718
+ 5. Add Redis, events, Admin routes, or OpenFeature only with their documented dependencies and lifecycle setup.
719
+ 6. Build the consumer against the installed package and verify an enabled and disabled result. Use real evaluation for targeting behavior; boolean stubs only verify consuming branches.
720
+ 7. When changing the library repository itself, use its [AGENTS.md](https://github.com/nestarc/nestjs-feature-flag/blob/main/AGENTS.md) and documentation verification commands. Historical plans do not supersede current declarations and executable examples.