diff --git a/migrations/feature-flags_index.sql b/migrations/feature-flags_index.sql new file mode 100644 index 00000000..16875201 --- /dev/null +++ b/migrations/feature-flags_index.sql @@ -0,0 +1,9 @@ +-- up +-- Supports the optional enabled filter used by GET /api/feature-flags. +-- Keeping this as a standalone migration makes the plan easy to verify with +-- EXPLAIN and lets operators roll it back independently. +CREATE INDEX IF NOT EXISTS feature_flags_enabled_idx + ON feature_flags (enabled); + +-- down +DROP INDEX IF EXISTS feature_flags_enabled_idx; diff --git a/src/routes/feature-flags.ts b/src/routes/feature-flags.ts index 9b3b1b0d..9a618927 100644 --- a/src/routes/feature-flags.ts +++ b/src/routes/feature-flags.ts @@ -39,6 +39,7 @@ featureFlagsRouter.use( const featureFlagsQuerySchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().positive().max(100).default(DEFAULT_PAGE_SIZE), + enabled: z.enum(["true", "false"]).transform((value) => value === "true").optional(), }); featureFlagsRouter.get("/", async (req, res, next) => { @@ -49,7 +50,7 @@ featureFlagsRouter.get("/", async (req, res, next) => { throw parsed.error; } - const { cursor, limit: rawLimit } = parsed.data; + const { cursor, limit: rawLimit, enabled } = parsed.data; const limit = clampLimit(rawLimit, DEFAULT_PAGE_SIZE); const flagsRecord = await abortableRace( @@ -58,11 +59,13 @@ featureFlagsRouter.get("/", async (req, res, next) => { ); // Convert the Record to a sorted array for pagination. - const flags = Object.entries(flagsRecord).map(([id, value]) => ({ + const flags = Object.entries(flagsRecord) + .filter(([, value]) => enabled === undefined || value.enabled === enabled) + .map(([id, value]) => ({ id, enabled: value.enabled, variant: (value.metadata?.variant as string | undefined) ?? null, - })); + })); const sorted = flags.sort((a, b) => b.id.localeCompare(a.id)); const page = paginate( diff --git a/tests/featureFlagsRoute.test.ts b/tests/featureFlagsRoute.test.ts index 7c1ca045..25659137 100644 --- a/tests/featureFlagsRoute.test.ts +++ b/tests/featureFlagsRoute.test.ts @@ -82,6 +82,19 @@ describe('GET /feature-flags', () => { expect(typeof res.body.next_cursor).toBe('string'); }); + it('filters flags by enabled state before pagination', async () => { + const res = await request(app) + .get('/feature-flags') + .query({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body.items).toEqual([ + { id: 'NEW_MARKET_FLOW', enabled: true, variant: 'v2' }, + ]); + expect(res.body.total).toBe(1); + expect(res.body.next_cursor).toBeNull(); + }); + it('should return next_cursor as null on the last page', async () => { const first = await request(app) .get('/feature-flags')