@12-apps/notifications 4.3.1 → 4.5.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.
Files changed (42) hide show
  1. package/dist/{chunk-4PSUZ7X5.js → chunk-ORXJH3VM.js} +2 -2
  2. package/dist/chunk-ORXJH3VM.js.map +1 -0
  3. package/dist/chunk-WU6QJLSZ.js +94 -0
  4. package/dist/chunk-WU6QJLSZ.js.map +1 -0
  5. package/dist/chunk-YE24MDS6.js +1022 -0
  6. package/dist/chunk-YE24MDS6.js.map +1 -0
  7. package/dist/create-api-notifications-CgBdjfyF.d.ts +909 -0
  8. package/dist/create-web-notifications-Du3hTs7P.d.ts +354 -0
  9. package/dist/{generators-FATT537X.d.ts → generators-B9xt3sRh.d.ts} +1 -1
  10. package/dist/hono/index.d.ts +5 -5
  11. package/dist/hono/index.js +1 -1
  12. package/dist/index.d.ts +3 -3
  13. package/dist/jobs-DhDjrAX5.d.ts +74 -0
  14. package/dist/manifest/index.d.ts +20 -6
  15. package/dist/manifest/index.js +2 -1
  16. package/dist/manifest/index.js.map +1 -1
  17. package/dist/manifest/server.d.ts +22 -30
  18. package/dist/manifest/server.js +12 -3
  19. package/dist/manifest/server.js.map +1 -1
  20. package/dist/manifest/web.d.ts +51 -0
  21. package/dist/manifest/web.js +15 -0
  22. package/dist/manifest/web.js.map +1 -0
  23. package/dist/react/index.d.ts +6 -353
  24. package/dist/react/index.js +20 -1008
  25. package/dist/react/index.js.map +1 -1
  26. package/dist/server/index.d.ts +79 -897
  27. package/dist/server/index.js +14 -3
  28. package/dist/{types-yq_o4N01.d.ts → types-CXLAG3UU.d.ts} +1 -1
  29. package/dist/web-push/index.d.ts +2 -2
  30. package/dist/{web-push-KLY6UMRT.d.ts → web-push-Cs14Wp9u.d.ts} +2 -2
  31. package/dist/{wire-SDUtscGu.d.ts → wire-5IRin4zH.d.ts} +1 -1
  32. package/package.json +10 -3
  33. package/src/manifest/index.ts +20 -6
  34. package/src/manifest/server.ts +8 -0
  35. package/src/manifest/web.ts +46 -0
  36. package/src/server/index.ts +20 -0
  37. package/src/server/jobs.ts +118 -0
  38. package/src/server/transports/email.ts +8 -3
  39. package/src/server/wire-notify-port.ts +125 -0
  40. package/dist/chunk-4PSUZ7X5.js.map +0 -1
  41. package/dist/chunk-F5ANWJCY.js +0 -1
  42. package/dist/chunk-F5ANWJCY.js.map +0 -1
@@ -1,4 +1,10 @@
1
- import "../chunk-F5ANWJCY.js";
1
+ import {
2
+ NOTIFICATIONS_DRAIN_CRON,
3
+ NOTIFICATIONS_DRAIN_LEASE_MS,
4
+ NOTIFICATIONS_JOBS,
5
+ NOTIFICATIONS_SWEEP_QUEUE,
6
+ wireNotifyPort
7
+ } from "../chunk-WU6QJLSZ.js";
2
8
  import {
3
9
  EMAIL_DRIVERS,
4
10
  NotificationProviderError,
@@ -19,12 +25,16 @@ import {
19
25
  smsTransport,
20
26
  webPushTransport,
21
27
  whatsAppTransport
22
- } from "../chunk-4PSUZ7X5.js";
28
+ } from "../chunk-ORXJH3VM.js";
23
29
  import "../chunk-AHNRSA6U.js";
24
30
  import "../chunk-4TTYQVPK.js";
25
31
  import "../chunk-7QVYU63E.js";
26
32
  export {
27
33
  EMAIL_DRIVERS,
34
+ NOTIFICATIONS_DRAIN_CRON,
35
+ NOTIFICATIONS_DRAIN_LEASE_MS,
36
+ NOTIFICATIONS_JOBS,
37
+ NOTIFICATIONS_SWEEP_QUEUE,
28
38
  NotificationProviderError,
29
39
  NotificationsApiError,
30
40
  SMS_DRIVERS,
@@ -42,6 +52,7 @@ export {
42
52
  ok,
43
53
  smsTransport,
44
54
  webPushTransport,
45
- whatsAppTransport
55
+ whatsAppTransport,
56
+ wireNotifyPort
46
57
  };
47
58
  //# sourceMappingURL=index.js.map
@@ -152,4 +152,4 @@ interface NotificationLogger {
152
152
  error(message: string, ...meta: unknown[]): void;
153
153
  }
154
154
 
155
- export { type DeliveryStatus as D, type NotificationGenerator as N, type TransportRecipient as T, NOTIFICATION_CHANNELS as a, type NotificationCategory as b, type NotificationChannel as c, type NotificationContent as d, type NotificationEvent as e, type NotificationLogger as f, type NotificationRecipient as g, type NotificationTaxonomy as h, type NotificationTransport as i, taxonomyOf as t };
155
+ export { type DeliveryStatus as D, NOTIFICATION_CHANNELS as N, type TransportRecipient as T, type NotificationCategory as a, type NotificationChannel as b, type NotificationContent as c, type NotificationEvent as d, type NotificationGenerator as e, type NotificationLogger as f, type NotificationRecipient as g, type NotificationTaxonomy as h, type NotificationTransport as i, taxonomyOf as t };
@@ -1,5 +1,5 @@
1
- import { W as WebPushSender } from '../web-push-KLY6UMRT.js';
2
- import '../types-yq_o4N01.js';
1
+ import { c as WebPushSender } from '../web-push-Cs14Wp9u.js';
2
+ import '../types-CXLAG3UU.js';
3
3
 
4
4
  /**
5
5
  * `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.
@@ -1,4 +1,4 @@
1
- import { f as NotificationLogger, d as NotificationContent, i as NotificationTransport } from './types-yq_o4N01.js';
1
+ import { f as NotificationLogger, c as NotificationContent, i as NotificationTransport } from './types-CXLAG3UU.js';
2
2
 
3
3
  /**
4
4
  * The DRIVER port — the reason a host adds a vendor with a config entry and no
@@ -125,4 +125,4 @@ declare const WEB_PUSH_DRIVERS: Record<string, (declaration: WebPushDriverDeclar
125
125
  declare function formatWebPush(content: NotificationContent): WebPushMessage;
126
126
  declare function webPushTransport(declaration: WebPushDriverDeclaration, subscriptions: WebPushSubscriptionSource, extraDrivers?: Record<string, (d: WebPushDriverDeclaration) => WebPushSender>): NotificationTransport<WebPushMessage>;
127
127
 
128
- export { type DriverDeclarationBase as D, type FetchImpl as F, NotificationProviderError as N, type WebPushSender as W, type WebPushSubscriptionSource as a, type WebPushDriverDeclaration as b, WEB_PUSH_DRIVERS as c, type WebPushMessage as d, type WebPushSubscription as e, absoluteLink as f, formatWebPush as g, webPushTransport as w };
128
+ export { type DriverDeclarationBase as D, type FetchImpl as F, NotificationProviderError as N, WEB_PUSH_DRIVERS as W, type WebPushDriverDeclaration as a, type WebPushMessage as b, type WebPushSender as c, type WebPushSubscription as d, type WebPushSubscriptionSource as e, absoluteLink as f, formatWebPush as g, webPushTransport as w };
@@ -1,4 +1,4 @@
1
- import { b as NotificationCategory, c as NotificationChannel } from './types-yq_o4N01.js';
1
+ import { a as NotificationCategory, b as NotificationChannel } from './types-CXLAG3UU.js';
2
2
 
3
3
  /**
4
4
  * Every sentence this package can say to a USER, stated by the HOST.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/notifications",
3
- "version": "4.3.1",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
6
6
  "exports": {
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/manifest/server.d.ts",
33
33
  "default": "./dist/manifest/server.js"
34
34
  },
35
+ "./manifest/web": {
36
+ "types": "./dist/manifest/web.d.ts",
37
+ "default": "./dist/manifest/web.js"
38
+ },
35
39
  "./package.json": "./package.json"
36
40
  },
37
41
  "scripts": {
@@ -46,7 +50,7 @@
46
50
  "prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
47
51
  },
48
52
  "dependencies": {
49
- "@12-apps/ui": "^6.2.0"
53
+ "@12-apps/ui": "^6.9.0"
50
54
  },
51
55
  "peerDependencies": {
52
56
  "@12-apps/wiring": ">=1.3.0",
@@ -67,12 +71,15 @@
67
71
  },
68
72
  "web-push": {
69
73
  "optional": true
74
+ },
75
+ "@12-apps/wiring": {
76
+ "optional": true
70
77
  }
71
78
  },
72
79
  "devDependencies": {
73
80
  "@12-apps/eslint-config": "^1.21.1",
74
81
  "@12-apps/typescript-config": "^1.20.1",
75
- "@12-apps/wiring": "^1.10.0",
82
+ "@12-apps/wiring": "^1.13.0",
76
83
  "@testing-library/react": "^16.1.0",
77
84
  "@types/react": "19.2.2",
78
85
  "@types/react-dom": "19.2.2",
@@ -14,11 +14,24 @@
14
14
  * (`config.transports`, `config.drivers`), so what would be declared here
15
15
  * is a seam the host already fills — and declaring it would oblige an
16
16
  * adopter to bind a mailer this package never owns.
17
- * - **No `web` inventory**, though `./react` ships the bell and the
18
- * preference screens. Listing it would oblige every SERVER host adopting
19
- * this manifest to answer for a React surface it never mounts —
20
- * `assemble()` refuses a declared-but-unanswered capability, so the
21
- * inventory must not overstate.
17
+ *
18
+ * ON THE `jobs` DECLARATION. The dispatch fast path and the retry sweep are
19
+ * this package's own cadence decisions — attempts, backoff, the five-minute
20
+ * tick, the single-flight lease — and they were host code in every adopting
21
+ * host, restated by hand from this package's docstrings. That is the
22
+ * `paymentsJobBlueprints()` incident's shape exactly: a mechanism a host must
23
+ * remember to schedule is a mechanism most hosts silently do not have, and the
24
+ * one host that DID write them wrote them correctly only because someone read
25
+ * the source. `./server`'s `NOTIFICATIONS_JOBS` declares both; a host with no
26
+ * worker declines the capability in writing and the report says so.
27
+ *
28
+ * ON THE `web` INVENTORY, which this manifest used to narrow away. The reason
29
+ * given — that listing it would oblige every SERVER host to answer for a React
30
+ * surface it never mounts — is not how the consumer behaves: a capability
31
+ * declared for the OTHER runtime is reported `out-of-scope`, and only an
32
+ * applicable, unanswered one is `unbound`. So the narrowing protected nothing
33
+ * and hid Bell, Panel and Preferences from every adopting host, which is why
34
+ * the origin host hand-duplicated two of the three.
22
35
  *
23
36
  * ON THE `db` DECLARATION. The origin host already composes
24
37
  * `prisma/notifications.prisma` into its schema — but by STRUCTURAL
@@ -45,5 +58,6 @@ export const notificationsManifest = {
45
58
  * exhausts its attempts files under `notifications`, not nowhere.
46
59
  */
47
60
  observability: { namespace: 'notifications' },
48
- server: ['http'],
61
+ server: ['http', 'jobs'],
62
+ web: ['surface'],
49
63
  } as const satisfies PackageManifest;
@@ -27,6 +27,7 @@ import type { AnyServerManifest, WireRequest } from '@12-apps/wiring';
27
27
 
28
28
  import {
29
29
  createApiNotifications,
30
+ NOTIFICATIONS_JOBS,
30
31
  type ApiNotifications,
31
32
  type NotificationsRoute,
32
33
  type NotificationsServerConfig,
@@ -75,4 +76,11 @@ export function createWireApiNotifications(
75
76
  export const notificationsServerManifest = {
76
77
  name: '@12-apps/notifications',
77
78
  http: { create: createWireApiNotifications },
79
+ /**
80
+ * The dispatch fast path and the retry sweep, with their cadence. The host
81
+ * binds `{ dispatchDeliveries, drainPending }` off its own mount — the two
82
+ * methods the aggregate already hands it — and deletes the hand-rolled
83
+ * copies. See `../server/jobs` for why the numbers are the package's.
84
+ */
85
+ jobs: NOTIFICATIONS_JOBS,
78
86
  } as const satisfies AnyServerManifest;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `@12-apps/notifications/manifest/web` — the web capabilities.
3
+ *
4
+ * `surface.create` IS `createWebNotifications`, unchanged: the bell, the inbox
5
+ * slide-over, the preferences screen and the bound store, built once per
6
+ * adoption by the consumer's binder (the members are component TYPES, so
7
+ * rebuilding per render unmounts the panel mid-interaction — the memoisation
8
+ * rule every hand wiring carries as a comment today).
9
+ *
10
+ * ## Why this manifest exists now
11
+ *
12
+ * It was narrowed away with a reason that reads plausibly and is FALSE:
13
+ * "listing `web` would oblige every server host adopting this manifest to
14
+ * answer for a React surface it never mounts." The consumer does not work
15
+ * that way. A capability declared for the other runtime is reported
16
+ * `out-of-scope` — "a web host answers for this" — and `assemble()` returns
17
+ * fine; only a capability applicable to THIS runtime and unanswered is
18
+ * `unbound`. `wiring`'s own fixture package declares both halves and its
19
+ * server-host suite asserts exactly that.
20
+ *
21
+ * The narrowing therefore bought nothing and cost the thing the capability is
22
+ * for. `./react` and `./web-push` ship Bell, Panel and Preferences — the exact
23
+ * screens the adaptation report wanted to stop being hand-duplicated in hosts,
24
+ * and the origin host duplicated the preferences screen and the push setup
25
+ * anyway, because a manifest that never mentions them is a manifest nobody
26
+ * discovers them from.
27
+ *
28
+ * ## Why there are no `areas`
29
+ *
30
+ * The bell is not a routed screen: it lives in a host's header chrome, beside
31
+ * whatever else that host puts there, and no package can suggest a route for
32
+ * it. Preferences IS routed, but where it belongs differs per host — under
33
+ * account settings in one app, under a tenant's configuration in another — and
34
+ * it is offered as `page` on the surface for the host to route at its own
35
+ * call site. A suggested nav row would be wrong for every host but the first,
36
+ * which is the `AreaContribution` doctrine's own test.
37
+ */
38
+
39
+ import type { AnyWebManifest } from '@12-apps/wiring';
40
+
41
+ import { createWebNotifications } from '../react/create-web-notifications';
42
+
43
+ export const notificationsWebManifest = {
44
+ name: '@12-apps/notifications',
45
+ surface: { create: createWebNotifications },
46
+ } as const satisfies AnyWebManifest;
@@ -113,3 +113,23 @@ export {
113
113
  type WebPushSubscription,
114
114
  type WebPushSubscriptionSource,
115
115
  } from './transports/web-push';
116
+
117
+ /**
118
+ * The `NotifyPort` adapter — this package as a host's ONE notification
119
+ * channel, for every package that has something to say and no dependency on
120
+ * this one. See `./wire-notify-port` for why an emit never throws.
121
+ */
122
+ export { wireNotifyPort, type NotifyPortSource } from './wire-notify-port';
123
+
124
+ /**
125
+ * The two background jobs, declared with their cadence — see `./jobs` for why
126
+ * the attempts, the tick and the lease are this package's numbers rather than
127
+ * a host's.
128
+ */
129
+ export {
130
+ NOTIFICATIONS_JOBS,
131
+ NOTIFICATIONS_DRAIN_CRON,
132
+ NOTIFICATIONS_DRAIN_LEASE_MS,
133
+ NOTIFICATIONS_SWEEP_QUEUE,
134
+ type NotificationsJobDeps,
135
+ } from './jobs';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The two background jobs getting a message out actually needs.
3
+ *
4
+ * ## Why they are declared here and not left to the host
5
+ *
6
+ * `notify()` commits the inbox record and one QUEUED delivery per channel in a
7
+ * transaction, and that row IS the durable record of the send. Everything after
8
+ * it is a decision about WHEN the provider call happens — and every one of
9
+ * those decisions is this package's knowledge, not a host's:
10
+ *
11
+ * - the fast path retries only INFRASTRUCTURE faults, because a provider
12
+ * rejecting one channel does not throw here (the row goes FAILED and the
13
+ * sweep owns the retry), so three spaced attempts is about surviving a
14
+ * restart rather than chasing a provider;
15
+ * - the sweep's five-minute cadence is the resolution at which "my customer
16
+ * never got the e-mail" stops being an incident;
17
+ * - the sweep runs single-flight, because overlapping passes are duplicate
18
+ * billed provider calls;
19
+ * - and the sweep is what makes the system UNSTUCK-ABLE: it re-dispatches
20
+ * FAILED rows and stale claims whose dispatching process died mid-flight.
21
+ * Before it existed a provider blip left a delivery FAILED forever.
22
+ *
23
+ * A host asked to restate all of that is a host that can get it wrong — and,
24
+ * far more likely, a host that never schedules the sweep at all and quietly
25
+ * has no retry. That is the `paymentsJobBlueprints()` incident exactly: a
26
+ * mechanism a host must remember to schedule is a mechanism most hosts do not
27
+ * have. The origin host DID write both jobs, correctly, by hand — cadence,
28
+ * lease ttl, attempts and concurrency restated in its own `lib/jobs` — which
29
+ * is the drift this declaration ends rather than a gap it fills.
30
+ *
31
+ * What stays the host's: whether to run them at all, on which queue runtime,
32
+ * and the lease implementation. A host with no worker declines the `jobs`
33
+ * capability in writing and the wiring report says so.
34
+ */
35
+
36
+ import type { JobsContribution, WireJobBlueprint } from '@12-apps/wiring';
37
+
38
+ import type { ApiNotifications } from './create-api-notifications';
39
+
40
+ /**
41
+ * What the host closes over at bind time — the mounted api's two send paths,
42
+ * and nothing else. Deliberately a `Pick` of the real thing rather than a
43
+ * parallel interface: a reshape of either method stops compiling here.
44
+ */
45
+ export type NotificationsJobDeps = Pick<
46
+ ApiNotifications,
47
+ 'dispatchDeliveries' | 'drainPending'
48
+ >;
49
+
50
+ /**
51
+ * The single-flight queue name — the same string `@12-apps/jobs` exports as
52
+ * `SWEEP_QUEUE`, stated as a literal because this package does not depend on
53
+ * the job library (the payments-backend precedent, for the same reason).
54
+ */
55
+ export const NOTIFICATIONS_SWEEP_QUEUE = 'sweeps';
56
+
57
+ /** Five minutes — see the header on why this number is the package's. */
58
+ export const NOTIFICATIONS_DRAIN_CRON = '*/5 * * * *';
59
+
60
+ /**
61
+ * Comfortably longer than a drain pass, and under the cadence's own patience:
62
+ * every delivery a pass re-dispatches is a provider call.
63
+ */
64
+ export const NOTIFICATIONS_DRAIN_LEASE_MS = 4 * 60_000;
65
+
66
+ /** Deliver one already-committed notification through its enabled channels. */
67
+ const dispatch: WireJobBlueprint<{ notificationId: string }, NotificationsJobDeps> = {
68
+ name: 'dispatch',
69
+ // INFRASTRUCTURE faults only — a lost database connection, an OOM-killed
70
+ // worker. A provider rejecting one channel is recorded FAILED on that row
71
+ // and picked up by the drain below, so retrying here would re-send the
72
+ // channels that DID succeed.
73
+ attempts: 3,
74
+ backoff: { type: 'exponential', delayMs: 10_000 },
75
+ handle: async (payload, deps) => {
76
+ await deps.dispatchDeliveries(payload.notificationId);
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Re-dispatch everything that did not get out.
82
+ *
83
+ * Idempotent and cheap when there is nothing to do (one indexed read), and it
84
+ * only touches rows whose `updated_at` is older than its own cutoff — so it
85
+ * never races the fast path and never re-picks a row it just re-queued.
86
+ *
87
+ * The lease stays even though the package's per-delivery CLAIM already makes
88
+ * concurrent sweeps safe: the claim stops two passes sending the same row, the
89
+ * lease stops one container stacking overlapping passes at all.
90
+ */
91
+ const drain: WireJobBlueprint<void, NotificationsJobDeps> = {
92
+ name: 'drain',
93
+ queue: NOTIFICATIONS_SWEEP_QUEUE,
94
+ concurrency: 1,
95
+ schedule: { pattern: NOTIFICATIONS_DRAIN_CRON },
96
+ // Never retried by the queue: the next tick re-finds everything from durable
97
+ // state anyway, and a retry storm against a provider that is already down is
98
+ // the failure this avoids.
99
+ attempts: 1,
100
+ lease: { ttlMs: NOTIFICATIONS_DRAIN_LEASE_MS },
101
+ handle: async (_payload, deps, context) => {
102
+ const { dispatched } = await deps.drainPending();
103
+ if (dispatched > 0) {
104
+ context.logger.info(`notifications.drain re-dispatched ${dispatched} delivery(ies).`);
105
+ }
106
+ },
107
+ };
108
+
109
+ /**
110
+ * The jobs contribution. `namespace` is prepended once at bind time, so these
111
+ * arrive at a runner as `notifications.dispatch` and `notifications.drain` —
112
+ * the wire names the origin host already uses, so adopting is a deletion
113
+ * rather than a rename.
114
+ */
115
+ export const NOTIFICATIONS_JOBS = {
116
+ namespace: 'notifications',
117
+ blueprints: { dispatch, drain },
118
+ } as const satisfies JobsContribution<NotificationsJobDeps>;
@@ -46,8 +46,13 @@ export interface EmailDriverDeclaration extends DriverDeclarationBase {
46
46
  from?: string;
47
47
  /** Where the CTA link points; without it a link is dropped. */
48
48
  appUrl?: string;
49
- /** CTA label. pt-BR product copy by default. */
50
- linkLabel?: string;
49
+ /**
50
+ * The CTA link's label. REQUIRED — this used to default to `'Ver detalhes'`,
51
+ * so a host that declared EMAIL and nothing else mailed this product's
52
+ * Portuguese to its own users, in their inbox, signed with the host's own
53
+ * `from` address.
54
+ */
55
+ linkLabel: string;
51
56
  logger?: NotificationLogger;
52
57
  }
53
58
 
@@ -109,7 +114,7 @@ export function formatEmail(
109
114
  declaration: EmailDriverDeclaration,
110
115
  ): EmailMessage {
111
116
  const href = absoluteLink(content.link, declaration.appUrl);
112
- const label = declaration.linkLabel ?? 'Ver detalhes';
117
+ const label = declaration.linkLabel;
113
118
  return {
114
119
  subject: content.title,
115
120
  text: href ? `${content.body}\n\n${href}` : content.body,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `wireNotifyPort(api)` — the adapter that makes this package a host's
3
+ * `NotifyPort`.
4
+ *
5
+ * The RFC's founding incident is a package with something to say and no way to
6
+ * say it: `@12-apps/rbac`'s `invites.invite()` records a `TenantInvite` row,
7
+ * returns `{ status: 'invited' }`, and nobody mails the invitee — not because
8
+ * anyone chose silence, but because no package had any channel to ASK for a
9
+ * notification. Every host that wanted one invented a bespoke callback at the
10
+ * mount (`notifyDispatched: …`), so the seam existed N times and composed zero
11
+ * times.
12
+ *
13
+ * `NotifyPort` is that channel, and this function is its one reference
14
+ * implementation. A host mounts notifications as it always has, wraps the
15
+ * result once, and hands the port to every adopted package through
16
+ * `createWiringHost({ ports })`. A package that raises an event types against
17
+ * `NotifyPort` for the cost of zero dependencies; a host that declines binds
18
+ * nothing and the decline is written in the wiring report instead of being
19
+ * invisible.
20
+ *
21
+ * ## The two recipient shapes are two different calls
22
+ *
23
+ * `NotifyPort` addresses either one user or everyone holding a permission,
24
+ * which is exactly the `notify` / `notifyByPermission` split this package
25
+ * already draws. The port keeps the distinction because collapsing it would
26
+ * lose the property `notifyByPermission` exists for: "tell whoever can act on
27
+ * this" must resolve the audience at emit time, against the host's live
28
+ * authorization engine, not against a list some caller cached.
29
+ *
30
+ * ## Why it swallows every failure
31
+ *
32
+ * `NotifyPort.emit` NEVER throws — the `enqueueJob` doctrine, and the whole
33
+ * reason the port is safe to hand a package that has no idea what a host's
34
+ * notification pipeline is. A deferred side effect must not take down the
35
+ * request that scheduled it: an invite whose mail fails is still an invite,
36
+ * and a checkout whose receipt fails is still a paid order. Failure is an
37
+ * OUTCOME here (`{ accepted: false, reason }`), which is a thing the wiring
38
+ * report and the host's logger can both read, rather than an exception that
39
+ * unwinds a caller who cannot do anything about it.
40
+ *
41
+ * That is also why the reasons are specific. Three failures are ordinary and
42
+ * each means something different to whoever is reading:
43
+ *
44
+ * - **no generator** — the package emitted a type no host generator claims.
45
+ * That is a WIRING gap (the package declared a blueprint the host never
46
+ * registered), and it is the single most likely thing to be wrong the first
47
+ * time a package's event reaches a host.
48
+ * - **unknown recipient** — the user id does not resolve. Ordinary in a
49
+ * tenant where the addressee was removed between the write and the emit.
50
+ * - **no audience directory** — `notifyByPermission` needs the host's
51
+ * authorization engine and the host did not pass one. Loud in the reason,
52
+ * because the alternative is a money alert nobody gets.
53
+ *
54
+ * The call is deliberately made AFTER the caller's transaction commits, and
55
+ * that rule is enforced where the host binds the port rather than here: this
56
+ * function cannot see a transaction it was never given. `router.notify`
57
+ * already announces after its own commit for the same read-your-own-hint
58
+ * reason.
59
+ */
60
+
61
+ import type { NotifyEvent, NotifyOutcome, NotifyPort } from '@12-apps/wiring/ports';
62
+
63
+ import { UnknownNotificationRecipientError, UnknownNotificationTypeError } from '../errors';
64
+ import type { ApiNotifications } from './create-api-notifications';
65
+
66
+ /** What this adapter needs of a mount — narrower than the whole api, on purpose. */
67
+ export type NotifyPortSource = Pick<ApiNotifications, 'notify' | 'notifyByPermission'>;
68
+
69
+ /** Every emit failure, rendered as a reason a human can act on. */
70
+ function reasonFor(error: unknown): string {
71
+ if (error instanceof UnknownNotificationTypeError) {
72
+ return `no generator is registered for this type — the host never bound the blueprint (${error.message})`;
73
+ }
74
+ if (error instanceof UnknownNotificationRecipientError) {
75
+ return `the recipient does not resolve to a user (${error.message})`;
76
+ }
77
+ return error instanceof Error ? error.message : String(error);
78
+ }
79
+
80
+ /**
81
+ * Adapt a mounted notifications api to the shared `NotifyPort`.
82
+ *
83
+ * ```ts
84
+ * const notifications = createApiNotifications({ … });
85
+ * const host = createWiringHost({
86
+ * name: 'web',
87
+ * kind: 'server',
88
+ * ports: { notify: wireNotifyPort(notifications) },
89
+ * });
90
+ * ```
91
+ */
92
+ export function wireNotifyPort(api: NotifyPortSource): NotifyPort {
93
+ return {
94
+ async emit(event: NotifyEvent): Promise<NotifyOutcome> {
95
+ try {
96
+ if ('userId' in event.recipient) {
97
+ await api.notify({
98
+ type: event.type,
99
+ recipient: { userId: event.recipient.userId },
100
+ payload: event.payload,
101
+ });
102
+ return { accepted: true };
103
+ }
104
+
105
+ const { tenantId, permission } = event.recipient;
106
+ const result = await api.notifyByPermission(tenantId, [permission], {
107
+ type: event.type,
108
+ payload: event.payload,
109
+ });
110
+ // Nobody holding the permission is not a FAILURE — the fan-out ran and
111
+ // correctly reached zero people. Saying otherwise would have a host
112
+ // treat "this tenant has no manager" as a broken pipeline. The count
113
+ // is in the reason so the distinction survives into the log.
114
+ return result.notified.length > 0
115
+ ? { accepted: true }
116
+ : {
117
+ accepted: false,
118
+ reason: `no user of tenant "${tenantId}" holds "${permission}" (${result.skipped.length} candidates skipped)`,
119
+ };
120
+ } catch (error) {
121
+ return { accepted: false, reason: reasonFor(error) };
122
+ }
123
+ },
124
+ };
125
+ }