@ultimat3/core 16.0.0 → 18.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.
@@ -252,57 +252,14 @@ export function renderMetaRecord(
252
252
  * There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
253
253
  * from being the same breach.
254
254
  *
255
- * A deliberate duplicate of `describeValue` in `packages/schema/src/describe-value.ts`, for the
256
- * reason `SCHEMA_ERROR_CODE_TITLES` is one: `@ultimat3/schema` is tier 0 alongside this package,
257
- * so neither may import the other. Keep the two answering IDENTICALLY — that is what
258
- * `packages/cli/src/describe-value-pin.test.ts` holds — and changing one alone is the bug.
259
- */
260
- export function describeValue(value: unknown): string {
261
- if (value === undefined) return 'undefined';
262
- if (value === null) return 'null';
263
- switch (typeof value) {
264
- case 'string':
265
- return countOf(charCount(value), 'string', 'character');
266
- case 'number':
267
- return describeNumber(value);
268
- case 'boolean':
269
- return 'a boolean';
270
- case 'bigint':
271
- return 'a bigint';
272
- case 'symbol':
273
- return 'a symbol';
274
- case 'function':
275
- return 'a function';
276
- default:
277
- break;
278
- }
279
- if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
280
- // `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
281
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
282
- return 'an object';
283
- }
284
-
285
- function describeNumber(value: number): string {
286
- if (Number.isNaN(value)) return 'NaN';
287
- if (value === Number.POSITIVE_INFINITY) return 'Infinity';
288
- if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
289
- return 'a number';
290
- }
291
-
292
- function countOf(size: number, noun: string, unit: string): string {
293
- if (size === 0) return `an empty ${noun}`;
294
- const article = noun === 'array' ? 'an' : 'a';
295
- return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
296
- }
297
-
298
- /**
299
- * The twin of `@ultimat3/schema`'s `char-count.ts`, duplicated for the same reason `describeValue`
300
- * is: both packages are tier 0 and neither may import the other. Code points, because the rules
301
- * that reject a string count in them and the message must quote the same unit — `'👍'.length` is 2.
302
- * Only a surrogate makes the two counts differ, so every ASCII value keeps the O(1) read.
255
+ * DECLARED IN `@ultimat3/schema` and re-exported here, `As of 2026-08-27`. It was a
256
+ * character-for-character copy of `packages/schema/src/describe-value.ts` — the two tier-0
257
+ * packages could not import each other — held equal by a 63-line behavioural pin in
258
+ * `@ultimat3/cli`, a TIER-5 package, that no rule required to exist. Whose function it is has not
259
+ * changed: schema's `expected()` is the other reader, and a widening that leaks a value is now one
260
+ * edit in one file rather than two files that can drift apart in silence.
261
+ *
262
+ * `charCount` went with it — the same duplication, one call deep — so the unit a message quotes
263
+ * and the unit a length rule counts in are the same code, not two files that agree today.
303
264
  */
304
- const HAS_SURROGATE = /[\uD800-\uDBFF]/;
305
-
306
- function charCount(value: string): number {
307
- return HAS_SURROGATE.test(value) ? [...value].length : value.length;
308
- }
265
+ export { describeValue } from '@ultimat3/schema';
@@ -106,9 +106,21 @@ export function registerErrorRetry(retries: Readonly<Record<string, ErrorRetry>>
106
106
  }
107
107
  }
108
108
 
109
- /** Test seam. Production registers once at boot and never unregisters. */
110
- export function resetErrorRetry(): void {
109
+ /**
110
+ * Test seam. Production registers once at boot and never unregisters.
111
+ *
112
+ * `restore` is not a nicety: this map is PROCESS-WIDE and every package fills it at module
113
+ * evaluation, so a bare `clear()` in one file's `afterEach` deletes registrations that file never
114
+ * made. Bun runs one process for the whole suite and `scripts/test-setup.ts` imports packages
115
+ * before any test runs, so their module bodies never run again to repair it — measured,
116
+ * `@ultimat3/policy`'s codes read as unclassified in every file loaded after core's tests, and
117
+ * `@ultimat3/db`, `@ultimat3/ai` and `@ultimat3/jobs` each carry a `beforeEach` that re-registers
118
+ * their own table for exactly this reason. Pass `registeredErrorRetry()` captured at module scope
119
+ * and the wipe is scoped to what the test added.
120
+ */
121
+ export function resetErrorRetry(restore: Readonly<Record<string, ErrorRetry>> = {}): void {
111
122
  REGISTERED.clear();
123
+ for (const [code, retry] of Object.entries(restore)) REGISTERED.set(code, retry);
112
124
  }
113
125
 
114
126
  /**
@@ -0,0 +1,54 @@
1
+ // Single responsibility: the one refusal the framework makes for a numeric option that is not a
2
+ // number. Tier 0 because twenty-one packages need it — `jobs`, `realtime` and `query` each carried
3
+ // a byte-identical copy, the shape three backoff curves and three SQL escapes already took.
4
+ // `bun run finite-bounds` is the ratchet; this is the repair it recognises.
5
+
6
+ import { assert } from './assert';
7
+
8
+ /**
9
+ * The option, when it is a real number. Returns it so a call site stays one expression:
10
+ * `this.#capacity = finiteOption('ChangeBuffer', 'capacity', options.capacity ?? 1024)`.
11
+ *
12
+ * WHY IT TAKES A FUNCTION AT ALL, since every part of the job looks already done by an operator:
13
+ * `??` guards NULLISH and `NaN` is not nullish, so `Number(process.env.X)` on an unset variable, a
14
+ * `parseInt` of a typo and a JSON `null`-turned-`NaN` all walk straight past the default. And
15
+ * `Math.max` / `Math.min` / `Math.floor` PROPAGATE it rather than validating, so none of the three
16
+ * is a screen either — `Math.min(raw, Infinity)` is `Infinity`. What the value then bounds decides
17
+ * WHICH failure you get, and all four have shipped: a comparison that reads false forever, an
18
+ * `Array.from({ length: NaN })` that is `[]`, a `setTimeout(fn, NaN)` that is `setTimeout(fn, 0)`,
19
+ * and a loop that never terminates. Several packages' comments point here for this argument.
20
+ */
21
+ export function finiteOption(subject: string, option: string, value: number): number {
22
+ assert(
23
+ Number.isFinite(value),
24
+ `${subject} ${option} is ${String(value)}, so every comparison against it is false and the bound it sets does not exist`,
25
+ `pass a finite ${option} to ${subject}, or omit it and take the default — Math.max/Math.min/Math.floor do not validate it, they propagate it`,
26
+ );
27
+ return value;
28
+ }
29
+
30
+ /**
31
+ * The same rule where the option counts THINGS — rows, bytes, entries, milliseconds, slots — and a
32
+ * fraction is as wrong as a `NaN`: `Array.from({ length: 2.5 })` throws, a `slice` truncates, and
33
+ * above 2^53 a double cannot name its own successor, so a "count" up there is already rounded.
34
+ * `Number.isSafeInteger`, which is what `@ultimat3/schema` demands of an integer at the wire.
35
+ *
36
+ * `min` is the caller's because only the caller knows what zero MEANS: `requestTimeoutMs: 0` is
37
+ * "no deadline" and `maxInflight: 0` is "never shed", both decisions the code reads, while
38
+ * `max: 0` connections is a pool nothing can run on. A helper that picked one would be wrong at
39
+ * half the call sites, and this is deliberately the only knob it takes — a third function for
40
+ * "positive" would be the copy this file exists to prevent.
41
+ */
42
+ export function finiteCount(
43
+ subject: string,
44
+ option: string,
45
+ value: number,
46
+ min: 0 | 1 = 0,
47
+ ): number {
48
+ assert(
49
+ Number.isSafeInteger(value) && value >= min,
50
+ `${subject} ${option} is ${String(value)}, and it counts things: it must be a whole number of ${min === 1 ? 'at least 1' : '0 or more'}, or the bound it sets is not one`,
51
+ `pass a whole ${option} to ${subject} — and parse an environment value before you pass it, because Number(process.env.…) is NaN when the variable is unset and Math.floor does not repair that`,
52
+ );
53
+ return value;
54
+ }
package/src/ids.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { type Clock, systemClock } from './clock';
5
5
  import { describeValue } from './error-render';
6
6
  import { UltimateError } from './errors';
7
+ import { finiteCount } from './finite-option';
7
8
 
8
9
  /** Nominal typing without a runtime cost. `Brand<string, 'post'>` never mixes with `'user'`. */
9
10
  export type Brand<T, K extends string> = T & { readonly __brand: K };
@@ -39,8 +40,13 @@ function seedCounter(): number {
39
40
  return (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)) & COUNTER_SEED_MASK;
40
41
  }
41
42
 
43
+ /**
44
+ * `new Uint8Array(NaN)` is a zero-length array, not a throw, so an unscreened length made this
45
+ * answer `''` — an id that is no id, minted silently. `min: 1`: zero bytes of randomness is the
46
+ * same empty string, and every caller here (`uuid`, `traceId`, `spanId`) wants a width.
47
+ */
42
48
  export function randomHex(byteLength: number): string {
43
- const bytes = randomBytes(byteLength);
49
+ const bytes = randomBytes(finiteCount('randomHex', 'byteLength', byteLength, 1));
44
50
  let out = '';
45
51
  for (const byte of bytes) {
46
52
  out += HEX[byte >> 4];
@@ -109,9 +115,15 @@ export function uuidTimestamp(id: string): Date {
109
115
  return new Date(Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16));
110
116
  }
111
117
 
112
- /** URL-safe random id. Not sortable — use it for tokens and slugs, never primary keys. */
118
+ /**
119
+ * URL-safe random id. Not sortable — use it for tokens and slugs, never primary keys.
120
+ *
121
+ * The default is not a screen: `??` guards nullish and `NaN` is not nullish, so
122
+ * `nanoid(Number(process.env.ID_LENGTH))` on an unset variable walked past `21` and answered the
123
+ * empty string. `min: 1` for the reason `randomHex` gives.
124
+ */
113
125
  export function nanoid(length = 21): string {
114
- const bytes = randomBytes(length);
126
+ const bytes = randomBytes(finiteCount('nanoid', 'length', length, 1));
115
127
  let out = '';
116
128
  for (const byte of bytes) out += NANO_ALPHABET[byte & 63] as string;
117
129
  return out;
@@ -3,6 +3,7 @@
3
3
  // copy: responsive variants, blur placeholders and PWA icons are the same three steps with
4
4
  // different numbers, and a framework that generated them twice would drift twice.
5
5
 
6
+ import { assert } from '../assert';
6
7
  import { composeOnto, layOut, type ResizeSpec } from './canvas';
7
8
  import { imageFromBunError, imageUnsupported } from './errors';
8
9
  import { unshared } from './png-bytes';
@@ -34,6 +35,27 @@ export const canEncode = (format: string): format is EncodableFormat =>
34
35
  /** 1-100, lossy formats only. Bun's own default, pinned here so output cannot drift with it. */
35
36
  export const DEFAULT_IMAGE_QUALITY = 80;
36
37
 
38
+ /**
39
+ * The encoder CLAMPS and never refuses, which is why this is a screen and not a comment. Exported
40
+ * because `@ultimat3/storage` mints a variant KEY from the same number without ever reaching this
41
+ * pipeline — two screens that refuse different qualities would mint `q150` keys for bytes this
42
+ * encoder goes on to refuse, which is the dead-on-arrival shape `buildSignedUrl`'s own
43
+ * `finiteCount` screen already exists for — that package held a private `assertFiniteSignedUrlBound`
44
+ * until this sweep collapsed it onto the tier-0 check. Measured
45
+ * on a 3x2 PNG re-encoded as JPEG: quality 80 wrote 663 bytes, and `NaN`, `-5` and `0` each wrote
46
+ * 635 — the worst encoding the codec can make — while `Infinity` and `1e9` each wrote 719. Every
47
+ * one of them silently. `Number(url.searchParams.get('q'))` is `NaN` when the parameter is absent,
48
+ * so the degraded image is the one an unparameterised URL gets.
49
+ */
50
+ export const assertFiniteImageQuality = (quality: number): number => {
51
+ assert(
52
+ Number.isInteger(quality) && quality >= 1 && quality <= 100,
53
+ `image quality is ${String(quality)}, and the encoder clamps rather than refusing: it must be a whole number from 1 to 100`,
54
+ `pass a whole quality from 1 to 100 — transformImageBytes(bytes, { quality: ${String(DEFAULT_IMAGE_QUALITY)} }) — and parse a query value before you pass it`,
55
+ );
56
+ return quality;
57
+ };
58
+
37
59
  const encodeFix =
38
60
  "request 'png', 'jpeg' or 'webp', or route the transform through an ImageTransformDriver (a " +
39
61
  'CDN or an external encoder) that can produce it';
@@ -86,7 +108,7 @@ export async function transformImageBytes(
86
108
  if (format !== undefined && !canEncode(format)) {
87
109
  throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
88
110
  }
89
- const quality = spec.quality ?? DEFAULT_IMAGE_QUALITY;
111
+ const quality = assertFiniteImageQuality(spec.quality ?? DEFAULT_IMAGE_QUALITY);
90
112
  const source = await run('reading the image header', () => bunImage(bytes).metadata());
91
113
  const output: EncodableFormat = format ?? (canEncode(source.format) ? source.format : 'png');
92
114
  const layout = layOut(source, spec);
package/src/index.ts CHANGED
@@ -65,13 +65,15 @@ export type {
65
65
  DatabaseConfig,
66
66
  JobsConfig,
67
67
  McpConfig,
68
- PwaConfig,
68
+ NotifyConfig,
69
69
  RealtimeConfig,
70
70
  RealtimeTransport,
71
71
  ThemeConfig,
72
72
  ThemeMode,
73
73
  } from './config';
74
- export { defineConfig } from './config';
74
+ export { defineConfig, INBOX_RETENTION_KEYS } from './config';
75
+ export type { PwaColors, PwaConfig, PwaSchemeColors } from './config-pwa';
76
+ export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
75
77
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
76
78
  export {
77
79
  createContext,
@@ -390,6 +392,7 @@ export {
390
392
  writeMasterKeyFile,
391
393
  writeSecretsFile,
392
394
  } from './exports/secrets';
395
+ export { finiteCount, finiteOption } from './finite-option';
393
396
  export type {
394
397
  FlightGate,
395
398
  FlightGateLimits,
@@ -432,6 +435,7 @@ export type {
432
435
  ImageTransformSpec,
433
436
  } from './image/pipeline';
434
437
  export {
438
+ assertFiniteImageQuality,
435
439
  blurDataUrl,
436
440
  canDecode,
437
441
  canEncode,
@@ -453,7 +457,14 @@ export {
453
457
  rasterFrom,
454
458
  } from './image/raster';
455
459
  export { impersonate, impersonationReason, isImpersonating } from './impersonate';
456
- export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
460
+ export {
461
+ assertLocale,
462
+ cachedFormatter,
463
+ canonicalLocale,
464
+ localeInvalid,
465
+ MAX_CACHED_FORMATTERS,
466
+ MAX_LOCALE_EXCERPT,
467
+ } from './intl-cache';
457
468
  export { isJsonObject } from './json-object';
458
469
  export type {
459
470
  HealthPayload,
package/src/intl-cache.ts CHANGED
@@ -1,8 +1,11 @@
1
- // One bounded cache, on one canonical key, for every `Intl` formatter the framework builds.
1
+ // One bounded cache, on one canonical key, and one screen, for every `Intl` formatter the
2
+ // framework builds.
2
3
  // A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on the
3
4
  // caller's spelling is memory the client chooses — 31 MB and 55.1 MB, measured `As of 2026-08` and
4
5
  // written up in the README. The bound and the canonical key are two halves of ONE rule.
5
6
 
7
+ import { UltimateError } from './errors';
8
+
6
9
  /**
7
10
  * Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
8
11
  * and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
@@ -24,6 +27,16 @@ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: ()
24
27
  return formatter;
25
28
  }
26
29
 
30
+ /**
31
+ * The cap on the tag an `X_LOCALE_INVALID` cause quotes back, in code points.
32
+ *
33
+ * 35 is RFC 5646 §4.4.1's own number — Figure 7 derives it as the longest tag the registry can
34
+ * form (language 8 + script 5 + region 4 + two variants 9+9), and the same section says a protocol
35
+ * with a fixed buffer "MUST allow for language tags of at least 35 characters". So every tag a
36
+ * caller could legitimately have meant fits, and nothing longer is a tag being debugged.
37
+ */
38
+ export const MAX_LOCALE_EXCERPT = 35;
39
+
27
40
  /**
28
41
  * The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
29
42
  * (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
@@ -41,3 +54,77 @@ export function canonicalLocale(locale: string): string | undefined {
41
54
  return undefined;
42
55
  }
43
56
  }
57
+
58
+ /**
59
+ * A tag `Intl` cannot parse. Distinct from `@ultimat3/i18n`'s `X_LOCALE_UNSUPPORTED`, which is a
60
+ * well-formed tag outside the app's supported set — this one is not a tag at all, and a raw
61
+ * `RangeError` out of a formatter says nothing about which caller supplied it.
62
+ */
63
+ export function localeInvalid(locale: string): UltimateError {
64
+ return new UltimateError({
65
+ code: 'X_LOCALE_INVALID',
66
+ cause: `${localeExcerpt(locale)} is not a well-formed BCP 47 language tag`,
67
+ fix: "pass a tag like 'en', 'en-GB' or 'de-DE' — screen a header-supplied value with Intl.DateTimeFormat.supportedLocalesOf([tag]) before it reaches a formatter",
68
+ // The raw tag, under a NAME. A `cause` is copied into the 400 body and into the log line by
69
+ // `@ultimat3/http`'s `toProblem`, and a logger redacts by key — so a caller's value spliced
70
+ // into prose has no key left to redact, which is the whole argument `describeValue` rests on.
71
+ // `meta` is the key, and it is machine-read, so it carries the value WHOLE: an excerpt here
72
+ // would be a value a redactor or a bug report reads as complete when it is not.
73
+ meta: { locale },
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The tag as a reader can act on it: the first `MAX_LOCALE_EXCERPT` code points, quoted, and SAID
79
+ * to be cut when there were more.
80
+ *
81
+ * `describeValue` is the usual answer for a caller-supplied value in a `cause` and it is the wrong
82
+ * one here — it renders `en_US` as "a 5-character string", deleting the only actionable content in
83
+ * a sentence whose entire job is to say WHICH tag was refused. Bounding it keeps the diagnostic and
84
+ * removes the part a stranger chooses: without a cap the whole of an `Accept-Language` value —
85
+ * megabytes, at the caller's option — became the error's `message`, its 400 body and its log line.
86
+ *
87
+ * Code points, never `slice`: cutting between a surrogate pair leaves a lone surrogate, which is
88
+ * not text and survives no encoder between here and the log index. The string iterator is lazy, so
89
+ * a megabyte tag costs 35 steps rather than a megabyte-long array.
90
+ *
91
+ * No escaping here: `UltimateError`'s constructor runs `singleLine` over every line-bearing field
92
+ * exactly once, which is what keeps a newline in a tag from writing a second line an operator reads
93
+ * as a genuine framework message. A second pass at this call site would be a second place that has
94
+ * to be right.
95
+ */
96
+ function localeExcerpt(locale: string): string {
97
+ let head = '';
98
+ let taken = 0;
99
+ for (const char of locale) {
100
+ if (taken === MAX_LOCALE_EXCERPT) return `"${head}" (truncated at ${taken} characters)`;
101
+ head += char;
102
+ taken += 1;
103
+ }
104
+ return `"${head}"`;
105
+ }
106
+
107
+ /**
108
+ * The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. The ONE screen a
109
+ * caller-supplied BCP 47 tag passes before it reaches an `Intl` constructor.
110
+ *
111
+ * Validating and keying are one step, which is why this lives beside the cache rather than in
112
+ * either caller: `getCanonicalLocales` runs exactly the structural check
113
+ * `Intl.DateTimeFormat.supportedLocalesOf` throws on — what the `fix:` above tells the caller to
114
+ * run — and unlike it hands back the spelling every formatter cache keys on, so `EN-us` and
115
+ * `en-US` cannot mint two entries for one locale.
116
+ *
117
+ * It is tier 0 for the reason `cachedFormatter` is: `@ultimat3/time` and `@ultimat3/money` both
118
+ * need it and `money -> time` is a sideways import `bun run boundaries` refuses. Money was the
119
+ * last package still letting the tag through — a `RangeError` off an `Accept-Language` header —
120
+ * on the argument that this seam "decides a cache key, never whether a locale is acceptable",
121
+ * which is true of the cache and was never an argument for passing the tag on.
122
+ *
123
+ * Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for
124
+ * those, and a user carrying a locale the runtime has no data for must still get a rendered page.
125
+ */
126
+ export function assertLocale(locale: string): string {
127
+ const tag = canonicalLocale(locale);
128
+ if (tag === undefined) throw localeInvalid(locale);
129
+ return tag;
130
+ }
package/src/lifecycle.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { type Clock, systemClock } from './clock';
6
6
  import { UltimateError } from './errors';
7
+ import { finiteCount } from './finite-option';
7
8
  import { settleWithin } from './lifecycle-deadline';
8
9
  import { lifecycleDrained } from './lifecycle-errors';
9
10
  import { type LogFields, type Logger, logger as rootLogger } from './logger';
@@ -49,6 +50,8 @@ export interface LifecycleOptions {
49
50
  * lapses and another worker re-claims it, which is what at-least-once already promises. The
50
51
  * alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
51
52
  * kubelet's grace period, with no log line naming what overran.
53
+ *
54
+ * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
52
55
  */
53
56
  readonly deadlineMs?: number | undefined;
54
57
  readonly clock?: Clock | undefined;
@@ -119,7 +122,15 @@ let idleWaiters: (() => void)[] = [];
119
122
  const readiness = new Map<string, ReadinessCheck>();
120
123
 
121
124
  export function configureLifecycle(options: LifecycleOptions): void {
122
- if (options.deadlineMs !== undefined) deadlineMs = options.deadlineMs;
125
+ // Screened above the write, never beside the arithmetic: `Math.max(0, deadlineAt - monotonic())`
126
+ // PROPAGATES a NaN into `setTimeout(fn, NaN)`, i.e. 0 — measured, one in-flight operation dropped
127
+ // and a 300ms close hook ABANDONED 111ms into a 25s budget, while `X_SHUTDOWN_TIMEOUT` rendered
128
+ // `NaNms` and told the operator to RAISE a budget that was never a number. `min: 0` because 0 is
129
+ // a real budget — drain now, no grace — and `@ultimat3/http`'s `drainTimeoutMs` accepts 0 and
130
+ // hands it straight here, so a floor of 1 would refuse at boot what that package declares.
131
+ if (options.deadlineMs !== undefined) {
132
+ deadlineMs = finiteCount('configureLifecycle', 'deadlineMs', options.deadlineMs, 0);
133
+ }
123
134
  if (options.clock !== undefined) {
124
135
  clock = options.clock;
125
136
  startedAtMono = clock.monotonic();
@@ -0,0 +1,68 @@
1
+ // Single responsibility: the identifier grammar every metric name and every LABEL name obeys, and
2
+ // the one code that refuses a value outside it. Split out of `metrics.ts` because that file is at
3
+ // the 500-line ceiling `x verify`'s `filesize` step enforces, and because the two refusals are one
4
+ // rule asked of two things — the exposition format spells them identically.
5
+
6
+ import { type CodedErrorInit, UltimateError } from './errors';
7
+ import type { MetricAttributes } from './metrics-types';
8
+
9
+ export class MetricNameInvalidError extends UltimateError {
10
+ static readonly code = 'X_METRIC_NAME_INVALID';
11
+ override readonly name = 'MetricNameInvalidError';
12
+ constructor(init: CodedErrorInit) {
13
+ super({ ...init, code: MetricNameInvalidError.code });
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Lowercase snake, the intersection of what every exposition format accepts. OTel's dotted names
19
+ * survive an OTLP hop but not a Prometheus scrape, and the autoscaler reads the scrape.
20
+ */
21
+ export const METRIC_NAME_RE = /^[a-z_][a-z0-9_]*$/;
22
+
23
+ /**
24
+ * The suggestion both `fix:` lines below hand back, and it has to SATISFY `METRIC_NAME_RE`, not
25
+ * merely resemble it: the grammar's first character is `[a-z_]`, so lowercasing `2digits` left
26
+ * `2digits` and the refusal told the caller to rename the label to the same name it had just
27
+ * refused. An underscore prefix is the one repair that keeps the rest of the identifier — which is
28
+ * also what makes the empty string a nameable `_` rather than a second refusal.
29
+ */
30
+ const snake = (value: string): string => {
31
+ const lowered = value.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_');
32
+ return /^[a-z_]/.test(lowered) ? lowered : `_${lowered}`;
33
+ };
34
+
35
+ /** Refused at DECLARATION, where the instrument is still nameable. */
36
+ export function assertMetricName(name: string): void {
37
+ if (METRIC_NAME_RE.test(name)) return;
38
+ throw new MetricNameInvalidError({
39
+ cause: `"${name}" is not lowercase snake_case matching ${METRIC_NAME_RE.source}`,
40
+ fix: `rename the instrument to lowercase snake_case, e.g. ${snake(name)}`,
41
+ meta: { name },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * A LABEL name obeys that same grammar, and it was guarded nowhere: `metrics-text.ts` escapes the
47
+ * label VALUE and interpolates the KEY raw, so `bad"key` rendered `orders_total{bad"key="x"} 1`
48
+ * and a `\n` in one split the body into a line the scraper reads as a different series. A scraper
49
+ * rejects the WHOLE response over one unparseable line, so `http_requests_total`, `connections`
50
+ * and `queue_depth` — the three the chart's HPA reads — disappear together, and the autoscaler
51
+ * sees no signal rather than a bad one.
52
+ *
53
+ * Refused on the way in, never sanitised at render: a silently renamed label is a different
54
+ * series, and the point would land somewhere the dashboard querying the declared name cannot see.
55
+ * Once per label set the instrument has not seen — `seriesFor` calls this on its MISS, ahead of
56
+ * the cardinality ceiling, because the overflow branch answers without allocating a series and a
57
+ * check hung off the allocation would have made the refusal a function of how busy the process is.
58
+ */
59
+ export function assertLabelNames(metric: string, attributes: MetricAttributes): void {
60
+ for (const label of Object.keys(attributes)) {
61
+ if (METRIC_NAME_RE.test(label)) continue;
62
+ throw new MetricNameInvalidError({
63
+ cause: `${metric} was given the label "${label}", which is not lowercase snake_case matching ${METRIC_NAME_RE.source} — the exposition line it renders is one no scraper can parse, and one bad line drops the whole scrape`,
64
+ fix: `rename the label at the call site to lowercase snake_case, e.g. { ${snake(label)}: … }`,
65
+ meta: { metric, label },
66
+ });
67
+ }
68
+ }
@@ -14,6 +14,12 @@ function escapeLabel(value: string): string {
14
14
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
15
15
  }
16
16
 
17
+ /**
18
+ * The KEY is interpolated bare and the VALUE is escaped, which is the whole grammar: a label name
19
+ * is an identifier `metrics.ts` refuses at series creation (`X_METRIC_NAME_INVALID`), a label
20
+ * value is arbitrary text. Never sanitise a key here — a renamed label is a different series, and
21
+ * this function has no way to tell the dashboard that.
22
+ */
17
23
  function labels(attributes: MetricAttributes, extra?: readonly [string, string]): string {
18
24
  const pairs = Object.entries(attributes)
19
25
  .sort(([a], [b]) => (a < b ? -1 : 1))
package/src/metrics.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  // aggregated in process, read through one `collectMetrics()`. Shaped exactly like `telemetry.ts`:
3
3
  // always on, a no-op exporter by default, and the wire format supplied by a driver, never here.
4
4
 
5
+ import { assert } from './assert';
5
6
  import { type Clock, systemClock } from './clock';
6
7
  import { renderThrowable } from './error-render';
7
8
  import { type CodedErrorInit, UltimateError } from './errors';
8
9
  import { logger } from './logger';
10
+ import { assertLabelNames, assertMetricName, MetricNameInvalidError } from './metric-names';
9
11
  import type {
10
12
  Counter,
11
13
  Gauge,
@@ -22,8 +24,10 @@ import type {
22
24
  } from './metrics-types';
23
25
  import { serviceResource } from './telemetry';
24
26
 
25
- // The data model is a module of its own; the public surface is unchanged, so nothing that imports
26
- // a metric type from here has to learn a second path.
27
+ // The data model and the identifier grammar are modules of their own; the public surface is
28
+ // unchanged, so nothing that imports a metric type or the name error from here learns a second
29
+ // path.
30
+ export { MetricNameInvalidError } from './metric-names';
27
31
  export type {
28
32
  Counter,
29
33
  Gauge,
@@ -42,14 +46,6 @@ export type {
42
46
  ReadableMetric,
43
47
  } from './metrics-types';
44
48
 
45
- export class MetricNameInvalidError extends UltimateError {
46
- static readonly code = 'X_METRIC_NAME_INVALID';
47
- override readonly name = 'MetricNameInvalidError';
48
- constructor(init: CodedErrorInit) {
49
- super({ ...init, code: MetricNameInvalidError.code });
50
- }
51
- }
52
-
53
49
  export class MetricValueInvalidError extends UltimateError {
54
50
  static readonly code = 'X_METRIC_VALUE_INVALID';
55
51
  override readonly name = 'MetricValueInvalidError';
@@ -88,12 +84,6 @@ export const DEFAULT_HISTOGRAM_BOUNDS: readonly number[] = Object.freeze([
88
84
  0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
89
85
  ]);
90
86
 
91
- /**
92
- * Lowercase snake, the intersection of what every exposition format accepts. OTel's dotted names
93
- * survive an OTLP hop but not a Prometheus scrape, and the autoscaler reads the scrape.
94
- */
95
- const METRIC_NAME_RE = /^[a-z_][a-z0-9_]*$/;
96
-
97
87
  export const noopMetricExporter: MetricExporter = Object.freeze({
98
88
  export(): void {
99
89
  // Intentionally empty: instruments are always live, and free until an exporter is configured.
@@ -231,13 +221,7 @@ function assertBounds(name: string, bounds: readonly number[] | undefined): void
231
221
  }
232
222
 
233
223
  function declare(name: string, kind: MetricKind, options: GaugeOptions & HistogramOptions) {
234
- if (!METRIC_NAME_RE.test(name)) {
235
- throw new MetricNameInvalidError({
236
- cause: `"${name}" is not lowercase snake_case matching ${METRIC_NAME_RE.source}`,
237
- fix: `rename the instrument to lowercase snake_case, e.g. ${name.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_')}`,
238
- meta: { name },
239
- });
240
- }
224
+ assertMetricName(name);
241
225
  assertBounds(name, options.bounds);
242
226
  const existing = instruments.get(name);
243
227
  if (existing !== undefined) {
@@ -367,6 +351,12 @@ function seriesFor(instrument: Instrument, attributes: MetricAttributes): Series
367
351
  const key = seriesKey(attributes);
368
352
  const found = instrument.series.get(key);
369
353
  if (found !== undefined) return found;
354
+ // On the MISS, ahead of the ceiling — never inside `createSeries`, which the overflow branch
355
+ // below returns without reaching. A screen that ran only where a series is BORN was a screen
356
+ // that depended on load: `bad"key` threw on a fresh process and was swallowed on a busy one,
357
+ // once the instrument had filled up, which is exactly when an unparseable label is likeliest to
358
+ // arrive. A hit needs no check — a key in the map passed this on the way in.
359
+ assertLabelNames(instrument.descriptor.name, attributes);
370
360
  if (instrument.series.size >= instrument.maxSeries) {
371
361
  reportOverflow(instrument);
372
362
  // Created directly rather than through this function again: the overflow series is the ONE
@@ -485,6 +475,14 @@ export function exportMetrics(): void {
485
475
  * — the drain hook, not this timer, decides when the process may leave.
486
476
  */
487
477
  export function startMetricExport(intervalMs = 60_000): () => void {
478
+ // A timer given a non-finite delay does not export less often — `setInterval(fn, NaN)` runs at
479
+ // 1ms in this Bun, measured, so a `Number(process.env.METRICS_INTERVAL_MS)` on an unset variable
480
+ // pushes a thousand collections a second for the life of the process.
481
+ assert(
482
+ Number.isSafeInteger(intervalMs) && intervalMs >= 1,
483
+ `startMetricExport was given ${String(intervalMs)}ms; an export interval is a whole number of at least 1ms, and a timer given anything else runs at 1ms`,
484
+ "pass a whole number of milliseconds — startMetricExport(60_000) — and parse an environment value first: Number(process.env.METRICS_INTERVAL_MS ?? '') is NaN when the variable is unset",
485
+ );
488
486
  const timer = setInterval(exportMetrics, intervalMs);
489
487
  timer.unref();
490
488
  return () => clearInterval(timer);
@@ -11,6 +11,7 @@ import type {
11
11
  ReadableMetric,
12
12
  } from './metrics';
13
13
  import {
14
+ assertFiniteOtlpBound,
14
15
  OTLP_SCOPE,
15
16
  otlpAttributes,
16
17
  otlpEndpoint,
@@ -118,7 +119,7 @@ export interface OtlpMetricExporter extends MetricExporter {
118
119
  export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): OtlpMetricExporter {
119
120
  const url = otlpEndpoint('metrics', options.endpoint);
120
121
  const headers = otlpHeaders(options.headers);
121
- const timeoutMs = options.timeoutMs ?? 10_000;
122
+ const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
122
123
  const send = options.fetch ?? globalThis.fetch;
123
124
  let startedAtMs = options.startedAtMs;
124
125
  let inflight: Promise<void> = Promise.resolve();
@@ -4,6 +4,7 @@
4
4
  import { renderThrowable } from './error-render';
5
5
  import { logger } from './logger';
6
6
  import {
7
+ assertFiniteOtlpBound,
7
8
  OTLP_SCOPE,
8
9
  type OtlpKeyValue,
9
10
  otlpAttributes,
@@ -126,9 +127,13 @@ export interface OtlpSpanExporter extends SpanExporter {
126
127
  export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpanExporter {
127
128
  const url = otlpEndpoint('traces', options.endpoint);
128
129
  const headers = otlpHeaders(options.headers);
129
- const maxBatchSize = options.maxBatchSize ?? 512;
130
- const maxQueueSize = options.maxQueueSize ?? 2048;
131
- const timeoutMs = options.timeoutMs ?? 10_000;
130
+ const maxBatchSize = assertFiniteOtlpBound('maxBatchSize', options.maxBatchSize ?? 512);
131
+ const maxQueueSize = assertFiniteOtlpBound('maxQueueSize', options.maxQueueSize ?? 2048);
132
+ const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
133
+ const flushIntervalMs = assertFiniteOtlpBound(
134
+ 'flushIntervalMs',
135
+ options.flushIntervalMs ?? 5_000,
136
+ );
132
137
  const send = options.fetch ?? globalThis.fetch;
133
138
  const queue: ReadableSpan[] = [];
134
139
  let inflight: Promise<void> = Promise.resolve();
@@ -173,7 +178,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
173
178
 
174
179
  // Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
175
180
  // decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
176
- const timer = setInterval(() => void drainQueue(), options.flushIntervalMs ?? 5_000);
181
+ const timer = setInterval(() => void drainQueue(), flushIntervalMs);
177
182
  timer.unref();
178
183
 
179
184
  return {