@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 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=v1.1.0
170
- sha256=d83ff1c960ad29830c00b57727591da628f4323b777faea245f603565d1c9ae9
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
- if (this.collectionHash !== newHash) {
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.normaliseCollection)(response.data);
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
- const feature = this.data[key];
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 already `{ "myToggle": true }`.
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
- const values = Object.values(data);
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
- if (this.collectionHash !== newHash) {
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
- this.data = (0, mapping_1.normaliseCollection)(response.data);
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
- const configData = require(configPathOrUrl);
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
- const feature = this.data[key];
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tehw0lf/yaft",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "YaFT - Feature Toggles using Go&PostgreSQL or any source!",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",