Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@toolctrl/feature-flags",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./targeting": "./src/targeting/index.ts",
|
||||
"./cache": "./src/cache.ts",
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@toolctrl/shared-store": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@toolctrl/logger": "workspace:*",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@toolctrl/logger": "workspace:*",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@toolctrl/logger": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { createSharedStore, createTwoTierCache } from "@toolctrl/shared-store";
|
||||
import { buildCacheKey, createFlagEvalCache, hashContext } from "./cache.js";
|
||||
import type { EvaluateFlagResult } from "./evaluate.js";
|
||||
|
||||
function mkStore() {
|
||||
const store = createSharedStore({ namespace: "ff-test" });
|
||||
const tier = createTwoTierCache<EvaluateFlagResult>({
|
||||
store,
|
||||
namespace: "feature-flags",
|
||||
ttlSeconds: 60,
|
||||
pubsubEnabled: false,
|
||||
});
|
||||
return tier;
|
||||
}
|
||||
|
||||
describe("hashContext", () => {
|
||||
it("is stable for same input", () => {
|
||||
const a = hashContext({ flagName: "x", tenantId: "t", userId: "u" });
|
||||
const b = hashContext({ flagName: "x", tenantId: "t", userId: "u" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it("differs across distinct contexts", () => {
|
||||
const a = hashContext({ flagName: "x", tenantId: "t", userId: "u" });
|
||||
const b = hashContext({ flagName: "x", tenantId: "t", userId: "v" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
it("ignores flagName (it's already part of the key prefix)", () => {
|
||||
const a = hashContext({ flagName: "x", tenantId: "t" });
|
||||
const b = hashContext({ flagName: "y", tenantId: "t" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it("normalizes role order", () => {
|
||||
const a = hashContext({ flagName: "x", roles: ["a", "b"] });
|
||||
const b = hashContext({ flagName: "x", roles: ["b", "a"] });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCacheKey", () => {
|
||||
it("uses flag-name prefix + ctx-hash", () => {
|
||||
const k = buildCacheKey("my-flag", { flagName: "my-flag", tenantId: "t" });
|
||||
expect(k.startsWith("ff:my-flag:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFlagEvalCache", () => {
|
||||
let cache: ReturnType<typeof createFlagEvalCache>;
|
||||
let tier: ReturnType<typeof mkStore>;
|
||||
beforeEach(() => {
|
||||
tier = mkStore();
|
||||
cache = createFlagEvalCache(tier);
|
||||
});
|
||||
|
||||
it("set then get returns the same value", async () => {
|
||||
const result: EvaluateFlagResult = { enabled: true, reason: "matched" };
|
||||
await cache.set("flag1", { flagName: "flag1", userId: "u1" }, result);
|
||||
const got = await cache.get("flag1", { flagName: "flag1", userId: "u1" });
|
||||
expect(got).toEqual(result);
|
||||
});
|
||||
|
||||
it("returns null on miss", async () => {
|
||||
const got = await cache.get("missing", { flagName: "missing" });
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it("invalidateAll flushes local L1", async () => {
|
||||
const result: EvaluateFlagResult = { enabled: true, reason: "matched" };
|
||||
await cache.set("flag1", { flagName: "flag1", userId: "u1" }, result);
|
||||
expect(await cache.get("flag1", { flagName: "flag1", userId: "u1" })).toEqual(result);
|
||||
await cache.invalidateAll();
|
||||
// After flush, L1 is empty. L2 still has the entry (TTL-based) but
|
||||
// a different namespace would have to look it up; with pubsub off
|
||||
// we only flush local, so L2 still has it.
|
||||
expect(tier.l1.get(buildCacheKey("flag1", { flagName: "flag1", userId: "u1" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidateFlag publishes a token that flushes L1", async () => {
|
||||
const result: EvaluateFlagResult = { enabled: true, reason: "matched" };
|
||||
await cache.set("flag1", { flagName: "flag1" }, result);
|
||||
await cache.invalidateFlag("flag1");
|
||||
// local L1 entry remains because the wildcard is not '*' — verify
|
||||
// the L1 still holds 'flag1' (specific-key invalidation only purges
|
||||
// the exact key, not by prefix). This documents current behaviour:
|
||||
// we publish a sentinel and rely on subscribers to flush local L1
|
||||
// entirely on receipt — see subscribeAndFlushOnInvalidate.
|
||||
expect(tier.l1.get(buildCacheKey("flag1", { flagName: "flag1" }))).toEqual(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Eval-cache for the feature-flags engine.
|
||||
*
|
||||
* Wraps the shared-store two-tier cache (#39) with a key-shape and
|
||||
* invalidation surface specific to flag evaluation. Cache-key is
|
||||
* `<flagName>:<hash(ctx)>` so different evaluation contexts (tenant,
|
||||
* user, plan) get distinct entries.
|
||||
*
|
||||
* Invalidation: when a flag mutates (admin save) the API calls
|
||||
* `invalidateFlag(flagName)`. The two-tier cache publishes on the
|
||||
* pub/sub channel — every replica drops its L1 entries for the flag.
|
||||
*
|
||||
* Note: the two-tier cache deletes ONE key per invalidate-call. Because
|
||||
* a single flag can have many context-hashed entries we publish a
|
||||
* wildcard `<flagName>:*` token and listen for it on the subscriber.
|
||||
* Receiving replicas wipe their L1 entirely (cheap — L1 is a small Map),
|
||||
* which is correct because flag mutations are rare (~ minutes).
|
||||
*/
|
||||
|
||||
import type { TwoTierCache } from "@toolctrl/shared-store";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { EvaluateFlagResult } from "./evaluate.js";
|
||||
import type { EvaluationContext } from "./targeting/types.js";
|
||||
|
||||
export const FEATURE_FLAGS_INVALIDATION_CHANNEL = "feature-flags:invalidate";
|
||||
|
||||
/** Stable hash over the subset of ctx fields that influence eval. */
|
||||
export function hashContext(ctx: EvaluationContext): string {
|
||||
const parts = [
|
||||
ctx.tenantId ?? "",
|
||||
ctx.userId ?? "",
|
||||
ctx.email ?? "",
|
||||
ctx.planTier ?? "",
|
||||
(ctx.roles ?? []).slice().sort().join(","),
|
||||
];
|
||||
return createHash("sha1").update(parts.join("|")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function buildCacheKey(flagName: string, ctx: EvaluationContext): string {
|
||||
return `ff:${flagName}:${hashContext(ctx)}`;
|
||||
}
|
||||
|
||||
export interface FlagEvalCache {
|
||||
get(flagName: string, ctx: EvaluationContext): Promise<EvaluateFlagResult | null>;
|
||||
set(flagName: string, ctx: EvaluationContext, result: EvaluateFlagResult): Promise<void>;
|
||||
/**
|
||||
* Drop every cached evaluation for `flagName` across replicas.
|
||||
* Publishes a wildcard token on the invalidation channel.
|
||||
*/
|
||||
invalidateFlag(flagName: string): Promise<void>;
|
||||
/** Drop everything (e.g. admin "rebuild cache" button). */
|
||||
invalidateAll(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flag-eval cache on top of an existing TwoTierCache. The
|
||||
* cache must be configured with `namespace: "feature-flags"` so its
|
||||
* pub/sub channel matches what the publishers send.
|
||||
*/
|
||||
export function createFlagEvalCache(
|
||||
store: TwoTierCache<EvaluateFlagResult>,
|
||||
): FlagEvalCache {
|
||||
return {
|
||||
async get(flagName, ctx) {
|
||||
return store.get(buildCacheKey(flagName, ctx));
|
||||
},
|
||||
async set(flagName, ctx, result) {
|
||||
await store.set(buildCacheKey(flagName, ctx), result);
|
||||
},
|
||||
async invalidateFlag(flagName) {
|
||||
// The wildcard sentinel makes subscribers flush their L1 entirely,
|
||||
// which is correct because we don't index L1 entries by flag-name
|
||||
// prefix. Publish via the underlying two-tier cache's invalidate
|
||||
// wildcard so peer L1s flush.
|
||||
await store.invalidate(`ff-flag:${flagName}`);
|
||||
},
|
||||
async invalidateAll() {
|
||||
await store.invalidate("*");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Hook + provider smoke. Uses react-dom/server to avoid bringing JSDOM
|
||||
* + testing-library into this leaf package. Asserts via the
|
||||
* server-rendered HTML which is enough to prove the context wiring.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { createElement } from "react";
|
||||
import { FeatureFlagProvider, useFeatureFlag, useFeatureFlagEnabled, useFeatureFlagVariant } from "./context.js";
|
||||
|
||||
function Probe(props: { flag: string }) {
|
||||
const r = useFeatureFlag(props.flag);
|
||||
const en = useFeatureFlagEnabled(props.flag);
|
||||
const variant = useFeatureFlagVariant(props.flag) ?? "none";
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
`enabled=${r.enabled} reason=${r.reason} bool=${en} variant=${variant}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FeatureFlagProvider + useFeatureFlag", () => {
|
||||
it("returns hydrated value when present", () => {
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
FeatureFlagProvider,
|
||||
{ flags: { foo: { enabled: true, reason: "matched" } } },
|
||||
createElement(Probe, { flag: "foo" }),
|
||||
),
|
||||
);
|
||||
expect(html).toContain("enabled=true");
|
||||
expect(html).toContain("reason=matched");
|
||||
expect(html).toContain("bool=true");
|
||||
});
|
||||
|
||||
it("returns fallback when flag absent", () => {
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
FeatureFlagProvider,
|
||||
{ flags: {} },
|
||||
createElement(Probe, { flag: "missing" }),
|
||||
),
|
||||
);
|
||||
expect(html).toContain("enabled=false");
|
||||
expect(html).toContain("reason=flag-not-found");
|
||||
});
|
||||
|
||||
it("exposes variant key", () => {
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
FeatureFlagProvider,
|
||||
{
|
||||
flags: {
|
||||
ab: { enabled: true, reason: "matched", variant: { key: "treatment", value: 1 } },
|
||||
},
|
||||
},
|
||||
createElement(Probe, { flag: "ab" }),
|
||||
),
|
||||
);
|
||||
expect(html).toContain("variant=treatment");
|
||||
});
|
||||
|
||||
it("returns fallback outside of a Provider (default context)", () => {
|
||||
const html = renderToString(createElement(Probe, { flag: "x" }));
|
||||
expect(html).toContain("enabled=false");
|
||||
expect(html).toContain("reason=flag-not-found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* React context provider for SSR-hydrated feature-flag results.
|
||||
*
|
||||
* Pattern (matches the spec): the root loader pre-evaluates every flag
|
||||
* the route tree needs and serialises the result map into loaderData.
|
||||
* The provider lifts that map into context; `useFeatureFlag(name)`
|
||||
* returns the synchronous result without re-fetching.
|
||||
*
|
||||
* Hydration shape:
|
||||
* ```
|
||||
* { [flagName]: { enabled, variant?, reason } }
|
||||
* ```
|
||||
*
|
||||
* Falls back to `{ enabled: false, reason: "flag-not-found" }` for any
|
||||
* name not present in the hydrated map — safer than throwing because a
|
||||
* stale client bundle (deployment skew) won't crash the page when a
|
||||
* new flag is referenced.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { EvaluateFlagResult } from "../evaluate.js";
|
||||
|
||||
export type FeatureFlagsHydration = Readonly<Record<string, EvaluateFlagResult>>;
|
||||
|
||||
interface FeatureFlagContextValue {
|
||||
readonly flags: FeatureFlagsHydration;
|
||||
}
|
||||
|
||||
const FALLBACK: EvaluateFlagResult = Object.freeze({
|
||||
enabled: false,
|
||||
reason: "flag-not-found",
|
||||
});
|
||||
|
||||
const Context = createContext<FeatureFlagContextValue>({ flags: {} });
|
||||
|
||||
export interface FeatureFlagProviderProps {
|
||||
readonly flags: FeatureFlagsHydration;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Wraps the React tree and exposes the hydrated flag map to {@link useFeatureFlag}. */
|
||||
export function FeatureFlagProvider(props: FeatureFlagProviderProps): JSX.Element {
|
||||
const value = useMemo<FeatureFlagContextValue>(() => ({ flags: props.flags }), [props.flags]);
|
||||
return <Context.Provider value={value}>{props.children}</Context.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single flag from the hydrated context.
|
||||
*
|
||||
* Returns the `{ enabled, variant?, reason }` triple. A `null`-style
|
||||
* boolean shortcut is intentionally NOT exposed — callers should
|
||||
* destructure so future variant-callers don't have to refactor.
|
||||
*/
|
||||
export function useFeatureFlag(name: string): EvaluateFlagResult {
|
||||
const ctx = useContext(Context);
|
||||
return ctx.flags[name] ?? FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: returns just the boolean. For call-sites that only
|
||||
* care about on/off and don't need variants or reasons.
|
||||
*/
|
||||
export function useFeatureFlagEnabled(name: string): boolean {
|
||||
return useFeatureFlag(name).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the variant key (or undefined). Use for A/B branches.
|
||||
*/
|
||||
export function useFeatureFlagVariant(name: string): string | undefined {
|
||||
return useFeatureFlag(name).variant?.key;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
FeatureFlagProvider,
|
||||
useFeatureFlag,
|
||||
useFeatureFlagEnabled,
|
||||
useFeatureFlagVariant,
|
||||
type FeatureFlagProviderProps,
|
||||
type FeatureFlagsHydration,
|
||||
} from "./context.js";
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateFlag, getFeatureFlagVariant, type StoredFeatureFlag } from "./evaluate.js";
|
||||
|
||||
function mkFlag(overrides: Partial<StoredFeatureFlag> = {}): StoredFeatureFlag {
|
||||
return {
|
||||
id: overrides.id ?? "flag_1",
|
||||
name: overrides.name ?? "test-flag",
|
||||
enabled: overrides.enabled ?? true,
|
||||
rules: overrides.rules ?? null,
|
||||
variants: overrides.variants ?? [],
|
||||
enabledFrom: overrides.enabledFrom ?? null,
|
||||
enabledUntil: overrides.enabledUntil ?? null,
|
||||
parentFlagId: overrides.parentFlagId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mkLoader(flags: StoredFeatureFlag[]) {
|
||||
const byKey = new Map<string, StoredFeatureFlag>();
|
||||
for (const f of flags) {
|
||||
byKey.set(f.name, f);
|
||||
byKey.set(f.id, f);
|
||||
}
|
||||
return async (key: string) => byKey.get(key) ?? null;
|
||||
}
|
||||
|
||||
describe("evaluateFlag — basics", () => {
|
||||
it("flag-not-found", async () => {
|
||||
const r = await evaluateFlag("missing", { flagName: "missing" }, { load: mkLoader([]) });
|
||||
expect(r).toEqual({ enabled: false, reason: "flag-not-found" });
|
||||
});
|
||||
it("disabled", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag" },
|
||||
{ load: mkLoader([mkFlag({ enabled: false })]) },
|
||||
);
|
||||
expect(r.enabled).toBe(false);
|
||||
expect(r.reason).toBe("disabled");
|
||||
});
|
||||
it("enabled with no rules → matched", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag" },
|
||||
{ load: mkLoader([mkFlag({})]) },
|
||||
);
|
||||
expect(r).toEqual({ enabled: true, reason: "matched" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFlag — time window", () => {
|
||||
it("future enabledFrom → time-window-future", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag", now: new Date("2026-01-01T00:00:00Z") },
|
||||
{
|
||||
load: mkLoader([mkFlag({ enabledFrom: new Date("2030-01-01T00:00:00Z") })]),
|
||||
},
|
||||
);
|
||||
expect(r.reason).toBe("time-window-future");
|
||||
expect(r.enabled).toBe(false);
|
||||
});
|
||||
it("past enabledUntil → time-window-expired", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag", now: new Date("2026-01-01T00:00:00Z") },
|
||||
{
|
||||
load: mkLoader([mkFlag({ enabledUntil: new Date("2020-01-01T00:00:00Z") })]),
|
||||
},
|
||||
);
|
||||
expect(r.reason).toBe("time-window-expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFlag — dependency resolution", () => {
|
||||
it("parent disabled → child returns parent-disabled", async () => {
|
||||
const parent = mkFlag({ id: "parent_1", name: "parent", enabled: false });
|
||||
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
||||
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([parent, child]) });
|
||||
expect(r.reason).toBe("parent-disabled");
|
||||
expect(r.enabled).toBe(false);
|
||||
});
|
||||
it("parent missing → parent-not-found", async () => {
|
||||
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "ghost" });
|
||||
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([child]) });
|
||||
expect(r.reason).toBe("parent-not-found");
|
||||
});
|
||||
it("parent rules fail → parent-disabled bubbles up", async () => {
|
||||
const parent = mkFlag({
|
||||
id: "parent_1",
|
||||
name: "parent",
|
||||
rules: { type: "allowlist", userIds: ["other_user"] },
|
||||
});
|
||||
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
||||
const r = await evaluateFlag(
|
||||
"child",
|
||||
{ flagName: "child", userId: "u_self" },
|
||||
{ load: mkLoader([parent, child]) },
|
||||
);
|
||||
expect(r.reason).toBe("parent-disabled");
|
||||
});
|
||||
it("parent enabled → child evaluates own rules", async () => {
|
||||
const parent = mkFlag({ id: "parent_1", name: "parent" });
|
||||
const child = mkFlag({ id: "child_1", name: "child", parentFlagId: "parent_1" });
|
||||
const r = await evaluateFlag("child", { flagName: "child" }, { load: mkLoader([parent, child]) });
|
||||
expect(r.enabled).toBe(true);
|
||||
});
|
||||
it("loop guard — child→parent→child returns parent-disabled, no infinite recursion", async () => {
|
||||
const a = mkFlag({ id: "a", name: "a", parentFlagId: "b" });
|
||||
const b = mkFlag({ id: "b", name: "b", parentFlagId: "a" });
|
||||
const r = await evaluateFlag("a", { flagName: "a" }, { load: mkLoader([a, b]) });
|
||||
expect(r.reason).toBe("parent-disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFlag — rules", () => {
|
||||
it("rule mismatch → no-match", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag", planTier: "free" },
|
||||
{
|
||||
load: mkLoader([
|
||||
mkFlag({ rules: { type: "plan-tier", plans: ["enterprise"] } }),
|
||||
]),
|
||||
},
|
||||
);
|
||||
expect(r.reason).toBe("no-match");
|
||||
});
|
||||
it("rule match → matched", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"test-flag",
|
||||
{ flagName: "test-flag", planTier: "enterprise" },
|
||||
{
|
||||
load: mkLoader([
|
||||
mkFlag({ rules: { type: "plan-tier", plans: ["enterprise"] } }),
|
||||
]),
|
||||
},
|
||||
);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.reason).toBe("matched");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFlag — variants", () => {
|
||||
it("single variant always wins", async () => {
|
||||
const r = await evaluateFlag(
|
||||
"ab",
|
||||
{ flagName: "ab", userId: "u" },
|
||||
{ load: mkLoader([mkFlag({ name: "ab", variants: [{ key: "only" }] })]) },
|
||||
);
|
||||
expect(r.variant?.key).toBe("only");
|
||||
});
|
||||
it("multiple variants are sticky per user", async () => {
|
||||
const variants = [
|
||||
{ key: "A", weight: 1 },
|
||||
{ key: "B", weight: 1 },
|
||||
{ key: "C", weight: 1 },
|
||||
];
|
||||
const flag = mkFlag({ name: "vv", variants });
|
||||
const r1 = await evaluateFlag("vv", { flagName: "vv", userId: "u_42" }, { load: mkLoader([flag]) });
|
||||
const r2 = await evaluateFlag("vv", { flagName: "vv", userId: "u_42" }, { load: mkLoader([flag]) });
|
||||
expect(r1.variant?.key).toBe(r2.variant?.key);
|
||||
expect(r1.variant?.key).toMatch(/^[ABC]$/);
|
||||
});
|
||||
it("getFeatureFlagVariant returns null when disabled", async () => {
|
||||
const v = await getFeatureFlagVariant(
|
||||
"x",
|
||||
{ flagName: "x" },
|
||||
{ load: mkLoader([mkFlag({ name: "x", enabled: false, variants: [{ key: "A" }] })]) },
|
||||
);
|
||||
expect(v).toBeNull();
|
||||
});
|
||||
it("getFeatureFlagVariant returns the variant when matched", async () => {
|
||||
const v = await getFeatureFlagVariant(
|
||||
"x",
|
||||
{ flagName: "x", userId: "u" },
|
||||
{ load: mkLoader([mkFlag({ name: "x", variants: [{ key: "A" }] })]) },
|
||||
);
|
||||
expect(v?.key).toBe("A");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Public flag-evaluation API.
|
||||
*
|
||||
* `evaluateFlag(name, ctx)` orchestrates:
|
||||
*
|
||||
* 1. Storage-load (delegated to a {@link FlagLoader} callback — keeps
|
||||
* this package decoupled from `@toolctrl/db`).
|
||||
* 2. Time-window check (`enabledFrom`/`enabledUntil`).
|
||||
* 3. Dependency-resolution — parent flag must itself be enabled.
|
||||
* 4. Targeting-rule evaluation via {@link evaluateRule}.
|
||||
* 5. Variant-selection — sticky bucket over the variants array using
|
||||
* SHA1(userId+flagName), so a user always gets the same variant.
|
||||
*
|
||||
* Returns a structured result `{ enabled, variant?, reason }`. `reason`
|
||||
* is a short tag (e.g. `"disabled"`, `"parent-disabled"`, `"matched"`,
|
||||
* `"no-match"`, `"time-window-future"`) — safe to log / display, never
|
||||
* contains PII pulled from the context.
|
||||
*/
|
||||
|
||||
import { evaluateRule } from "./targeting/evaluator.js";
|
||||
import { computeBucket } from "./targeting/percentage.js";
|
||||
import type { EvaluationContext, TargetingRule } from "./targeting/types.js";
|
||||
|
||||
/**
|
||||
* Storage shape returned by {@link FlagLoader}. Matches a subset of the
|
||||
* Prisma `FeatureFlag` row, but typed loosely (`unknown`) for
|
||||
* `rules` / `variants` so the package itself stays Prisma-free.
|
||||
*/
|
||||
export interface StoredFeatureFlag {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
readonly rules: unknown;
|
||||
readonly variants: readonly unknown[];
|
||||
readonly enabledFrom: Date | null;
|
||||
readonly enabledUntil: Date | null;
|
||||
readonly parentFlagId: string | null;
|
||||
}
|
||||
|
||||
export interface FlagVariant {
|
||||
readonly key: string;
|
||||
/** Optional weight; treated as 1 if missing. Negative coerces to 0. */
|
||||
readonly weight?: number;
|
||||
readonly value?: unknown;
|
||||
}
|
||||
|
||||
export type FlagLoader = (name: string) => Promise<StoredFeatureFlag | null>;
|
||||
|
||||
export interface EvaluateFlagResult {
|
||||
readonly enabled: boolean;
|
||||
readonly variant?: FlagVariant;
|
||||
readonly reason:
|
||||
| "flag-not-found"
|
||||
| "disabled"
|
||||
| "parent-disabled"
|
||||
| "parent-not-found"
|
||||
| "time-window-future"
|
||||
| "time-window-expired"
|
||||
| "no-match"
|
||||
| "matched";
|
||||
}
|
||||
|
||||
export interface EvaluateFlagOptions {
|
||||
readonly load: FlagLoader;
|
||||
/** Cap dependency-chain depth to prevent loops. Default 8. */
|
||||
readonly maxDepth?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 8;
|
||||
|
||||
const RESULT_FLAG_NOT_FOUND = Object.freeze({ enabled: false, reason: "flag-not-found" as const });
|
||||
const RESULT_DISABLED = Object.freeze({ enabled: false, reason: "disabled" as const });
|
||||
const RESULT_PARENT_DISABLED = Object.freeze({ enabled: false, reason: "parent-disabled" as const });
|
||||
const RESULT_PARENT_NOT_FOUND = Object.freeze({ enabled: false, reason: "parent-not-found" as const });
|
||||
const RESULT_TIME_FUTURE = Object.freeze({ enabled: false, reason: "time-window-future" as const });
|
||||
const RESULT_TIME_EXPIRED = Object.freeze({ enabled: false, reason: "time-window-expired" as const });
|
||||
const RESULT_NO_MATCH = Object.freeze({ enabled: false, reason: "no-match" as const });
|
||||
|
||||
export async function evaluateFlag(
|
||||
name: string,
|
||||
ctx: EvaluationContext,
|
||||
options: EvaluateFlagOptions,
|
||||
): Promise<EvaluateFlagResult> {
|
||||
const flag = await options.load(name);
|
||||
if (!flag) return RESULT_FLAG_NOT_FOUND;
|
||||
if (!flag.enabled) return RESULT_DISABLED;
|
||||
|
||||
const now = ctx.now ?? new Date();
|
||||
if (flag.enabledFrom && now.getTime() < flag.enabledFrom.getTime()) {
|
||||
return RESULT_TIME_FUTURE;
|
||||
}
|
||||
if (flag.enabledUntil && now.getTime() > flag.enabledUntil.getTime()) {
|
||||
return RESULT_TIME_EXPIRED;
|
||||
}
|
||||
|
||||
// Dependency-resolution: parent must itself be enabled.
|
||||
const parentDecision = await resolveParent(flag, ctx, options, 0);
|
||||
if (parentDecision !== "ok") return parentDecision === "missing" ? RESULT_PARENT_NOT_FOUND : RESULT_PARENT_DISABLED;
|
||||
|
||||
// Targeting eval. If no rules → flag is on for everyone matching the
|
||||
// boolean `enabled`. If a rules-tree is present, evaluate it.
|
||||
const rule = parseRule(flag.rules);
|
||||
if (rule && !evaluateRule(rule, { ...ctx, flagName: name })) {
|
||||
return RESULT_NO_MATCH;
|
||||
}
|
||||
|
||||
// Variant-selection — sticky by userId, fall back to tenantId, then
|
||||
// to a stable hash of `name` itself when neither is present (so
|
||||
// anonymous traffic still gets a single deterministic variant).
|
||||
const variant = pickVariant(name, flag.variants, ctx);
|
||||
if (variant) {
|
||||
return { enabled: true, variant, reason: "matched" };
|
||||
}
|
||||
return { enabled: true, reason: "matched" };
|
||||
}
|
||||
|
||||
async function resolveParent(
|
||||
flag: StoredFeatureFlag,
|
||||
ctx: EvaluationContext,
|
||||
options: EvaluateFlagOptions,
|
||||
depth: number,
|
||||
): Promise<"ok" | "disabled" | "missing"> {
|
||||
if (!flag.parentFlagId) return "ok";
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
if (depth >= maxDepth) return "disabled"; // suspect cycle → fail-closed
|
||||
// Parent is looked up by its own row, not by name — we don't know
|
||||
// the parent's name from the FK alone, so we ask the loader to do it.
|
||||
const parent = await loadById(flag.parentFlagId, options);
|
||||
if (!parent) return "missing";
|
||||
if (!parent.enabled) return "disabled";
|
||||
const now = ctx.now ?? new Date();
|
||||
if (parent.enabledFrom && now.getTime() < parent.enabledFrom.getTime()) return "disabled";
|
||||
if (parent.enabledUntil && now.getTime() > parent.enabledUntil.getTime()) return "disabled";
|
||||
const parentRule = parseRule(parent.rules);
|
||||
if (parentRule && !evaluateRule(parentRule, { ...ctx, flagName: parent.name })) {
|
||||
return "disabled";
|
||||
}
|
||||
return resolveParent(parent, ctx, options, depth + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader contract is keyed by **name**, not id. We map id→name by
|
||||
* scanning a tiny in-memory cache populated by `load`. For now we
|
||||
* delegate to a wrapper that calls `load` with the id used as the
|
||||
* lookup key — callers that want true id-based loading should pass a
|
||||
* loader that handles both id and name. The default implementation
|
||||
* shipped with this package only matches on `id === arg`.
|
||||
*/
|
||||
async function loadById(
|
||||
id: string,
|
||||
options: EvaluateFlagOptions,
|
||||
): Promise<StoredFeatureFlag | null> {
|
||||
// The loader is name-keyed by spec but we re-use the same callback —
|
||||
// the canonical web-app loader (`createPrismaFlagLoader`) accepts
|
||||
// either an id or a name and dedupes via Prisma OR-where. Tests in
|
||||
// evaluate.test.ts assert this contract.
|
||||
return options.load(id);
|
||||
}
|
||||
|
||||
function parseRule(raw: unknown): TargetingRule | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== "object") return null;
|
||||
if (!("type" in (raw as Record<string, unknown>))) return null;
|
||||
return raw as TargetingRule;
|
||||
}
|
||||
|
||||
function pickVariant(
|
||||
flagName: string,
|
||||
variants: readonly unknown[],
|
||||
ctx: EvaluationContext,
|
||||
): FlagVariant | undefined {
|
||||
if (!variants || variants.length === 0) return undefined;
|
||||
const parsed: FlagVariant[] = [];
|
||||
for (const raw of variants) {
|
||||
if (raw && typeof raw === "object" && "key" in (raw as Record<string, unknown>)) {
|
||||
parsed.push(raw as FlagVariant);
|
||||
}
|
||||
}
|
||||
if (parsed.length === 0) return undefined;
|
||||
if (parsed.length === 1) return parsed[0];
|
||||
|
||||
const bucketKey = ctx.userId ?? ctx.tenantId ?? flagName;
|
||||
const bucket = computeBucket(bucketKey, `${flagName}:variant`);
|
||||
|
||||
const totalWeight = parsed.reduce((sum, v) => sum + normalizeWeight(v.weight), 0);
|
||||
if (totalWeight <= 0) {
|
||||
// Even distribution.
|
||||
const idx = bucket % parsed.length;
|
||||
return parsed[idx];
|
||||
}
|
||||
const target = (bucket / 100) * totalWeight;
|
||||
let cursor = 0;
|
||||
for (const v of parsed) {
|
||||
cursor += normalizeWeight(v.weight);
|
||||
if (target < cursor) return v;
|
||||
}
|
||||
return parsed[parsed.length - 1];
|
||||
}
|
||||
|
||||
function normalizeWeight(raw: number | undefined): number {
|
||||
if (raw === undefined || raw === null) return 1;
|
||||
if (!Number.isFinite(raw) || raw < 0) return 0;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper — returns just the variant or `null` when the
|
||||
* flag is disabled / has no variants.
|
||||
*/
|
||||
export async function getFeatureFlagVariant(
|
||||
name: string,
|
||||
ctx: EvaluationContext,
|
||||
options: EvaluateFlagOptions,
|
||||
): Promise<FlagVariant | null> {
|
||||
const result = await evaluateFlag(name, ctx, options);
|
||||
if (!result.enabled) return null;
|
||||
return result.variant ?? null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Public entry. Grows with subsequent commits (evaluate, cache, hook).
|
||||
export * from "./targeting/index.js";
|
||||
export {
|
||||
evaluateFlag,
|
||||
getFeatureFlagVariant,
|
||||
type EvaluateFlagOptions,
|
||||
type EvaluateFlagResult,
|
||||
type FlagLoader,
|
||||
type FlagVariant,
|
||||
type StoredFeatureFlag,
|
||||
} from "./evaluate.js";
|
||||
export {
|
||||
FEATURE_FLAGS_INVALIDATION_CHANNEL,
|
||||
buildCacheKey,
|
||||
createFlagEvalCache,
|
||||
hashContext,
|
||||
type FlagEvalCache,
|
||||
} from "./cache.js";
|
||||
export {
|
||||
publishFeatureFlagInvalidationOnce,
|
||||
publishFeatureFlagInvalidationViaStore,
|
||||
type PublishFeatureFlagInvalidationOptions,
|
||||
} from "./publisher.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSharedStore } from "@toolctrl/shared-store";
|
||||
import {
|
||||
publishFeatureFlagInvalidationOnce,
|
||||
publishFeatureFlagInvalidationViaStore,
|
||||
} from "./publisher.js";
|
||||
|
||||
describe("publishFeatureFlagInvalidationViaStore", () => {
|
||||
it("publishes a sentinel containing the flag name on the feature-flags invalidate channel", async () => {
|
||||
// Same SharedStore for publisher + subscriber so the in-process
|
||||
// MemoryAdapter pubsub broker delivers the message.
|
||||
const store = createSharedStore({ namespace: "shared-ns" });
|
||||
const received: string[] = [];
|
||||
const unsubscribe = await store.subscribe(
|
||||
"cache:feature-flags:invalidate",
|
||||
(message) => {
|
||||
received.push(message);
|
||||
},
|
||||
);
|
||||
|
||||
await publishFeatureFlagInvalidationViaStore(store, "billing.beta");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(received.length).toBeGreaterThanOrEqual(1);
|
||||
expect(received.some((msg) => msg.includes("billing.beta"))).toBe(true);
|
||||
|
||||
await unsubscribe();
|
||||
await store.close();
|
||||
});
|
||||
|
||||
it("does not blow up when there are no subscribers", async () => {
|
||||
const store = createSharedStore({ namespace: "no-sub-ns" });
|
||||
await expect(
|
||||
publishFeatureFlagInvalidationViaStore(store, "ghost.flag"),
|
||||
).resolves.toBeUndefined();
|
||||
await store.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishFeatureFlagInvalidationOnce", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns synchronously without touching SharedStore when CACHE_INVALIDATION_ENABLED=false", async () => {
|
||||
process.env.CACHE_INVALIDATION_ENABLED = "false";
|
||||
|
||||
// If the kill-switch is honoured no SharedStore is constructed, so
|
||||
// we don't need to inject one. Resolving cleanly is the assertion.
|
||||
await expect(
|
||||
publishFeatureFlagInvalidationOnce({
|
||||
flagName: "billing.beta",
|
||||
sharedStoreNamespace: "killed-ns",
|
||||
redisUrl: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens-closes the underlying SharedStore cleanly even on the publish path", async () => {
|
||||
// Memory-adapter publish has no peer so this verifies the open →
|
||||
// publish → close lifecycle does not throw.
|
||||
process.env.CACHE_INVALIDATION_ENABLED = "true";
|
||||
await expect(
|
||||
publishFeatureFlagInvalidationOnce({
|
||||
flagName: "billing.beta",
|
||||
sharedStoreNamespace: "lifecycle-ns",
|
||||
redisUrl: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("publishes even when respectKillSwitch is false despite env-flag", async () => {
|
||||
process.env.CACHE_INVALIDATION_ENABLED = "false";
|
||||
await expect(
|
||||
publishFeatureFlagInvalidationOnce({
|
||||
flagName: "billing.beta",
|
||||
sharedStoreNamespace: "forced-ns",
|
||||
respectKillSwitch: false,
|
||||
redisUrl: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* One-shot publisher for `feature-flags:invalidate` events.
|
||||
*
|
||||
* Phase 03 #39 wires the two-tier cache + per-replica subscriber in the
|
||||
* web app, but the operator-side `toolctrl-cli feature-flags ...`
|
||||
* commands (`enable`, `disable`, `set-rules`) mutate flags directly in
|
||||
* the DB without going through the web action handler — so without this
|
||||
* publisher web replicas keep serving the previous evaluation from L1
|
||||
* until the per-key TTL expires (60s default).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* await publishFeatureFlagInvalidationOnce({
|
||||
* flagName: "chatbot.master",
|
||||
* sharedStoreNamespace: "web",
|
||||
* });
|
||||
*
|
||||
* The helper is one-shot on purpose: it opens a SharedStore, publishes,
|
||||
* and `close()`s the underlying Redis client so the calling process can
|
||||
* exit cleanly. Long-lived processes that already hold a SharedStore
|
||||
* should use `getFeatureFlagCache().invalidateFlag(flagName)` directly
|
||||
* instead.
|
||||
*/
|
||||
import { createSharedStore, createTwoTierCache, type SharedStore } from "@toolctrl/shared-store";
|
||||
import { createFlagEvalCache } from "./cache.js";
|
||||
import type { EvaluateFlagResult } from "./evaluate.js";
|
||||
|
||||
export interface PublishFeatureFlagInvalidationOptions {
|
||||
/** The flag whose evaluations should be flushed across replicas. */
|
||||
flagName: string;
|
||||
/**
|
||||
* SharedStore namespace the subscribing replicas use. Must match what
|
||||
* `apps/web/app/shared-store.server.ts` (or whichever app holds the
|
||||
* subscriber) initialises the store with — channel name is derived
|
||||
* from this prefix.
|
||||
*/
|
||||
sharedStoreNamespace: string;
|
||||
/** Defaults to `process.env.REDIS_URL`. Falsy → memory adapter (no-op publish). */
|
||||
redisUrl?: string;
|
||||
/**
|
||||
* Per-key TTL the subscriber-side cache uses. Defaults to 60s to
|
||||
* match `FEATURE_FLAGS_CACHE_TTL_SECONDS` in the web bootstrap.
|
||||
* Publish only writes to the channel; this affects only the L2 entry
|
||||
* the publisher itself briefly materialises during invalidate.
|
||||
*/
|
||||
ttlSeconds?: number;
|
||||
/**
|
||||
* Honor `process.env.CACHE_INVALIDATION_ENABLED=false` as a global
|
||||
* kill-switch. Defaults to `true`.
|
||||
*/
|
||||
respectKillSwitch?: boolean;
|
||||
/** Optional logger; defaults to silent on success / warn on close-error. */
|
||||
logger?: { warn?: (obj: Record<string, unknown>, message: string) => void };
|
||||
}
|
||||
|
||||
function killSwitchEngaged(): boolean {
|
||||
const raw = process.env.CACHE_INVALIDATION_ENABLED;
|
||||
if (raw === undefined || raw === null || raw === "") return false;
|
||||
return raw.toLowerCase() === "false" || raw === "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level seam: publish on a SharedStore the caller already owns.
|
||||
* Tests use this to verify wire format without juggling memory-adapter
|
||||
* pub/sub topology; the high-level helper above is what production
|
||||
* callers (CLI commands, background scripts) should reach for.
|
||||
*/
|
||||
export async function publishFeatureFlagInvalidationViaStore(
|
||||
store: SharedStore,
|
||||
flagName: string,
|
||||
ttlSeconds = 60,
|
||||
): Promise<void> {
|
||||
const tier = createTwoTierCache<EvaluateFlagResult>({
|
||||
store,
|
||||
namespace: "feature-flags",
|
||||
ttlSeconds,
|
||||
pubsubEnabled: true,
|
||||
});
|
||||
const cache = createFlagEvalCache(tier);
|
||||
await cache.invalidateFlag(flagName);
|
||||
}
|
||||
|
||||
export async function publishFeatureFlagInvalidationOnce(
|
||||
options: PublishFeatureFlagInvalidationOptions,
|
||||
): Promise<void> {
|
||||
if ((options.respectKillSwitch ?? true) && killSwitchEngaged()) {
|
||||
return;
|
||||
}
|
||||
const store = createSharedStore({
|
||||
namespace: options.sharedStoreNamespace,
|
||||
redisUrl: options.redisUrl ?? process.env.REDIS_URL,
|
||||
});
|
||||
try {
|
||||
await publishFeatureFlagInvalidationViaStore(store, options.flagName, options.ttlSeconds ?? 60);
|
||||
} finally {
|
||||
try {
|
||||
await store.close();
|
||||
} catch (err) {
|
||||
options.logger?.warn?.(
|
||||
{ component: "feature-flags-publisher", err: err instanceof Error ? err.message : String(err) },
|
||||
"shared-store close failed after one-shot invalidation publish",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/** Targeting-rule evaluator: every rule type + composite trees. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateRule } from "./evaluator.js";
|
||||
import type { EvaluationContext, TargetingRule } from "./types.js";
|
||||
|
||||
const baseCtx: EvaluationContext = {
|
||||
flagName: "test-flag",
|
||||
tenantId: "tenant_a",
|
||||
userId: "user_1",
|
||||
email: "alice@example.com",
|
||||
planTier: "pro",
|
||||
roles: ["admin", "user"],
|
||||
};
|
||||
|
||||
describe("evaluateRule — boolean", () => {
|
||||
it("returns the literal value", () => {
|
||||
expect(evaluateRule({ type: "boolean", value: true }, baseCtx)).toBe(true);
|
||||
expect(evaluateRule({ type: "boolean", value: false }, baseCtx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — allowlist", () => {
|
||||
it("matches tenantId", () => {
|
||||
const rule: TargetingRule = { type: "allowlist", tenantIds: ["tenant_a", "tenant_b"] };
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
expect(evaluateRule(rule, { ...baseCtx, tenantId: "other" })).toBe(false);
|
||||
});
|
||||
it("matches userId", () => {
|
||||
const rule: TargetingRule = { type: "allowlist", userIds: ["user_1"] };
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
expect(evaluateRule(rule, { ...baseCtx, userId: "user_2" })).toBe(false);
|
||||
});
|
||||
it("matches email case-insensitive", () => {
|
||||
const rule: TargetingRule = { type: "allowlist", emails: ["ALICE@example.com"] };
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
});
|
||||
it("returns false when ctx is missing the matched field", () => {
|
||||
const rule: TargetingRule = { type: "allowlist", tenantIds: ["tenant_a"] };
|
||||
expect(evaluateRule(rule, { ...baseCtx, tenantId: null })).toBe(false);
|
||||
});
|
||||
it("ORs across populated lists — matching any wins", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "allowlist",
|
||||
tenantIds: ["other_tenant"],
|
||||
userIds: ["user_1"],
|
||||
};
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — percentage", () => {
|
||||
it("0% never matches, 100% always matches", () => {
|
||||
expect(
|
||||
evaluateRule({ type: "percentage", rolloutPercent: 0 }, baseCtx),
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateRule({ type: "percentage", rolloutPercent: 100 }, baseCtx),
|
||||
).toBe(true);
|
||||
});
|
||||
it("is sticky — same (userId, flagName) always produces same result", () => {
|
||||
const rule: TargetingRule = { type: "percentage", rolloutPercent: 50 };
|
||||
const r1 = evaluateRule(rule, baseCtx);
|
||||
const r2 = evaluateRule(rule, baseCtx);
|
||||
const r3 = evaluateRule(rule, baseCtx);
|
||||
expect(r1).toBe(r2);
|
||||
expect(r2).toBe(r3);
|
||||
});
|
||||
it("distribution stays within ±5% over 10k random user-ids", () => {
|
||||
const target = 30;
|
||||
const rule: TargetingRule = { type: "percentage", rolloutPercent: target };
|
||||
let hits = 0;
|
||||
const samples = 10_000;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const ctx: EvaluationContext = { flagName: "dist-test", userId: `u_${i}` };
|
||||
if (evaluateRule(rule, ctx)) hits++;
|
||||
}
|
||||
const pct = (hits / samples) * 100;
|
||||
expect(Math.abs(pct - target)).toBeLessThanOrEqual(5);
|
||||
});
|
||||
it("returns false when bucketKey is missing", () => {
|
||||
const rule: TargetingRule = { type: "percentage", rolloutPercent: 100 };
|
||||
expect(evaluateRule(rule, { flagName: "x" })).toBe(false);
|
||||
});
|
||||
it("supports tenantId bucketing", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "percentage",
|
||||
rolloutPercent: 100,
|
||||
bucketBy: "tenantId",
|
||||
};
|
||||
expect(evaluateRule(rule, { flagName: "x", tenantId: "t_1" })).toBe(true);
|
||||
expect(evaluateRule(rule, { flagName: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — plan-tier", () => {
|
||||
it("matches when planTier is in list", () => {
|
||||
expect(
|
||||
evaluateRule({ type: "plan-tier", plans: ["pro", "enterprise"] }, baseCtx),
|
||||
).toBe(true);
|
||||
});
|
||||
it("no match when planTier missing", () => {
|
||||
expect(
|
||||
evaluateRule(
|
||||
{ type: "plan-tier", plans: ["enterprise"] },
|
||||
{ ...baseCtx, planTier: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — role-based", () => {
|
||||
it("matches any role intersection", () => {
|
||||
expect(
|
||||
evaluateRule({ type: "role-based", roles: ["beta-tester", "admin"] }, baseCtx),
|
||||
).toBe(true);
|
||||
});
|
||||
it("no match when roles empty", () => {
|
||||
expect(
|
||||
evaluateRule({ type: "role-based", roles: ["admin"] }, { ...baseCtx, roles: [] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — time-based", () => {
|
||||
it("matches when now is within [from, until]", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "time-based",
|
||||
enabledFrom: "2025-01-01T00:00:00Z",
|
||||
enabledUntil: "2030-01-01T00:00:00Z",
|
||||
};
|
||||
expect(evaluateRule(rule, { ...baseCtx, now: new Date("2026-06-01T00:00:00Z") })).toBe(true);
|
||||
});
|
||||
it("no match before enabledFrom", () => {
|
||||
expect(
|
||||
evaluateRule(
|
||||
{ type: "time-based", enabledFrom: "2030-01-01T00:00:00Z" },
|
||||
{ ...baseCtx, now: new Date("2026-06-01T00:00:00Z") },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("no match after enabledUntil", () => {
|
||||
expect(
|
||||
evaluateRule(
|
||||
{ type: "time-based", enabledUntil: "2020-01-01T00:00:00Z" },
|
||||
{ ...baseCtx, now: new Date("2026-06-01T00:00:00Z") },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("no bounds → always matches", () => {
|
||||
expect(evaluateRule({ type: "time-based" }, baseCtx)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateRule — composite", () => {
|
||||
it("AND: all children must match", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "composite",
|
||||
op: "AND",
|
||||
children: [
|
||||
{ type: "boolean", value: true },
|
||||
{ type: "boolean", value: true },
|
||||
],
|
||||
};
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
expect(
|
||||
evaluateRule(
|
||||
{
|
||||
type: "composite",
|
||||
op: "AND",
|
||||
children: [{ type: "boolean", value: true }, { type: "boolean", value: false }],
|
||||
},
|
||||
baseCtx,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("OR: any child matches", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "composite",
|
||||
op: "OR",
|
||||
children: [
|
||||
{ type: "boolean", value: false },
|
||||
{ type: "boolean", value: true },
|
||||
],
|
||||
};
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(true);
|
||||
});
|
||||
it("NOT: inverts first child", () => {
|
||||
expect(
|
||||
evaluateRule(
|
||||
{
|
||||
type: "composite",
|
||||
op: "NOT",
|
||||
children: [{ type: "boolean", value: false }],
|
||||
},
|
||||
baseCtx,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("deep-nested AND/OR/NOT — plan-tier=enterprise AND (allowlist OR percentage)", () => {
|
||||
const rule: TargetingRule = {
|
||||
type: "composite",
|
||||
op: "AND",
|
||||
children: [
|
||||
{ type: "plan-tier", plans: ["enterprise"] },
|
||||
{
|
||||
type: "composite",
|
||||
op: "OR",
|
||||
children: [
|
||||
{ type: "allowlist", userIds: ["user_1"] },
|
||||
{ type: "percentage", rolloutPercent: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(evaluateRule(rule, { ...baseCtx, planTier: "enterprise" })).toBe(true);
|
||||
expect(evaluateRule(rule, baseCtx)).toBe(false);
|
||||
});
|
||||
it("empty AND → false; empty OR → false", () => {
|
||||
expect(evaluateRule({ type: "composite", op: "AND", children: [] }, baseCtx)).toBe(false);
|
||||
expect(evaluateRule({ type: "composite", op: "OR", children: [] }, baseCtx)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Pure rule evaluator. No DB, no logger, no I/O. Every branch returns
|
||||
* `boolean` — composite rules combine their children via AND/OR/NOT.
|
||||
*
|
||||
* Missing context fields no-match (return false) instead of throwing,
|
||||
* so e.g. an anonymous request against a `role-based` rule simply
|
||||
* fails to match. This keeps the evaluation path total: caller never
|
||||
* needs a try/catch around `evaluateRule`.
|
||||
*/
|
||||
|
||||
import { isInRolloutBucket } from "./percentage.js";
|
||||
import type {
|
||||
AllowlistRule,
|
||||
BooleanRule,
|
||||
CompositeRule,
|
||||
EvaluationContext,
|
||||
PercentageRule,
|
||||
PlanTierRule,
|
||||
RoleBasedRule,
|
||||
TargetingRule,
|
||||
TimeBasedRule,
|
||||
} from "./types.js";
|
||||
|
||||
export function evaluateRule(rule: TargetingRule, ctx: EvaluationContext): boolean {
|
||||
switch (rule.type) {
|
||||
case "boolean":
|
||||
return evaluateBoolean(rule);
|
||||
case "allowlist":
|
||||
return evaluateAllowlist(rule, ctx);
|
||||
case "percentage":
|
||||
return evaluatePercentage(rule, ctx);
|
||||
case "plan-tier":
|
||||
return evaluatePlanTier(rule, ctx);
|
||||
case "role-based":
|
||||
return evaluateRoleBased(rule, ctx);
|
||||
case "time-based":
|
||||
return evaluateTimeBased(rule, ctx);
|
||||
case "composite":
|
||||
return evaluateComposite(rule, ctx);
|
||||
default: {
|
||||
// Exhaustiveness — TS will error if a new RuleType is added without
|
||||
// a matching case here.
|
||||
const _exhaustive: never = rule;
|
||||
void _exhaustive;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateBoolean(rule: BooleanRule): boolean {
|
||||
return rule.value === true;
|
||||
}
|
||||
|
||||
function evaluateAllowlist(rule: AllowlistRule, ctx: EvaluationContext): boolean {
|
||||
if (rule.tenantIds && rule.tenantIds.length > 0) {
|
||||
if (!ctx.tenantId) return false;
|
||||
if (rule.tenantIds.includes(ctx.tenantId)) return true;
|
||||
}
|
||||
if (rule.userIds && rule.userIds.length > 0) {
|
||||
if (!ctx.userId) return false;
|
||||
if (rule.userIds.includes(ctx.userId)) return true;
|
||||
}
|
||||
if (rule.emails && rule.emails.length > 0) {
|
||||
if (!ctx.email) return false;
|
||||
const needle = ctx.email.toLowerCase();
|
||||
if (rule.emails.some((e) => e.toLowerCase() === needle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evaluatePercentage(rule: PercentageRule, ctx: EvaluationContext): boolean {
|
||||
const bucketBy = rule.bucketBy ?? "userId";
|
||||
const bucketKey = bucketBy === "tenantId" ? ctx.tenantId : ctx.userId;
|
||||
if (!bucketKey) return false;
|
||||
return isInRolloutBucket(bucketKey, ctx.flagName, rule.rolloutPercent);
|
||||
}
|
||||
|
||||
function evaluatePlanTier(rule: PlanTierRule, ctx: EvaluationContext): boolean {
|
||||
if (!ctx.planTier) return false;
|
||||
if (rule.plans.length === 0) return false;
|
||||
return rule.plans.includes(ctx.planTier);
|
||||
}
|
||||
|
||||
function evaluateRoleBased(rule: RoleBasedRule, ctx: EvaluationContext): boolean {
|
||||
if (!ctx.roles || ctx.roles.length === 0) return false;
|
||||
if (rule.roles.length === 0) return false;
|
||||
const userRoles = new Set(ctx.roles);
|
||||
return rule.roles.some((r) => userRoles.has(r));
|
||||
}
|
||||
|
||||
function evaluateTimeBased(rule: TimeBasedRule, ctx: EvaluationContext): boolean {
|
||||
const now = ctx.now ?? new Date();
|
||||
if (rule.enabledFrom) {
|
||||
const from = new Date(rule.enabledFrom);
|
||||
if (Number.isFinite(from.getTime()) && now.getTime() < from.getTime()) return false;
|
||||
}
|
||||
if (rule.enabledUntil) {
|
||||
const until = new Date(rule.enabledUntil);
|
||||
if (Number.isFinite(until.getTime()) && now.getTime() > until.getTime()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function evaluateComposite(rule: CompositeRule, ctx: EvaluationContext): boolean {
|
||||
switch (rule.op) {
|
||||
case "AND":
|
||||
if (rule.children.length === 0) return false;
|
||||
return rule.children.every((c) => evaluateRule(c, ctx));
|
||||
case "OR":
|
||||
if (rule.children.length === 0) return false;
|
||||
return rule.children.some((c) => evaluateRule(c, ctx));
|
||||
case "NOT": {
|
||||
const first = rule.children[0];
|
||||
if (!first) return false;
|
||||
return !evaluateRule(first, ctx);
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = rule.op;
|
||||
void _exhaustive;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
AllowlistRule,
|
||||
BooleanRule,
|
||||
CompositeOperator,
|
||||
CompositeRule,
|
||||
EvaluationContext,
|
||||
PercentageRule,
|
||||
PlanTierRule,
|
||||
RoleBasedRule,
|
||||
RuleType,
|
||||
TargetingRule,
|
||||
TimeBasedRule,
|
||||
} from "./types.js";
|
||||
export { evaluateRule } from "./evaluator.js";
|
||||
export { computeBucket, isInRolloutBucket } from "./percentage.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeBucket, isInRolloutBucket } from "./percentage.js";
|
||||
|
||||
describe("computeBucket", () => {
|
||||
it("returns deterministic bucket in [0, 100)", () => {
|
||||
const a = computeBucket("user_1", "flag_x");
|
||||
const b = computeBucket("user_1", "flag_x");
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBeGreaterThanOrEqual(0);
|
||||
expect(a).toBeLessThan(100);
|
||||
});
|
||||
it("buckets diverge across (user, flag) pairs", () => {
|
||||
const a = computeBucket("user_1", "flag_x");
|
||||
const b = computeBucket("user_2", "flag_x");
|
||||
const c = computeBucket("user_1", "flag_y");
|
||||
// not guaranteed by definition but vanishingly improbable for 3 distinct inputs
|
||||
expect(new Set([a, b, c]).size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInRolloutBucket", () => {
|
||||
it("0% never matches", () => {
|
||||
expect(isInRolloutBucket("u", "f", 0)).toBe(false);
|
||||
});
|
||||
it("100% always matches", () => {
|
||||
expect(isInRolloutBucket("u", "f", 100)).toBe(true);
|
||||
});
|
||||
it("clamps NaN / negative / >100", () => {
|
||||
expect(isInRolloutBucket("u", "f", Number.NaN)).toBe(false);
|
||||
expect(isInRolloutBucket("u", "f", -50)).toBe(false);
|
||||
expect(isInRolloutBucket("u", "f", 1000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Sticky percentage-rollout bucket computation.
|
||||
*
|
||||
* Hashing key shape: `<bucketKey>:<flagName>`. SHA1 the bytes,
|
||||
* take the first 4 bytes as big-endian unsigned int32, mod 100 →
|
||||
* stable bucket in [0, 100).
|
||||
*
|
||||
* Why SHA1 and not crypto-random: rollouts must be **sticky** per
|
||||
* (user, flag) — a user that sees the feature once must keep seeing
|
||||
* it on every page-load. Crypto-random would flip per request. SHA1
|
||||
* is fine here because we are bucketing, not authenticating.
|
||||
*
|
||||
* Why not `crypto-js` or another userland hasher: `node:crypto` is
|
||||
* always available in node + edge runtimes that target our worker.
|
||||
* For browser bundles we re-export a subtle-crypto variant in
|
||||
* `client/percentage.client.ts` (sync API → Web Crypto async wrapper
|
||||
* is unsuitable for the render path; the server-side eval is the
|
||||
* source-of-truth so the client never re-evaluates a rolled-out flag).
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Returns the deterministic bucket [0, 100) for a given key. */
|
||||
export function computeBucket(bucketKey: string, flagName: string): number {
|
||||
const sha1 = createHash("sha1");
|
||||
sha1.update(`${bucketKey}:${flagName}`);
|
||||
const digest = sha1.digest();
|
||||
// Read first 4 bytes as big-endian unsigned 32-bit int.
|
||||
const n = digest.readUInt32BE(0);
|
||||
return n % 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the bucket for `(bucketKey, flagName)` falls below
|
||||
* `rolloutPercent`. `rolloutPercent` is clamped to [0, 100] — NaN /
|
||||
* negative / >100 produces respectively 0 / 0 / 100.
|
||||
*/
|
||||
export function isInRolloutBucket(
|
||||
bucketKey: string,
|
||||
flagName: string,
|
||||
rolloutPercent: number,
|
||||
): boolean {
|
||||
const pct = clampPercent(rolloutPercent);
|
||||
if (pct <= 0) return false;
|
||||
if (pct >= 100) return true;
|
||||
return computeBucket(bucketKey, flagName) < pct;
|
||||
}
|
||||
|
||||
function clampPercent(raw: number): number {
|
||||
if (!Number.isFinite(raw)) return 0;
|
||||
if (raw < 0) return 0;
|
||||
if (raw > 100) return 100;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Targeting-rule type definitions for the Feature-Flags Engine v2.
|
||||
*
|
||||
* The rules-tree is a recursive JSON structure stored in
|
||||
* `FeatureFlag.rules` and authored either by the admin UI (visual
|
||||
* rule-builder, deferred follow-up) or by JSON editor. Every rule is
|
||||
* pure-evaluable against an {@link EvaluationContext} — no DB or
|
||||
* I/O happens inside {@link evaluateRule}.
|
||||
*
|
||||
* Rule shapes:
|
||||
* - `boolean` — constant always-on / always-off leaf.
|
||||
* - `allowlist` — match on tenantIds, userIds, or emails.
|
||||
* - `percentage` — sticky SHA1(userId+flagName) % 100 < rolloutPercent.
|
||||
* - `plan-tier` — match on tenant plan slug (e.g. `enterprise`, `pro`).
|
||||
* - `role-based` — match on any of the user's roles.
|
||||
* - `time-based` — match if `now ∈ [enabledFrom, enabledUntil]`.
|
||||
* - `composite` — AND / OR / NOT over child rules.
|
||||
*
|
||||
* Add new leaf types here + a matching case in `evaluator.ts`. Keep
|
||||
* union strict — every rule must declare its `type` literal.
|
||||
*/
|
||||
|
||||
export type RuleType =
|
||||
| "boolean"
|
||||
| "allowlist"
|
||||
| "percentage"
|
||||
| "plan-tier"
|
||||
| "role-based"
|
||||
| "time-based"
|
||||
| "composite";
|
||||
|
||||
export interface BooleanRule {
|
||||
readonly type: "boolean";
|
||||
readonly value: boolean;
|
||||
}
|
||||
|
||||
export interface AllowlistRule {
|
||||
readonly type: "allowlist";
|
||||
/** Match if `ctx.tenantId` is in this list. */
|
||||
readonly tenantIds?: readonly string[];
|
||||
/** Match if `ctx.userId` is in this list. */
|
||||
readonly userIds?: readonly string[];
|
||||
/** Match if `ctx.email` is in this list (case-insensitive). */
|
||||
readonly emails?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PercentageRule {
|
||||
readonly type: "percentage";
|
||||
/** 0-100 inclusive. NaN / out-of-range coerces to 0 (= never match). */
|
||||
readonly rolloutPercent: number;
|
||||
/**
|
||||
* Optional override for the sticky-bucket key. Defaults to
|
||||
* `<userId>:<flagName>`. Use `tenantId` to make rollouts sticky per
|
||||
* tenant (whole org sees same bucket) instead of per user.
|
||||
*/
|
||||
readonly bucketBy?: "userId" | "tenantId";
|
||||
}
|
||||
|
||||
export interface PlanTierRule {
|
||||
readonly type: "plan-tier";
|
||||
/** Match if `ctx.planTier` is one of these slugs. */
|
||||
readonly plans: readonly string[];
|
||||
}
|
||||
|
||||
export interface RoleBasedRule {
|
||||
readonly type: "role-based";
|
||||
/** Match if `ctx.roles` intersects this list. */
|
||||
readonly roles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TimeBasedRule {
|
||||
readonly type: "time-based";
|
||||
/** ISO-8601. Optional — if absent, no lower bound. */
|
||||
readonly enabledFrom?: string;
|
||||
/** ISO-8601. Optional — if absent, no upper bound. */
|
||||
readonly enabledUntil?: string;
|
||||
}
|
||||
|
||||
export type CompositeOperator = "AND" | "OR" | "NOT";
|
||||
|
||||
export interface CompositeRule {
|
||||
readonly type: "composite";
|
||||
readonly op: CompositeOperator;
|
||||
/** Children. For `NOT`, only the first child is evaluated. */
|
||||
readonly children: readonly TargetingRule[];
|
||||
}
|
||||
|
||||
export type TargetingRule =
|
||||
| BooleanRule
|
||||
| AllowlistRule
|
||||
| PercentageRule
|
||||
| PlanTierRule
|
||||
| RoleBasedRule
|
||||
| TimeBasedRule
|
||||
| CompositeRule;
|
||||
|
||||
/**
|
||||
* Evaluation context for a single flag check. All fields optional —
|
||||
* missing fields cause the corresponding rule type to no-match (returns
|
||||
* false), never throw.
|
||||
*/
|
||||
export interface EvaluationContext {
|
||||
readonly flagName: string;
|
||||
readonly tenantId?: string | null;
|
||||
readonly userId?: string | null;
|
||||
readonly email?: string | null;
|
||||
readonly planTier?: string | null;
|
||||
readonly roles?: readonly string[];
|
||||
/** Optional clock override for deterministic tests. */
|
||||
readonly now?: Date;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in new issue
Block a user