@zudojs/feature-flags 0.1.0 → 1.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -13
  3. package/dist/evaluator/evaluator.core.d.ts +15 -1
  4. package/dist/evaluator/evaluator.core.js +48 -10
  5. package/dist/evaluator/evaluatorAttribute.d.ts +5 -0
  6. package/dist/evaluator/evaluatorAttribute.js +62 -4
  7. package/dist/evaluator/evaluatorRule.core.js +32 -7
  8. package/dist/featureFlagTypes/featureFlagProvider.d.ts +10 -0
  9. package/dist/featureFlagTypes/index.d.ts +1 -1
  10. package/dist/featureFlags/featureFlags.core.d.ts +28 -8
  11. package/dist/featureFlags/featureFlags.core.js +101 -34
  12. package/dist/featureFlags/featureFlags.resolve.d.ts +4 -3
  13. package/dist/featureFlags/featureFlags.resolve.js +29 -12
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +1 -0
  16. package/dist/provider/providerCached.core.d.ts +3 -3
  17. package/dist/provider/providerCached.core.js +22 -3
  18. package/dist/provider/providerComposite.core.d.ts +2 -2
  19. package/dist/provider/providerComposite.core.js +62 -14
  20. package/dist/provider/providerEnvironment.core.d.ts +3 -3
  21. package/dist/provider/providerEnvironment.core.js +9 -4
  22. package/dist/provider/providerMemory.core.d.ts +18 -2
  23. package/dist/provider/providerMemory.core.js +38 -4
  24. package/package.json +26 -14
  25. package/dist/evaluator/evaluator.core.d.ts.map +0 -1
  26. package/dist/evaluator/evaluator.core.js.map +0 -1
  27. package/dist/evaluator/evaluatorAttribute.d.ts.map +0 -1
  28. package/dist/evaluator/evaluatorAttribute.js.map +0 -1
  29. package/dist/evaluator/evaluatorRule.core.d.ts.map +0 -1
  30. package/dist/evaluator/evaluatorRule.core.js.map +0 -1
  31. package/dist/evaluator/index.d.ts.map +0 -1
  32. package/dist/evaluator/index.js.map +0 -1
  33. package/dist/featureFlagErrors/featureFlagError.base.d.ts.map +0 -1
  34. package/dist/featureFlagErrors/featureFlagError.base.js.map +0 -1
  35. package/dist/featureFlagErrors/featureFlagError.types.d.ts.map +0 -1
  36. package/dist/featureFlagErrors/featureFlagError.types.js.map +0 -1
  37. package/dist/featureFlagErrors/index.d.ts.map +0 -1
  38. package/dist/featureFlagErrors/index.js.map +0 -1
  39. package/dist/featureFlagTypes/featureFlag.interface.d.ts.map +0 -1
  40. package/dist/featureFlagTypes/featureFlag.interface.js.map +0 -1
  41. package/dist/featureFlagTypes/featureFlagContext.d.ts.map +0 -1
  42. package/dist/featureFlagTypes/featureFlagContext.js.map +0 -1
  43. package/dist/featureFlagTypes/featureFlagEvaluation.d.ts.map +0 -1
  44. package/dist/featureFlagTypes/featureFlagEvaluation.js.map +0 -1
  45. package/dist/featureFlagTypes/featureFlagProvider.d.ts.map +0 -1
  46. package/dist/featureFlagTypes/featureFlagProvider.js.map +0 -1
  47. package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.d.ts.map +0 -1
  48. package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.js.map +0 -1
  49. package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.d.ts.map +0 -1
  50. package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.js.map +0 -1
  51. package/dist/featureFlagTypes/featureFlagRule/index.d.ts.map +0 -1
  52. package/dist/featureFlagTypes/featureFlagRule/index.js.map +0 -1
  53. package/dist/featureFlagTypes/index.d.ts.map +0 -1
  54. package/dist/featureFlagTypes/index.js.map +0 -1
  55. package/dist/featureFlags/featureFlags.core.d.ts.map +0 -1
  56. package/dist/featureFlags/featureFlags.core.js.map +0 -1
  57. package/dist/featureFlags/featureFlags.resolve.d.ts.map +0 -1
  58. package/dist/featureFlags/featureFlags.resolve.js.map +0 -1
  59. package/dist/featureFlags/index.d.ts.map +0 -1
  60. package/dist/featureFlags/index.js.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/index.js.map +0 -1
  63. package/dist/provider/index.d.ts.map +0 -1
  64. package/dist/provider/index.js.map +0 -1
  65. package/dist/provider/providerCached.core.d.ts.map +0 -1
  66. package/dist/provider/providerCached.core.js.map +0 -1
  67. package/dist/provider/providerComposite.core.d.ts.map +0 -1
  68. package/dist/provider/providerComposite.core.js.map +0 -1
  69. package/dist/provider/providerEnvironment.core.d.ts.map +0 -1
  70. package/dist/provider/providerEnvironment.core.js.map +0 -1
  71. package/dist/provider/providerMemory.core.d.ts.map +0 -1
  72. package/dist/provider/providerMemory.core.js.map +0 -1
  73. package/dist/registry/index.d.ts.map +0 -1
  74. package/dist/registry/index.js.map +0 -1
  75. package/dist/registry/registry.core.d.ts.map +0 -1
  76. package/dist/registry/registry.core.js.map +0 -1
  77. package/dist/rollout/index.d.ts.map +0 -1
  78. package/dist/rollout/index.js.map +0 -1
  79. package/dist/rollout/rolloutBucketing.d.ts.map +0 -1
  80. package/dist/rollout/rolloutBucketing.js.map +0 -1
  81. package/dist/rollout/rolloutHashing.d.ts.map +0 -1
  82. package/dist/rollout/rolloutHashing.js.map +0 -1
  83. package/dist/utils/index.d.ts.map +0 -1
  84. package/dist/utils/index.js.map +0 -1
  85. package/dist/utils/utils.helper.d.ts.map +0 -1
  86. package/dist/utils/utils.helper.js.map +0 -1
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createFeatureFlagRegistry } from "../registry/registry.core.js";
9
9
  import { evaluateFlag } from "../evaluator/evaluator.core.js";
10
- import { FeatureFlagNotFoundError } from "../featureFlagErrors/featureFlagError.types.js";
10
+ import { FeatureFlagNotFoundError, FeatureFlagProviderError, } from "../featureFlagErrors/featureFlagError.types.js";
11
11
  import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
12
12
  /**
13
13
  * Create a FeatureFlags instance.
@@ -16,45 +16,103 @@ import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
16
16
  * @returns A FeatureFlags API object.
17
17
  */
18
18
  export function createFeatureFlags(options) {
19
- const { provider, defaultContext = {}, throwOnMissing = false } = options;
19
+ const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, } = options;
20
20
  let registry = createFeatureFlagRegistry();
21
+ let loaded = false;
22
+ /**
23
+ * Report a provider failure, or rethrow it when the caller asked to own it.
24
+ *
25
+ * @returns `false` when the failure was contained.
26
+ */
27
+ function handleProviderError(error, source) {
28
+ if (throwOnProviderError)
29
+ throw error;
30
+ onError?.(error instanceof Error
31
+ ? error
32
+ : new FeatureFlagProviderError(`Provider ${source} failed.`, {
33
+ cause: error,
34
+ }), source);
35
+ return false;
36
+ }
21
37
  async function load() {
22
- const flags = await provider.getAll();
23
- registry = createFeatureFlagRegistry(flags);
38
+ try {
39
+ const flags = await provider.getAll();
40
+ registry = createFeatureFlagRegistry(flags);
41
+ loaded = true;
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ return handleProviderError(error, "FeatureFlagProvider.getAll");
46
+ }
24
47
  }
25
48
  async function ensureLoaded() {
26
- if (registry.size === 0)
27
- await load();
49
+ // Keyed on whether a load has ever succeeded, not on `registry.size`: a
50
+ // provider that legitimately holds no flags used to be re-queried on
51
+ // every single evaluation.
52
+ if (loaded)
53
+ return true;
54
+ return load();
28
55
  }
29
56
  async function resolveFlag(key) {
30
- let flag = registry.get(key);
31
- if (!flag) {
32
- flag = await provider.get(key);
57
+ const known = registry.get(key);
58
+ if (known)
59
+ return { flag: known, reachable: true };
60
+ try {
61
+ const flag = await provider.get(key);
33
62
  if (flag)
34
63
  registry.set(flag);
64
+ return { flag, reachable: true };
65
+ }
66
+ catch (error) {
67
+ handleProviderError(error, "FeatureFlagProvider.get");
68
+ // A `get()` that failed is not a `get()` that found nothing: reporting
69
+ // it as `not_found` (or throwing FeatureFlagNotFoundError under
70
+ // `throwOnMissing`) told the caller the flag does not exist when the
71
+ // truth is that the store could not be asked.
72
+ return { flag: undefined, reachable: false };
35
73
  }
36
- return flag;
37
74
  }
38
- return {
75
+ // A provider that can announce changes is asked to: without this, a flag
76
+ // flipped at the source never reached an already-loaded instance until
77
+ // somebody called refresh() by hand.
78
+ let unsubscribe = provider.subscribe?.((flags) => {
79
+ registry = createFeatureFlagRegistry(flags);
80
+ loaded = true;
81
+ });
82
+ const api = {
39
83
  async isEnabled(key, context) {
40
- const result = await this.evaluate(key, context);
41
- return result.value;
84
+ const result = await api.evaluate(key, context);
85
+ // Strictly `true`. Returning the raw value meant a flag holding a
86
+ // string or a number — "off", 0.5, a config object — came back truthy
87
+ // from a method whose whole contract is a boolean.
88
+ return result.value === true;
42
89
  },
43
90
  async get(key, context) {
44
- const result = await this.evaluate(key, context);
91
+ const result = await api.evaluate(key, context);
45
92
  return result.value;
46
93
  },
47
94
  async getBoolean(key, defaultValue, context) {
48
- const result = await this.evaluate(key, context);
49
- if (result.reason === "not_found")
95
+ const result = await api.evaluate(key, context);
96
+ if (result.reason === "not_found" || result.reason === "error") {
50
97
  return defaultValue;
51
- return result.value;
98
+ }
99
+ return typeof result.value === "boolean" ? result.value : defaultValue;
52
100
  },
53
101
  async evaluate(key, context) {
54
- await ensureLoaded();
102
+ const available = await ensureLoaded();
55
103
  const mergedCtx = mergeContext(defaultContext, context);
56
- const flag = await resolveFlag(key);
104
+ const { flag, reachable } = await resolveFlag(key);
57
105
  if (!flag) {
106
+ if (!available || !reachable) {
107
+ // The store could not be reached and nothing is known about this
108
+ // flag. Report it as an error, not as a decision.
109
+ return {
110
+ key,
111
+ value: undefined,
112
+ reason: "error",
113
+ defaulted: true,
114
+ };
115
+ }
58
116
  if (throwOnMissing)
59
117
  throw new FeatureFlagNotFoundError(key);
60
118
  return {
@@ -64,17 +122,10 @@ export function createFeatureFlags(options) {
64
122
  defaulted: true,
65
123
  };
66
124
  }
67
- if (flag.dependencies && flag.dependencies.length > 0) {
68
- if (!resolveDependencies(key, registry)) {
69
- return {
70
- key,
71
- value: flag.defaultValue,
72
- reason: "dependency_disabled",
73
- defaulted: true,
74
- };
75
- }
76
- }
77
- return evaluateFlag(flag, mergedCtx);
125
+ const dependenciesSatisfied = !flag.dependencies ||
126
+ flag.dependencies.length === 0 ||
127
+ resolveDependencies(key, registry);
128
+ return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
78
129
  },
79
130
  async snapshot(context) {
80
131
  await ensureLoaded();
@@ -82,20 +133,36 @@ export function createFeatureFlags(options) {
82
133
  const flags = registry.getAll();
83
134
  const results = new Map();
84
135
  for (const flag of flags) {
85
- if (flag.visibility === "client") {
86
- results.set(flag.key, evaluateFlag(flag, mergedCtx));
87
- }
136
+ // Only flags explicitly marked client-visible. A flag that declares
137
+ // no visibility stays server-side: a snapshot is shipped to a
138
+ // browser, so the safe default is to withhold.
139
+ if (flag.visibility !== "client")
140
+ continue;
141
+ const dependenciesSatisfied = !flag.dependencies ||
142
+ flag.dependencies.length === 0 ||
143
+ resolveDependencies(flag.key, registry);
144
+ results.set(flag.key, evaluateFlag(flag, mergedCtx, { dependenciesSatisfied }));
88
145
  }
89
146
  return results;
90
147
  },
91
148
  async refresh() {
92
- await provider.refresh?.();
149
+ try {
150
+ await provider.refresh?.();
151
+ }
152
+ catch (error) {
153
+ handleProviderError(error, "FeatureFlagProvider.refresh");
154
+ }
93
155
  await load();
94
156
  },
95
157
  async getAll() {
96
158
  await ensureLoaded();
97
159
  return registry.getAll();
98
160
  },
161
+ close() {
162
+ unsubscribe?.();
163
+ unsubscribe = undefined;
164
+ },
99
165
  };
166
+ return api;
100
167
  }
101
168
  //# sourceMappingURL=featureFlags.core.js.map
@@ -10,10 +10,11 @@ import type { FeatureFlagRegistry } from "../registry/registry.core.js";
10
10
  *
11
11
  * @param key - The flag key to resolve.
12
12
  * @param registry - The flag registry.
13
- * @param visited - Set of already-visited keys (for cycle detection).
14
- * @returns True if all dependencies are enabled.
13
+ * @param chain - Keys on the current resolution path, for cycle detection.
14
+ * @param satisfied - Keys already proved satisfied, to avoid re-walking them.
15
+ * @returns True if the flag and all its transitive dependencies are enabled.
15
16
  */
16
- export declare function resolveDependencies(key: string, registry: FeatureFlagRegistry, visited?: Set<string>): boolean;
17
+ export declare function resolveDependencies(key: string, registry: FeatureFlagRegistry, chain?: Set<string>, satisfied?: Set<string>): boolean;
17
18
  /**
18
19
  * Merge default context with provided context.
19
20
  */
@@ -8,23 +8,40 @@
8
8
  *
9
9
  * @param key - The flag key to resolve.
10
10
  * @param registry - The flag registry.
11
- * @param visited - Set of already-visited keys (for cycle detection).
12
- * @returns True if all dependencies are enabled.
11
+ * @param chain - Keys on the current resolution path, for cycle detection.
12
+ * @param satisfied - Keys already proved satisfied, to avoid re-walking them.
13
+ * @returns True if the flag and all its transitive dependencies are enabled.
13
14
  */
14
- export function resolveDependencies(key, registry, visited = new Set()) {
15
- if (visited.has(key))
15
+ export function resolveDependencies(key, registry, chain = new Set(), satisfied = new Set()) {
16
+ // A key already proved good on another branch is good here too. Reusing one
17
+ // "visited" set for both jobs meant a diamond — A depends on B and C, both
18
+ // of which depend on D — reported D as a cycle the second time it was
19
+ // reached and disabled A.
20
+ if (satisfied.has(key))
21
+ return true;
22
+ if (chain.has(key))
16
23
  return false;
17
- visited.add(key);
24
+ chain.add(key);
18
25
  const flag = registry.get(key);
19
- if (!flag || !flag.enabled)
20
- return false;
21
- if (!flag.dependencies || flag.dependencies.length === 0)
22
- return true;
23
- for (const dep of flag.dependencies) {
24
- if (!resolveDependencies(dep, registry, visited))
26
+ try {
27
+ if (!flag || !flag.enabled)
25
28
  return false;
29
+ if (!flag.dependencies || flag.dependencies.length === 0) {
30
+ satisfied.add(key);
31
+ return true;
32
+ }
33
+ for (const dep of flag.dependencies) {
34
+ if (!resolveDependencies(dep, registry, chain, satisfied))
35
+ return false;
36
+ }
37
+ satisfied.add(key);
38
+ return true;
39
+ }
40
+ finally {
41
+ // Leaving the key in the chain would make a sibling branch see a cycle
42
+ // that is not there.
43
+ chain.delete(key);
26
44
  }
27
- return true;
28
45
  }
29
46
  /**
30
47
  * Merge default context with provided context.
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { FeatureFlagNotFoundError, FeatureFlagProviderError, FeatureFlagEvaluati
12
12
  export { hashString } from "./rollout/rolloutHashing.js";
13
13
  export { getBucket, isInRollout } from "./rollout/rolloutBucketing.js";
14
14
  export { createMemoryProvider } from "./provider/providerMemory.core.js";
15
+ export type { MemoryFeatureFlagProvider } from "./provider/providerMemory.core.js";
15
16
  export { createEnvironmentProvider } from "./provider/providerEnvironment.core.js";
16
17
  export type { EnvironmentProviderOptions } from "./provider/providerEnvironment.core.js";
17
18
  export { createCompositeProvider } from "./provider/providerComposite.core.js";
@@ -21,9 +22,11 @@ export { resolvePath, matchAttribute } from "./evaluator/evaluatorAttribute.js";
21
22
  export { evaluateRule } from "./evaluator/evaluatorRule.core.js";
22
23
  export type { RuleEvaluationResult } from "./evaluator/evaluatorRule.core.js";
23
24
  export { evaluateFlag } from "./evaluator/evaluator.core.js";
25
+ export type { EvaluateFlagOptions } from "./evaluator/evaluator.core.js";
24
26
  export { createFeatureFlagRegistry } from "./registry/registry.core.js";
25
27
  export type { FeatureFlagRegistry } from "./registry/registry.core.js";
26
28
  export { createFeatureFlags } from "./featureFlags/featureFlags.core.js";
27
- export type { FeatureFlagsOptions } from "./featureFlags/featureFlags.core.js";
29
+ export type { FeatureFlags, FeatureFlagsOptions, } from "./featureFlags/featureFlags.core.js";
30
+ export { resolveDependencies, mergeContext, } from "./featureFlags/featureFlags.resolve.js";
28
31
  export { isPlainObject, valuesEqual } from "./utils/utils.helper.js";
29
32
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export { evaluateFlag } from "./evaluator/evaluator.core.js";
24
24
  export { createFeatureFlagRegistry } from "./registry/registry.core.js";
25
25
  // ─── FeatureFlags ─────────────────────────────────────────────────────────
26
26
  export { createFeatureFlags } from "./featureFlags/featureFlags.core.js";
27
+ export { resolveDependencies, mergeContext, } from "./featureFlags/featureFlags.resolve.js";
27
28
  // ─── Utils ────────────────────────────────────────────────────────────────
28
29
  export { isPlainObject, valuesEqual } from "./utils/utils.helper.js";
29
30
  //# sourceMappingURL=index.js.map
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module provider/providerCached
7
7
  */
8
- import type { FeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
8
+ import type { FeatureFlagProvider, RefreshableFeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
9
9
  /** Options for the cached provider. */
10
10
  export interface CachedProviderOptions {
11
11
  /** Time-to-live in milliseconds (default: 30,000). */
@@ -16,7 +16,7 @@ export interface CachedProviderOptions {
16
16
  *
17
17
  * @param inner - The upstream provider to cache.
18
18
  * @param options - Caching options.
19
- * @returns A FeatureFlagProvider with in-memory TTL caching.
19
+ * @returns A provider with in-memory TTL caching.
20
20
  */
21
- export declare function createCachedProvider(inner: FeatureFlagProvider, options?: CachedProviderOptions): FeatureFlagProvider;
21
+ export declare function createCachedProvider(inner: FeatureFlagProvider, options?: CachedProviderOptions): RefreshableFeatureFlagProvider;
22
22
  //# sourceMappingURL=providerCached.core.d.ts.map
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * @param inner - The upstream provider to cache.
12
12
  * @param options - Caching options.
13
- * @returns A FeatureFlagProvider with in-memory TTL caching.
13
+ * @returns A provider with in-memory TTL caching.
14
14
  */
15
15
  export function createCachedProvider(inner, options = {}) {
16
16
  const ttl = options.ttl ?? 30_000;
@@ -19,7 +19,27 @@ export function createCachedProvider(inner, options = {}) {
19
19
  function isExpired(entry) {
20
20
  return Date.now() > entry.expiresAt;
21
21
  }
22
+ function clear() {
23
+ flagCache.clear();
24
+ listCache = undefined;
25
+ }
26
+ // A change announced by the upstream provider is forwarded, and the cache
27
+ // is dropped first so a `get()` made by the listener sees the new state.
28
+ // Without this the wrapper had no `subscribe`, so `createFeatureFlags` on
29
+ // a cached provider never heard about a flag flipped at the source and
30
+ // served the stale copy until the TTL ran out.
31
+ const subscribe = inner.subscribe
32
+ ? {
33
+ subscribe(listener) {
34
+ return inner.subscribe((flags) => {
35
+ clear();
36
+ listener(flags);
37
+ });
38
+ },
39
+ }
40
+ : {};
22
41
  return {
42
+ ...subscribe,
23
43
  async get(key) {
24
44
  const cached = flagCache.get(key);
25
45
  if (cached && !isExpired(cached))
@@ -36,8 +56,7 @@ export function createCachedProvider(inner, options = {}) {
36
56
  return flags;
37
57
  },
38
58
  async refresh() {
39
- flagCache.clear();
40
- listCache = undefined;
59
+ clear();
41
60
  await inner.refresh?.();
42
61
  },
43
62
  };
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module provider/providerComposite
7
7
  */
8
- import type { FeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
8
+ import type { FeatureFlagProvider, RefreshableFeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
9
9
  /**
10
10
  * Create a composite provider that queries providers in order.
11
11
  *
@@ -15,5 +15,5 @@ import type { FeatureFlagProvider } from "../featureFlagTypes/featureFlagProvide
15
15
  * @param providers - Providers to chain, in priority order.
16
16
  * @returns A composite FeatureFlagProvider.
17
17
  */
18
- export declare function createCompositeProvider(providers: readonly FeatureFlagProvider[]): FeatureFlagProvider;
18
+ export declare function createCompositeProvider(providers: readonly FeatureFlagProvider[]): RefreshableFeatureFlagProvider;
19
19
  //# sourceMappingURL=providerComposite.core.d.ts.map
@@ -15,7 +15,68 @@
15
15
  * @returns A composite FeatureFlagProvider.
16
16
  */
17
17
  export function createCompositeProvider(providers) {
18
+ /**
19
+ * The last flag list each member produced — from its own `getAll()` or
20
+ * from a change it announced. Lets a change be re-announced synchronously
21
+ * as the merged view, without a round trip to every other member.
22
+ */
23
+ const lastSeen = new Map();
24
+ function merge(lists) {
25
+ const seen = new Set();
26
+ const result = [];
27
+ for (const flags of lists) {
28
+ for (const flag of flags) {
29
+ if (!seen.has(flag.key)) {
30
+ seen.add(flag.key);
31
+ result.push(flag);
32
+ }
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+ async function getAll() {
38
+ const lists = [];
39
+ for (const provider of providers) {
40
+ const flags = await provider.getAll();
41
+ lastSeen.set(provider, flags);
42
+ lists.push(flags);
43
+ }
44
+ return merge(lists);
45
+ }
46
+ // Changes announced by any member are re-announced as the merged view, so
47
+ // a consumer subscribed to the composite sees the same precedence
48
+ // `getAll()` applies. The composite used to have no `subscribe` at all,
49
+ // which silently cut change propagation for every provider behind it.
50
+ const subscribable = providers.filter((provider) => provider.subscribe);
51
+ const subscribe = subscribable.length > 0
52
+ ? {
53
+ subscribe(listener) {
54
+ // A merged snapshot fetched asynchronously must not overtake a
55
+ // later change.
56
+ let version = 0;
57
+ const unsubscribes = subscribable.map((provider) => provider.subscribe((flags) => {
58
+ lastSeen.set(provider, flags);
59
+ const current = ++version;
60
+ if (providers.every((member) => lastSeen.has(member))) {
61
+ listener(merge(providers.map((member) => lastSeen.get(member))));
62
+ return;
63
+ }
64
+ void getAll().then((merged) => {
65
+ if (current === version)
66
+ listener(merged);
67
+ }, () => {
68
+ /* a member that cannot be read keeps the last view */
69
+ });
70
+ }));
71
+ return () => {
72
+ for (const unsubscribe of unsubscribes)
73
+ unsubscribe();
74
+ };
75
+ },
76
+ }
77
+ : {};
18
78
  return {
79
+ ...subscribe,
19
80
  async get(key) {
20
81
  for (const provider of providers) {
21
82
  const flag = await provider.get(key);
@@ -24,20 +85,7 @@ export function createCompositeProvider(providers) {
24
85
  }
25
86
  return undefined;
26
87
  },
27
- async getAll() {
28
- const seen = new Set();
29
- const result = [];
30
- for (const provider of providers) {
31
- const flags = await provider.getAll();
32
- for (const flag of flags) {
33
- if (!seen.has(flag.key)) {
34
- seen.add(flag.key);
35
- result.push(flag);
36
- }
37
- }
38
- }
39
- return result;
40
- },
88
+ getAll,
41
89
  async refresh() {
42
90
  for (const provider of providers) {
43
91
  await provider.refresh?.();
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module provider/providerEnvironment
9
9
  */
10
- import type { FeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
10
+ import type { RefreshableFeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
11
11
  /** Options for the environment provider. */
12
12
  export interface EnvironmentProviderOptions {
13
13
  /** Prefix for environment variable names (default: "FEATURE_"). */
@@ -19,7 +19,7 @@ export interface EnvironmentProviderOptions {
19
19
  * Create an environment-variable feature flag provider.
20
20
  *
21
21
  * @param options - Configuration options.
22
- * @returns A FeatureFlagProvider that reads from environment variables.
22
+ * @returns A provider that reads from environment variables.
23
23
  */
24
- export declare function createEnvironmentProvider(options?: EnvironmentProviderOptions): FeatureFlagProvider;
24
+ export declare function createEnvironmentProvider(options?: EnvironmentProviderOptions): RefreshableFeatureFlagProvider;
25
25
  //# sourceMappingURL=providerEnvironment.core.d.ts.map
@@ -15,16 +15,21 @@ function parseEnvValue(raw) {
15
15
  return true;
16
16
  if (raw === "false")
17
17
  return false;
18
- const num = Number(raw);
19
- if (!Number.isNaN(num))
20
- return num;
18
+ // `Number("")` and `Number(" ")` are both `0`, so an empty
19
+ // `FEATURE_X=` used to become the number zero. Only a value that is not
20
+ // blank is a candidate for a number; blank stays the string it is.
21
+ if (raw.trim() !== "") {
22
+ const num = Number(raw);
23
+ if (!Number.isNaN(num))
24
+ return num;
25
+ }
21
26
  return raw;
22
27
  }
23
28
  /**
24
29
  * Create an environment-variable feature flag provider.
25
30
  *
26
31
  * @param options - Configuration options.
27
- * @returns A FeatureFlagProvider that reads from environment variables.
32
+ * @returns A provider that reads from environment variables.
28
33
  */
29
34
  export function createEnvironmentProvider(options = {}) {
30
35
  const prefix = options.prefix ?? "FEATURE_";
@@ -6,12 +6,28 @@
6
6
  * @module provider/providerMemory
7
7
  */
8
8
  import type { FeatureFlag } from "../featureFlagTypes/featureFlag.interface.js";
9
- import type { FeatureFlagProvider } from "../featureFlagTypes/featureFlagProvider.js";
9
+ import type { FeatureFlagChangeListener, FeatureFlagProvider, Unsubscribe } from "../featureFlagTypes/featureFlagProvider.js";
10
+ /**
11
+ * An in-memory provider, with the mutation methods it actually has.
12
+ *
13
+ * `createMemoryProvider` used to return `FeatureFlagProvider` through a cast,
14
+ * so `set` and `delete` existed at runtime and were invisible to the type
15
+ * system — callers had to cast to reach them.
16
+ */
17
+ export interface MemoryFeatureFlagProvider extends FeatureFlagProvider {
18
+ /** Add or update a flag at runtime, notifying subscribers. */
19
+ set(flag: FeatureFlag): void;
20
+ /** Remove a flag by key, notifying subscribers. Returns whether it existed. */
21
+ delete(key: string): boolean;
22
+ /** Replace every flag at once, notifying subscribers. */
23
+ setAll(flags: readonly FeatureFlag[]): void;
24
+ subscribe(listener: FeatureFlagChangeListener): Unsubscribe;
25
+ }
10
26
  /**
11
27
  * Create an in-memory feature flag provider.
12
28
  *
13
29
  * @param flags - Initial flag definitions.
14
30
  * @returns A FeatureFlagProvider backed by a Map.
15
31
  */
16
- export declare function createMemoryProvider(flags?: readonly FeatureFlag[]): FeatureFlagProvider;
32
+ export declare function createMemoryProvider(flags?: readonly FeatureFlag[]): MemoryFeatureFlagProvider;
17
33
  //# sourceMappingURL=providerMemory.core.d.ts.map
@@ -13,23 +13,57 @@
13
13
  */
14
14
  export function createMemoryProvider(flags = []) {
15
15
  const store = new Map();
16
+ const listeners = new Set();
16
17
  for (const flag of flags) {
17
18
  store.set(flag.key, Object.freeze({ ...flag }));
18
19
  }
20
+ function snapshot() {
21
+ return [...store.values()];
22
+ }
23
+ /** Announce a change so an already-loaded consumer picks it up. */
24
+ function notify() {
25
+ if (listeners.size === 0)
26
+ return;
27
+ const current = snapshot();
28
+ for (const listener of listeners) {
29
+ // A broken listener must not stop the others, or the mutation itself.
30
+ try {
31
+ listener(current);
32
+ }
33
+ catch {
34
+ /* listener failures are the listener's problem */
35
+ }
36
+ }
37
+ }
19
38
  return {
20
39
  async get(key) {
21
40
  return store.get(key);
22
41
  },
23
42
  async getAll() {
24
- return [...store.values()];
43
+ return snapshot();
25
44
  },
26
- /** Add or update a flag at runtime. */
27
45
  set(flag) {
28
46
  store.set(flag.key, Object.freeze({ ...flag }));
47
+ notify();
29
48
  },
30
- /** Remove a flag by key. */
31
49
  delete(key) {
32
- return store.delete(key);
50
+ const existed = store.delete(key);
51
+ if (existed)
52
+ notify();
53
+ return existed;
54
+ },
55
+ setAll(next) {
56
+ store.clear();
57
+ for (const flag of next) {
58
+ store.set(flag.key, Object.freeze({ ...flag }));
59
+ }
60
+ notify();
61
+ },
62
+ subscribe(listener) {
63
+ listeners.add(listener);
64
+ return () => {
65
+ listeners.delete(listener);
66
+ };
33
67
  },
34
68
  };
35
69
  }