@zudojs/feature-flags 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,9 @@ interface FeatureFlag {
60
60
  }
61
61
  ```
62
62
 
63
+ `expiresAt` may also arrive as an ISO string or a timestamp — a flag loaded
64
+ from JSON does — and expires the flag just the same.
65
+
63
66
  ## Rules
64
67
 
65
68
  | Type | Matches when |
@@ -130,7 +133,13 @@ const provider = createCachedProvider(
130
133
  ```
131
134
 
132
135
  `createMemoryProvider` returns a typed provider with `set`, `delete` and
133
- `setAll`, and it announces every change to subscribers.
136
+ `setAll`, and it announces every change to subscribers. `createCachedProvider`
137
+ and `createCompositeProvider` forward those announcements — the cache is
138
+ dropped first, and the composite announces its merged view — so the stack
139
+ above still propagates a change made to `remoteProvider`.
140
+
141
+ `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
142
+ including an empty `FEATURE_X=`, stays a string.
134
143
 
135
144
  ## Change propagation
136
145
 
@@ -151,6 +160,10 @@ changes.
151
160
  | Dependency not satisfied | `dependency_disabled`, the declared default |
152
161
  | Provider unreachable | `error`, reported to `onError`; never enabled |
153
162
 
163
+ "Unreachable" covers both `getAll()` and a `get()` for a flag not yet loaded:
164
+ a lookup the store could not answer is `error`, never `not_found`, and does
165
+ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
166
+
154
167
  An unreachable store never enables a flag. Set `throwOnProviderError: true`
155
168
  to own the failure yourself instead.
156
169
 
@@ -6,6 +6,25 @@
6
6
  * @module evaluator/evaluator
7
7
  */
8
8
  import { evaluateRule } from "./evaluatorRule.core.js";
9
+ /**
10
+ * Whether an `expiresAt` lies in the past.
11
+ *
12
+ * The type says `Date`, but a flag loaded from JSON — which is what every
13
+ * remote provider hands over — carries an ISO string, and `"2020-01-01" <
14
+ * new Date()` is always `false`. A flag that had expired at the source
15
+ * therefore never expired here. Anything `Date` can parse is honoured; a
16
+ * value it cannot parse is treated as no expiry.
17
+ */
18
+ function isExpired(expiresAt) {
19
+ if (expiresAt === undefined || expiresAt === null)
20
+ return false;
21
+ const time = expiresAt instanceof Date
22
+ ? expiresAt.getTime()
23
+ : typeof expiresAt === "string" || typeof expiresAt === "number"
24
+ ? new Date(expiresAt).getTime()
25
+ : Number.NaN;
26
+ return !Number.isNaN(time) && time < Date.now();
27
+ }
9
28
  /** The evaluation reason a matching rule of each type produces. */
10
29
  function reasonFor(type) {
11
30
  switch (type) {
@@ -49,7 +68,7 @@ export function evaluateFlag(flag, context = {}, options = {}) {
49
68
  defaulted: true,
50
69
  };
51
70
  }
52
- if (flag.metadata?.expiresAt && flag.metadata.expiresAt < new Date()) {
71
+ if (isExpired(flag.metadata?.expiresAt)) {
53
72
  return {
54
73
  key: flag.key,
55
74
  value: flag.defaultValue,
@@ -56,16 +56,20 @@ export function createFeatureFlags(options) {
56
56
  async function resolveFlag(key) {
57
57
  const known = registry.get(key);
58
58
  if (known)
59
- return known;
59
+ return { flag: known, reachable: true };
60
60
  try {
61
61
  const flag = await provider.get(key);
62
62
  if (flag)
63
63
  registry.set(flag);
64
- return flag;
64
+ return { flag, reachable: true };
65
65
  }
66
66
  catch (error) {
67
67
  handleProviderError(error, "FeatureFlagProvider.get");
68
- return undefined;
68
+ // A `get()` that failed is not a `get()` that found nothing: reporting
69
+ // it as `not_found` (or throwing FeatureFlagNotFoundError under
70
+ // `throwOnMissing`) told the caller the flag does not exist when the
71
+ // truth is that the store could not be asked.
72
+ return { flag: undefined, reachable: false };
69
73
  }
70
74
  }
71
75
  // A provider that can announce changes is asked to: without this, a flag
@@ -97,9 +101,9 @@ export function createFeatureFlags(options) {
97
101
  async evaluate(key, context) {
98
102
  const available = await ensureLoaded();
99
103
  const mergedCtx = mergeContext(defaultContext, context);
100
- const flag = await resolveFlag(key);
104
+ const { flag, reachable } = await resolveFlag(key);
101
105
  if (!flag) {
102
- if (!available) {
106
+ if (!available || !reachable) {
103
107
  // The store could not be reached and nothing is known about this
104
108
  // flag. Report it as an error, not as a decision.
105
109
  return {
@@ -19,7 +19,27 @@ export function createCachedProvider(inner, options = {}) {
19
19
  function isExpired(entry) {
20
20
  return Date.now() > entry.expiresAt;
21
21
  }
22
+ function clear() {
23
+ flagCache.clear();
24
+ listCache = undefined;
25
+ }
26
+ // A change announced by the upstream provider is forwarded, and the cache
27
+ // is dropped first so a `get()` made by the listener sees the new state.
28
+ // Without this the wrapper had no `subscribe`, so `createFeatureFlags` on
29
+ // a cached provider never heard about a flag flipped at the source and
30
+ // served the stale copy until the TTL ran out.
31
+ const subscribe = inner.subscribe
32
+ ? {
33
+ subscribe(listener) {
34
+ return inner.subscribe((flags) => {
35
+ clear();
36
+ listener(flags);
37
+ });
38
+ },
39
+ }
40
+ : {};
22
41
  return {
42
+ ...subscribe,
23
43
  async get(key) {
24
44
  const cached = flagCache.get(key);
25
45
  if (cached && !isExpired(cached))
@@ -36,8 +56,7 @@ export function createCachedProvider(inner, options = {}) {
36
56
  return flags;
37
57
  },
38
58
  async refresh() {
39
- flagCache.clear();
40
- listCache = undefined;
59
+ clear();
41
60
  await inner.refresh?.();
42
61
  },
43
62
  };
@@ -15,7 +15,68 @@
15
15
  * @returns A composite FeatureFlagProvider.
16
16
  */
17
17
  export function createCompositeProvider(providers) {
18
+ /**
19
+ * The last flag list each member produced — from its own `getAll()` or
20
+ * from a change it announced. Lets a change be re-announced synchronously
21
+ * as the merged view, without a round trip to every other member.
22
+ */
23
+ const lastSeen = new Map();
24
+ function merge(lists) {
25
+ const seen = new Set();
26
+ const result = [];
27
+ for (const flags of lists) {
28
+ for (const flag of flags) {
29
+ if (!seen.has(flag.key)) {
30
+ seen.add(flag.key);
31
+ result.push(flag);
32
+ }
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+ async function getAll() {
38
+ const lists = [];
39
+ for (const provider of providers) {
40
+ const flags = await provider.getAll();
41
+ lastSeen.set(provider, flags);
42
+ lists.push(flags);
43
+ }
44
+ return merge(lists);
45
+ }
46
+ // Changes announced by any member are re-announced as the merged view, so
47
+ // a consumer subscribed to the composite sees the same precedence
48
+ // `getAll()` applies. The composite used to have no `subscribe` at all,
49
+ // which silently cut change propagation for every provider behind it.
50
+ const subscribable = providers.filter((provider) => provider.subscribe);
51
+ const subscribe = subscribable.length > 0
52
+ ? {
53
+ subscribe(listener) {
54
+ // A merged snapshot fetched asynchronously must not overtake a
55
+ // later change.
56
+ let version = 0;
57
+ const unsubscribes = subscribable.map((provider) => provider.subscribe((flags) => {
58
+ lastSeen.set(provider, flags);
59
+ const current = ++version;
60
+ if (providers.every((member) => lastSeen.has(member))) {
61
+ listener(merge(providers.map((member) => lastSeen.get(member))));
62
+ return;
63
+ }
64
+ void getAll().then((merged) => {
65
+ if (current === version)
66
+ listener(merged);
67
+ }, () => {
68
+ /* a member that cannot be read keeps the last view */
69
+ });
70
+ }));
71
+ return () => {
72
+ for (const unsubscribe of unsubscribes)
73
+ unsubscribe();
74
+ };
75
+ },
76
+ }
77
+ : {};
18
78
  return {
79
+ ...subscribe,
19
80
  async get(key) {
20
81
  for (const provider of providers) {
21
82
  const flag = await provider.get(key);
@@ -24,20 +85,7 @@ export function createCompositeProvider(providers) {
24
85
  }
25
86
  return undefined;
26
87
  },
27
- async getAll() {
28
- const seen = new Set();
29
- const result = [];
30
- for (const provider of providers) {
31
- const flags = await provider.getAll();
32
- for (const flag of flags) {
33
- if (!seen.has(flag.key)) {
34
- seen.add(flag.key);
35
- result.push(flag);
36
- }
37
- }
38
- }
39
- return result;
40
- },
88
+ getAll,
41
89
  async refresh() {
42
90
  for (const provider of providers) {
43
91
  await provider.refresh?.();
@@ -15,9 +15,14 @@ function parseEnvValue(raw) {
15
15
  return true;
16
16
  if (raw === "false")
17
17
  return false;
18
- const num = Number(raw);
19
- if (!Number.isNaN(num))
20
- return num;
18
+ // `Number("")` and `Number(" ")` are both `0`, so an empty
19
+ // `FEATURE_X=` used to become the number zero. Only a value that is not
20
+ // blank is a candidate for a number; blank stays the string it is.
21
+ if (raw.trim() !== "") {
22
+ const num = Number(raw);
23
+ if (!Number.isNaN(num))
24
+ return num;
25
+ }
21
26
  return raw;
22
27
  }
23
28
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/feature-flags",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Feature flag system with deterministic rollouts, rule engine, providers, variants, snapshots, and evaluation context.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,13 +11,17 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.0.0"
14
+ "@zudojs/errors": "1.0.1"
15
15
  },
16
16
  "devDependencies": {
17
17
  "typescript": "7.0.2",
18
18
  "vitest": "^4.1.11"
19
19
  },
20
20
  "license": "MIT",
21
+ "author": {
22
+ "name": "Oluwayemi Oyinlola",
23
+ "url": "https://github.com/oyinlola-tech"
24
+ },
21
25
  "type": "module",
22
26
  "files": [
23
27
  "dist",