sdxc

[ Operations ]

@sdxc/flags-engine

Flag evaluation engine: typed targeting rules, percentage splits and pluggable stores

npm add @sdxc/flags-engine remix
pnpm add @sdxc/flags-engine remix
yarn add @sdxc/flags-engine remix
bun add @sdxc/flags-engine remix
Used by
uptime, auth-saas, reader

Flag evaluation engine: typed targeting rules, percentage splits and pluggable stores.

A flag is a set of named variants plus an ordered list of rules choosing between them. This package parses a definition set into a snapshot and resolves flags against it with a pure, synchronous function, so the same call answers a provider, an HTTP endpoint and an admin preview. Definitions arrive through a one-method FlagStore, so where they live is yours to choose.

Installation

npm add @sdxc/flags-engine @sdxc/flags

@sdxc/flags is what an application evaluates through, and it supplies the evaluation context, resolution details, reasons and error codes this package answers in. Loads answer a Result from @sdxc/result. The ./conformance entry point needs vitest.

Usage

Resolve a flag from a set in memory

An engine reads its store once, keeps the parsed snapshot and resolves every later flag against it.

import { createEngine } from "@sdxc/flags-engine";
import { InMemoryFlagStore } from "@sdxc/flags-engine/store/memory";

let engine = createEngine({
	store: new InMemoryFlagStore({
		flags: {
			"new-checkout": { variants: { on: true, off: false }, defaultVariant: "off" },
		},
	}),
});

await engine.load();

engine.evaluate("new-checkout", false);
// { value: false, reason: "STATIC", variant: "off" }

Target subjects with rules and segments

Rules run in order and the first whose condition holds decides the variant. A segment is a condition declared once and referenced by name from any flag.

import { createEngine } from "@sdxc/flags-engine";
import { InMemoryFlagStore } from "@sdxc/flags-engine/store/memory";

let engine = createEngine({
	store: new InMemoryFlagStore({
		segments: {
			internal: { op: "endsWith", field: "email", value: "@example.com" },
		},
		flags: {
			"new-checkout": {
				variants: { on: true, off: false },
				defaultVariant: "off",
				targeting: [
					{ when: { op: "segment", name: "internal" }, serve: "on" },
					{
						when: {
							op: "all",
							of: [
								{ op: "eq", field: "plan.tier", value: "pro" },
								{ op: "semver", field: "appVersion", compare: ">=", value: "2.0.0" },
							],
						},
						serve: "on",
					},
				],
			},
		},
	}),
});

await engine.load();

engine.evaluate("new-checkout", false, {
	targetingKey: "user-1",
	plan: { tier: "pro" },
	appVersion: "2.3.0",
});
// { value: true, reason: "TARGETING_MATCH", variant: "on" }

engine.evaluate("new-checkout", false, { targetingKey: "user-2", plan: { tier: "free" } });
// { value: false, reason: "DEFAULT", variant: "off" }

Roll out to a percentage

A rule serving weights buckets its subjects among the arms. The subject is hashed with the flag key, so the same subject lands in the same arm on every request and in every isolate.

let engine = createEngine({
	store: new InMemoryFlagStore({
		flags: {
			"new-checkout": {
				variants: { on: true, off: false },
				defaultVariant: "off",
				targeting: [{ when: { op: "always" }, serve: { weights: { on: 10, off: 90 } } }],
			},
		},
	}),
});

await engine.load();

engine.evaluate("new-checkout", false, { targetingKey: "user-1" });
// { value: true or false, reason: "SPLIT", variant: "on" or "off" }, stable for "user-1"

Serve flags through @sdxc/flags

EngineProvider adapts an engine to the Provider an @sdxc/flags client evaluates through. In a Cloudflare Worker, WorkerKVFlagStore reads the set from a KV namespace.

import { env } from "cloudflare:workers";
import { createFlags } from "@sdxc/flags/client";
import { createEngine } from "@sdxc/flags-engine";
import { EngineProvider } from "@sdxc/flags-engine/provider";
import { WorkerKVFlagStore } from "@sdxc/flags-engine/store/worker-kv";

export let flags = createFlags({
	provider: () =>
		new EngineProvider(
			createEngine({ store: new WorkerKVFlagStore(env.FLAGS), maxAge: "5 minutes" }),
		),
});

await flags.getClient().boolean("new-checkout", false, { targetingKey: "user-1" });

API

Entry pointContains
@sdxc/flags-engineDefinition types, schemas, flagConditions, evaluation, engine
@sdxc/flags-engine/storeFlagStore, StoredFlagSet, FlagStoreError
@sdxc/flags-engine/store/memoryInMemoryFlagStore
@sdxc/flags-engine/store/worker-kvWorkerKVFlagStore
@sdxc/flags-engine/conformanceconformance, the Vitest suite every store passes
@sdxc/flags-engine/providerEngineProvider

createEngine(options: EngineOptions): Engine

Builds an engine over options.store. options.maxAge (a duration such as "5 minutes") is how long a loaded snapshot counts as current; without it a snapshot stays current until the next load.

Engine

  • load(): MaybePromise<Result<FlagSnapshot, FlagStoreError>> reads the store, parses the set and keeps the snapshot. It is synchronous when the store is, and await-able either way.

  • evaluate(key, defaultValue, context?) resolves one flag against the held snapshot, answering the default with PROVIDER_NOT_READY until a load succeeds.

  • evaluateAll(context?) resolves every held flag, and answers {} before the first load.

  • snapshot is the held FlagSnapshot, or undefined before a load succeeds.

  • failures lists the definitions the held set carried and the engine refused.

  • stale is true before the first load and once the snapshot has aged past maxAge.

The caller decides when to reload: on a request, inside waitUntil or from a cron trigger.

evaluate(snapshot, key, defaultValue, context?): ResolutionDetails<T>

Resolves one flag for one context. defaultValue is both the fallback and the type every variant is checked against, so a string variant asked for as a boolean answers the default with TYPE_MISMATCH.

evaluateAll(snapshot, context?): Record<string, ResolutionDetails<FlagValue>>

Resolves every flag in the snapshot, refused ones included. A flag producing no value of its own reports value: null beside its reason.

parseFlagSet(stored: StoredFlagSet): FlagSnapshot

Parses a stored set: segments resolved, patterns compiled, variant names checked. Each flag parses on its own, so a refused flag lands in snapshot.failures and answers PARSE_ERROR while its siblings resolve normally.

Reasons

OutcomereasonerrorCode
No targeting, and a defaultVariantSTATIC
A rule matched and named a variantTARGETING_MATCH
A rule matched and bucketedSPLIT
No rule matched, or no defaultVariantDEFAULT
state: "disabled"DISABLED
No flag under that keyERRORFLAG_NOT_FOUND
The definition was refused at parse timeERRORPARSE_ERROR
The variant holds another type than the defaultERRORTYPE_MISMATCH
A split found no value under its bucketing fieldERRORTARGETING_KEY_MISSING
The engine has not loaded yetERRORPROVIDER_NOT_READY

Every outcome without a variant answers the caller's default. A flag's metadata travels on every resolution as flagMetadata.

Conditions

A Condition is a JSON object narrowed on op, in the @sdxc/expression language.

ConditionHolds when
{ op: "all" | "any", of: Condition[] }Every member holds, or some member holds
{ op: "not", of: Condition }The member fails
{ op: "eq" | "ne", field, value }The field equals, or differs from, a JSON primitive
{ op: "in" | "notIn", field, values }The list carries, or omits, the field's value
{ op: "lt" | "lte" | "gt" | "gte", field, value }The field is a number in that relation
{ op: "startsWith" | "endsWith", field, value }The field is a string with that prefix or suffix
{ op: "contains", field, value }The field is a string carrying that substring
{ op: "matches", field, pattern }The field is a string the pattern matches (v flag)
{ op: "semver", field, compare, value }The field is a version in that relation
{ op: "exists", field }The path resolves to a value
{ op: "segment", name }The named segment's condition holds
{ op: "always" }Every subject

field is a dotted path into the context (plan.tier, roles.0). A missing field makes every operator except exists false, and no operator coerces across types. The comparisons from eq to gte take path in place of value or values to compare two context fields: { op: "eq", field: "country", path: "billing.country" }.

flagConditions

The targeting language: compile(condition, { references: segments }) validates and compiles one condition, answering a Result; evaluate(compiled, context) answers whether it holds; parse(text) and stringify(condition) convert between the JSON form and a text form such as ctx.plan.tier == "pro" and semver(ctx.appVersion, ">=", "2.0.0"). schema validates the JSON form.

Schemas

FLAG_DEFINITION_SCHEMA, TARGETING_RULE_SCHEMA, CONDITION_SCHEMA, SPLIT_SCHEMA and SEGMENT_SET_SCHEMA are the parsers the engine reads stored JSON with, as Standard Schema values. Validate an edit against them to refuse exactly what the engine would.

FlagStoreError

new FlagStoreError("KV refused the get", { code: "unavailable", location: "flags", cause });

The failure every store read reports. code is "unavailable" when the storage could not be reached or refused, and "invalid_value" when it held something other than a JSON set; location names where the store looked. An empty store reads as a success holding { flags: {} }.

new InMemoryFlagStore(set?: StoredFlagSet)

Holds one set in memory, empty by default. read() answers a copy synchronously and always succeeds; write(set) replaces the whole set.

new WorkerKVFlagStore(kv: KVNamespace, options?: { key?: string })

Keeps the whole set as one JSON value under options.key (default "flags", readable as store.key) in a Cloudflare KV namespace. A key holding nothing reads as an empty set. write(set) replaces the value and answers a Result<void, FlagStoreError>.

conformance(options: ConformanceOptions): void

Registers Vitest tests that check a FlagStore behaves as the engine expects.

  • name: labels the suite.

  • create: builds an empty store; called per test, so give each one its own location.

  • seed(store, set): puts a set where the store reads it from.

  • write(store, set): optional; writes through the store and adds the round-trip tests.

  • writeText(store, text): optional; puts raw text in storage and adds the tests for values the store did not write.

new EngineProvider(engine: Engine)

An @sdxc/flags Provider over an engine, named "flags-engine". Its resolvers answer whatever engine.evaluate answers.

  • initialize() loads and emits PROVIDER_READY; when the store fails it emits PROVIDER_ERROR and throws a ProviderError (GENERAL for unavailable, PARSE_ERROR for invalid_value) with the FlagStoreError as cause.

  • refresh() loads again, emits PROVIDER_CONFIGURATION_CHANGED with every key either snapshot answered for, and answers the load's Result without throwing. A failed refresh over a stale snapshot emits PROVIDER_STALE once; the next success emits PROVIDER_READY.

Types

  • FlagSet is { flags: Record<string, FlagDefinition>; segments?: SegmentSet }, and SegmentSet is Record<string, Condition>; a segment may reference another segment.

  • FlagDefinition has variants (at least one), defaultVariant?, state?: FlagState ("enabled" or "disabled"), targeting?: TargetingRule[] and metadata?. Without a defaultVariant, a flag serves the caller's default to everyone no rule matches.

  • TargetingRule is { when: Condition; serve: string | Split }.

  • Split is { weights; by?; seed? }: whole, non-negative weights taken against their sum, bucketing on by (default targetingKey). Two flags sharing a seed bucket the same subjects together.

  • SemVerComparison is "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "^"; ~ matches major and minor, ^ matches major.

  • FlagSnapshot holds flags and failures maps keyed by flag key, the resolved segments, the store's version and the createdAt time. CompiledFlag, CompiledRule, CompiledCondition and CompiledSegments are its parts; FlagParseFailure is { key, message }.

  • EngineOptions and Engine are described above.

  • From ./store: FlagStore is { read(): MaybePromise<Result<StoredFlagSet, FlagStoreError>> }; StoredFlagSet is { flags: Record<string, unknown>; segments?; version? }, unvalidated JSON the engine parses. FlagStoreErrorCode and FlagStoreErrorOptions type the error.

  • WorkerKVFlagStoreOptions from ./store/worker-kv and ConformanceOptions from ./conformance type their options.

Pattern: Reloading On A Schedule You Own

The engine reports stale; reloading behind the response keeps every request on the snapshot in hand.

import { env } from "cloudflare:workers";
import { createEngine } from "@sdxc/flags-engine";
import { WorkerKVFlagStore } from "@sdxc/flags-engine/store/worker-kv";

let engine = createEngine({ store: new WorkerKVFlagStore(env.FLAGS), maxAge: "5 minutes" });

export default {
	async fetch(request: Request, _env: Env, ctx: ExecutionContext) {
		if (engine.stale) ctx.waitUntil(Promise.resolve(engine.load()));

		let userId = request.headers.get("X-User-Id") ?? "anonymous";
		let checkout = engine.evaluate("new-checkout", false, { targetingKey: userId });

		return new Response(checkout.value ? "new checkout" : "old checkout");
	},

	async scheduled() {
		await engine.load();
	},
};

Until the first load finishes, evaluations answer the caller's default with PROVIDER_NOT_READY; await engine.load() first where that matters.

Pattern: Logging Refused Definitions

A refused definition costs only its own flag. Log the refusals once after each load instead of on every evaluation.

import { createEngine } from "@sdxc/flags-engine";
import { InMemoryFlagStore } from "@sdxc/flags-engine/store/memory";
import { isFailure } from "@sdxc/result";

let engine = createEngine({
	store: new InMemoryFlagStore({
		flags: {
			beta: { variants: { on: true, off: false }, defaultVariant: "off" },
			broken: { variants: { on: true }, defaultVariant: "missing" },
		},
	}),
});

let loaded = await engine.load();
if (isFailure(loaded)) throw loaded.error;

for (let failure of engine.failures) {
	console.warn(`Flag ${failure.key} refused: ${failure.message}`);
}

engine.evaluate("broken", false); // { value: false, reason: "ERROR", errorCode: "PARSE_ERROR", ... }
engine.evaluate("beta", false); // { value: false, reason: "STATIC", variant: "off" }

Pattern: Multivariate Flags And A Kill Switch

Variants hold any JSON value. state: "disabled" serves the caller's default while the rules stay written down, and metadata reaches every resolution for analytics.

import { evaluate, parseFlagSet } from "@sdxc/flags-engine";

let snapshot = parseFlagSet({
	flags: {
		"checkout-layout": {
			variants: {
				classic: { columns: 1, upsell: false },
				compact: { columns: 2, upsell: true },
			},
			defaultVariant: "classic",
			targeting: [{ when: { op: "in", field: "country", values: ["US", "CA"] }, serve: "compact" }],
			metadata: { owner: "payments" },
		},
		"holiday-banner": {
			state: "disabled",
			variants: { on: true, off: false },
			targeting: [{ when: { op: "always" }, serve: "on" }],
		},
	},
});

let fallback = { columns: 1, upsell: false };

evaluate(snapshot, "checkout-layout", fallback, { country: "US" });
// { value: { columns: 2, upsell: true }, reason: "TARGETING_MATCH", variant: "compact", flagMetadata: { owner: "payments" } }

evaluate(snapshot, "checkout-layout", "classic"); // reason: "ERROR", errorCode: "TYPE_MISMATCH"
evaluate(snapshot, "holiday-banner", false); // { value: false, reason: "DISABLED" }

Pattern: Publishing Edits From An Admin Endpoint

Validate the edit with the engine's own schema, preview it against a sample context, then write the whole set and refresh the provider so the new definitions apply at once.

import type { EvaluationContext } from "@sdxc/flags";
import type { StoredFlagSet } from "@sdxc/flags-engine/store";

import { env } from "cloudflare:workers";
import {
	createEngine,
	evaluateAll,
	FLAG_DEFINITION_SCHEMA,
	parseFlagSet,
} from "@sdxc/flags-engine";
import { EngineProvider } from "@sdxc/flags-engine/provider";
import { WorkerKVFlagStore } from "@sdxc/flags-engine/store/worker-kv";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";

let store = new WorkerKVFlagStore(env.FLAGS);
let provider = new EngineProvider(createEngine({ store }));

function preview(set: StoredFlagSet, context: EvaluationContext) {
	return evaluateAll(parseFlagSet(set), context);
}

async function publish(key: string, draft: unknown) {
	let checked = s.parseSafe(FLAG_DEFINITION_SCHEMA, draft);
	if (!checked.success) return { issues: checked.issues };

	let current = await store.read();
	if (isFailure(current)) return { error: current.error.message };

	let next = { ...current.data, flags: { ...current.data.flags, [key]: checked.value } };
	let sample = preview(next, { targetingKey: "user-1", plan: { tier: "pro" } })[key];

	let written = await store.write(next);
	if (isFailure(written)) return { error: written.error.message };

	await provider.refresh();
	return { sample };
}

A second WorkerKVFlagStore(env.FLAGS, { key: "flags:staging" }) holds a staging set in the same namespace.

Pattern: Editing Conditions As Text

flagConditions shows a stored condition as text, reads edited text back into JSON with a line and column on failure, and compiles it against the set's segments to catch an unknown segment, a segment cycle or a pattern that does not compile.

import type { Condition } from "@sdxc/flags-engine";

import { flagConditions } from "@sdxc/flags-engine";
import { isFailure } from "@sdxc/result";

let stored: Condition = {
	op: "any",
	of: [
		{ op: "segment", name: "internal" },
		{ op: "semver", field: "appVersion", compare: ">=", value: "2.0.0" },
	],
};

flagConditions.stringify(stored);
// 'segment("internal") or semver(ctx.appVersion, ">=", "2.0.0")'

function readCondition(text: string, segments: Record<string, unknown>) {
	let parsed = flagConditions.parse(text);
	if (isFailure(parsed)) {
		let { message, line, column } = parsed.error;
		return { error: message, line, column };
	}

	let compiled = flagConditions.compile(parsed.data, { references: segments });
	if (isFailure(compiled)) return { error: compiled.error.message, at: compiled.error.path };

	return {
		when: parsed.data,
		holds: flagConditions.evaluate(compiled.data, { appVersion: "2.1.0" }),
	};
}

Every context path in the text form starts with ctx..

Pattern: A Store Of Your Own

A store is one read method answering the whole set, so a store over your own database is a small class. Scoping lives in the store: one built for a tenant reads that tenant's flags.

import type { FlagStore, StoredFlagSet } from "@sdxc/flags-engine/store";
import type { Result } from "@sdxc/result";

import { FlagStoreError } from "@sdxc/flags-engine/store";
import { failure, success } from "@sdxc/result";

import { db } from "./db.js";

export class TenantFlagStore implements FlagStore {
	constructor(readonly tenantId: string) {}

	async read(): Promise<Result<StoredFlagSet, FlagStoreError>> {
		try {
			let row = await db.flagSets.findByTenant(this.tenantId);
			return success(row?.set ?? { flags: {} });
		} catch (cause) {
			return failure(
				new FlagStoreError(`Could not read flags for ${this.tenantId}`, {
					code: "unavailable",
					location: this.tenantId,
					cause,
				}),
			);
		}
	}
}

Then run the conformance suite against it, giving each test a tenant of its own:

import { conformance } from "@sdxc/flags-engine/conformance";
import { generateUUID } from "@sdxc/uuid/v4";

import { db } from "./db.js";
import { TenantFlagStore } from "./tenant-flag-store.js";

conformance({
	name: "TenantFlagStore",
	create: () => new TenantFlagStore(generateUUID()),
	seed: (store, set) => db.flagSets.upsert(store.tenantId, set),
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.