@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 +278 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3716 -98
- package/dist/index.d.ts +3716 -98
- package/dist/index.js +278 -27
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
|
259
|
-
|
|
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
|
-
|
|
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
|
|
618
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
|
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";
|