@terreno/feature-flags 0.7.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/biome.jsonc +20 -0
- package/bunfig.toml +8 -0
- package/dist/evaluate.d.ts +17 -0
- package/dist/evaluate.js +233 -0
- package/dist/featureFlagModel.d.ts +2 -0
- package/dist/featureFlagModel.js +119 -0
- package/dist/featureFlagsApp.d.ts +27 -0
- package/dist/featureFlagsApp.js +124 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/tests/bunSetup.d.ts +1 -0
- package/dist/tests/bunSetup.js +35 -0
- package/dist/tests/evaluate.test.d.ts +1 -0
- package/dist/tests/evaluate.test.js +594 -0
- package/dist/tests/featureFlagModel.test.d.ts +1 -0
- package/dist/tests/featureFlagModel.test.js +342 -0
- package/dist/tests/featureFlagsApp.test.d.ts +1 -0
- package/dist/tests/featureFlagsApp.test.js +391 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +2 -0
- package/package.json +55 -0
- package/src/evaluate.ts +195 -0
- package/src/featureFlagModel.ts +138 -0
- package/src/featureFlagsApp.ts +95 -0
- package/src/index.ts +13 -0
- package/src/tests/bunSetup.ts +36 -0
- package/src/tests/evaluate.test.ts +603 -0
- package/src/tests/featureFlagModel.test.ts +205 -0
- package/src/tests/featureFlagsApp.test.ts +221 -0
- package/src/types.ts +46 -0
- package/tsconfig.json +31 -0
package/biome.jsonc
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
3
|
+
"extends": ["../biome.jsonc"],
|
|
4
|
+
"files": {
|
|
5
|
+
"includes": [
|
|
6
|
+
"package.json",
|
|
7
|
+
"src/**/*.ts",
|
|
8
|
+
"src/**/*.tsx",
|
|
9
|
+
"!!**/node_modules",
|
|
10
|
+
"!!node_modules",
|
|
11
|
+
"!!**/dist",
|
|
12
|
+
"!!dist",
|
|
13
|
+
"!!**/build",
|
|
14
|
+
"!!build",
|
|
15
|
+
"!!**/coverage",
|
|
16
|
+
"!!coverage"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"root": true
|
|
20
|
+
}
|
package/bunfig.toml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[test]
|
|
2
|
+
preload = ["../api/src/tests/bunSetup.ts"]
|
|
3
|
+
root = "./src"
|
|
4
|
+
coveragePathIgnorePatterns = ["**/dist/**", "**/*.test.ts", "**/tests/**", "**/node_modules/**", "../**"]
|
|
5
|
+
# Enforced by scripts/check-coverage.ts; Bun parses this key but does not
|
|
6
|
+
# fail the run when coverage is below threshold. See
|
|
7
|
+
# https://github.com/oven-sh/bun/issues/7367 and https://github.com/oven-sh/bun/pull/27933.
|
|
8
|
+
coverageThreshold = { line = 95, function = 95 }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EvaluationResult, FeatureFlagDocument, SegmentFunction } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic hash of a string to a number between 0 and 99.
|
|
4
|
+
* Uses a simple but effective hash (djb2) to ensure the same user+flag
|
|
5
|
+
* combination always produces the same result.
|
|
6
|
+
*/
|
|
7
|
+
export declare const deterministicHash: (input: string) => number;
|
|
8
|
+
/**
|
|
9
|
+
* Evaluate a single feature flag for a user.
|
|
10
|
+
* Returns boolean for boolean flags, string key for variant flags, or null for disabled variant flags.
|
|
11
|
+
*/
|
|
12
|
+
export declare const evaluateFlag: (flag: FeatureFlagDocument, userId: string, user: unknown, segments: Record<string, SegmentFunction>) => boolean | string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Evaluate all enabled, non-archived feature flags for a user.
|
|
15
|
+
* Returns a map of flag key to evaluation result.
|
|
16
|
+
*/
|
|
17
|
+
export declare const evaluateAllFlags: (flagModel: typeof import("./featureFlagModel").FeatureFlag, userId: string, user: unknown, segments: Record<string, SegmentFunction>) => Promise<EvaluationResult>;
|
package/dist/evaluate.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
12
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
13
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
14
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
15
|
+
function step(op) {
|
|
16
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
17
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
18
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
19
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
20
|
+
switch (op[0]) {
|
|
21
|
+
case 0: case 1: t = op; break;
|
|
22
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
23
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
24
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
25
|
+
default:
|
|
26
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
27
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
28
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
29
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
30
|
+
if (t[2]) _.ops.pop();
|
|
31
|
+
_.trys.pop(); continue;
|
|
32
|
+
}
|
|
33
|
+
op = body.call(thisArg, _);
|
|
34
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
35
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var __values = (this && this.__values) || function(o) {
|
|
39
|
+
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
|
|
40
|
+
if (m) return m.call(o);
|
|
41
|
+
if (o && typeof o.length === "number") return {
|
|
42
|
+
next: function () {
|
|
43
|
+
if (o && i >= o.length) o = void 0;
|
|
44
|
+
return { value: o && o[i++], done: !o };
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
|
|
48
|
+
};
|
|
49
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
50
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
51
|
+
};
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.evaluateAllFlags = exports.evaluateFlag = exports.deterministicHash = void 0;
|
|
54
|
+
var api_1 = require("@terreno/api");
|
|
55
|
+
var get_1 = __importDefault(require("lodash/get"));
|
|
56
|
+
var isDebugEnabled = function () { return process.env.FEATURE_FLAGS_DEBUG !== "false"; };
|
|
57
|
+
/**
|
|
58
|
+
* Deterministic hash of a string to a number between 0 and 99.
|
|
59
|
+
* Uses a simple but effective hash (djb2) to ensure the same user+flag
|
|
60
|
+
* combination always produces the same result.
|
|
61
|
+
*/
|
|
62
|
+
var deterministicHash = function (input) {
|
|
63
|
+
var hash = 5381;
|
|
64
|
+
for (var i = 0; i < input.length; i++) {
|
|
65
|
+
hash = (hash * 33) ^ input.charCodeAt(i);
|
|
66
|
+
}
|
|
67
|
+
return Math.abs(hash) % 100;
|
|
68
|
+
};
|
|
69
|
+
exports.deterministicHash = deterministicHash;
|
|
70
|
+
var matchesFieldRule = function (user, rule) {
|
|
71
|
+
if (!rule.field || !rule.operator) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
var fieldValue = (0, get_1.default)(user, rule.field);
|
|
75
|
+
var ruleValue = rule.value;
|
|
76
|
+
var result = false;
|
|
77
|
+
switch (rule.operator) {
|
|
78
|
+
case "eq":
|
|
79
|
+
result = ruleValue !== undefined && fieldValue === ruleValue;
|
|
80
|
+
break;
|
|
81
|
+
case "neq":
|
|
82
|
+
result = ruleValue !== undefined && fieldValue !== ruleValue;
|
|
83
|
+
break;
|
|
84
|
+
case "in":
|
|
85
|
+
result = Array.isArray(ruleValue) && ruleValue.includes(fieldValue);
|
|
86
|
+
break;
|
|
87
|
+
case "nin":
|
|
88
|
+
result = Array.isArray(ruleValue) && !ruleValue.includes(fieldValue);
|
|
89
|
+
break;
|
|
90
|
+
case "gt":
|
|
91
|
+
result =
|
|
92
|
+
typeof fieldValue === "number" && typeof ruleValue === "number" && fieldValue > ruleValue;
|
|
93
|
+
break;
|
|
94
|
+
case "lt":
|
|
95
|
+
result =
|
|
96
|
+
typeof fieldValue === "number" && typeof ruleValue === "number" && fieldValue < ruleValue;
|
|
97
|
+
break;
|
|
98
|
+
case "contains":
|
|
99
|
+
result =
|
|
100
|
+
typeof fieldValue === "string" &&
|
|
101
|
+
typeof ruleValue === "string" &&
|
|
102
|
+
fieldValue.includes(ruleValue);
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (isDebugEnabled()) {
|
|
108
|
+
api_1.logger.info("[feature-flags] field rule: user.".concat(rule.field, "=").concat(JSON.stringify(fieldValue), " ").concat(rule.operator, " ").concat(JSON.stringify(ruleValue), " \u2192 ").concat(result));
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
var matchesRule = function (user, rule, segments) {
|
|
113
|
+
if (rule.segment) {
|
|
114
|
+
var segmentFn = segments[rule.segment];
|
|
115
|
+
if (!segmentFn) {
|
|
116
|
+
api_1.logger.warn("Segment function not found: ".concat(rule.segment));
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
var result = segmentFn(user);
|
|
121
|
+
if (isDebugEnabled()) {
|
|
122
|
+
api_1.logger.info("[feature-flags] segment rule: \"".concat(rule.segment, "\" \u2192 ").concat(result));
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
api_1.logger.warn("Segment function \"".concat(rule.segment, "\" threw: ").concat(err));
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return matchesFieldRule(user, rule);
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Evaluate a single feature flag for a user.
|
|
135
|
+
* Returns boolean for boolean flags, string key for variant flags, or null for disabled variant flags.
|
|
136
|
+
*/
|
|
137
|
+
var evaluateFlag = function (flag, userId, user, segments) {
|
|
138
|
+
var e_1, _a;
|
|
139
|
+
var _b, _c;
|
|
140
|
+
var debug = isDebugEnabled();
|
|
141
|
+
if (!flag.enabled) {
|
|
142
|
+
if (debug) {
|
|
143
|
+
api_1.logger.info("[feature-flags] \"".concat(flag.key, "\" is disabled \u2192 ").concat(flag.type === "variant" ? "null" : "false"));
|
|
144
|
+
}
|
|
145
|
+
return flag.type === "variant" ? null : false;
|
|
146
|
+
}
|
|
147
|
+
// Check rules in order — first match wins
|
|
148
|
+
for (var i = 0; i < flag.rules.length; i++) {
|
|
149
|
+
var rule = flag.rules[i];
|
|
150
|
+
var matched = matchesRule(user, rule, segments);
|
|
151
|
+
if (matched) {
|
|
152
|
+
var result = flag.type === "variant" ? ((_b = rule.variant) !== null && _b !== void 0 ? _b : null) : ((_c = rule.enabled) !== null && _c !== void 0 ? _c : false);
|
|
153
|
+
if (debug) {
|
|
154
|
+
api_1.logger.info("[feature-flags] \"".concat(flag.key, "\" matched rule ").concat(i, " \u2192 ").concat(JSON.stringify(result)));
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// No rules matched — use deterministic hashing
|
|
160
|
+
var hash = (0, exports.deterministicHash)("".concat(userId, ":").concat(flag.key));
|
|
161
|
+
if (flag.type === "boolean") {
|
|
162
|
+
var result = hash < flag.rolloutPercentage;
|
|
163
|
+
if (debug) {
|
|
164
|
+
api_1.logger.info("[feature-flags] \"".concat(flag.key, "\" no rules matched, hash=").concat(hash, " rollout=").concat(flag.rolloutPercentage, "% \u2192 ").concat(result));
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
// Variant assignment based on cumulative weights
|
|
169
|
+
var cumulativeWeight = 0;
|
|
170
|
+
try {
|
|
171
|
+
for (var _d = __values(flag.variants), _e = _d.next(); !_e.done; _e = _d.next()) {
|
|
172
|
+
var variant = _e.value;
|
|
173
|
+
cumulativeWeight += variant.weight;
|
|
174
|
+
if (hash < cumulativeWeight) {
|
|
175
|
+
if (debug) {
|
|
176
|
+
api_1.logger.info("[feature-flags] \"".concat(flag.key, "\" no rules matched, hash=").concat(hash, " \u2192 variant \"").concat(variant.key, "\""));
|
|
177
|
+
}
|
|
178
|
+
return variant.key;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
183
|
+
finally {
|
|
184
|
+
try {
|
|
185
|
+
if (_e && !_e.done && (_a = _d.return)) _a.call(_d);
|
|
186
|
+
}
|
|
187
|
+
finally { if (e_1) throw e_1.error; }
|
|
188
|
+
}
|
|
189
|
+
// Fallback (shouldn't happen if weights sum to 100)
|
|
190
|
+
var fallback = flag.variants.length > 0 ? flag.variants[flag.variants.length - 1].key : null;
|
|
191
|
+
if (debug) {
|
|
192
|
+
api_1.logger.info("[feature-flags] \"".concat(flag.key, "\" variant fallback \u2192 ").concat(JSON.stringify(fallback)));
|
|
193
|
+
}
|
|
194
|
+
return fallback;
|
|
195
|
+
};
|
|
196
|
+
exports.evaluateFlag = evaluateFlag;
|
|
197
|
+
/**
|
|
198
|
+
* Evaluate all enabled, non-archived feature flags for a user.
|
|
199
|
+
* Returns a map of flag key to evaluation result.
|
|
200
|
+
*/
|
|
201
|
+
var evaluateAllFlags = function (flagModel, userId, user, segments) { return __awaiter(void 0, void 0, void 0, function () {
|
|
202
|
+
var flags, results, flags_1, flags_1_1, flag;
|
|
203
|
+
var e_2, _a;
|
|
204
|
+
return __generator(this, function (_b) {
|
|
205
|
+
switch (_b.label) {
|
|
206
|
+
case 0: return [4 /*yield*/, flagModel.find({ archived: { $ne: true }, enabled: true })];
|
|
207
|
+
case 1:
|
|
208
|
+
flags = _b.sent();
|
|
209
|
+
results = {};
|
|
210
|
+
if (isDebugEnabled()) {
|
|
211
|
+
api_1.logger.info("[feature-flags] evaluating ".concat(flags.length, " flags for user ").concat(userId));
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
for (flags_1 = __values(flags), flags_1_1 = flags_1.next(); !flags_1_1.done; flags_1_1 = flags_1.next()) {
|
|
215
|
+
flag = flags_1_1.value;
|
|
216
|
+
results[flag.key] = (0, exports.evaluateFlag)(flag, userId, user, segments);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (e_2_1) { e_2 = { error: e_2_1 }; }
|
|
220
|
+
finally {
|
|
221
|
+
try {
|
|
222
|
+
if (flags_1_1 && !flags_1_1.done && (_a = flags_1.return)) _a.call(flags_1);
|
|
223
|
+
}
|
|
224
|
+
finally { if (e_2) throw e_2.error; }
|
|
225
|
+
}
|
|
226
|
+
if (isDebugEnabled()) {
|
|
227
|
+
api_1.logger.info("[feature-flags] results: ".concat(JSON.stringify(results)));
|
|
228
|
+
}
|
|
229
|
+
return [2 /*return*/, results];
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}); };
|
|
233
|
+
exports.evaluateAllFlags = evaluateAllFlags;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FeatureFlag = void 0;
|
|
7
|
+
var api_1 = require("@terreno/api");
|
|
8
|
+
var mongoose_1 = __importDefault(require("mongoose"));
|
|
9
|
+
var featureFlagSchema = new mongoose_1.default.Schema({
|
|
10
|
+
archived: {
|
|
11
|
+
default: false,
|
|
12
|
+
description: "Archived flags are excluded from evaluation. Use this instead of deleting flags to prevent bloat as new features are added.",
|
|
13
|
+
type: Boolean,
|
|
14
|
+
},
|
|
15
|
+
description: {
|
|
16
|
+
default: "",
|
|
17
|
+
description: "Explanation of what this flag controls",
|
|
18
|
+
type: String,
|
|
19
|
+
},
|
|
20
|
+
enabled: {
|
|
21
|
+
default: false,
|
|
22
|
+
description: "Global kill switch — if false, flag is off for everyone",
|
|
23
|
+
type: Boolean,
|
|
24
|
+
},
|
|
25
|
+
key: {
|
|
26
|
+
description: "Unique identifier for the flag, e.g., 'new-checkout-flow'",
|
|
27
|
+
required: true,
|
|
28
|
+
trim: true,
|
|
29
|
+
type: String,
|
|
30
|
+
unique: true,
|
|
31
|
+
},
|
|
32
|
+
name: {
|
|
33
|
+
description: "Human-readable display name",
|
|
34
|
+
required: true,
|
|
35
|
+
type: String,
|
|
36
|
+
},
|
|
37
|
+
rolloutPercentage: {
|
|
38
|
+
default: 100,
|
|
39
|
+
description: "For boolean flags with no matching rules: percentage of users who get true",
|
|
40
|
+
max: 100,
|
|
41
|
+
min: 0,
|
|
42
|
+
type: Number,
|
|
43
|
+
},
|
|
44
|
+
rules: [
|
|
45
|
+
{
|
|
46
|
+
enabled: {
|
|
47
|
+
description: "Whether the flag/variant is turned on when this rule matches. For boolean flags this is the override value; for variant flags it gates whether the forced variant applies.",
|
|
48
|
+
type: Boolean,
|
|
49
|
+
},
|
|
50
|
+
field: {
|
|
51
|
+
description: "User field to match against (use with operator + value). Supports dot notation, e.g., 'email', 'admin', 'address.zip'. Use field/operator/value together, OR segment alone.",
|
|
52
|
+
type: String,
|
|
53
|
+
},
|
|
54
|
+
operator: {
|
|
55
|
+
description: "Comparison operator for field-based rules (use with field + value). Use field/operator/value together, OR segment alone.",
|
|
56
|
+
enum: ["eq", "neq", "in", "nin", "gt", "lt", "contains"],
|
|
57
|
+
type: String,
|
|
58
|
+
},
|
|
59
|
+
segment: {
|
|
60
|
+
description: "Name of a registered segment function, e.g., 'pro-users'. Use segment alone, OR field/operator/value together.",
|
|
61
|
+
type: String,
|
|
62
|
+
},
|
|
63
|
+
value: {
|
|
64
|
+
description: "Value to compare against (use with field + operator). String, number, boolean, or array for in/nin. Use field/operator/value together, OR segment alone.",
|
|
65
|
+
type: mongoose_1.default.Schema.Types.Mixed,
|
|
66
|
+
},
|
|
67
|
+
variant: {
|
|
68
|
+
description: "For variant flags only: forced variant key when this rule matches. Use field/operator/value together, OR segment alone.",
|
|
69
|
+
type: String,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
type: {
|
|
74
|
+
default: "boolean",
|
|
75
|
+
description: "Boolean toggle or multi-variant A/B test",
|
|
76
|
+
enum: ["boolean", "variant"],
|
|
77
|
+
type: String,
|
|
78
|
+
},
|
|
79
|
+
variants: [
|
|
80
|
+
{
|
|
81
|
+
key: {
|
|
82
|
+
description: "Variant identifier, e.g., 'control', 'variant-a'",
|
|
83
|
+
required: true,
|
|
84
|
+
type: String,
|
|
85
|
+
},
|
|
86
|
+
weight: {
|
|
87
|
+
description: "Percentage weight for assignment (0-100, all must sum to 100)",
|
|
88
|
+
max: 100,
|
|
89
|
+
min: 0,
|
|
90
|
+
required: true,
|
|
91
|
+
type: Number,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}, { strict: "throw", toJSON: { virtuals: true }, toObject: { virtuals: true } });
|
|
96
|
+
featureFlagSchema.plugin(api_1.createdUpdatedPlugin);
|
|
97
|
+
featureFlagSchema.plugin(api_1.isDeletedPlugin);
|
|
98
|
+
featureFlagSchema.plugin(api_1.findExactlyOne);
|
|
99
|
+
featureFlagSchema.plugin(api_1.findOneOrNone);
|
|
100
|
+
featureFlagSchema.index({ archived: 1, enabled: 1 });
|
|
101
|
+
featureFlagSchema.pre("save", function () {
|
|
102
|
+
if (this.type === "variant") {
|
|
103
|
+
if (!this.variants || this.variants.length === 0) {
|
|
104
|
+
throw new api_1.APIError({
|
|
105
|
+
status: 400,
|
|
106
|
+
title: "Variant flags must have at least one variant",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
var totalWeight = this.variants.reduce(function (sum, v) { return sum + v.weight; }, 0);
|
|
110
|
+
if (totalWeight !== 100) {
|
|
111
|
+
throw new api_1.APIError({
|
|
112
|
+
detail: "Total weight is ".concat(totalWeight, ", expected 100"),
|
|
113
|
+
status: 400,
|
|
114
|
+
title: "Variant weights must sum to 100",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
exports.FeatureFlag = mongoose_1.default.model("FeatureFlag", featureFlagSchema);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type TerrenoPlugin } from "@terreno/api";
|
|
2
|
+
import type express from "express";
|
|
3
|
+
import type { FeatureFlagsOptions } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* TerrenoPlugin that provides feature flags and A/B testing.
|
|
6
|
+
*
|
|
7
|
+
* Creates admin CRUD endpoints for managing flags and a user-facing
|
|
8
|
+
* evaluation endpoint that returns all flag values for the current user.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* const segments = {
|
|
13
|
+
* "pro-users": (user) => user.plan === "pro",
|
|
14
|
+
* "beta-testers": (user) => user.betaTester === true,
|
|
15
|
+
* };
|
|
16
|
+
*
|
|
17
|
+
* new TerrenoApp({ userModel: User })
|
|
18
|
+
* .register(new FeatureFlagsApp({ segments }))
|
|
19
|
+
* .start();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare class FeatureFlagsApp implements TerrenoPlugin {
|
|
23
|
+
private options;
|
|
24
|
+
private segments;
|
|
25
|
+
constructor(options?: FeatureFlagsOptions);
|
|
26
|
+
register(app: express.Application, openApi?: unknown): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __assign = (this && this.__assign) || function () {
|
|
3
|
+
__assign = Object.assign || function(t) {
|
|
4
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
5
|
+
s = arguments[i];
|
|
6
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
7
|
+
t[p] = s[p];
|
|
8
|
+
}
|
|
9
|
+
return t;
|
|
10
|
+
};
|
|
11
|
+
return __assign.apply(this, arguments);
|
|
12
|
+
};
|
|
13
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
14
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
15
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
16
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
17
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
18
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
19
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
23
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
24
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
25
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
26
|
+
function step(op) {
|
|
27
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
28
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
29
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
30
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
31
|
+
switch (op[0]) {
|
|
32
|
+
case 0: case 1: t = op; break;
|
|
33
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
34
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
35
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
36
|
+
default:
|
|
37
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
38
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
39
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
40
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
41
|
+
if (t[2]) _.ops.pop();
|
|
42
|
+
_.trys.pop(); continue;
|
|
43
|
+
}
|
|
44
|
+
op = body.call(thisArg, _);
|
|
45
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
46
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.FeatureFlagsApp = void 0;
|
|
51
|
+
var api_1 = require("@terreno/api");
|
|
52
|
+
var evaluate_1 = require("./evaluate");
|
|
53
|
+
var featureFlagModel_1 = require("./featureFlagModel");
|
|
54
|
+
/**
|
|
55
|
+
* TerrenoPlugin that provides feature flags and A/B testing.
|
|
56
|
+
*
|
|
57
|
+
* Creates admin CRUD endpoints for managing flags and a user-facing
|
|
58
|
+
* evaluation endpoint that returns all flag values for the current user.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const segments = {
|
|
63
|
+
* "pro-users": (user) => user.plan === "pro",
|
|
64
|
+
* "beta-testers": (user) => user.betaTester === true,
|
|
65
|
+
* };
|
|
66
|
+
*
|
|
67
|
+
* new TerrenoApp({ userModel: User })
|
|
68
|
+
* .register(new FeatureFlagsApp({ segments }))
|
|
69
|
+
* .start();
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
var FeatureFlagsApp = /** @class */ (function () {
|
|
73
|
+
function FeatureFlagsApp(options) {
|
|
74
|
+
var _a;
|
|
75
|
+
this.options = options !== null && options !== void 0 ? options : {};
|
|
76
|
+
this.segments = (_a = this.options.segments) !== null && _a !== void 0 ? _a : {};
|
|
77
|
+
}
|
|
78
|
+
FeatureFlagsApp.prototype.register = function (app, openApi) {
|
|
79
|
+
var _this = this;
|
|
80
|
+
var _a;
|
|
81
|
+
var basePath = (_a = this.options.basePath) !== null && _a !== void 0 ? _a : "/feature-flags";
|
|
82
|
+
var routerOptions = __assign(__assign({}, (openApi ? { openApi: openApi } : {})), { permissions: {
|
|
83
|
+
create: [api_1.Permissions.IsAdmin],
|
|
84
|
+
delete: [api_1.Permissions.IsAdmin],
|
|
85
|
+
list: [api_1.Permissions.IsAdmin],
|
|
86
|
+
read: [api_1.Permissions.IsAdmin],
|
|
87
|
+
update: [api_1.Permissions.IsAdmin],
|
|
88
|
+
}, sort: "-created" });
|
|
89
|
+
// Admin CRUD routes for flags
|
|
90
|
+
app.use("".concat(basePath, "/flags"), (0, api_1.modelRouter)(featureFlagModel_1.FeatureFlag, routerOptions));
|
|
91
|
+
// GET /feature-flags/evaluate — evaluate all flags for current user
|
|
92
|
+
app.get("".concat(basePath, "/evaluate"), (0, api_1.authenticateMiddleware)(), (0, api_1.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
|
|
93
|
+
var user, userId, results;
|
|
94
|
+
var _a;
|
|
95
|
+
return __generator(this, function (_b) {
|
|
96
|
+
switch (_b.label) {
|
|
97
|
+
case 0:
|
|
98
|
+
user = req.user;
|
|
99
|
+
if (!user) {
|
|
100
|
+
throw new api_1.APIError({ status: 401, title: "Authentication required" });
|
|
101
|
+
}
|
|
102
|
+
userId = String((_a = user._id) !== null && _a !== void 0 ? _a : user.id);
|
|
103
|
+
return [4 /*yield*/, (0, evaluate_1.evaluateAllFlags)(featureFlagModel_1.FeatureFlag, userId, user, this.segments)];
|
|
104
|
+
case 1:
|
|
105
|
+
results = _b.sent();
|
|
106
|
+
return [2 /*return*/, res.json({ data: results })];
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}); }));
|
|
110
|
+
// GET /feature-flags/segments — list registered segment names (admin only)
|
|
111
|
+
app.get("".concat(basePath, "/segments"), (0, api_1.authenticateMiddleware)(), (0, api_1.asyncHandler)(function (req, res) { return __awaiter(_this, void 0, void 0, function () {
|
|
112
|
+
var user;
|
|
113
|
+
return __generator(this, function (_a) {
|
|
114
|
+
user = req.user;
|
|
115
|
+
if (!(user === null || user === void 0 ? void 0 : user.admin)) {
|
|
116
|
+
throw new api_1.APIError({ status: 403, title: "Only admins can view segments" });
|
|
117
|
+
}
|
|
118
|
+
return [2 /*return*/, res.json({ data: Object.keys(this.segments) })];
|
|
119
|
+
});
|
|
120
|
+
}); }));
|
|
121
|
+
};
|
|
122
|
+
return FeatureFlagsApp;
|
|
123
|
+
}());
|
|
124
|
+
exports.FeatureFlagsApp = FeatureFlagsApp;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { deterministicHash, evaluateAllFlags, evaluateFlag } from "./evaluate";
|
|
2
|
+
export { FeatureFlag } from "./featureFlagModel";
|
|
3
|
+
export { FeatureFlagsApp } from "./featureFlagsApp";
|
|
4
|
+
export type { EvaluationResult, FeatureFlagDocument, FeatureFlagModel, FeatureFlagRule, FeatureFlagsOptions, FeatureFlagType, FeatureFlagVariant, SegmentFunction, } from "./types";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FeatureFlagsApp = exports.FeatureFlag = exports.evaluateFlag = exports.evaluateAllFlags = exports.deterministicHash = void 0;
|
|
4
|
+
var evaluate_1 = require("./evaluate");
|
|
5
|
+
Object.defineProperty(exports, "deterministicHash", { enumerable: true, get: function () { return evaluate_1.deterministicHash; } });
|
|
6
|
+
Object.defineProperty(exports, "evaluateAllFlags", { enumerable: true, get: function () { return evaluate_1.evaluateAllFlags; } });
|
|
7
|
+
Object.defineProperty(exports, "evaluateFlag", { enumerable: true, get: function () { return evaluate_1.evaluateFlag; } });
|
|
8
|
+
var featureFlagModel_1 = require("./featureFlagModel");
|
|
9
|
+
Object.defineProperty(exports, "FeatureFlag", { enumerable: true, get: function () { return featureFlagModel_1.FeatureFlag; } });
|
|
10
|
+
var featureFlagsApp_1 = require("./featureFlagsApp");
|
|
11
|
+
Object.defineProperty(exports, "FeatureFlagsApp", { enumerable: true, get: function () { return featureFlagsApp_1.FeatureFlagsApp; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
var bun_test_1 = require("bun:test");
|
|
4
|
+
// Mock @sentry/bun module
|
|
5
|
+
bun_test_1.mock.module("@sentry/bun", function () {
|
|
6
|
+
var mockFn = function () { return (0, bun_test_1.mock)(function () { }); };
|
|
7
|
+
return {
|
|
8
|
+
addBreadcrumb: mockFn(),
|
|
9
|
+
captureException: mockFn(),
|
|
10
|
+
captureMessage: mockFn(),
|
|
11
|
+
close: (0, bun_test_1.mock)(function () { return Promise.resolve(true); }),
|
|
12
|
+
default: {
|
|
13
|
+
captureException: mockFn(),
|
|
14
|
+
captureMessage: mockFn(),
|
|
15
|
+
Handlers: {
|
|
16
|
+
errorHandler: (0, bun_test_1.mock)(function () { return function (err, _req, _res, next) {
|
|
17
|
+
return next(err);
|
|
18
|
+
}; }),
|
|
19
|
+
requestHandler: (0, bun_test_1.mock)(function () { return function (_req, _res, next) { return next(); }; }),
|
|
20
|
+
tracingHandler: (0, bun_test_1.mock)(function () { return function (_req, _res, next) { return next(); }; }),
|
|
21
|
+
},
|
|
22
|
+
init: mockFn(),
|
|
23
|
+
isInitialized: (0, bun_test_1.mock)(function () { return true; }),
|
|
24
|
+
setupExpressErrorHandler: mockFn(),
|
|
25
|
+
},
|
|
26
|
+
flush: (0, bun_test_1.mock)(function () { return Promise.resolve(true); }),
|
|
27
|
+
init: mockFn(),
|
|
28
|
+
isInitialized: (0, bun_test_1.mock)(function () { return true; }),
|
|
29
|
+
setContext: mockFn(),
|
|
30
|
+
setTag: mockFn(),
|
|
31
|
+
setUser: mockFn(),
|
|
32
|
+
setupExpressErrorHandler: mockFn(),
|
|
33
|
+
withScope: (0, bun_test_1.mock)(function (callback) { return callback({}); }),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|