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:
saravanakumardb1 2026-02-17 20:53:48 -08:00
parent 6f7299aa7a
commit ca70a05e1d
3 changed files with 143 additions and 4 deletions

View File

@ -13,6 +13,8 @@ describe('CreateFlagSchema', () => {
expect(result.data.enabled).toBe(true);
expect(result.data.percentage).toBe(100);
expect(result.data.platforms).toEqual([]);
expect(result.data.regions).toEqual([]);
expect(result.data.osVersions).toEqual([]);
expect(result.data.segments).toEqual([]);
}
});
@ -23,10 +25,43 @@ describe('CreateFlagSchema', () => {
enabled: false,
description: 'New dashboard UI',
platforms: ['web', 'ios'],
regions: ['us', 'eu'],
osVersions: [
{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' },
{ platform: 'android', minVersion: '14.0' },
],
segments: ['beta'],
percentage: 50,
});
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', () => {
@ -70,6 +105,54 @@ describe('UpdateFlagSchema', () => {
});
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)', () => {

View File

@ -30,7 +30,24 @@ function hashUserFlag(userId: string, flagKey: string): number {
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) {
// List all flags
@ -40,15 +57,36 @@ export async function flagRoutes(app: FastifyInstance) {
});
// Polling endpoint for clients
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
// ?platform=xxx — filter flags by platform
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
// ?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 => {
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 active = all.filter(f => {
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;
// 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;
});
const flags: Record<string, boolean> = {};

View File

@ -5,6 +5,12 @@
import { z } from 'zod';
export interface OsVersionRange {
platform: string;
minVersion?: string;
maxVersion?: string;
}
export interface FeatureFlagDoc {
id: string;
productId: string;
@ -12,12 +18,20 @@ export interface FeatureFlagDoc {
enabled: boolean;
description: string;
platforms: string[];
regions: string[];
osVersions: OsVersionRange[];
segments: string[];
percentage: number;
createdAt: 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({
key: z
.string()
@ -26,6 +40,8 @@ export const CreateFlagSchema = z.object({
enabled: z.boolean().default(true),
description: 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([]),
percentage: z.number().min(0).max(100).default(100),
});
@ -34,6 +50,8 @@ export const UpdateFlagSchema = z.object({
enabled: z.boolean().optional(),
description: 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(),
percentage: z.number().min(0).max(100).optional(),
});