@classytic/arc-notifications 0.9.0 → 0.10.0

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
@@ -129,6 +129,21 @@ sseHub.onConnect((userId, orgId) => backfill.onConnect(userId, orgId)); // debou
129
129
  badge's axis). Both stay host-owned in the repository.
130
130
  - All realtime is **best-effort on top of the durable feed** — a dead socket never
131
131
  fails a request or a send; clients converge via `GET /notifications/`.
132
+ - **Cursor replay.** Pass the client's last-seen time on connect —
133
+ `backfill.onConnect(userId, orgId, { since: req.headers['last-event-id'] })` — and
134
+ a repository that also implements `listSince(org, user, since, limit)` replays the
135
+ gap, including rows an earlier connection received but dropped; they merge with the
136
+ claimed rows into the same single bulk event.
137
+ - **List envelope and verbs.** `inAppFeedResource({ listEnvelope: 'offset' })` answers
138
+ repo-core's `OffsetPaginationResult` (`feedListResult` is the same conversion);
139
+ `{ actions: true }` also mounts `markRead` / `markAllRead` as arc action-router verbs.
140
+ - **One push per recipient.** The package calls `port.push` once per recipient and event
141
+ and never publishes on a bus itself. If your port forwards frames onto an event bus,
142
+ give each frame its own correlation id (it is a transport hop, not a second publish of
143
+ one logical event), or arc's dev duplicate-publish detector will warn on every
144
+ multi-recipient notification.
145
+ - **Clock.** `createUnreadCountCache({ clock })` and
146
+ `createRepositoryDeliveryLog(repo, { clock })` take a `{ now(): Date }`.
132
147
 
133
148
  ### 5. Durable dispatch — crash-safe bulk sends (10k+)
134
149
 
package/dist/index.d.mts CHANGED
@@ -1,11 +1,11 @@
1
- import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
2
- import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
3
1
  import { ArcModule, EventHandlerDefinition } from "@classytic/arc/factory";
2
+ import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, DispatchResult, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
3
+ import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
4
4
  import { PermissionCheck } from "@classytic/arc/permissions";
5
+ import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
5
6
  import { FastifyInstance, FastifyRequest } from "fastify";
6
7
  import { DomainEvent } from "@classytic/primitives/events";
7
8
  import { RepositoryLike } from "@classytic/repo-core/adapter";
8
- //#region src/inApp.d.ts
9
9
  /**
10
10
  * In-app notification content — the shared seam between a `notifyOn` trigger's
11
11
  * declarative `inApp` template and the channels that deliver it (the DB feed
@@ -23,7 +23,7 @@ import { RepositoryLike } from "@classytic/repo-core/adapter";
23
23
  * in-app convention; the email template layer keeps its own `${var}` engine.
24
24
  */
25
25
  /** Reserved `payload.data` key carrying the rendered in-app content. */
26
- declare const INAPP_DATA_KEY = "__inApp";
26
+ export declare const INAPP_DATA_KEY = "__inApp";
27
27
  /**
28
28
  * Host realtime transport — push an event to one user within a tenant.
29
29
  * The transport itself (connection registry, heartbeats, the `/stream`
@@ -59,11 +59,21 @@ interface InAppContent {
59
59
  readonly priority: InAppPriority;
60
60
  }
61
61
  /** Render a trigger's in-app template against event data + priority. */
62
- declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
62
+ export declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
63
63
  /** Read the rendered in-app content a trigger parked on the send payload's data. */
64
- declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
65
- //#endregion
66
- //#region src/inAppFeed.d.ts
64
+ export declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
65
+ /**
66
+ * The notification clock — one seam so every "now" the in-app feed, the reconnect backfill
67
+ * and the delivery log stamp goes through a single injectable source.
68
+ *
69
+ * Structural on purpose: a host's own `Clock` (`{ now(): Date }`) satisfies it with no
70
+ * adapter, and a test passes a frozen or stepped one to make unread-cache expiry, ordering
71
+ * and `timestamp` stamps deterministic. The default is real time.
72
+ */
73
+ interface NotificationClock {
74
+ now(): Date;
75
+ }
76
+ export declare const systemNotificationClock: NotificationClock;
67
77
  /** A feed row the {@link InAppChannel} writes and this resource reads. */
68
78
  interface InAppFeedRow {
69
79
  readonly organizationId: string;
@@ -124,6 +134,18 @@ interface InAppFeedRepository<TRow = unknown> {
124
134
  * the debounce window) from double-toasting the same rows.
125
135
  */
126
136
  claimUndelivered?(organizationId: string, userId: string, limit: number): Promise<TRow[]>;
137
+ /**
138
+ * OPTIONAL — rows created AFTER `since`, oldest first, WITHOUT claiming them. Powers the
139
+ * cursor flavour of reconnect backfill (`onConnect(user, org, { since })`): a client that
140
+ * remembers the time of its last event (SSE `Last-Event-ID`) replays exactly the gap,
141
+ * including rows an earlier connection already received but dropped before rendering.
142
+ */
143
+ listSince?(organizationId: string, userId: string, since: Date, limit: number): Promise<TRow[]>;
144
+ /**
145
+ * OPTIONAL — stamp one row delivered (a live push reached a client), so the next
146
+ * reconnect does not re-send it. `false` when not found / not owned.
147
+ */
148
+ markDelivered?(organizationId: string, userId: string, id: string): Promise<boolean>;
127
149
  /**
128
150
  * OPTIONAL — delete one row (user dismissed it for good); `false` when not
129
151
  * found / not owned. When implemented, the feed resource registers
@@ -168,13 +190,15 @@ interface CreateUnreadCountCacheOptions {
168
190
  readonly ttlMs?: number;
169
191
  /** Entry cap — oldest entries evicted past it (bounds memory). Default 10_000. */
170
192
  readonly maxEntries?: number;
193
+ /** Source of "now" for entry freshness. Default real time. */
194
+ readonly clock?: NotificationClock;
171
195
  }
172
196
  /**
173
197
  * In-process TTL cache for unread counts. Per-process: invalidations don't
174
198
  * cross workers, so a multi-process host either accepts up-to-TTL staleness on
175
199
  * the other workers or brings a Redis-backed {@link UnreadCountCache}.
176
200
  */
177
- declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
201
+ export declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
178
202
  /** The current user + tenant, resolved off a request by the host. */
179
203
  interface InAppFeedScope {
180
204
  readonly userId: string;
@@ -206,10 +230,31 @@ interface InAppFeedResourceOptions {
206
230
  * instance with `InAppChannel` so new-row writes invalidate too.
207
231
  */
208
232
  readonly unreadCache?: UnreadCountCache;
233
+ /**
234
+ * Shape of `GET <prefix>/`. `"native"` (default) answers the port's `{ data, pagination }`
235
+ * page untouched; `"offset"` answers repo-core's canonical `OffsetPaginationResult` (see
236
+ * {@link feedListResult}) for a host whose every other list is that shape.
237
+ */
238
+ readonly listEnvelope?: "native" | "offset";
239
+ /**
240
+ * Also mount `markRead` (`POST /:id/action`) and `markAllRead` (`POST /action`) as arc
241
+ * action-router verbs ({@link FEED_ACTION_VERBS}, camelCase), beside the `PATCH` routes.
242
+ * Same handlers, same cache invalidation and cross-tab read-sync. Default false.
243
+ */
244
+ readonly actions?: boolean;
209
245
  }
210
- declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
211
- //#endregion
212
- //#region src/backfill.d.ts
246
+ /** The action-router verbs {@link InAppFeedResourceOptions.actions} mounts. */
247
+ export declare const FEED_ACTION_VERBS: {
248
+ readonly markRead: "markRead";
249
+ readonly markAllRead: "markAllRead";
250
+ };
251
+ /**
252
+ * A feed page in repo-core's canonical offset envelope. Pure and exported so a host that
253
+ * builds its own list route (or asserts the envelope in a test) converts the same way the
254
+ * resource does. `hasNext`/`hasPrev` follow from the page arithmetic the port already did.
255
+ */
256
+ export declare function feedListResult<TRow>(page: InAppFeedPage<TRow>): OffsetPaginationResult<TRow>;
257
+ export declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
213
258
  interface InAppBackfillOptions {
214
259
  /** Feed repository — MUST implement the optional `claimUndelivered`. */
215
260
  readonly repository: InAppFeedRepository;
@@ -224,15 +269,18 @@ interface InAppBackfillOptions {
224
269
  /** Claim/push failure sink (backfill is best-effort). Default: swallow. */
225
270
  readonly onError?: (error: unknown) => void;
226
271
  }
272
+ /** Per-connection hints for {@link InAppBackfill.onConnect}. */
273
+ interface InAppBackfillConnect {
274
+ /** Replay rows created after this instant (a `Last-Event-ID` / `since` cursor). */
275
+ readonly since?: Date | string | number | undefined;
276
+ }
227
277
  interface InAppBackfill {
228
278
  /** Call from the host's SSE/WS connection handler. Sync, never throws. */
229
- onConnect(userId: string, organizationId: string): void;
279
+ onConnect(userId: string, organizationId: string, connect?: InAppBackfillConnect): void;
230
280
  /** Cancel pending debounces (graceful shutdown / tests). */
231
281
  close(): void;
232
282
  }
233
- declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
234
- //#endregion
235
- //#region src/triggerOverride.d.ts
283
+ export declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
236
284
  /**
237
285
  * Notification trigger OVERRIDES — the admin-editable half of the registry.
238
286
  *
@@ -299,7 +347,7 @@ declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfi
299
347
  * which a host fills from its integrations registry. A channel nobody registered
300
348
  * is simply not offered, instead of being unrepresentable.
301
349
  */
302
- declare const IN_APP_CHANNEL = "in-app";
350
+ export declare const IN_APP_CHANNEL = "in-app";
303
351
  /**
304
352
  * Open by design — see above. Kept as a named alias rather than inlining
305
353
  * `string` so the intent reads at every call site and a future narrowing has one
@@ -390,7 +438,7 @@ interface CreateCachedTriggerOverrideStoreOptions {
390
438
  * any mutation so an operator's change applies within one save rather than one
391
439
  * TTL window.
392
440
  */
393
- declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
441
+ export declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
394
442
  invalidate: () => void;
395
443
  };
396
444
  /**
@@ -435,7 +483,7 @@ interface CodeTriggerDefaults {
435
483
  */
436
484
  sourceChannels?: readonly string[] | undefined;
437
485
  }
438
- declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
486
+ export declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
439
487
  /**
440
488
  * Why a notification was not delivered.
441
489
  *
@@ -476,9 +524,7 @@ type NotificationDecision = {
476
524
  * is ever consulted, so an event with no `sourceChannel` under no restriction
477
525
  * still delivers.
478
526
  */
479
- declare function evaluateSourceChannel(sourceChannels: string[] | null | undefined, sourceChannel: string | undefined): NotificationDecision;
480
- //#endregion
481
- //#region src/channelCatalog.d.ts
527
+ export declare function evaluateSourceChannel(sourceChannels: string[] | null | undefined, sourceChannel: string | undefined): NotificationDecision;
482
528
  /**
483
529
  * Which channels a deployment can deliver on.
484
530
  *
@@ -509,9 +555,206 @@ interface NotificationChannelCatalog {
509
555
  * than stored, so a misconfigured provider cannot introduce an unusable ""
510
556
  * channel that validation would then accept.
511
557
  */
512
- declare function createNotificationChannelCatalog(connectorChannels?: readonly string[]): NotificationChannelCatalog;
513
- //#endregion
514
- //#region src/dbFirstResolver.d.ts
558
+ export declare function createNotificationChannelCatalog(connectorChannels?: readonly string[]): NotificationChannelCatalog;
559
+ /** `YYYY-MM-DD`. */
560
+ type CivilDateString = string;
561
+ /** True for a real calendar date in `YYYY-MM-DD` form. */
562
+ export declare function isCivilDateString(value: unknown): value is CivilDateString;
563
+ /** `date` moved by `days` (negative = earlier). */
564
+ export declare function addCivilDays(date: CivilDateString, days: number): CivilDateString;
565
+ /** Whole days from `a` to `b` (`b - a`). */
566
+ export declare function civilDaysBetween(a: CivilDateString, b: CivilDateString): number;
567
+ /** When a rule fires, relative to the item's date. */
568
+ interface DateRuleSchedule {
569
+ /**
570
+ * Days from the date to the FIRST occurrence: negative = before (`-30` = 30 days
571
+ * before), `0` = on the day, positive = after (`1` = the day after: "overdue").
572
+ */
573
+ readonly offsetDays: number;
574
+ /** Fire again every N days after the first occurrence while the item stays open. Omit = once. */
575
+ readonly repeatEveryDays?: number;
576
+ /** Bounds on repetition. Without it a repeating rule runs until the item is no longer open. */
577
+ readonly until?: {
578
+ /** Total occurrences, the first included. */
579
+ readonly occurrences?: number;
580
+ /** No occurrence after `date + offsetDays` (relative to the item's date, like `offsetDays`). */
581
+ readonly offsetDays?: number;
582
+ };
583
+ }
584
+ /**
585
+ * One admin-defined date rule. `TAudience` is the host's audience selector — this
586
+ * package never interprets it; it is handed to the host's `resolveAudience`.
587
+ */
588
+ interface DateRule<TAudience = unknown> {
589
+ /** Stable id: part of every ledger key, so it must not change for the life of the rule. */
590
+ readonly id: string;
591
+ /** Default true. */
592
+ readonly enabled?: boolean;
593
+ /** A registered {@link DateRuleSource} key. */
594
+ readonly source: string;
595
+ /** One of that source's `dateFields`. */
596
+ readonly dateField: string;
597
+ readonly schedule: DateRuleSchedule;
598
+ readonly audience: TAudience;
599
+ /** Restrict to these channel names. Omit = every channel that applies. */
600
+ readonly channels?: readonly string[];
601
+ /**
602
+ * Template id / notification kind (the feed row's type). Default
603
+ * `<source>.<dateField>`. With a template resolver configured on the service it
604
+ * also names the email template.
605
+ */
606
+ readonly template?: string;
607
+ /** In-app copy with `{var}` placeholders resolved against the item's `data`. */
608
+ readonly inApp?: InAppTemplate;
609
+ readonly priority?: InAppPriority;
610
+ /** Scope passed to the source; `null`/absent = every organization the source serves. */
611
+ readonly organizationId?: string | null;
612
+ }
613
+ /** One record a source reports, with the date the rule's `dateField` names. */
614
+ interface DateRuleItem {
615
+ /** Stable record id (part of the ledger key). */
616
+ readonly id: string;
617
+ /** The `dateField` value, `YYYY-MM-DD`. */
618
+ readonly date: CivilDateString;
619
+ /** Template variables (and anything the host's audience resolver needs). */
620
+ readonly data: Record<string, unknown>;
621
+ /** The business document, for the delivery log's per-document history. */
622
+ readonly subject?: {
623
+ readonly sourceModel: string;
624
+ readonly sourceId: string;
625
+ };
626
+ readonly organizationId?: string;
627
+ }
628
+ interface DateRuleSourceQuery {
629
+ readonly dateField: string;
630
+ /** Inclusive lower bound; absent = unbounded (an open-ended repeating rule). */
631
+ readonly from?: CivilDateString;
632
+ /** Inclusive upper bound. */
633
+ readonly to: CivilDateString;
634
+ readonly organizationId?: string | null;
635
+ }
636
+ /**
637
+ * A host record type that carries dates. Registered by the host (one per record
638
+ * type); the admin screen lists them through {@link DateRuleEngine.catalog}.
639
+ */
640
+ interface DateRuleSource {
641
+ readonly key: string;
642
+ readonly label?: string;
643
+ readonly dateFields: ReadonlyArray<{
644
+ readonly key: string;
645
+ readonly label?: string;
646
+ }>;
647
+ /** Template variables items carry, as a hint for rule authors (`{employeeName}`). */
648
+ readonly variables?: readonly string[];
649
+ /**
650
+ * OPEN items whose `dateField` lies in `[from, to]`. "Open" is the source's call:
651
+ * an item that no longer needs reminding must not be returned.
652
+ */
653
+ find(query: DateRuleSourceQuery): Promise<readonly DateRuleItem[]> | AsyncIterable<DateRuleItem>;
654
+ }
655
+ type DateRuleAudienceResolver<TAudience = unknown> = (audience: TAudience, context: {
656
+ readonly rule: DateRule<TAudience>;
657
+ readonly item: DateRuleItem;
658
+ readonly occurrence: DateRuleOccurrence;
659
+ }) => readonly Recipient[] | null | undefined | Promise<readonly Recipient[] | null | undefined>;
660
+ /**
661
+ * "Has this occurrence fired?" — claimed BEFORE dispatch, released on failure so the
662
+ * next sweep retries. Back it with a unique index in production; the memory ledger
663
+ * is for tests and single-process dev.
664
+ */
665
+ interface DateRuleLedger {
666
+ /** `true` when this call claimed the key (first time), `false` when already claimed. */
667
+ claim(key: string, info: DateRuleLedgerEntry): Promise<boolean>;
668
+ /** Undo a claim whose dispatch failed. */
669
+ release(key: string): Promise<void>;
670
+ }
671
+ interface DateRuleLedgerEntry {
672
+ readonly ruleId: string;
673
+ readonly itemId: string;
674
+ readonly occurrenceOn: CivilDateString;
675
+ readonly index: number;
676
+ readonly sweptOn: CivilDateString;
677
+ }
678
+ export declare function createMemoryDateRuleLedger(): DateRuleLedger & {
679
+ keys(): string[];
680
+ };
681
+ interface DateRuleOccurrence {
682
+ /** 0-based occurrence index. */
683
+ readonly index: number;
684
+ /** The civil date this occurrence is scheduled for. */
685
+ readonly on: CivilDateString;
686
+ }
687
+ /** Problems with a schedule, as messages; empty = valid. */
688
+ export declare function validateDateRuleSchedule(schedule: DateRuleSchedule): string[];
689
+ /**
690
+ * The occurrence of `schedule` for an item dated `date` that a sweep on `today`
691
+ * should send, or `null`. The latest occurrence on or before `today` is due when it
692
+ * is at most `graceDays` old — so a missed sweep catches up, but a rule created
693
+ * today does not replay years of history.
694
+ */
695
+ export declare function dueOccurrence(schedule: DateRuleSchedule, date: CivilDateString, today: CivilDateString, graceDays?: number): DateRuleOccurrence | null;
696
+ /** The `[from, to]` range of item dates a sweep on `today` must ask a source for. */
697
+ export declare function dateRuleWindow(schedule: DateRuleSchedule, today: CivilDateString, graceDays?: number): {
698
+ from?: CivilDateString;
699
+ to: CivilDateString;
700
+ };
701
+ interface DateRuleEngineOptions<TAudience = unknown> {
702
+ readonly sources: Iterable<DateRuleSource>;
703
+ /** The rules to evaluate (admin-created rows); disabled rows are skipped. */
704
+ readonly rules: () => readonly DateRule<TAudience>[] | Promise<readonly DateRule<TAudience>[]>;
705
+ readonly resolveAudience: DateRuleAudienceResolver<TAudience>;
706
+ /** The notification service (read per sweep, so a module export can back it). */
707
+ readonly service: () => NotificationService;
708
+ /** Default: an in-memory ledger (single process, lost on restart). */
709
+ readonly ledger?: DateRuleLedger;
710
+ /** How many days late an occurrence may still be sent (missed sweeps). Default 0. */
711
+ readonly graceDays?: number;
712
+ /** Extra send data per item (e.g. email `subject`/`text` rendered from the rule's copy). */
713
+ readonly data?: (rule: DateRule<TAudience>, item: DateRuleItem) => Record<string, unknown>;
714
+ /** A rule or item failed; the sweep continues. */
715
+ readonly onError?: (err: unknown, context: {
716
+ readonly ruleId: string;
717
+ readonly itemId?: string;
718
+ }) => void;
719
+ }
720
+ interface DateRuleSweepResult {
721
+ readonly date: CivilDateString;
722
+ /** Enabled rules evaluated. */
723
+ readonly rules: number;
724
+ /** Items whose occurrence was due. */
725
+ readonly due: number;
726
+ /** Occurrences dispatched by this sweep. */
727
+ readonly fired: number;
728
+ /** Occurrences already claimed (an earlier sweep, another replica). */
729
+ readonly duplicates: number;
730
+ /** Occurrences with nobody to tell. */
731
+ readonly noRecipients: number;
732
+ readonly failed: number;
733
+ }
734
+ interface DateRuleCatalogEntry {
735
+ readonly key: string;
736
+ readonly label: string;
737
+ readonly dateFields: ReadonlyArray<{
738
+ readonly key: string;
739
+ readonly label: string;
740
+ }>;
741
+ readonly variables: readonly string[];
742
+ }
743
+ interface DateRuleEngine<TAudience = unknown> {
744
+ /** Evaluate every enabled rule for the civil date `today`. */
745
+ sweep(today: CivilDateString): Promise<DateRuleSweepResult>;
746
+ /** Items a rule would notify on `today` (no claims, no sends): the admin "preview". */
747
+ preview(rule: DateRule<TAudience>, today: CivilDateString): Promise<Array<DateRuleItem & {
748
+ occurrence: DateRuleOccurrence;
749
+ }>>;
750
+ /** Registered sources for the rule editor. */
751
+ catalog(): DateRuleCatalogEntry[];
752
+ /** Problems with a rule against the registered sources, as messages; empty = valid. */
753
+ validate(rule: Pick<DateRule<TAudience>, "source" | "dateField" | "schedule">): string[];
754
+ }
755
+ /** Ledger key of one occurrence. */
756
+ export declare const dateRuleOccurrenceKey: (ruleId: string, itemId: string, on: CivilDateString) => string;
757
+ export declare function createDateRuleEngine<TAudience = unknown>(options: DateRuleEngineOptions<TAudience>): DateRuleEngine<TAudience>;
515
758
  /** One draftable template row — the shape be-prod's EmailTemplate model proved. */
516
759
  interface StoredTemplate {
517
760
  readonly key: string;
@@ -547,9 +790,7 @@ interface DbFirstTemplateResolver {
547
790
  /** Drop one key (after a draft is saved) or the whole cache. */
548
791
  invalidate(key?: string): void;
549
792
  }
550
- declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
551
- //#endregion
552
- //#region src/inAppChannel.d.ts
793
+ export declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
553
794
  interface InAppChannelConfig extends ChannelConfig {
554
795
  /** Feed store the channel writes rows to (host-owned persistence). */
555
796
  readonly repository: InAppFeedRepository;
@@ -571,7 +812,7 @@ interface InAppChannelConfig extends ChannelConfig {
571
812
  */
572
813
  readonly unreadCache?: UnreadCountCache;
573
814
  }
574
- declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
815
+ export declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
575
816
  private readonly repository;
576
817
  private readonly port;
577
818
  private readonly eventName;
@@ -582,8 +823,6 @@ declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
582
823
  send(payload: NotificationPayload): Promise<SendResult>;
583
824
  private orgOf;
584
825
  }
585
- //#endregion
586
- //#region src/jobsQueue.d.ts
587
826
  interface NotificationsJobOptions {
588
827
  /** Job/queue name registered with `jobsPlugin`. Default "notifications". */
589
828
  readonly name?: string;
@@ -619,9 +858,7 @@ interface NotificationsJob {
619
858
  * `enqueue`/`getJob`/`size` through `fastify.jobs`, keying `enqueue` on
620
859
  * `idempotencyKey` so crash-recovery / at-least-once redelivery dedupes.
621
860
  */
622
- declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
623
- //#endregion
624
- //#region src/module.d.ts
861
+ export declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
625
862
  /**
626
863
  * Resolve a trigger's host-typed `audience` selector to concrete recipients —
627
864
  * the shared seam so branch-role → members (or team → users, tenant → admins)
@@ -728,13 +965,13 @@ declare module "fastify" {
728
965
  notifications?: NotificationService;
729
966
  }
730
967
  }
731
- declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
968
+ export declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
732
969
  /**
733
970
  * Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
734
971
  * the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
735
972
  * `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
736
973
  */
737
- declare function notifyTriggerHandlers(options: {
974
+ export declare function notifyTriggerHandlers(options: {
738
975
  /** Handler-name prefix; arc rejects duplicate handler names across the module graph. */
739
976
  readonly name: string;
740
977
  readonly service: () => NotificationService;
@@ -742,11 +979,9 @@ declare function notifyTriggerHandlers(options: {
742
979
  readonly recipientResolver?: RecipientResolver;
743
980
  }): EventHandlerDefinition[];
744
981
  /** One event → N sends. Exported for direct unit testing. */
745
- declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
982
+ export declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
746
983
  recipientResolver?: RecipientResolver;
747
984
  }): Promise<void>;
748
- //#endregion
749
- //#region src/repositoryDeliveryLog.d.ts
750
985
  type EntryStatus = DeliveryLogEntry["status"];
751
986
  /** The persisted document shape (also what the resource/UI reads). */
752
987
  interface DeliveryLogDoc {
@@ -774,21 +1009,54 @@ interface DeliveryLogDoc {
774
1009
  readonly sourceModel: string;
775
1010
  readonly sourceId: string;
776
1011
  };
1012
+ /**
1013
+ * The authored rule (an admin-defined trigger) that produced this delivery, when there
1014
+ * is one. A top-level column — not a `metadata` key — so a store can index it and
1015
+ * {@link RepositoryDeliveryLog.listByRule} is a keyed read.
1016
+ */
1017
+ readonly ruleId?: string;
1018
+ /** Host-typed columns stored verbatim beside the package's own. Never read by the package. */
1019
+ readonly extraFields?: Record<string, unknown>;
777
1020
  }
778
- interface RepositoryDeliveryLogOptions {
1021
+ interface RepositoryDeliveryLogOptions<TExtra extends Record<string, unknown> = Record<string, unknown>> {
779
1022
  /** Cap for `query()` when the caller gives no limit. Default 100. */
780
1023
  readonly defaultLimit?: number;
1024
+ /** Source of the entry `timestamp`. Default real time. */
1025
+ readonly clock?: NotificationClock;
1026
+ /**
1027
+ * Which authored rule a send belongs to. Default: `payload.metadata.ruleId` when it is a
1028
+ * non-empty string. A host whose rule id rides elsewhere (an event name such as
1029
+ * `notify.rule.<id>`) supplies its own resolver.
1030
+ */
1031
+ readonly ruleId?: (payload: NotificationPayload) => string | undefined;
1032
+ /** Host-typed columns to persist on the entry (`DeliveryLogDoc.extraFields`). */
1033
+ readonly extraFields?: (payload: NotificationPayload, dispatch: DispatchResult) => TExtra | undefined;
1034
+ }
1035
+ /** A delivery entry with the rule and host columns the plain `DeliveryLogEntry` has no slot for. */
1036
+ type RuleDeliveryLogEntry<TExtra extends Record<string, unknown> = Record<string, unknown>> = DeliveryLogEntry & {
1037
+ readonly ruleId?: string;
1038
+ readonly extraFields?: TExtra;
1039
+ };
1040
+ interface DeliveryLogByRuleQuery {
1041
+ /** Cap on rows returned. Falls back to the log's `defaultLimit`. */
1042
+ readonly limit?: number;
1043
+ readonly status?: EntryStatus;
1044
+ readonly after?: Date;
1045
+ readonly before?: Date;
1046
+ }
1047
+ /** The kernel `DeliveryLog` plus the per-rule read. Assignable wherever a `DeliveryLog` is. */
1048
+ interface RepositoryDeliveryLog<TExtra extends Record<string, unknown> = Record<string, unknown>> extends DeliveryLog {
1049
+ /** Newest-first deliveries of ONE authored rule. */
1050
+ listByRule(ruleId: string, query?: DeliveryLogByRuleQuery): Promise<RuleDeliveryLogEntry<TExtra>[]>;
781
1051
  }
782
- declare function createRepositoryDeliveryLog(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions): DeliveryLog;
783
- //#endregion
784
- //#region src/sseChannel.d.ts
1052
+ export declare function createRepositoryDeliveryLog<TExtra extends Record<string, unknown> = Record<string, unknown>>(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions<TExtra>): RepositoryDeliveryLog<TExtra>;
785
1053
  interface SseChannelConfig extends ChannelConfig {
786
1054
  /** Host realtime transport (be-prod's SSE manager, a WS hub, ...). */
787
1055
  readonly port: RealtimePushPort;
788
1056
  /** SSE event name pushed to the client. Default "notification". */
789
1057
  readonly eventName?: string;
790
1058
  }
791
- declare class SseChannel extends BaseChannel<SseChannelConfig> {
1059
+ export declare class SseChannel extends BaseChannel<SseChannelConfig> {
792
1060
  private readonly port;
793
1061
  private readonly eventName;
794
1062
  constructor(config: SseChannelConfig);
@@ -796,8 +1064,6 @@ declare class SseChannel extends BaseChannel<SseChannelConfig> {
796
1064
  send(payload: NotificationPayload): Promise<SendResult>;
797
1065
  private orgOf;
798
1066
  }
799
- //#endregion
800
- //#region src/templatesResource.d.ts
801
1067
  /** Code-defined template map (same shape `createSimpleResolver` accepts). */
802
1068
  type CodeTemplates = Record<string, {
803
1069
  readonly subject?: string;
@@ -824,9 +1090,7 @@ interface TemplatesResourceOptions {
824
1090
  /** Tenant field. Default `false` — templates are company-wide (be-prod convention). */
825
1091
  readonly tenantField?: string | false;
826
1092
  }
827
- declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
828
- //#endregion
829
- //#region src/triggerAuthoring.d.ts
1093
+ export declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
830
1094
  /** Who a trigger targets. */
831
1095
  type NotificationAudienceType = "staff" | "customer";
832
1096
  /** Direct contact for a customer-facing notification. */
@@ -946,8 +1210,6 @@ interface NotificationTrigger {
946
1210
  */
947
1211
  extract: (payload: Record<string, any>) => NotificationExtractResult | null;
948
1212
  }
949
- //#endregion
950
- //#region src/triggerAdapter.d.ts
951
1213
  /**
952
1214
  * `entityType` → the `extract` variable holding that entity's id.
953
1215
  *
@@ -955,7 +1217,7 @@ interface NotificationTrigger {
955
1217
  * the variable it lives under is named per entity. This is the default
956
1218
  * commerce mapping; a host with its own entity kinds passes `entityIdVars`.
957
1219
  */
958
- declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
1220
+ export declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
959
1221
  interface TriggerAdapterOptions {
960
1222
  /**
961
1223
  * Extra `entityType` → id-variable mappings, merged over the defaults.
@@ -1051,14 +1313,14 @@ interface TriggerOverrideResolution {
1051
1313
  * calls both per dispatch, so an `extract` with side effects would run them
1052
1314
  * twice — keep extractors pure (every one in practice is a payload reshape).
1053
1315
  */
1054
- declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
1316
+ export declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
1055
1317
  /**
1056
1318
  * Adapt a whole registry — what a host assigns to the module's `notifyOn`.
1057
1319
  *
1058
1320
  * With `options.overrides`, each trigger becomes one entry per channel, gated on
1059
1321
  * the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
1060
1322
  */
1061
- declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
1323
+ export declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
1062
1324
  /**
1063
1325
  * First trigger registered for an event.
1064
1326
  *
@@ -1067,20 +1329,18 @@ declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], opti
1067
1329
  * returns only the first. For anything that must see all of them — a docs page,
1068
1330
  * an admin matrix, a dispatch count — use `getTriggersByEvent`.
1069
1331
  */
1070
- declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
1332
+ export declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
1071
1333
  /** Every trigger registered for an event, in registration order. */
1072
- declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
1334
+ export declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
1073
1335
  /** Distinct event names the registry subscribes to (for docs / admin UI). */
1074
- declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
1336
+ export declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
1075
1337
  /**
1076
1338
  * Notification kind → recipient roles (for the admin UI).
1077
1339
  *
1078
1340
  * Keyed by `type ?? event`, so two triggers sharing an event stay distinct
1079
1341
  * rows — which is exactly why they carry different `type`s.
1080
1342
  */
1081
- declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
1082
- //#endregion
1083
- //#region src/triggerCatalog.d.ts
1343
+ export declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
1084
1344
  /**
1085
1345
  * One trigger as an operator sees it, with the CODE defaults an override replaces.
1086
1346
  *
@@ -1121,5 +1381,4 @@ interface TriggerCatalogEntry {
1121
1381
  * exists", which is the silent-empty failure this file exists to avoid.
1122
1382
  */
1123
1383
  type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
1124
- //#endregion
1125
- export { type CodeTemplates, type CodeTriggerDefaults, type CreateCachedTriggerOverrideStoreOptions, type CreateUnreadCountCacheOptions, DEFAULT_ENTITY_ID_VARS, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, type EffectiveTriggerSettings, INAPP_DATA_KEY, IN_APP_CHANNEL, type InAppBackfill, type InAppBackfillOptions, InAppChannel, type InAppChannelConfig, type InAppContent, type InAppFeedListOptions, type InAppFeedPage, type InAppFeedRepository, type InAppFeedResourceOptions, type InAppFeedRow, type InAppFeedScope, type InAppPriority, type InAppTemplate, type NotificationAudienceType, type NotificationChannel, type NotificationChannelCatalog, type NotificationDecision, type NotificationExtractResult, type NotificationRecipientContact, type NotificationSkipReason, type NotificationTrigger, type NotificationTriggerOverrideDoc, type NotificationsJob, type NotificationsJobOptions, type NotificationsModuleConfig, type NotifyTrigger, type RealtimePushPort, type RecipientResolver, type RepositoryDeliveryLogOptions, SseChannel, type SseChannelConfig, type StoredTemplate, type TemplateStore, type TemplatesResourceOptions, type TriggerAdapterOptions, type TriggerCatalogEntry, type TriggerCatalogPort, type TriggerOverrideLoader, type TriggerOverrideResolution, type TriggerOverrideStore, type UnreadCountCache, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, notifyTriggerHandlers, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
1384
+ export type { CivilDateString, CodeTemplates, CodeTriggerDefaults, CreateCachedTriggerOverrideStoreOptions, CreateUnreadCountCacheOptions, DateRule, DateRuleAudienceResolver, DateRuleCatalogEntry, DateRuleEngine, DateRuleEngineOptions, DateRuleItem, DateRuleLedger, DateRuleLedgerEntry, DateRuleOccurrence, DateRuleSchedule, DateRuleSource, DateRuleSourceQuery, DateRuleSweepResult, DbFirstResolverOptions, DbFirstTemplateResolver, DeliveryLogByRuleQuery, DeliveryLogDoc, EffectiveTriggerSettings, InAppBackfill, InAppBackfillConnect, InAppBackfillOptions, InAppChannelConfig, InAppContent, InAppFeedListOptions, InAppFeedPage, InAppFeedRepository, InAppFeedResourceOptions, InAppFeedRow, InAppFeedScope, InAppPriority, InAppTemplate, NotificationAudienceType, NotificationChannel, NotificationChannelCatalog, NotificationClock, NotificationDecision, NotificationExtractResult, NotificationRecipientContact, NotificationSkipReason, NotificationTrigger, NotificationTriggerOverrideDoc, NotificationsJob, NotificationsJobOptions, NotificationsModuleConfig, NotifyTrigger, RealtimePushPort, RecipientResolver, RepositoryDeliveryLog, RepositoryDeliveryLogOptions, RuleDeliveryLogEntry, SseChannelConfig, StoredTemplate, TemplateStore, TemplatesResourceOptions, TriggerAdapterOptions, TriggerCatalogEntry, TriggerCatalogPort, TriggerOverrideLoader, TriggerOverrideResolution, TriggerOverrideStore, UnreadCountCache };