Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ module.exports = {
},
rules: {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/method-signature-style": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"promise/param-names": "off",
},
};
2 changes: 1 addition & 1 deletion src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { jsonStringifyWithBigInt } from "../bigIntUtils";
import { Reforge } from "../reforge";
import type { ReforgeInterface } from "../reforge";
import type { ReforgeInterface } from "../types";
import type { ResolverAPI } from "../resolver";
import type { GetValue } from "../unwrap";
import { tests } from "./integrationHelper";
Expand Down
14 changes: 8 additions & 6 deletions src/__tests__/reforge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import rolloutFlag from "./fixtures/rolloutFlag";
import envConfig from "./fixtures/envConfig";
import propIsOneOf from "./fixtures/propIsOneOf";
import propIsOneOfAndEndsWith from "./fixtures/propIsOneOfAndEndsWith";
import {
Reforge,
type TypedNodeServerConfigurationRaw,
MULTIPLE_INIT_WARNING,
} from "../reforge";
import type { Contexts, ProjectEnvId, Config, ConfigValue } from "../types";
import { Reforge, MULTIPLE_INIT_WARNING } from "../reforge";
import type {
Contexts,
ProjectEnvId,
Config,
ConfigValue,
TypedNodeServerConfigurationRaw,
} from "../types";
import {
LogLevel,
Criterion_CriterionOperator,
Expand Down
162 changes: 162 additions & 0 deletions src/__tests__/reforgeClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Reforge } from "../reforge";
import { ConfigType, ConfigValueType } from "../types";
import type { Config } from "../types";
import { projectEnvIdUnderTest, irrelevant } from "./testHelpers";

const createSimpleConfig = (key: string, value: string): Config => {
return {
id: "1",
projectId: 1,
key,
changedBy: undefined,
rows: [
{
properties: {},
values: [
{
criteria: [
{
propertyName: "user.country",
operator: "PROP_IS_ONE_OF" as any,
valueToMatch: {
stringList: {
values: ["US"],
},
},
},
],
value: { string: value },
},
{
criteria: [],
value: { string: "default" },
},
],
},
],
allowableValues: [],
configType: ConfigType.Config,
valueType: ConfigValueType.String,
sendToClientSdk: false,
};
};

describe("ReforgeClient", () => {
describe("withContext", () => {
it("returns a context-scoped client that doesn't expose internal resolver", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
const config = createSimpleConfig("test.key", "us-value");
reforge.setConfig([config], projectEnvIdUnderTest, new Map());

const scopedClient = reforge.withContext({ user: { country: "US" } });

// Should have the public API methods
expect(typeof scopedClient.get).toBe("function");
expect(typeof scopedClient.isFeatureEnabled).toBe("function");
expect(typeof scopedClient.logger).toBe("function");
expect(typeof scopedClient.shouldLog).toBe("function");
expect(typeof scopedClient.getLogLevel).toBe("function");
expect(typeof scopedClient.withContext).toBe("function");
expect(typeof scopedClient.inContext).toBe("function");
expect(typeof scopedClient.updateIfStalerThan).toBe("function");
expect(typeof scopedClient.addConfigChangeListener).toBe("function");

// Should NOT expose internal resolver methods
expect((scopedClient as any).raw).toBeUndefined();
expect((scopedClient as any).set).toBeUndefined();
expect((scopedClient as any).keys).toBeUndefined();
expect((scopedClient as any).cloneWithContext).toBeUndefined();
expect((scopedClient as any).update).toBeUndefined();

// Should apply context correctly
expect(scopedClient.get("test.key")).toBe("us-value");
});

it("allows chaining withContext calls", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
const config = createSimpleConfig("test.key", "us-value");
reforge.setConfig([config], projectEnvIdUnderTest, new Map());

const client1 = reforge.withContext({ user: { country: "FR" } });
expect(client1.get("test.key")).toBe("default");

const client2 = client1.withContext({ user: { country: "US" } });
expect(client2.get("test.key")).toBe("us-value");
});

it("merges context when additional context is provided to methods", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
const config = createSimpleConfig("test.key", "us-value");
reforge.setConfig([config], projectEnvIdUnderTest, new Map());

const client = reforge.withContext({ user: { name: "Alice" } });

// Provide additional context that includes the country
expect(client.get("test.key", { user: { country: "US" } })).toBe(
"us-value"
);
});
});

describe("inContext", () => {
it("provides a context-scoped client to the callback", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
const config = createSimpleConfig("test.key", "us-value");
reforge.setConfig([config], projectEnvIdUnderTest, new Map());

const result = reforge.inContext(
{ user: { country: "US" } },
(client) => {
// Should not expose internal methods
expect((client as any).raw).toBeUndefined();
expect((client as any).set).toBeUndefined();

// Should apply context
expect(client.get("test.key")).toBe("us-value");

return "success";
}
);

expect(result).toBe("success");
});

it("allows nested inContext calls", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
const config = createSimpleConfig("test.key", "us-value");
reforge.setConfig([config], projectEnvIdUnderTest, new Map());

reforge.inContext({ user: { country: "FR" } }, (outer) => {
expect(outer.get("test.key")).toBe("default");

outer.inContext({ user: { country: "US" } }, (inner) => {
expect(inner.get("test.key")).toBe("us-value");
});
});
});
});

describe("shared state", () => {
it("shares telemetry with parent", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
reforge.setConfig([], projectEnvIdUnderTest, new Map());

const client = reforge.withContext({ user: { country: "US" } });

expect(client.telemetry).toBe(reforge.telemetry);
});

it("delegates methods to parent reforge instance", () => {
const reforge = new Reforge({ sdkKey: irrelevant });
reforge.setConfig([], projectEnvIdUnderTest, new Map());

const client = reforge.withContext({ user: { country: "US" } });

// Telemetry is shared
expect(client.telemetry).toBe(reforge.telemetry);

// getLogLevel delegates to parent
expect(typeof client.getLogLevel).toBe("function");
});
});
});
20 changes: 15 additions & 5 deletions src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ import {
type HashByPropertyValue,
type ProjectEnvId,
} from "./types";
import type { MinimumConfig, Resolver } from "./resolver";
import type { MinimumConfig } from "./resolver";

import { type GetValue, unwrap } from "./unwrap";
import { contextLookup } from "./contextLookup";
import { sortRows } from "./sortRows";
import SemanticVersion from "./semanticversion";
import { isBigInt, jsonStringifyWithBigInt } from "./bigIntUtils";

/**
* Minimal interface for resolving segments and encryption keys during evaluation.
* This interface is used internally to decouple evaluate.ts from the full Resolver.
* @internal
*/
interface SegmentResolver {
raw(key: string): MinimumConfig | undefined;
get(key: string, contexts?: Contexts): unknown;
}

const getHashByPropertyValue = (
value: ConfigValue | undefined,
contexts: Contexts
Expand Down Expand Up @@ -102,7 +112,7 @@ const propContainsOneOf = (
const inSegment = (
criterion: Criterion,
contexts: Contexts,
resolver: Resolver
resolver: SegmentResolver
): boolean => {
const segmentKey = criterion.valueToMatch?.string;

Expand Down Expand Up @@ -282,7 +292,7 @@ const allCriteriaMatch = (
value: ConditionalValue,
namespace: string | undefined,
contexts: Contexts,
resolver: Resolver
resolver: SegmentResolver
): boolean => {
if (value.criteria === undefined) {
return true;
Expand Down Expand Up @@ -380,7 +390,7 @@ const matchingConfigValue = (
projectEnvId: ProjectEnvId,
namespace: string | undefined,
contexts: Contexts,
resolver: Resolver
resolver: SegmentResolver
): [number, number, ConfigValue | undefined] => {
let match: ConfigValue | undefined;
let conditionalValueIndex: number = -1;
Expand Down Expand Up @@ -413,7 +423,7 @@ export interface EvaluateArgs {
projectEnvId: ProjectEnvId;
namespace: string | undefined;
contexts: Contexts;
resolver: Resolver;
resolver: SegmentResolver;
}

export interface Evaluation {
Expand Down
74 changes: 9 additions & 65 deletions src/reforge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { apiClient, type ApiClient } from "./apiClient";
import { loadConfig } from "./loadConfig";
import { Resolver, type MinimumConfig, type ResolverAPI } from "./resolver";
import { Resolver, type MinimumConfig } from "./resolver";
import { Sources } from "./sources";
import { jsonStringifyWithBigInt } from "./bigIntUtils";
import {
Expand All @@ -21,10 +21,14 @@ import type {
ConfigValue,
ConfigRow,
Provided,
TypedNodeServerConfigurationRaw,
Telemetry,
ReforgeInterface,
} from "./types";
import { LOG_LEVEL_RANK_LOOKUP, type makeLogger } from "./logger";
import { SSEConnection } from "./sseConnection";
import { TelemetryReporter } from "./telemetry/reporter";
import { ReforgeClient } from "./reforgeClient";

import type { ContextUploadMode } from "./telemetry/types";
import { knownLoggers } from "./telemetry/knownLoggers";
Expand All @@ -51,65 +55,6 @@ function requireResolver(
}
}

// @reforge-com/cli#generate will create interfaces into this namespace for Node to consume
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface NodeServerConfigurationRaw {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface NodeServerConfigurationAccessor {}

export type TypedNodeServerConfigurationRaw =
keyof NodeServerConfigurationRaw extends never
? Record<string, unknown>
: {
[TypedFlagKey in keyof NodeServerConfigurationRaw]: NodeServerConfigurationRaw[TypedFlagKey];
};

export type TypedNodeServerConfigurationAccessor =
keyof NodeServerConfigurationAccessor extends never
? Record<string, unknown>
: {
[TypedFlagKey in keyof NodeServerConfigurationAccessor]: NodeServerConfigurationAccessor[TypedFlagKey];
};

export interface ReforgeInterface {
get: <K extends keyof TypedNodeServerConfigurationRaw>(
key: K,
contexts?: Contexts | ContextObj,
defaultValue?: TypedNodeServerConfigurationRaw[K]
) => TypedNodeServerConfigurationRaw[K];
isFeatureEnabled: <K extends keyof TypedNodeServerConfigurationRaw>(
key: K,
contexts?: Contexts | ContextObj
) => boolean;
logger: (
loggerName: string,
defaultLevel: LogLevel
) => ReturnType<typeof makeLogger>;
shouldLog: ({
loggerName,
desiredLevel,
defaultLevel,
contexts,
}: {
loggerName: string;
desiredLevel: LogLevel;
defaultLevel?: LogLevel;
contexts?: Contexts | ContextObj;
}) => boolean;
getLogLevel: (loggerName: string) => LogLevel;
telemetry?: Telemetry;
updateIfStalerThan: (durationInMs: number) => Promise<void> | undefined;
withContext: (contexts: Contexts | ContextObj) => ResolverAPI;
addConfigChangeListener: (callback: GlobalListenerCallback) => () => void;
}

export interface Telemetry {
knownLoggers: ReturnType<typeof knownLoggers>;
contextShapes: ReturnType<typeof contextShapes>;
exampleContexts: ReturnType<typeof exampleContexts>;
evaluationSummaries: ReturnType<typeof evaluationSummaries>;
}

interface ConstructorProps {
sdkKey: string;
sources?: string[];
Expand Down Expand Up @@ -411,17 +356,17 @@ class Reforge implements ReforgeInterface {

inContext<T>(
contexts: Contexts | ContextObj,
func: (reforge: Resolver) => T
func: (reforge: ReforgeInterface) => T
): T {
requireResolver(this.resolver);

return func(this.resolver.cloneWithContext(contexts));
return func(new ReforgeClient(this, contexts));
}

withContext(contexts: Contexts | ContextObj): ResolverAPI {
withContext(contexts: Contexts | ContextObj): ReforgeInterface {
requireResolver(this.resolver);

return this.resolver.cloneWithContext(contexts);
return new ReforgeClient(this, contexts);
}

get<K extends keyof TypedNodeServerConfigurationRaw>(
Expand Down Expand Up @@ -562,5 +507,4 @@ export {
type Contexts,
SchemaType,
type Provided,
Resolver,
};
Loading