@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,205 @@
1
+ // Single responsibility: the failure-capture buffer. While a `FailureStore` is installed, every
2
+ // `invoke` and `runQuery` opens one buffer; the seams below it (`@ultimat3/entity`'s reads,
3
+ // `@ultimat3/flags`' evaluations) record into it; a 5xx HOLDS the buffer on the thrown value, and
4
+ // `reportError` releases it with the report's own fingerprint. With no store, nothing allocates.
5
+
6
+ import { asyncContext } from './async-context';
7
+ import { tryUseContext } from './context';
8
+ import { renderThrowable } from './error-render';
9
+ import { isUltimateError } from './errors';
10
+ import type { CapturedValue } from './failure-capture-mask';
11
+ import { captureValue } from './failure-capture-mask';
12
+ import { logger } from './logger';
13
+
14
+ export type FailurePrimitive = 'action' | 'query';
15
+
16
+ /** One row a repository answered while the failing call ran: sealed columns never, secrets masked. */
17
+ export interface FailureRead {
18
+ readonly entity: string;
19
+ readonly row: Readonly<Record<string, unknown>>;
20
+ readonly masked: readonly string[];
21
+ }
22
+
23
+ /** The actor, without `facts`: an app's facts are never logged and are not stored here either. */
24
+ export interface FailureActor {
25
+ readonly kind: string;
26
+ readonly id: string;
27
+ readonly orgId: string | null;
28
+ readonly roles: readonly string[];
29
+ readonly scopes: readonly string[];
30
+ readonly permissions: readonly string[];
31
+ }
32
+
33
+ /** What a 5xx left behind before the report named its group. */
34
+ export interface HeldFailure {
35
+ readonly primitive: FailurePrimitive;
36
+ readonly name: string;
37
+ /** Epoch ms of the failure, from the request's own clock: the instant a replay freezes. */
38
+ readonly at: number;
39
+ readonly requestId: string | null;
40
+ readonly traceId: string | null;
41
+ readonly build: string | null;
42
+ readonly input: unknown;
43
+ readonly masked: readonly string[];
44
+ readonly actor: FailureActor | null;
45
+ readonly flags: Readonly<Record<string, unknown>>;
46
+ readonly reads: readonly FailureRead[];
47
+ readonly truncated: boolean;
48
+ }
49
+
50
+ /** The stored bundle: what was held, plus the group the report put it in. */
51
+ export interface FailureBundle extends HeldFailure {
52
+ readonly id: string;
53
+ readonly fingerprint: string;
54
+ readonly code: string;
55
+ /** The report's own `scope.operation` — `POST /api/x` from HTTP — or `null` when it named none. */
56
+ readonly operation: string | null;
57
+ }
58
+
59
+ /** Where bundles go. `@ultimat3/db`'s `postgresFailureStore()` is the one the boot installs. */
60
+ export interface FailureStore {
61
+ save(bundle: FailureBundle): Promise<void>;
62
+ }
63
+
64
+ export interface FailureCaptureLimits {
65
+ readonly perHour: number;
66
+ readonly maxRows: number;
67
+ readonly maxBytes: number;
68
+ }
69
+
70
+ interface Buffer {
71
+ readonly primitive: FailurePrimitive;
72
+ readonly name: string;
73
+ input: CapturedValue | undefined;
74
+ readonly flags: Map<string, unknown>;
75
+ readonly reads: FailureRead[];
76
+ truncated: boolean;
77
+ }
78
+
79
+ export type FailureFact =
80
+ | { readonly kind: 'read'; readonly entity: string; readonly row: unknown }
81
+ | { readonly kind: 'flag'; readonly key: string; readonly value: unknown };
82
+
83
+ const buffers = asyncContext<Buffer>('failure capture');
84
+ let store: FailureStore | null = null;
85
+ let limits: FailureCaptureLimits = { perHour: 5, maxRows: 50, maxBytes: 64 * 1024 };
86
+ /** Held on the thrown value itself, so no request id has to survive the trip to the reporter. */
87
+ const held = new WeakMap<object, HeldFailure>();
88
+
89
+ /** Install the store (capture on) or `null` (off). Returns the previous one, for a harness. */
90
+ export function setFailureStore(
91
+ next: FailureStore | null,
92
+ options: Partial<FailureCaptureLimits> = {},
93
+ ): FailureStore | null {
94
+ const previous = store;
95
+ store = next;
96
+ limits = { ...limits, ...options };
97
+ return previous;
98
+ }
99
+
100
+ export const failureCaptureLimits = (): FailureCaptureLimits => limits;
101
+
102
+ /** Whether a call in flight is being captured: a seam asks this before it builds anything. */
103
+ export const failureCaptureActive = (): boolean => buffers.get() !== undefined;
104
+
105
+ /** One fact into the buffer in flight. Off, this is one `undefined` check and nothing else. */
106
+ export function recordFailureFact(fact: FailureFact): void {
107
+ const buffer = buffers.get();
108
+ if (buffer === undefined) return;
109
+ if (fact.kind === 'flag') {
110
+ if (!buffer.flags.has(fact.key)) buffer.flags.set(fact.key, captureValue(fact.value).value);
111
+ return;
112
+ }
113
+ if (buffer.reads.length >= limits.maxRows) {
114
+ buffer.truncated = true;
115
+ return;
116
+ }
117
+ const { value, masked } = captureValue(fact.row);
118
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return;
119
+ buffer.reads.push({ entity: fact.entity, row: value as Record<string, unknown>, masked });
120
+ }
121
+
122
+ /** The parsed input, already masked by its schema. The first one wins: it is the outermost call's. */
123
+ export function recordFailureInput(input: unknown): void {
124
+ const buffer = buffers.get();
125
+ if (buffer !== undefined && buffer.input === undefined) buffer.input = captureValue(input);
126
+ }
127
+
128
+ const actorNow = (): FailureActor | null => {
129
+ const actor = tryUseContext()?.actor;
130
+ if (actor === undefined) return null;
131
+ return {
132
+ kind: actor.kind,
133
+ id: actor.id,
134
+ orgId: actor.orgId ?? null,
135
+ roles: [...actor.roles],
136
+ scopes: [...actor.scopes],
137
+ permissions: [...actor.permissions],
138
+ };
139
+ };
140
+
141
+ function holdOf(buffer: Buffer): HeldFailure {
142
+ const ctx = tryUseContext();
143
+ return {
144
+ primitive: buffer.primitive,
145
+ name: buffer.name,
146
+ at: ctx === undefined ? 0 : ctx.now().getTime(),
147
+ requestId: ctx?.requestId ?? null,
148
+ traceId: ctx?.traceId ?? null,
149
+ build: ctx?.buildId ?? null,
150
+ input: buffer.input?.value ?? null,
151
+ masked: buffer.input?.masked ?? [],
152
+ actor: actorNow(),
153
+ flags: Object.fromEntries(buffer.flags),
154
+ reads: [...buffer.reads],
155
+ truncated: buffer.truncated,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Run one `invoke` / `runQuery` under a buffer. No store, or a buffer already open (a call inside
161
+ * a call: the OUTER one is the request), and `run` runs bare. A throw whose status is 5xx is held
162
+ * on the thrown value; a 4xx is the caller's, and has a problem document instead.
163
+ */
164
+ export async function withFailureCapture<T>(
165
+ primitive: FailurePrimitive,
166
+ name: string,
167
+ statusOf: (code: string) => number,
168
+ run: () => Promise<T>,
169
+ ): Promise<T> {
170
+ if (store === null || buffers.get() !== undefined) return run();
171
+ const buffer: Buffer = {
172
+ primitive,
173
+ name,
174
+ input: undefined,
175
+ flags: new Map(),
176
+ reads: [],
177
+ truncated: false,
178
+ };
179
+ return buffers.run(buffer, async () => {
180
+ try {
181
+ return await run();
182
+ } catch (error) {
183
+ const code = isUltimateError(error) ? error.code : 'X_INTERNAL';
184
+ if (typeof error === 'object' && error !== null && statusOf(code) >= 500) {
185
+ held.set(error, holdOf(buffer));
186
+ }
187
+ throw error;
188
+ }
189
+ });
190
+ }
191
+
192
+ /** Read and forget what a thrown value holds — `reportError`'s half, in `failure-capture-flush.ts`. */
193
+ export function takeHeldFailure(error: unknown): HeldFailure | undefined {
194
+ if (typeof error !== 'object' || error === null) return undefined;
195
+ const found = held.get(error);
196
+ if (found !== undefined) held.delete(error);
197
+ return found;
198
+ }
199
+
200
+ export const installedFailureStore = (): FailureStore | null => store;
201
+
202
+ /** A store that refused is logged and never reaches the response that is already on its way. */
203
+ export function failureStoreRefused(failure: unknown): void {
204
+ logger.warn('failure capture store refused a bundle', { error: renderThrowable(failure) });
205
+ }
@@ -0,0 +1,30 @@
1
+ // Single responsibility: a pinned HTTP primitive's request shape — the URL with every `:param`
2
+ // filled (`fillTemplate`), and the input WITHOUT those keys, which the URL carries. The one split
3
+ // `@ultimat3/action`'s client, `@ultimat3/query`'s client and `@ultimat3/realtime`'s writes share;
4
+ // each refuses an unfillable param with its own code. Imports only the template module.
5
+
6
+ import { fillTemplate, templateParams } from './route-path-template';
7
+
8
+ export type FilledWire =
9
+ | {
10
+ readonly ok: true;
11
+ /** The path, filled — no base, no search string: the caller adds its own. */
12
+ readonly url: string;
13
+ /** The input minus the `:param` keys; for a static path, the input itself. */
14
+ readonly rest: unknown;
15
+ }
16
+ | { readonly ok: false; readonly missing: string; readonly reason: string };
17
+
18
+ export function fillWire(path: string, input: unknown): FilledWire {
19
+ const params = templateParams(path);
20
+ if (params.length === 0) return { ok: true, url: path, rest: input };
21
+ const filled = fillTemplate(path, input);
22
+ if (!filled.ok) return filled;
23
+ // `fillTemplate` read a string from every param, so `input` is an object here.
24
+ const entries = Object.entries(input as Readonly<Record<string, unknown>>);
25
+ return {
26
+ ok: true,
27
+ url: filled.path,
28
+ rest: Object.fromEntries(entries.filter(([key]) => !params.includes(key))),
29
+ };
30
+ }
@@ -52,3 +52,10 @@ export function finiteCount(
52
52
  );
53
53
  return value;
54
54
  }
55
+
56
+ /**
57
+ * The longest delay a timer holds: a signed 32-bit count of milliseconds, about 24.8 days. Past it
58
+ * `setTimeout` arms ~1 ms, so a "longer" ceiling fires at once. Every timer an option arms clamps or
59
+ * refuses at this.
60
+ */
61
+ export const MAX_TIMER_MS = 2_147_483_647;
@@ -2,7 +2,7 @@
2
2
  // outside every pipeline, so the body is a stranger's to read: everyone gets the verdict, and the
3
3
  // build id, the in-flight count and the readiness check names go only to a listed peer.
4
4
 
5
- import { classifyAddress } from './address-class';
5
+ import { peerAddressClass } from './address-class';
6
6
  import type { HealthReport } from './lifecycle';
7
7
  import type { Role } from './roles';
8
8
 
@@ -33,7 +33,7 @@ export function healthBody(
33
33
  */
34
34
  export function healthPeerListed(peers: readonly string[], address: string | null): boolean {
35
35
  if (address === null || !Array.isArray(peers)) return false;
36
- const kind = classifyAddress(address);
36
+ const kind = peerAddressClass(address);
37
37
  if (kind === undefined) return false;
38
38
  const literal = address.trim().toLowerCase();
39
39
  return peers.some(
package/src/hmac.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Single responsibility: the framework's ONE keyed hash. Every HMAC a package takes — a webhook
2
+ // signature, a cursor, an OAuth cookie, a TOTP code, a keyed fingerprint — goes through `hmac()`,
3
+ // so a defect in the primitive under it is fixed in one file. `bun run keyed-hasher` refuses a
4
+ // keyed `new Bun.CryptoHasher(alg, key)` anywhere else (`X_KEYED_HASHER_OUTSIDE_CORE`).
5
+
6
+ // why: Bun 1.4.4 — after a WebCrypto EC import fails ANYWHERE in the process (one bad JWKS or
7
+ // VAPID key), the next keyed `new Bun.CryptoHasher(alg, key).update()` throws that stale OpenSSL
8
+ // error (`POINT_IS_NOT_ON_CURVE`) once. Unkeyed hashing, `crypto.subtle` HMAC and `node:crypto`'s
9
+ // `createHmac` are unaffected; Bun has no other synchronous HMAC. Pinned by `hmac.test.ts`, whose
10
+ // last test goes red when Bun fixes it.
11
+ import { createHmac } from 'node:crypto';
12
+
13
+ /** The two a caller needs: SHA-1 for TOTP (RFC 6238 requires it), SHA-256 for everything else. */
14
+ export type HmacAlgorithm = 'sha1' | 'sha256';
15
+
16
+ /** A string is its UTF-8 bytes, exactly as `Bun.CryptoHasher#update(string)` read it. */
17
+ export type HmacInput = string | Uint8Array;
18
+
19
+ export type HmacEncoding = 'hex' | 'base64' | 'base64url';
20
+
21
+ /**
22
+ * HMAC of `data` under `key`. A LIST is the MAC over the parts' concatenation, so a canonical
23
+ * string and a raw body are fed without joining them through a decoder. With an `encoding` the
24
+ * digest is text; without one it is a fresh `Uint8Array` the caller owns.
25
+ */
26
+ export function hmac(
27
+ algorithm: HmacAlgorithm,
28
+ key: HmacInput,
29
+ data: HmacInput | readonly HmacInput[],
30
+ ): Uint8Array;
31
+ export function hmac(
32
+ algorithm: HmacAlgorithm,
33
+ key: HmacInput,
34
+ data: HmacInput | readonly HmacInput[],
35
+ encoding: HmacEncoding,
36
+ ): string;
37
+ export function hmac(
38
+ algorithm: HmacAlgorithm,
39
+ key: HmacInput,
40
+ data: HmacInput | readonly HmacInput[],
41
+ encoding?: HmacEncoding,
42
+ ): Uint8Array | string {
43
+ const mac = createHmac(algorithm, key);
44
+ if (typeof data === 'string' || data instanceof Uint8Array) mac.update(data);
45
+ else for (const part of data) mac.update(part);
46
+ return encoding === undefined ? Uint8Array.from(mac.digest()) : mac.digest(encoding);
47
+ }