@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/dist/schema.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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 * as s from "remix/data-schema";
|
|
10
|
+
import { lazy } from "remix/data-schema/lazy";
|
|
11
|
+
/** Reads a value a condition compares against, rejecting anything JSON cannot carry. */
|
|
12
|
+
const JSON_PRIMITIVE_SCHEMA = s.union([
|
|
13
|
+
s.string(),
|
|
14
|
+
s.number(),
|
|
15
|
+
s.boolean(),
|
|
16
|
+
s.null_(),
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Reads a variant's value. Arrays are tried before records so a stored list
|
|
20
|
+
* keeps its shape, and the recursion is deferred because a structure variant
|
|
21
|
+
* nests without a declared depth.
|
|
22
|
+
*/
|
|
23
|
+
const FLAG_VALUE_SCHEMA = lazy(() => s.union([
|
|
24
|
+
s.string(),
|
|
25
|
+
s.number(),
|
|
26
|
+
s.boolean(),
|
|
27
|
+
s.null_(),
|
|
28
|
+
s.array(FLAG_VALUE_SCHEMA),
|
|
29
|
+
s.record(s.string(), FLAG_VALUE_SCHEMA),
|
|
30
|
+
]));
|
|
31
|
+
/** A dotted path into the evaluation context, which an empty string cannot name. */
|
|
32
|
+
const FIELD_SCHEMA = s.string().refine((field) => field.length > 0, "Expected a context field");
|
|
33
|
+
/**
|
|
34
|
+
* Holds a discriminant at the literal type it was written as, so the parsed
|
|
35
|
+
* condition narrows on `op` the way the union it belongs to promises.
|
|
36
|
+
*/
|
|
37
|
+
function operator(name) {
|
|
38
|
+
return s.literal(name);
|
|
39
|
+
}
|
|
40
|
+
/** The eight comparisons `semver` closes over, so nothing parses a range mini-language. */
|
|
41
|
+
const SEMVER_COMPARISON_SCHEMA = s.enum_(["=", "!=", "<", "<=", ">", ">=", "~", "^"]);
|
|
42
|
+
/**
|
|
43
|
+
* Reads one targeting condition. Annotated rather than inferred because
|
|
44
|
+
* `s.variant` widens the discriminant it merges, which would cost every
|
|
45
|
+
* consumer the narrowing the union exists for.
|
|
46
|
+
*/
|
|
47
|
+
export const CONDITION_SCHEMA = lazy(() => s.variant("op", {
|
|
48
|
+
all: s.object({ op: operator("all"), of: s.array(CONDITION_SCHEMA) }),
|
|
49
|
+
any: s.object({ op: operator("any"), of: s.array(CONDITION_SCHEMA) }),
|
|
50
|
+
not: s.object({ op: operator("not"), of: CONDITION_SCHEMA }),
|
|
51
|
+
eq: s.object({ op: operator("eq"), field: FIELD_SCHEMA, value: JSON_PRIMITIVE_SCHEMA }),
|
|
52
|
+
ne: s.object({ op: operator("ne"), field: FIELD_SCHEMA, value: JSON_PRIMITIVE_SCHEMA }),
|
|
53
|
+
in: s.object({
|
|
54
|
+
op: operator("in"),
|
|
55
|
+
field: FIELD_SCHEMA,
|
|
56
|
+
values: s.array(JSON_PRIMITIVE_SCHEMA),
|
|
57
|
+
}),
|
|
58
|
+
notIn: s.object({
|
|
59
|
+
op: operator("notIn"),
|
|
60
|
+
field: FIELD_SCHEMA,
|
|
61
|
+
values: s.array(JSON_PRIMITIVE_SCHEMA),
|
|
62
|
+
}),
|
|
63
|
+
lt: s.object({ op: operator("lt"), field: FIELD_SCHEMA, value: s.number() }),
|
|
64
|
+
lte: s.object({ op: operator("lte"), field: FIELD_SCHEMA, value: s.number() }),
|
|
65
|
+
gt: s.object({ op: operator("gt"), field: FIELD_SCHEMA, value: s.number() }),
|
|
66
|
+
gte: s.object({ op: operator("gte"), field: FIELD_SCHEMA, value: s.number() }),
|
|
67
|
+
startsWith: s.object({
|
|
68
|
+
op: operator("startsWith"),
|
|
69
|
+
field: FIELD_SCHEMA,
|
|
70
|
+
value: s.string(),
|
|
71
|
+
}),
|
|
72
|
+
endsWith: s.object({ op: operator("endsWith"), field: FIELD_SCHEMA, value: s.string() }),
|
|
73
|
+
contains: s.object({ op: operator("contains"), field: FIELD_SCHEMA, value: s.string() }),
|
|
74
|
+
matches: s.object({ op: operator("matches"), field: FIELD_SCHEMA, pattern: s.string() }),
|
|
75
|
+
semver: s.object({
|
|
76
|
+
op: operator("semver"),
|
|
77
|
+
field: FIELD_SCHEMA,
|
|
78
|
+
compare: SEMVER_COMPARISON_SCHEMA,
|
|
79
|
+
value: s.string(),
|
|
80
|
+
}),
|
|
81
|
+
exists: s.object({ op: operator("exists"), field: FIELD_SCHEMA }),
|
|
82
|
+
segment: s.object({ op: operator("segment"), name: s.string() }),
|
|
83
|
+
always: s.object({ op: operator("always") }),
|
|
84
|
+
}));
|
|
85
|
+
/** A weight, kept whole so a bucket is a count of shares rather than a fraction. */
|
|
86
|
+
const WEIGHT_SCHEMA = s
|
|
87
|
+
.number()
|
|
88
|
+
.refine((weight) => Number.isInteger(weight) && weight >= 0, "Expected a whole, non-negative weight");
|
|
89
|
+
/** Sums the weights so a split that could never select an arm is refused at parse time. */
|
|
90
|
+
function hasPositiveTotal(weights) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (let weight of Object.values(weights))
|
|
93
|
+
total += weight;
|
|
94
|
+
return total > 0;
|
|
95
|
+
}
|
|
96
|
+
/** Reads the weighted spread a rule serves instead of naming one variant. */
|
|
97
|
+
export const SPLIT_SCHEMA = s.object({
|
|
98
|
+
weights: s
|
|
99
|
+
.record(s.string(), WEIGHT_SCHEMA)
|
|
100
|
+
.refine(hasPositiveTotal, "Expected weights summing above zero"),
|
|
101
|
+
by: s.optional(FIELD_SCHEMA),
|
|
102
|
+
seed: s.optional(s.string()),
|
|
103
|
+
});
|
|
104
|
+
/** Reads one targeting row, whose `serve` is either a variant name or a split. */
|
|
105
|
+
export const TARGETING_RULE_SCHEMA = s.object({
|
|
106
|
+
when: CONDITION_SCHEMA,
|
|
107
|
+
serve: s.union([s.string(), SPLIT_SCHEMA]),
|
|
108
|
+
});
|
|
109
|
+
/** Reads the properties a resolution of this flag carries to whatever reads the event. */
|
|
110
|
+
const FLAG_METADATA_SCHEMA = s.record(s.string(), s.union([s.string(), s.number(), s.boolean()]));
|
|
111
|
+
/**
|
|
112
|
+
* Reads one flag. A flag with no variants is refused here, so every later step
|
|
113
|
+
* is choosing among values that exist.
|
|
114
|
+
*/
|
|
115
|
+
export const FLAG_DEFINITION_SCHEMA = s.object({
|
|
116
|
+
variants: s
|
|
117
|
+
.record(s.string(), FLAG_VALUE_SCHEMA)
|
|
118
|
+
.refine((variants) => Object.keys(variants).length > 0, "Expected at least one variant"),
|
|
119
|
+
defaultVariant: s.optional(s.string()),
|
|
120
|
+
state: s.optional(s.enum_(["enabled", "disabled"])),
|
|
121
|
+
targeting: s.optional(s.array(TARGETING_RULE_SCHEMA)),
|
|
122
|
+
metadata: s.optional(FLAG_METADATA_SCHEMA),
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Reads a whole segment map at once, for an editor validating what it is about
|
|
126
|
+
* to write. The engine reads segments one at a time so a bad one costs only the
|
|
127
|
+
* flags that reference it.
|
|
128
|
+
*/
|
|
129
|
+
export const SEGMENT_SET_SCHEMA = s.record(s.string(), CONDITION_SCHEMA);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form the engine holds a definition set in once it has been parsed:
|
|
3
|
+
* segments already resolved, patterns already compiled, variant names already
|
|
4
|
+
* checked, so evaluating a flag walks a structure known to be well formed.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { FlagMetadata, FlagValue } from "@sdxc/flags";
|
|
10
|
+
import type { JSONPrimitive } from "@sdxc/types";
|
|
11
|
+
import type { FlagState, SemVerComparison, Split } from "./definition.js";
|
|
12
|
+
/**
|
|
13
|
+
* A condition with the two parts evaluation cannot do cheaply already done: a
|
|
14
|
+
* `matches` pattern is a compiled expression, and a `segment` carries the
|
|
15
|
+
* condition it names, so no operator reaches back to the snapshot to resolve.
|
|
16
|
+
*/
|
|
17
|
+
export type CompiledCondition = {
|
|
18
|
+
op: "all";
|
|
19
|
+
of: CompiledCondition[];
|
|
20
|
+
} | {
|
|
21
|
+
op: "any";
|
|
22
|
+
of: CompiledCondition[];
|
|
23
|
+
} | {
|
|
24
|
+
op: "not";
|
|
25
|
+
of: CompiledCondition;
|
|
26
|
+
} | {
|
|
27
|
+
op: "eq" | "ne";
|
|
28
|
+
field: string;
|
|
29
|
+
value: JSONPrimitive;
|
|
30
|
+
} | {
|
|
31
|
+
op: "in" | "notIn";
|
|
32
|
+
field: string;
|
|
33
|
+
values: JSONPrimitive[];
|
|
34
|
+
} | {
|
|
35
|
+
op: "lt" | "lte" | "gt" | "gte";
|
|
36
|
+
field: string;
|
|
37
|
+
value: number;
|
|
38
|
+
} | {
|
|
39
|
+
op: "startsWith" | "endsWith" | "contains";
|
|
40
|
+
field: string;
|
|
41
|
+
value: string;
|
|
42
|
+
} | {
|
|
43
|
+
op: "matches";
|
|
44
|
+
field: string;
|
|
45
|
+
pattern: RegExp;
|
|
46
|
+
} | {
|
|
47
|
+
op: "semver";
|
|
48
|
+
field: string;
|
|
49
|
+
compare: SemVerComparison;
|
|
50
|
+
value: string;
|
|
51
|
+
} | {
|
|
52
|
+
op: "exists";
|
|
53
|
+
field: string;
|
|
54
|
+
} | {
|
|
55
|
+
op: "segment";
|
|
56
|
+
name: string;
|
|
57
|
+
of: CompiledCondition;
|
|
58
|
+
} | {
|
|
59
|
+
op: "always";
|
|
60
|
+
};
|
|
61
|
+
/** One targeting row, whose `serve` is known to name variants the flag declares. */
|
|
62
|
+
export interface CompiledRule {
|
|
63
|
+
when: CompiledCondition;
|
|
64
|
+
serve: string | Split;
|
|
65
|
+
}
|
|
66
|
+
/** Every segment that resolved, by the name a condition references it under. */
|
|
67
|
+
export type CompiledSegments = ReadonlyMap<string, CompiledCondition>;
|
|
68
|
+
/**
|
|
69
|
+
* A flag ready to evaluate. `defaultVariant`, every `serve` and every split
|
|
70
|
+
* weight name a variant in `variants`, so selecting a variant is a lookup that
|
|
71
|
+
* hits.
|
|
72
|
+
*/
|
|
73
|
+
export interface CompiledFlag {
|
|
74
|
+
key: string;
|
|
75
|
+
variants: ReadonlyMap<string, FlagValue>;
|
|
76
|
+
/** Absent when the flag falls back to the value the caller passed. */
|
|
77
|
+
defaultVariant?: string;
|
|
78
|
+
state: FlagState;
|
|
79
|
+
/** Empty when the flag serves its default unconditionally, which reads as `STATIC`. */
|
|
80
|
+
targeting: readonly CompiledRule[];
|
|
81
|
+
metadata?: FlagMetadata;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A definition the set carried and the engine refused. It is what every
|
|
85
|
+
* evaluation of that key answers with, as `PARSE_ERROR` and this message, and
|
|
86
|
+
* what a caller logs once after a load.
|
|
87
|
+
*/
|
|
88
|
+
export interface FlagParseFailure {
|
|
89
|
+
key: string;
|
|
90
|
+
/** Why the definition was refused, phrased for a log line or an `errorMessage`. */
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A parsed definition set, and the whole of what evaluation reads. A key is in
|
|
95
|
+
* `flags` or in `failures` and never both, so a broken definition answers for
|
|
96
|
+
* itself while every other flag in the set resolves.
|
|
97
|
+
*/
|
|
98
|
+
export interface FlagSnapshot {
|
|
99
|
+
flags: ReadonlyMap<string, CompiledFlag>;
|
|
100
|
+
failures: ReadonlyMap<string, FlagParseFailure>;
|
|
101
|
+
segments: CompiledSegments;
|
|
102
|
+
/** What the store called this revision, for a caller that caches snapshots. */
|
|
103
|
+
version?: string;
|
|
104
|
+
/** Epoch milliseconds this snapshot was stamped at, so staleness needs no clock to evaluate. */
|
|
105
|
+
createdAt: number;
|
|
106
|
+
}
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form the engine holds a definition set in once it has been parsed:
|
|
3
|
+
* segments already resolved, patterns already compiled, variant names already
|
|
4
|
+
* checked, so evaluating a flag walks a structure known to be well formed.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where definitions come from: one read that answers with the whole set, the
|
|
3
|
+
* shape it answers in, and the single failure type it reports. Implemented by
|
|
4
|
+
* every store, and the only thing the engine knows about storage.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { MaybePromise } from "@sdxc/flags";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
/**
|
|
12
|
+
* A definition set as a store holds it. Values arrive as `unknown` because
|
|
13
|
+
* producing the JSON is the store's job and deciding whether that JSON is a
|
|
14
|
+
* valid flag is the engine's, in one place against one schema.
|
|
15
|
+
*/
|
|
16
|
+
export interface StoredFlagSet {
|
|
17
|
+
flags: Record<string, unknown>;
|
|
18
|
+
segments?: Record<string, unknown>;
|
|
19
|
+
/** What the store calls this revision, for a caller that caches snapshots. */
|
|
20
|
+
version?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A source of flag definitions. The engine asks for everything at once because
|
|
24
|
+
* segments are shared across flags and a store is read once per snapshot, so
|
|
25
|
+
* the query that matters is the one that fills it.
|
|
26
|
+
*
|
|
27
|
+
* `read` may answer synchronously, so a store already holding its set in memory
|
|
28
|
+
* costs no promise.
|
|
29
|
+
*/
|
|
30
|
+
export interface FlagStore {
|
|
31
|
+
read(): MaybePromise<Result<StoredFlagSet, FlagStoreError>>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Normalized reason a store could not hand over its set. A caller branches on
|
|
35
|
+
* this; whatever the underlying storage threw travels alongside as `cause`.
|
|
36
|
+
*/
|
|
37
|
+
export type FlagStoreErrorCode =
|
|
38
|
+
/** The storage could not be reached, or refused the read. */
|
|
39
|
+
"unavailable"
|
|
40
|
+
/** The storage answered, and what it holds is not JSON. */
|
|
41
|
+
| "invalid_value";
|
|
42
|
+
/** What a store states about a failure when it constructs the error. */
|
|
43
|
+
export interface FlagStoreErrorOptions extends ErrorOptions {
|
|
44
|
+
code: FlagStoreErrorCode;
|
|
45
|
+
/** Where the store looked, such as the key a value was read from. */
|
|
46
|
+
location?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Failure carried by every `FlagStore` result.
|
|
50
|
+
*
|
|
51
|
+
* An empty store is a success holding an empty set, so reaching this type means
|
|
52
|
+
* the definitions exist somewhere and could not be obtained — a configuration
|
|
53
|
+
* failure a caller reports, rather than a flag falling back.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* failure(new FlagStoreError("KV refused the get", { code: "unavailable" }));
|
|
57
|
+
*/
|
|
58
|
+
export declare class FlagStoreError extends Error {
|
|
59
|
+
name: string;
|
|
60
|
+
readonly code: FlagStoreErrorCode;
|
|
61
|
+
/** Where the store looked, such as the key a value was read from. */
|
|
62
|
+
readonly location?: string;
|
|
63
|
+
/**
|
|
64
|
+
* @param message What went wrong, for a log or a rethrow.
|
|
65
|
+
* @param options The code, where the store looked, and the original error as `cause`.
|
|
66
|
+
*/
|
|
67
|
+
constructor(message: string, { code, location, ...options }: FlagStoreErrorOptions);
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where definitions come from: one read that answers with the whole set, the
|
|
3
|
+
* shape it answers in, and the single failure type it reports. Implemented by
|
|
4
|
+
* every store, and the only thing the engine knows about storage.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Failure carried by every `FlagStore` result.
|
|
11
|
+
*
|
|
12
|
+
* An empty store is a success holding an empty set, so reaching this type means
|
|
13
|
+
* the definitions exist somewhere and could not be obtained — a configuration
|
|
14
|
+
* failure a caller reports, rather than a flag falling back.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* failure(new FlagStoreError("KV refused the get", { code: "unavailable" }));
|
|
18
|
+
*/
|
|
19
|
+
export class FlagStoreError extends Error {
|
|
20
|
+
name = "FlagStoreError";
|
|
21
|
+
code;
|
|
22
|
+
/** Where the store looked, such as the key a value was read from. */
|
|
23
|
+
location;
|
|
24
|
+
/**
|
|
25
|
+
* @param message What went wrong, for a log or a rethrow.
|
|
26
|
+
* @param options The code, where the store looked, and the original error as `cause`.
|
|
27
|
+
*/
|
|
28
|
+
constructor(message, { code, location, ...options }) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.location = location;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store holding its definitions in an object, for tests and for a set compiled
|
|
3
|
+
* into the worker that reads it. It answers a read synchronously, so a caller
|
|
4
|
+
* whose flags are already in memory pays a function call to load them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { FlagStore, FlagStoreError, StoredFlagSet } from "./index.js";
|
|
11
|
+
/**
|
|
12
|
+
* Holds one definition set for the life of the instance.
|
|
13
|
+
*
|
|
14
|
+
* Every read hands over a copy, so a caller walking a set keeps reading what it
|
|
15
|
+
* read while the store goes on being written to.
|
|
16
|
+
*/
|
|
17
|
+
export declare class InMemoryFlagStore implements FlagStore {
|
|
18
|
+
#private;
|
|
19
|
+
/**
|
|
20
|
+
* @param set The definitions the store starts out holding, empty by default.
|
|
21
|
+
*/
|
|
22
|
+
constructor(set?: StoredFlagSet);
|
|
23
|
+
/**
|
|
24
|
+
* @returns The set being held, always as a success: definitions in memory are
|
|
25
|
+
* reachable by the code that holds them.
|
|
26
|
+
*/
|
|
27
|
+
read(): Result<StoredFlagSet, FlagStoreError>;
|
|
28
|
+
/**
|
|
29
|
+
* Replaces the whole set, which is the write a store backed by one value takes.
|
|
30
|
+
* It lives on the store that has the capability, so an admin path writes through
|
|
31
|
+
* the same object every reader reads through.
|
|
32
|
+
*
|
|
33
|
+
* @param set The definitions every later read answers with.
|
|
34
|
+
*/
|
|
35
|
+
write(set: StoredFlagSet): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store holding its definitions in an object, for tests and for a set compiled
|
|
3
|
+
* into the worker that reads it. It answers a read synchronously, so a caller
|
|
4
|
+
* whose flags are already in memory pays a function call to load them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { success } from "@sdxc/result";
|
|
10
|
+
/**
|
|
11
|
+
* Holds one definition set for the life of the instance.
|
|
12
|
+
*
|
|
13
|
+
* Every read hands over a copy, so a caller walking a set keeps reading what it
|
|
14
|
+
* read while the store goes on being written to.
|
|
15
|
+
*/
|
|
16
|
+
export class InMemoryFlagStore {
|
|
17
|
+
#set;
|
|
18
|
+
/**
|
|
19
|
+
* @param set The definitions the store starts out holding, empty by default.
|
|
20
|
+
*/
|
|
21
|
+
constructor(set = { flags: {} }) {
|
|
22
|
+
this.#set = set;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* @returns The set being held, always as a success: definitions in memory are
|
|
26
|
+
* reachable by the code that holds them.
|
|
27
|
+
*/
|
|
28
|
+
read() {
|
|
29
|
+
return success(structuredClone(this.#set));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Replaces the whole set, which is the write a store backed by one value takes.
|
|
33
|
+
* It lives on the store that has the capability, so an admin path writes through
|
|
34
|
+
* the same object every reader reads through.
|
|
35
|
+
*
|
|
36
|
+
* @param set The definitions every later read answers with.
|
|
37
|
+
*/
|
|
38
|
+
write(set) {
|
|
39
|
+
this.#set = set;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store over a Cloudflare KV namespace, holding the whole definition set as one
|
|
3
|
+
* JSON value under one key. Filling a snapshot is a single `get` served from the
|
|
4
|
+
* edge cache, and an admin write rewrites the value.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { FlagStore, StoredFlagSet } from "./index.js";
|
|
11
|
+
import { FlagStoreError } from "./index.js";
|
|
12
|
+
/** Where a Worker KV store keeps its set. */
|
|
13
|
+
export interface WorkerKVFlagStoreOptions {
|
|
14
|
+
/**
|
|
15
|
+
* The key the whole set is stored under. An application holding more than one set
|
|
16
|
+
* gives each of them its own.
|
|
17
|
+
*
|
|
18
|
+
* @default "flags"
|
|
19
|
+
*/
|
|
20
|
+
key?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reads and writes one definition set in a KV namespace.
|
|
24
|
+
*
|
|
25
|
+
* A key holding nothing reads as an empty set, so a namespace an admin has yet to
|
|
26
|
+
* write to is a working store rather than a failure a caller has to special-case.
|
|
27
|
+
*/
|
|
28
|
+
export declare class WorkerKVFlagStore implements FlagStore {
|
|
29
|
+
#private;
|
|
30
|
+
/** The key the set lives under, for a caller that also manages the namespace. */
|
|
31
|
+
readonly key: string;
|
|
32
|
+
/**
|
|
33
|
+
* @param kv The namespace the set is stored in.
|
|
34
|
+
* @param options The key the set lives under.
|
|
35
|
+
*/
|
|
36
|
+
constructor(kv: KVNamespace, { key }?: WorkerKVFlagStoreOptions);
|
|
37
|
+
/**
|
|
38
|
+
* @returns The stored set, an empty one when the key holds nothing, `invalid_value`
|
|
39
|
+
* when what it holds is not a JSON object, and `unavailable` when the namespace
|
|
40
|
+
* refused the read.
|
|
41
|
+
*/
|
|
42
|
+
read(): Promise<Result<StoredFlagSet, FlagStoreError>>;
|
|
43
|
+
/**
|
|
44
|
+
* Replaces the whole set, which is the write a store backed by one value takes.
|
|
45
|
+
* It lives on the store that has the capability, so an admin path writes through
|
|
46
|
+
* the same key every reader reads through.
|
|
47
|
+
*
|
|
48
|
+
* @param set The definitions every later read answers with.
|
|
49
|
+
* @returns Nothing on success, `invalid_value` for a set JSON cannot write, and
|
|
50
|
+
* `unavailable` when the namespace refused the put.
|
|
51
|
+
*/
|
|
52
|
+
write(set: StoredFlagSet): Promise<Result<void, FlagStoreError>>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store over a Cloudflare KV namespace, holding the whole definition set as one
|
|
3
|
+
* JSON value under one key. Filling a snapshot is a single `get` served from the
|
|
4
|
+
* edge cache, and an admin write rewrites the value.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import { FlagStoreError } from "./index.js";
|
|
11
|
+
/** The key the set lives under when the caller names none. */
|
|
12
|
+
const DEFAULT_KEY = "flags";
|
|
13
|
+
/** Whether the value is a JSON object, which is what both a set and its flags are. */
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether a parsed value is a set, which it is when it carries flags as an object.
|
|
19
|
+
* That is as far as a store looks: what a definition has to be is the engine's
|
|
20
|
+
* question, answered in one place against one schema.
|
|
21
|
+
*
|
|
22
|
+
* @param value What the stored JSON parsed to.
|
|
23
|
+
*/
|
|
24
|
+
function isFlagSet(value) {
|
|
25
|
+
return isRecord(value) && isRecord(value["flags"]);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reads and writes one definition set in a KV namespace.
|
|
29
|
+
*
|
|
30
|
+
* A key holding nothing reads as an empty set, so a namespace an admin has yet to
|
|
31
|
+
* write to is a working store rather than a failure a caller has to special-case.
|
|
32
|
+
*/
|
|
33
|
+
export class WorkerKVFlagStore {
|
|
34
|
+
#kv;
|
|
35
|
+
/** The key the set lives under, for a caller that also manages the namespace. */
|
|
36
|
+
key;
|
|
37
|
+
/**
|
|
38
|
+
* @param kv The namespace the set is stored in.
|
|
39
|
+
* @param options The key the set lives under.
|
|
40
|
+
*/
|
|
41
|
+
constructor(kv, { key = DEFAULT_KEY } = {}) {
|
|
42
|
+
this.#kv = kv;
|
|
43
|
+
this.key = key;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @returns The stored set, an empty one when the key holds nothing, `invalid_value`
|
|
47
|
+
* when what it holds is not a JSON object, and `unavailable` when the namespace
|
|
48
|
+
* refused the read.
|
|
49
|
+
*/
|
|
50
|
+
async read() {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = await this.#kv.get(this.key, "text");
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
return failure(this.#refused("read", cause));
|
|
57
|
+
}
|
|
58
|
+
if (text === null)
|
|
59
|
+
return success({ flags: {} });
|
|
60
|
+
let value;
|
|
61
|
+
try {
|
|
62
|
+
value = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
return failure(new FlagStoreError(`The value stored at ${this.key} is not JSON.`, {
|
|
66
|
+
code: "invalid_value",
|
|
67
|
+
location: this.key,
|
|
68
|
+
cause,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
if (!isFlagSet(value)) {
|
|
72
|
+
return failure(new FlagStoreError(`The value stored at ${this.key} holds no flags object.`, {
|
|
73
|
+
code: "invalid_value",
|
|
74
|
+
location: this.key,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
return success(value);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Replaces the whole set, which is the write a store backed by one value takes.
|
|
81
|
+
* It lives on the store that has the capability, so an admin path writes through
|
|
82
|
+
* the same key every reader reads through.
|
|
83
|
+
*
|
|
84
|
+
* @param set The definitions every later read answers with.
|
|
85
|
+
* @returns Nothing on success, `invalid_value` for a set JSON cannot write, and
|
|
86
|
+
* `unavailable` when the namespace refused the put.
|
|
87
|
+
*/
|
|
88
|
+
async write(set) {
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = JSON.stringify(set);
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
return failure(new FlagStoreError(`The set given for ${this.key} cannot be written as JSON.`, {
|
|
95
|
+
code: "invalid_value",
|
|
96
|
+
location: this.key,
|
|
97
|
+
cause,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await this.#kv.put(this.key, text);
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
return failure(this.#refused("write", cause));
|
|
105
|
+
}
|
|
106
|
+
return success(undefined);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* States a namespace failure as the code a caller branches on, carrying what KV
|
|
110
|
+
* threw so the original reason survives the translation.
|
|
111
|
+
*
|
|
112
|
+
* @param operation What was being attempted, which the message names.
|
|
113
|
+
* @param cause What the namespace threw.
|
|
114
|
+
*/
|
|
115
|
+
#refused(operation, cause) {
|
|
116
|
+
let reason = cause instanceof Error ? cause.message : String(cause);
|
|
117
|
+
return new FlagStoreError(`The namespace could not ${operation} ${this.key}: ${reason}`, {
|
|
118
|
+
code: "unavailable",
|
|
119
|
+
location: this.key,
|
|
120
|
+
cause,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite that says what a flag store is, registered as Vitest tests against
|
|
3
|
+
* whatever the caller constructs. Every store runs it, the one an application
|
|
4
|
+
* writes against its own tables included, which is what makes them substitutes.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { FlagStore, StoredFlagSet } from "../store/index.js";
|
|
10
|
+
/** What the suite needs to exercise a store. */
|
|
11
|
+
export interface ConformanceOptions<Store extends FlagStore = FlagStore> {
|
|
12
|
+
/** Store name, which labels the registered suite. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Builds the store under test, holding nothing. It is called for every test, so a
|
|
16
|
+
* store over shared storage points each one at a location of its own.
|
|
17
|
+
*/
|
|
18
|
+
create: () => Store | Promise<Store>;
|
|
19
|
+
/**
|
|
20
|
+
* Puts the set where the store reads it from, by whatever means the storage gives.
|
|
21
|
+
* A store with a write of its own seeds through it, and a read-only store seeds
|
|
22
|
+
* through the file, endpoint or object behind it.
|
|
23
|
+
*/
|
|
24
|
+
seed: (store: Store, set: StoredFlagSet) => void | Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Stores the set through the store's own write. Supplying it registers the round
|
|
27
|
+
* trip assertions, which say a store reads back what it was told to hold.
|
|
28
|
+
*/
|
|
29
|
+
write?: (store: Store, set: StoredFlagSet) => void | Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Puts text where the store reads it from, in place of anything the store would
|
|
32
|
+
* serialize. Supplying it registers the assertions about a value the store did not
|
|
33
|
+
* write, which is where a store either reports or throws.
|
|
34
|
+
*/
|
|
35
|
+
writeText?: (store: Store, text: string) => void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Registers the suite every flag store has to pass.
|
|
39
|
+
*
|
|
40
|
+
* @param options The store under test, and the capabilities it has beyond reading.
|
|
41
|
+
* @example conformance({ name: "worker-kv", create: () => new WorkerKVFlagStore(env.FLAGS), seed })
|
|
42
|
+
*/
|
|
43
|
+
export declare function conformance<Store extends FlagStore>({ name, create, seed, write, writeText, }: ConformanceOptions<Store>): void;
|