@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.
@@ -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
+ */
@@ -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>>;
@@ -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
+ }
@@ -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;