@objectstack/service-messaging 7.4.1 → 7.6.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 +1062 -184
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5266 -31
- package/dist/index.d.ts +5266 -31
- package/dist/index.js +1050 -183
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/messaging-service-plugin.ts
|
|
2
|
-
import { randomUUID as
|
|
2
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
3
3
|
|
|
4
4
|
// src/recipient-resolver.ts
|
|
5
5
|
function looksLikeEmail(s) {
|
|
@@ -290,8 +290,81 @@ function msg2(err) {
|
|
|
290
290
|
return err?.message ?? String(err);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// src/inbox-channel.ts
|
|
294
|
+
var INBOX_OBJECT = "sys_inbox_message";
|
|
295
|
+
var RECEIPT_OBJECT = "sys_notification_receipt";
|
|
296
|
+
function createInboxChannel(opts) {
|
|
297
|
+
const objectName = opts.objectName ?? INBOX_OBJECT;
|
|
298
|
+
const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT;
|
|
299
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
300
|
+
async function writeDeliveredReceipt(ctx, data, r) {
|
|
301
|
+
if (!r.notificationId) return;
|
|
302
|
+
try {
|
|
303
|
+
await data.insert(receiptObject, {
|
|
304
|
+
notification_id: r.notificationId,
|
|
305
|
+
delivery_id: null,
|
|
306
|
+
user_id: r.userId,
|
|
307
|
+
channel: "inbox",
|
|
308
|
+
state: "delivered",
|
|
309
|
+
at: r.at,
|
|
310
|
+
organization_id: r.organizationId ?? null,
|
|
311
|
+
created_at: r.at
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
ctx.logger.warn(
|
|
315
|
+
`[inbox] delivered receipt write failed for '${r.userId}' (${err.message}); inbox row stands`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
id: "inbox",
|
|
321
|
+
async send(ctx, delivery) {
|
|
322
|
+
const data = opts.getData();
|
|
323
|
+
const n = delivery.notification;
|
|
324
|
+
if (!data) {
|
|
325
|
+
ctx.logger.warn(
|
|
326
|
+
`[inbox] no data engine registered; inbox row for '${delivery.recipient}' not persisted`
|
|
327
|
+
);
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
const userId = delivery.recipient;
|
|
331
|
+
const at = now();
|
|
332
|
+
const row = {
|
|
333
|
+
user_id: userId,
|
|
334
|
+
notification_id: n.notificationId ?? null,
|
|
335
|
+
topic: n.topic,
|
|
336
|
+
title: n.title,
|
|
337
|
+
body_md: n.body,
|
|
338
|
+
severity: n.severity ?? "info",
|
|
339
|
+
action_url: n.actionUrl,
|
|
340
|
+
organization_id: n.organizationId ?? null,
|
|
341
|
+
created_at: at
|
|
342
|
+
};
|
|
343
|
+
let inboxId;
|
|
344
|
+
try {
|
|
345
|
+
const created = await data.insert(objectName, row);
|
|
346
|
+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
|
|
347
|
+
inboxId = id != null ? String(id) : void 0;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return { ok: false, error: `inbox insert failed: ${err.message}` };
|
|
350
|
+
}
|
|
351
|
+
await writeDeliveredReceipt(ctx, data, {
|
|
352
|
+
notificationId: n.notificationId,
|
|
353
|
+
userId,
|
|
354
|
+
organizationId: n.organizationId,
|
|
355
|
+
at
|
|
356
|
+
});
|
|
357
|
+
return { ok: true, externalId: inboxId };
|
|
358
|
+
},
|
|
359
|
+
classifyError(_err) {
|
|
360
|
+
return "retryable";
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
293
365
|
// src/messaging-service.ts
|
|
294
366
|
var NOTIFICATION_EVENT_OBJECT = "sys_notification";
|
|
367
|
+
var READ_RECEIPT_STATES = /* @__PURE__ */ new Set(["read", "clicked", "dismissed"]);
|
|
295
368
|
var MessagingService = class {
|
|
296
369
|
constructor(ctx) {
|
|
297
370
|
this.ctx = ctx;
|
|
@@ -313,6 +386,49 @@ var MessagingService = class {
|
|
|
313
386
|
setOutbox(outbox) {
|
|
314
387
|
this.outbox = outbox;
|
|
315
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Attach the generic outbound-HTTP delivery outbox (ADR-0018 M3). Wired by
|
|
391
|
+
* the plugin at `kernel:ready` once the data engine is resolvable. Once set,
|
|
392
|
+
* {@link enqueueHttp} persists durable rows the {@link HttpDispatcher}
|
|
393
|
+
* drains with retry / dead-letter; the Flow `http` node enqueues through it.
|
|
394
|
+
*/
|
|
395
|
+
setHttpOutbox(outbox) {
|
|
396
|
+
this.httpOutbox = outbox;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Whether durable HTTP delivery is available. Callers (e.g. the `http` node)
|
|
400
|
+
* fall back to a direct, non-durable send when this is `false`.
|
|
401
|
+
*/
|
|
402
|
+
isHttpDeliveryReady() {
|
|
403
|
+
return this.httpOutbox !== void 0;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Enqueue a durable outbound-HTTP delivery (ADR-0018 M3). Returns the row id.
|
|
407
|
+
* Throws if no HTTP outbox is wired — guard with {@link isHttpDeliveryReady}.
|
|
408
|
+
*/
|
|
409
|
+
async enqueueHttp(input) {
|
|
410
|
+
if (!this.httpOutbox) {
|
|
411
|
+
throw new Error("messaging: HTTP delivery outbox not configured (no data engine / reliableDelivery off)");
|
|
412
|
+
}
|
|
413
|
+
return this.httpOutbox.enqueue(input);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Reset a terminal HTTP delivery row back to `pending` so the dispatcher
|
|
417
|
+
* re-sends it (ADR-0018 M3). Backs the webhook redeliver admin endpoint.
|
|
418
|
+
* Throws if no HTTP outbox is wired, or `HttpRedeliverError` for a missing /
|
|
419
|
+
* non-terminal row.
|
|
420
|
+
*/
|
|
421
|
+
async redeliverHttp(id) {
|
|
422
|
+
if (!this.httpOutbox) {
|
|
423
|
+
throw new Error("messaging: HTTP delivery outbox not configured");
|
|
424
|
+
}
|
|
425
|
+
return this.httpOutbox.redeliver(id);
|
|
426
|
+
}
|
|
427
|
+
/** List HTTP delivery rows (admin/tests). Empty when no outbox is wired. */
|
|
428
|
+
async listHttp(filter) {
|
|
429
|
+
if (!this.httpOutbox) return [];
|
|
430
|
+
return this.httpOutbox.list(filter);
|
|
431
|
+
}
|
|
316
432
|
/** Register a channel implementation. A duplicate id warns and replaces. */
|
|
317
433
|
registerChannel(channel) {
|
|
318
434
|
if (this.channels.has(channel.id)) {
|
|
@@ -333,6 +449,119 @@ var MessagingService = class {
|
|
|
333
449
|
getRegisteredChannels() {
|
|
334
450
|
return [...this.channels.keys()];
|
|
335
451
|
}
|
|
452
|
+
/* ------------------------------------------------------------------ */
|
|
453
|
+
/* Inbox read API (ADR-0030) — backs /api/v1/notifications */
|
|
454
|
+
/* */
|
|
455
|
+
/* The REST notification surface reads the L5 `sys_inbox_message` */
|
|
456
|
+
/* materialization joined with the `sys_notification_receipt` */
|
|
457
|
+
/* read-state spine — NOT the re-modeled `sys_notification` L2 event */
|
|
458
|
+
/* (which carries no recipient/read columns). Mark-read upserts the */
|
|
459
|
+
/* receipt, keyed `(notification_id, user_id, channel:'inbox')`. */
|
|
460
|
+
/* ------------------------------------------------------------------ */
|
|
461
|
+
/**
|
|
462
|
+
* List the signed-in user's in-app inbox, joined with read-state.
|
|
463
|
+
*
|
|
464
|
+
* A message is unread until its event has a `read`/`clicked`/`dismissed`
|
|
465
|
+
* receipt; the `read` filter (when given) is applied in-memory after the
|
|
466
|
+
* join. `unreadCount` is computed over the fetched window (bounded by
|
|
467
|
+
* `limit`, like the Console bell's poll). Returns the REST contract shape
|
|
468
|
+
* (`ListNotificationsResponseSchema`): `{ notifications, unreadCount }`.
|
|
469
|
+
*/
|
|
470
|
+
async listInbox(userId, opts = {}) {
|
|
471
|
+
const data = this.ctx.getData?.();
|
|
472
|
+
if (!data || !userId) return { notifications: [], unreadCount: 0 };
|
|
473
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
474
|
+
const where = { user_id: userId };
|
|
475
|
+
if (opts.type) where.topic = opts.type;
|
|
476
|
+
const [rows, receipts] = await Promise.all([
|
|
477
|
+
data.find(INBOX_OBJECT, { where, orderBy: [{ field: "created_at", order: "desc" }], limit }),
|
|
478
|
+
// Read-state spine. Best-effort: if receipts are unavailable the
|
|
479
|
+
// inbox still lists (everything reads as unread) rather than erroring.
|
|
480
|
+
data.find(RECEIPT_OBJECT, { where: { user_id: userId, channel: "inbox" } }).catch(() => [])
|
|
481
|
+
]);
|
|
482
|
+
const stateByNotif = /* @__PURE__ */ new Map();
|
|
483
|
+
for (const r of receipts) {
|
|
484
|
+
const nid = r?.notification_id != null ? String(r.notification_id) : "";
|
|
485
|
+
if (!nid) continue;
|
|
486
|
+
const state = String(r.state ?? "delivered");
|
|
487
|
+
const prev = stateByNotif.get(nid);
|
|
488
|
+
if (!prev || !READ_RECEIPT_STATES.has(prev) && READ_RECEIPT_STATES.has(state)) {
|
|
489
|
+
stateByNotif.set(nid, state);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let unreadCount = 0;
|
|
493
|
+
const all = rows.map((m) => {
|
|
494
|
+
const nid = m?.notification_id != null ? String(m.notification_id) : null;
|
|
495
|
+
const state = nid ? stateByNotif.get(nid) : void 0;
|
|
496
|
+
const read = state ? READ_RECEIPT_STATES.has(state) : false;
|
|
497
|
+
if (!read) unreadCount += 1;
|
|
498
|
+
return {
|
|
499
|
+
id: nid ?? String(m.id),
|
|
500
|
+
type: m.topic ?? "notification",
|
|
501
|
+
title: m.title ?? "",
|
|
502
|
+
body: m.body_md ?? "",
|
|
503
|
+
read,
|
|
504
|
+
actionUrl: m.action_url ?? void 0,
|
|
505
|
+
createdAt: m.created_at ?? this.now()
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
const notifications = opts.read === void 0 ? all : all.filter((n) => n.read === opts.read);
|
|
509
|
+
return { notifications, unreadCount };
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Mark specific notifications read by upserting their inbox receipts to
|
|
513
|
+
* `read`. Updates the existing `delivered` receipt in place (keyed
|
|
514
|
+
* `(notification_id, user_id, channel:'inbox')`); inserts one only when
|
|
515
|
+
* absent. `ids` are notification (event) ids. Returns the REST contract
|
|
516
|
+
* shape (`MarkNotificationsReadResponseSchema`): `{ success, readCount }`.
|
|
517
|
+
*/
|
|
518
|
+
async markRead(userId, ids) {
|
|
519
|
+
const data = this.ctx.getData?.();
|
|
520
|
+
if (!data || !userId || !ids?.length) return { success: true, readCount: 0 };
|
|
521
|
+
const at = this.now();
|
|
522
|
+
let readCount = 0;
|
|
523
|
+
for (const id of ids) {
|
|
524
|
+
const nid = String(id ?? "");
|
|
525
|
+
if (!nid) continue;
|
|
526
|
+
try {
|
|
527
|
+
readCount += await this.upsertReadReceipt(data, userId, nid, at);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
this.ctx.logger.warn(`[messaging] markRead failed for '${nid}': ${err.message}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return { success: true, readCount };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Mark every currently-unread inbox message for the user as read. Returns
|
|
536
|
+
* `{ success, readCount }` (`MarkAllNotificationsReadResponseSchema`).
|
|
537
|
+
*/
|
|
538
|
+
async markAllRead(userId) {
|
|
539
|
+
const data = this.ctx.getData?.();
|
|
540
|
+
if (!data || !userId) return { success: true, readCount: 0 };
|
|
541
|
+
const { notifications } = await this.listInbox(userId, { read: false, limit: 200 });
|
|
542
|
+
return this.markRead(userId, notifications.map((n) => n.id));
|
|
543
|
+
}
|
|
544
|
+
/** Upsert a `read` receipt for one notification; returns 1 when it persisted. */
|
|
545
|
+
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) {
|
|
551
|
+
await data.update(RECEIPT_OBJECT, { state: "read", at }, { where: { id: existing.id } });
|
|
552
|
+
} else {
|
|
553
|
+
await data.insert(RECEIPT_OBJECT, {
|
|
554
|
+
notification_id: notificationId,
|
|
555
|
+
delivery_id: null,
|
|
556
|
+
user_id: userId,
|
|
557
|
+
channel: "inbox",
|
|
558
|
+
state: "read",
|
|
559
|
+
at,
|
|
560
|
+
created_at: at
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return 1;
|
|
564
|
+
}
|
|
336
565
|
/**
|
|
337
566
|
* The single notification ingress. Writes the L2 event, resolves the
|
|
338
567
|
* audience, and fans the result out to its channels. An unregistered
|
|
@@ -540,78 +769,6 @@ function actionUrlFor(input, payload) {
|
|
|
540
769
|
return obj && id ? `/${obj}/${id}` : void 0;
|
|
541
770
|
}
|
|
542
771
|
|
|
543
|
-
// src/inbox-channel.ts
|
|
544
|
-
var INBOX_OBJECT = "sys_inbox_message";
|
|
545
|
-
var RECEIPT_OBJECT = "sys_notification_receipt";
|
|
546
|
-
function createInboxChannel(opts) {
|
|
547
|
-
const objectName = opts.objectName ?? INBOX_OBJECT;
|
|
548
|
-
const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT;
|
|
549
|
-
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
550
|
-
async function writeDeliveredReceipt(ctx, data, r) {
|
|
551
|
-
if (!r.notificationId) return;
|
|
552
|
-
try {
|
|
553
|
-
await data.insert(receiptObject, {
|
|
554
|
-
notification_id: r.notificationId,
|
|
555
|
-
delivery_id: null,
|
|
556
|
-
user_id: r.userId,
|
|
557
|
-
channel: "inbox",
|
|
558
|
-
state: "delivered",
|
|
559
|
-
at: r.at,
|
|
560
|
-
organization_id: r.organizationId ?? null,
|
|
561
|
-
created_at: r.at
|
|
562
|
-
});
|
|
563
|
-
} catch (err) {
|
|
564
|
-
ctx.logger.warn(
|
|
565
|
-
`[inbox] delivered receipt write failed for '${r.userId}' (${err.message}); inbox row stands`
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
return {
|
|
570
|
-
id: "inbox",
|
|
571
|
-
async send(ctx, delivery) {
|
|
572
|
-
const data = opts.getData();
|
|
573
|
-
const n = delivery.notification;
|
|
574
|
-
if (!data) {
|
|
575
|
-
ctx.logger.warn(
|
|
576
|
-
`[inbox] no data engine registered; inbox row for '${delivery.recipient}' not persisted`
|
|
577
|
-
);
|
|
578
|
-
return { ok: true };
|
|
579
|
-
}
|
|
580
|
-
const userId = delivery.recipient;
|
|
581
|
-
const at = now();
|
|
582
|
-
const row = {
|
|
583
|
-
user_id: userId,
|
|
584
|
-
notification_id: n.notificationId ?? null,
|
|
585
|
-
topic: n.topic,
|
|
586
|
-
title: n.title,
|
|
587
|
-
body_md: n.body,
|
|
588
|
-
severity: n.severity ?? "info",
|
|
589
|
-
action_url: n.actionUrl,
|
|
590
|
-
organization_id: n.organizationId ?? null,
|
|
591
|
-
created_at: at
|
|
592
|
-
};
|
|
593
|
-
let inboxId;
|
|
594
|
-
try {
|
|
595
|
-
const created = await data.insert(objectName, row);
|
|
596
|
-
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
|
|
597
|
-
inboxId = id != null ? String(id) : void 0;
|
|
598
|
-
} catch (err) {
|
|
599
|
-
return { ok: false, error: `inbox insert failed: ${err.message}` };
|
|
600
|
-
}
|
|
601
|
-
await writeDeliveredReceipt(ctx, data, {
|
|
602
|
-
notificationId: n.notificationId,
|
|
603
|
-
userId,
|
|
604
|
-
organizationId: n.organizationId,
|
|
605
|
-
at
|
|
606
|
-
});
|
|
607
|
-
return { ok: true, externalId: inboxId };
|
|
608
|
-
},
|
|
609
|
-
classifyError(_err) {
|
|
610
|
-
return "retryable";
|
|
611
|
-
}
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
772
|
// src/sql-outbox.ts
|
|
616
773
|
import { randomUUID } from "crypto";
|
|
617
774
|
|
|
@@ -677,9 +834,297 @@ var SqlNotificationOutbox = class {
|
|
|
677
834
|
partition_key: hashPartition(input.notificationId, this.partitionCount),
|
|
678
835
|
status: "pending",
|
|
679
836
|
attempts: 0,
|
|
680
|
-
// Deferred dispatch (quiet-hours, P3): claim() skips pending rows
|
|
681
|
-
// whose next_attempt_at is in the future.
|
|
682
|
-
next_attempt_at: input.notBefore ?? null,
|
|
837
|
+
// Deferred dispatch (quiet-hours, P3): claim() skips pending rows
|
|
838
|
+
// whose next_attempt_at is in the future.
|
|
839
|
+
next_attempt_at: input.notBefore ?? null,
|
|
840
|
+
created_at: now,
|
|
841
|
+
updated_at: now
|
|
842
|
+
};
|
|
843
|
+
try {
|
|
844
|
+
await this.engine.insert(this.objectName, row);
|
|
845
|
+
return id;
|
|
846
|
+
} catch (err) {
|
|
847
|
+
const winner = await this.engine.findOne(this.objectName, { where: dedup, fields: ["id"] });
|
|
848
|
+
if (winner?.id) return String(winner.id);
|
|
849
|
+
throw err;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async claim(opts) {
|
|
853
|
+
const now = opts.now ?? Date.now();
|
|
854
|
+
await this.engine.update(
|
|
855
|
+
this.objectName,
|
|
856
|
+
{ status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
|
|
857
|
+
{ where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
|
|
858
|
+
);
|
|
859
|
+
const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
|
|
860
|
+
const candidates = await this.engine.find(this.objectName, {
|
|
861
|
+
where: {
|
|
862
|
+
status: "pending",
|
|
863
|
+
...partitionFilter,
|
|
864
|
+
$or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
|
|
865
|
+
},
|
|
866
|
+
fields: ["id"],
|
|
867
|
+
limit: opts.limit
|
|
868
|
+
});
|
|
869
|
+
if (!candidates.length) return [];
|
|
870
|
+
const ids = candidates.map((c) => c.id);
|
|
871
|
+
await this.engine.update(
|
|
872
|
+
this.objectName,
|
|
873
|
+
{ status: "in_flight", claimed_by: opts.nodeId, claimed_at: now, updated_at: now },
|
|
874
|
+
{ where: { id: { $in: ids }, status: "pending" }, multi: true }
|
|
875
|
+
);
|
|
876
|
+
const claimed = await this.engine.find(this.objectName, {
|
|
877
|
+
where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
|
|
878
|
+
});
|
|
879
|
+
return claimed.map((r) => this.toRecord(r));
|
|
880
|
+
}
|
|
881
|
+
async ack(id, result) {
|
|
882
|
+
const current = await this.engine.findOne(this.objectName, {
|
|
883
|
+
where: { id },
|
|
884
|
+
fields: ["attempts"]
|
|
885
|
+
});
|
|
886
|
+
if (!current) return;
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
let status;
|
|
889
|
+
let nextAttemptAt = null;
|
|
890
|
+
let error = null;
|
|
891
|
+
if (result.success) {
|
|
892
|
+
status = "success";
|
|
893
|
+
} else if (result.suppressed) {
|
|
894
|
+
status = "suppressed";
|
|
895
|
+
error = result.error ?? null;
|
|
896
|
+
} else if (result.dead) {
|
|
897
|
+
status = "dead";
|
|
898
|
+
error = result.error ?? null;
|
|
899
|
+
} else {
|
|
900
|
+
status = "pending";
|
|
901
|
+
nextAttemptAt = result.nextAttemptAt ?? null;
|
|
902
|
+
error = result.error ?? null;
|
|
903
|
+
}
|
|
904
|
+
await this.engine.update(
|
|
905
|
+
this.objectName,
|
|
906
|
+
{
|
|
907
|
+
status,
|
|
908
|
+
attempts: (current.attempts ?? 0) + 1,
|
|
909
|
+
last_attempted_at: now,
|
|
910
|
+
claimed_by: null,
|
|
911
|
+
claimed_at: null,
|
|
912
|
+
next_attempt_at: nextAttemptAt,
|
|
913
|
+
error,
|
|
914
|
+
updated_at: now
|
|
915
|
+
},
|
|
916
|
+
{ where: { id }, multi: false }
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
async list(filter) {
|
|
920
|
+
const where = {};
|
|
921
|
+
if (filter?.status) where.status = filter.status;
|
|
922
|
+
if (filter?.notificationId) where.notification_id = filter.notificationId;
|
|
923
|
+
const rows = await this.engine.find(this.objectName, { where });
|
|
924
|
+
return rows.map((r) => this.toRecord(r));
|
|
925
|
+
}
|
|
926
|
+
toRecord(r) {
|
|
927
|
+
let payload = r.payload ?? {};
|
|
928
|
+
if (typeof payload === "string") {
|
|
929
|
+
try {
|
|
930
|
+
payload = JSON.parse(payload);
|
|
931
|
+
} catch {
|
|
932
|
+
payload = {};
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
id: r.id,
|
|
937
|
+
notificationId: r.notification_id,
|
|
938
|
+
recipientId: r.recipient_id,
|
|
939
|
+
channel: r.channel,
|
|
940
|
+
topic: r.topic ?? void 0,
|
|
941
|
+
payload,
|
|
942
|
+
organizationId: r.organization_id ?? void 0,
|
|
943
|
+
partitionKey: r.partition_key,
|
|
944
|
+
status: r.status,
|
|
945
|
+
attempts: r.attempts,
|
|
946
|
+
claimedBy: r.claimed_by ?? void 0,
|
|
947
|
+
claimedAt: r.claimed_at ?? void 0,
|
|
948
|
+
nextAttemptAt: r.next_attempt_at ?? void 0,
|
|
949
|
+
lastAttemptedAt: r.last_attempted_at ?? void 0,
|
|
950
|
+
error: r.error ?? void 0,
|
|
951
|
+
createdAt: r.created_at,
|
|
952
|
+
updatedAt: r.updated_at
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
// src/sql-http-outbox.ts
|
|
958
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
959
|
+
|
|
960
|
+
// src/http-outbox.ts
|
|
961
|
+
var HttpRedeliverError = class extends Error {
|
|
962
|
+
constructor(message, code) {
|
|
963
|
+
super(message);
|
|
964
|
+
this.code = code;
|
|
965
|
+
this.name = "HttpRedeliverError";
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// src/objects/http-delivery.object.ts
|
|
970
|
+
import { Field, ObjectSchema } from "@objectstack/spec/data";
|
|
971
|
+
var HttpDelivery = ObjectSchema.create({
|
|
972
|
+
name: "sys_http_delivery",
|
|
973
|
+
label: "HTTP Delivery",
|
|
974
|
+
pluralLabel: "HTTP Deliveries",
|
|
975
|
+
icon: "globe",
|
|
976
|
+
isSystem: true,
|
|
977
|
+
managedBy: "system",
|
|
978
|
+
userActions: { create: false, edit: false, delete: false, import: false },
|
|
979
|
+
description: "Durable outbox row for one outbound-HTTP attempt (ADR-0018). Managed by @objectstack/service-messaging; do not write directly.",
|
|
980
|
+
displayNameField: "id",
|
|
981
|
+
titleFormat: "{label} \u2192 {url}",
|
|
982
|
+
compactLayout: ["source", "url", "status", "attempts", "next_retry_at"],
|
|
983
|
+
listViews: {
|
|
984
|
+
recent: {
|
|
985
|
+
type: "grid",
|
|
986
|
+
name: "recent",
|
|
987
|
+
label: "Recent",
|
|
988
|
+
data: { provider: "object", object: "sys_http_delivery" },
|
|
989
|
+
columns: ["source", "label", "url", "status", "attempts", "response_code", "updated_at"],
|
|
990
|
+
sort: [{ field: "updated_at", order: "desc" }],
|
|
991
|
+
pagination: { pageSize: 50 }
|
|
992
|
+
},
|
|
993
|
+
failures: {
|
|
994
|
+
type: "grid",
|
|
995
|
+
name: "failures",
|
|
996
|
+
label: "Failures",
|
|
997
|
+
data: { provider: "object", object: "sys_http_delivery" },
|
|
998
|
+
columns: ["source", "url", "status", "attempts", "response_code", "error", "updated_at"],
|
|
999
|
+
filter: [{ field: "status", operator: "in", value: ["failed", "dead"] }],
|
|
1000
|
+
sort: [{ field: "updated_at", order: "desc" }],
|
|
1001
|
+
pagination: { pageSize: 50 }
|
|
1002
|
+
},
|
|
1003
|
+
pending: {
|
|
1004
|
+
type: "grid",
|
|
1005
|
+
name: "pending",
|
|
1006
|
+
label: "Pending",
|
|
1007
|
+
data: { provider: "object", object: "sys_http_delivery" },
|
|
1008
|
+
columns: ["source", "url", "attempts", "next_retry_at", "updated_at"],
|
|
1009
|
+
filter: [{ field: "status", operator: "equals", value: "pending" }],
|
|
1010
|
+
sort: [{ field: "next_retry_at", order: "asc" }],
|
|
1011
|
+
pagination: { pageSize: 50 }
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
fields: {
|
|
1015
|
+
id: Field.text({
|
|
1016
|
+
label: "Delivery ID",
|
|
1017
|
+
required: true,
|
|
1018
|
+
maxLength: 64,
|
|
1019
|
+
description: "UUID \u2014 also doubles as the receiver-side idempotency key"
|
|
1020
|
+
}),
|
|
1021
|
+
source: Field.text({
|
|
1022
|
+
label: "Source",
|
|
1023
|
+
required: true,
|
|
1024
|
+
maxLength: 32,
|
|
1025
|
+
description: "Provenance domain, e.g. 'webhook' | 'flow'. UNIQUE(source, dedup_key)."
|
|
1026
|
+
}),
|
|
1027
|
+
ref_id: Field.text({
|
|
1028
|
+
label: "Ref ID",
|
|
1029
|
+
required: true,
|
|
1030
|
+
maxLength: 128,
|
|
1031
|
+
description: "Partition/ordering anchor within source (webhook id, flow id, \u2026)"
|
|
1032
|
+
}),
|
|
1033
|
+
dedup_key: Field.text({
|
|
1034
|
+
label: "Dedup Key",
|
|
1035
|
+
required: true,
|
|
1036
|
+
maxLength: 191,
|
|
1037
|
+
description: "UNIQUE(source, dedup_key) for at-most-once enqueue"
|
|
1038
|
+
}),
|
|
1039
|
+
label: Field.text({
|
|
1040
|
+
label: "Label",
|
|
1041
|
+
required: false,
|
|
1042
|
+
maxLength: 191,
|
|
1043
|
+
description: "Diagnostic label / event type \u2014 surfaced on X-Objectstack-Event"
|
|
1044
|
+
}),
|
|
1045
|
+
url: Field.text({
|
|
1046
|
+
label: "Target URL",
|
|
1047
|
+
required: true,
|
|
1048
|
+
maxLength: 2048,
|
|
1049
|
+
description: "Snapshotted at enqueue so config edits do not rewrite live rows"
|
|
1050
|
+
}),
|
|
1051
|
+
method: Field.text({ label: "Method", required: false, maxLength: 10 }),
|
|
1052
|
+
headers_json: Field.textarea({ label: "Headers JSON", required: false }),
|
|
1053
|
+
signing_secret: Field.text({ label: "HMAC Secret", required: false, maxLength: 256 }),
|
|
1054
|
+
timeout_ms: Field.number({ label: "Timeout (ms)", required: false }),
|
|
1055
|
+
payload_json: Field.textarea({ label: "Payload JSON", required: true }),
|
|
1056
|
+
partition_key: Field.number({
|
|
1057
|
+
label: "Partition",
|
|
1058
|
+
required: true,
|
|
1059
|
+
description: "hash(ref_id) mod partitionCount \u2014 precomputed for cheap WHERE"
|
|
1060
|
+
}),
|
|
1061
|
+
status: Field.text({
|
|
1062
|
+
label: "Status",
|
|
1063
|
+
required: true,
|
|
1064
|
+
defaultValue: "pending",
|
|
1065
|
+
maxLength: 16,
|
|
1066
|
+
description: "pending | in_flight | success | failed | dead"
|
|
1067
|
+
}),
|
|
1068
|
+
attempts: Field.number({
|
|
1069
|
+
label: "Attempts",
|
|
1070
|
+
required: true,
|
|
1071
|
+
defaultValue: 0,
|
|
1072
|
+
description: "Number of attempts made so far"
|
|
1073
|
+
}),
|
|
1074
|
+
claimed_by: Field.text({ label: "Claimed By", required: false, maxLength: 128 }),
|
|
1075
|
+
claimed_at: Field.number({ label: "Claimed At (ms)", required: false }),
|
|
1076
|
+
next_retry_at: Field.number({ label: "Next Retry At (ms)", required: false }),
|
|
1077
|
+
last_attempted_at: Field.number({ label: "Last Attempted At (ms)", required: false }),
|
|
1078
|
+
response_code: Field.number({ label: "HTTP Status", required: false }),
|
|
1079
|
+
response_body: Field.textarea({ label: "Response Body (capped)", required: false }),
|
|
1080
|
+
error: Field.textarea({ label: "Error", required: false }),
|
|
1081
|
+
created_at: Field.number({ label: "Created At (ms)", required: true }),
|
|
1082
|
+
updated_at: Field.number({ label: "Updated At (ms)", required: true })
|
|
1083
|
+
},
|
|
1084
|
+
indexes: [
|
|
1085
|
+
{ fields: ["source", "dedup_key"], unique: true },
|
|
1086
|
+
// Hot path: claim query
|
|
1087
|
+
{ fields: ["status", "partition_key", "next_retry_at"] },
|
|
1088
|
+
// Reaper: scan stale in_flight rows by claimed_at
|
|
1089
|
+
{ fields: ["status", "claimed_at"] },
|
|
1090
|
+
{ fields: ["source", "ref_id"] }
|
|
1091
|
+
]
|
|
1092
|
+
});
|
|
1093
|
+
var SYS_HTTP_DELIVERY = "sys_http_delivery";
|
|
1094
|
+
|
|
1095
|
+
// src/sql-http-outbox.ts
|
|
1096
|
+
var SqlHttpOutbox = class {
|
|
1097
|
+
constructor(engine, opts) {
|
|
1098
|
+
this.engine = engine;
|
|
1099
|
+
if (opts.partitionCount <= 0) {
|
|
1100
|
+
throw new Error("SqlHttpOutbox: partitionCount must be > 0");
|
|
1101
|
+
}
|
|
1102
|
+
this.objectName = opts.objectName ?? SYS_HTTP_DELIVERY;
|
|
1103
|
+
this.partitionCount = opts.partitionCount;
|
|
1104
|
+
}
|
|
1105
|
+
async enqueue(input) {
|
|
1106
|
+
const existing = await this.engine.findOne(this.objectName, {
|
|
1107
|
+
where: { source: input.source, dedup_key: input.dedupKey },
|
|
1108
|
+
fields: ["id"]
|
|
1109
|
+
});
|
|
1110
|
+
if (existing?.id) return existing.id;
|
|
1111
|
+
const id = randomUUID2();
|
|
1112
|
+
const now = Date.now();
|
|
1113
|
+
const row = {
|
|
1114
|
+
id,
|
|
1115
|
+
source: input.source,
|
|
1116
|
+
ref_id: input.refId,
|
|
1117
|
+
dedup_key: input.dedupKey,
|
|
1118
|
+
label: input.label,
|
|
1119
|
+
url: input.url,
|
|
1120
|
+
method: input.method ?? "POST",
|
|
1121
|
+
headers_json: input.headers ? JSON.stringify(input.headers) : void 0,
|
|
1122
|
+
signing_secret: input.signingSecret,
|
|
1123
|
+
timeout_ms: input.timeoutMs,
|
|
1124
|
+
payload_json: JSON.stringify(input.payload ?? null),
|
|
1125
|
+
partition_key: hashPartition(input.refId, this.partitionCount),
|
|
1126
|
+
status: "pending",
|
|
1127
|
+
attempts: 0,
|
|
683
1128
|
created_at: now,
|
|
684
1129
|
updated_at: now
|
|
685
1130
|
};
|
|
@@ -687,8 +1132,11 @@ var SqlNotificationOutbox = class {
|
|
|
687
1132
|
await this.engine.insert(this.objectName, row);
|
|
688
1133
|
return id;
|
|
689
1134
|
} catch (err) {
|
|
690
|
-
const winner = await this.engine.findOne(this.objectName, {
|
|
691
|
-
|
|
1135
|
+
const winner = await this.engine.findOne(this.objectName, {
|
|
1136
|
+
where: { source: input.source, dedup_key: input.dedupKey },
|
|
1137
|
+
fields: ["id"]
|
|
1138
|
+
});
|
|
1139
|
+
if (winner?.id) return winner.id;
|
|
692
1140
|
throw err;
|
|
693
1141
|
}
|
|
694
1142
|
}
|
|
@@ -697,19 +1145,25 @@ var SqlNotificationOutbox = class {
|
|
|
697
1145
|
await this.engine.update(
|
|
698
1146
|
this.objectName,
|
|
699
1147
|
{ status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
|
|
700
|
-
{
|
|
1148
|
+
{
|
|
1149
|
+
where: {
|
|
1150
|
+
status: "in_flight",
|
|
1151
|
+
claimed_at: { $lt: now - opts.claimTtlMs }
|
|
1152
|
+
},
|
|
1153
|
+
multi: true
|
|
1154
|
+
}
|
|
701
1155
|
);
|
|
702
1156
|
const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
|
|
703
1157
|
const candidates = await this.engine.find(this.objectName, {
|
|
704
1158
|
where: {
|
|
705
1159
|
status: "pending",
|
|
706
1160
|
...partitionFilter,
|
|
707
|
-
$or: [{
|
|
1161
|
+
$or: [{ next_retry_at: null }, { next_retry_at: { $lte: now } }]
|
|
708
1162
|
},
|
|
709
1163
|
fields: ["id"],
|
|
710
1164
|
limit: opts.limit
|
|
711
1165
|
});
|
|
712
|
-
if (
|
|
1166
|
+
if (candidates.length === 0) return [];
|
|
713
1167
|
const ids = candidates.map((c) => c.id);
|
|
714
1168
|
await this.engine.update(
|
|
715
1169
|
this.objectName,
|
|
@@ -719,7 +1173,7 @@ var SqlNotificationOutbox = class {
|
|
|
719
1173
|
const claimed = await this.engine.find(this.objectName, {
|
|
720
1174
|
where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
|
|
721
1175
|
});
|
|
722
|
-
return claimed.map((r) => this.
|
|
1176
|
+
return claimed.map((r) => this.toDelivery(r));
|
|
723
1177
|
}
|
|
724
1178
|
async ack(id, result) {
|
|
725
1179
|
const current = await this.engine.findOne(this.objectName, {
|
|
@@ -729,19 +1183,19 @@ var SqlNotificationOutbox = class {
|
|
|
729
1183
|
if (!current) return;
|
|
730
1184
|
const now = Date.now();
|
|
731
1185
|
let status;
|
|
732
|
-
let
|
|
733
|
-
let error
|
|
1186
|
+
let nextRetryAt;
|
|
1187
|
+
let error;
|
|
734
1188
|
if (result.success) {
|
|
735
1189
|
status = "success";
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
error = result.error ?? null;
|
|
1190
|
+
nextRetryAt = null;
|
|
1191
|
+
error = null;
|
|
739
1192
|
} else if (result.dead) {
|
|
740
1193
|
status = "dead";
|
|
1194
|
+
nextRetryAt = null;
|
|
741
1195
|
error = result.error ?? null;
|
|
742
1196
|
} else {
|
|
743
1197
|
status = "pending";
|
|
744
|
-
|
|
1198
|
+
nextRetryAt = result.nextRetryAt ?? null;
|
|
745
1199
|
error = result.error ?? null;
|
|
746
1200
|
}
|
|
747
1201
|
await this.engine.update(
|
|
@@ -752,7 +1206,9 @@ var SqlNotificationOutbox = class {
|
|
|
752
1206
|
last_attempted_at: now,
|
|
753
1207
|
claimed_by: null,
|
|
754
1208
|
claimed_at: null,
|
|
755
|
-
|
|
1209
|
+
response_code: result.httpStatus ?? null,
|
|
1210
|
+
response_body: result.responseBody ?? null,
|
|
1211
|
+
next_retry_at: nextRetryAt,
|
|
756
1212
|
error,
|
|
757
1213
|
updated_at: now
|
|
758
1214
|
},
|
|
@@ -762,34 +1218,65 @@ var SqlNotificationOutbox = class {
|
|
|
762
1218
|
async list(filter) {
|
|
763
1219
|
const where = {};
|
|
764
1220
|
if (filter?.status) where.status = filter.status;
|
|
765
|
-
if (filter?.
|
|
1221
|
+
if (filter?.source) where.source = filter.source;
|
|
766
1222
|
const rows = await this.engine.find(this.objectName, { where });
|
|
767
|
-
return rows.map((r) => this.
|
|
1223
|
+
return rows.map((r) => this.toDelivery(r));
|
|
768
1224
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1225
|
+
async redeliver(id) {
|
|
1226
|
+
const current = await this.engine.findOne(this.objectName, { where: { id } });
|
|
1227
|
+
if (!current) {
|
|
1228
|
+
throw new HttpRedeliverError(`Delivery row '${id}' not found`, "not_found");
|
|
1229
|
+
}
|
|
1230
|
+
if (current.status !== "success" && current.status !== "failed" && current.status !== "dead") {
|
|
1231
|
+
throw new HttpRedeliverError(
|
|
1232
|
+
`Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`,
|
|
1233
|
+
"not_eligible"
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const now = Date.now();
|
|
1237
|
+
await this.engine.update(
|
|
1238
|
+
this.objectName,
|
|
1239
|
+
{
|
|
1240
|
+
status: "pending",
|
|
1241
|
+
attempts: 0,
|
|
1242
|
+
claimed_by: null,
|
|
1243
|
+
claimed_at: null,
|
|
1244
|
+
next_retry_at: null,
|
|
1245
|
+
last_attempted_at: null,
|
|
1246
|
+
response_code: null,
|
|
1247
|
+
response_body: null,
|
|
1248
|
+
error: null,
|
|
1249
|
+
updated_at: now
|
|
1250
|
+
},
|
|
1251
|
+
{ where: { id, status: { $in: ["success", "failed", "dead"] } }, multi: false }
|
|
1252
|
+
);
|
|
1253
|
+
const after = await this.engine.findOne(this.objectName, { where: { id } });
|
|
1254
|
+
if (!after || after.status !== "pending") {
|
|
1255
|
+
throw new HttpRedeliverError(`Delivery row '${id}' state changed during redeliver`, "not_eligible");
|
|
777
1256
|
}
|
|
1257
|
+
return this.toDelivery(after);
|
|
1258
|
+
}
|
|
1259
|
+
toDelivery(r) {
|
|
778
1260
|
return {
|
|
779
1261
|
id: r.id,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1262
|
+
source: r.source,
|
|
1263
|
+
refId: r.ref_id,
|
|
1264
|
+
dedupKey: r.dedup_key,
|
|
1265
|
+
label: r.label ?? void 0,
|
|
1266
|
+
url: r.url,
|
|
1267
|
+
method: r.method ?? void 0,
|
|
1268
|
+
headers: r.headers_json ? JSON.parse(r.headers_json) : void 0,
|
|
1269
|
+
signingSecret: r.signing_secret ?? void 0,
|
|
1270
|
+
timeoutMs: r.timeout_ms ?? void 0,
|
|
1271
|
+
payload: JSON.parse(r.payload_json),
|
|
787
1272
|
status: r.status,
|
|
788
1273
|
attempts: r.attempts,
|
|
789
1274
|
claimedBy: r.claimed_by ?? void 0,
|
|
790
1275
|
claimedAt: r.claimed_at ?? void 0,
|
|
791
|
-
|
|
1276
|
+
nextRetryAt: r.next_retry_at ?? void 0,
|
|
792
1277
|
lastAttemptedAt: r.last_attempted_at ?? void 0,
|
|
1278
|
+
responseCode: r.response_code ?? void 0,
|
|
1279
|
+
responseBody: r.response_body ?? void 0,
|
|
793
1280
|
error: r.error ?? void 0,
|
|
794
1281
|
createdAt: r.created_at,
|
|
795
1282
|
updatedAt: r.updated_at
|
|
@@ -944,6 +1431,237 @@ function stableNodeOffset(nodeId, partitionCount) {
|
|
|
944
1431
|
return Math.abs(h) % partitionCount;
|
|
945
1432
|
}
|
|
946
1433
|
|
|
1434
|
+
// src/http-sender.ts
|
|
1435
|
+
import { createHmac, randomUUID as randomUUID3 } from "crypto";
|
|
1436
|
+
var DEFAULT_HTTP_TIMEOUT_MS = 15e3;
|
|
1437
|
+
var RESPONSE_BODY_CAP = 16 * 1024;
|
|
1438
|
+
async function sendOnce(delivery, fetchImpl) {
|
|
1439
|
+
const body = typeof delivery.payload === "string" ? delivery.payload : JSON.stringify(delivery.payload ?? null);
|
|
1440
|
+
const headers = {
|
|
1441
|
+
"Content-Type": "application/json",
|
|
1442
|
+
"User-Agent": "ObjectStack-Http/1.0",
|
|
1443
|
+
"X-Objectstack-Delivery": delivery.id,
|
|
1444
|
+
"X-Objectstack-Attempt": String(delivery.attempts + 1),
|
|
1445
|
+
...delivery.label ? { "X-Objectstack-Event": delivery.label } : {},
|
|
1446
|
+
...delivery.headers ?? {}
|
|
1447
|
+
};
|
|
1448
|
+
if (delivery.signingSecret) {
|
|
1449
|
+
const sig = createHmac("sha256", delivery.signingSecret).update(body).digest("hex");
|
|
1450
|
+
headers["X-Objectstack-Signature"] = `sha256=${sig}`;
|
|
1451
|
+
}
|
|
1452
|
+
const timeoutMs = delivery.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
|
|
1453
|
+
const controller = new AbortController();
|
|
1454
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1455
|
+
const start = Date.now();
|
|
1456
|
+
try {
|
|
1457
|
+
const res = await fetchImpl(delivery.url, {
|
|
1458
|
+
method: delivery.method ?? "POST",
|
|
1459
|
+
headers,
|
|
1460
|
+
body,
|
|
1461
|
+
signal: controller.signal
|
|
1462
|
+
});
|
|
1463
|
+
clearTimeout(timer);
|
|
1464
|
+
const responseText = await safeReadBody(res);
|
|
1465
|
+
const durationMs = Date.now() - start;
|
|
1466
|
+
if (res.ok) {
|
|
1467
|
+
return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
|
|
1468
|
+
}
|
|
1469
|
+
const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
|
|
1470
|
+
return {
|
|
1471
|
+
success: false,
|
|
1472
|
+
retriable,
|
|
1473
|
+
httpStatus: res.status,
|
|
1474
|
+
responseBody: responseText,
|
|
1475
|
+
error: `HTTP ${res.status}`,
|
|
1476
|
+
durationMs
|
|
1477
|
+
};
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
clearTimeout(timer);
|
|
1480
|
+
const durationMs = Date.now() - start;
|
|
1481
|
+
const e = err;
|
|
1482
|
+
const error = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
|
|
1483
|
+
return { success: false, retriable: true, error, durationMs };
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
async function safeReadBody(res) {
|
|
1487
|
+
try {
|
|
1488
|
+
const text = await res.text();
|
|
1489
|
+
return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
|
|
1490
|
+
} catch {
|
|
1491
|
+
return void 0;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
function nextHttpRetryDelayMs(attemptsSoFar, rng = Math.random) {
|
|
1495
|
+
const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5, 216e5, 864e5];
|
|
1496
|
+
if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
|
|
1497
|
+
const base = SCHEDULE[attemptsSoFar - 1];
|
|
1498
|
+
const jitter = 0.8 + rng() * 0.4;
|
|
1499
|
+
return Math.floor(base * jitter);
|
|
1500
|
+
}
|
|
1501
|
+
function classifyAttempt(outcome, attemptsSoFar, now = Date.now(), rng) {
|
|
1502
|
+
if (outcome.success) return outcome;
|
|
1503
|
+
if (!outcome.retriable) {
|
|
1504
|
+
return {
|
|
1505
|
+
success: false,
|
|
1506
|
+
httpStatus: outcome.httpStatus,
|
|
1507
|
+
responseBody: outcome.responseBody,
|
|
1508
|
+
error: outcome.error,
|
|
1509
|
+
durationMs: outcome.durationMs,
|
|
1510
|
+
dead: true
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
const delay = nextHttpRetryDelayMs(attemptsSoFar + 1, rng);
|
|
1514
|
+
if (delay === null) {
|
|
1515
|
+
return {
|
|
1516
|
+
success: false,
|
|
1517
|
+
httpStatus: outcome.httpStatus,
|
|
1518
|
+
responseBody: outcome.responseBody,
|
|
1519
|
+
error: outcome.error,
|
|
1520
|
+
durationMs: outcome.durationMs,
|
|
1521
|
+
dead: true
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
return {
|
|
1525
|
+
success: false,
|
|
1526
|
+
httpStatus: outcome.httpStatus,
|
|
1527
|
+
responseBody: outcome.responseBody,
|
|
1528
|
+
error: outcome.error,
|
|
1529
|
+
durationMs: outcome.durationMs,
|
|
1530
|
+
nextRetryAt: now + delay
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
function newDeliveryId() {
|
|
1534
|
+
return randomUUID3();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/http-dispatcher.ts
|
|
1538
|
+
var SINGLE_NODE_CLUSTER2 = {
|
|
1539
|
+
lock: {
|
|
1540
|
+
async acquire() {
|
|
1541
|
+
return { release() {
|
|
1542
|
+
}, isHeld: () => true, renew() {
|
|
1543
|
+
} };
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
var HttpDispatcher = class {
|
|
1548
|
+
constructor(options) {
|
|
1549
|
+
this.running = false;
|
|
1550
|
+
const intervalMs = options.intervalMs ?? 500;
|
|
1551
|
+
const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
|
|
1552
|
+
this.opts = {
|
|
1553
|
+
nodeId: options.nodeId,
|
|
1554
|
+
outbox: options.outbox,
|
|
1555
|
+
cluster: options.cluster ?? SINGLE_NODE_CLUSTER2,
|
|
1556
|
+
partitionCount: options.partitionCount ?? 8,
|
|
1557
|
+
batchSize: options.batchSize ?? 32,
|
|
1558
|
+
intervalMs,
|
|
1559
|
+
lockTtlMs,
|
|
1560
|
+
claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
|
|
1561
|
+
fetchImpl: options.fetchImpl,
|
|
1562
|
+
rng: options.rng,
|
|
1563
|
+
now: options.now,
|
|
1564
|
+
logger: options.logger,
|
|
1565
|
+
onAttempt: options.onAttempt
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
/** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
|
|
1569
|
+
start() {
|
|
1570
|
+
if (this.running) return;
|
|
1571
|
+
this.running = true;
|
|
1572
|
+
this.scheduleTick();
|
|
1573
|
+
this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
|
|
1574
|
+
this.timer.unref?.();
|
|
1575
|
+
}
|
|
1576
|
+
/** Stop the loop and wait for the in-flight tick to drain. */
|
|
1577
|
+
async stop() {
|
|
1578
|
+
if (!this.running) return;
|
|
1579
|
+
this.running = false;
|
|
1580
|
+
if (this.timer) {
|
|
1581
|
+
clearInterval(this.timer);
|
|
1582
|
+
this.timer = void 0;
|
|
1583
|
+
}
|
|
1584
|
+
if (this.inflightTick) {
|
|
1585
|
+
try {
|
|
1586
|
+
await this.inflightTick;
|
|
1587
|
+
} catch {
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/** Run one full tick (all partitions). Exposed for deterministic tests. */
|
|
1592
|
+
async tick() {
|
|
1593
|
+
await this.runTick();
|
|
1594
|
+
}
|
|
1595
|
+
scheduleTick() {
|
|
1596
|
+
if (this.inflightTick) return;
|
|
1597
|
+
this.inflightTick = this.runTick().catch((err) => {
|
|
1598
|
+
this.opts.logger?.warn?.("http-dispatcher: tick failed", {
|
|
1599
|
+
nodeId: this.opts.nodeId,
|
|
1600
|
+
error: err?.message ?? String(err)
|
|
1601
|
+
});
|
|
1602
|
+
}).finally(() => {
|
|
1603
|
+
this.inflightTick = void 0;
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
async runTick() {
|
|
1607
|
+
const partitionCount = this.opts.partitionCount;
|
|
1608
|
+
const offset = stableNodeOffset2(this.opts.nodeId, partitionCount);
|
|
1609
|
+
for (let step = 0; step < partitionCount; step++) {
|
|
1610
|
+
const i = (offset + step) % partitionCount;
|
|
1611
|
+
await this.runPartition(i);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async runPartition(index) {
|
|
1615
|
+
const key = `http.dispatcher.partition.${index}`;
|
|
1616
|
+
const handle = await this.opts.cluster.lock.acquire(key, {
|
|
1617
|
+
ttlMs: this.opts.lockTtlMs,
|
|
1618
|
+
waitMs: 0
|
|
1619
|
+
});
|
|
1620
|
+
if (!handle) return;
|
|
1621
|
+
try {
|
|
1622
|
+
const claimed = await this.opts.outbox.claim({
|
|
1623
|
+
nodeId: this.opts.nodeId,
|
|
1624
|
+
limit: this.opts.batchSize,
|
|
1625
|
+
partition: { index, count: this.opts.partitionCount },
|
|
1626
|
+
claimTtlMs: this.opts.claimTtlMs,
|
|
1627
|
+
now: this.opts.now?.()
|
|
1628
|
+
});
|
|
1629
|
+
if (claimed.length === 0) return;
|
|
1630
|
+
await handle.renew?.(this.opts.lockTtlMs);
|
|
1631
|
+
for (const row of claimed) {
|
|
1632
|
+
if (handle.isHeld && !handle.isHeld()) break;
|
|
1633
|
+
await this.processRow(row);
|
|
1634
|
+
}
|
|
1635
|
+
} finally {
|
|
1636
|
+
await handle.release();
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
async processRow(row) {
|
|
1640
|
+
const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
|
|
1641
|
+
if (!fetchImpl) {
|
|
1642
|
+
this.opts.logger?.warn?.("http-dispatcher: no fetch impl available", { rowId: row.id });
|
|
1643
|
+
await this.opts.outbox.ack(row.id, {
|
|
1644
|
+
success: false,
|
|
1645
|
+
error: "no fetch implementation",
|
|
1646
|
+
durationMs: 0,
|
|
1647
|
+
dead: true
|
|
1648
|
+
});
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const outcome = await sendOnce(row, fetchImpl);
|
|
1652
|
+
const result = classifyAttempt(outcome, row.attempts, this.opts.now?.() ?? Date.now(), this.opts.rng);
|
|
1653
|
+
await this.opts.outbox.ack(row.id, result);
|
|
1654
|
+
this.opts.onAttempt?.(row, result.success);
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
function stableNodeOffset2(nodeId, partitionCount) {
|
|
1658
|
+
let h = 0;
|
|
1659
|
+
for (let i = 0; i < nodeId.length; i++) {
|
|
1660
|
+
h = h * 31 + nodeId.charCodeAt(i) | 0;
|
|
1661
|
+
}
|
|
1662
|
+
return Math.abs(h) % partitionCount;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
947
1665
|
// src/retention.ts
|
|
948
1666
|
var DEFAULT_RETENTION_TARGETS = [
|
|
949
1667
|
{ object: RECEIPT_OBJECT, tsField: "created_at", format: "iso" },
|
|
@@ -1151,8 +1869,8 @@ function createEmailChannel(opts) {
|
|
|
1151
1869
|
}
|
|
1152
1870
|
|
|
1153
1871
|
// src/objects/inbox-message.object.ts
|
|
1154
|
-
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1155
|
-
var InboxMessage =
|
|
1872
|
+
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
1873
|
+
var InboxMessage = ObjectSchema2.create({
|
|
1156
1874
|
name: "sys_inbox_message",
|
|
1157
1875
|
label: "Inbox Message",
|
|
1158
1876
|
pluralLabel: "Inbox Messages",
|
|
@@ -1174,37 +1892,37 @@ var InboxMessage = ObjectSchema.create({
|
|
|
1174
1892
|
}
|
|
1175
1893
|
},
|
|
1176
1894
|
fields: {
|
|
1177
|
-
id:
|
|
1895
|
+
id: Field2.text({
|
|
1178
1896
|
label: "Inbox Message ID",
|
|
1179
1897
|
required: true,
|
|
1180
1898
|
readonly: true
|
|
1181
1899
|
}),
|
|
1182
|
-
user_id:
|
|
1900
|
+
user_id: Field2.text({
|
|
1183
1901
|
label: "Recipient User",
|
|
1184
1902
|
required: true,
|
|
1185
1903
|
searchable: true
|
|
1186
1904
|
}),
|
|
1187
|
-
notification_id:
|
|
1905
|
+
notification_id: Field2.text({
|
|
1188
1906
|
label: "Notification Event",
|
|
1189
1907
|
searchable: true,
|
|
1190
1908
|
description: "FK \u2192 sys_notification (the L2 event this row materializes)"
|
|
1191
1909
|
}),
|
|
1192
|
-
delivery_id:
|
|
1910
|
+
delivery_id: Field2.text({
|
|
1193
1911
|
label: "Delivery",
|
|
1194
1912
|
description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
|
|
1195
1913
|
}),
|
|
1196
|
-
topic:
|
|
1914
|
+
topic: Field2.text({
|
|
1197
1915
|
label: "Topic",
|
|
1198
1916
|
searchable: true
|
|
1199
1917
|
}),
|
|
1200
|
-
title:
|
|
1918
|
+
title: Field2.text({
|
|
1201
1919
|
label: "Title",
|
|
1202
1920
|
required: true
|
|
1203
1921
|
}),
|
|
1204
|
-
body_md:
|
|
1922
|
+
body_md: Field2.markdown({
|
|
1205
1923
|
label: "Body"
|
|
1206
1924
|
}),
|
|
1207
|
-
severity:
|
|
1925
|
+
severity: Field2.select({
|
|
1208
1926
|
label: "Severity",
|
|
1209
1927
|
options: [
|
|
1210
1928
|
{ label: "Info", value: "info" },
|
|
@@ -1212,10 +1930,10 @@ var InboxMessage = ObjectSchema.create({
|
|
|
1212
1930
|
{ label: "Critical", value: "critical" }
|
|
1213
1931
|
]
|
|
1214
1932
|
}),
|
|
1215
|
-
action_url:
|
|
1933
|
+
action_url: Field2.text({
|
|
1216
1934
|
label: "Action URL"
|
|
1217
1935
|
}),
|
|
1218
|
-
created_at:
|
|
1936
|
+
created_at: Field2.datetime({
|
|
1219
1937
|
label: "Created At",
|
|
1220
1938
|
readonly: true
|
|
1221
1939
|
})
|
|
@@ -1223,8 +1941,8 @@ var InboxMessage = ObjectSchema.create({
|
|
|
1223
1941
|
});
|
|
1224
1942
|
|
|
1225
1943
|
// src/objects/notification-receipt.object.ts
|
|
1226
|
-
import { ObjectSchema as
|
|
1227
|
-
var NotificationReceipt =
|
|
1944
|
+
import { ObjectSchema as ObjectSchema3, Field as Field3 } from "@objectstack/spec/data";
|
|
1945
|
+
var NotificationReceipt = ObjectSchema3.create({
|
|
1228
1946
|
name: "sys_notification_receipt",
|
|
1229
1947
|
label: "Notification Receipt",
|
|
1230
1948
|
pluralLabel: "Notification Receipts",
|
|
@@ -1235,43 +1953,43 @@ var NotificationReceipt = ObjectSchema2.create({
|
|
|
1235
1953
|
titleFormat: "{state}",
|
|
1236
1954
|
compactLayout: ["notification_id", "user_id", "channel", "state", "at"],
|
|
1237
1955
|
fields: {
|
|
1238
|
-
id:
|
|
1956
|
+
id: Field3.text({
|
|
1239
1957
|
label: "Receipt ID",
|
|
1240
1958
|
required: true,
|
|
1241
1959
|
readonly: true
|
|
1242
1960
|
}),
|
|
1243
|
-
notification_id:
|
|
1961
|
+
notification_id: Field3.text({
|
|
1244
1962
|
label: "Notification Event",
|
|
1245
1963
|
required: true,
|
|
1246
1964
|
searchable: true,
|
|
1247
1965
|
description: "FK \u2192 sys_notification (L2 event)"
|
|
1248
1966
|
}),
|
|
1249
|
-
delivery_id:
|
|
1967
|
+
delivery_id: Field3.text({
|
|
1250
1968
|
label: "Delivery",
|
|
1251
1969
|
required: false,
|
|
1252
1970
|
description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
|
|
1253
1971
|
}),
|
|
1254
|
-
user_id:
|
|
1972
|
+
user_id: Field3.text({
|
|
1255
1973
|
label: "Recipient User",
|
|
1256
1974
|
required: true,
|
|
1257
1975
|
searchable: true
|
|
1258
1976
|
}),
|
|
1259
|
-
channel:
|
|
1977
|
+
channel: Field3.text({
|
|
1260
1978
|
label: "Channel",
|
|
1261
1979
|
required: true,
|
|
1262
1980
|
description: "Channel id this receipt is for (inbox / email / push / \u2026)"
|
|
1263
1981
|
}),
|
|
1264
|
-
state:
|
|
1982
|
+
state: Field3.select(["delivered", "read", "clicked", "dismissed"], {
|
|
1265
1983
|
label: "State",
|
|
1266
1984
|
required: true,
|
|
1267
1985
|
defaultValue: "delivered"
|
|
1268
1986
|
}),
|
|
1269
|
-
at:
|
|
1987
|
+
at: Field3.datetime({
|
|
1270
1988
|
label: "At",
|
|
1271
1989
|
required: false,
|
|
1272
1990
|
description: "When the receipt reached its current state"
|
|
1273
1991
|
}),
|
|
1274
|
-
created_at:
|
|
1992
|
+
created_at: Field3.datetime({
|
|
1275
1993
|
label: "Created At",
|
|
1276
1994
|
readonly: true
|
|
1277
1995
|
})
|
|
@@ -1283,8 +2001,8 @@ var NotificationReceipt = ObjectSchema2.create({
|
|
|
1283
2001
|
});
|
|
1284
2002
|
|
|
1285
2003
|
// src/objects/notification-delivery.object.ts
|
|
1286
|
-
import { ObjectSchema as
|
|
1287
|
-
var NotificationDelivery =
|
|
2004
|
+
import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
|
|
2005
|
+
var NotificationDelivery = ObjectSchema4.create({
|
|
1288
2006
|
name: "sys_notification_delivery",
|
|
1289
2007
|
label: "Notification Delivery",
|
|
1290
2008
|
pluralLabel: "Notification Deliveries",
|
|
@@ -1295,34 +2013,34 @@ var NotificationDelivery = ObjectSchema3.create({
|
|
|
1295
2013
|
titleFormat: "{channel} \u2192 {recipient_id}",
|
|
1296
2014
|
compactLayout: ["notification_id", "recipient_id", "channel", "status", "attempts"],
|
|
1297
2015
|
fields: {
|
|
1298
|
-
id:
|
|
1299
|
-
notification_id:
|
|
2016
|
+
id: Field4.text({ label: "Delivery ID", required: true, readonly: true }),
|
|
2017
|
+
notification_id: Field4.text({
|
|
1300
2018
|
label: "Notification Event",
|
|
1301
2019
|
required: true,
|
|
1302
2020
|
searchable: true,
|
|
1303
2021
|
description: "FK \u2192 sys_notification (L2 event)"
|
|
1304
2022
|
}),
|
|
1305
|
-
recipient_id:
|
|
1306
|
-
channel:
|
|
1307
|
-
topic:
|
|
1308
|
-
payload:
|
|
2023
|
+
recipient_id: Field4.text({ label: "Recipient User", required: true, searchable: true }),
|
|
2024
|
+
channel: Field4.text({ label: "Channel", required: true }),
|
|
2025
|
+
topic: Field4.text({ label: "Topic", searchable: true }),
|
|
2026
|
+
payload: Field4.json({
|
|
1309
2027
|
label: "Payload",
|
|
1310
2028
|
description: "Snapshot of the rendered notification content for dispatch."
|
|
1311
2029
|
}),
|
|
1312
|
-
status:
|
|
2030
|
+
status: Field4.select(["pending", "in_flight", "success", "failed", "dead", "suppressed"], {
|
|
1313
2031
|
label: "Status",
|
|
1314
2032
|
required: true,
|
|
1315
2033
|
defaultValue: "pending"
|
|
1316
2034
|
}),
|
|
1317
|
-
attempts:
|
|
1318
|
-
partition_key:
|
|
1319
|
-
claimed_by:
|
|
1320
|
-
claimed_at:
|
|
1321
|
-
next_attempt_at:
|
|
1322
|
-
last_attempted_at:
|
|
1323
|
-
error:
|
|
1324
|
-
created_at:
|
|
1325
|
-
updated_at:
|
|
2035
|
+
attempts: Field4.number({ label: "Attempts", defaultValue: 0 }),
|
|
2036
|
+
partition_key: Field4.number({ label: "Partition Key", defaultValue: 0 }),
|
|
2037
|
+
claimed_by: Field4.text({ label: "Claimed By", description: "Node id while in_flight" }),
|
|
2038
|
+
claimed_at: Field4.number({ label: "Claimed At (ms)" }),
|
|
2039
|
+
next_attempt_at: Field4.number({ label: "Next Attempt At (ms)" }),
|
|
2040
|
+
last_attempted_at: Field4.number({ label: "Last Attempted At (ms)" }),
|
|
2041
|
+
error: Field4.textarea({ label: "Error" }),
|
|
2042
|
+
created_at: Field4.number({ label: "Created At (ms)", readonly: true }),
|
|
2043
|
+
updated_at: Field4.number({ label: "Updated At (ms)" })
|
|
1326
2044
|
},
|
|
1327
2045
|
indexes: [
|
|
1328
2046
|
// Dedup: one delivery per (event, recipient, channel).
|
|
@@ -1336,8 +2054,8 @@ var NotificationDelivery = ObjectSchema3.create({
|
|
|
1336
2054
|
});
|
|
1337
2055
|
|
|
1338
2056
|
// src/objects/notification-preference.object.ts
|
|
1339
|
-
import { ObjectSchema as
|
|
1340
|
-
var NotificationPreference =
|
|
2057
|
+
import { ObjectSchema as ObjectSchema5, Field as Field5 } from "@objectstack/spec/data";
|
|
2058
|
+
var NotificationPreference = ObjectSchema5.create({
|
|
1341
2059
|
name: "sys_notification_preference",
|
|
1342
2060
|
label: "Notification Preference",
|
|
1343
2061
|
pluralLabel: "Notification Preferences",
|
|
@@ -1348,44 +2066,44 @@ var NotificationPreference = ObjectSchema4.create({
|
|
|
1348
2066
|
titleFormat: "{user_id} \xB7 {topic} \xB7 {channel}",
|
|
1349
2067
|
compactLayout: ["user_id", "topic", "channel", "enabled", "digest"],
|
|
1350
2068
|
fields: {
|
|
1351
|
-
id:
|
|
1352
|
-
user_id:
|
|
2069
|
+
id: Field5.text({ label: "Preference ID", required: true, readonly: true }),
|
|
2070
|
+
user_id: Field5.text({
|
|
1353
2071
|
label: "User",
|
|
1354
2072
|
required: true,
|
|
1355
2073
|
searchable: true,
|
|
1356
2074
|
description: "Recipient user id, or '*' for the admin-global default."
|
|
1357
2075
|
}),
|
|
1358
|
-
topic:
|
|
2076
|
+
topic: Field5.text({
|
|
1359
2077
|
label: "Topic",
|
|
1360
2078
|
required: true,
|
|
1361
2079
|
searchable: true,
|
|
1362
2080
|
defaultValue: "*",
|
|
1363
2081
|
description: "Notification topic, or '*' for all topics."
|
|
1364
2082
|
}),
|
|
1365
|
-
channel:
|
|
2083
|
+
channel: Field5.text({
|
|
1366
2084
|
label: "Channel",
|
|
1367
2085
|
required: true,
|
|
1368
2086
|
defaultValue: "*",
|
|
1369
2087
|
description: "Channel id (inbox/email/push/\u2026), or '*' for all channels."
|
|
1370
2088
|
}),
|
|
1371
|
-
enabled:
|
|
2089
|
+
enabled: Field5.boolean({
|
|
1372
2090
|
label: "Enabled",
|
|
1373
2091
|
defaultValue: true,
|
|
1374
2092
|
description: "When false, this (user, topic, channel) is muted."
|
|
1375
2093
|
}),
|
|
1376
|
-
digest:
|
|
2094
|
+
digest: Field5.select(["none", "daily", "weekly"], {
|
|
1377
2095
|
label: "Digest",
|
|
1378
2096
|
required: false,
|
|
1379
2097
|
defaultValue: "none",
|
|
1380
2098
|
description: "Batch cadence (P3 digest middleware)."
|
|
1381
2099
|
}),
|
|
1382
|
-
quiet_hours:
|
|
2100
|
+
quiet_hours: Field5.json({
|
|
1383
2101
|
label: "Quiet Hours",
|
|
1384
2102
|
required: false,
|
|
1385
2103
|
description: "Optional { tz, start, end } window (P3 quiet-hours middleware)."
|
|
1386
2104
|
}),
|
|
1387
|
-
created_at:
|
|
1388
|
-
updated_at:
|
|
2105
|
+
created_at: Field5.datetime({ label: "Created At", readonly: true }),
|
|
2106
|
+
updated_at: Field5.datetime({ label: "Updated At", required: false })
|
|
1389
2107
|
},
|
|
1390
2108
|
indexes: [
|
|
1391
2109
|
{ fields: ["user_id", "topic", "channel"], unique: true },
|
|
@@ -1394,8 +2112,8 @@ var NotificationPreference = ObjectSchema4.create({
|
|
|
1394
2112
|
});
|
|
1395
2113
|
|
|
1396
2114
|
// src/objects/notification-subscription.object.ts
|
|
1397
|
-
import { ObjectSchema as
|
|
1398
|
-
var NotificationSubscription =
|
|
2115
|
+
import { ObjectSchema as ObjectSchema6, Field as Field6 } from "@objectstack/spec/data";
|
|
2116
|
+
var NotificationSubscription = ObjectSchema6.create({
|
|
1399
2117
|
name: "sys_notification_subscription",
|
|
1400
2118
|
label: "Notification Subscription",
|
|
1401
2119
|
pluralLabel: "Notification Subscriptions",
|
|
@@ -1406,25 +2124,25 @@ var NotificationSubscription = ObjectSchema5.create({
|
|
|
1406
2124
|
titleFormat: "{principal} \xB7 {topic}",
|
|
1407
2125
|
compactLayout: ["topic", "principal", "enabled", "created_at"],
|
|
1408
2126
|
fields: {
|
|
1409
|
-
id:
|
|
1410
|
-
topic:
|
|
2127
|
+
id: Field6.text({ label: "Subscription ID", required: true, readonly: true }),
|
|
2128
|
+
topic: Field6.text({
|
|
1411
2129
|
label: "Topic",
|
|
1412
2130
|
required: true,
|
|
1413
2131
|
searchable: true,
|
|
1414
2132
|
description: "Notification topic this principal subscribes to."
|
|
1415
2133
|
}),
|
|
1416
|
-
principal:
|
|
2134
|
+
principal: Field6.text({
|
|
1417
2135
|
label: "Principal",
|
|
1418
2136
|
required: true,
|
|
1419
2137
|
searchable: true,
|
|
1420
2138
|
description: "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | bare user id."
|
|
1421
2139
|
}),
|
|
1422
|
-
enabled:
|
|
2140
|
+
enabled: Field6.boolean({
|
|
1423
2141
|
label: "Enabled",
|
|
1424
2142
|
defaultValue: true,
|
|
1425
2143
|
description: "When false, the subscription is inactive."
|
|
1426
2144
|
}),
|
|
1427
|
-
created_at:
|
|
2145
|
+
created_at: Field6.datetime({ label: "Created At", readonly: true })
|
|
1428
2146
|
},
|
|
1429
2147
|
indexes: [
|
|
1430
2148
|
{ fields: ["topic", "principal"], unique: true },
|
|
@@ -1433,8 +2151,8 @@ var NotificationSubscription = ObjectSchema5.create({
|
|
|
1433
2151
|
});
|
|
1434
2152
|
|
|
1435
2153
|
// src/objects/notification-template.object.ts
|
|
1436
|
-
import { ObjectSchema as
|
|
1437
|
-
var NotificationTemplate =
|
|
2154
|
+
import { ObjectSchema as ObjectSchema7, Field as Field7 } from "@objectstack/spec/data";
|
|
2155
|
+
var NotificationTemplate = ObjectSchema7.create({
|
|
1438
2156
|
name: "sys_notification_template",
|
|
1439
2157
|
label: "Notification Template",
|
|
1440
2158
|
pluralLabel: "Notification Templates",
|
|
@@ -1445,47 +2163,47 @@ var NotificationTemplate = ObjectSchema6.create({
|
|
|
1445
2163
|
titleFormat: "{topic} \xB7 {channel} \xB7 {locale}",
|
|
1446
2164
|
compactLayout: ["topic", "channel", "locale", "is_active"],
|
|
1447
2165
|
fields: {
|
|
1448
|
-
id:
|
|
1449
|
-
topic:
|
|
1450
|
-
channel:
|
|
2166
|
+
id: Field7.text({ label: "Template ID", required: true, readonly: true }),
|
|
2167
|
+
topic: Field7.text({ label: "Topic", required: true, searchable: true }),
|
|
2168
|
+
channel: Field7.text({
|
|
1451
2169
|
label: "Channel",
|
|
1452
2170
|
required: true,
|
|
1453
2171
|
defaultValue: "email",
|
|
1454
2172
|
description: "Channel id this template renders for (email/inbox/push/\u2026)."
|
|
1455
2173
|
}),
|
|
1456
|
-
locale:
|
|
2174
|
+
locale: Field7.text({
|
|
1457
2175
|
label: "Locale",
|
|
1458
2176
|
required: true,
|
|
1459
2177
|
defaultValue: "en",
|
|
1460
2178
|
description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'."
|
|
1461
2179
|
}),
|
|
1462
|
-
version:
|
|
2180
|
+
version: Field7.number({
|
|
1463
2181
|
label: "Version",
|
|
1464
2182
|
required: false,
|
|
1465
2183
|
defaultValue: 1
|
|
1466
2184
|
}),
|
|
1467
|
-
subject:
|
|
2185
|
+
subject: Field7.text({
|
|
1468
2186
|
label: "Subject / Title",
|
|
1469
2187
|
required: false,
|
|
1470
2188
|
description: "Rendered into the email subject / inbox title. Supports {{ payload.x }}."
|
|
1471
2189
|
}),
|
|
1472
|
-
body:
|
|
2190
|
+
body: Field7.markdown({
|
|
1473
2191
|
label: "Body",
|
|
1474
2192
|
required: false,
|
|
1475
2193
|
description: "Template body. Supports {{ payload.x }}. Interpreted per `format`."
|
|
1476
2194
|
}),
|
|
1477
|
-
format:
|
|
2195
|
+
format: Field7.select(["markdown", "html", "text", "mjml"], {
|
|
1478
2196
|
label: "Body Format",
|
|
1479
2197
|
required: false,
|
|
1480
2198
|
defaultValue: "markdown"
|
|
1481
2199
|
}),
|
|
1482
|
-
is_active:
|
|
2200
|
+
is_active: Field7.boolean({
|
|
1483
2201
|
label: "Active",
|
|
1484
2202
|
defaultValue: true,
|
|
1485
2203
|
description: "Only active templates are selected at render time."
|
|
1486
2204
|
}),
|
|
1487
|
-
created_at:
|
|
1488
|
-
updated_at:
|
|
2205
|
+
created_at: Field7.datetime({ label: "Created At", readonly: true }),
|
|
2206
|
+
updated_at: Field7.datetime({ label: "Updated At", required: false })
|
|
1489
2207
|
},
|
|
1490
2208
|
indexes: [
|
|
1491
2209
|
{ fields: ["topic", "channel", "locale"] },
|
|
@@ -1528,6 +2246,7 @@ var MessagingServicePlugin = class {
|
|
|
1528
2246
|
service.registerChannel(createInboxChannel({ getData }));
|
|
1529
2247
|
}
|
|
1530
2248
|
ctx.registerService("messaging", service);
|
|
2249
|
+
ctx.registerService("notification", service);
|
|
1531
2250
|
ctx.getService("manifest").register({
|
|
1532
2251
|
id: "com.objectstack.service.messaging",
|
|
1533
2252
|
name: "Messaging Service",
|
|
@@ -1540,7 +2259,8 @@ var MessagingServicePlugin = class {
|
|
|
1540
2259
|
NotificationDelivery,
|
|
1541
2260
|
NotificationPreference,
|
|
1542
2261
|
NotificationSubscription,
|
|
1543
|
-
NotificationTemplate
|
|
2262
|
+
NotificationTemplate,
|
|
2263
|
+
HttpDelivery
|
|
1544
2264
|
],
|
|
1545
2265
|
navigationContributions: [
|
|
1546
2266
|
{
|
|
@@ -1587,7 +2307,7 @@ var MessagingServicePlugin = class {
|
|
|
1587
2307
|
cluster = void 0;
|
|
1588
2308
|
}
|
|
1589
2309
|
this.dispatcher = new NotificationDispatcher({
|
|
1590
|
-
nodeId: `notify-${process.pid}-${
|
|
2310
|
+
nodeId: `notify-${process.pid}-${randomUUID4().slice(0, 8)}`,
|
|
1591
2311
|
outbox,
|
|
1592
2312
|
channels: service,
|
|
1593
2313
|
channelContext: { logger: ctx.logger },
|
|
@@ -1600,6 +2320,20 @@ var MessagingServicePlugin = class {
|
|
|
1600
2320
|
ctx.logger.info(
|
|
1601
2321
|
`[messaging] reliable delivery on (outbox + dispatcher, ${this.options.partitionCount} partitions${cluster ? ", clustered" : ", single-node"})`
|
|
1602
2322
|
);
|
|
2323
|
+
const httpOutbox = new SqlHttpOutbox(engine, { partitionCount: this.options.partitionCount });
|
|
2324
|
+
service.setHttpOutbox(httpOutbox);
|
|
2325
|
+
this.httpDispatcher = new HttpDispatcher({
|
|
2326
|
+
nodeId: `http-${process.pid}-${randomUUID4().slice(0, 8)}`,
|
|
2327
|
+
outbox: httpOutbox,
|
|
2328
|
+
cluster,
|
|
2329
|
+
partitionCount: this.options.partitionCount,
|
|
2330
|
+
intervalMs: this.options.dispatchIntervalMs,
|
|
2331
|
+
logger: ctx.logger
|
|
2332
|
+
});
|
|
2333
|
+
this.httpDispatcher.start();
|
|
2334
|
+
ctx.logger.info(
|
|
2335
|
+
`[messaging] HTTP delivery on (sys_http_delivery outbox + dispatcher, ${this.options.partitionCount} partitions)`
|
|
2336
|
+
);
|
|
1603
2337
|
});
|
|
1604
2338
|
}
|
|
1605
2339
|
if (this.options.retentionDays > 0 && typeof ctx.hook === "function") {
|
|
@@ -1627,6 +2361,8 @@ var MessagingServicePlugin = class {
|
|
|
1627
2361
|
async stop() {
|
|
1628
2362
|
await this.dispatcher?.stop();
|
|
1629
2363
|
this.dispatcher = void 0;
|
|
2364
|
+
await this.httpDispatcher?.stop();
|
|
2365
|
+
this.httpDispatcher = void 0;
|
|
1630
2366
|
if (this.retentionTimer) {
|
|
1631
2367
|
clearInterval(this.retentionTimer);
|
|
1632
2368
|
this.retentionTimer = void 0;
|
|
@@ -1635,7 +2371,7 @@ var MessagingServicePlugin = class {
|
|
|
1635
2371
|
};
|
|
1636
2372
|
|
|
1637
2373
|
// src/memory-outbox.ts
|
|
1638
|
-
import { randomUUID as
|
|
2374
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1639
2375
|
var MemoryNotificationOutbox = class {
|
|
1640
2376
|
constructor(partitionCount = 8, clock = () => Date.now()) {
|
|
1641
2377
|
this.partitionCount = partitionCount;
|
|
@@ -1648,7 +2384,7 @@ var MemoryNotificationOutbox = class {
|
|
|
1648
2384
|
return r.id;
|
|
1649
2385
|
}
|
|
1650
2386
|
}
|
|
1651
|
-
const id =
|
|
2387
|
+
const id = randomUUID5();
|
|
1652
2388
|
const now = this.clock();
|
|
1653
2389
|
this.rows.set(id, {
|
|
1654
2390
|
id,
|
|
@@ -1725,14 +2461,139 @@ var MemoryNotificationOutbox = class {
|
|
|
1725
2461
|
return rows.map((r) => ({ ...r }));
|
|
1726
2462
|
}
|
|
1727
2463
|
};
|
|
2464
|
+
|
|
2465
|
+
// src/memory-http-outbox.ts
|
|
2466
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
2467
|
+
var MemoryHttpOutbox = class {
|
|
2468
|
+
constructor() {
|
|
2469
|
+
this.rows = /* @__PURE__ */ new Map();
|
|
2470
|
+
/** Dedup index keyed by `${source}::${dedupKey}` -> row id. */
|
|
2471
|
+
this.dedup = /* @__PURE__ */ new Map();
|
|
2472
|
+
}
|
|
2473
|
+
async enqueue(input) {
|
|
2474
|
+
const dedupKey = `${input.source}::${input.dedupKey}`;
|
|
2475
|
+
const existing = this.dedup.get(dedupKey);
|
|
2476
|
+
if (existing) return existing;
|
|
2477
|
+
const id = randomUUID6();
|
|
2478
|
+
const now = Date.now();
|
|
2479
|
+
const row = {
|
|
2480
|
+
id,
|
|
2481
|
+
source: input.source,
|
|
2482
|
+
refId: input.refId,
|
|
2483
|
+
dedupKey: input.dedupKey,
|
|
2484
|
+
label: input.label,
|
|
2485
|
+
url: input.url,
|
|
2486
|
+
method: input.method ?? "POST",
|
|
2487
|
+
headers: input.headers,
|
|
2488
|
+
signingSecret: input.signingSecret,
|
|
2489
|
+
timeoutMs: input.timeoutMs,
|
|
2490
|
+
payload: input.payload,
|
|
2491
|
+
status: "pending",
|
|
2492
|
+
attempts: 0,
|
|
2493
|
+
createdAt: now,
|
|
2494
|
+
updatedAt: now
|
|
2495
|
+
};
|
|
2496
|
+
this.rows.set(id, row);
|
|
2497
|
+
this.dedup.set(dedupKey, id);
|
|
2498
|
+
return id;
|
|
2499
|
+
}
|
|
2500
|
+
async claim(opts) {
|
|
2501
|
+
const now = opts.now ?? Date.now();
|
|
2502
|
+
const claimed = [];
|
|
2503
|
+
for (const row of this.rows.values()) {
|
|
2504
|
+
if (row.status === "in_flight" && row.claimedAt !== void 0 && now - row.claimedAt > opts.claimTtlMs) {
|
|
2505
|
+
row.status = "pending";
|
|
2506
|
+
row.claimedBy = void 0;
|
|
2507
|
+
row.claimedAt = void 0;
|
|
2508
|
+
row.updatedAt = now;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
for (const row of this.rows.values()) {
|
|
2512
|
+
if (claimed.length >= opts.limit) break;
|
|
2513
|
+
if (row.status !== "pending") continue;
|
|
2514
|
+
if (row.nextRetryAt !== void 0 && row.nextRetryAt > now) continue;
|
|
2515
|
+
if (opts.partition) {
|
|
2516
|
+
const p = hashPartition(row.refId, opts.partition.count);
|
|
2517
|
+
if (p !== opts.partition.index) continue;
|
|
2518
|
+
}
|
|
2519
|
+
row.status = "in_flight";
|
|
2520
|
+
row.claimedBy = opts.nodeId;
|
|
2521
|
+
row.claimedAt = now;
|
|
2522
|
+
row.updatedAt = now;
|
|
2523
|
+
claimed.push({ ...row });
|
|
2524
|
+
}
|
|
2525
|
+
return claimed;
|
|
2526
|
+
}
|
|
2527
|
+
async ack(id, result) {
|
|
2528
|
+
const row = this.rows.get(id);
|
|
2529
|
+
if (!row) return;
|
|
2530
|
+
const now = Date.now();
|
|
2531
|
+
row.attempts += 1;
|
|
2532
|
+
row.lastAttemptedAt = now;
|
|
2533
|
+
row.updatedAt = now;
|
|
2534
|
+
row.claimedBy = void 0;
|
|
2535
|
+
row.claimedAt = void 0;
|
|
2536
|
+
row.responseCode = result.httpStatus;
|
|
2537
|
+
row.responseBody = result.responseBody;
|
|
2538
|
+
let status;
|
|
2539
|
+
if (result.success) {
|
|
2540
|
+
status = "success";
|
|
2541
|
+
row.nextRetryAt = void 0;
|
|
2542
|
+
row.error = void 0;
|
|
2543
|
+
} else if (result.dead) {
|
|
2544
|
+
status = "dead";
|
|
2545
|
+
row.error = result.error;
|
|
2546
|
+
row.nextRetryAt = void 0;
|
|
2547
|
+
} else {
|
|
2548
|
+
status = "pending";
|
|
2549
|
+
row.error = result.error;
|
|
2550
|
+
row.nextRetryAt = result.nextRetryAt;
|
|
2551
|
+
}
|
|
2552
|
+
row.status = status;
|
|
2553
|
+
}
|
|
2554
|
+
async list(filter) {
|
|
2555
|
+
let all = Array.from(this.rows.values()).map((r) => ({ ...r }));
|
|
2556
|
+
if (filter?.status) all = all.filter((r) => r.status === filter.status);
|
|
2557
|
+
if (filter?.source) all = all.filter((r) => r.source === filter.source);
|
|
2558
|
+
return all;
|
|
2559
|
+
}
|
|
2560
|
+
async redeliver(id) {
|
|
2561
|
+
const row = this.rows.get(id);
|
|
2562
|
+
if (!row) {
|
|
2563
|
+
throw new HttpRedeliverError(`Delivery row '${id}' not found`, "not_found");
|
|
2564
|
+
}
|
|
2565
|
+
if (row.status !== "success" && row.status !== "failed" && row.status !== "dead") {
|
|
2566
|
+
throw new HttpRedeliverError(
|
|
2567
|
+
`Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
|
|
2568
|
+
"not_eligible"
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
const now = Date.now();
|
|
2572
|
+
row.status = "pending";
|
|
2573
|
+
row.attempts = 0;
|
|
2574
|
+
row.claimedBy = void 0;
|
|
2575
|
+
row.claimedAt = void 0;
|
|
2576
|
+
row.nextRetryAt = void 0;
|
|
2577
|
+
row.error = void 0;
|
|
2578
|
+
row.responseCode = void 0;
|
|
2579
|
+
row.responseBody = void 0;
|
|
2580
|
+
row.updatedAt = now;
|
|
2581
|
+
return { ...row };
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
1728
2584
|
export {
|
|
2585
|
+
DEFAULT_HTTP_TIMEOUT_MS,
|
|
1729
2586
|
DEFAULT_LOCALE,
|
|
1730
2587
|
DEFAULT_RETENTION_TARGETS,
|
|
1731
2588
|
DELIVERY_OBJECT,
|
|
1732
2589
|
USER_OBJECT2 as EMAIL_USER_OBJECT,
|
|
2590
|
+
HttpDelivery,
|
|
2591
|
+
HttpDispatcher,
|
|
2592
|
+
HttpRedeliverError,
|
|
1733
2593
|
INBOX_OBJECT,
|
|
1734
2594
|
InboxMessage,
|
|
1735
2595
|
MEMBER_OBJECT,
|
|
2596
|
+
MemoryHttpOutbox,
|
|
1736
2597
|
MemoryNotificationOutbox,
|
|
1737
2598
|
MessagingService,
|
|
1738
2599
|
MessagingServicePlugin,
|
|
@@ -1749,17 +2610,23 @@ export {
|
|
|
1749
2610
|
PreferenceResolver,
|
|
1750
2611
|
RECEIPT_OBJECT,
|
|
1751
2612
|
RecipientResolver,
|
|
2613
|
+
SYS_HTTP_DELIVERY,
|
|
2614
|
+
SqlHttpOutbox,
|
|
1752
2615
|
SqlNotificationOutbox,
|
|
1753
2616
|
TEAM_MEMBER_OBJECT,
|
|
1754
2617
|
TEMPLATE_OBJECT,
|
|
1755
2618
|
USER_OBJECT,
|
|
1756
2619
|
classifyDeliveryAttempt,
|
|
2620
|
+
classifyAttempt as classifyHttpAttempt,
|
|
1757
2621
|
createEmailChannel,
|
|
1758
2622
|
createInboxChannel,
|
|
1759
2623
|
hashPartition,
|
|
1760
2624
|
interpolate,
|
|
2625
|
+
newDeliveryId as newHttpDeliveryId,
|
|
2626
|
+
nextHttpRetryDelayMs,
|
|
1761
2627
|
nextRetryDelayMs,
|
|
1762
2628
|
quietHoursDeferral,
|
|
1763
|
-
renderNotification
|
|
2629
|
+
renderNotification,
|
|
2630
|
+
sendOnce as sendHttpOnce
|
|
1764
2631
|
};
|
|
1765
2632
|
//# sourceMappingURL=index.js.map
|