@zudojs/feature-flags 1.1.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
@@ -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,7 +61,7 @@ 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
  ```
@@ -70,7 +76,7 @@ from JSON does — and expires the flag just the same.
70
76
  | `static` | always |
71
77
  | `user` | `context.userId` is in `users` |
72
78
  | `tenant` | `context.tenantId` is in `tenants` |
73
- | `attribute` | `attribute` compared to `value` with `operator` |
79
+ | `attribute` | `attribute` compared to `value` with `operator`; serves `result` (default `true`) |
74
80
  | `percentage` | the subject's bucket falls inside `percentage` |
75
81
  | `schedule` | now is between `startAt` and `endAt` |
76
82
  | `variant` | always, assigning a variant by weight |
@@ -86,9 +92,25 @@ Operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`,
86
92
  Attribute paths use dot notation and are resolved against the context first,
87
93
  then `context.attributes`. Only **own** properties are traversed:
88
94
  `__proto__`, `constructor` and `prototype` never resolve, so a rule cannot
89
- accidentally (or deliberately) target everyone through the prototype chain. A
90
- `matches` pattern that does not compile, or is longer than 512 characters,
91
- 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.
92
114
 
93
115
  ## Rollouts and variants
94
116
 
@@ -105,9 +127,13 @@ is 90/10.
105
127
  ## Dependencies
106
128
 
107
129
  A flag may declare `dependencies`. It evaluates normally only when every
108
- dependency — transitively — exists and is enabled; otherwise the result is
109
- `dependency_disabled` with the declared default. Cycles resolve to disabled;
110
- 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.
111
137
 
112
138
  `evaluateFlag()` on its own has no registry and cannot resolve dependencies,
113
139
  so it reports `dependency_disabled` for any flag that declares them unless
@@ -138,6 +164,18 @@ and `createCompositeProvider` forward those announcements — the cache is
138
164
  dropped first, and the composite announces its merged view — so the stack
139
165
  above still propagates a change made to `remoteProvider`.
140
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
+ 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
+
141
179
  `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
142
180
  including an empty `FEATURE_X=`, stays a string.
143
181
 
@@ -167,6 +205,12 @@ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
167
205
  An unreachable store never enables a flag. Set `throwOnProviderError: true`
168
206
  to own the failure yourself instead.
169
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
+
170
214
  ```typescript
171
215
  const flags = createFeatureFlags({
172
216
  provider,
@@ -101,6 +101,14 @@ export function evaluateFlag(flag, context = {}, options = {}) {
101
101
  }
102
102
  for (let i = 0; i < flag.rules.length; i++) {
103
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
+ }
104
112
  const result = evaluateRule(rule, context, flag.key);
105
113
  if (result.matched) {
106
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 {
@@ -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
@@ -34,6 +34,19 @@ 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;
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;
37
50
  }
38
51
  /** The public FeatureFlags API. */
39
52
  export interface FeatureFlags {
@@ -9,6 +9,8 @@ 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";
13
+ import { createProviderCooloff, DEFAULT_PROVIDER_COOLOFF_MS, } from "./featureFlags.cooloff.js";
12
14
  /**
13
15
  * Create a FeatureFlags instance.
14
16
  *
@@ -16,7 +18,9 @@ import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
16
18
  * @returns A FeatureFlags API object.
17
19
  */
18
20
  export function createFeatureFlags(options) {
19
- const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, } = options;
21
+ const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, providerCooloffMs = DEFAULT_PROVIDER_COOLOFF_MS, } = options;
22
+ const misses = createMissCache(missingFlagTtlMs);
23
+ const cooloff = createProviderCooloff(providerCooloffMs);
20
24
  let registry = createFeatureFlagRegistry();
21
25
  let loaded = false;
22
26
  /**
@@ -25,6 +29,7 @@ export function createFeatureFlags(options) {
25
29
  * @returns `false` when the failure was contained.
26
30
  */
27
31
  function handleProviderError(error, source) {
32
+ cooloff.recordFailure();
28
33
  if (throwOnProviderError)
29
34
  throw error;
30
35
  onError?.(error instanceof Error
@@ -39,6 +44,8 @@ export function createFeatureFlags(options) {
39
44
  const flags = await provider.getAll();
40
45
  registry = createFeatureFlagRegistry(flags);
41
46
  loaded = true;
47
+ misses.clear();
48
+ cooloff.recordSuccess();
42
49
  return true;
43
50
  }
44
51
  catch (error) {
@@ -51,16 +58,42 @@ export function createFeatureFlags(options) {
51
58
  // every single evaluation.
52
59
  if (loaded)
53
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;
54
65
  return load();
55
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
+ }
56
82
  async function resolveFlag(key) {
57
83
  const known = registry.get(key);
58
84
  if (known)
59
85
  return { flag: known, reachable: true };
86
+ if (misses.has(key))
87
+ return { flag: undefined, reachable: true };
88
+ if (cooloff.active())
89
+ return { flag: undefined, reachable: false };
60
90
  try {
61
91
  const flag = await provider.get(key);
62
92
  if (flag)
63
93
  registry.set(flag);
94
+ else if (loaded)
95
+ misses.add(key);
96
+ cooloff.recordSuccess();
64
97
  return { flag, reachable: true };
65
98
  }
66
99
  catch (error) {
@@ -78,6 +111,8 @@ export function createFeatureFlags(options) {
78
111
  let unsubscribe = provider.subscribe?.((flags) => {
79
112
  registry = createFeatureFlagRegistry(flags);
80
113
  loaded = true;
114
+ misses.clear();
115
+ cooloff.recordSuccess();
81
116
  });
82
117
  const api = {
83
118
  async isEnabled(key, context) {
@@ -124,11 +159,11 @@ export function createFeatureFlags(options) {
124
159
  }
125
160
  const dependenciesSatisfied = !flag.dependencies ||
126
161
  flag.dependencies.length === 0 ||
127
- resolveDependencies(key, registry);
162
+ resolveDependencies(key, registry, undefined, undefined, mergedCtx);
128
163
  return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
129
164
  },
130
165
  async snapshot(context) {
131
- await ensureLoaded();
166
+ requireAvailable(await ensureLoaded(), "snapshot");
132
167
  const mergedCtx = mergeContext(defaultContext, context);
133
168
  const flags = registry.getAll();
134
169
  const results = new Map();
@@ -140,7 +175,7 @@ export function createFeatureFlags(options) {
140
175
  continue;
141
176
  const dependenciesSatisfied = !flag.dependencies ||
142
177
  flag.dependencies.length === 0 ||
143
- resolveDependencies(flag.key, registry);
178
+ resolveDependencies(flag.key, registry, undefined, undefined, mergedCtx);
144
179
  results.set(flag.key, evaluateFlag(flag, mergedCtx, { dependenciesSatisfied }));
145
180
  }
146
181
  return results;
@@ -155,7 +190,7 @@ export function createFeatureFlags(options) {
155
190
  await load();
156
191
  },
157
192
  async getAll() {
158
- await ensureLoaded();
193
+ requireAvailable(await ensureLoaded(), "getAll");
159
194
  return registry.getAll();
160
195
  },
161
196
  close() {
@@ -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,11 +14,28 @@
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
+ }
22
39
  function clear() {
23
40
  flagCache.clear();
24
41
  listCache = undefined;
@@ -45,7 +62,7 @@ export function createCachedProvider(inner, options = {}) {
45
62
  if (cached && !isExpired(cached))
46
63
  return cached.value;
47
64
  const flag = await inner.get(key);
48
- flagCache.set(key, { value: flag, expiresAt: Date.now() + ttl });
65
+ remember(key, { value: flag, expiresAt: Date.now() + ttl });
49
66
  return flag;
50
67
  },
51
68
  async getAll() {
@@ -5,11 +5,23 @@
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.
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.
13
25
  */
14
26
  export declare function valuesEqual(a: FeatureFlagValue, b: FeatureFlagValue): boolean;
15
27
  //# sourceMappingURL=utils.helper.d.ts.map
@@ -4,24 +4,77 @@
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.
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.
14
24
  */
15
25
  export function valuesEqual(a, b) {
26
+ return deepEqual(a, b, new Map());
27
+ }
28
+ function deepEqual(a, b, seen) {
16
29
  if (a === b)
17
30
  return true;
18
- 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")
19
35
  return false;
20
- if (typeof a !== typeof b)
36
+ if (a === null || b === null)
21
37
  return false;
22
- if (typeof a === "object" && typeof b === "object") {
23
- 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);
49
+ }
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());
24
57
  }
25
- return false;
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;
26
79
  }
27
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.1.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,7 +11,8 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.0.1"
14
+ "@zudojs/errors": "1.2.0",
15
+ "@zudojs/types": "1.1.1"
15
16
  },
16
17
  "devDependencies": {
17
18
  "typescript": "7.0.2",