@lunora/notify 1.0.0-alpha.29 → 1.0.0-alpha.30

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.
package/README.md CHANGED
@@ -91,7 +91,26 @@ Move a large broadcast off the request path with `@lunora/queue`:
91
91
  await enqueuePushBroadcast(ctx.queues.push, { payload: { title: "New drop", body: "…" } });
92
92
 
93
93
  // consumer (lunora/queues.ts)
94
- for (const message of batch.messages) await runPushBroadcastJob(ctx.push, message.body);
94
+ for (const message of batch.messages) {
95
+ const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
96
+
97
+ // One message = ONE bounded page. Discarding `nextCursor` delivers only the
98
+ // first page (default 250 devices) and reports success for the whole audience.
99
+ if (nextCursor !== undefined) {
100
+ await enqueuePushBroadcast(ctx.queues.push, {
101
+ payload: message.body.payload,
102
+ filter: { ...message.body.filter, after: nextCursor },
103
+ });
104
+ }
105
+
106
+ // Redeliver ONLY the recipients that failed — a retry of the whole page would
107
+ // re-POST everyone it already reached.
108
+ if (failedIds.length > 0) {
109
+ await enqueuePushBroadcast(ctx.queues.push, { payload: message.body.payload, retryIds: failedIds });
110
+ }
111
+
112
+ message.ack();
113
+ }
95
114
  ```
96
115
 
97
116
  ## Subscription storage
package/dist/index.d.mts CHANGED
@@ -247,7 +247,7 @@ interface LunoraPush {
247
247
  * materialized wholesale in the isolate — see `CreateNotifyOptions`'s
248
248
  * `broadcastPageSize`. This call still processes the WHOLE matched
249
249
  * audience in one request/queue message; use {@link LunoraPush.broadcastPage}
250
- * directly (as `runPushBroadcastJob` does) to bound a single queue message
250
+ * directly (as `runPushBroadcastPage` does) to bound a single queue message
251
251
  * to one page.
252
252
  */
253
253
  broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
@@ -258,7 +258,7 @@ interface LunoraPush {
258
258
  * {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
259
259
  * scoped to a single page; returns the page's own {@link BroadcastResult}
260
260
  * plus a `nextCursor` to fetch the next page (`undefined` when done).
261
- * Backs `runPushBroadcastJob` so one queue message does bounded work
261
+ * Backs `runPushBroadcastPage` so one queue message does bounded work
262
262
  * regardless of audience size — most app code should call
263
263
  * {@link LunoraPush.broadcast} instead.
264
264
  */
@@ -419,7 +419,7 @@ interface CreateNotifyOptions {
419
419
  * Each page is fetched, delivered, and counted independently before the
420
420
  * next page's store round trip, so a huge audience is never materialized
421
421
  * wholesale in the isolate. Also the per-message bound `push.broadcastPage`
422
- * (and so `runPushBroadcastJob`) uses. A test/tuning seam — most apps never
422
+ * (and so `runPushBroadcastPage`) uses. A test/tuning seam — most apps never
423
423
  * need to set this.
424
424
  */
425
425
  broadcastPageSize?: number;
@@ -505,23 +505,45 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
505
505
  * fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
506
506
  * `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
507
507
  * `filter.after`, when set, resumes a broadcast partway through (see
508
- * {@link runPushBroadcastJob}'s continuation semantics).
508
+ * {@link runPushBroadcastPage}'s continuation semantics).
509
509
  */
510
510
  interface PushBroadcastJob {
511
511
  /** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
512
512
  filter?: SubscriptionFilter;
513
513
  /** The push payload to deliver (the `to` target is derived per subscription). */
514
514
  payload: PushContent;
515
+ /**
516
+ * Redeliver to exactly these subscription ids instead of walking a page —
517
+ * an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
518
+ * consumer when it re-enqueues a page's transient failures; `filter` is
519
+ * ignored on such a job. See {@link runPushBroadcastPage}.
520
+ */
521
+ retryIds?: string[];
515
522
  /** Discriminator so a shared queue can multiplex message kinds. */
516
523
  type: "lunora.push.broadcast";
517
524
  }
525
+ /**
526
+ * One page's outcome plus the ids that need redelivering.
527
+ *
528
+ * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
529
+ * `failedIds` redelivers the recipients this page missed. Acking a message while
530
+ * ignoring either silently drops part of the audience.
531
+ */
532
+ interface PushBroadcastPageOutcome extends BroadcastPageResult {
533
+ /**
534
+ * Subscriptions that failed transiently on this run (gone/pruned devices are
535
+ * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
536
+ * as `retryIds` to redeliver to just them.
537
+ */
538
+ failedIds: string[];
539
+ }
518
540
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
519
541
  interface QueueProducerLike {
520
542
  send: (body: PushBroadcastJob) => Promise<void>;
521
543
  }
522
544
  /**
523
545
  * Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
524
- * queue instead of blocking the request. Pair with {@link runPushBroadcastJob} in
546
+ * queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
525
547
  * the queue consumer — see its doc comment for how a large audience continues
526
548
  * across MULTIPLE messages (one bounded page per message), not one.
527
549
  *
@@ -532,7 +554,7 @@ interface QueueProducerLike {
532
554
  * // in lunora/queues.ts consumer:
533
555
  * export const push = defineQueue({ async handler(batch, ctx) {
534
556
  * for (const message of batch.messages) {
535
- * const { nextCursor } = await runPushBroadcastJob(ctx.push, message.body);
557
+ * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
536
558
  *
537
559
  * if (nextCursor !== undefined) {
538
560
  * // More pages remain — enqueue the continuation. Each message still
@@ -542,6 +564,13 @@ interface QueueProducerLike {
542
564
  * filter: { ...message.body.filter, after: nextCursor },
543
565
  * });
544
566
  * }
567
+ *
568
+ * if (failedIds.length > 0) {
569
+ * // Redeliver ONLY the recipients that failed — never the whole page.
570
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: message.body.payload, retryIds: failedIds });
571
+ * }
572
+ *
573
+ * message.ack();
545
574
  * }
546
575
  * }});
547
576
  * ```
@@ -553,37 +582,33 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
553
582
  * reuses the engine's retry + circuit-breaker middleware and prunes gone
554
583
  * subscriptions).
555
584
  *
556
- * RETRY / CONTINUATION SEMANTICS (rewritten for plan 222 / NOTIFY-01 — a
557
- * broadcast job used to process the WHOLE audience in one message, which could
558
- * exceed Worker CPU/wall limits for a large audience and made a retry re-run
559
- * everything):
585
+ * RETRY / CONTINUATION SEMANTICS:
560
586
  *
561
- * - A job now processes exactly ONE bounded page (see `CreateNotifyOptions`'s
587
+ * - A job processes exactly ONE bounded page (see `CreateNotifyOptions`'s
562
588
  * page-size option, default 250, or `job.filter.limit` when smaller),
563
589
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
564
590
  * — so per-message work is bounded regardless of total audience size.
565
- * - Retry is still gated on `result.failed` the count of TRANSIENT delivery
566
- * errors (a provider 5xx / network fault worth another attempt). When at
567
- * least one recipient in THIS PAGE `failed`, the job is RE-THROWN so the
568
- * queue does NOT ack it and its normal retry/backoff (and, on exhaustion,
569
- * dead-letter) applies to just this page, not the whole broadcast.
570
- * - A page with zero `failed` resolves and is acked — this includes the
571
- * all-`pruned` case (every device on the page had unsubscribed:
572
- * `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL prune, not a
573
- * failure, so throwing on it would spuriously retry and pressure the DLQ;
574
- * and the empty-page case (zero `total`), which has nothing to retry.
575
- * - The returned `nextCursor` is set when more pages remain. `@lunora/notify`
576
- * does NOT enqueue the continuation itself it has no `@lunora/queue`
577
- * dependency (the seam stays structural) and no reference to the producer
578
- * that enqueued this message so the CALLER (the `lunora/queues.ts`
579
- * consumer) is responsible for re-enqueueing with `filter.after: nextCursor`
580
- * when present. See the consumer example on {@link enqueuePushBroadcast}.
581
- * - A retry of a page redelivers only that page's already-delivered recipients
582
- * on a transient partial failure (a page is not individually idempotent)
583
- * the accepted cost of getting the transiently failed ones redelivered, now
584
- * scoped to one page instead of the whole broadcast.
585
- */
586
- declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastPageResult>;
591
+ * - A page NEVER throws for a partial failure. Throwing discarded the page's
592
+ * `nextCursor`, which is the only way the broadcast advances: one device that
593
+ * fails permanently (a rotated VAPID keypair leaves a stale device answering
594
+ * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
595
+ * every already-delivered recipient on each retry, dead-letter, and leave
596
+ * every LATER page unreached. The page's `nextCursor` and its `failedIds`
597
+ * both come back instead.
598
+ * - The CALLER (the `lunora/queues.ts` consumer) re-enqueues: `filter.after:
599
+ * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
600
+ * any recipient failed. `@lunora/notify` cannot do it itself it has no
601
+ * `@lunora/queue` dependency (the seam stays structural) and no reference to
602
+ * the producer that enqueued this message. See the consumer example on
603
+ * {@link enqueuePushBroadcast}.
604
+ * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
605
+ * of them still fails, so the queue's backoff/dead-letter bounds it. That
606
+ * message contains no already-delivered recipient, so nothing is re-sent.
607
+ * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
608
+ * never appear in `failedIds` an all-`pruned` page is a success, not a
609
+ * failure, as is an empty page.
610
+ */
611
+ declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
587
612
  /**
588
613
  * The minimal structural slice of Cloudflare's `D1Database` this store uses. A
589
614
  * structural type (rather than importing `@cloudflare/workers-types`) keeps the
@@ -702,13 +727,13 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
702
727
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
703
728
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
704
729
  * deliberately **not** on the edge facade — route heavy fan-out through
705
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
730
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
706
731
  *
707
732
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
708
733
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
709
734
  * @packageDocumentation
710
735
  */
711
- FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
736
+ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
712
737
  /**
713
738
  * `@lunora/notify`
714
739
  *
@@ -721,7 +746,7 @@ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type No
721
746
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
722
747
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
723
748
  * deliberately **not** on the edge facade — route heavy fan-out through
724
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
749
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
725
750
  *
726
751
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
727
752
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
@@ -740,13 +765,13 @@ WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1Subsc
740
765
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
741
766
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
742
767
  * deliberately **not** on the edge facade — route heavy fan-out through
743
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
768
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
744
769
  *
745
770
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
746
771
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
747
772
  * @packageDocumentation
748
773
  */
749
- fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf,
774
+ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
750
775
  /**
751
776
  * `@lunora/notify`
752
777
  *
@@ -759,7 +784,7 @@ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, nor
759
784
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
760
785
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
761
786
  * deliberately **not** on the edge facade — route heavy fan-out through
762
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
787
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
763
788
  *
764
789
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
765
790
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
package/dist/index.d.ts CHANGED
@@ -247,7 +247,7 @@ interface LunoraPush {
247
247
  * materialized wholesale in the isolate — see `CreateNotifyOptions`'s
248
248
  * `broadcastPageSize`. This call still processes the WHOLE matched
249
249
  * audience in one request/queue message; use {@link LunoraPush.broadcastPage}
250
- * directly (as `runPushBroadcastJob` does) to bound a single queue message
250
+ * directly (as `runPushBroadcastPage` does) to bound a single queue message
251
251
  * to one page.
252
252
  */
253
253
  broadcast: (payload: PushContent, filter?: SubscriptionFilter) => Promise<BroadcastResult>;
@@ -258,7 +258,7 @@ interface LunoraPush {
258
258
  * {@link LunoraPush.broadcast} (retry/circuit-breaker, gone-pruning) but
259
259
  * scoped to a single page; returns the page's own {@link BroadcastResult}
260
260
  * plus a `nextCursor` to fetch the next page (`undefined` when done).
261
- * Backs `runPushBroadcastJob` so one queue message does bounded work
261
+ * Backs `runPushBroadcastPage` so one queue message does bounded work
262
262
  * regardless of audience size — most app code should call
263
263
  * {@link LunoraPush.broadcast} instead.
264
264
  */
@@ -419,7 +419,7 @@ interface CreateNotifyOptions {
419
419
  * Each page is fetched, delivered, and counted independently before the
420
420
  * next page's store round trip, so a huge audience is never materialized
421
421
  * wholesale in the isolate. Also the per-message bound `push.broadcastPage`
422
- * (and so `runPushBroadcastJob`) uses. A test/tuning seam — most apps never
422
+ * (and so `runPushBroadcastPage`) uses. A test/tuning seam — most apps never
423
423
  * need to set this.
424
424
  */
425
425
  broadcastPageSize?: number;
@@ -505,23 +505,45 @@ declare const buildEngine: (resolved: ResolvedProviders) => Notification;
505
505
  * fan-out. Shaped to travel through a `@lunora/queue` producer/consumer without
506
506
  * `@lunora/notify` depending on `@lunora/queue` (the seam stays structural).
507
507
  * `filter.after`, when set, resumes a broadcast partway through (see
508
- * {@link runPushBroadcastJob}'s continuation semantics).
508
+ * {@link runPushBroadcastPage}'s continuation semantics).
509
509
  */
510
510
  interface PushBroadcastJob {
511
511
  /** Subscription filter (which devices/users to target; `filter.after` resumes a paged broadcast). */
512
512
  filter?: SubscriptionFilter;
513
513
  /** The push payload to deliver (the `to` target is derived per subscription). */
514
514
  payload: PushContent;
515
+ /**
516
+ * Redeliver to exactly these subscription ids instead of walking a page —
517
+ * an earlier page's {@link PushBroadcastPageOutcome.failedIds}. Set by the
518
+ * consumer when it re-enqueues a page's transient failures; `filter` is
519
+ * ignored on such a job. See {@link runPushBroadcastPage}.
520
+ */
521
+ retryIds?: string[];
515
522
  /** Discriminator so a shared queue can multiplex message kinds. */
516
523
  type: "lunora.push.broadcast";
517
524
  }
525
+ /**
526
+ * One page's outcome plus the ids that need redelivering.
527
+ *
528
+ * The consumer MUST act on BOTH fields: `nextCursor` continues the broadcast and
529
+ * `failedIds` redelivers the recipients this page missed. Acking a message while
530
+ * ignoring either silently drops part of the audience.
531
+ */
532
+ interface PushBroadcastPageOutcome extends BroadcastPageResult {
533
+ /**
534
+ * Subscriptions that failed transiently on this run (gone/pruned devices are
535
+ * NOT here — they are deleted, not retried). Re-enqueue a job carrying these
536
+ * as `retryIds` to redeliver to just them.
537
+ */
538
+ failedIds: string[];
539
+ }
518
540
  /** The structural slice of a `@lunora/queue` producer (`ctx.queues.<name>`) used here. */
519
541
  interface QueueProducerLike {
520
542
  send: (body: PushBroadcastJob) => Promise<void>;
521
543
  }
522
544
  /**
523
545
  * Enqueue a fan-out broadcast for background delivery through a `@lunora/queue`
524
- * queue instead of blocking the request. Pair with {@link runPushBroadcastJob} in
546
+ * queue instead of blocking the request. Pair with {@link runPushBroadcastPage} in
525
547
  * the queue consumer — see its doc comment for how a large audience continues
526
548
  * across MULTIPLE messages (one bounded page per message), not one.
527
549
  *
@@ -532,7 +554,7 @@ interface QueueProducerLike {
532
554
  * // in lunora/queues.ts consumer:
533
555
  * export const push = defineQueue({ async handler(batch, ctx) {
534
556
  * for (const message of batch.messages) {
535
- * const { nextCursor } = await runPushBroadcastJob(ctx.push, message.body);
557
+ * const { failedIds, nextCursor } = await runPushBroadcastPage(ctx.push, message.body);
536
558
  *
537
559
  * if (nextCursor !== undefined) {
538
560
  * // More pages remain — enqueue the continuation. Each message still
@@ -542,6 +564,13 @@ interface QueueProducerLike {
542
564
  * filter: { ...message.body.filter, after: nextCursor },
543
565
  * });
544
566
  * }
567
+ *
568
+ * if (failedIds.length > 0) {
569
+ * // Redeliver ONLY the recipients that failed — never the whole page.
570
+ * await enqueuePushBroadcast(ctx.queues.push, { payload: message.body.payload, retryIds: failedIds });
571
+ * }
572
+ *
573
+ * message.ack();
545
574
  * }
546
575
  * }});
547
576
  * ```
@@ -553,37 +582,33 @@ declare const enqueuePushBroadcast: (queue: QueueProducerLike, job: Omit<PushBro
553
582
  * reuses the engine's retry + circuit-breaker middleware and prunes gone
554
583
  * subscriptions).
555
584
  *
556
- * RETRY / CONTINUATION SEMANTICS (rewritten for plan 222 / NOTIFY-01 — a
557
- * broadcast job used to process the WHOLE audience in one message, which could
558
- * exceed Worker CPU/wall limits for a large audience and made a retry re-run
559
- * everything):
585
+ * RETRY / CONTINUATION SEMANTICS:
560
586
  *
561
- * - A job now processes exactly ONE bounded page (see `CreateNotifyOptions`'s
587
+ * - A job processes exactly ONE bounded page (see `CreateNotifyOptions`'s
562
588
  * page-size option, default 250, or `job.filter.limit` when smaller),
563
589
  * keyset-paginated on the subscription `id` (see `SubscriptionFilter.after`)
564
590
  * — so per-message work is bounded regardless of total audience size.
565
- * - Retry is still gated on `result.failed` the count of TRANSIENT delivery
566
- * errors (a provider 5xx / network fault worth another attempt). When at
567
- * least one recipient in THIS PAGE `failed`, the job is RE-THROWN so the
568
- * queue does NOT ack it and its normal retry/backoff (and, on exhaustion,
569
- * dead-letter) applies to just this page, not the whole broadcast.
570
- * - A page with zero `failed` resolves and is acked — this includes the
571
- * all-`pruned` case (every device on the page had unsubscribed:
572
- * `sent:0`, `failed:0`, `pruned:N`), which is a SUCCESSFUL prune, not a
573
- * failure, so throwing on it would spuriously retry and pressure the DLQ;
574
- * and the empty-page case (zero `total`), which has nothing to retry.
575
- * - The returned `nextCursor` is set when more pages remain. `@lunora/notify`
576
- * does NOT enqueue the continuation itself it has no `@lunora/queue`
577
- * dependency (the seam stays structural) and no reference to the producer
578
- * that enqueued this message so the CALLER (the `lunora/queues.ts`
579
- * consumer) is responsible for re-enqueueing with `filter.after: nextCursor`
580
- * when present. See the consumer example on {@link enqueuePushBroadcast}.
581
- * - A retry of a page redelivers only that page's already-delivered recipients
582
- * on a transient partial failure (a page is not individually idempotent)
583
- * the accepted cost of getting the transiently failed ones redelivered, now
584
- * scoped to one page instead of the whole broadcast.
585
- */
586
- declare const runPushBroadcastJob: (push: LunoraPush, job: PushBroadcastJob) => Promise<BroadcastPageResult>;
591
+ * - A page NEVER throws for a partial failure. Throwing discarded the page's
592
+ * `nextCursor`, which is the only way the broadcast advances: one device that
593
+ * fails permanently (a rotated VAPID keypair leaves a stale device answering
594
+ * `403 VapidPkHashMismatch` forever) would then stall the cursor, re-POST
595
+ * every already-delivered recipient on each retry, dead-letter, and leave
596
+ * every LATER page unreached. The page's `nextCursor` and its `failedIds`
597
+ * both come back instead.
598
+ * - The CALLER (the `lunora/queues.ts` consumer) re-enqueues: `filter.after:
599
+ * nextCursor` while more pages remain, and a `retryIds: failedIds` job when
600
+ * any recipient failed. `@lunora/notify` cannot do it itself it has no
601
+ * `@lunora/queue` dependency (the seam stays structural) and no reference to
602
+ * the producer that enqueued this message. See the consumer example on
603
+ * {@link enqueuePushBroadcast}.
604
+ * - A `retryIds` job redelivers to exactly those ids and DOES throw while any
605
+ * of them still fails, so the queue's backoff/dead-letter bounds it. That
606
+ * message contains no already-delivered recipient, so nothing is re-sent.
607
+ * - Gone subscriptions (404/410, FCM `UNREGISTERED`) are pruned by the page and
608
+ * never appear in `failedIds` an all-`pruned` page is a success, not a
609
+ * failure, as is an empty page.
610
+ */
611
+ declare const runPushBroadcastPage: (push: LunoraPush, job: PushBroadcastJob) => Promise<PushBroadcastPageOutcome>;
587
612
  /**
588
613
  * The minimal structural slice of Cloudflare's `D1Database` this store uses. A
589
614
  * structural type (rather than importing `@cloudflare/workers-types`) keeps the
@@ -702,13 +727,13 @@ export { type BroadcastOutcome, type BroadcastResult, type CreateNotifyOptions,
702
727
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
703
728
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
704
729
  * deliberately **not** on the edge facade — route heavy fan-out through
705
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
730
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
706
731
  *
707
732
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
708
733
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
709
734
  * @packageDocumentation
710
735
  */
711
- FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
736
+ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type NotifyConfig, type NotifyDefinition, type NotifyDeliveryStatus, type NotifyEnv, type NotifyLogger, type NotifyMetrics, type NotifySkipReason, type PushBroadcastJob, type PushBroadcastPageOutcome, type PushSubscriptionDevice, type PushSubscriptionsResult, type QueueProducerLike, type RegisterInput, type ResolvedProviders, type RoutingPushOptions, type StoredSubscription, type SubscriptionFilter, type SubscriptionKind, type SubscriptionStatus, type SubscriptionStore,
712
737
  /**
713
738
  * `@lunora/notify`
714
739
  *
@@ -721,7 +746,7 @@ FCM_ENV_KEYS, type FcmConfigFactory, type LunoraNotify, type LunoraPush, type No
721
746
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
722
747
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
723
748
  * deliberately **not** on the edge facade — route heavy fan-out through
724
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
749
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
725
750
  *
726
751
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
727
752
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
@@ -740,13 +765,13 @@ WEB_PUSH_ENV_KEYS, type WebPushConfigFactory, buildEngine, createNotify, d1Subsc
740
765
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
741
766
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
742
767
  * deliberately **not** on the edge facade — route heavy fan-out through
743
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
768
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
744
769
  *
745
770
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
746
771
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
747
772
  * @packageDocumentation
748
773
  */
749
- fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastJob, targetOf,
774
+ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, normalizeRegisterInput, routingPushProvider, runPushBroadcastPage, targetOf,
750
775
  /**
751
776
  * `@lunora/notify`
752
777
  *
@@ -759,7 +784,7 @@ fcmFromEnv, fcmId, isGoneError, isNotifyDefinition, memorySubscriptionStore, nor
759
784
  * Edge-safety: Web Push (VAPID + RFC 8291) and FCM (HTTP v1) run on `fetch` + Web
760
785
  * Crypto under workerd. APNs (`node:http2`) and SMS / Node-only queue adapters are
761
786
  * deliberately **not** on the edge facade — route heavy fan-out through
762
- * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastJob`).
787
+ * `@lunora/queue` (`enqueuePushBroadcast` / `runPushBroadcastPage`).
763
788
  *
764
789
  * - `@lunora/notify` — `defineNotify`, `createNotify`, config resolvers, subscription stores and types.
765
790
  * - `@lunora/notify/web` — the browser `subscribeToPush` service-worker helper.
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-CUi2k7pP.mjs";import{createNotify as p}from"./packem_shared/createNotify-BDE42tvH.mjs";import{buildEngine as d,routingPushProvider as x}from"./packem_shared/buildEngine-oDWs9Pom.mjs";import{enqueuePushBroadcast as c,runPushBroadcastJob as P}from"./packem_shared/enqueuePushBroadcast-tf1LqgzA.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-s-AJH4hS.mjs";import{memorySubscriptionStore as N}from"./packem_shared/memorySubscriptionStore-FY8yANEM.mjs";import{fcmId as g,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-Dwh_R5xe.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,d as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,c as enqueuePushBroadcast,i as fcmFromEnv,g as fcmId,y as isGoneError,u as isNotifyDefinition,N as memorySubscriptionStore,v as normalizeRegisterInput,x as routingPushProvider,P as runPushBroadcastJob,B as targetOf,f as webPushFromEnv,F as webPushId};
1
+ import{FCM_ENV_KEYS as e,WEB_PUSH_ENV_KEYS as t,fcmFromEnv as i,webPushFromEnv as f}from"./packem_shared/FCM_ENV_KEYS-BbhPScGH.mjs";import{defineNotify as n,isNotifyDefinition as u}from"./packem_shared/defineNotify-CUi2k7pP.mjs";import{createNotify as p}from"./packem_shared/createNotify-BDE42tvH.mjs";import{buildEngine as a,routingPushProvider as d}from"./packem_shared/buildEngine-oDWs9Pom.mjs";import{enqueuePushBroadcast as P,runPushBroadcastPage as c}from"./packem_shared/enqueuePushBroadcast-B6v3DLLp.mjs";import{d1SubscriptionStore as b}from"./packem_shared/d1SubscriptionStore-s-AJH4hS.mjs";import{memorySubscriptionStore as h}from"./packem_shared/memorySubscriptionStore-FY8yANEM.mjs";import{fcmId as _,isGoneError as y,normalizeRegisterInput as v,targetOf as B,webPushId as F}from"./packem_shared/fcmId-Dwh_R5xe.mjs";export{e as FCM_ENV_KEYS,t as WEB_PUSH_ENV_KEYS,a as buildEngine,p as createNotify,b as d1SubscriptionStore,n as defineNotify,P as enqueuePushBroadcast,i as fcmFromEnv,_ as fcmId,y as isGoneError,u as isNotifyDefinition,h as memorySubscriptionStore,v as normalizeRegisterInput,d as routingPushProvider,c as runPushBroadcastPage,B as targetOf,f as webPushFromEnv,F as webPushId};
@@ -0,0 +1 @@
1
+ import{LunoraError as i}from"@lunora/errors";const u=e=>e.filter(t=>t.status==="failed").map(t=>t.id),h=(e,t)=>e.send({...t,type:"lunora.push.broadcast"}),c=async(e,t,s)=>{const r=[];for(const a of s)try{const o=await e.send(a,t);r.push({id:a,status:o.successful?"ok":"failed"})}catch(o){r.push({error:o instanceof Error?o.message:String(o),id:a,status:"failed"})}const n=u(r),d=r.length-n.length,l={failed:n.length,outcomes:r,pruned:0,sent:d,total:r.length};if(n.length>0)throw new i("INTERNAL",`@lunora/notify: push retry failed for ${n.length.toString()} of ${r.length.toString()} subscription(s) — throwing so the queue retries and eventually dead-letters them`);return{failedIds:n,nextCursor:void 0,result:l}},g=async(e,t)=>{if(t.retryIds!==void 0&&t.retryIds.length>0)return c(e,t.payload,t.retryIds);const s=await e.broadcastPage(t.payload,t.filter);return{failedIds:u(s.result.outcomes),nextCursor:s.nextCursor,result:s.result}};export{h as enqueuePushBroadcast,g as runPushBroadcastPage};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/notify",
3
- "version": "1.0.0-alpha.29",
3
+ "version": "1.0.0-alpha.30",
4
4
  "description": "Multi-channel notifications for Lunora — ctx.notify / ctx.push over @visulima/notification: edge-safe Web Push + FCM, plus chat, in-app inbox and webhook channels, with subscription storage and queue-backed fan-out",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as s}from"@lunora/errors";const o=(e,r)=>e.send({...r,type:"lunora.push.broadcast"}),n=async(e,r)=>{const t=await e.broadcastPage(r.payload,r.filter);if(t.result.failed>0)throw new s("INTERNAL",`@lunora/notify: push broadcast page had ${t.result.failed.toString()} transient failure(s) of ${t.result.total.toString()} subscription(s) (${t.result.sent.toString()} sent, ${t.result.pruned.toString()} pruned) — throwing so the queue retries this page`);return t};export{o as enqueuePushBroadcast,n as runPushBroadcastJob};