@ultimat3/flags 23.0.0 → 25.0.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 +9 -3
- package/README.md +7 -1
- package/package.json +3 -3
- package/src/bucket.ts +5 -17
- package/src/flag.ts +13 -13
- package/src/index.ts +1 -1
- package/src/runtime.ts +12 -2
package/CLAUDE.md
CHANGED
|
@@ -102,11 +102,17 @@ what lets `policy` (tier 2) call it from inside a predicate.
|
|
|
102
102
|
alone honoured the PROCESS's zone for a clock-time form: `'2026-12-01T00:00:00'` measured as
|
|
103
103
|
1796083200000 in UTC, 1796101200000 in America/New_York and 1796050800000 in Asia/Tokyo — fourteen
|
|
104
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`
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
against a comment claiming the opposite. `flag.ts` asks `@ultimat3/core`'s `isIsoDateTime` — the
|
|
106
|
+
one predicate `t.date` and `fromIso` ask — `As of 2026-10`. It carried a local
|
|
107
|
+
`CLOCK_TIME`/`UTC_OFFSET` pair until then, two of the predicate's three patterns: with no SHAPE
|
|
108
|
+
check, `'December 1, 2026'` and `'12/01/2026'` parsed at the host's local midnight and
|
|
109
|
+
`'2026-02-30'` rolled over to March 2nd. All are `X_FLAG_EXPIRY_INVALID` now. **Breaking.**
|
|
110
|
+
`scripts/test-setup.ts` pins the runner to UTC, so the
|
|
108
111
|
failure is invisible in process by construction and `flag.test.ts` spawns a `TZ=` subprocess per
|
|
109
112
|
zone — the same reason `packages/time/src/plain-date.test.ts` does.
|
|
113
|
+
- **`configureFlags` screens `reportEveryMs` with `finiteCount`, before it touches anything.**
|
|
114
|
+
`now - previous < NaN` is false, so a `NaN` interval reported on every evaluation; `Infinity`
|
|
115
|
+
muted the flag for good. `0` is legal — a caller may choose it.
|
|
110
116
|
- **`configureFlags` clears the report watermarks when it swaps the clock, and only then.** A
|
|
111
117
|
monotonic reading is meaningful only against the clock that produced it: a process that reported
|
|
112
118
|
at monotonic 10,000,000 and then took a clock starting at 0 computed `now - previous` as
|
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@ permanent set is a product surface; the temporary set is forced to shrink.
|
|
|
15
15
|
| `permanent` | a real product or ops switch — a plan capability, a kill switch, a rollout that became the product | none; it legitimately lives forever |
|
|
16
16
|
| `temporary` | scaffolding around an in-progress change | `expiresAt` and `owner` are **required**; past the expiry every evaluation reports `X_FLAG_EXPIRED` |
|
|
17
17
|
|
|
18
|
+
`expiresAt` is ISO-8601 — a date (`2026-12-01`, UTC) or a date-time with `Z` or an offset, naming
|
|
19
|
+
a day its month has. `'December 1, 2026'`, `'12/01/2026'`, `'2026-02-30'` and a clock time with no
|
|
20
|
+
zone are `X_FLAG_EXPIRY_INVALID`: each parses, at an instant that depends on the host.
|
|
21
|
+
|
|
18
22
|
Omitting `expiresAt` on a `temporary` flag is a **type** error, not a lint rule —
|
|
19
23
|
`FlagExpiryIsMandatory` in `src/flag.ts` is a compile-time assertion that fails `tsc` if the union
|
|
20
24
|
is ever loosened. `toFlag()` re-checks it at runtime, because a store snapshot and a plain-JS
|
|
@@ -157,7 +161,9 @@ configureErrorReporting({ reporter: sentryErrorReporter({ dsn }) });
|
|
|
157
161
|
What this package adds is the rate limit core has no opinion about: one report per flag per
|
|
158
162
|
`DEFAULT_REPORT_INTERVAL_MS` (1 hour), on the **monotonic** clock, so a flag read on every request
|
|
159
163
|
does not become the loudest thing in the monitor — which is how a report that fires per call ends
|
|
160
|
-
up muted and the debt invisible again. `configureFlags({ clock, reportEveryMs })` tunes it
|
|
164
|
+
up muted and the debt invisible again. `configureFlags({ clock, reportEveryMs })` tunes it;
|
|
165
|
+
`reportEveryMs` is a whole number of milliseconds, 0 or more — `NaN`, `Infinity`, a fraction or a
|
|
166
|
+
negative is refused (`X_INVARIANT`) and changes nothing.
|
|
161
167
|
|
|
162
168
|
## Projection
|
|
163
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/flags",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Feature flags: permanent switches, and temporary ones that cannot be forgotten",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/bucket.ts
CHANGED
|
@@ -3,31 +3,19 @@
|
|
|
3
3
|
// a rollout that re-rolls per call shows one user the new experience on one request and the old
|
|
4
4
|
// one on the next, which is a worse product than no rollout at all — and untestable besides.
|
|
5
5
|
|
|
6
|
+
import { fnv1a } from '@ultimat3/core';
|
|
7
|
+
|
|
6
8
|
/** A rollout is declared as a percentage, so the bucket space is 100. */
|
|
7
9
|
export const BUCKETS = 100;
|
|
8
10
|
|
|
9
|
-
const FNV_OFFSET_BASIS = 0x811c_9dc5;
|
|
10
|
-
const FNV_PRIME = 0x0100_0193;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 32-bit FNV-1a. Chosen over a cryptographic digest because bucketing is not a security decision
|
|
14
|
-
* and this one is synchronous, dependency-free and identical in every process — which is the
|
|
15
|
-
* property that matters: two nodes must agree about one actor without talking to each other.
|
|
16
|
-
*/
|
|
17
|
-
export function fnv1a(text: string): number {
|
|
18
|
-
let hash = FNV_OFFSET_BASIS;
|
|
19
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
20
|
-
hash ^= text.charCodeAt(index);
|
|
21
|
-
hash = Math.imul(hash, FNV_PRIME);
|
|
22
|
-
}
|
|
23
|
-
return hash >>> 0;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
11
|
/**
|
|
27
12
|
* The flag key is hashed WITH the subject id, not the subject id alone: hashing the subject by
|
|
28
13
|
* itself would put the same unlucky cohort in the first 10% of every 10% rollout the app ever
|
|
29
14
|
* runs, so one group of users — or one group of tenants — would meet every half-finished feature.
|
|
30
15
|
*
|
|
16
|
+
* The hash is core's 32-bit FNV-1a: bucketing is not a security decision, and two nodes must
|
|
17
|
+
* agree about one actor without talking — a property a synchronous, published hash has.
|
|
18
|
+
*
|
|
31
19
|
* `subjectId` is whatever axis the targeting buckets by: an actor id, or an org id when
|
|
32
20
|
* `bucketBy: 'org'` keeps a tenant on one side of the boundary. Pure, so two nodes agree about a
|
|
33
21
|
* subject without talking, and a restart does not re-roll anyone.
|
package/src/flag.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// reports it (see `evaluate.ts`). The state space stays bounded because the temporary half is
|
|
8
8
|
// forced to shrink.
|
|
9
9
|
|
|
10
|
+
import { isIsoDateTime } from '@ultimat3/core';
|
|
10
11
|
import { flagExpiryInvalid } from './errors';
|
|
11
12
|
import type { FlagTargeting } from './targeting';
|
|
12
13
|
import { assertTargeting } from './targeting-assert';
|
|
@@ -106,22 +107,21 @@ export function withTargeting(flag: Flag, targeting: FlagTargeting): Flag {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
110
|
+
* `isIsoDateTime` is the framework's one rule for "a string whose instant is the same on every
|
|
111
|
+
* host and is the one written" — `t.date`, the HTTP coercion and `@ultimat3/time`'s `fromIso` all
|
|
112
|
+
* ask it. This file used to restate two of its three patterns, and the missing one was the shape:
|
|
113
|
+
* `'December 1, 2026'` and `'12/01/2026'` carry no clock time, so they passed the zone screen and
|
|
114
|
+
* `Date.parse` read them at the HOST's local midnight — the deadline moved with the pod's `TZ` —
|
|
115
|
+
* and `'2026-02-30'` rolled over to March 2nd. A clock time with no `Z` or offset is refused by
|
|
116
|
+
* the same predicate: `2026-12-01T00:00:00` measured fourteen hours apart between
|
|
117
|
+
* America/New_York and Asia/Tokyo. A date-only form is UTC by specification, so it passes.
|
|
114
118
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* `scripts/test-setup.ts` pins the runner to UTC and the failure is invisible in process.
|
|
119
|
+
* `typeof` first: a snapshot pushed from a store, or a plain-JS caller, has no types to be checked
|
|
120
|
+
* by. `flag.test.ts` spawns a `TZ=` subprocess per zone, because `scripts/test-setup.ts` pins the
|
|
121
|
+
* runner to UTC and the failure is invisible in process.
|
|
119
122
|
*/
|
|
120
|
-
const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
|
|
121
|
-
const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
122
|
-
|
|
123
123
|
function expiryMsOf(key: string, expiresAt: string): number {
|
|
124
|
-
if (
|
|
124
|
+
if (typeof expiresAt !== 'string' || !isIsoDateTime(expiresAt)) {
|
|
125
125
|
throw flagExpiryInvalid(key, expiresAt);
|
|
126
126
|
}
|
|
127
127
|
const ms = Date.parse(expiresAt);
|
package/src/index.ts
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// caught failure, while a flag is evaluated on every request.
|
|
9
9
|
|
|
10
10
|
import type { Clock, UltimateError } from '@ultimat3/core';
|
|
11
|
-
import { reportError, systemClock } from '@ultimat3/core';
|
|
11
|
+
import { finiteCount, reportError, systemClock } from '@ultimat3/core';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* One hour. Small enough that an overdue flag shows up the same day, large enough that a flag read
|
|
@@ -37,13 +37,23 @@ const lastReportedAt = new Map<string, number>();
|
|
|
37
37
|
*
|
|
38
38
|
* Only the clock. An interval change re-reads the SAME clock, so clearing there would let a report
|
|
39
39
|
* through on every configure call and turn the rate limit into a suggestion.
|
|
40
|
+
*
|
|
41
|
+
* `reportEveryMs` is screened, and BEFORE the clock is touched so a refused call changes nothing.
|
|
42
|
+
* `now - previous < NaN` is false, so a `NaN` interval — `Number(process.env.X)` on an unset
|
|
43
|
+
* variable — removed the rate limit entirely and an overdue flag reported on every evaluation;
|
|
44
|
+
* `Infinity` did the opposite and muted it for good. `0` stays legal: every evaluation reporting
|
|
45
|
+
* is a decision a caller can make on purpose.
|
|
40
46
|
*/
|
|
41
47
|
export function configureFlags(options: FlagsRuntimeOptions): void {
|
|
48
|
+
const interval =
|
|
49
|
+
options.reportEveryMs === undefined
|
|
50
|
+
? reportEveryMs
|
|
51
|
+
: finiteCount('configureFlags', 'reportEveryMs', options.reportEveryMs);
|
|
42
52
|
if (options.clock !== undefined && options.clock !== clock) {
|
|
43
53
|
clock = options.clock;
|
|
44
54
|
lastReportedAt.clear();
|
|
45
55
|
}
|
|
46
|
-
|
|
56
|
+
reportEveryMs = interval;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
export const flagsClock = (): Clock => clock;
|