Skip to content

Commit 0ef2f5f

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

10 files changed

Lines changed: 414 additions & 81 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
};

src/__tests__/integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { jsonStringifyWithBigInt } from "../bigIntUtils";
22
import { Reforge } from "../reforge";
3-
import type { ReforgeInterface } from "../reforge";
3+
import type { ReforgeInterface } from "../types";
44
import type { ResolverAPI } from "../resolver";
55
import type { GetValue } from "../unwrap";
66
import { tests } from "./integrationHelper";

src/__tests__/reforge.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ import rolloutFlag from "./fixtures/rolloutFlag";
1010
import envConfig from "./fixtures/envConfig";
1111
import propIsOneOf from "./fixtures/propIsOneOf";
1212
import propIsOneOfAndEndsWith from "./fixtures/propIsOneOfAndEndsWith";
13-
import {
14-
Reforge,
15-
type TypedNodeServerConfigurationRaw,
16-
MULTIPLE_INIT_WARNING,
17-
} from "../reforge";
13+
import { Reforge, MULTIPLE_INIT_WARNING } from "../reforge";
14+
import type { TypedNodeServerConfigurationRaw } from "../types";
1815
import type { Contexts, ProjectEnvId, Config, ConfigValue } from "../types";
1916
import {
2017
LogLevel,
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: 9 additions & 65 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 {
@@ -21,10 +21,14 @@ import type {
2121
ConfigValue,
2222
ConfigRow,
2323
Provided,
24+
TypedNodeServerConfigurationRaw,
25+
Telemetry,
26+
ReforgeInterface,
2427
} from "./types";
2528
import { LOG_LEVEL_RANK_LOOKUP, type makeLogger } from "./logger";
2629
import { SSEConnection } from "./sseConnection";
2730
import { TelemetryReporter } from "./telemetry/reporter";
31+
import { ReforgeClient } from "./reforgeClient";
2832

2933
import type { ContextUploadMode } from "./telemetry/types";
3034
import { knownLoggers } from "./telemetry/knownLoggers";
@@ -51,65 +55,6 @@ function requireResolver(
5155
}
5256
}
5357

54-
// @reforge-com/cli#generate will create interfaces into this namespace for Node to consume
55-
// eslint-disable-next-line @typescript-eslint/no-empty-interface
56-
export interface NodeServerConfigurationRaw {}
57-
// eslint-disable-next-line @typescript-eslint/no-empty-interface
58-
export interface NodeServerConfigurationAccessor {}
59-
60-
export type TypedNodeServerConfigurationRaw =
61-
keyof NodeServerConfigurationRaw extends never
62-
? Record<string, unknown>
63-
: {
64-
[TypedFlagKey in keyof NodeServerConfigurationRaw]: NodeServerConfigurationRaw[TypedFlagKey];
65-
};
66-
67-
export type TypedNodeServerConfigurationAccessor =
68-
keyof NodeServerConfigurationAccessor extends never
69-
? Record<string, unknown>
70-
: {
71-
[TypedFlagKey in keyof NodeServerConfigurationAccessor]: NodeServerConfigurationAccessor[TypedFlagKey];
72-
};
73-
74-
export interface ReforgeInterface {
75-
get: <K extends keyof TypedNodeServerConfigurationRaw>(
76-
key: K,
77-
contexts?: Contexts | ContextObj,
78-
defaultValue?: TypedNodeServerConfigurationRaw[K]
79-
) => TypedNodeServerConfigurationRaw[K];
80-
isFeatureEnabled: <K extends keyof TypedNodeServerConfigurationRaw>(
81-
key: K,
82-
contexts?: Contexts | ContextObj
83-
) => boolean;
84-
logger: (
85-
loggerName: string,
86-
defaultLevel: LogLevel
87-
) => ReturnType<typeof makeLogger>;
88-
shouldLog: ({
89-
loggerName,
90-
desiredLevel,
91-
defaultLevel,
92-
contexts,
93-
}: {
94-
loggerName: string;
95-
desiredLevel: LogLevel;
96-
defaultLevel?: LogLevel;
97-
contexts?: Contexts | ContextObj;
98-
}) => boolean;
99-
getLogLevel: (loggerName: string) => LogLevel;
100-
telemetry?: Telemetry;
101-
updateIfStalerThan: (durationInMs: number) => Promise<void> | undefined;
102-
withContext: (contexts: Contexts | ContextObj) => ResolverAPI;
103-
addConfigChangeListener: (callback: GlobalListenerCallback) => () => void;
104-
}
105-
106-
export interface Telemetry {
107-
knownLoggers: ReturnType<typeof knownLoggers>;
108-
contextShapes: ReturnType<typeof contextShapes>;
109-
exampleContexts: ReturnType<typeof exampleContexts>;
110-
evaluationSummaries: ReturnType<typeof evaluationSummaries>;
111-
}
112-
11358
interface ConstructorProps {
11459
sdkKey: string;
11560
sources?: string[];
@@ -411,17 +356,17 @@ class Reforge implements ReforgeInterface {
411356

412357
inContext<T>(
413358
contexts: Contexts | ContextObj,
414-
func: (reforge: Resolver) => T
359+
func: (reforge: ReforgeInterface) => T
415360
): T {
416361
requireResolver(this.resolver);
417362

418-
return func(this.resolver.cloneWithContext(contexts));
363+
return func(new ReforgeClient(this, contexts));
419364
}
420365

421-
withContext(contexts: Contexts | ContextObj): ResolverAPI {
366+
withContext(contexts: Contexts | ContextObj): ReforgeInterface {
422367
requireResolver(this.resolver);
423368

424-
return this.resolver.cloneWithContext(contexts);
369+
return new ReforgeClient(this, contexts);
425370
}
426371

427372
get<K extends keyof TypedNodeServerConfigurationRaw>(
@@ -562,5 +507,4 @@ export {
562507
type Contexts,
563508
SchemaType,
564509
type Provided,
565-
Resolver,
566510
};

0 commit comments

Comments
 (0)