Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,30 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({
});
```

## Feature flag runtime

The runtime client polls the v2 flag payload and evaluates flags locally. When a
flag is off it serves `off_value`. Otherwise rules are evaluated in array order:
the first matching rule's `value` wins, and `default_value` applies when no rule
matches. A matching rule can serve `false`. Every condition in a rule must match;
`one_of` matches an exact ID for its target type. Unknown rule kinds and condition
operators never match.

`isEnabled(key, context, defaultValue)` returns `defaultValue` (`false` when
omitted) if the flag is missing or the served value is not a boolean.
`getAllFlags` uses `false` in that case.

`getFlag(key)` returns the flag's `FlagPollEntryV2`: `slug`, `enabled`,
`default_value`, `off_value` and `rules`. The `previous` and `current` values in
`change` events have the same shape. Reordering rules emits a `change` event;
reordering IDs within a condition does not.

To start from saved configuration, pass a `FlagPollResponseV2`
(`{ version: 2, flags: { [slug]: FlagPollEntryV2 } }`) as `bootstrapFlags`.
Unrecognized bootstrap data, including a bare map of entries without the
envelope, is ignored and readiness still resolves; polling populates the cache
afterward. Failed polls keep the last good snapshot.

## SDK Versioning

For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades.
Expand Down
5 changes: 4 additions & 1 deletion src/feature-flags/evaluator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Evaluator } from './evaluator';
import { InMemoryStore } from './in-memory-store';
import { toV2 } from './payload';
import {
EvaluationContext,
FlagPollEntry,
Expand Down Expand Up @@ -64,12 +65,14 @@ describe('Evaluator', () => {
error: jest.fn(),
};
evaluator = new Evaluator(store, logger);
store.swap({
const payload = toV2({
'enabled-flag': enabledFlag,
'disabled-flag': disabledFlag,
'targeted-flag': targetedFlag,
'default-on-flag': defaultOnFlag,
});
if (!payload) throw new Error('Invalid legacy test payload');
store.swap(payload.flags);
});

describe('isEnabled', () => {
Expand Down
77 changes: 36 additions & 41 deletions src/feature-flags/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { InMemoryStore } from './in-memory-store';
import {
EvaluationContext,
EvaluationResource,
FlagPollEntry,
FlagPollEntryV2,
RuntimeClientLogger,
} from './interfaces';

Expand Down Expand Up @@ -44,17 +44,16 @@ export class Evaluator {
// per flag.
const normalizedContext = this.normalizeContext(context);
const flags = this.store.getAll();
const result: Record<string, boolean> = {};

for (const slug of Object.keys(flags)) {
result[slug] = this.evaluate(flags[slug], normalizedContext, false);
}

return result;
return Object.fromEntries(
Object.entries(flags).map(([slug, flag]) => [
slug,
this.evaluate(flag, normalizedContext, false),
]),
);
}

private evaluate(
entry: FlagPollEntry | undefined,
entry: FlagPollEntryV2 | undefined,
normalizedContext: Map<string, string>,
defaultValue: boolean,
): boolean {
Expand All @@ -63,18 +62,31 @@ export class Evaluator {
}

if (!entry.enabled) {
return false;
return this.servedValue(entry.off_value, defaultValue);
}

// Evaluation is enable-only: any enabled target matching the context
// turns the flag on, with no precedence between target types.
for (const [targetType, targetId] of normalizedContext) {
if (this.hasEnabledTarget(entry, targetType, targetId)) {
return true;
for (const rule of entry.rules) {
if (rule.kind !== 'conditions' || !rule.conditions?.length) continue;
const matches = rule.conditions.every((condition) => {
if (
condition.operator !== 'one_of' ||
!condition.target_type ||
!Array.isArray(condition.values)
)
return false;
const id = normalizedContext.get(condition.target_type);
return id !== undefined && condition.values.includes(id);
});
if (matches) {
return this.servedValue(rule.value, defaultValue);
}
}

return entry.default_value;
return this.servedValue(entry.default_value, defaultValue);
Comment thread
Deborah-Digges marked this conversation as resolved.
}

private servedValue(value: unknown, defaultValue: boolean): boolean {
return typeof value === 'boolean' ? value : defaultValue;
}

/**
Expand All @@ -85,6 +97,14 @@ export class Evaluator {
*/
private normalizeContext(context: EvaluationContext): Map<string, string> {
const normalized = new Map<string, string>();
if (
typeof context !== 'object' ||
context === null ||
Array.isArray(context)
) {
this.logger?.warn('Ignoring invalid evaluation context');
return normalized;
}
const record: Record<string, unknown> = context;

const legacyEntries: Array<[string, string]> = [];
Expand Down Expand Up @@ -161,29 +181,4 @@ export class Evaluator {

return normalized;
}

/**
* A target participates in evaluation only while its `enabled` is true. A
* `false` value is reserved for future disabled overrides and is treated
* as if the target were absent.
*/
private hasEnabledTarget(
entry: FlagPollEntry,
targetType: string,
targetId: string,
): boolean {
if (targetType === 'user') {
return entry.targets.users.some((t) => t.id === targetId && t.enabled);
}

if (targetType === 'organization') {
return entry.targets.organizations.some(
(t) => t.id === targetId && t.enabled,
);
}

return (entry.targets.custom_targets ?? []).some(
(t) => t.type === targetType && t.id === targetId && t.enabled,
);
}
}
15 changes: 7 additions & 8 deletions src/feature-flags/in-memory-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { InMemoryStore } from './in-memory-store';
import { FlagPollEntry } from './interfaces';
import { FlagPollEntryV2 } from './interfaces';

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

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

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

beforeEach(() => {
Expand Down
12 changes: 6 additions & 6 deletions src/feature-flags/in-memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { FlagPollEntry, FlagPollResponse } from './interfaces';
import { FlagPollEntryV2, FlagPollResponseV2 } from './interfaces';

export class InMemoryStore {
private flags: FlagPollResponse = {};
private flags: FlagPollResponseV2['flags'] = {};

swap(newFlags: FlagPollResponse): void {
swap(newFlags: FlagPollResponseV2['flags']): void {
this.flags = { ...newFlags };
}

get(slug: string): FlagPollEntry | undefined {
return this.flags[slug];
get(slug: string): FlagPollEntryV2 | undefined {
return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined;
}

getAll(): FlagPollResponse {
getAll(): FlagPollResponseV2['flags'] {
return { ...this.flags };
}

Expand Down
6 changes: 3 additions & 3 deletions src/feature-flags/interfaces/flag-change.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FlagPollEntry } from './flag-poll-response.interface';
import { FlagPollEntryV2 } from './flag-poll-response.interface';

export interface FlagChange {
key: string;
previous: FlagPollEntry | null;
current: FlagPollEntry | null;
previous: FlagPollEntryV2 | null;
current: FlagPollEntryV2 | null;
}
34 changes: 33 additions & 1 deletion src/feature-flags/interfaces/flag-poll-response.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,36 @@ export interface FlagPollEntry {
};
}

export type FlagPollResponse = Record<string, FlagPollEntry>;
export type FlagPollResponseV1 = Record<string, FlagPollEntry>;

export interface FlagConditionV2 {
// Future operators may carry different operands. Unsupported conditions
// never match, even if another condition in the rule matches.
operator: string;
target_type?: string;
values?: unknown;
}

export interface FlagRuleV2 {
id: string;
kind: string;
// Future multivariate values must fall back to the caller's boolean default.
value: unknown;
conditions?: FlagConditionV2[];
}

export interface FlagPollEntryV2 {
slug: string;
enabled: boolean;
default_value: unknown;
off_value: unknown;
/** Evaluated in array order; the first matching rule wins. */
rules: FlagRuleV2[];
}

export interface FlagPollResponseV2 {
version: 2;
flags: Record<string, FlagPollEntryV2>;
}

export type FlagPollResponse = FlagPollResponseV1 | FlagPollResponseV2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Response alias breaks indexing

FlagPollResponse now includes a v2 envelope without a string index signature. Existing TypeScript code that accepts this exported type and indexes it by flag slug will no longer compile. The README describes the getFlag() and change-event migration, but not this additional type change, leaving those callers without migration guidance.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/feature-flags/interfaces/flag-poll-response.interface.ts
Line: 56

Comment:
**Response alias breaks indexing**

`FlagPollResponse` now includes a v2 envelope without a string index signature. Existing TypeScript code that accepts this exported type and indexes it by flag slug will no longer compile. The README describes the `getFlag()` and change-event migration, but not this additional type change, leaving those callers without migration guidance.

**Knowledge Base Used:**
- [Feature flag evaluation](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-node/-/docs/feature-flags.md)
- [SDK initialization and public entrypoints](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-node/-/docs/sdk-foundation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type change is intentional as part of the major SDK update, but agreed that we’re missing migration guidance here. I’ll add a note to the README’s configuration migration section to use FlagPollResponseV1 for legacy maps, or FlagPollResponseV2 with .flags[slug] for v2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That migration note addresses the concern. Please make the distinction explicit: legacy flat maps should use FlagPollResponseV1, while v2 consumers should access entries through FlagPollResponseV2.flags[slug]; then callers can update their indexing without relying on the union’s shape.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FlagPollEntry } from './flag-poll-response.interface';
import { FlagPollResponse } from './flag-poll-response.interface';

export interface RuntimeClientLogger {
debug(...args: unknown[]): void;
Expand All @@ -9,7 +9,8 @@ export interface RuntimeClientLogger {

export interface RuntimeClientOptions {
pollingIntervalMs?: number;
bootstrapFlags?: Record<string, FlagPollEntry>;
/** A legacy flat payload or a versioned v2 envelope, as returned by polling. */
bootstrapFlags?: FlagPollResponse;
requestTimeoutMs?: number;
logger?: RuntimeClientLogger;
}
Loading
Loading