@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 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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": [
7
+ "src"
8
+ ]
9
+ }