unified-notification-core 0.1.0 → 0.1.2
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 +95 -0
- package/README.md +157 -5
- package/dist/core.d.ts +2 -2
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +967 -297
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +88 -4
- package/dist/errors.js.map +1 -1
- package/dist/json.d.ts +8 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +207 -0
- package/dist/json.js.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +68 -0
- package/dist/keys.js.map +1 -0
- package/dist/preferences.d.ts +9 -0
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +59 -14
- package/dist/preferences.js.map +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +92 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/strings.d.ts +5 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +63 -0
- package/dist/strings.js.map +1 -0
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/docs/adoption-notes.md +76 -0
- package/docs/architecture.md +126 -3
- package/package.json +1 -1
package/dist/core.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, lte, or, } from "drizzle-orm";
|
|
1
|
+
import { and, asc, count, desc, eq, inArray, is, isNotNull, isNull, lt, lte, notInArray, or, sql, } from "drizzle-orm";
|
|
2
|
+
import { PgTransaction } from "drizzle-orm/pg-core";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { types as nodeTypes } from "node:util";
|
|
2
5
|
import { NotificationConfigurationError, NotificationDeliveryError, } from "./errors.js";
|
|
3
|
-
import {
|
|
6
|
+
import { canonicalNotificationKeySql, getOwnRecordValue, normalizeChannelRecord, normalizeNotificationKey, normalizePreferenceDefaults, normalizeUniqueNotificationKeys, } from "./keys.js";
|
|
7
|
+
import { normalizeJsonObject } from "./json.js";
|
|
8
|
+
import { captureArrayValues, capturePlainRecord, inspectArray, } from "./runtime.js";
|
|
9
|
+
import { assertPostgresSafeString, isPostgresSafeString, sanitizeAndTruncatePostgresString, } from "./strings.js";
|
|
10
|
+
import { createStoredPreferenceMap, resolvePreference, resolveNormalizedPreference, } from "./preferences.js";
|
|
4
11
|
import { notificationSchema as defaultNotificationSchema, } from "./schema.js";
|
|
5
12
|
import { BUILT_IN_CHANNELS, PREFERENCE_WILDCARD, } from "./types.js";
|
|
6
13
|
const DEFAULT_RETRY_DELAYS_MS = [
|
|
@@ -13,42 +20,300 @@ const DEFAULT_RETRY_DELAYS_MS = [
|
|
|
13
20
|
function defaultRetryDelayMs(attempt) {
|
|
14
21
|
return (DEFAULT_RETRY_DELAYS_MS[Math.min(Math.max(attempt - 1, 0), DEFAULT_RETRY_DELAYS_MS.length - 1)] ?? 6 * 60 * 60_000);
|
|
15
22
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
const MAX_DISPATCH_LIMIT = 1_000;
|
|
24
|
+
const MAX_DISPATCH_CONCURRENCY = 100;
|
|
25
|
+
const MAX_ATTEMPTS = 2_147_483_647;
|
|
26
|
+
const MAX_PROVIDER_MESSAGE_ID_LENGTH = 10_000;
|
|
27
|
+
const MAX_DELIVERY_DIAGNOSTIC_LENGTH = 10_000;
|
|
28
|
+
const RETRY_DELAY_POLICY_TIMEOUT_MS = 100;
|
|
29
|
+
const DIAGNOSTIC_TRUNCATION_MARKER = "…";
|
|
30
|
+
const CONFIG_KEYS = new Set([
|
|
31
|
+
"db",
|
|
32
|
+
"schema",
|
|
33
|
+
"adapters",
|
|
34
|
+
"defaultChannels",
|
|
35
|
+
"preferenceDefaults",
|
|
36
|
+
"defaultPreferenceEnabled",
|
|
37
|
+
"defaultMaxAttempts",
|
|
38
|
+
"retryDelayMs",
|
|
39
|
+
"lockTimeoutMs",
|
|
40
|
+
"now",
|
|
41
|
+
"logger",
|
|
42
|
+
]);
|
|
43
|
+
const PUBLISH_KEYS = new Set([
|
|
44
|
+
"recipientIds",
|
|
45
|
+
"topic",
|
|
46
|
+
"payload",
|
|
47
|
+
"channels",
|
|
48
|
+
"actorId",
|
|
49
|
+
"entityType",
|
|
50
|
+
"entityId",
|
|
51
|
+
"idempotencyKey",
|
|
52
|
+
"scheduledFor",
|
|
53
|
+
"preferencePolicy",
|
|
54
|
+
"maxAttempts",
|
|
55
|
+
]);
|
|
56
|
+
const NATIVE_PROMISE = Promise;
|
|
57
|
+
const NATIVE_PROMISE_PROTOTYPE = Promise.prototype;
|
|
58
|
+
const NATIVE_PROMISE_THEN = Promise.prototype.then;
|
|
59
|
+
const NATIVE_PROMISE_SPECIES = Symbol.species;
|
|
60
|
+
const MONOTONIC_NOW = performance.now.bind(performance);
|
|
61
|
+
const IS_PROMISE = nodeTypes.isPromise.bind(nodeTypes);
|
|
62
|
+
const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf.bind(Object);
|
|
63
|
+
const OBJECT_HAS_OWN = Object.hasOwn.bind(Object);
|
|
64
|
+
const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor.bind(Object);
|
|
65
|
+
const REFLECT_APPLY = Reflect.apply.bind(Reflect);
|
|
66
|
+
const NATIVE_SET_TIMEOUT = globalThis.setTimeout.bind(globalThis);
|
|
67
|
+
const NATIVE_CLEAR_TIMEOUT = globalThis.clearTimeout.bind(globalThis);
|
|
68
|
+
const NATIVE_PROMISE_CONSTRUCTOR_DESCRIPTOR = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(NATIVE_PROMISE_PROTOTYPE, "constructor");
|
|
69
|
+
const NATIVE_PROMISE_SPECIES_DESCRIPTOR = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(Promise, NATIVE_PROMISE_SPECIES);
|
|
70
|
+
function sameDescriptor(current, expected) {
|
|
71
|
+
return (current !== undefined &&
|
|
72
|
+
expected !== undefined &&
|
|
73
|
+
current.configurable === expected.configurable &&
|
|
74
|
+
current.enumerable === expected.enumerable &&
|
|
75
|
+
current.writable === expected.writable &&
|
|
76
|
+
current.value === expected.value &&
|
|
77
|
+
current.get === expected.get &&
|
|
78
|
+
current.set === expected.set);
|
|
79
|
+
}
|
|
80
|
+
function isExactNativePromise(value) {
|
|
81
|
+
if (!IS_PROMISE(value)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return (OBJECT_GET_PROTOTYPE_OF(value) === NATIVE_PROMISE_PROTOTYPE &&
|
|
86
|
+
!OBJECT_HAS_OWN(value, "constructor") &&
|
|
87
|
+
!OBJECT_HAS_OWN(value, "then") &&
|
|
88
|
+
sameDescriptor(OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(NATIVE_PROMISE_PROTOTYPE, "constructor"), NATIVE_PROMISE_CONSTRUCTOR_DESCRIPTOR) &&
|
|
89
|
+
sameDescriptor(OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(NATIVE_PROMISE, NATIVE_PROMISE_SPECIES), NATIVE_PROMISE_SPECIES_DESCRIPTOR));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function attachNativePromiseObservation(promise, observe) {
|
|
96
|
+
try {
|
|
97
|
+
void REFLECT_APPLY(NATIVE_PROMISE_THEN, promise, [
|
|
98
|
+
(value) => observe({ status: "fulfilled", value }),
|
|
99
|
+
(reason) => observe({ status: "rejected", reason }),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
catch (reason) {
|
|
103
|
+
observe({ status: "rejected", reason });
|
|
20
104
|
}
|
|
21
|
-
return normalized;
|
|
22
105
|
}
|
|
23
106
|
function assertBounded(value, label, max) {
|
|
107
|
+
if (typeof value !== "string") {
|
|
108
|
+
throw new NotificationConfigurationError(`${label} must be a string`);
|
|
109
|
+
}
|
|
24
110
|
if (!value.trim()) {
|
|
25
111
|
throw new NotificationConfigurationError(`${label} must not be empty`);
|
|
26
112
|
}
|
|
27
113
|
if (value.length > max) {
|
|
28
114
|
throw new NotificationConfigurationError(`${label} must be at most ${max} characters`);
|
|
29
115
|
}
|
|
116
|
+
assertPostgresSafeString(value, label);
|
|
30
117
|
}
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
118
|
+
function normalizeOptionalIdentifier(value, label, maximum) {
|
|
119
|
+
if (value === undefined || value === null) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return normalizeNotificationKey(value, label, maximum);
|
|
123
|
+
}
|
|
124
|
+
function normalizeNotificationKeyArray(values, label, maximum) {
|
|
125
|
+
const snapshot = captureArrayValues(values, label);
|
|
126
|
+
const normalized = [];
|
|
127
|
+
for (let index = 0; index < snapshot.length; index += 1) {
|
|
128
|
+
normalized.push(normalizeNotificationKey(snapshot[index], label, maximum));
|
|
129
|
+
}
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
function normalizeBoolean(value, label, fallback) {
|
|
133
|
+
if (value === undefined && fallback !== undefined) {
|
|
134
|
+
return fallback;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value !== "boolean") {
|
|
137
|
+
throw new NotificationConfigurationError(`${label} must be a boolean`);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
function normalizePositiveInteger(value, label, minimum = 1) {
|
|
142
|
+
if (typeof value !== "number" ||
|
|
143
|
+
!Number.isFinite(value) ||
|
|
144
|
+
!Number.isInteger(value) ||
|
|
145
|
+
value < minimum) {
|
|
146
|
+
throw new NotificationConfigurationError(`${label} must be an integer no less than ${minimum}`);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
function normalizeMaxAttempts(value, label) {
|
|
151
|
+
if (typeof value !== "number" ||
|
|
152
|
+
!Number.isFinite(value) ||
|
|
153
|
+
!Number.isInteger(value) ||
|
|
154
|
+
value < 1 ||
|
|
155
|
+
value > MAX_ATTEMPTS) {
|
|
156
|
+
throw new NotificationConfigurationError(`${label} must be an integer from 1 through ${MAX_ATTEMPTS}`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function normalizeDate(value, label) {
|
|
161
|
+
let time = Number.NaN;
|
|
162
|
+
try {
|
|
163
|
+
if (value instanceof Date) {
|
|
164
|
+
time = Date.prototype.getTime.call(value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
time = Number.NaN;
|
|
169
|
+
}
|
|
170
|
+
if (!Number.isFinite(time)) {
|
|
171
|
+
throw new NotificationConfigurationError(`${label} must be a valid Date`);
|
|
172
|
+
}
|
|
173
|
+
return new Date(time);
|
|
174
|
+
}
|
|
175
|
+
function normalizePreferencePolicy(value) {
|
|
176
|
+
if (value === "respect" || value === "ignore") {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
throw new NotificationConfigurationError('preferencePolicy must be either "respect" or "ignore"');
|
|
180
|
+
}
|
|
181
|
+
function normalizeDispatchInteger(value, label, fallback, maximum) {
|
|
182
|
+
if (value === undefined) {
|
|
183
|
+
return fallback;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value !== "number" ||
|
|
186
|
+
!Number.isFinite(value) ||
|
|
187
|
+
!Number.isInteger(value) ||
|
|
188
|
+
value < 1 ||
|
|
189
|
+
value > maximum) {
|
|
190
|
+
throw new NotificationConfigurationError(`${label} must be a positive integer no greater than ${maximum}`);
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function capDiagnosticMessage(message) {
|
|
195
|
+
return sanitizeAndTruncatePostgresString(message, MAX_DELIVERY_DIAGNOSTIC_LENGTH, DIAGNOSTIC_TRUNCATION_MARKER);
|
|
196
|
+
}
|
|
197
|
+
function ownDataValue(value, key) {
|
|
198
|
+
if (value === null ||
|
|
199
|
+
(typeof value !== "object" && typeof value !== "function")) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
204
|
+
return descriptor && "value" in descriptor
|
|
205
|
+
? descriptor.value
|
|
206
|
+
: undefined;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function safeDiagnosticMessage(value, fallback) {
|
|
213
|
+
let message;
|
|
214
|
+
if (typeof value === "string") {
|
|
215
|
+
message = value;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const ownMessage = ownDataValue(value, "message");
|
|
219
|
+
if (typeof ownMessage === "string") {
|
|
220
|
+
message = ownMessage;
|
|
221
|
+
}
|
|
222
|
+
else if (value === null ||
|
|
223
|
+
typeof value === "number" ||
|
|
224
|
+
typeof value === "boolean" ||
|
|
225
|
+
typeof value === "bigint" ||
|
|
226
|
+
typeof value === "symbol" ||
|
|
227
|
+
value === undefined) {
|
|
228
|
+
try {
|
|
229
|
+
message = String(value);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
message = undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return capDiagnosticMessage(message || fallback);
|
|
237
|
+
}
|
|
238
|
+
function adapterResultDiagnostic(error) {
|
|
239
|
+
const detail = safeDiagnosticMessage(error, "invalid adapter result");
|
|
240
|
+
return capDiagnosticMessage(`Adapter resolved successfully, but its optional result was discarded: ${detail}`);
|
|
241
|
+
}
|
|
242
|
+
function adapterResultValue(result, key) {
|
|
243
|
+
const descriptor = Object.getOwnPropertyDescriptor(result, key);
|
|
244
|
+
if (!descriptor) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
if (!("value" in descriptor)) {
|
|
248
|
+
throw new NotificationConfigurationError(`adapter result ${key} must be a data property`);
|
|
249
|
+
}
|
|
250
|
+
return descriptor.value;
|
|
251
|
+
}
|
|
252
|
+
function normalizeAdapterResult(result) {
|
|
253
|
+
if (result === undefined) {
|
|
254
|
+
return { providerMessageId: null, metadata: null, diagnostic: null };
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
258
|
+
throw new NotificationConfigurationError("adapter result must be an object or undefined");
|
|
259
|
+
}
|
|
260
|
+
const providerMessageId = adapterResultValue(result, "providerMessageId");
|
|
261
|
+
if (providerMessageId !== undefined) {
|
|
262
|
+
assertBounded(providerMessageId, "adapter result providerMessageId", MAX_PROVIDER_MESSAGE_ID_LENGTH);
|
|
263
|
+
}
|
|
264
|
+
const metadata = adapterResultValue(result, "metadata");
|
|
265
|
+
const normalizedMetadata = metadata === undefined
|
|
266
|
+
? undefined
|
|
267
|
+
: normalizeJsonObject(metadata, "adapter result metadata");
|
|
33
268
|
return {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
269
|
+
providerMessageId: providerMessageId ?? null,
|
|
270
|
+
metadata: normalizedMetadata ?? null,
|
|
271
|
+
diagnostic: null,
|
|
37
272
|
};
|
|
38
273
|
}
|
|
39
|
-
|
|
274
|
+
catch (error) {
|
|
40
275
|
return {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
276
|
+
providerMessageId: null,
|
|
277
|
+
metadata: null,
|
|
278
|
+
diagnostic: adapterResultDiagnostic(error),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function persistenceSafeErrorCode(code) {
|
|
283
|
+
if (typeof code !== "string") {
|
|
284
|
+
return "delivery_failed";
|
|
285
|
+
}
|
|
286
|
+
const normalized = code.trim();
|
|
287
|
+
return normalized && normalized.length <= 191 && isPostgresSafeString(normalized)
|
|
288
|
+
? normalized
|
|
289
|
+
: "delivery_failed";
|
|
290
|
+
}
|
|
291
|
+
function errorDetails(error) {
|
|
292
|
+
let isDeliveryError = false;
|
|
293
|
+
try {
|
|
294
|
+
isDeliveryError = error instanceof NotificationDeliveryError;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
isDeliveryError = false;
|
|
298
|
+
}
|
|
299
|
+
if (isDeliveryError) {
|
|
300
|
+
return {
|
|
301
|
+
code: persistenceSafeErrorCode(ownDataValue(error, "code")),
|
|
302
|
+
message: safeDiagnosticMessage(error, "Notification delivery failed"),
|
|
303
|
+
retryable: ownDataValue(error, "retryable") !== false,
|
|
44
304
|
};
|
|
45
305
|
}
|
|
46
306
|
return {
|
|
47
307
|
code: "delivery_failed",
|
|
48
|
-
message:
|
|
308
|
+
message: safeDiagnosticMessage(error, "Unknown delivery failure"),
|
|
49
309
|
retryable: true,
|
|
50
310
|
};
|
|
51
311
|
}
|
|
312
|
+
function assertDispatchExecutor(db) {
|
|
313
|
+
if (is(db, PgTransaction)) {
|
|
314
|
+
throw new NotificationConfigurationError("dispatchDue() and publishAndDispatch() require a top-level PgDatabase; use publish() in the application transaction and dispatch from a top-level core after it commits");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
52
317
|
export class UnifiedNotificationCore {
|
|
53
318
|
#db;
|
|
54
319
|
#schema;
|
|
@@ -62,201 +327,357 @@ export class UnifiedNotificationCore {
|
|
|
62
327
|
#now;
|
|
63
328
|
#logger;
|
|
64
329
|
constructor(config) {
|
|
330
|
+
config = capturePlainRecord(config, "config", CONFIG_KEYS);
|
|
331
|
+
if (!config.db || typeof config.db !== "object") {
|
|
332
|
+
throw new NotificationConfigurationError("db must be a database object");
|
|
333
|
+
}
|
|
65
334
|
this.#db = config.db;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.#
|
|
73
|
-
|
|
74
|
-
this.#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
335
|
+
const schema = Object.hasOwn(config, "schema")
|
|
336
|
+
? config.schema
|
|
337
|
+
: defaultNotificationSchema;
|
|
338
|
+
if (!schema || typeof schema !== "object" || inspectArray(schema, "schema")) {
|
|
339
|
+
throw new NotificationConfigurationError("schema must be an object");
|
|
340
|
+
}
|
|
341
|
+
this.#schema = schema;
|
|
342
|
+
const adapters = Object.hasOwn(config, "adapters") ? config.adapters : {};
|
|
343
|
+
this.#adapters = normalizeChannelRecord(adapters, "adapters");
|
|
344
|
+
for (const channel of Object.keys(this.#adapters)) {
|
|
345
|
+
if (typeof this.#adapters[channel] !== "function") {
|
|
346
|
+
throw new NotificationConfigurationError(`adapters value for channel "${channel}" must be a function`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.#defaultChannels = normalizeUniqueNotificationKeys(Object.hasOwn(config, "defaultChannels")
|
|
350
|
+
? config.defaultChannels
|
|
351
|
+
: [BUILT_IN_CHANNELS.IN_APP], "defaultChannels", 64);
|
|
352
|
+
this.#preferenceDefaults = normalizePreferenceDefaults(Object.hasOwn(config, "preferenceDefaults")
|
|
353
|
+
? config.preferenceDefaults
|
|
354
|
+
: {});
|
|
355
|
+
this.#defaultPreferenceEnabled = Object.hasOwn(config, "defaultPreferenceEnabled")
|
|
356
|
+
? normalizeBoolean(config.defaultPreferenceEnabled, "defaultPreferenceEnabled")
|
|
357
|
+
: true;
|
|
358
|
+
this.#defaultMaxAttempts = Object.hasOwn(config, "defaultMaxAttempts")
|
|
359
|
+
? normalizeMaxAttempts(config.defaultMaxAttempts, "defaultMaxAttempts")
|
|
360
|
+
: 5;
|
|
361
|
+
const retryDelayMs = Object.hasOwn(config, "retryDelayMs")
|
|
362
|
+
? config.retryDelayMs
|
|
363
|
+
: defaultRetryDelayMs;
|
|
364
|
+
if (typeof retryDelayMs !== "function") {
|
|
365
|
+
throw new NotificationConfigurationError("retryDelayMs must be a function");
|
|
366
|
+
}
|
|
367
|
+
this.#retryDelayMs = retryDelayMs;
|
|
368
|
+
this.#lockTimeoutMs = Object.hasOwn(config, "lockTimeoutMs")
|
|
369
|
+
? normalizePositiveInteger(config.lockTimeoutMs, "lockTimeoutMs", 1_000)
|
|
370
|
+
: 5 * 60_000;
|
|
371
|
+
const now = Object.hasOwn(config, "now") ? config.now : () => new Date();
|
|
372
|
+
if (typeof now !== "function") {
|
|
373
|
+
throw new NotificationConfigurationError("now must be a function");
|
|
79
374
|
}
|
|
80
|
-
|
|
81
|
-
|
|
375
|
+
this.#now = now;
|
|
376
|
+
const logger = Object.hasOwn(config, "logger") ? config.logger : undefined;
|
|
377
|
+
if (Object.hasOwn(config, "logger") &&
|
|
378
|
+
(!logger || typeof logger !== "object" || inspectArray(logger, "logger"))) {
|
|
379
|
+
throw new NotificationConfigurationError("logger must be an object");
|
|
82
380
|
}
|
|
381
|
+
const loggerSnapshot = logger
|
|
382
|
+
? capturePlainRecord(logger, "logger", new Set(["debug", "error"]))
|
|
383
|
+
: undefined;
|
|
384
|
+
for (const level of ["debug", "error"]) {
|
|
385
|
+
if (!loggerSnapshot || !Object.hasOwn(loggerSnapshot, level)) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (typeof loggerSnapshot[level] !== "function") {
|
|
389
|
+
throw new NotificationConfigurationError(`logger.${level} must be a function`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
this.#logger = loggerSnapshot;
|
|
83
393
|
this.#validateChannels(this.#defaultChannels);
|
|
84
394
|
}
|
|
85
395
|
async publish(input) {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
assertBounded(
|
|
91
|
-
|
|
396
|
+
input = capturePlainRecord(input, "publish input", PUBLISH_KEYS);
|
|
397
|
+
const recipientIds = normalizeUniqueNotificationKeys(input.recipientIds, "recipientId", 10_000);
|
|
398
|
+
const topic = normalizeNotificationKey(input.topic, "topic", 191);
|
|
399
|
+
const normalizedPayload = normalizeJsonObject(input.payload, "payload");
|
|
400
|
+
assertBounded(normalizedPayload.title, "payload.title", 10_000);
|
|
401
|
+
assertBounded(normalizedPayload.body, "payload.body", 100_000);
|
|
402
|
+
if (normalizedPayload.actionUrl !== undefined) {
|
|
403
|
+
assertBounded(normalizedPayload.actionUrl, "payload.actionUrl", 100_000);
|
|
404
|
+
}
|
|
405
|
+
if (normalizedPayload.data !== undefined) {
|
|
406
|
+
normalizeJsonObject(normalizedPayload.data, "payload.data");
|
|
407
|
+
}
|
|
408
|
+
const channels = normalizeUniqueNotificationKeys(Object.hasOwn(input, "channels")
|
|
409
|
+
? input.channels
|
|
410
|
+
: this.#defaultChannels, "channels", 64);
|
|
92
411
|
this.#validateChannels(channels);
|
|
412
|
+
let channelData;
|
|
413
|
+
if (normalizedPayload.channelData !== undefined) {
|
|
414
|
+
const normalizedChannelData = normalizeJsonObject(normalizedPayload.channelData, "payload.channelData");
|
|
415
|
+
channelData = normalizeChannelRecord(normalizedChannelData, "payload.channelData");
|
|
416
|
+
}
|
|
417
|
+
const payload = {
|
|
418
|
+
...normalizedPayload,
|
|
419
|
+
...(channelData ? { channelData } : {}),
|
|
420
|
+
};
|
|
421
|
+
let configuredMaxAttempts;
|
|
422
|
+
if (input.maxAttempts !== undefined) {
|
|
423
|
+
if (typeof input.maxAttempts === "number") {
|
|
424
|
+
configuredMaxAttempts = normalizeMaxAttempts(input.maxAttempts, "maxAttempts");
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const record = normalizeChannelRecord(input.maxAttempts, "maxAttempts");
|
|
428
|
+
for (const [channel, value] of Object.entries(record)) {
|
|
429
|
+
normalizeMaxAttempts(value, `maxAttempts for channel "${channel}"`);
|
|
430
|
+
}
|
|
431
|
+
configuredMaxAttempts = record;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const maxAttemptsByChannel = new Map(channels.map((channel) => [
|
|
435
|
+
channel,
|
|
436
|
+
this.#maxAttemptsFor(channel, configuredMaxAttempts),
|
|
437
|
+
]));
|
|
93
438
|
if (input.idempotencyKey !== undefined) {
|
|
94
439
|
assertBounded(input.idempotencyKey, "idempotencyKey", 255);
|
|
95
440
|
}
|
|
96
|
-
const
|
|
97
|
-
const
|
|
441
|
+
const actorId = normalizeOptionalIdentifier(input.actorId, "actorId", 10_000);
|
|
442
|
+
const entityType = normalizeOptionalIdentifier(input.entityType, "entityType", 191);
|
|
443
|
+
const entityId = normalizeOptionalIdentifier(input.entityId, "entityId", 10_000);
|
|
444
|
+
const scheduledFor = input.scheduledFor === undefined
|
|
445
|
+
? this.#currentTime()
|
|
446
|
+
: normalizeDate(input.scheduledFor, "scheduledFor");
|
|
447
|
+
const preferencePolicy = Object.hasOwn(input, "preferencePolicy")
|
|
448
|
+
? normalizePreferencePolicy(input.preferencePolicy)
|
|
449
|
+
: "respect";
|
|
98
450
|
const { notifications, notificationDeliveries, notificationPreferences, } = this.#schema;
|
|
99
451
|
return this.#db.transaction(async (tx) => {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
title: input.payload.title,
|
|
108
|
-
body: input.payload.body,
|
|
109
|
-
actionUrl: input.payload.actionUrl ?? null,
|
|
110
|
-
data: input.payload.data ?? {},
|
|
111
|
-
payload: input.payload,
|
|
112
|
-
actorId: input.actorId ?? null,
|
|
113
|
-
entityType: input.entityType ?? null,
|
|
114
|
-
entityId: input.entityId ?? null,
|
|
115
|
-
idempotencyKey: input.idempotencyKey ?? null,
|
|
116
|
-
scheduledFor,
|
|
452
|
+
const preferencesByRecipient = new Map();
|
|
453
|
+
if (preferencePolicy === "respect") {
|
|
454
|
+
const selection = {
|
|
455
|
+
recipientId: notificationPreferences.recipientId,
|
|
456
|
+
topic: notificationPreferences.topic,
|
|
457
|
+
channel: notificationPreferences.channel,
|
|
458
|
+
enabled: notificationPreferences.enabled,
|
|
117
459
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
[notification] = await tx
|
|
142
|
-
.insert(notifications)
|
|
143
|
-
.values(values)
|
|
144
|
-
.returning();
|
|
460
|
+
const exactPreferences = await tx
|
|
461
|
+
.select(selection)
|
|
462
|
+
.from(notificationPreferences)
|
|
463
|
+
.where(inArray(notificationPreferences.recipientId, recipientIds));
|
|
464
|
+
const legacyPreferences = await tx
|
|
465
|
+
.select(selection)
|
|
466
|
+
.from(notificationPreferences)
|
|
467
|
+
.where(and(notInArray(notificationPreferences.recipientId, recipientIds), inArray(canonicalNotificationKeySql(notificationPreferences.recipientId), recipientIds)));
|
|
468
|
+
for (const preference of [
|
|
469
|
+
...exactPreferences,
|
|
470
|
+
...legacyPreferences,
|
|
471
|
+
]) {
|
|
472
|
+
const recipientId = normalizeNotificationKey(preference.recipientId, "storedPreference.recipientId", 10_000);
|
|
473
|
+
const stored = preferencesByRecipient.get(recipientId) ?? [];
|
|
474
|
+
stored.push({
|
|
475
|
+
topic: preference.topic,
|
|
476
|
+
channel: preference.channel,
|
|
477
|
+
enabled: preference.enabled,
|
|
478
|
+
});
|
|
479
|
+
preferencesByRecipient.set(recipientId, stored);
|
|
145
480
|
}
|
|
146
|
-
|
|
147
|
-
|
|
481
|
+
}
|
|
482
|
+
const notificationValues = recipientIds.map((recipientId) => ({
|
|
483
|
+
recipientId,
|
|
484
|
+
topic,
|
|
485
|
+
title: payload.title,
|
|
486
|
+
body: payload.body,
|
|
487
|
+
actionUrl: payload.actionUrl ?? null,
|
|
488
|
+
data: payload.data ?? {},
|
|
489
|
+
payload,
|
|
490
|
+
actorId,
|
|
491
|
+
entityType,
|
|
492
|
+
entityId,
|
|
493
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
494
|
+
scheduledFor,
|
|
495
|
+
}));
|
|
496
|
+
let insertedNotifications;
|
|
497
|
+
let selectedNotifications;
|
|
498
|
+
if (input.idempotencyKey !== undefined) {
|
|
499
|
+
insertedNotifications = await tx
|
|
500
|
+
.insert(notifications)
|
|
501
|
+
.values(notificationValues)
|
|
502
|
+
.onConflictDoNothing({
|
|
503
|
+
target: [
|
|
504
|
+
notifications.recipientId,
|
|
505
|
+
notifications.idempotencyKey,
|
|
506
|
+
],
|
|
507
|
+
where: isNotNull(notifications.idempotencyKey),
|
|
508
|
+
})
|
|
509
|
+
.returning();
|
|
510
|
+
selectedNotifications = await tx
|
|
511
|
+
.select()
|
|
512
|
+
.from(notifications)
|
|
513
|
+
.where(and(inArray(notifications.recipientId, recipientIds), eq(notifications.idempotencyKey, input.idempotencyKey)));
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
insertedNotifications = await tx
|
|
517
|
+
.insert(notifications)
|
|
518
|
+
.values(notificationValues)
|
|
519
|
+
.returning();
|
|
520
|
+
selectedNotifications = insertedNotifications;
|
|
521
|
+
}
|
|
522
|
+
const notificationsByRecipient = new Map(selectedNotifications.map((notification) => [
|
|
523
|
+
notification.recipientId,
|
|
524
|
+
notification,
|
|
525
|
+
]));
|
|
526
|
+
if (notificationsByRecipient.size !== recipientIds.length) {
|
|
527
|
+
throw new Error("notification insert did not return every recipient row");
|
|
528
|
+
}
|
|
529
|
+
const createdNotificationIds = new Set(insertedNotifications.map((notification) => notification.id));
|
|
530
|
+
const storedPreferenceMaps = new Map();
|
|
531
|
+
for (const recipientId of recipientIds) {
|
|
532
|
+
storedPreferenceMaps.set(recipientId, createStoredPreferenceMap(preferencesByRecipient.get(recipientId) ?? []));
|
|
533
|
+
}
|
|
534
|
+
const deliveryValues = [];
|
|
535
|
+
for (const recipientId of recipientIds) {
|
|
536
|
+
const notification = notificationsByRecipient.get(recipientId);
|
|
537
|
+
if (!createdNotificationIds.has(notification.id)) {
|
|
538
|
+
continue;
|
|
148
539
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
enabled: notificationPreferences.enabled,
|
|
156
|
-
})
|
|
157
|
-
.from(notificationPreferences)
|
|
158
|
-
.where(eq(notificationPreferences.recipientId, recipientId))
|
|
159
|
-
: [];
|
|
160
|
-
await tx.insert(notificationDeliveries).values(channels.map((channel) => {
|
|
161
|
-
const enabled = preferencePolicy === "ignore"
|
|
162
|
-
? true
|
|
163
|
-
: resolvePreference({
|
|
164
|
-
topic,
|
|
165
|
-
channel,
|
|
166
|
-
stored,
|
|
167
|
-
defaults: this.#preferenceDefaults,
|
|
168
|
-
globalDefault: this.#defaultPreferenceEnabled,
|
|
169
|
-
}).enabled;
|
|
170
|
-
return {
|
|
171
|
-
notificationId: notification.id,
|
|
540
|
+
const stored = storedPreferenceMaps.get(recipientId);
|
|
541
|
+
for (const channel of channels) {
|
|
542
|
+
const enabled = preferencePolicy === "ignore"
|
|
543
|
+
? true
|
|
544
|
+
: resolveNormalizedPreference({
|
|
545
|
+
topic,
|
|
172
546
|
channel,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
547
|
+
stored,
|
|
548
|
+
defaults: this.#preferenceDefaults,
|
|
549
|
+
globalDefault: this.#defaultPreferenceEnabled,
|
|
550
|
+
}).enabled;
|
|
551
|
+
deliveryValues.push({
|
|
552
|
+
notificationId: notification.id,
|
|
553
|
+
channel,
|
|
554
|
+
status: enabled ? "pending" : "skipped",
|
|
555
|
+
maxAttempts: maxAttemptsByChannel.get(channel),
|
|
556
|
+
nextAttemptAt: enabled ? scheduledFor : null,
|
|
557
|
+
errorCode: enabled ? null : "preference_disabled",
|
|
558
|
+
errorMessage: enabled
|
|
559
|
+
? null
|
|
560
|
+
: "Delivery disabled by recipient preference",
|
|
561
|
+
});
|
|
182
562
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
563
|
+
}
|
|
564
|
+
if (deliveryValues.length > 0) {
|
|
565
|
+
await tx.insert(notificationDeliveries).values(deliveryValues);
|
|
566
|
+
}
|
|
567
|
+
const notificationIds = selectedNotifications.map((notification) => notification.id);
|
|
568
|
+
const deliveryRows = await tx
|
|
569
|
+
.select({
|
|
570
|
+
notificationId: notificationDeliveries.notificationId,
|
|
571
|
+
deliveryId: notificationDeliveries.id,
|
|
572
|
+
channel: notificationDeliveries.channel,
|
|
573
|
+
status: notificationDeliveries.status,
|
|
574
|
+
})
|
|
575
|
+
.from(notificationDeliveries)
|
|
576
|
+
.where(inArray(notificationDeliveries.notificationId, notificationIds));
|
|
577
|
+
const deliveriesByNotification = new Map();
|
|
578
|
+
for (const delivery of deliveryRows) {
|
|
579
|
+
const current = deliveriesByNotification.get(delivery.notificationId) ?? [];
|
|
580
|
+
current.push(delivery);
|
|
581
|
+
deliveriesByNotification.set(delivery.notificationId, current);
|
|
582
|
+
}
|
|
583
|
+
const channelOrder = new Map(channels.map((channel, index) => [channel, index]));
|
|
584
|
+
return recipientIds.map((recipientId) => {
|
|
585
|
+
const notification = notificationsByRecipient.get(recipientId);
|
|
586
|
+
const rows = deliveriesByNotification.get(notification.id) ?? [];
|
|
587
|
+
rows.sort((left, right) => (channelOrder.get(left.channel) ?? channels.length) -
|
|
588
|
+
(channelOrder.get(right.channel) ?? channels.length) ||
|
|
589
|
+
left.channel.localeCompare(right.channel));
|
|
590
|
+
return {
|
|
192
591
|
notificationId: notification.id,
|
|
193
592
|
recipientId,
|
|
194
|
-
created,
|
|
195
|
-
deliveries:
|
|
593
|
+
created: createdNotificationIds.has(notification.id),
|
|
594
|
+
deliveries: rows.map((delivery) => ({
|
|
196
595
|
deliveryId: delivery.deliveryId,
|
|
197
596
|
channel: delivery.channel,
|
|
198
597
|
status: delivery.status,
|
|
199
598
|
})),
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return published;
|
|
599
|
+
};
|
|
600
|
+
});
|
|
203
601
|
});
|
|
204
602
|
}
|
|
205
603
|
async publishAndDispatch(input, options = {}) {
|
|
604
|
+
options = capturePlainRecord(options, "publishAndDispatch options", new Set(["workerId", "concurrency"]));
|
|
605
|
+
assertDispatchExecutor(this.#db);
|
|
606
|
+
const concurrency = normalizeDispatchInteger(options.concurrency, "concurrency", 10, MAX_DISPATCH_CONCURRENCY);
|
|
607
|
+
const workerId = options.workerId === undefined
|
|
608
|
+
? undefined
|
|
609
|
+
: normalizeNotificationKey(options.workerId, "workerId", 154);
|
|
206
610
|
const published = await this.publish(input);
|
|
207
611
|
const dispatched = await this.#dispatch({
|
|
208
|
-
limit: published.reduce((total, item) => total + item.deliveries.length, 0),
|
|
209
|
-
...(
|
|
210
|
-
|
|
211
|
-
? { concurrency: options.concurrency }
|
|
212
|
-
: {}),
|
|
612
|
+
limit: Math.min(published.reduce((total, item) => total + item.deliveries.length, 0), MAX_DISPATCH_LIMIT),
|
|
613
|
+
...(workerId !== undefined ? { workerId } : {}),
|
|
614
|
+
concurrency,
|
|
213
615
|
notificationIds: published.map((item) => item.notificationId),
|
|
214
616
|
});
|
|
215
617
|
return { published, dispatched };
|
|
216
618
|
}
|
|
217
619
|
async dispatchDue(options = {}) {
|
|
620
|
+
options = capturePlainRecord(options, "dispatch options", new Set(["limit", "workerId", "concurrency"]));
|
|
218
621
|
return this.#dispatch(options);
|
|
219
622
|
}
|
|
220
623
|
async setPreference(preference) {
|
|
624
|
+
preference = capturePlainRecord(preference, "preference", new Set(["recipientId", "topic", "channel", "enabled"]));
|
|
221
625
|
const { notificationPreferences } = this.#schema;
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
626
|
+
const recipientId = normalizeNotificationKey(preference.recipientId, "preference.recipientId", 10_000);
|
|
627
|
+
const topic = normalizeNotificationKey(preference.topic, "preference.topic", 191);
|
|
628
|
+
const channel = normalizeNotificationKey(preference.channel, "preference.channel", 64);
|
|
629
|
+
const enabled = normalizeBoolean(preference.enabled, "preference.enabled");
|
|
630
|
+
const now = this.#currentTime();
|
|
631
|
+
await this.#db.transaction(async (tx) => {
|
|
632
|
+
await tx
|
|
633
|
+
.delete(notificationPreferences)
|
|
634
|
+
.where(and(eq(canonicalNotificationKeySql(notificationPreferences.recipientId), recipientId), eq(canonicalNotificationKeySql(notificationPreferences.topic), topic), eq(canonicalNotificationKeySql(notificationPreferences.channel), channel)));
|
|
635
|
+
await tx
|
|
636
|
+
.insert(notificationPreferences)
|
|
637
|
+
.values({
|
|
638
|
+
recipientId,
|
|
639
|
+
topic,
|
|
640
|
+
channel,
|
|
641
|
+
enabled,
|
|
642
|
+
updatedAt: now,
|
|
643
|
+
})
|
|
644
|
+
.onConflictDoUpdate({
|
|
645
|
+
target: [
|
|
646
|
+
notificationPreferences.recipientId,
|
|
647
|
+
notificationPreferences.topic,
|
|
648
|
+
notificationPreferences.channel,
|
|
649
|
+
],
|
|
650
|
+
set: { enabled, updatedAt: now },
|
|
651
|
+
});
|
|
244
652
|
});
|
|
245
653
|
}
|
|
246
654
|
async setPreferences(recipientId, preferences) {
|
|
655
|
+
const normalizedRecipientId = normalizeNotificationKey(recipientId, "preference.recipientId", 10_000);
|
|
656
|
+
const preferenceSnapshot = captureArrayValues(preferences, "preferences");
|
|
657
|
+
const normalizedPreferences = [];
|
|
658
|
+
for (let index = 0; index < preferenceSnapshot.length; index += 1) {
|
|
659
|
+
const preference = capturePlainRecord(preferenceSnapshot[index], `preferences[${index}]`, new Set(["topic", "channel", "enabled"]));
|
|
660
|
+
normalizedPreferences.push({
|
|
661
|
+
topic: normalizeNotificationKey(preference.topic, "preference.topic", 191),
|
|
662
|
+
channel: normalizeNotificationKey(preference.channel, "preference.channel", 64),
|
|
663
|
+
enabled: normalizeBoolean(preference.enabled, "preference.enabled"),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const now = this.#currentTime();
|
|
247
667
|
await this.#db.transaction(async (tx) => {
|
|
248
668
|
const { notificationPreferences } = this.#schema;
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
669
|
+
for (const preference of normalizedPreferences) {
|
|
670
|
+
const { topic, channel, enabled } = preference;
|
|
671
|
+
await tx
|
|
672
|
+
.delete(notificationPreferences)
|
|
673
|
+
.where(and(eq(canonicalNotificationKeySql(notificationPreferences.recipientId), normalizedRecipientId), eq(canonicalNotificationKeySql(notificationPreferences.topic), topic), eq(canonicalNotificationKeySql(notificationPreferences.channel), channel)));
|
|
253
674
|
await tx
|
|
254
675
|
.insert(notificationPreferences)
|
|
255
676
|
.values({
|
|
256
|
-
recipientId,
|
|
257
|
-
topic
|
|
258
|
-
channel
|
|
259
|
-
enabled
|
|
677
|
+
recipientId: normalizedRecipientId,
|
|
678
|
+
topic,
|
|
679
|
+
channel,
|
|
680
|
+
enabled,
|
|
260
681
|
updatedAt: now,
|
|
261
682
|
})
|
|
262
683
|
.onConflictDoUpdate({
|
|
@@ -265,21 +686,29 @@ export class UnifiedNotificationCore {
|
|
|
265
686
|
notificationPreferences.topic,
|
|
266
687
|
notificationPreferences.channel,
|
|
267
688
|
],
|
|
268
|
-
set: { enabled
|
|
689
|
+
set: { enabled, updatedAt: now },
|
|
269
690
|
});
|
|
270
691
|
}
|
|
271
692
|
});
|
|
272
693
|
}
|
|
273
694
|
async clearPreference(input) {
|
|
695
|
+
input = capturePlainRecord(input, "clearPreference input", new Set(["recipientId", "topic", "channel"]));
|
|
274
696
|
const { notificationPreferences } = this.#schema;
|
|
697
|
+
const recipientId = normalizeNotificationKey(input.recipientId, "preference.recipientId", 10_000);
|
|
698
|
+
const topic = normalizeNotificationKey(input.topic, "preference.topic", 191);
|
|
699
|
+
const channel = normalizeNotificationKey(input.channel, "preference.channel", 64);
|
|
275
700
|
const deleted = await this.#db
|
|
276
701
|
.delete(notificationPreferences)
|
|
277
|
-
.where(and(eq(notificationPreferences.recipientId,
|
|
702
|
+
.where(and(eq(canonicalNotificationKeySql(notificationPreferences.recipientId), recipientId), eq(canonicalNotificationKeySql(notificationPreferences.topic), topic), eq(canonicalNotificationKeySql(notificationPreferences.channel), channel)))
|
|
278
703
|
.returning({ recipientId: notificationPreferences.recipientId });
|
|
279
704
|
return deleted.length > 0;
|
|
280
705
|
}
|
|
281
706
|
async getPreferences(input) {
|
|
707
|
+
input = capturePlainRecord(input, "getPreferences input", new Set(["recipientId", "topics", "channels"]));
|
|
282
708
|
const { notificationPreferences } = this.#schema;
|
|
709
|
+
const recipientId = normalizeNotificationKey(input.recipientId, "preference.recipientId", 10_000);
|
|
710
|
+
const topics = normalizeNotificationKeyArray(input.topics, "preference.topic", 191);
|
|
711
|
+
const channels = normalizeNotificationKeyArray(input.channels, "preference.channel", 64);
|
|
283
712
|
const stored = await this.#db
|
|
284
713
|
.select({
|
|
285
714
|
topic: notificationPreferences.topic,
|
|
@@ -287,8 +716,8 @@ export class UnifiedNotificationCore {
|
|
|
287
716
|
enabled: notificationPreferences.enabled,
|
|
288
717
|
})
|
|
289
718
|
.from(notificationPreferences)
|
|
290
|
-
.where(eq(notificationPreferences.recipientId,
|
|
291
|
-
return
|
|
719
|
+
.where(eq(canonicalNotificationKeySql(notificationPreferences.recipientId), recipientId));
|
|
720
|
+
return topics.flatMap((topic) => channels.map((channel) => resolvePreference({
|
|
292
721
|
topic,
|
|
293
722
|
channel,
|
|
294
723
|
stored,
|
|
@@ -297,10 +726,24 @@ export class UnifiedNotificationCore {
|
|
|
297
726
|
})));
|
|
298
727
|
}
|
|
299
728
|
async listInbox(input) {
|
|
729
|
+
input = capturePlainRecord(input, "listInbox input", new Set(["recipientId", "limit", "cursor", "includeArchived"]));
|
|
300
730
|
const { notifications, notificationDeliveries } = this.#schema;
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
731
|
+
const recipientId = normalizeNotificationKey(input.recipientId, "inbox.recipientId", 10_000);
|
|
732
|
+
const limit = normalizeDispatchInteger(input.limit, "inbox.limit", 30, 100);
|
|
733
|
+
const includeArchived = Object.hasOwn(input, "includeArchived")
|
|
734
|
+
? normalizeBoolean(input.includeArchived, "includeArchived")
|
|
735
|
+
: false;
|
|
736
|
+
const cursor = Object.hasOwn(input, "cursor")
|
|
737
|
+
? capturePlainRecord(input.cursor, "inbox.cursor", new Set(["createdAt", "id"]))
|
|
738
|
+
: undefined;
|
|
739
|
+
const cursorCreatedAt = cursor
|
|
740
|
+
? normalizeDate(cursor.createdAt, "inbox.cursor.createdAt")
|
|
741
|
+
: undefined;
|
|
742
|
+
const cursorId = cursor
|
|
743
|
+
? normalizeNotificationKey(cursor.id, "inbox.cursor.id", 191)
|
|
744
|
+
: undefined;
|
|
745
|
+
const cursorFilter = cursorCreatedAt && cursorId
|
|
746
|
+
? or(lt(notifications.createdAt, cursorCreatedAt), and(eq(notifications.createdAt, cursorCreatedAt), lt(notifications.id, cursorId)))
|
|
304
747
|
: undefined;
|
|
305
748
|
const rows = await this.#db
|
|
306
749
|
.select({
|
|
@@ -320,7 +763,7 @@ export class UnifiedNotificationCore {
|
|
|
320
763
|
})
|
|
321
764
|
.from(notifications)
|
|
322
765
|
.innerJoin(notificationDeliveries, eq(notificationDeliveries.notificationId, notifications.id))
|
|
323
|
-
.where(and(eq(notifications.recipientId,
|
|
766
|
+
.where(and(eq(notifications.recipientId, recipientId), isNull(notifications.canceledAt), eq(notificationDeliveries.channel, BUILT_IN_CHANNELS.IN_APP), eq(notificationDeliveries.status, "sent"), includeArchived ? undefined : isNull(notifications.archivedAt), cursorFilter))
|
|
324
767
|
.orderBy(desc(notifications.createdAt), desc(notifications.id))
|
|
325
768
|
.limit(limit + 1);
|
|
326
769
|
const hasMore = rows.length > limit;
|
|
@@ -336,49 +779,73 @@ export class UnifiedNotificationCore {
|
|
|
336
779
|
}
|
|
337
780
|
async unreadCount(recipientId) {
|
|
338
781
|
const { notifications, notificationDeliveries } = this.#schema;
|
|
782
|
+
const normalizedRecipientId = normalizeNotificationKey(recipientId, "inbox.recipientId", 10_000);
|
|
339
783
|
const [row] = await this.#db
|
|
340
784
|
.select({ value: count() })
|
|
341
785
|
.from(notifications)
|
|
342
786
|
.innerJoin(notificationDeliveries, eq(notificationDeliveries.notificationId, notifications.id))
|
|
343
|
-
.where(and(eq(notifications.recipientId,
|
|
787
|
+
.where(and(eq(notifications.recipientId, normalizedRecipientId), isNull(notifications.readAt), isNull(notifications.archivedAt), isNull(notifications.canceledAt), eq(notificationDeliveries.channel, BUILT_IN_CHANNELS.IN_APP), eq(notificationDeliveries.status, "sent")));
|
|
344
788
|
return row?.value ?? 0;
|
|
345
789
|
}
|
|
346
790
|
async markRead(input) {
|
|
791
|
+
input = capturePlainRecord(input, "markRead input", new Set(["notificationId", "recipientId", "read"]));
|
|
347
792
|
const { notifications } = this.#schema;
|
|
793
|
+
const read = Object.hasOwn(input, "read")
|
|
794
|
+
? normalizeBoolean(input.read, "read")
|
|
795
|
+
: true;
|
|
796
|
+
const notificationId = normalizeNotificationKey(input.notificationId, "notificationId", 191);
|
|
797
|
+
const recipientId = normalizeNotificationKey(input.recipientId, "inbox.recipientId", 10_000);
|
|
798
|
+
const now = this.#currentTime();
|
|
348
799
|
const updated = await this.#db
|
|
349
800
|
.update(notifications)
|
|
350
801
|
.set({
|
|
351
|
-
readAt:
|
|
352
|
-
updatedAt:
|
|
802
|
+
readAt: read ? now : null,
|
|
803
|
+
updatedAt: now,
|
|
353
804
|
})
|
|
354
|
-
.where(and(eq(notifications.id,
|
|
805
|
+
.where(and(eq(notifications.id, notificationId), eq(notifications.recipientId, recipientId)))
|
|
355
806
|
.returning({ id: notifications.id });
|
|
356
807
|
return updated.length > 0;
|
|
357
808
|
}
|
|
358
809
|
async archive(input) {
|
|
810
|
+
input = capturePlainRecord(input, "archive input", new Set(["notificationId", "recipientId", "archived"]));
|
|
359
811
|
const { notifications } = this.#schema;
|
|
812
|
+
const archived = Object.hasOwn(input, "archived")
|
|
813
|
+
? normalizeBoolean(input.archived, "archived")
|
|
814
|
+
: true;
|
|
815
|
+
const notificationId = normalizeNotificationKey(input.notificationId, "notificationId", 191);
|
|
816
|
+
const recipientId = normalizeNotificationKey(input.recipientId, "inbox.recipientId", 10_000);
|
|
817
|
+
const now = this.#currentTime();
|
|
360
818
|
const updated = await this.#db
|
|
361
819
|
.update(notifications)
|
|
362
820
|
.set({
|
|
363
|
-
archivedAt:
|
|
364
|
-
updatedAt:
|
|
821
|
+
archivedAt: archived ? now : null,
|
|
822
|
+
updatedAt: now,
|
|
365
823
|
})
|
|
366
|
-
.where(and(eq(notifications.id,
|
|
824
|
+
.where(and(eq(notifications.id, notificationId), eq(notifications.recipientId, recipientId)))
|
|
367
825
|
.returning({ id: notifications.id });
|
|
368
826
|
return updated.length > 0;
|
|
369
827
|
}
|
|
370
828
|
async cancel(notificationId) {
|
|
371
829
|
const { notifications, notificationDeliveries } = this.#schema;
|
|
830
|
+
const normalizedNotificationId = normalizeNotificationKey(notificationId, "notificationId", 191);
|
|
831
|
+
const now = this.#currentTime();
|
|
372
832
|
return this.#db.transaction(async (tx) => {
|
|
373
|
-
const now = this.#now();
|
|
374
833
|
const updated = await tx
|
|
375
834
|
.update(notifications)
|
|
376
835
|
.set({ canceledAt: now, updatedAt: now })
|
|
377
|
-
.where(and(eq(notifications.id,
|
|
836
|
+
.where(and(eq(notifications.id, normalizedNotificationId), isNull(notifications.canceledAt)))
|
|
378
837
|
.returning({ id: notifications.id });
|
|
379
838
|
if (updated.length === 0) {
|
|
380
839
|
return false;
|
|
381
840
|
}
|
|
841
|
+
await tx
|
|
842
|
+
.update(notificationDeliveries)
|
|
843
|
+
.set({
|
|
844
|
+
status: "canceled",
|
|
845
|
+
nextAttemptAt: null,
|
|
846
|
+
updatedAt: now,
|
|
847
|
+
})
|
|
848
|
+
.where(and(eq(notificationDeliveries.notificationId, normalizedNotificationId), eq(notificationDeliveries.status, "processing")));
|
|
382
849
|
await tx
|
|
383
850
|
.update(notificationDeliveries)
|
|
384
851
|
.set({
|
|
@@ -388,41 +855,68 @@ export class UnifiedNotificationCore {
|
|
|
388
855
|
lockedBy: null,
|
|
389
856
|
updatedAt: now,
|
|
390
857
|
})
|
|
391
|
-
.where(and(eq(notificationDeliveries.notificationId,
|
|
392
|
-
"pending",
|
|
393
|
-
"retrying",
|
|
394
|
-
"processing",
|
|
395
|
-
])));
|
|
858
|
+
.where(and(eq(notificationDeliveries.notificationId, normalizedNotificationId), inArray(notificationDeliveries.status, ["pending", "retrying"])));
|
|
396
859
|
return true;
|
|
397
860
|
});
|
|
398
861
|
}
|
|
399
862
|
async requeueDelivery(input) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
863
|
+
input = capturePlainRecord(input, "requeueDelivery input", new Set(["deliveryId", "resetAttempts", "maxAttempts"]));
|
|
864
|
+
const deliveryId = normalizeNotificationKey(input.deliveryId, "deliveryId", 191);
|
|
865
|
+
const resetAttempts = Object.hasOwn(input, "resetAttempts")
|
|
866
|
+
? normalizeBoolean(input.resetAttempts, "resetAttempts")
|
|
867
|
+
: false;
|
|
868
|
+
const configuredMaxAttempts = input.maxAttempts === undefined
|
|
869
|
+
? undefined
|
|
870
|
+
: normalizeMaxAttempts(input.maxAttempts, "maxAttempts");
|
|
871
|
+
const now = this.#currentTime();
|
|
872
|
+
const { notifications, notificationDeliveries } = this.#schema;
|
|
873
|
+
return this.#db.transaction(async (tx) => {
|
|
874
|
+
const [delivery] = await tx
|
|
875
|
+
.select({
|
|
876
|
+
attempts: notificationDeliveries.attempts,
|
|
877
|
+
maxAttempts: notificationDeliveries.maxAttempts,
|
|
878
|
+
})
|
|
879
|
+
.from(notificationDeliveries)
|
|
880
|
+
.innerJoin(notifications, eq(notificationDeliveries.notificationId, notifications.id))
|
|
881
|
+
.where(and(eq(notificationDeliveries.id, deliveryId), eq(notificationDeliveries.status, "failed"), isNull(notifications.canceledAt)))
|
|
882
|
+
.limit(1)
|
|
883
|
+
.for("update");
|
|
884
|
+
if (!delivery) {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
const attempts = resetAttempts ? 0 : delivery.attempts;
|
|
888
|
+
const maxAttempts = configuredMaxAttempts ?? delivery.maxAttempts;
|
|
889
|
+
if (attempts >= maxAttempts) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
const [updated] = await tx
|
|
893
|
+
.update(notificationDeliveries)
|
|
894
|
+
.set({
|
|
895
|
+
status: "retrying",
|
|
896
|
+
attempts,
|
|
897
|
+
maxAttempts,
|
|
898
|
+
nextAttemptAt: now,
|
|
899
|
+
lockedAt: null,
|
|
900
|
+
lockedBy: null,
|
|
901
|
+
sentAt: null,
|
|
902
|
+
providerMessageId: null,
|
|
903
|
+
metadata: null,
|
|
904
|
+
errorCode: null,
|
|
905
|
+
errorMessage: null,
|
|
906
|
+
updatedAt: now,
|
|
907
|
+
})
|
|
908
|
+
.where(and(eq(notificationDeliveries.id, deliveryId), eq(notificationDeliveries.status, "failed"), eq(notificationDeliveries.attempts, delivery.attempts), eq(notificationDeliveries.maxAttempts, delivery.maxAttempts)))
|
|
909
|
+
.returning({ id: notificationDeliveries.id });
|
|
910
|
+
return updated !== undefined;
|
|
911
|
+
});
|
|
420
912
|
}
|
|
421
913
|
async #dispatch(options) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
914
|
+
options = capturePlainRecord(options, "dispatch options", new Set(["limit", "workerId", "concurrency", "notificationIds"]));
|
|
915
|
+
assertDispatchExecutor(this.#db);
|
|
916
|
+
const limit = normalizeDispatchInteger(options.limit, "limit", 100, MAX_DISPATCH_LIMIT);
|
|
917
|
+
const concurrency = normalizeDispatchInteger(options.concurrency, "concurrency", 10, MAX_DISPATCH_CONCURRENCY);
|
|
918
|
+
const workerId = normalizeNotificationKey(options.workerId ??
|
|
919
|
+
`unf-${process.pid}-${crypto.randomUUID().slice(0, 8)}`, "workerId", 154);
|
|
426
920
|
const claimed = await this.#claimDue({
|
|
427
921
|
limit,
|
|
428
922
|
workerId,
|
|
@@ -438,7 +932,7 @@ export class UnifiedNotificationCore {
|
|
|
438
932
|
nextIndex += 1;
|
|
439
933
|
const item = claimed[index];
|
|
440
934
|
if (item) {
|
|
441
|
-
results.push(await this.#deliver(item
|
|
935
|
+
results.push(await this.#deliver(item));
|
|
442
936
|
}
|
|
443
937
|
}
|
|
444
938
|
});
|
|
@@ -447,9 +941,28 @@ export class UnifiedNotificationCore {
|
|
|
447
941
|
}
|
|
448
942
|
async #claimDue(input) {
|
|
449
943
|
const { notifications, notificationDeliveries } = this.#schema;
|
|
450
|
-
const now = this.#
|
|
944
|
+
const now = this.#currentTime();
|
|
451
945
|
const staleBefore = new Date(now.getTime() - this.#lockTimeoutMs);
|
|
452
946
|
return this.#db.transaction(async (tx) => {
|
|
947
|
+
await tx
|
|
948
|
+
.update(notificationDeliveries)
|
|
949
|
+
.set({
|
|
950
|
+
status: "failed",
|
|
951
|
+
nextAttemptAt: null,
|
|
952
|
+
lockedAt: null,
|
|
953
|
+
lockedBy: null,
|
|
954
|
+
errorCode: "max_attempts_exhausted",
|
|
955
|
+
errorMessage: "Maximum delivery attempts exhausted",
|
|
956
|
+
updatedAt: now,
|
|
957
|
+
})
|
|
958
|
+
.where(and(input.notificationIds?.length
|
|
959
|
+
? inArray(notificationDeliveries.notificationId, [
|
|
960
|
+
...input.notificationIds,
|
|
961
|
+
])
|
|
962
|
+
: undefined, or(sql `${notificationDeliveries.attempts} >= ${notificationDeliveries.maxAttempts}`, sql `${notificationDeliveries.attempts} >= ${MAX_ATTEMPTS}`), or(and(inArray(notificationDeliveries.status, [
|
|
963
|
+
"pending",
|
|
964
|
+
"retrying",
|
|
965
|
+
]), lte(notificationDeliveries.nextAttemptAt, now)), and(eq(notificationDeliveries.status, "processing"), or(isNull(notificationDeliveries.lockedAt), lt(notificationDeliveries.lockedAt, staleBefore))))));
|
|
453
966
|
const due = await tx
|
|
454
967
|
.select({
|
|
455
968
|
deliveryId: notificationDeliveries.id,
|
|
@@ -470,7 +983,7 @@ export class UnifiedNotificationCore {
|
|
|
470
983
|
.innerJoin(notifications, eq(notificationDeliveries.notificationId, notifications.id))
|
|
471
984
|
.where(and(isNull(notifications.canceledAt), lte(notifications.scheduledFor, now), input.notificationIds?.length
|
|
472
985
|
? inArray(notifications.id, [...input.notificationIds])
|
|
473
|
-
: undefined, or(and(inArray(notificationDeliveries.status, [
|
|
986
|
+
: undefined, sql `${notificationDeliveries.attempts} < ${notificationDeliveries.maxAttempts}`, sql `${notificationDeliveries.attempts} < ${MAX_ATTEMPTS}`, or(and(inArray(notificationDeliveries.status, [
|
|
474
987
|
"pending",
|
|
475
988
|
"retrying",
|
|
476
989
|
]), lte(notificationDeliveries.nextAttemptAt, now)), and(eq(notificationDeliveries.status, "processing"), or(isNull(notificationDeliveries.lockedAt), lt(notificationDeliveries.lockedAt, staleBefore))))))
|
|
@@ -480,40 +993,59 @@ export class UnifiedNotificationCore {
|
|
|
480
993
|
if (due.length === 0) {
|
|
481
994
|
return [];
|
|
482
995
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
.
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
996
|
+
const claimed = [];
|
|
997
|
+
for (const item of due) {
|
|
998
|
+
const claimToken = `${input.workerId}:${crypto.randomUUID()}`;
|
|
999
|
+
const [updated] = await tx
|
|
1000
|
+
.update(notificationDeliveries)
|
|
1001
|
+
.set({
|
|
1002
|
+
status: "processing",
|
|
1003
|
+
attempts: sql `${notificationDeliveries.attempts} + 1`,
|
|
1004
|
+
lastAttemptAt: now,
|
|
1005
|
+
lockedAt: now,
|
|
1006
|
+
lockedBy: claimToken,
|
|
1007
|
+
updatedAt: now,
|
|
1008
|
+
})
|
|
1009
|
+
.where(and(eq(notificationDeliveries.id, item.deliveryId), sql `${notificationDeliveries.attempts} < ${notificationDeliveries.maxAttempts}`, sql `${notificationDeliveries.attempts} < ${MAX_ATTEMPTS}`))
|
|
1010
|
+
.returning({
|
|
1011
|
+
attempts: notificationDeliveries.attempts,
|
|
1012
|
+
maxAttempts: notificationDeliveries.maxAttempts,
|
|
1013
|
+
});
|
|
1014
|
+
if (!updated) {
|
|
1015
|
+
throw new Error(`delivery ${item.deliveryId} could not be claimed`);
|
|
1016
|
+
}
|
|
1017
|
+
claimed.push({
|
|
1018
|
+
...item,
|
|
1019
|
+
attempts: updated.attempts,
|
|
1020
|
+
maxAttempts: updated.maxAttempts,
|
|
1021
|
+
channel: item.channel,
|
|
1022
|
+
payload: item.payload,
|
|
1023
|
+
claimToken,
|
|
1024
|
+
claimedAt: now,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return claimed;
|
|
497
1028
|
});
|
|
498
1029
|
}
|
|
499
|
-
async #deliver(delivery
|
|
1030
|
+
async #deliver(delivery) {
|
|
500
1031
|
const { notificationDeliveries } = this.#schema;
|
|
501
|
-
const attempt = delivery.attempts
|
|
1032
|
+
const attempt = delivery.attempts;
|
|
502
1033
|
const adapter = delivery.channel === BUILT_IN_CHANNELS.IN_APP
|
|
503
1034
|
? async () => undefined
|
|
504
|
-
: this.#adapters
|
|
1035
|
+
: getOwnRecordValue(this.#adapters, delivery.channel);
|
|
1036
|
+
let rawResult;
|
|
505
1037
|
try {
|
|
506
1038
|
if (!adapter) {
|
|
507
1039
|
throw new NotificationDeliveryError("adapter_not_configured", `No adapter is configured for channel "${delivery.channel}"`, { retryable: false });
|
|
508
1040
|
}
|
|
509
|
-
|
|
1041
|
+
rawResult = await adapter({
|
|
510
1042
|
notificationId: delivery.notificationId,
|
|
511
1043
|
deliveryId: delivery.deliveryId,
|
|
512
1044
|
recipientId: delivery.recipientId,
|
|
513
1045
|
topic: delivery.topic,
|
|
514
1046
|
channel: delivery.channel,
|
|
515
1047
|
payload: delivery.payload,
|
|
516
|
-
channelData: delivery.payload.channelData
|
|
1048
|
+
channelData: getOwnRecordValue(delivery.payload.channelData, delivery.channel),
|
|
517
1049
|
attempt,
|
|
518
1050
|
maxAttempts: delivery.maxAttempts,
|
|
519
1051
|
createdAt: delivery.createdAt,
|
|
@@ -522,92 +1054,230 @@ export class UnifiedNotificationCore {
|
|
|
522
1054
|
entityType: delivery.entityType,
|
|
523
1055
|
entityId: delivery.entityId,
|
|
524
1056
|
});
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
return this.#finalizeDeliveryFailure(delivery, error);
|
|
1060
|
+
}
|
|
1061
|
+
const result = normalizeAdapterResult(rawResult);
|
|
1062
|
+
const now = delivery.claimedAt;
|
|
1063
|
+
const [persisted] = await this.#db
|
|
1064
|
+
.update(notificationDeliveries)
|
|
1065
|
+
.set({
|
|
1066
|
+
status: "sent",
|
|
1067
|
+
nextAttemptAt: null,
|
|
1068
|
+
lockedAt: null,
|
|
1069
|
+
lockedBy: null,
|
|
1070
|
+
sentAt: now,
|
|
1071
|
+
providerMessageId: result.providerMessageId,
|
|
1072
|
+
metadata: result.metadata,
|
|
1073
|
+
errorCode: result.diagnostic ? "invalid_adapter_result" : null,
|
|
1074
|
+
errorMessage: result.diagnostic,
|
|
1075
|
+
updatedAt: now,
|
|
1076
|
+
})
|
|
1077
|
+
.where(and(eq(notificationDeliveries.id, delivery.deliveryId), eq(notificationDeliveries.lockedBy, delivery.claimToken), inArray(notificationDeliveries.status, ["processing", "canceled"])))
|
|
1078
|
+
.returning({
|
|
1079
|
+
status: notificationDeliveries.status,
|
|
1080
|
+
attempts: notificationDeliveries.attempts,
|
|
1081
|
+
errorCode: notificationDeliveries.errorCode,
|
|
1082
|
+
errorMessage: notificationDeliveries.errorMessage,
|
|
1083
|
+
});
|
|
1084
|
+
const durable = await this.#durableDispatchResult(delivery, persisted);
|
|
1085
|
+
if (result.diagnostic) {
|
|
1086
|
+
this.#safeLog("error", "Notification adapter result was discarded", {
|
|
544
1087
|
deliveryId: delivery.deliveryId,
|
|
545
1088
|
notificationId: delivery.notificationId,
|
|
546
|
-
recipientId: delivery.recipientId,
|
|
547
1089
|
channel: delivery.channel,
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1090
|
+
attempt: durable.attempt,
|
|
1091
|
+
durableStatus: durable.status,
|
|
1092
|
+
code: "invalid_adapter_result",
|
|
1093
|
+
message: result.diagnostic,
|
|
1094
|
+
});
|
|
551
1095
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1096
|
+
return durable;
|
|
1097
|
+
}
|
|
1098
|
+
async #finalizeDeliveryFailure(delivery, error) {
|
|
1099
|
+
const { notificationDeliveries } = this.#schema;
|
|
1100
|
+
const details = errorDetails(error);
|
|
1101
|
+
const shouldRetry = details.retryable && delivery.attempts < delivery.maxAttempts;
|
|
1102
|
+
const now = delivery.claimedAt;
|
|
1103
|
+
const delay = shouldRetry
|
|
1104
|
+
? await this.#safeRetryDelayMs(delivery.attempts, error, delivery, now)
|
|
1105
|
+
: 0;
|
|
1106
|
+
const status = shouldRetry ? "retrying" : "failed";
|
|
1107
|
+
const nextAttemptAt = shouldRetry
|
|
1108
|
+
? new Date(now.getTime() + delay)
|
|
1109
|
+
: null;
|
|
1110
|
+
const [persisted] = await this.#db
|
|
1111
|
+
.update(notificationDeliveries)
|
|
1112
|
+
.set({
|
|
1113
|
+
status: sql `case when ${notificationDeliveries.status} = 'canceled' then 'canceled' else ${status} end`,
|
|
1114
|
+
nextAttemptAt: sql `case when ${notificationDeliveries.status} = 'canceled' then null::timestamptz else ${nextAttemptAt}::timestamptz end`,
|
|
1115
|
+
lockedAt: null,
|
|
1116
|
+
lockedBy: null,
|
|
1117
|
+
errorCode: details.code,
|
|
1118
|
+
errorMessage: details.message,
|
|
1119
|
+
updatedAt: now,
|
|
1120
|
+
})
|
|
1121
|
+
.where(and(eq(notificationDeliveries.id, delivery.deliveryId), eq(notificationDeliveries.lockedBy, delivery.claimToken), inArray(notificationDeliveries.status, ["processing", "canceled"])))
|
|
1122
|
+
.returning({
|
|
1123
|
+
status: notificationDeliveries.status,
|
|
1124
|
+
attempts: notificationDeliveries.attempts,
|
|
1125
|
+
errorCode: notificationDeliveries.errorCode,
|
|
1126
|
+
errorMessage: notificationDeliveries.errorMessage,
|
|
1127
|
+
});
|
|
1128
|
+
const durable = await this.#durableDispatchResult(delivery, persisted);
|
|
1129
|
+
this.#safeLog("error", "Notification delivery failed", {
|
|
1130
|
+
deliveryId: delivery.deliveryId,
|
|
1131
|
+
notificationId: delivery.notificationId,
|
|
1132
|
+
channel: delivery.channel,
|
|
1133
|
+
attempt: durable.attempt,
|
|
1134
|
+
retrying: durable.status === "retrying",
|
|
1135
|
+
durableStatus: durable.status,
|
|
1136
|
+
code: details.code,
|
|
1137
|
+
message: details.message,
|
|
1138
|
+
});
|
|
1139
|
+
return durable;
|
|
1140
|
+
}
|
|
1141
|
+
async #safeRetryDelayMs(attempt, error, delivery, now) {
|
|
1142
|
+
let timeout;
|
|
1143
|
+
const startedAt = MONOTONIC_NOW();
|
|
1144
|
+
const timedOut = () => MONOTONIC_NOW() - startedAt >= RETRY_DELAY_POLICY_TIMEOUT_MS;
|
|
1145
|
+
const timeoutObservation = () => ({
|
|
1146
|
+
status: "rejected",
|
|
1147
|
+
reason: new NotificationConfigurationError(`retryDelayMs did not settle within ${RETRY_DELAY_POLICY_TIMEOUT_MS}ms`),
|
|
1148
|
+
});
|
|
1149
|
+
let settleDeadline = () => { };
|
|
1150
|
+
const deadlineSettlement = new NATIVE_PROMISE((resolve) => {
|
|
1151
|
+
settleDeadline = resolve;
|
|
1152
|
+
});
|
|
1153
|
+
timeout = NATIVE_SET_TIMEOUT(() => settleDeadline(timeoutObservation()), RETRY_DELAY_POLICY_TIMEOUT_MS);
|
|
1154
|
+
try {
|
|
1155
|
+
let policyResult;
|
|
1156
|
+
try {
|
|
1157
|
+
policyResult = this.#retryDelayMs(attempt, error);
|
|
1158
|
+
}
|
|
1159
|
+
catch (policyError) {
|
|
1160
|
+
throw policyError;
|
|
1161
|
+
}
|
|
1162
|
+
let delay = policyResult;
|
|
1163
|
+
if (typeof policyResult !== "number") {
|
|
1164
|
+
if (!isExactNativePromise(policyResult)) {
|
|
1165
|
+
throw new NotificationConfigurationError("retryDelayMs must return a number or an exact native Promise<number>");
|
|
1166
|
+
}
|
|
1167
|
+
attachNativePromiseObservation(policyResult, (observation) => {
|
|
1168
|
+
settleDeadline(timedOut() ? timeoutObservation() : observation);
|
|
1169
|
+
});
|
|
1170
|
+
if (timedOut()) {
|
|
1171
|
+
throw new NotificationConfigurationError(`retryDelayMs did not settle within ${RETRY_DELAY_POLICY_TIMEOUT_MS}ms`);
|
|
1172
|
+
}
|
|
1173
|
+
const observation = await deadlineSettlement;
|
|
1174
|
+
if (observation.status === "rejected") {
|
|
1175
|
+
throw observation.reason;
|
|
1176
|
+
}
|
|
1177
|
+
delay = observation.value;
|
|
1178
|
+
}
|
|
1179
|
+
else if (timedOut()) {
|
|
1180
|
+
throw new NotificationConfigurationError(`retryDelayMs did not settle within ${RETRY_DELAY_POLICY_TIMEOUT_MS}ms`);
|
|
1181
|
+
}
|
|
1182
|
+
const nextAttemptAt = typeof delay === "number" ? new Date(now.getTime() + delay) : null;
|
|
1183
|
+
if (typeof delay !== "number" ||
|
|
1184
|
+
!Number.isFinite(delay) ||
|
|
1185
|
+
delay < 0 ||
|
|
1186
|
+
!nextAttemptAt ||
|
|
1187
|
+
Number.isNaN(nextAttemptAt.getTime())) {
|
|
1188
|
+
throw new NotificationConfigurationError("retryDelayMs must return a finite non-negative number");
|
|
1189
|
+
}
|
|
1190
|
+
return delay;
|
|
1191
|
+
}
|
|
1192
|
+
catch (policyError) {
|
|
1193
|
+
const details = errorDetails(policyError);
|
|
1194
|
+
this.#safeLog("error", "Notification retryDelayMs policy failed", {
|
|
575
1195
|
deliveryId: delivery.deliveryId,
|
|
576
1196
|
notificationId: delivery.notificationId,
|
|
577
1197
|
channel: delivery.channel,
|
|
578
1198
|
attempt,
|
|
579
|
-
retrying: shouldRetry,
|
|
580
1199
|
code: details.code,
|
|
581
1200
|
message: details.message,
|
|
1201
|
+
fallbackDelayMs: defaultRetryDelayMs(attempt),
|
|
582
1202
|
});
|
|
583
|
-
return
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1203
|
+
return defaultRetryDelayMs(attempt);
|
|
1204
|
+
}
|
|
1205
|
+
finally {
|
|
1206
|
+
if (timeout !== undefined) {
|
|
1207
|
+
NATIVE_CLEAR_TIMEOUT(timeout);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
#safeLog(level, message, context) {
|
|
1212
|
+
try {
|
|
1213
|
+
const callback = this.#logger?.[level];
|
|
1214
|
+
if (!callback) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const returned = callback(message, context);
|
|
1218
|
+
if (isExactNativePromise(returned)) {
|
|
1219
|
+
attachNativePromiseObservation(returned, () => { });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// Observability is deliberately isolated from durable delivery state.
|
|
593
1224
|
}
|
|
594
1225
|
}
|
|
1226
|
+
async #durableDispatchResult(delivery, persisted) {
|
|
1227
|
+
let state = persisted;
|
|
1228
|
+
if (!state) {
|
|
1229
|
+
const { notificationDeliveries } = this.#schema;
|
|
1230
|
+
[state] = await this.#db
|
|
1231
|
+
.select({
|
|
1232
|
+
status: notificationDeliveries.status,
|
|
1233
|
+
attempts: notificationDeliveries.attempts,
|
|
1234
|
+
errorCode: notificationDeliveries.errorCode,
|
|
1235
|
+
errorMessage: notificationDeliveries.errorMessage,
|
|
1236
|
+
})
|
|
1237
|
+
.from(notificationDeliveries)
|
|
1238
|
+
.where(eq(notificationDeliveries.id, delivery.deliveryId))
|
|
1239
|
+
.limit(1);
|
|
1240
|
+
}
|
|
1241
|
+
if (!state) {
|
|
1242
|
+
throw new Error(`delivery ${delivery.deliveryId} no longer has a durable state`);
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
deliveryId: delivery.deliveryId,
|
|
1246
|
+
notificationId: delivery.notificationId,
|
|
1247
|
+
recipientId: delivery.recipientId,
|
|
1248
|
+
channel: delivery.channel,
|
|
1249
|
+
status: state.status,
|
|
1250
|
+
attempt: state.attempts,
|
|
1251
|
+
...(state.errorCode ? { errorCode: state.errorCode } : {}),
|
|
1252
|
+
...(state.errorMessage
|
|
1253
|
+
? { errorMessage: capDiagnosticMessage(state.errorMessage) }
|
|
1254
|
+
: {}),
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
595
1257
|
#validateChannels(channels) {
|
|
596
1258
|
for (const channel of channels) {
|
|
597
1259
|
assertBounded(channel, "channel", 64);
|
|
598
1260
|
if (channel !== BUILT_IN_CHANNELS.IN_APP &&
|
|
599
|
-
!this.#adapters
|
|
1261
|
+
!Object.hasOwn(this.#adapters, channel)) {
|
|
600
1262
|
throw new NotificationConfigurationError(`No adapter is configured for channel "${channel}"`);
|
|
601
1263
|
}
|
|
602
1264
|
}
|
|
603
1265
|
}
|
|
1266
|
+
#currentTime() {
|
|
1267
|
+
let value;
|
|
1268
|
+
try {
|
|
1269
|
+
value = this.#now();
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
throw new NotificationConfigurationError("now must return a valid Date");
|
|
1273
|
+
}
|
|
1274
|
+
return normalizeDate(value, "now result");
|
|
1275
|
+
}
|
|
604
1276
|
#maxAttemptsFor(channel, configured) {
|
|
605
1277
|
const value = typeof configured === "number"
|
|
606
1278
|
? configured
|
|
607
|
-
: configured
|
|
608
|
-
|
|
609
|
-
throw new NotificationConfigurationError(`maxAttempts for channel "${channel}" must be a positive integer`);
|
|
610
|
-
}
|
|
1279
|
+
: getOwnRecordValue(configured, channel) ??
|
|
1280
|
+
this.#defaultMaxAttempts;
|
|
611
1281
|
return value;
|
|
612
1282
|
}
|
|
613
1283
|
}
|