@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
@@ -64,6 +64,25 @@ export type LeaseRenewOptions = {
64
64
  ttlMs?: number;
65
65
  };
66
66
 
67
+ /**
68
+ * State required to restore a lease handle in another runtime invocation.
69
+ */
70
+ export type LeaseRestoreOptions = {
71
+ /**
72
+ * TTL to use when `renew()` is called without an explicit override.
73
+ */
74
+ ttlMs: number;
75
+ /**
76
+ * Persisted expiry when the caller has it. Omitted values stay unknown.
77
+ */
78
+ expiresAt?: Date;
79
+ /**
80
+ * Persisted fencing token when the caller has it. Omitted values stay
81
+ * unknown rather than being fabricated.
82
+ */
83
+ fencingToken?: string | number;
84
+ };
85
+
67
86
  /**
68
87
  * Active lease handle returned by a lock provider.
69
88
  */
@@ -100,7 +119,11 @@ export type LocksPort = {
100
119
  options: LeaseAcquireOptions,
101
120
  fn: (ctx: { lease: LeaseHandle }) => MaybePromise<T>,
102
121
  ): Promise<T | undefined>;
103
- restore(key: string, ownerToken: string): LeaseHandle;
122
+ restore(
123
+ key: string,
124
+ ownerToken: string,
125
+ options: LeaseRestoreOptions,
126
+ ): LeaseHandle;
104
127
  forceRelease(key: string): Promise<boolean>;
105
128
  };
106
129
 
@@ -220,20 +243,21 @@ export function createMemoryLocks(
220
243
  await result.lease.release();
221
244
  }
222
245
  },
223
- restore(key, ownerToken) {
246
+ restore(key, ownerToken, restoreOptions) {
224
247
  if (!key) throw new LeaseOptionsError("Lease key is required.");
225
248
  if (!ownerToken) {
226
249
  throw new LeaseOptionsError("Lease owner token is required.");
227
250
  }
251
+ validateRestoreOptions(restoreOptions);
228
252
 
229
253
  return createMemoryLease(
230
254
  port,
231
255
  {
232
256
  key,
233
257
  ownerToken,
234
- expiresAt: new Date(0),
235
- ttlMs: 1,
236
- fencingToken: 0,
258
+ ttlMs: restoreOptions.ttlMs,
259
+ expiresAt: restoreOptions.expiresAt,
260
+ fencingToken: restoreOptions.fencingToken,
237
261
  },
238
262
  now,
239
263
  );
@@ -306,32 +330,40 @@ export function withLease<T>(
306
330
 
307
331
  function createMemoryLease(
308
332
  port: MemoryLocksPort,
309
- record: MemoryLeaseRecord,
333
+ record: {
334
+ key: string;
335
+ ownerToken: string;
336
+ ttlMs: number;
337
+ expiresAt?: Date;
338
+ fencingToken?: string | number;
339
+ },
310
340
  now: () => Date,
311
341
  ): LeaseHandle {
312
342
  const lease: LeaseHandle = {
313
343
  key: record.key,
314
344
  ownerToken: record.ownerToken,
315
- expiresAt: record.expiresAt,
316
- fencingToken: record.fencingToken,
345
+ ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
346
+ ...(record.fencingToken !== undefined
347
+ ? { fencingToken: record.fencingToken }
348
+ : {}),
317
349
  async renew(options) {
318
350
  const active = port.leases.get(record.key);
319
- if (
320
- !active ||
321
- active.ownerToken !== record.ownerToken ||
322
- active.expiresAt.getTime() <= now().getTime()
323
- ) {
351
+ if (!active || active.ownerToken !== record.ownerToken) {
352
+ return false;
353
+ }
354
+ if (active.expiresAt.getTime() <= now().getTime()) {
324
355
  port.leases.delete(record.key);
325
356
  return false;
326
357
  }
327
358
 
328
- const ttlMs = options?.ttlMs ?? active.ttlMs;
359
+ const ttlMs = options?.ttlMs ?? record.ttlMs;
329
360
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
330
361
  throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
331
362
  }
332
363
 
333
364
  active.ttlMs = ttlMs;
334
365
  active.expiresAt = new Date(now().getTime() + ttlMs);
366
+ record.ttlMs = ttlMs;
335
367
  lease.expiresAt = active.expiresAt;
336
368
  return true;
337
369
  },
@@ -352,6 +384,23 @@ function createMemoryLease(
352
384
  return lease;
353
385
  }
354
386
 
387
+ function validateRestoreOptions(options: LeaseRestoreOptions): void {
388
+ if (!options || !Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
389
+ throw new LeaseOptionsError(
390
+ "Lease restore ttlMs must be a positive number.",
391
+ );
392
+ }
393
+ if (
394
+ options.expiresAt !== undefined &&
395
+ (!(options.expiresAt instanceof Date) ||
396
+ !Number.isFinite(options.expiresAt.getTime()))
397
+ ) {
398
+ throw new LeaseOptionsError(
399
+ "Lease restore expiresAt must be a valid Date.",
400
+ );
401
+ }
402
+ }
403
+
355
404
  function instrumentLocks(
356
405
  locks: LocksPort,
357
406
  instrumentation: ReturnType<typeof createProviderInstrumentation>,
@@ -387,8 +436,8 @@ function instrumentLocks(
387
436
  await result.lease.release();
388
437
  }
389
438
  },
390
- restore(key, ownerToken) {
391
- return locks.restore(key, ownerToken);
439
+ restore(key, ownerToken, options) {
440
+ return locks.restore(key, ownerToken, options);
392
441
  },
393
442
  async forceRelease(key) {
394
443
  const released = await locks.forceRelease(key);
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  type EventPayloadDef,
10
+ type EventPublishOptions,
10
11
  type InferEventPayload,
11
12
  parseEventPayload,
12
13
  } from "../events/index.js";
@@ -21,15 +22,20 @@ import {
21
22
  shouldRetryJob,
22
23
  } from "../jobs/index.js";
23
24
  import type { JobDispatcherPort } from "../ports/events.js";
24
- import type {
25
- BufferedDomainEventRecorder,
26
- DomainEventRecorderPort,
27
- } from "../ports/unit-of-work.js";
25
+ import type { DomainEventRecorderPort } from "../ports/unit-of-work.js";
28
26
  import {
29
27
  type BaseProviderInstrumentationEvent,
30
28
  createProviderInstrumentation,
31
29
  type ProviderInstrumentationTarget,
32
30
  } from "../providers/index.js";
31
+ import {
32
+ captureTraceCarrier,
33
+ parseTraceCarrier,
34
+ resolveTracingPort,
35
+ runWithTracing,
36
+ type TraceCarrier,
37
+ type TracingPort,
38
+ } from "../tracing/index.js";
33
39
 
34
40
  /**
35
41
  * Value or promise of that value.
@@ -113,6 +119,8 @@ export interface OutboxEnqueueInput {
113
119
  * JSON-serializable payload.
114
120
  */
115
121
  payload: OutboxJsonValue;
122
+ /** Versioned trace context captured when the message was recorded. */
123
+ trace?: TraceCarrier;
116
124
  /**
117
125
  * Earliest time the message may be claimed.
118
126
  */
@@ -143,6 +151,8 @@ export interface OutboxMessage {
143
151
  * JSON-serializable payload.
144
152
  */
145
153
  payload: OutboxJsonValue;
154
+ /** Versioned trace context captured when the message was recorded. */
155
+ trace?: TraceCarrier;
146
156
  /**
147
157
  * Current delivery status.
148
158
  */
@@ -490,6 +500,10 @@ export interface EnqueueTypedOutboxOptions {
490
500
  * Maximum delivery attempts before dead-lettering.
491
501
  */
492
502
  maxAttempts?: number;
503
+ /** Explicit trace context to persist with this message. */
504
+ trace?: TraceCarrier;
505
+ /** Tracing port used to capture the active context at enqueue time. */
506
+ tracing?: TracingPort;
493
507
  }
494
508
 
495
509
  /**
@@ -547,6 +561,7 @@ export interface DrainOutboxOptions {
547
561
  publish<E extends EventPayloadDef>(
548
562
  event: E,
549
563
  payload: InferEventPayload<E>,
564
+ options?: EventPublishOptions,
550
565
  ): MaybePromise<void>;
551
566
  };
552
567
  /**
@@ -592,6 +607,23 @@ export interface DrainOutboxOptions {
592
607
  error: unknown,
593
608
  message: ClaimedOutboxMessage,
594
609
  ) => MaybePromise<void>;
610
+ /**
611
+ * Observer called after a failed delivery is successfully moved to the dead
612
+ * letter state. Observer failures are ignored.
613
+ */
614
+ onDeadLetter?: (
615
+ error: unknown,
616
+ message: ClaimedOutboxMessage,
617
+ ) => MaybePromise<void>;
618
+ /**
619
+ * Observer called when a failed delivery cannot be settled as retryable or
620
+ * dead-lettered. Observer failures are ignored.
621
+ */
622
+ onSettlementError?: (
623
+ settlementError: unknown,
624
+ message: ClaimedOutboxMessage,
625
+ deliveryError: unknown,
626
+ ) => MaybePromise<void>;
595
627
  }
596
628
 
597
629
  /**
@@ -921,11 +953,13 @@ export function createOutboxMessage(
921
953
  }
922
954
 
923
955
  const now = options.now ?? new Date();
956
+ const trace = parseTraceCarrier(input.trace);
924
957
  return {
925
958
  id: options.id ?? input.id ?? options.createId?.() ?? createId(),
926
959
  kind: input.kind,
927
960
  name: input.name,
928
961
  payload: toOutboxJsonValue(input.payload),
962
+ ...(trace ? { trace } : {}),
929
963
  status: "pending",
930
964
  attempts: 0,
931
965
  maxAttempts: input.maxAttempts ?? DEFAULT_OUTBOX_MAX_ATTEMPTS,
@@ -1236,11 +1270,14 @@ export async function enqueueEvent<E extends EventPayloadDef>(
1236
1270
  options: EnqueueTypedOutboxOptions = {},
1237
1271
  ): Promise<OutboxMessage> {
1238
1272
  const parsed = await parseEventPayload(event, payload);
1273
+ const trace =
1274
+ parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
1239
1275
  return outbox.enqueue({
1240
1276
  id: options.id,
1241
1277
  kind: "event",
1242
1278
  name: event.name,
1243
1279
  payload: toOutboxJsonValue(parsed),
1280
+ trace,
1244
1281
  availableAt: options.availableAt,
1245
1282
  maxAttempts: options.maxAttempts,
1246
1283
  });
@@ -1256,11 +1293,14 @@ export async function enqueueJob<J extends JobDef>(
1256
1293
  options: EnqueueTypedOutboxOptions = {},
1257
1294
  ): Promise<OutboxMessage> {
1258
1295
  const parsed = await parseJobPayload(job, payload);
1296
+ const trace =
1297
+ parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
1259
1298
  return outbox.enqueue({
1260
1299
  id: options.id,
1261
1300
  kind: "job",
1262
1301
  name: job.name,
1263
1302
  payload: toOutboxJsonValue(parsed),
1303
+ trace,
1264
1304
  availableAt: options.availableAt,
1265
1305
  maxAttempts: options.maxAttempts ?? getJobRetryMaxAttempts(job.retry),
1266
1306
  });
@@ -1272,16 +1312,14 @@ export async function enqueueJob<J extends JobDef>(
1272
1312
  export function createOutboxEventRecorder(
1273
1313
  outbox: OutboxPort,
1274
1314
  options: EnqueueTypedOutboxOptions = {},
1275
- ): BufferedDomainEventRecorder {
1315
+ ): DomainEventRecorderPort {
1276
1316
  return {
1277
- async record(event, payload) {
1278
- await enqueueEvent(outbox, event, payload, options);
1279
- },
1280
- entries() {
1281
- return [];
1317
+ async record(event, payload, publishOptions) {
1318
+ await enqueueEvent(outbox, event, payload, {
1319
+ ...options,
1320
+ trace: publishOptions?.trace ?? options.trace,
1321
+ });
1282
1322
  },
1283
- clear() {},
1284
- async flush() {},
1285
1323
  };
1286
1324
  }
1287
1325
 
@@ -1293,8 +1331,11 @@ export function createOutboxJobDispatcher(
1293
1331
  options: EnqueueTypedOutboxOptions = {},
1294
1332
  ): JobDispatcherPort {
1295
1333
  return {
1296
- async dispatch(job, payload) {
1297
- await enqueueJob(outbox, job, payload, options);
1334
+ async dispatch(job, payload, dispatchOptions) {
1335
+ await enqueueJob(outbox, job, payload, {
1336
+ ...options,
1337
+ trace: dispatchOptions?.trace ?? options.trace,
1338
+ });
1298
1339
  },
1299
1340
  };
1300
1341
  }
@@ -1365,6 +1406,7 @@ function outboxInstrumentationDetails(
1365
1406
  async function deliverOutboxMessage(
1366
1407
  options: DrainOutboxOptions,
1367
1408
  message: ClaimedOutboxMessage,
1409
+ trace?: TraceCarrier,
1368
1410
  ): Promise<void> {
1369
1411
  if (message.kind === "event") {
1370
1412
  if (!options.eventBus) {
@@ -1381,7 +1423,11 @@ async function deliverOutboxMessage(
1381
1423
  }
1382
1424
 
1383
1425
  const payload = await parseEventPayload(event, message.payload);
1384
- await options.eventBus.publish(event, payload);
1426
+ await options.eventBus.publish(
1427
+ event,
1428
+ payload,
1429
+ trace ? { trace } : undefined,
1430
+ );
1385
1431
  return;
1386
1432
  }
1387
1433
 
@@ -1414,11 +1460,12 @@ async function deliverOutboxMessage(
1414
1460
  await singleAttempt(job, payload, {
1415
1461
  attempt: message.attempts,
1416
1462
  maxAttempts: message.maxAttempts,
1463
+ trace,
1417
1464
  });
1418
1465
  return;
1419
1466
  }
1420
1467
 
1421
- await options.jobs.dispatch(job, payload);
1468
+ await options.jobs.dispatch(job, payload, trace ? { trace } : undefined);
1422
1469
  }
1423
1470
 
1424
1471
  /**
@@ -1448,6 +1495,7 @@ export async function drainOutbox(
1448
1495
  watcher: "jobs",
1449
1496
  },
1450
1497
  );
1498
+ const tracing = resolveTracingPort(options.instrumentation);
1451
1499
 
1452
1500
  const now = options.now ?? new Date();
1453
1501
  const messages = await options.outbox.claimBatch({
@@ -1464,7 +1512,28 @@ export async function drainOutbox(
1464
1512
 
1465
1513
  for (const message of messages) {
1466
1514
  try {
1467
- await deliverOutboxMessage(options, message);
1515
+ const parentTrace = parseTraceCarrier(message.trace);
1516
+ const traceAttributes = {
1517
+ "beignet.outbox.message_kind": message.kind,
1518
+ "beignet.outbox.message_name": message.name,
1519
+ } as const;
1520
+ await runWithTracing(
1521
+ tracing,
1522
+ {
1523
+ name: `beignet.outbox deliver ${message.name}`,
1524
+ type: "outbox",
1525
+ kind: "consumer",
1526
+ parent: parentTrace,
1527
+ attributes: traceAttributes,
1528
+ metricAttributes: traceAttributes,
1529
+ },
1530
+ (span) =>
1531
+ deliverOutboxMessage(
1532
+ options,
1533
+ message,
1534
+ captureTraceCarrier(span?.context ?? parentTrace),
1535
+ ),
1536
+ );
1468
1537
  await options.outbox.markDelivered({
1469
1538
  id: message.id,
1470
1539
  claimToken: message.claimToken,
@@ -1492,18 +1561,41 @@ export async function drainOutbox(
1492
1561
  const retryDelayMs = deadLetter
1493
1562
  ? 0
1494
1563
  : resolveRetryDelayMs(options, message, error, now);
1495
- await options.outbox.markFailed({
1496
- id: message.id,
1497
- claimToken: message.claimToken,
1498
- error,
1499
- deadLetter,
1500
- now,
1501
- retryAt: deadLetter
1502
- ? undefined
1503
- : new Date(now.getTime() + retryDelayMs),
1504
- });
1564
+ try {
1565
+ await options.outbox.markFailed({
1566
+ id: message.id,
1567
+ claimToken: message.claimToken,
1568
+ error,
1569
+ deadLetter,
1570
+ now,
1571
+ retryAt: deadLetter
1572
+ ? undefined
1573
+ : new Date(now.getTime() + retryDelayMs),
1574
+ });
1575
+ } catch (settlementError) {
1576
+ try {
1577
+ await options.onSettlementError?.(settlementError, message, error);
1578
+ } catch {
1579
+ // Preserve the settlement failure when its observer also fails.
1580
+ }
1581
+ instrumentation.custom({
1582
+ name: "outbox.settlement.failed",
1583
+ label: "Outbox settlement failed",
1584
+ summary: `Could not settle failed ${message.kind} "${message.name}"`,
1585
+ details: outboxInstrumentationDetails(message, {
1586
+ deliveryError: serializeOutboxError(error),
1587
+ settlementError: serializeOutboxError(settlementError),
1588
+ }),
1589
+ });
1590
+ continue;
1591
+ }
1505
1592
 
1506
1593
  if (deadLetter) {
1594
+ try {
1595
+ await options.onDeadLetter?.(error, message);
1596
+ } catch {
1597
+ // Dead-letter observers must not change the settled message state.
1598
+ }
1507
1599
  instrumentation.record({
1508
1600
  type: "outbox",
1509
1601
  ...options.instrumentationContext,
@@ -1,11 +1,13 @@
1
1
  import type {
2
2
  EventPayloadDef,
3
+ EventPublishOptions,
3
4
  InferEventPayload as InferContractEventPayload,
4
5
  StandardSchema,
5
6
  } from "../events/index.js";
6
7
  import type {
7
8
  JobDef as ContractJobDef,
8
9
  InferJobPayload as InferContractJobPayload,
10
+ JobDispatchOptions,
9
11
  } from "../jobs/index.js";
10
12
 
11
13
  /**
@@ -74,6 +76,7 @@ export interface EventBusPort {
74
76
  publish<E extends DomainEventDef>(
75
77
  event: E,
76
78
  payload: InferEventPayload<E>,
79
+ options?: EventPublishOptions,
77
80
  ): Promise<void> | void;
78
81
 
79
82
  /**
@@ -81,7 +84,10 @@ export interface EventBusPort {
81
84
  */
82
85
  subscribe<E extends DomainEventDef>(
83
86
  event: E,
84
- handler: (payload: InferEventPayload<E>) => Promise<void> | void,
87
+ handler: (
88
+ payload: InferEventPayload<E>,
89
+ options?: EventPublishOptions,
90
+ ) => Promise<void> | void,
85
91
  ): () => void;
86
92
  }
87
93
 
@@ -96,5 +102,6 @@ export interface JobDispatcherPort {
96
102
  dispatch<J extends JobDef>(
97
103
  job: J,
98
104
  payload: InferJobPayload<J>,
105
+ options?: JobDispatchOptions,
99
106
  ): Promise<void> | void;
100
107
  }
@@ -75,7 +75,7 @@ export type DeferredPortsDefinition<
75
75
  * Define the set of ports (outbound dependencies) for your application.
76
76
  *
77
77
  * The identity form captures the exact shape of the provided `ports` object
78
- * so you can export `type AppPorts = typeof appPorts`.
78
+ * so you can export `type AppPorts = typeof initialPorts`.
79
79
  *
80
80
  * The curried form, `definePorts<AppPorts>()({ bound, deferred })`, declares
81
81
  * which port keys providers contribute at server startup. Deferred keys boot
@@ -86,14 +86,14 @@ export type DeferredPortsDefinition<
86
86
  * @example
87
87
  * ```ts
88
88
  * // Identity form: every port is bound directly.
89
- * const appPorts = definePorts({
89
+ * const initialPorts = definePorts({
90
90
  * db: dbAdapter,
91
91
  * mailer: mailerAdapter,
92
92
  * });
93
- * export type AppPorts = typeof appPorts;
93
+ * export type AppPorts = typeof initialPorts;
94
94
  *
95
95
  * // Deferred form: providers contribute the rest at startup.
96
- * export const appPorts = definePorts<AppPorts>()({
96
+ * export const initialPorts = definePorts<AppPorts>()({
97
97
  * bound: { gate },
98
98
  * deferred: ["db", "mailer", "storage"],
99
99
  * });
@@ -13,7 +13,7 @@ function unboundPortMessage(portName: string): string {
13
13
  return (
14
14
  `Port "${portName}" is not bound. "${portName}" is declared as deferred ` +
15
15
  "in definePorts(...). Register a provider that contributes it " +
16
- "(server/providers.ts) or bind it in infra/app-ports.ts."
16
+ "(server/providers.ts) or bind it in infra/port-wiring.ts."
17
17
  );
18
18
  }
19
19
 
@@ -1,4 +1,7 @@
1
- import { parseEventPayload } from "../events/index.js";
1
+ import {
2
+ type EventPublishOptions,
3
+ parseEventPayload,
4
+ } from "../events/index.js";
2
5
  import type {
3
6
  DomainEventDef,
4
7
  EventBusPort,
@@ -71,6 +74,8 @@ export interface RecordedDomainEvent {
71
74
  * Unparsed payload recorded during the transaction.
72
75
  */
73
76
  payload: unknown;
77
+ /** Optional metadata propagated when the event is flushed. */
78
+ options?: EventPublishOptions;
74
79
  }
75
80
 
76
81
  /**
@@ -87,6 +92,7 @@ export interface DomainEventRecorderPort {
87
92
  record<E extends DomainEventDef>(
88
93
  event: E,
89
94
  payload: InferEventPayload<E>,
95
+ options?: EventPublishOptions,
90
96
  ): Promise<void> | void;
91
97
  }
92
98
 
@@ -167,11 +173,12 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
167
173
  const records: RecordedDomainEvent[] = [];
168
174
 
169
175
  return {
170
- record(event, payload) {
176
+ record(event, payload, options) {
171
177
  records.push({
172
178
  event,
173
179
  eventName: event.name,
174
180
  payload,
181
+ ...(options ? { options } : {}),
175
182
  });
176
183
  },
177
184
 
@@ -187,7 +194,7 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
187
194
  while (records.length > 0) {
188
195
  const record = records[0];
189
196
  const payload = await parseEventPayload(record.event, record.payload);
190
- await eventBus.publish(record.event, payload);
197
+ await eventBus.publish(record.event, payload, record.options);
191
198
  records.shift();
192
199
  }
193
200
  },
@@ -0,0 +1,130 @@
1
+ const QUERY_VALUE_PREFIX = "~beignet-query:";
2
+
3
+ type EncodedQueryValue =
4
+ | { type: "null" }
5
+ | { type: "string"; value: string }
6
+ | { type: "number"; value: number }
7
+ | { type: "boolean"; value: boolean }
8
+ | { type: "date"; value: string }
9
+ | { type: "array"; value: EncodedQueryValue[] }
10
+ | { type: "object"; value: Record<string, EncodedQueryValue> };
11
+
12
+ function encodeStructuredValue(
13
+ value: unknown,
14
+ ancestors: Set<object>,
15
+ ): EncodedQueryValue {
16
+ if (value === null) return { type: "null" };
17
+ if (typeof value === "string") return { type: "string", value };
18
+ if (typeof value === "number" && Number.isFinite(value)) {
19
+ return { type: "number", value };
20
+ }
21
+ if (typeof value === "boolean") return { type: "boolean", value };
22
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
23
+ return { type: "date", value: value.toISOString() };
24
+ }
25
+ if (typeof value !== "object" || value === null) {
26
+ throw new TypeError("Query value is not serializable.");
27
+ }
28
+ if (ancestors.has(value)) {
29
+ throw new TypeError("Query value contains a circular reference.");
30
+ }
31
+
32
+ ancestors.add(value);
33
+ try {
34
+ if (Array.isArray(value)) {
35
+ return {
36
+ type: "array",
37
+ value: value.map((entry) => encodeStructuredValue(entry, ancestors)),
38
+ };
39
+ }
40
+
41
+ const prototype = Object.getPrototypeOf(value);
42
+ if (prototype !== Object.prototype && prototype !== null) {
43
+ throw new TypeError("Query value contains a non-plain object.");
44
+ }
45
+ return {
46
+ type: "object",
47
+ value: Object.fromEntries(
48
+ Object.entries(value)
49
+ .filter(([, entry]) => entry !== undefined)
50
+ .map(([key, entry]) => [
51
+ key,
52
+ encodeStructuredValue(entry, ancestors),
53
+ ]),
54
+ ),
55
+ };
56
+ } finally {
57
+ ancestors.delete(value);
58
+ }
59
+ }
60
+
61
+ function decodeStructuredValue(value: unknown): unknown {
62
+ if (typeof value !== "object" || value === null || !("type" in value)) {
63
+ throw new TypeError("Invalid encoded query value.");
64
+ }
65
+ const encoded = value as { type: unknown; value?: unknown };
66
+
67
+ switch (encoded.type) {
68
+ case "null":
69
+ return null;
70
+ case "string":
71
+ if (typeof encoded.value === "string") return encoded.value;
72
+ break;
73
+ case "number":
74
+ if (typeof encoded.value === "number" && Number.isFinite(encoded.value)) {
75
+ return encoded.value;
76
+ }
77
+ break;
78
+ case "boolean":
79
+ if (typeof encoded.value === "boolean") return encoded.value;
80
+ break;
81
+ case "date": {
82
+ if (typeof encoded.value !== "string") break;
83
+ const date = new Date(encoded.value);
84
+ if (Number.isFinite(date.getTime())) return date;
85
+ break;
86
+ }
87
+ case "array":
88
+ if (Array.isArray(encoded.value)) {
89
+ return encoded.value.map(decodeStructuredValue);
90
+ }
91
+ break;
92
+ case "object":
93
+ if (
94
+ typeof encoded.value === "object" &&
95
+ encoded.value !== null &&
96
+ !Array.isArray(encoded.value)
97
+ ) {
98
+ return Object.fromEntries(
99
+ Object.entries(encoded.value).map(([key, entry]) => [
100
+ key,
101
+ decodeStructuredValue(entry),
102
+ ]),
103
+ );
104
+ }
105
+ break;
106
+ }
107
+
108
+ throw new TypeError("Invalid encoded query value.");
109
+ }
110
+
111
+ export function encodeQueryValue(value: unknown): string {
112
+ if (typeof value === "string" && !value.startsWith(QUERY_VALUE_PREFIX)) {
113
+ return value;
114
+ }
115
+ return `${QUERY_VALUE_PREFIX}${JSON.stringify(
116
+ encodeStructuredValue(value, new Set()),
117
+ )}`;
118
+ }
119
+
120
+ export function decodeQueryValue(value: string): unknown {
121
+ if (!value.startsWith(QUERY_VALUE_PREFIX)) return value;
122
+
123
+ try {
124
+ return decodeStructuredValue(
125
+ JSON.parse(value.slice(QUERY_VALUE_PREFIX.length)) as unknown,
126
+ );
127
+ } catch {
128
+ return value;
129
+ }
130
+ }