@beignet/core 0.0.51 → 0.0.53

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 (85) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +112 -20
  3. package/dist/application/index.d.ts +1 -1
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +5 -2
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/encryption/index.d.ts +40 -0
  8. package/dist/encryption/index.d.ts.map +1 -0
  9. package/dist/encryption/index.js +134 -0
  10. package/dist/encryption/index.js.map +1 -0
  11. package/dist/events/index.d.ts +26 -2
  12. package/dist/events/index.d.ts.map +1 -1
  13. package/dist/events/index.js +84 -10
  14. package/dist/events/index.js.map +1 -1
  15. package/dist/events/payload-state.d.ts +14 -2
  16. package/dist/events/payload-state.d.ts.map +1 -1
  17. package/dist/events/payload-state.js +116 -4
  18. package/dist/events/payload-state.js.map +1 -1
  19. package/dist/events/transport.d.ts +25 -0
  20. package/dist/events/transport.d.ts.map +1 -0
  21. package/dist/events/transport.js +192 -0
  22. package/dist/events/transport.js.map +1 -0
  23. package/dist/openapi/index.d.ts.map +1 -1
  24. package/dist/openapi/index.js +27 -4
  25. package/dist/openapi/index.js.map +1 -1
  26. package/dist/outbox/index.d.ts.map +1 -1
  27. package/dist/outbox/index.js +10 -13
  28. package/dist/outbox/index.js.map +1 -1
  29. package/dist/ports/events.d.ts +4 -1
  30. package/dist/ports/events.d.ts.map +1 -1
  31. package/dist/ports/storage.d.ts +7 -0
  32. package/dist/ports/storage.d.ts.map +1 -1
  33. package/dist/ports/storage.js +4 -0
  34. package/dist/ports/storage.js.map +1 -1
  35. package/dist/ports/testing.d.ts +3 -2
  36. package/dist/ports/testing.d.ts.map +1 -1
  37. package/dist/ports/testing.js +7 -4
  38. package/dist/ports/testing.js.map +1 -1
  39. package/dist/ports/unit-of-work.d.ts +4 -4
  40. package/dist/ports/unit-of-work.d.ts.map +1 -1
  41. package/dist/ports/unit-of-work.js +8 -9
  42. package/dist/ports/unit-of-work.js.map +1 -1
  43. package/dist/search/index.js +2 -2
  44. package/dist/search/index.js.map +1 -1
  45. package/dist/server/instrumentation.d.ts +5 -5
  46. package/dist/server/instrumentation.d.ts.map +1 -1
  47. package/dist/server/instrumentation.js +3 -4
  48. package/dist/server/instrumentation.js.map +1 -1
  49. package/dist/server/request-executor.d.ts.map +1 -1
  50. package/dist/server/request-executor.js +13 -0
  51. package/dist/server/request-executor.js.map +1 -1
  52. package/dist/server/response-finalization.d.ts.map +1 -1
  53. package/dist/server/response-finalization.js +25 -13
  54. package/dist/server/response-finalization.js.map +1 -1
  55. package/dist/server/server.d.ts +8 -5
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/server.js +1 -3
  58. package/dist/server/server.js.map +1 -1
  59. package/dist/server/use-case-route.d.ts +82 -17
  60. package/dist/server/use-case-route.d.ts.map +1 -1
  61. package/dist/server/use-case-route.js +40 -4
  62. package/dist/server/use-case-route.js.map +1 -1
  63. package/dist/uploads/index.d.ts.map +1 -1
  64. package/dist/uploads/index.js +37 -16
  65. package/dist/uploads/index.js.map +1 -1
  66. package/package.json +6 -2
  67. package/skills/app-architecture/SKILL.md +29 -6
  68. package/src/application/index.ts +24 -3
  69. package/src/encryption/index.ts +198 -0
  70. package/src/events/index.ts +137 -10
  71. package/src/events/payload-state.ts +205 -6
  72. package/src/events/transport.ts +242 -0
  73. package/src/openapi/index.ts +41 -3
  74. package/src/outbox/index.ts +26 -18
  75. package/src/ports/events.ts +4 -1
  76. package/src/ports/storage.ts +10 -0
  77. package/src/ports/testing.ts +11 -4
  78. package/src/ports/unit-of-work.ts +20 -16
  79. package/src/search/index.ts +2 -2
  80. package/src/server/instrumentation.ts +10 -8
  81. package/src/server/request-executor.ts +19 -0
  82. package/src/server/response-finalization.ts +33 -15
  83. package/src/server/server.ts +9 -8
  84. package/src/server/use-case-route.ts +234 -27
  85. package/src/uploads/index.ts +34 -16
@@ -0,0 +1,242 @@
1
+ /** Canonical JSON value that can cross an event transport boundary. */
2
+ export type EventTransportValue =
3
+ | null
4
+ | string
5
+ | number
6
+ | boolean
7
+ | readonly EventTransportValue[]
8
+ | { readonly [key: string]: EventTransportValue };
9
+
10
+ /** Why an event payload cannot safely cross a serialized transport. */
11
+ export type EventTransportErrorReason = "not-json-safe" | "not-stable";
12
+
13
+ /** Error thrown when an event payload cannot survive canonical JSON transport. */
14
+ export class EventTransportError extends Error {
15
+ /** Stable event name. */
16
+ readonly eventName: string;
17
+ /** Whether the value is not JSON-safe or changes under repeated parsing. */
18
+ readonly reason: EventTransportErrorReason;
19
+ /** Payload path associated with a JSON-safety failure. */
20
+ readonly path?: string;
21
+
22
+ constructor(args: {
23
+ eventName: string;
24
+ reason: EventTransportErrorReason;
25
+ message: string;
26
+ path?: string;
27
+ cause?: unknown;
28
+ }) {
29
+ super(`Event "${args.eventName}" payload ${args.message}`, {
30
+ cause: args.cause,
31
+ });
32
+ this.name = "EventTransportError";
33
+ this.eventName = args.eventName;
34
+ this.reason = args.reason;
35
+ this.path = args.path;
36
+ }
37
+ }
38
+
39
+ function childTransportPath(path: string, key: string): string {
40
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
41
+ ? `${path}.${key}`
42
+ : `${path}[${JSON.stringify(key)}]`;
43
+ }
44
+
45
+ function throwJsonSafetyError(args: {
46
+ eventName: string;
47
+ path: string;
48
+ message: string;
49
+ }): never {
50
+ throw new EventTransportError({
51
+ eventName: args.eventName,
52
+ reason: "not-json-safe",
53
+ path: args.path,
54
+ message: `is not transport-safe at ${args.path}: ${args.message}`,
55
+ });
56
+ }
57
+
58
+ export function toEventTransportValue(
59
+ eventName: string,
60
+ value: unknown,
61
+ path = "payload",
62
+ seen: WeakSet<object> = new WeakSet(),
63
+ ): EventTransportValue {
64
+ if (value === null) return null;
65
+
66
+ if (typeof value === "string" || typeof value === "boolean") return value;
67
+ if (typeof value === "number") {
68
+ if (!Number.isFinite(value)) {
69
+ return throwJsonSafetyError({
70
+ eventName,
71
+ path,
72
+ message: "numbers must be finite.",
73
+ });
74
+ }
75
+ return Object.is(value, -0) ? 0 : value;
76
+ }
77
+
78
+ if (typeof value !== "object") {
79
+ return throwJsonSafetyError({
80
+ eventName,
81
+ path,
82
+ message: `received ${typeof value}; use null, strings, finite numbers, booleans, arrays, or plain objects.`,
83
+ });
84
+ }
85
+
86
+ try {
87
+ if (value instanceof Date) {
88
+ return throwJsonSafetyError({
89
+ eventName,
90
+ path,
91
+ message: "Date values are not supported; use an ISO string or number.",
92
+ });
93
+ }
94
+
95
+ if (seen.has(value)) {
96
+ return throwJsonSafetyError({
97
+ eventName,
98
+ path,
99
+ message: "circular references are not supported.",
100
+ });
101
+ }
102
+
103
+ seen.add(value);
104
+ if (Array.isArray(value)) {
105
+ const output: EventTransportValue[] = [];
106
+ const ownKeys = Reflect.ownKeys(value);
107
+ const hasUnsupportedKey = ownKeys.some((key) => {
108
+ if (key === "length") return false;
109
+ if (typeof key !== "string") return true;
110
+ const index = Number(key);
111
+ return (
112
+ !Number.isInteger(index) ||
113
+ index < 0 ||
114
+ index >= value.length ||
115
+ String(index) !== key
116
+ );
117
+ });
118
+ if (hasUnsupportedKey) {
119
+ return throwJsonSafetyError({
120
+ eventName,
121
+ path,
122
+ message: "arrays cannot contain symbol or custom properties.",
123
+ });
124
+ }
125
+ if (ownKeys.length !== value.length + 1) {
126
+ return throwJsonSafetyError({
127
+ eventName,
128
+ path,
129
+ message: "sparse arrays are not supported.",
130
+ });
131
+ }
132
+ for (let index = 0; index < value.length; index += 1) {
133
+ const key = String(index);
134
+ const itemPath = `${path}[${index}]`;
135
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
136
+ if (!descriptor) {
137
+ return throwJsonSafetyError({
138
+ eventName,
139
+ path: itemPath,
140
+ message: "sparse arrays are not supported.",
141
+ });
142
+ }
143
+ if (!("value" in descriptor) || !descriptor.enumerable) {
144
+ return throwJsonSafetyError({
145
+ eventName,
146
+ path: itemPath,
147
+ message: "array items must be enumerable data properties.",
148
+ });
149
+ }
150
+ output.push(
151
+ toEventTransportValue(eventName, descriptor.value, itemPath, seen),
152
+ );
153
+ }
154
+ return output;
155
+ }
156
+
157
+ const prototype = Object.getPrototypeOf(value);
158
+ if (prototype !== Object.prototype && prototype !== null) {
159
+ return throwJsonSafetyError({
160
+ eventName,
161
+ path,
162
+ message: "objects must be plain objects.",
163
+ });
164
+ }
165
+
166
+ const output = {} as Record<string, EventTransportValue>;
167
+ for (const key of Reflect.ownKeys(value)) {
168
+ if (typeof key !== "string") {
169
+ return throwJsonSafetyError({
170
+ eventName,
171
+ path,
172
+ message: "symbol properties are not supported.",
173
+ });
174
+ }
175
+ const propertyPath = childTransportPath(path, key);
176
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
177
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
178
+ return throwJsonSafetyError({
179
+ eventName,
180
+ path: propertyPath,
181
+ message: "properties must be enumerable data properties.",
182
+ });
183
+ }
184
+ Object.defineProperty(output, key, {
185
+ configurable: true,
186
+ enumerable: true,
187
+ writable: true,
188
+ value: toEventTransportValue(
189
+ eventName,
190
+ descriptor.value,
191
+ propertyPath,
192
+ seen,
193
+ ),
194
+ });
195
+ }
196
+ return output;
197
+ } catch (error) {
198
+ if (error instanceof EventTransportError) throw error;
199
+ throw new EventTransportError({
200
+ eventName,
201
+ reason: "not-json-safe",
202
+ path,
203
+ message: `could not be inspected as transport data at ${path}.`,
204
+ cause: error,
205
+ });
206
+ } finally {
207
+ seen.delete(value);
208
+ }
209
+ }
210
+
211
+ export function eventTransportValuesEqual(
212
+ left: EventTransportValue,
213
+ right: EventTransportValue,
214
+ ): boolean {
215
+ if (left === right) return true;
216
+ if (left === null || right === null || typeof left !== typeof right) {
217
+ return false;
218
+ }
219
+ if (Array.isArray(left) || Array.isArray(right)) {
220
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
221
+ return (
222
+ left.length === right.length &&
223
+ left.every((value, index) =>
224
+ eventTransportValuesEqual(value, right[index]),
225
+ )
226
+ );
227
+ }
228
+ if (typeof left !== "object" || typeof right !== "object") return false;
229
+
230
+ const leftRecord = left as Readonly<Record<string, EventTransportValue>>;
231
+ const rightRecord = right as Readonly<Record<string, EventTransportValue>>;
232
+ const leftKeys = Object.keys(leftRecord).sort();
233
+ const rightKeys = Object.keys(rightRecord).sort();
234
+ return (
235
+ leftKeys.length === rightKeys.length &&
236
+ leftKeys.every(
237
+ (key, index) =>
238
+ key === rightKeys[index] &&
239
+ eventTransportValuesEqual(leftRecord[key], rightRecord[key]),
240
+ )
241
+ );
242
+ }
@@ -436,20 +436,58 @@ export function contractsToOpenAPI(
436
436
  ],
437
437
  };
438
438
  const operationIds = new Map<string, string>();
439
+ const operations = new Map<
440
+ string,
441
+ { readonly contractName: string; readonly route: string }
442
+ >();
443
+ const pathShapes = new Map<
444
+ string,
445
+ {
446
+ readonly contractName: string;
447
+ readonly pathKey: string;
448
+ readonly route: string;
449
+ }
450
+ >();
439
451
 
440
452
  for (const contract of contracts) {
441
453
  const config = resolveContract(contract);
442
454
  assertValidContractLifecycle(config);
455
+ const pathTemplate = parsePathTemplate(config.path);
456
+ const pathKey = pathTemplate.openApiPath;
457
+ const route = `${config.method.toUpperCase()} ${config.path}`;
458
+ const operationRoute = `${config.method.toUpperCase()} ${pathKey}`;
459
+ const conflictingOperation = operations.get(operationRoute);
460
+ if (conflictingOperation) {
461
+ throw new Error(
462
+ `Duplicate OpenAPI operation: ${operationRoute} is produced by both contract "${conflictingOperation.contractName}" (${conflictingOperation.route}) and contract "${config.name}" (${route}). Each method + normalized path combination must be unique within an OpenAPI document.`,
463
+ );
464
+ }
465
+ const conflictingPathShape = pathShapes.get(pathTemplate.shapeKey);
466
+ if (conflictingPathShape && conflictingPathShape.pathKey !== pathKey) {
467
+ throw new Error(
468
+ `Ambiguous OpenAPI path: ${pathKey} from contract "${config.name}" (${route}) conflicts with ${conflictingPathShape.pathKey} from contract "${conflictingPathShape.contractName}" (${conflictingPathShape.route}). Templated paths with the same hierarchy must use the same parameter names within an OpenAPI document.`,
469
+ );
470
+ }
443
471
  const operationId = getContractOperationId(config);
444
- const route = `${config.method} ${config.path}`;
445
472
  const conflictingRoute = operationIds.get(operationId);
446
473
  if (conflictingRoute) {
447
474
  throw new Error(
448
475
  `Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
449
476
  );
450
477
  }
478
+ operations.set(operationRoute, {
479
+ contractName: config.name,
480
+ route,
481
+ });
482
+ if (!conflictingPathShape) {
483
+ pathShapes.set(pathTemplate.shapeKey, {
484
+ contractName: config.name,
485
+ pathKey,
486
+ route,
487
+ });
488
+ }
451
489
  operationIds.set(operationId, route);
452
- addContractToPaths(config, paths, state);
490
+ addContractToPaths(config, pathKey, paths, state);
453
491
  }
454
492
 
455
493
  const openapi: OpenAPIObject = {
@@ -481,10 +519,10 @@ export function contractsToOpenAPI(
481
519
  */
482
520
  function addContractToPaths(
483
521
  contract: AnyContract,
522
+ pathKey: string,
484
523
  paths: PathsObject,
485
524
  state: GeneratorState,
486
525
  ): void {
487
- const pathKey = parsePathTemplate(contract.path).openApiPath;
488
526
  if (!paths[pathKey]) {
489
527
  paths[pathKey] = {};
490
528
  }
@@ -8,13 +8,10 @@
8
8
  import {
9
9
  type EventPayloadDef,
10
10
  type EventPublishOptions,
11
+ type EventTransportValue,
11
12
  type InferEventPayload,
12
- parseEventPayload,
13
+ prepareEventPayloadForTransport,
13
14
  } from "../events/index.js";
14
- import {
15
- isEventPayloadParsed,
16
- markEventPayloadParsed,
17
- } from "../events/payload-state.js";
18
15
  import {
19
16
  getJobRetryDelayMs,
20
17
  getJobRetryMaxAttempts,
@@ -1508,14 +1505,19 @@ export async function enqueueEvent<E extends EventPayloadDef>(
1508
1505
  payload: InferEventPayload<E>,
1509
1506
  options: EnqueueTypedOutboxOptions = {},
1510
1507
  ): Promise<OutboxMessage> {
1511
- const parsed = await parseEventPayload(event, payload);
1512
- return await enqueueParsedEvent(outbox, event, parsed, options);
1508
+ const prepared = await prepareEventPayloadForTransport(event, payload);
1509
+ return await enqueueTransportEvent(
1510
+ outbox,
1511
+ event,
1512
+ prepared.transportValue,
1513
+ options,
1514
+ );
1513
1515
  }
1514
1516
 
1515
- async function enqueueParsedEvent<E extends EventPayloadDef>(
1517
+ async function enqueueTransportEvent<E extends EventPayloadDef>(
1516
1518
  outbox: OutboxPort,
1517
1519
  event: E,
1518
- payload: InferEventPayload<E>,
1520
+ payload: EventTransportValue,
1519
1521
  options: EnqueueTypedOutboxOptions,
1520
1522
  ): Promise<OutboxMessage> {
1521
1523
  const trace =
@@ -1524,7 +1526,7 @@ async function enqueueParsedEvent<E extends EventPayloadDef>(
1524
1526
  id: options.id,
1525
1527
  kind: "event",
1526
1528
  name: event.name,
1527
- payload: toOutboxJsonValue(payload),
1529
+ payload,
1528
1530
  trace,
1529
1531
  availableAt: options.availableAt,
1530
1532
  maxAttempts: options.maxAttempts,
@@ -1563,12 +1565,14 @@ export function createOutboxEventRecorder(
1563
1565
  ): DomainEventRecorderPort {
1564
1566
  return {
1565
1567
  async record(event, payload, publishOptions) {
1566
- const parsed = isEventPayloadParsed(publishOptions)
1567
- ? payload
1568
- : await parseEventPayload(event, payload);
1569
- await enqueueParsedEvent(outbox, event, parsed, {
1568
+ const prepared = await prepareEventPayloadForTransport(
1569
+ event,
1570
+ payload,
1571
+ publishOptions,
1572
+ );
1573
+ await enqueueTransportEvent(outbox, event, prepared.transportValue, {
1570
1574
  ...options,
1571
- trace: publishOptions?.trace ?? options.trace,
1575
+ trace: prepared.publishOptions.trace ?? options.trace,
1572
1576
  });
1573
1577
  },
1574
1578
  };
@@ -1673,11 +1677,15 @@ async function deliverOutboxMessage(
1673
1677
  );
1674
1678
  }
1675
1679
 
1676
- const payload = await parseEventPayload(event, message.payload);
1680
+ const prepared = await prepareEventPayloadForTransport(
1681
+ event,
1682
+ message.payload,
1683
+ trace ? { trace } : undefined,
1684
+ );
1677
1685
  await options.eventBus.publish(
1678
1686
  event,
1679
- payload,
1680
- markEventPayloadParsed(trace ? { trace } : undefined),
1687
+ prepared.payload,
1688
+ prepared.publishOptions,
1681
1689
  );
1682
1690
  return;
1683
1691
  }
@@ -53,7 +53,10 @@ export type InferJobPayload<J extends JobDef> = InferContractJobPayload<J>;
53
53
  * An EventBus port for publishing and subscribing to domain events.
54
54
  *
55
55
  * This interface defines a framework-agnostic contract for event-driven
56
- * communication within your application.
56
+ * communication within your application. Implementations must prepare
57
+ * producer payloads with `prepareEventPayloadForTransport(...)` from
58
+ * `@beignet/core/events` so direct publication and provider swaps preserve the
59
+ * same canonical JSON semantics.
57
60
  *
58
61
  * @example
59
62
  * ```ts
@@ -82,6 +82,13 @@ export interface StorageObjectBody extends StorageObject {
82
82
  * buffering them.
83
83
  */
84
84
  readonly bodyUsed: boolean;
85
+ /**
86
+ * Discard an unread body and release any resources held by its provider.
87
+ *
88
+ * Calling this after consumption has started is a no-op. Callers that only
89
+ * inspect object metadata should cancel the body in a `finally` block.
90
+ */
91
+ cancel(reason?: unknown): Promise<void>;
85
92
  /**
86
93
  * Consume the object as a readable byte stream.
87
94
  */
@@ -376,6 +383,9 @@ function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
376
383
  get bodyUsed() {
377
384
  return bodyUsed;
378
385
  },
386
+ async cancel() {
387
+ if (!bodyUsed) bodyUsed = true;
388
+ },
379
389
  stream() {
380
390
  return bytesToStream(consumeBytes());
381
391
  },
@@ -1,3 +1,4 @@
1
+ import { prepareEventPayloadForTransport } from "../events/index.js";
1
2
  import type {
2
3
  MemoryIdempotencyEntry,
3
4
  MemoryIdempotencyStore,
@@ -81,8 +82,9 @@ export interface RecordedEventExpectation {
81
82
  /**
82
83
  * Create a recording event bus for testing.
83
84
  *
84
- * This bus records all published events for later assertion,
85
- * but does not support subscription (throws if called).
85
+ * This bus validates canonical transport output and records published events
86
+ * asynchronously for later assertion. Await `publish(...)` before reading the
87
+ * captured log. Subscription is not supported and throws when called.
86
88
  *
87
89
  * @example
88
90
  * ```ts
@@ -104,8 +106,13 @@ export function createRecordingEventBus(): {
104
106
  const events: RecordedEvent[] = [];
105
107
 
106
108
  const bus: EventBusPort = {
107
- publish(event, payload) {
108
- events.push({ name: event.name, payload });
109
+ async publish(event, payload, options) {
110
+ const prepared = await prepareEventPayloadForTransport(
111
+ event,
112
+ payload,
113
+ options,
114
+ );
115
+ events.push({ name: event.name, payload: prepared.payload });
109
116
  },
110
117
  subscribe() {
111
118
  throw new Error("Not implemented for recording bus");
@@ -1,11 +1,8 @@
1
1
  import {
2
2
  type EventPublishOptions,
3
- parseEventPayload,
3
+ prepareEventPayloadForTransport,
4
4
  } from "../events/index.js";
5
- import {
6
- isEventPayloadParsed,
7
- markEventPayloadParsed,
8
- } from "../events/payload-state.js";
5
+ import { isEventPayloadParsed } from "../events/payload-state.js";
9
6
  import type {
10
7
  DomainEventDef,
11
8
  EventBusPort,
@@ -93,8 +90,8 @@ export interface RecordedDomainEvent {
93
90
  */
94
91
  readonly eventName: string;
95
92
  /**
96
- * Recorded payload. Use-case helpers store parsed schema output; direct
97
- * recorder calls are validated when the buffer is flushed.
93
+ * Recorded payload. Use-case helpers store canonical transport-stable schema
94
+ * output; direct recorder calls are validated when the buffer is flushed.
98
95
  */
99
96
  readonly payload: unknown;
100
97
  /** Optional metadata propagated when the event is flushed. */
@@ -132,8 +129,8 @@ export interface BufferedDomainEventRecorder extends DomainEventRecorderPort {
132
129
  */
133
130
  clear(): void;
134
131
  /**
135
- * Publish recorded events in FIFO order, validating entries recorded
136
- * directly without the use-case event helper.
132
+ * Publish recorded events in FIFO order, proving transport stability for
133
+ * entries recorded directly without the use-case event helper.
137
134
  */
138
135
  flush(eventBus: EventBusPort): Promise<void>;
139
136
  }
@@ -229,7 +226,10 @@ export function createObservedUnitOfWork<TxPorts>(
229
226
  */
230
227
  export function createDomainEventRecorder(): BufferedDomainEventRecorder {
231
228
  const records: RecordedDomainEvent[] = [];
232
- const parsedRecords = new WeakSet<RecordedDomainEvent>();
229
+ const validationOptions = new WeakMap<
230
+ RecordedDomainEvent,
231
+ EventPublishOptions
232
+ >();
233
233
 
234
234
  return {
235
235
  record(event, payload, options) {
@@ -239,7 +239,9 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
239
239
  payload,
240
240
  ...(options?.trace ? { options: { trace: options.trace } } : {}),
241
241
  };
242
- if (isEventPayloadParsed(options)) parsedRecords.add(record);
242
+ if (isEventPayloadParsed(event, payload, options) && options) {
243
+ validationOptions.set(record, { ...options });
244
+ }
243
245
  records.push(record);
244
246
  },
245
247
 
@@ -257,13 +259,15 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
257
259
  async flush(eventBus) {
258
260
  while (records.length > 0) {
259
261
  const record = records[0];
260
- const payload = parsedRecords.has(record)
261
- ? record.payload
262
- : await parseEventPayload(record.event, record.payload);
262
+ const prepared = await prepareEventPayloadForTransport(
263
+ record.event,
264
+ record.payload,
265
+ validationOptions.get(record) ?? record.options,
266
+ );
263
267
  await eventBus.publish(
264
268
  record.event,
265
- payload as never,
266
- markEventPayloadParsed(record.options),
269
+ prepared.payload as never,
270
+ prepared.publishOptions,
267
271
  );
268
272
  records.shift();
269
273
  }
@@ -435,10 +435,10 @@ function instrumentSearch(
435
435
  instrumentation.custom({
436
436
  name: "search.query",
437
437
  label: "Search query",
438
- summary: `${index.name}: ${result.query}`,
438
+ summary: `${index.name}: ${result.hits.length} hits`,
439
439
  details: {
440
440
  index: index.name,
441
- query: result.query,
441
+ queryLength: result.query.length,
442
442
  hits: result.hits.length,
443
443
  total: result.page.total,
444
444
  durationMs: Date.now() - startedAt,
@@ -61,8 +61,8 @@ export interface ServerInstrumentationOptions<Ctx = unknown> {
61
61
  traceContextHeader?: string | false;
62
62
 
63
63
  /**
64
- * Request path prefixes that should not enter ambient correlation or record
65
- * events. Response headers are still written.
64
+ * Request path prefixes that should not record instrumentation events.
65
+ * Ambient correlation still runs, and enabled response headers are written.
66
66
  *
67
67
  * Defaults to the devtools dashboard prefix so its polling traffic does not
68
68
  * fill the event timeline.
@@ -125,10 +125,10 @@ export interface ServerInstrumentationRuntime<Ctx> {
125
125
  */
126
126
  createServiceCorrelation(): RequestCorrelation;
127
127
  /**
128
- * Pipeline hook installed before user hooks, when instrumentation is
129
- * enabled.
128
+ * Pipeline hook installed before user hooks. It always owns ambient
129
+ * correlation; response headers and event recording remain configurable.
130
130
  */
131
- hook?: ServerHook<Ctx, AnyPorts>;
131
+ hook: ServerHook<Ctx, AnyPorts>;
132
132
  }
133
133
 
134
134
  type TraceContextFields = {
@@ -350,7 +350,6 @@ export function createServerInstrumentation<Ctx>(
350
350
  req: HttpRequestLike;
351
351
  ctx?: unknown;
352
352
  }) => {
353
- if (isIgnoredPath(getPathname(args.req), ignorePaths)) return;
354
353
  const trace = resolveTraceContext(args);
355
354
  enterActiveRequestContext({
356
355
  requestId: resolveRequestId(args),
@@ -377,7 +376,10 @@ export function createServerInstrumentation<Ctx>(
377
376
  return undefined;
378
377
  },
379
378
  beforeSend: ({ req, ctx, response }) => {
380
- if (requestIdHeader === false && traceContextHeader === false) {
379
+ if (
380
+ !enabled ||
381
+ (requestIdHeader === false && traceContextHeader === false)
382
+ ) {
381
383
  return undefined;
382
384
  }
383
385
 
@@ -502,6 +504,6 @@ export function createServerInstrumentation<Ctx>(
502
504
  requestId: createRequestId(),
503
505
  trace: tracing?.current() ?? createTraceContext(),
504
506
  }),
505
- hook: enabled ? hook : undefined,
507
+ hook,
506
508
  };
507
509
  }
@@ -83,6 +83,7 @@ import {
83
83
  } from "./route-matching.js";
84
84
  import type { TrustedRequestInfo } from "./trusted-proxy.js";
85
85
  import { InvalidRequestUrlError } from "./trusted-proxy-internal.js";
86
+ import { UseCaseRouteInputValidationError } from "./use-case-route.js";
86
87
 
87
88
  function withoutHeadResponseBody(
88
89
  response: HttpResponse,
@@ -282,6 +283,24 @@ export function createRequestExecutor<
282
283
  };
283
284
  }
284
285
 
286
+ if (currentError instanceof UseCaseRouteInputValidationError) {
287
+ return {
288
+ ctx,
289
+ response: errorResponse(
290
+ 500,
291
+ currentError.code,
292
+ currentError.message,
293
+ {
294
+ contractName: currentError.contractName,
295
+ useCaseName: currentError.useCaseName,
296
+ location: "useCaseInput",
297
+ },
298
+ ),
299
+ error: currentError,
300
+ owner: "framework",
301
+ };
302
+ }
303
+
285
304
  if (currentError instanceof InvalidRequestUrlError) {
286
305
  return {
287
306
  ctx,