@terreno/feature-flags 0.19.0 → 0.20.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.
- package/dist/featureFlagModel.js +38 -1
- package/dist/featureFlagsApp.d.ts +1 -0
- package/dist/featureFlagsApp.js +124 -6
- package/dist/flagConfiguration.d.ts +11 -0
- package/dist/flagConfiguration.js +74 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +6 -1
- package/dist/openFeatureProvider.d.ts +22 -0
- package/dist/openFeatureProvider.js +150 -0
- package/dist/tests/featureFlagModel.test.js +71 -0
- package/dist/tests/featureFlagsApp.liveUpdates.test.d.ts +1 -0
- package/dist/tests/featureFlagsApp.liveUpdates.test.js +203 -0
- package/dist/tests/featureFlagsApp.test.js +149 -0
- package/dist/tests/openFeatureProvider.test.d.ts +1 -0
- package/dist/tests/openFeatureProvider.test.js +395 -0
- package/dist/types.d.ts +31 -0
- package/package.json +3 -2
- package/src/featureFlagModel.ts +40 -1
- package/src/featureFlagsApp.ts +124 -4
- package/src/flagConfiguration.ts +57 -0
- package/src/index.ts +6 -0
- package/src/openFeatureProvider.ts +141 -0
- package/src/tests/featureFlagModel.test.ts +46 -0
- package/src/tests/featureFlagsApp.liveUpdates.test.ts +114 -0
- package/src/tests/featureFlagsApp.test.ts +101 -0
- package/src/tests/openFeatureProvider.test.ts +289 -0
- package/src/types.ts +35 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {ErrorCode, OpenFeature, StandardResolutionReasons} from "@openfeature/server-sdk";
|
|
3
|
+
import {setupDb} from "@terreno/api/src/tests";
|
|
4
|
+
|
|
5
|
+
import {FeatureFlag} from "../featureFlagModel";
|
|
6
|
+
import {MongoFeatureFlagProvider} from "../openFeatureProvider";
|
|
7
|
+
import type {SegmentFunction} from "../types";
|
|
8
|
+
|
|
9
|
+
const noopLogger = {
|
|
10
|
+
debug: (): void => {},
|
|
11
|
+
error: (): void => {},
|
|
12
|
+
info: (): void => {},
|
|
13
|
+
warn: (): void => {},
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
describe("MongoFeatureFlagProvider", () => {
|
|
17
|
+
let provider: MongoFeatureFlagProvider;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await setupDb();
|
|
21
|
+
await FeatureFlag.deleteMany({});
|
|
22
|
+
provider = new MongoFeatureFlagProvider({flagModel: FeatureFlag, segments: {}});
|
|
23
|
+
await OpenFeature.clearProviders();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await FeatureFlag.deleteMany({});
|
|
28
|
+
await OpenFeature.clearProviders();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns FLAG_NOT_FOUND for a missing flag", async () => {
|
|
32
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
33
|
+
"nope",
|
|
34
|
+
false,
|
|
35
|
+
{targetingKey: "u1"},
|
|
36
|
+
noopLogger
|
|
37
|
+
);
|
|
38
|
+
expect(res.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND);
|
|
39
|
+
expect(res.reason).toBe(StandardResolutionReasons.ERROR);
|
|
40
|
+
expect(res.value).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns TYPE_MISMATCH when resolveStringEvaluation targets a boolean flag", async () => {
|
|
44
|
+
await FeatureFlag.create({
|
|
45
|
+
enabled: true,
|
|
46
|
+
key: "bool-only",
|
|
47
|
+
name: "Bool",
|
|
48
|
+
rolloutPercentage: 100,
|
|
49
|
+
type: "boolean",
|
|
50
|
+
});
|
|
51
|
+
const res = await provider.resolveStringEvaluation(
|
|
52
|
+
"bool-only",
|
|
53
|
+
"x",
|
|
54
|
+
{targetingKey: "u1"},
|
|
55
|
+
noopLogger
|
|
56
|
+
);
|
|
57
|
+
expect(res.errorCode).toBe(ErrorCode.TYPE_MISMATCH);
|
|
58
|
+
expect(res.value).toBe("x");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns TYPE_MISMATCH when resolveBooleanEvaluation targets a variant flag", async () => {
|
|
62
|
+
await FeatureFlag.create({
|
|
63
|
+
enabled: true,
|
|
64
|
+
key: "var-only",
|
|
65
|
+
name: "Var",
|
|
66
|
+
rules: [],
|
|
67
|
+
type: "variant",
|
|
68
|
+
variants: [
|
|
69
|
+
{key: "a", weight: 50},
|
|
70
|
+
{key: "b", weight: 50},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
74
|
+
"var-only",
|
|
75
|
+
false,
|
|
76
|
+
{targetingKey: "u1"},
|
|
77
|
+
noopLogger
|
|
78
|
+
);
|
|
79
|
+
expect(res.errorCode).toBe(ErrorCode.TYPE_MISMATCH);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns DISABLED with defaultValue for a disabled boolean flag", async () => {
|
|
83
|
+
await FeatureFlag.create({
|
|
84
|
+
defaultVariant: "on",
|
|
85
|
+
enabled: false,
|
|
86
|
+
key: "disabled-bool",
|
|
87
|
+
name: "Off",
|
|
88
|
+
rolloutPercentage: 100,
|
|
89
|
+
type: "boolean",
|
|
90
|
+
});
|
|
91
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
92
|
+
"disabled-bool",
|
|
93
|
+
false,
|
|
94
|
+
{targetingKey: "u1"},
|
|
95
|
+
noopLogger
|
|
96
|
+
);
|
|
97
|
+
expect(res.reason).toBe(StandardResolutionReasons.DISABLED);
|
|
98
|
+
expect(res.value).toBe(false);
|
|
99
|
+
expect(res.variant).toBe("on");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resolves boolean rollout via deterministic hash when no rules match", async () => {
|
|
103
|
+
await FeatureFlag.create({
|
|
104
|
+
enabled: true,
|
|
105
|
+
key: "rollout",
|
|
106
|
+
name: "Rollout",
|
|
107
|
+
rolloutPercentage: 100,
|
|
108
|
+
rules: [],
|
|
109
|
+
type: "boolean",
|
|
110
|
+
});
|
|
111
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
112
|
+
"rollout",
|
|
113
|
+
false,
|
|
114
|
+
{targetingKey: "user-abc"},
|
|
115
|
+
noopLogger
|
|
116
|
+
);
|
|
117
|
+
expect(res.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
|
|
118
|
+
expect(res.value).toBe(true);
|
|
119
|
+
expect(res.variant).toBe("on");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("matches a field rule on a boolean flag", async () => {
|
|
123
|
+
await FeatureFlag.create({
|
|
124
|
+
enabled: true,
|
|
125
|
+
key: "rule-bool",
|
|
126
|
+
name: "Rule",
|
|
127
|
+
rolloutPercentage: 0,
|
|
128
|
+
rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
|
|
129
|
+
type: "boolean",
|
|
130
|
+
});
|
|
131
|
+
const adminRes = await provider.resolveBooleanEvaluation(
|
|
132
|
+
"rule-bool",
|
|
133
|
+
false,
|
|
134
|
+
{targetingKey: "id1", user: {admin: true}},
|
|
135
|
+
noopLogger
|
|
136
|
+
);
|
|
137
|
+
expect(adminRes.value).toBe(true);
|
|
138
|
+
|
|
139
|
+
const userRes = await provider.resolveBooleanEvaluation(
|
|
140
|
+
"rule-bool",
|
|
141
|
+
false,
|
|
142
|
+
{targetingKey: "id2", user: {admin: false}},
|
|
143
|
+
noopLogger
|
|
144
|
+
);
|
|
145
|
+
expect(userRes.value).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("matches a segment rule when segments are registered", async () => {
|
|
149
|
+
const segments: Record<string, SegmentFunction> = {
|
|
150
|
+
"vip-users": (u: unknown) => (u as {vip?: boolean}).vip === true,
|
|
151
|
+
};
|
|
152
|
+
const p = new MongoFeatureFlagProvider({flagModel: FeatureFlag, segments});
|
|
153
|
+
await FeatureFlag.create({
|
|
154
|
+
enabled: true,
|
|
155
|
+
key: "seg-flag",
|
|
156
|
+
name: "Seg",
|
|
157
|
+
rolloutPercentage: 0,
|
|
158
|
+
rules: [{enabled: true, segment: "vip-users"}],
|
|
159
|
+
type: "boolean",
|
|
160
|
+
});
|
|
161
|
+
const yes = await p.resolveBooleanEvaluation(
|
|
162
|
+
"seg-flag",
|
|
163
|
+
false,
|
|
164
|
+
{targetingKey: "x", user: {vip: true}},
|
|
165
|
+
noopLogger
|
|
166
|
+
);
|
|
167
|
+
expect(yes.value).toBe(true);
|
|
168
|
+
const no = await p.resolveBooleanEvaluation(
|
|
169
|
+
"seg-flag",
|
|
170
|
+
false,
|
|
171
|
+
{targetingKey: "y", user: {vip: false}},
|
|
172
|
+
noopLogger
|
|
173
|
+
);
|
|
174
|
+
expect(no.value).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("treats missing targetingKey as empty string for hashing", async () => {
|
|
178
|
+
await FeatureFlag.create({
|
|
179
|
+
enabled: true,
|
|
180
|
+
key: "hash-key",
|
|
181
|
+
name: "Hash",
|
|
182
|
+
rolloutPercentage: 100,
|
|
183
|
+
rules: [],
|
|
184
|
+
type: "boolean",
|
|
185
|
+
});
|
|
186
|
+
const res = await provider.resolveBooleanEvaluation("hash-key", false, {}, noopLogger);
|
|
187
|
+
expect(res.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
|
|
188
|
+
expect(typeof res.value).toBe("boolean");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("uses flat context as user when user field is absent", async () => {
|
|
192
|
+
await FeatureFlag.create({
|
|
193
|
+
enabled: true,
|
|
194
|
+
key: "flat-ctx",
|
|
195
|
+
name: "Flat",
|
|
196
|
+
rolloutPercentage: 0,
|
|
197
|
+
rules: [{enabled: true, field: "admin", operator: "eq", value: true}],
|
|
198
|
+
type: "boolean",
|
|
199
|
+
});
|
|
200
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
201
|
+
"flat-ctx",
|
|
202
|
+
false,
|
|
203
|
+
{admin: true, targetingKey: "t1"} as never,
|
|
204
|
+
noopLogger
|
|
205
|
+
);
|
|
206
|
+
expect(res.value).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("resolves variant assignment and returns TARGETING_MATCH", async () => {
|
|
210
|
+
await FeatureFlag.create({
|
|
211
|
+
enabled: true,
|
|
212
|
+
key: "layout",
|
|
213
|
+
name: "Layout",
|
|
214
|
+
rules: [],
|
|
215
|
+
type: "variant",
|
|
216
|
+
variants: [
|
|
217
|
+
{key: "compact", weight: 50},
|
|
218
|
+
{key: "detailed", weight: 50},
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
const res = await provider.resolveStringEvaluation(
|
|
222
|
+
"layout",
|
|
223
|
+
"fallback",
|
|
224
|
+
{targetingKey: "user-z"},
|
|
225
|
+
noopLogger
|
|
226
|
+
);
|
|
227
|
+
expect(res.reason).toBe(StandardResolutionReasons.TARGETING_MATCH);
|
|
228
|
+
expect(["compact", "detailed"].includes(res.value)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("returns DISABLED for variant flag when evaluateFlag yields null", async () => {
|
|
232
|
+
await FeatureFlag.create({
|
|
233
|
+
defaultVariant: "compact",
|
|
234
|
+
enabled: false,
|
|
235
|
+
key: "var-off",
|
|
236
|
+
name: "Var off",
|
|
237
|
+
rules: [],
|
|
238
|
+
type: "variant",
|
|
239
|
+
variants: [
|
|
240
|
+
{key: "compact", weight: 50},
|
|
241
|
+
{key: "detailed", weight: 50},
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
const res = await provider.resolveStringEvaluation(
|
|
245
|
+
"var-off",
|
|
246
|
+
"fallback",
|
|
247
|
+
{targetingKey: "u1"},
|
|
248
|
+
noopLogger
|
|
249
|
+
);
|
|
250
|
+
expect(res.reason).toBe(StandardResolutionReasons.DISABLED);
|
|
251
|
+
expect(res.value).toBe("fallback");
|
|
252
|
+
expect(res.variant).toBe("compact");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("resolveNumberEvaluation returns FLAG_NOT_FOUND with default", async () => {
|
|
256
|
+
const res = await provider.resolveNumberEvaluation("any", 42, {targetingKey: "u"}, noopLogger);
|
|
257
|
+
expect(res.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND);
|
|
258
|
+
expect(res.value).toBe(42);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("resolveObjectEvaluation returns FLAG_NOT_FOUND with default", async () => {
|
|
262
|
+
const res = await provider.resolveObjectEvaluation(
|
|
263
|
+
"any",
|
|
264
|
+
{a: 1},
|
|
265
|
+
{targetingKey: "u"},
|
|
266
|
+
noopLogger
|
|
267
|
+
);
|
|
268
|
+
expect(res.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND);
|
|
269
|
+
expect(res.value).toEqual({a: 1});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("does not resolve archived flags", async () => {
|
|
273
|
+
await FeatureFlag.create({
|
|
274
|
+
archived: true,
|
|
275
|
+
enabled: true,
|
|
276
|
+
key: "gone",
|
|
277
|
+
name: "Gone",
|
|
278
|
+
rolloutPercentage: 100,
|
|
279
|
+
type: "boolean",
|
|
280
|
+
});
|
|
281
|
+
const res = await provider.resolveBooleanEvaluation(
|
|
282
|
+
"gone",
|
|
283
|
+
false,
|
|
284
|
+
{targetingKey: "u1"},
|
|
285
|
+
noopLogger
|
|
286
|
+
);
|
|
287
|
+
expect(res.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND);
|
|
288
|
+
});
|
|
289
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type FeatureFlagDocument = mongoose.Document<mongoose.Types.ObjectId> & {
|
|
|
21
21
|
key: string;
|
|
22
22
|
name: string;
|
|
23
23
|
description: string;
|
|
24
|
+
/** OpenFeature default variant key; see schema description on {@link FeatureFlag}. */
|
|
25
|
+
defaultVariant?: string;
|
|
24
26
|
enabled: boolean;
|
|
25
27
|
type: FeatureFlagType;
|
|
26
28
|
variants: FeatureFlagVariant[];
|
|
@@ -38,6 +40,21 @@ export type FeatureFlagModel = mongoose.Model<FeatureFlagDocument> &
|
|
|
38
40
|
|
|
39
41
|
export type SegmentFunction = (user: unknown) => boolean;
|
|
40
42
|
|
|
43
|
+
/** Minimal Socket.io server shape for broadcasting flag change notifications. */
|
|
44
|
+
export interface FeatureFlagsSocketEmitter {
|
|
45
|
+
emit(event: string, ...args: unknown[]): unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FeatureFlagsLiveUpdatesOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Socket.io server (or a getter returning it once initialized). When null,
|
|
51
|
+
* live updates are skipped.
|
|
52
|
+
*/
|
|
53
|
+
socketIoServer: FeatureFlagsSocketEmitter | (() => FeatureFlagsSocketEmitter | null);
|
|
54
|
+
/** Custom event name. Default: `featureFlagsChanged`. */
|
|
55
|
+
eventName?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
export interface FeatureFlagsOptions {
|
|
42
59
|
basePath?: string;
|
|
43
60
|
segments?: Record<string, SegmentFunction>;
|
|
@@ -54,6 +71,24 @@ export interface FeatureFlagsOptions {
|
|
|
54
71
|
* to checking `user.admin === true` when not provided.
|
|
55
72
|
*/
|
|
56
73
|
segmentsPermission?: (user: unknown) => boolean;
|
|
74
|
+
/** Optional Socket.io integration for live client refresh when flags change. */
|
|
75
|
+
liveUpdates?: FeatureFlagsLiveUpdatesOptions;
|
|
76
|
+
/**
|
|
77
|
+
* OpenFeature provider domain for {@link OpenFeature.setProvider}. Defaults
|
|
78
|
+
* to `"feature-flags"` so the global default provider is left untouched.
|
|
79
|
+
*/
|
|
80
|
+
openFeatureDomain?: string;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
export type EvaluationResult = Record<string, boolean | string | null>;
|
|
84
|
+
|
|
85
|
+
/** One flag entry in the `/flagConfiguration` response (OpenFeature static shape). */
|
|
86
|
+
export interface FlagDefinition {
|
|
87
|
+
variants: Record<string, boolean | string>;
|
|
88
|
+
disabled: boolean;
|
|
89
|
+
defaultVariant: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FlagConfigurationResponse {
|
|
93
|
+
data: Record<string, FlagDefinition>;
|
|
94
|
+
}
|