Skip to content

Commit e2193d3

Browse files
authored
Merge pull request #44 from flippercloud/fix/actors-vs-percentage-of-actors
Refactor Flipper gate precedence and semantics to match Ruby implementation
2 parents 53d5450 + c6b5756 commit e2193d3

File tree

13 files changed

+307
-38
lines changed

13 files changed

+307
-38
lines changed

packages/flipper/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @flippercloud/flipper
22

3+
## 1.0.1
4+
5+
### Fixed
6+
7+
- Ruby parity: gate precedence now matches Ruby (boolean → expression → actor → percentage of actors → percentage of time → group).
8+
- Ruby parity: expressions now evaluate against actor `flipperProperties` (instead of losing properties due to pre-wrapping).
9+
- Ruby parity: percentage-of-actors bucketing now matches Ruby semantics (CRC32 + scaling factor) and supports up to 3 decimal places.
10+
- `disableExpression()` now disables only the expression gate (no longer clears unrelated gate values).
11+
- Memory adapter: disabling numeric gates (percentage-of-actors / percentage-of-time) no longer clears other gate values.
12+
- Ruby parity: actor/group gates treat missing/invalid actor ids as closed (no more literal `'undefined'` string checks).
13+
314
## 1.0.0
415

516
Initial release of Flipper feature flags for TypeScript/JavaScript.

packages/flipper/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@flippercloud/flipper",
33
"description": "Flipper feature flags for TypeScript/JavaScript",
4-
"version": "1.0.0",
4+
"version": "1.0.1",
55
"author": "Jonathan Hoyt",
66
"license": "MIT",
77
"main": "./dist/cjs/index.js",

packages/flipper/src/ActorGate.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import ActorGate from './ActorGate'
2+
import ActorType from './ActorType'
3+
import FeatureCheckContext from './FeatureCheckContext'
4+
import GateValues from './GateValues'
25

36
const gate = new ActorGate()
47

@@ -8,4 +11,16 @@ describe('ActorGate', () => {
811
expect(gate.key).toEqual('actors')
912
expect(gate.dataType).toEqual('set')
1013
})
14+
15+
test('isOpen returns false when actor flipperId is missing', () => {
16+
const values = new GateValues({ actors: ['undefined'] })
17+
18+
// Note: flipperId is present as a key, but the value is invalid at runtime.
19+
// Ruby treats this like "no actor".
20+
const actorWithMissingId = { flipperId: undefined as unknown as string }
21+
const actorType = ActorType.wrap(actorWithMissingId as unknown)
22+
23+
const context = new FeatureCheckContext('feature-1', values, actorType)
24+
expect(gate.isOpen(context)).toEqual(false)
25+
})
1126
})

packages/flipper/src/ActorGate.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,20 @@ class ActorGate implements IGate {
6262
* @returns True if the actor is in the enabled set
6363
*/
6464
public isOpen(context: FeatureCheckContext): boolean {
65-
if (context.thing === 'undefined') {
65+
if (!this.protectsThing(context.thing)) {
6666
return false
67-
} else {
68-
if (this.protectsThing(context.thing)) {
69-
const enabledActors = context.actorsValue
70-
const actorType = context.thing as ActorType
71-
return enabledActors.has(String(actorType.value))
72-
} else {
73-
return false
74-
}
7567
}
68+
69+
const enabledActors = context.actorsValue
70+
const actorId = context.thing instanceof ActorType ? context.thing.value : (context.thing as any).flipperId
71+
72+
// Actor ids must be present and string-like (Ruby parity: a missing flipper_id
73+
// is treated as no actor).
74+
if (typeof actorId !== 'string' || actorId.length === 0) {
75+
return false
76+
}
77+
78+
return enabledActors.has(actorId)
7679
}
7780

7881
/**

packages/flipper/src/Dsl.test.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Dsl from './Dsl'
22
import { IActor } from './interfaces'
33
import MemoryAdapter from './MemoryAdapter'
4+
import { crc32 } from 'crc'
45
import { makeActor } from './testHelpers'
56

67
let dsl: Dsl
@@ -30,8 +31,10 @@ describe('Dsl', () => {
3031
test('enables feature for percentage of actors', async () => {
3132
await dsl.enablePercentageOfActors('feature-1', 50)
3233

33-
expect(await dsl.isFeatureEnabled('feature-1', makeActor(5))).toBe(true)
34-
expect(await dsl.isFeatureEnabled('feature-1', makeActor(8))).toBe(false)
34+
const { enabledActor, disabledActor } = findEnabledAndDisabledActors('feature-1', 50)
35+
36+
expect(await dsl.isFeatureEnabled('feature-1', enabledActor)).toBe(true)
37+
expect(await dsl.isFeatureEnabled('feature-1', disabledActor)).toBe(false)
3538
})
3639

3740
test('enables feature for percentage of time', async () => {
@@ -61,10 +64,13 @@ describe('Dsl', () => {
6164

6265
test('disables percentage of actors', async () => {
6366
await dsl.enablePercentageOfActors('feature-1', 50)
64-
expect(await dsl.isFeatureEnabled('feature-1', makeActor(5))).toBe(true)
67+
68+
const { enabledActor } = findEnabledAndDisabledActors('feature-1', 50)
69+
expect(await dsl.isFeatureEnabled('feature-1', enabledActor)).toBe(true)
6570

6671
await dsl.disablePercentageOfActors('feature-1')
67-
expect(await dsl.isFeatureEnabled('feature-1', makeActor(5))).toBe(false)
72+
73+
expect(await dsl.isFeatureEnabled('feature-1', enabledActor)).toBe(false)
6874
expect(await dsl.feature('feature-1').percentageOfActorsValue()).toBe(0)
6975
})
7076

@@ -185,3 +191,39 @@ describe('Dsl', () => {
185191
})
186192
})
187193
})
194+
195+
function rubyPercentageOfActorsOpen(featureName: string, actorId: string, percentage: number): boolean {
196+
const scalingFactor = 1000
197+
const id = `${featureName}${actorId}`
198+
const hash = crc32(id).valueOf()
199+
return hash % (100 * scalingFactor) < percentage * scalingFactor
200+
}
201+
202+
function findEnabledAndDisabledActors(
203+
featureName: string,
204+
percentage: number
205+
): { enabledActor: IActor; disabledActor: IActor } {
206+
let enabledActor: IActor | null = null
207+
let disabledActor: IActor | null = null
208+
209+
for (let i = 0; i < 100_000; i++) {
210+
const candidate = makeActor(i)
211+
const open = rubyPercentageOfActorsOpen(featureName, candidate.flipperId, percentage)
212+
213+
if (!enabledActor && open) {
214+
enabledActor = candidate
215+
}
216+
if (!disabledActor && !open) {
217+
disabledActor = candidate
218+
}
219+
if (enabledActor && disabledActor) {
220+
break
221+
}
222+
}
223+
224+
if (!enabledActor || !disabledActor) {
225+
throw new Error('Failed to find both enabled and disabled actors for percentage-of-actors test')
226+
}
227+
228+
return { enabledActor, disabledActor }
229+
}

packages/flipper/src/Feature.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Feature from './Feature'
22
import MemoryAdapter from './MemoryAdapter'
3+
import MemoryInstrumenter from './instrumenters/MemoryInstrumenter'
34
import { makeActor } from './testHelpers'
45

56
let adapter: MemoryAdapter
@@ -11,6 +12,49 @@ describe('Feature', () => {
1112
feature = new Feature('feature-1', adapter, {})
1213
})
1314

15+
describe('gate precedence (Ruby parity)', () => {
16+
test('percentage of time wins over group when both are enabled', async () => {
17+
const { default: Dsl } = await import('./Dsl.js')
18+
const dsl = new Dsl(adapter)
19+
dsl.register('admins', (_actor: unknown) => true)
20+
21+
const instrumenter = new MemoryInstrumenter()
22+
const featureWithGroups = new Feature('precedence-feature', adapter, dsl.groups, {
23+
instrumenter,
24+
})
25+
26+
const actor = makeActor(1)
27+
await featureWithGroups.enableGroup('admins')
28+
await featureWithGroups.enablePercentageOfTime(100)
29+
30+
instrumenter.reset()
31+
expect(await featureWithGroups.isEnabled(actor)).toEqual(true)
32+
const event = instrumenter.eventByName('feature_operation.flipper')
33+
expect(event?.payload.gate_name).toEqual('percentageOfTime')
34+
})
35+
36+
test('expression evaluates using actor properties', async () => {
37+
const { default: Flipper } = await import('./Flipper.js')
38+
39+
const instrumenter = new MemoryInstrumenter()
40+
const featureWithInstrumenter = new Feature('expression-actor-props', adapter, {}, {
41+
instrumenter,
42+
})
43+
44+
await featureWithInstrumenter.enableExpression(Flipper.property('admin'))
45+
46+
const actorWithProps = {
47+
flipperId: 'actor:1',
48+
flipperProperties: { admin: true },
49+
}
50+
51+
instrumenter.reset()
52+
expect(await featureWithInstrumenter.isEnabled(actorWithProps)).toEqual(true)
53+
const event = instrumenter.eventByName('feature_operation.flipper')
54+
expect(event?.payload.gate_name).toEqual('expression')
55+
})
56+
})
57+
1458
test('has name', () => {
1559
expect(feature.name).toEqual('feature-1')
1660
})
@@ -32,6 +76,33 @@ describe('Feature', () => {
3276
expect(await feature.isEnabled(actor)).toEqual(false)
3377
})
3478

79+
describe('disableExpression', () => {
80+
test('only clears the expression gate (does not clear other gate values)', async () => {
81+
const { default: Flipper } = await import('./Flipper.js')
82+
83+
const enabledActor = makeActor(1)
84+
const expressionActor = {
85+
flipperId: 'actor:2',
86+
flipperProperties: { admin: true },
87+
}
88+
89+
await feature.enableActor(enabledActor)
90+
await feature.enableExpression(Flipper.property('admin'))
91+
92+
// Expression is active pre-disable.
93+
expect(await feature.isEnabled(expressionActor)).toEqual(true)
94+
95+
await feature.disableExpression()
96+
97+
// Actor enablement should remain.
98+
expect(await feature.isEnabled(enabledActor)).toEqual(true)
99+
100+
// Expression should be removed.
101+
expect(await feature.isEnabled(expressionActor)).toEqual(false)
102+
expect(await feature.enabledGateNames()).toEqual(['actor'])
103+
})
104+
})
105+
35106
describe('disablePercentageOfActors', () => {
36107
test('sets percentage to 0', async () => {
37108
await feature.enablePercentageOfActors(25)
@@ -53,6 +124,18 @@ describe('Feature', () => {
53124
await feature.disablePercentageOfActors()
54125
expect(await feature.percentageOfActorsValue()).toEqual(0)
55126
})
127+
128+
test('does not clear other gate values', async () => {
129+
const actor = makeActor(5)
130+
131+
await feature.enableActor(actor)
132+
await feature.enablePercentageOfActors(50)
133+
134+
await feature.disablePercentageOfActors()
135+
136+
// Actor enablement should remain after disabling the percentage gate.
137+
expect(await feature.isEnabled(actor)).toEqual(true)
138+
})
56139
})
57140

58141
describe('disablePercentageOfTime', () => {
@@ -75,6 +158,18 @@ describe('Feature', () => {
75158
await feature.disablePercentageOfTime()
76159
expect(await feature.percentageOfTimeValue()).toEqual(0)
77160
})
161+
162+
test('does not clear other gate values', async () => {
163+
const actor = makeActor(5)
164+
165+
await feature.enableActor(actor)
166+
await feature.enablePercentageOfTime(50)
167+
168+
await feature.disablePercentageOfTime()
169+
170+
// Actor enablement should remain after disabling the percentage gate.
171+
expect(await feature.isEnabled(actor)).toEqual(true)
172+
})
78173
})
79174

80175
describe('state', () => {

packages/flipper/src/Feature.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ class Feature {
109109
new BooleanGate(),
110110
new ExpressionGate(),
111111
new ActorGate(),
112-
new GroupGate(this.groups),
113112
new PercentageOfActorsGate(),
114113
new PercentageOfTimeGate(),
114+
new GroupGate(this.groups),
115115
]
116116
}
117117

@@ -253,12 +253,18 @@ class Feature {
253253
* @returns True if successful
254254
*/
255255
async disableExpression(): Promise<boolean> {
256-
const gate = this.gate('expression')
256+
const gate = this.gates.find(g => g.name === 'expression')
257257
if (!gate) {
258258
throw new Error('Expression gate not found')
259259
}
260+
260261
await this.adapter.add(this)
261-
return this.adapter.clear(this)
262+
263+
// Adapter APIs require a "thing" value for disable(). For JSON gates (like
264+
// expression), adapters should ignore the specific value and simply remove
265+
// the stored gate key.
266+
const placeholder = ExpressionType.wrap({ Constant: false })
267+
return this.adapter.disable(this, gate, placeholder)
262268
}
263269

264270
/**
@@ -277,10 +283,17 @@ class Feature {
277283

278284
this.gates.some(gate => {
279285
let thingType: unknown = thing
280-
const actorGate = this.gate('actor')
281-
if (typeof thingType !== 'undefined' && actorGate) {
282-
thingType = actorGate.wrap(thing)
286+
287+
// Only wrap the thing for gates that require ActorType.
288+
// Wrapping everything breaks expression evaluation, which needs access
289+
// to actor `flipperProperties`.
290+
if (gate.name === 'actor' || gate.name === 'group') {
291+
const actorGate = this.gate('actor')
292+
if (actorGate && actorGate.protectsThing(thingType)) {
293+
thingType = actorGate.wrap(thingType)
294+
}
283295
}
296+
284297
const context = new FeatureCheckContext(this.name, values, thingType)
285298
const isOpen = gate.isOpen(context)
286299
if (isOpen) {
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
1+
import ActorType from './ActorType'
2+
import FeatureCheckContext from './FeatureCheckContext'
3+
import GateValues from './GateValues'
14
import GroupGate from './GroupGate'
2-
3-
const gate = new GroupGate({})
5+
import GroupType from './GroupType'
46

57
describe('GroupGate', () => {
8+
const gate = new GroupGate({})
9+
610
test('has name, key, and dataType', () => {
711
expect(gate.name).toBe('group')
812
expect(gate.key).toBe('groups')
913
expect(gate.dataType).toBe('set')
1014
})
15+
16+
test('isOpen returns false when actor flipperId is missing (even if group callback matches)', () => {
17+
const groups = {
18+
admins: new GroupType('admins', () => true),
19+
}
20+
21+
const groupGate = new GroupGate(groups)
22+
const values = new GateValues({ groups: ['admins'] })
23+
24+
// Note: flipperId is present as a key, but the value is invalid at runtime.
25+
// Ruby treats this like "no actor".
26+
const actorWithMissingId = { flipperId: undefined as unknown as string, role: 'admin' }
27+
const actorType = ActorType.wrap(actorWithMissingId as unknown)
28+
29+
const context = new FeatureCheckContext('feature-1', values, actorType)
30+
expect(groupGate.isOpen(context)).toEqual(false)
31+
})
1132
})

0 commit comments

Comments
 (0)