@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 +14 -1
- package/dist/evaluator/evaluator.core.js +20 -1
- package/dist/featureFlags/featureFlags.core.js +9 -5
- package/dist/provider/providerCached.core.js +21 -2
- package/dist/provider/providerComposite.core.js +62 -14
- package/dist/provider/providerEnvironment.core.js +8 -3
- package/package.json +6 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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.
|
|
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",
|