@ultimat3/core 24.0.0 → 25.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 (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +18 -11
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +1 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. package/src/time-zone-name.ts +0 -14
package/src/index.ts CHANGED
@@ -5,15 +5,17 @@
5
5
  // secrets — because each is one subject spread over a dozen modules. Every name they carry is
6
6
  // still written out below: `export *` would make the contract something a reader has to resolve.
7
7
 
8
- // Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
9
- // the module regardless (oven-sh/bun#40650). This module registers @ultimat3/schema's error TITLES,
10
- // because schema is tier 0 and cannot register its own — and what reads them is `UltimateError`'s
11
- // constructor, which never imports this file. Shaken out, every X_VALIDATION_FAILED renders
12
- // untitled in the browser with nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and
13
- // `bun run side-effects` enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts`
14
- // are declared side-effecting too and are deliberately NOT anchored: each is reached by whatever
15
- // uses it, and anchoring `context.ts` alone measured +3,485 B on a browser chunk for a provider a
16
- // browser can never fire.
8
+ // Anchored on purpose, and not by the `sideEffects` array alone: Bun before 1.4.1 read any array as
9
+ // `false` and dropped the module regardless (oven-sh/bun#40650), and a bare import holds on every
10
+ // bundler — the array still lists both, because one that honours it drops a bare import of a module
11
+ // it does not list. This module registers @ultimat3/schema's error TITLES, because schema is tier 0
12
+ // and cannot register its own — and what reads them is `UltimateError`'s constructor, which never
13
+ // imports this file. Shaken out, every X_VALIDATION_FAILED renders untitled in the browser with
14
+ // nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and `bun run side-effects`
15
+ // enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts` also run something at
16
+ // import and are neither anchored NOR listed: only their own bindings need the effect, so they ride
17
+ // along exactly where they are used. Listed, Bun 1.4.2 kept all three in every chunk reaching this
18
+ // barrel, ~5.4 kB an island (`SIDE_EFFECTS_BY_USE` in `scripts/side-effects.ts`).
17
19
  import './core-error-codes';
18
20
  import './schema-error-codes';
19
21
 
@@ -30,6 +32,7 @@ export {
30
32
  ACTOR_KINDS,
31
33
  actorFact,
32
34
  actorLabel,
35
+ actorOf,
33
36
  actorOrigin,
34
37
  agentActor,
35
38
  anonymousActor,
@@ -42,22 +45,45 @@ export {
42
45
  withFacts,
43
46
  } from './actor';
44
47
  export type { AddressClass } from './address-class';
45
- export { classifyAddress, isPublicAddress } from './address-class';
48
+ export { addressNetwork, classifyAddress, isPublicAddress } from './address-class';
46
49
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
47
- export { assert, assertNever, type InvariantOptions, invariant } from './assert';
50
+ export { type AssertCodedOptions, assert, assertCoded, assertNever } from './assert';
48
51
  export { type AsyncContext, asyncContext } from './async-context';
49
52
  /** The four shapes an async region can be in — produced by `realtime`, rendered by `ui`. */
50
53
  export type { AsyncState } from './async-state';
54
+ /** The audit seam `action` and `query` share: one record shape, one installed sink. */
55
+ export type {
56
+ AuditFailure,
57
+ AuditOutcome,
58
+ AuditPrimitive,
59
+ AuditRecord,
60
+ AuditSink,
61
+ AuditSurface,
62
+ } from './audit';
63
+ export {
64
+ AUDIT_RECORD_FIELDS,
65
+ getAuditSink,
66
+ resetAuditSink,
67
+ setAuditSink,
68
+ } from './audit';
69
+ export type {
70
+ AwsCredentials,
71
+ AwsPayload,
72
+ SignAwsRequestInput,
73
+ SignedAwsRequest,
74
+ } from './aws-sigv4';
75
+ export { signAwsRequest, UNSIGNED_PAYLOAD } from './aws-sigv4';
51
76
  export type { BackoffCurve, BackoffOptions, JitterMode, Random } from './backoff';
52
- export { backoffDelay } from './backoff';
77
+ export { backoffDelay, jitterStatedDelay } from './backoff';
78
+ export { isCompiledBundle } from './bunfs';
53
79
  export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
54
80
  export { canonicalJson, fingerprint } from './canonical-json';
55
81
  export type { FetchLike, TransportRequest } from './client-dispatch';
56
- export { IDEMPOTENCY_HEADER } from './client-dispatch';
82
+ export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './client-dispatch';
57
83
  /**
58
84
  * Flight control for a typed client, and OPT-IN by construction: `@ultimat3/action`'s and
59
85
  * `@ultimat3/query`'s `client.ts` each name `ClientFlight` as a TYPE only, so a caller that never
60
- * mentions `createClientFlight` pays nothing for the fence, the dedup map or the retry loop.
86
+ * mentions `clientFlight` pays nothing for the fence, the dedup map or the retry loop.
61
87
  * Both packages re-export these names unchanged; this is the one copy.
62
88
  */
63
89
  export type {
@@ -67,7 +93,7 @@ export type {
67
93
  FlightKeyOptions,
68
94
  FlightPlan,
69
95
  } from './client-flight';
70
- export { createClientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
96
+ export { clientFlight, DEFAULT_CLIENT_RETRY, isTransientFailure } from './client-flight';
71
97
  export type { ActionPathStyle, ActionRoute } from './client-paths';
72
98
  export {
73
99
  ACTION_PATH_PREFIX,
@@ -80,6 +106,9 @@ export {
80
106
  splitWords,
81
107
  } from './client-paths';
82
108
  export type { TransportFailure } from './client-problem';
109
+ export { MAX_REMOTE_TITLE_LENGTH, remoteTitleOf, withStatedDelay } from './client-problem';
110
+ /** What a decoder reads off a refusal: the stated `Retry-After`, and the body's display title. */
111
+ export { MAX_RETRY_AFTER_SECONDS, retryAfterSecondsOf } from './client-retry-after';
83
112
  /**
84
113
  * The browser seam (plan 101): ONE HTTP function, the records envelope it decodes, the per-tab
85
114
  * page handle records land in, and the principal fence every client layer subscribes to.
@@ -93,8 +122,6 @@ export { FRAMEWORK_CODE, problemOf, retryForStatus, traceHeaders } from './clien
93
122
  export { notifyClientWrite, onClientWrite } from './client-writes';
94
123
  export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
95
124
  export type {
96
- AiConfig,
97
- AiConfigInput,
98
125
  AppConfig,
99
126
  AppConfigInput,
100
127
  AppConfigOverlay,
@@ -102,7 +129,6 @@ export type {
102
129
  CacheConfig,
103
130
  DatabaseConfig,
104
131
  JobsConfig,
105
- McpConfig,
106
132
  NotifyConfig,
107
133
  PwaConfigInput,
108
134
  RealtimeConfig,
@@ -111,9 +137,17 @@ export type {
111
137
  ThemeMode,
112
138
  } from './config';
113
139
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
140
+ export type { AiConfig, AiConfigInput, McpConfig } from './config-ai';
114
141
  export type { DrainConfig, HealthConfig, ReadinessMode } from './config-health';
115
142
  export { READINESS_MODES } from './config-health';
116
143
  export type { IslandsConfig, IslandsSection, IslandsSectionInput } from './config-islands';
144
+ export { JOBS_CONCURRENCY_DEFAULT, type JobsConcurrency } from './config-jobs';
145
+ export type {
146
+ MailConfig,
147
+ MailRetainMimeConfig,
148
+ MailSection,
149
+ MailSectionInput,
150
+ } from './config-mail';
117
151
  export type {
118
152
  NavigationConfig,
119
153
  NavigationSection,
@@ -125,7 +159,6 @@ export type {
125
159
  export {
126
160
  DEFAULT_SPECULATION,
127
161
  NAVIGATION_SURFACES,
128
- resolveSpeculation,
129
162
  SPECULATION_EAGERNESS,
130
163
  } from './config-navigation';
131
164
  export type {
@@ -138,7 +171,7 @@ export type {
138
171
  PwaShortcut,
139
172
  PwaText,
140
173
  } from './config-pwa';
141
- export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
174
+ export { isSameOriginPath, PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
142
175
  export type {
143
176
  SeoConfig,
144
177
  SeoConfigInput,
@@ -154,7 +187,7 @@ export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-pol
154
187
  export { resolveConflict } from './conflict-policy';
155
188
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
156
189
  export {
157
- createContext,
190
+ ctxOf,
158
191
  DEFAULT_LOCALE,
159
192
  DEFAULT_TIME_ZONE,
160
193
  hasContext,
@@ -165,10 +198,13 @@ export {
165
198
  useService,
166
199
  withChildContext,
167
200
  } from './context';
168
- /** The one `Cookie:` reader — auth, http and i18n each parsed the header and could not share it. */
169
- export { readCookie } from './cookie';
201
+ /** The one cookie codec — auth, http and i18n each parsed `Cookie:` and spelled `Set-Cookie`. */
202
+ export type { CookiePriority, CookieSameSite, SetCookieOptions } from './cookie';
203
+ export { CookieInvalidError, readCookie, serializeSetCookie } from './cookie';
170
204
  export type { CursorPayload } from './cursor';
171
205
  export {
206
+ CURSOR_SECRET_FIX,
207
+ CURSOR_SECRET_KEY,
172
208
  CursorInvalidError,
173
209
  configureCursorSigning,
174
210
  decodeCursor,
@@ -176,9 +212,19 @@ export {
176
212
  resetCursorSigning,
177
213
  usesDevCursorSecret,
178
214
  } from './cursor';
215
+ /** The ONE page shape — `nextCursor` is `null` exactly when `hasMore` is false (25.0.0). */
216
+ export type { Page } from './cursor-page';
217
+ export { pageOf } from './cursor-page';
179
218
  export { compareDecimalText } from './decimal-order';
219
+ export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
220
+ export { recordDeprecatedCall, renderDeprecation } from './deprecation';
180
221
  export type { DevSecretsOptions } from './dev-secrets';
181
- export { assertNoDevSecretsOutsideLocal, CursorSecretDevError } from './dev-secrets';
222
+ export {
223
+ assertNoDevSecretsOutsideLocal,
224
+ CursorSecretDevError,
225
+ devSecretsRefused,
226
+ } from './dev-secrets';
227
+ export { DRAIN_DEADLINE_DEFAULT_MS, DRAIN_DEADLINE_MAX_MS } from './drain-deadline';
182
228
  export type {
183
229
  Env,
184
230
  EnvBooleanVar,
@@ -196,10 +242,8 @@ export type {
196
242
  export { checkEnv, defineEnv, describeEnv, maskedEnvValues } from './env';
197
243
  export type { EnvExampleOptions, EnvExampleReport } from './env-example';
198
244
  export {
199
- assertEnvExample,
200
245
  checkEnvExample,
201
246
  ENV_EXAMPLE_PATH,
202
- EnvExampleDriftError,
203
247
  envFileCandidates,
204
248
  parseEnvKeys,
205
249
  renderEnvExample,
@@ -269,7 +313,6 @@ export {
269
313
  statedDelayMs,
270
314
  stringField,
271
315
  toUltimateError,
272
- ULTIMATE_ERROR_BRAND,
273
316
  UltimateError,
274
317
  } from './exports/error-contract';
275
318
  export type {
@@ -338,7 +381,6 @@ export {
338
381
  configureTelemetry,
339
382
  connections,
340
383
  counter,
341
- createLogger,
342
384
  currentSampler,
343
385
  currentSpan,
344
386
  currentSpanContext,
@@ -405,6 +447,7 @@ export {
405
447
  setLogStream,
406
448
  startMetricExport,
407
449
  startSpan,
450
+ structuredLogger,
408
451
  traceparent,
409
452
  tryOtlpEndpoint,
410
453
  withSpan,
@@ -434,6 +477,7 @@ export {
434
477
  masterKeyPath,
435
478
  openSecrets,
436
479
  parseMasterKey,
480
+ promoteStagedMasterKey,
437
481
  readSecretsFile,
438
482
  requireMasterKey,
439
483
  revealOptionalSecret,
@@ -445,6 +489,7 @@ export {
445
489
  SECRETS_KEY_MODE,
446
490
  SecretsFileInvalidError,
447
491
  SecretsFileMissingError,
492
+ SecretsKeyAclError,
448
493
  SecretsKeyInvalidError,
449
494
  SecretsKeyMismatchError,
450
495
  SecretsKeyMissingError,
@@ -456,6 +501,7 @@ export {
456
501
  secretsPath,
457
502
  serializeSecretValues,
458
503
  stagedMasterKeyPath,
504
+ stageMasterKeyFile,
459
505
  writeMasterKeyFile,
460
506
  writeSecretsFile,
461
507
  } from './exports/secrets';
@@ -466,11 +512,11 @@ export type {
466
512
  FlightGateOptions,
467
513
  FlightGateState,
468
514
  } from './flight-gate';
469
- export { createFlightGate, gateOverloaded } from './flight-gate';
515
+ export { flightGate, gateOverloaded } from './flight-gate';
470
516
  export { fnv1a } from './fnv1a';
471
517
  export { formatBytes } from './format-bytes';
472
518
  export type { GenerationFence } from './generation-fence';
473
- export { createFence, isSuperseded } from './generation-fence';
519
+ export { generationFence, isSuperseded } from './generation-fence';
474
520
  export type { PublicHealthBody } from './health-disclosure';
475
521
  export { DEFAULT_HEALTH_DETAIL_PEERS, healthBody, healthPeerListed } from './health-disclosure';
476
522
  export type { HostDecision, HostRule } from './host-rules';
@@ -488,8 +534,8 @@ export {
488
534
  spanId,
489
535
  traceId,
490
536
  typedId,
491
- uuid,
492
537
  uuidTimestamp,
538
+ uuidV7,
493
539
  } from './ids';
494
540
  export type { ImageFit, ResizeSpec } from './image/canvas';
495
541
  export { parseColor } from './image/color';
@@ -523,7 +569,7 @@ export type { ImageFormat, ImageInfo } from './image/probe';
523
569
  export { IMAGE_FORMATS, IMAGE_MIME_TYPES, probeImage, sniffImageFormat } from './image/probe';
524
570
  export type { ImageSize, Raster } from './image/raster';
525
571
  export {
526
- createRaster,
572
+ blankRaster,
527
573
  hasAlpha,
528
574
  MAX_IMAGE_PIXELS,
529
575
  } from './image/raster';
@@ -585,7 +631,7 @@ export {
585
631
  } from './lifecycle-grace';
586
632
  export type { ReadinessCheck, ReadinessStatus } from './lifecycle-readiness';
587
633
  export type { SignalHandlerOptions } from './lifecycle-signals';
588
- export { installSignalHandlers } from './lifecycle-signals';
634
+ export { drainSignals, installSignalHandlers } from './lifecycle-signals';
589
635
  export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
590
636
  export type { Direction } from './locale-direction';
591
637
  export { directionOf, isRtl } from './locale-direction';
@@ -594,9 +640,16 @@ export { localeSegment, localizePath, splitLocalePath } from './locale-path';
594
640
  // The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
595
641
  export type { LogSink } from './logger';
596
642
  export { setLogSink } from './logger';
597
- export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
643
+ export {
644
+ isMcpExposed,
645
+ type McpAnnotationHints,
646
+ type McpExposureDeclaration,
647
+ type McpListFilterOp,
648
+ type McpListParams,
649
+ } from './mcp-exposure';
598
650
  export type { MeasurementActorFactory } from './measurement-actor';
599
651
  export {
652
+ declaredMeasurementActor,
600
653
  defineMeasurementActor,
601
654
  MEASUREMENT_ACTOR_ID,
602
655
  measurementActor,
@@ -686,10 +739,11 @@ export {
686
739
  type ServiceFactory,
687
740
  } from './service';
688
741
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
689
- export { createSingleFlight } from './single-flight';
742
+ export { singleFlight } from './single-flight';
690
743
  export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
691
744
  export type { StoreMode } from './store-mode';
692
745
  export { STORE_MODES, storeMode } from './store-mode';
746
+ export { THEME_STORAGE_KEY } from './theme-storage';
693
747
  export { timingSafeEqual } from './timing-safe-equal';
694
748
  export {
695
749
  frameworkVersion,
package/src/iso-date.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Single responsibility: re-export `@ultimat3/schema`'s ISO date-time predicate, so a package that
2
2
  // depends on core and not on schema — `@ultimat3/ui` formats dates, it validates nothing — judges a
3
- // date string with the ONE rule `t.date` and `timestamp()` use. The `time-zone-name.ts` shape.
3
+ // date string with the ONE rule `t.date` and `timestamp()` use.
4
4
 
5
5
  export { isIsoDateTime } from '@ultimat3/schema';
@@ -15,7 +15,7 @@ registerErrorCodes({
15
15
  *
16
16
  * Loud rather than tolerant, because the silent version was measured and is worse than a failed
17
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
18
+ * `httpServer().start()` bound a real port, answered `X_DRAINING` (503) to every request, and was
19
19
  * still accepting connections after its own `stop()` returned — a dead listener holding a port,
20
20
  * with no log line naming what happened.
21
21
  *
@@ -1,5 +1,8 @@
1
- // Single responsibility: the readiness CHECK — its signature and its two answers. The mode that
2
- // decides what a failing one does to `/readyz` is `config-health.ts`'s.
1
+ // Single responsibility: the readiness CHECK — its signature, its two answers, and the registry of
2
+ // named checks `/readyz` runs. The mode that decides what a failing one does to `/readyz` is
3
+ // `config-health.ts`'s; the lifecycle that reports through them is `lifecycle.ts`.
4
+
5
+ import { UltimateError } from './errors';
3
6
 
4
7
  export type ReadinessStatus = 'ok' | 'failing';
5
8
 
@@ -19,3 +22,58 @@ export type ReadinessStatus = 'ok' | 'failing';
19
22
  * is `failing`.
20
23
  */
21
24
  export type ReadinessCheck = () => boolean;
25
+
26
+ const checks = new Map<string, ReadinessCheck>();
27
+
28
+ /**
29
+ * Register a named readiness check. Returns its unregister — the same shape as `onShutdown`, and
30
+ * owned by whoever can be started twice, for the same reason.
31
+ */
32
+ export function registerReadinessCheck(name: string, check: ReadinessCheck): () => void {
33
+ if (checks.has(name)) {
34
+ throw new UltimateError({
35
+ code: 'X_READINESS_CHECK_DUPLICATE',
36
+ cause: `a readiness check named "${name}" is already registered (have: ${[...checks.keys()].join(', ')})`,
37
+ fix: `name the second check for what it actually probes, e.g. registerReadinessCheck('${name}-replica', check) — or hold the unregister the first registration returned and call it first`,
38
+ meta: { name },
39
+ });
40
+ }
41
+ checks.set(name, check);
42
+ return () => {
43
+ if (checks.get(name) === check) checks.delete(name);
44
+ };
45
+ }
46
+
47
+ /** Test-only: registered checks. A count that climbs across a start/stop cycle is a leak. */
48
+ export function readinessCheckCount(): number {
49
+ return checks.size;
50
+ }
51
+
52
+ /** Drop every check — `resetLifecycle()`'s, and nothing else's. */
53
+ export function clearReadinessChecks(): void {
54
+ checks.clear();
55
+ }
56
+
57
+ /**
58
+ * Every check, run now, by name. A check that throws is `failing` — never an unhandled error —
59
+ * and is handed to `onThrow`, which `lifecycle.ts` routes through its total `report`.
60
+ *
61
+ * Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
62
+ * name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
63
+ * report, `ready` was computed over an empty object — vacuously true — and a failing check
64
+ * answered 200. `fromEntries` defines own properties and has no such name.
65
+ */
66
+ export function runReadinessChecks(
67
+ onThrow: (name: string, thrown: unknown) => void,
68
+ ): Readonly<Record<string, ReadinessStatus>> {
69
+ const results: [string, ReadinessStatus][] = [];
70
+ for (const [name, check] of checks) {
71
+ try {
72
+ results.push([name, check() ? 'ok' : 'failing']);
73
+ } catch (thrown) {
74
+ results.push([name, 'failing']);
75
+ onThrow(name, thrown);
76
+ }
77
+ }
78
+ return Object.fromEntries(results);
79
+ }
@@ -7,11 +7,36 @@ export interface SignalHandlerOptions {
7
7
  readonly signals?: readonly ProcessSignal[] | undefined;
8
8
  /** Call `process.exit()` once drained. Off in tests. */
9
9
  readonly exit?: boolean | undefined;
10
+ /** Whose default signal set to install — `process.platform` unless a test names one. */
11
+ readonly platform?: string | undefined;
10
12
  }
11
13
 
12
- /** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
14
+ const POSIX_SIGNALS: readonly ProcessSignal[] = Object.freeze(['SIGTERM', 'SIGINT']);
15
+
16
+ /**
17
+ * Windows never sends SIGTERM to a console process: a service stop or a closed console window
18
+ * arrives as SIGHUP, and Ctrl-Break as SIGBREAK. Listening for the POSIX pair alone meant either
19
+ * one killed the process outright, mid-request, with no drain. SIGHUP stays off POSIX on purpose:
20
+ * there it is a terminal hang-up a supervised process should not read as "stop".
21
+ */
22
+ const WINDOWS_SIGNALS: readonly ProcessSignal[] = Object.freeze([
23
+ 'SIGTERM',
24
+ 'SIGINT',
25
+ 'SIGHUP',
26
+ 'SIGBREAK',
27
+ ]);
28
+
29
+ /** The signals that start a drain on `platform`. */
30
+ export function drainSignals(platform: string = process.platform): readonly ProcessSignal[] {
31
+ return platform === 'win32' ? WINDOWS_SIGNALS : POSIX_SIGNALS;
32
+ }
33
+
34
+ /**
35
+ * Install drain-on-signal handling — SIGTERM/SIGINT, plus SIGHUP/SIGBREAK on Windows
36
+ * (`drainSignals`). Returns an uninstall function.
37
+ */
13
38
  export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
14
- const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
39
+ const signals = options?.signals ?? drainSignals(options?.platform);
15
40
  const handlers = new Map<ProcessSignal, () => void>();
16
41
 
17
42
  for (const signal of signals) {
@@ -0,0 +1,96 @@
1
+ // Single responsibility: the lifecycle's data model — its states, phases, hook and option shapes,
2
+ // and the health report it answers with. The mechanism that moves between them is `lifecycle.ts`,
3
+ // which re-exports every name here so a caller keeps one import path.
4
+
5
+ import type { Clock } from './clock';
6
+ import type { ReadinessMode } from './config-health';
7
+ import type { ReadinessStatus } from './lifecycle-readiness';
8
+ import type { Logger } from './logger';
9
+
10
+ export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
11
+
12
+ /** Ordered. `accept` runs first, `close` last. */
13
+ export type ShutdownPhase = 'accept' | 'inflight' | 'close';
14
+
15
+ export const SHUTDOWN_PHASES: readonly ShutdownPhase[] = ['accept', 'inflight', 'close'];
16
+
17
+ /**
18
+ * Signals Ultimate reacts to. Narrower than `NodeJS.Signals` on purpose. `SIGBREAK` is Windows'
19
+ * Ctrl-Break — the one console signal there that is neither Ctrl-C nor a window closing.
20
+ */
21
+ export type ProcessSignal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGQUIT' | 'SIGBREAK';
22
+
23
+ export interface ShutdownReason {
24
+ readonly signal: string;
25
+ /**
26
+ * Real monotonic ms (`systemClock`) after which hooks are abandoned — deliberately NOT the
27
+ * injected clock. The budget this bounds is `terminationGracePeriodSeconds`, counted by the
28
+ * kubelet in real seconds, so a frozen clock must be unable to extend it: read off `clock` a
29
+ * test that advanced an hour of fake time handed the drain a 16-minute grace period, while
30
+ * `waitForIdle` went on sleeping on a real `setTimeout`. `clock` still owns `uptimeMs`.
31
+ */
32
+ readonly deadlineAt: number;
33
+ }
34
+
35
+ export type ShutdownHook = (reason: ShutdownReason) => void | Promise<void>;
36
+
37
+ export interface OnShutdownOptions {
38
+ readonly phase?: ShutdownPhase | undefined;
39
+ }
40
+
41
+ export interface LifecycleOptions {
42
+ /**
43
+ * The whole drain's budget — the in-flight wait AND every hook, in every phase. 25s by default,
44
+ * and **enforced whether or not an app sets it**: `ShutdownReason.deadlineAt` was always computed
45
+ * and handed to every hook, so the deadline was declared by the design and only the enforcement
46
+ * was missing. No hook reads `deadlineAt`, which is why it has to be imposed here.
47
+ *
48
+ * The lever is a LARGER value, not the absence of one: a `worker` holding a 10-minute job wants
49
+ * `configureLifecycle({ deadlineMs: 600_000 })` and a `terminationGracePeriodSeconds` at least as
50
+ * large. Left at 25s it is abandoned and the process exits clean — the row's visibility lease
51
+ * lapses and another worker re-claims it, which is what at-least-once already promises. The
52
+ * alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
53
+ * kubelet's grace period, with no log line naming what overran.
54
+ *
55
+ * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
56
+ */
57
+ readonly deadlineMs?: number | undefined;
58
+ /**
59
+ * How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
60
+ * endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
61
+ * endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
62
+ * `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
63
+ * drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
64
+ * A whole number from 0 to 60000; 0 is no grace.
65
+ */
66
+ readonly readinessGraceMs?: number | undefined;
67
+ /** What a failing check does to `/readyz` — see `ReadinessMode`. Default `'dependencies'`. */
68
+ readonly readiness?: ReadinessMode | undefined;
69
+ readonly clock?: Clock | undefined;
70
+ readonly logger?: Logger | undefined;
71
+ }
72
+
73
+ export interface HealthReport {
74
+ readonly state: HealthState;
75
+ readonly ready: boolean;
76
+ readonly uptimeMs: number;
77
+ readonly inflight: number;
78
+ readonly buildId: string;
79
+ /** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
80
+ readonly checks: Readonly<Record<string, ReadinessStatus>>;
81
+ /**
82
+ * How many checks are registered. `checks: {}` reads identically for "every check passed" and
83
+ * "nobody registered one", and only the second is a `/readyz` that means no more than "the
84
+ * socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
85
+ * Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
86
+ * no dependency does not have to invent a check to boot.
87
+ */
88
+ readonly registered: number;
89
+ }
90
+
91
+ export interface HealthPayload {
92
+ readonly ok: boolean;
93
+ /** The status code the HTTP layer should return. Core stays HTTP-free; this is just data. */
94
+ readonly status: number;
95
+ readonly body: HealthReport;
96
+ }