feat(flags): add region, osVersion targeting to feature flags
- Add OsVersionRange interface + Zod schema (platform, minVersion?, maxVersion?) - Add regions[] and osVersions[] to FeatureFlagDoc, CreateFlagSchema, UpdateFlagSchema - Add compareVersions() helper for dot-separated semver comparison - Extend GET /flags/poll with ?region and ?osVersion query params - Region targeting: flag only returned if client region is in flag's regions list - OS version targeting: per-platform min/max version range filtering - Add 10 new tests (schema validation, compareVersions edge cases) - 634 tests passing, tsc clean
This commit is contained in:
parent
6f7299aa7a
commit
ca70a05e1d
@ -13,6 +13,8 @@ describe('CreateFlagSchema', () => {
|
|||||||
expect(result.data.enabled).toBe(true);
|
expect(result.data.enabled).toBe(true);
|
||||||
expect(result.data.percentage).toBe(100);
|
expect(result.data.percentage).toBe(100);
|
||||||
expect(result.data.platforms).toEqual([]);
|
expect(result.data.platforms).toEqual([]);
|
||||||
|
expect(result.data.regions).toEqual([]);
|
||||||
|
expect(result.data.osVersions).toEqual([]);
|
||||||
expect(result.data.segments).toEqual([]);
|
expect(result.data.segments).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -23,10 +25,43 @@ describe('CreateFlagSchema', () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
description: 'New dashboard UI',
|
description: 'New dashboard UI',
|
||||||
platforms: ['web', 'ios'],
|
platforms: ['web', 'ios'],
|
||||||
|
regions: ['us', 'eu'],
|
||||||
|
osVersions: [
|
||||||
|
{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' },
|
||||||
|
{ platform: 'android', minVersion: '14.0' },
|
||||||
|
],
|
||||||
segments: ['beta'],
|
segments: ['beta'],
|
||||||
percentage: 50,
|
percentage: 50,
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.regions).toEqual(['us', 'eu']);
|
||||||
|
expect(result.data.osVersions).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts osVersions with only minVersion', () => {
|
||||||
|
const result = CreateFlagSchema.safeParse({
|
||||||
|
key: 'ios_only',
|
||||||
|
osVersions: [{ platform: 'ios', minVersion: '16.0' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts osVersions with only maxVersion', () => {
|
||||||
|
const result = CreateFlagSchema.safeParse({
|
||||||
|
key: 'legacy_only',
|
||||||
|
osVersions: [{ platform: 'android', maxVersion: '13.0' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects osVersions with empty platform', () => {
|
||||||
|
const result = CreateFlagSchema.safeParse({
|
||||||
|
key: 'bad_os',
|
||||||
|
osVersions: [{ platform: '' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects key with spaces', () => {
|
it('rejects key with spaces', () => {
|
||||||
@ -70,6 +105,54 @@ describe('UpdateFlagSchema', () => {
|
|||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts regions update', () => {
|
||||||
|
const result = UpdateFlagSchema.safeParse({
|
||||||
|
regions: ['us', 'apac'],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts osVersions update', () => {
|
||||||
|
const result = UpdateFlagSchema.safeParse({
|
||||||
|
osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareVersions', () => {
|
||||||
|
let compareVersions: (a: string, b: string) => number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await import('./routes.js');
|
||||||
|
compareVersions = mod.compareVersions;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equal versions return 0', () => {
|
||||||
|
expect(compareVersions('17.2.1', '17.2.1')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a < b returns -1', () => {
|
||||||
|
expect(compareVersions('16.4', '17.0')).toBe(-1);
|
||||||
|
expect(compareVersions('17.0.0', '17.0.1')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a > b returns 1', () => {
|
||||||
|
expect(compareVersions('18.0', '17.2.1')).toBe(1);
|
||||||
|
expect(compareVersions('17.1', '17.0.9')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles different segment lengths', () => {
|
||||||
|
expect(compareVersions('17.0', '17.0.0')).toBe(0);
|
||||||
|
expect(compareVersions('17', '17.0.0')).toBe(0);
|
||||||
|
expect(compareVersions('17.0.1', '17')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single-segment versions', () => {
|
||||||
|
expect(compareVersions('14', '15')).toBe(-1);
|
||||||
|
expect(compareVersions('15', '14')).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hashUserFlag (deterministic A/B assignment)', () => {
|
describe('hashUserFlag (deterministic A/B assignment)', () => {
|
||||||
|
|||||||
@ -30,7 +30,24 @@ function hashUserFlag(userId: string, flagKey: string): number {
|
|||||||
return (hash >>> 0) % 100; // 0-99 bucket
|
return (hash >>> 0) % 100; // 0-99 bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
export { hashUserFlag };
|
/**
|
||||||
|
* Compare two dot-separated version strings (e.g. "17.2.1" vs "18.0").
|
||||||
|
* Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||||
|
*/
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = a.split('.').map(Number);
|
||||||
|
const pb = b.split('.').map(Number);
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const na = pa[i] ?? 0;
|
||||||
|
const nb = pb[i] ?? 0;
|
||||||
|
if (na < nb) return -1;
|
||||||
|
if (na > nb) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashUserFlag, compareVersions };
|
||||||
|
|
||||||
export async function flagRoutes(app: FastifyInstance) {
|
export async function flagRoutes(app: FastifyInstance) {
|
||||||
// List all flags
|
// List all flags
|
||||||
@ -40,15 +57,36 @@ export async function flagRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Polling endpoint for clients
|
// Polling endpoint for clients
|
||||||
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
|
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
|
||||||
// ?platform=xxx — filter flags by platform
|
// ?platform=xxx — filter flags by platform (e.g. ios, android, web, desktop)
|
||||||
|
// ?region=xxx — filter flags by region (e.g. us, eu, apac)
|
||||||
|
// ?osVersion=xxx — filter flags by OS version (e.g. 17.2.1)
|
||||||
app.get('/flags/poll', async req => {
|
app.get('/flags/poll', async req => {
|
||||||
const productId = getRequestProductId(req);
|
const productId = getRequestProductId(req);
|
||||||
const { platform, userId } = req.query as { platform?: string; userId?: string };
|
const { platform, userId, region, osVersion } = req.query as {
|
||||||
|
platform?: string;
|
||||||
|
userId?: string;
|
||||||
|
region?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
};
|
||||||
const all = await repo.list(productId);
|
const all = await repo.list(productId);
|
||||||
const active = all.filter(f => {
|
const active = all.filter(f => {
|
||||||
if (!f.enabled) return false;
|
if (!f.enabled) return false;
|
||||||
|
// Platform targeting: flag specifies which platforms it applies to
|
||||||
if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false;
|
if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false;
|
||||||
|
// Region targeting: flag specifies which regions it applies to
|
||||||
|
if (f.regions?.length > 0 && region && !f.regions.includes(region)) return false;
|
||||||
|
// OS version targeting: flag specifies version ranges per platform
|
||||||
|
if (f.osVersions?.length > 0 && platform && osVersion) {
|
||||||
|
const rule = f.osVersions.find(r => r.platform === platform);
|
||||||
|
if (rule) {
|
||||||
|
if (rule.minVersion && compareVersions(osVersion, rule.minVersion) < 0) return false;
|
||||||
|
if (rule.maxVersion && compareVersions(osVersion, rule.maxVersion) > 0) return false;
|
||||||
|
} else {
|
||||||
|
// osVersions defined but no rule for this platform — skip this flag
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const flags: Record<string, boolean> = {};
|
const flags: Record<string, boolean> = {};
|
||||||
|
|||||||
@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export interface OsVersionRange {
|
||||||
|
platform: string;
|
||||||
|
minVersion?: string;
|
||||||
|
maxVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeatureFlagDoc {
|
export interface FeatureFlagDoc {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
@ -12,12 +18,20 @@ export interface FeatureFlagDoc {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
|
regions: string[];
|
||||||
|
osVersions: OsVersionRange[];
|
||||||
segments: string[];
|
segments: string[];
|
||||||
percentage: number;
|
percentage: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const OsVersionRangeSchema = z.object({
|
||||||
|
platform: z.string().min(1),
|
||||||
|
minVersion: z.string().optional(),
|
||||||
|
maxVersion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CreateFlagSchema = z.object({
|
export const CreateFlagSchema = z.object({
|
||||||
key: z
|
key: z
|
||||||
.string()
|
.string()
|
||||||
@ -26,6 +40,8 @@ export const CreateFlagSchema = z.object({
|
|||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
platforms: z.array(z.string()).default([]),
|
platforms: z.array(z.string()).default([]),
|
||||||
|
regions: z.array(z.string()).default([]),
|
||||||
|
osVersions: z.array(OsVersionRangeSchema).default([]),
|
||||||
segments: z.array(z.string()).default([]),
|
segments: z.array(z.string()).default([]),
|
||||||
percentage: z.number().min(0).max(100).default(100),
|
percentage: z.number().min(0).max(100).default(100),
|
||||||
});
|
});
|
||||||
@ -34,6 +50,8 @@ export const UpdateFlagSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
platforms: z.array(z.string()).optional(),
|
platforms: z.array(z.string()).optional(),
|
||||||
|
regions: z.array(z.string()).optional(),
|
||||||
|
osVersions: z.array(OsVersionRangeSchema).optional(),
|
||||||
segments: z.array(z.string()).optional(),
|
segments: z.array(z.string()).optional(),
|
||||||
percentage: z.number().min(0).max(100).optional(),
|
percentage: z.number().min(0).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user