@beignet/core 0.0.36 → 0.0.38

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 (145) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +135 -19
  3. package/dist/client/client.d.ts.map +1 -1
  4. package/dist/client/client.js +6 -28
  5. package/dist/client/client.js.map +1 -1
  6. package/dist/contracts/contract-builder.d.ts +7 -0
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +22 -0
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/contracts/contract-group.d.ts +7 -0
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +14 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/index.d.ts +4 -0
  15. package/dist/contracts/index.d.ts.map +1 -1
  16. package/dist/contracts/index.js +4 -0
  17. package/dist/contracts/index.js.map +1 -1
  18. package/dist/contracts/lifecycle.d.ts +40 -0
  19. package/dist/contracts/lifecycle.d.ts.map +1 -0
  20. package/dist/contracts/lifecycle.js +174 -0
  21. package/dist/contracts/lifecycle.js.map +1 -0
  22. package/dist/contracts/types.d.ts +5 -0
  23. package/dist/contracts/types.d.ts.map +1 -1
  24. package/dist/contracts/types.js.map +1 -1
  25. package/dist/error-reporting/index.d.ts +42 -1
  26. package/dist/error-reporting/index.d.ts.map +1 -1
  27. package/dist/error-reporting/index.js +60 -0
  28. package/dist/error-reporting/index.js.map +1 -1
  29. package/dist/error-reporting/internal.d.ts +11 -0
  30. package/dist/error-reporting/internal.d.ts.map +1 -0
  31. package/dist/error-reporting/internal.js +36 -0
  32. package/dist/error-reporting/internal.js.map +1 -0
  33. package/dist/events/index.d.ts +9 -4
  34. package/dist/events/index.d.ts.map +1 -1
  35. package/dist/events/index.js +5 -3
  36. package/dist/events/index.js.map +1 -1
  37. package/dist/idempotency/index.d.ts +25 -2
  38. package/dist/idempotency/index.d.ts.map +1 -1
  39. package/dist/idempotency/index.js +35 -5
  40. package/dist/idempotency/index.js.map +1 -1
  41. package/dist/jobs/index.d.ts +26 -4
  42. package/dist/jobs/index.d.ts.map +1 -1
  43. package/dist/jobs/index.js +51 -6
  44. package/dist/jobs/index.js.map +1 -1
  45. package/dist/locks/index.d.ts +19 -1
  46. package/dist/locks/index.d.ts.map +1 -1
  47. package/dist/locks/index.js +27 -12
  48. package/dist/locks/index.js.map +1 -1
  49. package/dist/openapi/index.d.ts +3 -1
  50. package/dist/openapi/index.d.ts.map +1 -1
  51. package/dist/openapi/index.js +14 -2
  52. package/dist/openapi/index.js.map +1 -1
  53. package/dist/outbox/index.d.ts +23 -4
  54. package/dist/outbox/index.d.ts.map +1 -1
  55. package/dist/outbox/index.js +71 -23
  56. package/dist/outbox/index.js.map +1 -1
  57. package/dist/ports/events.d.ts +5 -5
  58. package/dist/ports/events.d.ts.map +1 -1
  59. package/dist/ports/index.d.ts +4 -4
  60. package/dist/ports/unbound.js +1 -1
  61. package/dist/ports/unbound.js.map +1 -1
  62. package/dist/ports/unit-of-work.d.ts +4 -1
  63. package/dist/ports/unit-of-work.d.ts.map +1 -1
  64. package/dist/ports/unit-of-work.js +4 -3
  65. package/dist/ports/unit-of-work.js.map +1 -1
  66. package/dist/query-codec.d.ts +3 -0
  67. package/dist/query-codec.d.ts.map +1 -0
  68. package/dist/query-codec.js +110 -0
  69. package/dist/query-codec.js.map +1 -0
  70. package/dist/server/hooks/cors.d.ts +3 -2
  71. package/dist/server/hooks/cors.d.ts.map +1 -1
  72. package/dist/server/hooks/cors.js +7 -3
  73. package/dist/server/hooks/cors.js.map +1 -1
  74. package/dist/server/hooks/error-reporting.d.ts +8 -0
  75. package/dist/server/hooks/error-reporting.d.ts.map +1 -1
  76. package/dist/server/hooks/error-reporting.js +55 -25
  77. package/dist/server/hooks/error-reporting.js.map +1 -1
  78. package/dist/server/hooks/idempotency.d.ts +1 -1
  79. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  80. package/dist/server/hooks/idempotency.js +30 -9
  81. package/dist/server/hooks/idempotency.js.map +1 -1
  82. package/dist/server/request-executor.d.ts.map +1 -1
  83. package/dist/server/request-executor.js +7 -12
  84. package/dist/server/request-executor.js.map +1 -1
  85. package/dist/server/request-preparation.d.ts.map +1 -1
  86. package/dist/server/request-preparation.js +3 -1
  87. package/dist/server/request-preparation.js.map +1 -1
  88. package/dist/server/response-finalization.d.ts +5 -1
  89. package/dist/server/response-finalization.d.ts.map +1 -1
  90. package/dist/server/response-finalization.js +66 -2
  91. package/dist/server/response-finalization.js.map +1 -1
  92. package/dist/server/server.d.ts.map +1 -1
  93. package/dist/server/server.js +10 -1
  94. package/dist/server/server.js.map +1 -1
  95. package/dist/testing/index.d.ts +5 -5
  96. package/dist/testing/index.d.ts.map +1 -1
  97. package/dist/testing/index.js +9 -4
  98. package/dist/testing/index.js.map +1 -1
  99. package/dist/tracing/index.d.ts +27 -0
  100. package/dist/tracing/index.d.ts.map +1 -1
  101. package/dist/tracing/index.js +96 -0
  102. package/dist/tracing/index.js.map +1 -1
  103. package/dist/uploads/index.d.ts.map +1 -1
  104. package/dist/uploads/index.js +39 -6
  105. package/dist/uploads/index.js.map +1 -1
  106. package/dist/webhooks/index.d.ts +2 -1
  107. package/dist/webhooks/index.d.ts.map +1 -1
  108. package/dist/webhooks/index.js +25 -1
  109. package/dist/webhooks/index.js.map +1 -1
  110. package/package.json +1 -1
  111. package/skills/app-architecture/SKILL.md +19 -1
  112. package/src/client/client.ts +6 -31
  113. package/src/contracts/contract-builder.ts +38 -0
  114. package/src/contracts/contract-group.ts +25 -0
  115. package/src/contracts/index.ts +8 -0
  116. package/src/contracts/lifecycle.ts +236 -0
  117. package/src/contracts/types.ts +5 -0
  118. package/src/error-reporting/index.ts +111 -0
  119. package/src/error-reporting/internal.ts +47 -0
  120. package/src/events/index.ts +20 -4
  121. package/src/idempotency/index.ts +81 -7
  122. package/src/jobs/index.ts +85 -6
  123. package/src/locks/index.ts +65 -16
  124. package/src/openapi/index.ts +22 -2
  125. package/src/outbox/index.ts +119 -27
  126. package/src/ports/events.ts +8 -1
  127. package/src/ports/index.ts +4 -4
  128. package/src/ports/unbound.ts +1 -1
  129. package/src/ports/unit-of-work.ts +10 -3
  130. package/src/query-codec.ts +130 -0
  131. package/src/server/hooks/cors.ts +10 -3
  132. package/src/server/hooks/error-reporting.ts +79 -23
  133. package/src/server/hooks/idempotency.ts +36 -10
  134. package/src/server/request-executor.ts +10 -13
  135. package/src/server/request-preparation.ts +4 -2
  136. package/src/server/response-finalization.ts +94 -5
  137. package/src/server/server.ts +15 -1
  138. package/src/testing/index.ts +25 -14
  139. package/src/tracing/index.ts +120 -0
  140. package/src/uploads/index.ts +44 -6
  141. package/src/webhooks/index.ts +34 -2
  142. package/dist/domain/events.d.ts +0 -44
  143. package/dist/domain/events.d.ts.map +0 -1
  144. package/dist/domain/events.js +0 -24
  145. package/dist/domain/events.js.map +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  type StandardSchemaV1,
13
13
  } from "../contracts/index.js";
14
14
  import { isErrorResponseBody, SchemaValidationError } from "../errors/index.js";
15
+ import { encodeQueryValue } from "../query-codec.js";
15
16
  import type {
16
17
  CallArgs,
17
18
  ClientConfig,
@@ -948,13 +949,7 @@ export class Endpoint<
948
949
  const normalizedQuery = queryToSerialize ?? query;
949
950
  for (const [key, value] of Object.entries(normalizedQuery)) {
950
951
  if (value !== undefined && value !== null) {
951
- if (Array.isArray(value)) {
952
- for (const v of value) {
953
- params.append(key, this.serializeQueryParam(key, v));
954
- }
955
- } else {
956
- params.append(key, this.serializeQueryParam(key, value));
957
- }
952
+ params.append(key, this.serializeQueryParam(key, value));
958
953
  }
959
954
  }
960
955
  const queryString = params.toString();
@@ -972,31 +967,11 @@ export class Endpoint<
972
967
  }
973
968
 
974
969
  private serializeQueryParam(key: string, value: unknown): string {
975
- if (
976
- typeof value === "string" ||
977
- typeof value === "number" ||
978
- typeof value === "boolean"
979
- ) {
980
- return String(value);
981
- }
982
-
983
- if (value instanceof Date) {
984
- if (!Number.isFinite(value.getTime())) {
985
- throw this.invalidQueryParam(key, "contains an invalid Date");
986
- }
987
- return value.toISOString();
988
- }
989
-
990
- if (typeof value === "object" && value !== null) {
991
- try {
992
- const serialized = JSON.stringify(value);
993
- if (serialized !== undefined) return serialized;
994
- } catch {
995
- // Report the contract/query key below instead of leaking JSON internals.
996
- }
970
+ try {
971
+ return encodeQueryValue(value);
972
+ } catch {
973
+ throw this.invalidQueryParam(key, "contains a non-serializable value");
997
974
  }
998
-
999
- throw this.invalidQueryParam(key, "contains a non-serializable value");
1000
975
  }
1001
976
 
1002
977
  private invalidQueryParam(key: string, reason: string): ContractError {
@@ -5,6 +5,11 @@ import {
5
5
  mergeCatalogErrors,
6
6
  responsesFromErrors,
7
7
  } from "./catalog-errors.js";
8
+ import {
9
+ assertValidContractDeprecation,
10
+ assertValidContractLifecycle,
11
+ type ContractDeprecationMeta,
12
+ } from "./lifecycle.js";
8
13
  import { mergeContractMeta } from "./metadata.js";
9
14
  import type { OpenAPIOperationMeta } from "./openapi-meta.js";
10
15
  import { parsePathTemplate } from "./path-template.js";
@@ -68,6 +73,7 @@ export class ContractBuilder<
68
73
  THeaders
69
74
  >,
70
75
  ) {
76
+ assertValidContractLifecycle(config);
71
77
  if (config.body && !methodSupportsRequestBody(config.method)) {
72
78
  throw new Error(
73
79
  `Request bodies are not supported for ${config.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
@@ -457,6 +463,38 @@ export class ContractBuilder<
457
463
  });
458
464
  }
459
465
 
466
+ /**
467
+ * Mark this contract as deprecated for external clients.
468
+ */
469
+ deprecated<const TDeprecation extends ContractDeprecationMeta>(
470
+ deprecation: TDeprecation,
471
+ ): ContractBuilder<
472
+ TMethod,
473
+ TPathParams,
474
+ TQuery,
475
+ TBody,
476
+ THeaders,
477
+ TResponses,
478
+ MergeContractMeta<TMeta, { deprecation: TDeprecation }>,
479
+ TPath
480
+ > {
481
+ assertValidContractDeprecation(deprecation, this.name);
482
+ return new ContractBuilder({
483
+ kind: this.kind,
484
+ name: this.name,
485
+ namespace: this.namespace,
486
+ localName: this.localName,
487
+ method: this.method,
488
+ path: this._path,
489
+ pathParams: this._pathParams,
490
+ query: this._query,
491
+ headers: this._headers,
492
+ body: this._body,
493
+ responses: this._responses,
494
+ metadata: mergeContractMeta(this._meta, { deprecation }),
495
+ });
496
+ }
497
+
460
498
  /**
461
499
  * Merge OpenAPI operation metadata into this contract.
462
500
  */
@@ -5,6 +5,10 @@ import {
5
5
  responsesFromErrors,
6
6
  } from "./catalog-errors.js";
7
7
  import { ContractBuilder } from "./contract-builder.js";
8
+ import {
9
+ assertValidContractDeprecation,
10
+ type ContractDeprecationMeta,
11
+ } from "./lifecycle.js";
8
12
  import { mergeContractMeta } from "./metadata.js";
9
13
  import { parsePathTemplate } from "./path-template.js";
10
14
  import type {
@@ -164,6 +168,27 @@ export class ContractGroup<
164
168
  });
165
169
  }
166
170
 
171
+ /**
172
+ * Mark every contract created from this group as deprecated.
173
+ */
174
+ deprecated<const TDeprecation extends ContractDeprecationMeta>(
175
+ deprecation: TDeprecation,
176
+ ): ContractGroup<
177
+ TSharedResponses,
178
+ MergeContractMeta<TSharedMeta, { deprecation: TDeprecation }>,
179
+ TSharedHeaders,
180
+ TPathPrefix
181
+ > {
182
+ assertValidContractDeprecation(deprecation, this._namespace || "group");
183
+ return new ContractGroup({
184
+ namespace: this._namespace,
185
+ meta: mergeContractMeta(this._meta, { deprecation }),
186
+ responses: this._responses,
187
+ headers: this._headers,
188
+ pathPrefix: this._pathPrefix,
189
+ });
190
+ }
191
+
167
192
  /**
168
193
  * Add shared route-owned response schemas to contracts created from this group.
169
194
  *
@@ -35,6 +35,14 @@ export {
35
35
  type ResolveContract,
36
36
  resolveContract,
37
37
  } from "./contract-like.js";
38
+ /**
39
+ * Contract lifecycle metadata and errors.
40
+ */
41
+ export {
42
+ type ContractDeprecationMeta,
43
+ ContractLifecycleError,
44
+ type ContractLifecycleFindingCode,
45
+ } from "./lifecycle.js";
38
46
  /**
39
47
  * OpenAPI operation metadata type.
40
48
  */
@@ -0,0 +1,236 @@
1
+ import type { HttpContractConfig } from "./types.js";
2
+
3
+ /**
4
+ * Lifecycle metadata for an HTTP contract that external clients should stop
5
+ * using.
6
+ */
7
+ export type ContractDeprecationMeta = {
8
+ /** UTC ISO 8601 timestamp when the contract became deprecated. */
9
+ since: string;
10
+ /** Optional human-readable explanation. */
11
+ reason?: string;
12
+ /** UTC ISO 8601 timestamp after which the contract may stop being served. */
13
+ sunset?: string;
14
+ /** URI reference for the preferred replacement operation. */
15
+ replacement?: string;
16
+ /** Absolute HTTP(S) URL with migration or deprecation documentation. */
17
+ documentation?: string;
18
+ };
19
+
20
+ /** Stable code identifying invalid lifecycle or operation metadata. */
21
+ export type ContractLifecycleFindingCode =
22
+ | "INVALID_DEPRECATION_SINCE"
23
+ | "INVALID_DEPRECATION_SUNSET"
24
+ | "DEPRECATION_SUNSET_BEFORE_SINCE"
25
+ | "INVALID_DEPRECATION_REASON"
26
+ | "INVALID_DEPRECATION_REPLACEMENT"
27
+ | "INVALID_DEPRECATION_DOCUMENTATION"
28
+ | "INVALID_OPERATION_ID";
29
+
30
+ /** Error raised when contract lifecycle metadata is malformed. */
31
+ export class ContractLifecycleError extends Error {
32
+ /** Stable machine-readable finding code. */
33
+ readonly code: ContractLifecycleFindingCode;
34
+ /** Name of the invalid contract, or the group label during group setup. */
35
+ readonly contract: string;
36
+
37
+ constructor(args: {
38
+ code: ContractLifecycleFindingCode;
39
+ contract: string;
40
+ message: string;
41
+ }) {
42
+ super(args.message);
43
+ this.name = "ContractLifecycleError";
44
+ this.code = args.code;
45
+ this.contract = args.contract;
46
+ }
47
+ }
48
+
49
+ const ISO_8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
50
+ const INVALID_URI_REFERENCE_CHARACTERS = /[\s<>"\\]/;
51
+ const INVALID_PERCENT_ENCODING = /%(?![0-9A-Fa-f]{2})/;
52
+
53
+ function parseUtcTimestamp(value: string): number | undefined {
54
+ if (!ISO_8601_UTC.test(value)) return undefined;
55
+ const timestamp = Date.parse(value);
56
+ if (!Number.isFinite(timestamp)) return undefined;
57
+
58
+ const canonical = new Date(timestamp).toISOString();
59
+ if (value !== canonical && value !== canonical.replace(".000Z", "Z")) {
60
+ return undefined;
61
+ }
62
+
63
+ return timestamp;
64
+ }
65
+
66
+ function assertNonEmptyOptionalString(args: {
67
+ value: unknown;
68
+ field: "reason" | "replacement";
69
+ code: ContractLifecycleFindingCode;
70
+ contract: string;
71
+ }): string | undefined {
72
+ if (args.value === undefined) return undefined;
73
+ if (typeof args.value !== "string" || args.value.trim().length === 0) {
74
+ throw new ContractLifecycleError({
75
+ code: args.code,
76
+ contract: args.contract,
77
+ message: `Contract "${args.contract}" deprecation ${args.field} must be a non-empty string.`,
78
+ });
79
+ }
80
+ return args.value;
81
+ }
82
+
83
+ /** Validate deprecation metadata supplied by builders or raw contract configs. */
84
+ export function assertValidContractDeprecation(
85
+ deprecation: unknown,
86
+ contract: string,
87
+ ): asserts deprecation is ContractDeprecationMeta {
88
+ if (typeof deprecation !== "object" || deprecation === null) {
89
+ throw new ContractLifecycleError({
90
+ code: "INVALID_DEPRECATION_SINCE",
91
+ contract,
92
+ message: `Contract "${contract}" deprecation metadata must include a valid "since" timestamp.`,
93
+ });
94
+ }
95
+
96
+ const metadata = deprecation as Record<string, unknown>;
97
+ const since =
98
+ typeof metadata.since === "string"
99
+ ? parseUtcTimestamp(metadata.since)
100
+ : undefined;
101
+ if (since === undefined) {
102
+ throw new ContractLifecycleError({
103
+ code: "INVALID_DEPRECATION_SINCE",
104
+ contract,
105
+ message: `Contract "${contract}" deprecation "since" must be a valid UTC ISO 8601 timestamp such as 2026-07-11T00:00:00Z.`,
106
+ });
107
+ }
108
+
109
+ let sunset: number | undefined;
110
+ if (metadata.sunset !== undefined) {
111
+ sunset =
112
+ typeof metadata.sunset === "string"
113
+ ? parseUtcTimestamp(metadata.sunset)
114
+ : undefined;
115
+ if (sunset === undefined) {
116
+ throw new ContractLifecycleError({
117
+ code: "INVALID_DEPRECATION_SUNSET",
118
+ contract,
119
+ message: `Contract "${contract}" deprecation "sunset" must be a valid UTC ISO 8601 timestamp.`,
120
+ });
121
+ }
122
+ if (sunset < since) {
123
+ throw new ContractLifecycleError({
124
+ code: "DEPRECATION_SUNSET_BEFORE_SINCE",
125
+ contract,
126
+ message: `Contract "${contract}" deprecation "sunset" must not be earlier than "since".`,
127
+ });
128
+ }
129
+ }
130
+
131
+ assertNonEmptyOptionalString({
132
+ value: metadata.reason,
133
+ field: "reason",
134
+ code: "INVALID_DEPRECATION_REASON",
135
+ contract,
136
+ });
137
+ const replacement = assertNonEmptyOptionalString({
138
+ value: metadata.replacement,
139
+ field: "replacement",
140
+ code: "INVALID_DEPRECATION_REPLACEMENT",
141
+ contract,
142
+ });
143
+ let replacementIsValid = true;
144
+ if (replacement) {
145
+ try {
146
+ new URL(replacement, "https://beignet.invalid");
147
+ } catch {
148
+ replacementIsValid = false;
149
+ }
150
+ }
151
+ if (
152
+ replacement &&
153
+ (!replacementIsValid ||
154
+ INVALID_URI_REFERENCE_CHARACTERS.test(replacement) ||
155
+ INVALID_PERCENT_ENCODING.test(replacement))
156
+ ) {
157
+ throw new ContractLifecycleError({
158
+ code: "INVALID_DEPRECATION_REPLACEMENT",
159
+ contract,
160
+ message: `Contract "${contract}" deprecation "replacement" must be a valid URI reference without whitespace.`,
161
+ });
162
+ }
163
+
164
+ if (metadata.documentation !== undefined) {
165
+ let documentation: URL | undefined;
166
+ if (typeof metadata.documentation === "string") {
167
+ try {
168
+ documentation = new URL(metadata.documentation);
169
+ } catch {
170
+ documentation = undefined;
171
+ }
172
+ }
173
+ if (
174
+ !documentation ||
175
+ (documentation.protocol !== "http:" &&
176
+ documentation.protocol !== "https:") ||
177
+ INVALID_URI_REFERENCE_CHARACTERS.test(metadata.documentation as string)
178
+ ) {
179
+ throw new ContractLifecycleError({
180
+ code: "INVALID_DEPRECATION_DOCUMENTATION",
181
+ contract,
182
+ message: `Contract "${contract}" deprecation "documentation" must be an absolute HTTP(S) URL.`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Return and validate the operation ID used by OpenAPI and route registries. */
189
+ export function getContractOperationId(
190
+ contract: Pick<HttpContractConfig, "name" | "metadata">,
191
+ ): string {
192
+ const operationId = contract.metadata.openapi?.operationId ?? contract.name;
193
+ if (
194
+ typeof operationId !== "string" ||
195
+ operationId.trim().length === 0 ||
196
+ operationId !== operationId.trim()
197
+ ) {
198
+ throw new ContractLifecycleError({
199
+ code: "INVALID_OPERATION_ID",
200
+ contract: contract.name,
201
+ message: `Contract "${contract.name}" operationId must be a non-empty string without surrounding whitespace.`,
202
+ });
203
+ }
204
+ return operationId;
205
+ }
206
+
207
+ /** Validate lifecycle metadata on a complete contract definition. */
208
+ export function assertValidContractLifecycle(
209
+ contract: Pick<HttpContractConfig, "name" | "metadata">,
210
+ ): void {
211
+ getContractOperationId(contract);
212
+ const deprecation = contract.metadata.deprecation;
213
+ if (deprecation !== undefined) {
214
+ assertValidContractDeprecation(deprecation, contract.name);
215
+ }
216
+ }
217
+
218
+ /** Build standards-based HTTP response headers for a deprecated contract. */
219
+ export function contractLifecycleResponseHeaders(
220
+ contract: Pick<HttpContractConfig, "name" | "metadata">,
221
+ ): Record<string, string> {
222
+ const deprecation = contract.metadata.deprecation;
223
+ if (deprecation === undefined) return {};
224
+ assertValidContractDeprecation(deprecation, contract.name);
225
+
226
+ const headers: Record<string, string> = {
227
+ Deprecation: `@${Math.floor(Date.parse(deprecation.since) / 1000)}`,
228
+ };
229
+ if (deprecation.sunset) {
230
+ headers.Sunset = new Date(deprecation.sunset).toUTCString();
231
+ }
232
+ if (deprecation.documentation) {
233
+ headers.Link = `<${deprecation.documentation}>; rel="deprecation"`;
234
+ }
235
+ return headers;
236
+ }
@@ -1,5 +1,6 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { IdempotencyMeta } from "../idempotency/index.js";
3
+ import type { ContractDeprecationMeta } from "./lifecycle.js";
3
4
  import type { OpenAPIOperationMeta } from "./openapi-meta.js";
4
5
  import type { RateLimitMeta } from "./rate-limit.js";
5
6
 
@@ -228,6 +229,10 @@ export type ContractMeta = {
228
229
  * OpenAPI operation metadata.
229
230
  */
230
231
  openapi?: OpenAPIOperationMeta;
232
+ /**
233
+ * External API lifecycle metadata for a deprecated contract.
234
+ */
235
+ deprecation?: ContractDeprecationMeta;
231
236
  /**
232
237
  * Optional rate limit configuration for this contract.
233
238
  *
@@ -4,6 +4,18 @@
4
4
  * Provider-neutral error reporting primitives for Beignet applications.
5
5
  */
6
6
 
7
+ import { type RedactionOptions, redactValue } from "../ports/redaction.js";
8
+ import {
9
+ DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
10
+ errorReportingObserverTimeout,
11
+ runErrorReportingOperation,
12
+ } from "./internal.js";
13
+
14
+ export {
15
+ DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
16
+ ErrorReportingTimeoutError,
17
+ } from "./internal.js";
18
+
7
19
  type MaybePromise<T> = T | Promise<T>;
8
20
 
9
21
  /**
@@ -67,6 +79,33 @@ export type ErrorReportOptions = {
67
79
  traceparent?: string;
68
80
  };
69
81
 
82
+ /**
83
+ * Arguments accepted by `tryReportException(...)`.
84
+ */
85
+ export interface TryReportExceptionOptions {
86
+ /** Reporter that owns the capture. Omit it to make reporting a no-op. */
87
+ reporter?: ErrorReporterPort | ErrorReporterResolver;
88
+ /** Original application or infrastructure error. */
89
+ error: unknown;
90
+ /** Structured metadata attached to the report. */
91
+ reportOptions?: ErrorReportOptions;
92
+ /**
93
+ * Maximum time allowed for capture and, separately, the failure observer.
94
+ * Set to `false` only when the reporting implementation is intentionally
95
+ * unbounded.
96
+ *
97
+ * @default 1000
98
+ */
99
+ timeoutMs?: number | false;
100
+ /**
101
+ * Observer for reporter failures. Observer failures are also isolated.
102
+ */
103
+ onReporterError?: (args: {
104
+ error: unknown;
105
+ reportingError: unknown;
106
+ }) => MaybePromise<void>;
107
+ }
108
+
70
109
  /**
71
110
  * Result returned by a reporting provider after capture.
72
111
  */
@@ -102,6 +141,11 @@ export type ErrorReporterPort = {
102
141
  flush(options?: ErrorReporterFlushOptions): Promise<boolean>;
103
142
  };
104
143
 
144
+ /** Lazy reporter resolver evaluated inside the best-effort capture deadline. */
145
+ export type ErrorReporterResolver = () => MaybePromise<
146
+ ErrorReporterPort | undefined
147
+ >;
148
+
105
149
  /**
106
150
  * Captured exception stored by `createMemoryErrorReporter(...)`.
107
151
  */
@@ -252,6 +296,73 @@ export function reportMessage(
252
296
  return reporter.captureMessage(message, options);
253
297
  }
254
298
 
299
+ /**
300
+ * Best-effort exception capture for runtime boundaries.
301
+ *
302
+ * Missing reporters, reporter failures, and reporter-failure observer errors
303
+ * resolve to `undefined` so diagnostics cannot replace application behavior.
304
+ */
305
+ export async function tryReportException(
306
+ options: TryReportExceptionOptions,
307
+ ): Promise<ErrorReportResult | undefined> {
308
+ const reporterSource = options.reporter;
309
+ if (!reporterSource) return undefined;
310
+
311
+ const timeoutMs = options.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
312
+
313
+ try {
314
+ return await runErrorReportingOperation(async () => {
315
+ const reporter =
316
+ typeof reporterSource === "function"
317
+ ? await reporterSource()
318
+ : reporterSource;
319
+ if (!reporter) return undefined;
320
+ return reporter.captureException(options.error, options.reportOptions);
321
+ }, timeoutMs);
322
+ } catch (reportingError) {
323
+ try {
324
+ if (options.onReporterError) {
325
+ await runErrorReportingOperation(
326
+ () =>
327
+ options.onReporterError?.({
328
+ error: options.error,
329
+ reportingError,
330
+ }),
331
+ errorReportingObserverTimeout(timeoutMs),
332
+ );
333
+ }
334
+ } catch {
335
+ // Reporter failure observers must not replace application behavior.
336
+ }
337
+ return undefined;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Redact structured error-report metadata with Beignet's shared sensitive-key
343
+ * rules. The original exception is intentionally not part of this operation.
344
+ */
345
+ export function redactErrorReportOptions(
346
+ options: ErrorReportOptions,
347
+ redactionOptions: RedactionOptions = {},
348
+ ): ErrorReportOptions {
349
+ return {
350
+ ...options,
351
+ user: options.user
352
+ ? redactValue(options.user, redactionOptions)
353
+ : options.user,
354
+ tags: options.tags
355
+ ? redactValue(options.tags, redactionOptions)
356
+ : options.tags,
357
+ contexts: options.contexts
358
+ ? redactValue(options.contexts, redactionOptions)
359
+ : options.contexts,
360
+ extra: options.extra
361
+ ? redactValue(options.extra, redactionOptions)
362
+ : options.extra,
363
+ };
364
+ }
365
+
255
366
  function withAmbientState(
256
367
  port: MemoryErrorReporterPort,
257
368
  options: ErrorReportOptions | undefined,
@@ -0,0 +1,47 @@
1
+ type MaybePromise<T> = T | Promise<T>;
2
+
3
+ export const DEFAULT_ERROR_REPORTING_TIMEOUT_MS = 1000;
4
+
5
+ export class ErrorReportingTimeoutError extends Error {
6
+ readonly code = "ERROR_REPORTING_TIMEOUT" as const;
7
+ readonly timeoutMs: number;
8
+
9
+ constructor(timeoutMs: number) {
10
+ super(`Error reporting timed out after ${timeoutMs}ms.`);
11
+ this.name = "ErrorReportingTimeoutError";
12
+ this.timeoutMs = timeoutMs;
13
+ }
14
+ }
15
+
16
+ export function errorReportingObserverTimeout(
17
+ timeoutMs: number | false,
18
+ ): number | false {
19
+ return timeoutMs === false || (Number.isFinite(timeoutMs) && timeoutMs > 0)
20
+ ? timeoutMs
21
+ : DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
22
+ }
23
+
24
+ export async function runErrorReportingOperation<T>(
25
+ operation: () => MaybePromise<T>,
26
+ timeoutMs: number | false = DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
27
+ ): Promise<T> {
28
+ if (timeoutMs === false) return operation();
29
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30
+ throw new TypeError("Error reporting timeoutMs must be a positive number.");
31
+ }
32
+
33
+ let timeout: ReturnType<typeof setTimeout> | undefined;
34
+ try {
35
+ return await Promise.race([
36
+ Promise.resolve().then(operation),
37
+ new Promise<never>((_, reject) => {
38
+ timeout = setTimeout(
39
+ () => reject(new ErrorReportingTimeoutError(timeoutMs)),
40
+ timeoutMs,
41
+ );
42
+ }),
43
+ ]);
44
+ } finally {
45
+ if (timeout !== undefined) clearTimeout(timeout);
46
+ }
47
+ }
@@ -1,6 +1,10 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import { runWithResolvedTracingContext } from "../tracing/execution.js";
3
- import type { TracingPort } from "../tracing/index.js";
3
+ import {
4
+ parseTraceCarrier,
5
+ type TraceCarrier,
6
+ type TracingPort,
7
+ } from "../tracing/index.js";
4
8
 
5
9
  /**
6
10
  * Any Standard Schema compatible validator.
@@ -58,6 +62,12 @@ export interface EventDef<
58
62
  export type InferEventPayload<E extends EventPayloadDef> =
59
63
  E["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
60
64
 
65
+ /** Metadata propagated with an event delivery. */
66
+ export interface EventPublishOptions {
67
+ /** Versioned trace context captured by the event producer. */
68
+ trace?: TraceCarrier;
69
+ }
70
+
61
71
  /**
62
72
  * Options for `defineEvent(...)`.
63
73
  */
@@ -140,13 +150,17 @@ export interface EventBusLike {
140
150
  publish<E extends EventPayloadDef>(
141
151
  event: E,
142
152
  payload: InferEventPayload<E>,
153
+ options?: EventPublishOptions,
143
154
  ): MaybePromise<void>;
144
155
  /**
145
156
  * Subscribe to an event and return an unsubscribe function.
146
157
  */
147
158
  subscribe<E extends EventPayloadDef>(
148
159
  event: E,
149
- handler: (payload: InferEventPayload<E>) => MaybePromise<void>,
160
+ handler: (
161
+ payload: InferEventPayload<E>,
162
+ options?: EventPublishOptions,
163
+ ) => MaybePromise<void>,
150
164
  ): () => void;
151
165
  }
152
166
 
@@ -299,9 +313,10 @@ export async function publishEvent<E extends EventPayloadDef>(
299
313
  eventBus: EventBusLike,
300
314
  event: E,
301
315
  payload: InferEventPayload<E>,
316
+ options?: EventPublishOptions,
302
317
  ): Promise<void> {
303
318
  const parsed = await parseEventPayload(event, payload);
304
- await eventBus.publish(event, parsed);
319
+ await eventBus.publish(event, parsed, options);
305
320
  }
306
321
 
307
322
  /**
@@ -316,7 +331,7 @@ export function registerListeners<Ctx>(
316
331
  options: RegisterListenersOptions<Ctx> = {},
317
332
  ): () => void {
318
333
  const unsubscribes = listeners.map((listener) =>
319
- eventBus.subscribe(listener.event, async (rawPayload) => {
334
+ eventBus.subscribe(listener.event, async (rawPayload, publishOptions) => {
320
335
  try {
321
336
  const payload = await parseEventPayload(listener.event, rawPayload);
322
337
  const traceAttributes = {
@@ -330,6 +345,7 @@ export function registerListeners<Ctx>(
330
345
  name: `beignet.listener ${listener.name}`,
331
346
  type: "listener",
332
347
  kind: "consumer",
348
+ parent: parseTraceCarrier(publishOptions?.trace),
333
349
  attributes: traceAttributes,
334
350
  metricAttributes: traceAttributes,
335
351
  },