[ Operations ]
@sdxc/flags-engine
Flag evaluation engine: typed targeting rules, percentage splits and pluggable stores
- Used by
- uptime, auth-saas, reader
- Source
- packages/flags-engine
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 point | Contains |
|---|---|
@sdxc/flags-engine | Definition types, schemas, flagConditions, evaluation, engine |
@sdxc/flags-engine/store | FlagStore, StoredFlagSet, FlagStoreError |
@sdxc/flags-engine/store/memory | InMemoryFlagStore |
@sdxc/flags-engine/store/worker-kv | WorkerKVFlagStore |
@sdxc/flags-engine/conformance | conformance, the Vitest suite every store passes |
@sdxc/flags-engine/provider | EngineProvider |
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, andawait-able either way.evaluate(key, defaultValue, context?)resolves one flag against the held snapshot, answering the default withPROVIDER_NOT_READYuntil a load succeeds.evaluateAll(context?)resolves every held flag, and answers{}before the first load.snapshotis the heldFlagSnapshot, orundefinedbefore a load succeeds.failureslists the definitions the held set carried and the engine refused.staleistruebefore the first load and once the snapshot has aged pastmaxAge.
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
| Outcome | reason | errorCode |
|---|---|---|
No targeting, and a defaultVariant | STATIC | |
| A rule matched and named a variant | TARGETING_MATCH | |
| A rule matched and bucketed | SPLIT | |
No rule matched, or no defaultVariant | DEFAULT | |
state: "disabled" | DISABLED | |
| No flag under that key | ERROR | FLAG_NOT_FOUND |
| The definition was refused at parse time | ERROR | PARSE_ERROR |
| The variant holds another type than the default | ERROR | TYPE_MISMATCH |
| A split found no value under its bucketing field | ERROR | TARGETING_KEY_MISSING |
| The engine has not loaded yet | ERROR | PROVIDER_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.
| Condition | Holds 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 emitsPROVIDER_READY; when the store fails it emitsPROVIDER_ERRORand throws aProviderError(GENERALforunavailable,PARSE_ERRORforinvalid_value) with theFlagStoreErrorascause.refresh()loads again, emitsPROVIDER_CONFIGURATION_CHANGEDwith every key either snapshot answered for, and answers the load'sResultwithout throwing. A failed refresh over a stale snapshot emitsPROVIDER_STALEonce; the next success emitsPROVIDER_READY.
Types
FlagSetis{ flags: Record<string, FlagDefinition>; segments?: SegmentSet }, andSegmentSetisRecord<string, Condition>; a segment may reference another segment.FlagDefinitionhasvariants(at least one),defaultVariant?,state?: FlagState("enabled"or"disabled"),targeting?: TargetingRule[]andmetadata?. Without adefaultVariant, a flag serves the caller's default to everyone no rule matches.TargetingRuleis{ when: Condition; serve: string | Split }.Splitis{ weights; by?; seed? }: whole, non-negative weights taken against their sum, bucketing onby(defaulttargetingKey). Two flags sharing aseedbucket the same subjects together.SemVerComparisonis"=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "^";~matches major and minor,^matches major.FlagSnapshotholdsflagsandfailuresmaps keyed by flag key, the resolvedsegments, the store'sversionand thecreatedAttime.CompiledFlag,CompiledRule,CompiledConditionandCompiledSegmentsare its parts;FlagParseFailureis{ key, message }.EngineOptionsandEngineare described above.From
./store:FlagStoreis{ read(): MaybePromise<Result<StoredFlagSet, FlagStoreError>> };StoredFlagSetis{ flags: Record<string, unknown>; segments?; version? }, unvalidated JSON the engine parses.FlagStoreErrorCodeandFlagStoreErrorOptionstype the error.WorkerKVFlagStoreOptionsfrom./store/worker-kvandConformanceOptionsfrom./conformancetype 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),
});