@pylonsync/feature-flags 0.3.83
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/package.json +26 -0
- package/src/eval.test.ts +160 -0
- package/src/eval.ts +178 -0
- package/src/index.ts +95 -0
- package/src/types.ts +120 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pylonsync/feature-flags",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.3.83",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json --noEmit",
|
|
12
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@pylonsync/sdk": "0.3.81",
|
|
16
|
+
"@pylonsync/functions": "0.3.81"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"bun-types": "*"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"bun-types": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/eval.test.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { evaluate, hashBucket } from "./eval";
|
|
4
|
+
import type { BooleanFlag, MultivariateFlag } from "./types";
|
|
5
|
+
|
|
6
|
+
describe("hashBucket", () => {
|
|
7
|
+
test("0% rollout always returns false", () => {
|
|
8
|
+
expect(hashBucket("user_1", 0)).toBe(false);
|
|
9
|
+
expect(hashBucket("user_2", 0)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("100% rollout always returns true", () => {
|
|
13
|
+
expect(hashBucket("user_1", 100)).toBe(true);
|
|
14
|
+
expect(hashBucket("user_2", 100)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("50% rollout distributes ~uniformly", () => {
|
|
18
|
+
let hits = 0;
|
|
19
|
+
const N = 10000;
|
|
20
|
+
for (let i = 0; i < N; i++) {
|
|
21
|
+
if (hashBucket(`u_${i}`, 50)) hits++;
|
|
22
|
+
}
|
|
23
|
+
const ratio = hits / N;
|
|
24
|
+
expect(ratio).toBeGreaterThan(0.45);
|
|
25
|
+
expect(ratio).toBeLessThan(0.55);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("same key + percent = same answer (deterministic)", () => {
|
|
29
|
+
const v1 = hashBucket("user_42", 25);
|
|
30
|
+
const v2 = hashBucket("user_42", 25);
|
|
31
|
+
expect(v1).toBe(v2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("boolean flag evaluation", () => {
|
|
36
|
+
test("default value when no rollout", () => {
|
|
37
|
+
const flag: BooleanFlag = { type: "boolean", default: true };
|
|
38
|
+
const r = evaluate(flag, { userId: "u_1" });
|
|
39
|
+
expect(r.value).toBe(true);
|
|
40
|
+
expect(r.matched).toBe("default");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rollout splits users", () => {
|
|
44
|
+
const flag: BooleanFlag = {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
default: true,
|
|
47
|
+
rollout: { percent: 100 },
|
|
48
|
+
};
|
|
49
|
+
const r = evaluate(flag, { userId: "u_1" });
|
|
50
|
+
expect(r.value).toBe(true);
|
|
51
|
+
expect(r.matched).toBe("rollout");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("targeting rule beats rollout", () => {
|
|
55
|
+
const flag: BooleanFlag = {
|
|
56
|
+
type: "boolean",
|
|
57
|
+
default: false,
|
|
58
|
+
rollout: { percent: 0 },
|
|
59
|
+
targeting: [
|
|
60
|
+
{
|
|
61
|
+
value: true,
|
|
62
|
+
when: [{ property: "plan", op: "eq", value: "enterprise" }],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
const r = evaluate(flag, {
|
|
67
|
+
userId: "u_1",
|
|
68
|
+
properties: { plan: "enterprise" },
|
|
69
|
+
});
|
|
70
|
+
expect(r.value).toBe(true);
|
|
71
|
+
expect(r.matched).toBe("targeting");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("targeting predicates AND together", () => {
|
|
75
|
+
const flag: BooleanFlag = {
|
|
76
|
+
type: "boolean",
|
|
77
|
+
default: false,
|
|
78
|
+
targeting: [
|
|
79
|
+
{
|
|
80
|
+
value: true,
|
|
81
|
+
when: [
|
|
82
|
+
{ property: "plan", op: "eq", value: "pro" },
|
|
83
|
+
{ property: "betaTester", op: "eq", value: true },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
// Only one predicate satisfied — should fall back to default.
|
|
89
|
+
const r1 = evaluate(flag, {
|
|
90
|
+
userId: "u_1",
|
|
91
|
+
properties: { plan: "pro", betaTester: false },
|
|
92
|
+
});
|
|
93
|
+
expect(r1.value).toBe(false);
|
|
94
|
+
// Both satisfied — rule fires.
|
|
95
|
+
const r2 = evaluate(flag, {
|
|
96
|
+
userId: "u_1",
|
|
97
|
+
properties: { plan: "pro", betaTester: true },
|
|
98
|
+
});
|
|
99
|
+
expect(r2.value).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("contains / starts_with / ends_with predicates", () => {
|
|
103
|
+
const flag: BooleanFlag = {
|
|
104
|
+
type: "boolean",
|
|
105
|
+
default: false,
|
|
106
|
+
targeting: [
|
|
107
|
+
{
|
|
108
|
+
value: true,
|
|
109
|
+
when: [{ property: "email", op: "ends_with", value: "@acme.com" }],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
expect(
|
|
114
|
+
evaluate(flag, {
|
|
115
|
+
userId: "u_1",
|
|
116
|
+
properties: { email: "ceo@acme.com" },
|
|
117
|
+
}).value,
|
|
118
|
+
).toBe(true);
|
|
119
|
+
expect(
|
|
120
|
+
evaluate(flag, {
|
|
121
|
+
userId: "u_1",
|
|
122
|
+
properties: { email: "ceo@elsewhere.com" },
|
|
123
|
+
}).value,
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("multivariate flag evaluation", () => {
|
|
129
|
+
test("variants distribute by weight", () => {
|
|
130
|
+
const flag: MultivariateFlag = {
|
|
131
|
+
type: "multivariate",
|
|
132
|
+
default: "control",
|
|
133
|
+
variants: [
|
|
134
|
+
{ name: "control", weight: 50 },
|
|
135
|
+
{ name: "treatment", weight: 50 },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
let treatment = 0;
|
|
139
|
+
const N = 10000;
|
|
140
|
+
for (let i = 0; i < N; i++) {
|
|
141
|
+
const r = evaluate(flag, { userId: `u_${i}` });
|
|
142
|
+
if (r.value === "treatment") treatment++;
|
|
143
|
+
}
|
|
144
|
+
const ratio = treatment / N;
|
|
145
|
+
expect(ratio).toBeGreaterThan(0.45);
|
|
146
|
+
expect(ratio).toBeLessThan(0.55);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("payload is returned when set", () => {
|
|
150
|
+
const flag: MultivariateFlag = {
|
|
151
|
+
type: "multivariate",
|
|
152
|
+
default: "v1",
|
|
153
|
+
variants: [
|
|
154
|
+
{ name: "v1", weight: 100, payload: { maxTokens: 1000 } },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
const r = evaluate(flag, { userId: "u_1" });
|
|
158
|
+
expect(r.value).toEqual({ maxTokens: 1000 });
|
|
159
|
+
});
|
|
160
|
+
});
|
package/src/eval.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag evaluation. Pure functions over a flag catalog + an eval
|
|
3
|
+
* context. No state, no I/O — making this trivially testable +
|
|
4
|
+
* cheap to call on hot paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
BooleanFlag,
|
|
9
|
+
EvalContext,
|
|
10
|
+
FlagDefinition,
|
|
11
|
+
FlagValue,
|
|
12
|
+
MultivariateFlag,
|
|
13
|
+
TargetingPredicate,
|
|
14
|
+
TargetingRule,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
export function evaluate(
|
|
18
|
+
flag: FlagDefinition,
|
|
19
|
+
ctx: EvalContext,
|
|
20
|
+
): { value: FlagValue; matched: "default" | "targeting" | "rollout" } {
|
|
21
|
+
// Targeting rules are first — they override rollout. First
|
|
22
|
+
// match wins.
|
|
23
|
+
if (flag.type === "boolean") {
|
|
24
|
+
const ruleHit = firstMatchingRule(flag.targeting ?? [], ctx);
|
|
25
|
+
if (ruleHit !== undefined) return { value: ruleHit, matched: "targeting" };
|
|
26
|
+
return evalBooleanRollout(flag, ctx);
|
|
27
|
+
}
|
|
28
|
+
const ruleHit = firstMatchingRule(flag.targeting ?? [], ctx);
|
|
29
|
+
if (ruleHit !== undefined) {
|
|
30
|
+
const variant = flag.variants.find((v) => v.name === ruleHit);
|
|
31
|
+
return {
|
|
32
|
+
value: (variant?.payload as FlagValue) ?? ruleHit,
|
|
33
|
+
matched: "targeting",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return evalMultivariate(flag, ctx);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function evalBooleanRollout(
|
|
40
|
+
flag: BooleanFlag,
|
|
41
|
+
ctx: EvalContext,
|
|
42
|
+
): { value: boolean; matched: "default" | "rollout" } {
|
|
43
|
+
if (!flag.rollout) {
|
|
44
|
+
return { value: flag.default, matched: "default" };
|
|
45
|
+
}
|
|
46
|
+
const bucketKey = pickBucketKey(flag.rollout.key ?? "userId", ctx);
|
|
47
|
+
if (!bucketKey) return { value: flag.default, matched: "default" };
|
|
48
|
+
const bucket = hashBucket(bucketKey, flag.rollout.percent);
|
|
49
|
+
return {
|
|
50
|
+
value: bucket ? flag.default : !flag.default,
|
|
51
|
+
matched: "rollout",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function evalMultivariate(
|
|
56
|
+
flag: MultivariateFlag,
|
|
57
|
+
ctx: EvalContext,
|
|
58
|
+
): { value: string | Record<string, unknown>; matched: "default" } {
|
|
59
|
+
const bucketKey = pickBucketKey("userId", ctx);
|
|
60
|
+
if (!bucketKey) {
|
|
61
|
+
const def = flag.variants.find((v) => v.name === flag.default);
|
|
62
|
+
return {
|
|
63
|
+
value: (def?.payload as Record<string, unknown>) ?? flag.default,
|
|
64
|
+
matched: "default",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const variant = weightedPick(flag.variants, bucketKey);
|
|
68
|
+
return {
|
|
69
|
+
value: (variant.payload as Record<string, unknown>) ?? variant.name,
|
|
70
|
+
matched: "default",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function firstMatchingRule<T>(
|
|
75
|
+
rules: TargetingRule<T>[],
|
|
76
|
+
ctx: EvalContext,
|
|
77
|
+
): T | undefined {
|
|
78
|
+
for (const rule of rules) {
|
|
79
|
+
if (rule.when.every((p) => matchPredicate(p, ctx))) return rule.value;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matchPredicate(p: TargetingPredicate, ctx: EvalContext): boolean {
|
|
85
|
+
const props = ctx.properties ?? {};
|
|
86
|
+
// Specials: userId / orgId / deviceId come from the top-level
|
|
87
|
+
// context fields, not the properties bag.
|
|
88
|
+
let actual: unknown;
|
|
89
|
+
if (p.property === "userId") actual = ctx.userId;
|
|
90
|
+
else if (p.property === "orgId") actual = ctx.orgId;
|
|
91
|
+
else if (p.property === "deviceId") actual = ctx.deviceId;
|
|
92
|
+
else actual = props[p.property];
|
|
93
|
+
|
|
94
|
+
switch (p.op) {
|
|
95
|
+
case "eq":
|
|
96
|
+
return actual === p.value;
|
|
97
|
+
case "neq":
|
|
98
|
+
return actual !== p.value;
|
|
99
|
+
case "in":
|
|
100
|
+
return Array.isArray(p.value) && p.value.includes(actual as never);
|
|
101
|
+
case "not_in":
|
|
102
|
+
return Array.isArray(p.value) && !p.value.includes(actual as never);
|
|
103
|
+
case "gt":
|
|
104
|
+
return typeof actual === "number" && actual > (p.value as number);
|
|
105
|
+
case "gte":
|
|
106
|
+
return typeof actual === "number" && actual >= (p.value as number);
|
|
107
|
+
case "lt":
|
|
108
|
+
return typeof actual === "number" && actual < (p.value as number);
|
|
109
|
+
case "lte":
|
|
110
|
+
return typeof actual === "number" && actual <= (p.value as number);
|
|
111
|
+
case "contains":
|
|
112
|
+
return typeof actual === "string" && actual.includes(p.value as string);
|
|
113
|
+
case "starts_with":
|
|
114
|
+
return typeof actual === "string" && actual.startsWith(p.value as string);
|
|
115
|
+
case "ends_with":
|
|
116
|
+
return typeof actual === "string" && actual.endsWith(p.value as string);
|
|
117
|
+
case "regex":
|
|
118
|
+
try {
|
|
119
|
+
return (
|
|
120
|
+
typeof actual === "string" &&
|
|
121
|
+
new RegExp(p.value as string).test(actual)
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pickBucketKey(
|
|
131
|
+
keyName: string,
|
|
132
|
+
ctx: EvalContext,
|
|
133
|
+
): string | undefined {
|
|
134
|
+
if (keyName === "userId") return ctx.userId;
|
|
135
|
+
if (keyName === "orgId") return ctx.orgId;
|
|
136
|
+
if (keyName === "deviceId") return ctx.deviceId;
|
|
137
|
+
const v = ctx.properties?.[keyName];
|
|
138
|
+
return typeof v === "string" ? v : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Deterministic bucketing. The hash is FNV-1a (cheap, well-
|
|
143
|
+
* distributed) over the bucketKey; map to 0..99 and compare against
|
|
144
|
+
* the rollout percent.
|
|
145
|
+
*
|
|
146
|
+
* Why not SHA-256: bucket calls are on the hot path (every
|
|
147
|
+
* isEnabled() check), and FNV-1a gives near-uniform distribution
|
|
148
|
+
* for the input domain (32-char IDs) at a fraction of the cost.
|
|
149
|
+
* Same primitive PostHog uses for local-eval bucketing.
|
|
150
|
+
*/
|
|
151
|
+
export function hashBucket(key: string, percent: number): boolean {
|
|
152
|
+
let h = 0x811c9dc5;
|
|
153
|
+
for (let i = 0; i < key.length; i++) {
|
|
154
|
+
h ^= key.charCodeAt(i);
|
|
155
|
+
h = Math.imul(h, 0x01000193);
|
|
156
|
+
}
|
|
157
|
+
const bucket = ((h >>> 0) % 10000) / 100; // 0..99.99
|
|
158
|
+
return bucket < percent;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function weightedPick<T extends { name: string; weight: number }>(
|
|
162
|
+
variants: T[],
|
|
163
|
+
key: string,
|
|
164
|
+
): T {
|
|
165
|
+
const totalWeight = variants.reduce((s, v) => s + v.weight, 0);
|
|
166
|
+
let h = 0x811c9dc5;
|
|
167
|
+
for (let i = 0; i < key.length; i++) {
|
|
168
|
+
h ^= key.charCodeAt(i);
|
|
169
|
+
h = Math.imul(h, 0x01000193);
|
|
170
|
+
}
|
|
171
|
+
const point = ((h >>> 0) % totalWeight) + 1;
|
|
172
|
+
let acc = 0;
|
|
173
|
+
for (const v of variants) {
|
|
174
|
+
acc += v.weight;
|
|
175
|
+
if (point <= acc) return v;
|
|
176
|
+
}
|
|
177
|
+
return variants[variants.length - 1];
|
|
178
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces:
|
|
5
|
+
* - Inline catalog: declare flags in TS, evaluate via `isEnabled` /
|
|
6
|
+
* `getVariant` / `getPayload` over a process-local `flags` object.
|
|
7
|
+
* - Editable catalog: stored in a `FeatureFlag` entity, mutated via
|
|
8
|
+
* admin actions, cached in-process. Useful for kill switches that
|
|
9
|
+
* ops needs to flip without a redeploy.
|
|
10
|
+
*
|
|
11
|
+
* The plugin doesn't ship an A/B testing analyzer — that's a different
|
|
12
|
+
* concern (compute statistical significance from event logs). Pair
|
|
13
|
+
* with @pylonsync/audit-log to record `flag.assigned` events for
|
|
14
|
+
* downstream analysis.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { evaluate, hashBucket } from "./eval";
|
|
18
|
+
export type {
|
|
19
|
+
BooleanFlag,
|
|
20
|
+
MultivariateFlag,
|
|
21
|
+
FlagDefinition,
|
|
22
|
+
FlagValue,
|
|
23
|
+
RolloutConfig,
|
|
24
|
+
TargetingRule,
|
|
25
|
+
TargetingPredicate,
|
|
26
|
+
EvalContext,
|
|
27
|
+
FlagPluginConfig,
|
|
28
|
+
} from "./types";
|
|
29
|
+
|
|
30
|
+
import { evaluate } from "./eval";
|
|
31
|
+
import type { EvalContext, FlagDefinition } from "./types";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Boolean check. Returns the resolved boolean for the flag in the
|
|
35
|
+
* given eval context. Unknown flag names return `false` (closed by
|
|
36
|
+
* default — apps that want different semantics for unknown keys
|
|
37
|
+
* check via `getRawValue`).
|
|
38
|
+
*/
|
|
39
|
+
export function isEnabled(
|
|
40
|
+
flags: Record<string, FlagDefinition>,
|
|
41
|
+
name: string,
|
|
42
|
+
ctx: EvalContext,
|
|
43
|
+
): boolean {
|
|
44
|
+
const def = flags[name];
|
|
45
|
+
if (!def || def.type !== "boolean") return false;
|
|
46
|
+
const r = evaluate(def, ctx);
|
|
47
|
+
return Boolean(r.value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Multivariate check. Returns the variant name (or default if the
|
|
52
|
+
* flag is missing / typed wrong).
|
|
53
|
+
*/
|
|
54
|
+
export function getVariant(
|
|
55
|
+
flags: Record<string, FlagDefinition>,
|
|
56
|
+
name: string,
|
|
57
|
+
ctx: EvalContext,
|
|
58
|
+
): string {
|
|
59
|
+
const def = flags[name];
|
|
60
|
+
if (!def || def.type !== "multivariate") return "";
|
|
61
|
+
const r = evaluate(def, ctx);
|
|
62
|
+
return typeof r.value === "string" ? r.value : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Variant payload (JSON config). Useful for remote-config style flags
|
|
67
|
+
* where each variant carries different settings (rate limits, model
|
|
68
|
+
* choice, copy strings).
|
|
69
|
+
*/
|
|
70
|
+
export function getPayload(
|
|
71
|
+
flags: Record<string, FlagDefinition>,
|
|
72
|
+
name: string,
|
|
73
|
+
ctx: EvalContext,
|
|
74
|
+
): unknown {
|
|
75
|
+
const def = flags[name];
|
|
76
|
+
if (!def) return undefined;
|
|
77
|
+
const r = evaluate(def, ctx);
|
|
78
|
+
return r.value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bulk evaluation — useful for SSR bootstrapping where the client
|
|
83
|
+
* receives every flag's resolved value with the initial page load.
|
|
84
|
+
* Returns `{ [flagName]: value }`.
|
|
85
|
+
*/
|
|
86
|
+
export function evaluateAll(
|
|
87
|
+
flags: Record<string, FlagDefinition>,
|
|
88
|
+
ctx: EvalContext,
|
|
89
|
+
): Record<string, unknown> {
|
|
90
|
+
const out: Record<string, unknown> = {};
|
|
91
|
+
for (const [name, def] of Object.entries(flags)) {
|
|
92
|
+
out[name] = evaluate(def, ctx).value;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@pylonsync/feature-flags` — local-eval feature flags for Pylon
|
|
3
|
+
* apps. Boolean + multivariate flags, percentage rollouts, targeting
|
|
4
|
+
* rules, JSON payloads per variant, kill switches.
|
|
5
|
+
*
|
|
6
|
+
* Local-eval means there's no network call per check — flag
|
|
7
|
+
* definitions are loaded into the process once at startup, and
|
|
8
|
+
* `isEnabled(flag, ctx)` is a deterministic in-memory computation.
|
|
9
|
+
* That's the same model PostHog/LaunchDarkly local-eval mode uses
|
|
10
|
+
* and is what makes flag checks viable on hot request paths
|
|
11
|
+
* (e.g. inside auth or middleware).
|
|
12
|
+
*
|
|
13
|
+
* Flag definitions live in the app's `FeatureFlag` entity (declared
|
|
14
|
+
* via the manifest fragment); apps mutate them via the dashboard
|
|
15
|
+
* actions exposed in `handlers/`. The plugin caches the flag list
|
|
16
|
+
* in-process and invalidates on `setFlag` mutations.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type FlagValue = boolean | string | Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
export interface BooleanFlag {
|
|
22
|
+
type: "boolean";
|
|
23
|
+
default: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Rollout config. If absent, the default value is returned for
|
|
26
|
+
* everyone. If `percent` is set, `default` is the value for
|
|
27
|
+
* users in the rollout; non-rollout users get `!default`.
|
|
28
|
+
*/
|
|
29
|
+
rollout?: RolloutConfig;
|
|
30
|
+
/** Optional targeting rules — first match wins. */
|
|
31
|
+
targeting?: TargetingRule<boolean>[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MultivariateFlag {
|
|
35
|
+
type: "multivariate";
|
|
36
|
+
/** Variant catalog. Weights must sum to 100. */
|
|
37
|
+
variants: Array<{ name: string; weight: number; payload?: unknown }>;
|
|
38
|
+
/** Default variant name when no targeting rule matches. */
|
|
39
|
+
default: string;
|
|
40
|
+
/** Optional targeting rules — first match wins. */
|
|
41
|
+
targeting?: TargetingRule<string>[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FlagDefinition = BooleanFlag | MultivariateFlag;
|
|
45
|
+
|
|
46
|
+
export interface RolloutConfig {
|
|
47
|
+
/** 0..100 — percentage of distinct-ids that get the rollout. */
|
|
48
|
+
percent: number;
|
|
49
|
+
/**
|
|
50
|
+
* Hashing key used to bucket users. Default `userId`. Set to
|
|
51
|
+
* `orgId` for per-tenant rollouts (every member of the tenant
|
|
52
|
+
* sees the same value), or a custom key for cohort experiments.
|
|
53
|
+
*/
|
|
54
|
+
key?: "userId" | "orgId" | "deviceId" | string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TargetingRule<TValue> {
|
|
58
|
+
value: TValue;
|
|
59
|
+
when: TargetingPredicate[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Composable predicate. Multiple predicates AND together. Each
|
|
64
|
+
* compares a `property` (looked up on the eval context) to a
|
|
65
|
+
* literal `value` via an operator.
|
|
66
|
+
*/
|
|
67
|
+
export interface TargetingPredicate {
|
|
68
|
+
property: string;
|
|
69
|
+
op:
|
|
70
|
+
| "eq"
|
|
71
|
+
| "neq"
|
|
72
|
+
| "in"
|
|
73
|
+
| "not_in"
|
|
74
|
+
| "gt"
|
|
75
|
+
| "gte"
|
|
76
|
+
| "lt"
|
|
77
|
+
| "lte"
|
|
78
|
+
| "contains"
|
|
79
|
+
| "starts_with"
|
|
80
|
+
| "ends_with"
|
|
81
|
+
| "regex";
|
|
82
|
+
value: string | number | boolean | Array<string | number>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface EvalContext {
|
|
86
|
+
/** Stable user identifier — drives default bucketing. */
|
|
87
|
+
userId?: string;
|
|
88
|
+
orgId?: string;
|
|
89
|
+
deviceId?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Free-form properties consulted by targeting rules. Apps
|
|
92
|
+
* stuff whatever signal they want here (`country`, `plan`,
|
|
93
|
+
* `betaTester`, `signupDaysAgo`, etc.).
|
|
94
|
+
*/
|
|
95
|
+
properties?: Record<string, unknown>;
|
|
96
|
+
/**
|
|
97
|
+
* Server-side `now` injectable for tests. Defaults to
|
|
98
|
+
* `Date.now()`.
|
|
99
|
+
*/
|
|
100
|
+
now?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FlagPluginConfig {
|
|
104
|
+
/**
|
|
105
|
+
* Inline flag catalog. Apps that want their flags in code (e.g.
|
|
106
|
+
* to keep them in version control) declare them here. Apps that
|
|
107
|
+
* want runtime-editable flags omit this and use the
|
|
108
|
+
* `FeatureFlag` entity instead.
|
|
109
|
+
*/
|
|
110
|
+
flags?: Record<string, FlagDefinition>;
|
|
111
|
+
/**
|
|
112
|
+
* Database-backed flags. When true, the manifest fragment adds
|
|
113
|
+
* a `FeatureFlag` entity + a `setFlag`/`deleteFlag` admin
|
|
114
|
+
* mutation pair. The runtime merges inline + database flags
|
|
115
|
+
* with database winning.
|
|
116
|
+
*/
|
|
117
|
+
editable?: boolean;
|
|
118
|
+
/** Entity name override. */
|
|
119
|
+
entityName?: string;
|
|
120
|
+
}
|