Skip to content

Commit 53d5450

Browse files
authored
Merge pull request #43 from flippercloud/fix/gate-keys
Ensure snake_case gate keys for Ruby compatibility
2 parents 2730686 + cbdc9f3 commit 53d5450

4 files changed

Lines changed: 76 additions & 9 deletions

File tree

packages/flipper-sequelize/CHANGELOG.md

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

3+
## 1.0.1 (2025-12-31)
4+
5+
### Fixes
6+
7+
- Normalize percentage gate storage to snake_case and read both snake_case/camelCase, keeping rollouts set by Ruby and TypeScript in sync and visible in the UI.
8+
- Added regression coverage to ensure Sequelize reads existing snake_case gates and writes the canonical form going forward.
9+
310
## 1.0.0
411

512
Initial release of Sequelize adapter for Flipper feature flags.

packages/flipper-sequelize/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-sequelize",
33
"description": "Sequelize adapter for Flipper feature flags",
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-sequelize/src/SequelizeAdapter.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ class SequelizeAdapter implements IAdapter {
374374
try {
375375
await this.Gate.create({
376376
featureKey: feature.key,
377-
key: gate.key,
377+
key: this.storageKey(gate),
378378
value: String(thing.value),
379379
})
380380
} catch (error) {
@@ -412,7 +412,7 @@ class SequelizeAdapter implements IAdapter {
412412
try {
413413
await this.Gate.create({
414414
featureKey: feature.key,
415-
key: gate.key,
415+
key: this.storageKey(gate),
416416
value,
417417
})
418418
} catch (error) {
@@ -434,7 +434,7 @@ class SequelizeAdapter implements IAdapter {
434434
await this.Gate.destroy({
435435
where: {
436436
featureKey: feature.key,
437-
key: gate.key,
437+
key: this.storageKeys(gate),
438438
},
439439
})
440440
}
@@ -446,7 +446,7 @@ class SequelizeAdapter implements IAdapter {
446446
await this.Gate.destroy({
447447
where: {
448448
featureKey: feature.key,
449-
key: gate.key,
449+
key: this.storageKeys(gate),
450450
value,
451451
},
452452
})
@@ -465,7 +465,7 @@ class SequelizeAdapter implements IAdapter {
465465

466466
for (const gate of feature.gates) {
467467
const gateKey = gate.key
468-
const gateRows = validRows.filter(row => row.key === gateKey)
468+
const gateRows = validRows.filter(row => this.storageKeys(gate).includes(row.key ?? ''))
469469

470470
if (gateRows.length === 0) {
471471
continue
@@ -541,6 +541,32 @@ class SequelizeAdapter implements IAdapter {
541541
return value
542542
}
543543
}
544+
545+
/**
546+
* Return the primary storage key for a gate, using snake_case for cross-language compatibility.
547+
* Ruby Flipper stores percentage gates as `percentage_of_actors`/`percentage_of_time`, so we
548+
* mirror that format while still accepting camelCase keys that may already exist in the DB.
549+
*/
550+
private storageKey(gate: IGate): string {
551+
return this.toSnakeCase(gate.key)
552+
}
553+
554+
/**
555+
* Return all accepted storage keys for a gate (camelCase + snake_case) so we can read and clean
556+
* up data regardless of which client wrote it.
557+
*/
558+
private storageKeys(gate: IGate): string[] {
559+
const keys = [gate.key]
560+
const snake = this.toSnakeCase(gate.key)
561+
if (!keys.includes(snake)) {
562+
keys.push(snake)
563+
}
564+
return keys
565+
}
566+
567+
private toSnakeCase(key: string): string {
568+
return key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
569+
}
544570
}
545571

546572
export default SequelizeAdapter

packages/flipper-sequelize/src/index.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,37 @@ describe('SequelizeAdapter', () => {
253253
})
254254
})
255255

256+
describe('Ruby compatibility for percentage gate keys', () => {
257+
it('reads percentage gates stored in snake_case', async () => {
258+
const featureKey = 'ruby-feature'
259+
await FeatureModel.create({ key: featureKey })
260+
await GateModel.create({ featureKey, key: 'percentage_of_actors', value: '10' })
261+
await GateModel.create({ featureKey, key: 'percentage_of_time', value: '15' })
262+
263+
const feature = new Feature(featureKey, adapter, {})
264+
const state = await adapter.get(feature)
265+
266+
expect(state.percentageOfActors).toBe(10)
267+
expect(state.percentageOfTime).toBe(15)
268+
})
269+
270+
it('writes percentage gates using snake_case for storage', async () => {
271+
await flipper.enablePercentageOfActors('ts-feature', 20)
272+
await flipper.enablePercentageOfTime('ts-feature', 30)
273+
274+
const gates = await GateModel.findAll({
275+
where: { featureKey: 'ts-feature' },
276+
order: [['key', 'ASC']],
277+
})
278+
279+
const keys = gates.map((g: { key: string }) => g.key).sort()
280+
expect(keys).toEqual(expect.arrayContaining(['percentage_of_actors', 'percentage_of_time']))
281+
// Ensure we did not leave camelCase duplicates
282+
expect(keys).not.toContain('percentageOfActors')
283+
expect(keys).not.toContain('percentageOfTime')
284+
})
285+
})
286+
256287
describe('export', () => {
257288
it('exports features as JSON', async () => {
258289
await flipper.enable('test-feature')
@@ -335,11 +366,14 @@ describe('SequelizeAdapter', () => {
335366

336367
// Assertions
337368
expect(spyFindAll).toHaveBeenCalled()
338-
const findAllArgs = spyFindAll.mock.calls[0]?.[0]
369+
const findAllArgs = spyFindAll.mock.calls[0]?.[0] as { useMaster?: boolean } | undefined
339370
expect(findAllArgs).toMatchObject({ useMaster: true })
340371

341372
// Ensure at least one findOne received useMaster true
342-
const findOneCallWithUseMaster = spyFindOne.mock.calls.find(call => call[0]?.useMaster === true)
373+
const findOneCallWithUseMaster = spyFindOne.mock.calls.find(call => {
374+
const args = call[0] as { useMaster?: boolean } | undefined
375+
return args?.useMaster === true
376+
})
343377
expect(findOneCallWithUseMaster).toBeTruthy()
344378

345379
// Cleanup spies
@@ -350,7 +384,7 @@ describe('SequelizeAdapter', () => {
350384
it('defaults to replica (no useMaster) when option not set', async () => {
351385
const spyFindAll = jest.spyOn(FeatureModel, 'findAll')
352386
await adapter.features()
353-
const findAllArgs = spyFindAll.mock.calls[0]?.[0]
387+
const findAllArgs = spyFindAll.mock.calls[0]?.[0] as { useMaster?: boolean } | undefined
354388
expect(findAllArgs?.useMaster).toBe(false)
355389
spyFindAll.mockRestore()
356390
})

0 commit comments

Comments
 (0)