@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
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)
|