@objectstack/service-messaging 7.8.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.cjs CHANGED
@@ -242,7 +242,8 @@ var PreferenceResolver = class {
242
242
  const channel = String(r.channel ?? WILDCARD);
243
243
  index.set(`${user}|${topic}|${channel}`, {
244
244
  enabled: asBool(r.enabled),
245
- quietHours: parseQuietHours(r.quiet_hours)
245
+ quietHours: parseQuietHours(r.quiet_hours),
246
+ digest: parseDigest(r.digest)
246
247
  });
247
248
  }
248
249
  const nowMs = ctx.now ?? Date.now();
@@ -254,11 +255,22 @@ var PreferenceResolver = class {
254
255
  );
255
256
  if (accepted.length === 0) continue;
256
257
  let notBefore;
258
+ let digest;
257
259
  if (!critical) {
258
- const qh = this.resolveQuietHours(index, recipient, ctx.topic);
259
- notBefore = qh ? quietHoursDeferral(qh, nowMs) : void 0;
260
+ const cadence = this.resolveDigest(index, recipient, ctx.topic);
261
+ if (cadence) {
262
+ const d = digestDeferral(cadence, nowMs, this.resolveQuietHours(index, recipient, ctx.topic)?.tz);
263
+ notBefore = d.notBefore;
264
+ digest = { window: d.window };
265
+ } else {
266
+ const qh = this.resolveQuietHours(index, recipient, ctx.topic);
267
+ notBefore = qh ? quietHoursDeferral(qh, nowMs) : void 0;
268
+ }
260
269
  }
261
- targets.push(notBefore != null ? { recipient, channels: accepted, notBefore } : { recipient, channels: accepted });
270
+ const target = { recipient, channels: accepted };
271
+ if (notBefore != null) target.notBefore = notBefore;
272
+ if (digest) target.digest = digest;
273
+ targets.push(target);
262
274
  }
263
275
  return targets;
264
276
  }
@@ -301,6 +313,20 @@ var PreferenceResolver = class {
301
313
  }
302
314
  return void 0;
303
315
  }
316
+ /**
317
+ * Resolve a recipient's digest cadence. Like quiet-hours, declared on a
318
+ * channel-wildcard row (`(user, *, *)` or `(user, topic, *)`) — batching is a
319
+ * per-person, channel-agnostic setting. Most-specific user/topic wins.
320
+ */
321
+ resolveDigest(index, user, topic) {
322
+ for (const u of [user, WILDCARD]) {
323
+ for (const t of [topic, WILDCARD]) {
324
+ const hit = index.get(`${u}|${t}|${WILDCARD}`);
325
+ if (hit?.digest) return hit.digest;
326
+ }
327
+ }
328
+ return void 0;
329
+ }
304
330
  };
305
331
  function asBool(v) {
306
332
  return v === true || v === 1 || v === "1" || v === "true";
@@ -318,6 +344,21 @@ function parseQuietHours(v) {
318
344
  if (o.start == null || o.end == null) return void 0;
319
345
  return { tz: o.tz, start: String(o.start), end: String(o.end) };
320
346
  }
347
+ function parseDigest(v) {
348
+ return v === "daily" || v === "weekly" ? v : void 0;
349
+ }
350
+ function digestDeferral(cadence, nowMs, tz) {
351
+ const zone = tz ?? "UTC";
352
+ const { minutesOfDay, isoWeekday, date } = wallClockInTz(nowMs, zone);
353
+ if (cadence === "daily") {
354
+ const minsToMidnight = 1440 - minutesOfDay;
355
+ return { notBefore: nowMs + minsToMidnight * 6e4, window: date };
356
+ }
357
+ const daysToNextMonday = (8 - isoWeekday) % 7 || 7;
358
+ const minsToTarget = daysToNextMonday * 1440 - minutesOfDay;
359
+ const mondayMs = nowMs - (isoWeekday - 1) * 864e5;
360
+ return { notBefore: nowMs + minsToTarget * 6e4, window: wallClockInTz(mondayMs, zone).date };
361
+ }
321
362
  function quietHoursDeferral(quietHours, nowMs) {
322
363
  const start = parseHHMM(quietHours.start);
323
364
  const end = parseHHMM(quietHours.end);
@@ -357,6 +398,38 @@ function minutesOfDayInTz(nowMs, tz) {
357
398
  return d.getUTCHours() * 60 + d.getUTCMinutes();
358
399
  }
359
400
  }
401
+ function wallClockInTz(nowMs, tz) {
402
+ try {
403
+ const parts = new Intl.DateTimeFormat("en-US", {
404
+ hour12: false,
405
+ year: "numeric",
406
+ month: "2-digit",
407
+ day: "2-digit",
408
+ hour: "2-digit",
409
+ minute: "2-digit",
410
+ timeZone: tz
411
+ }).formatToParts(new Date(nowMs));
412
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? "0";
413
+ const y = Number(get("year"));
414
+ const mo = Number(get("month"));
415
+ const d = Number(get("day"));
416
+ const hour = Number(get("hour")) % 24;
417
+ const minute = Number(get("minute"));
418
+ const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay();
419
+ return { minutesOfDay: hour * 60 + minute, isoWeekday: dow === 0 ? 7 : dow, date: `${y}-${pad(mo)}-${pad(d)}` };
420
+ } catch {
421
+ const dt = new Date(nowMs);
422
+ const dow = dt.getUTCDay();
423
+ return {
424
+ minutesOfDay: dt.getUTCHours() * 60 + dt.getUTCMinutes(),
425
+ isoWeekday: dow === 0 ? 7 : dow,
426
+ date: dt.toISOString().slice(0, 10)
427
+ };
428
+ }
429
+ }
430
+ function pad(n) {
431
+ return String(n).padStart(2, "0");
432
+ }
360
433
  function msg2(err) {
361
434
  return err?.message ?? String(err);
362
435
  }
@@ -436,6 +509,13 @@ function createInboxChannel(opts) {
436
509
  // src/messaging-service.ts
437
510
  var NOTIFICATION_EVENT_OBJECT = "sys_notification";
438
511
  var READ_RECEIPT_STATES = /* @__PURE__ */ new Set(["read", "clicked", "dismissed"]);
512
+ function isUniqueViolation(err) {
513
+ const e = err;
514
+ if (!e) return false;
515
+ if (e.code === "23505" || e.code === "ER_DUP_ENTRY" || e.code === "SQLITE_CONSTRAINT_UNIQUE") return true;
516
+ const msg3 = String(e.message ?? "").toLowerCase();
517
+ return msg3.includes("unique constraint failed") || msg3.includes("duplicate key") || msg3.includes("duplicate entry");
518
+ }
439
519
  var MessagingService = class {
440
520
  constructor(ctx) {
441
521
  this.ctx = ctx;
@@ -614,13 +694,15 @@ var MessagingService = class {
614
694
  }
615
695
  /** Upsert a `read` receipt for one notification; returns 1 when it persisted. */
616
696
  async upsertReadReceipt(data, userId, notificationId, at) {
617
- const existing = await data.findOne(RECEIPT_OBJECT, {
618
- where: { notification_id: notificationId, user_id: userId, channel: "inbox" },
619
- fields: ["id"]
620
- });
621
- if (existing?.id) {
697
+ const where = { notification_id: notificationId, user_id: userId, channel: "inbox" };
698
+ const flipToRead = async () => {
699
+ const existing = await data.findOne(RECEIPT_OBJECT, { where, fields: ["id"] });
700
+ if (!existing?.id) return false;
622
701
  await data.update(RECEIPT_OBJECT, { state: "read", at }, { where: { id: existing.id } });
623
- } else {
702
+ return true;
703
+ };
704
+ if (await flipToRead()) return 1;
705
+ try {
624
706
  await data.insert(RECEIPT_OBJECT, {
625
707
  notification_id: notificationId,
626
708
  delivery_id: null,
@@ -630,8 +712,11 @@ var MessagingService = class {
630
712
  at,
631
713
  created_at: at
632
714
  });
715
+ return 1;
716
+ } catch (err) {
717
+ if (isUniqueViolation(err) && await flipToRead()) return 1;
718
+ throw err;
633
719
  }
634
- return 1;
635
720
  }
636
721
  /**
637
722
  * The single notification ingress. Writes the L2 event, resolves the
@@ -720,7 +805,7 @@ var MessagingService = class {
720
805
  actionUrl: actionUrlFor(input, payload)
721
806
  };
722
807
  const deliveries = [];
723
- for (const { recipient, channels, notBefore } of targets) {
808
+ for (const { recipient, channels, notBefore, digest } of targets) {
724
809
  for (const channel of channels) {
725
810
  try {
726
811
  const id = await outbox.enqueue({
@@ -730,9 +815,12 @@ var MessagingService = class {
730
815
  topic: input.topic,
731
816
  payload: deliveryPayload,
732
817
  organizationId: input.organizationId,
733
- // Quiet-hours deferral (P3b): the dispatcher won't claim
734
- // this row until `notBefore`. Absent ⇒ immediate.
735
- notBefore
818
+ // Quiet-hours / digest deferral (P3b): the dispatcher won't
819
+ // claim this row until `notBefore`. Absent ⇒ immediate.
820
+ notBefore,
821
+ // P3b-2: tag batched deliveries so the dispatcher collapses
822
+ // a `(recipient, channel, window)` group into one message.
823
+ digestKey: digest ? `${recipient}|${channel}|${digest.window}` : void 0
736
824
  });
737
825
  deliveries.push({ channel, recipient, ok: true, externalId: id });
738
826
  } catch (err) {
@@ -902,12 +990,15 @@ var SqlNotificationOutbox = class {
902
990
  topic: input.topic ?? null,
903
991
  payload: input.payload ?? {},
904
992
  organization_id: input.organizationId ?? null,
905
- partition_key: hashPartition(input.notificationId, this.partitionCount),
993
+ // Digest rows partition by their group key so a window's rows land in
994
+ // one partition and a single node collapses them under its lock.
995
+ partition_key: hashPartition(input.digestKey ?? input.notificationId, this.partitionCount),
906
996
  status: "pending",
907
997
  attempts: 0,
908
- // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
909
- // whose next_attempt_at is in the future.
998
+ // Deferred dispatch (quiet-hours / digest, P3): claim() skips pending
999
+ // rows whose next_attempt_at is in the future.
910
1000
  next_attempt_at: input.notBefore ?? null,
1001
+ digest_key: input.digestKey ?? null,
911
1002
  created_at: now,
912
1003
  updated_at: now
913
1004
  };
@@ -931,6 +1022,7 @@ var SqlNotificationOutbox = class {
931
1022
  const candidates = await this.engine.find(this.objectName, {
932
1023
  where: {
933
1024
  status: "pending",
1025
+ digest_key: null,
934
1026
  ...partitionFilter,
935
1027
  $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
936
1028
  },
@@ -949,6 +1041,36 @@ var SqlNotificationOutbox = class {
949
1041
  });
950
1042
  return claimed.map((r) => this.toRecord(r));
951
1043
  }
1044
+ async claimDigest(opts) {
1045
+ const now = opts.now ?? Date.now();
1046
+ await this.engine.update(
1047
+ this.objectName,
1048
+ { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
1049
+ { where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
1050
+ );
1051
+ const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
1052
+ const candidates = await this.engine.find(this.objectName, {
1053
+ where: {
1054
+ status: "pending",
1055
+ digest_key: { $ne: null },
1056
+ ...partitionFilter,
1057
+ $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
1058
+ },
1059
+ fields: ["id"],
1060
+ limit: 1e4
1061
+ });
1062
+ if (!candidates.length) return [];
1063
+ const ids = candidates.map((c) => c.id);
1064
+ await this.engine.update(
1065
+ this.objectName,
1066
+ { status: "in_flight", claimed_by: opts.nodeId, claimed_at: now, updated_at: now },
1067
+ { where: { id: { $in: ids }, status: "pending" }, multi: true }
1068
+ );
1069
+ const claimed = await this.engine.find(this.objectName, {
1070
+ where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
1071
+ });
1072
+ return claimed.map((r) => this.toRecord(r));
1073
+ }
952
1074
  async ack(id, result) {
953
1075
  const current = await this.engine.findOne(this.objectName, {
954
1076
  where: { id },
@@ -1019,6 +1141,7 @@ var SqlNotificationOutbox = class {
1019
1141
  nextAttemptAt: r.next_attempt_at ?? void 0,
1020
1142
  lastAttemptedAt: r.last_attempted_at ?? void 0,
1021
1143
  error: r.error ?? void 0,
1144
+ digestKey: r.digest_key ?? void 0,
1022
1145
  createdAt: r.created_at,
1023
1146
  updatedAt: r.updated_at
1024
1147
  };
@@ -1355,6 +1478,24 @@ var SqlHttpOutbox = class {
1355
1478
  }
1356
1479
  };
1357
1480
 
1481
+ // src/digest-render.ts
1482
+ function renderDigest(rows) {
1483
+ const items = rows.map((r) => {
1484
+ const p = r.payload ?? {};
1485
+ return {
1486
+ notificationId: r.notificationId,
1487
+ title: typeof p.title === "string" && p.title ? p.title : r.topic ?? "Notification",
1488
+ body: typeof p.body === "string" && p.body ? p.body : void 0,
1489
+ topic: r.topic,
1490
+ actionUrl: typeof p.actionUrl === "string" && p.actionUrl ? p.actionUrl : void 0
1491
+ };
1492
+ });
1493
+ const count = items.length;
1494
+ const title = count === 1 ? items[0].title : `You have ${count} notifications`;
1495
+ const body = items.map((it) => `\u2022 ${it.title}`).join("\n");
1496
+ return { title, body, severity: "info", count, items };
1497
+ }
1498
+
1358
1499
  // src/dispatcher.ts
1359
1500
  var SINGLE_NODE_CLUSTER = {
1360
1501
  lock: {
@@ -1445,16 +1586,73 @@ var NotificationDispatcher = class {
1445
1586
  partition: { index, count: this.opts.partitionCount },
1446
1587
  claimTtlMs: this.opts.claimTtlMs
1447
1588
  });
1448
- if (claimed.length === 0) return;
1449
- await handle.renew?.(this.opts.lockTtlMs);
1450
- for (const row of claimed) {
1451
- if (handle.isHeld && !handle.isHeld()) break;
1452
- await this.processRow(row);
1589
+ if (claimed.length > 0) {
1590
+ await handle.renew?.(this.opts.lockTtlMs);
1591
+ for (const row of claimed) {
1592
+ if (handle.isHeld && !handle.isHeld()) break;
1593
+ await this.processRow(row);
1594
+ }
1595
+ }
1596
+ const digestRows = await this.opts.outbox.claimDigest({
1597
+ nodeId: this.opts.nodeId,
1598
+ limit: this.opts.batchSize,
1599
+ partition: { index, count: this.opts.partitionCount },
1600
+ claimTtlMs: this.opts.claimTtlMs
1601
+ });
1602
+ if (digestRows.length > 0) {
1603
+ await handle.renew?.(this.opts.lockTtlMs);
1604
+ for (const group of groupByDigestKey(digestRows)) {
1605
+ if (handle.isHeld && !handle.isHeld()) break;
1606
+ await this.processDigestGroup(group);
1607
+ }
1453
1608
  }
1454
1609
  } finally {
1455
1610
  await handle.release();
1456
1611
  }
1457
1612
  }
1613
+ /**
1614
+ * Send one collapsed message for a `(recipient, channel, window)` group and
1615
+ * ack every row in it with that one outcome. On failure the whole group
1616
+ * re-defers together (each row keeps its own backoff via its `attempts`).
1617
+ */
1618
+ async processDigestGroup(rows) {
1619
+ const channelName = rows[0].channel;
1620
+ const recipient = rows[0].recipientId;
1621
+ const channel = this.opts.channels.getChannel(channelName);
1622
+ if (!channel) {
1623
+ for (const row of rows) {
1624
+ await this.opts.outbox.ack(row.id, { success: false, error: `channel '${channelName}' not registered`, dead: true });
1625
+ this.opts.onAttempt?.(row, false);
1626
+ }
1627
+ return;
1628
+ }
1629
+ const digest = renderDigest(rows);
1630
+ const notification = {
1631
+ notificationId: rows[0].notificationId,
1632
+ // representative event id
1633
+ organizationId: rows[0].organizationId,
1634
+ topic: rows[0].topic,
1635
+ title: digest.title,
1636
+ body: digest.body,
1637
+ severity: "info",
1638
+ recipients: [recipient],
1639
+ channels: [channelName],
1640
+ payload: { digest: true, count: digest.count, items: digest.items }
1641
+ };
1642
+ let result;
1643
+ try {
1644
+ result = await channel.send(this.opts.channelContext, { notification, channel: channelName, recipient });
1645
+ } catch (err) {
1646
+ result = { ok: false, error: err?.message ?? String(err) };
1647
+ }
1648
+ const errorClass = !result.ok && channel.classifyError ? channel.classifyError(result.error) : void 0;
1649
+ const now = this.opts.now?.() ?? Date.now();
1650
+ for (const row of rows) {
1651
+ const ack = classifyDeliveryAttempt(result, errorClass, row.attempts, now, this.opts.rng);
1652
+ await this.opts.outbox.ack(row.id, ack);
1653
+ this.opts.onAttempt?.(row, result.ok);
1654
+ }
1655
+ }
1458
1656
  async processRow(row) {
1459
1657
  const channel = this.opts.channels.getChannel(row.channel);
1460
1658
  if (!channel) {
@@ -1496,6 +1694,19 @@ var NotificationDispatcher = class {
1496
1694
  this.opts.onAttempt?.(row, result.ok);
1497
1695
  }
1498
1696
  };
1697
+ function groupByDigestKey(rows) {
1698
+ const groups = /* @__PURE__ */ new Map();
1699
+ for (const r of rows) {
1700
+ const key = r.digestKey ?? r.id;
1701
+ let g = groups.get(key);
1702
+ if (!g) {
1703
+ g = [];
1704
+ groups.set(key, g);
1705
+ }
1706
+ g.push(r);
1707
+ }
1708
+ return [...groups.values()];
1709
+ }
1499
1710
  function stableNodeOffset(nodeId, partitionCount) {
1500
1711
  let h = 0;
1501
1712
  for (let i = 0; i < nodeId.length; i++) h = h * 31 + nodeId.charCodeAt(i) | 0;
@@ -2094,6 +2305,16 @@ var NotificationDelivery = import_data4.ObjectSchema.create({
2094
2305
  recipient_id: import_data4.Field.text({ label: "Recipient User", required: true, searchable: true }),
2095
2306
  channel: import_data4.Field.text({ label: "Channel", required: true }),
2096
2307
  topic: import_data4.Field.text({ label: "Topic", searchable: true }),
2308
+ // P3b-2 digest: when the recipient's preference batches this channel
2309
+ // (`digest: daily|weekly`), the row enqueues deferred to the next window
2310
+ // and carries `${recipient}|${channel}|${window}` here. The dispatcher's
2311
+ // digest pass collapses all same-key rows into ONE rendered message at
2312
+ // window time. Null ⇒ an ordinary (immediate / quiet-hours) delivery.
2313
+ digest_key: import_data4.Field.text({
2314
+ label: "Digest Key",
2315
+ searchable: true,
2316
+ description: "recipient|channel|window grouping key for batched (digest) deliveries; null for normal sends."
2317
+ }),
2097
2318
  payload: import_data4.Field.json({
2098
2319
  label: "Payload",
2099
2320
  description: "Snapshot of the rendered notification content for dispatch."
@@ -2120,7 +2341,9 @@ var NotificationDelivery = import_data4.ObjectSchema.create({
2120
2341
  { fields: ["status", "partition_key", "next_attempt_at"] },
2121
2342
  // Stale-in_flight reaper.
2122
2343
  { fields: ["status", "claimed_at"] },
2123
- { fields: ["notification_id"] }
2344
+ { fields: ["notification_id"] },
2345
+ // P3b-2: the digest collapse pass — claim due batched rows by group.
2346
+ { fields: ["digest_key", "status", "next_attempt_at"] }
2124
2347
  ]
2125
2348
  });
2126
2349
 
@@ -2465,12 +2688,15 @@ var MemoryNotificationOutbox = class {
2465
2688
  topic: input.topic,
2466
2689
  payload: input.payload ?? {},
2467
2690
  organizationId: input.organizationId,
2468
- partitionKey: hashPartition(input.notificationId, this.partitionCount),
2691
+ // Digest rows partition by their group key so a window's rows land in
2692
+ // one partition and a single node collapses them under its lock.
2693
+ partitionKey: hashPartition(input.digestKey ?? input.notificationId, this.partitionCount),
2469
2694
  status: "pending",
2470
2695
  attempts: 0,
2471
- // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
2472
- // whose nextAttemptAt is still in the future.
2696
+ // Deferred dispatch (quiet-hours / digest, P3): claim() skips pending
2697
+ // rows whose nextAttemptAt is still in the future.
2473
2698
  nextAttemptAt: input.notBefore,
2699
+ digestKey: input.digestKey,
2474
2700
  createdAt: now,
2475
2701
  updatedAt: now
2476
2702
  });
@@ -2490,6 +2716,31 @@ var MemoryNotificationOutbox = class {
2490
2716
  for (const r of this.rows.values()) {
2491
2717
  if (out.length >= opts.limit) break;
2492
2718
  if (r.status !== "pending") continue;
2719
+ if (r.digestKey != null) continue;
2720
+ if (opts.partition && r.partitionKey !== opts.partition.index) continue;
2721
+ if (r.nextAttemptAt != null && r.nextAttemptAt > now) continue;
2722
+ r.status = "in_flight";
2723
+ r.claimedBy = opts.nodeId;
2724
+ r.claimedAt = now;
2725
+ r.updatedAt = now;
2726
+ out.push({ ...r });
2727
+ }
2728
+ return out;
2729
+ }
2730
+ async claimDigest(opts) {
2731
+ const now = opts.now ?? this.clock();
2732
+ for (const r of this.rows.values()) {
2733
+ if (r.status === "in_flight" && (r.claimedAt ?? 0) < now - opts.claimTtlMs) {
2734
+ r.status = "pending";
2735
+ r.claimedBy = void 0;
2736
+ r.claimedAt = void 0;
2737
+ r.updatedAt = now;
2738
+ }
2739
+ }
2740
+ const out = [];
2741
+ for (const r of this.rows.values()) {
2742
+ if (r.status !== "pending") continue;
2743
+ if (r.digestKey == null) continue;
2493
2744
  if (opts.partition && r.partitionKey !== opts.partition.index) continue;
2494
2745
  if (r.nextAttemptAt != null && r.nextAttemptAt > now) continue;
2495
2746
  r.status = "in_flight";