Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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"]
}