@ultimat3/flags 3.0.0 → 4.1.0
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/CLAUDE.md +30 -0
- package/package.json +2 -2
- package/src/errors.ts +1 -1
- package/src/flag.ts +26 -5
- package/src/registry.ts +2 -1
- package/src/runtime.ts +15 -1
- package/src/targeting-assert.ts +156 -0
- package/src/targeting.ts +2 -74
package/CLAUDE.md
CHANGED
|
@@ -13,6 +13,7 @@ what lets `policy` (tier 2) call it from inside a predicate.
|
|
|
13
13
|
| Errors | `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
14
14
|
| Files | one responsibility each, < 200 lines, tests beside the source |
|
|
15
15
|
| Subjects | `src/subject.ts` — one resolver; never a second allow list per record kind |
|
|
16
|
+
| Validation | `src/targeting-assert.ts` — every declaration-time refusal. `targeting.ts` stays the evaluation path |
|
|
16
17
|
|
|
17
18
|
## Invariants
|
|
18
19
|
|
|
@@ -84,6 +85,35 @@ what lets `policy` (tier 2) call it from inside a predicate.
|
|
|
84
85
|
`errors.test.ts` running the generated snippet through `new Function`.
|
|
85
86
|
- **An unknown key throws.** Answering `false` is a branch that never runs and never says so.
|
|
86
87
|
- `default: true` beside a `rollout` is refused: the two answer the same actors and disagree.
|
|
88
|
+
- **`assertTargeting` establishes the argument is an OBJECT before reading a field, and every one
|
|
89
|
+
of the four lists goes through the same check** (`As of 2026-08`). It destructured first, so
|
|
90
|
+
`applyFlagSnapshot({ 'billing.new': null })` was a bare `TypeError` and `{ 'billing.new': 'off' }`
|
|
91
|
+
was ACCEPTED — `default`, `actors`, `rollout` and `subjects` are all `undefined` on a string.
|
|
92
|
+
`default` was the one required field with no shape check while `rollout` got `Number.isInteger`,
|
|
93
|
+
so `{}` answered `undefined` and `{ default: 'yes' }` answered `"yes"` through a declared
|
|
94
|
+
`boolean` return, silently changing the meaning of every `=== true` / `=== false` call site. And
|
|
95
|
+
the `Array.isArray` rule reached `subjects` only, while CLAUDE.md called the four lists one rank:
|
|
96
|
+
`actors: 'user_100'` matched by SUBSTRING (`isEnabled` answered `true` for `user_1`, `user_10`,
|
|
97
|
+
`ser_10` and `u`), `orgs` the same, and a non-array `roles` reached `.some()` as a bare
|
|
98
|
+
`TypeError` out of a path this file documents as pure and synchronous — inside a policy predicate.
|
|
99
|
+
`renderGiven` from `errors.ts` renders every rejected value; never `JSON.stringify` directly, for
|
|
100
|
+
the reason that file states.
|
|
101
|
+
- **A temporary flag's `expiresAt` must carry no ambient zone** (`As of 2026-08`). `Date.parse`
|
|
102
|
+
alone honoured the PROCESS's zone for a clock-time form: `'2026-12-01T00:00:00'` measured as
|
|
103
|
+
1796083200000 in UTC, 1796101200000 in America/New_York and 1796050800000 in Asia/Tokyo — fourteen
|
|
104
|
+
hours of spread across a fleet, so `X_FLAG_EXPIRED` started on a different DAY on different pods,
|
|
105
|
+
against a comment claiming the opposite. `flag.ts` carries a local `CLOCK_TIME`/`UTC_OFFSET` pair
|
|
106
|
+
mirroring `@ultimat3/time`'s `fromIso`, restated rather than imported because this package is tier
|
|
107
|
+
1 and may import `@ultimat3/core` only. `scripts/test-setup.ts` pins the runner to UTC, so the
|
|
108
|
+
failure is invisible in process by construction and `flag.test.ts` spawns a `TZ=` subprocess per
|
|
109
|
+
zone — the same reason `packages/time/src/plain-date.test.ts` does.
|
|
110
|
+
- **`configureFlags` clears the report watermarks when it swaps the clock, and only then.** A
|
|
111
|
+
monotonic reading is meaningful only against the clock that produced it: a process that reported
|
|
112
|
+
at monotonic 10,000,000 and then took a clock starting at 0 computed `now - previous` as
|
|
113
|
+
-10,000,000, below every interval, so that key could never report again — on a frozen test clock,
|
|
114
|
+
never. `resetFlagReporting()` always did this; `configureFlags` is what apps and test kits call.
|
|
115
|
+
An interval change re-reads the SAME clock and must NOT clear, or the rate limit becomes a
|
|
116
|
+
suggestion one configure call can bypass.
|
|
87
117
|
|
|
88
118
|
## Open
|
|
89
119
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/flags",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Feature flags: permanent switches, and temporary ones that cannot be forgotten",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "4.1.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const flagSubjectRequired = (init: {
|
|
|
111
111
|
* something that is not `X_FLAG_EXPIRY_INVALID`. A cause only has to describe, so a value that
|
|
112
112
|
* defeats rendering degrades to its type rather than destroying the refusal.
|
|
113
113
|
*/
|
|
114
|
-
const renderGiven = (given: unknown): string => {
|
|
114
|
+
export const renderGiven = (given: unknown): string => {
|
|
115
115
|
if (given === undefined) return 'undefined';
|
|
116
116
|
if (typeof given === 'bigint') return `${given}n`;
|
|
117
117
|
if (typeof given === 'symbol') return String(given);
|
package/src/flag.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { flagExpiryInvalid } from './errors';
|
|
11
11
|
import type { FlagTargeting } from './targeting';
|
|
12
|
-
import { assertTargeting } from './targeting';
|
|
12
|
+
import { assertTargeting } from './targeting-assert';
|
|
13
13
|
|
|
14
14
|
export const FLAG_KINDS = ['permanent', 'temporary'] as const;
|
|
15
15
|
|
|
@@ -87,10 +87,7 @@ export function toFlag(def: FlagDef): Flag {
|
|
|
87
87
|
owner: null,
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
// no ambient zone, which is the framework's rule about dates applied to a deadline.
|
|
92
|
-
const expiresAtMs = Date.parse(def.expiresAt);
|
|
93
|
-
if (Number.isNaN(expiresAtMs)) throw flagExpiryInvalid(def.key, def.expiresAt);
|
|
90
|
+
const expiresAtMs = expiryMsOf(def.key, def.expiresAt);
|
|
94
91
|
return Object.freeze({
|
|
95
92
|
key: def.key,
|
|
96
93
|
kind: def.kind,
|
|
@@ -107,3 +104,27 @@ export function withTargeting(flag: Flag, targeting: FlagTargeting): Flag {
|
|
|
107
104
|
assertTargeting(flag.key, targeting);
|
|
108
105
|
return Object.freeze({ ...flag, targeting });
|
|
109
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A time of day, and the zone it is stated in. `2026-12-01T00:00:00` without one is resolved by
|
|
110
|
+
* `Date.parse` through the PROCESS's zone: measured, that one string is 1796083200000 in UTC,
|
|
111
|
+
* 1796101200000 in America/New_York and 1796050800000 in Asia/Tokyo — fourteen hours of spread
|
|
112
|
+
* across a fleet, so `X_FLAG_EXPIRED` starts on a different DAY on different pods. A date-only
|
|
113
|
+
* form carries no clock time and is UTC by specification, so it passes.
|
|
114
|
+
*
|
|
115
|
+
* The same two patterns as `@ultimat3/time`'s `fromIso`, restated rather than imported: this
|
|
116
|
+
* package is tier 1 and may import `@ultimat3/core` only — a deadline is one date, and one date is
|
|
117
|
+
* not worth a tier edge. `flag.test.ts` spawns a `TZ=` subprocess per zone, because
|
|
118
|
+
* `scripts/test-setup.ts` pins the runner to UTC and the failure is invisible in process.
|
|
119
|
+
*/
|
|
120
|
+
const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
|
|
121
|
+
const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
122
|
+
|
|
123
|
+
function expiryMsOf(key: string, expiresAt: string): number {
|
|
124
|
+
if (CLOCK_TIME.test(expiresAt) && !UTC_OFFSET.test(expiresAt)) {
|
|
125
|
+
throw flagExpiryInvalid(key, expiresAt);
|
|
126
|
+
}
|
|
127
|
+
const ms = Date.parse(expiresAt);
|
|
128
|
+
if (Number.isNaN(ms)) throw flagExpiryInvalid(key, expiresAt);
|
|
129
|
+
return ms;
|
|
130
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { flagDuplicate, flagUnknown } from './errors';
|
|
6
6
|
import type { Flag, FlagDef } from './flag';
|
|
7
7
|
import { toFlag, withTargeting } from './flag';
|
|
8
|
-
import {
|
|
8
|
+
import type { FlagTargeting } from './targeting';
|
|
9
|
+
import { assertTargeting } from './targeting-assert';
|
|
9
10
|
|
|
10
11
|
const flags = new Map<string, Flag>();
|
|
11
12
|
|
package/src/runtime.ts
CHANGED
|
@@ -27,8 +27,22 @@ let clock: Clock = systemClock;
|
|
|
27
27
|
let reportEveryMs = DEFAULT_REPORT_INTERVAL_MS;
|
|
28
28
|
const lastReportedAt = new Map<string, number>();
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Swapping the clock CLEARS the watermarks, because a monotonic reading is only meaningful against
|
|
32
|
+
* the clock that produced it. A process that reported at monotonic 10_000_000 and then took a
|
|
33
|
+
* clock starting at 0 computed `now - previous` as -10_000_000 — below every interval, so that key
|
|
34
|
+
* could never report again until the new clock passed the old one's reading, which on a frozen
|
|
35
|
+
* test clock is never. `resetFlagReporting()` always did this; `configureFlags` is what apps and
|
|
36
|
+
* test kits actually call.
|
|
37
|
+
*
|
|
38
|
+
* Only the clock. An interval change re-reads the SAME clock, so clearing there would let a report
|
|
39
|
+
* through on every configure call and turn the rate limit into a suggestion.
|
|
40
|
+
*/
|
|
30
41
|
export function configureFlags(options: FlagsRuntimeOptions): void {
|
|
31
|
-
if (options.clock !== undefined
|
|
42
|
+
if (options.clock !== undefined && options.clock !== clock) {
|
|
43
|
+
clock = options.clock;
|
|
44
|
+
lastReportedAt.clear();
|
|
45
|
+
}
|
|
32
46
|
if (options.reportEveryMs !== undefined) reportEveryMs = options.reportEveryMs;
|
|
33
47
|
}
|
|
34
48
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Single responsibility: declaration-time validation of a `FlagTargeting`, the way `can()`
|
|
2
|
+
// validates its permission rather than waiting for a request. Split from `targeting.ts` so that
|
|
3
|
+
// file stays the evaluation path — the one that runs inside a policy predicate and must not grow.
|
|
4
|
+
|
|
5
|
+
import { BUCKETS } from './bucket';
|
|
6
|
+
import { flagTargetingInvalid, renderGiven } from './errors';
|
|
7
|
+
import { BUILT_IN_SUBJECT_KINDS, isBuiltInSubjectKind } from './subject';
|
|
8
|
+
import type { FlagTargeting } from './targeting';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The three flat allow lists, all checked by the same loop. They are ONE rank with `subjects` at
|
|
12
|
+
* evaluation time, and CLAUDE.md says so — so a validation rule that reached only `subjects` made
|
|
13
|
+
* three quarters of that rank unchecked.
|
|
14
|
+
*/
|
|
15
|
+
const ID_LISTS = ['actors', 'roles', 'orgs'] as const;
|
|
16
|
+
|
|
17
|
+
type IdListField = (typeof ID_LISTS)[number];
|
|
18
|
+
|
|
19
|
+
const LIST_EXAMPLE: Readonly<Record<IdListField, string>> = {
|
|
20
|
+
actors: 'user_100',
|
|
21
|
+
roles: 'admin',
|
|
22
|
+
orgs: 'org_acme',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Each rule closes a way for a flag to look wired and decide something other than what it says:
|
|
27
|
+
*
|
|
28
|
+
* | Rejected | Why |
|
|
29
|
+
* |---|---|
|
|
30
|
+
* | `rollout: 0.5` | read as a fraction it means "half", read as a percentage it means "nobody" |
|
|
31
|
+
* | `default: true` with a `rollout` | the two answer the same actors and disagree; there is no reading of "on for everyone, and also on for 10%" |
|
|
32
|
+
* | `bucketBy` with no `rollout` | it names what a rollout divides, and there is no rollout to divide |
|
|
33
|
+
* | a blank `bucketBy` | names no kind at all |
|
|
34
|
+
* | `subjects.actor` / `subjects.org` | `actors` and `orgs` are the one spelling; two would disagree |
|
|
35
|
+
* | a `subjects` entry that is not a list of non-empty ids | reachable from a store snapshot, and it matches nothing while reading as an allow list |
|
|
36
|
+
*
|
|
37
|
+
* The checks narrow by hand rather than through a schema: this package's other runtime re-checks
|
|
38
|
+
* (`Number.isInteger`, the expiry pattern in `flag.ts`) do the same, and a dependency here would
|
|
39
|
+
* buy one validation on a path that must stay allocation-free.
|
|
40
|
+
*/
|
|
41
|
+
export function assertTargeting(key: string, targeting: FlagTargeting): void {
|
|
42
|
+
const declared = assertObject(key, targeting);
|
|
43
|
+
const { bucketBy, rollout } = declared;
|
|
44
|
+
assertDefault(key, declared.default);
|
|
45
|
+
for (const field of ID_LISTS) assertIdList(key, field, declared[field]);
|
|
46
|
+
if (declared.subjects !== undefined) assertSubjects(key, declared.subjects);
|
|
47
|
+
if (bucketBy !== undefined) {
|
|
48
|
+
if (typeof bucketBy !== 'string' || bucketBy.trim() === '') {
|
|
49
|
+
throw flagTargetingInvalid(
|
|
50
|
+
key,
|
|
51
|
+
`bucketBy is ${JSON.stringify(bucketBy)}, which names no subject kind`,
|
|
52
|
+
`set bucketBy to a subject kind — '${BUILT_IN_SUBJECT_KINDS.join("', '")}', or one your call site passes — in defineFlag({ key: '${key}' })`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (rollout === undefined) {
|
|
56
|
+
throw flagTargetingInvalid(
|
|
57
|
+
key,
|
|
58
|
+
`bucketBy is '${bucketBy}' with no rollout, so it divides nothing`,
|
|
59
|
+
`add a rollout to defineFlag({ key: '${key}' }), or remove bucketBy`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (rollout === undefined) return;
|
|
64
|
+
if (!Number.isInteger(rollout)) {
|
|
65
|
+
const problem = `rollout is ${rollout}; a rollout is a whole percentage, not a fraction`;
|
|
66
|
+
throw flagTargetingInvalid(key, problem);
|
|
67
|
+
}
|
|
68
|
+
if (rollout < 0 || rollout > BUCKETS) {
|
|
69
|
+
throw flagTargetingInvalid(key, `rollout is ${rollout}, outside 0-${BUCKETS}`);
|
|
70
|
+
}
|
|
71
|
+
if (declared.default) {
|
|
72
|
+
throw flagTargetingInvalid(key, `default is true and rollout is ${rollout}; the two disagree`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The argument is a targeting AT ALL, established before any field is read.
|
|
78
|
+
*
|
|
79
|
+
* The declared parameter type is a promise the CALLER makes, and this function exists for the case
|
|
80
|
+
* nobody made it: `applyFlagSnapshot` lands a store payload no type ever saw, so a `null` used to
|
|
81
|
+
* reach the destructure as a bare `TypeError` and a bare string used to pass every check —
|
|
82
|
+
* `default`, `actors`, `rollout` and `subjects` are all `undefined` on a string — and then answer
|
|
83
|
+
* `undefined` from a function declared to return `boolean`.
|
|
84
|
+
*/
|
|
85
|
+
function assertObject(key: string, targeting: FlagTargeting): FlagTargeting {
|
|
86
|
+
const given: unknown = targeting;
|
|
87
|
+
if (typeof given !== 'object' || given === null || Array.isArray(given)) {
|
|
88
|
+
throw flagTargetingInvalid(
|
|
89
|
+
key,
|
|
90
|
+
`targeting is ${renderGiven(given)}, which is not a targeting object`,
|
|
91
|
+
`give the flag a targeting object — { default: false } — in defineFlag({ key: '${key}' })`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return given as FlagTargeting;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `default` is the one REQUIRED field, and it was the one field with no shape check while
|
|
99
|
+
* `rollout` got `Number.isInteger`. `isEnabled` is declared to return `boolean`: a missing default
|
|
100
|
+
* answered `undefined` through that type and `{ default: 'yes' }` answered the string, so every
|
|
101
|
+
* `=== true` / `=== false` call site silently changed meaning without a single failure anywhere.
|
|
102
|
+
*/
|
|
103
|
+
function assertDefault(key: string, value: boolean): void {
|
|
104
|
+
if (typeof value === 'boolean') return;
|
|
105
|
+
throw flagTargetingInvalid(
|
|
106
|
+
key,
|
|
107
|
+
`default is ${renderGiven(value)}; a flag answers true or false and nothing else`,
|
|
108
|
+
`set default to true or false in defineFlag({ key: '${key}' })`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A list of non-empty ids, or absent. The check that matters is `Array.isArray`, and its absence
|
|
114
|
+
* was a silent grant: `includes` on a STRING matches by substring, so `actors: 'user_100'` answered
|
|
115
|
+
* `true` for `user_1`, `user_10`, `ser_10` and `u`. `roles` failed the other way — a string has no
|
|
116
|
+
* `.some()`, so it threw a bare `TypeError` out of `evaluateTargeting`, which runs inside policy
|
|
117
|
+
* predicates and render passes.
|
|
118
|
+
*/
|
|
119
|
+
function assertIdList(key: string, field: IdListField, list: unknown): void {
|
|
120
|
+
if (list === undefined) return;
|
|
121
|
+
const fix = `set ${field} to a list of ids — ${field}: ['${LIST_EXAMPLE[field]}'] — in defineFlag({ key: '${key}' })`;
|
|
122
|
+
if (!Array.isArray(list)) {
|
|
123
|
+
throw flagTargetingInvalid(
|
|
124
|
+
key,
|
|
125
|
+
`${field} is ${renderGiven(list)}, not a list of ids — a bare string is matched by substring, so it claims every id inside it`,
|
|
126
|
+
fix,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
for (const id of list as readonly unknown[]) {
|
|
130
|
+
if (typeof id !== 'string' || id === '') {
|
|
131
|
+
throw flagTargetingInvalid(key, `${field} holds ${renderGiven(id)}, which is not an id`, fix);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertSubjects(key: string, subjects: Readonly<Record<string, readonly string[]>>): void {
|
|
137
|
+
const fix = `give each subjects entry a kind and a list of ids — { bank: ['bank_integration:bbva'] } — in defineFlag({ key: '${key}' })`;
|
|
138
|
+
for (const [kind, ids] of Object.entries<unknown>(subjects)) {
|
|
139
|
+
if (kind.trim() === '') throw flagTargetingInvalid(key, 'a subjects kind is blank', fix);
|
|
140
|
+
if (isBuiltInSubjectKind(kind)) {
|
|
141
|
+
throw flagTargetingInvalid(
|
|
142
|
+
key,
|
|
143
|
+
`subjects.${kind} restates a built-in kind`,
|
|
144
|
+
`use ${kind === 'org' ? 'orgs' : 'actors'} instead of subjects.${kind} in defineFlag({ key: '${key}' })`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(ids)) {
|
|
148
|
+
throw flagTargetingInvalid(key, `subjects.${kind} is not a list of ids`, fix);
|
|
149
|
+
}
|
|
150
|
+
for (const id of ids as readonly unknown[]) {
|
|
151
|
+
if (typeof id !== 'string' || id === '') {
|
|
152
|
+
throw flagTargetingInvalid(key, `subjects.${kind} holds an id that is not a string`, fix);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/targeting.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
// touches a clock. Loading the store is somebody else's job (`applyFlagSnapshot`).
|
|
4
4
|
import type { Actor } from '@ultimat3/core';
|
|
5
5
|
import { hasRole } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
import { flagTargetingInvalid } from './errors';
|
|
6
|
+
import { bucketOf } from './bucket';
|
|
8
7
|
import type { FlagSubjects } from './subject';
|
|
9
|
-
import {
|
|
8
|
+
import { subjectIdOf } from './subject';
|
|
10
9
|
|
|
11
10
|
export interface FlagTargeting {
|
|
12
11
|
/** The answer when no allow list and no rollout claims this actor. `false` is off, `true` is on. */
|
|
@@ -39,77 +38,6 @@ export interface FlagTargeting {
|
|
|
39
38
|
readonly bucketBy?: string | undefined;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
/**
|
|
43
|
-
* Declaration-time validation, the way `can()` validates its permission rather than waiting for a
|
|
44
|
-
* request. Each rule closes a way for a flag to look wired and decide nothing:
|
|
45
|
-
*
|
|
46
|
-
* | Rejected | Why |
|
|
47
|
-
* |---|---|
|
|
48
|
-
* | `rollout: 0.5` | read as a fraction it means "half", read as a percentage it means "nobody" |
|
|
49
|
-
* | `default: true` with a `rollout` | the two answer the same actors and disagree; there is no reading of "on for everyone, and also on for 10%" |
|
|
50
|
-
* | `bucketBy` with no `rollout` | it names what a rollout divides, and there is no rollout to divide |
|
|
51
|
-
* | a blank `bucketBy` | names no kind at all |
|
|
52
|
-
* | `subjects.actor` / `subjects.org` | `actors` and `orgs` are the one spelling; two would disagree |
|
|
53
|
-
* | a `subjects` entry that is not a list of non-empty ids | reachable from a store snapshot, and it matches nothing while reading as an allow list |
|
|
54
|
-
*
|
|
55
|
-
* The `subjects` checks narrow by hand rather than through a schema: this package's other runtime
|
|
56
|
-
* re-checks (`Number.isInteger`, `Date.parse`) do the same, and a dependency here would buy one
|
|
57
|
-
* validation on a path that must stay allocation-free.
|
|
58
|
-
*/
|
|
59
|
-
export function assertTargeting(key: string, targeting: FlagTargeting): void {
|
|
60
|
-
const { bucketBy, rollout } = targeting;
|
|
61
|
-
if (targeting.subjects !== undefined) assertSubjects(key, targeting.subjects);
|
|
62
|
-
if (bucketBy !== undefined) {
|
|
63
|
-
if (typeof bucketBy !== 'string' || bucketBy.trim() === '') {
|
|
64
|
-
throw flagTargetingInvalid(
|
|
65
|
-
key,
|
|
66
|
-
`bucketBy is ${JSON.stringify(bucketBy)}, which names no subject kind`,
|
|
67
|
-
`set bucketBy to a subject kind — '${BUILT_IN_SUBJECT_KINDS.join("', '")}', or one your call site passes — in defineFlag({ key: '${key}' })`,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
if (rollout === undefined) {
|
|
71
|
-
throw flagTargetingInvalid(
|
|
72
|
-
key,
|
|
73
|
-
`bucketBy is '${bucketBy}' with no rollout, so it divides nothing`,
|
|
74
|
-
`add a rollout to defineFlag({ key: '${key}' }), or remove bucketBy`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (rollout === undefined) return;
|
|
79
|
-
if (!Number.isInteger(rollout)) {
|
|
80
|
-
const problem = `rollout is ${rollout}; a rollout is a whole percentage, not a fraction`;
|
|
81
|
-
throw flagTargetingInvalid(key, problem);
|
|
82
|
-
}
|
|
83
|
-
if (rollout < 0 || rollout > BUCKETS) {
|
|
84
|
-
throw flagTargetingInvalid(key, `rollout is ${rollout}, outside 0-${BUCKETS}`);
|
|
85
|
-
}
|
|
86
|
-
if (targeting.default) {
|
|
87
|
-
throw flagTargetingInvalid(key, `default is true and rollout is ${rollout}; the two disagree`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function assertSubjects(key: string, subjects: Readonly<Record<string, readonly string[]>>): void {
|
|
92
|
-
const fix = `give each subjects entry a kind and a list of ids — { bank: ['bank_integration:bbva'] } — in defineFlag({ key: '${key}' })`;
|
|
93
|
-
for (const [kind, ids] of Object.entries<unknown>(subjects)) {
|
|
94
|
-
if (kind.trim() === '') throw flagTargetingInvalid(key, 'a subjects kind is blank', fix);
|
|
95
|
-
if (isBuiltInSubjectKind(kind)) {
|
|
96
|
-
throw flagTargetingInvalid(
|
|
97
|
-
key,
|
|
98
|
-
`subjects.${kind} restates a built-in kind`,
|
|
99
|
-
`use ${kind === 'org' ? 'orgs' : 'actors'} instead of subjects.${kind} in defineFlag({ key: '${key}' })`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
if (!Array.isArray(ids)) {
|
|
103
|
-
throw flagTargetingInvalid(key, `subjects.${kind} is not a list of ids`, fix);
|
|
104
|
-
}
|
|
105
|
-
for (const id of ids as readonly unknown[]) {
|
|
106
|
-
if (typeof id !== 'string' || id === '') {
|
|
107
|
-
throw flagTargetingInvalid(key, `subjects.${kind} holds an id that is not a string`, fix);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
41
|
/**
|
|
114
42
|
* Allow lists first, rollout second, declared default last. That order is the contract: a subject
|
|
115
43
|
* an operator explicitly named must not depend on where a hash happened to put it, which is the
|