Skip to content

Commit e727d3e

Browse files
committed
refactor: improve public interface + don't expose resolver
1 parent a62c037 commit e727d3e

7 files changed

Lines changed: 376 additions & 17 deletions

File tree

.eslintrc.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ module.exports = {
1212
},
1313
rules: {
1414
"@typescript-eslint/no-var-requires": "off",
15+
"@typescript-eslint/method-signature-style": "off",
16+
"@typescript-eslint/strict-boolean-expressions": "off",
1517
"promise/param-names": "off",
1618
},
1719
};
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { Reforge } from "../reforge";
2+
import { ConfigType, ConfigValueType } from "../types";
3+
import type { Config } from "../types";
4+
import { projectEnvIdUnderTest, irrelevant } from "./testHelpers";
5+
6+
const createSimpleConfig = (key: string, value: string): Config => {
7+
return {
8+
id: "1",
9+
projectId: 1,
10+
key,
11+
changedBy: undefined,
12+
rows: [
13+
{
14+
properties: {},
15+
values: [
16+
{
17+
criteria: [
18+
{
19+
propertyName: "user.country",
20+
operator: "PROP_IS_ONE_OF" as any,
21+
valueToMatch: {
22+
stringList: {
23+
values: ["US"],
24+
},
25+
},
26+
},
27+
],
28+
value: { string: value },
29+
},
30+
{
31+
criteria: [],
32+
value: { string: "default" },
33+
},
34+
],
35+
},
36+
],
37+
allowableValues: [],
38+
configType: ConfigType.Config,
39+
valueType: ConfigValueType.String,
40+
sendToClientSdk: false,
41+
};
42+
};
43+
44+
describe("ReforgeClient", () => {
45+
describe("withContext", () => {
46+
it("returns a context-scoped client that doesn't expose internal resolver", () => {
47+
const reforge = new Reforge({ sdkKey: irrelevant });
48+
const config = createSimpleConfig("test.key", "us-value");
49+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
50+
51+
const scopedClient = reforge.withContext({ user: { country: "US" } });
52+
53+
// Should have the public API methods
54+
expect(typeof scopedClient.get).toBe("function");
55+
expect(typeof scopedClient.isFeatureEnabled).toBe("function");
56+
expect(typeof scopedClient.logger).toBe("function");
57+
expect(typeof scopedClient.shouldLog).toBe("function");
58+
expect(typeof scopedClient.getLogLevel).toBe("function");
59+
expect(typeof scopedClient.withContext).toBe("function");
60+
expect(typeof scopedClient.inContext).toBe("function");
61+
expect(typeof scopedClient.updateIfStalerThan).toBe("function");
62+
expect(typeof scopedClient.addConfigChangeListener).toBe("function");
63+
64+
// Should NOT expose internal resolver methods
65+
expect((scopedClient as any).raw).toBeUndefined();
66+
expect((scopedClient as any).set).toBeUndefined();
67+
expect((scopedClient as any).keys).toBeUndefined();
68+
expect((scopedClient as any).cloneWithContext).toBeUndefined();
69+
expect((scopedClient as any).update).toBeUndefined();
70+
71+
// Should apply context correctly
72+
expect(scopedClient.get("test.key")).toBe("us-value");
73+
});
74+
75+
it("allows chaining withContext calls", () => {
76+
const reforge = new Reforge({ sdkKey: irrelevant });
77+
const config = createSimpleConfig("test.key", "us-value");
78+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
79+
80+
const client1 = reforge.withContext({ user: { country: "FR" } });
81+
expect(client1.get("test.key")).toBe("default");
82+
83+
const client2 = client1.withContext({ user: { country: "US" } });
84+
expect(client2.get("test.key")).toBe("us-value");
85+
});
86+
87+
it("merges context when additional context is provided to methods", () => {
88+
const reforge = new Reforge({ sdkKey: irrelevant });
89+
const config = createSimpleConfig("test.key", "us-value");
90+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
91+
92+
const client = reforge.withContext({ user: { name: "Alice" } });
93+
94+
// Provide additional context that includes the country
95+
expect(client.get("test.key", { user: { country: "US" } })).toBe(
96+
"us-value"
97+
);
98+
});
99+
});
100+
101+
describe("inContext", () => {
102+
it("provides a context-scoped client to the callback", () => {
103+
const reforge = new Reforge({ sdkKey: irrelevant });
104+
const config = createSimpleConfig("test.key", "us-value");
105+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
106+
107+
const result = reforge.inContext(
108+
{ user: { country: "US" } },
109+
(client) => {
110+
// Should not expose internal methods
111+
expect((client as any).raw).toBeUndefined();
112+
expect((client as any).set).toBeUndefined();
113+
114+
// Should apply context
115+
expect(client.get("test.key")).toBe("us-value");
116+
117+
return "success";
118+
}
119+
);
120+
121+
expect(result).toBe("success");
122+
});
123+
124+
it("allows nested inContext calls", () => {
125+
const reforge = new Reforge({ sdkKey: irrelevant });
126+
const config = createSimpleConfig("test.key", "us-value");
127+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
128+
129+
reforge.inContext({ user: { country: "FR" } }, (outer) => {
130+
expect(outer.get("test.key")).toBe("default");
131+
132+
outer.inContext({ user: { country: "US" } }, (inner) => {
133+
expect(inner.get("test.key")).toBe("us-value");
134+
});
135+
});
136+
});
137+
});
138+
139+
describe("shared state", () => {
140+
it("shares telemetry with parent", () => {
141+
const reforge = new Reforge({ sdkKey: irrelevant });
142+
reforge.setConfig([], projectEnvIdUnderTest, new Map());
143+
144+
const client = reforge.withContext({ user: { country: "US" } });
145+
146+
expect(client.telemetry).toBe(reforge.telemetry);
147+
});
148+
149+
it("delegates methods to parent reforge instance", () => {
150+
const reforge = new Reforge({ sdkKey: irrelevant });
151+
reforge.setConfig([], projectEnvIdUnderTest, new Map());
152+
153+
const client = reforge.withContext({ user: { country: "US" } });
154+
155+
// Telemetry is shared
156+
expect(client.telemetry).toBe(reforge.telemetry);
157+
158+
// getLogLevel delegates to parent
159+
expect(typeof client.getLogLevel).toBe("function");
160+
});
161+
});
162+
});

src/evaluate.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,24 @@ import {
1010
type HashByPropertyValue,
1111
type ProjectEnvId,
1212
} from "./types";
13-
import type { MinimumConfig, Resolver } from "./resolver";
13+
import type { MinimumConfig } from "./resolver";
1414

1515
import { type GetValue, unwrap } from "./unwrap";
1616
import { contextLookup } from "./contextLookup";
1717
import { sortRows } from "./sortRows";
1818
import SemanticVersion from "./semanticversion";
1919
import { isBigInt, jsonStringifyWithBigInt } from "./bigIntUtils";
2020

21+
/**
22+
* Minimal interface for resolving segments and encryption keys during evaluation.
23+
* This interface is used internally to decouple evaluate.ts from the full Resolver.
24+
* @internal
25+
*/
26+
interface SegmentResolver {
27+
raw(key: string): MinimumConfig | undefined;
28+
get(key: string, contexts?: Contexts): unknown;
29+
}
30+
2131
const getHashByPropertyValue = (
2232
value: ConfigValue | undefined,
2333
contexts: Contexts
@@ -102,7 +112,7 @@ const propContainsOneOf = (
102112
const inSegment = (
103113
criterion: Criterion,
104114
contexts: Contexts,
105-
resolver: Resolver
115+
resolver: SegmentResolver
106116
): boolean => {
107117
const segmentKey = criterion.valueToMatch?.string;
108118

@@ -282,7 +292,7 @@ const allCriteriaMatch = (
282292
value: ConditionalValue,
283293
namespace: string | undefined,
284294
contexts: Contexts,
285-
resolver: Resolver
295+
resolver: SegmentResolver
286296
): boolean => {
287297
if (value.criteria === undefined) {
288298
return true;
@@ -380,7 +390,7 @@ const matchingConfigValue = (
380390
projectEnvId: ProjectEnvId,
381391
namespace: string | undefined,
382392
contexts: Contexts,
383-
resolver: Resolver
393+
resolver: SegmentResolver
384394
): [number, number, ConfigValue | undefined] => {
385395
let match: ConfigValue | undefined;
386396
let conditionalValueIndex: number = -1;
@@ -413,7 +423,7 @@ export interface EvaluateArgs {
413423
projectEnvId: ProjectEnvId;
414424
namespace: string | undefined;
415425
contexts: Contexts;
416-
resolver: Resolver;
426+
resolver: SegmentResolver;
417427
}
418428

419429
export interface Evaluation {

src/reforge.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { apiClient, type ApiClient } from "./apiClient";
22
import { loadConfig } from "./loadConfig";
3-
import { Resolver, type MinimumConfig, type ResolverAPI } from "./resolver";
3+
import { Resolver, type MinimumConfig } from "./resolver";
44
import { Sources } from "./sources";
55
import { jsonStringifyWithBigInt } from "./bigIntUtils";
66
import {
@@ -25,6 +25,7 @@ import type {
2525
import { LOG_LEVEL_RANK_LOOKUP, type makeLogger } from "./logger";
2626
import { SSEConnection } from "./sseConnection";
2727
import { TelemetryReporter } from "./telemetry/reporter";
28+
import { ReforgeClient } from "./reforgeClient";
2829

2930
import type { ContextUploadMode } from "./telemetry/types";
3031
import { knownLoggers } from "./telemetry/knownLoggers";
@@ -83,7 +84,8 @@ export interface ReforgeInterface {
8384
) => boolean;
8485
logger: (
8586
loggerName: string,
86-
defaultLevel: LogLevel
87+
defaultLevel?: LogLevel,
88+
contexts?: Contexts | ContextObj
8789
) => ReturnType<typeof makeLogger>;
8890
shouldLog: ({
8991
loggerName,
@@ -99,7 +101,11 @@ export interface ReforgeInterface {
99101
getLogLevel: (loggerName: string) => LogLevel;
100102
telemetry?: Telemetry;
101103
updateIfStalerThan: (durationInMs: number) => Promise<void> | undefined;
102-
withContext: (contexts: Contexts | ContextObj) => ResolverAPI;
104+
withContext: (contexts: Contexts | ContextObj) => ReforgeInterface;
105+
inContext: <T>(
106+
contexts: Contexts | ContextObj,
107+
func: (reforge: ReforgeInterface) => T
108+
) => T;
103109
addConfigChangeListener: (callback: GlobalListenerCallback) => () => void;
104110
}
105111

@@ -129,6 +135,7 @@ interface ConstructorProps {
129135
onUpdate?: (configs: Array<Config | MinimumConfig>) => void;
130136
}
131137

138+
132139
class Reforge implements ReforgeInterface {
133140
private readonly sdkKey: string;
134141
readonly sources: Sources;
@@ -411,17 +418,17 @@ class Reforge implements ReforgeInterface {
411418

412419
inContext<T>(
413420
contexts: Contexts | ContextObj,
414-
func: (reforge: Resolver) => T
421+
func: (reforge: ReforgeInterface) => T
415422
): T {
416423
requireResolver(this.resolver);
417424

418-
return func(this.resolver.cloneWithContext(contexts));
425+
return func(new ReforgeClient(this, contexts));
419426
}
420427

421-
withContext(contexts: Contexts | ContextObj): ResolverAPI {
428+
withContext(contexts: Contexts | ContextObj): ReforgeInterface {
422429
requireResolver(this.resolver);
423430

424-
return this.resolver.cloneWithContext(contexts);
431+
return new ReforgeClient(this, contexts);
425432
}
426433

427434
get<K extends keyof TypedNodeServerConfigurationRaw>(
@@ -562,5 +569,4 @@ export {
562569
type Contexts,
563570
SchemaType,
564571
type Provided,
565-
Resolver,
566572
};

0 commit comments

Comments
 (0)