@objectstack/service-messaging 7.9.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -171,7 +171,8 @@ var PreferenceResolver = class {
171
171
  const channel = String(r.channel ?? WILDCARD);
172
172
  index.set(`${user}|${topic}|${channel}`, {
173
173
  enabled: asBool(r.enabled),
174
- quietHours: parseQuietHours(r.quiet_hours)
174
+ quietHours: parseQuietHours(r.quiet_hours),
175
+ digest: parseDigest(r.digest)
175
176
  });
176
177
  }
177
178
  const nowMs = ctx.now ?? Date.now();
@@ -183,11 +184,22 @@ var PreferenceResolver = class {
183
184
  );
184
185
  if (accepted.length === 0) continue;
185
186
  let notBefore;
187
+ let digest;
186
188
  if (!critical) {
187
- const qh = this.resolveQuietHours(index, recipient, ctx.topic);
188
- notBefore = qh ? quietHoursDeferral(qh, nowMs) : void 0;
189
+ const cadence = this.resolveDigest(index, recipient, ctx.topic);
190
+ if (cadence) {
191
+ const d = digestDeferral(cadence, nowMs, this.resolveQuietHours(index, recipient, ctx.topic)?.tz);
192
+ notBefore = d.notBefore;
193
+ digest = { window: d.window };
194
+ } else {
195
+ const qh = this.resolveQuietHours(index, recipient, ctx.topic);
196
+ notBefore = qh ? quietHoursDeferral(qh, nowMs) : void 0;
197
+ }
189
198
  }
190
- targets.push(notBefore != null ? { recipient, channels: accepted, notBefore } : { recipient, channels: accepted });
199
+ const target = { recipient, channels: accepted };
200
+ if (notBefore != null) target.notBefore = notBefore;
201
+ if (digest) target.digest = digest;
202
+ targets.push(target);
191
203
  }
192
204
  return targets;
193
205
  }
@@ -230,6 +242,20 @@ var PreferenceResolver = class {
230
242
  }
231
243
  return void 0;
232
244
  }
245
+ /**
246
+ * Resolve a recipient's digest cadence. Like quiet-hours, declared on a
247
+ * channel-wildcard row (`(user, *, *)` or `(user, topic, *)`) — batching is a
248
+ * per-person, channel-agnostic setting. Most-specific user/topic wins.
249
+ */
250
+ resolveDigest(index, user, topic) {
251
+ for (const u of [user, WILDCARD]) {
252
+ for (const t of [topic, WILDCARD]) {
253
+ const hit = index.get(`${u}|${t}|${WILDCARD}`);
254
+ if (hit?.digest) return hit.digest;
255
+ }
256
+ }
257
+ return void 0;
258
+ }
233
259
  };
234
260
  function asBool(v) {
235
261
  return v === true || v === 1 || v === "1" || v === "true";
@@ -247,6 +273,21 @@ function parseQuietHours(v) {
247
273
  if (o.start == null || o.end == null) return void 0;
248
274
  return { tz: o.tz, start: String(o.start), end: String(o.end) };
249
275
  }
276
+ function parseDigest(v) {
277
+ return v === "daily" || v === "weekly" ? v : void 0;
278
+ }
279
+ function digestDeferral(cadence, nowMs, tz) {
280
+ const zone = tz ?? "UTC";
281
+ const { minutesOfDay, isoWeekday, date } = wallClockInTz(nowMs, zone);
282
+ if (cadence === "daily") {
283
+ const minsToMidnight = 1440 - minutesOfDay;
284
+ return { notBefore: nowMs + minsToMidnight * 6e4, window: date };
285
+ }
286
+ const daysToNextMonday = (8 - isoWeekday) % 7 || 7;
287
+ const minsToTarget = daysToNextMonday * 1440 - minutesOfDay;
288
+ const mondayMs = nowMs - (isoWeekday - 1) * 864e5;
289
+ return { notBefore: nowMs + minsToTarget * 6e4, window: wallClockInTz(mondayMs, zone).date };
290
+ }
250
291
  function quietHoursDeferral(quietHours, nowMs) {
251
292
  const start = parseHHMM(quietHours.start);
252
293
  const end = parseHHMM(quietHours.end);
@@ -286,6 +327,38 @@ function minutesOfDayInTz(nowMs, tz) {
286
327
  return d.getUTCHours() * 60 + d.getUTCMinutes();
287
328
  }
288
329
  }
330
+ function wallClockInTz(nowMs, tz) {
331
+ try {
332
+ const parts = new Intl.DateTimeFormat("en-US", {
333
+ hour12: false,
334
+ year: "numeric",
335
+ month: "2-digit",
336
+ day: "2-digit",
337
+ hour: "2-digit",
338
+ minute: "2-digit",
339
+ timeZone: tz
340
+ }).formatToParts(new Date(nowMs));
341
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? "0";
342
+ const y = Number(get("year"));
343
+ const mo = Number(get("month"));
344
+ const d = Number(get("day"));
345
+ const hour = Number(get("hour")) % 24;
346
+ const minute = Number(get("minute"));
347
+ const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay();
348
+ return { minutesOfDay: hour * 60 + minute, isoWeekday: dow === 0 ? 7 : dow, date: `${y}-${pad(mo)}-${pad(d)}` };
349
+ } catch {
350
+ const dt = new Date(nowMs);
351
+ const dow = dt.getUTCDay();
352
+ return {
353
+ minutesOfDay: dt.getUTCHours() * 60 + dt.getUTCMinutes(),
354
+ isoWeekday: dow === 0 ? 7 : dow,
355
+ date: dt.toISOString().slice(0, 10)
356
+ };
357
+ }
358
+ }
359
+ function pad(n) {
360
+ return String(n).padStart(2, "0");
361
+ }
289
362
  function msg2(err) {
290
363
  return err?.message ?? String(err);
291
364
  }
@@ -365,6 +438,13 @@ function createInboxChannel(opts) {
365
438
  // src/messaging-service.ts
366
439
  var NOTIFICATION_EVENT_OBJECT = "sys_notification";
367
440
  var READ_RECEIPT_STATES = /* @__PURE__ */ new Set(["read", "clicked", "dismissed"]);
441
+ function isUniqueViolation(err) {
442
+ const e = err;
443
+ if (!e) return false;
444
+ if (e.code === "23505" || e.code === "ER_DUP_ENTRY" || e.code === "SQLITE_CONSTRAINT_UNIQUE") return true;
445
+ const msg3 = String(e.message ?? "").toLowerCase();
446
+ return msg3.includes("unique constraint failed") || msg3.includes("duplicate key") || msg3.includes("duplicate entry");
447
+ }
368
448
  var MessagingService = class {
369
449
  constructor(ctx) {
370
450
  this.ctx = ctx;
@@ -543,13 +623,15 @@ var MessagingService = class {
543
623
  }
544
624
  /** Upsert a `read` receipt for one notification; returns 1 when it persisted. */
545
625
  async upsertReadReceipt(data, userId, notificationId, at) {
546
- const existing = await data.findOne(RECEIPT_OBJECT, {
547
- where: { notification_id: notificationId, user_id: userId, channel: "inbox" },
548
- fields: ["id"]
549
- });
550
- if (existing?.id) {
626
+ const where = { notification_id: notificationId, user_id: userId, channel: "inbox" };
627
+ const flipToRead = async () => {
628
+ const existing = await data.findOne(RECEIPT_OBJECT, { where, fields: ["id"] });
629
+ if (!existing?.id) return false;
551
630
  await data.update(RECEIPT_OBJECT, { state: "read", at }, { where: { id: existing.id } });
552
- } else {
631
+ return true;
632
+ };
633
+ if (await flipToRead()) return 1;
634
+ try {
553
635
  await data.insert(RECEIPT_OBJECT, {
554
636
  notification_id: notificationId,
555
637
  delivery_id: null,
@@ -559,8 +641,11 @@ var MessagingService = class {
559
641
  at,
560
642
  created_at: at
561
643
  });
644
+ return 1;
645
+ } catch (err) {
646
+ if (isUniqueViolation(err) && await flipToRead()) return 1;
647
+ throw err;
562
648
  }
563
- return 1;
564
649
  }
565
650
  /**
566
651
  * The single notification ingress. Writes the L2 event, resolves the
@@ -649,7 +734,7 @@ var MessagingService = class {
649
734
  actionUrl: actionUrlFor(input, payload)
650
735
  };
651
736
  const deliveries = [];
652
- for (const { recipient, channels, notBefore } of targets) {
737
+ for (const { recipient, channels, notBefore, digest } of targets) {
653
738
  for (const channel of channels) {
654
739
  try {
655
740
  const id = await outbox.enqueue({
@@ -659,9 +744,12 @@ var MessagingService = class {
659
744
  topic: input.topic,
660
745
  payload: deliveryPayload,
661
746
  organizationId: input.organizationId,
662
- // Quiet-hours deferral (P3b): the dispatcher won't claim
663
- // this row until `notBefore`. Absent ⇒ immediate.
664
- notBefore
747
+ // Quiet-hours / digest deferral (P3b): the dispatcher won't
748
+ // claim this row until `notBefore`. Absent ⇒ immediate.
749
+ notBefore,
750
+ // P3b-2: tag batched deliveries so the dispatcher collapses
751
+ // a `(recipient, channel, window)` group into one message.
752
+ digestKey: digest ? `${recipient}|${channel}|${digest.window}` : void 0
665
753
  });
666
754
  deliveries.push({ channel, recipient, ok: true, externalId: id });
667
755
  } catch (err) {
@@ -831,12 +919,15 @@ var SqlNotificationOutbox = class {
831
919
  topic: input.topic ?? null,
832
920
  payload: input.payload ?? {},
833
921
  organization_id: input.organizationId ?? null,
834
- partition_key: hashPartition(input.notificationId, this.partitionCount),
922
+ // Digest rows partition by their group key so a window's rows land in
923
+ // one partition and a single node collapses them under its lock.
924
+ partition_key: hashPartition(input.digestKey ?? input.notificationId, this.partitionCount),
835
925
  status: "pending",
836
926
  attempts: 0,
837
- // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
838
- // whose next_attempt_at is in the future.
927
+ // Deferred dispatch (quiet-hours / digest, P3): claim() skips pending
928
+ // rows whose next_attempt_at is in the future.
839
929
  next_attempt_at: input.notBefore ?? null,
930
+ digest_key: input.digestKey ?? null,
840
931
  created_at: now,
841
932
  updated_at: now
842
933
  };
@@ -860,6 +951,7 @@ var SqlNotificationOutbox = class {
860
951
  const candidates = await this.engine.find(this.objectName, {
861
952
  where: {
862
953
  status: "pending",
954
+ digest_key: null,
863
955
  ...partitionFilter,
864
956
  $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
865
957
  },
@@ -878,6 +970,36 @@ var SqlNotificationOutbox = class {
878
970
  });
879
971
  return claimed.map((r) => this.toRecord(r));
880
972
  }
973
+ async claimDigest(opts) {
974
+ const now = opts.now ?? Date.now();
975
+ await this.engine.update(
976
+ this.objectName,
977
+ { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
978
+ { where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
979
+ );
980
+ const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
981
+ const candidates = await this.engine.find(this.objectName, {
982
+ where: {
983
+ status: "pending",
984
+ digest_key: { $ne: null },
985
+ ...partitionFilter,
986
+ $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
987
+ },
988
+ fields: ["id"],
989
+ limit: 1e4
990
+ });
991
+ if (!candidates.length) return [];
992
+ const ids = candidates.map((c) => c.id);
993
+ await this.engine.update(
994
+ this.objectName,
995
+ { status: "in_flight", claimed_by: opts.nodeId, claimed_at: now, updated_at: now },
996
+ { where: { id: { $in: ids }, status: "pending" }, multi: true }
997
+ );
998
+ const claimed = await this.engine.find(this.objectName, {
999
+ where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
1000
+ });
1001
+ return claimed.map((r) => this.toRecord(r));
1002
+ }
881
1003
  async ack(id, result) {
882
1004
  const current = await this.engine.findOne(this.objectName, {
883
1005
  where: { id },
@@ -948,6 +1070,7 @@ var SqlNotificationOutbox = class {
948
1070
  nextAttemptAt: r.next_attempt_at ?? void 0,
949
1071
  lastAttemptedAt: r.last_attempted_at ?? void 0,
950
1072
  error: r.error ?? void 0,
1073
+ digestKey: r.digest_key ?? void 0,
951
1074
  createdAt: r.created_at,
952
1075
  updatedAt: r.updated_at
953
1076
  };
@@ -1284,6 +1407,24 @@ var SqlHttpOutbox = class {
1284
1407
  }
1285
1408
  };
1286
1409
 
1410
+ // src/digest-render.ts
1411
+ function renderDigest(rows) {
1412
+ const items = rows.map((r) => {
1413
+ const p = r.payload ?? {};
1414
+ return {
1415
+ notificationId: r.notificationId,
1416
+ title: typeof p.title === "string" && p.title ? p.title : r.topic ?? "Notification",
1417
+ body: typeof p.body === "string" && p.body ? p.body : void 0,
1418
+ topic: r.topic,
1419
+ actionUrl: typeof p.actionUrl === "string" && p.actionUrl ? p.actionUrl : void 0
1420
+ };
1421
+ });
1422
+ const count = items.length;
1423
+ const title = count === 1 ? items[0].title : `You have ${count} notifications`;
1424
+ const body = items.map((it) => `\u2022 ${it.title}`).join("\n");
1425
+ return { title, body, severity: "info", count, items };
1426
+ }
1427
+
1287
1428
  // src/dispatcher.ts
1288
1429
  var SINGLE_NODE_CLUSTER = {
1289
1430
  lock: {
@@ -1374,16 +1515,73 @@ var NotificationDispatcher = class {
1374
1515
  partition: { index, count: this.opts.partitionCount },
1375
1516
  claimTtlMs: this.opts.claimTtlMs
1376
1517
  });
1377
- if (claimed.length === 0) return;
1378
- await handle.renew?.(this.opts.lockTtlMs);
1379
- for (const row of claimed) {
1380
- if (handle.isHeld && !handle.isHeld()) break;
1381
- await this.processRow(row);
1518
+ if (claimed.length > 0) {
1519
+ await handle.renew?.(this.opts.lockTtlMs);
1520
+ for (const row of claimed) {
1521
+ if (handle.isHeld && !handle.isHeld()) break;
1522
+ await this.processRow(row);
1523
+ }
1524
+ }
1525
+ const digestRows = await this.opts.outbox.claimDigest({
1526
+ nodeId: this.opts.nodeId,
1527
+ limit: this.opts.batchSize,
1528
+ partition: { index, count: this.opts.partitionCount },
1529
+ claimTtlMs: this.opts.claimTtlMs
1530
+ });
1531
+ if (digestRows.length > 0) {
1532
+ await handle.renew?.(this.opts.lockTtlMs);
1533
+ for (const group of groupByDigestKey(digestRows)) {
1534
+ if (handle.isHeld && !handle.isHeld()) break;
1535
+ await this.processDigestGroup(group);
1536
+ }
1382
1537
  }
1383
1538
  } finally {
1384
1539
  await handle.release();
1385
1540
  }
1386
1541
  }
1542
+ /**
1543
+ * Send one collapsed message for a `(recipient, channel, window)` group and
1544
+ * ack every row in it with that one outcome. On failure the whole group
1545
+ * re-defers together (each row keeps its own backoff via its `attempts`).
1546
+ */
1547
+ async processDigestGroup(rows) {
1548
+ const channelName = rows[0].channel;
1549
+ const recipient = rows[0].recipientId;
1550
+ const channel = this.opts.channels.getChannel(channelName);
1551
+ if (!channel) {
1552
+ for (const row of rows) {
1553
+ await this.opts.outbox.ack(row.id, { success: false, error: `channel '${channelName}' not registered`, dead: true });
1554
+ this.opts.onAttempt?.(row, false);
1555
+ }
1556
+ return;
1557
+ }
1558
+ const digest = renderDigest(rows);
1559
+ const notification = {
1560
+ notificationId: rows[0].notificationId,
1561
+ // representative event id
1562
+ organizationId: rows[0].organizationId,
1563
+ topic: rows[0].topic,
1564
+ title: digest.title,
1565
+ body: digest.body,
1566
+ severity: "info",
1567
+ recipients: [recipient],
1568
+ channels: [channelName],
1569
+ payload: { digest: true, count: digest.count, items: digest.items }
1570
+ };
1571
+ let result;
1572
+ try {
1573
+ result = await channel.send(this.opts.channelContext, { notification, channel: channelName, recipient });
1574
+ } catch (err) {
1575
+ result = { ok: false, error: err?.message ?? String(err) };
1576
+ }
1577
+ const errorClass = !result.ok && channel.classifyError ? channel.classifyError(result.error) : void 0;
1578
+ const now = this.opts.now?.() ?? Date.now();
1579
+ for (const row of rows) {
1580
+ const ack = classifyDeliveryAttempt(result, errorClass, row.attempts, now, this.opts.rng);
1581
+ await this.opts.outbox.ack(row.id, ack);
1582
+ this.opts.onAttempt?.(row, result.ok);
1583
+ }
1584
+ }
1387
1585
  async processRow(row) {
1388
1586
  const channel = this.opts.channels.getChannel(row.channel);
1389
1587
  if (!channel) {
@@ -1425,6 +1623,19 @@ var NotificationDispatcher = class {
1425
1623
  this.opts.onAttempt?.(row, result.ok);
1426
1624
  }
1427
1625
  };
1626
+ function groupByDigestKey(rows) {
1627
+ const groups = /* @__PURE__ */ new Map();
1628
+ for (const r of rows) {
1629
+ const key = r.digestKey ?? r.id;
1630
+ let g = groups.get(key);
1631
+ if (!g) {
1632
+ g = [];
1633
+ groups.set(key, g);
1634
+ }
1635
+ g.push(r);
1636
+ }
1637
+ return [...groups.values()];
1638
+ }
1428
1639
  function stableNodeOffset(nodeId, partitionCount) {
1429
1640
  let h = 0;
1430
1641
  for (let i = 0; i < nodeId.length; i++) h = h * 31 + nodeId.charCodeAt(i) | 0;
@@ -2023,6 +2234,16 @@ var NotificationDelivery = ObjectSchema4.create({
2023
2234
  recipient_id: Field4.text({ label: "Recipient User", required: true, searchable: true }),
2024
2235
  channel: Field4.text({ label: "Channel", required: true }),
2025
2236
  topic: Field4.text({ label: "Topic", searchable: true }),
2237
+ // P3b-2 digest: when the recipient's preference batches this channel
2238
+ // (`digest: daily|weekly`), the row enqueues deferred to the next window
2239
+ // and carries `${recipient}|${channel}|${window}` here. The dispatcher's
2240
+ // digest pass collapses all same-key rows into ONE rendered message at
2241
+ // window time. Null ⇒ an ordinary (immediate / quiet-hours) delivery.
2242
+ digest_key: Field4.text({
2243
+ label: "Digest Key",
2244
+ searchable: true,
2245
+ description: "recipient|channel|window grouping key for batched (digest) deliveries; null for normal sends."
2246
+ }),
2026
2247
  payload: Field4.json({
2027
2248
  label: "Payload",
2028
2249
  description: "Snapshot of the rendered notification content for dispatch."
@@ -2049,7 +2270,9 @@ var NotificationDelivery = ObjectSchema4.create({
2049
2270
  { fields: ["status", "partition_key", "next_attempt_at"] },
2050
2271
  // Stale-in_flight reaper.
2051
2272
  { fields: ["status", "claimed_at"] },
2052
- { fields: ["notification_id"] }
2273
+ { fields: ["notification_id"] },
2274
+ // P3b-2: the digest collapse pass — claim due batched rows by group.
2275
+ { fields: ["digest_key", "status", "next_attempt_at"] }
2053
2276
  ]
2054
2277
  });
2055
2278
 
@@ -2394,12 +2617,15 @@ var MemoryNotificationOutbox = class {
2394
2617
  topic: input.topic,
2395
2618
  payload: input.payload ?? {},
2396
2619
  organizationId: input.organizationId,
2397
- partitionKey: hashPartition(input.notificationId, this.partitionCount),
2620
+ // Digest rows partition by their group key so a window's rows land in
2621
+ // one partition and a single node collapses them under its lock.
2622
+ partitionKey: hashPartition(input.digestKey ?? input.notificationId, this.partitionCount),
2398
2623
  status: "pending",
2399
2624
  attempts: 0,
2400
- // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
2401
- // whose nextAttemptAt is still in the future.
2625
+ // Deferred dispatch (quiet-hours / digest, P3): claim() skips pending
2626
+ // rows whose nextAttemptAt is still in the future.
2402
2627
  nextAttemptAt: input.notBefore,
2628
+ digestKey: input.digestKey,
2403
2629
  createdAt: now,
2404
2630
  updatedAt: now
2405
2631
  });
@@ -2419,6 +2645,31 @@ var MemoryNotificationOutbox = class {
2419
2645
  for (const r of this.rows.values()) {
2420
2646
  if (out.length >= opts.limit) break;
2421
2647
  if (r.status !== "pending") continue;
2648
+ if (r.digestKey != null) continue;
2649
+ if (opts.partition && r.partitionKey !== opts.partition.index) continue;
2650
+ if (r.nextAttemptAt != null && r.nextAttemptAt > now) continue;
2651
+ r.status = "in_flight";
2652
+ r.claimedBy = opts.nodeId;
2653
+ r.claimedAt = now;
2654
+ r.updatedAt = now;
2655
+ out.push({ ...r });
2656
+ }
2657
+ return out;
2658
+ }
2659
+ async claimDigest(opts) {
2660
+ const now = opts.now ?? this.clock();
2661
+ for (const r of this.rows.values()) {
2662
+ if (r.status === "in_flight" && (r.claimedAt ?? 0) < now - opts.claimTtlMs) {
2663
+ r.status = "pending";
2664
+ r.claimedBy = void 0;
2665
+ r.claimedAt = void 0;
2666
+ r.updatedAt = now;
2667
+ }
2668
+ }
2669
+ const out = [];
2670
+ for (const r of this.rows.values()) {
2671
+ if (r.status !== "pending") continue;
2672
+ if (r.digestKey == null) continue;
2422
2673
  if (opts.partition && r.partitionKey !== opts.partition.index) continue;
2423
2674
  if (r.nextAttemptAt != null && r.nextAttemptAt > now) continue;
2424
2675
  r.status = "in_flight";