@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
@@ -7,6 +7,14 @@ import type {
7
7
  ErrorReporterPort,
8
8
  ErrorReportOptions,
9
9
  } from "../../error-reporting/index.js";
10
+ import {
11
+ DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
12
+ tryReportException,
13
+ } from "../../error-reporting/index.js";
14
+ import {
15
+ errorReportingObserverTimeout,
16
+ runErrorReportingOperation,
17
+ } from "../../error-reporting/internal.js";
10
18
  import { isAppError, SchemaValidationError } from "../../errors/index.js";
11
19
  import {
12
20
  IdempotencyConflictError,
@@ -81,6 +89,14 @@ export interface ErrorReportingHooksOptions<Ctx> {
81
89
  reportOptions?:
82
90
  | ErrorReportOptions
83
91
  | ((args: ErrorReportingHookArgs<Ctx>) => MaybePromise<ErrorReportOptions>);
92
+ /**
93
+ * Maximum time allowed for reporting preparation, capture, and failure
94
+ * observation. Each phase is bounded independently. Set to `false` only for
95
+ * intentionally unbounded reporters.
96
+ *
97
+ * @default 1000
98
+ */
99
+ timeoutMs?: number | false;
84
100
  /**
85
101
  * Observer for reporter failures. Reporter failures are otherwise ignored so
86
102
  * error reporting cannot change the HTTP response.
@@ -107,42 +123,82 @@ export function createErrorReportingHooks<
107
123
  return {
108
124
  name: "error-reporting",
109
125
  async onCaughtError(args) {
110
- const shouldReport = config.shouldReport
111
- ? await config.shouldReport(args)
112
- : shouldReportServerError(args.err);
113
- if (!shouldReport) return;
126
+ const timeoutMs = config.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
127
+ let prepared:
128
+ | { reporter: ErrorReporterPort; reportOptions: ErrorReportOptions }
129
+ | undefined;
114
130
 
115
- const reporter = await resolveReporter(config.reporter, args);
116
- if (!reporter) return;
131
+ try {
132
+ prepared = await runErrorReportingOperation(async () => {
133
+ const shouldReport = config.shouldReport
134
+ ? await config.shouldReport(args)
135
+ : shouldReportServerError(args.err);
136
+ if (!shouldReport) return undefined;
117
137
 
118
- const baseOptions = createDefaultReportOptions(args);
119
- const customOptions =
120
- typeof config.reportOptions === "function"
121
- ? await config.reportOptions(args)
122
- : config.reportOptions;
138
+ const reporter = await resolveReporter(config.reporter, args);
139
+ if (!reporter) return undefined;
123
140
 
124
- try {
125
- await reporter.captureException(
126
- args.err,
127
- mergeReportOptions(baseOptions, customOptions),
128
- );
141
+ const baseOptions = createDefaultReportOptions(args);
142
+ const customOptions =
143
+ typeof config.reportOptions === "function"
144
+ ? await config.reportOptions(args)
145
+ : config.reportOptions;
146
+
147
+ return {
148
+ reporter,
149
+ reportOptions: mergeReportOptions(baseOptions, customOptions),
150
+ };
151
+ }, timeoutMs);
129
152
  } catch (reportingError) {
130
- try {
131
- await config.onReporterError?.({
153
+ await notifyReporterError(config, args, reportingError, timeoutMs);
154
+ return;
155
+ }
156
+
157
+ if (!prepared) return;
158
+
159
+ await tryReportException({
160
+ reporter: prepared.reporter,
161
+ error: args.err,
162
+ reportOptions: prepared.reportOptions,
163
+ timeoutMs,
164
+ onReporterError: ({ reportingError }) =>
165
+ config.onReporterError?.({
132
166
  error: args.err,
133
167
  reportingError,
134
168
  ctx: args.ctx,
135
169
  req: args.req,
136
170
  contract: args.contract,
137
- });
138
- } catch {
139
- // Reporter failure observers must not affect request handling.
140
- }
141
- }
171
+ }),
172
+ });
142
173
  },
143
174
  };
144
175
  }
145
176
 
177
+ async function notifyReporterError<Ctx>(
178
+ config: ErrorReportingHooksOptions<Ctx>,
179
+ args: ErrorReportingHookArgs<Ctx>,
180
+ reportingError: unknown,
181
+ timeoutMs: number | false,
182
+ ): Promise<void> {
183
+ if (!config.onReporterError) return;
184
+
185
+ try {
186
+ await runErrorReportingOperation(
187
+ () =>
188
+ config.onReporterError?.({
189
+ error: args.err,
190
+ reportingError,
191
+ ctx: args.ctx,
192
+ req: args.req,
193
+ contract: args.contract,
194
+ }),
195
+ errorReportingObserverTimeout(timeoutMs),
196
+ );
197
+ } catch {
198
+ // Reporting observers must not affect request handling.
199
+ }
200
+ }
201
+
146
202
  /**
147
203
  * Default filter for HTTP error reporting.
148
204
  */
@@ -11,11 +11,17 @@ import {
11
11
  type IdempotencyPort,
12
12
  type IdempotencyScope,
13
13
  } from "../../idempotency/index.js";
14
- import type { ActivityActor, ActivityTenant } from "../../ports/index.js";
14
+ import {
15
+ type ActivityActor,
16
+ type ActivityTenant,
17
+ AuthUnauthorizedError,
18
+ TenantRequiredError,
19
+ } from "../../ports/index.js";
15
20
  import {
16
21
  type ResponseFinalizerServerHook,
17
22
  responseFinalizerHook,
18
23
  } from "../internal-hooks.js";
24
+ import { finalizeResponse } from "../response-finalization.js";
19
25
  import type {
20
26
  HttpRequestLike,
21
27
  HttpResponseLike,
@@ -84,6 +90,7 @@ type PendingReservation = {
84
90
  key: string;
85
91
  scope: IdempotencyScope;
86
92
  fingerprint: string;
93
+ reservationToken: string;
87
94
  };
88
95
 
89
96
  function defaultIdempotencyScope(
@@ -96,11 +103,15 @@ function defaultIdempotencyScope(
96
103
  case "global":
97
104
  return "global";
98
105
  case "actor":
99
- return { actorId: ctx.actor?.id };
106
+ if (!ctx.actor?.id) throw new AuthUnauthorizedError();
107
+ return { actorId: ctx.actor.id };
100
108
  case "tenant":
101
- return { tenantId: ctx.tenant?.id };
109
+ if (!ctx.tenant?.id) throw new TenantRequiredError();
110
+ return { tenantId: ctx.tenant.id };
102
111
  case "actor-tenant":
103
- return { actorId: ctx.actor?.id, tenantId: ctx.tenant?.id };
112
+ if (!ctx.actor?.id) throw new AuthUnauthorizedError();
113
+ if (!ctx.tenant?.id) throw new TenantRequiredError();
114
+ return { actorId: ctx.actor.id, tenantId: ctx.tenant.id };
104
115
  }
105
116
  }
106
117
 
@@ -193,6 +204,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
193
204
  scope,
194
205
  fingerprint,
195
206
  ttlSec: meta.ttlSec,
207
+ reservationTtlSec: meta.reservationTtlSec,
196
208
  });
197
209
 
198
210
  switch (reservation.status) {
@@ -205,13 +217,17 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
205
217
  );
206
218
  }
207
219
 
220
+ const validated = await finalizeResponse(
221
+ contract,
222
+ reservation.result,
223
+ );
208
224
  return {
209
- status: reservation.result.status,
225
+ status: validated.status,
210
226
  headers: {
211
- ...(reservation.result.headers ?? {}),
227
+ ...(validated.headers ?? {}),
212
228
  [IDEMPOTENCY_REPLAYED_HEADER]: "true",
213
229
  },
214
- body: reservation.result.body,
230
+ body: validated.body,
215
231
  };
216
232
  }
217
233
  case "inProgress": {
@@ -227,6 +243,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
227
243
  key,
228
244
  scope,
229
245
  fingerprint,
246
+ reservationToken: reservation.reservationToken,
230
247
  });
231
248
  return undefined;
232
249
  }
@@ -246,11 +263,12 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
246
263
  }
247
264
  pending.delete(req);
248
265
 
249
- const { port, namespace, key, scope, fingerprint } = reservation;
266
+ const { port, namespace, key, scope, fingerprint, reservationToken } =
267
+ reservation;
250
268
 
251
269
  if (
252
270
  owner === "route" &&
253
- responseValidation !== "not-applicable" &&
271
+ responseValidation === "validated" &&
254
272
  !native &&
255
273
  !error &&
256
274
  response.status >= 200 &&
@@ -261,6 +279,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
261
279
  key,
262
280
  scope,
263
281
  fingerprint,
282
+ reservationToken,
264
283
  result: {
265
284
  status: response.status,
266
285
  headers: response.headers,
@@ -272,7 +291,14 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
272
291
 
273
292
  // Framework-owned responses, errors, non-2xx responses, and native
274
293
  // `Response` results release the reservation. Streams are not replayable.
275
- await port.fail({ namespace, key, scope, fingerprint, error });
294
+ await port.fail({
295
+ namespace,
296
+ key,
297
+ scope,
298
+ fingerprint,
299
+ reservationToken,
300
+ error,
301
+ });
276
302
  return undefined;
277
303
  },
278
304
  };
@@ -72,6 +72,7 @@ import {
72
72
  responseForHooks,
73
73
  responseOwnerFor,
74
74
  toContractViolationResponse,
75
+ withContractLifecycleHeaders,
75
76
  withFrameworkErrorOwnerHeader,
76
77
  } from "./response-finalization.js";
77
78
  import {
@@ -888,28 +889,20 @@ export function createRequestExecutor<
888
889
  let finalOwner = responseOwnerFor(finalResponse, result.owner);
889
890
  let responseValidation: ResponseFinalizerValidationState =
890
891
  "not-applicable";
891
- if (
892
- finalOwner === "route" &&
893
- !isWebResponse(finalResponse) &&
894
- !(options.validateResponses ?? true)
895
- ) {
896
- responseValidation = "disabled";
897
- } else if (
898
- finalOwner === "route" &&
899
- !isWebResponse(finalResponse) &&
900
- (options.validateResponses ?? true)
901
- ) {
892
+ if (finalOwner === "route" && !isWebResponse(finalResponse)) {
893
+ const validateContract = options.validateResponses ?? true;
902
894
  try {
903
895
  finalResponse = await finalizeResponse(
904
896
  contract,
905
897
  finalResponse,
906
898
  target.responseValidationExemptStatus,
899
+ { validateContract },
907
900
  );
908
901
  result = {
909
902
  ...result,
910
903
  response: finalResponse,
911
904
  };
912
- responseValidation = "validated";
905
+ responseValidation = validateContract ? "validated" : "disabled";
913
906
  } catch (error) {
914
907
  if (error instanceof ResponseContractViolationError) {
915
908
  result = {
@@ -947,6 +940,7 @@ export function createRequestExecutor<
947
940
  finalOwner,
948
941
  );
949
942
  }
943
+ finalResponse = withContractLifecycleHeaders(finalResponse, contract);
950
944
  finalResponse = withoutHeadResponseBody(finalResponse, req.method);
951
945
  stages.sendMs = performance.now() - sendStartedAt;
952
946
 
@@ -987,7 +981,10 @@ export function createRequestExecutor<
987
981
  },
988
982
  );
989
983
  const response = withoutHeadResponseBody(
990
- normalizeHttpResponse(result.response),
984
+ withContractLifecycleHeaders(
985
+ normalizeHttpResponse(result.response),
986
+ contract,
987
+ ),
991
988
  req.method,
992
989
  );
993
990
  if (isWebResponse(response)) {
@@ -4,6 +4,7 @@ import {
4
4
  methodSupportsRequestBody,
5
5
  type StandardSchema,
6
6
  } from "../contracts/index.js";
7
+ import { decodeQueryValue } from "../query-codec.js";
7
8
  import type { HttpRequestLike, HttpResponseLike } from "./http.js";
8
9
  import {
9
10
  parseStandardSchema,
@@ -272,10 +273,11 @@ export async function prepareRequestInputs(args: {
272
273
  maxRequestBodyBytes,
273
274
  rawRoute,
274
275
  } = args;
275
- const rawQuery: Record<string, string | string[]> = {};
276
+ const rawQuery: Record<string, unknown> = {};
276
277
  for (const key of new Set(url.searchParams.keys())) {
277
278
  const values = url.searchParams.getAll(key);
278
- rawQuery[key] = values.length === 1 ? values[0] : values;
279
+ const decoded = values.map(decodeQueryValue);
280
+ rawQuery[key] = decoded.length === 1 ? decoded[0] : decoded;
279
281
  }
280
282
 
281
283
  let query: unknown = rawQuery;
@@ -3,6 +3,7 @@ import {
3
3
  type ContractErrorDefinition,
4
4
  type HttpContractConfig,
5
5
  } from "../contracts/index.js";
6
+ import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js";
6
7
  import {
7
8
  createErrorResponseBody,
8
9
  isErrorResponseBody,
@@ -74,6 +75,63 @@ export function withFrameworkErrorOwnerHeader(
74
75
  };
75
76
  }
76
77
 
78
+ function setRecordHeader(
79
+ headers: Record<string, string>,
80
+ name: string,
81
+ value: string,
82
+ ): void {
83
+ const existingName = Object.keys(headers).find(
84
+ (key) => key.toLowerCase() === name.toLowerCase(),
85
+ );
86
+ if (existingName && existingName !== name) {
87
+ delete headers[existingName];
88
+ }
89
+ headers[name] = value;
90
+ }
91
+
92
+ /** Apply contract-owned deprecation headers to any response representation. */
93
+ export function withContractLifecycleHeaders(
94
+ res: HttpResponse,
95
+ contract: HttpContractConfig,
96
+ ): HttpResponse {
97
+ const lifecycleHeaders = contractLifecycleResponseHeaders(contract);
98
+ if (Object.keys(lifecycleHeaders).length === 0) return res;
99
+
100
+ if (isWebResponse(res)) {
101
+ const headers = new Headers(res.headers);
102
+ for (const [name, value] of Object.entries(lifecycleHeaders)) {
103
+ if (name.toLowerCase() === "link" && headers.has(name)) {
104
+ headers.append(name, value);
105
+ } else {
106
+ headers.set(name, value);
107
+ }
108
+ }
109
+ return new Response(res.body, {
110
+ status: res.status,
111
+ statusText: res.statusText,
112
+ headers,
113
+ });
114
+ }
115
+
116
+ const headers = { ...(res.headers ?? {}) };
117
+ for (const [name, value] of Object.entries(lifecycleHeaders)) {
118
+ if (name.toLowerCase() === "link") {
119
+ const existingName = Object.keys(headers).find(
120
+ (key) => key.toLowerCase() === "link",
121
+ );
122
+ const existing = existingName ? headers[existingName] : undefined;
123
+ setRecordHeader(
124
+ headers,
125
+ name,
126
+ existing ? `${existing}, ${value}` : value,
127
+ );
128
+ } else {
129
+ setRecordHeader(headers, name, value);
130
+ }
131
+ }
132
+ return { ...res, headers };
133
+ }
134
+
77
135
  export function responseOwnerFor(
78
136
  res: HttpResponse,
79
137
  owner?: ResponseOwner,
@@ -333,17 +391,48 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
333
391
  }
334
392
  }
335
393
 
394
+ const BODYLESS_RESPONSE_STATUSES = new Set([204, 205, 304]);
395
+
396
+ function validateHttpResponseSemantics(
397
+ contract: HttpContractConfig,
398
+ res: HttpResponseLike,
399
+ ): void {
400
+ if (
401
+ !BODYLESS_RESPONSE_STATUSES.has(res.status) ||
402
+ res.body === undefined ||
403
+ res.body === null
404
+ ) {
405
+ return;
406
+ }
407
+
408
+ throw new ResponseContractViolationError({
409
+ code: "RESPONSE_VALIDATION_ERROR",
410
+ message: responseContractViolationMessage(contract, res.status),
411
+ details: responseContractViolationDetails(contract, res.status, {
412
+ issues: [
413
+ {
414
+ message: `HTTP status ${res.status} must not include a response body.`,
415
+ },
416
+ ],
417
+ }),
418
+ });
419
+ }
420
+
336
421
  export async function finalizeResponse<C extends HttpContractConfig>(
337
422
  contract: C,
338
423
  res: HttpResponseLike,
339
424
  responseValidationExemptStatus?: number,
425
+ options: { validateContract?: boolean } = {},
340
426
  ): Promise<HttpResponseLike> {
341
427
  const normalized = normalizeResponse(res);
342
- await validateResponseAgainstContract(
343
- contract,
344
- normalized,
345
- responseValidationExemptStatus,
346
- );
428
+ validateHttpResponseSemantics(contract, normalized);
429
+ if (options.validateContract ?? true) {
430
+ await validateResponseAgainstContract(
431
+ contract,
432
+ normalized,
433
+ responseValidationExemptStatus,
434
+ );
435
+ }
347
436
  return normalized;
348
437
  }
349
438
 
@@ -3,6 +3,10 @@ import {
3
3
  type HttpContractConfig,
4
4
  methodSupportsRequestBody,
5
5
  } from "../contracts/index.js";
6
+ import {
7
+ assertValidContractLifecycle,
8
+ getContractOperationId,
9
+ } from "../contracts/lifecycle.js";
6
10
  import {
7
11
  comparePathParamsToTemplate,
8
12
  formatPathParamsMismatch,
@@ -539,6 +543,7 @@ export async function createServer<
539
543
  const registeredPaths = new Set<string>();
540
544
  const registeredShapes = new Map<string, string>();
541
545
  const registeredNames = new Map<string, string>();
546
+ const registeredOperationIds = new Map<string, string>();
542
547
 
543
548
  const registerRoute = <C extends HttpContractConfig>(
544
549
  contract: C,
@@ -546,6 +551,7 @@ export async function createServer<
546
551
  routeHooks: readonly RouteHook<unknown, object>[] = [],
547
552
  responseValidationExemptStatus?: number,
548
553
  ): void => {
554
+ assertValidContractLifecycle(contract);
549
555
  if (contract.body && !methodSupportsRequestBody(contract.method)) {
550
556
  throw new Error(
551
557
  `Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
@@ -572,6 +578,13 @@ export async function createServer<
572
578
  `Duplicate contract name: "${contract.name}" is registered for both ${conflictingName} and ${routeKey}. Contract names must be unique because typed clients, OpenAPI operations, and devtools key on them.`,
573
579
  );
574
580
  }
581
+ const operationId = getContractOperationId(contract);
582
+ const conflictingOperationId = registeredOperationIds.get(operationId);
583
+ if (conflictingOperationId) {
584
+ throw new Error(
585
+ `Duplicate OpenAPI operationId: "${operationId}" is registered for both ${conflictingOperationId} and ${routeKey}. Operation IDs must be unique across the registered route surface.`,
586
+ );
587
+ }
575
588
  if (contract.pathParams) {
576
589
  const shape = getObjectSchemaShape(contract.pathParams);
577
590
  if (shape) {
@@ -590,6 +603,7 @@ export async function createServer<
590
603
  registeredPaths.add(routeKey);
591
604
  registeredShapes.set(shapeRouteKey, routeKey);
592
605
  registeredNames.set(contract.name, routeKey);
606
+ registeredOperationIds.set(operationId, routeKey);
593
607
 
594
608
  const builtHandler = buildHandler(
595
609
  options,
@@ -720,7 +734,7 @@ export async function createServer<
720
734
  const message =
721
735
  `Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
722
736
  "Each port declared as deferred in definePorts(...) must be contributed " +
723
- "by a provider (server/providers.ts) or bound in infra/app-ports.ts. " +
737
+ "by a provider (server/providers.ts) or bound in infra/port-wiring.ts. " +
724
738
  'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
725
739
  if (onUnboundPorts === "error") {
726
740
  throw new Error(message);
@@ -13,7 +13,11 @@ import {
13
13
  createMemoryNotificationPort,
14
14
  type MemoryNotificationPort,
15
15
  } from "../notifications/index.js";
16
- import { createMemoryOutbox, type MemoryOutboxPort } from "../outbox/index.js";
16
+ import {
17
+ createMemoryOutbox,
18
+ enqueueEvent,
19
+ type MemoryOutboxPort,
20
+ } from "../outbox/index.js";
17
21
  import {
18
22
  createMemoryPayments,
19
23
  type MemoryPaymentsPort,
@@ -289,11 +293,11 @@ export interface CreateTestPortsTransactionOptions<
289
293
  */
290
294
  afterRollback?: (error: unknown, tx: TxPorts) => MaybePromise<void>;
291
295
  /**
292
- * Flush `tx.events` (a buffered domain event recorder) to `ports.eventBus`
296
+ * Enqueue `tx.events` (a buffered domain event recorder) to `ports.outbox`
293
297
  * after the transaction commits, and clear it after a rollback.
294
298
  *
295
- * Requires `transaction.ports` to include an `events` recorder such as
296
- * `createDomainEventRecorder()` or `createOutboxEventRecorder(...)`.
299
+ * Requires `transaction.ports` to include an `events` recorder created by
300
+ * `createDomainEventRecorder()`.
297
301
  */
298
302
  outbox?: boolean;
299
303
  }
@@ -328,7 +332,7 @@ export interface CreateTestPortsOptions<
328
332
  TxPorts = Ports,
329
333
  > {
330
334
  /**
331
- * App-owned default ports, usually imported from `infra/app-ports`. Common
335
+ * App-owned default ports, usually imported from `infra/port-wiring`. Common
332
336
  * Beignet test defaults replace matching keys from `base`; use `overrides`
333
337
  * for app ports that should win.
334
338
  *
@@ -452,9 +456,16 @@ export function createTestPorts<
452
456
  {
453
457
  afterCommit: async (tx) => {
454
458
  if (flushEventsToOutbox) {
455
- await resolveBufferedTransactionEvents(tx).flush(
456
- (portsWithoutUow as unknown as { eventBus: EventBusPort }).eventBus,
457
- );
459
+ const events = resolveBufferedTransactionEvents(tx);
460
+ const outbox = (
461
+ portsWithoutUow as unknown as {
462
+ outbox: MemoryOutboxPort;
463
+ }
464
+ ).outbox;
465
+ for (const entry of events.entries()) {
466
+ await enqueueEvent(outbox, entry.event, entry.payload);
467
+ }
468
+ events.clear();
458
469
  }
459
470
  await transactionOptions?.afterCommit?.(tx);
460
471
  },
@@ -578,26 +589,26 @@ function completeTestPortOverrides<Ports extends AnyPorts>(
578
589
 
579
590
  function resolveBufferedTransactionEvents(
580
591
  tx: unknown,
581
- ): Pick<BufferedDomainEventRecorder, "flush" | "clear"> {
592
+ ): Pick<BufferedDomainEventRecorder, "entries" | "clear"> {
582
593
  const events =
583
594
  tx && typeof tx === "object"
584
595
  ? (tx as { events?: unknown }).events
585
596
  : undefined;
586
597
  const recorder = events as
587
- | Partial<Pick<BufferedDomainEventRecorder, "flush" | "clear">>
598
+ | Partial<Pick<BufferedDomainEventRecorder, "entries" | "clear">>
588
599
  | undefined;
589
600
  if (
590
601
  !recorder ||
591
- typeof recorder.flush !== "function" ||
602
+ typeof recorder.entries !== "function" ||
592
603
  typeof recorder.clear !== "function"
593
604
  ) {
594
605
  throw new Error(
595
606
  "createTestPorts transaction.outbox requires tx.events to be a buffered domain event recorder. " +
596
- "Add events: createDomainEventRecorder() or createOutboxEventRecorder(...) to transaction.ports.",
607
+ "Add events: createDomainEventRecorder() to transaction.ports.",
597
608
  );
598
609
  }
599
610
 
600
- return recorder as Pick<BufferedDomainEventRecorder, "flush" | "clear">;
611
+ return recorder as Pick<BufferedDomainEventRecorder, "entries" | "clear">;
601
612
  }
602
613
 
603
614
  /**
@@ -831,7 +842,7 @@ export interface CreateTestContextOptions<Ctx, Ports extends AnyPorts> {
831
842
  */
832
843
  auth?: unknown;
833
844
  /**
834
- * App-owned default ports, usually imported from `infra/app-ports`. Common
845
+ * App-owned default ports, usually imported from `infra/port-wiring`. Common
835
846
  * Beignet test defaults replace matching keys from `base`; use `ports` for
836
847
  * app ports that should win.
837
848
  */