@brandtg/flapjack 1.3.0 → 1.5.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
@@ -9,6 +9,7 @@ A simple feature flags library with PostgreSQL integration, inspired by [django-
9
9
  ## Features
10
10
 
11
11
  - **Multiple targeting strategies**: Enable features for specific users, roles, groups, or percentage rollouts
12
+ - **External subject targeting**: Assign flags (or flag groups) to external subjects like tenants or org IDs
12
13
  - **Consistent hashing**: Deterministic user bucketing for A/B testing and experimentation
13
14
  - **PostgreSQL-backed**: Reliable, transactional flag storage with your existing database
14
15
  - **CLI included**: Manage feature flags from the command line
@@ -63,6 +64,16 @@ const active = await featureFlag.isActiveForUser({
63
64
  user: "1234",
64
65
  });
65
66
 
67
+ // Assign a flag to an external subject ID (for example, a tenant)
68
+ await featureFlags.addSubject(flag.id, "tenant:acme");
69
+
70
+ // Check if active for a broader context that includes external subject IDs
71
+ const activeForTenant = await featureFlags.isActiveForContext({
72
+ name: "enable_new_checkout_20250101_gbrandt",
73
+ user: "1234",
74
+ subjects: ["tenant:acme"],
75
+ });
76
+
66
77
  // Enable the feature flag for certain roles
67
78
  await featureFlags.update(flag.id, {
68
79
  roles: ["admin", "staff"],
@@ -101,9 +112,41 @@ When checking if a feature flag is active for a user, Flapjack evaluates rules i
101
112
  1. **Everyone Override** (`everyone: true/false`): If set, immediately returns this value, ignoring all other rules
102
113
  2. **User List** (`users: [...]`): If the user ID is in this list, returns `true`
103
114
  3. **Group Membership** (`groups: [...]`): If the user belongs to any specified group, returns `true`
104
- 4. **Role Membership** (`roles: [...]`): If the user has any specified role, returns `true`
105
- 5. **Percentage Rollout** (`percent: 0-99.9`): Uses consistent hashing to deterministically bucket users
106
- 6. **Default**: Returns `false` if no conditions are met
115
+ 4. **External Subject Match** (`subjects: [...]`): If any provided subject matches a direct flag subject or a flag-group subject, returns `true`
116
+ 5. **Role Membership** (`roles: [...]`): If the user has any specified role, returns `true`
117
+ 6. **Percentage Rollout** (`percent: 0-99.9`): Uses consistent hashing to deterministically bucket users
118
+ 7. **Default**: Returns `false` if no conditions are met
119
+
120
+ ## External Subject Targeting
121
+
122
+ Use subjects when identity/group membership is managed outside Flapjack (for example, tenant IDs from another system).
123
+
124
+ ```typescript
125
+ const groupModel = new FeatureFlagGroupModel(pool);
126
+ const flagModel = new FeatureFlagModel(pool);
127
+
128
+ const rolloutGroup = await groupModel.create({
129
+ name: "checkout_rollout",
130
+ });
131
+
132
+ const checkoutFlag = await flagModel.create({
133
+ name: "enable_checkout_v2",
134
+ });
135
+
136
+ await groupModel.addFeatureFlag(rolloutGroup.id, checkoutFlag.id);
137
+
138
+ // Attach external subject to all flags in this feature flag group
139
+ await groupModel.addSubject(rolloutGroup.id, "tenant:acme");
140
+
141
+ // You can also attach a subject directly to a single flag
142
+ await flagModel.addSubject(checkoutFlag.id, "tenant:beta");
143
+
144
+ const isActive = await flagModel.isActiveForContext({
145
+ name: "enable_checkout_v2",
146
+ user: "user_123",
147
+ subjects: ["tenant:acme"],
148
+ });
149
+ ```
107
150
 
108
151
  ### Example Evaluation
109
152
 
@@ -138,6 +181,36 @@ await featureFlags.isActiveForUser({
138
181
  });
139
182
  ```
140
183
 
184
+ ## Organizing Flags with Tags
185
+
186
+ Tags are freeform labels for organizing flags without encoding that information in
187
+ the flag name. They do **not** affect evaluation — they are purely organizational
188
+ metadata. A common use is associating a release version with a flag, which can then
189
+ be bumped via a database update without any code change.
190
+
191
+ ```typescript
192
+ // Tag a flag with a release version (and any other labels)
193
+ await featureFlags.create({
194
+ name: "checkout_v2",
195
+ tags: ["release:1.5.0", "checkout"],
196
+ });
197
+
198
+ // Find every flag shipping in a given release
199
+ const flags = await featureFlags.listByTag("release:1.5.0");
200
+
201
+ // Bump the release version later — no code change required
202
+ await featureFlags.update(flag.id, { tags: ["release:1.6.0", "checkout"] });
203
+ ```
204
+
205
+ The equivalent CLI commands:
206
+
207
+ ```bash
208
+ flapjack create --name checkout_v2 --tags release:1.5.0 checkout
209
+ flapjack list-by-tag release:1.5.0
210
+ flapjack update <id> --tags release:1.6.0 checkout
211
+ flapjack update <id> --clear-tags
212
+ ```
213
+
141
214
  ## Performance Considerations
142
215
 
143
216
  ⚠️ **Important**: Without caching, Flapjack queries the database on every `isActiveForUser()` call. For high-traffic applications, use the built-in caching layer:
@@ -299,17 +372,21 @@ await featureFlags.update(flagId, { percent: 100 });
299
372
 
300
373
  Creates a new feature flag.
301
374
 
302
- #### `getById(id: number): Promise<FeatureFlag | null>`
375
+ #### `getById(id: number, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
303
376
 
304
- Retrieves a feature flag by its ID.
377
+ Retrieves a feature flag by its ID. Archived flags are excluded unless `includeArchived` is `true`.
305
378
 
306
- #### `getByName(name: string): Promise<FeatureFlag | null>`
379
+ #### `getByName(name: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag | null>`
307
380
 
308
- Retrieves a feature flag by its name.
381
+ Retrieves a feature flag by its name. Archived flags are excluded unless `includeArchived` is `true`.
309
382
 
310
- #### `list(): Promise<FeatureFlag[]>`
383
+ #### `list(options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>`
311
384
 
312
- Lists all feature flags, ordered by ID.
385
+ Lists all feature flags, ordered by ID. Archived flags are excluded unless `includeArchived` is `true`.
386
+
387
+ #### `listByTag(tag: string, options?: { includeArchived?: boolean }): Promise<FeatureFlag[]>`
388
+
389
+ Lists all feature flags carrying the given tag (exact match), ordered by ID. Archived flags are excluded unless `includeArchived` is `true`.
313
390
 
314
391
  #### `update(id: number, changes: UpdateChanges): Promise<FeatureFlag | null>`
315
392
 
@@ -317,7 +394,13 @@ Updates a feature flag. Returns the updated flag or null if not found.
317
394
 
318
395
  #### `delete(id: number): Promise<boolean>`
319
396
 
320
- Deletes a feature flag. Returns true if deleted, false if not found.
397
+ Permanently deletes a feature flag and all its relationships. Returns true if deleted, false if not found. To hide a flag while keeping its history, use `archive` instead.
398
+
399
+ #### `archive(id: number): Promise<FeatureFlag | null>`
400
+
401
+ Archives (hides) a feature flag while keeping the row for historical/audit purposes. Archived flags are excluded from normal reads and evaluate as inactive, but remain retrievable via `{ includeArchived: true }`. Returns the archived flag, or null if it does not exist or is already archived.
402
+
403
+ Archiving is **irreversible** (there is no `unarchive`) and the `archived` timestamp is immutable once set. The flag's name stays reserved permanently — a new flag cannot reuse it; create a new flag with a different name instead. The same `archive(id)` / `includeArchived` semantics apply to `FeatureFlagGroupModel`.
321
404
 
322
405
  #### `isActiveForUser(params): Promise<boolean>`
323
406
 
@@ -341,21 +424,70 @@ flapjack create --name my_feature --roles admin --note "Admin-only feature"
341
424
  # List all flags
342
425
  flapjack list
343
426
 
427
+ # Include archived (hidden) flags in any read command
428
+ flapjack list --include-archived
429
+ flapjack get-by-name my_feature --include-archived
430
+
431
+ # List flags carrying a tag (e.g. a release version)
432
+ flapjack list-by-tag release:1.5.0
433
+
344
434
  # Get a specific flag
345
435
  flapjack get-by-name my_feature
346
436
 
347
437
  # Check if active for a user
348
438
  flapjack is-active my_feature --user user123 --roles admin
349
439
 
440
+ # Check if multiple flags are active for a user
441
+ flapjack are-active --names my_feature other_feature --user user123 --roles admin
442
+
443
+ # Check if active for context (with external subject IDs)
444
+ flapjack is-active-context my_feature --user user123 --subjects tenant:acme
445
+
446
+ # Check multiple flags for context
447
+ flapjack are-active-context --names my_feature other_feature --subjects tenant:acme
448
+
350
449
  # Update a flag
351
450
  flapjack update 1 --percent 50 --everyone false
352
451
 
353
452
  # Clear specific fields
354
453
  flapjack update 1 --clear-roles --clear-percent
355
454
 
356
- # Delete a flag
455
+ # Delete a flag (permanent — destroys all metadata)
357
456
  flapjack delete 1
358
457
 
458
+ # Archive a flag (hide it but keep its history; irreversible)
459
+ flapjack archive 1
460
+
461
+ # Add/remove/list subject mappings on a flag
462
+ flapjack add-subject 1 tenant:acme
463
+ flapjack remove-subject 1 tenant:acme
464
+ flapjack list-subjects 1
465
+ flapjack list-by-subject tenant:acme
466
+
467
+ # Feature flag groups
468
+ flapjack group-create --name checkout_rollout --note "Checkout launch cohort"
469
+ flapjack group-list
470
+ flapjack group-get 1
471
+ flapjack group-get-by-name checkout_rollout
472
+ flapjack group-update 1 --note "Updated"
473
+ flapjack group-delete 1
474
+ flapjack group-archive 1
475
+
476
+ # Group membership
477
+ flapjack group-add-flag 1 10
478
+ flapjack group-remove-flag 1 10
479
+ flapjack group-list-flags 1
480
+ flapjack group-list-for-flag 10
481
+
482
+ # Bulk update all flags in a group
483
+ flapjack group-update-all 1 --percent 25 --roles admin
484
+
485
+ # Group-level subject mappings
486
+ flapjack group-add-subject 1 tenant:acme
487
+ flapjack group-remove-subject 1 tenant:acme
488
+ flapjack group-list-subjects 1
489
+ flapjack group-list-by-subject tenant:acme
490
+
359
491
  # Debug user bucketing
360
492
  flapjack hash-user user123
361
493
  ```
@@ -409,8 +541,11 @@ await featureFlags.update(flagId, { percent: 10 });
409
541
  await featureFlags.update(flagId, { everyone: true });
410
542
 
411
543
  // 5. Cleanup (after feature is stable)
412
- // Remove feature flag checks from code
413
- await featureFlags.delete(flagId);
544
+ // Remove feature flag checks from code, then either:
545
+ // - delete to remove it entirely, or
546
+ // - archive to hide it while keeping its history for audit
547
+ await featureFlags.archive(flagId);
548
+ // (archive is irreversible and the name stays reserved permanently)
414
549
  ```
415
550
 
416
551
  ### Error Handling
package/dist/cache.d.ts CHANGED
@@ -44,5 +44,12 @@ export declare class FeatureFlagCache {
44
44
  roles?: string[];
45
45
  groups?: string[];
46
46
  }): Promise<boolean>;
47
+ isActiveForContext({ name, user, roles, groups, subjects, }: {
48
+ name: string;
49
+ user?: string;
50
+ roles?: string[];
51
+ groups?: string[];
52
+ subjects?: string[];
53
+ }): Promise<boolean>;
47
54
  }
48
55
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACzC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AASD;;GAEG;AACH,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,KAAK,CAAC,CAAC,CAAC;IAC/C,OAAO,CAAC,KAAK,CAAoC;IAE3C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAgBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;OAEG;IACH,YAAY,IAAI,IAAI;IASpB;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,GAAG,CAAS;gBAER,EACV,KAAK,EACL,KAAK,EACL,GAAiB,GAClB,EAAE;QACD,KAAK,EAAE,gBAAgB,CAAC;QACxB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACtB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd;IAMD;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAyBlB,eAAe,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,GACP,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,GAAG,OAAO,CAAC,OAAO,CAAC;CAuBrB"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAG9C,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACzC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AASD;;GAEG;AACH,qBAAa,aAAa,CAAC,CAAC,CAAE,YAAW,KAAK,CAAC,CAAC,CAAC;IAC/C,OAAO,CAAC,KAAK,CAAoC;IAE3C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAgBxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;OAEG;IACH,YAAY,IAAI,IAAI;IASpB;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,GAAG,CAAS;gBAER,EACV,KAAK,EACL,KAAK,EACL,GAAiB,GAClB,EAAE;QACD,KAAK,EAAE,gBAAgB,CAAC;QACxB,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACtB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd;IAMD;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA4BlB,eAAe,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,GACP,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,GAAG,OAAO,CAAC,OAAO,CAAC;IASd,kBAAkB,CAAC,EACvB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAM,EACN,QAAQ,GACT,EAAE;QACD,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,GAAG,OAAO,CAAC,OAAO,CAAC;CA8BrB"}
package/dist/cache.js CHANGED
@@ -61,13 +61,14 @@ export class FeatureFlagCache {
61
61
  /**
62
62
  * Generates a cache key using murmur3 hash of the flag name and parameters.
63
63
  */
64
- generateCacheKey({ name, user, roles, groups, }) {
64
+ generateCacheKey({ name, user, roles, groups, subjects, }) {
65
65
  // Create a consistent string representation of the parameters
66
66
  const keyParts = [
67
67
  name,
68
68
  user || "",
69
69
  (roles || []).sort().join(","),
70
70
  (groups || []).sort().join(","),
71
+ (subjects || []).sort().join(","),
71
72
  ];
72
73
  const keyString = keyParts.join("|");
73
74
  // Generate murmur3 hash
@@ -75,19 +76,34 @@ export class FeatureFlagCache {
75
76
  return `flag:${hash}`;
76
77
  }
77
78
  async isActiveForUser({ name, user, roles, groups, }) {
79
+ return this.isActiveForContext({
80
+ name,
81
+ user,
82
+ roles,
83
+ groups,
84
+ });
85
+ }
86
+ async isActiveForContext({ name, user, roles, groups, subjects, }) {
78
87
  // Generate cache key
79
- const cacheKey = this.generateCacheKey({ name, user, roles, groups });
88
+ const cacheKey = this.generateCacheKey({
89
+ name,
90
+ user,
91
+ roles,
92
+ groups,
93
+ subjects,
94
+ });
80
95
  // Try to get from cache first
81
96
  const cachedResult = await this.cache.get(cacheKey);
82
97
  if (cachedResult !== undefined) {
83
98
  return cachedResult;
84
99
  }
85
100
  // Cache miss, get from model
86
- const result = await this.model.isActiveForUser({
101
+ const result = await this.model.isActiveForContext({
87
102
  name,
88
103
  user,
89
104
  roles,
90
105
  groups,
106
+ subjects,
91
107
  });
92
108
  // Store in cache with TTL
93
109
  this.cache.set(cacheKey, result, this.ttl);