@warlock.js/notifications 4.2.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/cjs/index.cjs +1034 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/channels/database-channel.d.mts +10 -0
- package/esm/channels/database-channel.d.mts.map +1 -0
- package/esm/channels/database-channel.mjs +19 -0
- package/esm/channels/database-channel.mjs.map +1 -0
- package/esm/channels/index.d.mts +2 -0
- package/esm/channels/index.mjs +4 -0
- package/esm/channels/mail-channel.d.mts +11 -0
- package/esm/channels/mail-channel.d.mts.map +1 -0
- package/esm/channels/mail-channel.mjs +43 -0
- package/esm/channels/mail-channel.mjs.map +1 -0
- package/esm/config.d.mts +46 -0
- package/esm/config.d.mts.map +1 -0
- package/esm/config.mjs +32 -0
- package/esm/config.mjs.map +1 -0
- package/esm/contracts/channel.contract.d.mts +38 -0
- package/esm/contracts/channel.contract.d.mts.map +1 -0
- package/esm/contracts/index.d.mts +5 -0
- package/esm/contracts/notification.contract.d.mts +18 -0
- package/esm/contracts/notification.contract.d.mts.map +1 -0
- package/esm/contracts/preference-provider.contract.d.mts +20 -0
- package/esm/contracts/preference-provider.contract.d.mts.map +1 -0
- package/esm/contracts/queue-dispatcher.contract.d.mts +23 -0
- package/esm/contracts/queue-dispatcher.contract.d.mts.map +1 -0
- package/esm/contracts/rate-limiter.contract.d.mts +23 -0
- package/esm/contracts/rate-limiter.contract.d.mts.map +1 -0
- package/esm/dispatch/define-channel.d.mts +37 -0
- package/esm/dispatch/define-channel.d.mts.map +1 -0
- package/esm/dispatch/define-channel.mjs +39 -0
- package/esm/dispatch/define-channel.mjs.map +1 -0
- package/esm/dispatch/define-notification.d.mts +22 -0
- package/esm/dispatch/define-notification.d.mts.map +1 -0
- package/esm/dispatch/define-notification.mjs +125 -0
- package/esm/dispatch/define-notification.mjs.map +1 -0
- package/esm/dispatch/dispatch-id.mjs +15 -0
- package/esm/dispatch/dispatch-id.mjs.map +1 -0
- package/esm/dispatch/dispatcher.mjs +120 -0
- package/esm/dispatch/dispatcher.mjs.map +1 -0
- package/esm/dispatch/index.d.mts +4 -0
- package/esm/dispatch/index.mjs +6 -0
- package/esm/dispatch/notifications-event-bus.d.mts +18 -0
- package/esm/dispatch/notifications-event-bus.d.mts.map +1 -0
- package/esm/dispatch/notifications-event-bus.mjs +58 -0
- package/esm/dispatch/notifications-event-bus.mjs.map +1 -0
- package/esm/dispatch/notify.d.mts +20 -0
- package/esm/dispatch/notify.d.mts.map +1 -0
- package/esm/dispatch/notify.mjs +80 -0
- package/esm/dispatch/notify.mjs.map +1 -0
- package/esm/errors.d.mts +32 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +56 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/in-app/base-notifications-repository.d.mts +82 -0
- package/esm/in-app/base-notifications-repository.d.mts.map +1 -0
- package/esm/in-app/base-notifications-repository.mjs +187 -0
- package/esm/in-app/base-notifications-repository.mjs.map +1 -0
- package/esm/in-app/column-map.d.mts +65 -0
- package/esm/in-app/column-map.d.mts.map +1 -0
- package/esm/in-app/column-map.mjs +28 -0
- package/esm/in-app/column-map.mjs.map +1 -0
- package/esm/in-app/database-notification.d.mts +30 -0
- package/esm/in-app/database-notification.d.mts.map +1 -0
- package/esm/in-app/database-notification.mjs +70 -0
- package/esm/in-app/database-notification.mjs.map +1 -0
- package/esm/in-app/in-app.d.mts +56 -0
- package/esm/in-app/in-app.d.mts.map +1 -0
- package/esm/in-app/in-app.mjs +67 -0
- package/esm/in-app/in-app.mjs.map +1 -0
- package/esm/in-app/index.d.mts +4 -0
- package/esm/in-app/index.mjs +6 -0
- package/esm/index.d.mts +25 -0
- package/esm/index.mjs +23 -0
- package/esm/migration/index.d.mts +1 -0
- package/esm/migration/index.mjs +3 -0
- package/esm/migration/notification-columns.d.mts +13 -0
- package/esm/migration/notification-columns.d.mts.map +1 -0
- package/esm/migration/notification-columns.mjs +53 -0
- package/esm/migration/notification-columns.mjs.map +1 -0
- package/esm/queue/herald-queue.d.mts +10 -0
- package/esm/queue/herald-queue.d.mts.map +1 -0
- package/esm/queue/herald-queue.mjs +14 -0
- package/esm/queue/herald-queue.mjs.map +1 -0
- package/esm/queue/index.d.mts +3 -0
- package/esm/queue/index.mjs +5 -0
- package/esm/queue/load-herald.d.mts +6 -0
- package/esm/queue/load-herald.d.mts.map +1 -0
- package/esm/queue/load-herald.mjs +35 -0
- package/esm/queue/load-herald.mjs.map +1 -0
- package/esm/queue/notifications-worker.d.mts +9 -0
- package/esm/queue/notifications-worker.d.mts.map +1 -0
- package/esm/queue/notifications-worker.mjs +46 -0
- package/esm/queue/notifications-worker.mjs.map +1 -0
- package/esm/types.d.mts +130 -0
- package/esm/types.d.mts.map +1 -0
- package/llms-full.txt +994 -0
- package/llms.txt +17 -0
- package/package.json +40 -0
- package/skills/README.md +23 -0
- package/skills/configure-notifications/SKILL.md +138 -0
- package/skills/define-channel/SKILL.md +107 -0
- package/skills/define-notification/SKILL.md +133 -0
- package/skills/notifications-basics/SKILL.md +94 -0
- package/skills/observe-notifications/SKILL.md +66 -0
- package/skills/queue-notifications/SKILL.md +81 -0
- package/skills/send-ad-hoc/SKILL.md +95 -0
- package/skills/use-in-app/SKILL.md +168 -0
- package/skills/write-notification-migration/SKILL.md +70 -0
package/cjs/index.cjs
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
let _warlock_js_logger = require("@warlock.js/logger");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
let _warlock_js_core = require("@warlock.js/core");
|
|
5
|
+
let _warlock_js_cascade = require("@warlock.js/cascade");
|
|
6
|
+
|
|
7
|
+
//#region ../@warlock.js/notifications/src/errors.ts
|
|
8
|
+
/**
|
|
9
|
+
* Domain errors for `@warlock.js/notifications`. All extend `Error` directly
|
|
10
|
+
* (no framework HttpError) so the package stays runtime-agnostic — the host
|
|
11
|
+
* app maps them to its own error shape via `instanceof` if needed.
|
|
12
|
+
*
|
|
13
|
+
* Each captures a clean V8 stack (drops the Error-constructor frame) to match
|
|
14
|
+
* the cascade convention.
|
|
15
|
+
*/
|
|
16
|
+
function captureStack(target, ctor) {
|
|
17
|
+
Error.captureStackTrace?.(target, ctor);
|
|
18
|
+
}
|
|
19
|
+
var NotificationsNotConfiguredError = class NotificationsNotConfiguredError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("Notifications not configured — add `src/config/notifications.ts` with a default-exported config; the notifications connector registers it at boot.");
|
|
22
|
+
this.name = "NotificationsNotConfiguredError";
|
|
23
|
+
captureStack(this, NotificationsNotConfiguredError);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var ChannelNotFoundError = class ChannelNotFoundError extends Error {
|
|
27
|
+
constructor(channelName) {
|
|
28
|
+
super(`Channel "${channelName}" is not configured. Add it to the \`channels\` map in \`src/config/notifications.ts\`.`);
|
|
29
|
+
this.name = "ChannelNotFoundError";
|
|
30
|
+
captureStack(this, ChannelNotFoundError);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var NoQueueDispatcherError = class NoQueueDispatcherError extends Error {
|
|
34
|
+
constructor(channelName) {
|
|
35
|
+
super(`Cannot queue to channel "${channelName}" — no queue dispatcher configured. Add a \`queue\` to \`src/config/notifications.ts\`, or call \`.send()\` instead of \`.queue()\`.`);
|
|
36
|
+
this.name = "NoQueueDispatcherError";
|
|
37
|
+
captureStack(this, NoQueueDispatcherError);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var MissingRendererError = class MissingRendererError extends Error {
|
|
41
|
+
constructor(notificationType, channelName) {
|
|
42
|
+
super(`Notification "${notificationType}" routes to channel "${channelName}" via \`via\` but defines no \`${channelName}\` renderer.`);
|
|
43
|
+
this.name = "MissingRendererError";
|
|
44
|
+
captureStack(this, MissingRendererError);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Thrown when a channel's `route()` resolver runs but returns `undefined` for
|
|
49
|
+
* a recipient (e.g. a mail recipient with no email). Distinct from "the
|
|
50
|
+
* channel declares no resolver" — that case falls back to `{ id }`.
|
|
51
|
+
*/
|
|
52
|
+
var UnresolvableRouteError = class UnresolvableRouteError extends Error {
|
|
53
|
+
constructor(channelName, recipientId) {
|
|
54
|
+
super(`Channel "${channelName}" could not resolve a route for recipient ${recipientId ?? "(unknown)"}. The channel's \`route()\` returned undefined — the recipient is missing the address column this channel needs.`);
|
|
55
|
+
this.name = "UnresolvableRouteError";
|
|
56
|
+
captureStack(this, UnresolvableRouteError);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region ../@warlock.js/notifications/src/config.ts
|
|
62
|
+
let activeConfig;
|
|
63
|
+
/**
|
|
64
|
+
* Set the active notifications configuration. In a Warlock app the
|
|
65
|
+
* notifications connector calls this at boot with the default export of
|
|
66
|
+
* `src/config/notifications.ts`. Subsequent calls REPLACE the active config
|
|
67
|
+
* (does not merge) — pass the complete config, not a partial.
|
|
68
|
+
*/
|
|
69
|
+
function setNotificationConfig(config) {
|
|
70
|
+
activeConfig = config;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get the active notifications configuration. Throws if not yet configured —
|
|
74
|
+
* fail loudly at the first send rather than silently doing nothing.
|
|
75
|
+
*/
|
|
76
|
+
function getNotificationConfig() {
|
|
77
|
+
if (!activeConfig) throw new NotificationsNotConfiguredError();
|
|
78
|
+
return activeConfig;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Clear the active configuration. Intended for tests; not part of the runtime
|
|
82
|
+
* surface. Production code should never call this.
|
|
83
|
+
*/
|
|
84
|
+
function resetNotificationConfig() {
|
|
85
|
+
activeConfig = void 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region ../@warlock.js/notifications/src/dispatch/define-channel.ts
|
|
90
|
+
/**
|
|
91
|
+
* Identity helper that narrows the channel type to its payload. Use it when
|
|
92
|
+
* you want TypeScript to enforce the `send` payload shape against the
|
|
93
|
+
* payload type you declared.
|
|
94
|
+
*
|
|
95
|
+
* Built-in channels (`mail`, `database`) ship as factories; custom channels
|
|
96
|
+
* are usually defined with this helper.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* import { defineChannel } from "@warlock.js/notifications";
|
|
100
|
+
*
|
|
101
|
+
* type DiscordPayload = { content: string };
|
|
102
|
+
*
|
|
103
|
+
* export const discordChannel = () =>
|
|
104
|
+
* defineChannel<DiscordPayload>({
|
|
105
|
+
* name: "discord",
|
|
106
|
+
* route: (n) => n.get("discord_webhook"),
|
|
107
|
+
* async send({ payload, route }) {
|
|
108
|
+
* await fetch(route as string, {
|
|
109
|
+
* method: "POST",
|
|
110
|
+
* headers: { "content-type": "application/json" },
|
|
111
|
+
* body: JSON.stringify(payload),
|
|
112
|
+
* });
|
|
113
|
+
* },
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* // Then teach the registry:
|
|
117
|
+
* declare module "@warlock.js/notifications" {
|
|
118
|
+
* interface NotificationChannels { discord: DiscordPayload }
|
|
119
|
+
* }
|
|
120
|
+
*/
|
|
121
|
+
function defineChannel(channel) {
|
|
122
|
+
return channel;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region ../@warlock.js/notifications/src/dispatch/dispatch-id.ts
|
|
127
|
+
/**
|
|
128
|
+
* A unique id for one (channel, recipient) dispatch. `sending` and its terminal
|
|
129
|
+
* `sent` / `failed` event share it so observers can pair them — for tracing
|
|
130
|
+
* spans, latency, or spotting a `sending` with no terminal (a hung send).
|
|
131
|
+
*/
|
|
132
|
+
function newDispatchId() {
|
|
133
|
+
return (0, node_crypto.randomUUID)();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region ../@warlock.js/notifications/src/dispatch/notifications-event-bus.ts
|
|
138
|
+
/**
|
|
139
|
+
* Typed event bus for notifications observability. Emits `sent`, `failed`,
|
|
140
|
+
* and `skipped` events per (channel, recipient) — fan-out emits N events.
|
|
141
|
+
*
|
|
142
|
+
* Intentionally minimal — a `Map<event, Set<handler>>` keeps tests trivial
|
|
143
|
+
* and avoids pulling event-emitter machinery for what is fundamentally
|
|
144
|
+
* pub/sub. Handler exceptions are logged-and-swallowed so one bad listener
|
|
145
|
+
* cannot break the dispatcher.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* notifications.on("sent", ({ channel }) => metrics.inc(`notif.${channel}.sent`));
|
|
149
|
+
* notifications.on("skipped", ({ reason }) => metrics.inc(`notif.skipped.${reason}`));
|
|
150
|
+
* const off = notifications.on("failed", logErr);
|
|
151
|
+
* off(); // unsubscribe
|
|
152
|
+
*/
|
|
153
|
+
const handlers = {
|
|
154
|
+
sending: /* @__PURE__ */ new Set(),
|
|
155
|
+
sent: /* @__PURE__ */ new Set(),
|
|
156
|
+
failed: /* @__PURE__ */ new Set(),
|
|
157
|
+
skipped: /* @__PURE__ */ new Set()
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Public observability surface.
|
|
161
|
+
*
|
|
162
|
+
* `on(event, handler)` returns an unsubscribe function. `off` exists for
|
|
163
|
+
* symmetry with libraries that hold handler references for later removal.
|
|
164
|
+
*/
|
|
165
|
+
const notifications = {
|
|
166
|
+
on(event, handler) {
|
|
167
|
+
handlers[event].add(handler);
|
|
168
|
+
return () => {
|
|
169
|
+
handlers[event].delete(handler);
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
off(event, handler) {
|
|
173
|
+
handlers[event].delete(handler);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Internal emit — used by the dispatcher. Not exported from the package.
|
|
178
|
+
* Handlers run in registration order; an awaited Promise.allSettled isolates
|
|
179
|
+
* each listener so one throw cannot block the others.
|
|
180
|
+
*/
|
|
181
|
+
async function emit(event, data) {
|
|
182
|
+
const set = handlers[event];
|
|
183
|
+
if (set.size === 0) return;
|
|
184
|
+
const results = await Promise.allSettled(Array.from(set).map(async (handler) => {
|
|
185
|
+
await handler(data);
|
|
186
|
+
}));
|
|
187
|
+
for (const result of results) if (result.status === "rejected") _warlock_js_logger.log.error("notifications", "event-handler", result.reason);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region ../@warlock.js/notifications/src/dispatch/dispatcher.ts
|
|
192
|
+
/**
|
|
193
|
+
* Internal dispatch core — shared by `defineNotification` and `notify.<channel>`.
|
|
194
|
+
*
|
|
195
|
+
* Responsibilities:
|
|
196
|
+
* 1. Look up the channel in the configured registry (throws if missing).
|
|
197
|
+
* 2. Run the rate-limit gate (if a notification type is known) — drop with a
|
|
198
|
+
* `skipped` event when the limiter refuses.
|
|
199
|
+
* 3. Resolve the route: raw string > channel.route(notifiable) > { id }.
|
|
200
|
+
* 4. Inject the per-send `idempotencyKey` into the database payload.
|
|
201
|
+
* 5. Dispatch (sync via channel.send OR async via queue.dispatch).
|
|
202
|
+
* 6. Emit `sent` on success, `failed` on throw.
|
|
203
|
+
*
|
|
204
|
+
* Per-channel failure isolation is the CALLER's job (it wraps a list of
|
|
205
|
+
* `dispatchChannel` calls in `Promise.allSettled`).
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* Dispatch ONE rendered payload to ONE recipient through ONE channel.
|
|
209
|
+
* Does NOT consult `PreferenceProvider` — that gate runs at the caller level.
|
|
210
|
+
*
|
|
211
|
+
* Throws `ChannelNotFoundError` (channel not registered) and
|
|
212
|
+
* `UnresolvableRouteError` (channel's `route()` returned undefined) — both are
|
|
213
|
+
* configuration/data errors that should surface loudly. Transport failures
|
|
214
|
+
* (channel.send throwing) are emitted as `failed` AND rethrown.
|
|
215
|
+
*/
|
|
216
|
+
async function dispatchChannel(args) {
|
|
217
|
+
const config = getNotificationConfig();
|
|
218
|
+
const channel = config.channels[args.channelName];
|
|
219
|
+
if (!channel) throw new ChannelNotFoundError(args.channelName);
|
|
220
|
+
const dispatchId = newDispatchId();
|
|
221
|
+
if (args.to && args.notificationType && config.rateLimit) {
|
|
222
|
+
if (!await config.rateLimit.allow(args.to, args.channelName, args.notificationType)) {
|
|
223
|
+
await emit("skipped", {
|
|
224
|
+
dispatchId,
|
|
225
|
+
channel: args.channelName,
|
|
226
|
+
notifiable: args.to,
|
|
227
|
+
reason: "rate-limit",
|
|
228
|
+
options: args.options
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const route = resolveRoute(args, channel.route);
|
|
234
|
+
let payload = args.payload;
|
|
235
|
+
if (args.channelName === "database" && args.options.idempotencyKey && payload && typeof payload === "object") payload = {
|
|
236
|
+
...payload,
|
|
237
|
+
idempotencyKey: args.options.idempotencyKey
|
|
238
|
+
};
|
|
239
|
+
await emit("sending", {
|
|
240
|
+
dispatchId,
|
|
241
|
+
channel: args.channelName,
|
|
242
|
+
notifiable: args.to,
|
|
243
|
+
payload,
|
|
244
|
+
options: args.options
|
|
245
|
+
});
|
|
246
|
+
const startedAt = Date.now();
|
|
247
|
+
try {
|
|
248
|
+
if (args.mode === "queue") {
|
|
249
|
+
if (!config.queue) throw new NoQueueDispatcherError(args.channelName);
|
|
250
|
+
await config.queue.dispatch({
|
|
251
|
+
channel: args.channelName,
|
|
252
|
+
route,
|
|
253
|
+
payload,
|
|
254
|
+
options: args.options
|
|
255
|
+
});
|
|
256
|
+
} else await channel.send({
|
|
257
|
+
payload,
|
|
258
|
+
route,
|
|
259
|
+
notifiable: args.to,
|
|
260
|
+
options: args.options
|
|
261
|
+
});
|
|
262
|
+
await emit("sent", {
|
|
263
|
+
dispatchId,
|
|
264
|
+
channel: args.channelName,
|
|
265
|
+
notifiable: args.to,
|
|
266
|
+
payload,
|
|
267
|
+
options: args.options,
|
|
268
|
+
durationMs: Date.now() - startedAt
|
|
269
|
+
});
|
|
270
|
+
} catch (error) {
|
|
271
|
+
await emit("failed", {
|
|
272
|
+
dispatchId,
|
|
273
|
+
channel: args.channelName,
|
|
274
|
+
notifiable: args.to,
|
|
275
|
+
payload,
|
|
276
|
+
error,
|
|
277
|
+
options: args.options,
|
|
278
|
+
durationMs: Date.now() - startedAt
|
|
279
|
+
});
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Route resolution priority:
|
|
285
|
+
* 1. raw route string (ad-hoc `notify.mail("x@y.com", …)`)
|
|
286
|
+
* 2. the channel's `route(notifiable)` resolver
|
|
287
|
+
* 3. `{ id: notifiable.id }` — ONLY when the channel declares no resolver
|
|
288
|
+
*
|
|
289
|
+
* When a channel HAS a resolver but it returns undefined (e.g. a mail
|
|
290
|
+
* recipient with no email), we throw `UnresolvableRouteError` rather than
|
|
291
|
+
* silently coercing to `{ id }` — that would hand a string-route channel an
|
|
292
|
+
* object and fail deep inside the transport.
|
|
293
|
+
*/
|
|
294
|
+
function resolveRoute(args, resolver) {
|
|
295
|
+
if (args.rawRoute !== void 0) return args.rawRoute;
|
|
296
|
+
if (!args.to) throw new UnresolvableRouteError(args.channelName);
|
|
297
|
+
if (!resolver) return { id: args.to.id };
|
|
298
|
+
const resolved = resolver(args.to);
|
|
299
|
+
if (resolved === void 0) throw new UnresolvableRouteError(args.channelName, args.to.id);
|
|
300
|
+
return resolved;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region ../@warlock.js/notifications/src/dispatch/define-notification.ts
|
|
305
|
+
/**
|
|
306
|
+
* `defineNotification` — reusable, type-safe, multi-channel notification.
|
|
307
|
+
*
|
|
308
|
+
* Functional, not class-based: pass `via` + a renderer per channel + a
|
|
309
|
+
* stable `type`. The returned object exposes `.send` / `.queue` / `.only`.
|
|
310
|
+
*
|
|
311
|
+
* Renderer signature is `(data, to, ctx)` where `ctx` carries `locale` and
|
|
312
|
+
* `meta` from `SendOptions`. The database renderer's `type` is OPTIONAL — the
|
|
313
|
+
* dispatcher injects `def.type`, keeping the type defined in ONE place.
|
|
314
|
+
*
|
|
315
|
+
* ## Error policy
|
|
316
|
+
* - **Config errors** (missing renderer, no queue dispatcher, unknown channel)
|
|
317
|
+
* are programmer mistakes → they REJECT `.send()`/`.queue()` (and a static
|
|
318
|
+
* `via` with a missing renderer throws at definition time). Consistent with
|
|
319
|
+
* `notify`, which rejects `ChannelNotFoundError`.
|
|
320
|
+
* - **Transport errors** (a channel's `send` throwing) stay ISOLATED — emitted
|
|
321
|
+
* as a `failed` event, never aborting sibling channels/recipients.
|
|
322
|
+
* - **`via()` / preferences throws** for one recipient are logged and that
|
|
323
|
+
* recipient is dropped (the rest of a fan-out still go).
|
|
324
|
+
*
|
|
325
|
+
* @example
|
|
326
|
+
* export const orderShipped = defineNotification<{ order: Order }>({
|
|
327
|
+
* type: "order.shipped",
|
|
328
|
+
* via: ["mail", "database"],
|
|
329
|
+
* mail: ({ order }, to) => ({ subject: `#${order.number} shipped`, html: "…" }),
|
|
330
|
+
* database: ({ order }) => ({ title: "Your order shipped", payload: { id: order.id } }),
|
|
331
|
+
* });
|
|
332
|
+
* await orderShipped.send(user, { order });
|
|
333
|
+
* await orderShipped.queue([buyer, salesRep], { order }, { delay: "10m" });
|
|
334
|
+
* await orderShipped.only("mail").send(user, { order });
|
|
335
|
+
*/
|
|
336
|
+
/** Errors that represent a misconfiguration and must surface (reject), not isolate. */
|
|
337
|
+
function isConfigError(error) {
|
|
338
|
+
return error instanceof MissingRendererError || error instanceof NoQueueDispatcherError || error instanceof ChannelNotFoundError;
|
|
339
|
+
}
|
|
340
|
+
function defineNotification(def) {
|
|
341
|
+
const make = (restrict) => {
|
|
342
|
+
if (Array.isArray(def.via)) {
|
|
343
|
+
const channels = restrict ? def.via.filter((c) => restrict.includes(c)) : def.via;
|
|
344
|
+
for (const channel of channels) if (!def[channel]) throw new MissingRendererError(def.type, channel);
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
send(to, data, options) {
|
|
348
|
+
return dispatchAll(def, to, data, options ?? {}, "send", restrict);
|
|
349
|
+
},
|
|
350
|
+
queue(to, data, options) {
|
|
351
|
+
return dispatchAll(def, to, data, options ?? {}, "queue", restrict);
|
|
352
|
+
},
|
|
353
|
+
only(...channels) {
|
|
354
|
+
return make(channels);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
return make();
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Fan-out to all recipients. Each recipient dispatches independently; one
|
|
362
|
+
* recipient's transport failure never aborts the others. CONFIG errors,
|
|
363
|
+
* however, propagate — they reject the returned promise.
|
|
364
|
+
*/
|
|
365
|
+
async function dispatchAll(def, to, data, options, mode, restrict) {
|
|
366
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
367
|
+
rethrowConfigErrors(await Promise.allSettled(recipients.map((recipient) => dispatchToRecipient(def, recipient, data, options, mode, restrict))));
|
|
368
|
+
}
|
|
369
|
+
async function dispatchToRecipient(def, to, data, options, mode, restrict) {
|
|
370
|
+
const config = getNotificationConfig();
|
|
371
|
+
let channels;
|
|
372
|
+
try {
|
|
373
|
+
channels = typeof def.via === "function" ? def.via(data, to) : [...def.via];
|
|
374
|
+
if (restrict) channels = channels.filter((c) => restrict.includes(c));
|
|
375
|
+
if (!options.force && config.preferences) {
|
|
376
|
+
const allowed = await config.preferences.resolveChannels(to, def.type, channels);
|
|
377
|
+
for (const channel of channels.filter((c) => !allowed.includes(c))) await emit("skipped", {
|
|
378
|
+
dispatchId: newDispatchId(),
|
|
379
|
+
channel,
|
|
380
|
+
notifiable: to,
|
|
381
|
+
reason: "preference",
|
|
382
|
+
options
|
|
383
|
+
});
|
|
384
|
+
channels = channels.filter((c) => allowed.includes(c));
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
_warlock_js_logger.log.error("notifications", `resolve.${def.type}`, error);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const renderCtx = {
|
|
391
|
+
locale: options.locale,
|
|
392
|
+
meta: options.meta
|
|
393
|
+
};
|
|
394
|
+
rethrowConfigErrors(await Promise.allSettled(channels.map((name) => {
|
|
395
|
+
const renderer = def[name];
|
|
396
|
+
if (!renderer) return Promise.reject(new MissingRendererError(def.type, name));
|
|
397
|
+
let payload = renderer(data, to, renderCtx);
|
|
398
|
+
if (name === "database" && payload && typeof payload === "object" && !("type" in payload)) payload = {
|
|
399
|
+
...payload,
|
|
400
|
+
type: def.type
|
|
401
|
+
};
|
|
402
|
+
return dispatchChannel({
|
|
403
|
+
channelName: name,
|
|
404
|
+
payload,
|
|
405
|
+
to,
|
|
406
|
+
notificationType: def.type,
|
|
407
|
+
mode,
|
|
408
|
+
options
|
|
409
|
+
});
|
|
410
|
+
})));
|
|
411
|
+
}
|
|
412
|
+
/** Collect config-class rejections and rethrow (single or AggregateError). */
|
|
413
|
+
function rethrowConfigErrors(results) {
|
|
414
|
+
const configErrors = results.filter((r) => r.status === "rejected" && isConfigError(r.reason)).map((r) => r.reason);
|
|
415
|
+
if (configErrors.length === 1) throw configErrors[0];
|
|
416
|
+
if (configErrors.length > 1) throw new AggregateError(configErrors, "Multiple notification configuration errors");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region ../@warlock.js/notifications/src/dispatch/notify.ts
|
|
421
|
+
/**
|
|
422
|
+
* `notify` — ad-hoc, per-channel send facade.
|
|
423
|
+
*
|
|
424
|
+
* A Proxy keyed by the channel registry. `notify.<channel>(to, payload,
|
|
425
|
+
* options?)` works for ANY channel name registered in
|
|
426
|
+
* `NotificationChannels` — built-in or custom (via declaration merging).
|
|
427
|
+
*
|
|
428
|
+
* For multi-channel sends, use `defineNotification` — that's the reusable
|
|
429
|
+
* pattern and the only mental model for "send through several channels at
|
|
430
|
+
* once" (no inline multi-channel overload here on purpose).
|
|
431
|
+
*
|
|
432
|
+
* `notify.channel(name)` is the runtime escape when the channel name isn't a
|
|
433
|
+
* compile-time literal.
|
|
434
|
+
*
|
|
435
|
+
* @example
|
|
436
|
+
* await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
|
|
437
|
+
* await notify.database(user, { type: "welcome", title: "Welcome!" });
|
|
438
|
+
* await notify.mail("guest@example.com", { subject: "…", html: "…" }); // raw target
|
|
439
|
+
* await notify.channel(dynamicName).send(user, payload); // dynamic name
|
|
440
|
+
*/
|
|
441
|
+
/**
|
|
442
|
+
* Per-call dispatch for `notify.<channel>`. Mirrors `defineNotification`'s
|
|
443
|
+
* pipeline but without rendering: payload is already concrete.
|
|
444
|
+
*
|
|
445
|
+
* Preferences gate runs ONLY when a notification `type` is known — explicit
|
|
446
|
+
* via `options.type`, or implicit from a database payload's `type` field.
|
|
447
|
+
* Without a type we have nothing to gate on, and ad-hoc sends bypass
|
|
448
|
+
* preferences silently (use `defineNotification` if you want guaranteed
|
|
449
|
+
* gating).
|
|
450
|
+
*/
|
|
451
|
+
async function notifyImpl(channelName, to, payload, options = {}) {
|
|
452
|
+
const isRawTarget = typeof to === "string";
|
|
453
|
+
const notifiable = isRawTarget ? void 0 : to;
|
|
454
|
+
let notificationType = options.type;
|
|
455
|
+
if (notificationType === void 0 && payload && typeof payload === "object" && "type" in payload && typeof payload.type === "string") notificationType = payload.type;
|
|
456
|
+
if (notifiable && notificationType && !options.force) {
|
|
457
|
+
const config = getNotificationConfig();
|
|
458
|
+
if (config.preferences) {
|
|
459
|
+
if (!(await config.preferences.resolveChannels(notifiable, notificationType, [channelName])).includes(channelName)) {
|
|
460
|
+
await emit("skipped", {
|
|
461
|
+
dispatchId: newDispatchId(),
|
|
462
|
+
channel: channelName,
|
|
463
|
+
notifiable,
|
|
464
|
+
reason: "preference",
|
|
465
|
+
options
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
await dispatchChannel({
|
|
472
|
+
channelName,
|
|
473
|
+
payload,
|
|
474
|
+
to: notifiable,
|
|
475
|
+
rawRoute: isRawTarget ? to : void 0,
|
|
476
|
+
notificationType,
|
|
477
|
+
mode: "send",
|
|
478
|
+
options
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Build the `notify` proxy. `channel(name)` returns a `{ send }` object;
|
|
483
|
+
* any other property access returns a one-shot dispatcher for that channel.
|
|
484
|
+
*/
|
|
485
|
+
const notify = new Proxy({}, { get(_target, prop) {
|
|
486
|
+
if (typeof prop === "symbol") return;
|
|
487
|
+
if (prop === "then" || prop === "catch" || prop === "finally" || prop === "toJSON") return;
|
|
488
|
+
if (prop === "channel") return (name) => ({ send: (to, payload, options) => notifyImpl(name, to, payload, options) });
|
|
489
|
+
return (to, payload, options) => notifyImpl(prop, to, payload, options);
|
|
490
|
+
} });
|
|
491
|
+
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region ../@warlock.js/notifications/src/channels/database-channel.ts
|
|
494
|
+
function databaseChannel(repo) {
|
|
495
|
+
return defineChannel({
|
|
496
|
+
name: "database",
|
|
497
|
+
route: (notifiable) => ({ id: notifiable.id }),
|
|
498
|
+
async send({ payload, route, notifiable }) {
|
|
499
|
+
const id = typeof route === "object" ? route.id : route;
|
|
500
|
+
const { tenantColumn } = repo;
|
|
501
|
+
const tenantId = tenantColumn && notifiable ? notifiable.get(tenantColumn) : void 0;
|
|
502
|
+
await repo.createFor(id, payload, tenantId);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region ../@warlock.js/notifications/src/channels/mail-channel.ts
|
|
509
|
+
/**
|
|
510
|
+
* `mailChannel` — wraps `@warlock.js/core` `sendMail`.
|
|
511
|
+
*
|
|
512
|
+
* Route is `notifiable.email` by convention; override via `config.route`
|
|
513
|
+
* when the column is named differently or computed.
|
|
514
|
+
*
|
|
515
|
+
* The renderer's payload is forwarded verbatim (`subject`, `html`, `text`,
|
|
516
|
+
* `cc`). `from` is set once at channel-config time; per-send overrides go
|
|
517
|
+
* through the payload itself (you can include `from` in `MailPayload` if
|
|
518
|
+
* you extend the registry — but the convention is one channel = one
|
|
519
|
+
* sender identity).
|
|
520
|
+
*
|
|
521
|
+
* @example
|
|
522
|
+
* // src/config/notifications.ts
|
|
523
|
+
* channels: {
|
|
524
|
+
* mail: mailChannel({ from: "no-reply@store.com" }),
|
|
525
|
+
* }
|
|
526
|
+
*
|
|
527
|
+
* // anywhere
|
|
528
|
+
* await notify.mail(user, { subject: "Welcome", html: "<p>Hi!</p>" });
|
|
529
|
+
*/
|
|
530
|
+
function mailChannel(config = {}) {
|
|
531
|
+
const resolveRoute = config.route ?? ((n) => n.get("email"));
|
|
532
|
+
return defineChannel({
|
|
533
|
+
name: "mail",
|
|
534
|
+
route: (notifiable) => resolveRoute(notifiable),
|
|
535
|
+
async send({ payload, route }) {
|
|
536
|
+
await (0, _warlock_js_core.sendMail)({
|
|
537
|
+
to: route,
|
|
538
|
+
from: config.from,
|
|
539
|
+
...payload
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region ../@warlock.js/notifications/src/in-app/column-map.ts
|
|
547
|
+
const DEFAULT_RECIPIENT_COLUMN = "user_id";
|
|
548
|
+
const DEFAULT_READ_AT_COLUMN = "read_at";
|
|
549
|
+
/**
|
|
550
|
+
* Resolve a model's declared `columnMap` into a complete map: default the
|
|
551
|
+
* recipient column, and fall back to a `read_at` timestamp when the model
|
|
552
|
+
* declares no read-state column. The fallback guarantees the result always
|
|
553
|
+
* has a usable read-state representation, so no boot-time validation is needed.
|
|
554
|
+
*/
|
|
555
|
+
function resolveColumnMap(map) {
|
|
556
|
+
const recipient = map?.recipient ?? DEFAULT_RECIPIENT_COLUMN;
|
|
557
|
+
const tenant = map?.tenant;
|
|
558
|
+
if (!map?.readAt && !map?.isRead) return {
|
|
559
|
+
recipient,
|
|
560
|
+
tenant,
|
|
561
|
+
readAt: DEFAULT_READ_AT_COLUMN
|
|
562
|
+
};
|
|
563
|
+
return {
|
|
564
|
+
recipient,
|
|
565
|
+
tenant,
|
|
566
|
+
readAt: map.readAt,
|
|
567
|
+
isRead: map.isRead
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region ../@warlock.js/notifications/src/in-app/base-notifications-repository.ts
|
|
573
|
+
/**
|
|
574
|
+
* Shipped behavior repository for the in-app store.
|
|
575
|
+
*
|
|
576
|
+
* Concrete (NOT abstract) so `inApp.configure({ model })` can
|
|
577
|
+
* `new BaseNotificationsRepository(model)` without subclassing. The dev only
|
|
578
|
+
* extends this when they need extra query methods.
|
|
579
|
+
*
|
|
580
|
+
* ## The single source of truth: the model's `columnMap`
|
|
581
|
+
*
|
|
582
|
+
* The model declares its physical columns ONCE via `static columnMap`. The
|
|
583
|
+
* constructor resolves that map and derives BOTH sides from it:
|
|
584
|
+
*
|
|
585
|
+
* - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,
|
|
586
|
+
* `type`, …) onto the model's columns; `RepositoryManager` applies it.
|
|
587
|
+
* - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate
|
|
588
|
+
* logical keys through the same `filterBy` via `toRow`.
|
|
589
|
+
*
|
|
590
|
+
* Because both come from one `columnMap`, reads and writes can never target
|
|
591
|
+
* different columns. `unread` is mode-agnostic: it filters `is_read = false`
|
|
592
|
+
* when the model has that flag, otherwise `read_at IS NULL`.
|
|
593
|
+
*
|
|
594
|
+
* @example default (SQL snake_case — no subclass needed)
|
|
595
|
+
* inApp.configure({ model: Notification })
|
|
596
|
+
*
|
|
597
|
+
* @example custom columns — declare them on the model, not here
|
|
598
|
+
* class Notification extends DatabaseNotification {
|
|
599
|
+
* public static columnMap = { recipient: "audience_id", isRead: "seen" };
|
|
600
|
+
* }
|
|
601
|
+
*/
|
|
602
|
+
var BaseNotificationsRepository = class extends _warlock_js_core.RepositoryManager {
|
|
603
|
+
/** Resolved physical columns for the bound model — the single source. */
|
|
604
|
+
columns;
|
|
605
|
+
filterBy;
|
|
606
|
+
constructor(model) {
|
|
607
|
+
super();
|
|
608
|
+
if (model) this.source = model;
|
|
609
|
+
this.columns = resolveColumnMap(model?.columnMap);
|
|
610
|
+
this.filterBy = this.buildFilterBy();
|
|
611
|
+
}
|
|
612
|
+
/** The tenant column for this model, or `undefined` for single-tenant. */
|
|
613
|
+
get tenantColumn() {
|
|
614
|
+
return this.columns.tenant;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Build `filterBy` from the resolved column map. `unread` is the
|
|
618
|
+
* mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for
|
|
619
|
+
* direct filtering only when the model declares those columns.
|
|
620
|
+
*/
|
|
621
|
+
buildFilterBy() {
|
|
622
|
+
const { recipient, tenant, readAt, isRead } = this.columns;
|
|
623
|
+
const rules = {
|
|
624
|
+
id: "=",
|
|
625
|
+
type: "=",
|
|
626
|
+
recipientId: ["=", recipient],
|
|
627
|
+
idempotencyKey: ["=", "idempotency_key"],
|
|
628
|
+
unread: (value, query) => {
|
|
629
|
+
if (value !== true && value !== "true") return;
|
|
630
|
+
if (isRead) query.where(isRead, false);
|
|
631
|
+
else if (readAt) query.whereNull(readAt);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
if (isRead) rules.isRead = ["=", isRead];
|
|
635
|
+
if (readAt) rules.readAt = ["=", readAt];
|
|
636
|
+
if (tenant) rules.tenant = ["=", tenant];
|
|
637
|
+
return rules;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Translate an object keyed by LOGICAL names into one keyed by PHYSICAL
|
|
641
|
+
* columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple
|
|
642
|
+
* (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —
|
|
643
|
+
* they're already physical. Used for every write / update / delete payload +
|
|
644
|
+
* filter (the paths `RepositoryManager` does NOT map).
|
|
645
|
+
*/
|
|
646
|
+
toRow(obj) {
|
|
647
|
+
const rules = this.filterBy;
|
|
648
|
+
const row = {};
|
|
649
|
+
for (const [logical, value] of Object.entries(obj)) {
|
|
650
|
+
const rule = rules[logical];
|
|
651
|
+
const column = Array.isArray(rule) ? rule[1] : logical;
|
|
652
|
+
row[column] = value;
|
|
653
|
+
}
|
|
654
|
+
return row;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Create one row for the recipient. New rows start unread; the tenant column
|
|
658
|
+
* (when the model declares one) is written from `tenantId`, which the
|
|
659
|
+
* database channel reads off the recipient.
|
|
660
|
+
*
|
|
661
|
+
* Idempotency: when `input.idempotencyKey` is set, find-or-create — return
|
|
662
|
+
* the existing row instead of inserting a duplicate. A unique index on the
|
|
663
|
+
* key is the backstop for the rare insert race (we re-fetch on conflict).
|
|
664
|
+
*/
|
|
665
|
+
async createFor(recipientId, input, tenantId) {
|
|
666
|
+
const { idempotencyKey } = input;
|
|
667
|
+
if (idempotencyKey) {
|
|
668
|
+
const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
|
|
669
|
+
if (existing) return existing;
|
|
670
|
+
}
|
|
671
|
+
const row = {
|
|
672
|
+
recipientId,
|
|
673
|
+
...input
|
|
674
|
+
};
|
|
675
|
+
if (this.columns.isRead) row.isRead = false;
|
|
676
|
+
if (this.columns.tenant && tenantId !== void 0) row.tenant = tenantId;
|
|
677
|
+
try {
|
|
678
|
+
return await this.create(this.toRow(row));
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (idempotencyKey) {
|
|
681
|
+
const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);
|
|
682
|
+
if (existing) return existing;
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Bulk-create one row per recipient sharing the same input + tenant. Reserved
|
|
689
|
+
* for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient
|
|
690
|
+
* send path yet.
|
|
691
|
+
*/
|
|
692
|
+
createManyFor(recipientIds, input, tenantId) {
|
|
693
|
+
return Promise.all(recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Mark rows read — recipient-scoped. `id` omitted → all unread rows for the
|
|
697
|
+
* recipient; `id` given → that one row, still scoped to the recipient. Scoped
|
|
698
|
+
* to unread so a re-mark never overwrites an earlier `read_at` timestamp.
|
|
699
|
+
* Sets whichever read-state column(s) the model declares.
|
|
700
|
+
*/
|
|
701
|
+
markRead(recipientId, id) {
|
|
702
|
+
const { isRead, readAt } = this.columns;
|
|
703
|
+
const unreadScope = isRead ? { isRead: false } : { readAt: null };
|
|
704
|
+
const data = {};
|
|
705
|
+
if (isRead) data.isRead = true;
|
|
706
|
+
if (readAt) data.readAt = /* @__PURE__ */ new Date();
|
|
707
|
+
return this.updateMany(this.toRow({
|
|
708
|
+
recipientId,
|
|
709
|
+
...unreadScope,
|
|
710
|
+
...id !== void 0 ? { id } : {}
|
|
711
|
+
}), this.toRow(data));
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Inverse of `markRead`, same recipient-scoping. No unread scope needed —
|
|
715
|
+
* clearing the read-state of an already-unread row is a no-op.
|
|
716
|
+
*/
|
|
717
|
+
markUnread(recipientId, id) {
|
|
718
|
+
const { isRead, readAt } = this.columns;
|
|
719
|
+
const data = {};
|
|
720
|
+
if (isRead) data.isRead = false;
|
|
721
|
+
if (readAt) data.readAt = null;
|
|
722
|
+
return this.updateMany(this.toRow({
|
|
723
|
+
recipientId,
|
|
724
|
+
...id !== void 0 ? { id } : {}
|
|
725
|
+
}), this.toRow(data));
|
|
726
|
+
}
|
|
727
|
+
/** Find one row for a recipient (read path — `filterBy` maps the keys). */
|
|
728
|
+
findFor(recipientId, id) {
|
|
729
|
+
return this.first({
|
|
730
|
+
recipientId,
|
|
731
|
+
id
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Delete rows — recipient-scoped. `id` omitted → clear all for the
|
|
736
|
+
* recipient; `id` given → that one row.
|
|
737
|
+
*/
|
|
738
|
+
deleteFor(recipientId, id) {
|
|
739
|
+
return this.deleteMany(this.toRow({
|
|
740
|
+
recipientId,
|
|
741
|
+
...id !== void 0 ? { id } : {}
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
/** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */
|
|
745
|
+
firstByIdempotencyKey(recipientId, idempotencyKey) {
|
|
746
|
+
return this.first({
|
|
747
|
+
recipientId,
|
|
748
|
+
idempotencyKey
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region ../@warlock.js/notifications/src/in-app/database-notification.ts
|
|
755
|
+
/**
|
|
756
|
+
* Shipped BASE in-app notification model. The scaffolded user model
|
|
757
|
+
* `extends DatabaseNotification`, sets `static table`, and declares its
|
|
758
|
+
* physical columns ONCE via `static columnMap`. Every accessor below derives
|
|
759
|
+
* from that map, so the model, the repository, and the migration agree by
|
|
760
|
+
* construction.
|
|
761
|
+
*
|
|
762
|
+
* The package ships NO concrete table or migration — those eject to userland
|
|
763
|
+
* (thin eject). See `notificationColumns()` for the matching migration factory.
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* @RegisterModel()
|
|
767
|
+
* export class Notification extends DatabaseNotification {
|
|
768
|
+
* public static table = "notifications";
|
|
769
|
+
* public static columnMap: NotificationColumnMap = {
|
|
770
|
+
* tenant: "organization_id",
|
|
771
|
+
* readAt: "read_at",
|
|
772
|
+
* };
|
|
773
|
+
* }
|
|
774
|
+
*/
|
|
775
|
+
var DatabaseNotification = class extends _warlock_js_cascade.Model {
|
|
776
|
+
/**
|
|
777
|
+
* Physical column bindings for THIS model's table. Override in the subclass;
|
|
778
|
+
* the empty default resolves to `{ recipient: "user_id", readAt: "read_at" }`.
|
|
779
|
+
*/
|
|
780
|
+
static columnMap = {};
|
|
781
|
+
/** The resolved column map for this row's model — defaults applied. */
|
|
782
|
+
get columns() {
|
|
783
|
+
return resolveColumnMap(this.constructor.columnMap);
|
|
784
|
+
}
|
|
785
|
+
get recipientId() {
|
|
786
|
+
return this.get(this.columns.recipient);
|
|
787
|
+
}
|
|
788
|
+
/** Tenant scope value, or `undefined` for single-tenant models. */
|
|
789
|
+
get tenantId() {
|
|
790
|
+
const { tenant } = this.columns;
|
|
791
|
+
return tenant ? this.get(tenant) : void 0;
|
|
792
|
+
}
|
|
793
|
+
get type() {
|
|
794
|
+
return this.get("type");
|
|
795
|
+
}
|
|
796
|
+
get isRead() {
|
|
797
|
+
const { isRead, readAt } = this.columns;
|
|
798
|
+
if (isRead) return Boolean(this.get(isRead));
|
|
799
|
+
return readAt ? this.get(readAt) != null : false;
|
|
800
|
+
}
|
|
801
|
+
get readAt() {
|
|
802
|
+
const { readAt } = this.columns;
|
|
803
|
+
return readAt ? this.get(readAt) ?? null : null;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Mark this row read. Sets whichever read-state column(s) the model
|
|
807
|
+
* declares — the boolean (fast unread filtering) and/or the timestamp
|
|
808
|
+
* (when it was read). Persists via `save()`.
|
|
809
|
+
*/
|
|
810
|
+
async markRead() {
|
|
811
|
+
const { isRead, readAt } = this.columns;
|
|
812
|
+
if (isRead) this.set(isRead, true);
|
|
813
|
+
if (readAt) this.set(readAt, /* @__PURE__ */ new Date());
|
|
814
|
+
await this.save();
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region ../@warlock.js/notifications/src/in-app/in-app.ts
|
|
820
|
+
const idOf = (recipient) => typeof recipient === "object" ? recipient.id : recipient;
|
|
821
|
+
var InApp = class {
|
|
822
|
+
repo;
|
|
823
|
+
/**
|
|
824
|
+
* Bind the in-app store AND return the `database` channel. Called once
|
|
825
|
+
* from `config/notifications.ts`. Subsequent calls REPLACE the binding.
|
|
826
|
+
*/
|
|
827
|
+
configure(options) {
|
|
828
|
+
this.repo = "repository" in options && options.repository ? options.repository : new BaseNotificationsRepository(options.model);
|
|
829
|
+
return databaseChannel(this.repo);
|
|
830
|
+
}
|
|
831
|
+
get repository() {
|
|
832
|
+
if (!this.repo) throw new Error("In-app notifications not configured — add `database: inApp.configure({ model: Notification })` to config/notifications.ts");
|
|
833
|
+
return this.repo;
|
|
834
|
+
}
|
|
835
|
+
/** General list — scoped to recipient; pass `options` for paging + filters. */
|
|
836
|
+
list(recipient, options) {
|
|
837
|
+
return this.repository.list({
|
|
838
|
+
...options,
|
|
839
|
+
recipientId: idOf(recipient)
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
/** Unread-only list — common case for badges + dashboards. */
|
|
843
|
+
listUnread(recipient, options) {
|
|
844
|
+
return this.repository.list({
|
|
845
|
+
...options,
|
|
846
|
+
recipientId: idOf(recipient),
|
|
847
|
+
unread: true
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Cached unread count — backs the badge. Auto-invalidated by the repo's
|
|
852
|
+
* model create/update events (`RepositoryManager.registerEvents`).
|
|
853
|
+
*/
|
|
854
|
+
countUnread(recipient) {
|
|
855
|
+
return this.repository.countCached({
|
|
856
|
+
recipientId: idOf(recipient),
|
|
857
|
+
unread: true
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/** Find one notification for a recipient — for a detail view. */
|
|
861
|
+
find(recipient, id) {
|
|
862
|
+
return this.repository.findFor(idOf(recipient), id);
|
|
863
|
+
}
|
|
864
|
+
/** Mark read — `id` omitted = all unread for this recipient. */
|
|
865
|
+
markAsRead(recipient, id) {
|
|
866
|
+
return this.repository.markRead(idOf(recipient), id);
|
|
867
|
+
}
|
|
868
|
+
/** Mark unread — same recipient-scoping. */
|
|
869
|
+
markAsUnread(recipient, id) {
|
|
870
|
+
return this.repository.markUnread(idOf(recipient), id);
|
|
871
|
+
}
|
|
872
|
+
/** Delete/dismiss — `id` omitted = clear all for this recipient. */
|
|
873
|
+
dismiss(recipient, id) {
|
|
874
|
+
return this.repository.deleteFor(idOf(recipient), id);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
/** Single in-app facade — bound at boot via `inApp.configure(...)`. */
|
|
878
|
+
const inApp = new InApp();
|
|
879
|
+
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region ../@warlock.js/notifications/src/migration/notification-columns.ts
|
|
882
|
+
/**
|
|
883
|
+
* Column factory for the notifications table, driven by the model's
|
|
884
|
+
* `columnMap`. The recipient / read-state / tenant columns take the NAMES the
|
|
885
|
+
* model declares, so the table, the repository, and the model accessors all
|
|
886
|
+
* agree by construction. The stable lexical columns (`type`, `title`, `body`,
|
|
887
|
+
* `payload`, `idempotency_key`) keep fixed names.
|
|
888
|
+
*
|
|
889
|
+
* Read-state follows the map's presence rules (see `NotificationColumnMap`):
|
|
890
|
+
* `readAt` → a nullable timestamp, `isRead` → an indexed boolean, both → both.
|
|
891
|
+
*
|
|
892
|
+
* Spread + extend for app-specific extras (FK references, composite indexes).
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* import { Migration } from "@warlock.js/cascade";
|
|
896
|
+
* import { notificationColumns } from "@warlock.js/notifications";
|
|
897
|
+
* import { Notification } from "../notification.model";
|
|
898
|
+
*
|
|
899
|
+
* export default Migration.create(Notification, notificationColumns(Notification));
|
|
900
|
+
*
|
|
901
|
+
* @example // with extras (multi-tenant FK)
|
|
902
|
+
* export default Migration.create(Notification, {
|
|
903
|
+
* ...notificationColumns(Notification),
|
|
904
|
+
* organization_id: uuid().references(Organization.table).notNullable(),
|
|
905
|
+
* });
|
|
906
|
+
*/
|
|
907
|
+
/**
|
|
908
|
+
* Returns the column map for a notification table, named from the model's
|
|
909
|
+
* `columnMap`. Without a model, falls back to the resolved defaults
|
|
910
|
+
* (`user_id` recipient + `read_at` read-state).
|
|
911
|
+
*/
|
|
912
|
+
function notificationColumns(model) {
|
|
913
|
+
const { recipient, tenant, readAt, isRead } = resolveColumnMap(model?.columnMap);
|
|
914
|
+
const columns = {
|
|
915
|
+
[recipient]: (0, _warlock_js_cascade.uuid)().index().notNullable(),
|
|
916
|
+
type: (0, _warlock_js_cascade.string)().index().notNullable(),
|
|
917
|
+
title: (0, _warlock_js_cascade.string)().notNullable(),
|
|
918
|
+
body: (0, _warlock_js_cascade.text)().nullable(),
|
|
919
|
+
payload: (0, _warlock_js_cascade.json)().nullable()
|
|
920
|
+
};
|
|
921
|
+
if (readAt) columns[readAt] = (0, _warlock_js_cascade.timestamp)().nullable();
|
|
922
|
+
if (isRead) columns[isRead] = (0, _warlock_js_cascade.boolCol)().default(false).index();
|
|
923
|
+
if (tenant) columns[tenant] = (0, _warlock_js_cascade.uuid)().index().notNullable();
|
|
924
|
+
columns.idempotency_key = (0, _warlock_js_cascade.string)().nullable().unique();
|
|
925
|
+
return columns;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region ../@warlock.js/notifications/src/queue/load-herald.ts
|
|
930
|
+
/**
|
|
931
|
+
* Lazily load `@warlock.js/herald` — the optional peer backing `.queue()`.
|
|
932
|
+
*
|
|
933
|
+
* Memoized: the dynamic import runs at most once. A missing package surfaces a
|
|
934
|
+
* curated install message at use time rather than a boot-time resolution error
|
|
935
|
+
* (the lazy-optional-peer pattern used across the framework's drivers).
|
|
936
|
+
*/
|
|
937
|
+
let heraldModule;
|
|
938
|
+
const INSTALL_INSTRUCTIONS = `
|
|
939
|
+
The notifications queue requires the @warlock.js/herald package.
|
|
940
|
+
Install it with:
|
|
941
|
+
|
|
942
|
+
npm install @warlock.js/herald
|
|
943
|
+
|
|
944
|
+
Or with your preferred package manager:
|
|
945
|
+
|
|
946
|
+
pnpm add @warlock.js/herald
|
|
947
|
+
yarn add @warlock.js/herald
|
|
948
|
+
`.trim();
|
|
949
|
+
async function loadHerald() {
|
|
950
|
+
if (heraldModule) return heraldModule;
|
|
951
|
+
try {
|
|
952
|
+
heraldModule = await import("@warlock.js/herald");
|
|
953
|
+
return heraldModule;
|
|
954
|
+
} catch {
|
|
955
|
+
throw new Error(INSTALL_INSTRUCTIONS);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** The herald channel notification jobs are published to / consumed from. */
|
|
959
|
+
const DEFAULT_QUEUE_CHANNEL = "notifications.dispatch";
|
|
960
|
+
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region ../@warlock.js/notifications/src/queue/herald-queue.ts
|
|
963
|
+
function heraldQueue(options = {}) {
|
|
964
|
+
const channelName = options.channel ?? "notifications.dispatch";
|
|
965
|
+
return { async dispatch(job) {
|
|
966
|
+
const { herald } = await loadHerald();
|
|
967
|
+
await herald(options.broker).channel(channelName).publish(job);
|
|
968
|
+
} };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region ../@warlock.js/notifications/src/queue/notifications-worker.ts
|
|
973
|
+
/**
|
|
974
|
+
* Notification queue worker — consumes the jobs `heraldQueue` publishes and
|
|
975
|
+
* runs the actual `channel.send`. Call this once in a worker process (or the
|
|
976
|
+
* web process) after the notifications config + herald broker are up.
|
|
977
|
+
*
|
|
978
|
+
* The job carries an ALREADY-RENDERED payload + resolved route, so the worker
|
|
979
|
+
* only looks the channel up by name and dispatches — no notifiable needed.
|
|
980
|
+
*
|
|
981
|
+
* @example
|
|
982
|
+
* // in a worker entrypoint, after the notifications config + broker are up
|
|
983
|
+
* import { startNotificationsWorker } from "@warlock.js/notifications";
|
|
984
|
+
* await startNotificationsWorker();
|
|
985
|
+
*/
|
|
986
|
+
async function startNotificationsWorker(options = {}) {
|
|
987
|
+
const channelName = options.channel ?? "notifications.dispatch";
|
|
988
|
+
const { herald } = await loadHerald();
|
|
989
|
+
await herald(options.broker).channel(channelName).subscribe(async (message, ctx) => {
|
|
990
|
+
const job = message.payload;
|
|
991
|
+
try {
|
|
992
|
+
const channel = getNotificationConfig().channels[job.channel];
|
|
993
|
+
if (!channel) {
|
|
994
|
+
_warlock_js_logger.log.error("notifications", "queue.worker", `Unknown channel "${job.channel}" — dropping job`);
|
|
995
|
+
await ctx.ack();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
await channel.send({
|
|
999
|
+
payload: job.payload,
|
|
1000
|
+
route: job.route,
|
|
1001
|
+
options: job.options
|
|
1002
|
+
});
|
|
1003
|
+
await ctx.ack();
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
_warlock_js_logger.log.error("notifications", "queue.worker", error);
|
|
1006
|
+
await ctx.ack();
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
//#endregion
|
|
1012
|
+
exports.BaseNotificationsRepository = BaseNotificationsRepository;
|
|
1013
|
+
exports.ChannelNotFoundError = ChannelNotFoundError;
|
|
1014
|
+
exports.DEFAULT_QUEUE_CHANNEL = DEFAULT_QUEUE_CHANNEL;
|
|
1015
|
+
exports.DatabaseNotification = DatabaseNotification;
|
|
1016
|
+
exports.MissingRendererError = MissingRendererError;
|
|
1017
|
+
exports.NoQueueDispatcherError = NoQueueDispatcherError;
|
|
1018
|
+
exports.NotificationsNotConfiguredError = NotificationsNotConfiguredError;
|
|
1019
|
+
exports.UnresolvableRouteError = UnresolvableRouteError;
|
|
1020
|
+
exports.databaseChannel = databaseChannel;
|
|
1021
|
+
exports.defineChannel = defineChannel;
|
|
1022
|
+
exports.defineNotification = defineNotification;
|
|
1023
|
+
exports.getNotificationConfig = getNotificationConfig;
|
|
1024
|
+
exports.heraldQueue = heraldQueue;
|
|
1025
|
+
exports.inApp = inApp;
|
|
1026
|
+
exports.mailChannel = mailChannel;
|
|
1027
|
+
exports.notificationColumns = notificationColumns;
|
|
1028
|
+
exports.notifications = notifications;
|
|
1029
|
+
exports.notify = notify;
|
|
1030
|
+
exports.resetNotificationConfig = resetNotificationConfig;
|
|
1031
|
+
exports.resolveColumnMap = resolveColumnMap;
|
|
1032
|
+
exports.setNotificationConfig = setNotificationConfig;
|
|
1033
|
+
exports.startNotificationsWorker = startNotificationsWorker;
|
|
1034
|
+
//# sourceMappingURL=index.cjs.map
|