Decisions
Not on npm yet
npm install @evanion/feature does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
resolve and isEnabled
features.resolve(context); // every feature, with its explanation
features.isEnabled('express-pickup', context); // one booleanBoth are pure functions of (config, context, now) and write nothing. context
is optional; now defaults to new Date() at the call.
isEnabled returns false for a key that is not configured. resolve returns
a record whose keys are exactly the configured ones, in dependency order.
features.resolve({ now: novemberFirst, targetingKey: 'cust-0101' });
// {
// 'new-checkout': { key: 'new-checkout', enabled: true,
// reason: 'rule-match', rule: 'after-launch' },
// 'express-pickup': { key: 'express-pickup', enabled: true, reason: 'default-on' },
// }Call resolve once per request and read from the record, rather than calling
isEnabled per flag. isEnabled resolves the whole store to answer one
question, which is cheap for ten flags and is still ten times the work for ten
questions.
A decision
interface Decision<F> {
key: F;
enabled: boolean;
reason: Reason;
rule?: string; // the matching rule, on 'rule-match'
rules?: RuleOutcome[]; // the per-rule breakdown, on 'no-rule-matched'
blockedBy?: F; // the immediate parent, on 'dependency-off'
cause?: Cause<F>; // the first ancestor off for its own reason
}enabled is the decision. Everything else is explanation, and is output only —
nothing in this library reads reason, rule, rules, blockedBy or cause
back to decide anything. The cascade is typed to see an enabled flag and
nothing else, so that is enforced by the type system rather than by convention.
reason | Meaning |
|---|---|
default-on | enabled, no rules |
rule-match | enabled, a rule matched; carries rule |
explicitly-off | enabled === false |
no-rule-matched | enabled, rules present, none passed; carries a per-rule breakdown |
dependency-off | a parent resolved off; carries blockedBy and cause |
Reading a cascade
const features = createFeatures([
{ key: 'a', enabled: true },
{ key: 'b', enabled: true, dependsOn: ['a'] },
{ key: 'c', enabled: true, dependsOn: ['b'] },
]);
features.toggle('a', false);
features.resolve();
// a: { enabled: false, reason: 'explicitly-off' }
// b: { enabled: false, reason: 'dependency-off', blockedBy: 'a',
// cause: { key: 'a', reason: 'explicitly-off' } }
// c: { enabled: false, reason: 'dependency-off', blockedBy: 'b',
// cause: { key: 'a', reason: 'explicitly-off' } }blockedBy is the edge, for a graph view. cause walks to the first ancestor
that is off for a reason of its own, for an operator who wants to know what to
fix. On c those are two different features, which is the point of carrying
both.
cause.rule is named when there is exactly one rule to name: the matching rule,
or the single rule that failed. With several rules and none matching there is no
one rule to blame, and the full breakdown sits on that ancestor’s own decision.
Why a rule did not match
features.resolve({ role: 'customer' });
// { key: 'k', enabled: false, reason: 'no-rule-matched', rules: [
// { rule: '#0', matched: false,
// failed: { field: 'role', op: 'eq', value: 'bookseller' } },
// ] }failed is the first condition that failed, so a UI can name it. When the
rollout is why the rule did not match, rollout carries the numbers instead:
// { rule: 'after-launch', matched: false,
// rollout: { percent: 25, by: 'targetingKey', bucket: 0.9875, member: false } }bucket is absent when the context did not carry the bucketing field. A rule
whose when failed carries no rollout at all — conditions are checked first,
and the rollout is never evaluated.
Rule ids default to #0, #1 and so on. Name them.
toggle
import { } from '@evanion/feature';
const = ([
{ : 'new-checkout', : true },
{ : 'express-pickup', : true, : ['new-checkout'] },
{ : 'demo-night-booking', : true, : ['express-pickup'] },
]);
.('new-checkout', false); // -> { ok: true, key: 'new-checkout', enabled: false, willDisable: ['express-pickup', 'demo-night-booking'] }The only writer. It sets enabled on the stored definition and reports which
dependants go off with it — transitive, in dependency order, and only the ones
that resolve on now and will not after. Turning the new checkout off takes
express pickup with it, and demo night booking behind that.
Both sides of that comparison are evaluated against one context, so
willDisable is not an artefact of the clock moving between the two
evaluations. Pass the context you care about; it defaults the same way resolve
does.
willDisable is empty when enabling, and empty when the feature was already
off. A UI can confirm before applying; a script can ignore it.
features.toggle('nope', false);
// { ok: false, key: 'nope', error: 'unknown-feature' }An unknown key returns that rather than throwing. Narrow on ok.
Dependencies cascade one way only — a dependant never blocks its parent — but
the information that upward blocking would have provided is kept, in
willDisable.
Inspecting the store
features.keys; // every key, in configuration order
features.config; // the stored intent, deeply frozen
features.definition('express-pickup'); // one definition, or undefined
features.dependants('new-checkout'); // ['express-pickup'] — transitive, in dependency orderconfig is a deep clone of what you passed, frozen, so an attempt to edit it
throws rather than silently diverging from what was resolved.