@zudojs/feature-flags 1.1.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 +41 -8
- package/dist/evaluator/evaluator.core.js +8 -0
- package/dist/evaluator/evaluatorAttribute.js +6 -2
- package/dist/evaluator/evaluatorPattern.safety.d.ts +23 -0
- package/dist/evaluator/evaluatorPattern.safety.js +79 -0
- package/dist/evaluator/evaluatorRule.core.js +1 -1
- package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.d.ts +11 -1
- package/dist/featureFlags/featureFlags.core.d.ts +7 -0
- package/dist/featureFlags/featureFlags.core.js +11 -3
- package/dist/featureFlags/featureFlags.dependency.d.ts +34 -0
- package/dist/featureFlags/featureFlags.dependency.js +72 -0
- package/dist/featureFlags/featureFlags.missCache.d.ts +26 -0
- package/dist/featureFlags/featureFlags.missCache.js +49 -0
- package/dist/featureFlags/featureFlags.resolve.d.ts +8 -3
- package/dist/featureFlags/featureFlags.resolve.js +14 -26
- package/dist/provider/providerCached.core.d.ts +6 -0
- package/dist/provider/providerCached.core.js +18 -1
- package/dist/utils/utils.helper.d.ts +5 -2
- package/dist/utils/utils.helper.js +5 -4
- package/package.json +3 -2
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
|
|
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.
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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,13 @@ 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
|
+
|
|
141
174
|
`createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
|
|
142
175
|
including an empty `FEATURE_X=`, stays a string.
|
|
143
176
|
|
|
@@ -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
|
-
/**
|
|
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) {
|
|
@@ -57,10 +60,14 @@ export function createFeatureFlags(options) {
|
|
|
57
60
|
const known = registry.get(key);
|
|
58
61
|
if (known)
|
|
59
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);
|
|
69
|
+
else if (loaded)
|
|
70
|
+
misses.add(key);
|
|
64
71
|
return { flag, reachable: true };
|
|
65
72
|
}
|
|
66
73
|
catch (error) {
|
|
@@ -78,6 +85,7 @@ export function createFeatureFlags(options) {
|
|
|
78
85
|
let unsubscribe = provider.subscribe?.((flags) => {
|
|
79
86
|
registry = createFeatureFlagRegistry(flags);
|
|
80
87
|
loaded = true;
|
|
88
|
+
misses.clear();
|
|
81
89
|
});
|
|
82
90
|
const api = {
|
|
83
91
|
async isEnabled(key, context) {
|
|
@@ -124,7 +132,7 @@ export function createFeatureFlags(options) {
|
|
|
124
132
|
}
|
|
125
133
|
const dependenciesSatisfied = !flag.dependencies ||
|
|
126
134
|
flag.dependencies.length === 0 ||
|
|
127
|
-
resolveDependencies(key, registry);
|
|
135
|
+
resolveDependencies(key, registry, undefined, undefined, mergedCtx);
|
|
128
136
|
return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
|
|
129
137
|
},
|
|
130
138
|
async snapshot(context) {
|
|
@@ -140,7 +148,7 @@ export function createFeatureFlags(options) {
|
|
|
140
148
|
continue;
|
|
141
149
|
const dependenciesSatisfied = !flag.dependencies ||
|
|
142
150
|
flag.dependencies.length === 0 ||
|
|
143
|
-
resolveDependencies(flag.key, registry);
|
|
151
|
+
resolveDependencies(flag.key, registry, undefined, undefined, mergedCtx);
|
|
144
152
|
results.set(flag.key, evaluateFlag(flag, mergedCtx, { dependenciesSatisfied }));
|
|
145
153
|
}
|
|
146
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
|
|
15
|
-
* @
|
|
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
|
|
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
|
|
13
|
-
* @
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
remember(key, { value: flag, expiresAt: Date.now() + ttl });
|
|
49
66
|
return flag;
|
|
50
67
|
},
|
|
51
68
|
async getAll() {
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featureFlagValue.type.js";
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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,7 +11,8 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@zudojs/errors": "1.0
|
|
14
|
+
"@zudojs/errors": "1.1.0",
|
|
15
|
+
"@zudojs/types": "1.1.0"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
|
17
18
|
"typescript": "7.0.2",
|