@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,74 @@
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
+ * Held for the life of the module so bucketing a subject allocates only the
11
+ * byte array it hashes.
12
+ */
13
+ const TEXT_ENCODER = new TextEncoder();
14
+ /**
15
+ * Hashes a subject string to an unsigned 32-bit integer, spread evenly enough
16
+ * that a ten percent bucket holds ten percent of subjects. The string is hashed
17
+ * as its UTF-8 bytes, so a subject outside ASCII lands where the reference puts it.
18
+ *
19
+ * @param input The subject string, already assembled from seed and context field.
20
+ * @returns A value in `[0, 2^32)`.
21
+ * @example murmurHash3("welcome-banneruser-42") // 2364819184
22
+ */
23
+ export function murmurHash3(input) {
24
+ let bytes = TEXT_ENCODER.encode(input);
25
+ let tailLength = bytes.length % 4;
26
+ let blockEnd = bytes.length - tailLength;
27
+ let hash = 0;
28
+ for (let index = 0; index < blockEnd; index += 4) {
29
+ hash = mixBlock(hash, readBlock(bytes, index));
30
+ }
31
+ hash ^= scramble(readTail(bytes, blockEnd, tailLength));
32
+ hash ^= bytes.length;
33
+ return avalanche(hash) >>> 0;
34
+ }
35
+ /** Rotates within 32 bits, which is where the algorithm's diffusion comes from. */
36
+ function rotateLeft(value, bits) {
37
+ return (value << bits) | (value >>> (32 - bits));
38
+ }
39
+ /** Spreads one word's bits before it reaches the running hash. */
40
+ function scramble(block) {
41
+ return Math.imul(rotateLeft(Math.imul(block, 0xcc_9e_2d_51), 15), 0x1b_87_35_93);
42
+ }
43
+ /** Folds one whole word into the running hash, ordering-sensitively. */
44
+ function mixBlock(hash, block) {
45
+ return (Math.imul(rotateLeft(hash ^ scramble(block), 13), 5) + 0xe6_54_6b_64) | 0;
46
+ }
47
+ /** Finishes the hash so every input bit reaches every output bit. */
48
+ function avalanche(hash) {
49
+ let mixed = Math.imul(hash ^ (hash >>> 16), 0x85_eb_ca_6b);
50
+ mixed = Math.imul(mixed ^ (mixed >>> 13), 0xc2_b2_ae_35);
51
+ return mixed ^ (mixed >>> 16);
52
+ }
53
+ /** Reads a whole word little-endian, the byte order the algorithm is defined in. */
54
+ function readBlock(bytes, start) {
55
+ return ((bytes[start] ?? 0) |
56
+ ((bytes[start + 1] ?? 0) << 8) |
57
+ ((bytes[start + 2] ?? 0) << 16) |
58
+ ((bytes[start + 3] ?? 0) << 24));
59
+ }
60
+ /**
61
+ * Packs the trailing bytes that do not fill a word into one, keeping their
62
+ * little-endian positions so a one-byte tail differs from the same byte in
63
+ * second position. A length of zero answers with zero, which mixes into nothing.
64
+ */
65
+ function readTail(bytes, start, length) {
66
+ let block = 0;
67
+ if (length > 2)
68
+ block ^= (bytes[start + 2] ?? 0) << 16;
69
+ if (length > 1)
70
+ block ^= (bytes[start + 1] ?? 0) << 8;
71
+ if (length > 0)
72
+ block ^= bytes[start] ?? 0;
73
+ return block;
74
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Reads the dotted path a targeting condition names out of an evaluation
3
+ * context. A rule written against a field the caller did not send has to see
4
+ * nothing there rather than fail, so every miss answers with `undefined`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { EvaluationContext } from "@sdxc/flags";
10
+ import type { JSONValue } from "@sdxc/types";
11
+ /** Whatever a context field may hold, plus the absence a miss answers with. */
12
+ export type ContextValue = Date | JSONValue | undefined;
13
+ /**
14
+ * Walks `path` segment by segment, so `plan.tier` reads a nested structure,
15
+ * `country` a scalar and `roles.0` an array element. Every dot separates, so a
16
+ * field is addressed by the shape it sits inside.
17
+ *
18
+ * @param context The merged context an evaluation was given.
19
+ * @param path A dotted path; an empty one resolves to nothing.
20
+ * @returns The value at the path, or `undefined` when it resolves to nothing; a
21
+ * field explicitly holding `null` reads back as `null`.
22
+ * @example read({ plan: { tier: "pro" } }, "plan.tier") // "pro"
23
+ */
24
+ export declare function read(context: EvaluationContext, path: string): ContextValue;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Reads the dotted path a targeting condition names out of an evaluation
3
+ * context. A rule written against a field the caller did not send has to see
4
+ * nothing there rather than fail, so every miss answers with `undefined`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** A canonical array index: no sign, no leading zero, so `roles.01` reads nothing. */
10
+ const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
11
+ /**
12
+ * Walks `path` segment by segment, so `plan.tier` reads a nested structure,
13
+ * `country` a scalar and `roles.0` an array element. Every dot separates, so a
14
+ * field is addressed by the shape it sits inside.
15
+ *
16
+ * @param context The merged context an evaluation was given.
17
+ * @param path A dotted path; an empty one resolves to nothing.
18
+ * @returns The value at the path, or `undefined` when it resolves to nothing; a
19
+ * field explicitly holding `null` reads back as `null`.
20
+ * @example read({ plan: { tier: "pro" } }, "plan.tier") // "pro"
21
+ */
22
+ export function read(context, path) {
23
+ if (path === "")
24
+ return undefined;
25
+ let current = context;
26
+ for (let segment of path.split("."))
27
+ current = step(current, segment);
28
+ return current;
29
+ }
30
+ /**
31
+ * Descends one segment, answering with nothing whenever the value in hand has
32
+ * no such member — a primitive, an array under a non-index segment, or an
33
+ * object without the key.
34
+ */
35
+ function step(value, segment) {
36
+ if (Array.isArray(value)) {
37
+ return ARRAY_INDEX.test(segment) ? value[Number(segment)] : undefined;
38
+ }
39
+ if (!isTraversable(value))
40
+ return undefined;
41
+ return Object.hasOwn(value, segment) ? value[segment] : undefined;
42
+ }
43
+ /**
44
+ * Recognizes the structures a path descends into, which keeps a context field
45
+ * carrying a `Date` a value a condition compares whole.
46
+ */
47
+ function isTraversable(value) {
48
+ return typeof value === "object" && value !== null && !(value instanceof Date);
49
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which arm of a percentage split a subject lands in, from a hash of that
3
+ * subject and nothing else, so a rollout covers the same people on every
4
+ * request, in every isolate, for as long as the weights stay put.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { EvaluationContext } from "@sdxc/flags";
10
+ import type { Result } from "@sdxc/result";
11
+ import type { Split } from "../definition.js";
12
+ /**
13
+ * Puts a subject in one arm of a split, hashing the seed — or the flag key,
14
+ * which is what makes two flags at ten percent cover different tenths — with
15
+ * the subject read out of the context.
16
+ *
17
+ * @param split The weights to spread subjects across, and where to read one from.
18
+ * @param flagKey Mixed into the hash for a split that names no `seed`.
19
+ * @param context The merged context an evaluation was given.
20
+ * @returns The variant that won the bucket, or a failure when the field the
21
+ * split buckets on carries nothing to hash, which the caller reports as
22
+ * `TARGETING_KEY_MISSING` rather than serving an arm nobody was assigned to.
23
+ * @example selectVariant({ weights: { on: 10, off: 90 } }, "beta", { targetingKey: "u-1" })
24
+ */
25
+ export declare function selectVariant(split: Split, flagKey: string, context: EvaluationContext): Result<string, Error>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which arm of a percentage split a subject lands in, from a hash of that
3
+ * subject and nothing else, so a rollout covers the same people on every
4
+ * request, in every isolate, for as long as the weights stay put.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, success } from "@sdxc/result";
10
+ import { murmurHash3 } from "./hash.js";
11
+ import { read } from "./path.js";
12
+ /** Where a split reads its subject from when it names no field of its own. */
13
+ const SUBJECT_FIELD = "targetingKey";
14
+ /** The width of the hash the bucket is scaled down from by a multiply-high. */
15
+ const HASH_BITS = 32n;
16
+ /**
17
+ * Puts a subject in one arm of a split, hashing the seed — or the flag key,
18
+ * which is what makes two flags at ten percent cover different tenths — with
19
+ * the subject read out of the context.
20
+ *
21
+ * @param split The weights to spread subjects across, and where to read one from.
22
+ * @param flagKey Mixed into the hash for a split that names no `seed`.
23
+ * @param context The merged context an evaluation was given.
24
+ * @returns The variant that won the bucket, or a failure when the field the
25
+ * split buckets on carries nothing to hash, which the caller reports as
26
+ * `TARGETING_KEY_MISSING` rather than serving an arm nobody was assigned to.
27
+ * @example selectVariant({ weights: { on: 10, off: 90 } }, "beta", { targetingKey: "u-1" })
28
+ */
29
+ export function selectVariant(split, flagKey, context) {
30
+ let field = split.by ?? SUBJECT_FIELD;
31
+ let subject = read(context, field);
32
+ if (!isSubject(subject)) {
33
+ return failure(new Error(`The context carries no "${field}" to bucket on`));
34
+ }
35
+ let weights = orderedWeights(split.weights);
36
+ let total = weights.reduce((sum, [, weight]) => sum + weight, 0);
37
+ let hash = murmurHash3(`${split.seed ?? flagKey}${subject}`);
38
+ let bucket = Number((BigInt(hash) * BigInt(total)) >> HASH_BITS);
39
+ let cumulative = 0;
40
+ for (let [variant, weight] of weights) {
41
+ cumulative += weight;
42
+ if (bucket < cumulative)
43
+ return success(variant);
44
+ }
45
+ return failure(new Error(`The split on "${flagKey}" declares no weight above zero`));
46
+ }
47
+ /**
48
+ * Recognizes the values a subject string can be built from, so an account id
49
+ * buckets the same whether the caller sent it as a number or as text, and a
50
+ * structure is reported as no subject instead of hashing as one.
51
+ */
52
+ function isSubject(value) {
53
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
54
+ }
55
+ /**
56
+ * Walks the arms in variant-name order, which is the only order two isolates
57
+ * are guaranteed to agree on: a store, an editor or a JSON round trip is free
58
+ * to hand the same weights back with their keys in another sequence.
59
+ */
60
+ function orderedWeights(weights) {
61
+ return Object.keys(weights)
62
+ .sort()
63
+ .map((variant) => [variant, weights[variant] ?? 0]);
64
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Turns a stored definition set into the snapshot the engine evaluates
3
+ * against. Each flag is parsed on its own, so a mistyped rule costs the flag
4
+ * someone just edited and every sibling in the set still resolves.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { FlagSnapshot } from "./snapshot.js";
10
+ import type { StoredFlagSet } from "./store/index.js";
11
+ /**
12
+ * Reads a stored set into the snapshot evaluation runs against, answering for
13
+ * any input at all: a flag that cannot be parsed lands in `failures` under its
14
+ * own key with the reason, and every other flag lands in `flags` ready to
15
+ * evaluate.
16
+ *
17
+ * @param stored The set as a `FlagStore` handed it over, validated by nobody yet.
18
+ * @returns A snapshot stamped with the time it was built and the store's revision.
19
+ *
20
+ * @example
21
+ * let snapshot = parseFlagSet({ flags: { beta: { variants: { on: true } } } });
22
+ */
23
+ export declare function parseFlagSet(stored: StoredFlagSet): FlagSnapshot;
package/dist/parse.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Turns a stored definition set into the snapshot the engine evaluates
3
+ * against. Each flag is parsed on its own, so a mistyped rule costs the flag
4
+ * someone just edited and every sibling in the set still resolves.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure, isSuccess, success, wrap } from "@sdxc/result";
10
+ import * as s from "remix/data-schema";
11
+ import { CONDITION_SCHEMA, FLAG_DEFINITION_SCHEMA } from "./schema.js";
12
+ /** Names the position an issue was raised at, whichever form the segment takes. */
13
+ function segmentKey(segment) {
14
+ return String(typeof segment === "object" ? segment.key : segment);
15
+ }
16
+ /**
17
+ * Reads a value against a schema and reports the refusal as one message, so
18
+ * every failure in this module is an `Error` a snapshot can record verbatim.
19
+ */
20
+ function parseInto(schema, value) {
21
+ let result = s.parseSafe(schema, value);
22
+ if (result.success)
23
+ return success(result.value);
24
+ let message = result.issues
25
+ .map((issue) => {
26
+ let path = (issue.path ?? []).map(segmentKey).join(".");
27
+ return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
28
+ })
29
+ .join("; ");
30
+ return failure(new Error(message.length > 0 ? message : "Expected a flag definition"));
31
+ }
32
+ /** Reads the entries of whatever a store handed over, treating anything else as empty. */
33
+ function entriesOf(value) {
34
+ if (typeof value !== "object" || value === null)
35
+ return [];
36
+ return Object.entries(value);
37
+ }
38
+ /**
39
+ * Reads the segment map one entry at a time. A segment that does not parse is
40
+ * absent from the result, which reaches the flags referencing it as an unknown
41
+ * name and leaves the rest of the set untouched.
42
+ */
43
+ function readSegments(segments) {
44
+ let declared = new Map();
45
+ for (let [name, value] of entriesOf(segments)) {
46
+ let parsed = parseInto(CONDITION_SCHEMA, value);
47
+ if (isSuccess(parsed))
48
+ declared.set(name, parsed.data);
49
+ }
50
+ return declared;
51
+ }
52
+ /**
53
+ * Resolves one segment to the condition it stands for, memoizing it so twenty
54
+ * flags naming it share one compiled tree. `visiting` holds the chain currently
55
+ * being walked, which is what turns a cycle into a failure here rather than an
56
+ * infinite walk on the first request that reaches it.
57
+ */
58
+ function resolveSegment(name, declared, resolved, visiting) {
59
+ let already = resolved.get(name);
60
+ if (already !== undefined)
61
+ return success(already);
62
+ if (visiting.has(name)) {
63
+ return failure(new Error(`Segment "${name}" takes part in a reference cycle`));
64
+ }
65
+ let condition = declared.get(name);
66
+ if (condition === undefined)
67
+ return failure(new Error(`Unknown segment "${name}"`));
68
+ visiting.add(name);
69
+ let compiled = compileCondition(condition, declared, resolved, visiting);
70
+ visiting.delete(name);
71
+ if (isFailure(compiled))
72
+ return compiled;
73
+ resolved.set(name, compiled.data);
74
+ return compiled;
75
+ }
76
+ /**
77
+ * Compiles the work evaluation would otherwise repeat: a pattern becomes an
78
+ * expression the `v` flag accepts, and a segment carries the condition it
79
+ * names. Every other operator is already in its evaluable form.
80
+ */
81
+ function compileCondition(condition, declared, resolved, visiting) {
82
+ switch (condition.op) {
83
+ case "all":
84
+ case "any": {
85
+ let of = [];
86
+ for (let member of condition.of) {
87
+ let compiled = compileCondition(member, declared, resolved, visiting);
88
+ if (isFailure(compiled))
89
+ return compiled;
90
+ of.push(compiled.data);
91
+ }
92
+ if (condition.op === "all")
93
+ return success({ op: "all", of });
94
+ return success({ op: "any", of });
95
+ }
96
+ case "not": {
97
+ let compiled = compileCondition(condition.of, declared, resolved, visiting);
98
+ if (isFailure(compiled))
99
+ return compiled;
100
+ return success({ op: "not", of: compiled.data });
101
+ }
102
+ case "matches": {
103
+ let pattern = wrap(() => new RegExp(condition.pattern, "v"));
104
+ if (isFailure(pattern)) {
105
+ return failure(new Error(`Pattern ${JSON.stringify(condition.pattern)} does not compile`, {
106
+ cause: pattern.error,
107
+ }));
108
+ }
109
+ return success({ op: "matches", field: condition.field, pattern: pattern.data });
110
+ }
111
+ case "segment": {
112
+ let segment = resolveSegment(condition.name, declared, resolved, visiting);
113
+ if (isFailure(segment))
114
+ return segment;
115
+ return success({ op: "segment", name: condition.name, of: segment.data });
116
+ }
117
+ default:
118
+ return success(condition);
119
+ }
120
+ }
121
+ /**
122
+ * Checks that a rule can only select a value the flag holds, so evaluation
123
+ * reaches a variant lookup that hits rather than a reason table entry for a
124
+ * name nobody declared.
125
+ */
126
+ function checkServe(serve, variants) {
127
+ if (typeof serve === "string") {
128
+ if (variants.has(serve))
129
+ return success(serve);
130
+ return failure(new Error(`Rule serves "${serve}", which the flag does not declare`));
131
+ }
132
+ for (let name of Object.keys(serve.weights)) {
133
+ if (variants.has(name))
134
+ continue;
135
+ return failure(new Error(`Split weight "${name}" does not name a declared variant`));
136
+ }
137
+ return success(serve);
138
+ }
139
+ /**
140
+ * Parses one flag and compiles its rules. Every refusal here is the whole flag,
141
+ * because a definition half of whose rules were dropped would resolve subjects
142
+ * to variants its author never chose.
143
+ */
144
+ function compileFlag(key, value, declared, resolved) {
145
+ let parsed = parseInto(FLAG_DEFINITION_SCHEMA, value);
146
+ if (isFailure(parsed))
147
+ return parsed;
148
+ let definition = parsed.data;
149
+ let variants = new Map(Object.entries(definition.variants));
150
+ if (definition.defaultVariant !== undefined && !variants.has(definition.defaultVariant)) {
151
+ let name = definition.defaultVariant;
152
+ return failure(new Error(`defaultVariant "${name}" is not a declared variant`));
153
+ }
154
+ let targeting = [];
155
+ for (let rule of definition.targeting ?? []) {
156
+ let when = compileCondition(rule.when, declared, resolved, new Set());
157
+ if (isFailure(when))
158
+ return when;
159
+ let serve = checkServe(rule.serve, variants);
160
+ if (isFailure(serve))
161
+ return serve;
162
+ targeting.push({ when: when.data, serve: serve.data });
163
+ }
164
+ return success({
165
+ key,
166
+ variants,
167
+ defaultVariant: definition.defaultVariant,
168
+ state: definition.state ?? "enabled",
169
+ targeting,
170
+ metadata: definition.metadata,
171
+ });
172
+ }
173
+ /**
174
+ * Reads a stored set into the snapshot evaluation runs against, answering for
175
+ * any input at all: a flag that cannot be parsed lands in `failures` under its
176
+ * own key with the reason, and every other flag lands in `flags` ready to
177
+ * evaluate.
178
+ *
179
+ * @param stored The set as a `FlagStore` handed it over, validated by nobody yet.
180
+ * @returns A snapshot stamped with the time it was built and the store's revision.
181
+ *
182
+ * @example
183
+ * let snapshot = parseFlagSet({ flags: { beta: { variants: { on: true } } } });
184
+ */
185
+ export function parseFlagSet(stored) {
186
+ let declared = readSegments(stored.segments);
187
+ let resolved = new Map();
188
+ for (let name of declared.keys())
189
+ resolveSegment(name, declared, resolved, new Set());
190
+ let flags = new Map();
191
+ let failures = new Map();
192
+ for (let [key, value] of entriesOf(stored.flags)) {
193
+ let compiled = compileFlag(key, value, declared, resolved);
194
+ if (isFailure(compiled))
195
+ failures.set(key, { key, message: compiled.error.message });
196
+ else
197
+ flags.set(key, compiled.data);
198
+ }
199
+ return { flags, failures, segments: resolved, version: stored.version, createdAt: Date.now() };
200
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The adapter that makes an engine answer as a flag provider: a lifecycle over
3
+ * `load`, four resolvers over `evaluate`, and the status an application already
4
+ * subscribes to. It is the one module here that knows OpenFeature.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { EvaluationContext, ProviderMetadata, ResolutionDetails } from "@sdxc/flags";
10
+ import type { Provider } from "@sdxc/flags/provider";
11
+ import type { Result } from "@sdxc/result";
12
+ import type { JSONValue } from "@sdxc/types";
13
+ import { ProviderEvents } from "@sdxc/flags/provider";
14
+ import type { Engine } from "../engine.js";
15
+ import type { FlagSnapshot } from "../snapshot.js";
16
+ import type { FlagStoreError } from "../store/index.js";
17
+ /**
18
+ * Resolves flags through an engine the caller builds and reloads, so one
19
+ * snapshot serves this provider and whatever else evaluates against the same
20
+ * engine, and an application registers a whole rule set as one provider.
21
+ *
22
+ * @example
23
+ * let provider = new EngineProvider(createEngine({ store: new WorkerKVFlagStore(env.FLAGS) }));
24
+ */
25
+ export declare class EngineProvider implements Provider {
26
+ #private;
27
+ readonly metadata: ProviderMetadata;
28
+ readonly events: ProviderEvents;
29
+ /**
30
+ * @param engine The engine every resolution reads, loaded by `initialize` and
31
+ * reloaded by `refresh`.
32
+ */
33
+ constructor(engine: Engine);
34
+ /**
35
+ * Reads the definitions once, so every resolution that follows walks memory,
36
+ * and announces readiness before it returns.
37
+ *
38
+ * @throws ProviderError When the store could not hand its set over, which is a
39
+ * configuration failure announced on the status channel first.
40
+ */
41
+ initialize(_context?: EvaluationContext, _domain?: string): Promise<void>;
42
+ /**
43
+ * Reads the definitions again, on whatever schedule the caller keeps, and
44
+ * names every key either the outgoing set or the incoming one answers for, so
45
+ * a listener caching per key drops both sides of a whole-set replacement.
46
+ *
47
+ * @returns The snapshot now held, or why the store could not hand its set over.
48
+ */
49
+ refresh(): Promise<Result<FlagSnapshot, FlagStoreError>>;
50
+ resolveBoolean(key: string, defaultValue: boolean, context?: EvaluationContext): ResolutionDetails<boolean>;
51
+ resolveString(key: string, defaultValue: string, context?: EvaluationContext): ResolutionDetails<string>;
52
+ resolveNumber(key: string, defaultValue: number, context?: EvaluationContext): ResolutionDetails<number>;
53
+ resolveObject(key: string, defaultValue: JSONValue, context?: EvaluationContext): ResolutionDetails<JSONValue>;
54
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The adapter that makes an engine answer as a flag provider: a lifecycle over
3
+ * `load`, four resolvers over `evaluate`, and the status an application already
4
+ * subscribes to. It is the one module here that knows OpenFeature.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { ProviderError, ProviderEvents } from "@sdxc/flags/provider";
10
+ import { isFailure } from "@sdxc/result";
11
+ /** What a store's own failure is called once it reaches the evaluation surface. */
12
+ const STORE_ERROR_CODES = {
13
+ unavailable: "GENERAL",
14
+ invalid_value: "PARSE_ERROR",
15
+ };
16
+ /**
17
+ * Resolves flags through an engine the caller builds and reloads, so one
18
+ * snapshot serves this provider and whatever else evaluates against the same
19
+ * engine, and an application registers a whole rule set as one provider.
20
+ *
21
+ * @example
22
+ * let provider = new EngineProvider(createEngine({ store: new WorkerKVFlagStore(env.FLAGS) }));
23
+ */
24
+ export class EngineProvider {
25
+ metadata = { name: "flags-engine" };
26
+ events = new ProviderEvents();
27
+ #engine;
28
+ /**
29
+ * Whether the status channel has been told the definitions aged, so one aging
30
+ * is one announcement and the reload that ends it says so once.
31
+ */
32
+ #announced = false;
33
+ /**
34
+ * @param engine The engine every resolution reads, loaded by `initialize` and
35
+ * reloaded by `refresh`.
36
+ */
37
+ constructor(engine) {
38
+ this.#engine = engine;
39
+ }
40
+ /**
41
+ * Reads the definitions once, so every resolution that follows walks memory,
42
+ * and announces readiness before it returns.
43
+ *
44
+ * @throws ProviderError When the store could not hand its set over, which is a
45
+ * configuration failure announced on the status channel first.
46
+ */
47
+ async initialize(_context = {}, _domain) {
48
+ let result = await this.#engine.load();
49
+ if (isFailure(result)) {
50
+ let errorCode = STORE_ERROR_CODES[result.error.code];
51
+ let message = result.error.message;
52
+ this.events.emit("PROVIDER_ERROR", { errorCode, message });
53
+ throw new ProviderError(errorCode, message, { cause: result.error });
54
+ }
55
+ this.#announced = false;
56
+ this.events.emit("PROVIDER_READY");
57
+ }
58
+ /**
59
+ * Reads the definitions again, on whatever schedule the caller keeps, and
60
+ * names every key either the outgoing set or the incoming one answers for, so
61
+ * a listener caching per key drops both sides of a whole-set replacement.
62
+ *
63
+ * @returns The snapshot now held, or why the store could not hand its set over.
64
+ */
65
+ async refresh() {
66
+ let previous = this.#engine.snapshot;
67
+ let result = await this.#engine.load();
68
+ if (isFailure(result)) {
69
+ this.#announceStale();
70
+ return result;
71
+ }
72
+ if (this.#announced) {
73
+ this.#announced = false;
74
+ this.events.emit("PROVIDER_READY");
75
+ }
76
+ this.events.emit("PROVIDER_CONFIGURATION_CHANGED", {
77
+ flagsChanged: [...new Set([...answeredBy(previous), ...answeredBy(result.data)])],
78
+ });
79
+ return result;
80
+ }
81
+ resolveBoolean(key, defaultValue, context = {}) {
82
+ return this.#engine.evaluate(key, defaultValue, context);
83
+ }
84
+ resolveString(key, defaultValue, context = {}) {
85
+ return this.#engine.evaluate(key, defaultValue, context);
86
+ }
87
+ resolveNumber(key, defaultValue, context = {}) {
88
+ return this.#engine.evaluate(key, defaultValue, context);
89
+ }
90
+ resolveObject(key, defaultValue, context = {}) {
91
+ return this.#engine.evaluate(key, defaultValue, context);
92
+ }
93
+ /**
94
+ * Says the definitions are aging at the one moment the provider reads the
95
+ * engine away from the evaluation path: a reload that kept a snapshot already
96
+ * past `maxAge`, which is where an age a caller can act on becomes visible.
97
+ */
98
+ #announceStale() {
99
+ if (this.#announced)
100
+ return;
101
+ if (this.#engine.snapshot === undefined || !this.#engine.stale)
102
+ return;
103
+ this.#announced = true;
104
+ this.events.emit("PROVIDER_STALE", {
105
+ message: "Serving definitions older than maxAge until a reload replaces them",
106
+ });
107
+ }
108
+ }
109
+ /**
110
+ * Every key a snapshot has an answer for, the definitions it refused included,
111
+ * since a flag that failed to parse resolves as `PARSE_ERROR` under its own key.
112
+ */
113
+ function answeredBy(snapshot) {
114
+ if (snapshot === undefined)
115
+ return [];
116
+ return [...snapshot.flags.keys(), ...snapshot.failures.keys()];
117
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The parsers that turn stored, untrusted JSON into definitions. They are the
3
+ * package's published contract for what a flag is, so an admin UI validates a
4
+ * rule against the same schema before writing what the engine would refuse.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Schema } from "remix/data-schema";
10
+ import type { Condition, FlagDefinition, SegmentSet, Split, TargetingRule } from "./definition.js";
11
+ /**
12
+ * Reads one targeting condition. Annotated rather than inferred because
13
+ * `s.variant` widens the discriminant it merges, which would cost every
14
+ * consumer the narrowing the union exists for.
15
+ */
16
+ export declare const CONDITION_SCHEMA: Schema<unknown, Condition>;
17
+ /** Reads the weighted spread a rule serves instead of naming one variant. */
18
+ export declare const SPLIT_SCHEMA: Schema<unknown, Split>;
19
+ /** Reads one targeting row, whose `serve` is either a variant name or a split. */
20
+ export declare const TARGETING_RULE_SCHEMA: Schema<unknown, TargetingRule>;
21
+ /**
22
+ * Reads one flag. A flag with no variants is refused here, so every later step
23
+ * is choosing among values that exist.
24
+ */
25
+ export declare const FLAG_DEFINITION_SCHEMA: Schema<unknown, FlagDefinition>;
26
+ /**
27
+ * Reads a whole segment map at once, for an editor validating what it is about
28
+ * to write. The engine reads segments one at a time so a bad one costs only the
29
+ * flags that reference it.
30
+ */
31
+ export declare const SEGMENT_SET_SCHEMA: Schema<unknown, SegmentSet>;