@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
package/src/index.ts CHANGED
@@ -46,7 +46,12 @@ export {
46
46
  withFacts,
47
47
  } from './actor';
48
48
  export type { AddressClass } from './address-class';
49
- export { addressNetwork, classifyAddress, isPublicAddress } from './address-class';
49
+ export {
50
+ addressNetwork,
51
+ classifyAddress,
52
+ isPublicAddress,
53
+ peerAddressClass,
54
+ } from './address-class';
50
55
  /** Test support: the one name a live suite gives the database it creates (`probe-databases`). */
51
56
  export { APP_CONFIG_FILE, APP_STATE_DIR, appDirOf, appStatePath } from './app-dir';
52
57
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
@@ -72,19 +77,34 @@ export {
72
77
  export type {
73
78
  AwsCredentials,
74
79
  AwsPayload,
80
+ PresignAwsUrlInput,
81
+ PresignedAwsUrl,
75
82
  SignAwsRequestInput,
76
83
  SignedAwsRequest,
77
84
  } from './aws-sigv4';
78
- export { signAwsRequest, UNSIGNED_PAYLOAD } from './aws-sigv4';
85
+ export {
86
+ MAX_AWS_PRESIGN_SECONDS,
87
+ presignAwsUrl,
88
+ signAwsRequest,
89
+ UNSIGNED_PAYLOAD,
90
+ } from './aws-sigv4';
79
91
  export type { BackoffCurve, BackoffOptions, JitterMode, Random } from './backoff';
80
92
  export { backoffDelay, jitterStatedDelay } from './backoff';
93
+ export type {
94
+ Breaker,
95
+ BreakerOptions,
96
+ BreakerState,
97
+ BreakerTicket,
98
+ BreakerVerdict,
99
+ } from './breaker';
100
+ export { breaker, settleWith } from './breaker';
81
101
  export { isCompiledBundle } from './bunfs';
82
102
  export type { CacheEvictionOptions } from './cache-eviction';
83
103
  export { CACHE_TEMP_GRACE_MS, evictCacheFiles } from './cache-eviction';
84
104
  export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
85
105
  export { canonicalJson, fingerprint } from './canonical-json';
86
106
  export type { FetchLike, TransportRequest } from './client-dispatch';
87
- export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './client-dispatch';
107
+ export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER, REPLAYED_HEADER } from './client-dispatch';
88
108
  /**
89
109
  * Flight control for a typed client, and OPT-IN by construction: `@ultimat3/action`'s and
90
110
  * `@ultimat3/query`'s `client.ts` each name `ClientFlight` as a TYPE only, so a caller that never
@@ -110,8 +130,19 @@ export {
110
130
  queryPath,
111
131
  splitWords,
112
132
  } from './client-paths';
133
+ export type { ActionPin, ClientPins, PinnedActionMethod } from './client-paths-pins';
134
+ export {
135
+ encodeClientPins,
136
+ renderedActionPin,
137
+ renderedQueryPin,
138
+ } from './client-paths-pins';
113
139
  export type { TransportFailure } from './client-problem';
114
- export { MAX_REMOTE_TITLE_LENGTH, remoteTitleOf, withStatedDelay } from './client-problem';
140
+ export {
141
+ MAX_REMOTE_TITLE_LENGTH,
142
+ remoteTitleOf,
143
+ withRequestId,
144
+ withStatedDelay,
145
+ } from './client-problem';
115
146
  /** What a decoder reads off a refusal: the stated `Retry-After`, and the body's display title. */
116
147
  export { MAX_RETRY_AFTER_SECONDS, retryAfterSecondsOf } from './client-retry-after';
117
148
  /**
@@ -120,6 +151,8 @@ export { MAX_RETRY_AFTER_SECONDS, retryAfterSecondsOf } from './client-retry-aft
120
151
  */
121
152
  export type { ClientScope } from './client-scope';
122
153
  export { onRescope, rescope } from './client-scope';
154
+ export type { StreamRequest } from './client-stream';
155
+ export { clientStream } from './client-stream';
123
156
  export { clientTransport } from './client-transport';
124
157
  /** What a typed client puts on the wire. `retryForStatus` is what fills a failure's `retry`. */
125
158
  export type { WireAnswer } from './client-wire';
@@ -143,6 +176,30 @@ export type {
143
176
  } from './config';
144
177
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
145
178
  export type { AiConfig, AiConfigInput, McpConfig } from './config-ai';
179
+ export type {
180
+ AssetsConfig,
181
+ AssetsRetainConfig,
182
+ AssetsSection,
183
+ AssetsSectionInput,
184
+ } from './config-assets';
185
+ export type {
186
+ ClientErrorsConfig,
187
+ ClientErrorsSection,
188
+ ClientErrorsSectionInput,
189
+ ClientVitalsConfig,
190
+ ClientVitalsSurface,
191
+ } from './config-client-errors';
192
+ export {
193
+ CLIENT_ERRORS_DEFAULTS,
194
+ CLIENT_VITALS_DEFAULTS,
195
+ CLIENT_VITALS_SURFACES,
196
+ } from './config-client-errors';
197
+ export type {
198
+ FailureCaptureConfig,
199
+ FailureCaptureSection,
200
+ FailureCaptureSectionInput,
201
+ } from './config-failure-capture';
202
+ export { FAILURE_CAPTURE_DEFAULTS } from './config-failure-capture';
146
203
  export type { DrainConfig, HealthConfig, ReadinessMode } from './config-health';
147
204
  export { READINESS_MODES } from './config-health';
148
205
  export type { IslandsConfig, IslandsSection, IslandsSectionInput } from './config-islands';
@@ -210,12 +267,24 @@ export {
210
267
  sectionPatterns,
211
268
  sitemapSectionIssues,
212
269
  } from './config-sitemap';
270
+ export type { AppTimeConfig, TimeSection, TimeSectionInput } from './config-time';
271
+ export { TIME_DEFAULTS } from './config-time';
213
272
  export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-policy';
214
273
  export { resolveConflict } from './conflict-policy';
215
- export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
274
+ export type {
275
+ CompletionBound,
276
+ Ctx,
277
+ CtxFacts,
278
+ CtxInit,
279
+ CtxPatch,
280
+ CtxServices,
281
+ ServiceBag,
282
+ } from './context';
216
283
  export {
284
+ abandonment,
217
285
  ctxOf,
218
286
  hasContext,
287
+ runToCompletion,
219
288
  runWithContext,
220
289
  throwIfAborted,
221
290
  tryUseContext,
@@ -242,9 +311,30 @@ export {
242
311
  export type { Page } from './cursor-page';
243
312
  export { pageOf } from './cursor-page';
244
313
  export { compareDecimalText } from './decimal-order';
314
+ // `errors:` on an action and a query: the rule both share, since the two packages are one tier.
315
+ export type { DeclaredErrorsOwner, DeclaredErrorVerdict, ErrorStatusOf } from './declared-errors';
316
+ export {
317
+ ActionErrorsInvalidError,
318
+ checkDeclaredErrors,
319
+ describeDeclaredErrors,
320
+ ErrorUndeclaredError,
321
+ enforceDeclared,
322
+ judgeThrown,
323
+ LIFETIME_ERROR_CODES,
324
+ withDeclaredErrors,
325
+ } from './declared-errors';
326
+ export type { DeclaredResponsesWire } from './declared-errors-openapi';
327
+ export { DECLARED_ERRORS_EXTENSION, withDeclaredResponses } from './declared-errors-openapi';
328
+ export type { SourceReader } from './declared-errors-scan';
329
+ export { definedErrorClassCodes, scanHandlerSource } from './declared-errors-scan';
245
330
  export { DEFAULT_LOCALE, DEFAULT_TIME_ZONE } from './default-locale';
246
331
  export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
247
- export { recordDeprecatedCall, renderDeprecation } from './deprecation';
332
+ export {
333
+ recordDeprecatedCall,
334
+ renderDeprecation,
335
+ successorLink,
336
+ successorPathOf,
337
+ } from './deprecation';
248
338
  export type { DevSecretsOptions } from './dev-secrets';
249
339
  export {
250
340
  assertNoDevSecretsOutsideLocal,
@@ -255,6 +345,7 @@ export {
255
345
  DRAIN_DEADLINE_DEFAULT_MS,
256
346
  DRAIN_DEADLINE_MAX_MS,
257
347
  WORKER_DRAIN_DEADLINE_MAX_MS,
348
+ WORKER_RETIRE_DEFAULT_MS,
258
349
  } from './drain-deadline';
259
350
  export type {
260
351
  Env,
@@ -291,14 +382,37 @@ export {
291
382
  resolveEnvironment,
292
383
  tryResolveEnvironment,
293
384
  } from './environment';
385
+ export { ExerciseEnvironmentError, ExerciseUnscopedError } from './exercise-errors';
386
+ // A contract exercise's one loop: `@ultimat3/action`'s and `@ultimat3/query`'s, one tier apart from neither.
387
+ export type {
388
+ DrawnInput,
389
+ ExerciseAttempt,
390
+ ExerciseReport,
391
+ ExerciseSubject,
392
+ ExerciseVerdict,
393
+ } from './exercise-run';
394
+ export {
395
+ assertExerciseScoped,
396
+ bindDrawn,
397
+ EXERCISE_RUNS,
398
+ EXERCISE_RUNS_MAX,
399
+ exerciseDefect,
400
+ exerciseReplay,
401
+ runExercise,
402
+ sealedHost,
403
+ } from './exercise-run';
294
404
  export type {
295
405
  CodedErrorInit,
296
406
  CoreErrorCode,
407
+ DefinedError,
408
+ DefinedErrorExtra,
409
+ DefineErrorSpec,
297
410
  ErrorAudience,
298
411
  ErrorCodeDeclaration,
299
412
  ErrorCodeDescriptor,
300
413
  ErrorCodeEntry,
301
414
  ErrorRetry,
415
+ ErrorText,
302
416
  FormatErrorOptions,
303
417
  UltimateErrorInit,
304
418
  UltimateErrorJSON,
@@ -309,6 +423,7 @@ export {
309
423
  classifyThrown,
310
424
  DEFAULT_ERROR_RETRY,
311
425
  declaredErrorRetry,
426
+ defineError,
312
427
  deniedCallerFix,
313
428
  describeErrorCode,
314
429
  describeValue,
@@ -349,6 +464,7 @@ export {
349
464
  } from './exports/error-contract';
350
465
  export type {
351
466
  AttributeValue,
467
+ CapturedValue,
352
468
  Counter,
353
469
  ErrorReport,
354
470
  ErrorReporter,
@@ -356,8 +472,16 @@ export type {
356
472
  ErrorScope,
357
473
  ErrorSeverity,
358
474
  ErrorSource,
475
+ FailureActor,
476
+ FailureBundle,
477
+ FailureCaptureLimits,
478
+ FailureFact,
479
+ FailurePrimitive,
480
+ FailureRead,
481
+ FailureStore,
359
482
  Gauge,
360
483
  GaugeOptions,
484
+ HeldFailure,
361
485
  Histogram,
362
486
  HistogramOptions,
363
487
  HistogramPoint,
@@ -407,6 +531,8 @@ export type {
407
531
  export {
408
532
  alwaysOffSampler,
409
533
  alwaysOnSampler,
534
+ captureValue,
535
+ chainErrorReporters,
410
536
  collectMetrics,
411
537
  configureErrorReporting,
412
538
  configureMetrics,
@@ -422,10 +548,16 @@ export {
422
548
  defaultSampler,
423
549
  ERROR_SOURCES,
424
550
  ErrorReporterDsnInvalidError,
551
+ errorFingerprint,
425
552
  errorReport,
553
+ errorReportersOf,
426
554
  exportMetrics,
555
+ failureCaptureActive,
556
+ failureCaptureLimits,
427
557
  gauge,
428
558
  histogram,
559
+ installedErrorReporter,
560
+ installedFailureStore,
429
561
  isRedactedKey,
430
562
  jobs,
431
563
  LOG_LEVELS,
@@ -443,6 +575,7 @@ export {
443
575
  noopErrorReporter,
444
576
  noopExporter,
445
577
  noopMetricExporter,
578
+ OTLP_RETRY_STATUSES,
446
579
  OtlpEndpointInvalidError,
447
580
  OtlpHeadersInvalidError,
448
581
  OtlpProtocolUnsupportedError,
@@ -459,6 +592,8 @@ export {
459
592
  REDACTED,
460
593
  ratioSampler,
461
594
  recordConnection,
595
+ recordFailureFact,
596
+ recordFailureInput,
462
597
  recordJob,
463
598
  recordLeaseLost,
464
599
  recordQueueDepth,
@@ -468,6 +603,7 @@ export {
468
603
  requestDuration,
469
604
  requests,
470
605
  resetErrorReporting,
606
+ resetFailureCaptureWindows,
471
607
  resetMetrics,
472
608
  resetTelemetry,
473
609
  SCALING_METRICS,
@@ -475,13 +611,16 @@ export {
475
611
  sentryEnvelope,
476
612
  sentryErrorReporter,
477
613
  serviceResource,
614
+ setFailureStore,
478
615
  setLoggerContextFields,
479
616
  setLogStream,
617
+ settleFailureCaptures,
480
618
  startMetricExport,
481
619
  startSpan,
482
620
  structuredLogger,
483
621
  traceparent,
484
622
  tryOtlpEndpoint,
623
+ withFailureCapture,
485
624
  withSpan,
486
625
  withSpanContext,
487
626
  } from './exports/observability';
@@ -537,7 +676,9 @@ export {
537
676
  writeMasterKeyFile,
538
677
  writeSecretsFile,
539
678
  } from './exports/secrets';
540
- export { finiteCount, finiteOption } from './finite-option';
679
+ export type { FilledWire } from './fill-wire';
680
+ export { fillWire } from './fill-wire';
681
+ export { finiteCount, finiteOption, MAX_TIMER_MS } from './finite-option';
541
682
  export type {
542
683
  FlightGate,
543
684
  FlightGateLimits,
@@ -551,6 +692,9 @@ export type { GenerationFence } from './generation-fence';
551
692
  export { generationFence, isSuperseded } from './generation-fence';
552
693
  export type { PublicHealthBody } from './health-disclosure';
553
694
  export { DEFAULT_HEALTH_DETAIL_PEERS, healthBody, healthPeerListed } from './health-disclosure';
695
+ // The ONE keyed hash: every HMAC in the framework (`bun run keyed-hasher`).
696
+ export type { HmacAlgorithm, HmacEncoding, HmacInput } from './hmac';
697
+ export { hmac } from './hmac';
554
698
  export type { HostDecision, HostRule } from './host-rules';
555
699
  export { ANY_HOST, hostDecision, hostMatches } from './host-rules';
556
700
  export { escapeHtml } from './html-escape';
@@ -617,6 +761,7 @@ export {
617
761
  } from './intl-cache';
618
762
  export { isIsoDateTime } from './iso-date';
619
763
  export { isJsonObject } from './json-object';
764
+ export { jsonbRefusal } from './json-storable';
620
765
  export type { FingerprintMatch } from './keyed-fingerprint';
621
766
  export {
622
767
  compareFingerprint,
@@ -712,6 +857,7 @@ export {
712
857
  CLIENT_NAVIGATION_SURFACE_HEADER,
713
858
  CLIENT_PATH_STYLE_META,
714
859
  CLIENT_PERSIST_META,
860
+ CLIENT_PINS_META,
715
861
  CLIENT_SCOPE_HEADER,
716
862
  CLIENT_SCOPE_META,
717
863
  CLIENT_SYNC_META,
@@ -756,11 +902,22 @@ export {
756
902
  REQUEST_TIMEOUT_HEADER,
757
903
  remainingBudgetMs,
758
904
  } from './request-budget';
759
- export type { RetryDecision, RetryDeps, RetryPolicy, RetryStopReason } from './retry';
760
- export { retry, retryDecision } from './retry';
905
+ export type { RetryDeps } from './retry';
906
+ export { retry } from './retry';
907
+ export type { RetryDecision, RetryPolicy, RetryStopReason } from './retry-loop';
908
+ export { retryDecision } from './retry-loop';
761
909
  export { isRetryableStatus, RETRYABLE_STATUSES } from './retryable-status';
762
910
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
763
911
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
912
+ export { HttpPathInputConflictError, mergePathParams } from './route-path-merge';
913
+ export type { FilledTemplate, TemplateKeyNode } from './route-path-template';
914
+ export {
915
+ fillTemplate,
916
+ pathTemplateProblem,
917
+ templateKeyProblem,
918
+ templateParams,
919
+ toOpenApiPath,
920
+ } from './route-path-template';
764
921
  export { routeRank } from './route-rank';
765
922
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
766
923
  export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
@@ -791,6 +948,7 @@ export {
791
948
  resetServices,
792
949
  type ServiceFactory,
793
950
  } from './service';
951
+ export { onSessionLost, sessionIsLost } from './session-lost';
794
952
  export type { FlightJoin, Scheduler, SingleFlight, SingleFlightOptions } from './single-flight';
795
953
  export { singleFlight } from './single-flight';
796
954
  export { endOfLiteral, maskLiterals, QUOTES, stripComments } from './source-mask';
@@ -798,12 +956,17 @@ export type { StoreMode } from './store-mode';
798
956
  export { STORE_MODES, storeMode } from './store-mode';
799
957
  export { THEME_STORAGE_KEY } from './theme-storage';
800
958
  export { timingSafeEqual } from './timing-safe-equal';
959
+ // A projected tool's description: `@ultimat3/mcp`'s and `@ultimat3/ai`'s, one tier apart from neither.
960
+ export type { ToolDescriptionSource } from './tool-description';
961
+ export { toolDescription } from './tool-description';
801
962
  export {
802
963
  frameworkVersion,
803
964
  readPackageVersion,
804
965
  resolveVersion,
805
966
  VERSION_DEFINE,
806
967
  } from './version';
968
+ export type { VisibleIntervalOptions } from './visible-interval';
969
+ export { visibleInterval } from './visible-interval';
807
970
  // The webhook wire format, at the tier both halves can reach — `@ultimat3/jobs` signs a delivery
808
971
  // and `@ultimat3/http` verifies one, and neither may import the other. Same argument
809
972
  // `timing-safe-equal.ts` makes for itself, one line above.
@@ -0,0 +1,21 @@
1
+ // What a `jsonb` column refuses of text `JSON.stringify` accepts. Asked of the text, by whoever is
2
+ // about to store it, so a memory store and Postgres answer alike and the refusal lands before a
3
+ // statement does: only production meets `jsonb`, after the work behind the value has already run.
4
+
5
+ /** One escape at a time, so the second backslash of `\\u0000` is never read as opening a third. */
6
+ const ESCAPE = /\\(?:u(?:0000|d[89a-f][0-9a-f]{2})|[\s\S])/g;
7
+
8
+ /**
9
+ * Why `jsonb` would refuse this `JSON.stringify` output, or `undefined` when it would not. A pair
10
+ * of surrogates is written raw by `JSON.stringify`, so an escaped surrogate in its output is always
11
+ * a lone one. The reason names the class of thing, never the text, which can carry anything.
12
+ */
13
+ export function jsonbRefusal(text: string): string | undefined {
14
+ // Nearly every value holds no `\u` at all: the scan is paid only by one that does.
15
+ if (!text.includes('\\u')) return undefined;
16
+ for (const [token] of text.matchAll(ESCAPE)) {
17
+ if (token === '\\u0000') return 'a NUL character in a string, which jsonb has no form for';
18
+ if (token.startsWith('\\ud')) return 'a lone surrogate in a string, which jsonb refuses';
19
+ }
20
+ return undefined;
21
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { canonicalJson, fingerprint } from './canonical-json';
8
8
  import { currentSigningSecret } from './cursor';
9
+ import { hmac } from './hmac';
9
10
  import { timingSafeEqual } from './timing-safe-equal';
10
11
 
11
12
  /** The format tag. A stored value without it predates keying (see `compareFingerprint`). */
@@ -23,9 +24,7 @@ const LEGACY = /^[0-9a-f]{16}$/;
23
24
 
24
25
  /** One key per purpose, derived from the signing secret, so no two uses share a MAC key. */
25
26
  function derivedKey(purpose: string): string {
26
- return new Bun.CryptoHasher('sha256', currentSigningSecret())
27
- .update(`ultimate:keyed-fingerprint:${purpose}`)
28
- .digest('hex');
27
+ return hmac('sha256', currentSigningSecret(), `ultimate:keyed-fingerprint:${purpose}`, 'hex');
29
28
  }
30
29
 
31
30
  /**
@@ -43,10 +42,7 @@ function keyId(key: string): string {
43
42
  */
44
43
  export function keyedFingerprint(value: unknown, purpose: string): string {
45
44
  const key = derivedKey(purpose);
46
- const mac = new Bun.CryptoHasher('sha256', key)
47
- .update(canonicalJson(value))
48
- .digest('hex')
49
- .slice(0, 32);
45
+ const mac = hmac('sha256', key, canonicalJson(value), 'hex').slice(0, 32);
50
46
  return `${KEYED_FINGERPRINT_VERSION}:${keyId(key)}:${mac}`;
51
47
  }
52
48
 
package/src/lifecycle.ts CHANGED
@@ -280,7 +280,11 @@ function remainingBudget(reason: ShutdownReason): number {
280
280
  }
281
281
 
282
282
  async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<void> {
283
- for (const registration of registrations.filter((entry) => entry.phase === phase)) {
283
+ const hooks = registrations.filter((entry) => entry.phase === phase);
284
+ // Resources are released in reverse of acquisition, and only `close` releases any: an exporter
285
+ // registered at boot flushes after what it serves has ended its last span.
286
+ if (phase === 'close') hooks.reverse();
287
+ for (const registration of hooks) {
284
288
  const outcome = await settleWithin(() => registration.hook(reason), remainingBudget(reason));
285
289
  if (outcome.kind === 'failed') {
286
290
  report('error', 'shutdown hook failed', {
package/src/logger.ts CHANGED
@@ -361,7 +361,7 @@ function timestamp(clock: Clock): string {
361
361
  * `LOG_LEVEL`, and the default where there is no environment to read it from.
362
362
  *
363
363
  * A BROWSER has no `process` binding at all, and this read runs at MODULE INIT — `logger` at the
364
- * foot of this file is `structuredLogger()` evaluated when the module is. Measured on ai-maxxing's
364
+ * foot of this file is `structuredLogger()` evaluated when the module is. Measured on one adopter's
365
365
  * session console island: `@ultimat3/realtime`'s `channel.ts` calls `logger.warn`, so the shaker
366
366
  * keeps `logger`, and the island's chunk died on `ReferenceError: process is not defined` before a
367
367
  * line of the app's own code ran — the wrapper rendered `data-x-failed="process is not defined"`
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  assertFiniteOtlpBound,
15
15
  OTLP_SCOPE,
16
+ type OtlpPostOptions,
16
17
  otlpAttributes,
17
18
  otlpEndpoint,
18
19
  otlpHeaders,
@@ -100,6 +101,10 @@ export interface OtlpMetricExporterOptions {
100
101
  readonly timeoutMs?: number | undefined;
101
102
  /** Injected by tests; the preload seals the real one. */
102
103
  readonly fetch?: typeof globalThis.fetch | undefined;
104
+ /** The retry's wait, die and clock, passed to `postOtlp`. Injected by tests. */
105
+ readonly sleep?: OtlpPostOptions['sleep'];
106
+ readonly random?: OtlpPostOptions['random'];
107
+ readonly now?: OtlpPostOptions['now'];
103
108
  /**
104
109
  * Epoch ms this process's counters started at. Defaults to the first export, which is close
105
110
  * enough for a rate and exact for a restart detector; pass the boot time when you have it.
@@ -121,6 +126,7 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
121
126
  const headers = otlpHeaders(options.headers, process.env, 'metrics');
122
127
  const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
123
128
  const send = options.fetch ?? globalThis.fetch;
129
+ const seams = { sleep: options.sleep, random: options.random, now: options.now };
124
130
  let startedAtMs = options.startedAtMs;
125
131
  /** The ONE POST in flight, and the one body waiting behind it — never a chain. */
126
132
  let sending: Promise<void> | undefined;
@@ -138,7 +144,7 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
138
144
  const body = waiting;
139
145
  waiting = undefined;
140
146
  if (body === undefined) return Promise.resolve();
141
- sending = postOtlp({ url, headers, body, timeoutMs, fetch: send })
147
+ sending = postOtlp({ url, headers, body, timeoutMs, fetch: send, ...seams })
142
148
  .then(
143
149
  () => undefined,
144
150
  () => undefined,
@@ -0,0 +1,147 @@
1
+ // Single responsibility: deliver one OTLP/HTTP body, again when the collector says "not now". A
2
+ // rolling restart of a collector answers 503 to every pod at once, and a single-shot POST turned
3
+ // each of those answers into a lost window of traces on the surface an operator reads in an outage.
4
+
5
+ import type { Random } from './backoff';
6
+ import { retryAfterSecondsOf } from './client-retry-after';
7
+ import { systemClock } from './clock';
8
+ import { renderThrowable } from './error-render';
9
+ import { UltimateError } from './errors';
10
+ import { logger } from './logger';
11
+ import { type RetryPolicy, retryLoop } from './retry-loop';
12
+
13
+ /**
14
+ * The OTLP/HTTP specification's retryable answers, and only those. Not `isRetryableStatus`: that
15
+ * table answers "may a client repeat this call?", and a 500 or a 400 from a validating collector
16
+ * rejects the same batch identically on every attempt.
17
+ */
18
+ export const OTLP_RETRY_STATUSES: ReadonlySet<number> = new Set([429, 502, 503, 504]);
19
+
20
+ /** Core's curve, the same shape `outbound()` defaults to; `timeoutMs` adds the time budget. */
21
+ const OTLP_RETRY_POLICY: Omit<RetryPolicy, 'timeBudgetMs'> = Object.freeze({
22
+ attempts: 3,
23
+ base: 250,
24
+ max: 5_000,
25
+ jitter: 'full',
26
+ });
27
+
28
+ export interface OtlpPostOptions {
29
+ readonly url: string;
30
+ readonly headers: Readonly<Record<string, string>>;
31
+ readonly body: string;
32
+ /** The WHOLE export — every attempt and every wait between them — so a batch is held no longer. */
33
+ readonly timeoutMs: number;
34
+ readonly fetch: typeof globalThis.fetch;
35
+ /** Injected so a retry schedule is provable without waiting for one. Defaults to a timer. */
36
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
37
+ /** The jitter's die. Defaults to `Math.random`. */
38
+ readonly random?: Random | undefined;
39
+ /** Monotonic ms the budget is measured on. Defaults to the system clock. */
40
+ readonly now?: (() => number) | undefined;
41
+ }
42
+
43
+ /** How one export ended. Every one of them is a log line or silence, never a rejection. */
44
+ type Outcome =
45
+ | { readonly kind: 'delivered' }
46
+ | { readonly kind: 'rejected'; readonly status: number }
47
+ | { readonly kind: 'failed'; readonly error: unknown };
48
+
49
+ /**
50
+ * why: an `UltimateError` only so `retryDecision` can classify it and read the stated delay off
51
+ * `meta.retryAfterSeconds` — `classifyThrown` reads nothing but that brand. It is control flow, the
52
+ * same rule a job's suspension follows: thrown inside `retryLoop`, caught in `postOtlp`, and never
53
+ * seen by a caller or a log line. `X_DRAINING` is the existing core code that means exactly this
54
+ * answer (the responder is restarting or shedding, try again), so no code is minted for a value
55
+ * that cannot escape.
56
+ */
57
+ class OtlpRetryable extends UltimateError {
58
+ readonly outcome: Exclude<Outcome, { kind: 'delivered' }>;
59
+ constructor(outcome: Exclude<Outcome, { kind: 'delivered' }>, retryAfterSeconds?: number) {
60
+ super({
61
+ code: 'X_DRAINING',
62
+ cause: 'the OTLP collector asked for this batch again',
63
+ fix: 'postOtlp(request) # nothing to run: packages/core/src/otlp-post.ts sends the batch again itself',
64
+ retry: retryAfterSeconds === undefined ? 'retryable' : 'retry-after',
65
+ meta: retryAfterSeconds === undefined ? undefined : { retryAfterSeconds },
66
+ });
67
+ this.outcome = outcome;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * A REF'D wait: it holds the process open, because `timeoutMs` already bounds it and the drain
73
+ * deadline bounds a shutdown. Unref'd, a shutdown flush answered 503 exited mid-wait — the batch
74
+ * lost and its line never printed. The same shape as `retry.ts`'s `abortableTimer`.
75
+ */
76
+ const retryTimer = (ms: number): Promise<void> =>
77
+ new Promise<void>((done) => {
78
+ setTimeout(done, ms);
79
+ });
80
+
81
+ function logOutcome(url: string, outcome: Outcome, attempts: number): void {
82
+ if (outcome.kind === 'rejected') {
83
+ logger.warn('otlp export rejected', { url, status: outcome.status, attempts });
84
+ } else if (outcome.kind === 'failed') {
85
+ logger.warn('otlp export failed', {
86
+ url,
87
+ attempts,
88
+ // `renderThrowable`: the read that renders a caught value must not itself throw, or the
89
+ // export failure this function exists to swallow escapes as an unhandled rejection.
90
+ error: renderThrowable(outcome.error),
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Never throws and never rejects. Telemetry delivery is not the request: a collector that is down
97
+ * must not become the app's outage, and the alternative — an unhandled rejection from a `void`ed
98
+ * promise — takes the process with it on Bun. One line per batch, never per attempt, so a collector
99
+ * that stays down is not also a log flood.
100
+ *
101
+ * `retryLoop`, not `retry()`: `retry()` adopts the ambient request's signal and deadline, and a
102
+ * span batch flushed from inside a request would then die with that request. This send belongs to
103
+ * the process, and its one lifetime is `timeoutMs`.
104
+ */
105
+ export async function postOtlp(options: OtlpPostOptions): Promise<void> {
106
+ const now = options.now ?? ((): number => systemClock.monotonic());
107
+ const startedAt = now();
108
+ let attempts = 0;
109
+ const send = async (): Promise<Outcome> => {
110
+ attempts += 1;
111
+ const remaining = Math.max(1, Math.ceil(options.timeoutMs - (now() - startedAt)));
112
+ const signal = AbortSignal.timeout(remaining);
113
+ let response: Response;
114
+ try {
115
+ response = await options.fetch(options.url, {
116
+ method: 'POST',
117
+ headers: { ...options.headers },
118
+ body: options.body,
119
+ signal,
120
+ });
121
+ } catch (error) {
122
+ // The budget's own abort ends the export: what remained of `timeoutMs` is spent.
123
+ if (signal.aborted) return { kind: 'failed', error };
124
+ throw new OtlpRetryable({ kind: 'failed', error });
125
+ }
126
+ if (response.ok) return { kind: 'delivered' };
127
+ const rejected = { kind: 'rejected', status: response.status } as const;
128
+ if (!OTLP_RETRY_STATUSES.has(response.status)) return rejected;
129
+ const stated = retryAfterSecondsOf(
130
+ response.headers.get('retry-after'),
131
+ response.headers.get('date'),
132
+ );
133
+ throw new OtlpRetryable(rejected, stated);
134
+ };
135
+ let outcome: Outcome;
136
+ try {
137
+ outcome = await retryLoop(
138
+ send,
139
+ { ...OTLP_RETRY_POLICY, timeBudgetMs: options.timeoutMs },
140
+ { sleep: options.sleep ?? retryTimer, now, random: options.random },
141
+ );
142
+ } catch (failure) {
143
+ outcome =
144
+ failure instanceof OtlpRetryable ? failure.outcome : { kind: 'failed', error: failure };
145
+ }
146
+ logOutcome(options.url, outcome, attempts);
147
+ }