@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,638 @@
1
+ # @sdxc/flags-engine
2
+
3
+ Flag evaluation engine: typed targeting rules, percentage splits and pluggable stores.
4
+
5
+ A flag here is a set of variants, an ordered list of rules about who sees which one, and a
6
+ percentage. This package holds that definition format and resolves it: a definition set and an
7
+ evaluation context in, a `ResolutionDetails` out. Every outcome travels back on those details,
8
+ the failures included, so the evaluation path has one shape.
9
+
10
+ Evaluation is a pure, synchronous function, which is what lets the same call answer a provider,
11
+ an HTTP endpoint and an admin preview. Definitions arrive through a one-method `FlagStore`, so
12
+ where they live stays yours to choose.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm add @sdxc/flags-engine @sdxc/flags
18
+ ```
19
+
20
+ [`@sdxc/flags`](https://www.npmjs.com/package/@sdxc/flags) is what an application evaluates
21
+ through, and it supplies the vocabulary this package answers in: the evaluation context, the
22
+ resolution details, the reasons and the error codes.
23
+ [`vitest`](https://www.npmjs.com/package/vitest) is an optional peer, needed by the conformance
24
+ suite alone.
25
+
26
+ ## Usage
27
+
28
+ ### Resolving against a set in memory
29
+
30
+ An engine reads a store once, keeps the parsed snapshot, and resolves every later flag against
31
+ it:
32
+
33
+ ```typescript
34
+ import { createEngine } from "@sdxc/flags-engine";
35
+ import { InMemoryFlagStore } from "@sdxc/flags-engine/store/memory";
36
+
37
+ let engine = createEngine({
38
+ store: new InMemoryFlagStore({
39
+ flags: {
40
+ "new-checkout": { variants: { on: true, off: false }, defaultVariant: "off" },
41
+ },
42
+ }),
43
+ });
44
+
45
+ await engine.load();
46
+
47
+ engine.evaluate("new-checkout", false);
48
+ // { value: false, reason: "STATIC", variant: "off" }
49
+ ```
50
+
51
+ ### Targeting a rule
52
+
53
+ Rules are tried in the order they were written, and the first one whose condition holds decides
54
+ the variant:
55
+
56
+ ```typescript
57
+ let engine = createEngine({
58
+ store: new InMemoryFlagStore({
59
+ flags: {
60
+ "new-checkout": {
61
+ variants: { on: true, off: false },
62
+ defaultVariant: "off",
63
+ targeting: [{ when: { op: "eq", field: "plan.tier", value: "pro" }, serve: "on" }],
64
+ },
65
+ },
66
+ }),
67
+ });
68
+
69
+ await engine.load();
70
+
71
+ engine.evaluate("new-checkout", false, { targetingKey: "user-1", plan: { tier: "pro" } });
72
+ // { value: true, reason: "TARGETING_MATCH", variant: "on" }
73
+ ```
74
+
75
+ ### Rolling out to a percentage
76
+
77
+ A rule serving weights buckets its subjects among the arms instead of naming one:
78
+
79
+ ```typescript
80
+ let engine = createEngine({
81
+ store: new InMemoryFlagStore({
82
+ flags: {
83
+ "new-checkout": {
84
+ variants: { on: true, off: false },
85
+ defaultVariant: "off",
86
+ targeting: [{ when: { op: "always" }, serve: { weights: { on: 10, off: 90 } } }],
87
+ },
88
+ },
89
+ }),
90
+ });
91
+
92
+ await engine.load();
93
+
94
+ engine.evaluate("new-checkout", false, { targetingKey: "user-1" });
95
+ // { value: …, reason: "SPLIT", variant: … } — the same arm for "user-1" on every request
96
+ ```
97
+
98
+ ### Serving flags through `@sdxc/flags`
99
+
100
+ `EngineProvider` is the adapter from an engine onto the `Provider` interface an application
101
+ evaluates through:
102
+
103
+ ```typescript
104
+ import { createFlags } from "@sdxc/flags/client";
105
+ import { createEngine } from "@sdxc/flags-engine";
106
+ import { EngineProvider } from "@sdxc/flags-engine/provider";
107
+ import { WorkerKVFlagStore } from "@sdxc/flags-engine/store/worker-kv";
108
+
109
+ export let flags = createFlags({
110
+ provider: () =>
111
+ new EngineProvider(
112
+ createEngine({ store: new WorkerKVFlagStore(env.FLAGS), maxAge: "5 minutes" }),
113
+ ),
114
+ });
115
+
116
+ let client = flags.getClient();
117
+
118
+ await client.boolean("new-checkout", false, { targetingKey: "user-1" });
119
+ ```
120
+
121
+ ## API
122
+
123
+ A path says what a module is: everything under `/store` is the one storage contract, and
124
+ `/provider` sits beside it because it consumes an engine rather than supplying one.
125
+
126
+ | Entry point | Contains |
127
+ | ------------------------------------ | ---------------------------------------------------------------- |
128
+ | `@sdxc/flags-engine` | The definition types, their schema, `evaluate` and `evaluateAll` |
129
+ | `@sdxc/flags-engine/store` | The `FlagStore` interface and `FlagStoreError` |
130
+ | `@sdxc/flags-engine/store/memory` | `InMemoryFlagStore` |
131
+ | `@sdxc/flags-engine/store/worker-kv` | A store over a Cloudflare KV namespace |
132
+ | `@sdxc/flags-engine/conformance` | The suite every store runs |
133
+ | `@sdxc/flags-engine/provider` | `EngineProvider`, the adapter onto `@sdxc/flags` |
134
+
135
+ ### `FlagSet` and `FlagDefinition`
136
+
137
+ ```typescript
138
+ interface FlagSet {
139
+ flags: Record<string, FlagDefinition>;
140
+ segments?: SegmentSet;
141
+ }
142
+
143
+ interface FlagDefinition {
144
+ variants: Record<string, FlagValue>;
145
+ defaultVariant?: string;
146
+ state?: "enabled" | "disabled";
147
+ targeting?: TargetingRule[];
148
+ metadata?: FlagMetadata;
149
+ }
150
+ ```
151
+
152
+ `variants` holds every value the flag can take, by name, and a flag declares at least one.
153
+ `targeting` is tried in order and the first match decides; when none matches, `defaultVariant`
154
+ is served. A flag declaring no `defaultVariant` answers with the value the caller passed, which
155
+ is how a flag exists to target a few subjects and leave everyone else on their own default. A
156
+ flag at `state: "disabled"` serves the caller's default too, so it is switched off with the
157
+ rules describing its rollout still written down. `metadata` travels onto every resolution of the
158
+ flag. The `state` union is exported as `FlagState`.
159
+
160
+ ### `SegmentSet`
161
+
162
+ ```typescript
163
+ type SegmentSet = Record<string, Condition>;
164
+ ```
165
+
166
+ Conditions declared once and referenced by name, so "is an internal user" is written in one
167
+ place and read by twenty flags. A segment may reference another segment.
168
+
169
+ ### `TargetingRule` and `Condition`
170
+
171
+ ```typescript
172
+ interface TargetingRule {
173
+ when: Condition;
174
+ serve: string | Split;
175
+ }
176
+ ```
177
+
178
+ `serve` is a variant name or the weights the rule buckets its subjects among.
179
+
180
+ Targeting is a typed union rather than an expression language, so an editor completes the
181
+ operator and a definition narrows on `op`:
182
+
183
+ | Condition | Holds when |
184
+ | ------------------------------------------------------ | ------------------------------------------------------ |
185
+ | `{ op: "all", of: Condition[] }` | Every member holds |
186
+ | `{ op: "any", of: Condition[] }` | Some member holds |
187
+ | `{ op: "not", of: Condition }` | The member fails |
188
+ | `{ op: "eq" \| "ne", field, value }` | The field equals, or differs from, a JSON primitive |
189
+ | `{ op: "in" \| "notIn", field, values }` | The list carries, or omits, the field's value |
190
+ | `{ op: "lt" \| "lte" \| "gt" \| "gte", field, value }` | The field is a number standing in that relation |
191
+ | `{ op: "startsWith" \| "endsWith", field, value }` | The field is a string with that prefix or suffix |
192
+ | `{ op: "contains", field, value }` | The field is a string carrying that substring |
193
+ | `{ op: "matches", field, pattern }` | The field is a string the pattern matches |
194
+ | `{ op: "semver", field, compare, value }` | The field is a version standing in that relation |
195
+ | `{ op: "exists", field }` | The path resolves to a value |
196
+ | `{ op: "segment", name }` | The named segment's condition holds |
197
+ | `{ op: "always" }` | Every subject, which is what a blanket rollout matches |
198
+
199
+ `field` is a dotted path into the merged evaluation context, so `plan.tier` reads a nested
200
+ structure, `country` a scalar and `roles.0` an array element. A path that resolves to nothing
201
+ makes every operator except `exists` false, which keeps a rule about a field the caller left out
202
+ from matching everyone; `not` around such a rule holds, as it does around any condition that
203
+ fails.
204
+
205
+ Every operator compares within one type. `eq` on a string field against a number is false rather
206
+ than coerced, and `lt` against a value that is not a number is false.
207
+
208
+ `matches` compiles its pattern with the `v` flag, and a pattern that compiles elsewhere fails
209
+ its own flag at parse time.
210
+
211
+ ### `SemVerComparison`
212
+
213
+ ```typescript
214
+ type SemVerComparison = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "^";
215
+ ```
216
+
217
+ How `semver` compares a version field against its value: `~` holds for a matching major and
218
+ minor, `^` for a matching major, and the other six read as they do in arithmetic.
219
+
220
+ ### `Split`
221
+
222
+ ```typescript
223
+ interface Split {
224
+ weights: Record<string, number>;
225
+ by?: string;
226
+ seed?: string;
227
+ }
228
+ ```
229
+
230
+ `weights` are whole, non-negative numbers taken against their own sum, so `{ on: 1, off: 1 }` is
231
+ a half-and-half split and one arm widens while the rest stay as written. Subjects are read from
232
+ `by`, defaulting to `targetingKey`, and hashed with the flag key — so the same subject lands in
233
+ the same arm on every request, in every isolate, and two flags at ten percent cover different
234
+ tenths. Two flags naming the same `seed` cover the same subjects instead.
235
+
236
+ A split needs a subject to hash. When the field it buckets on carries nothing, the evaluation
237
+ reports `TARGETING_KEY_MISSING` with the caller's default, so a rollout that reached nobody
238
+ reads as one.
239
+
240
+ ### Reasons
241
+
242
+ Each outcome has exactly one cause, so a consumer reading `reason` alone tells a flag that is
243
+ off from a flag system that is broken:
244
+
245
+ | Outcome | `reason` | `errorCode` |
246
+ | ------------------------------------------------ | ----------------- | ----------------------- |
247
+ | No targeting, and a `defaultVariant` | `STATIC` | |
248
+ | A rule matched and named a variant | `TARGETING_MATCH` | |
249
+ | A rule matched and bucketed | `SPLIT` | |
250
+ | Targeting ran and no rule matched | `DEFAULT` | |
251
+ | No `defaultVariant` to fall back to | `DEFAULT` | |
252
+ | `state: "disabled"` | `DISABLED` | |
253
+ | No flag under that key | `ERROR` | `FLAG_NOT_FOUND` |
254
+ | The definition was refused at parse time | `ERROR` | `PARSE_ERROR` |
255
+ | The variant holds another type | `ERROR` | `TYPE_MISMATCH` |
256
+ | A split found no value under its bucketing field | `ERROR` | `TARGETING_KEY_MISSING` |
257
+ | An evaluation arrived before a load succeeded | `ERROR` | `PROVIDER_NOT_READY` |
258
+
259
+ ### `evaluate(snapshot, key, defaultValue, context?)`
260
+
261
+ Resolves one flag for one context, answering with the caller's own default whenever the
262
+ definition serves no value of the requested type. `defaultValue` is both the fallback and the
263
+ type every variant is checked against, so a string variant asked for as a boolean is
264
+ `TYPE_MISMATCH`.
265
+
266
+ ```typescript
267
+ import { evaluate, parseFlagSet } from "@sdxc/flags-engine";
268
+
269
+ let snapshot = parseFlagSet({ flags: { beta: { variants: { on: true, off: false } } } });
270
+
271
+ evaluate(snapshot, "beta", false, { targetingKey: "user-1" });
272
+ ```
273
+
274
+ ### `evaluateAll(snapshot, context?)`
275
+
276
+ Resolves every flag the snapshot carries, the refused ones included, for a caller with no
277
+ per-flag default to fall back on. A flag producing no value of its own reports `null` beside the
278
+ reason.
279
+
280
+ ```typescript
281
+ evaluateAll(snapshot, { targetingKey: "user-1" })["beta"];
282
+ // { value: true, reason: "STATIC", variant: "on" }
283
+ ```
284
+
285
+ ### `parseFlagSet(stored)`
286
+
287
+ Reads a `StoredFlagSet` into the `FlagSnapshot` evaluation runs against: segments resolved,
288
+ patterns compiled, variant names checked. It answers for any input at all, because each flag is
289
+ parsed on its own — a flag that is refused lands in `snapshot.failures` under its own key with
290
+ the reason, and every sibling lands in `snapshot.flags` ready to evaluate.
291
+
292
+ Parsing once per load is why the snapshot is a type of its own: evaluating a flag then walks a
293
+ structure already known to be well formed.
294
+
295
+ ### `createEngine(options)`
296
+
297
+ Builds an engine over a store, holding definitions once `load` succeeds.
298
+
299
+ ```typescript
300
+ let engine = createEngine({ store: new WorkerKVFlagStore(env.FLAGS), maxAge: "5 minutes" });
301
+ ```
302
+
303
+ - `options.store` — where definitions are read from, and the whole of what the engine knows
304
+ about storage.
305
+ - `options.maxAge` — how long a loaded snapshot counts as current, which is what `stale` is
306
+ measured against. Given none, a snapshot stays current until a caller loads another.
307
+
308
+ The options object is exported as `EngineOptions`.
309
+
310
+ ### `Engine`
311
+
312
+ ```typescript
313
+ interface Engine {
314
+ readonly snapshot: FlagSnapshot | undefined;
315
+ readonly failures: readonly FlagParseFailure[];
316
+ readonly stale: boolean;
317
+
318
+ load(): MaybePromise<Result<FlagSnapshot, FlagStoreError>>;
319
+ evaluate<T extends FlagValue>(
320
+ key: string,
321
+ defaultValue: T,
322
+ context?: EvaluationContext,
323
+ ): ResolutionDetails<T>;
324
+ evaluateAll(context?: EvaluationContext): Record<string, ResolutionDetails<FlagValue>>;
325
+ }
326
+ ```
327
+
328
+ `load` reads the store, parses the set once, and keeps the snapshot for every evaluation that
329
+ follows; a store reading synchronously loads synchronously, and the answer is `await`-able
330
+ either way. It reports a `FlagStoreError` when the store could not hand its set over, which is a
331
+ configuration failure a caller reports rather than a flag falling back.
332
+
333
+ `evaluate` and `evaluateAll` delegate to the pure functions against the held snapshot, answering
334
+ with the caller's default under `PROVIDER_NOT_READY` until a load succeeds.
335
+
336
+ `failures` lists what the held set carried and the engine refused, so a caller logs them once
337
+ after a load. `stale` says whether the snapshot has aged past `maxAge`, and reads `true` on a
338
+ fresh engine, so one that wants a load reads as one.
339
+
340
+ Reloading is the caller's to schedule — on the next request, inside `waitUntil`, or from a cron
341
+ trigger — because a Worker has a timer only while a request is in flight.
342
+
343
+ ### Snapshot types
344
+
345
+ `FlagSnapshot` carries `flags` and `failures` as maps keyed by flag key, the resolved
346
+ `segments`, the `version` the store called this revision, and the `createdAt` it was stamped at.
347
+ `CompiledFlag`, `CompiledRule`, `CompiledCondition` and `CompiledSegments` are the forms inside
348
+ it, and `FlagParseFailure` is a `{ key, message }` pair.
349
+
350
+ ### Schemas
351
+
352
+ `FLAG_DEFINITION_SCHEMA`, `TARGETING_RULE_SCHEMA`, `CONDITION_SCHEMA`, `SPLIT_SCHEMA` and
353
+ `SEGMENT_SET_SCHEMA` are the parsers that turn stored JSON into definitions, and they are the
354
+ published contract for what a flag is. An admin UI validates a rule against the same schema
355
+ before writing what the engine would refuse. Each is a
356
+ [Standard Schema](https://standardschema.dev), so it reads through whichever validation library
357
+ the caller already has.
358
+
359
+ ### `FlagStore` and `StoredFlagSet`
360
+
361
+ `@sdxc/flags-engine/store` — the contract between the engine and wherever definitions live.
362
+
363
+ ```typescript
364
+ interface FlagStore {
365
+ read(): MaybePromise<Result<StoredFlagSet, FlagStoreError>>;
366
+ }
367
+
368
+ interface StoredFlagSet {
369
+ flags: Record<string, unknown>;
370
+ segments?: Record<string, unknown>;
371
+ version?: string;
372
+ }
373
+ ```
374
+
375
+ One read that answers with the whole set, because segments are shared across flags and a store
376
+ is read once per snapshot. Values arrive as `unknown`: producing the JSON is the store's job and
377
+ deciding whether that JSON is a valid flag is the engine's, in one place against one schema.
378
+ `read` may answer synchronously, so a store already holding its set costs no promise.
379
+
380
+ Writing is each store's own business. Both shipped stores expose a `write`, which is where the
381
+ capability is used, and a store that is read-only — a JSON file bundled with the worker, a
382
+ remote endpoint — is a complete `FlagStore` as it stands.
383
+
384
+ ### `FlagStoreError`
385
+
386
+ ```typescript
387
+ new FlagStoreError("KV refused the get", { code: "unavailable", location: "flags", cause });
388
+ ```
389
+
390
+ The normalized reason a store could not hand over its set, carried by every failed read.
391
+ `code` is `"unavailable"` when the storage could not be reached or refused the read, and
392
+ `"invalid_value"` when it answered with something that is not JSON. `location` names where the
393
+ store looked, and whatever the underlying storage threw travels as `cause`.
394
+
395
+ An empty store is a success holding an empty set, so reaching this type means the definitions
396
+ exist somewhere and could not be obtained.
397
+
398
+ ### `InMemoryFlagStore`
399
+
400
+ `@sdxc/flags-engine/store/memory` — holds one definition set in an object, for tests and for a
401
+ set compiled into the worker that reads it.
402
+
403
+ ```typescript
404
+ let store = new InMemoryFlagStore({ flags: { beta: { variants: { on: true, off: false } } } });
405
+
406
+ store.read(); // synchronous, always a success
407
+ store.write({ flags: {} }); // replaces the whole set
408
+ ```
409
+
410
+ Every read hands over a copy, so a caller walking a set keeps reading what it read while the
411
+ store goes on being written to. Constructed without a set, it starts out empty.
412
+
413
+ ### `WorkerKVFlagStore`
414
+
415
+ `@sdxc/flags-engine/store/worker-kv` — keeps the whole definition set as one JSON value under
416
+ one key in a [Cloudflare KV](https://developers.cloudflare.com/kv/) namespace, so filling a
417
+ snapshot is a single `get` served from the edge cache.
418
+
419
+ ```typescript
420
+ let store = new WorkerKVFlagStore(env.FLAGS);
421
+ let scoped = new WorkerKVFlagStore(env.FLAGS, { key: "flags:staging" });
422
+
423
+ await store.read();
424
+ await store.write({ flags: { beta: { variants: { on: true, off: false } } } });
425
+ ```
426
+
427
+ - `options.key` — the key the whole set is stored under. Defaults to `"flags"`, and an
428
+ application holding more than one set gives each of them its own. It is readable back off the
429
+ instance as `store.key`.
430
+
431
+ A key holding nothing reads as an empty set, so a namespace an admin has yet to write to is a
432
+ working store. A value that is not a JSON object reports `invalid_value`, and a namespace that
433
+ refused the call reports `unavailable`. `write` replaces the whole value and reports the same
434
+ two codes.
435
+
436
+ ### `conformance(options)`
437
+
438
+ `@sdxc/flags-engine/conformance` — registers the suite that says what a flag store is, as Vitest
439
+ tests against whatever you construct. Run it against a store and the suite says whether it is
440
+ one.
441
+
442
+ ```typescript
443
+ import { conformance } from "@sdxc/flags-engine/conformance";
444
+ import { WorkerKVFlagStore } from "@sdxc/flags-engine/store/worker-kv";
445
+
446
+ conformance({
447
+ name: "WorkerKVFlagStore",
448
+ create: () => new WorkerKVFlagStore(env.FLAGS),
449
+ seed: (store, set) => env.FLAGS.put(store.key, JSON.stringify(set)),
450
+ write: (store, set) => store.write(set),
451
+ writeText: (store, text) => env.FLAGS.put(store.key, text),
452
+ });
453
+ ```
454
+
455
+ - `name` — labels the registered suite.
456
+ - `create` — builds the store under test, holding nothing. Called for every test, so a store
457
+ over shared storage points each one at a location of its own.
458
+ - `seed` — puts a set where the store reads it from, by whatever means the storage gives. A
459
+ read-only store seeds through the file, endpoint or object behind it.
460
+ - `write` — stores a set through the store's own write. Supplying it registers the round trip
461
+ assertions, which say a store reads back what it was told to hold.
462
+ - `writeText` — puts text where the store reads it from, in place of anything the store would
463
+ serialize. Supplying it registers the assertions about a value the store did not write.
464
+
465
+ ### `EngineProvider`
466
+
467
+ `@sdxc/flags-engine/provider` — implements the `Provider` interface of
468
+ [`@sdxc/flags`](https://www.npmjs.com/package/@sdxc/flags) over an engine and nothing else.
469
+
470
+ ```typescript
471
+ let provider = new EngineProvider(createEngine({ store, maxAge: "5 minutes" }));
472
+
473
+ await provider.initialize(); // PROVIDER_READY, or PROVIDER_ERROR and a throw
474
+ await provider.refresh(); // Result<FlagSnapshot, FlagStoreError>
475
+ ```
476
+
477
+ It holds no rules, no hash and no schema, which is the demonstration that the engine is usable
478
+ on its own: the four resolvers hand their type to `engine.evaluate` and return what it answers,
479
+ so a flag the engine reports as `PROVIDER_NOT_READY` or `TYPE_MISMATCH` arrives that way here.
480
+
481
+ - `initialize` loads the store and emits `PROVIDER_READY`. A store it cannot read emits
482
+ `PROVIDER_ERROR` and throws a `ProviderError` carrying the `FlagStoreError` as its `cause`,
483
+ coded `PARSE_ERROR` for a value that is not a set and `GENERAL` for a store out of reach.
484
+ - `refresh` loads again and emits `PROVIDER_CONFIGURATION_CHANGED`, naming every key either
485
+ snapshot answered for — definitions that failed to parse included, since those answer under
486
+ their own key too, so a per-key cache drops exactly what moved.
487
+ - `refresh` answers with a `Result` rather than throwing, which is what makes it callable from a
488
+ scheduled reload where a failure is something to log rather than to propagate.
489
+ - A reload that fails over an aged snapshot emits `PROVIDER_STALE` once, so an application reads
490
+ ageing definitions off the status channel. The next successful reload returns the provider to
491
+ `PROVIDER_READY`.
492
+
493
+ `metadata.name` is `"flags-engine"`.
494
+
495
+ ## Pattern: Reloading on a schedule you own
496
+
497
+ The engine holds a snapshot and reports `stale`; deciding when to read the store again is the
498
+ caller's, so put the reload wherever the runtime gives you time for it. Answering the request
499
+ from the snapshot in hand and refreshing behind the response keeps evaluation off the read:
500
+
501
+ ```typescript
502
+ let engine = createEngine({ store: new WorkerKVFlagStore(env.FLAGS), maxAge: "5 minutes" });
503
+
504
+ export default {
505
+ async fetch(request, env, ctx) {
506
+ if (engine.stale) ctx.waitUntil(Promise.resolve(engine.load()));
507
+
508
+ let enabled = engine.evaluate("new-checkout", false, { targetingKey: userId(request) });
509
+
510
+ return new Response(enabled.value ? "new" : "old");
511
+ },
512
+ };
513
+ ```
514
+
515
+ A cron trigger is the other placement: reload there, and every request reads a snapshot someone
516
+ else already paid for.
517
+
518
+ ## Pattern: Logging the definitions that were refused
519
+
520
+ A definition that is refused costs its own flag and leaves every sibling resolving. The refusals
521
+ sit on the engine after a load, which is where they are logged once rather than once per
522
+ evaluation of the key:
523
+
524
+ ```typescript
525
+ let loaded = await engine.load();
526
+
527
+ if (isFailure(loaded)) logger.error("flags unavailable", { cause: loaded.error });
528
+
529
+ for (let failure of engine.failures) {
530
+ logger.warn("flag definition refused", { key: failure.key, reason: failure.message });
531
+ }
532
+ ```
533
+
534
+ `isFailure` comes from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which is
535
+ what `load` answers with.
536
+
537
+ ## Pattern: A store of your own
538
+
539
+ The interface is one method, so a store against your own tables is a file rather than a project.
540
+ Scoping belongs in the store while it is there — one constructed for a tenant reads that
541
+ tenant's flags, and the engine never learns a tenant exists:
542
+
543
+ ```typescript
544
+ import type { FlagStore, StoredFlagSet } from "@sdxc/flags-engine/store";
545
+ import type { Result } from "@sdxc/result";
546
+
547
+ import { FlagStoreError } from "@sdxc/flags-engine/store";
548
+ import { failure, success } from "@sdxc/result";
549
+
550
+ export class TenantFlagStore implements FlagStore {
551
+ constructor(readonly tenantId: string) {}
552
+
553
+ async read(): Promise<Result<StoredFlagSet, FlagStoreError>> {
554
+ try {
555
+ let row = await db.flagSets.findByTenant(this.tenantId);
556
+
557
+ return success(row?.set ?? { flags: {} });
558
+ } catch (cause) {
559
+ return failure(
560
+ new FlagStoreError(`The database refused the read for ${this.tenantId}`, {
561
+ code: "unavailable",
562
+ location: this.tenantId,
563
+ cause,
564
+ }),
565
+ );
566
+ }
567
+ }
568
+ }
569
+ ```
570
+
571
+ Then point the conformance suite at it, giving each test a tenant of its own:
572
+
573
+ ```typescript
574
+ conformance({
575
+ name: "TenantFlagStore",
576
+ create: () => new TenantFlagStore(randomUUID()),
577
+ seed: (store, set) => db.flagSets.upsert(store.tenantId, set),
578
+ });
579
+ ```
580
+
581
+ ## Pattern: Previewing a rule before it ships
582
+
583
+ Evaluation is pure, so an admin UI answers "who would this rule serve" by parsing the edited set
584
+ and evaluating it against a context typed into a form — no store, no engine, nothing kept:
585
+
586
+ ```typescript
587
+ import type { EvaluationContext } from "@sdxc/flags";
588
+ import type { StoredFlagSet } from "@sdxc/flags-engine/store";
589
+
590
+ import { evaluateAll, parseFlagSet } from "@sdxc/flags-engine";
591
+
592
+ function preview(set: StoredFlagSet, context: EvaluationContext) {
593
+ return evaluateAll(parseFlagSet(set), context);
594
+ }
595
+
596
+ preview(edited, { targetingKey: "user-1", plan: { tier: "pro" } })["new-checkout"];
597
+ // { value: true, reason: "TARGETING_MATCH", variant: "on" }
598
+ ```
599
+
600
+ Validating the edit before it is stored uses the same schemas the engine parses with, so the UI
601
+ refuses exactly what the engine would:
602
+
603
+ ```typescript
604
+ import { FLAG_DEFINITION_SCHEMA } from "@sdxc/flags-engine";
605
+ import * as s from "remix/data-schema";
606
+
607
+ let checked = s.parseSafe(FLAG_DEFINITION_SCHEMA, draft);
608
+ ```
609
+
610
+ ## Versioning
611
+
612
+ Releases are dated rather than semantic. A version is the UTC date it was published, written
613
+ `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out
614
+ per day.
615
+
616
+ Those numbers say when, not what: a later date means a later release and carries no
617
+ compatibility promise. Any release may change or remove an export.
618
+
619
+ Depend on one exact date, and move it when you are ready to take the change:
620
+
621
+ ```json
622
+ {
623
+ "dependencies": {
624
+ "@sdxc/flags-engine": "2026.9.4"
625
+ }
626
+ }
627
+ ```
628
+
629
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later
630
+ release in the same year. An exact version keeps the upgrade yours to schedule.
631
+
632
+ ## License
633
+
634
+ MIT
635
+
636
+ ## Author
637
+
638
+ [Sergio Xalambrí](https://sergiodxa.com)