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/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 { resolvePreference } from "./preferences.js";
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
- function uniqueNonEmpty(values, label) {
17
- const normalized = [...new Set(values.map((value) => value.trim()))].filter(Boolean);
18
- if (normalized.length === 0) {
19
- throw new NotificationConfigurationError(`${label} must not be empty`);
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 errorDetails(error) {
32
- if (error instanceof NotificationDeliveryError) {
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
- code: error.code,
35
- message: error.message,
36
- retryable: error.retryable,
269
+ providerMessageId: providerMessageId ?? null,
270
+ metadata: normalizedMetadata ?? null,
271
+ diagnostic: null,
37
272
  };
38
273
  }
39
- if (error instanceof Error) {
274
+ catch (error) {
40
275
  return {
41
- code: "delivery_failed",
42
- message: error.message,
43
- retryable: true,
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: String(error),
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
- this.#schema = config.schema ?? defaultNotificationSchema;
67
- this.#adapters = config.adapters ?? {};
68
- this.#defaultChannels =
69
- config.defaultChannels ?? [BUILT_IN_CHANNELS.IN_APP];
70
- this.#preferenceDefaults = config.preferenceDefaults ?? {};
71
- this.#defaultPreferenceEnabled = config.defaultPreferenceEnabled ?? true;
72
- this.#defaultMaxAttempts = config.defaultMaxAttempts ?? 5;
73
- this.#retryDelayMs = config.retryDelayMs ?? defaultRetryDelayMs;
74
- this.#lockTimeoutMs = config.lockTimeoutMs ?? 5 * 60_000;
75
- this.#now = config.now ?? (() => new Date());
76
- this.#logger = config.logger;
77
- if (this.#defaultMaxAttempts < 1) {
78
- throw new NotificationConfigurationError("defaultMaxAttempts must be at least 1");
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
- if (this.#lockTimeoutMs < 1_000) {
81
- throw new NotificationConfigurationError("lockTimeoutMs must be at least 1000");
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
- const recipientIds = uniqueNonEmpty(input.recipientIds, "recipientIds");
87
- const topic = input.topic.trim();
88
- assertBounded(topic, "topic", 191);
89
- assertBounded(input.payload.title, "payload.title", 10_000);
90
- assertBounded(input.payload.body, "payload.body", 100_000);
91
- const channels = uniqueNonEmpty(input.channels ?? this.#defaultChannels, "channels");
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 scheduledFor = input.scheduledFor ?? this.#now();
97
- const preferencePolicy = input.preferencePolicy ?? "respect";
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 published = [];
101
- for (const recipientId of recipientIds) {
102
- let notification;
103
- let created = true;
104
- const values = {
105
- recipientId,
106
- topic,
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
- if (input.idempotencyKey) {
119
- const [inserted] = await tx
120
- .insert(notifications)
121
- .values(values)
122
- .onConflictDoNothing({
123
- target: [
124
- notifications.recipientId,
125
- notifications.idempotencyKey,
126
- ],
127
- where: isNotNull(notifications.idempotencyKey),
128
- })
129
- .returning();
130
- notification = inserted;
131
- if (!notification) {
132
- created = false;
133
- [notification] = await tx
134
- .select()
135
- .from(notifications)
136
- .where(and(eq(notifications.recipientId, recipientId), eq(notifications.idempotencyKey, input.idempotencyKey)))
137
- .limit(1);
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
- if (!notification) {
147
- throw new Error("notification insert did not return a row");
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
- if (created) {
150
- const stored = preferencePolicy === "respect"
151
- ? await tx
152
- .select({
153
- topic: notificationPreferences.topic,
154
- channel: notificationPreferences.channel,
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
- status: enabled ? "pending" : "skipped",
174
- maxAttempts: this.#maxAttemptsFor(channel, input.maxAttempts),
175
- nextAttemptAt: enabled ? scheduledFor : null,
176
- errorCode: enabled ? null : "preference_disabled",
177
- errorMessage: enabled
178
- ? null
179
- : "Delivery disabled by recipient preference",
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
- const deliveryRows = await tx
184
- .select({
185
- deliveryId: notificationDeliveries.id,
186
- channel: notificationDeliveries.channel,
187
- status: notificationDeliveries.status,
188
- })
189
- .from(notificationDeliveries)
190
- .where(eq(notificationDeliveries.notificationId, notification.id));
191
- published.push({
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: deliveryRows.map((delivery) => ({
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
- ...(options.workerId ? { workerId: options.workerId } : {}),
210
- ...(options.concurrency
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 topic = preference.topic.trim();
223
- const channel = preference.channel.trim();
224
- assertBounded(topic, "preference.topic", 191);
225
- assertBounded(channel, "preference.channel", 64);
226
- assertBounded(preference.recipientId, "preference.recipientId", 10_000);
227
- const now = this.#now();
228
- await this.#db
229
- .insert(notificationPreferences)
230
- .values({
231
- recipientId: preference.recipientId,
232
- topic,
233
- channel,
234
- enabled: preference.enabled,
235
- updatedAt: now,
236
- })
237
- .onConflictDoUpdate({
238
- target: [
239
- notificationPreferences.recipientId,
240
- notificationPreferences.topic,
241
- notificationPreferences.channel,
242
- ],
243
- set: { enabled: preference.enabled, updatedAt: now },
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 now = this.#now();
250
- for (const preference of preferences) {
251
- assertBounded(preference.topic.trim(), "preference.topic", 191);
252
- assertBounded(preference.channel.trim(), "preference.channel", 64);
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: preference.topic.trim(),
258
- channel: preference.channel.trim(),
259
- enabled: preference.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: preference.enabled, updatedAt: now },
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, input.recipientId), eq(notificationPreferences.topic, input.topic), eq(notificationPreferences.channel, input.channel)))
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, input.recipientId));
291
- return input.topics.flatMap((topic) => input.channels.map((channel) => resolvePreference({
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 limit = Math.min(Math.max(input.limit ?? 30, 1), 100);
302
- const cursorFilter = input.cursor
303
- ? or(lt(notifications.createdAt, input.cursor.createdAt), and(eq(notifications.createdAt, input.cursor.createdAt), lt(notifications.id, input.cursor.id)))
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, input.recipientId), isNull(notifications.canceledAt), eq(notificationDeliveries.channel, BUILT_IN_CHANNELS.IN_APP), eq(notificationDeliveries.status, "sent"), input.includeArchived ? undefined : isNull(notifications.archivedAt), cursorFilter))
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, recipientId), isNull(notifications.readAt), isNull(notifications.archivedAt), isNull(notifications.canceledAt), eq(notificationDeliveries.channel, BUILT_IN_CHANNELS.IN_APP), eq(notificationDeliveries.status, "sent")));
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: input.read === false ? null : this.#now(),
352
- updatedAt: this.#now(),
802
+ readAt: read ? now : null,
803
+ updatedAt: now,
353
804
  })
354
- .where(and(eq(notifications.id, input.notificationId), eq(notifications.recipientId, input.recipientId)))
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: input.archived === false ? null : this.#now(),
364
- updatedAt: this.#now(),
821
+ archivedAt: archived ? now : null,
822
+ updatedAt: now,
365
823
  })
366
- .where(and(eq(notifications.id, input.notificationId), eq(notifications.recipientId, input.recipientId)))
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, notificationId), isNull(notifications.canceledAt)))
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, notificationId), inArray(notificationDeliveries.status, [
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
- if (input.maxAttempts !== undefined && input.maxAttempts < 1) {
401
- throw new NotificationConfigurationError("maxAttempts must be at least 1");
402
- }
403
- const { notificationDeliveries } = this.#schema;
404
- const updated = await this.#db
405
- .update(notificationDeliveries)
406
- .set({
407
- status: "retrying",
408
- attempts: input.resetAttempts ? 0 : undefined,
409
- maxAttempts: input.maxAttempts,
410
- nextAttemptAt: this.#now(),
411
- lockedAt: null,
412
- lockedBy: null,
413
- errorCode: null,
414
- errorMessage: null,
415
- updatedAt: this.#now(),
416
- })
417
- .where(eq(notificationDeliveries.id, input.deliveryId))
418
- .returning({ id: notificationDeliveries.id });
419
- return updated.length > 0;
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
- const limit = Math.min(Math.max(options.limit ?? 100, 1), 1_000);
423
- const concurrency = Math.min(Math.max(options.concurrency ?? 10, 1), 100);
424
- const workerId = options.workerId ??
425
- `unf-${process.pid}-${crypto.randomUUID().slice(0, 8)}`;
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, workerId));
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.#now();
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
- await tx
484
- .update(notificationDeliveries)
485
- .set({
486
- status: "processing",
487
- lockedAt: now,
488
- lockedBy: input.workerId,
489
- updatedAt: now,
490
- })
491
- .where(inArray(notificationDeliveries.id, due.map((item) => item.deliveryId)));
492
- return due.map((item) => ({
493
- ...item,
494
- channel: item.channel,
495
- payload: item.payload,
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, workerId) {
1030
+ async #deliver(delivery) {
500
1031
  const { notificationDeliveries } = this.#schema;
501
- const attempt = delivery.attempts + 1;
1032
+ const attempt = delivery.attempts;
502
1033
  const adapter = delivery.channel === BUILT_IN_CHANNELS.IN_APP
503
1034
  ? async () => undefined
504
- : this.#adapters[delivery.channel];
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
- const result = await adapter({
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?.[delivery.channel],
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
- const now = this.#now();
526
- await this.#db
527
- .update(notificationDeliveries)
528
- .set({
529
- status: "sent",
530
- attempts: attempt,
531
- lastAttemptAt: now,
532
- nextAttemptAt: null,
533
- lockedAt: null,
534
- lockedBy: null,
535
- sentAt: now,
536
- providerMessageId: result?.providerMessageId ?? null,
537
- metadata: result?.metadata ?? null,
538
- errorCode: null,
539
- errorMessage: null,
540
- updatedAt: now,
541
- })
542
- .where(and(eq(notificationDeliveries.id, delivery.deliveryId), eq(notificationDeliveries.lockedBy, workerId), eq(notificationDeliveries.status, "processing")));
543
- return {
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
- status: "sent",
549
- attempt,
550
- };
1090
+ attempt: durable.attempt,
1091
+ durableStatus: durable.status,
1092
+ code: "invalid_adapter_result",
1093
+ message: result.diagnostic,
1094
+ });
551
1095
  }
552
- catch (error) {
553
- const details = errorDetails(error);
554
- const shouldRetry = details.retryable && attempt < delivery.maxAttempts;
555
- const now = this.#now();
556
- const delay = shouldRetry ? this.#retryDelayMs(attempt, error) : 0;
557
- const status = shouldRetry ? "retrying" : "failed";
558
- await this.#db
559
- .update(notificationDeliveries)
560
- .set({
561
- status,
562
- attempts: attempt,
563
- lastAttemptAt: now,
564
- nextAttemptAt: shouldRetry
565
- ? new Date(now.getTime() + Math.max(delay, 0))
566
- : null,
567
- lockedAt: null,
568
- lockedBy: null,
569
- errorCode: details.code,
570
- errorMessage: details.message,
571
- updatedAt: now,
572
- })
573
- .where(and(eq(notificationDeliveries.id, delivery.deliveryId), eq(notificationDeliveries.lockedBy, workerId), eq(notificationDeliveries.status, "processing")));
574
- this.#logger?.error?.("Notification delivery failed", {
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
- deliveryId: delivery.deliveryId,
585
- notificationId: delivery.notificationId,
586
- recipientId: delivery.recipientId,
587
- channel: delivery.channel,
588
- status,
589
- attempt,
590
- errorCode: details.code,
591
- errorMessage: details.message,
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[channel]) {
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?.[channel] ?? this.#defaultMaxAttempts;
608
- if (!Number.isInteger(value) || value < 1) {
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
  }