@ultimat3/flags 30.8.0 → 31.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
@@ -113,13 +113,14 @@ what lets `policy` (tier 2) call it from inside a predicate.
113
113
  - **`configureFlags` screens `reportEveryMs` with `finiteCount`, before it touches anything.**
114
114
  `now - previous < NaN` is false, so a `NaN` interval reported on every evaluation; `Infinity`
115
115
  muted the flag for good. `0` is legal — a caller may choose it.
116
- - **`configureFlags` clears the report watermarks when it swaps the clock, and only then.** A
117
- monotonic reading is meaningful only against the clock that produced it: a process that reported
118
- at monotonic 10,000,000 and then took a clock starting at 0 computed `now - previous` as
119
- -10,000,000, below every interval, so that key could never report again — on a frozen test clock,
120
- never. `resetFlagReporting()` always did this; `configureFlags` is what apps and test kits call.
116
+ - **The clock is core's `ambientClock()`, never this package's** (v31: `configureFlags({ clock })`
117
+ is gone; `clock` in `apps/<app>/runtime.ts`, or `installClock` in a test). **`flagsClock()` clears
118
+ the report watermarks when the ambient clock is a different object from the one they were read
119
+ on, and only then.** A monotonic reading is meaningful only against the clock that produced it: a
120
+ process that reported at monotonic 10,000,000 and then took a clock starting at 0 computed
121
+ `now - previous` as -10,000,000, below every interval — on a frozen test clock, never again.
121
122
  An interval change re-reads the SAME clock and must NOT clear, or the rate limit becomes a
122
- suggestion one configure call can bypass.
123
+ suggestion one configure call can bypass (`runtime.test.ts`).
123
124
 
124
125
  ## Open
125
126
 
@@ -134,6 +135,6 @@ what lets `policy` (tier 2) call it from inside a predicate.
134
135
  ## Commands
135
136
 
136
137
  ```
137
- bun test packages/flags
138
+ bun test ./packages/flags/
138
139
  bun run --filter @ultimat3/flags typecheck
139
140
  ```
package/README.md CHANGED
@@ -165,16 +165,24 @@ work on the day it is needed.
165
165
 
166
166
  There is no reporter seam in this package. An overdue flag goes through `@ultimat3/core`'s
167
167
  `ErrorReporter` — the framework's one error-monitoring seam — as a `warning` from `source:
168
- 'process'`, so an app wires its monitor in exactly one place:
168
+ 'process'`, so an app wires its monitor in exactly one place, `apps/web/runtime.ts` (or sets
169
+ `SENTRY_DSN` and lets the boot add the transport):
169
170
 
170
171
  ```ts
171
- configureErrorReporting({ reporter: sentryErrorReporter({ dsn }) });
172
+ import type { RuntimeOverrides } from '@ultimat3/cli/serve';
173
+ import { sentryErrorReporter } from '@ultimat3/core';
174
+
175
+ declare const dsn: string;
176
+
177
+ export const runtime = { errorReporter: sentryErrorReporter({ dsn }) } satisfies RuntimeOverrides;
172
178
  ```
173
179
 
174
180
  What this package adds is the rate limit core has no opinion about: one report per flag per
175
181
  `DEFAULT_REPORT_INTERVAL_MS` (1 hour), on the **monotonic** clock, so a flag read on every request
176
182
  does not become the loudest thing in the monitor — which is how a report that fires per call ends
177
- up muted and the debt invisible again. `configureFlags({ clock, reportEveryMs })` tunes it;
183
+ up muted and the debt invisible again. `configureFlags({ reportEveryMs })` tunes it — the clock is
184
+ `@ultimat3/core`'s process clock (`ambientClock()`), set by `clock` in `apps/<app>/runtime.ts`, so
185
+ `configureFlags` takes none since 31.0.0;
178
186
  `reportEveryMs` is a whole number of milliseconds, 0 or more — `NaN`, `Infinity`, a fraction or a
179
187
  negative is refused (`X_INVARIANT`) and changes nothing.
180
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/flags",
3
- "version": "30.8.0",
3
+ "version": "31.0.0",
4
4
  "description": "Feature flags: permanent switches, and temporary ones that cannot be forgotten",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -41,6 +41,6 @@
41
41
  "test": "bun test"
42
42
  },
43
43
  "dependencies": {
44
- "@ultimat3/core": "30.8.0"
44
+ "@ultimat3/core": "31.0.0"
45
45
  }
46
46
  }
package/src/index.ts CHANGED
@@ -1,36 +1,17 @@
1
1
  // Public API of @ultimat3/flags. Explicit re-exports only.
2
2
 
3
- export { BUCKETS, bucketOf } from './bucket';
4
- export type { FlagSubjectVia, FlagsErrorCode } from './errors';
5
- export {
6
- FLAGS_ERROR_CODES,
7
- FLAGS_ERROR_TITLES,
8
- FlagsError,
9
- flagDuplicate,
10
- flagExpired,
11
- flagExpiryInvalid,
12
- flagSubjectRequired,
13
- flagTargetingInvalid,
14
- flagUnknown,
15
- } from './errors';
3
+ export { BUCKETS } from './bucket';
4
+ export type { FlagsErrorCode } from './errors';
5
+ export { FlagsError, flagExpiryInvalid } from './errors';
16
6
  export { isEnabled } from './evaluate';
17
- export type {
18
- Flag,
19
- FlagDef,
20
- FlagExpiryIsMandatory,
21
- FlagKind,
22
- PermanentFlagDef,
23
- TemporaryFlagDef,
24
- } from './flag';
25
- export { FLAG_KINDS } from './flag';
7
+ export type { Flag, FlagDef, FlagKind, PermanentFlagDef, TemporaryFlagDef } from './flag';
26
8
  export type { FlagFacts, FlagsReport } from './projection';
27
9
  export { flagsReport } from './projection';
28
10
  export type { SnapshotResult } from './registry';
29
- export { allFlags, applyFlagSnapshot, defineFlag, hasFlag, resetFlags } from './registry';
11
+ export { applyFlagSnapshot, defineFlag } from './registry';
30
12
  export type { FlagsRuntimeOptions } from './runtime';
31
13
  // The reporter seam is `@ultimat3/core`'s `ErrorReporter`, wired once with
32
- // `configureErrorReporting()`. This package deliberately re-exports none of it.
33
- export { configureFlags, DEFAULT_REPORT_INTERVAL_MS, resetFlagReporting } from './runtime';
34
- export type { BuiltInSubjectKind, FlagSubjects } from './subject';
35
- export { BUILT_IN_SUBJECT_KINDS } from './subject';
14
+ // `runtime.ts`'s `errorReporter`. This package deliberately re-exports none of it.
15
+ export { configureFlags } from './runtime';
16
+ export type { FlagSubjects } from './subject';
36
17
  export type { FlagTargeting } from './targeting';
package/src/runtime.ts CHANGED
@@ -1,5 +1,6 @@
1
- // Single responsibility: the two ambient things an evaluation needs that the declaration cannot
2
- // carry — what "now" is, and how often the same overdue flag may report.
1
+ // Single responsibility: how often the same overdue flag may report, and the watermarks that rate
2
+ // limit keeps. "Now" is not this package's: it is core's `ambientClock()`, which the boot fills
3
+ // from `clock` in `apps/<app>/runtime.ts` and a test from `@ultimat3/testing` (v31).
3
4
  //
4
5
  // The reporter itself is NOT here. `@ultimat3/core`'s `ErrorReporter` is the framework's one
5
6
  // error-monitoring seam; a second one in this package would be a second place an app has to wire
@@ -8,7 +9,7 @@
8
9
  // caught failure, while a flag is evaluated on every request.
9
10
 
10
11
  import type { Clock, UltimateError } from '@ultimat3/core';
11
- import { finiteCount, recordFailureFact, reportError, systemClock } from '@ultimat3/core';
12
+ import { ambientClock, finiteCount, recordFailureFact, reportError } from '@ultimat3/core';
12
13
 
13
14
  /**
14
15
  * One hour. Small enough that an overdue flag shows up the same day, large enough that a flag read
@@ -18,27 +19,36 @@ import { finiteCount, recordFailureFact, reportError, systemClock } from '@ultim
18
19
  export const DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1000;
19
20
 
20
21
  export interface FlagsRuntimeOptions {
21
- readonly clock?: Clock | undefined;
22
22
  /** Minimum gap between two reports of the SAME flag key. */
23
23
  readonly reportEveryMs?: number | undefined;
24
24
  }
25
25
 
26
- let clock: Clock = systemClock;
27
26
  let reportEveryMs = DEFAULT_REPORT_INTERVAL_MS;
28
27
  const lastReportedAt = new Map<string, number>();
28
+ /** The clock every watermark in `lastReportedAt` was read from. */
29
+ let watermarkClock: Clock | undefined;
29
30
 
30
31
  /**
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.
32
+ * The process clock, and the watermarks forgotten whenever it is a DIFFERENT clock from the one
33
+ * they were read on: a monotonic reading is only meaningful against the clock that produced it. A
34
+ * process that reported at monotonic 10_000_000 and then took a clock starting at 0 computed
35
+ * `now - previous` as -10_000_000 — below every interval, so that key could never report again
36
+ * until the new clock passed the old one's reading, which on a frozen test clock is never.
37
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
- *
41
- * `reportEveryMs` is screened, and BEFORE the clock is touched so a refused call changes nothing.
38
+ * Only on a clock change. An interval change re-reads the SAME clock, so clearing there would let
39
+ * a report through on every configure call and turn the rate limit into a suggestion.
40
+ */
41
+ export const flagsClock = (): Clock => {
42
+ const clock = ambientClock();
43
+ if (clock !== watermarkClock) {
44
+ lastReportedAt.clear();
45
+ watermarkClock = clock;
46
+ }
47
+ return clock;
48
+ };
49
+
50
+ /**
51
+ * `reportEveryMs` is screened, and BEFORE anything is touched so a refused call changes nothing.
42
52
  * `now - previous < NaN` is false, so a `NaN` interval — `Number(process.env.X)` on an unset
43
53
  * variable — removed the rate limit entirely and an overdue flag reported on every evaluation;
44
54
  * `Infinity` did the opposite and muted it for good. `0` stays legal: every evaluation reporting
@@ -49,15 +59,9 @@ export function configureFlags(options: FlagsRuntimeOptions): void {
49
59
  options.reportEveryMs === undefined
50
60
  ? reportEveryMs
51
61
  : finiteCount('configureFlags', 'reportEveryMs', options.reportEveryMs);
52
- if (options.clock !== undefined && options.clock !== clock) {
53
- clock = options.clock;
54
- lastReportedAt.clear();
55
- }
56
62
  reportEveryMs = interval;
57
63
  }
58
64
 
59
- export const flagsClock = (): Clock => clock;
60
-
61
65
  /**
62
66
  * Report `build()`'s error through core's seam at most once per `reportEveryMs` for this key, and
63
67
  * say whether it did. The rate limit is keyed on the MONOTONIC clock: a wall-clock jump — an NTP
@@ -71,7 +75,7 @@ export const flagsClock = (): Clock => clock;
71
75
  * happened to evaluate it first.
72
76
  */
73
77
  export function reportOnce(key: string, build: () => UltimateError): boolean {
74
- const now = clock.monotonic();
78
+ const now = flagsClock().monotonic();
75
79
  const previous = lastReportedAt.get(key);
76
80
  if (previous !== undefined && now - previous < reportEveryMs) return false;
77
81
  lastReportedAt.set(key, now);
@@ -92,7 +96,7 @@ export function recordEvaluation(key: string, on: boolean): boolean {
92
96
 
93
97
  /** Test-only, and the counterpart to every other reset in the framework. */
94
98
  export function resetFlagReporting(): void {
95
- clock = systemClock;
99
+ watermarkClock = undefined;
96
100
  reportEveryMs = DEFAULT_REPORT_INTERVAL_MS;
97
101
  lastReportedAt.clear();
98
102
  }