@ultimat3/core 29.4.0 → 30.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.
Files changed (89) hide show
  1. package/CLAUDE.md +45 -81
  2. package/README.md +137 -10
  3. package/package.json +19 -3
  4. package/src/address-class.ts +53 -12
  5. package/src/aws-sigv4.ts +104 -8
  6. package/src/breaker.ts +177 -0
  7. package/src/client-dispatch.ts +76 -17
  8. package/src/client-flight.ts +4 -4
  9. package/src/client-paths-pins.ts +92 -0
  10. package/src/client-problem.ts +25 -1
  11. package/src/client-scope.ts +3 -1
  12. package/src/client-stream.ts +106 -0
  13. package/src/client-transport.ts +16 -2
  14. package/src/config-assets.ts +64 -0
  15. package/src/config-client-errors.ts +132 -0
  16. package/src/config-defaults.ts +18 -4
  17. package/src/config-failure-capture.ts +70 -0
  18. package/src/config-health.ts +8 -0
  19. package/src/config-islands.ts +1 -1
  20. package/src/config-keys.ts +2 -0
  21. package/src/config-site.ts +16 -0
  22. package/src/config-time.ts +40 -0
  23. package/src/config.ts +37 -26
  24. package/src/context.ts +92 -10
  25. package/src/core-error-codes.ts +19 -0
  26. package/src/cursor.ts +7 -3
  27. package/src/deadline-signal.ts +57 -0
  28. package/src/declared-errors-openapi.ts +70 -0
  29. package/src/declared-errors-scan.ts +79 -0
  30. package/src/declared-errors.ts +185 -0
  31. package/src/define-error.ts +200 -0
  32. package/src/deprecation.ts +14 -3
  33. package/src/drain-deadline.ts +22 -0
  34. package/src/env.ts +8 -3
  35. package/src/error-fingerprint.ts +95 -0
  36. package/src/error-reporter-chain.ts +45 -0
  37. package/src/error-reporter-sentry.ts +4 -0
  38. package/src/error-reporter.ts +14 -1
  39. package/src/error-status-declared.ts +82 -0
  40. package/src/exercise-errors.ts +41 -0
  41. package/src/exercise-run.ts +176 -0
  42. package/src/exports/error-contract.ts +7 -0
  43. package/src/exports/observability.ts +26 -0
  44. package/src/failure-capture-flush.ts +93 -0
  45. package/src/failure-capture-mask.ts +54 -0
  46. package/src/failure-capture.ts +205 -0
  47. package/src/fill-wire.ts +30 -0
  48. package/src/finite-option.ts +7 -0
  49. package/src/health-disclosure.ts +2 -2
  50. package/src/hmac.ts +47 -0
  51. package/src/index.ts +172 -9
  52. package/src/json-storable.ts +21 -0
  53. package/src/keyed-fingerprint.ts +3 -7
  54. package/src/lifecycle.ts +5 -1
  55. package/src/logger.ts +1 -1
  56. package/src/otlp-metric-exporter.ts +7 -1
  57. package/src/otlp-post.ts +147 -0
  58. package/src/otlp-span-exporter.ts +7 -1
  59. package/src/otlp.ts +4 -37
  60. package/src/outbound-attempt.ts +235 -0
  61. package/src/outbound-dial.ts +103 -0
  62. package/src/outbound-errors.ts +193 -0
  63. package/src/outbound-hops.ts +135 -0
  64. package/src/outbound-read.ts +103 -0
  65. package/src/outbound-subpath.ts +26 -0
  66. package/src/outbound-types.ts +108 -0
  67. package/src/outbound.ts +223 -0
  68. package/src/page-meta.ts +8 -0
  69. package/src/page.ts +9 -0
  70. package/src/record-envelope-openapi.ts +6 -0
  71. package/src/record-envelope.ts +33 -9
  72. package/src/record-sink.ts +13 -0
  73. package/src/redis-client.ts +78 -0
  74. package/src/registrar.ts +11 -0
  75. package/src/render-node.ts +13 -0
  76. package/src/render-nodes.ts +137 -0
  77. package/src/retry-loop.ts +197 -0
  78. package/src/retry.ts +47 -145
  79. package/src/roles.ts +2 -2
  80. package/src/route-path-merge.ts +55 -0
  81. package/src/route-path-template.ts +124 -0
  82. package/src/runtime-metrics.ts +16 -1
  83. package/src/same-origin.ts +18 -2
  84. package/src/session-lost.ts +115 -0
  85. package/src/single-flight.ts +75 -40
  86. package/src/source-mask.ts +36 -2
  87. package/src/tool-description.ts +29 -0
  88. package/src/visible-interval.ts +153 -0
  89. package/src/webhook-signature.ts +9 -11
@@ -0,0 +1,185 @@
1
+ // Single responsibility: an action's or a query's `errors:` list — what may be listed, which throw
2
+ // out of a handler the list judges, and what a test run and a production run do with a miss. Here
3
+ // and not in `@ultimat3/action` because `@ultimat3/query` needs the same rule and tiers never go
4
+ // sideways; the HTTP status is passed in because `@ultimat3/http` sits above this package.
5
+
6
+ import { FRAMEWORK_CODE } from './client-wire';
7
+ import { tryResolveEnvironment } from './environment';
8
+ import { renderCauseValue } from './error-render';
9
+ import { isUltimateError, UltimateError } from './errors';
10
+ import { logger } from './logger';
11
+ import { counter } from './metrics';
12
+
13
+ /** The status a code answers, `undefined` when nothing declared one: `@ultimat3/http`'s reader. */
14
+ export type ErrorStatusOf = (code: string) => number | undefined;
15
+
16
+ /** Which declaration a verdict is about — the two primitives that carry `errors:`. */
17
+ export interface DeclaredErrorsOwner {
18
+ readonly primitive: 'action' | 'query';
19
+ readonly name: string;
20
+ }
21
+
22
+ /**
23
+ * The process's lifetime, never a refusal a handler chose: a drain or an abort reaches every
24
+ * handler alike, so listing them on each would be a list nobody could keep true.
25
+ */
26
+ export const LIFETIME_ERROR_CODES: readonly string[] = ['X_ABORTED', 'X_DRAINING'];
27
+
28
+ /** `open` — no list declared; `exempt` — not a contract member; else the list's answer. */
29
+ export type DeclaredErrorVerdict = 'open' | 'exempt' | 'declared' | 'undeclared';
30
+
31
+ /** Is `status` a refusal the caller can branch on? A 5xx or no status is an outage. */
32
+ const isRefusal = (status: number | undefined): boolean =>
33
+ status !== undefined && status >= 400 && status <= 499;
34
+
35
+ /** The one judgement both halves make: the dynamic wrap and the contract's source scan. */
36
+ export function judgeThrown(
37
+ declared: readonly string[] | undefined,
38
+ code: string,
39
+ status: number | undefined,
40
+ ): DeclaredErrorVerdict {
41
+ if (declared === undefined) return 'open';
42
+ if (LIFETIME_ERROR_CODES.includes(code) || !isRefusal(status)) return 'exempt';
43
+ return declared.includes(code) ? 'declared' : 'undeclared';
44
+ }
45
+
46
+ export class ErrorUndeclaredError extends UltimateError {
47
+ static readonly code = 'X_ERROR_UNDECLARED';
48
+ override readonly name = 'ErrorUndeclaredError';
49
+ constructor(owner: DeclaredErrorsOwner, code: string, sourceError?: unknown) {
50
+ super({
51
+ code: ErrorUndeclaredError.code,
52
+ cause: `${owner.primitive} ${owner.name} threw ${code}, which its errors: list does not declare`,
53
+ fix: `errors: [ …, '${code}' ] # add it in the ${owner.name} definition, or stop throwing it there; then: x verify --only contract`,
54
+ meta: { primitive: owner.primitive, name: owner.name, undeclared: code },
55
+ sourceError,
56
+ });
57
+ }
58
+ }
59
+
60
+ export class ActionErrorsInvalidError extends UltimateError {
61
+ static readonly code = 'X_ACTION_ERRORS_INVALID';
62
+ override readonly name = 'ActionErrorsInvalidError';
63
+ constructor(owner: string, listed: unknown, why: string) {
64
+ const shown = renderCauseValue(listed);
65
+ const code = typeof listed === 'string' && FRAMEWORK_CODE.test(listed) ? listed : '<CODE>';
66
+ super({
67
+ code: ActionErrorsInvalidError.code,
68
+ cause: `errors: in ${owner} lists ${shown}, which ${why}`,
69
+ fix: `defineError({ code: '${code}', title, status: 4xx, cause, fix }) # import it where ${owner} is declared, list its .code once; x errors explain ${code} --json`,
70
+ meta: { owner, listed: shown },
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The list as written, refused where it is written: malformed, unregistered, not a 4xx, or
77
+ * repeated. Answers a frozen, sorted copy — what the descriptor, the manifest and the OpenAPI
78
+ * document publish — so a caller mutating the literal it passed changes nothing that was checked.
79
+ */
80
+ export function checkDeclaredErrors(
81
+ errors: readonly string[],
82
+ statusOf: ErrorStatusOf,
83
+ owner: string,
84
+ ): readonly string[];
85
+ export function checkDeclaredErrors(
86
+ errors: readonly string[] | undefined,
87
+ statusOf: ErrorStatusOf,
88
+ owner: string,
89
+ ): readonly string[] | undefined;
90
+ export function checkDeclaredErrors(
91
+ errors: readonly string[] | undefined,
92
+ statusOf: ErrorStatusOf,
93
+ owner: string,
94
+ ): readonly string[] | undefined {
95
+ if (errors === undefined) return undefined;
96
+ if (!Array.isArray(errors)) throw new ActionErrorsInvalidError(owner, errors, 'is not an array');
97
+ const seen = new Set<string>();
98
+ for (const code of errors as readonly unknown[]) {
99
+ if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
100
+ throw new ActionErrorsInvalidError(owner, code, 'is not an X_SCREAMING_SNAKE code');
101
+ }
102
+ if (seen.has(code)) throw new ActionErrorsInvalidError(owner, code, 'is listed twice');
103
+ const status = statusOf(code);
104
+ if (!isRefusal(status)) {
105
+ throw new ActionErrorsInvalidError(
106
+ owner,
107
+ code,
108
+ status === undefined ? 'has no registered HTTP status' : `answers ${status}, not a 4xx`,
109
+ );
110
+ }
111
+ seen.add(code);
112
+ }
113
+ return Object.freeze([...seen].sort());
114
+ }
115
+
116
+ const undeclaredErrors = counter('undeclared_errors_total', {
117
+ unit: '{error}',
118
+ description: 'Refusals a handler threw outside its declared errors: list, by primitive and name',
119
+ });
120
+
121
+ /**
122
+ * Where a test or a dev run goes red. Anything that is not literally `test` or `development` —
123
+ * an unset environment included — takes the production branch: a bookkeeping miss must never turn
124
+ * a refusal into a 500 in front of a user.
125
+ */
126
+ const raisesUndeclared = (): boolean => {
127
+ const environment = tryResolveEnvironment({ fallback: 'production' });
128
+ return environment === 'test' || environment === 'development';
129
+ };
130
+
131
+ /** The (primitive, name, code) triples already warned about in this process. */
132
+ const WARNED = new Set<string>();
133
+
134
+ /**
135
+ * What to throw in place of `error`, a throw out of a handler. The original, unless it is a 4xx
136
+ * code the list does not name: then `X_ERROR_UNDECLARED` in test and development (the
137
+ * original as its `sourceError`), and the original with a warning and a count in production.
138
+ */
139
+ export function enforceDeclared(
140
+ error: unknown,
141
+ declared: readonly string[] | undefined,
142
+ statusOf: ErrorStatusOf,
143
+ owner: DeclaredErrorsOwner,
144
+ ): unknown {
145
+ if (declared === undefined || !isUltimateError(error)) return error;
146
+ const { code } = error;
147
+ if (judgeThrown(declared, code, statusOf(code)) !== 'undeclared') return error;
148
+ if (raisesUndeclared()) return new ErrorUndeclaredError(owner, code, error);
149
+ undeclaredErrors.add(1, { primitive: owner.primitive, name: owner.name, code });
150
+ // One line per (primitive, name, code) per process: a hot endpoint is counted on every hit and
151
+ // logged once, never a line per request. The key space is bounded by the codebase.
152
+ const key = `${owner.primitive}\u0000${owner.name}\u0000${code}`;
153
+ if (!WARNED.has(key)) {
154
+ WARNED.add(key);
155
+ logger.warn('declared-errors.undeclared', { ...owner, code });
156
+ }
157
+ return error;
158
+ }
159
+
160
+ /** Run a handler under its list: the wrap `invoke` and the read path put around `handle`/`sql`. */
161
+ export async function withDeclaredErrors<T>(
162
+ declared: readonly string[] | undefined,
163
+ statusOf: ErrorStatusOf,
164
+ owner: DeclaredErrorsOwner,
165
+ run: () => T | Promise<T>,
166
+ ): Promise<T> {
167
+ try {
168
+ return await run();
169
+ } catch (error) {
170
+ throw enforceDeclared(error, declared, statusOf, owner);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * A tool's description, ending with the refusals its declaration lists — so an agent plans around
176
+ * a refusal before it calls. One sentence for `@ultimat3/mcp`'s tool and `@ultimat3/ai`'s, which
177
+ * the tier table keeps apart (`scripts/ai-mcp-tool-parity.test.ts`). Unchanged without a list.
178
+ */
179
+ export function describeDeclaredErrors(
180
+ description: string,
181
+ errors: readonly string[] | undefined,
182
+ ): string {
183
+ if (errors === undefined || errors.length === 0) return description;
184
+ return `${description} Declared refusals: ${errors.join(', ')}.`;
185
+ }
@@ -0,0 +1,200 @@
1
+ // Single responsibility: `defineError` — an APP code's one declaration: its title, its HTTP status
2
+ // (or `offSocket`), its retry, and the `UltimateError` subclass that throws it. Its own module so a
3
+ // chunk importing only `UltimateError` never retains it (`define-error-bytes.test.ts`). A framework
4
+ // code goes through `bun run new-error-code` instead; the split is in this package's CLAUDE.md.
5
+
6
+ import { describeErrorCode, hasErrorCode, registerErrorCodes } from './error-codes';
7
+ import { renderCauseValue } from './error-render';
8
+ import { type ErrorRetry, registerErrorRetry } from './error-retry';
9
+ import {
10
+ appErrorStatus,
11
+ declareAppErrorStatus,
12
+ definedErrorStatus,
13
+ isHttpStatus,
14
+ recordDefinedError,
15
+ } from './error-status-declared';
16
+ import { isUltimateError, UltimateError } from './errors';
17
+
18
+ /** A line written once, or computed from the throw's params. */
19
+ export type ErrorText<P> = string | ((params: P) => string);
20
+
21
+ interface ErrorSpecFields<P> {
22
+ /** `X_SCREAMING_SNAKE`, written as a literal here: the `errors` step and the manifest read it. */
23
+ readonly code: string;
24
+ readonly title: string;
25
+ readonly cause: ErrorText<P>;
26
+ /** A command to run, a call to paste, or an edit naming a file — `x verify --only errors` checks. */
27
+ readonly fix: ErrorText<P>;
28
+ readonly callerFix?: ErrorText<P> | undefined;
29
+ readonly docs?: string | undefined;
30
+ readonly retry?: ErrorRetry | undefined;
31
+ readonly meta?: ((params: P) => Readonly<Record<string, unknown>>) | undefined;
32
+ }
33
+
34
+ /**
35
+ * Exactly one of `status` (what a response answers) or `offSocket: true` (a boot, CLI or job fault
36
+ * no response ever carries). Neither was the 500-by-omission a reference app shipped for "post not
37
+ * found"; the type makes the choice, and `X_ERROR_DECLARATION_INVALID` holds it at runtime.
38
+ */
39
+ export type DefineErrorSpec<P = undefined> = ErrorSpecFields<P> &
40
+ (
41
+ | { readonly status: number; readonly offSocket?: undefined }
42
+ | { readonly offSocket: true; readonly status?: undefined }
43
+ );
44
+
45
+ /** What a throw site may add beyond the params: the value it wraps, and a one-site retry. */
46
+ export interface DefinedErrorExtra {
47
+ readonly sourceError?: unknown;
48
+ readonly retry?: ErrorRetry | undefined;
49
+ }
50
+
51
+ type DefinedErrorArgs<P> = [P] extends [undefined]
52
+ ? [params?: undefined, extra?: DefinedErrorExtra]
53
+ : [params: P, extra?: DefinedErrorExtra];
54
+
55
+ export interface DefinedError<P = undefined> {
56
+ new (...args: DefinedErrorArgs<P>): UltimateError;
57
+ readonly prototype: UltimateError;
58
+ readonly code: string;
59
+ /** `undefined` when declared `offSocket`. */
60
+ readonly status: number | undefined;
61
+ /**
62
+ * The brand plus the code — never `instanceof`, which a browser's `RemoteActionError` and a
63
+ * duplicated module instance both fail.
64
+ */
65
+ is(value: unknown): value is UltimateError;
66
+ }
67
+
68
+ const SCREAMING_SNAKE = /^X(?:_[A-Z0-9]+)+$/;
69
+
70
+ const invalid = (code: unknown, cause: string): UltimateError =>
71
+ new UltimateError({
72
+ code: 'X_ERROR_DECLARATION_INVALID',
73
+ cause: `defineError({ code: ${renderCauseValue(code)} }) ${cause}`,
74
+ fix: 'x verify --only errors # after editing the defineError call the cause names',
75
+ meta: { code: renderCauseValue(code) },
76
+ });
77
+
78
+ const duplicate = (code: string, cause: string): UltimateError =>
79
+ new UltimateError({
80
+ code: 'X_ERROR_CODE_DUPLICATE',
81
+ cause: `${code} is already declared ${cause}`,
82
+ fix: `grep -rn "${code}" apps packages # keep one declaration of the code, then x verify --only errors`,
83
+ meta: { code },
84
+ });
85
+
86
+ /**
87
+ * Where each code was declared: the caller's stack frame, as text, never parsed here. The class a
88
+ * declaration returns is named `Defined` whatever the app binds it to, so `errors:`'s source scan
89
+ * (`declared-errors-scan.ts`) reads the binding off this line — test-time, on demand.
90
+ */
91
+ const DECLARED_AT = new Map<string, string>();
92
+
93
+ /** Code → the stack frame that declared it. Read by `definedErrorClassCodes` alone. */
94
+ export const definedErrorSites = (): ReadonlyMap<string, string> => DECLARED_AT;
95
+
96
+ function recordSite(code: string): void {
97
+ if (DECLARED_AT.has(code)) return;
98
+ const holder: { stack?: string } = {};
99
+ Error.captureStackTrace(holder, defineError);
100
+ const frame = holder.stack?.split('\n')[1];
101
+ if (frame !== undefined) DECLARED_AT.set(code, frame);
102
+ }
103
+
104
+ const text = <P>(value: ErrorText<P>, params: P): string =>
105
+ typeof value === 'function' ? value(params) : value;
106
+
107
+ /** Every refusal, before any table is written: a refused declaration leaves no half behind. */
108
+ function refusalOf<P>(spec: DefineErrorSpec<P>): UltimateError | undefined {
109
+ const { code } = spec;
110
+ if (typeof code !== 'string' || !SCREAMING_SNAKE.test(code)) {
111
+ return invalid(code, 'is not X_SCREAMING_SNAKE (X_ then upper-case words and digits)');
112
+ }
113
+ const hasStatus = spec.status !== undefined;
114
+ const offSocket = spec.offSocket === true;
115
+ if (hasStatus === offSocket) {
116
+ return invalid(
117
+ code,
118
+ hasStatus ? 'gives both status and offSocket' : 'gives neither status nor offSocket',
119
+ );
120
+ }
121
+ if (hasStatus && !isHttpStatus(spec.status)) {
122
+ return invalid(
123
+ code,
124
+ `gives status ${renderCauseValue(spec.status)}, which is not an HTTP status (100-599)`,
125
+ );
126
+ }
127
+ const defined = definedErrorStatus(code);
128
+ if (hasErrorCode(code)) {
129
+ // Registered and not by this function: a framework package's code (or an app table's). Its
130
+ // status is not this declaration's to give — X_FORBIDDEN's class would claim 200 while http
131
+ // answers 403 — so a matching title is no licence.
132
+ if (defined === undefined) return duplicate(code, 'by the framework');
133
+ const title = describeErrorCode(code).title;
134
+ if (title !== spec.title) {
135
+ return duplicate(code, `with the title ${renderCauseValue(title)}`);
136
+ }
137
+ }
138
+ // Off-socket is a declaration too, so a later `status: 404` for it is a disagreement — which only
139
+ // the record can see: the status table holds no row for an off-socket code.
140
+ const status = defined === undefined ? appErrorStatus(code) : defined.status;
141
+ if ((defined !== undefined || status !== undefined) && status !== spec.status) {
142
+ return duplicate(code, status === undefined ? 'off-socket' : `with the status ${status}`);
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /**
148
+ * Declare an app error code and get its class back. Importing the module that calls it IS the
149
+ * registration — `registerErrorCodes`' convention. The same declaration twice is a no-op (a module
150
+ * re-evaluated under `x dev`); a different title or status is `X_ERROR_CODE_DUPLICATE`.
151
+ *
152
+ * ```ts
153
+ * export const PostNotFound = defineError<{ readonly ref: string }>({
154
+ * code: 'X_POST_NOT_FOUND',
155
+ * title: 'no such post in this organisation',
156
+ * status: 404,
157
+ * cause: (p) => `no post ${p.ref} in the actor's organisation`,
158
+ * fix: 'x queries show postById --json, then pass an id it returns',
159
+ * });
160
+ * throw new PostNotFound({ ref });
161
+ * ```
162
+ */
163
+ export function defineError<P = undefined>(spec: DefineErrorSpec<P>): DefinedError<P> {
164
+ const refused = refusalOf(spec);
165
+ if (refused !== undefined) throw refused;
166
+ const { code } = spec;
167
+ // `registerErrorRetry` refuses an unknown kind or a conflicting one BEFORE it writes, and the
168
+ // two writes after it cannot fail: `refusalOf` already answered for both.
169
+ if (spec.retry !== undefined) registerErrorRetry({ [code]: spec.retry });
170
+ if (!hasErrorCode(code)) {
171
+ registerErrorCodes({ [code]: { title: spec.title, docs: spec.docs } });
172
+ }
173
+ if (spec.status !== undefined) declareAppErrorStatus(code, spec.status);
174
+ recordDefinedError(code, spec.status);
175
+ recordSite(code);
176
+
177
+ class Defined extends UltimateError {
178
+ static readonly code = code;
179
+ static readonly status = spec.status;
180
+
181
+ static is(value: unknown): value is UltimateError {
182
+ return isUltimateError(value) && value.code === code;
183
+ }
184
+
185
+ constructor(...[params, extra]: DefinedErrorArgs<P>) {
186
+ const input = params as P;
187
+ super({
188
+ code,
189
+ cause: text(spec.cause, input),
190
+ fix: text(spec.fix, input),
191
+ callerFix: spec.callerFix === undefined ? undefined : text(spec.callerFix, input),
192
+ docs: spec.docs,
193
+ meta: spec.meta?.(input),
194
+ retry: extra?.retry,
195
+ sourceError: extra?.sourceError,
196
+ });
197
+ }
198
+ }
199
+ return Defined;
200
+ }
@@ -44,6 +44,19 @@ export function recordDeprecatedCall(primitive: 'action' | 'query', name: string
44
44
  deprecatedCalls.add(1, { primitive, name });
45
45
  }
46
46
 
47
+ /** The RFC 8288 link naming a retired declaration's replacement. One spelling, written and read. */
48
+ export function successorLink(path: string): string {
49
+ return `<${path}>; rel="successor-version"`;
50
+ }
51
+
52
+ /**
53
+ * The path `successorLink` wrote, or `undefined` for a link that is not exactly one: another
54
+ * relation is an app's own header, and whoever re-serves a route has no claim on it.
55
+ */
56
+ export function successorPathOf(link: string): string | undefined {
57
+ return /^<([^<>]*)>; rel="successor-version"$/.exec(link)?.[1];
58
+ }
59
+
47
60
  /**
48
61
  * `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
49
62
  * HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
@@ -64,9 +77,7 @@ export function renderDeprecation(
64
77
  };
65
78
  // The successor's URL, derived by the caller from its own route naming — the one the typed
66
79
  // client uses. A link built here from the export name would be a second URL derivation.
67
- if (successorPath !== undefined) {
68
- headers['link'] = `<${successorPath}>; rel="successor-version"`;
69
- }
80
+ if (successorPath !== undefined) headers['link'] = successorLink(successorPath);
70
81
 
71
82
  const meta: Record<string, string> = {
72
83
  since: new Date(since).toISOString(),
@@ -26,8 +26,16 @@ export const DRAIN_DEADLINE_MAX_MS = 3_600_000;
26
26
  */
27
27
  export const WORKER_DRAIN_DEADLINE_MAX_MS = 86_400_000;
28
28
 
29
+ /**
30
+ * An hour: how long a retiring worker (`SIGUSR2`, the chart's `preStop`) may finish what it holds,
31
+ * aborting nothing, before the SIGTERM. Not a second drain budget: this one cancels nothing. On by
32
+ * default because an idle worker exits at once, so it costs only while a job really holds a pod.
33
+ */
34
+ export const WORKER_RETIRE_DEFAULT_MS = 3_600_000;
35
+
29
36
  const DEADLINE_KEY = 'drain.deadlineMs';
30
37
  const WORKER_DEADLINE_KEY = 'drain.workerDeadlineMs';
38
+ const WORKER_RETIRE_KEY = 'drain.workerRetireMs';
31
39
 
32
40
  /**
33
41
  * Why a value is not a drain budget, or `undefined` when it is one. A whole number of milliseconds
@@ -55,15 +63,29 @@ export function workerDrainDeadlineIssue(value: unknown): string | undefined {
55
63
  : undefined;
56
64
  }
57
65
 
66
+ /**
67
+ * Why a value is not the worker's retire, or `undefined` when it is one. A whole number in
68
+ * `0 ≤ v ≤ 86400000`; unlike a drain budget 0 is a real answer here: no retire, the plain SIGTERM.
69
+ */
70
+ export function workerRetireIssue(value: unknown): string | undefined {
71
+ const count = countIssue(WORKER_RETIRE_KEY, value, 0);
72
+ if (count !== undefined) return count;
73
+ return (value as number) > WORKER_DRAIN_DEADLINE_MAX_MS
74
+ ? `${WORKER_RETIRE_KEY} must be at most ${WORKER_DRAIN_DEADLINE_MAX_MS} milliseconds (a day) — a job longer than that belongs in steps, which replay instead of re-running`
75
+ : undefined;
76
+ }
77
+
58
78
  /** The `drain` section's issues, one per key, for `defineConfig`'s validator. */
59
79
  export function drainIssues(drain: {
60
80
  readonly readinessGraceMs: unknown;
61
81
  readonly deadlineMs: unknown;
62
82
  readonly workerDeadlineMs?: unknown;
83
+ readonly workerRetireMs?: unknown;
63
84
  }): readonly (string | undefined)[] {
64
85
  return [
65
86
  readinessGraceIssue(drain.readinessGraceMs),
66
87
  drainDeadlineIssue(drain.deadlineMs),
67
88
  workerDrainDeadlineIssue(drain.workerDeadlineMs),
89
+ workerRetireIssue(drain.workerRetireMs),
68
90
  ];
69
91
  }
package/src/env.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // is reported in ONE error — an agent should never have to restart the process six times to
3
3
  // discover six missing variables.
4
4
 
5
+ import { numeric } from '@ultimat3/schema';
5
6
  import { describeValue } from './error-render';
6
7
  import { EnvMissingError } from './errors';
7
8
  import { REDACTED, redactKeys } from './logger';
@@ -135,8 +136,9 @@ function parseValue(decl: EnvVarDecl, raw: string): { ok: true; value: unknown }
135
136
  case 'number':
136
137
  case 'integer':
137
138
  case 'port': {
138
- const value = Number(raw);
139
- if (!Number.isFinite(value)) return { ok: false };
139
+ // Schema's one decimal reader: `Number()` reads `' '` as 0 and `0x1F90` as 8080.
140
+ const value = numeric(raw);
141
+ if (value === undefined) return { ok: false };
140
142
  if (decl.type !== 'number' && !Number.isInteger(value)) return { ok: false };
141
143
  if (decl.type === 'port' && (value < 1 || value > 65535)) return { ok: false };
142
144
  if (decl.min !== undefined && value < decl.min) return { ok: false };
@@ -150,7 +152,10 @@ function parseValue(decl: EnvVarDecl, raw: string): { ok: true; value: unknown }
150
152
  case 'url':
151
153
  return URL.canParse(raw) ? { ok: true, value: raw } : { ok: false };
152
154
  default:
153
- if (decl.pattern !== undefined && !decl.pattern.test(raw)) return { ok: false };
155
+ if (decl.pattern === undefined) return { ok: true, value: raw };
156
+ // A `g`/`y` pattern resumes at its `lastIndex`: a second check would refuse a valid value.
157
+ decl.pattern.lastIndex = 0;
158
+ if (!decl.pattern.test(raw)) return { ok: false };
154
159
  return { ok: true, value: raw };
155
160
  }
156
161
  }
@@ -0,0 +1,95 @@
1
+ // Single responsibility: which error reports are the same fault. A pure function over a report,
2
+ // never a field on it: `ErrorReport` is public, and a member every hand-built report must now
3
+ // carry, or every reader must now tolerate missing, is a break for a key a monitor can derive.
4
+
5
+ import { fingerprint } from './canonical-json';
6
+ import { stringField } from './error-render';
7
+ import type { ErrorReport } from './error-reporter';
8
+
9
+ /** `errorReport()`'s code for a throw nobody classified: the one code too wide to group on. */
10
+ const UNCLASSIFIED = 'X_INTERNAL';
11
+
12
+ /** ` at fn (path:1:2)`, ` at async fn (path:1:2)`, ` at path:1:2`. */
13
+ const FRAME = /^\s*at\s+(?:async\s+)?(?:(.*?)\s+\((.*)\)|(.*))$/;
14
+
15
+ /** A line and a column, or a line alone: an edit above the throw moves both. */
16
+ const POSITION = /(?::\d+){1,2}$/;
17
+
18
+ /** Library frames: the first frame that is the APP's names the fault, not the library under it. */
19
+ const LIBRARY = /(?:^|\/)(?:node_modules|@ultimat3)\//;
20
+
21
+ /** Reads that cannot throw: this runs on the path that reports a failure, and adds none. */
22
+ function read<T>(get: () => T): T | undefined {
23
+ try {
24
+ return get();
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ function workingDirectory(): string | undefined {
31
+ const cwd = read(() => process.cwd());
32
+ return cwd === undefined ? undefined : `${cwd.replaceAll('\\', '/').replace(/\/$/, '')}/`;
33
+ }
34
+
35
+ /** `file://`, the working directory and the position dropped; `/` separators on every OS. */
36
+ function relativePath(location: string, root: string | undefined): string {
37
+ let path = location.trim().replace(POSITION, '').replaceAll('\\', '/');
38
+ if (path.startsWith('file://')) path = path.slice('file://'.length);
39
+ if (root !== undefined && path.startsWith(root)) path = path.slice(root.length);
40
+ return path.replace(/^\.\//, '');
41
+ }
42
+
43
+ /**
44
+ * The stack's FRAMES: the trailing run of `at …` lines. Read from the end, because the header
45
+ * above them is the error's message, and a message may itself hold `\n at x (y:1:1)` — which
46
+ * read top-down would set the group to whatever the message's author typed.
47
+ */
48
+ function framesOf(stack: string): readonly RegExpExecArray[] {
49
+ const frames: RegExpExecArray[] = [];
50
+ const lines = stack.trimEnd().split('\n');
51
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
52
+ const match = FRAME.exec(lines[index] ?? '');
53
+ if (match === null) break;
54
+ frames.unshift(match);
55
+ }
56
+ return frames;
57
+ }
58
+
59
+ /**
60
+ * The top application frame as `fn path`, or `undefined`. A frame with no file location (`native`,
61
+ * `<anonymous>`, `processTicksAndRejections (native:7:39)`) is skipped as well: it is the
62
+ * runtime's, and two faults share it. A file location has a `/`; a runtime's never does.
63
+ */
64
+ function applicationFrame(stack: string | undefined): string | undefined {
65
+ if (stack === undefined) return undefined;
66
+ const root = workingDirectory();
67
+ for (const match of framesOf(stack)) {
68
+ const location = match[2] ?? match[3] ?? '';
69
+ if (!POSITION.test(location.trim())) continue;
70
+ if (!location.replaceAll('\\', '/').includes('/')) continue;
71
+ const path = relativePath(location, root);
72
+ if (path === '' || LIBRARY.test(path)) continue;
73
+ const name = (match[1] ?? '').replace(/^new\s+/, '').trim();
74
+ return name === '' ? path : `${name} ${path}`;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ /**
80
+ * A stable, 16-hex grouping key for a report. A coded fault groups on `code`, `source` and
81
+ * `scope.operation`; `X_INTERNAL` adds the top application frame (function and root-relative path,
82
+ * no line or column), because on its code alone every unclassified throw in an app is one group.
83
+ * Never `cause`, `meta`, an id or `release`: a row id splits one fault into a group per row, and a
84
+ * deploy is not a new fault. Total: a report it cannot read degrades to the frameless key.
85
+ */
86
+ export function errorFingerprint(report: ErrorReport): string {
87
+ const code = stringField(report, 'code');
88
+ const source = stringField(report, 'source');
89
+ const operation = stringField(
90
+ read(() => report.scope),
91
+ 'operation',
92
+ );
93
+ const frame = code === UNCLASSIFIED ? applicationFrame(stringField(report, 'stack')) : undefined;
94
+ return fingerprint({ code, source, operation, frame });
95
+ }
@@ -0,0 +1,45 @@
1
+ // Single responsibility: several `ErrorReporter`s behind the ONE seam — a vendor transport beside
2
+ // an app's own sink (`@ultimat3/admin`'s `errorGroupsSink`). Not a second seam: the chain IS a
3
+ // reporter, installed through `configureErrorReporting` like any other.
4
+
5
+ import { renderThrowable } from './error-render';
6
+ import { type ErrorReport, type ErrorReporter, noopErrorReporter } from './error-reporter';
7
+ import { logger } from './logger';
8
+
9
+ /** Which reporters a chain was built from — read back by `errorReportersOf`, never mutated. */
10
+ const members = new WeakMap<ErrorReporter, readonly ErrorReporter[]>();
11
+
12
+ /** The flat member list a reporter stands for: a chain's members, the no-op's none, else itself. */
13
+ export function errorReportersOf(reporter: ErrorReporter): readonly ErrorReporter[] {
14
+ if (reporter === noopErrorReporter) return [];
15
+ return members.get(reporter) ?? [reporter];
16
+ }
17
+
18
+ /**
19
+ * One reporter that hands every report to each member, in order. A member that throws is logged
20
+ * and costs ONLY itself — `reportError`'s own catch guards the whole reporter, so without this one
21
+ * a vendor that is down would silence every member after it. Chains flatten and a member listed
22
+ * twice reports once, so a boot that composes with what is installed cannot double a sink.
23
+ */
24
+ export function chainErrorReporters(...reporters: readonly ErrorReporter[]): ErrorReporter {
25
+ const flat = [...new Set(reporters.flatMap(errorReportersOf))];
26
+ if (flat.length === 0) return noopErrorReporter;
27
+ if (flat.length === 1) return flat[0] ?? noopErrorReporter;
28
+ const chain: ErrorReporter = {
29
+ report(event: ErrorReport): void {
30
+ flat.forEach((member, index) => {
31
+ try {
32
+ member.report(event);
33
+ } catch (failure) {
34
+ logger.warn('error reporter failed', {
35
+ source: event.source,
36
+ member: index,
37
+ error: renderThrowable(failure),
38
+ });
39
+ }
40
+ });
41
+ },
42
+ };
43
+ members.set(chain, Object.freeze(flat));
44
+ return chain;
45
+ }
@@ -3,6 +3,7 @@
3
3
  // is documented and several self-hostable monitors ingest it. Nothing here names a host, a project
4
4
  // or an organisation — the DSN is the app's own typed env, passed in at wiring time.
5
5
 
6
+ import { errorFingerprint } from './error-fingerprint';
6
7
  import { renderThrowable } from './error-render';
7
8
  import type { ErrorReport, ErrorReporter, ErrorSeverity } from './error-reporter';
8
9
  import { type CodedErrorInit, UltimateError } from './errors';
@@ -89,6 +90,9 @@ function payloadOf(report: ErrorReport, eventId: string): Record<string, unknown
89
90
  server_name: report.resource.serviceName,
90
91
  ...(report.release === null ? {} : { release: report.release }),
91
92
  ...(report.scope.operation === undefined ? {} : { transaction: report.scope.operation }),
93
+ // The group. Without it the monitor groups on its own reading of the exception, which is the
94
+ // code, so every unclassified throw in the app was one issue (`errorFingerprint`).
95
+ fingerprint: [errorFingerprint(report)],
92
96
  // Tags are the monitor's facets, so only bounded values go here — the same rule metric labels
93
97
  // follow. `requestId` and `actorId` are unbounded and live in `extra`.
94
98
  tags: {