@beignet/core 0.0.37 → 0.0.39

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 (111) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +108 -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/error-reporting/index.d.ts +42 -1
  7. package/dist/error-reporting/index.d.ts.map +1 -1
  8. package/dist/error-reporting/index.js +60 -0
  9. package/dist/error-reporting/index.js.map +1 -1
  10. package/dist/error-reporting/internal.d.ts +11 -0
  11. package/dist/error-reporting/internal.d.ts.map +1 -0
  12. package/dist/error-reporting/internal.js +36 -0
  13. package/dist/error-reporting/internal.js.map +1 -0
  14. package/dist/events/index.d.ts +9 -4
  15. package/dist/events/index.d.ts.map +1 -1
  16. package/dist/events/index.js +5 -3
  17. package/dist/events/index.js.map +1 -1
  18. package/dist/idempotency/index.d.ts +25 -2
  19. package/dist/idempotency/index.d.ts.map +1 -1
  20. package/dist/idempotency/index.js +35 -5
  21. package/dist/idempotency/index.js.map +1 -1
  22. package/dist/jobs/index.d.ts +26 -4
  23. package/dist/jobs/index.d.ts.map +1 -1
  24. package/dist/jobs/index.js +51 -6
  25. package/dist/jobs/index.js.map +1 -1
  26. package/dist/locks/index.d.ts +19 -1
  27. package/dist/locks/index.d.ts.map +1 -1
  28. package/dist/locks/index.js +27 -12
  29. package/dist/locks/index.js.map +1 -1
  30. package/dist/outbox/index.d.ts +23 -4
  31. package/dist/outbox/index.d.ts.map +1 -1
  32. package/dist/outbox/index.js +71 -23
  33. package/dist/outbox/index.js.map +1 -1
  34. package/dist/ports/events.d.ts +5 -5
  35. package/dist/ports/events.d.ts.map +1 -1
  36. package/dist/ports/index.d.ts +4 -4
  37. package/dist/ports/unbound.js +1 -1
  38. package/dist/ports/unbound.js.map +1 -1
  39. package/dist/ports/unit-of-work.d.ts +4 -1
  40. package/dist/ports/unit-of-work.d.ts.map +1 -1
  41. package/dist/ports/unit-of-work.js +4 -3
  42. package/dist/ports/unit-of-work.js.map +1 -1
  43. package/dist/query-codec.d.ts +3 -0
  44. package/dist/query-codec.d.ts.map +1 -0
  45. package/dist/query-codec.js +110 -0
  46. package/dist/query-codec.js.map +1 -0
  47. package/dist/server/hooks/cors.d.ts +3 -2
  48. package/dist/server/hooks/cors.d.ts.map +1 -1
  49. package/dist/server/hooks/cors.js +7 -3
  50. package/dist/server/hooks/cors.js.map +1 -1
  51. package/dist/server/hooks/error-reporting.d.ts +8 -0
  52. package/dist/server/hooks/error-reporting.d.ts.map +1 -1
  53. package/dist/server/hooks/error-reporting.js +55 -25
  54. package/dist/server/hooks/error-reporting.js.map +1 -1
  55. package/dist/server/hooks/idempotency.d.ts +1 -1
  56. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  57. package/dist/server/hooks/idempotency.js +30 -9
  58. package/dist/server/hooks/idempotency.js.map +1 -1
  59. package/dist/server/request-executor.d.ts.map +1 -1
  60. package/dist/server/request-executor.js +4 -10
  61. package/dist/server/request-executor.js.map +1 -1
  62. package/dist/server/request-preparation.d.ts.map +1 -1
  63. package/dist/server/request-preparation.js +3 -1
  64. package/dist/server/request-preparation.js.map +1 -1
  65. package/dist/server/response-finalization.d.ts +3 -1
  66. package/dist/server/response-finalization.d.ts.map +1 -1
  67. package/dist/server/response-finalization.js +24 -2
  68. package/dist/server/response-finalization.js.map +1 -1
  69. package/dist/server/server.js +1 -1
  70. package/dist/server/server.js.map +1 -1
  71. package/dist/testing/index.d.ts +5 -5
  72. package/dist/testing/index.d.ts.map +1 -1
  73. package/dist/testing/index.js +9 -4
  74. package/dist/testing/index.js.map +1 -1
  75. package/dist/tracing/index.d.ts +27 -0
  76. package/dist/tracing/index.d.ts.map +1 -1
  77. package/dist/tracing/index.js +96 -0
  78. package/dist/tracing/index.js.map +1 -1
  79. package/dist/uploads/index.d.ts.map +1 -1
  80. package/dist/uploads/index.js +39 -6
  81. package/dist/uploads/index.js.map +1 -1
  82. package/dist/webhooks/index.d.ts +2 -1
  83. package/dist/webhooks/index.d.ts.map +1 -1
  84. package/dist/webhooks/index.js +25 -1
  85. package/dist/webhooks/index.js.map +1 -1
  86. package/package.json +1 -1
  87. package/skills/app-architecture/SKILL.md +19 -1
  88. package/src/client/client.ts +6 -31
  89. package/src/error-reporting/index.ts +111 -0
  90. package/src/error-reporting/internal.ts +47 -0
  91. package/src/events/index.ts +20 -4
  92. package/src/idempotency/index.ts +81 -7
  93. package/src/jobs/index.ts +85 -6
  94. package/src/locks/index.ts +65 -16
  95. package/src/outbox/index.ts +119 -27
  96. package/src/ports/events.ts +8 -1
  97. package/src/ports/index.ts +4 -4
  98. package/src/ports/unbound.ts +1 -1
  99. package/src/ports/unit-of-work.ts +10 -3
  100. package/src/query-codec.ts +130 -0
  101. package/src/server/hooks/cors.ts +10 -3
  102. package/src/server/hooks/error-reporting.ts +79 -23
  103. package/src/server/hooks/idempotency.ts +36 -10
  104. package/src/server/request-executor.ts +4 -12
  105. package/src/server/request-preparation.ts +4 -2
  106. package/src/server/response-finalization.ts +36 -5
  107. package/src/server/server.ts +1 -1
  108. package/src/testing/index.ts +25 -14
  109. package/src/tracing/index.ts +120 -0
  110. package/src/uploads/index.ts +44 -6
  111. package/src/webhooks/index.ts +34 -2
@@ -104,8 +104,9 @@ export function applyCorsHeaders(
104
104
  /**
105
105
  * Create CORS hooks for preflight and regular responses.
106
106
  *
107
- * `OPTIONS` requests short-circuit with a 204 response. All other responses are
108
- * decorated in `beforeSend`.
107
+ * CORS preflight requests short-circuit with a 204 response. Explicit
108
+ * `OPTIONS` routes without `Access-Control-Request-Method` continue through the
109
+ * normal route pipeline. All responses are decorated in `beforeSend`.
109
110
  */
110
111
  export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
111
112
  const corsConfig = resolveCorsConfig(config);
@@ -113,7 +114,13 @@ export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
113
114
  return {
114
115
  name: "cors",
115
116
  onRequest: ({ req }) => {
116
- if (req.method !== "OPTIONS") return undefined;
117
+ if (
118
+ req.method !== "OPTIONS" ||
119
+ !req.headers.get("Origin") ||
120
+ !req.headers.get("Access-Control-Request-Method")
121
+ ) {
122
+ return undefined;
123
+ }
117
124
  const headers: Record<string, string> = {};
118
125
  applyCorsHeaders(headers, req, corsConfig);
119
126
  return {
@@ -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
  };
@@ -889,28 +889,20 @@ export function createRequestExecutor<
889
889
  let finalOwner = responseOwnerFor(finalResponse, result.owner);
890
890
  let responseValidation: ResponseFinalizerValidationState =
891
891
  "not-applicable";
892
- if (
893
- finalOwner === "route" &&
894
- !isWebResponse(finalResponse) &&
895
- !(options.validateResponses ?? true)
896
- ) {
897
- responseValidation = "disabled";
898
- } else if (
899
- finalOwner === "route" &&
900
- !isWebResponse(finalResponse) &&
901
- (options.validateResponses ?? true)
902
- ) {
892
+ if (finalOwner === "route" && !isWebResponse(finalResponse)) {
893
+ const validateContract = options.validateResponses ?? true;
903
894
  try {
904
895
  finalResponse = await finalizeResponse(
905
896
  contract,
906
897
  finalResponse,
907
898
  target.responseValidationExemptStatus,
899
+ { validateContract },
908
900
  );
909
901
  result = {
910
902
  ...result,
911
903
  response: finalResponse,
912
904
  };
913
- responseValidation = "validated";
905
+ responseValidation = validateContract ? "validated" : "disabled";
914
906
  } catch (error) {
915
907
  if (error instanceof ResponseContractViolationError) {
916
908
  result = {
@@ -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;
@@ -391,17 +391,48 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
391
391
  }
392
392
  }
393
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
+
394
421
  export async function finalizeResponse<C extends HttpContractConfig>(
395
422
  contract: C,
396
423
  res: HttpResponseLike,
397
424
  responseValidationExemptStatus?: number,
425
+ options: { validateContract?: boolean } = {},
398
426
  ): Promise<HttpResponseLike> {
399
427
  const normalized = normalizeResponse(res);
400
- await validateResponseAgainstContract(
401
- contract,
402
- normalized,
403
- responseValidationExemptStatus,
404
- );
428
+ validateHttpResponseSemantics(contract, normalized);
429
+ if (options.validateContract ?? true) {
430
+ await validateResponseAgainstContract(
431
+ contract,
432
+ normalized,
433
+ responseValidationExemptStatus,
434
+ );
435
+ }
405
436
  return normalized;
406
437
  }
407
438
 
@@ -734,7 +734,7 @@ export async function createServer<
734
734
  const message =
735
735
  `Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
736
736
  "Each port declared as deferred in definePorts(...) must be contributed " +
737
- "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. " +
738
738
  'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
739
739
  if (onUnboundPorts === "error") {
740
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
  */
@@ -9,6 +9,29 @@
9
9
  */
10
10
 
11
11
  const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
12
+ const MAX_TRACESTATE_LENGTH = 512;
13
+ const MAX_TRACESTATE_MEMBERS = 32;
14
+ const TRACESTATE_SIMPLE_KEY_PATTERN = /^[a-z][a-z0-9_*/-]{0,255}$/;
15
+ const TRACESTATE_VENDOR_KEY_PATTERN =
16
+ /^[a-z0-9][a-z0-9_*/-]{0,240}@[a-z][a-z0-9_*/-]{0,13}$/;
17
+ const TRACESTATE_VALUE_PATTERN =
18
+ /^[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]$/;
19
+
20
+ /** Current version of Beignet's durable trace carrier. */
21
+ export const TRACE_CARRIER_VERSION = 1 as const;
22
+
23
+ /**
24
+ * Vendor-neutral trace context stored in durable messages and transport
25
+ * envelopes. Unknown versions and malformed values are ignored by consumers.
26
+ */
27
+ export interface TraceCarrier {
28
+ /** Carrier schema version. */
29
+ readonly version: typeof TRACE_CARRIER_VERSION;
30
+ /** W3C traceparent value captured at the producing boundary. */
31
+ readonly traceparent: string;
32
+ /** Optional W3C tracestate value captured at the producing boundary. */
33
+ readonly tracestate?: string;
34
+ }
12
35
 
13
36
  /**
14
37
  * Trace context used to correlate related activity.
@@ -161,6 +184,103 @@ export function resolveTracingPort(target: unknown): TracingPort | undefined {
161
184
  return resolveTracingPort(ports);
162
185
  }
163
186
 
187
+ function validTracestate(value: unknown): string | undefined {
188
+ if (typeof value !== "string") return undefined;
189
+ const normalized = value.trim();
190
+ if (normalized.length === 0 || normalized.length > MAX_TRACESTATE_LENGTH) {
191
+ return undefined;
192
+ }
193
+
194
+ const members = normalized.split(",");
195
+ if (members.length > MAX_TRACESTATE_MEMBERS) return undefined;
196
+
197
+ const keys = new Set<string>();
198
+ for (const rawMember of members) {
199
+ const member = rawMember.trim();
200
+ const separator = member.indexOf("=");
201
+ if (separator <= 0) return undefined;
202
+
203
+ const key = member.slice(0, separator).trim();
204
+ const memberValue = member.slice(separator + 1).trimStart();
205
+ if (
206
+ (!TRACESTATE_SIMPLE_KEY_PATTERN.test(key) &&
207
+ !TRACESTATE_VENDOR_KEY_PATTERN.test(key)) ||
208
+ !TRACESTATE_VALUE_PATTERN.test(memberValue) ||
209
+ keys.has(key)
210
+ ) {
211
+ return undefined;
212
+ }
213
+ keys.add(key);
214
+ }
215
+
216
+ return normalized;
217
+ }
218
+
219
+ /**
220
+ * Parse an untrusted durable trace carrier.
221
+ *
222
+ * Invalid carriers return `undefined`; trace metadata must never prevent
223
+ * message delivery.
224
+ */
225
+ export function parseTraceCarrier(value: unknown): TraceCarrier | undefined {
226
+ if (!isObject(value) || value.version !== TRACE_CARRIER_VERSION) {
227
+ return undefined;
228
+ }
229
+
230
+ const parsed = parseTraceparent(
231
+ typeof value.traceparent === "string" ? value.traceparent : undefined,
232
+ );
233
+ if (!parsed) return undefined;
234
+
235
+ if (value.tracestate !== undefined) {
236
+ const tracestate = validTracestate(value.tracestate);
237
+ if (!tracestate) return undefined;
238
+ return {
239
+ version: TRACE_CARRIER_VERSION,
240
+ traceparent: parsed.traceparent,
241
+ tracestate,
242
+ };
243
+ }
244
+
245
+ return {
246
+ version: TRACE_CARRIER_VERSION,
247
+ traceparent: parsed.traceparent,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Capture the current trace context from a tracing port, ports object, app
253
+ * context, or explicit trace context. Returns `undefined` when no valid
254
+ * context is available.
255
+ */
256
+ export function captureTraceCarrier(target: unknown): TraceCarrier | undefined {
257
+ let context: TraceContextInput | undefined;
258
+ try {
259
+ context = resolveTraceContextInput(target);
260
+ } catch {
261
+ // Context-like inputs may be proxy-backed and reject unknown properties.
262
+ }
263
+
264
+ try {
265
+ context = resolveTracingPort(target)?.current() ?? context;
266
+ } catch {
267
+ // Tracing is best-effort and must not block the owning operation.
268
+ }
269
+
270
+ try {
271
+ if (!context?.traceparent) return undefined;
272
+
273
+ return parseTraceCarrier({
274
+ version: TRACE_CARRIER_VERSION,
275
+ traceparent: context.traceparent,
276
+ ...(context.tracestate ? { tracestate: context.tracestate } : {}),
277
+ });
278
+ } catch {
279
+ // Trace contexts are app-provided and remain best-effort inputs.
280
+ return undefined;
281
+ }
282
+ }
283
+
164
284
  /** Resolve trace fields from a trace context or context-like object. */
165
285
  export function resolveTraceContextInput(
166
286
  target: unknown,