@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.
@@ -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
+ }