@featurevisor/sdk 1.35.3 → 2.0.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -381
  3. package/coverage/clover.xml +707 -645
  4. package/coverage/coverage-final.json +11 -9
  5. package/coverage/lcov-report/{segments.ts.html → bucketer.ts.html} +155 -77
  6. package/coverage/lcov-report/child.ts.html +940 -0
  7. package/coverage/lcov-report/conditions.ts.html +107 -158
  8. package/coverage/lcov-report/datafileReader.ts.html +763 -103
  9. package/coverage/lcov-report/emitter.ts.html +77 -59
  10. package/coverage/lcov-report/evaluate.ts.html +689 -416
  11. package/coverage/lcov-report/events.ts.html +334 -0
  12. package/coverage/lcov-report/helpers.ts.html +184 -0
  13. package/coverage/lcov-report/{bucket.ts.html → hooks.ts.html} +86 -239
  14. package/coverage/lcov-report/index.html +119 -89
  15. package/coverage/lcov-report/instance.ts.html +341 -773
  16. package/coverage/lcov-report/logger.ts.html +64 -64
  17. package/coverage/lcov.info +1433 -1226
  18. package/dist/bucketer.d.ts +11 -0
  19. package/dist/child.d.ts +26 -0
  20. package/dist/compareVersions.d.ts +4 -0
  21. package/dist/conditions.d.ts +4 -4
  22. package/dist/datafileReader.d.ts +26 -6
  23. package/dist/emitter.d.ts +8 -9
  24. package/dist/evaluate.d.ts +31 -29
  25. package/dist/events.d.ts +5 -0
  26. package/dist/helpers.d.ts +5 -0
  27. package/dist/hooks.d.ts +45 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/index.mjs +1 -1
  32. package/dist/index.mjs.gz +0 -0
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/instance.d.ts +40 -72
  35. package/dist/logger.d.ts +6 -5
  36. package/dist/murmurhash.d.ts +1 -0
  37. package/jest.config.js +2 -0
  38. package/lib/bucketer.d.ts +11 -0
  39. package/lib/child.d.ts +26 -0
  40. package/lib/compareVersions.d.ts +4 -0
  41. package/lib/conditions.d.ts +4 -4
  42. package/lib/datafileReader.d.ts +26 -6
  43. package/lib/emitter.d.ts +8 -9
  44. package/lib/evaluate.d.ts +31 -29
  45. package/lib/events.d.ts +5 -0
  46. package/lib/helpers.d.ts +5 -0
  47. package/lib/hooks.d.ts +45 -0
  48. package/lib/index.d.ts +3 -2
  49. package/lib/instance.d.ts +40 -72
  50. package/lib/logger.d.ts +6 -5
  51. package/lib/murmurhash.d.ts +1 -0
  52. package/package.json +3 -5
  53. package/src/bucketer.spec.ts +165 -0
  54. package/src/bucketer.ts +84 -0
  55. package/src/child.spec.ts +267 -0
  56. package/src/child.ts +285 -0
  57. package/src/compareVersions.ts +93 -0
  58. package/src/conditions.spec.ts +563 -353
  59. package/src/conditions.ts +46 -63
  60. package/src/datafileReader.spec.ts +396 -84
  61. package/src/datafileReader.ts +280 -60
  62. package/src/emitter.spec.ts +27 -86
  63. package/src/emitter.ts +38 -32
  64. package/src/evaluate.ts +349 -258
  65. package/src/events.spec.ts +154 -0
  66. package/src/events.ts +83 -0
  67. package/src/helpers.ts +33 -0
  68. package/src/hooks.ts +88 -0
  69. package/src/index.ts +3 -2
  70. package/src/instance.spec.ts +305 -489
  71. package/src/instance.ts +247 -391
  72. package/src/logger.spec.ts +212 -134
  73. package/src/logger.ts +36 -36
  74. package/src/murmurhash.ts +71 -0
  75. package/coverage/lcov-report/feature.ts.html +0 -508
  76. package/dist/bucket.d.ts +0 -30
  77. package/dist/feature.d.ts +0 -16
  78. package/dist/segments.d.ts +0 -5
  79. package/lib/bucket.d.ts +0 -30
  80. package/lib/feature.d.ts +0 -16
  81. package/lib/segments.d.ts +0 -5
  82. package/src/bucket.spec.ts +0 -37
  83. package/src/bucket.ts +0 -139
  84. package/src/feature.ts +0 -141
  85. package/src/segments.spec.ts +0 -468
  86. package/src/segments.ts +0 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featurevisor/sdk",
3
- "version": "1.35.3",
3
+ "version": "2.0.1",
4
4
  "description": "Featurevisor SDK for Node.js and the browser",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.mjs",
@@ -49,9 +49,7 @@
49
49
  },
50
50
  "license": "MIT",
51
51
  "dependencies": {
52
- "@featurevisor/types": "1.35.3",
53
- "compare-versions": "^6.0.0-rc.1",
54
- "murmurhash": "^2.0.1"
52
+ "@featurevisor/types": "2.0.1"
55
53
  },
56
- "gitHead": "253ff5dade7c9ee953f6a57cda097a6c9ed93aa2"
54
+ "gitHead": "f5d883e1d6f8ba1b0c14fbbd79329b98f66b46e8"
57
55
  }
@@ -0,0 +1,165 @@
1
+ import { getBucketedNumber, MAX_BUCKETED_NUMBER, getBucketKey } from "./bucketer";
2
+ import { createLogger } from "./logger";
3
+
4
+ describe("sdk: Bucket", function () {
5
+ describe("getBucketedNumber", function () {
6
+ it("should be a function", function () {
7
+ expect(typeof getBucketedNumber).toEqual("function");
8
+ });
9
+
10
+ it("should return a number between 0 and 100000", function () {
11
+ const keys = ["foo", "bar", "baz", "123adshlk348-93asdlk"];
12
+
13
+ keys.forEach((key) => {
14
+ const n = getBucketedNumber(key);
15
+
16
+ expect(n >= 0).toEqual(true);
17
+ expect(n <= MAX_BUCKETED_NUMBER).toEqual(true);
18
+ });
19
+ });
20
+
21
+ // these assertions will be copied to unit tests of SDKs ported to other languages,
22
+ // so we can keep consistent bucketing across all SDKs
23
+ it("should return expected number for known keys", function () {
24
+ const expectedResults = {
25
+ foo: 20602,
26
+ bar: 89144,
27
+ "123.foo": 3151,
28
+ "123.bar": 9710,
29
+ "123.456.foo": 14432,
30
+ "123.456.bar": 1982,
31
+ };
32
+
33
+ Object.keys(expectedResults).forEach((key) => {
34
+ const n = getBucketedNumber(key);
35
+
36
+ expect(n).toEqual(expectedResults[key]);
37
+ });
38
+ });
39
+ });
40
+
41
+ describe("getBucketKey", function () {
42
+ const logger = createLogger({
43
+ level: "warn",
44
+ });
45
+
46
+ it("should be a function", function () {
47
+ expect(typeof getBucketKey).toEqual("function");
48
+ });
49
+
50
+ it("plain: should return a bucket key for a plain bucketBy", function () {
51
+ const featureKey = "test-feature";
52
+ const bucketBy = "userId";
53
+ const context = { userId: "123", browser: "chrome" };
54
+
55
+ const bucketKey = getBucketKey({
56
+ featureKey,
57
+ bucketBy,
58
+ context,
59
+ logger,
60
+ });
61
+
62
+ expect(bucketKey).toEqual("123.test-feature");
63
+ });
64
+
65
+ it("plain: should return a bucket key with feature key only if value is missing in context", function () {
66
+ const featureKey = "test-feature";
67
+ const bucketBy = "userId";
68
+ const context = { browser: "chrome" };
69
+
70
+ const bucketKey = getBucketKey({
71
+ featureKey,
72
+ bucketBy,
73
+ context,
74
+ logger,
75
+ });
76
+
77
+ expect(bucketKey).toEqual("test-feature");
78
+ });
79
+
80
+ it("and: should combine multiple field values together if present", function () {
81
+ const featureKey = "test-feature";
82
+ const bucketBy = ["organizationId", "userId"];
83
+ const context = { organizationId: "123", userId: "234", browser: "chrome" };
84
+
85
+ const bucketKey = getBucketKey({
86
+ featureKey,
87
+ bucketBy,
88
+ context,
89
+ logger,
90
+ });
91
+
92
+ expect(bucketKey).toEqual("123.234.test-feature");
93
+ });
94
+
95
+ it("and: should combine only available field values together if present", function () {
96
+ const featureKey = "test-feature";
97
+ const bucketBy = ["organizationId", "userId"];
98
+ const context = { organizationId: "123", browser: "chrome" };
99
+
100
+ const bucketKey = getBucketKey({
101
+ featureKey,
102
+ bucketBy,
103
+ context,
104
+ logger,
105
+ });
106
+
107
+ expect(bucketKey).toEqual("123.test-feature");
108
+ });
109
+
110
+ it("and: should combine all available fields, with dot separated paths", function () {
111
+ const featureKey = "test-feature";
112
+ const bucketBy = ["organizationId", "user.id"];
113
+ const context = {
114
+ organizationId: "123",
115
+ user: {
116
+ id: "234",
117
+ },
118
+ browser: "chrome",
119
+ };
120
+
121
+ const bucketKey = getBucketKey({
122
+ featureKey,
123
+ bucketBy,
124
+ context,
125
+ logger,
126
+ });
127
+
128
+ expect(bucketKey).toEqual("123.234.test-feature");
129
+ });
130
+
131
+ it("or: should take first available field value", function () {
132
+ const featureKey = "test-feature";
133
+ const bucketBy = {
134
+ or: ["userId", "deviceId"],
135
+ };
136
+ const context = { deviceId: "deviceIdHere", userId: "234", browser: "chrome" };
137
+
138
+ const bucketKey = getBucketKey({
139
+ featureKey,
140
+ bucketBy,
141
+ context,
142
+ logger,
143
+ });
144
+
145
+ expect(bucketKey).toEqual("234.test-feature");
146
+ });
147
+
148
+ it("or: should take first available field value", function () {
149
+ const featureKey = "test-feature";
150
+ const bucketBy = {
151
+ or: ["userId", "deviceId"],
152
+ };
153
+ const context = { deviceId: "deviceIdHere", browser: "chrome" };
154
+
155
+ const bucketKey = getBucketKey({
156
+ featureKey,
157
+ bucketBy,
158
+ context,
159
+ logger,
160
+ });
161
+
162
+ expect(bucketKey).toEqual("deviceIdHere.test-feature");
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,84 @@
1
+ import type { BucketKey, Context, AttributeValue, FeatureKey, BucketBy } from "@featurevisor/types";
2
+
3
+ import { Logger } from "./logger";
4
+ import { getValueFromContext } from "./conditions";
5
+ import { MurmurHashV3 } from "./murmurhash";
6
+
7
+ /**
8
+ * Generic hashing
9
+ */
10
+ const HASH_SEED = 1;
11
+ const MAX_HASH_VALUE = Math.pow(2, 32);
12
+
13
+ export const MAX_BUCKETED_NUMBER = 100000; // 100% * 1000 to include three decimal places in the same integer value
14
+
15
+ export function getBucketedNumber(bucketKey: string): number {
16
+ const hashValue = MurmurHashV3(bucketKey, HASH_SEED);
17
+ const ratio = hashValue / MAX_HASH_VALUE;
18
+
19
+ return Math.floor(ratio * MAX_BUCKETED_NUMBER);
20
+ }
21
+
22
+ /**
23
+ * Bucket key
24
+ */
25
+ const DEFAULT_BUCKET_KEY_SEPARATOR = ".";
26
+
27
+ export interface GetBucketKeyOptions {
28
+ featureKey: FeatureKey;
29
+ bucketBy: BucketBy;
30
+ context: Context;
31
+
32
+ logger: Logger;
33
+ }
34
+
35
+ export function getBucketKey(options: GetBucketKeyOptions): BucketKey {
36
+ const {
37
+ featureKey,
38
+ bucketBy,
39
+ context,
40
+
41
+ logger,
42
+ } = options;
43
+
44
+ let type;
45
+ let attributeKeys;
46
+
47
+ if (typeof bucketBy === "string") {
48
+ type = "plain";
49
+ attributeKeys = [bucketBy];
50
+ } else if (Array.isArray(bucketBy)) {
51
+ type = "and";
52
+ attributeKeys = bucketBy;
53
+ } else if (typeof bucketBy === "object" && Array.isArray(bucketBy.or)) {
54
+ type = "or";
55
+ attributeKeys = bucketBy.or;
56
+ } else {
57
+ logger.error("invalid bucketBy", { featureKey, bucketBy });
58
+
59
+ throw new Error("invalid bucketBy");
60
+ }
61
+
62
+ const bucketKey: AttributeValue[] = [];
63
+
64
+ attributeKeys.forEach((attributeKey) => {
65
+ const attributeValue = getValueFromContext(context, attributeKey);
66
+
67
+ if (typeof attributeValue === "undefined") {
68
+ return;
69
+ }
70
+
71
+ if (type === "plain" || type === "and") {
72
+ bucketKey.push(attributeValue);
73
+ } else {
74
+ // or
75
+ if (bucketKey.length === 0) {
76
+ bucketKey.push(attributeValue);
77
+ }
78
+ }
79
+ });
80
+
81
+ bucketKey.push(featureKey);
82
+
83
+ return bucketKey.join(DEFAULT_BUCKET_KEY_SEPARATOR);
84
+ }
@@ -0,0 +1,267 @@
1
+ import { createInstance } from "./instance";
2
+
3
+ describe("sdk: child", function () {
4
+ it("should create a child instance", function () {
5
+ const f = createInstance({
6
+ datafile: {
7
+ schemaVersion: "2",
8
+ revision: "1.0",
9
+ features: {
10
+ test: {
11
+ key: "test",
12
+ bucketBy: "userId",
13
+ variablesSchema: {
14
+ color: {
15
+ key: "color",
16
+ type: "string",
17
+ defaultValue: "red",
18
+ },
19
+ showSidebar: {
20
+ key: "showSidebar",
21
+ type: "boolean",
22
+ defaultValue: false,
23
+ },
24
+ sidebarTitle: {
25
+ key: "sidebarTitle",
26
+ type: "string",
27
+ defaultValue: "sidebar title",
28
+ },
29
+ count: {
30
+ key: "count",
31
+ type: "integer",
32
+ defaultValue: 0,
33
+ },
34
+ price: {
35
+ key: "price",
36
+ type: "double",
37
+ defaultValue: 9.99,
38
+ },
39
+ paymentMethods: {
40
+ key: "paymentMethods",
41
+ type: "array",
42
+ defaultValue: ["paypal", "creditcard"],
43
+ },
44
+ flatConfig: {
45
+ key: "flatConfig",
46
+ type: "object",
47
+ defaultValue: {
48
+ key: "value",
49
+ },
50
+ },
51
+ nestedConfig: {
52
+ key: "nestedConfig",
53
+ type: "json",
54
+ defaultValue: JSON.stringify({
55
+ key: {
56
+ nested: "value",
57
+ },
58
+ }),
59
+ },
60
+ },
61
+ variations: [
62
+ { value: "control" },
63
+ {
64
+ value: "treatment",
65
+ variables: {
66
+ showSidebar: true,
67
+ sidebarTitle: "sidebar title from variation",
68
+ },
69
+ variableOverrides: {
70
+ showSidebar: [
71
+ {
72
+ segments: ["netherlands"],
73
+ value: false,
74
+ },
75
+ {
76
+ conditions: [
77
+ {
78
+ attribute: "country",
79
+ operator: "equals",
80
+ value: "de",
81
+ },
82
+ ],
83
+ value: false,
84
+ },
85
+ ],
86
+ sidebarTitle: [
87
+ {
88
+ segments: ["netherlands"],
89
+ value: "Dutch title",
90
+ },
91
+ {
92
+ conditions: [
93
+ {
94
+ attribute: "country",
95
+ operator: "equals",
96
+ value: "de",
97
+ },
98
+ ],
99
+ value: "German title",
100
+ },
101
+ ],
102
+ },
103
+ },
104
+ ],
105
+ force: [
106
+ {
107
+ conditions: [{ attribute: "userId", operator: "equals", value: "user-ch" }],
108
+ enabled: true,
109
+ variation: "control",
110
+ variables: {
111
+ color: "red and white",
112
+ },
113
+ },
114
+ {
115
+ conditions: [{ attribute: "userId", operator: "equals", value: "user-gb" }],
116
+ enabled: false,
117
+ },
118
+ {
119
+ conditions: [
120
+ { attribute: "userId", operator: "equals", value: "user-forced-variation" },
121
+ ],
122
+ enabled: true,
123
+ variation: "treatment",
124
+ },
125
+ ],
126
+ traffic: [
127
+ // belgium
128
+ {
129
+ key: "2",
130
+ segments: ["belgium"],
131
+ percentage: 100000,
132
+ allocation: [
133
+ { variation: "control", range: [0, 0] },
134
+ {
135
+ variation: "treatment",
136
+ range: [0, 100000],
137
+ },
138
+ ],
139
+ variation: "control",
140
+ variables: {
141
+ color: "black",
142
+ },
143
+ },
144
+
145
+ // everyone
146
+ {
147
+ key: "1",
148
+ segments: "*",
149
+ percentage: 100000,
150
+ allocation: [
151
+ { variation: "control", range: [0, 0] },
152
+ {
153
+ variation: "treatment",
154
+ range: [0, 100000],
155
+ },
156
+ ],
157
+ },
158
+ ],
159
+ },
160
+ anotherTest: {
161
+ key: "test",
162
+ bucketBy: "userId",
163
+ traffic: [
164
+ // everyone
165
+ {
166
+ key: "1",
167
+ segments: "*",
168
+ percentage: 100000,
169
+ },
170
+ ],
171
+ },
172
+ },
173
+ segments: {
174
+ netherlands: {
175
+ key: "netherlands",
176
+ conditions: JSON.stringify([
177
+ {
178
+ attribute: "country",
179
+ operator: "equals",
180
+ value: "nl",
181
+ },
182
+ ]),
183
+ },
184
+ belgium: {
185
+ key: "belgium",
186
+ conditions: JSON.stringify([
187
+ {
188
+ attribute: "country",
189
+ operator: "equals",
190
+ value: "be",
191
+ },
192
+ ]),
193
+ },
194
+ },
195
+ },
196
+ context: {
197
+ appVersion: "1.0.0",
198
+ },
199
+ });
200
+
201
+ expect(f).toBeDefined();
202
+ expect(f.getContext()).toEqual({ appVersion: "1.0.0" });
203
+
204
+ const childF = f.spawn({
205
+ userId: "123",
206
+ country: "nl",
207
+ });
208
+
209
+ expect(childF).toBeDefined();
210
+ expect(childF.getContext()).toEqual({ appVersion: "1.0.0", userId: "123", country: "nl" });
211
+
212
+ let contextUpdated = false;
213
+ const unsubscribeContext = childF.on("context_set", () => {
214
+ contextUpdated = true;
215
+ });
216
+
217
+ childF.setContext({ country: "be" });
218
+ expect(childF.getContext()).toEqual({ appVersion: "1.0.0", userId: "123", country: "be" });
219
+
220
+ expect(childF.isEnabled("test")).toBe(true);
221
+ expect(childF.getVariation("test")).toEqual("control");
222
+
223
+ expect(childF.getVariable("test", "color")).toEqual("black");
224
+ expect(childF.getVariableString("test", "color")).toEqual("black");
225
+
226
+ expect(childF.getVariable("test", "showSidebar")).toEqual(false);
227
+ expect(childF.getVariableBoolean("test", "showSidebar")).toEqual(false);
228
+
229
+ expect(childF.getVariable("test", "sidebarTitle")).toEqual("sidebar title");
230
+ expect(childF.getVariableString("test", "sidebarTitle")).toEqual("sidebar title");
231
+
232
+ expect(childF.getVariable("test", "count")).toEqual(0);
233
+ expect(childF.getVariableInteger("test", "count")).toEqual(0);
234
+
235
+ expect(childF.getVariable("test", "price")).toEqual(9.99);
236
+ expect(childF.getVariableDouble("test", "price")).toEqual(9.99);
237
+
238
+ expect(childF.getVariable("test", "paymentMethods")).toEqual(["paypal", "creditcard"]);
239
+ expect(childF.getVariableArray("test", "paymentMethods")).toEqual(["paypal", "creditcard"]);
240
+
241
+ expect(childF.getVariable("test", "flatConfig")).toEqual({ key: "value" });
242
+ expect(childF.getVariableObject("test", "flatConfig")).toEqual({ key: "value" });
243
+
244
+ expect(childF.getVariable("test", "nestedConfig")).toEqual({
245
+ key: { nested: "value" },
246
+ });
247
+ expect(childF.getVariableJSON("test", "nestedConfig")).toEqual({
248
+ key: { nested: "value" },
249
+ });
250
+
251
+ expect(contextUpdated).toBe(true);
252
+ unsubscribeContext();
253
+
254
+ expect(childF.isEnabled("newFeature")).toEqual(false);
255
+ childF.setSticky({
256
+ newFeature: {
257
+ enabled: true,
258
+ },
259
+ });
260
+ expect(childF.isEnabled("newFeature")).toEqual(true);
261
+
262
+ const allEvaluations = childF.getAllEvaluations();
263
+ expect(Object.keys(allEvaluations)).toEqual(["test", "anotherTest"]);
264
+
265
+ childF.close();
266
+ });
267
+ });