@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.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.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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
547
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
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
|
|
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";
|