@sdxc/flags-engine 0.0.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +638 -0
- package/dist/definition.d.ts +114 -0
- package/dist/definition.js +8 -0
- package/dist/engine.d.ts +80 -0
- package/dist/engine.js +76 -0
- package/dist/evaluate.d.ts +35 -0
- package/dist/evaluate.js +153 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/lib/condition.d.ts +20 -0
- package/dist/lib/condition.js +76 -0
- package/dist/lib/hash.d.ts +18 -0
- package/dist/lib/hash.js +74 -0
- package/dist/lib/path.d.ts +24 -0
- package/dist/lib/path.js +49 -0
- package/dist/lib/split.d.ts +25 -0
- package/dist/lib/split.js +64 -0
- package/dist/parse.d.ts +23 -0
- package/dist/parse.js +200 -0
- package/dist/provider/index.d.ts +54 -0
- package/dist/provider/index.js +117 -0
- package/dist/schema.d.ts +31 -0
- package/dist/schema.js +129 -0
- package/dist/snapshot.d.ts +106 -0
- package/dist/snapshot.js +8 -0
- package/dist/store/index.d.ts +68 -0
- package/dist/store/index.js +33 -0
- package/dist/store/memory.d.ts +36 -0
- package/dist/store/memory.js +41 -0
- package/dist/store/worker-kv.d.ts +53 -0
- package/dist/store/worker-kv.js +123 -0
- package/dist/testing/conformance.d.ts +43 -0
- package/dist/testing/conformance.js +117 -0
- package/package.json +40 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a flag is written down as: the values it can take, the ordered rules
|
|
3
|
+
* that choose between them, and the conditions those rules match on. Types
|
|
4
|
+
* only, so an admin UI and the engine describe a definition the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { FlagMetadata, FlagValue } from "@sdxc/flags";
|
|
10
|
+
import type { JSONPrimitive } from "@sdxc/types";
|
|
11
|
+
/**
|
|
12
|
+
* How `semver` compares a version field against its value. `~` holds for a
|
|
13
|
+
* matching major and minor, `^` for a matching major; the other six read as
|
|
14
|
+
* they do in arithmetic.
|
|
15
|
+
*/
|
|
16
|
+
export type SemVerComparison = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "^";
|
|
17
|
+
/**
|
|
18
|
+
* Whether a flag evaluates at all. A disabled flag serves the caller's own
|
|
19
|
+
* default value, which is how a flag is switched off without the rules that
|
|
20
|
+
* describe its rollout being deleted.
|
|
21
|
+
*/
|
|
22
|
+
export type FlagState = "enabled" | "disabled";
|
|
23
|
+
/**
|
|
24
|
+
* One test against the evaluation context. `field` is a dotted path, so
|
|
25
|
+
* `plan.tier` reads a nested structure; a path that resolves to nothing makes
|
|
26
|
+
* every operator except `exists` false, and each operator compares within one
|
|
27
|
+
* type rather than coercing across them.
|
|
28
|
+
*/
|
|
29
|
+
export type Condition = {
|
|
30
|
+
op: "all";
|
|
31
|
+
of: Condition[];
|
|
32
|
+
} | {
|
|
33
|
+
op: "any";
|
|
34
|
+
of: Condition[];
|
|
35
|
+
} | {
|
|
36
|
+
op: "not";
|
|
37
|
+
of: Condition;
|
|
38
|
+
} | {
|
|
39
|
+
op: "eq" | "ne";
|
|
40
|
+
field: string;
|
|
41
|
+
value: JSONPrimitive;
|
|
42
|
+
} | {
|
|
43
|
+
op: "in" | "notIn";
|
|
44
|
+
field: string;
|
|
45
|
+
values: JSONPrimitive[];
|
|
46
|
+
} | {
|
|
47
|
+
op: "lt" | "lte" | "gt" | "gte";
|
|
48
|
+
field: string;
|
|
49
|
+
value: number;
|
|
50
|
+
} | {
|
|
51
|
+
op: "startsWith" | "endsWith" | "contains";
|
|
52
|
+
field: string;
|
|
53
|
+
value: string;
|
|
54
|
+
} | {
|
|
55
|
+
op: "matches";
|
|
56
|
+
field: string;
|
|
57
|
+
pattern: string;
|
|
58
|
+
} | {
|
|
59
|
+
op: "semver";
|
|
60
|
+
field: string;
|
|
61
|
+
compare: SemVerComparison;
|
|
62
|
+
value: string;
|
|
63
|
+
} | {
|
|
64
|
+
op: "exists";
|
|
65
|
+
field: string;
|
|
66
|
+
} | {
|
|
67
|
+
op: "segment";
|
|
68
|
+
name: string;
|
|
69
|
+
} | {
|
|
70
|
+
op: "always";
|
|
71
|
+
};
|
|
72
|
+
/** How a rule spreads one condition's subjects across several variants. */
|
|
73
|
+
export interface Split {
|
|
74
|
+
/**
|
|
75
|
+
* Whole, non-negative weights by variant name, taken against their own sum,
|
|
76
|
+
* so `{ on: 1, off: 1 }` is a half-and-half split and one arm widens without
|
|
77
|
+
* the others being rebalanced.
|
|
78
|
+
*/
|
|
79
|
+
weights: Record<string, number>;
|
|
80
|
+
/** The context field the subject is read from. @default "targetingKey" */
|
|
81
|
+
by?: string;
|
|
82
|
+
/** Mixed into the hash instead of the flag key, so two flags share a bucketing. */
|
|
83
|
+
seed?: string;
|
|
84
|
+
}
|
|
85
|
+
/** One row of a flag's targeting: who it is about, and what they are served. */
|
|
86
|
+
export interface TargetingRule {
|
|
87
|
+
when: Condition;
|
|
88
|
+
/** The variant this rule names, or the weights it buckets its subjects among. */
|
|
89
|
+
serve: string | Split;
|
|
90
|
+
}
|
|
91
|
+
/** A flag as an author writes it, and as a store hands it back. */
|
|
92
|
+
export interface FlagDefinition {
|
|
93
|
+
/** Every value this flag can take, by variant name. */
|
|
94
|
+
variants: Record<string, FlagValue>;
|
|
95
|
+
/** The variant served when no rule matches; absent means the caller's own default. */
|
|
96
|
+
defaultVariant?: string;
|
|
97
|
+
/** @default "enabled" */
|
|
98
|
+
state?: FlagState;
|
|
99
|
+
/** Tried in order; the first rule whose condition holds decides the variant. */
|
|
100
|
+
targeting?: TargetingRule[];
|
|
101
|
+
/** Travels onto every resolution of this flag, for whatever reads the wide event. */
|
|
102
|
+
metadata?: FlagMetadata;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Conditions declared once and referenced by name, so "is an internal user" is
|
|
106
|
+
* written in one place and read by twenty flags. A segment may reference
|
|
107
|
+
* another segment.
|
|
108
|
+
*/
|
|
109
|
+
export type SegmentSet = Record<string, Condition>;
|
|
110
|
+
/** A whole definition set: the flags to evaluate, and the segments they share. */
|
|
111
|
+
export interface FlagSet {
|
|
112
|
+
flags: Record<string, FlagDefinition>;
|
|
113
|
+
segments?: SegmentSet;
|
|
114
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a flag is written down as: the values it can take, the ordered rules
|
|
3
|
+
* that choose between them, and the conditions those rules match on. Types
|
|
4
|
+
* only, so an admin UI and the engine describe a definition the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of evaluation that holds state: an object that reads a store, parses
|
|
3
|
+
* the set once, and resolves every later flag against the snapshot it kept. It
|
|
4
|
+
* is what a worker holds for the life of an isolate.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
import type { EvaluationContext, FlagValue, MaybePromise, ResolutionDetails } from "@sdxc/flags";
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { FlagParseFailure, FlagSnapshot } from "./snapshot.js";
|
|
13
|
+
import type { FlagStore, FlagStoreError } from "./store/index.js";
|
|
14
|
+
/** What an engine is built from. */
|
|
15
|
+
export interface EngineOptions {
|
|
16
|
+
/** Where definitions are read from, and the whole of what the engine knows about storage. */
|
|
17
|
+
store: FlagStore;
|
|
18
|
+
/**
|
|
19
|
+
* How long a loaded snapshot counts as current, which is what `stale` is
|
|
20
|
+
* measured against. Omitted, a snapshot stays current until a caller loads
|
|
21
|
+
* another one.
|
|
22
|
+
*/
|
|
23
|
+
maxAge?: DurationInput;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Holds one definition set and resolves flags against it. A caller decides when
|
|
27
|
+
* the definitions are read again — on a request, inside `waitUntil`, or from a
|
|
28
|
+
* cron trigger — and `stale` is what that decision is made on.
|
|
29
|
+
*/
|
|
30
|
+
export interface Engine {
|
|
31
|
+
/** The set being evaluated against, present once a load has succeeded. */
|
|
32
|
+
readonly snapshot: FlagSnapshot | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The definitions the held set carried and the engine refused, so a caller
|
|
35
|
+
* logs them once after a load instead of once per evaluation of the key.
|
|
36
|
+
*/
|
|
37
|
+
readonly failures: readonly FlagParseFailure[];
|
|
38
|
+
/**
|
|
39
|
+
* Whether what the engine holds has aged past `maxAge`. It reads `true` until
|
|
40
|
+
* a load has succeeded, so a fresh engine reads as one that wants a load.
|
|
41
|
+
*/
|
|
42
|
+
readonly stale: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Reads the store and parses the set once, keeping the snapshot for every
|
|
45
|
+
* evaluation that follows. A store that reads synchronously loads
|
|
46
|
+
* synchronously, and the answer is `await`-able either way.
|
|
47
|
+
*
|
|
48
|
+
* @returns The snapshot now held, or the reason the store could not hand its
|
|
49
|
+
* set over — a configuration failure a caller reports.
|
|
50
|
+
*/
|
|
51
|
+
load(): MaybePromise<Result<FlagSnapshot, FlagStoreError>>;
|
|
52
|
+
/**
|
|
53
|
+
* Resolves one flag against the held snapshot, answering with the caller's own
|
|
54
|
+
* default and `PROVIDER_NOT_READY` until a load has succeeded.
|
|
55
|
+
*
|
|
56
|
+
* @param key The flag to resolve.
|
|
57
|
+
* @param defaultValue What the caller uses when nothing resolves, and the type
|
|
58
|
+
* every variant is checked against.
|
|
59
|
+
* @param context The merged context targeting reads.
|
|
60
|
+
*/
|
|
61
|
+
evaluate<T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext): ResolutionDetails<T>;
|
|
62
|
+
/**
|
|
63
|
+
* Resolves every flag the held snapshot carries, for a caller with no per-flag
|
|
64
|
+
* default to fall back on. An engine before its first load answers with an
|
|
65
|
+
* empty record, which is every flag it knows of.
|
|
66
|
+
*
|
|
67
|
+
* @param context The merged context targeting reads.
|
|
68
|
+
*/
|
|
69
|
+
evaluateAll(context?: EvaluationContext): Record<string, ResolutionDetails<FlagValue>>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds an engine over a store, holding no definitions until `load` succeeds.
|
|
73
|
+
*
|
|
74
|
+
* @param options The store to read from, and how long a snapshot counts as current.
|
|
75
|
+
* @returns An engine a caller loads, evaluates through, and reloads on its own schedule.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* let engine = createEngine({ store: new InMemoryFlagStore(set), maxAge: "5 minutes" });
|
|
79
|
+
*/
|
|
80
|
+
export declare function createEngine({ store, maxAge }: EngineOptions): Engine;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of evaluation that holds state: an object that reads a store, parses
|
|
3
|
+
* the set once, and resolves every later flag against the snapshot it kept. It
|
|
4
|
+
* is what a worker holds for the life of an isolate.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { toMs } from "@sdxc/duration";
|
|
10
|
+
import { isFailure, success } from "@sdxc/result";
|
|
11
|
+
import { evaluate as resolve, evaluateAll as resolveAll } from "./evaluate.js";
|
|
12
|
+
import { parseFlagSet } from "./parse.js";
|
|
13
|
+
/**
|
|
14
|
+
* Builds an engine over a store, holding no definitions until `load` succeeds.
|
|
15
|
+
*
|
|
16
|
+
* @param options The store to read from, and how long a snapshot counts as current.
|
|
17
|
+
* @returns An engine a caller loads, evaluates through, and reloads on its own schedule.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* let engine = createEngine({ store: new InMemoryFlagStore(set), maxAge: "5 minutes" });
|
|
21
|
+
*/
|
|
22
|
+
export function createEngine({ store, maxAge }) {
|
|
23
|
+
let held;
|
|
24
|
+
let lifetime = maxAge === undefined ? Number.POSITIVE_INFINITY : toMs(maxAge);
|
|
25
|
+
/**
|
|
26
|
+
* Parses what a successful read handed over and keeps it, which is what makes
|
|
27
|
+
* parsing a cost per load while evaluation walks a structure already known to
|
|
28
|
+
* be well formed.
|
|
29
|
+
*/
|
|
30
|
+
function keep(read) {
|
|
31
|
+
if (isFailure(read))
|
|
32
|
+
return read;
|
|
33
|
+
held = parseFlagSet(read.data);
|
|
34
|
+
return success(held);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
get snapshot() {
|
|
38
|
+
return held;
|
|
39
|
+
},
|
|
40
|
+
get failures() {
|
|
41
|
+
return held === undefined ? [] : [...held.failures.values()];
|
|
42
|
+
},
|
|
43
|
+
get stale() {
|
|
44
|
+
return held === undefined || Date.now() - held.createdAt >= lifetime;
|
|
45
|
+
},
|
|
46
|
+
load() {
|
|
47
|
+
let read = store.read();
|
|
48
|
+
if (read instanceof Promise)
|
|
49
|
+
return read.then(keep);
|
|
50
|
+
return keep(read);
|
|
51
|
+
},
|
|
52
|
+
evaluate(key, defaultValue, context) {
|
|
53
|
+
if (held === undefined)
|
|
54
|
+
return notReady(defaultValue);
|
|
55
|
+
return resolve(held, key, defaultValue, context);
|
|
56
|
+
},
|
|
57
|
+
evaluateAll(context) {
|
|
58
|
+
if (held === undefined)
|
|
59
|
+
return {};
|
|
60
|
+
return resolveAll(held, context);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* What an evaluation gets while the engine holds no snapshot: the caller's own
|
|
66
|
+
* default, under the code the specification reserves for a resolution asked for
|
|
67
|
+
* before the flags arrived.
|
|
68
|
+
*/
|
|
69
|
+
function notReady(defaultValue) {
|
|
70
|
+
return {
|
|
71
|
+
value: defaultValue,
|
|
72
|
+
reason: "ERROR",
|
|
73
|
+
errorCode: "PROVIDER_NOT_READY",
|
|
74
|
+
errorMessage: "The engine holds no definitions until a load succeeds",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole of what evaluation is: a snapshot and a context in, a resolution
|
|
3
|
+
* out. Pure and synchronous, so the same function answers a provider, an HTTP
|
|
4
|
+
* endpoint and an admin preview, and it reports every outcome rather than
|
|
5
|
+
* throwing one.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { EvaluationContext, FlagValue, ResolutionDetails } from "@sdxc/flags";
|
|
11
|
+
import type { FlagSnapshot } from "./snapshot.js";
|
|
12
|
+
/**
|
|
13
|
+
* Resolves one flag for one context, answering with the caller's own default
|
|
14
|
+
* whenever the definition serves no value of the requested type — a flag that
|
|
15
|
+
* is off, absent, broken or holding another type included.
|
|
16
|
+
*
|
|
17
|
+
* @param snapshot The parsed definition set to resolve against.
|
|
18
|
+
* @param key The flag to resolve.
|
|
19
|
+
* @param defaultValue What the caller uses when nothing resolves, and the type
|
|
20
|
+
* every variant is checked against.
|
|
21
|
+
* @param context The merged context targeting reads; an absent one targets on nothing.
|
|
22
|
+
* @example evaluate(snapshot, "new-checkout", false, { targetingKey: "user-1" })
|
|
23
|
+
*/
|
|
24
|
+
export declare function evaluate<T extends FlagValue>(snapshot: FlagSnapshot, key: string, defaultValue: T, context?: EvaluationContext): ResolutionDetails<T>;
|
|
25
|
+
/**
|
|
26
|
+
* Resolves every flag the snapshot carries, the ones that failed to parse
|
|
27
|
+
* included, for a caller with no per-flag default to fall back on. A flag that
|
|
28
|
+
* produces no value of its own reports `null` beside the reason, which is the
|
|
29
|
+
* one value in `FlagValue` that stands for "use the default you already have".
|
|
30
|
+
*
|
|
31
|
+
* @param snapshot The parsed definition set to resolve against.
|
|
32
|
+
* @param context The merged context targeting reads; an absent one targets on nothing.
|
|
33
|
+
* @example evaluateAll(snapshot, { targetingKey: "user-1" })["new-checkout"]
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluateAll(snapshot: FlagSnapshot, context?: EvaluationContext): Record<string, ResolutionDetails<FlagValue>>;
|
package/dist/evaluate.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole of what evaluation is: a snapshot and a context in, a resolution
|
|
3
|
+
* out. Pure and synchronous, so the same function answers a provider, an HTTP
|
|
4
|
+
* endpoint and an admin preview, and it reports every outcome rather than
|
|
5
|
+
* throwing one.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import { isFailure } from "@sdxc/result";
|
|
11
|
+
import { matchesCondition } from "./lib/condition.js";
|
|
12
|
+
import { selectVariant } from "./lib/split.js";
|
|
13
|
+
/**
|
|
14
|
+
* Resolves one flag for one context, answering with the caller's own default
|
|
15
|
+
* whenever the definition serves no value of the requested type — a flag that
|
|
16
|
+
* is off, absent, broken or holding another type included.
|
|
17
|
+
*
|
|
18
|
+
* @param snapshot The parsed definition set to resolve against.
|
|
19
|
+
* @param key The flag to resolve.
|
|
20
|
+
* @param defaultValue What the caller uses when nothing resolves, and the type
|
|
21
|
+
* every variant is checked against.
|
|
22
|
+
* @param context The merged context targeting reads; an absent one targets on nothing.
|
|
23
|
+
* @example evaluate(snapshot, "new-checkout", false, { targetingKey: "user-1" })
|
|
24
|
+
*/
|
|
25
|
+
export function evaluate(snapshot, key, defaultValue, context = {}) {
|
|
26
|
+
let selection = select(snapshot, key, context);
|
|
27
|
+
if (selection.variant === undefined)
|
|
28
|
+
return detailsOf(defaultValue, selection);
|
|
29
|
+
if (!sameType(selection.value, defaultValue)) {
|
|
30
|
+
let held = typeName(selection.value);
|
|
31
|
+
return detailsOf(defaultValue, {
|
|
32
|
+
reason: "ERROR",
|
|
33
|
+
errorCode: "TYPE_MISMATCH",
|
|
34
|
+
errorMessage: `The variant "${selection.variant}" of "${key}" holds a ${held}`,
|
|
35
|
+
metadata: selection.metadata,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return detailsOf(selection.value, selection);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolves every flag the snapshot carries, the ones that failed to parse
|
|
42
|
+
* included, for a caller with no per-flag default to fall back on. A flag that
|
|
43
|
+
* produces no value of its own reports `null` beside the reason, which is the
|
|
44
|
+
* one value in `FlagValue` that stands for "use the default you already have".
|
|
45
|
+
*
|
|
46
|
+
* @param snapshot The parsed definition set to resolve against.
|
|
47
|
+
* @param context The merged context targeting reads; an absent one targets on nothing.
|
|
48
|
+
* @example evaluateAll(snapshot, { targetingKey: "user-1" })["new-checkout"]
|
|
49
|
+
*/
|
|
50
|
+
export function evaluateAll(snapshot, context = {}) {
|
|
51
|
+
let all = {};
|
|
52
|
+
for (let key of [...snapshot.flags.keys(), ...snapshot.failures.keys()]) {
|
|
53
|
+
let selection = select(snapshot, key, context);
|
|
54
|
+
all[key] = detailsOf(selection.value ?? null, selection);
|
|
55
|
+
}
|
|
56
|
+
return all;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Finds the flag and runs its rules, reporting the two things that can be wrong
|
|
60
|
+
* with a key before any rule runs: nothing is stored under it, or what is
|
|
61
|
+
* stored under it was refused when the set was parsed.
|
|
62
|
+
*/
|
|
63
|
+
function select(snapshot, key, context) {
|
|
64
|
+
let failure = snapshot.failures.get(key);
|
|
65
|
+
if (failure !== undefined) {
|
|
66
|
+
return { reason: "ERROR", errorCode: "PARSE_ERROR", errorMessage: failure.message };
|
|
67
|
+
}
|
|
68
|
+
let flag = snapshot.flags.get(key);
|
|
69
|
+
if (flag === undefined) {
|
|
70
|
+
return { reason: "ERROR", errorCode: "FLAG_NOT_FOUND", errorMessage: `No flag named "${key}"` };
|
|
71
|
+
}
|
|
72
|
+
return { ...served(flag, context), metadata: flag.metadata };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Runs the flag's rules in the order they were written, so the first rule whose
|
|
76
|
+
* condition holds decides. A flag with no rules at all serves its default
|
|
77
|
+
* variant statically, which is the distinction `STATIC` carries over `DEFAULT`.
|
|
78
|
+
*/
|
|
79
|
+
function served(flag, context) {
|
|
80
|
+
if (flag.state === "disabled")
|
|
81
|
+
return { reason: "DISABLED" };
|
|
82
|
+
if (flag.targeting.length === 0)
|
|
83
|
+
return fallback(flag, "STATIC");
|
|
84
|
+
for (let rule of flag.targeting) {
|
|
85
|
+
if (matchesCondition(rule.when, context))
|
|
86
|
+
return matched(flag, rule.serve, context);
|
|
87
|
+
}
|
|
88
|
+
return fallback(flag, "DEFAULT");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Serves what the matching rule names: one variant, or the arm the subject
|
|
92
|
+
* buckets into. A split whose subject is nowhere in the context reports that
|
|
93
|
+
* rather than serving an arm, so a rollout that never happened reads as one.
|
|
94
|
+
*/
|
|
95
|
+
function matched(flag, serve, context) {
|
|
96
|
+
if (typeof serve === "string") {
|
|
97
|
+
return { reason: "TARGETING_MATCH", variant: serve, value: flag.variants.get(serve) };
|
|
98
|
+
}
|
|
99
|
+
let bucketed = selectVariant(serve, flag.key, context);
|
|
100
|
+
if (isFailure(bucketed)) {
|
|
101
|
+
return {
|
|
102
|
+
reason: "ERROR",
|
|
103
|
+
errorCode: "TARGETING_KEY_MISSING",
|
|
104
|
+
errorMessage: bucketed.error.message,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { reason: "SPLIT", variant: bucketed.data, value: flag.variants.get(bucketed.data) };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Serves the variant a flag falls back to under the reason that describes how
|
|
111
|
+
* it got there. A flag declaring no `defaultVariant` exists to target a few
|
|
112
|
+
* subjects, so everyone else is served the default their call site passed.
|
|
113
|
+
*/
|
|
114
|
+
function fallback(flag, reason) {
|
|
115
|
+
if (flag.defaultVariant === undefined)
|
|
116
|
+
return { reason: "DEFAULT" };
|
|
117
|
+
return {
|
|
118
|
+
reason,
|
|
119
|
+
variant: flag.defaultVariant,
|
|
120
|
+
value: flag.variants.get(flag.defaultVariant),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Holds when the variant's value is one the caller could have passed as its own
|
|
125
|
+
* default. A structure is checked no further than being one, because what its
|
|
126
|
+
* fields hold is the caller's schema to decide.
|
|
127
|
+
*/
|
|
128
|
+
function sameType(value, expected) {
|
|
129
|
+
if (expected === null || typeof expected === "object")
|
|
130
|
+
return typeof value === "object";
|
|
131
|
+
return typeof value === typeof expected;
|
|
132
|
+
}
|
|
133
|
+
/** Names the type a variant turned out to hold, for the message a mismatch carries. */
|
|
134
|
+
function typeName(value) {
|
|
135
|
+
if (value === null)
|
|
136
|
+
return "null";
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return "array";
|
|
139
|
+
return typeof value;
|
|
140
|
+
}
|
|
141
|
+
/** Assembles the structure a caller reads, carrying only the fields this selection filled. */
|
|
142
|
+
function detailsOf(value, selection) {
|
|
143
|
+
let details = { value, reason: selection.reason };
|
|
144
|
+
if (selection.variant !== undefined)
|
|
145
|
+
details.variant = selection.variant;
|
|
146
|
+
if (selection.errorCode !== undefined)
|
|
147
|
+
details.errorCode = selection.errorCode;
|
|
148
|
+
if (selection.errorMessage !== undefined)
|
|
149
|
+
details.errorMessage = selection.errorMessage;
|
|
150
|
+
if (selection.metadata !== undefined)
|
|
151
|
+
details.flagMetadata = selection.metadata;
|
|
152
|
+
return details;
|
|
153
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a flag is written down as, the schema a definition is validated against,
|
|
3
|
+
* and the two ways to resolve one: the pure functions over a snapshot, and the
|
|
4
|
+
* engine that goes and gets the snapshot for them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export type { Condition, FlagDefinition, FlagSet, FlagState, SegmentSet, SemVerComparison, Split, TargetingRule, } from "./definition.js";
|
|
10
|
+
export type { Engine, EngineOptions } from "./engine.js";
|
|
11
|
+
export type { CompiledCondition, CompiledFlag, CompiledRule, CompiledSegments, FlagParseFailure, FlagSnapshot, } from "./snapshot.js";
|
|
12
|
+
export { createEngine } from "./engine.js";
|
|
13
|
+
export { evaluate, evaluateAll } from "./evaluate.js";
|
|
14
|
+
export { parseFlagSet } from "./parse.js";
|
|
15
|
+
export { CONDITION_SCHEMA, FLAG_DEFINITION_SCHEMA, SEGMENT_SET_SCHEMA, SPLIT_SCHEMA, TARGETING_RULE_SCHEMA, } from "./schema.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a flag is written down as, the schema a definition is validated against,
|
|
3
|
+
* and the two ways to resolve one: the pure functions over a snapshot, and the
|
|
4
|
+
* engine that goes and gets the snapshot for them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { createEngine } from "./engine.js";
|
|
10
|
+
export { evaluate, evaluateAll } from "./evaluate.js";
|
|
11
|
+
export { parseFlagSet } from "./parse.js";
|
|
12
|
+
export { CONDITION_SCHEMA, FLAG_DEFINITION_SCHEMA, SEGMENT_SET_SCHEMA, SPLIT_SCHEMA, TARGETING_RULE_SCHEMA, } from "./schema.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether one compiled condition holds for an evaluation context: the operator
|
|
3
|
+
* table a targeting rule is written in, applied one operator at a time over the
|
|
4
|
+
* dotted path each of them names.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { EvaluationContext } from "@sdxc/flags";
|
|
10
|
+
import type { CompiledCondition } from "../snapshot.js";
|
|
11
|
+
/**
|
|
12
|
+
* Answers whether the subject this context describes is one the condition is
|
|
13
|
+
* about. Composition is the whole of what `all`, `any`, `not` and `segment`
|
|
14
|
+
* add, so every other operator sees one field and one value.
|
|
15
|
+
*
|
|
16
|
+
* @param condition The condition as the snapshot compiled it.
|
|
17
|
+
* @param context The merged context an evaluation was given.
|
|
18
|
+
* @example matchesCondition({ op: "eq", field: "country", value: "AR" }, { country: "AR" })
|
|
19
|
+
*/
|
|
20
|
+
export declare function matchesCondition(condition: CompiledCondition, context: EvaluationContext): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether one compiled condition holds for an evaluation context: the operator
|
|
3
|
+
* table a targeting rule is written in, applied one operator at a time over the
|
|
4
|
+
* dotted path each of them names.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { satisfies } from "@sdxc/semver";
|
|
10
|
+
import { read } from "./path.js";
|
|
11
|
+
/**
|
|
12
|
+
* Answers whether the subject this context describes is one the condition is
|
|
13
|
+
* about. Composition is the whole of what `all`, `any`, `not` and `segment`
|
|
14
|
+
* add, so every other operator sees one field and one value.
|
|
15
|
+
*
|
|
16
|
+
* @param condition The condition as the snapshot compiled it.
|
|
17
|
+
* @param context The merged context an evaluation was given.
|
|
18
|
+
* @example matchesCondition({ op: "eq", field: "country", value: "AR" }, { country: "AR" })
|
|
19
|
+
*/
|
|
20
|
+
export function matchesCondition(condition, context) {
|
|
21
|
+
switch (condition.op) {
|
|
22
|
+
case "all":
|
|
23
|
+
return condition.of.every((member) => matchesCondition(member, context));
|
|
24
|
+
case "any":
|
|
25
|
+
return condition.of.some((member) => matchesCondition(member, context));
|
|
26
|
+
case "not":
|
|
27
|
+
return !matchesCondition(condition.of, context);
|
|
28
|
+
case "segment":
|
|
29
|
+
return matchesCondition(condition.of, context);
|
|
30
|
+
case "always":
|
|
31
|
+
return true;
|
|
32
|
+
default:
|
|
33
|
+
return matchesField(condition, context);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compares one field against the value a rule was written with. A path that
|
|
38
|
+
* resolves to nothing holds for no comparison, which is what keeps a rule about
|
|
39
|
+
* a field the caller did not send from matching everyone; `exists` asks about
|
|
40
|
+
* the path itself, so it answers first.
|
|
41
|
+
*/
|
|
42
|
+
function matchesField(condition, context) {
|
|
43
|
+
let value = read(context, condition.field);
|
|
44
|
+
if (condition.op === "exists")
|
|
45
|
+
return value !== undefined;
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return false;
|
|
48
|
+
switch (condition.op) {
|
|
49
|
+
case "eq":
|
|
50
|
+
return value === condition.value;
|
|
51
|
+
case "ne":
|
|
52
|
+
return value !== condition.value;
|
|
53
|
+
case "in":
|
|
54
|
+
return condition.values.some((member) => member === value);
|
|
55
|
+
case "notIn":
|
|
56
|
+
return condition.values.every((member) => member !== value);
|
|
57
|
+
case "lt":
|
|
58
|
+
return typeof value === "number" && value < condition.value;
|
|
59
|
+
case "lte":
|
|
60
|
+
return typeof value === "number" && value <= condition.value;
|
|
61
|
+
case "gt":
|
|
62
|
+
return typeof value === "number" && value > condition.value;
|
|
63
|
+
case "gte":
|
|
64
|
+
return typeof value === "number" && value >= condition.value;
|
|
65
|
+
case "startsWith":
|
|
66
|
+
return typeof value === "string" && value.startsWith(condition.value);
|
|
67
|
+
case "endsWith":
|
|
68
|
+
return typeof value === "string" && value.endsWith(condition.value);
|
|
69
|
+
case "contains":
|
|
70
|
+
return typeof value === "string" && value.includes(condition.value);
|
|
71
|
+
case "matches":
|
|
72
|
+
return typeof value === "string" && condition.pattern.test(value);
|
|
73
|
+
case "semver":
|
|
74
|
+
return typeof value === "string" && satisfies(value, condition.compare, condition.value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MurmurHash3 x86 32-bit at seed 0, the hash a percentage split buckets a
|
|
3
|
+
* subject with. The reference engine hashes the same way, so a rollout
|
|
4
|
+
* percentage written here selects the same subjects it selects there.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Hashes a subject string to an unsigned 32-bit integer, spread evenly enough
|
|
11
|
+
* that a ten percent bucket holds ten percent of subjects. The string is hashed
|
|
12
|
+
* as its UTF-8 bytes, so a subject outside ASCII lands where the reference puts it.
|
|
13
|
+
*
|
|
14
|
+
* @param input The subject string, already assembled from seed and context field.
|
|
15
|
+
* @returns A value in `[0, 2^32)`.
|
|
16
|
+
* @example murmurHash3("welcome-banneruser-42") // 2364819184
|
|
17
|
+
*/
|
|
18
|
+
export declare function murmurHash3(input: string): number;
|