@12-apps/notifications 4.4.0 → 4.6.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 +13 -5
- 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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnknownNotificationRecipientError,
|
|
3
|
+
UnknownNotificationTypeError
|
|
4
|
+
} from "./chunk-AHNRSA6U.js";
|
|
5
|
+
import {
|
|
6
|
+
__name
|
|
7
|
+
} from "./chunk-7QVYU63E.js";
|
|
8
|
+
|
|
9
|
+
// src/server/wire-notify-port.ts
|
|
10
|
+
function reasonFor(error) {
|
|
11
|
+
if (error instanceof UnknownNotificationTypeError) {
|
|
12
|
+
return `no generator is registered for this type \u2014 the host never bound the blueprint (${error.message})`;
|
|
13
|
+
}
|
|
14
|
+
if (error instanceof UnknownNotificationRecipientError) {
|
|
15
|
+
return `the recipient does not resolve to a user (${error.message})`;
|
|
16
|
+
}
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
__name(reasonFor, "reasonFor");
|
|
20
|
+
function wireNotifyPort(api) {
|
|
21
|
+
return {
|
|
22
|
+
async emit(event) {
|
|
23
|
+
try {
|
|
24
|
+
if ("userId" in event.recipient) {
|
|
25
|
+
await api.notify({
|
|
26
|
+
type: event.type,
|
|
27
|
+
recipient: { userId: event.recipient.userId },
|
|
28
|
+
payload: event.payload
|
|
29
|
+
});
|
|
30
|
+
return { accepted: true };
|
|
31
|
+
}
|
|
32
|
+
const { tenantId, permission } = event.recipient;
|
|
33
|
+
const result = await api.notifyByPermission(tenantId, [permission], {
|
|
34
|
+
type: event.type,
|
|
35
|
+
payload: event.payload
|
|
36
|
+
});
|
|
37
|
+
return result.notified.length > 0 ? { accepted: true } : {
|
|
38
|
+
accepted: false,
|
|
39
|
+
reason: `no user of tenant "${tenantId}" holds "${permission}" (${result.skipped.length} candidates skipped)`
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { accepted: false, reason: reasonFor(error) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
__name(wireNotifyPort, "wireNotifyPort");
|
|
48
|
+
|
|
49
|
+
// src/server/jobs.ts
|
|
50
|
+
var NOTIFICATIONS_SWEEP_QUEUE = "sweeps";
|
|
51
|
+
var NOTIFICATIONS_DRAIN_CRON = "*/5 * * * *";
|
|
52
|
+
var NOTIFICATIONS_DRAIN_LEASE_MS = 4 * 6e4;
|
|
53
|
+
var dispatch = {
|
|
54
|
+
name: "dispatch",
|
|
55
|
+
// INFRASTRUCTURE faults only — a lost database connection, an OOM-killed
|
|
56
|
+
// worker. A provider rejecting one channel is recorded FAILED on that row
|
|
57
|
+
// and picked up by the drain below, so retrying here would re-send the
|
|
58
|
+
// channels that DID succeed.
|
|
59
|
+
attempts: 3,
|
|
60
|
+
backoff: { type: "exponential", delayMs: 1e4 },
|
|
61
|
+
handle: /* @__PURE__ */ __name(async (payload, deps) => {
|
|
62
|
+
await deps.dispatchDeliveries(payload.notificationId);
|
|
63
|
+
}, "handle")
|
|
64
|
+
};
|
|
65
|
+
var drain = {
|
|
66
|
+
name: "drain",
|
|
67
|
+
queue: NOTIFICATIONS_SWEEP_QUEUE,
|
|
68
|
+
concurrency: 1,
|
|
69
|
+
schedule: { pattern: NOTIFICATIONS_DRAIN_CRON },
|
|
70
|
+
// Never retried by the queue: the next tick re-finds everything from durable
|
|
71
|
+
// state anyway, and a retry storm against a provider that is already down is
|
|
72
|
+
// the failure this avoids.
|
|
73
|
+
attempts: 1,
|
|
74
|
+
lease: { ttlMs: NOTIFICATIONS_DRAIN_LEASE_MS },
|
|
75
|
+
handle: /* @__PURE__ */ __name(async (_payload, deps, context) => {
|
|
76
|
+
const { dispatched } = await deps.drainPending();
|
|
77
|
+
if (dispatched > 0) {
|
|
78
|
+
context.logger.info(`notifications.drain re-dispatched ${dispatched} delivery(ies).`);
|
|
79
|
+
}
|
|
80
|
+
}, "handle")
|
|
81
|
+
};
|
|
82
|
+
var NOTIFICATIONS_JOBS = {
|
|
83
|
+
namespace: "notifications",
|
|
84
|
+
blueprints: { dispatch, drain }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
wireNotifyPort,
|
|
89
|
+
NOTIFICATIONS_SWEEP_QUEUE,
|
|
90
|
+
NOTIFICATIONS_DRAIN_CRON,
|
|
91
|
+
NOTIFICATIONS_DRAIN_LEASE_MS,
|
|
92
|
+
NOTIFICATIONS_JOBS
|
|
93
|
+
};
|
|
94
|
+
//# sourceMappingURL=chunk-WU6QJLSZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server/wire-notify-port.ts","../src/server/jobs.ts"],"sourcesContent":["/**\n * `wireNotifyPort(api)` — the adapter that makes this package a host's\n * `NotifyPort`.\n *\n * The RFC's founding incident is a package with something to say and no way to\n * say it: `@12-apps/rbac`'s `invites.invite()` records a `TenantInvite` row,\n * returns `{ status: 'invited' }`, and nobody mails the invitee — not because\n * anyone chose silence, but because no package had any channel to ASK for a\n * notification. Every host that wanted one invented a bespoke callback at the\n * mount (`notifyDispatched: …`), so the seam existed N times and composed zero\n * times.\n *\n * `NotifyPort` is that channel, and this function is its one reference\n * implementation. A host mounts notifications as it always has, wraps the\n * result once, and hands the port to every adopted package through\n * `createWiringHost({ ports })`. A package that raises an event types against\n * `NotifyPort` for the cost of zero dependencies; a host that declines binds\n * nothing and the decline is written in the wiring report instead of being\n * invisible.\n *\n * ## The two recipient shapes are two different calls\n *\n * `NotifyPort` addresses either one user or everyone holding a permission,\n * which is exactly the `notify` / `notifyByPermission` split this package\n * already draws. The port keeps the distinction because collapsing it would\n * lose the property `notifyByPermission` exists for: \"tell whoever can act on\n * this\" must resolve the audience at emit time, against the host's live\n * authorization engine, not against a list some caller cached.\n *\n * ## Why it swallows every failure\n *\n * `NotifyPort.emit` NEVER throws — the `enqueueJob` doctrine, and the whole\n * reason the port is safe to hand a package that has no idea what a host's\n * notification pipeline is. A deferred side effect must not take down the\n * request that scheduled it: an invite whose mail fails is still an invite,\n * and a checkout whose receipt fails is still a paid order. Failure is an\n * OUTCOME here (`{ accepted: false, reason }`), which is a thing the wiring\n * report and the host's logger can both read, rather than an exception that\n * unwinds a caller who cannot do anything about it.\n *\n * That is also why the reasons are specific. Three failures are ordinary and\n * each means something different to whoever is reading:\n *\n * - **no generator** — the package emitted a type no host generator claims.\n * That is a WIRING gap (the package declared a blueprint the host never\n * registered), and it is the single most likely thing to be wrong the first\n * time a package's event reaches a host.\n * - **unknown recipient** — the user id does not resolve. Ordinary in a\n * tenant where the addressee was removed between the write and the emit.\n * - **no audience directory** — `notifyByPermission` needs the host's\n * authorization engine and the host did not pass one. Loud in the reason,\n * because the alternative is a money alert nobody gets.\n *\n * The call is deliberately made AFTER the caller's transaction commits, and\n * that rule is enforced where the host binds the port rather than here: this\n * function cannot see a transaction it was never given. `router.notify`\n * already announces after its own commit for the same read-your-own-hint\n * reason.\n */\n\nimport type { NotifyEvent, NotifyOutcome, NotifyPort } from '@12-apps/wiring/ports';\n\nimport { UnknownNotificationRecipientError, UnknownNotificationTypeError } from '../errors';\nimport type { ApiNotifications } from './create-api-notifications';\n\n/** What this adapter needs of a mount — narrower than the whole api, on purpose. */\nexport type NotifyPortSource = Pick<ApiNotifications, 'notify' | 'notifyByPermission'>;\n\n/** Every emit failure, rendered as a reason a human can act on. */\nfunction reasonFor(error: unknown): string {\n if (error instanceof UnknownNotificationTypeError) {\n return `no generator is registered for this type — the host never bound the blueprint (${error.message})`;\n }\n if (error instanceof UnknownNotificationRecipientError) {\n return `the recipient does not resolve to a user (${error.message})`;\n }\n return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Adapt a mounted notifications api to the shared `NotifyPort`.\n *\n * ```ts\n * const notifications = createApiNotifications({ … });\n * const host = createWiringHost({\n * name: 'web',\n * kind: 'server',\n * ports: { notify: wireNotifyPort(notifications) },\n * });\n * ```\n */\nexport function wireNotifyPort(api: NotifyPortSource): NotifyPort {\n return {\n async emit(event: NotifyEvent): Promise<NotifyOutcome> {\n try {\n if ('userId' in event.recipient) {\n await api.notify({\n type: event.type,\n recipient: { userId: event.recipient.userId },\n payload: event.payload,\n });\n return { accepted: true };\n }\n\n const { tenantId, permission } = event.recipient;\n const result = await api.notifyByPermission(tenantId, [permission], {\n type: event.type,\n payload: event.payload,\n });\n // Nobody holding the permission is not a FAILURE — the fan-out ran and\n // correctly reached zero people. Saying otherwise would have a host\n // treat \"this tenant has no manager\" as a broken pipeline. The count\n // is in the reason so the distinction survives into the log.\n return result.notified.length > 0\n ? { accepted: true }\n : {\n accepted: false,\n reason: `no user of tenant \"${tenantId}\" holds \"${permission}\" (${result.skipped.length} candidates skipped)`,\n };\n } catch (error) {\n return { accepted: false, reason: reasonFor(error) };\n }\n },\n };\n}\n","/**\n * The two background jobs getting a message out actually needs.\n *\n * ## Why they are declared here and not left to the host\n *\n * `notify()` commits the inbox record and one QUEUED delivery per channel in a\n * transaction, and that row IS the durable record of the send. Everything after\n * it is a decision about WHEN the provider call happens — and every one of\n * those decisions is this package's knowledge, not a host's:\n *\n * - the fast path retries only INFRASTRUCTURE faults, because a provider\n * rejecting one channel does not throw here (the row goes FAILED and the\n * sweep owns the retry), so three spaced attempts is about surviving a\n * restart rather than chasing a provider;\n * - the sweep's five-minute cadence is the resolution at which \"my customer\n * never got the e-mail\" stops being an incident;\n * - the sweep runs single-flight, because overlapping passes are duplicate\n * billed provider calls;\n * - and the sweep is what makes the system UNSTUCK-ABLE: it re-dispatches\n * FAILED rows and stale claims whose dispatching process died mid-flight.\n * Before it existed a provider blip left a delivery FAILED forever.\n *\n * A host asked to restate all of that is a host that can get it wrong — and,\n * far more likely, a host that never schedules the sweep at all and quietly\n * has no retry. That is the `paymentsJobBlueprints()` incident exactly: a\n * mechanism a host must remember to schedule is a mechanism most hosts do not\n * have. The origin host DID write both jobs, correctly, by hand — cadence,\n * lease ttl, attempts and concurrency restated in its own `lib/jobs` — which\n * is the drift this declaration ends rather than a gap it fills.\n *\n * What stays the host's: whether to run them at all, on which queue runtime,\n * and the lease implementation. A host with no worker declines the `jobs`\n * capability in writing and the wiring report says so.\n */\n\nimport type { JobsContribution, WireJobBlueprint } from '@12-apps/wiring';\n\nimport type { ApiNotifications } from './create-api-notifications';\n\n/**\n * What the host closes over at bind time — the mounted api's two send paths,\n * and nothing else. Deliberately a `Pick` of the real thing rather than a\n * parallel interface: a reshape of either method stops compiling here.\n */\nexport type NotificationsJobDeps = Pick<\n ApiNotifications,\n 'dispatchDeliveries' | 'drainPending'\n>;\n\n/**\n * The single-flight queue name — the same string `@12-apps/jobs` exports as\n * `SWEEP_QUEUE`, stated as a literal because this package does not depend on\n * the job library (the payments-backend precedent, for the same reason).\n */\nexport const NOTIFICATIONS_SWEEP_QUEUE = 'sweeps';\n\n/** Five minutes — see the header on why this number is the package's. */\nexport const NOTIFICATIONS_DRAIN_CRON = '*/5 * * * *';\n\n/**\n * Comfortably longer than a drain pass, and under the cadence's own patience:\n * every delivery a pass re-dispatches is a provider call.\n */\nexport const NOTIFICATIONS_DRAIN_LEASE_MS = 4 * 60_000;\n\n/** Deliver one already-committed notification through its enabled channels. */\nconst dispatch: WireJobBlueprint<{ notificationId: string }, NotificationsJobDeps> = {\n name: 'dispatch',\n // INFRASTRUCTURE faults only — a lost database connection, an OOM-killed\n // worker. A provider rejecting one channel is recorded FAILED on that row\n // and picked up by the drain below, so retrying here would re-send the\n // channels that DID succeed.\n attempts: 3,\n backoff: { type: 'exponential', delayMs: 10_000 },\n handle: async (payload, deps) => {\n await deps.dispatchDeliveries(payload.notificationId);\n },\n};\n\n/**\n * Re-dispatch everything that did not get out.\n *\n * Idempotent and cheap when there is nothing to do (one indexed read), and it\n * only touches rows whose `updated_at` is older than its own cutoff — so it\n * never races the fast path and never re-picks a row it just re-queued.\n *\n * The lease stays even though the package's per-delivery CLAIM already makes\n * concurrent sweeps safe: the claim stops two passes sending the same row, the\n * lease stops one container stacking overlapping passes at all.\n */\nconst drain: WireJobBlueprint<void, NotificationsJobDeps> = {\n name: 'drain',\n queue: NOTIFICATIONS_SWEEP_QUEUE,\n concurrency: 1,\n schedule: { pattern: NOTIFICATIONS_DRAIN_CRON },\n // Never retried by the queue: the next tick re-finds everything from durable\n // state anyway, and a retry storm against a provider that is already down is\n // the failure this avoids.\n attempts: 1,\n lease: { ttlMs: NOTIFICATIONS_DRAIN_LEASE_MS },\n handle: async (_payload, deps, context) => {\n const { dispatched } = await deps.drainPending();\n if (dispatched > 0) {\n context.logger.info(`notifications.drain re-dispatched ${dispatched} delivery(ies).`);\n }\n },\n};\n\n/**\n * The jobs contribution. `namespace` is prepended once at bind time, so these\n * arrive at a runner as `notifications.dispatch` and `notifications.drain` —\n * the wire names the origin host already uses, so adopting is a deletion\n * rather than a rename.\n */\nexport const NOTIFICATIONS_JOBS = {\n namespace: 'notifications',\n blueprints: { dispatch, drain },\n} as const satisfies JobsContribution<NotificationsJobDeps>;\n"],"mappings":";;;;;;;;;AAqEA,SAAS,UAAU,OAAwB;AACzC,MAAI,iBAAiB,8BAA8B;AACjD,WAAO,uFAAkF,MAAM,OAAO;AAAA,EACxG;AACA,MAAI,iBAAiB,mCAAmC;AACtD,WAAO,6CAA6C,MAAM,OAAO;AAAA,EACnE;AACA,SAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D;AARS;AAsBF,SAAS,eAAe,KAAmC;AAChE,SAAO;AAAA,IACL,MAAM,KAAK,OAA4C;AACrD,UAAI;AACF,YAAI,YAAY,MAAM,WAAW;AAC/B,gBAAM,IAAI,OAAO;AAAA,YACf,MAAM,MAAM;AAAA,YACZ,WAAW,EAAE,QAAQ,MAAM,UAAU,OAAO;AAAA,YAC5C,SAAS,MAAM;AAAA,UACjB,CAAC;AACD,iBAAO,EAAE,UAAU,KAAK;AAAA,QAC1B;AAEA,cAAM,EAAE,UAAU,WAAW,IAAI,MAAM;AACvC,cAAM,SAAS,MAAM,IAAI,mBAAmB,UAAU,CAAC,UAAU,GAAG;AAAA,UAClE,MAAM,MAAM;AAAA,UACZ,SAAS,MAAM;AAAA,QACjB,CAAC;AAKD,eAAO,OAAO,SAAS,SAAS,IAC5B,EAAE,UAAU,KAAK,IACjB;AAAA,UACE,UAAU;AAAA,UACV,QAAQ,sBAAsB,QAAQ,YAAY,UAAU,MAAM,OAAO,QAAQ,MAAM;AAAA,QACzF;AAAA,MACN,SAAS,OAAO;AACd,eAAO,EAAE,UAAU,OAAO,QAAQ,UAAU,KAAK,EAAE;AAAA,MACrD;AAAA,IACF;AAAA,EACF;AACF;AAjCgB;;;ACrCT,IAAM,4BAA4B;AAGlC,IAAM,2BAA2B;AAMjC,IAAM,+BAA+B,IAAI;AAGhD,IAAM,WAA+E;AAAA,EACnF,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA,EAKN,UAAU;AAAA,EACV,SAAS,EAAE,MAAM,eAAe,SAAS,IAAO;AAAA,EAChD,QAAQ,8BAAO,SAAS,SAAS;AAC/B,UAAM,KAAK,mBAAmB,QAAQ,cAAc;AAAA,EACtD,GAFQ;AAGV;AAaA,IAAM,QAAsD;AAAA,EAC1D,MAAM;AAAA,EACN,OAAO;AAAA,EACP,aAAa;AAAA,EACb,UAAU,EAAE,SAAS,yBAAyB;AAAA;AAAA;AAAA;AAAA,EAI9C,UAAU;AAAA,EACV,OAAO,EAAE,OAAO,6BAA6B;AAAA,EAC7C,QAAQ,8BAAO,UAAU,MAAM,YAAY;AACzC,UAAM,EAAE,WAAW,IAAI,MAAM,KAAK,aAAa;AAC/C,QAAI,aAAa,GAAG;AAClB,cAAQ,OAAO,KAAK,qCAAqC,UAAU,iBAAiB;AAAA,IACtF;AAAA,EACF,GALQ;AAMV;AAQO,IAAM,qBAAqB;AAAA,EAChC,WAAW;AAAA,EACX,YAAY,EAAE,UAAU,MAAM;AAChC;","names":[]}
|