@zudojs/feature-flags 1.0.0 → 1.2.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
@@ -2,6 +2,12 @@
2
2
 
3
3
  Feature flag system with deterministic rollouts, rule engine, providers, variants, snapshots, and evaluation context.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-feature-flags](https://zudojs.oyinlola.site/docs/packages-feature-flags) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-feature-flags.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -55,11 +61,14 @@ interface FeatureFlag {
55
61
  state?: "active" | "archived" | "draft";
56
62
  visibility?: "client" | "server";
57
63
  rules?: FeatureFlagRule[]; // evaluated in order, first match wins
58
- dependencies?: string[]; // other flags that must be enabled
64
+ dependencies?: string[]; // other flags that must be on for the same context
59
65
  metadata?: { expiresAt?: Date /* … */ };
60
66
  }
61
67
  ```
62
68
 
69
+ `expiresAt` may also arrive as an ISO string or a timestamp — a flag loaded
70
+ from JSON does — and expires the flag just the same.
71
+
63
72
  ## Rules
64
73
 
65
74
  | Type | Matches when |
@@ -67,7 +76,7 @@ interface FeatureFlag {
67
76
  | `static` | always |
68
77
  | `user` | `context.userId` is in `users` |
69
78
  | `tenant` | `context.tenantId` is in `tenants` |
70
- | `attribute` | `attribute` compared to `value` with `operator` |
79
+ | `attribute` | `attribute` compared to `value` with `operator`; serves `result` (default `true`) |
71
80
  | `percentage` | the subject's bucket falls inside `percentage` |
72
81
  | `schedule` | now is between `startAt` and `endAt` |
73
82
  | `variant` | always, assigning a variant by weight |
@@ -83,9 +92,25 @@ Operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`,
83
92
  Attribute paths use dot notation and are resolved against the context first,
84
93
  then `context.attributes`. Only **own** properties are traversed:
85
94
  `__proto__`, `constructor` and `prototype` never resolve, so a rule cannot
86
- accidentally (or deliberately) target everyone through the prototype chain. A
87
- `matches` pattern that does not compile, or is longer than 512 characters,
88
- matches nothing instead of throwing.
95
+ accidentally (or deliberately) target everyone through the prototype chain.
96
+
97
+ An attribute rule's `value` is the comparison operand; what a match serves is
98
+ `result`, which defaults to `true`. On a non-boolean flag set `result`:
99
+
100
+ ```typescript
101
+ { key: "theme", enabled: true, defaultValue: "light",
102
+ rules: [{ type: "attribute", attribute: "plan", operator: "equals", value: "pro", result: "dark" }] }
103
+ ```
104
+
105
+ An attribute rule without `result` on a non-boolean flag is skipped, rather
106
+ than serving `true` from a string flag.
107
+
108
+ A `matches` pattern matches nothing, instead of throwing or hanging, when it
109
+ does not compile, is longer than 512 characters, or could backtrack
110
+ catastrophically — a repeated group that itself repeats or alternates
111
+ (`(a+)+`, `(a|aa)*`, `(\w+\s?){2,}`) or a backreference. It is tested only
112
+ against values up to 1,024 characters. Patterns used for targeting, such as
113
+ `@example\.com$` or `^(beta|alpha)-`, are unaffected.
89
114
 
90
115
  ## Rollouts and variants
91
116
 
@@ -102,9 +127,13 @@ is 90/10.
102
127
  ## Dependencies
103
128
 
104
129
  A flag may declare `dependencies`. It evaluates normally only when every
105
- dependency — transitively — exists and is enabled; otherwise the result is
106
- `dependency_disabled` with the declared default. Cycles resolve to disabled;
107
- a shared dependency reached down two branches is not a cycle.
130
+ dependency is **on for the same context**: the prerequisite is evaluated —
131
+ state, expiry, rules, rollout, and its own dependencies — and must not be
132
+ disabled, draft, archived or expired, nor evaluate to `false`, `null` or
133
+ `undefined`. A prerequisite rolled out to 10% keeps its dependents off for the
134
+ other 90%. Otherwise the result is `dependency_disabled` with the declared
135
+ default. Cycles resolve to disabled; a shared dependency reached down two
136
+ branches is not a cycle.
108
137
 
109
138
  `evaluateFlag()` on its own has no registry and cannot resolve dependencies,
110
139
  so it reports `dependency_disabled` for any flag that declares them unless
@@ -130,7 +159,20 @@ const provider = createCachedProvider(
130
159
  ```
131
160
 
132
161
  `createMemoryProvider` returns a typed provider with `set`, `delete` and
133
- `setAll`, and it announces every change to subscribers.
162
+ `setAll`, and it announces every change to subscribers. `createCachedProvider`
163
+ and `createCompositeProvider` forward those announcements — the cache is
164
+ dropped first, and the composite announces its merged view — so the stack
165
+ above still propagates a change made to `remoteProvider`.
166
+
167
+ `createCachedProvider` holds at most `maxEntries` keys (default 1,000),
168
+ sweeping expired entries and then evicting the oldest. `createFeatureFlags`
169
+ remembers a key the provider does not know for `missingFlagTtlMs` (default
170
+ 30 s, at most 1,000 keys; `0` disables it), so request-supplied keys cannot
171
+ turn every evaluation into a remote round trip. The memory is dropped on
172
+ every reload.
173
+
174
+ `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
175
+ including an empty `FEATURE_X=`, stays a string.
134
176
 
135
177
  ## Change propagation
136
178
 
@@ -151,6 +193,10 @@ changes.
151
193
  | Dependency not satisfied | `dependency_disabled`, the declared default |
152
194
  | Provider unreachable | `error`, reported to `onError`; never enabled |
153
195
 
196
+ "Unreachable" covers both `getAll()` and a `get()` for a flag not yet loaded:
197
+ a lookup the store could not answer is `error`, never `not_found`, and does
198
+ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
199
+
154
200
  An unreachable store never enables a flag. Set `throwOnProviderError: true`
155
201
  to own the failure yourself instead.
156
202
 
@@ -6,6 +6,25 @@
6
6
  * @module evaluator/evaluator
7
7
  */
8
8
  import { evaluateRule } from "./evaluatorRule.core.js";
9
+ /**
10
+ * Whether an `expiresAt` lies in the past.
11
+ *
12
+ * The type says `Date`, but a flag loaded from JSON — which is what every
13
+ * remote provider hands over — carries an ISO string, and `"2020-01-01" <
14
+ * new Date()` is always `false`. A flag that had expired at the source
15
+ * therefore never expired here. Anything `Date` can parse is honoured; a
16
+ * value it cannot parse is treated as no expiry.
17
+ */
18
+ function isExpired(expiresAt) {
19
+ if (expiresAt === undefined || expiresAt === null)
20
+ return false;
21
+ const time = expiresAt instanceof Date
22
+ ? expiresAt.getTime()
23
+ : typeof expiresAt === "string" || typeof expiresAt === "number"
24
+ ? new Date(expiresAt).getTime()
25
+ : Number.NaN;
26
+ return !Number.isNaN(time) && time < Date.now();
27
+ }
9
28
  /** The evaluation reason a matching rule of each type produces. */
10
29
  function reasonFor(type) {
11
30
  switch (type) {
@@ -49,7 +68,7 @@ export function evaluateFlag(flag, context = {}, options = {}) {
49
68
  defaulted: true,
50
69
  };
51
70
  }
52
- if (flag.metadata?.expiresAt && flag.metadata.expiresAt < new Date()) {
71
+ if (isExpired(flag.metadata?.expiresAt)) {
53
72
  return {
54
73
  key: flag.key,
55
74
  value: flag.defaultValue,
@@ -82,6 +101,14 @@ export function evaluateFlag(flag, context = {}, options = {}) {
82
101
  }
83
102
  for (let i = 0; i < flag.rules.length; i++) {
84
103
  const rule = flag.rules[i];
104
+ // An attribute rule with no `result` serves `true`, which is only a
105
+ // value of a boolean flag. On any other flag it is skipped, rather than
106
+ // handing `get<string>()` a boolean.
107
+ if (rule.type === "attribute" &&
108
+ rule.result === undefined &&
109
+ typeof flag.defaultValue !== "boolean") {
110
+ continue;
111
+ }
85
112
  const result = evaluateRule(rule, context, flag.key);
86
113
  if (result.matched) {
87
114
  const reason = reasonFor(rule.type);
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * @module evaluator/evaluatorAttribute
7
7
  */
8
+ import { isUnsafePattern, MAX_MATCH_INPUT_LENGTH, } from "./evaluatorPattern.safety.js";
8
9
  /**
9
10
  * Path segments that must never be traversed.
10
11
  *
@@ -58,7 +59,8 @@ const MAX_CACHED_PATTERNS = 256;
58
59
  *
59
60
  * A pattern that does not compile used to throw out of rule evaluation and
60
61
  * out of `isEnabled()` with it. A flag whose configuration is broken must
61
- * fall back to its default, not take the caller down.
62
+ * fall back to its default, not take the caller down. A pattern that could
63
+ * backtrack catastrophically is treated the same way: it never matches.
62
64
  */
63
65
  function compilePattern(pattern) {
64
66
  if (pattern.length > MAX_PATTERN_LENGTH)
@@ -68,7 +70,7 @@ function compilePattern(pattern) {
68
70
  return cached;
69
71
  let compiled;
70
72
  try {
71
- compiled = new RegExp(pattern);
73
+ compiled = isUnsafePattern(pattern) ? null : new RegExp(pattern);
72
74
  }
73
75
  catch {
74
76
  compiled = null;
@@ -132,6 +134,8 @@ export function matchAttribute(actual, operator, expected) {
132
134
  if (typeof actual !== "string" || typeof expected !== "string") {
133
135
  return false;
134
136
  }
137
+ if (actual.length > MAX_MATCH_INPUT_LENGTH)
138
+ return false;
135
139
  const pattern = compilePattern(expected);
136
140
  return pattern !== null && pattern.test(actual);
137
141
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Guards for the `matches` operator's regular expressions.
3
+ *
4
+ * Flag patterns run on the request path, synchronously, against attribute
5
+ * values the client controls (email, user agent). JavaScript's backtracking
6
+ * engine takes exponential time on a pattern such as `^(a+)+$`: 28 characters
7
+ * cost over three seconds of blocked event loop. Such patterns are refused,
8
+ * and the input a pattern runs against is capped.
9
+ *
10
+ * @module evaluator/evaluatorPattern.safety
11
+ */
12
+ /** Longest attribute value a `matches` rule is tested against. */
13
+ export declare const MAX_MATCH_INPUT_LENGTH = 1024;
14
+ /**
15
+ * Whether a pattern can backtrack catastrophically.
16
+ *
17
+ * Conservative: it refuses any repeated group that itself contains a
18
+ * repetition or an alternation (`(a+)+`, `(a|aa)*`, `(\w+\s?){2,}`), and any
19
+ * backreference. Patterns used for targeting — `@example\.com$`,
20
+ * `^(beta|alpha)-` — contain neither.
21
+ */
22
+ export declare function isUnsafePattern(pattern: string): boolean;
23
+ //# sourceMappingURL=evaluatorPattern.safety.d.ts.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Guards for the `matches` operator's regular expressions.
3
+ *
4
+ * Flag patterns run on the request path, synchronously, against attribute
5
+ * values the client controls (email, user agent). JavaScript's backtracking
6
+ * engine takes exponential time on a pattern such as `^(a+)+$`: 28 characters
7
+ * cost over three seconds of blocked event loop. Such patterns are refused,
8
+ * and the input a pattern runs against is capped.
9
+ *
10
+ * @module evaluator/evaluatorPattern.safety
11
+ */
12
+ /** Longest attribute value a `matches` rule is tested against. */
13
+ export const MAX_MATCH_INPUT_LENGTH = 1024;
14
+ /**
15
+ * Whether a repetition quantifier starts at `index`: `*`, `+` or `{n…}`.
16
+ *
17
+ * Bounded counts are included on purpose: `(.*a){20}` is polynomial of
18
+ * degree twenty, which is no better in practice.
19
+ */
20
+ function isRepetition(pattern, index) {
21
+ const char = pattern[index];
22
+ if (char === "*" || char === "+")
23
+ return true;
24
+ return char === "{" && /^\{\d+(,\d*)?\}/.test(pattern.slice(index));
25
+ }
26
+ /**
27
+ * Whether a pattern can backtrack catastrophically.
28
+ *
29
+ * Conservative: it refuses any repeated group that itself contains a
30
+ * repetition or an alternation (`(a+)+`, `(a|aa)*`, `(\w+\s?){2,}`), and any
31
+ * backreference. Patterns used for targeting — `@example\.com$`,
32
+ * `^(beta|alpha)-` — contain neither.
33
+ */
34
+ export function isUnsafePattern(pattern) {
35
+ if (/\\[1-9]|\\k</.test(pattern))
36
+ return true;
37
+ const stack = [];
38
+ let risky = false;
39
+ let inClass = false;
40
+ for (let i = 0; i < pattern.length; i++) {
41
+ const char = pattern[i];
42
+ if (char === "\\") {
43
+ i += 1;
44
+ continue;
45
+ }
46
+ if (inClass) {
47
+ if (char === "]")
48
+ inClass = false;
49
+ continue;
50
+ }
51
+ if (char === "[") {
52
+ inClass = true;
53
+ }
54
+ else if (char === "(") {
55
+ stack.push(risky);
56
+ risky = false;
57
+ }
58
+ else if (char === ")") {
59
+ const inner = risky;
60
+ risky = stack.pop() ?? false;
61
+ if (isRepetition(pattern, i + 1)) {
62
+ if (inner)
63
+ return true;
64
+ risky = true;
65
+ }
66
+ else if (inner) {
67
+ risky = true;
68
+ }
69
+ }
70
+ else if (char === "|") {
71
+ risky = true;
72
+ }
73
+ else if (isRepetition(pattern, i)) {
74
+ risky = true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ //# sourceMappingURL=evaluatorPattern.safety.js.map
@@ -39,7 +39,7 @@ export function evaluateRule(rule, context, flagKey) {
39
39
  actual = resolvePath(context.attributes, rule.attribute);
40
40
  }
41
41
  const matched = matchAttribute(actual, rule.operator, rule.value);
42
- return { matched, value: matched ? true : undefined };
42
+ return { matched, value: matched ? (rule.result ?? true) : undefined };
43
43
  }
44
44
  case "percentage": {
45
45
  const subject = context.userId ?? context.tenantId ?? context.sessionId ?? "anonymous";
@@ -25,12 +25,22 @@ export interface FeatureFlagTenantRule {
25
25
  readonly tenants: readonly string[];
26
26
  readonly value: FeatureFlagValue;
27
27
  }
28
- /** Target by attribute matching. */
28
+ /**
29
+ * Target by attribute matching.
30
+ *
31
+ * `value` is the comparison operand, not what the rule serves. What a match
32
+ * serves is `result`, which defaults to `true` — so on a non-boolean flag an
33
+ * attribute rule must set `result`, and one that does not is skipped rather
34
+ * than serving a boolean from a string, number or object flag.
35
+ */
29
36
  export interface FeatureFlagAttributeRule {
30
37
  readonly type: "attribute";
31
38
  readonly attribute: string;
32
39
  readonly operator: FeatureFlagOperator;
40
+ /** The operand the attribute is compared against. */
33
41
  readonly value: unknown;
42
+ /** The value served when the rule matches. Default: `true`. */
43
+ readonly result?: FeatureFlagValue;
34
44
  }
35
45
  /** Percentage-based rollout — deterministic per subject. */
36
46
  export interface FeatureFlagPercentageRule {
@@ -34,6 +34,13 @@ export interface FeatureFlagsOptions {
34
34
  * handles itself.
35
35
  */
36
36
  readonly throwOnProviderError?: boolean;
37
+ /**
38
+ * How long a key the provider does not know is remembered as missing, in
39
+ * ms. Default: 30,000. At most 1,000 missing keys are kept. `0` asks the
40
+ * provider on every evaluation, as before. The memory is dropped on every
41
+ * reload (`refresh()`, a provider change notification).
42
+ */
43
+ readonly missingFlagTtlMs?: number;
37
44
  }
38
45
  /** The public FeatureFlags API. */
39
46
  export interface FeatureFlags {
@@ -9,6 +9,7 @@ import { createFeatureFlagRegistry } from "../registry/registry.core.js";
9
9
  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
+ import { createMissCache, DEFAULT_MISSING_FLAG_TTL_MS, } from "./featureFlags.missCache.js";
12
13
  /**
13
14
  * Create a FeatureFlags instance.
14
15
  *
@@ -16,7 +17,8 @@ import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
16
17
  * @returns A FeatureFlags API object.
17
18
  */
18
19
  export function createFeatureFlags(options) {
19
- const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, } = options;
20
+ const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, } = options;
21
+ const misses = createMissCache(missingFlagTtlMs);
20
22
  let registry = createFeatureFlagRegistry();
21
23
  let loaded = false;
22
24
  /**
@@ -39,6 +41,7 @@ export function createFeatureFlags(options) {
39
41
  const flags = await provider.getAll();
40
42
  registry = createFeatureFlagRegistry(flags);
41
43
  loaded = true;
44
+ misses.clear();
42
45
  return true;
43
46
  }
44
47
  catch (error) {
@@ -56,16 +59,24 @@ export function createFeatureFlags(options) {
56
59
  async function resolveFlag(key) {
57
60
  const known = registry.get(key);
58
61
  if (known)
59
- return known;
62
+ return { flag: known, reachable: true };
63
+ if (misses.has(key))
64
+ return { flag: undefined, reachable: true };
60
65
  try {
61
66
  const flag = await provider.get(key);
62
67
  if (flag)
63
68
  registry.set(flag);
64
- return flag;
69
+ else if (loaded)
70
+ misses.add(key);
71
+ return { flag, reachable: true };
65
72
  }
66
73
  catch (error) {
67
74
  handleProviderError(error, "FeatureFlagProvider.get");
68
- return undefined;
75
+ // A `get()` that failed is not a `get()` that found nothing: reporting
76
+ // it as `not_found` (or throwing FeatureFlagNotFoundError under
77
+ // `throwOnMissing`) told the caller the flag does not exist when the
78
+ // truth is that the store could not be asked.
79
+ return { flag: undefined, reachable: false };
69
80
  }
70
81
  }
71
82
  // A provider that can announce changes is asked to: without this, a flag
@@ -74,6 +85,7 @@ export function createFeatureFlags(options) {
74
85
  let unsubscribe = provider.subscribe?.((flags) => {
75
86
  registry = createFeatureFlagRegistry(flags);
76
87
  loaded = true;
88
+ misses.clear();
77
89
  });
78
90
  const api = {
79
91
  async isEnabled(key, context) {
@@ -97,9 +109,9 @@ export function createFeatureFlags(options) {
97
109
  async evaluate(key, context) {
98
110
  const available = await ensureLoaded();
99
111
  const mergedCtx = mergeContext(defaultContext, context);
100
- const flag = await resolveFlag(key);
112
+ const { flag, reachable } = await resolveFlag(key);
101
113
  if (!flag) {
102
- if (!available) {
114
+ if (!available || !reachable) {
103
115
  // The store could not be reached and nothing is known about this
104
116
  // flag. Report it as an error, not as a decision.
105
117
  return {
@@ -120,7 +132,7 @@ export function createFeatureFlags(options) {
120
132
  }
121
133
  const dependenciesSatisfied = !flag.dependencies ||
122
134
  flag.dependencies.length === 0 ||
123
- resolveDependencies(key, registry);
135
+ resolveDependencies(key, registry, undefined, undefined, mergedCtx);
124
136
  return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
125
137
  },
126
138
  async snapshot(context) {
@@ -136,7 +148,7 @@ export function createFeatureFlags(options) {
136
148
  continue;
137
149
  const dependenciesSatisfied = !flag.dependencies ||
138
150
  flag.dependencies.length === 0 ||
139
- resolveDependencies(flag.key, registry);
151
+ resolveDependencies(flag.key, registry, undefined, undefined, mergedCtx);
140
152
  results.set(flag.key, evaluateFlag(flag, mergedCtx, { dependenciesSatisfied }));
141
153
  }
142
154
  return results;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Dependency satisfaction for feature flags.
3
+ *
4
+ * @module featureFlags/featureFlags.dependency
5
+ */
6
+ import type { FeatureFlag } from "../featureFlagTypes/featureFlag.interface.js";
7
+ import type { FeatureFlagContext } from "../featureFlagTypes/featureFlagContext.js";
8
+ import type { FeatureFlagEvaluation } from "../featureFlagTypes/featureFlagEvaluation.js";
9
+ import type { FeatureFlagRegistry } from "../registry/registry.core.js";
10
+ /**
11
+ * Whether a prerequisite's evaluation counts as "on" for this context.
12
+ *
13
+ * It must not be gated (disabled, archived, draft, expired, or blocked by its
14
+ * own dependencies) and must not evaluate to `false`, `null` or `undefined`.
15
+ * A percentage rollout the subject is outside of evaluates to the default,
16
+ * typically `false`, so the dependent flag stays off for that subject too.
17
+ */
18
+ export declare function isDependencyOn(evaluation: FeatureFlagEvaluation): boolean;
19
+ /**
20
+ * Whether every dependency of a flag is on for a context.
21
+ *
22
+ * Each prerequisite is *evaluated* — state, expiry, rules and rollout — for
23
+ * the same context, recursively. Checking only its kill switch let a flag
24
+ * turn on for users who could not have the feature it needs: an archived or
25
+ * expired prerequisite, or one rolled out to someone else.
26
+ *
27
+ * @param flag - The dependent flag.
28
+ * @param registry - Where prerequisites are looked up.
29
+ * @param context - The evaluation context.
30
+ * @param chain - Keys on the current path, for cycle detection.
31
+ * @param memo - Results for this context, so a diamond is walked once.
32
+ */
33
+ export declare function dependenciesOn(flag: FeatureFlag, registry: FeatureFlagRegistry, context: FeatureFlagContext, chain?: Set<string>, memo?: Map<string, boolean>): boolean;
34
+ //# sourceMappingURL=featureFlags.dependency.d.ts.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Dependency satisfaction for feature flags.
3
+ *
4
+ * @module featureFlags/featureFlags.dependency
5
+ */
6
+ import { evaluateFlag } from "../evaluator/evaluator.core.js";
7
+ /** Reasons that mean the prerequisite is off, whatever its value. */
8
+ const GATED = new Set([
9
+ "disabled",
10
+ "expired",
11
+ "dependency_disabled",
12
+ "not_found",
13
+ "error",
14
+ ]);
15
+ /**
16
+ * Whether a prerequisite's evaluation counts as "on" for this context.
17
+ *
18
+ * It must not be gated (disabled, archived, draft, expired, or blocked by its
19
+ * own dependencies) and must not evaluate to `false`, `null` or `undefined`.
20
+ * A percentage rollout the subject is outside of evaluates to the default,
21
+ * typically `false`, so the dependent flag stays off for that subject too.
22
+ */
23
+ export function isDependencyOn(evaluation) {
24
+ if (GATED.has(evaluation.reason))
25
+ return false;
26
+ return (evaluation.value !== false &&
27
+ evaluation.value !== null &&
28
+ evaluation.value !== undefined);
29
+ }
30
+ /**
31
+ * Whether every dependency of a flag is on for a context.
32
+ *
33
+ * Each prerequisite is *evaluated* — state, expiry, rules and rollout — for
34
+ * the same context, recursively. Checking only its kill switch let a flag
35
+ * turn on for users who could not have the feature it needs: an archived or
36
+ * expired prerequisite, or one rolled out to someone else.
37
+ *
38
+ * @param flag - The dependent flag.
39
+ * @param registry - Where prerequisites are looked up.
40
+ * @param context - The evaluation context.
41
+ * @param chain - Keys on the current path, for cycle detection.
42
+ * @param memo - Results for this context, so a diamond is walked once.
43
+ */
44
+ export function dependenciesOn(flag, registry, context, chain = new Set([flag.key]), memo = new Map()) {
45
+ for (const key of flag.dependencies ?? []) {
46
+ const known = memo.get(key);
47
+ if (known !== undefined) {
48
+ if (!known)
49
+ return false;
50
+ continue;
51
+ }
52
+ if (chain.has(key))
53
+ return false;
54
+ const dependency = registry.get(key);
55
+ let on = false;
56
+ if (dependency) {
57
+ chain.add(key);
58
+ try {
59
+ const inner = dependenciesOn(dependency, registry, context, chain, memo);
60
+ on = isDependencyOn(evaluateFlag(dependency, context, { dependenciesSatisfied: inner }));
61
+ }
62
+ finally {
63
+ chain.delete(key);
64
+ }
65
+ }
66
+ memo.set(key, on);
67
+ if (!on)
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ //# sourceMappingURL=featureFlags.dependency.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Negative cache for flag keys the provider does not know.
3
+ *
4
+ * @module featureFlags/featureFlags.missCache
5
+ */
6
+ /** Default lifetime of a remembered miss. */
7
+ export declare const DEFAULT_MISSING_FLAG_TTL_MS = 30000;
8
+ /** Most distinct missing keys remembered at once. */
9
+ export declare const MAX_MISSING_FLAGS = 1000;
10
+ /** Remembers recent "no such flag" answers. */
11
+ export interface MissCache {
12
+ has(key: string): boolean;
13
+ add(key: string): void;
14
+ clear(): void;
15
+ }
16
+ /**
17
+ * Create a bounded, expiring miss cache.
18
+ *
19
+ * A registry miss used to go to `provider.get(key)` on every evaluation. When
20
+ * keys can come from request data (a `?flag=` switch, a client snapshot
21
+ * request), each distinct key was a remote round trip, every time. A miss is
22
+ * now remembered for `ttlMs`, up to {@link MAX_MISSING_FLAGS} keys (oldest
23
+ * evicted). `ttlMs <= 0` disables it.
24
+ */
25
+ export declare function createMissCache(ttlMs: number): MissCache;
26
+ //# sourceMappingURL=featureFlags.missCache.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Negative cache for flag keys the provider does not know.
3
+ *
4
+ * @module featureFlags/featureFlags.missCache
5
+ */
6
+ /** Default lifetime of a remembered miss. */
7
+ export const DEFAULT_MISSING_FLAG_TTL_MS = 30_000;
8
+ /** Most distinct missing keys remembered at once. */
9
+ export const MAX_MISSING_FLAGS = 1_000;
10
+ /**
11
+ * Create a bounded, expiring miss cache.
12
+ *
13
+ * A registry miss used to go to `provider.get(key)` on every evaluation. When
14
+ * keys can come from request data (a `?flag=` switch, a client snapshot
15
+ * request), each distinct key was a remote round trip, every time. A miss is
16
+ * now remembered for `ttlMs`, up to {@link MAX_MISSING_FLAGS} keys (oldest
17
+ * evicted). `ttlMs <= 0` disables it.
18
+ */
19
+ export function createMissCache(ttlMs) {
20
+ const misses = new Map();
21
+ return {
22
+ has(key) {
23
+ const expiresAt = misses.get(key);
24
+ if (expiresAt === undefined)
25
+ return false;
26
+ if (Date.now() > expiresAt) {
27
+ misses.delete(key);
28
+ return false;
29
+ }
30
+ return true;
31
+ },
32
+ add(key) {
33
+ if (ttlMs <= 0)
34
+ return;
35
+ misses.delete(key);
36
+ misses.set(key, Date.now() + ttlMs);
37
+ while (misses.size > MAX_MISSING_FLAGS) {
38
+ const oldest = misses.keys().next();
39
+ if (oldest.done === true)
40
+ break;
41
+ misses.delete(oldest.value);
42
+ }
43
+ },
44
+ clear() {
45
+ misses.clear();
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=featureFlags.missCache.js.map
@@ -8,13 +8,18 @@ import type { FeatureFlagRegistry } from "../registry/registry.core.js";
8
8
  /**
9
9
  * Resolve dependencies for a flag, detecting cycles.
10
10
  *
11
+ * A dependency is satisfied when the prerequisite *evaluates* on for the
12
+ * given context (see `dependenciesOn`), not merely when its kill switch is
13
+ * on. Without a context the prerequisites are evaluated against `{}`.
14
+ *
11
15
  * @param key - The flag key to resolve.
12
16
  * @param registry - The flag registry.
13
17
  * @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.
18
+ * @param satisfied - Keys already proved satisfied *for this context*.
19
+ * @param context - The evaluation context the prerequisites are checked for.
20
+ * @returns True if the flag is enabled and every dependency is on.
16
21
  */
17
- export declare function resolveDependencies(key: string, registry: FeatureFlagRegistry, chain?: Set<string>, satisfied?: Set<string>): boolean;
22
+ export declare function resolveDependencies(key: string, registry: FeatureFlagRegistry, chain?: Set<string>, satisfied?: Set<string>, context?: FeatureFlagContext): boolean;
18
23
  /**
19
24
  * Merge default context with provided context.
20
25
  */
@@ -3,45 +3,33 @@
3
3
  *
4
4
  * @module featureFlags/featureFlags.resolve
5
5
  */
6
+ import { dependenciesOn } from "./featureFlags.dependency.js";
6
7
  /**
7
8
  * Resolve dependencies for a flag, detecting cycles.
8
9
  *
10
+ * A dependency is satisfied when the prerequisite *evaluates* on for the
11
+ * given context (see `dependenciesOn`), not merely when its kill switch is
12
+ * on. Without a context the prerequisites are evaluated against `{}`.
13
+ *
9
14
  * @param key - The flag key to resolve.
10
15
  * @param registry - The flag registry.
11
16
  * @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.
17
+ * @param satisfied - Keys already proved satisfied *for this context*.
18
+ * @param context - The evaluation context the prerequisites are checked for.
19
+ * @returns True if the flag is enabled and every dependency is on.
14
20
  */
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.
21
+ export function resolveDependencies(key, registry, chain = new Set(), satisfied = new Set(), context = {}) {
20
22
  if (satisfied.has(key))
21
23
  return true;
22
24
  if (chain.has(key))
23
25
  return false;
24
- chain.add(key);
25
26
  const flag = registry.get(key);
26
- try {
27
- if (!flag || !flag.enabled)
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
- }
27
+ if (!flag || !flag.enabled)
28
+ return false;
29
+ const ok = dependenciesOn(flag, registry, context, new Set([...chain, key]));
30
+ if (ok)
37
31
  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);
44
- }
32
+ return ok;
45
33
  }
46
34
  /**
47
35
  * Merge default context with provided context.
@@ -10,6 +10,12 @@ import type { FeatureFlagProvider, RefreshableFeatureFlagProvider } from "../fea
10
10
  export interface CachedProviderOptions {
11
11
  /** Time-to-live in milliseconds (default: 30,000). */
12
12
  readonly ttl?: number;
13
+ /**
14
+ * Most per-key entries held (default: 1,000). Past it, expired entries are
15
+ * swept and then the oldest evicted. Every distinct key asked for used to
16
+ * stay in the map for good, misses included.
17
+ */
18
+ readonly maxEntries?: number;
13
19
  }
14
20
  /**
15
21
  * Create a cached feature flag provider.
@@ -14,18 +14,55 @@
14
14
  */
15
15
  export function createCachedProvider(inner, options = {}) {
16
16
  const ttl = options.ttl ?? 30_000;
17
+ const maxEntries = options.maxEntries ?? 1_000;
17
18
  const flagCache = new Map();
18
19
  let listCache;
19
20
  function isExpired(entry) {
20
21
  return Date.now() > entry.expiresAt;
21
22
  }
23
+ function remember(key, entry) {
24
+ flagCache.delete(key);
25
+ flagCache.set(key, entry);
26
+ if (flagCache.size <= maxEntries)
27
+ return;
28
+ for (const [cachedKey, cached] of flagCache) {
29
+ if (isExpired(cached))
30
+ flagCache.delete(cachedKey);
31
+ }
32
+ while (flagCache.size > maxEntries) {
33
+ const oldest = flagCache.keys().next();
34
+ if (oldest.done === true)
35
+ break;
36
+ flagCache.delete(oldest.value);
37
+ }
38
+ }
39
+ function clear() {
40
+ flagCache.clear();
41
+ listCache = undefined;
42
+ }
43
+ // A change announced by the upstream provider is forwarded, and the cache
44
+ // is dropped first so a `get()` made by the listener sees the new state.
45
+ // Without this the wrapper had no `subscribe`, so `createFeatureFlags` on
46
+ // a cached provider never heard about a flag flipped at the source and
47
+ // served the stale copy until the TTL ran out.
48
+ const subscribe = inner.subscribe
49
+ ? {
50
+ subscribe(listener) {
51
+ return inner.subscribe((flags) => {
52
+ clear();
53
+ listener(flags);
54
+ });
55
+ },
56
+ }
57
+ : {};
22
58
  return {
59
+ ...subscribe,
23
60
  async get(key) {
24
61
  const cached = flagCache.get(key);
25
62
  if (cached && !isExpired(cached))
26
63
  return cached.value;
27
64
  const flag = await inner.get(key);
28
- flagCache.set(key, { value: flag, expiresAt: Date.now() + ttl });
65
+ remember(key, { value: flag, expiresAt: Date.now() + ttl });
29
66
  return flag;
30
67
  },
31
68
  async getAll() {
@@ -36,8 +73,7 @@ export function createCachedProvider(inner, options = {}) {
36
73
  return flags;
37
74
  },
38
75
  async refresh() {
39
- flagCache.clear();
40
- listCache = undefined;
76
+ clear();
41
77
  await inner.refresh?.();
42
78
  },
43
79
  };
@@ -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?.();
@@ -15,9 +15,14 @@ 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
  /**
@@ -5,9 +5,12 @@
5
5
  */
6
6
  import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featureFlagValue.type.js";
7
7
  /**
8
- * Check if a value is a plain object (not null, not array).
8
+ * `isPlainObject` is owned by `@zudojs/types` and re-exported here so the
9
+ * existing public export keeps working (and is the same function).
10
+ *
11
+ * @deprecated Import `isPlainObject` from `@zudojs/types`.
9
12
  */
10
- export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
13
+ export { isPlainObject } from "@zudojs/types";
11
14
  /**
12
15
  * Check if two feature flag values are equal.
13
16
  */
@@ -4,11 +4,12 @@
4
4
  * @module utils/utils
5
5
  */
6
6
  /**
7
- * Check if a value is a plain object (not null, not array).
7
+ * `isPlainObject` is owned by `@zudojs/types` and re-exported here so the
8
+ * existing public export keeps working (and is the same function).
9
+ *
10
+ * @deprecated Import `isPlainObject` from `@zudojs/types`.
8
11
  */
9
- export function isPlainObject(value) {
10
- return typeof value === "object" && value !== null && !Array.isArray(value);
11
- }
12
+ export { isPlainObject } from "@zudojs/types";
12
13
  /**
13
14
  * Check if two feature flag values are equal.
14
15
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/feature-flags",
3
- "version": "1.0.0",
3
+ "version": "1.2.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,13 +11,18 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.0.0"
14
+ "@zudojs/errors": "1.1.0",
15
+ "@zudojs/types": "1.1.0"
15
16
  },
16
17
  "devDependencies": {
17
18
  "typescript": "7.0.2",
18
19
  "vitest": "^4.1.11"
19
20
  },
20
21
  "license": "MIT",
22
+ "author": {
23
+ "name": "Oluwayemi Oyinlola",
24
+ "url": "https://github.com/oyinlola-tech"
25
+ },
21
26
  "type": "module",
22
27
  "files": [
23
28
  "dist",