From d241f14444d551775fc70292d790167c3bc3c9ac Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Tue, 2 Sep 2025 21:17:02 -0400 Subject: [PATCH] feat: add formal support for extract/hydrate of reforge config --- README.md | 28 +++++++++++---------- src/afterEvaluationCallback.test.ts | 8 +++--- src/reforge.test.ts | 38 +++++++++++++++++++++++------ src/reforge.ts | 32 +++++++++++++++++++++--- 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e8e7d3a..b18d587 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,21 @@ if (reforge.isEnabled('cool-feature') { setTimeout(ping, reforge.get('ping-delay')); ``` -Here's an explanation of each property - -| property | example | purpose | -| --------------- | ------------------------------------- | -------------------------------------------------------------------------------------------- | -| `isEnabled` | `reforge.isEnabled("new-logo")` | returns a boolean (default `false`) if a feature is enabled based on the current context | -| `get` | `reforge.get('retry-count')` | returns the value of a flag or config evaluated in the current context | -| `getDuration` | `reforge.getDuration('http.timeout')` | returns a duration object `{seconds: number, ms: number}` | -| `loaded` | `if (reforge.loaded) { ... }` | a boolean indicating whether reforge content has loaded | -| `shouldLog` | `if (reforge.shouldLog(...)) {` | returns a boolean indicating whether the proposed log level is valid for the current context | -| `poll` | `reforge.poll({frequencyInMs})` | starts polling every `frequencyInMs` ms. | -| `stopPolling` | `reforge.stopPolling()` | stops the polling process | -| `context` | `reforge.context` | get the current context (after `init()`). | -| `updateContext` | `reforge.updateContext(newContext)` | update the context and refetch. Pass `false` as a second argument to skip refetching | +## Client API + +| property | example | purpose | +| --------------- | -------------------------------------- | -------------------------------------------------------------------------------------------- | +| `isEnabled` | `reforge.isEnabled("new-logo")` | returns a boolean (default `false`) if a feature is enabled based on the current context | +| `get` | `reforge.get('retry-count')` | returns the value of a flag or config evaluated in the current context | +| `getDuration` | `reforge.getDuration('http.timeout')` | returns a duration object `{seconds: number, ms: number}` | +| `loaded` | `if (reforge.loaded) { ... }` | a boolean indicating whether reforge content has loaded | +| `shouldLog` | `if (reforge.shouldLog(...)) {` | returns a boolean indicating whether the proposed log level is valid for the current context | +| `poll` | `reforge.poll({frequencyInMs})` | starts polling every `frequencyInMs` ms. | +| `stopPolling` | `reforge.stopPolling()` | stops the polling process | +| `context` | `reforge.context` | get the current context (after `init()`). | +| `updateContext` | `reforge.updateContext(newContext)` | update the context and refetch. Pass `false` as a second argument to skip refetching | +| `extract` | `reforge.extract()` | returns the current config as a plain object of key, config value pairs | +| `hydrate` | `reforge.hydrate(configurationObject)` | sets the current config based on a plain object of key, config value pairs | ## `shouldLog()` diff --git a/src/afterEvaluationCallback.test.ts b/src/afterEvaluationCallback.test.ts index 763810d..cc334f0 100644 --- a/src/afterEvaluationCallback.test.ts +++ b/src/afterEvaluationCallback.test.ts @@ -25,7 +25,7 @@ describe("afterEvaluationCallback", () => { const reforge = new Reforge(); reforge.afterEvaluationCallback = callback; - reforge.setConfig({ turbo: 2.5 }); + reforge.hydrate({ turbo: 2.5 }); expect(callback).not.toHaveBeenCalled(); @@ -46,7 +46,7 @@ describe("afterEvaluationCallback", () => { reforge.afterEvaluationCallback = callback; - reforge.setConfig({ turbo: 2.5 }); + reforge.hydrate({ turbo: 2.5 }); expect(callback).not.toHaveBeenCalled(); @@ -72,7 +72,7 @@ describe("afterEvaluationCallback", () => { await waitForAsyncCall(); expect(callback).toHaveBeenCalledTimes(0); - reforge.setConfig({ foo: true }); + reforge.hydrate({ foo: true }); expect(reforge.isEnabled("foo")).toBe(true); @@ -94,7 +94,7 @@ describe("afterEvaluationCallback", () => { await waitForAsyncCall(); expect(callback).toHaveBeenCalledTimes(0); - reforge.setConfig({ foo: true }); + reforge.hydrate({ foo: true }); expect(reforge.isEnabled("foo")).toBe(true); diff --git a/src/reforge.test.ts b/src/reforge.test.ts index 1d96b83..d963437 100644 --- a/src/reforge.test.ts +++ b/src/reforge.test.ts @@ -213,11 +213,11 @@ describe("poll", () => { }); }); -describe("setConfig", () => { +describe("hydrate", () => { it("works when types are not provided", () => { expect(reforge.configs).toEqual({}); - reforge.setConfig({ + reforge.hydrate({ turbo: 2.5, foo: true, jsonExample: { foo: "bar", baz: 123 }, @@ -287,7 +287,7 @@ describe("bootstrapping", () => { }); test("get", () => { - reforge.setConfig({ + reforge.hydrate({ evaluations: { turbo: { value: { double: 2.5 } }, durationExample: { value: { duration: { millis: 1884000, definition: "PT1884S" } } }, @@ -310,7 +310,7 @@ test("get", () => { }); test("getDuration", () => { - reforge.setConfig({ + reforge.hydrate({ evaluations: { turbo: { value: { double: 2.5 } }, durationExample: { @@ -333,11 +333,33 @@ test("isEnabled", () => { // it is false when no config is loaded expect(reforge.isEnabled("foo")).toBe(false); - reforge.setConfig({ foo: true }); + reforge.hydrate({ foo: true }); expect(reforge.isEnabled("foo")).toBe(true); }); +describe("extract", () => { + it("correctly extracts configuration values", () => { + reforge.hydrate({ + turbo: 2.5, + foo: true, + jsonExample: { foo: "bar", baz: 123 }, + }); + + const extracted = reforge.extract(); + expect(extracted).toEqual({ + turbo: 2.5, + foo: true, + jsonExample: { foo: "bar", baz: 123 }, + }); + }); + + it("returns an empty object when no configs are set", () => { + const extracted = reforge.extract(); + expect(extracted).toEqual({}); + }); +}); + describe("shouldLog", () => { test("compares against the default level where there is no value", () => { expect( @@ -358,7 +380,7 @@ describe("shouldLog", () => { }); test("compares against the value when present", () => { - reforge.setConfig({ + reforge.hydrate({ "log-level.example": "INFO", }); @@ -382,7 +404,7 @@ describe("shouldLog", () => { test("traverses the hierarchy to get the closest level for the loggerName", () => { const loggerName = "some.test.name.with.more.levels"; - reforge.setConfig({ + reforge.hydrate({ "log-level.some.test.name": "TRACE", "log-level.some.test": "DEBUG", "log-level.irrelevant": "ERROR", @@ -422,7 +444,7 @@ describe("shouldLog", () => { }); it("can use the root log level setting if nothing is found in the hierarchy", () => { - reforge.setConfig({ + reforge.hydrate({ "log-level": "INFO", }); diff --git a/src/reforge.ts b/src/reforge.ts index 3df98ff..122e689 100644 --- a/src/reforge.ts +++ b/src/reforge.ts @@ -135,7 +135,24 @@ export class Reforge { return this.load(); } - get configs(): { [key: string]: Config } { + extract(): Record { + return Object.entries(this._configs).reduce( + (agg, [key, value]) => ({ + ...agg, + [key]: value.value, + }), + {} as Record + ); + } + + hydrate(rawValues: RawConfigWithoutTypes | EvaluationPayload): void { + this.setConfigPrivate(rawValues); + } + + get configs(): Record { + // eslint-disable-next-line no-console + console.warn("\x1b[33m%s\x1b[0m", 'Deprecated: Use "prefab.extract" instead'); + return this._configs; } @@ -175,7 +192,7 @@ export class Reforge { const bootstrapContext = new Context(reforgeBootstrap.context); if (this.context.equals(bootstrapContext)) { - this.setConfig({ evaluations: reforgeBootstrap.evaluations }); + this.setConfigPrivate({ evaluations: reforgeBootstrap.evaluations }); return Promise.resolve(); } } @@ -186,7 +203,7 @@ export class Reforge { return this.loader .load() .then((rawValues: any) => { - this.setConfig(rawValues as EvaluationPayload); + this.setConfigPrivate(rawValues as EvaluationPayload); }) .finally(() => { if (this.pollStatus.status === "running") { @@ -255,6 +272,13 @@ export class Reforge { } setConfig(rawValues: RawConfigWithoutTypes | EvaluationPayload) { + // eslint-disable-next-line no-console + console.warn("\x1b[33m%s\x1b[0m", 'Deprecated: Use "prefab.hydrate" instead'); + + this.setConfigPrivate(rawValues); + } + + private setConfigPrivate(rawValues: RawConfigWithoutTypes | EvaluationPayload) { this._configs = Config.digest(rawValues); this.loaded = true; } @@ -275,7 +299,7 @@ export class Reforge { return undefined; } - const config = this.configs[key]; + const config = this._configs[key]; const value = config?.value;