@brandtg/flapjack 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -347,6 +390,15 @@ flapjack get-by-name my_feature
347
390
  # Check if active for a user
348
391
  flapjack is-active my_feature --user user123 --roles admin
349
392
 
393
+ # Check if multiple flags are active for a user
394
+ flapjack are-active --names my_feature other_feature --user user123 --roles admin
395
+
396
+ # Check if active for context (with external subject IDs)
397
+ flapjack is-active-context my_feature --user user123 --subjects tenant:acme
398
+
399
+ # Check multiple flags for context
400
+ flapjack are-active-context --names my_feature other_feature --subjects tenant:acme
401
+
350
402
  # Update a flag
351
403
  flapjack update 1 --percent 50 --everyone false
352
404
 
@@ -356,6 +408,35 @@ flapjack update 1 --clear-roles --clear-percent
356
408
  # Delete a flag
357
409
  flapjack delete 1
358
410
 
411
+ # Add/remove/list subject mappings on a flag
412
+ flapjack add-subject 1 tenant:acme
413
+ flapjack remove-subject 1 tenant:acme
414
+ flapjack list-subjects 1
415
+ flapjack list-by-subject tenant:acme
416
+
417
+ # Feature flag groups
418
+ flapjack group-create --name checkout_rollout --note "Checkout launch cohort"
419
+ flapjack group-list
420
+ flapjack group-get 1
421
+ flapjack group-get-by-name checkout_rollout
422
+ flapjack group-update 1 --note "Updated"
423
+ flapjack group-delete 1
424
+
425
+ # Group membership
426
+ flapjack group-add-flag 1 10
427
+ flapjack group-remove-flag 1 10
428
+ flapjack group-list-flags 1
429
+ flapjack group-list-for-flag 10
430
+
431
+ # Bulk update all flags in a group
432
+ flapjack group-update-all 1 --percent 25 --roles admin
433
+
434
+ # Group-level subject mappings
435
+ flapjack group-add-subject 1 tenant:acme
436
+ flapjack group-remove-subject 1 tenant:acme
437
+ flapjack group-list-subjects 1
438
+ flapjack group-list-by-subject tenant:acme
439
+
359
440
  # Debug user bucketing
360
441
  flapjack hash-user user123
361
442
  ```
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);