Skip to content

Commit 2c017a6

Browse files
committed
feat(sequelize-adapter): ensure snake_case gate keys for Ruby compatibility
Enable cross-language compatibility with Ruby Flipper by storing percentage gates (percentage_of_actors, percentage_of_time) in snake_case rather than camelCase. The adapter now reads and writes both formats to avoid data inconsistencies. Added helper methods to convert keys and tests to verify reading and migration of gate keys. - Store new gates in snake_case format - Read and clean up both camelCase and snake_case keys - Add tests for Ruby compatibility in gate key handling
1 parent 2730686 commit 2c017a6

2 files changed

Lines changed: 68 additions & 8 deletions

File tree

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)