@tehw0lf/yaft 0.0.16 → 0.0.18
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 +25 -2
- package/examples/ApiServiceBooleanProvider.d.ts +2 -0
- package/examples/ApiServiceBooleanProvider.js +25 -12
- package/examples/ApiServiceFeatureProvider.d.ts +2 -0
- package/examples/ApiServiceFeatureProvider.js +16 -3
- package/examples/LocalStorageBooleanProvider.js +3 -6
- package/index.d.ts +1 -1
- package/index.js +3 -1
- package/mapping.d.ts +19 -0
- package/mapping.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,29 @@ rules can be applied directly to a feature without going through a provider.
|
|
|
129
129
|
A missing feature is off. Unset, `null` or unparseable dates are ignored rather
|
|
130
130
|
than treated as an error, and never throw.
|
|
131
131
|
|
|
132
|
+
### API providers and a failing backend
|
|
133
|
+
|
|
134
|
+
`ApiServiceFeatureProvider` and `ApiServiceBooleanProvider` keep their data
|
|
135
|
+
when a refresh fails — and, **since 0.0.18**, also when `/features` answers
|
|
136
|
+
`200` with something that is not a toggle group (`null`, an array, a proxy's
|
|
137
|
+
error page). Before, that replaced the data with nothing and switched every
|
|
138
|
+
feature off. A failed fetch is now also retried on the next
|
|
139
|
+
`getCollectionHash`; before, one failure stopped refreshing until the backend
|
|
140
|
+
changed again. `normaliseGroup` is exported for providers of your own.
|
|
141
|
+
|
|
142
|
+
### Boolean shape
|
|
143
|
+
|
|
144
|
+
The boolean providers hold `{ "myToggle": true }` and have no time logic. Only
|
|
145
|
+
the JSON boolean `true` is on. A JSON `false` is kept in the data and reads as
|
|
146
|
+
off. Any value that is not a boolean -- `"true"`, `"false"`, `1`, `null` -- is
|
|
147
|
+
dropped when the data loads, so its key is missing from the data and reads as
|
|
148
|
+
off. `normaliseBooleans` is exported and applies the same rule.
|
|
149
|
+
|
|
150
|
+
**Changed in 0.0.17.** Before, `isEnabled` returned whatever was stored, and a
|
|
151
|
+
caller testing it with `if` turned the string `"false"` **on**, because a
|
|
152
|
+
non-empty string is truthy. A configuration that relied on string or number
|
|
153
|
+
values has to switch to real booleans.
|
|
154
|
+
|
|
132
155
|
### Date format
|
|
133
156
|
|
|
134
157
|
Dates must be **RFC 3339 with an offset** (`2026-09-18T15:00:00Z` or
|
|
@@ -166,8 +189,8 @@ is tested against that suite rather than only against its own expectations.
|
|
|
166
189
|
The version is pinned in `conformance.lock`:
|
|
167
190
|
|
|
168
191
|
```
|
|
169
|
-
version=
|
|
170
|
-
sha256=
|
|
192
|
+
version=v2.0.0
|
|
193
|
+
sha256=a10c1342e9ef346f998c0a819d90ae7f4984b53c933ed555eefab09419e993aa
|
|
171
194
|
```
|
|
172
195
|
|
|
173
196
|
`npm test` fetches that release, verifies the checksum and unpacks it before
|
|
@@ -7,5 +7,7 @@ export declare class ApiServiceBooleanProvider implements FeatureProvider<boolea
|
|
|
7
7
|
constructor(apiUrl: string, baseUUID: string);
|
|
8
8
|
getCollectionHash(configPathOrUrl: string): Promise<void>;
|
|
9
9
|
getConfig(configPathOrUrl: string): Promise<void>;
|
|
10
|
+
/** Loads the group and reports whether it did; on failure the data stays. */
|
|
11
|
+
private loadGroup;
|
|
10
12
|
isEnabled(key: string): boolean;
|
|
11
13
|
}
|
|
@@ -20,9 +20,9 @@ class ApiServiceBooleanProvider {
|
|
|
20
20
|
try {
|
|
21
21
|
const response = await axios_1.default.get(configPathOrUrl);
|
|
22
22
|
const newHash = response.data.collectionHash || response.data.value;
|
|
23
|
-
|
|
23
|
+
// Recorded only once the group has loaded, so a failed fetch is retried.
|
|
24
|
+
if (this.collectionHash !== newHash && (await this.loadGroup(`${this.apiUrl}/features/${this.baseUUID}`))) {
|
|
24
25
|
this.collectionHash = newHash;
|
|
25
|
-
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
catch (error) {
|
|
@@ -30,6 +30,10 @@ class ApiServiceBooleanProvider {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async getConfig(configPathOrUrl) {
|
|
33
|
+
await this.loadGroup(configPathOrUrl);
|
|
34
|
+
}
|
|
35
|
+
/** Loads the group and reports whether it did; on failure the data stays. */
|
|
36
|
+
async loadGroup(configPathOrUrl) {
|
|
33
37
|
try {
|
|
34
38
|
const response = await axios_1.default.get(configPathOrUrl);
|
|
35
39
|
// A keyed boolean object is already in this provider's shape and is
|
|
@@ -37,8 +41,8 @@ class ApiServiceBooleanProvider {
|
|
|
37
41
|
// through the core normaliser, so the two providers cannot disagree
|
|
38
42
|
// about what a response means.
|
|
39
43
|
if (isKeyedBooleans(response.data)) {
|
|
40
|
-
this.data = response.data;
|
|
41
|
-
return;
|
|
44
|
+
this.data = (0, mapping_1.normaliseBooleans)(response.data);
|
|
45
|
+
return true;
|
|
42
46
|
}
|
|
43
47
|
// Only the value matters here. The boolean shape has no time logic by
|
|
44
48
|
// design (R21), so a feature collapses to whether its value is exactly
|
|
@@ -48,35 +52,44 @@ class ApiServiceBooleanProvider {
|
|
|
48
52
|
// schedules toggles: the window is then enforced only by the backend's
|
|
49
53
|
// cron job, which lags by up to a minute, instead of being evaluated
|
|
50
54
|
// locally. Use ApiServiceFeatureProvider when the toggles carry dates.
|
|
51
|
-
const features = (0, mapping_1.
|
|
55
|
+
const features = (0, mapping_1.normaliseGroup)(response.data);
|
|
56
|
+
if (features === undefined) {
|
|
57
|
+
console.error("Ignoring a response that is not a toggle group:", response.data);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
52
60
|
this.data = {};
|
|
53
61
|
for (const [key, feature] of Object.entries(features)) {
|
|
54
62
|
this.data[key] = feature.value === 'true';
|
|
55
63
|
}
|
|
64
|
+
return true;
|
|
56
65
|
}
|
|
57
66
|
catch (error) {
|
|
58
67
|
console.error("Failed to fetch feature toggle from API:", error);
|
|
68
|
+
return false;
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
isEnabled(key) {
|
|
62
|
-
|
|
63
|
-
if (feature === undefined || feature === null)
|
|
64
|
-
return false;
|
|
65
|
-
return feature;
|
|
72
|
+
return this.data[key] === true;
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
exports.ApiServiceBooleanProvider = ApiServiceBooleanProvider;
|
|
69
76
|
/**
|
|
70
|
-
* True when the payload is
|
|
77
|
+
* True when the payload is in the boolean shape, `{ "myToggle": true }`.
|
|
71
78
|
*
|
|
72
79
|
* Distinguishing this from a feature-shaped response matters: running a keyed
|
|
73
80
|
* boolean object through the feature normaliser would look for a `key` field,
|
|
74
81
|
* find none and discard every entry.
|
|
82
|
+
*
|
|
83
|
+
* One boolean value is enough. A mixed payload such as
|
|
84
|
+
* `{ "a": true, "b": "x" }` is still the boolean shape; normaliseBooleans then
|
|
85
|
+
* drops `b` and keeps `a` (R29), exactly as the local provider does. Requiring
|
|
86
|
+
* every value to be boolean sent it to the feature normaliser instead, which
|
|
87
|
+
* lost `a` as well. Feature responses never carry a boolean -- `value` is a
|
|
88
|
+
* string -- so they are not caught by this.
|
|
75
89
|
*/
|
|
76
90
|
function isKeyedBooleans(data) {
|
|
77
91
|
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
78
92
|
return false;
|
|
79
93
|
}
|
|
80
|
-
|
|
81
|
-
return values.length > 0 && values.every((v) => typeof v === 'boolean');
|
|
94
|
+
return Object.values(data).some((v) => typeof v === 'boolean');
|
|
82
95
|
}
|
|
@@ -13,5 +13,7 @@ export declare class ApiServiceFeatureProvider implements FeatureProvider<Featur
|
|
|
13
13
|
constructor(apiUrl: string, baseUUID: string, clock?: Clock);
|
|
14
14
|
getCollectionHash(configPathOrUrl: string): Promise<void>;
|
|
15
15
|
getConfig(configPathOrUrl: string): Promise<void>;
|
|
16
|
+
/** Loads the group and reports whether it did; on failure the data stays. */
|
|
17
|
+
private loadGroup;
|
|
16
18
|
isEnabled(key: string): boolean;
|
|
17
19
|
}
|
|
@@ -27,9 +27,11 @@ class ApiServiceFeatureProvider {
|
|
|
27
27
|
try {
|
|
28
28
|
const response = await axios_1.default.get(configPathOrUrl);
|
|
29
29
|
const newHash = response.data.collectionHash || response.data.value;
|
|
30
|
-
|
|
30
|
+
// The hash is recorded only once the group has loaded. Recording it
|
|
31
|
+
// first meant one failed fetch stopped every later refresh until the
|
|
32
|
+
// backend changed again.
|
|
33
|
+
if (this.collectionHash !== newHash && (await this.loadGroup(`${this.apiUrl}/features/${this.baseUUID}`))) {
|
|
31
34
|
this.collectionHash = newHash;
|
|
32
|
-
await this.getConfig(`${this.apiUrl}/features/${this.baseUUID}`);
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
catch (error) {
|
|
@@ -37,14 +39,25 @@ class ApiServiceFeatureProvider {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
async getConfig(configPathOrUrl) {
|
|
42
|
+
await this.loadGroup(configPathOrUrl);
|
|
43
|
+
}
|
|
44
|
+
/** Loads the group and reports whether it did; on failure the data stays. */
|
|
45
|
+
async loadGroup(configPathOrUrl) {
|
|
40
46
|
try {
|
|
41
47
|
const response = await axios_1.default.get(configPathOrUrl);
|
|
42
48
|
// The mapping rules live in the core, next to the evaluation rules,
|
|
43
49
|
// rather than being reimplemented per provider.
|
|
44
|
-
|
|
50
|
+
const group = (0, mapping_1.normaliseGroup)(response.data);
|
|
51
|
+
if (group === undefined) {
|
|
52
|
+
console.error("Ignoring a response that is not a toggle group:", response.data);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
this.data = group;
|
|
56
|
+
return true;
|
|
45
57
|
}
|
|
46
58
|
catch (error) {
|
|
47
59
|
console.error("Failed to fetch feature toggle from API:", error);
|
|
60
|
+
return false;
|
|
48
61
|
}
|
|
49
62
|
}
|
|
50
63
|
isEnabled(key) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LocalStorageBooleanProvider = void 0;
|
|
4
|
+
const mapping_1 = require("../mapping");
|
|
4
5
|
class LocalStorageBooleanProvider {
|
|
5
6
|
data = {};
|
|
6
7
|
constructor(configPath) {
|
|
@@ -8,8 +9,7 @@ class LocalStorageBooleanProvider {
|
|
|
8
9
|
}
|
|
9
10
|
getConfig(configPathOrUrl) {
|
|
10
11
|
try {
|
|
11
|
-
|
|
12
|
-
this.data = configData;
|
|
12
|
+
this.data = (0, mapping_1.normaliseBooleans)(require(configPathOrUrl));
|
|
13
13
|
}
|
|
14
14
|
catch (error) {
|
|
15
15
|
console.error("Failed to load configuration from local file:", error);
|
|
@@ -17,10 +17,7 @@ class LocalStorageBooleanProvider {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
isEnabled(key) {
|
|
20
|
-
|
|
21
|
-
if (feature === undefined || feature === null)
|
|
22
|
-
return false;
|
|
23
|
-
return feature;
|
|
20
|
+
return this.data[key] === true;
|
|
24
21
|
}
|
|
25
22
|
}
|
|
26
23
|
exports.LocalStorageBooleanProvider = LocalStorageBooleanProvider;
|
package/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { Feature, FeatureToggle, FeatureToggleBase, FeatureProvider, } from "./FeatureToggle";
|
|
2
2
|
export { Clock, evaluate, parseTimestamp, systemClock } from "./evaluate";
|
|
3
|
-
export { normaliseCollection, normaliseFeature } from "./mapping";
|
|
3
|
+
export { normaliseBooleans, normaliseCollection, normaliseFeature, normaliseGroup } from "./mapping";
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.normaliseFeature = exports.normaliseCollection = exports.systemClock = exports.parseTimestamp = exports.evaluate = exports.FeatureToggleBase = exports.FeatureToggle = void 0;
|
|
3
|
+
exports.normaliseGroup = exports.normaliseFeature = exports.normaliseCollection = exports.normaliseBooleans = exports.systemClock = exports.parseTimestamp = exports.evaluate = exports.FeatureToggleBase = exports.FeatureToggle = void 0;
|
|
4
4
|
var FeatureToggle_1 = require("./FeatureToggle");
|
|
5
5
|
Object.defineProperty(exports, "FeatureToggle", { enumerable: true, get: function () { return FeatureToggle_1.FeatureToggle; } });
|
|
6
6
|
Object.defineProperty(exports, "FeatureToggleBase", { enumerable: true, get: function () { return FeatureToggle_1.FeatureToggleBase; } });
|
|
@@ -9,5 +9,7 @@ Object.defineProperty(exports, "evaluate", { enumerable: true, get: function ()
|
|
|
9
9
|
Object.defineProperty(exports, "parseTimestamp", { enumerable: true, get: function () { return evaluate_1.parseTimestamp; } });
|
|
10
10
|
Object.defineProperty(exports, "systemClock", { enumerable: true, get: function () { return evaluate_1.systemClock; } });
|
|
11
11
|
var mapping_1 = require("./mapping");
|
|
12
|
+
Object.defineProperty(exports, "normaliseBooleans", { enumerable: true, get: function () { return mapping_1.normaliseBooleans; } });
|
|
12
13
|
Object.defineProperty(exports, "normaliseCollection", { enumerable: true, get: function () { return mapping_1.normaliseCollection; } });
|
|
13
14
|
Object.defineProperty(exports, "normaliseFeature", { enumerable: true, get: function () { return mapping_1.normaliseFeature; } });
|
|
15
|
+
Object.defineProperty(exports, "normaliseGroup", { enumerable: true, get: function () { return mapping_1.normaliseGroup; } });
|
package/mapping.d.ts
CHANGED
|
@@ -24,4 +24,23 @@ export declare function normaliseFeature(raw: RawFeature): Feature;
|
|
|
24
24
|
* where `isEnabled("")` could reach it.
|
|
25
25
|
*/
|
|
26
26
|
export declare function normaliseCollection(response: unknown): Record<string, Feature>;
|
|
27
|
+
/**
|
|
28
|
+
* Normalises a /features response, but only if it is recognisably a toggle
|
|
29
|
+
* group: a collection envelope -- an empty one is a valid empty group -- or a
|
|
30
|
+
* single toggle. Returns `undefined` for anything else.
|
|
31
|
+
*
|
|
32
|
+
* `normaliseCollection` turns null, an array, a proxy's error object or a
|
|
33
|
+
* collection whose entries are all unusable into `{}`. A provider storing that
|
|
34
|
+
* would switch every feature off without an error; this is what it should
|
|
35
|
+
* check instead. Unusable entries next to good ones are still skipped (R25).
|
|
36
|
+
*/
|
|
37
|
+
export declare function normaliseGroup(response: unknown): Record<string, Feature> | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Normalises a boolean-shape payload, `{ "myToggle": true }`.
|
|
40
|
+
*
|
|
41
|
+
* Only real booleans are kept (R29). Anything else is dropped, so its key
|
|
42
|
+
* reads as missing and therefore off. Keeping it and testing its truthiness
|
|
43
|
+
* would turn `"false"` on, since a non-empty string is truthy.
|
|
44
|
+
*/
|
|
45
|
+
export declare function normaliseBooleans(response: unknown): Record<string, boolean>;
|
|
27
46
|
export {};
|
package/mapping.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.normaliseFeature = normaliseFeature;
|
|
4
4
|
exports.normaliseCollection = normaliseCollection;
|
|
5
|
+
exports.normaliseGroup = normaliseGroup;
|
|
6
|
+
exports.normaliseBooleans = normaliseBooleans;
|
|
5
7
|
/**
|
|
6
8
|
* Reads a field by presence, not by truthiness.
|
|
7
9
|
*
|
|
@@ -77,3 +79,46 @@ function normaliseCollection(response) {
|
|
|
77
79
|
}
|
|
78
80
|
return data;
|
|
79
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Normalises a /features response, but only if it is recognisably a toggle
|
|
84
|
+
* group: a collection envelope -- an empty one is a valid empty group -- or a
|
|
85
|
+
* single toggle. Returns `undefined` for anything else.
|
|
86
|
+
*
|
|
87
|
+
* `normaliseCollection` turns null, an array, a proxy's error object or a
|
|
88
|
+
* collection whose entries are all unusable into `{}`. A provider storing that
|
|
89
|
+
* would switch every feature off without an error; this is what it should
|
|
90
|
+
* check instead. Unusable entries next to good ones are still skipped (R25).
|
|
91
|
+
*/
|
|
92
|
+
function normaliseGroup(response) {
|
|
93
|
+
if (response === null || typeof response !== 'object' || Array.isArray(response)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const data = normaliseCollection(response);
|
|
97
|
+
if (Object.keys(data).length > 0)
|
|
98
|
+
return data;
|
|
99
|
+
const body = response;
|
|
100
|
+
const collection = Array.isArray(body['toggles'])
|
|
101
|
+
? body['toggles']
|
|
102
|
+
: Array.isArray(body['value'])
|
|
103
|
+
? body['value']
|
|
104
|
+
: undefined;
|
|
105
|
+
return collection !== undefined && collection.length === 0 ? data : undefined;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Normalises a boolean-shape payload, `{ "myToggle": true }`.
|
|
109
|
+
*
|
|
110
|
+
* Only real booleans are kept (R29). Anything else is dropped, so its key
|
|
111
|
+
* reads as missing and therefore off. Keeping it and testing its truthiness
|
|
112
|
+
* would turn `"false"` on, since a non-empty string is truthy.
|
|
113
|
+
*/
|
|
114
|
+
function normaliseBooleans(response) {
|
|
115
|
+
if (response === null || typeof response !== 'object' || Array.isArray(response)) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
const data = {};
|
|
119
|
+
for (const [key, value] of Object.entries(response)) {
|
|
120
|
+
if (typeof value === 'boolean')
|
|
121
|
+
data[key] = value;
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|