@zudojs/feature-flags 1.3.0 → 1.4.1

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
@@ -57,8 +57,9 @@ choose.
57
57
  interface FeatureFlag {
58
58
  key: string;
59
59
  enabled: boolean; // the global kill switch
60
- defaultValue: FeatureFlagValue; // used whenever no rule decides
61
- state?: "active" | "archived" | "draft";
60
+ defaultValue: FeatureFlagValue; // used when the flag is on and no rule decides
61
+ offValue?: FeatureFlagValue; // served while the flag is off (see below)
62
+ state?: "active" | "disabled" | "archived" | "draft";
62
63
  visibility?: "client" | "server";
63
64
  rules?: FeatureFlagRule[]; // evaluated in order, first match wins
64
65
  dependencies?: string[]; // other flags that must be on for the same context
@@ -69,6 +70,27 @@ interface FeatureFlag {
69
70
  `expiresAt` may also arrive as an ISO string or a timestamp — a flag loaded
70
71
  from JSON does — and expires the flag just the same.
71
72
 
73
+ ### Off means off
74
+
75
+ A flag is **off** when it is killed (`enabled: false` or `state:
76
+ "disabled"`), a draft, archived, expired, or blocked by a dependency. An off
77
+ flag runs no rules and serves its **off value**:
78
+
79
+ 1. `offValue`, when the flag declares one;
80
+ 2. otherwise `false` for a boolean flag;
81
+ 3. otherwise `defaultValue` (a string, number or object flag has no natural
82
+ "off", so the baseline value is served).
83
+
84
+ So `{ key: "x", enabled: false, defaultValue: true }` evaluates to `false`
85
+ and `isEnabled("x")` is `false`. Before 1.4 an off flag served
86
+ `defaultValue`, which made the kill switch fail open for any flag whose
87
+ default was `true`. For a variant flag, declare the variant to fall back to:
88
+
89
+ ```typescript
90
+ { key: "checkout", enabled: false, defaultValue: "new", offValue: "control",
91
+ rules: [{ type: "variant", variants: [{ key: "new", weight: 50 }, { key: "control", weight: 50 }] }] }
92
+ ```
93
+
72
94
  ## Rules
73
95
 
74
96
  | Type | Matches when |
@@ -131,8 +153,8 @@ dependency is **on for the same context**: the prerequisite is evaluated —
131
153
  state, expiry, rules, rollout, and its own dependencies — and must not be
132
154
  disabled, draft, archived or expired, nor evaluate to `false`, `null` or
133
155
  `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
156
+ other 90%. Otherwise the result is `dependency_disabled` with the flag's
157
+ off value. Cycles resolve to disabled; a shared dependency reached down two
136
158
  branches is not a cycle.
137
159
 
138
160
  `evaluateFlag()` on its own has no registry and cannot resolve dependencies,
@@ -177,7 +199,16 @@ calls per window instead of two per evaluation. The first successful call
177
199
  closes the window, and `refresh()` always probes.
178
200
 
179
201
  `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
180
- including an empty `FEATURE_X=`, stays a string.
202
+ including an empty `FEATURE_X=`, stays a string. Every flag it reads is
203
+ enabled.
204
+
205
+ Keys are the variable name without the prefix, **lower-cased with `_`
206
+ turned into `-`**: `FEATURE_NEW_CHECKOUT=true` is the flag `new-checkout`,
207
+ the same key a memory or remote provider uses, so an environment variable
208
+ overrides that flag inside a composite. `get()` normalises the key it is
209
+ asked for the same way, so `NEW_CHECKOUT` and `new_checkout` also find it.
210
+ Pass `keyFormat: "preserve"` to keep the variable's spelling (`NEW_CHECKOUT`),
211
+ which was the behaviour before 1.4.
181
212
 
182
213
  ## Change propagation
183
214
 
@@ -193,9 +224,9 @@ changes.
193
224
  | -------------------------------- | ------------------------------------------------------ |
194
225
  | Flag not found | `not_found`, value `undefined`; `isEnabled` is `false` |
195
226
  | Flag not found, `throwOnMissing` | throws `FeatureFlagNotFoundError` |
196
- | Flag disabled or draft | `disabled`, the declared default |
197
- | Flag archived or expired | `expired`, the declared default |
198
- | Dependency not satisfied | `dependency_disabled`, the declared default |
227
+ | Flag disabled or draft | `disabled`, the off value; `isEnabled` is `false` |
228
+ | Flag archived or expired | `expired`, the off value; `isEnabled` is `false` |
229
+ | Dependency not satisfied | `dependency_disabled`, the off value |
199
230
  | Provider unreachable | `error`, reported to `onError`; never enabled |
200
231
 
201
232
  "Unreachable" covers both `getAll()` and a `get()` for a flag not yet loaded:
@@ -25,6 +25,10 @@ export interface EvaluateFlagOptions {
25
25
  /**
26
26
  * Evaluate a feature flag against a context.
27
27
  *
28
+ * A flag that is off — killed, draft, archived, expired, or blocked by a
29
+ * dependency — serves its off value (see {@link offValueOf}): `offValue`
30
+ * when declared, else `false` for a boolean flag, else `defaultValue`.
31
+ *
28
32
  * @param flag - The feature flag definition.
29
33
  * @param context - The evaluation context.
30
34
  * @param options - Facts the caller resolved that this function cannot.
@@ -6,25 +6,7 @@
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
+ import { gateReason, offValueOf } from "./evaluatorGate.core.js";
28
10
  /** The evaluation reason a matching rule of each type produces. */
29
11
  function reasonFor(type) {
30
12
  switch (type) {
@@ -46,48 +28,22 @@ function reasonFor(type) {
46
28
  /**
47
29
  * Evaluate a feature flag against a context.
48
30
  *
31
+ * A flag that is off — killed, draft, archived, expired, or blocked by a
32
+ * dependency — serves its off value (see {@link offValueOf}): `offValue`
33
+ * when declared, else `false` for a boolean flag, else `defaultValue`.
34
+ *
49
35
  * @param flag - The feature flag definition.
50
36
  * @param context - The evaluation context.
51
37
  * @param options - Facts the caller resolved that this function cannot.
52
38
  * @returns A structured evaluation result.
53
39
  */
54
40
  export function evaluateFlag(flag, context = {}, options = {}) {
55
- if (!flag.enabled) {
56
- return {
57
- key: flag.key,
58
- value: flag.defaultValue,
59
- reason: "disabled",
60
- defaulted: true,
61
- };
62
- }
63
- if (flag.state === "archived" || flag.state === "draft") {
64
- return {
65
- key: flag.key,
66
- value: flag.defaultValue,
67
- reason: flag.state === "archived" ? "expired" : "disabled",
68
- defaulted: true,
69
- };
70
- }
71
- if (isExpired(flag.metadata?.expiresAt)) {
72
- return {
73
- key: flag.key,
74
- value: flag.defaultValue,
75
- reason: "expired",
76
- defaulted: true,
77
- };
78
- }
79
- if (flag.dependencies &&
80
- flag.dependencies.length > 0 &&
81
- options.dependenciesSatisfied !== true) {
82
- // Previously this returned `dependency_disabled` for *every* flag that
83
- // declared a dependency, satisfied or not — so a flag with dependencies
84
- // could never turn on, and the caller's own dependency resolution was
85
- // computed and then discarded.
41
+ const gate = gateReason(flag, options);
42
+ if (gate) {
86
43
  return {
87
44
  key: flag.key,
88
- value: flag.defaultValue,
89
- reason: "dependency_disabled",
90
- matchedRule: undefined,
45
+ value: offValueOf(flag),
46
+ reason: gate,
91
47
  defaulted: true,
92
48
  };
93
49
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Gates that turn a flag off before any rule runs, and the value it serves
3
+ * while it is off.
4
+ *
5
+ * @module evaluator/evaluatorGate.core
6
+ */
7
+ import type { FeatureFlag } from "../featureFlagTypes/featureFlag.interface.js";
8
+ import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featureFlagValue.type.js";
9
+ import type { FeatureFlagEvaluationReason } from "../featureFlagTypes/featureFlagEvaluation.js";
10
+ import type { EvaluateFlagOptions } from "./evaluator.core.js";
11
+ /**
12
+ * Why a flag is off before any rule runs, or `undefined` when it is live.
13
+ *
14
+ * Checked in order: the kill switch (`enabled: false`, `state: "disabled"`),
15
+ * a draft, an archived or expired flag, then unsatisfied dependencies.
16
+ */
17
+ export declare function gateReason(flag: FeatureFlag, options: EvaluateFlagOptions): FeatureFlagEvaluationReason | undefined;
18
+ /**
19
+ * Resolve what a flag serves when it is off — killed (`enabled: false` or
20
+ * `state: "disabled"`), a draft, archived, expired, or blocked by a
21
+ * dependency.
22
+ *
23
+ * 1. `flag.offValue`, when the flag declares one.
24
+ * 2. `false`, for a boolean flag. The kill switch must turn a feature off,
25
+ * and serving `defaultValue: true` from a killed flag left it on.
26
+ * 3. `flag.defaultValue` otherwise. A string, number or object flag has no
27
+ * natural "off", so the declared default — the baseline experience — is
28
+ * the fallback. `isEnabled()` is `false` for these regardless, because it
29
+ * is `true` only for the boolean `true`.
30
+ *
31
+ * @param flag - The flag that is off.
32
+ * @returns The value to serve.
33
+ */
34
+ export declare function offValueOf(flag: FeatureFlag): FeatureFlagValue;
35
+ //# sourceMappingURL=evaluatorGate.core.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Gates that turn a flag off before any rule runs, and the value it serves
3
+ * while it is off.
4
+ *
5
+ * @module evaluator/evaluatorGate.core
6
+ */
7
+ /**
8
+ * Whether an `expiresAt` lies in the past.
9
+ *
10
+ * The type says `Date`, but a flag loaded from JSON — which is what every
11
+ * remote provider hands over — carries an ISO string, and `"2020-01-01" <
12
+ * new Date()` is always `false`. A flag that had expired at the source
13
+ * therefore never expired here. Anything `Date` can parse is honoured; a
14
+ * value it cannot parse is treated as no expiry.
15
+ */
16
+ function isExpired(expiresAt) {
17
+ if (expiresAt === undefined || expiresAt === null)
18
+ return false;
19
+ const time = expiresAt instanceof Date
20
+ ? expiresAt.getTime()
21
+ : typeof expiresAt === "string" || typeof expiresAt === "number"
22
+ ? new Date(expiresAt).getTime()
23
+ : Number.NaN;
24
+ return !Number.isNaN(time) && time < Date.now();
25
+ }
26
+ /**
27
+ * Why a flag is off before any rule runs, or `undefined` when it is live.
28
+ *
29
+ * Checked in order: the kill switch (`enabled: false`, `state: "disabled"`),
30
+ * a draft, an archived or expired flag, then unsatisfied dependencies.
31
+ */
32
+ export function gateReason(flag, options) {
33
+ if (!flag.enabled || flag.state === "disabled" || flag.state === "draft") {
34
+ return "disabled";
35
+ }
36
+ if (flag.state === "archived" || isExpired(flag.metadata?.expiresAt)) {
37
+ return "expired";
38
+ }
39
+ const hasDependencies = !!flag.dependencies && flag.dependencies.length > 0;
40
+ if (hasDependencies && options.dependenciesSatisfied !== true) {
41
+ return "dependency_disabled";
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Resolve what a flag serves when it is off — killed (`enabled: false` or
47
+ * `state: "disabled"`), a draft, archived, expired, or blocked by a
48
+ * dependency.
49
+ *
50
+ * 1. `flag.offValue`, when the flag declares one.
51
+ * 2. `false`, for a boolean flag. The kill switch must turn a feature off,
52
+ * and serving `defaultValue: true` from a killed flag left it on.
53
+ * 3. `flag.defaultValue` otherwise. A string, number or object flag has no
54
+ * natural "off", so the declared default — the baseline experience — is
55
+ * the fallback. `isEnabled()` is `false` for these regardless, because it
56
+ * is `true` only for the boolean `true`.
57
+ *
58
+ * @param flag - The flag that is off.
59
+ * @returns The value to serve.
60
+ */
61
+ export function offValueOf(flag) {
62
+ if (flag.offValue !== undefined)
63
+ return flag.offValue;
64
+ if (typeof flag.defaultValue === "boolean")
65
+ return false;
66
+ return flag.defaultValue;
67
+ }
68
+ //# sourceMappingURL=evaluatorGate.core.js.map
@@ -30,8 +30,17 @@ export interface FeatureFlag {
30
30
  readonly key: string;
31
31
  /** Default value when no rule matches. */
32
32
  readonly defaultValue: FeatureFlagValue;
33
- /** Whether the flag is globally enabled. */
33
+ /**
34
+ * Whether the flag is globally enabled — the kill switch. A flag with
35
+ * `enabled: false` serves its off value, never `defaultValue: true`.
36
+ */
34
37
  readonly enabled: boolean;
38
+ /**
39
+ * What the flag serves while it is off: killed, draft, archived, expired,
40
+ * or blocked by a dependency. Default: `false` for a boolean flag, the
41
+ * `defaultValue` for any other.
42
+ */
43
+ readonly offValue?: FeatureFlagValue;
35
44
  /** Human-readable description. */
36
45
  readonly description?: string;
37
46
  /** Lifecycle state. */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Reads feature flags from environment variables with a configurable prefix.
5
5
  *
6
- * Example: FEATURE_NEW_UI=true → key "NEW_UI" with value true.
6
+ * Example: FEATURE_NEW_UI=true → key "new-ui" with value true.
7
7
  *
8
8
  * @module provider/providerEnvironment
9
9
  */
@@ -14,7 +14,29 @@ export interface EnvironmentProviderOptions {
14
14
  readonly prefix?: string;
15
15
  /** Process env object to read from (default: process.env). */
16
16
  readonly env?: Readonly<Record<string, string | undefined>>;
17
+ /**
18
+ * How a variable name becomes a flag key, once the prefix is removed.
19
+ *
20
+ * - `"kebab"` (default): lower-cased, `_` becomes `-`, so
21
+ * `FEATURE_NEW_CHECKOUT` is the flag `new-checkout` — the same key a
22
+ * memory or remote provider would use.
23
+ * - `"preserve"`: the name as written (`NEW_CHECKOUT`), the behaviour
24
+ * before 1.4.
25
+ *
26
+ * With `"kebab"`, `get()` normalises the key it is asked for the same way,
27
+ * so `get("NEW_CHECKOUT")`, `get("new_checkout")` and
28
+ * `get("new-checkout")` all find the flag.
29
+ */
30
+ readonly keyFormat?: "kebab" | "preserve";
17
31
  }
32
+ /**
33
+ * Normalise an environment-derived key to kebab case: `NEW_CHECKOUT` and
34
+ * `new_checkout` both become `new-checkout`.
35
+ *
36
+ * @param key - The key, with the prefix already removed.
37
+ * @returns The lower-cased key with underscores replaced by hyphens.
38
+ */
39
+ export declare function toEnvironmentFlagKey(key: string): string;
18
40
  /**
19
41
  * Create an environment-variable feature flag provider.
20
42
  *
@@ -3,10 +3,20 @@
3
3
  *
4
4
  * Reads feature flags from environment variables with a configurable prefix.
5
5
  *
6
- * Example: FEATURE_NEW_UI=true → key "NEW_UI" with value true.
6
+ * Example: FEATURE_NEW_UI=true → key "new-ui" with value true.
7
7
  *
8
8
  * @module provider/providerEnvironment
9
9
  */
10
+ /**
11
+ * Normalise an environment-derived key to kebab case: `NEW_CHECKOUT` and
12
+ * `new_checkout` both become `new-checkout`.
13
+ *
14
+ * @param key - The key, with the prefix already removed.
15
+ * @returns The lower-cased key with underscores replaced by hyphens.
16
+ */
17
+ export function toEnvironmentFlagKey(key) {
18
+ return key.toLowerCase().replace(/_/g, "-");
19
+ }
10
20
  /**
11
21
  * Parse a string value into a feature flag value.
12
22
  */
@@ -33,12 +43,15 @@ function parseEnvValue(raw) {
33
43
  */
34
44
  export function createEnvironmentProvider(options = {}) {
35
45
  const prefix = options.prefix ?? "FEATURE_";
46
+ const normalise = options.keyFormat === "preserve"
47
+ ? (key) => key
48
+ : toEnvironmentFlagKey;
36
49
  const env = options.env ?? (typeof process !== "undefined" ? process.env : {});
37
50
  function readFlags() {
38
51
  const flags = [];
39
52
  for (const [key, value] of Object.entries(env)) {
40
53
  if (key.startsWith(prefix) && value !== undefined) {
41
- const flagKey = key.slice(prefix.length);
54
+ const flagKey = normalise(key.slice(prefix.length));
42
55
  flags.push({
43
56
  key: flagKey,
44
57
  enabled: true,
@@ -53,7 +66,8 @@ export function createEnvironmentProvider(options = {}) {
53
66
  async get(key) {
54
67
  if (!cached)
55
68
  cached = readFlags();
56
- return cached.find((f) => f.key === key);
69
+ const wanted = normalise(key);
70
+ return cached.find((f) => f.key === wanted);
57
71
  },
58
72
  async getAll() {
59
73
  if (!cached)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/feature-flags",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
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,12 +11,12 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.2.0",
15
- "@zudojs/types": "1.1.1"
14
+ "@zudojs/errors": "1.3.1",
15
+ "@zudojs/types": "1.2.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "typescript": "7.0.2",
19
- "vitest": "^4.1.11"
19
+ "vitest": "^5.0.1"
20
20
  },
21
21
  "license": "MIT",
22
22
  "author": {
@@ -43,7 +43,7 @@
43
43
  "rollout",
44
44
  "a-b-testing"
45
45
  ],
46
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
46
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-feature-flags",
47
47
  "bugs": {
48
48
  "url": "https://github.com/oyinlola-tech/zudo/issues"
49
49
  },