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.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)', () => {
|
||||
|
||||
@ -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> = {};
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user