Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/feature-flags/evaluator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Evaluator } from './evaluator';
import { InMemoryStore } from './in-memory-store';
import { FlagPollEntry } from './interfaces';

describe('Evaluator', () => {
let store: InMemoryStore;
let evaluator: Evaluator;

const enabledFlag: FlagPollEntry = {
slug: 'enabled-flag',
enabled: true,
default_value: true,
targets: { users: [], organizations: [] },
};

const disabledFlag: FlagPollEntry = {
slug: 'disabled-flag',
enabled: false,
default_value: true,
targets: { users: [], organizations: [] },
};

const targetedFlag: FlagPollEntry = {
slug: 'targeted-flag',
enabled: true,
default_value: false,
targets: {
organizations: [{ id: 'org_123', enabled: true }],
users: [
{ id: 'user_456', enabled: true },
{ id: 'user_blocked', enabled: false },
],
},
};

beforeEach(() => {
store = new InMemoryStore();
evaluator = new Evaluator(store);
store.swap({
'enabled-flag': enabledFlag,
'disabled-flag': disabledFlag,
'targeted-flag': targetedFlag,
});
});

describe('isEnabled', () => {
it('returns defaultValue when flag is not found', () => {
expect(evaluator.isEnabled('unknown')).toBe(false);
expect(evaluator.isEnabled('unknown', {}, true)).toBe(true);
});

it('returns false when flag is disabled (enabled=false)', () => {
expect(evaluator.isEnabled('disabled-flag')).toBe(false);
});

it('returns target.enabled for matching organization', () => {
expect(
evaluator.isEnabled('targeted-flag', { organizationId: 'org_123' }),
).toBe(true);
});

it('returns target.enabled for matching user', () => {
expect(evaluator.isEnabled('targeted-flag', { userId: 'user_456' })).toBe(
true,
);
});

it('returns false for user target with enabled=false', () => {
expect(
evaluator.isEnabled('targeted-flag', { userId: 'user_blocked' }),
).toBe(false);
});

it('prioritizes user target over organization target', () => {
expect(
evaluator.isEnabled('targeted-flag', {
userId: 'user_blocked',
organizationId: 'org_123',
}),
).toBe(false);
});

it('falls back to organization target when user target does not match', () => {
expect(
evaluator.isEnabled('targeted-flag', {
userId: 'user_other',
organizationId: 'org_123',
}),
).toBe(true);
});

it('returns default_value when no target matches', () => {
expect(
evaluator.isEnabled('targeted-flag', { userId: 'user_other' }),
).toBe(false);

expect(
evaluator.isEnabled('enabled-flag', { userId: 'user_other' }),
).toBe(true);
});
});

describe('getAllFlags', () => {
it('evaluates all flags for the given context', () => {
const result = evaluator.getAllFlags({ userId: 'user_456' });

expect(result).toEqual({
'enabled-flag': true,
'disabled-flag': false,
'targeted-flag': true,
});
});

it('works with empty context', () => {
const result = evaluator.getAllFlags();

expect(result).toEqual({
'enabled-flag': true,
'disabled-flag': false,
'targeted-flag': false,
});
});
});
});
53 changes: 53 additions & 0 deletions src/feature-flags/evaluator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { InMemoryStore } from './in-memory-store';
import { EvaluationContext } from './interfaces';

export class Evaluator {
constructor(private readonly store: InMemoryStore) {}

isEnabled(
flagKey: string,
context: EvaluationContext = {},
defaultValue: boolean = false,
): boolean {
const entry = this.store.get(flagKey);

if (!entry) {
return defaultValue;
}

if (!entry.enabled) {
return false;
}

if (context.userId) {
const userTarget = entry.targets.users.find(
(t) => t.id === context.userId,
);
if (userTarget) {
return userTarget.enabled;
}
}

if (context.organizationId) {
const orgTarget = entry.targets.organizations.find(
(t) => t.id === context.organizationId,
);
if (orgTarget) {
return orgTarget.enabled;
}
}

return entry.default_value;
}

getAllFlags(context: EvaluationContext = {}): Record<string, boolean> {
const flags = this.store.getAll();
const result: Record<string, boolean> = {};

for (const slug of Object.keys(flags)) {
result[slug] = this.isEnabled(slug, context);
}

return result;
}
}
8 changes: 8 additions & 0 deletions src/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
FeatureFlagResponse,
ListFeatureFlagsOptions,
RemoveFlagTargetOptions,
RuntimeClientOptions,
} from './interfaces';
import { deserializeFeatureFlag } from './serializers';
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
import { FeatureFlagsRuntimeClient } from './runtime-client';

export class FeatureFlags {
constructor(private readonly workos: WorkOS) {}
Expand Down Expand Up @@ -69,4 +71,10 @@ export class FeatureFlags {
const { slug, targetId } = options;
await this.workos.delete(`/feature-flags/${slug}/targets/${targetId}`);
}

createRuntimeClient(
options?: RuntimeClientOptions,
): FeatureFlagsRuntimeClient {
return new FeatureFlagsRuntimeClient(this.workos, options);
}
}
69 changes: 69 additions & 0 deletions src/feature-flags/in-memory-store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { InMemoryStore } from './in-memory-store';
import { FlagPollEntry } from './interfaces';

describe('InMemoryStore', () => {
let store: InMemoryStore;

const flagA: FlagPollEntry = {
slug: 'flag-a',
enabled: true,
default_value: true,
targets: { users: [], organizations: [] },
};

const flagB: FlagPollEntry = {
slug: 'flag-b',
enabled: false,
default_value: false,
targets: {
users: [{ id: 'user_123', enabled: true }],
organizations: [],
},
};

beforeEach(() => {
store = new InMemoryStore();
});

describe('swap', () => {
it('replaces all flags', () => {
store.swap({ 'flag-a': flagA });
expect(store.size).toBe(1);

store.swap({ 'flag-b': flagB });
expect(store.size).toBe(1);
expect(store.get('flag-a')).toBeUndefined();
expect(store.get('flag-b')).toEqual(flagB);
});
});

describe('get', () => {
it('returns the entry for a known slug', () => {
store.swap({ 'flag-a': flagA });
expect(store.get('flag-a')).toEqual(flagA);
});

it('returns undefined for an unknown slug', () => {
expect(store.get('unknown')).toBeUndefined();
});
});

describe('getAll', () => {
it('returns the full map', () => {
const flags = { 'flag-a': flagA, 'flag-b': flagB };
store.swap(flags);
expect(store.getAll()).toEqual(flags);
});
});

describe('size', () => {
it('starts at 0', () => {
expect(store.size).toBe(0);
});

it('tracks the number of flags', () => {
store.swap({ 'flag-a': flagA, 'flag-b': flagB });
expect(store.size).toBe(2);
});
});
});
21 changes: 21 additions & 0 deletions src/feature-flags/in-memory-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { FlagPollEntry, FlagPollResponse } from './interfaces';

export class InMemoryStore {
private flags: FlagPollResponse = {};

swap(newFlags: FlagPollResponse): void {
this.flags = { ...newFlags };
}

get(slug: string): FlagPollEntry | undefined {
return this.flags[slug];
}

getAll(): FlagPollResponse {
return { ...this.flags };
}

get size(): number {
return Object.keys(this.flags).length;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface EvaluationContext {
userId?: string;
organizationId?: string;
}
16 changes: 16 additions & 0 deletions src/feature-flags/interfaces/flag-poll-response.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface FlagTarget {
id: string;
enabled: boolean;
}

export interface FlagPollEntry {
slug: string;
enabled: boolean;
default_value: boolean;
targets: {
users: FlagTarget[];
organizations: FlagTarget[];
};
}

export type FlagPollResponse = Record<string, FlagPollEntry>;
4 changes: 4 additions & 0 deletions src/feature-flags/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export * from './add-flag-target-options.interface';
export * from './evaluation-context.interface';
export * from './feature-flag.interface';
export * from './flag-poll-response.interface';
export * from './list-feature-flags-options.interface';
export * from './remove-flag-target-options.interface';
export * from './runtime-client-options.interface';
export * from './runtime-client-stats.interface';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FlagPollEntry } from './flag-poll-response.interface';

export interface RuntimeClientLogger {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
error(...args: unknown[]): void;
}

export interface RuntimeClientOptions {
pollingIntervalMs?: number;
bootstrapFlags?: Record<string, FlagPollEntry>;
requestTimeoutMs?: number;
logger?: RuntimeClientLogger;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface RuntimeClientStats {
pollCount: number;
pollErrorCount: number;
lastPollAt: Date | null;
lastSuccessfulPollAt: Date | null;
cacheAge: number | null;
flagCount: number;
}
Loading
Loading