@12-apps/notifications 4.4.0 → 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.
- package/dist/chunk-WU6QJLSZ.js +94 -0
- package/dist/chunk-WU6QJLSZ.js.map +1 -0
- package/dist/chunk-YE24MDS6.js +1022 -0
- package/dist/chunk-YE24MDS6.js.map +1 -0
- package/dist/create-api-notifications-CgBdjfyF.d.ts +909 -0
- package/dist/create-web-notifications-Du3hTs7P.d.ts +354 -0
- package/dist/{generators-FATT537X.d.ts → generators-B9xt3sRh.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/index.d.ts +3 -3
- package/dist/jobs-DhDjrAX5.d.ts +74 -0
- package/dist/manifest/index.d.ts +20 -6
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +22 -30
- package/dist/manifest/server.js +11 -2
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +51 -0
- package/dist/manifest/web.js +15 -0
- package/dist/manifest/web.js.map +1 -0
- package/dist/react/index.d.ts +6 -353
- package/dist/react/index.js +20 -1008
- package/dist/react/index.js.map +1 -1
- package/dist/server/index.d.ts +79 -902
- package/dist/server/index.js +13 -2
- package/dist/{types-yq_o4N01.d.ts → types-CXLAG3UU.d.ts} +1 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-KLY6UMRT.d.ts → web-push-Cs14Wp9u.d.ts} +2 -2
- package/dist/{wire-SDUtscGu.d.ts → wire-5IRin4zH.d.ts} +1 -1
- package/package.json +10 -3
- package/src/manifest/index.ts +20 -6
- package/src/manifest/server.ts +8 -0
- package/src/manifest/web.ts +46 -0
- package/src/server/index.ts +20 -0
- package/src/server/jobs.ts +118 -0
- package/src/server/wire-notify-port.ts +125 -0
- package/dist/chunk-F5ANWJCY.js +0 -1
- package/dist/chunk-F5ANWJCY.js.map +0 -1
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
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,
|
|
@@ -25,6 +31,10 @@ 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,
|
|
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 };
|
package/dist/web-push/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import '../types-
|
|
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,
|
|
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,
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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.
|
|
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",
|
package/src/manifest/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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;
|
package/src/manifest/server.ts
CHANGED
|
@@ -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;
|
package/src/server/index.ts
CHANGED
|
@@ -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>;
|
|
@@ -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
|
+
}
|
package/dist/chunk-F5ANWJCY.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
//# sourceMappingURL=chunk-F5ANWJCY.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|