@zudojs/feature-flags 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,6 +171,11 @@ remembers a key the provider does not know for `missingFlagTtlMs` (default
171
171
  turn every evaluation into a remote round trip. The memory is dropped on
172
172
  every reload.
173
173
 
174
+ A provider that fails is left alone for `providerCooloffMs` (default 5 s;
175
+ `0` disables it) before it is probed again, so an outage costs one pair of
176
+ calls per window instead of two per evaluation. The first successful call
177
+ closes the window, and `refresh()` always probes.
178
+
174
179
  `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
175
180
  including an empty `FEATURE_X=`, stays a string.
176
181
 
@@ -200,6 +205,12 @@ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
200
205
  An unreachable store never enables a flag. Set `throwOnProviderError: true`
201
206
  to own the failure yourself instead.
202
207
 
208
+ `snapshot()` and `getAll()` reject with `FeatureFlagProviderError` when the
209
+ flags were never loaded: an empty `Map` cannot be told apart from "no flags
210
+ are configured", and shipping one to a browser turns an outage into every
211
+ flag being off. Once a load has succeeded they keep serving that data, even
212
+ if a later reload fails.
213
+
203
214
  ```typescript
204
215
  const flags = createFeatureFlags({
205
216
  provider,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cool-off window for a provider that is failing.
3
+ *
4
+ * @module featureFlags/featureFlags.cooloff
5
+ */
6
+ /** Default cool-off after a provider failure, in ms. */
7
+ export declare const DEFAULT_PROVIDER_COOLOFF_MS = 5000;
8
+ /** Tracks whether a failing provider should be probed again yet. */
9
+ export interface ProviderCooloff {
10
+ /** Whether the provider is inside its cool-off window and must not be called. */
11
+ active(): boolean;
12
+ /** Records a failed provider call, opening a new window. */
13
+ recordFailure(): void;
14
+ /** Records a successful provider call, closing any open window. */
15
+ recordSuccess(): void;
16
+ }
17
+ /**
18
+ * Create a cool-off gate.
19
+ *
20
+ * Without one, every evaluation against an unreachable store re-ran
21
+ * `getAll()` and `get(key)` — two remote round trips per call, each one
22
+ * waiting out its own timeout, for as long as the outage lasted. After a
23
+ * failure the provider is probed at most once per `cooloffMs`; a success
24
+ * clears the window immediately. `cooloffMs <= 0` disables the gate.
25
+ */
26
+ export declare function createProviderCooloff(cooloffMs: number): ProviderCooloff;
27
+ //# sourceMappingURL=featureFlags.cooloff.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cool-off window for a provider that is failing.
3
+ *
4
+ * @module featureFlags/featureFlags.cooloff
5
+ */
6
+ /** Default cool-off after a provider failure, in ms. */
7
+ export const DEFAULT_PROVIDER_COOLOFF_MS = 5_000;
8
+ /**
9
+ * Create a cool-off gate.
10
+ *
11
+ * Without one, every evaluation against an unreachable store re-ran
12
+ * `getAll()` and `get(key)` — two remote round trips per call, each one
13
+ * waiting out its own timeout, for as long as the outage lasted. After a
14
+ * failure the provider is probed at most once per `cooloffMs`; a success
15
+ * clears the window immediately. `cooloffMs <= 0` disables the gate.
16
+ */
17
+ export function createProviderCooloff(cooloffMs) {
18
+ const window = Number.isFinite(cooloffMs) ? Math.max(0, cooloffMs) : 0;
19
+ let until = 0;
20
+ return {
21
+ active() {
22
+ if (window === 0)
23
+ return false;
24
+ return Date.now() < until;
25
+ },
26
+ recordFailure() {
27
+ until = Date.now() + window;
28
+ },
29
+ recordSuccess() {
30
+ until = 0;
31
+ },
32
+ };
33
+ }
34
+ //# sourceMappingURL=featureFlags.cooloff.js.map
@@ -41,6 +41,12 @@ export interface FeatureFlagsOptions {
41
41
  * reload (`refresh()`, a provider change notification).
42
42
  */
43
43
  readonly missingFlagTtlMs?: number;
44
+ /**
45
+ * How long a failing provider is left alone before it is probed again, in
46
+ * ms. Default: 5,000. `0` calls the provider on every evaluation, as
47
+ * before. A successful call clears the window at once.
48
+ */
49
+ readonly providerCooloffMs?: number;
44
50
  }
45
51
  /** The public FeatureFlags API. */
46
52
  export interface FeatureFlags {
@@ -10,6 +10,7 @@ import { evaluateFlag } from "../evaluator/evaluator.core.js";
10
10
  import { FeatureFlagNotFoundError, FeatureFlagProviderError, } from "../featureFlagErrors/featureFlagError.types.js";
11
11
  import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
12
12
  import { createMissCache, DEFAULT_MISSING_FLAG_TTL_MS, } from "./featureFlags.missCache.js";
13
+ import { createProviderCooloff, DEFAULT_PROVIDER_COOLOFF_MS, } from "./featureFlags.cooloff.js";
13
14
  /**
14
15
  * Create a FeatureFlags instance.
15
16
  *
@@ -17,8 +18,9 @@ import { createMissCache, DEFAULT_MISSING_FLAG_TTL_MS, } from "./featureFlags.mi
17
18
  * @returns A FeatureFlags API object.
18
19
  */
19
20
  export function createFeatureFlags(options) {
20
- const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, } = options;
21
+ const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, providerCooloffMs = DEFAULT_PROVIDER_COOLOFF_MS, } = options;
21
22
  const misses = createMissCache(missingFlagTtlMs);
23
+ const cooloff = createProviderCooloff(providerCooloffMs);
22
24
  let registry = createFeatureFlagRegistry();
23
25
  let loaded = false;
24
26
  /**
@@ -27,6 +29,7 @@ export function createFeatureFlags(options) {
27
29
  * @returns `false` when the failure was contained.
28
30
  */
29
31
  function handleProviderError(error, source) {
32
+ cooloff.recordFailure();
30
33
  if (throwOnProviderError)
31
34
  throw error;
32
35
  onError?.(error instanceof Error
@@ -42,6 +45,7 @@ export function createFeatureFlags(options) {
42
45
  registry = createFeatureFlagRegistry(flags);
43
46
  loaded = true;
44
47
  misses.clear();
48
+ cooloff.recordSuccess();
45
49
  return true;
46
50
  }
47
51
  catch (error) {
@@ -54,20 +58,42 @@ export function createFeatureFlags(options) {
54
58
  // every single evaluation.
55
59
  if (loaded)
56
60
  return true;
61
+ // A provider that just failed is left alone until its window closes,
62
+ // rather than being re-queried (and waited on) by every evaluation.
63
+ if (cooloff.active())
64
+ return false;
57
65
  return load();
58
66
  }
67
+ /**
68
+ * Fails a bulk read that has no flags because the store could not be read.
69
+ *
70
+ * `evaluate()` can say `reason: "error"` per flag; a `Map` or an array has
71
+ * nowhere to put that, and an empty one is indistinguishable from "no flags
72
+ * are configured" — a total outage would otherwise ship to a browser as
73
+ * every flag being off. `onError` still sees the underlying failure first.
74
+ *
75
+ * @throws {FeatureFlagProviderError} When the flags were never loaded.
76
+ */
77
+ function requireAvailable(available, operation) {
78
+ if (available)
79
+ return;
80
+ throw new FeatureFlagProviderError(`Feature flags are unavailable: ${operation}() cannot report flags because the provider could not be loaded.`, { provider: "FeatureFlagProvider.getAll" });
81
+ }
59
82
  async function resolveFlag(key) {
60
83
  const known = registry.get(key);
61
84
  if (known)
62
85
  return { flag: known, reachable: true };
63
86
  if (misses.has(key))
64
87
  return { flag: undefined, reachable: true };
88
+ if (cooloff.active())
89
+ return { flag: undefined, reachable: false };
65
90
  try {
66
91
  const flag = await provider.get(key);
67
92
  if (flag)
68
93
  registry.set(flag);
69
94
  else if (loaded)
70
95
  misses.add(key);
96
+ cooloff.recordSuccess();
71
97
  return { flag, reachable: true };
72
98
  }
73
99
  catch (error) {
@@ -86,6 +112,7 @@ export function createFeatureFlags(options) {
86
112
  registry = createFeatureFlagRegistry(flags);
87
113
  loaded = true;
88
114
  misses.clear();
115
+ cooloff.recordSuccess();
89
116
  });
90
117
  const api = {
91
118
  async isEnabled(key, context) {
@@ -136,7 +163,7 @@ export function createFeatureFlags(options) {
136
163
  return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
137
164
  },
138
165
  async snapshot(context) {
139
- await ensureLoaded();
166
+ requireAvailable(await ensureLoaded(), "snapshot");
140
167
  const mergedCtx = mergeContext(defaultContext, context);
141
168
  const flags = registry.getAll();
142
169
  const results = new Map();
@@ -163,7 +190,7 @@ export function createFeatureFlags(options) {
163
190
  await load();
164
191
  },
165
192
  async getAll() {
166
- await ensureLoaded();
193
+ requireAvailable(await ensureLoaded(), "getAll");
167
194
  return registry.getAll();
168
195
  },
169
196
  close() {
@@ -13,6 +13,15 @@ import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featu
13
13
  export { isPlainObject } from "@zudojs/types";
14
14
  /**
15
15
  * Check if two feature flag values are equal.
16
+ *
17
+ * The comparison is structural, not `JSON.stringify`: key order does not
18
+ * matter, a key whose value is `undefined` is not the same as an absent key,
19
+ * and a self-referencing value is compared instead of throwing. Arrays match
20
+ * element-wise, `Date`s by instant, and `NaN` equals `NaN`. Any other class
21
+ * instance (Map, Set, RegExp, …) matches only by reference.
22
+ *
23
+ * `@zudojs/types` owns the shared type guards but has no deep-equality helper,
24
+ * so the walk lives here rather than pulling in a new dependency.
16
25
  */
17
26
  export declare function valuesEqual(a: FeatureFlagValue, b: FeatureFlagValue): boolean;
18
27
  //# sourceMappingURL=utils.helper.d.ts.map
@@ -12,17 +12,69 @@
12
12
  export { isPlainObject } from "@zudojs/types";
13
13
  /**
14
14
  * Check if two feature flag values are equal.
15
+ *
16
+ * The comparison is structural, not `JSON.stringify`: key order does not
17
+ * matter, a key whose value is `undefined` is not the same as an absent key,
18
+ * and a self-referencing value is compared instead of throwing. Arrays match
19
+ * element-wise, `Date`s by instant, and `NaN` equals `NaN`. Any other class
20
+ * instance (Map, Set, RegExp, …) matches only by reference.
21
+ *
22
+ * `@zudojs/types` owns the shared type guards but has no deep-equality helper,
23
+ * so the walk lives here rather than pulling in a new dependency.
15
24
  */
16
25
  export function valuesEqual(a, b) {
26
+ return deepEqual(a, b, new Map());
27
+ }
28
+ function deepEqual(a, b, seen) {
17
29
  if (a === b)
18
30
  return true;
19
- if (a === null || b === null)
31
+ if (typeof a === "number" && typeof b === "number") {
32
+ return Number.isNaN(a) && Number.isNaN(b);
33
+ }
34
+ if (typeof a !== "object" || typeof b !== "object")
20
35
  return false;
21
- if (typeof a !== typeof b)
36
+ if (a === null || b === null)
22
37
  return false;
23
- if (typeof a === "object" && typeof b === "object") {
24
- return JSON.stringify(a) === JSON.stringify(b);
38
+ // A pair already on the comparison stack is assumed equal: the cycle it
39
+ // closes is only reached through positions that matched.
40
+ const pending = seen.get(a);
41
+ if (pending?.has(b) === true)
42
+ return true;
43
+ if (pending)
44
+ pending.add(b);
45
+ else
46
+ seen.set(a, new Set([b]));
47
+ try {
48
+ return deepEqualObjects(a, b, seen);
25
49
  }
26
- return false;
50
+ finally {
51
+ seen.get(a)?.delete(b);
52
+ }
53
+ }
54
+ function deepEqualObjects(a, b, seen) {
55
+ if (a instanceof Date || b instanceof Date) {
56
+ return (a instanceof Date && b instanceof Date && a.getTime() === b.getTime());
57
+ }
58
+ if (Array.isArray(a) || Array.isArray(b)) {
59
+ if (!Array.isArray(a) || !Array.isArray(b))
60
+ return false;
61
+ if (a.length !== b.length)
62
+ return false;
63
+ return a.every((item, index) => deepEqual(item, b[index], seen));
64
+ }
65
+ if (!isComparableObject(a) || !isComparableObject(b))
66
+ return false;
67
+ const aKeys = Object.keys(a);
68
+ const bKeys = Object.keys(b);
69
+ if (aKeys.length !== bKeys.length)
70
+ return false;
71
+ const other = b;
72
+ return aKeys.every((key) => Object.hasOwn(other, key) &&
73
+ deepEqual(a[key], other[key], seen));
74
+ }
75
+ /** Plain objects and null-prototype records; anything else compares by reference. */
76
+ function isComparableObject(value) {
77
+ const proto = Object.getPrototypeOf(value);
78
+ return proto === Object.prototype || proto === null;
27
79
  }
28
80
  //# sourceMappingURL=utils.helper.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/feature-flags",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Feature flag system with deterministic rollouts, rule engine, providers, variants, snapshots, and evaluation context.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,8 +11,8 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.1.0",
15
- "@zudojs/types": "1.1.0"
14
+ "@zudojs/errors": "1.2.0",
15
+ "@zudojs/types": "1.1.1"
16
16
  },
17
17
  "devDependencies": {
18
18
  "typescript": "7.0.2",