@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 +84 -3
- package/dist/cache.d.ts +7 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +19 -3
- package/dist/cli.js +735 -1
- package/dist/model.d.ts +72 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +184 -2
- package/migrations/1779210257698_add-feature-flag-subjects.js +93 -0
- package/package.json +2 -2
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. **
|
|
105
|
-
5. **
|
|
106
|
-
6. **
|
|
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
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -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;
|
|
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({
|
|
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.
|
|
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);
|