@ultimat3/core 29.4.0 → 30.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.
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
@@ -21,16 +21,36 @@ export interface RecordEnvelope<T = unknown> {
21
21
  readonly records?: Readonly<Record<string, RecordRows>>;
22
22
  /** Record keys to drop, keyed by the entity's record type. */
23
23
  readonly removed?: Readonly<Record<string, readonly string[]>>;
24
+ /**
25
+ * A type's record keys in ANSWER order, sent only for a type whose `records` key order differs:
26
+ * every JS object — `JSON.parse`'s included — enumerates an integer-like key ascending, so an
27
+ * `integer()` key's order cannot ride `records` itself. Absent, `records` key order IS the order.
28
+ */
29
+ readonly order?: Readonly<Record<string, readonly string[]>>;
24
30
  }
25
31
 
26
- /** The server half. An empty `removed` is omitted, so the envelope never claims a deletion. */
32
+ /**
33
+ * The server half. An empty `removed` is omitted, so the envelope never claims a deletion. `order`
34
+ * is each type's keys first-seen; only a type whose order an object would lose is sent, so an
35
+ * answer keyed by uuids stays byte-identical on the wire.
36
+ */
27
37
  export function encodeRecordEnvelope<T>(
28
38
  data: T,
29
39
  records: Readonly<Record<string, RecordRows>>,
30
40
  removed?: Readonly<Record<string, readonly string[]>>,
41
+ order?: Readonly<Record<string, readonly string[]>>,
31
42
  ): RecordEnvelope<T> {
32
43
  const hasRemoved = removed !== undefined && Object.keys(removed).length > 0;
33
- return hasRemoved ? { data, records, removed } : { data, records };
44
+ const lost = Object.entries(order ?? {}).filter(([type, keys]) => {
45
+ const held = Object.keys(records[type] ?? {});
46
+ return keys.length !== held.length || keys.some((key, index) => key !== held[index]);
47
+ });
48
+ return {
49
+ data,
50
+ records,
51
+ ...(hasRemoved ? { removed } : {}),
52
+ ...(lost.length > 0 ? { order: nullProto(lost) } : {}),
53
+ };
34
54
  }
35
55
 
36
56
  /**
@@ -51,20 +71,24 @@ export function decodeRecordEnvelope(body: unknown): RecordEnvelope {
51
71
  }),
52
72
  );
53
73
  });
54
- const removed = byType(body['removed'], 'removed', (keys, at) => {
55
- if (!Array.isArray(keys)) throw envelopeInvalid(at, 'is not an array');
56
- const list: unknown[] = keys;
57
- const bad = list.findIndex((key) => typeof key !== 'string');
58
- if (bad !== -1) throw envelopeInvalid(`${at} key #${bad}`, 'is not a string');
59
- return list as string[];
60
- });
74
+ const removed = byType(body['removed'], 'removed', keysOf);
75
+ const order = byType(body['order'], 'order', keysOf);
61
76
  return {
62
77
  data: body['data'],
63
78
  ...(records === undefined ? {} : { records }),
64
79
  ...(removed === undefined ? {} : { removed }),
80
+ ...(order === undefined ? {} : { order }),
65
81
  };
66
82
  }
67
83
 
84
+ function keysOf(keys: unknown, at: string): string[] {
85
+ if (!Array.isArray(keys)) throw envelopeInvalid(at, 'is not an array');
86
+ const list: unknown[] = keys;
87
+ const bad = list.findIndex((key) => typeof key !== 'string');
88
+ if (bad !== -1) throw envelopeInvalid(`${at} key #${bad}`, 'is not a string');
89
+ return list as string[];
90
+ }
91
+
68
92
  function byType<T>(
69
93
  value: unknown,
70
94
  member: string,
@@ -40,11 +40,18 @@ export interface ScopeCell {
40
40
  readonly listeners: Set<(next: ClientScope, prev: ClientScope) => void>;
41
41
  }
42
42
 
43
+ /** Whether this page's session is lost (`session-lost.ts`). On the handle, so every copy agrees. */
44
+ export interface SessionLostCell {
45
+ lost: boolean;
46
+ readonly listeners: Set<() => void>;
47
+ }
48
+
43
49
  /** Headers every outbound request carries, or nothing. See `outbound-headers.ts`. */
44
50
  export type OutboundHeaders = () => Readonly<Record<string, string>>;
45
51
 
46
52
  interface PageClientHandle extends PageClient {
47
53
  readonly scopeCell: ScopeCell;
54
+ readonly sessionLost: SessionLostCell;
48
55
  /** The early-records buffer, or `undefined` where there is no page to install a store on. */
49
56
  readonly pending: PendingRecords | undefined;
50
57
  outboundHeaders: OutboundHeaders | undefined;
@@ -64,6 +71,11 @@ export function scopeCell(): ScopeCell {
64
71
  return handle().scopeCell;
65
72
  }
66
73
 
74
+ /** The session-loss cell. Internal: `session-lost.ts` and `rescope()` are its callers. */
75
+ export function sessionLostCell(): SessionLostCell {
76
+ return handle().sessionLost;
77
+ }
78
+
67
79
  /**
68
80
  * Where decoded records go RIGHT NOW: the installed store, else the browser's pending buffer, else
69
81
  * nowhere. Internal: `client-transport.ts` is its one caller.
@@ -109,6 +121,7 @@ function handle(): PageClientHandle {
109
121
  const created: PageClientHandle = {
110
122
  socket: undefined,
111
123
  scopeCell: cell,
124
+ sessionLost: { lost: false, listeners: new Set() },
112
125
  pending,
113
126
  outboundHeaders: undefined,
114
127
  get store(): RecordSink | undefined {
@@ -0,0 +1,78 @@
1
+ // Single responsibility: the framework's one Redis client — `@ultimat3/core/redis`, never the
2
+ // barrel (core is in every bundle graph and this reads `Bun.RedisClient`). `Bun.redis`'s defaults
3
+ // turn an outage into a hang and a restart into a dead tier; each option below is why it is not.
4
+ // Measured 2026-11-02 on Redis 7, Bun 1.4.4 (plan 101, `29-data-redis-outage.md`).
5
+
6
+ import type { RedisClient, RedisOptions } from 'bun';
7
+
8
+ /** The local server Bun itself falls back to, so an unset environment dials the same place. */
9
+ const LOCAL_REDIS_URL = 'redis://localhost:6379';
10
+
11
+ export const REDIS_CLIENT_OPTIONS: Readonly<
12
+ Required<Pick<RedisOptions, 'enableOfflineQueue' | 'maxRetries' | 'connectionTimeout'>>
13
+ > = Object.freeze({
14
+ /**
15
+ * why: with the offline queue on, a command issued while disconnected WAITS for a connection —
16
+ * the first read against a stopped server took 30,916 ms. Off, it refuses at once, and a refused
17
+ * cache read is a miss.
18
+ */
19
+ enableOfflineQueue: false,
20
+ /**
21
+ * why: the default 20 attempts are spent in ~31 s, then the client stays closed — a server
22
+ * back 37 s later still answered `ERR_REDIS_CONNECTION_CLOSED` on every pod until it restarted.
23
+ * The largest 32-bit count is "never give up"; with it the same restart recovered within 4 s.
24
+ */
25
+ maxRetries: 2_147_483_647,
26
+ /** why: the default 10 s connect is 10 s every reconnect attempt sits on an unanswering host. */
27
+ connectionTimeout: 1_000,
28
+ });
29
+
30
+ /** One client per process per URL: a second one is a second socket and a second reconnect loop. */
31
+ const clients = new Map<string, RedisClient>();
32
+
33
+ type RedisEnv = Readonly<Record<string, string | undefined>>;
34
+
35
+ /** The variable that supplied a Redis URL, and the URL. */
36
+ export interface RedisUrlSource {
37
+ readonly key: 'REDIS_URL' | 'VALKEY_URL';
38
+ readonly url: string;
39
+ }
40
+
41
+ /**
42
+ * `REDIS_URL`, the variable the CLI's tier selection reads; then `VALKEY_URL`, which `Bun.redis`
43
+ * also honours. An empty or blank value is unset; `undefined` when neither is set — the one
44
+ * precedence, for a caller that must tell an unset Redis from the local default.
45
+ */
46
+ export function redisUrlSource(
47
+ env: RedisEnv = process.env as Record<string, string | undefined>,
48
+ ): RedisUrlSource | undefined {
49
+ for (const key of ['REDIS_URL', 'VALKEY_URL'] as const) {
50
+ const url = env[key]?.trim();
51
+ if (url !== undefined && url !== '') return { key, url };
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** `redisUrlSource`'s URL, else the local default. */
57
+ export function redisUrl(
58
+ env: RedisEnv = process.env as Record<string, string | undefined>,
59
+ ): string {
60
+ return redisUrlSource(env)?.url ?? LOCAL_REDIS_URL;
61
+ }
62
+
63
+ /**
64
+ * The client for `url` (default `redisUrl()`), built once and connecting in the background: the
65
+ * caller never waits on a connect, and a command before the first connect is refused like any
66
+ * other command while down. `close()` on it closes it for every holder.
67
+ */
68
+ export function redisClient(url: string = redisUrl()): RedisClient {
69
+ const held = clients.get(url);
70
+ if (held !== undefined) return held;
71
+ const client = new Bun.RedisClient(url, { ...REDIS_CLIENT_OPTIONS });
72
+ clients.set(url, client);
73
+ // why: the rejection is not lost — every command while unconnected is refused with
74
+ // `ERR_REDIS_CONNECTION_CLOSED`, which is where a caller learns of it. Unhandled, it would kill
75
+ // the process on a cold start against a server that is still coming up.
76
+ client.connect().catch(() => undefined);
77
+ return client;
78
+ }
package/src/registrar.ts CHANGED
@@ -81,13 +81,24 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
81
81
  { factory: 'hive', pkg: '@ultimat3/ai', kind: 'action' },
82
82
  { factory: 'llm', pkg: '@ultimat3/ai', kind: 'action' },
83
83
  { factory: 'backfill', pkg: '@ultimat3/jobs', kind: 'job' },
84
+ { factory: 'consumer', pkg: '@ultimat3/jobs', kind: 'job' },
84
85
  { factory: 'exportRows', pkg: '@ultimat3/jobs', kind: 'job' },
86
+ { factory: 'importRows', pkg: '@ultimat3/jobs', kind: 'job' },
85
87
  { factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
88
+ { factory: 'refreshView', pkg: '@ultimat3/jobs', kind: 'job' },
89
+ { factory: 'rollup', pkg: '@ultimat3/jobs', kind: 'job' },
90
+ { factory: 'subjectErase', pkg: '@ultimat3/jobs', kind: 'job' },
91
+ { factory: 'subjectExport', pkg: '@ultimat3/jobs', kind: 'job' },
86
92
  { factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
93
+ { factory: 'webhookReceiver', pkg: '@ultimat3/jobs', kind: 'job' },
87
94
  { factory: 'mcpConfirmations', pkg: '@ultimat3/mcp', kind: 'action' },
88
95
  { factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
96
+ { factory: 'pushRegister', pkg: '@ultimat3/notify', kind: 'action' },
97
+ { factory: 'pushUnregister', pkg: '@ultimat3/notify', kind: 'action' },
89
98
  { factory: 'pushSubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
90
99
  { factory: 'pushUnsubscribe', pkg: '@ultimat3/pwa', kind: 'action' },
100
+ // A route: the value IS a websocket mount the web role serves (`path`, `fetch`, `websocket`).
101
+ { factory: 'deviceSocket', pkg: '@ultimat3/realtime', kind: 'route' },
91
102
  { factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
92
103
  ] satisfies readonly PrimitiveFactory[]
93
104
  ).map((entry) => Object.freeze(entry)),
@@ -0,0 +1,13 @@
1
+ // The render node brands, the one literal of each in the framework. A leaf with no import,
2
+ // published at `@ultimat3/core/render-node` so `@ultimat3/render` stamps them and
3
+ // `@ultimat3/core/render-nodes` reads them without either joining the barrel. Registered on the
4
+ // GLOBAL symbol registry: two copies of a module must agree on a node.
5
+
6
+ /** An element or a component call: `{ [JSX_NODE]: true, type, props }`. */
7
+ export const JSX_NODE: unique symbol = Symbol.for('ultimate.render.jsx') as never;
8
+
9
+ /** An island: a branded ARRAY whose `props.children` is the server shell. */
10
+ export const ISLAND_NODE: unique symbol = Symbol.for('ultimate.render.island') as never;
11
+
12
+ /** A stream hole: a branded ARRAY whose first flush is `props.fallback` and `props.error`. */
13
+ export const HOLE_NODE: unique symbol = Symbol.for('ultimate.render.hole') as never;
@@ -0,0 +1,137 @@
1
+ // A component's node tree, walked so a test can assert on the props an element carries (the
2
+ // `aria-live`, the `onKeyDown`, the `ref`) rather than on markup it was serialised into. Reads the
3
+ // render factory's branded node and a hand-built `{ inert: true }` one; needs nothing from
4
+ // `@ultimat3/render`, so it sits at tier 0 behind `@ultimat3/core/render-nodes`, off the barrel.
5
+
6
+ import { assert } from './assert';
7
+ import { HOLE_NODE, ISLAND_NODE, JSX_NODE } from './render-node';
8
+
9
+ export type RenderComponent = (props: Record<string, unknown>) => unknown;
10
+
11
+ export interface RenderNode {
12
+ /** A lowercase string is an element; a function is a component. */
13
+ readonly type: string | RenderComponent;
14
+ readonly props: Record<string, unknown>;
15
+ }
16
+
17
+ /**
18
+ * What a walk does at a component node. `expand` calls it and keeps only host nodes; `keep` calls
19
+ * it and keeps the component node too, so a test can read the props a view handed a design-system
20
+ * component; `stop` keeps the node, never calls it, and walks the children it was handed.
21
+ */
22
+ export type ComponentMode = 'expand' | 'keep' | 'stop';
23
+
24
+ export interface NodesOfOptions {
25
+ readonly components?: ComponentMode | undefined;
26
+ }
27
+
28
+ export function isRenderNode(value: unknown): value is RenderNode {
29
+ if (typeof value !== 'object' || value === null) return false;
30
+ return 'inert' in value || JSX_NODE in value;
31
+ }
32
+
33
+ /**
34
+ * Every node in the tree, depth first. Thunks are called: a component that reads a prop inside one
35
+ * renders nothing until something asks. A value no factory built contributes nothing.
36
+ */
37
+ export function nodesOf(value: unknown, options: NodesOfOptions = {}): RenderNode[] {
38
+ return walk(value, options.components ?? 'expand');
39
+ }
40
+
41
+ type BrandedProps = { readonly props?: Record<string, unknown> };
42
+
43
+ /**
44
+ * An island or a hole is an ARRAY to the type system, so each is tested before the array branch,
45
+ * which would walk nothing. An island's subtree is its server shell, `props.children`. A hole's
46
+ * first flush is `fallback` and `error`; its `children` are deferred work, never called here.
47
+ */
48
+ const isIsland = (value: unknown): value is BrandedProps =>
49
+ typeof value === 'object' && value !== null && ISLAND_NODE in value;
50
+ const isHole = (value: unknown): value is BrandedProps =>
51
+ typeof value === 'object' && value !== null && HOLE_NODE in value;
52
+
53
+ function walk(value: unknown, mode: ComponentMode): RenderNode[] {
54
+ if (isIsland(value)) return walk(value.props?.['children'], mode);
55
+ if (isHole(value)) return walk([value.props?.['fallback'], value.props?.['error']], mode);
56
+ if (Array.isArray(value)) return value.flatMap((item) => walk(item, mode));
57
+ if (isRenderNode(value)) {
58
+ const { type, props } = value;
59
+ if (typeof type !== 'function' || mode === 'stop')
60
+ return [value, ...walk(props['children'], mode)];
61
+ const output = walk(type(props), mode);
62
+ return mode === 'keep' ? [value, ...output] : output;
63
+ }
64
+ if (typeof value === 'function') return walk((value as () => unknown)(), mode);
65
+ return [];
66
+ }
67
+
68
+ /** Render a component to its nodes. `props` is the component's own, untyped on purpose. */
69
+ export function renderNodes(
70
+ component: unknown,
71
+ props: Record<string, unknown> = {},
72
+ options: NodesOfOptions = {},
73
+ ): RenderNode[] {
74
+ assert(
75
+ typeof component === 'function',
76
+ 'renderNodes() was handed a value that is not a component function',
77
+ 'renderNodes(Button, { label: "Save" }) — the component function itself, never <Button /> or its output',
78
+ );
79
+ return nodesOf((component as RenderComponent)(props), options);
80
+ }
81
+
82
+ export function byTag(nodes: readonly RenderNode[], tag: string): RenderNode[] {
83
+ return nodes.filter((node) => node.type === tag);
84
+ }
85
+
86
+ /** Nodes rendered by the named component, read off its function name: `byComponent(n, 'Money')`. */
87
+ export function byComponent(nodes: readonly RenderNode[], name: string): RenderNode[] {
88
+ return nodes.filter((node) => typeof node.type === 'function' && node.type.name === name);
89
+ }
90
+
91
+ /** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
92
+ export function withAttr(
93
+ nodes: readonly RenderNode[],
94
+ name: string,
95
+ value?: unknown,
96
+ ): RenderNode[] {
97
+ return nodes.filter((node) =>
98
+ value === undefined ? node.props[name] !== undefined : node.props[name] === value,
99
+ );
100
+ }
101
+
102
+ /** The one node a test means, or `X_INVARIANT` naming what it sought — never a silent `undefined`. */
103
+ export function one(nodes: readonly RenderNode[], what: string): RenderNode {
104
+ const node = nodes[0];
105
+ assert(
106
+ node !== undefined && nodes.length === 1,
107
+ `expected exactly one ${what}, found ${nodes.length}`,
108
+ 'one(withAttr(byTag(nodes, "button"), "aria-label", "Save"), "the save button") — narrow until one node matches',
109
+ );
110
+ return node;
111
+ }
112
+
113
+ /** `<button>` for an element, `<Money>` for a component: never a function's source text. */
114
+ const label = (node: RenderNode): string =>
115
+ `<${typeof node.type === 'function' ? node.type.name : node.type}>`;
116
+
117
+ /** Call an element's `ref` prop with the element a test built for it. */
118
+ export function attachRef(node: RenderNode, element: unknown): void {
119
+ const ref = node.props['ref'];
120
+ assert(
121
+ typeof ref === 'function',
122
+ `node ${label(node)} carries no ref`,
123
+ 'attachRef(one(withAttr(nodes, "ref"), "the node holding a ref"), element) — pick the node that declares ref={…}',
124
+ );
125
+ (ref as (el: unknown) => void)(element);
126
+ }
127
+
128
+ /** Call an element's event handler prop, e.g. `fire(menu, 'onKeyDown', keydown('ArrowDown'))`. */
129
+ export function fire(node: RenderNode, handler: string, event: unknown): void {
130
+ const fn = node.props[handler];
131
+ assert(
132
+ typeof fn === 'function',
133
+ `node ${label(node)} carries no ${handler}`,
134
+ `fire(one(withAttr(nodes, "${handler}"), "the node holding ${handler}"), "${handler}", event) — pick the node that declares it`,
135
+ );
136
+ (fn as (e: unknown) => void)(event);
137
+ }
@@ -0,0 +1,197 @@
1
+ // Single responsibility: the retry loop itself and the pure decision behind it, with every source of
2
+ // non-determinism and the caller's lifetime handed in. Split from `retry.ts` so `client-flight.ts`
3
+ // reaches the loop without `context.ts`: a page's bundle must not pay for the ambient request.
4
+
5
+ import {
6
+ type BackoffCurve,
7
+ backoffDelay,
8
+ type JitterMode,
9
+ jitterStatedDelay,
10
+ type Random,
11
+ } from './backoff';
12
+ import { systemClock } from './clock';
13
+ import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
14
+ import { finiteCount, finiteOption } from './finite-option';
15
+
16
+ export interface RetryPolicy {
17
+ /** Total attempts INCLUDING the first. `attempts: 1` means no retry. */
18
+ readonly attempts: number;
19
+ /** The first delay, in ms. */
20
+ readonly base: number;
21
+ /**
22
+ * Ceiling for any single delay, in ms. A `retry-after` the responder named is clamped to it
23
+ * too, and then gets its spread on top — at most half again, so a burst told one delay does
24
+ * not wake together (`jitterStatedDelay`).
25
+ */
26
+ readonly max: number;
27
+ /**
28
+ * Required, unlike `backoffDelay`'s, and that is the point: a retry loop with no jitter is the
29
+ * thundering herd itself, so the mode is a decision each caller makes rather than one it can
30
+ * inherit without noticing.
31
+ */
32
+ readonly jitter: JitterMode;
33
+ readonly curve?: BackoffCurve | undefined;
34
+ readonly factor?: number | undefined;
35
+ /**
36
+ * Wall-clock budget for the WHOLE loop, waits included. A wait that would end past it is never
37
+ * started — the caller's own deadline outranks the attempts it was allowed.
38
+ */
39
+ readonly timeBudgetMs?: number | undefined;
40
+ }
41
+
42
+ /** Why the loop stopped. Absent while it is still retrying. */
43
+ export type RetryStopReason = 'terminal' | 'attempts-exhausted' | 'budget-exhausted';
44
+
45
+ export interface RetryDecision {
46
+ readonly retry: boolean;
47
+ readonly delayMs: number;
48
+ readonly attempt: number;
49
+ readonly nextAttempt: number;
50
+ /** The classification consulted, or `undefined` when nobody classified the thrown code. */
51
+ readonly classification: ErrorRetry | undefined;
52
+ readonly stoppedBy: RetryStopReason | undefined;
53
+ }
54
+
55
+ /**
56
+ * Retry, stop, and when — pure, so a caller can print the schedule and a test can pin it without a
57
+ * loop. `terminal` stops on the attempt that failed: the same code run again is the same answer,
58
+ * and the attempts left are a queue slot, a provider bill, or three more wrong passwords at a site
59
+ * that locks the account after three. Everything else keeps the attempt count in charge, and
60
+ * `retry-after` replaces only the DELAY, never the ceiling.
61
+ */
62
+ export function retryDecision(
63
+ policy: RetryPolicy,
64
+ attempt: number,
65
+ error: unknown,
66
+ random?: Random,
67
+ ): RetryDecision {
68
+ // Screened HERE and not only in `retry()`: this function is exported so a caller can write its
69
+ // own loop, and `attempt >= NaN` is false for every attempt — the loop that asks it never ends.
70
+ const attempts = finiteCount('a retry policy', 'attempts', policy.attempts);
71
+ const classification = classifyThrown(error);
72
+ const stop = (stoppedBy: RetryStopReason): RetryDecision => ({
73
+ retry: false,
74
+ delayMs: 0,
75
+ attempt,
76
+ nextAttempt: attempt,
77
+ classification,
78
+ stoppedBy,
79
+ });
80
+
81
+ if (classification === 'terminal') return stop('terminal');
82
+ if (attempt >= attempts) return stop('attempts-exhausted');
83
+
84
+ const computed = backoffDelay({
85
+ attempt,
86
+ base: policy.base,
87
+ max: policy.max,
88
+ factor: policy.factor,
89
+ curve: policy.curve,
90
+ jitter: policy.jitter,
91
+ random,
92
+ });
93
+ const stated = classification === 'retry-after' ? statedDelayMs(error) : undefined;
94
+ return {
95
+ retry: true,
96
+ // The stated delay is a FLOOR plus a spread (`jitterStatedDelay`), never the bare number: a
97
+ // shedding server tells a whole burst `Retry-After: 1`, and waiting exactly that replays the
98
+ // burst in lockstep every second. The floor is clamped by the policy's own ceiling, which is
99
+ // what `max` is for — a responder naming a day is still a responder this deployment has not
100
+ // agreed to wait a day for — and the spread on top is at most half of it.
101
+ // `jitter: 'none'` is honoured here too — the caller's one decision, for a test or a printable
102
+ // schedule — and only that mode gets the bare floor.
103
+ delayMs:
104
+ stated === undefined
105
+ ? computed
106
+ : policy.jitter === 'none'
107
+ ? Math.min(stated, policy.max)
108
+ : jitterStatedDelay(Math.min(stated, policy.max), policy.max, random),
109
+ attempt,
110
+ nextAttempt: attempt + 1,
111
+ classification,
112
+ stoppedBy: undefined,
113
+ };
114
+ }
115
+
116
+ export interface RetryLoopDeps {
117
+ /** Handed the signal too, so a timer can let itself go on the abort that ended its wait. */
118
+ sleep(ms: number, signal: AbortSignal | undefined): Promise<void>;
119
+ /** Monotonic ms, read only when `timeBudgetMs` is set. Defaults to the system clock. */
120
+ now?: (() => number) | undefined;
121
+ random?: Random | undefined;
122
+ /** The caller's lifetime. Nothing is tried, and nothing is waited for, once it has aborted. */
123
+ signal?: AbortSignal | undefined;
124
+ }
125
+
126
+ /** The wait, ended at once by an abort whatever `sleep` was injected. A rejected sleep propagates. */
127
+ function waitOut(deps: RetryLoopDeps, ms: number): Promise<void> {
128
+ const { signal } = deps;
129
+ if (signal === undefined) return deps.sleep(ms, undefined);
130
+ if (signal.aborted) return Promise.resolve();
131
+ return new Promise<void>((resolve, reject) => {
132
+ const aborted = (): void => {
133
+ resolve();
134
+ };
135
+ signal.addEventListener('abort', aborted, { once: true });
136
+ deps.sleep(ms, signal).then(
137
+ () => {
138
+ signal.removeEventListener('abort', aborted);
139
+ resolve();
140
+ },
141
+ (error: unknown) => {
142
+ signal.removeEventListener('abort', aborted);
143
+ reject(error);
144
+ },
145
+ );
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Run `work` until it succeeds, the policy runs out, the classification says stop, the time budget
151
+ * does, or the signal aborts. `work` is handed the 1-based attempt number.
152
+ *
153
+ * The LAST error reaches the caller unchanged — never wrapped. An `UltimateError` carries a code, a
154
+ * cause and a runnable `fix:`, and a wrapper would replace all three with the fact that something
155
+ * was retried, which no reader can act on. An abort before any attempt has no such error, so it
156
+ * answers the signal's own reason.
157
+ */
158
+ export async function retryLoop<T>(
159
+ work: (attempt: number) => Promise<T>,
160
+ policy: RetryPolicy,
161
+ deps: RetryLoopDeps,
162
+ ): Promise<T> {
163
+ // Both bounds are refused BEFORE the first try: a policy that cannot stop the loop is a defect in
164
+ // the call, and running the work once first would report it as the work's own failure.
165
+ // `finiteOption` for the budget, not `finiteCount`: it is a duration a caller computes from a
166
+ // monotonic clock, so a fraction is real and a spent (negative) one means "do not wait at all".
167
+ // Zero stays legal and means what it always did — one try, no retry (`retry.test.ts` pins it).
168
+ finiteCount('a retry policy', 'attempts', policy.attempts);
169
+ const budget =
170
+ policy.timeBudgetMs === undefined
171
+ ? undefined
172
+ : finiteOption('a retry policy', 'timeBudgetMs', policy.timeBudgetMs);
173
+ const now = deps.now ?? ((): number => systemClock.monotonic());
174
+ // Read once even when no budget is set: a clock call per attempt would be a cost the common case
175
+ // does not owe. `startedAt` is only compared against when `timeBudgetMs` is present.
176
+ const startedAt = now();
177
+ let failed: { readonly error: unknown } | undefined;
178
+
179
+ for (let attempt = 1; ; attempt += 1) {
180
+ // The classification cannot say this: `X_ABORTED` is unclassified by design, or a job body's
181
+ // would dead-letter on attempt 1, so a loop that asks only it goes on sending for a dead caller.
182
+ if (deps.signal?.aborted === true) {
183
+ throw failed === undefined ? deps.signal.reason : failed.error;
184
+ }
185
+ try {
186
+ return await work(attempt);
187
+ } catch (error) {
188
+ const decision = retryDecision(policy, attempt, error, deps.random);
189
+ if (!decision.retry) throw error;
190
+ // Decided BEFORE the wait, never after: a loop that sleeps and then discovers it is out of
191
+ // budget has already spent the caller's deadline on a wait nobody could use.
192
+ if (budget !== undefined && now() - startedAt + decision.delayMs > budget) throw error;
193
+ failed = { error };
194
+ await waitOut(deps, decision.delayMs);
195
+ }
196
+ }
197
+ }