Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export class MultiProvider implements Provider {
Object.freeze(this.providerEntriesByName);
}

async initialize(context?: EvaluationContext): Promise<void> {
async initialize(context?: EvaluationContext, domain?: string): Promise<void> {
const result = await Promise.allSettled(
this.providerEntries.map((provider) => provider.provider.initialize?.(context)),
this.providerEntries.map((provider) => provider.provider.initialize?.(context, domain)),
);
throwAggregateErrorFromPromiseResults(result, this.providerEntries);
}
Expand Down
34 changes: 34 additions & 0 deletions packages/server/test/multi-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
FirstSuccessfulStrategy,
ComparisonStrategy,
} from '../src';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

class TestProvider implements Provider {
public metadata: ProviderMetadata = {
Expand Down Expand Up @@ -162,6 +163,39 @@ describe('MultiProvider', () => {
expect(initializations).toBe(2);
});

it('forwards domain to legacy single-argument child providers without error', async () => {
Comment thread
jonathannorris marked this conversation as resolved.
const legacyProvider1 = legacyInitializeProvider(
{ runsOn: 'server', asyncResolvers: true, name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const legacyProvider2 = legacyInitializeProvider(
{ runsOn: 'server', asyncResolvers: true, name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const multiProvider = new MultiProvider([
{ provider: legacyProvider1 as unknown as Provider },
{ provider: legacyProvider2 as unknown as Provider },
]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(legacyProvider1.initializeCalls).toBe(1);
expect(legacyProvider2.initializeCalls).toBe(1);
expect(legacyProvider1.lastContext).toEqual({ targetingKey: 'user' });
expect(legacyProvider2.lastContext).toEqual({ targetingKey: 'user' });
});

it('forwards domain to child provider initialize', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
const multiProvider = new MultiProvider([{ provider: provider1 }, { provider: provider2 }]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(provider1.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
expect(provider2.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
});

it('throws error if a provider errors on initialization', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
Expand Down
90 changes: 88 additions & 2 deletions packages/server/test/open-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Paradigm } from '@openfeature/core';
import type { Provider, ProviderStatus } from '../src';
import { OpenFeature, OpenFeatureAPI } from '../src';
import type { Provider } from '../src';
import { OpenFeature, OpenFeatureAPI, ProviderStatus } from '../src';
import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
return {
Expand Down Expand Up @@ -57,6 +58,41 @@ describe('OpenFeature', () => {
expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
expect(provider.initialize).toHaveBeenCalled();
});

it('MUST supply the bound domain to initialize when registering a domain-scoped provider', () => {
const domain = 'my-domain';
const provider = { ...mockProvider(), domainScoped: true } as Provider;
const spy = jest.spyOn(provider, 'initialize');
OpenFeature.setProvider(domain, provider);
expect(spy).toHaveBeenCalledWith({}, domain);
});

it('MUST not supply a domain to initialize for the default provider', () => {
const provider = mockProvider();
const spy = jest.spyOn(provider, 'initialize');
OpenFeature.setProvider(provider);
expect(spy).toHaveBeenCalledWith({}, undefined);
});

it('initializes legacy single-argument providers when bound to a domain', async () => {
const domain = 'my-domain';
const context = { targetingKey: 'user' };
const legacyProvider = legacyInitializeProvider({ runsOn: 'server', asyncResolvers: true });
OpenFeature.setContext(context);

await expect(
OpenFeature.setProviderAndWait(domain, legacyProvider as unknown as Provider),
).resolves.toBeUndefined();
expect(legacyProvider.lastContext).toEqual(context);
expect(OpenFeature.getClient(domain).providerStatus).toEqual(ProviderStatus.READY);
});

it('initializes legacy single-argument providers as the default provider', async () => {
const legacyProvider = legacyInitializeProvider({ runsOn: 'server', asyncResolvers: true });

await expect(OpenFeature.setProviderAndWait(legacyProvider as unknown as Provider)).resolves.toBeUndefined();
expect(OpenFeature.getClient().providerStatus).toEqual(ProviderStatus.READY);
});
});

describe('Requirement 1.1.2.3', () => {
Expand All @@ -68,6 +104,56 @@ describe('OpenFeature', () => {
expect(provider.onClose).toHaveBeenCalled();
});
});

describe('Condition 1.1.8', () => {
it('MUST NOT bind a domain-scoped provider instance to more than one domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;

OpenFeature.setProvider('domain-a', provider);
expect(() => OpenFeature.setProvider('domain-b', provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider('domain-a')).toBe(provider);
expect(OpenFeature.getProvider('domain-b').metadata.name).not.toBe(provider.metadata.name);
});

it('MUST NOT bind a domain-scoped default provider to a named domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;
const spy = jest.spyOn(provider, 'initialize');

OpenFeature.setProvider(provider);
expect(() => OpenFeature.setProvider('domain-a', provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider()).toBe(provider);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(expect.anything(), undefined);
});

it('MUST NOT bind a domain-scoped provider as default when already bound to a domain', () => {
const provider = { ...mockProvider(), domainScoped: true } as Provider;
const spy = jest.spyOn(provider, 'initialize');

OpenFeature.setProvider('domain-a', provider);
expect(() => OpenFeature.setProvider(provider)).toThrow(
"Cannot bind domain-scoped provider 'mock-events-success' to more than one domain.",
);
expect(OpenFeature.getProvider('domain-a')).toBe(provider);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(expect.anything(), 'domain-a');
});

it('allows a non-domain-scoped provider to back multiple domains', async () => {
const provider = { ...mockProvider(), onClose: jest.fn() };

await OpenFeature.setProviderAndWait('domain1', provider);
await OpenFeature.setProviderAndWait('domain2', provider);

expect(OpenFeature.getProvider('domain1')).toBe(provider);
expect(OpenFeature.getProvider('domain2')).toBe(provider);
expect(provider.initialize).toHaveBeenCalledTimes(1);
});
});
});

describe('Requirement 1.1.3', () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ export abstract class OpenFeatureCommonAPI<

// ignore no-ops
if (oldProvider === provider) {
// domain-scoped providers may already be the default; an explicit domain bind is not a no-op
if (provider.domainScoped && domain && this._domainScopedProviders.get(domain)?.provider !== provider) {
throw new GeneralError(
`Cannot bind domain-scoped provider '${provider.metadata.name}' to more than one domain.`,
);
}
this._logger.debug('Provider is already set, ignoring setProvider call');
return;
}
Expand All @@ -241,6 +247,10 @@ export abstract class OpenFeatureCommonAPI<
throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`);
}

if (provider.domainScoped && this.allProviders.includes(provider)) {
throw new GeneralError(`Cannot bind domain-scoped provider '${provider.metadata.name}' to more than one domain.`);
}

const emitters = this.getAssociatedEventEmitters(domain);

let initializationPromise: Promise<void> | void = undefined;
Expand All @@ -252,8 +262,9 @@ export abstract class OpenFeatureCommonAPI<

// initialize the provider if it implements "initialize" and it's not already registered
if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
const initContext = domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context;
initializationPromise = provider
.initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
.initialize?.(initContext, domain)
Comment thread
jonathannorris marked this conversation as resolved.
?.then(() => {
Comment thread
jonathannorris marked this conversation as resolved.
wrappedProvider.status = this._statusEnumType.READY;
// fetch the most recent event emitters, some may have been added during init
Expand Down
11 changes: 9 additions & 2 deletions packages/shared/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
*/
readonly runsOn?: Paradigm;

/**
* When true, the provider maintains state specific to a single domain (such as a
* persistent cache) and the SDK will bind it to at most one domain.
*/
readonly domainScoped?: boolean;

// TODO: in the future we could make this a never to force provider to remove it.
/**
* @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field.
Expand Down Expand Up @@ -123,9 +129,10 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
* When the returned promise resolves, the SDK fires the ProviderEvents.Ready event.
* If the returned promise rejects, the SDK fires the ProviderEvents.Error event.
* Use this function to perform any context-dependent setup within the provider.
* @param context
* @param context the global evaluation context
Comment thread
jonathannorris marked this conversation as resolved.
* @param domain the bound domain, if any
*/
initialize?(context?: EvaluationContext): Promise<void>;
initialize?(context?: EvaluationContext, domain?: string): Promise<void>;
Comment thread
jonathannorris marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we should make this an initialization config object so we can extend it in the future without breaking anything.

@toddbaert toddbaert Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think it matters as much here due to overloading, but it's possible and maybe a good idea. We could have an InitializationContext (we already have a HookContext that is comparable). "Context" is already a bit overloaded, but this is mostly a provider author interface so I'm not that worried about that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think we need an object here; overloading gives us room to extend without breaking existing initialize(context) implementations. I'd keep positional (context?, domain?) for this PR per spec#393.

If we do move to an options bag for the second arg later, I'd avoid another *Context name. Some options: ProviderBinding, ProviderRegistration, ProviderScope, InitializeMetadata.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ya I think my recommendation is we don't do this, but I'm open to disagreements.


/**
* Track a user action or application state, usually representing a business objective or outcome.
Expand Down
73 changes: 73 additions & 0 deletions packages/shared/test/legacy-initialize-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { EvaluationContext, Paradigm } from '../src';

type LegacyInitializeProviderOptions = {
runsOn: Paradigm;
name?: string;
/** When true, resolution stubs return promises (server SDK). Default false (web SDK). */
asyncResolvers?: boolean;
};

type LegacyInitializeProviderExtras = {
events: unknown;
hooks?: unknown[];
track?: jest.Mock;
};

type LegacyInitializeProviderResolvers = {
resolveBooleanEvaluation: jest.Mock;
resolveStringEvaluation: jest.Mock;
resolveNumberEvaluation: jest.Mock;
resolveObjectEvaluation: jest.Mock;
};

export type LegacyInitializeProvider = LegacyInitializeProviderResolvers & {
metadata: { name: string };
runsOn: Paradigm;
lastContext?: EvaluationContext;
initializeCalls: number;
initialize: (context?: EvaluationContext) => Promise<void>;
};

function createResolverStubs(asyncResolvers: boolean): LegacyInitializeProviderResolvers {
const mockValue = asyncResolvers
? <T>(value: T) => jest.fn().mockResolvedValue(value)
: <T>(value: T) => jest.fn().mockReturnValue(value);

return {
resolveBooleanEvaluation: mockValue({ value: false }),
resolveStringEvaluation: mockValue({ value: '' }),
resolveNumberEvaluation: mockValue({ value: 0 }),
resolveObjectEvaluation: mockValue({ value: {} }),
};
}

/**

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @returns declaration

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @param "extras" declaration

Check warning on line 44 in packages/shared/test/legacy-initialize-provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @param "options" declaration
* Provider with a single-argument initialize that ignores any extra arguments passed by the SDK.
* Pass optional extras when MultiProvider needs events, hooks, or track stubs on the child.
*/
Comment thread
jonathannorris marked this conversation as resolved.
export function legacyInitializeProvider(
options: LegacyInitializeProviderOptions,
extras?: LegacyInitializeProviderExtras,
): LegacyInitializeProvider {
const provider: LegacyInitializeProvider = {
metadata: { name: options.name ?? 'legacy-init' },
runsOn: options.runsOn,
lastContext: undefined,
initializeCalls: 0,
async initialize(context?: EvaluationContext): Promise<void> {
this.lastContext = context;
this.initializeCalls++;
},
...createResolverStubs(options.asyncResolvers ?? false),
};

if (extras) {
Object.assign(provider, {
events: extras.events,
hooks: extras.hooks ?? [],
track: extras.track ?? jest.fn(),
});
}

return provider;
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export class MultiProvider implements Provider {
Object.freeze(this.providerEntriesByName);
}

async initialize(context?: EvaluationContext): Promise<void> {
async initialize(context?: EvaluationContext, domain?: string): Promise<void> {
const result = await Promise.allSettled(
this.providerEntries.map((provider) => provider.provider.initialize?.(context)),
this.providerEntries.map((provider) => provider.provider.initialize?.(context, domain)),
);
throwAggregateErrorFromPromiseResults(result, this.providerEntries);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/test/evaluation-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('Evaluation Context', () => {
const context: EvaluationContext = { property1: false };
const provider = new MockProvider();
await OpenFeature.setProviderAndWait(provider, context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(initializeMock).toHaveBeenCalledWith(context, undefined);
expect(OpenFeature.getContext()).toEqual(context);
});

Expand All @@ -95,7 +95,7 @@ describe('Evaluation Context', () => {
await OpenFeature.setProviderAndWait(domain, provider, context);
expect(OpenFeature.getContext()).toEqual({});
expect(OpenFeature.getContext(domain)).toEqual(context);
expect(initializeMock).toHaveBeenCalledWith(context);
expect(initializeMock).toHaveBeenCalledWith(context, domain);
});
});

Expand Down
34 changes: 34 additions & 0 deletions packages/web/test/multi-provider-web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
FirstSuccessfulStrategy,
ComparisonStrategy,
} from '../src';
import { legacyInitializeProvider } from '../../shared/test/legacy-initialize-provider';

class TestProvider implements Provider {
public metadata: ProviderMetadata = {
Expand Down Expand Up @@ -162,6 +163,39 @@ describe('MultiProvider', () => {
expect(initializations).toBe(2);
});

it('forwards domain to legacy single-argument child providers without error', async () => {
Comment thread
jonathannorris marked this conversation as resolved.
const legacyProvider1 = legacyInitializeProvider(
{ runsOn: 'client', name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const legacyProvider2 = legacyInitializeProvider(
{ runsOn: 'client', name: 'LegacyInitTestProvider' },
{ events: new OpenFeatureEventEmitter() },
);
const multiProvider = new MultiProvider([
{ provider: legacyProvider1 as unknown as Provider },
{ provider: legacyProvider2 as unknown as Provider },
]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(legacyProvider1.initializeCalls).toBe(1);
expect(legacyProvider2.initializeCalls).toBe(1);
expect(legacyProvider1.lastContext).toEqual({ targetingKey: 'user' });
expect(legacyProvider2.lastContext).toEqual({ targetingKey: 'user' });
});

it('forwards domain to child provider initialize', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
const multiProvider = new MultiProvider([{ provider: provider1 }, { provider: provider2 }]);

await multiProvider.initialize({ targetingKey: 'user' }, 'my-domain');

expect(provider1.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
expect(provider2.initialize).toHaveBeenCalledWith({ targetingKey: 'user' }, 'my-domain');
});

it('throws error if a provider errors on initialization', async () => {
const provider1 = new TestProvider();
const provider2 = new TestProvider();
Expand Down
Loading
Loading