@ultimat3/core 1.2.0 → 2.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 (45) hide show
  1. package/CLAUDE.md +252 -0
  2. package/README.md +210 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +13 -0
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +100 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +61 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +277 -113
  25. package/src/lifecycle-deadline.ts +73 -0
  26. package/src/lifecycle-errors.ts +33 -0
  27. package/src/lifecycle.ts +178 -16
  28. package/src/logger.ts +99 -9
  29. package/src/mcp-exposure.ts +32 -0
  30. package/src/metrics.ts +0 -0
  31. package/src/otlp-metric-exporter.ts +136 -0
  32. package/src/otlp-span-exporter.ts +170 -0
  33. package/src/otlp.ts +217 -0
  34. package/src/read-capped.ts +47 -0
  35. package/src/runtime-metrics.ts +15 -0
  36. package/src/safe-url.ts +50 -0
  37. package/src/sampler.ts +126 -0
  38. package/src/schema-error-codes.ts +28 -0
  39. package/src/secrets-errors.ts +143 -0
  40. package/src/secrets-store.ts +173 -0
  41. package/src/secrets.ts +292 -0
  42. package/src/telemetry.ts +43 -11
  43. package/src/timing-safe-equal.ts +18 -0
  44. package/src/type-pins.ts +93 -0
  45. package/src/version.ts +53 -4
@@ -0,0 +1,62 @@
1
+ // Single responsibility: acting as another actor, on the record. `withChildContext({ actor })` is
2
+ // the mechanism; this is the ONE door through it, because a swap with no reason and no origin is
3
+ // indistinguishable in an audit trail from the customer doing it themselves.
4
+
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { type Actor, actorLabel, actorOrigin } from './actor';
7
+ import { assert } from './assert';
8
+ import { useContext, withChildContext } from './context';
9
+ import { currentSpan } from './telemetry';
10
+
11
+ const storage = new AsyncLocalStorage<string>();
12
+
13
+ /**
14
+ * Run `fn` as `actor`, recording who asked and why.
15
+ *
16
+ * ```ts
17
+ * await impersonate(customer, 'ticket 4821: reproduce the failed refund', async () => { … });
18
+ * ```
19
+ *
20
+ * Three properties, none optional. **The original actor is preserved** — stamped onto the child as
21
+ * `onBehalfOf`, so every log line, span and `actorLabel()` inside renders `support:eng-7→user:…`
22
+ * and a refund issued here can never read as the customer's. **The reason is required and
23
+ * non-blank** because it *is* the mechanism: a swap with no argument is a pragma, and the next
24
+ * reader cannot tell a support session from a bug. **There must be a context** — outside one there
25
+ * is no original actor to preserve, so there is nothing to impersonate *from*, and
26
+ * `withChildContext` says so with `X_NO_CONTEXT`.
27
+ *
28
+ * Same template as `@ultimat3/entity`'s `crossTenant()`, deliberately: two escapes from the
29
+ * framework's default posture should not be two different-looking things.
30
+ */
31
+ export function impersonate<T>(actor: Actor, reason: string, fn: () => T): T {
32
+ assert(
33
+ reason.trim() !== '',
34
+ 'impersonate() was given a blank reason, so the identity swap it performs carries no argument',
35
+ "pass why one actor is acting as another: impersonate(customer, 'ticket 4821: reproduce the failed refund', fn)",
36
+ );
37
+ const parent = useContext();
38
+ const impersonated: Actor = Object.freeze({ ...actor, onBehalfOf: actorOrigin(parent.actor) });
39
+ const label = actorLabel(impersonated);
40
+ // The PARENT's logger, because the parent is who is performing this — and `warn`, not `info`,
41
+ // because this is rare, always interesting, and an audit query that has to read info-level to
42
+ // find it will be run at the wrong level during the one incident where it matters.
43
+ parent.logger.warn('impersonation', { event: 'impersonation', actor: label, reason });
44
+ currentSpan()?.addEvent('impersonation', {
45
+ 'actor.label': label,
46
+ 'impersonation.reason': reason,
47
+ });
48
+ return withChildContext({ actor: impersonated }, () => storage.run(reason, fn));
49
+ }
50
+
51
+ /**
52
+ * The innermost enclosing reason, or `undefined` outside every scope — which is every request in
53
+ * an app where nobody is impersonating. Read by an audit sink, and by nothing else.
54
+ */
55
+ export function impersonationReason(): string | undefined {
56
+ return storage.getStore();
57
+ }
58
+
59
+ /** Is the caller acting as somebody else right now? */
60
+ export function isImpersonating(): boolean {
61
+ return storage.getStore() !== undefined;
62
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,24 @@
1
1
  // Single responsibility: the public API of @ultimat3/core. Explicit named exports only —
2
2
  // every other package imports from here, so this list is the tier-0 contract.
3
+ //
4
+ // Three slices arrive through a barrel in `exports/` — observability, the error contract and
5
+ // secrets — because each is one subject spread over a dozen modules. Every name they carry is
6
+ // still written out below: `export *` would make the contract something a reader has to resolve.
3
7
 
4
- export type { Actor, ActorInit, ActorKind } from './actor';
8
+ export type {
9
+ Actor,
10
+ ActorFactKey,
11
+ ActorFactMap,
12
+ ActorFacts,
13
+ ActorInit,
14
+ ActorKind,
15
+ ActorOrigin,
16
+ } from './actor';
5
17
  export {
6
18
  ACTOR_KINDS,
19
+ actorFact,
7
20
  actorLabel,
21
+ actorOrigin,
8
22
  agentActor,
9
23
  anonymousActor,
10
24
  hasRole,
@@ -13,7 +27,9 @@ export {
13
27
  isAnonymous,
14
28
  serviceActor,
15
29
  userActor,
30
+ withFacts,
16
31
  } from './actor';
32
+ export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
17
33
  export type { InvariantOptions } from './assert';
18
34
  export { assert, assertNever, invariant } from './assert';
19
35
  export type { Clock, FrozenClock } from './clock';
@@ -24,6 +40,7 @@ export type {
24
40
  AppConfig,
25
41
  AppConfigInput,
26
42
  AppConfigOverlay,
43
+ AuthConfig,
27
44
  CacheConfig,
28
45
  CacheTier,
29
46
  DatabaseConfig,
@@ -58,6 +75,7 @@ export {
58
75
  configureCursorSigning,
59
76
  decodeCursor,
60
77
  encodeCursor,
78
+ resetCursorSigning,
61
79
  usesDevCursorSecret,
62
80
  } from './cursor';
63
81
  export type {
@@ -95,44 +113,265 @@ export {
95
113
  isLocal,
96
114
  isProduction,
97
115
  resolveEnvironment,
116
+ tryResolveEnvironment,
98
117
  } from './environment';
99
118
  export type {
119
+ CodedErrorInit,
100
120
  CoreErrorCode,
101
121
  ErrorCodeDeclaration,
102
122
  ErrorCodeDescriptor,
103
123
  ErrorCodeEntry,
104
- } from './error-codes';
105
- export {
106
- CORE_ERROR_CODES,
107
- describeErrorCode,
108
- ERROR_DOCS_BASE,
109
- errorCodeSnapshot,
110
- errorDocsUrl,
111
- hasErrorCode,
112
- listErrorCodes,
113
- registerErrorCodes,
114
- resetErrorCodes,
115
- } from './error-codes';
116
- export type {
117
- CodedErrorInit,
124
+ ErrorRetry,
118
125
  FormatErrorOptions,
119
126
  UltimateErrorInit,
120
127
  UltimateErrorJSON,
121
- } from './errors';
128
+ } from './exports/error-contract';
122
129
  export {
130
+ CORE_ERROR_CODES,
123
131
  ConfigInvalidError,
132
+ DEFAULT_ERROR_RETRY,
133
+ describeErrorCode,
134
+ describeValue,
124
135
  EnvMissingError,
136
+ ERROR_DOCS_BASE,
137
+ ERROR_RETRY_KINDS,
138
+ errorCodeSnapshot,
139
+ errorDocsUrl,
140
+ errorRetry,
125
141
  formatError,
142
+ hasErrorCode,
126
143
  InternalError,
144
+ isErrorRetry,
145
+ isThrownError,
127
146
  isUltimateError,
147
+ listErrorCodes,
148
+ MAX_RENDERED_LENGTH,
128
149
  NotImplementedError,
129
150
  notImplemented,
151
+ registerErrorCodes,
152
+ registerErrorRetry,
153
+ registeredErrorRetry,
154
+ renderCauseValue,
155
+ renderFixLiteral,
156
+ renderThrowable,
157
+ resetErrorCodes,
158
+ resetErrorRetry,
159
+ retryFor,
160
+ SCHEMA_ERROR_CODE_TITLES,
161
+ stringField,
130
162
  toUltimateError,
131
163
  ULTIMATE_ERROR_BRAND,
132
164
  UltimateError,
133
- } from './errors';
165
+ } from './exports/error-contract';
166
+ export type {
167
+ AttributeValue,
168
+ Counter,
169
+ ErrorReport,
170
+ ErrorReporter,
171
+ ErrorReportingOptions,
172
+ ErrorScope,
173
+ ErrorSeverity,
174
+ ErrorSource,
175
+ Gauge,
176
+ GaugeOptions,
177
+ Histogram,
178
+ HistogramOptions,
179
+ HistogramPoint,
180
+ InstrumentOptions,
181
+ LogFields,
182
+ Logger,
183
+ LoggerOptions,
184
+ LogLevel,
185
+ MemoryErrorReporter,
186
+ MemoryExporter,
187
+ MemoryMetricExporter,
188
+ MetricAttributes,
189
+ MetricAttributeValue,
190
+ MetricCollection,
191
+ MetricDescriptor,
192
+ MetricExporter,
193
+ MetricKind,
194
+ MetricPoint,
195
+ MetricsOptions,
196
+ OtlpAnyValue,
197
+ OtlpKeyValue,
198
+ OtlpMetricExporter,
199
+ OtlpMetricExporterOptions,
200
+ OtlpSignal,
201
+ OtlpSpanExporter,
202
+ OtlpSpanExporterOptions,
203
+ ReadableMetric,
204
+ ReadableSpan,
205
+ ReportErrorOptions,
206
+ RequestSample,
207
+ Sampler,
208
+ SentryDsn,
209
+ SentryEnvelopeOptions,
210
+ SentryReporterOptions,
211
+ Span,
212
+ SpanAttributes,
213
+ SpanContext,
214
+ SpanEvent,
215
+ SpanExporter,
216
+ SpanKind,
217
+ SpanResource,
218
+ SpanStatus,
219
+ SpanStatusCode,
220
+ StartSpanOptions,
221
+ TelemetryOptions,
222
+ } from './exports/observability';
223
+ export {
224
+ alwaysOffSampler,
225
+ alwaysOnSampler,
226
+ collectMetrics,
227
+ configureErrorReporting,
228
+ configureMetrics,
229
+ configureTelemetry,
230
+ connections,
231
+ counter,
232
+ createLogger,
233
+ currentSampler,
234
+ currentSpan,
235
+ currentSpanContext,
236
+ DEFAULT_HISTOGRAM_BOUNDS,
237
+ DEFAULT_MAX_SERIES,
238
+ DEFAULT_SAMPLE_RATIO,
239
+ defaultSampler,
240
+ ERROR_SOURCES,
241
+ ErrorReporterDsnInvalidError,
242
+ errorReport,
243
+ exportMetrics,
244
+ gauge,
245
+ histogram,
246
+ isRedactedKey,
247
+ jobs,
248
+ LOG_LEVELS,
249
+ leasesLost,
250
+ logger,
251
+ METRICS_CONTENT_TYPE,
252
+ METRICS_PATH,
253
+ MetricCardinalityError,
254
+ MetricNameInvalidError,
255
+ MetricValueInvalidError,
256
+ memoryErrorReporter,
257
+ memoryExporter,
258
+ memoryMetricExporter,
259
+ metricsText,
260
+ noopErrorReporter,
261
+ noopExporter,
262
+ noopMetricExporter,
263
+ OTEL_SAMPLER_ARG_KEY,
264
+ OTEL_SAMPLER_KEY,
265
+ OTLP_ENDPOINT_KEY,
266
+ OTLP_HEADERS_KEY,
267
+ OTLP_PROTOCOL_KEY,
268
+ OTLP_SCOPE,
269
+ OtlpEndpointInvalidError,
270
+ OtlpProtocolUnsupportedError,
271
+ OVERFLOW_ATTRIBUTE,
272
+ otlpAttributes,
273
+ otlpEndpoint,
274
+ otlpHeaders,
275
+ otlpMetricExporter,
276
+ otlpMetricsRequest,
277
+ otlpResource,
278
+ otlpSpanExporter,
279
+ otlpTraceRequest,
280
+ parentBasedRatioSampler,
281
+ parseSentryDsn,
282
+ parseTraceparent,
283
+ queueDepth,
284
+ REDACTED,
285
+ ratioSampler,
286
+ recordConnection,
287
+ recordJob,
288
+ recordLeaseLost,
289
+ recordQueueDepth,
290
+ recordRequest,
291
+ redactKeys,
292
+ reportError,
293
+ requestDuration,
294
+ requests,
295
+ resetDefaultSampler,
296
+ resetErrorReporting,
297
+ resetMetrics,
298
+ resetTelemetry,
299
+ SCALING_METRICS,
300
+ samplerFromEnv,
301
+ sentryEnvelope,
302
+ sentryErrorReporter,
303
+ serviceResource,
304
+ setLoggerContextFields,
305
+ startMetricExport,
306
+ startSpan,
307
+ traceparent,
308
+ tryOtlpEndpoint,
309
+ unixNano,
310
+ withSpan,
311
+ withSpanContext,
312
+ } from './exports/observability';
313
+ export type {
314
+ MasterKeyRef,
315
+ MasterKeySource,
316
+ Secret,
317
+ SecretSummary,
318
+ SecretsEnvelope,
319
+ SecretsErrorCode,
320
+ SecretsInstallOptions,
321
+ SecretsInstallReport,
322
+ SecretsLocation,
323
+ SecretValues,
324
+ } from './exports/secrets';
325
+ export {
326
+ assertSecretValues,
327
+ describeSecrets,
328
+ findMasterKey,
329
+ generateMasterKey,
330
+ installSecrets,
331
+ isSecret,
332
+ masterKeyId,
333
+ masterKeyIdOf,
334
+ masterKeyPath,
335
+ openSecrets,
336
+ parseMasterKey,
337
+ parseSecretsEnvelope,
338
+ readSecretsFile,
339
+ requireMasterKey,
340
+ revealOptionalSecret,
341
+ revealSecret,
342
+ SECRET_BRAND,
343
+ SECRET_NAME,
344
+ SECRETS_ALG,
345
+ SECRETS_ERROR_CODES,
346
+ SECRETS_FILE,
347
+ SECRETS_IV_BYTES,
348
+ SECRETS_KEY_BYTES,
349
+ SECRETS_KEY_ENV,
350
+ SECRETS_KEY_FILE,
351
+ SECRETS_KEY_HEX_LENGTH,
352
+ SECRETS_KEY_ID_LENGTH,
353
+ SECRETS_KEY_MODE,
354
+ SECRETS_TAG_BYTES,
355
+ SECRETS_VERSION,
356
+ SecretsFileInvalidError,
357
+ SecretsFileMissingError,
358
+ SecretsKeyInvalidError,
359
+ SecretsKeyMismatchError,
360
+ SecretsKeyMissingError,
361
+ SecretsPlaintextInvalidError,
362
+ SecretsTamperedError,
363
+ sealSecrets,
364
+ secret,
365
+ secretsFileExists,
366
+ secretsPath,
367
+ serializeSecretValues,
368
+ writeMasterKeyFile,
369
+ writeSecretsFile,
370
+ } from './exports/secrets';
134
371
  export type { Brand, Id } from './ids';
135
372
  export {
373
+ isSpanId,
374
+ isTraceId,
136
375
  isUuid,
137
376
  nanoid,
138
377
  parseId,
@@ -187,6 +426,7 @@ export {
187
426
  } from './image/raster';
188
427
  export type { ImageFit, ResizeSpec } from './image/resize';
189
428
  export { fitBox, resizeRaster, scaledToFit } from './image/resize';
429
+ export { impersonate, impersonationReason, isImpersonating } from './impersonate';
190
430
  export type {
191
431
  HealthPayload,
192
432
  HealthReport,
@@ -194,6 +434,8 @@ export type {
194
434
  LifecycleOptions,
195
435
  OnShutdownOptions,
196
436
  ProcessSignal,
437
+ ReadinessCheck,
438
+ ReadinessStatus,
197
439
  ShutdownHook,
198
440
  ShutdownPhase,
199
441
  ShutdownReason,
@@ -203,17 +445,23 @@ export {
203
445
  beginWork,
204
446
  configureLifecycle,
205
447
  drain,
448
+ drainDeadlineMs,
206
449
  healthReport,
207
450
  healthzPayload,
451
+ idleWaiterCount,
208
452
  inflightCount,
209
453
  installSignalHandlers,
210
454
  isDraining,
211
455
  lifecycleState,
212
456
  markReady,
213
457
  onShutdown,
458
+ readinessCheckCount,
459
+ readinessChecks,
214
460
  readyzPayload,
461
+ registerReadinessCheck,
215
462
  resetLifecycle,
216
463
  SHUTDOWN_PHASES,
464
+ shutdownHookCount,
217
465
  } from './lifecycle';
218
466
  export {
219
467
  isSelfOrigin,
@@ -221,51 +469,10 @@ export {
221
469
  markListening,
222
470
  resetListeners,
223
471
  } from './listeners';
224
- export type { LogFields, Logger, LoggerOptions, LogLevel } from './logger';
225
- export {
226
- createLogger,
227
- isRedactedKey,
228
- LOG_LEVELS,
229
- logger,
230
- REDACTED,
231
- redactKeys,
232
- setLoggerContextFields,
233
- } from './logger';
234
- export type {
235
- Counter,
236
- Gauge,
237
- GaugeOptions,
238
- Histogram,
239
- HistogramOptions,
240
- HistogramPoint,
241
- InstrumentOptions,
242
- MemoryMetricExporter,
243
- MetricAttributes,
244
- MetricAttributeValue,
245
- MetricCollection,
246
- MetricDescriptor,
247
- MetricExporter,
248
- MetricKind,
249
- MetricPoint,
250
- MetricsOptions,
251
- ReadableMetric,
252
- } from './metrics';
253
- export {
254
- collectMetrics,
255
- configureMetrics,
256
- counter,
257
- DEFAULT_HISTOGRAM_BOUNDS,
258
- exportMetrics,
259
- gauge,
260
- histogram,
261
- MetricNameInvalidError,
262
- MetricValueInvalidError,
263
- memoryMetricExporter,
264
- noopMetricExporter,
265
- resetMetrics,
266
- startMetricExport,
267
- } from './metrics';
268
- export { METRICS_CONTENT_TYPE, METRICS_PATH, metricsText } from './metrics-text';
472
+ export type { McpExposureDeclaration } from './mcp-exposure';
473
+ export { isMcpExposed } from './mcp-exposure';
474
+ export type { CappedBody } from './read-capped';
475
+ export { readWithinLimit } from './read-capped';
269
476
  export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
270
477
  export {
271
478
  hasPrimitiveRegistrar,
@@ -278,57 +485,14 @@ export type { Err, Ok, Result } from './result';
278
485
  export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
279
486
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
280
487
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
281
- export type { RequestSample } from './runtime-metrics';
282
- export {
283
- connections,
284
- jobs,
285
- queueDepth,
286
- recordConnection,
287
- recordJob,
288
- recordQueueDepth,
289
- recordRequest,
290
- requestDuration,
291
- requests,
292
- SCALING_METRICS,
293
- } from './runtime-metrics';
294
- export type { Secret } from './secret';
295
- export {
296
- isSecret,
297
- revealOptionalSecret,
298
- revealSecret,
299
- SECRET_BRAND,
300
- secret,
301
- } from './secret';
488
+ export { safeUrl, URL_ATTRIBUTES } from './safe-url';
302
489
  export type { ServiceFactory } from './service';
303
490
  export { defineService, resetServices } from './service';
304
- export type {
305
- AttributeValue,
306
- MemoryExporter,
307
- ReadableSpan,
308
- Span,
309
- SpanAttributes,
310
- SpanContext,
311
- SpanEvent,
312
- SpanExporter,
313
- SpanKind,
314
- SpanResource,
315
- SpanStatus,
316
- SpanStatusCode,
317
- StartSpanOptions,
318
- TelemetryOptions,
319
- } from './telemetry';
491
+ export { timingSafeEqual } from './timing-safe-equal';
320
492
  export {
321
- configureTelemetry,
322
- currentSpan,
323
- currentSpanContext,
324
- memoryExporter,
325
- noopExporter,
326
- parseTraceparent,
327
- resetTelemetry,
328
- serviceResource,
329
- startSpan,
330
- traceparent,
331
- withSpan,
332
- withSpanContext,
333
- } from './telemetry';
334
- export { FRAMEWORK_VERSION, readPackageVersion, VERSION_MANIFEST } from './version';
493
+ frameworkVersion,
494
+ readPackageVersion,
495
+ resolveVersion,
496
+ VERSION_DEFINE,
497
+ VERSION_MANIFEST,
498
+ } from './version';
@@ -0,0 +1,73 @@
1
+ // Single responsibility: run one shutdown hook under the drain's remaining budget. A hook that
2
+ // overruns is ABANDONED — still running, no longer awaited — because a deadline that only logged
3
+ // leaves the kubelet to SIGKILL the process, which is the failure the budget exists to prevent.
4
+ // Split out of `lifecycle.ts` so the race has no access to the drain's module state.
5
+
6
+ /**
7
+ * What became of one hook. `abandoned` is the only outcome the caller must report: the hook is
8
+ * still running, nothing will ever read its result, and whatever it had in flight may be
9
+ * incomplete — which is the price of a deadline and has to be visible in the log line.
10
+ */
11
+ export type HookOutcome =
12
+ | { readonly kind: 'settled' }
13
+ | { readonly kind: 'failed'; readonly error: unknown }
14
+ | { readonly kind: 'abandoned' };
15
+
16
+ const SETTLED: HookOutcome = Object.freeze({ kind: 'settled' });
17
+ const ABANDONED: HookOutcome = Object.freeze({ kind: 'abandoned' });
18
+
19
+ const failed = (error: unknown): HookOutcome => ({ kind: 'failed', error });
20
+
21
+ /**
22
+ * `work()` raced against `budgetMs` of REAL elapsed time. `budgetMs` is required and is a `number`:
23
+ * every drain has a budget, so "no budget" is not a case this has to answer for, and the signature
24
+ * is what stops it from becoming one.
25
+ *
26
+ * A synchronous throw and a rejection are one outcome (`failed`); the caller logs both the same
27
+ * way. Nothing here throws: a drain that rejects never reaches `process.exit(0)`.
28
+ */
29
+ export function settleWithin(
30
+ work: () => void | Promise<void>,
31
+ budgetMs: number,
32
+ ): Promise<HookOutcome> {
33
+ let started: void | Promise<void>;
34
+ try {
35
+ started = work();
36
+ } catch (error) {
37
+ return Promise.resolve(failed(error));
38
+ }
39
+ const pending = Promise.resolve(started);
40
+ return new Promise<HookOutcome>((resolve) => {
41
+ let decided = false;
42
+ const timer = setTimeout(
43
+ () => {
44
+ if (decided) return;
45
+ decided = true;
46
+ resolve(ABANDONED);
47
+ },
48
+ Math.max(0, budgetMs),
49
+ );
50
+ // A budget already spent still gives a synchronous hook its turn — a resolved promise settles
51
+ // on a microtask and this timer on a macrotask — so closing a pool costs nothing it does not
52
+ // already have. It must also never be the thing keeping a drained process alive.
53
+ timer.unref?.();
54
+ // Attached unconditionally, on both settle paths: an abandoned hook that rejects later has
55
+ // nobody left awaiting it, and an unhandled rejection would kill the process this drain is
56
+ // trying to end cleanly. After the decision the outcome is dropped on purpose — the overrun
57
+ // was already reported, and a second line about a hook nobody is waiting for is noise.
58
+ pending.then(
59
+ () => {
60
+ if (decided) return;
61
+ decided = true;
62
+ clearTimeout(timer);
63
+ resolve(SETTLED);
64
+ },
65
+ (error: unknown) => {
66
+ if (decided) return;
67
+ decided = true;
68
+ clearTimeout(timer);
69
+ resolve(failed(error));
70
+ },
71
+ );
72
+ });
73
+ }
@@ -0,0 +1,33 @@
1
+ // Single responsibility: the one condition a lifecycle that has already drained raises, and the
2
+ // error that carries it. Split from `lifecycle.ts` so the state machine reads as a state machine,
3
+ // and registered here rather than in `error-codes.ts` for the reason `secrets-errors.ts` gives:
4
+ // the code and the module that throws it ship together.
5
+
6
+ import { registerErrorCodes } from './error-codes';
7
+ import { UltimateError } from './errors';
8
+
9
+ registerErrorCodes({
10
+ X_LIFECYCLE_DRAINED: { title: 'a drained process cannot become ready again' },
11
+ });
12
+
13
+ /**
14
+ * A role tried to become ready in a process whose lifecycle has already drained.
15
+ *
16
+ * Loud rather than tolerant, because the silent version was measured and is worse than a failed
17
+ * boot: `state` never leaves `stopped` and `drain()` has memoized its promise, so a second
18
+ * `createServer().start()` bound a real port, answered `X_DRAINING` (503) to every request, and was
19
+ * still accepting connections after its own `stop()` returned — a dead listener holding a port,
20
+ * with no log line naming what happened.
21
+ *
22
+ * The `fix` has to answer two readers, because there are exactly two ways here: a boot that starts
23
+ * a role after SIGTERM has already arrived, and a test that drained in one case and built a server
24
+ * in the next.
25
+ */
26
+ export function lifecycleDrained(state: 'draining' | 'stopped'): UltimateError {
27
+ return new UltimateError({
28
+ code: 'X_LIFECYCLE_DRAINED',
29
+ cause: `this process is ${state} — its lifecycle has already drained, and a drain is terminal: one process, one lifecycle. A role marked ready now would bind a socket that answers 503 to every request, and drain() memoizes, so nothing would ever close it`,
30
+ fix: 'start every role BEFORE the first drain — a drained process is on its way out and is replaced, never reused. In a test, call resetLifecycle() from @ultimat3/core between the drain and the next start()',
31
+ meta: { state },
32
+ });
33
+ }