@beignet/core 0.0.33 → 0.0.34

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 (38) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +65 -15
  3. package/dist/contracts/contract-builder.d.ts +0 -4
  4. package/dist/contracts/contract-builder.d.ts.map +1 -1
  5. package/dist/contracts/contract-builder.js +0 -6
  6. package/dist/contracts/contract-builder.js.map +1 -1
  7. package/dist/flags/index.d.ts +0 -4
  8. package/dist/flags/index.d.ts.map +1 -1
  9. package/dist/flags/index.js +0 -4
  10. package/dist/flags/index.js.map +1 -1
  11. package/dist/notifications/index.d.ts +194 -10
  12. package/dist/notifications/index.d.ts.map +1 -1
  13. package/dist/notifications/index.js +397 -59
  14. package/dist/notifications/index.js.map +1 -1
  15. package/dist/ports/events.d.ts +2 -2
  16. package/dist/ports/index.d.ts +1 -1
  17. package/dist/ports/index.d.ts.map +1 -1
  18. package/dist/ports/index.js +1 -1
  19. package/dist/ports/index.js.map +1 -1
  20. package/dist/providers/provider.d.ts +1 -1
  21. package/dist/server/server.d.ts +0 -10
  22. package/dist/server/server.d.ts.map +1 -1
  23. package/dist/server/server.js +20 -10
  24. package/dist/server/server.js.map +1 -1
  25. package/dist/testing/index.d.ts +1 -1
  26. package/dist/testing/index.d.ts.map +1 -1
  27. package/dist/testing/index.js +1 -0
  28. package/dist/testing/index.js.map +1 -1
  29. package/package.json +1 -5
  30. package/skills/app-architecture/SKILL.md +8 -0
  31. package/src/contracts/contract-builder.ts +0 -7
  32. package/src/flags/index.ts +0 -5
  33. package/src/notifications/index.ts +678 -70
  34. package/src/ports/events.ts +2 -2
  35. package/src/ports/index.ts +0 -1
  36. package/src/providers/provider.ts +1 -1
  37. package/src/server/server.ts +2 -34
  38. package/src/testing/index.ts +2 -0
@@ -1,4 +1,13 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import {
3
+ createJobs,
4
+ type JobDef,
5
+ type JobDispatcher,
6
+ type JobHook,
7
+ type JobRetryOptions,
8
+ type JobTimeoutDuration,
9
+ retry,
10
+ } from "../jobs/index.js";
2
11
  import type { SendMailOptions } from "../mail/index.js";
3
12
  import type { ProviderInstrumentationTarget } from "../providers/index.js";
4
13
  import {
@@ -60,7 +69,7 @@ export interface NotificationChannelResult {
60
69
  /**
61
70
  * Delivery outcome for this channel.
62
71
  */
63
- status: "sent" | "skipped" | "failed";
72
+ status: "queued" | "sent" | "skipped" | "failed";
64
73
  /**
65
74
  * Provider delivery ID when available.
66
75
  */
@@ -79,6 +88,14 @@ export interface NotificationChannelResult {
79
88
  details?: Record<string, unknown>;
80
89
  }
81
90
 
91
+ /**
92
+ * Original error captured for one failed notification channel.
93
+ */
94
+ export interface NotificationChannelError {
95
+ channel: string;
96
+ error: unknown;
97
+ }
98
+
82
99
  /**
83
100
  * Arguments passed to a notification channel handler.
84
101
  */
@@ -119,6 +136,45 @@ export type NotificationChannels<Payload extends StandardSchema, Ctx> = Record<
119
136
  NotificationChannelHandler<Payload, Ctx>
120
137
  >;
121
138
 
139
+ /**
140
+ * Arguments passed to an app-owned notification preference evaluator.
141
+ */
142
+ export interface NotificationPreferenceArgs<
143
+ Payload extends StandardSchema = StandardSchema,
144
+ Ctx = unknown,
145
+ > extends NotificationChannelHandleArgs<Payload, Ctx> {
146
+ /**
147
+ * Optional metadata supplied by the notification sender.
148
+ */
149
+ metadata?: Record<string, unknown>;
150
+ }
151
+
152
+ /**
153
+ * App-owned decision for one notification channel.
154
+ */
155
+ export interface NotificationPreferenceDecision {
156
+ /**
157
+ * Whether this channel should deliver.
158
+ */
159
+ deliver: boolean;
160
+ /**
161
+ * Optional reason recorded when delivery is skipped.
162
+ */
163
+ reason?: string;
164
+ }
165
+
166
+ /**
167
+ * Optional app-facing port for notification channel preferences and opt-outs.
168
+ */
169
+ export interface NotificationPreferencesPort<Ctx = unknown> {
170
+ /**
171
+ * Evaluate the current preference immediately before channel delivery.
172
+ */
173
+ evaluate(
174
+ args: NotificationPreferenceArgs<StandardSchema, Ctx>,
175
+ ): MaybePromise<NotificationPreferenceDecision>;
176
+ }
177
+
122
178
  /**
123
179
  * Notification definition created by `defineNotification(...)`.
124
180
  */
@@ -237,13 +293,23 @@ export interface InlineNotificationDispatcherOptions<Ctx> {
237
293
  */
238
294
  ctx?: Ctx | (() => MaybePromise<Ctx>);
239
295
  /**
240
- * Called when a channel handler fails. When omitted, errors are rethrown to
241
- * the caller as `NotificationDeliveryError`.
296
+ * Called when a channel handler or preference check fails. A returned result
297
+ * replaces the default failed result. Observer failures are ignored so the
298
+ * remaining channels still run.
242
299
  */
243
300
  onError?: (
244
301
  error: unknown,
245
302
  args: NotificationChannelHandleArgs<StandardSchema, Ctx>,
246
303
  ) => MaybePromise<NotificationChannelResult | undefined>;
304
+ /**
305
+ * How completed channel failures are surfaced. Defaults to `"report"`.
306
+ * `"throw"` still runs every selected channel before rejecting.
307
+ */
308
+ failureMode?: "report" | "throw";
309
+ /**
310
+ * Optional app-owned notification preference evaluator.
311
+ */
312
+ preferences?: NotificationPreferencesPort<Ctx>;
247
313
  /**
248
314
  * Optional devtools/provider instrumentation target.
249
315
  */
@@ -349,6 +415,102 @@ export interface Notifications<Ctx> {
349
415
  ): NotificationDef<Name, Payload, Ctx>;
350
416
  }
351
417
 
418
+ /**
419
+ * Notification definitions available to durable delivery workers.
420
+ */
421
+ export interface NotificationRegistry<Ctx = unknown> {
422
+ /**
423
+ * Registered definitions in declaration order.
424
+ */
425
+ readonly definitions: readonly NotificationDef<string, StandardSchema, Ctx>[];
426
+ /**
427
+ * Resolve a notification definition by its stable name.
428
+ */
429
+ get(name: string): NotificationDef<string, StandardSchema, Ctx> | undefined;
430
+ }
431
+
432
+ /**
433
+ * Payload carried by the first-party notification delivery job.
434
+ */
435
+ export interface NotificationDeliveryJobPayload {
436
+ notificationName: string;
437
+ channel: string;
438
+ payload: unknown;
439
+ options: Omit<SendNotificationOptions, "channels">;
440
+ }
441
+
442
+ type NotificationDeliveryPayloadSchema = StandardSchemaV1<
443
+ unknown,
444
+ NotificationDeliveryJobPayload
445
+ >;
446
+
447
+ /**
448
+ * Job definition used by queued notification dispatchers and workers.
449
+ */
450
+ export interface NotificationDeliveryJob<
451
+ Name extends string = string,
452
+ Ctx = unknown,
453
+ > extends JobDef<Name, NotificationDeliveryPayloadSchema, Ctx> {
454
+ /**
455
+ * Registry used by both enqueue-time checks and worker delivery.
456
+ */
457
+ readonly registry: NotificationRegistry<Ctx>;
458
+ }
459
+
460
+ /**
461
+ * Options for the first-party notification delivery job.
462
+ */
463
+ export interface DefineNotificationDeliveryJobOptions<
464
+ Name extends string,
465
+ Ctx,
466
+ > {
467
+ /**
468
+ * Stable job name. Defaults to `"notifications.deliver"`.
469
+ */
470
+ name?: Name;
471
+ /**
472
+ * Notification definitions available to the worker.
473
+ */
474
+ registry: NotificationRegistry<Ctx>;
475
+ /**
476
+ * Optional app-owned preferences evaluated when the job runs.
477
+ */
478
+ preferences?: NotificationPreferencesPort<Ctx>;
479
+ /**
480
+ * Retry policy. Defaults to exponential backoff with three attempts.
481
+ */
482
+ retry?: JobRetryOptions;
483
+ /**
484
+ * Optional maximum duration for each channel delivery attempt.
485
+ */
486
+ timeout?: JobTimeoutDuration;
487
+ /**
488
+ * Optional execution hooks applied to each delivery attempt.
489
+ */
490
+ hooks?: readonly JobHook<
491
+ JobDef<Name, NotificationDeliveryPayloadSchema, Ctx>,
492
+ Ctx
493
+ >[];
494
+ }
495
+
496
+ /**
497
+ * Options for a notification dispatcher backed by Beignet jobs.
498
+ */
499
+ export interface QueuedNotificationDispatcherOptions<Name extends string, Ctx> {
500
+ /**
501
+ * Job dispatcher used to enqueue one delivery job per channel.
502
+ */
503
+ jobs: JobDispatcher;
504
+ /**
505
+ * Registered notification delivery job.
506
+ */
507
+ deliveryJob: NotificationDeliveryJob<Name, Ctx>;
508
+ /**
509
+ * Optional devtools/provider instrumentation target.
510
+ */
511
+ instrumentation?: ProviderInstrumentationTarget;
512
+ }
513
+
352
514
  /**
353
515
  * Error thrown when notification payload validation fails.
354
516
  */
@@ -379,28 +541,57 @@ export class NotificationDeliveryError extends Error {
379
541
  */
380
542
  readonly notificationName: string;
381
543
  /**
382
- * Channel that failed.
544
+ * First channel that failed, retained for concise error handling.
383
545
  */
384
546
  readonly channel: string;
385
547
  /**
386
- * Original channel error.
548
+ * Original error for the first failed channel when available.
387
549
  */
388
550
  readonly cause: unknown;
551
+ /**
552
+ * Complete notification result after every selected channel ran.
553
+ */
554
+ readonly result: SendNotificationResult;
555
+ /**
556
+ * Failed channel results.
557
+ */
558
+ readonly failures: readonly NotificationChannelResult[];
559
+ /**
560
+ * Original channel errors in delivery order.
561
+ */
562
+ readonly errors: readonly NotificationChannelError[];
389
563
 
390
564
  constructor(args: {
391
- notificationName: string;
392
- channel: string;
393
- cause: unknown;
565
+ result: SendNotificationResult;
566
+ errors?: readonly NotificationChannelError[];
394
567
  }) {
568
+ const failures = args.result.results.filter(
569
+ (result) => result.status === "failed",
570
+ );
571
+ const firstFailure = failures[0];
572
+ const errors = args.errors ?? [];
395
573
  super(
396
- `Notification "${args.notificationName}" failed on channel "${args.channel}": ${
397
- args.cause instanceof Error ? args.cause.message : String(args.cause)
398
- }`,
574
+ `Notification "${args.result.notificationName}" failed on ${failures.length} channel${failures.length === 1 ? "" : "s"}: ${failures.map((failure) => failure.channel).join(", ")}.`,
399
575
  );
400
576
  this.name = "NotificationDeliveryError";
401
- this.notificationName = args.notificationName;
402
- this.channel = args.channel;
403
- this.cause = args.cause;
577
+ this.notificationName = args.result.notificationName;
578
+ this.channel = firstFailure?.channel ?? "unknown";
579
+ this.cause = errors.find(
580
+ (entry) => entry.channel === firstFailure?.channel,
581
+ )?.error;
582
+ this.result = args.result;
583
+ this.failures = failures;
584
+ this.errors = errors;
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Error thrown when a notification registry cannot safely resolve a delivery.
590
+ */
591
+ export class NotificationRegistryError extends Error {
592
+ constructor(message: string) {
593
+ super(message);
594
+ this.name = "NotificationRegistryError";
404
595
  }
405
596
  }
406
597
 
@@ -471,7 +662,7 @@ function resolveChannelNames(
471
662
  const selected = options?.channels ?? available;
472
663
 
473
664
  for (const channel of selected) {
474
- if (!notification.channels[channel]) {
665
+ if (typeof notification.channels[channel] !== "function") {
475
666
  throw new Error(
476
667
  `Notification "${notification.name}" does not define channel "${channel}".`,
477
668
  );
@@ -482,13 +673,75 @@ function resolveChannelNames(
482
673
  }
483
674
 
484
675
  function summarizeResults(results: readonly NotificationChannelResult[]) {
676
+ const queued = results.filter((result) => result.status === "queued").length;
485
677
  const sent = results.filter((result) => result.status === "sent").length;
486
678
  const skipped = results.filter(
487
679
  (result) => result.status === "skipped",
488
680
  ).length;
489
681
  const failed = results.filter((result) => result.status === "failed").length;
490
682
 
491
- return { sent, skipped, failed };
683
+ return { queued, sent, skipped, failed };
684
+ }
685
+
686
+ function resultSummary(results: readonly NotificationChannelResult[]): string {
687
+ const summary = summarizeResults(results);
688
+ const parts = [
689
+ summary.queued ? `${summary.queued} queued` : undefined,
690
+ `${summary.sent} sent`,
691
+ `${summary.skipped} skipped`,
692
+ `${summary.failed} failed`,
693
+ ].filter((part): part is string => Boolean(part));
694
+
695
+ return parts.join(", ");
696
+ }
697
+
698
+ function recordChannelResult(
699
+ instrumentation: ReturnType<typeof createProviderInstrumentation>,
700
+ notificationName: string,
701
+ result: NotificationChannelResult,
702
+ options: SendNotificationOptions,
703
+ ): void {
704
+ instrumentation.custom({
705
+ name: `notification.channel.${result.status}`,
706
+ label: `Notification channel ${result.status}`,
707
+ summary: `${notificationName} (${result.channel})`,
708
+ requestId: options.requestId,
709
+ traceId: options.traceId,
710
+ spanId: options.spanId,
711
+ parentSpanId: options.parentSpanId,
712
+ traceparent: options.traceparent,
713
+ details: {
714
+ notificationName,
715
+ channel: result.channel,
716
+ status: result.status,
717
+ id: result.id,
718
+ provider: result.provider,
719
+ reason: result.reason,
720
+ resultDetails: result.details,
721
+ metadata: options.metadata,
722
+ },
723
+ });
724
+ }
725
+
726
+ async function failedChannelResult<Ctx>(
727
+ error: unknown,
728
+ args: NotificationChannelHandleArgs<StandardSchema, Ctx>,
729
+ onError: InlineNotificationDispatcherOptions<Ctx>["onError"],
730
+ ): Promise<NotificationChannelResult> {
731
+ if (onError) {
732
+ try {
733
+ const handled = await onError(error, args);
734
+ if (handled) return { ...handled, channel: args.channel };
735
+ } catch {
736
+ // Error observers cannot interrupt delivery of the remaining channels.
737
+ }
738
+ }
739
+
740
+ return {
741
+ channel: args.channel,
742
+ status: "failed",
743
+ reason: error instanceof Error ? error.message : String(error),
744
+ };
492
745
  }
493
746
 
494
747
  function defineNotificationImpl<
@@ -508,6 +761,171 @@ function defineNotificationImpl<
508
761
  };
509
762
  }
510
763
 
764
+ function isRecord(value: unknown): value is Record<string, unknown> {
765
+ return typeof value === "object" && value !== null && !Array.isArray(value);
766
+ }
767
+
768
+ function contextInstrumentationTarget(
769
+ ctx: unknown,
770
+ ): ProviderInstrumentationTarget {
771
+ if (!isRecord(ctx)) return undefined;
772
+ return isRecord(ctx.ports) ? ctx.ports : ctx;
773
+ }
774
+
775
+ const notificationDeliveryPayloadSchema: NotificationDeliveryPayloadSchema = {
776
+ "~standard": {
777
+ version: 1,
778
+ vendor: "beignet",
779
+ validate(value: unknown) {
780
+ if (!isRecord(value)) {
781
+ return { issues: [{ message: "Expected an object." }] };
782
+ }
783
+
784
+ const issues: StandardSchemaV1.Issue[] = [];
785
+ if (
786
+ typeof value.notificationName !== "string" ||
787
+ value.notificationName.length === 0
788
+ ) {
789
+ issues.push({
790
+ message: "Expected a non-empty string.",
791
+ path: ["notificationName"],
792
+ });
793
+ }
794
+ if (typeof value.channel !== "string" || value.channel.length === 0) {
795
+ issues.push({
796
+ message: "Expected a non-empty string.",
797
+ path: ["channel"],
798
+ });
799
+ }
800
+ if (!("payload" in value)) {
801
+ issues.push({ message: "Required.", path: ["payload"] });
802
+ }
803
+ if (!isRecord(value.options)) {
804
+ issues.push({ message: "Expected an object.", path: ["options"] });
805
+ }
806
+
807
+ const deliveryOptions = isRecord(value.options) ? value.options : {};
808
+ const correlationFields = [
809
+ "requestId",
810
+ "traceId",
811
+ "spanId",
812
+ "parentSpanId",
813
+ "traceparent",
814
+ ] as const;
815
+ for (const field of correlationFields) {
816
+ if (
817
+ deliveryOptions[field] !== undefined &&
818
+ typeof deliveryOptions[field] !== "string"
819
+ ) {
820
+ issues.push({
821
+ message: "Expected a string.",
822
+ path: ["options", field],
823
+ });
824
+ }
825
+ }
826
+ if (
827
+ deliveryOptions.metadata !== undefined &&
828
+ !isRecord(deliveryOptions.metadata)
829
+ ) {
830
+ issues.push({
831
+ message: "Expected an object.",
832
+ path: ["options", "metadata"],
833
+ });
834
+ }
835
+
836
+ if (issues.length > 0) return { issues };
837
+
838
+ return {
839
+ value: {
840
+ notificationName: value.notificationName as string,
841
+ channel: value.channel as string,
842
+ payload: value.payload,
843
+ options: queuedSendOptions(deliveryOptions),
844
+ },
845
+ };
846
+ },
847
+ },
848
+ };
849
+
850
+ /**
851
+ * Define the notification catalog available to durable delivery workers.
852
+ * Duplicate names throw because queued delivery resolves definitions by name.
853
+ */
854
+ export function defineNotificationRegistry<Ctx>(
855
+ definitions: readonly NotificationDef<string, StandardSchema, Ctx>[],
856
+ ): NotificationRegistry<Ctx> {
857
+ const byName = new Map<
858
+ string,
859
+ NotificationDef<string, StandardSchema, Ctx>
860
+ >();
861
+
862
+ for (const notification of definitions) {
863
+ if (byName.has(notification.name)) {
864
+ throw new NotificationRegistryError(
865
+ `Duplicate notification definition "${notification.name}" in notification registry.`,
866
+ );
867
+ }
868
+ byName.set(notification.name, notification);
869
+ }
870
+
871
+ return {
872
+ definitions: [...definitions],
873
+ get(name) {
874
+ return byName.get(name);
875
+ },
876
+ };
877
+ }
878
+
879
+ /**
880
+ * Define the generic job that resolves and delivers one notification channel.
881
+ * Register the returned job with every worker or outbox registry that can
882
+ * receive queued notifications.
883
+ */
884
+ export function defineNotificationDeliveryJob<
885
+ Ctx,
886
+ Name extends string = "notifications.deliver",
887
+ >(
888
+ options: DefineNotificationDeliveryJobOptions<Name, Ctx>,
889
+ ): NotificationDeliveryJob<Name, Ctx> {
890
+ const name = (options.name ?? "notifications.deliver") as Name;
891
+ const { defineJob } = createJobs<Ctx>();
892
+ const job = defineJob(name, {
893
+ payload: notificationDeliveryPayloadSchema,
894
+ description: "Deliver one notification channel.",
895
+ retry: options.retry ?? retry.exponential({ attempts: 3 }),
896
+ timeout: options.timeout,
897
+ hooks: options.hooks,
898
+ async handle({ payload, ctx }) {
899
+ const notification = options.registry.get(payload.notificationName);
900
+ if (!notification) {
901
+ throw new NotificationRegistryError(
902
+ `Notification "${payload.notificationName}" is not registered for queued delivery.`,
903
+ );
904
+ }
905
+ if (!notification.channels[payload.channel]) {
906
+ throw new NotificationRegistryError(
907
+ `Notification "${payload.notificationName}" does not define queued channel "${payload.channel}".`,
908
+ );
909
+ }
910
+
911
+ const dispatcher = createInlineNotificationDispatcher<Ctx>({
912
+ ctx,
913
+ preferences: options.preferences,
914
+ failureMode: "throw",
915
+ instrumentation: contextInstrumentationTarget(ctx),
916
+ });
917
+ await dispatcher.send(notification as NotificationDef, payload.payload, {
918
+ ...payload.options,
919
+ channels: [payload.channel],
920
+ });
921
+ },
922
+ });
923
+
924
+ return Object.assign(job, {
925
+ registry: options.registry,
926
+ });
927
+ }
928
+
511
929
  /**
512
930
  * Validate and parse a notification payload with the notification's Standard
513
931
  * Schema.
@@ -524,8 +942,9 @@ export async function parseNotificationPayload<
524
942
  * Create an inline notification dispatcher.
525
943
  *
526
944
  * The dispatcher validates payloads and runs selected channel handlers
527
- * immediately. Use this directly in tests and local apps, or wrap it in jobs
528
- * and outbox delivery for production background execution.
945
+ * immediately. Channel failures are isolated and reported after every selected
946
+ * channel runs. Use this directly in tests and local apps, or use
947
+ * `createQueuedNotificationDispatcher(...)` for background execution.
529
948
  */
530
949
  export function createInlineNotificationDispatcher<Ctx>(
531
950
  options: InlineNotificationDispatcherOptions<Ctx> = {},
@@ -548,6 +967,7 @@ export function createInlineNotificationDispatcher<Ctx>(
548
967
  const channels = resolveChannelNames(notification, sendOptions);
549
968
  const ctx = await resolveCtx(options.ctx);
550
969
  const results: NotificationChannelResult[] = [];
970
+ const errors: NotificationChannelError[] = [];
551
971
 
552
972
  instrumentation.custom({
553
973
  name: "notification.send.started",
@@ -567,64 +987,80 @@ export function createInlineNotificationDispatcher<Ctx>(
567
987
 
568
988
  for (const channel of channels) {
569
989
  const handler = notification.channels[channel];
990
+ const args = {
991
+ notification,
992
+ payload: parsed,
993
+ ctx,
994
+ channel,
995
+ } satisfies NotificationChannelHandleArgs<StandardSchema, Ctx>;
570
996
 
571
997
  try {
572
- const result = await handler({
573
- notification,
574
- payload: parsed,
575
- ctx,
576
- channel,
998
+ const preference = await options.preferences?.evaluate({
999
+ ...args,
1000
+ metadata: sendOptions.metadata,
577
1001
  });
578
-
579
- results.push(
580
- result ?? {
1002
+ if (preference && !preference.deliver) {
1003
+ const result = {
581
1004
  channel,
582
- status: "sent",
583
- },
584
- );
585
- } catch (error) {
586
- const args = {
587
- notification,
588
- payload: parsed,
589
- ctx,
590
- channel,
591
- } satisfies NotificationChannelHandleArgs<StandardSchema, Ctx>;
592
- const handled = await options.onError?.(error, args);
593
- if (handled) {
594
- results.push(handled);
1005
+ status: "skipped",
1006
+ reason:
1007
+ preference.reason ?? "Disabled by notification preferences.",
1008
+ details: {
1009
+ source: "preferences",
1010
+ },
1011
+ } satisfies NotificationChannelResult;
1012
+ results.push(result);
1013
+ recordChannelResult(
1014
+ instrumentation,
1015
+ notification.name,
1016
+ result,
1017
+ sendOptions,
1018
+ );
595
1019
  continue;
596
1020
  }
597
1021
 
598
- instrumentation.custom({
599
- name: "notification.send.failed",
600
- label: "Notification failed",
601
- summary: `${notification.name} (${channel})`,
602
- requestId: sendOptions.requestId,
603
- traceId: sendOptions.traceId,
604
- spanId: sendOptions.spanId,
605
- parentSpanId: sendOptions.parentSpanId,
606
- traceparent: sendOptions.traceparent,
607
- details: {
608
- notificationName: notification.name,
609
- channel,
610
- error: error instanceof Error ? error.message : String(error),
611
- metadata: sendOptions.metadata,
612
- },
613
- });
614
-
615
- throw new NotificationDeliveryError({
616
- notificationName: notification.name,
617
- channel,
618
- cause: error,
619
- });
1022
+ const handled = await handler(args);
1023
+ const result = handled
1024
+ ? { ...handled, channel }
1025
+ : ({
1026
+ channel,
1027
+ status: "sent",
1028
+ } satisfies NotificationChannelResult);
1029
+
1030
+ results.push(result);
1031
+ recordChannelResult(
1032
+ instrumentation,
1033
+ notification.name,
1034
+ result,
1035
+ sendOptions,
1036
+ );
1037
+ } catch (error) {
1038
+ errors.push({ channel, error });
1039
+ const result = await failedChannelResult(
1040
+ error,
1041
+ args,
1042
+ options.onError,
1043
+ );
1044
+ results.push(result);
1045
+ recordChannelResult(
1046
+ instrumentation,
1047
+ notification.name,
1048
+ result,
1049
+ sendOptions,
1050
+ );
620
1051
  }
621
1052
  }
622
1053
 
623
- const summary = summarizeResults(results);
1054
+ const result = {
1055
+ notificationName: notification.name,
1056
+ payload: parsed,
1057
+ channels,
1058
+ results,
1059
+ } satisfies SendNotificationResult;
624
1060
  instrumentation.custom({
625
1061
  name: "notification.send.completed",
626
- label: "Notification sent",
627
- summary: `${notification.name} (${summary.sent} sent, ${summary.skipped} skipped, ${summary.failed} failed)`,
1062
+ label: "Notification completed",
1063
+ summary: `${notification.name} (${resultSummary(results)})`,
628
1064
  requestId: sendOptions.requestId,
629
1065
  traceId: sendOptions.traceId,
630
1066
  spanId: sendOptions.spanId,
@@ -638,12 +1074,14 @@ export function createInlineNotificationDispatcher<Ctx>(
638
1074
  },
639
1075
  });
640
1076
 
641
- return {
642
- notificationName: notification.name,
643
- payload: parsed,
644
- channels,
645
- results,
646
- };
1077
+ if (
1078
+ options.failureMode === "throw" &&
1079
+ results.some((channelResult) => channelResult.status === "failed")
1080
+ ) {
1081
+ throw new NotificationDeliveryError({ result, errors });
1082
+ }
1083
+
1084
+ return result;
647
1085
  },
648
1086
  };
649
1087
  }
@@ -705,6 +1143,176 @@ export function createInlineNotificationsProvider(
705
1143
  });
706
1144
  }
707
1145
 
1146
+ function queuedSendOptions(
1147
+ options: Partial<Record<keyof SendNotificationOptions, unknown>>,
1148
+ ): Omit<SendNotificationOptions, "channels"> {
1149
+ const queued: Omit<SendNotificationOptions, "channels"> = {};
1150
+ if (isRecord(options.metadata)) queued.metadata = options.metadata;
1151
+ if (typeof options.requestId === "string")
1152
+ queued.requestId = options.requestId;
1153
+ if (typeof options.traceId === "string") queued.traceId = options.traceId;
1154
+ if (typeof options.spanId === "string") queued.spanId = options.spanId;
1155
+ if (typeof options.parentSpanId === "string") {
1156
+ queued.parentSpanId = options.parentSpanId;
1157
+ }
1158
+ if (typeof options.traceparent === "string")
1159
+ queued.traceparent = options.traceparent;
1160
+ return queued;
1161
+ }
1162
+
1163
+ /**
1164
+ * Create a notification dispatcher that enqueues one delivery job per channel.
1165
+ * Separate jobs keep provider retries from resending channels that already
1166
+ * completed successfully.
1167
+ */
1168
+ export function createQueuedNotificationDispatcher<Name extends string, Ctx>(
1169
+ options: QueuedNotificationDispatcherOptions<Name, Ctx>,
1170
+ ): NotificationPort {
1171
+ const instrumentation = createProviderInstrumentation(
1172
+ options.instrumentation,
1173
+ {
1174
+ providerName: "queued-notifications",
1175
+ watcher: "notifications",
1176
+ },
1177
+ );
1178
+
1179
+ return {
1180
+ async send<N extends NotificationDef>(
1181
+ notification: N,
1182
+ payload: InferNotificationPayload<N>,
1183
+ sendOptions: SendNotificationOptions = {},
1184
+ ) {
1185
+ const registered = options.deliveryJob.registry.get(notification.name);
1186
+ if (!registered) {
1187
+ throw new NotificationRegistryError(
1188
+ `Notification "${notification.name}" is not registered for queued delivery.`,
1189
+ );
1190
+ }
1191
+
1192
+ const parsed = await parseNotificationPayload(registered, payload);
1193
+ const channels = resolveChannelNames(registered, sendOptions);
1194
+ const results: NotificationChannelResult[] = [];
1195
+
1196
+ instrumentation.custom({
1197
+ name: "notification.enqueue.started",
1198
+ label: "Notification enqueue started",
1199
+ summary: notification.name,
1200
+ requestId: sendOptions.requestId,
1201
+ traceId: sendOptions.traceId,
1202
+ spanId: sendOptions.spanId,
1203
+ parentSpanId: sendOptions.parentSpanId,
1204
+ traceparent: sendOptions.traceparent,
1205
+ details: {
1206
+ notificationName: notification.name,
1207
+ channels,
1208
+ metadata: sendOptions.metadata,
1209
+ jobName: options.deliveryJob.name,
1210
+ },
1211
+ });
1212
+
1213
+ for (const channel of channels) {
1214
+ let result: NotificationChannelResult;
1215
+ try {
1216
+ await options.jobs.dispatch(options.deliveryJob, {
1217
+ notificationName: notification.name,
1218
+ channel,
1219
+ payload: parsed,
1220
+ options: queuedSendOptions(sendOptions),
1221
+ });
1222
+ result = {
1223
+ channel,
1224
+ status: "queued",
1225
+ provider: "jobs",
1226
+ details: {
1227
+ jobName: options.deliveryJob.name,
1228
+ },
1229
+ };
1230
+ } catch (error) {
1231
+ result = {
1232
+ channel,
1233
+ status: "failed",
1234
+ reason: error instanceof Error ? error.message : String(error),
1235
+ details: {
1236
+ phase: "enqueue",
1237
+ jobName: options.deliveryJob.name,
1238
+ },
1239
+ };
1240
+ }
1241
+
1242
+ results.push(result);
1243
+ recordChannelResult(
1244
+ instrumentation,
1245
+ notification.name,
1246
+ result,
1247
+ sendOptions,
1248
+ );
1249
+ }
1250
+
1251
+ const result = {
1252
+ notificationName: notification.name,
1253
+ payload: parsed,
1254
+ channels,
1255
+ results,
1256
+ } satisfies SendNotificationResult;
1257
+ instrumentation.custom({
1258
+ name: "notification.enqueue.completed",
1259
+ label: "Notification enqueue completed",
1260
+ summary: `${notification.name} (${resultSummary(results)})`,
1261
+ requestId: sendOptions.requestId,
1262
+ traceId: sendOptions.traceId,
1263
+ spanId: sendOptions.spanId,
1264
+ parentSpanId: sendOptions.parentSpanId,
1265
+ traceparent: sendOptions.traceparent,
1266
+ details: {
1267
+ notificationName: notification.name,
1268
+ channels,
1269
+ results,
1270
+ metadata: sendOptions.metadata,
1271
+ jobName: options.deliveryJob.name,
1272
+ },
1273
+ });
1274
+
1275
+ return result;
1276
+ },
1277
+ };
1278
+ }
1279
+
1280
+ /**
1281
+ * Options for the queued notifications provider.
1282
+ */
1283
+ export interface QueuedNotificationsProviderOptions<Name extends string, Ctx> {
1284
+ /**
1285
+ * Registered notification delivery job.
1286
+ */
1287
+ deliveryJob: NotificationDeliveryJob<Name, Ctx>;
1288
+ /**
1289
+ * Provider name. Defaults to `"queued-notifications"`.
1290
+ */
1291
+ name?: string;
1292
+ }
1293
+
1294
+ /**
1295
+ * Create a provider that contributes a job-backed notification dispatcher.
1296
+ */
1297
+ export function createQueuedNotificationsProvider<Name extends string, Ctx>(
1298
+ options: QueuedNotificationsProviderOptions<Name, Ctx>,
1299
+ ) {
1300
+ return createProvider<{ jobs: JobDispatcher }>()({
1301
+ name: options.name ?? "queued-notifications",
1302
+ setup({ ports }) {
1303
+ return {
1304
+ ports: {
1305
+ notifications: createQueuedNotificationDispatcher({
1306
+ jobs: ports.jobs,
1307
+ deliveryJob: options.deliveryJob,
1308
+ instrumentation: ports,
1309
+ }),
1310
+ } satisfies InlineNotificationsProviderPorts,
1311
+ };
1312
+ },
1313
+ });
1314
+ }
1315
+
708
1316
  /**
709
1317
  * Define a mail-backed notification channel.
710
1318
  *