Skip to content

Commit edbd41e

Browse files
authored
Merge pull request #36 from flippercloud/sequelize-use-primary
Add configurable useMaster option to Sequelize adapter
2 parents 4ad18d8 + bbc2e6c commit edbd41e

4 files changed

Lines changed: 78 additions & 2 deletions

File tree

docs/adapters/sequelize.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ await flipper.enable('search')
9898
const enabled = await flipper.isFeatureEnabled('search')
9999
```
100100

101+
### Primary Reads (Replication Consistency)
102+
103+
If your Sequelize setup uses read replicas, there can be a lag between writes (e.g. calling `enable`) and subsequent reads on a replica. To guarantee read-after-write consistency for Flipper operations, construct the adapter with `useMaster: true` which passes `useMaster` to all Sequelize `find` queries, ensuring they target the primary connection.
104+
105+
```typescript
106+
const adapter = new SequelizeAdapter({
107+
Feature: models.Feature,
108+
Gate: models.Gate,
109+
useMaster: true, // force reads to primary DB
110+
})
111+
```
112+
113+
Default is `false`, allowing replicas to serve reads. Enable this only where strict consistency is required (e.g. synchronous feature toggling during a request lifecycle).
114+
101115
## API surface
102116

103117
The adapter implements the full Flipper adapter contract:

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": "0.0.5",
4+
"version": "0.0.6",
55
"author": "Jonathan Hoyt",
66
"license": "MIT",
77
"main": "./dist/cjs/index.js",

packages/flipper-sequelize/src/SequelizeAdapter.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export interface SequelizeAdapterOptions {
2020
* Whether the adapter is read-only (default: false)
2121
*/
2222
readOnly?: boolean
23+
/**
24+
* Force all read queries to use the primary database connection in a
25+
* replicated setup (default: false). This passes `useMaster: true` to
26+
* Sequelize find calls so they bypass replicas. Helpful when you have
27+
* just written feature changes and need read-after-write consistency.
28+
*/
29+
useMaster?: boolean
2330
}
2431

2532
/**
@@ -62,18 +69,24 @@ class SequelizeAdapter implements IAdapter {
6269
*/
6370
private _readOnly: boolean
6471

72+
/**
73+
* Whether read queries should be forced to the primary (master) connection.
74+
*/
75+
private _useMaster: boolean
76+
6577
constructor(options: SequelizeAdapterOptions) {
6678
this.Feature = options.Feature
6779
this.Gate = options.Gate
6880
this._readOnly = options.readOnly ?? false
81+
this._useMaster = options.useMaster ?? false
6982
}
7083

7184
/**
7285
* Get all features.
7386
* @returns Array of all Feature instances
7487
*/
7588
async features(): Promise<FeatureClass[]> {
76-
const features = await this.Feature.findAll({ raw: true })
89+
const features = await this.Feature.findAll({ raw: true, useMaster: this._useMaster })
7790
// We need to import Feature dynamically to avoid circular dependencies
7891
const module = await import('@flippercloud/flipper')
7992
const Feature = module.Feature
@@ -132,6 +145,7 @@ class SequelizeAdapter implements IAdapter {
132145
const dbFeature = await this.Feature.findOne({
133146
where: { key: feature.key },
134147
raw: true,
148+
useMaster: this._useMaster,
135149
})
136150

137151
if (!dbFeature) {
@@ -142,6 +156,7 @@ class SequelizeAdapter implements IAdapter {
142156
where: { featureKey: feature.key },
143157
attributes: ['key', 'value'],
144158
raw: true,
159+
useMaster: this._useMaster,
145160
})
146161

147162
return this.resultForGates(feature, gates as Array<{ key: string | null; value: string | null }>)
@@ -186,12 +201,14 @@ class SequelizeAdapter implements IAdapter {
186201
// Ensure feature exists
187202
let dbFeature = await this.Feature.findOne({
188203
where: { key: feature.key },
204+
useMaster: this._useMaster,
189205
})
190206

191207
if (!dbFeature) {
192208
await this.add(feature)
193209
dbFeature = await this.Feature.findOne({
194210
where: { key: feature.key },
211+
useMaster: this._useMaster,
195212
})
196213
}
197214

@@ -234,6 +251,7 @@ class SequelizeAdapter implements IAdapter {
234251

235252
const dbFeature = await this.Feature.findOne({
236253
where: { key: feature.key },
254+
useMaster: this._useMaster,
237255
})
238256

239257
if (!dbFeature) {
@@ -276,6 +294,7 @@ class SequelizeAdapter implements IAdapter {
276294

277295
const dbFeature = await this.Feature.findOne({
278296
where: { key: feature.key },
297+
useMaster: this._useMaster,
279298
})
280299

281300
if (!dbFeature) {

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,49 @@ describe('SequelizeAdapter', () => {
313313
})
314314
})
315315

316+
describe('useMaster option', () => {
317+
it('passes useMaster to read queries when true', async () => {
318+
// Spy on findAll and findOne
319+
const spyFindAll = jest.spyOn(FeatureModel, 'findAll')
320+
const spyFindOne = jest.spyOn(FeatureModel, 'findOne')
321+
322+
const masterAdapter = new SequelizeAdapter({
323+
Feature: FeatureModel,
324+
Gate: GateModel,
325+
useMaster: true,
326+
})
327+
328+
// Trigger reads
329+
await masterAdapter.features()
330+
331+
// Add then get to trigger findOne path with useMaster
332+
const feature = new Feature('master-read-feature', masterAdapter, {})
333+
await masterAdapter.add(feature)
334+
await masterAdapter.get(feature)
335+
336+
// Assertions
337+
expect(spyFindAll).toHaveBeenCalled()
338+
const findAllArgs = spyFindAll.mock.calls[0]?.[0]
339+
expect(findAllArgs).toMatchObject({ useMaster: true })
340+
341+
// Ensure at least one findOne received useMaster true
342+
const findOneCallWithUseMaster = spyFindOne.mock.calls.find(call => call[0]?.useMaster === true)
343+
expect(findOneCallWithUseMaster).toBeTruthy()
344+
345+
// Cleanup spies
346+
spyFindAll.mockRestore()
347+
spyFindOne.mockRestore()
348+
})
349+
350+
it('defaults to replica (no useMaster) when option not set', async () => {
351+
const spyFindAll = jest.spyOn(FeatureModel, 'findAll')
352+
await adapter.features()
353+
const findAllArgs = spyFindAll.mock.calls[0]?.[0]
354+
expect(findAllArgs?.useMaster).toBe(false)
355+
spyFindAll.mockRestore()
356+
})
357+
})
358+
316359
describe('isFeatureEnabled checks', () => {
317360
it('enabled feature returns true', async () => {
318361
await flipper.enable('enabled-feature')

0 commit comments

Comments
 (0)