@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 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` 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
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": "23.0.0",
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.0"
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": "23.0.0"
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
- * 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.
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
- * 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
+ * `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 (CLOCK_TIME.test(expiresAt) && !UTC_OFFSET.test(expiresAt)) {
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
@@ -1,6 +1,6 @@
1
1
  // Public API of @ultimat3/flags. Explicit re-exports only.
2
2
 
3
- export { BUCKETS, bucketOf, fnv1a } from './bucket';
3
+ export { BUCKETS, bucketOf } from './bucket';
4
4
  export type { FlagSubjectVia, FlagsErrorCode } from './errors';
5
5
  export {
6
6
  FLAGS_ERROR_CODES,
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
- if (options.reportEveryMs !== undefined) reportEveryMs = options.reportEveryMs;
56
+ reportEveryMs = interval;
47
57
  }
48
58
 
49
59
  export const flagsClock = (): Clock => clock;