@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,198 @@
1
+ import "../server-only.js";
2
+
3
+ /** Non-secret, authenticated metadata. Supply the same expected context on reads. */
4
+ export type EncryptionContext = Readonly<Record<string, string>>;
5
+
6
+ /** Options shared by string encryption and decryption. */
7
+ export interface EncryptionValueOptions {
8
+ /** Plaintext for encrypt; the complete encrypted value for decrypt. */
9
+ value: string;
10
+ /** Bind the value to its purpose, tenant, or record. This is not authorization. */
11
+ context?: EncryptionContext;
12
+ }
13
+
14
+ /** Server-side authenticated string encryption. */
15
+ export interface EncryptionPort {
16
+ /** Encrypt a string; persist the complete returned value. */
17
+ encrypt(options: EncryptionValueOptions): Promise<string>;
18
+ /** Authenticate and decrypt a value with the same expected context. */
19
+ decrypt(options: EncryptionValueOptions): Promise<string>;
20
+ }
21
+
22
+ /** Configuration for the built-in AES-256-GCM implementation. */
23
+ export interface CreateEncryptionOptions {
24
+ /** A base64: prefixed, canonical Base64 encoding of 32 random bytes. */
25
+ key: string;
26
+ /** Decryption-only keys in the same format. New writes always use key. */
27
+ previousKeys?: readonly string[];
28
+ }
29
+
30
+ /** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */
31
+ export class EncryptionDecryptionError extends Error {
32
+ /** Stable error name, independent of the decryption failure's cause. */
33
+ readonly name = "EncryptionDecryptionError";
34
+
35
+ constructor() {
36
+ super("Unable to decrypt encrypted value.");
37
+ }
38
+ }
39
+
40
+ const envelopePrefix = "beignet:enc:v1:";
41
+ const ivLength = 12;
42
+ const tagLength = 16;
43
+ const encoder = new TextEncoder();
44
+ const decoder = new TextDecoder("utf-8", { fatal: true });
45
+
46
+ function encodeBase64(bytes: Uint8Array): string {
47
+ // Bound each spread so large values do not overflow the argument stack.
48
+ const chunks: string[] = [];
49
+ for (let offset = 0; offset < bytes.length; offset += 8192) {
50
+ chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
51
+ }
52
+ return btoa(chunks.join(""));
53
+ }
54
+
55
+ function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
56
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
57
+ throw new Error("Invalid Base64 encoding.");
58
+ }
59
+ const bytes = Uint8Array.from(atob(value), (character) =>
60
+ character.charCodeAt(0),
61
+ );
62
+ if (encodeBase64(bytes) !== value)
63
+ throw new Error("Noncanonical Base64 encoding.");
64
+ return bytes;
65
+ }
66
+
67
+ function decodeKey(value: string, label: string): Uint8Array<ArrayBuffer> {
68
+ try {
69
+ if (
70
+ typeof value !== "string" ||
71
+ value.length !== 51 ||
72
+ !value.startsWith("base64:")
73
+ )
74
+ throw new Error();
75
+ const bytes = decodeBase64(value.slice(7));
76
+ if (bytes.length !== 32) throw new Error();
77
+ return bytes;
78
+ } catch {
79
+ throw new TypeError(
80
+ `${label} must be base64: followed by the Base64 encoding of 32 bytes. Use generateEncryptionKey().`,
81
+ );
82
+ }
83
+ }
84
+
85
+ function authenticatedData(
86
+ context: EncryptionContext | undefined,
87
+ ): Uint8Array<ArrayBuffer> {
88
+ if (
89
+ context !== undefined &&
90
+ (context === null ||
91
+ typeof context !== "object" ||
92
+ (Object.getPrototypeOf(context) !== Object.prototype &&
93
+ Object.getPrototypeOf(context) !== null))
94
+ ) {
95
+ throw new TypeError(
96
+ "Encryption context must be a plain record of strings.",
97
+ );
98
+ }
99
+ const entries = Object.entries(context ?? {}).sort(([left], [right]) =>
100
+ left < right ? -1 : left > right ? 1 : 0,
101
+ );
102
+ if (entries.some(([, value]) => typeof value !== "string")) {
103
+ throw new TypeError("Encryption context values must be strings.");
104
+ }
105
+ return encoder.encode(JSON.stringify([envelopePrefix, entries]));
106
+ }
107
+
108
+ /** Generate a new 256-bit key. Store it in a server secret store; never log it. */
109
+ export function generateEncryptionKey(): string {
110
+ return `base64:${encodeBase64(crypto.getRandomValues(new Uint8Array(32)))}`;
111
+ }
112
+
113
+ /**
114
+ * Create authenticated string encryption using Web Crypto AES-256-GCM.
115
+ * Configuration is validated synchronously. Each write uses a random 96-bit IV
116
+ * and a 128-bit authentication tag. Previous keys are used only for reads.
117
+ * No environment variables are read and no plaintext, keys, or context are logged.
118
+ */
119
+ export function createEncryption(
120
+ options: CreateEncryptionOptions,
121
+ ): EncryptionPort {
122
+ const current = decodeKey(options?.key, "Encryption key");
123
+ if (
124
+ options.previousKeys !== undefined &&
125
+ !Array.isArray(options.previousKeys)
126
+ ) {
127
+ throw new TypeError("Encryption previousKeys must be an array of keys.");
128
+ }
129
+ const rawKeys = [
130
+ current,
131
+ ...Array.from(options.previousKeys ?? [], (key, index) =>
132
+ decodeKey(key, `Encryption previousKeys[${index}]`),
133
+ ),
134
+ ];
135
+ const importedKeys: Array<Promise<CryptoKey> | undefined> = [];
136
+ const getKey = (index: number): Promise<CryptoKey> => {
137
+ const existing = importedKeys[index];
138
+ if (existing) return existing;
139
+ const imported = crypto.subtle.importKey(
140
+ "raw",
141
+ rawKeys[index],
142
+ "AES-GCM",
143
+ false,
144
+ ["encrypt", "decrypt"],
145
+ );
146
+ importedKeys[index] = imported;
147
+ return imported;
148
+ };
149
+
150
+ return {
151
+ async encrypt({ value, context }) {
152
+ if (typeof value !== "string")
153
+ throw new TypeError("Encryption value must be a string.");
154
+ const additionalData = authenticatedData(context);
155
+ const iv = crypto.getRandomValues(new Uint8Array(ivLength));
156
+ const ciphertext = new Uint8Array(
157
+ await crypto.subtle.encrypt(
158
+ { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
159
+ await getKey(0),
160
+ // JSON preserves all JavaScript strings, including lone UTF-16 surrogates.
161
+ encoder.encode(JSON.stringify(value)),
162
+ ),
163
+ );
164
+ const envelope = new Uint8Array(iv.length + ciphertext.length);
165
+ envelope.set(iv);
166
+ envelope.set(ciphertext, iv.length);
167
+ return `${envelopePrefix}${encodeBase64(envelope)}`;
168
+ },
169
+ async decrypt(options) {
170
+ try {
171
+ const { value, context } = options;
172
+ if (typeof value !== "string" || !value.startsWith(envelopePrefix))
173
+ throw new Error();
174
+ const envelope = decodeBase64(value.slice(envelopePrefix.length));
175
+ if (envelope.length < ivLength + tagLength + 2) throw new Error();
176
+ const additionalData = authenticatedData(context);
177
+ const iv = envelope.slice(0, ivLength);
178
+ const ciphertext = envelope.slice(ivLength);
179
+ for (let index = 0; index < rawKeys.length; index++) {
180
+ try {
181
+ const plaintext = await crypto.subtle.decrypt(
182
+ { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
183
+ await getKey(index),
184
+ ciphertext,
185
+ );
186
+ const parsed: unknown = JSON.parse(decoder.decode(plaintext));
187
+ if (typeof parsed === "string") return parsed;
188
+ } catch {
189
+ // Try only the configured key ring; never return unauthenticated data.
190
+ }
191
+ }
192
+ } catch {
193
+ // All decryption failures share one non-sensitive public error.
194
+ }
195
+ throw new EncryptionDecryptionError();
196
+ },
197
+ };
198
+ }
@@ -7,8 +7,21 @@ import {
7
7
  } from "../tracing/index.js";
8
8
  import {
9
9
  isEventPayloadParsed,
10
- markEventPayloadParsed,
10
+ isEventPayloadTransportStable,
11
+ markEventPayloadTransportStable,
11
12
  } from "./payload-state.js";
13
+ import {
14
+ EventTransportError,
15
+ type EventTransportValue,
16
+ eventTransportValuesEqual,
17
+ toEventTransportValue,
18
+ } from "./transport.js";
19
+
20
+ export {
21
+ EventTransportError,
22
+ type EventTransportErrorReason,
23
+ type EventTransportValue,
24
+ } from "./transport.js";
12
25
 
13
26
  /**
14
27
  * Any Standard Schema compatible validator.
@@ -299,6 +312,18 @@ export class EventValidationError extends Error {
299
312
  }
300
313
  }
301
314
 
315
+ /**
316
+ * Parsed runtime value and canonical JSON value for one event publication.
317
+ */
318
+ export interface PreparedEventPayload<E extends EventPayloadDef> {
319
+ /** Parsed Standard Schema output delivered to in-process listeners. */
320
+ readonly payload: InferEventPayload<E>;
321
+ /** Canonical JSON value written by serialized transports. */
322
+ readonly transportValue: EventTransportValue;
323
+ /** Complete publish metadata to forward to in-process subscribers. */
324
+ readonly publishOptions: EventPublishOptions;
325
+ }
326
+
302
327
  function formatPath(path: StandardSchemaV1.Issue["path"]): string {
303
328
  if (!path?.length) return "";
304
329
 
@@ -345,7 +370,9 @@ async function parsePayload<Schema extends StandardSchemaV1>(
345
370
  * Define a typed event.
346
371
  *
347
372
  * Event payloads are validated before publishing through `publishEvent(...)`
348
- * and before registered listeners run.
373
+ * and before registered listeners run. Producer helpers also require parsed
374
+ * output to be plain JSON that remains unchanged when validated again after a
375
+ * transport round trip.
349
376
  */
350
377
  export function defineEvent<
351
378
  Name extends string,
@@ -388,7 +415,101 @@ export async function parseEventPayload<E extends EventPayloadDef>(
388
415
  }
389
416
 
390
417
  /**
391
- * Validate an event payload and publish it through an event bus.
418
+ * Parse an event payload and prove that its canonical JSON survives transport.
419
+ *
420
+ * Custom event-bus providers must call this before publishing. The returned
421
+ * runtime payload is suitable for in-process listeners; `transportValue` is
422
+ * the exact JSON-safe value to encode for a serialized transport.
423
+ */
424
+ export async function prepareEventPayloadForTransport<
425
+ E extends EventPayloadDef,
426
+ >(
427
+ event: E,
428
+ payload: unknown,
429
+ options?: EventPublishOptions,
430
+ ): Promise<PreparedEventPayload<E>> {
431
+ const parsed = isEventPayloadParsed(event, payload, options)
432
+ ? (payload as InferEventPayload<E>)
433
+ : await parseEventPayload(event, payload);
434
+ const transportValue = toEventTransportValue(event.name, parsed);
435
+ let canonicalPayload = parsed;
436
+ let transportStateIsCurrent = false;
437
+
438
+ try {
439
+ transportStateIsCurrent = isEventPayloadTransportStable(
440
+ event,
441
+ parsed,
442
+ transportValue,
443
+ options,
444
+ );
445
+ } catch {
446
+ // Revalidate if a mutated or proxied payload can no longer be compared.
447
+ }
448
+
449
+ if (!transportStateIsCurrent) {
450
+ let reparsed: InferEventPayload<E>;
451
+ try {
452
+ reparsed = await parseEventPayload(event, transportValue);
453
+ } catch (error) {
454
+ throw new EventTransportError({
455
+ eventName: event.name,
456
+ reason: "not-stable",
457
+ message:
458
+ "is not transport-stable: its canonical JSON failed validation when decoded again. Event schemas must accept their own canonical JSON output.",
459
+ cause: error,
460
+ });
461
+ }
462
+
463
+ let reparsedTransportValue: EventTransportValue;
464
+ try {
465
+ reparsedTransportValue = toEventTransportValue(event.name, reparsed);
466
+ } catch (error) {
467
+ throw new EventTransportError({
468
+ eventName: event.name,
469
+ reason: "not-stable",
470
+ path: error instanceof EventTransportError ? error.path : undefined,
471
+ message:
472
+ "is not transport-stable: validating its canonical JSON produced a value that is not JSON-safe.",
473
+ cause: error,
474
+ });
475
+ }
476
+
477
+ if (!eventTransportValuesEqual(transportValue, reparsedTransportValue)) {
478
+ throw new EventTransportError({
479
+ eventName: event.name,
480
+ reason: "not-stable",
481
+ message:
482
+ "is not transport-stable: its canonical JSON changed when validated again. Event schema transforms must be idempotent.",
483
+ });
484
+ }
485
+ canonicalPayload = reparsed;
486
+ }
487
+
488
+ try {
489
+ return {
490
+ payload: canonicalPayload,
491
+ transportValue,
492
+ publishOptions: markEventPayloadTransportStable(
493
+ event,
494
+ canonicalPayload,
495
+ transportValue,
496
+ options,
497
+ ),
498
+ };
499
+ } catch (error) {
500
+ throw new EventTransportError({
501
+ eventName: event.name,
502
+ reason: "not-stable",
503
+ message:
504
+ "is not transport-stable: its canonical output could not be inspected consistently.",
505
+ cause: error,
506
+ });
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Validate an event payload, prove transport stability, and publish it through
512
+ * an event bus.
392
513
  */
393
514
  export async function publishEvent<E extends EventPayloadDef>(
394
515
  eventBus: EventBusLike,
@@ -396,8 +517,12 @@ export async function publishEvent<E extends EventPayloadDef>(
396
517
  payload: InferEventPayload<E>,
397
518
  options?: EventPublishOptions,
398
519
  ): Promise<void> {
399
- const parsed = await parseEventPayload(event, payload);
400
- await eventBus.publish(event, parsed, markEventPayloadParsed(options));
520
+ const prepared = await prepareEventPayloadForTransport(
521
+ event,
522
+ payload,
523
+ options,
524
+ );
525
+ await eventBus.publish(event, prepared.payload, prepared.publishOptions);
401
526
  }
402
527
 
403
528
  function assertReadyTimeoutMs(value: number): void {
@@ -490,9 +615,11 @@ export function registerListeners<Ctx>(
490
615
  listener.event,
491
616
  async (rawPayload, publishOptions) => {
492
617
  try {
493
- const payload = isEventPayloadParsed(publishOptions)
494
- ? rawPayload
495
- : await parseEventPayload(listener.event, rawPayload);
618
+ const prepared = await prepareEventPayloadForTransport(
619
+ listener.event,
620
+ rawPayload,
621
+ publishOptions,
622
+ );
496
623
  const traceAttributes = {
497
624
  "beignet.listener.name": listener.name,
498
625
  "beignet.event.name": listener.event.name,
@@ -504,14 +631,14 @@ export function registerListeners<Ctx>(
504
631
  name: `beignet.listener ${listener.name}`,
505
632
  type: "listener",
506
633
  kind: "consumer",
507
- parent: parseTraceCarrier(publishOptions?.trace),
634
+ parent: parseTraceCarrier(prepared.publishOptions.trace),
508
635
  attributes: traceAttributes,
509
636
  metricAttributes: traceAttributes,
510
637
  },
511
638
  run: (ctx) =>
512
639
  listener.handle({
513
640
  event: listener.event,
514
- payload,
641
+ payload: prepared.payload,
515
642
  ctx,
516
643
  }),
517
644
  });
@@ -1,24 +1,223 @@
1
1
  import type { EventPublishOptions } from "./index.js";
2
+ import type { EventTransportValue } from "./transport.js";
2
3
 
3
4
  const PARSED_EVENT_PAYLOAD = Symbol.for("beignet.eventPayload.parsed");
5
+ const TRANSPORT_STABLE_EVENT_PAYLOAD = Symbol.for(
6
+ "beignet.eventPayload.transportStable",
7
+ );
4
8
 
5
9
  type ParsedEventPublishOptions = EventPublishOptions & {
6
- [PARSED_EVENT_PAYLOAD]?: true;
10
+ [PARSED_EVENT_PAYLOAD]?: object;
11
+ [TRANSPORT_STABLE_EVENT_PAYLOAD]?: object;
7
12
  };
8
13
 
14
+ interface ParsedPayloadState {
15
+ readonly eventName: string;
16
+ readonly payloadSchema: object;
17
+ readonly validate: unknown;
18
+ readonly payload: unknown;
19
+ }
20
+
21
+ type PayloadSnapshot =
22
+ | { readonly kind: "primitive"; readonly value: unknown }
23
+ | {
24
+ readonly kind: "array";
25
+ readonly value: object;
26
+ readonly prototype: object | null;
27
+ readonly items: readonly PayloadSnapshot[];
28
+ }
29
+ | {
30
+ readonly kind: "object";
31
+ readonly value: object;
32
+ readonly prototype: object | null;
33
+ readonly entries: readonly (readonly [string, PayloadSnapshot])[];
34
+ };
35
+
36
+ interface TransportStablePayloadState {
37
+ readonly eventName: string;
38
+ readonly payloadSchema: object;
39
+ readonly validate: unknown;
40
+ readonly payload: PayloadSnapshot;
41
+ readonly fingerprint: string;
42
+ }
43
+
44
+ interface EventPayloadIdentity {
45
+ readonly name: string;
46
+ readonly payload: {
47
+ readonly "~standard": {
48
+ readonly validate: unknown;
49
+ };
50
+ };
51
+ }
52
+
53
+ const transportStablePayloadStates = new WeakMap<
54
+ object,
55
+ TransportStablePayloadState
56
+ >();
57
+ const parsedPayloadStates = new WeakMap<object, ParsedPayloadState>();
58
+
59
+ function transportFingerprint(value: EventTransportValue): string {
60
+ return JSON.stringify(value);
61
+ }
62
+
63
+ function capturePayloadSnapshot(value: unknown): PayloadSnapshot {
64
+ if (value === null || typeof value !== "object") {
65
+ return { kind: "primitive", value };
66
+ }
67
+ if (Array.isArray(value)) {
68
+ const items: PayloadSnapshot[] = [];
69
+ for (let index = 0; index < value.length; index += 1) {
70
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
71
+ if (!descriptor || !("value" in descriptor)) {
72
+ throw new TypeError(
73
+ "Transport-stable array state must contain data properties.",
74
+ );
75
+ }
76
+ items.push(capturePayloadSnapshot(descriptor.value));
77
+ }
78
+ return {
79
+ kind: "array",
80
+ value,
81
+ prototype: Object.getPrototypeOf(value),
82
+ items,
83
+ };
84
+ }
85
+ return {
86
+ kind: "object",
87
+ value,
88
+ prototype: Object.getPrototypeOf(value),
89
+ entries: Reflect.ownKeys(value).map((key) => {
90
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
91
+ if (typeof key !== "string" || !descriptor || !("value" in descriptor)) {
92
+ throw new TypeError(
93
+ "Transport-stable payload state must contain data properties.",
94
+ );
95
+ }
96
+ return [key, capturePayloadSnapshot(descriptor.value)] as const;
97
+ }),
98
+ };
99
+ }
100
+
101
+ function matchesPayloadSnapshot(
102
+ snapshot: PayloadSnapshot,
103
+ value: unknown,
104
+ ): boolean {
105
+ if (snapshot.kind === "primitive") return Object.is(snapshot.value, value);
106
+ if (snapshot.value !== value || value === null || typeof value !== "object") {
107
+ return false;
108
+ }
109
+ if (Object.getPrototypeOf(value) !== snapshot.prototype) return false;
110
+ if (snapshot.kind === "array") {
111
+ return (
112
+ Array.isArray(value) &&
113
+ value.length === snapshot.items.length &&
114
+ snapshot.items.every((item, index) => {
115
+ const descriptor = Object.getOwnPropertyDescriptor(
116
+ value,
117
+ String(index),
118
+ );
119
+ return (
120
+ descriptor !== undefined &&
121
+ "value" in descriptor &&
122
+ matchesPayloadSnapshot(item, descriptor.value)
123
+ );
124
+ })
125
+ );
126
+ }
127
+ if (Array.isArray(value)) return false;
128
+ const keys = Reflect.ownKeys(value);
129
+ return (
130
+ keys.length === snapshot.entries.length &&
131
+ snapshot.entries.every(([key, item], index) => {
132
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
133
+ return (
134
+ keys[index] === key &&
135
+ descriptor !== undefined &&
136
+ "value" in descriptor &&
137
+ matchesPayloadSnapshot(item, descriptor.value)
138
+ );
139
+ })
140
+ );
141
+ }
142
+
9
143
  export function markEventPayloadParsed(
144
+ event: EventPayloadIdentity,
145
+ payload: unknown,
10
146
  options?: EventPublishOptions,
11
147
  ): EventPublishOptions {
148
+ const token = {};
149
+ parsedPayloadStates.set(token, {
150
+ eventName: event.name,
151
+ payloadSchema: event.payload,
152
+ validate: event.payload["~standard"].validate,
153
+ payload,
154
+ });
12
155
  return {
13
156
  ...options,
14
- [PARSED_EVENT_PAYLOAD]: true,
157
+ [PARSED_EVENT_PAYLOAD]: token,
15
158
  } as ParsedEventPublishOptions;
16
159
  }
17
160
 
18
- export function isEventPayloadParsed(options?: EventPublishOptions): boolean {
161
+ export function isEventPayloadParsed(
162
+ event: EventPayloadIdentity,
163
+ payload: unknown,
164
+ options?: EventPublishOptions,
165
+ ): boolean {
166
+ const token = (options as ParsedEventPublishOptions | undefined)?.[
167
+ PARSED_EVENT_PAYLOAD
168
+ ];
169
+ const state = token ? parsedPayloadStates.get(token) : undefined;
170
+ return (
171
+ state?.eventName === event.name &&
172
+ state.payloadSchema === event.payload &&
173
+ state.validate === event.payload["~standard"].validate &&
174
+ Object.is(state.payload, payload)
175
+ );
176
+ }
177
+
178
+ export function markEventPayloadTransportStable(
179
+ event: EventPayloadIdentity,
180
+ payload: unknown,
181
+ transportValue: EventTransportValue,
182
+ options?: EventPublishOptions,
183
+ ): EventPublishOptions {
184
+ const parsedToken = {};
185
+ parsedPayloadStates.set(parsedToken, {
186
+ eventName: event.name,
187
+ payloadSchema: event.payload,
188
+ validate: event.payload["~standard"].validate,
189
+ payload,
190
+ });
191
+ const stableToken = {};
192
+ transportStablePayloadStates.set(stableToken, {
193
+ eventName: event.name,
194
+ payloadSchema: event.payload,
195
+ validate: event.payload["~standard"].validate,
196
+ payload: capturePayloadSnapshot(payload),
197
+ fingerprint: transportFingerprint(transportValue),
198
+ });
199
+ return {
200
+ ...options,
201
+ [PARSED_EVENT_PAYLOAD]: parsedToken,
202
+ [TRANSPORT_STABLE_EVENT_PAYLOAD]: stableToken,
203
+ } as ParsedEventPublishOptions;
204
+ }
205
+
206
+ export function isEventPayloadTransportStable(
207
+ event: EventPayloadIdentity,
208
+ payload: unknown,
209
+ transportValue: EventTransportValue,
210
+ options?: EventPublishOptions,
211
+ ): boolean {
212
+ const token = (options as ParsedEventPublishOptions | undefined)?.[
213
+ TRANSPORT_STABLE_EVENT_PAYLOAD
214
+ ];
215
+ const state = token ? transportStablePayloadStates.get(token) : undefined;
19
216
  return (
20
- (options as ParsedEventPublishOptions | undefined)?.[
21
- PARSED_EVENT_PAYLOAD
22
- ] === true
217
+ state?.eventName === event.name &&
218
+ state.payloadSchema === event.payload &&
219
+ state.validate === event.payload["~standard"].validate &&
220
+ matchesPayloadSnapshot(state.payload, payload) &&
221
+ state.fingerprint === transportFingerprint(transportValue)
23
222
  );
24
223
  }