@objectstack/service-messaging 7.5.0 → 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 CHANGED
@@ -20,13 +20,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_HTTP_TIMEOUT_MS: () => DEFAULT_HTTP_TIMEOUT_MS,
23
24
  DEFAULT_LOCALE: () => DEFAULT_LOCALE,
24
25
  DEFAULT_RETENTION_TARGETS: () => DEFAULT_RETENTION_TARGETS,
25
26
  DELIVERY_OBJECT: () => DELIVERY_OBJECT,
26
27
  EMAIL_USER_OBJECT: () => USER_OBJECT2,
28
+ HttpDelivery: () => HttpDelivery,
29
+ HttpDispatcher: () => HttpDispatcher,
30
+ HttpRedeliverError: () => HttpRedeliverError,
27
31
  INBOX_OBJECT: () => INBOX_OBJECT,
28
32
  InboxMessage: () => InboxMessage,
29
33
  MEMBER_OBJECT: () => MEMBER_OBJECT,
34
+ MemoryHttpOutbox: () => MemoryHttpOutbox,
30
35
  MemoryNotificationOutbox: () => MemoryNotificationOutbox,
31
36
  MessagingService: () => MessagingService,
32
37
  MessagingServicePlugin: () => MessagingServicePlugin,
@@ -43,23 +48,29 @@ __export(index_exports, {
43
48
  PreferenceResolver: () => PreferenceResolver,
44
49
  RECEIPT_OBJECT: () => RECEIPT_OBJECT,
45
50
  RecipientResolver: () => RecipientResolver,
51
+ SYS_HTTP_DELIVERY: () => SYS_HTTP_DELIVERY,
52
+ SqlHttpOutbox: () => SqlHttpOutbox,
46
53
  SqlNotificationOutbox: () => SqlNotificationOutbox,
47
54
  TEAM_MEMBER_OBJECT: () => TEAM_MEMBER_OBJECT,
48
55
  TEMPLATE_OBJECT: () => TEMPLATE_OBJECT,
49
56
  USER_OBJECT: () => USER_OBJECT,
50
57
  classifyDeliveryAttempt: () => classifyDeliveryAttempt,
58
+ classifyHttpAttempt: () => classifyAttempt,
51
59
  createEmailChannel: () => createEmailChannel,
52
60
  createInboxChannel: () => createInboxChannel,
53
61
  hashPartition: () => hashPartition,
54
62
  interpolate: () => interpolate,
63
+ newHttpDeliveryId: () => newDeliveryId,
64
+ nextHttpRetryDelayMs: () => nextHttpRetryDelayMs,
55
65
  nextRetryDelayMs: () => nextRetryDelayMs,
56
66
  quietHoursDeferral: () => quietHoursDeferral,
57
- renderNotification: () => renderNotification
67
+ renderNotification: () => renderNotification,
68
+ sendHttpOnce: () => sendOnce
58
69
  });
59
70
  module.exports = __toCommonJS(index_exports);
60
71
 
61
72
  // src/messaging-service-plugin.ts
62
- var import_node_crypto2 = require("crypto");
73
+ var import_node_crypto4 = require("crypto");
63
74
 
64
75
  // src/recipient-resolver.ts
65
76
  function looksLikeEmail(s) {
@@ -350,8 +361,81 @@ function msg2(err) {
350
361
  return err?.message ?? String(err);
351
362
  }
352
363
 
364
+ // src/inbox-channel.ts
365
+ var INBOX_OBJECT = "sys_inbox_message";
366
+ var RECEIPT_OBJECT = "sys_notification_receipt";
367
+ function createInboxChannel(opts) {
368
+ const objectName = opts.objectName ?? INBOX_OBJECT;
369
+ const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT;
370
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
371
+ async function writeDeliveredReceipt(ctx, data, r) {
372
+ if (!r.notificationId) return;
373
+ try {
374
+ await data.insert(receiptObject, {
375
+ notification_id: r.notificationId,
376
+ delivery_id: null,
377
+ user_id: r.userId,
378
+ channel: "inbox",
379
+ state: "delivered",
380
+ at: r.at,
381
+ organization_id: r.organizationId ?? null,
382
+ created_at: r.at
383
+ });
384
+ } catch (err) {
385
+ ctx.logger.warn(
386
+ `[inbox] delivered receipt write failed for '${r.userId}' (${err.message}); inbox row stands`
387
+ );
388
+ }
389
+ }
390
+ return {
391
+ id: "inbox",
392
+ async send(ctx, delivery) {
393
+ const data = opts.getData();
394
+ const n = delivery.notification;
395
+ if (!data) {
396
+ ctx.logger.warn(
397
+ `[inbox] no data engine registered; inbox row for '${delivery.recipient}' not persisted`
398
+ );
399
+ return { ok: true };
400
+ }
401
+ const userId = delivery.recipient;
402
+ const at = now();
403
+ const row = {
404
+ user_id: userId,
405
+ notification_id: n.notificationId ?? null,
406
+ topic: n.topic,
407
+ title: n.title,
408
+ body_md: n.body,
409
+ severity: n.severity ?? "info",
410
+ action_url: n.actionUrl,
411
+ organization_id: n.organizationId ?? null,
412
+ created_at: at
413
+ };
414
+ let inboxId;
415
+ try {
416
+ const created = await data.insert(objectName, row);
417
+ const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
418
+ inboxId = id != null ? String(id) : void 0;
419
+ } catch (err) {
420
+ return { ok: false, error: `inbox insert failed: ${err.message}` };
421
+ }
422
+ await writeDeliveredReceipt(ctx, data, {
423
+ notificationId: n.notificationId,
424
+ userId,
425
+ organizationId: n.organizationId,
426
+ at
427
+ });
428
+ return { ok: true, externalId: inboxId };
429
+ },
430
+ classifyError(_err) {
431
+ return "retryable";
432
+ }
433
+ };
434
+ }
435
+
353
436
  // src/messaging-service.ts
354
437
  var NOTIFICATION_EVENT_OBJECT = "sys_notification";
438
+ var READ_RECEIPT_STATES = /* @__PURE__ */ new Set(["read", "clicked", "dismissed"]);
355
439
  var MessagingService = class {
356
440
  constructor(ctx) {
357
441
  this.ctx = ctx;
@@ -373,6 +457,49 @@ var MessagingService = class {
373
457
  setOutbox(outbox) {
374
458
  this.outbox = outbox;
375
459
  }
460
+ /**
461
+ * Attach the generic outbound-HTTP delivery outbox (ADR-0018 M3). Wired by
462
+ * the plugin at `kernel:ready` once the data engine is resolvable. Once set,
463
+ * {@link enqueueHttp} persists durable rows the {@link HttpDispatcher}
464
+ * drains with retry / dead-letter; the Flow `http` node enqueues through it.
465
+ */
466
+ setHttpOutbox(outbox) {
467
+ this.httpOutbox = outbox;
468
+ }
469
+ /**
470
+ * Whether durable HTTP delivery is available. Callers (e.g. the `http` node)
471
+ * fall back to a direct, non-durable send when this is `false`.
472
+ */
473
+ isHttpDeliveryReady() {
474
+ return this.httpOutbox !== void 0;
475
+ }
476
+ /**
477
+ * Enqueue a durable outbound-HTTP delivery (ADR-0018 M3). Returns the row id.
478
+ * Throws if no HTTP outbox is wired — guard with {@link isHttpDeliveryReady}.
479
+ */
480
+ async enqueueHttp(input) {
481
+ if (!this.httpOutbox) {
482
+ throw new Error("messaging: HTTP delivery outbox not configured (no data engine / reliableDelivery off)");
483
+ }
484
+ return this.httpOutbox.enqueue(input);
485
+ }
486
+ /**
487
+ * Reset a terminal HTTP delivery row back to `pending` so the dispatcher
488
+ * re-sends it (ADR-0018 M3). Backs the webhook redeliver admin endpoint.
489
+ * Throws if no HTTP outbox is wired, or `HttpRedeliverError` for a missing /
490
+ * non-terminal row.
491
+ */
492
+ async redeliverHttp(id) {
493
+ if (!this.httpOutbox) {
494
+ throw new Error("messaging: HTTP delivery outbox not configured");
495
+ }
496
+ return this.httpOutbox.redeliver(id);
497
+ }
498
+ /** List HTTP delivery rows (admin/tests). Empty when no outbox is wired. */
499
+ async listHttp(filter) {
500
+ if (!this.httpOutbox) return [];
501
+ return this.httpOutbox.list(filter);
502
+ }
376
503
  /** Register a channel implementation. A duplicate id warns and replaces. */
377
504
  registerChannel(channel) {
378
505
  if (this.channels.has(channel.id)) {
@@ -393,6 +520,119 @@ var MessagingService = class {
393
520
  getRegisteredChannels() {
394
521
  return [...this.channels.keys()];
395
522
  }
523
+ /* ------------------------------------------------------------------ */
524
+ /* Inbox read API (ADR-0030) — backs /api/v1/notifications */
525
+ /* */
526
+ /* The REST notification surface reads the L5 `sys_inbox_message` */
527
+ /* materialization joined with the `sys_notification_receipt` */
528
+ /* read-state spine — NOT the re-modeled `sys_notification` L2 event */
529
+ /* (which carries no recipient/read columns). Mark-read upserts the */
530
+ /* receipt, keyed `(notification_id, user_id, channel:'inbox')`. */
531
+ /* ------------------------------------------------------------------ */
532
+ /**
533
+ * List the signed-in user's in-app inbox, joined with read-state.
534
+ *
535
+ * A message is unread until its event has a `read`/`clicked`/`dismissed`
536
+ * receipt; the `read` filter (when given) is applied in-memory after the
537
+ * join. `unreadCount` is computed over the fetched window (bounded by
538
+ * `limit`, like the Console bell's poll). Returns the REST contract shape
539
+ * (`ListNotificationsResponseSchema`): `{ notifications, unreadCount }`.
540
+ */
541
+ async listInbox(userId, opts = {}) {
542
+ const data = this.ctx.getData?.();
543
+ if (!data || !userId) return { notifications: [], unreadCount: 0 };
544
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
545
+ const where = { user_id: userId };
546
+ if (opts.type) where.topic = opts.type;
547
+ const [rows, receipts] = await Promise.all([
548
+ data.find(INBOX_OBJECT, { where, orderBy: [{ field: "created_at", order: "desc" }], limit }),
549
+ // Read-state spine. Best-effort: if receipts are unavailable the
550
+ // inbox still lists (everything reads as unread) rather than erroring.
551
+ data.find(RECEIPT_OBJECT, { where: { user_id: userId, channel: "inbox" } }).catch(() => [])
552
+ ]);
553
+ const stateByNotif = /* @__PURE__ */ new Map();
554
+ for (const r of receipts) {
555
+ const nid = r?.notification_id != null ? String(r.notification_id) : "";
556
+ if (!nid) continue;
557
+ const state = String(r.state ?? "delivered");
558
+ const prev = stateByNotif.get(nid);
559
+ if (!prev || !READ_RECEIPT_STATES.has(prev) && READ_RECEIPT_STATES.has(state)) {
560
+ stateByNotif.set(nid, state);
561
+ }
562
+ }
563
+ let unreadCount = 0;
564
+ const all = rows.map((m) => {
565
+ const nid = m?.notification_id != null ? String(m.notification_id) : null;
566
+ const state = nid ? stateByNotif.get(nid) : void 0;
567
+ const read = state ? READ_RECEIPT_STATES.has(state) : false;
568
+ if (!read) unreadCount += 1;
569
+ return {
570
+ id: nid ?? String(m.id),
571
+ type: m.topic ?? "notification",
572
+ title: m.title ?? "",
573
+ body: m.body_md ?? "",
574
+ read,
575
+ actionUrl: m.action_url ?? void 0,
576
+ createdAt: m.created_at ?? this.now()
577
+ };
578
+ });
579
+ const notifications = opts.read === void 0 ? all : all.filter((n) => n.read === opts.read);
580
+ return { notifications, unreadCount };
581
+ }
582
+ /**
583
+ * Mark specific notifications read by upserting their inbox receipts to
584
+ * `read`. Updates the existing `delivered` receipt in place (keyed
585
+ * `(notification_id, user_id, channel:'inbox')`); inserts one only when
586
+ * absent. `ids` are notification (event) ids. Returns the REST contract
587
+ * shape (`MarkNotificationsReadResponseSchema`): `{ success, readCount }`.
588
+ */
589
+ async markRead(userId, ids) {
590
+ const data = this.ctx.getData?.();
591
+ if (!data || !userId || !ids?.length) return { success: true, readCount: 0 };
592
+ const at = this.now();
593
+ let readCount = 0;
594
+ for (const id of ids) {
595
+ const nid = String(id ?? "");
596
+ if (!nid) continue;
597
+ try {
598
+ readCount += await this.upsertReadReceipt(data, userId, nid, at);
599
+ } catch (err) {
600
+ this.ctx.logger.warn(`[messaging] markRead failed for '${nid}': ${err.message}`);
601
+ }
602
+ }
603
+ return { success: true, readCount };
604
+ }
605
+ /**
606
+ * Mark every currently-unread inbox message for the user as read. Returns
607
+ * `{ success, readCount }` (`MarkAllNotificationsReadResponseSchema`).
608
+ */
609
+ async markAllRead(userId) {
610
+ const data = this.ctx.getData?.();
611
+ if (!data || !userId) return { success: true, readCount: 0 };
612
+ const { notifications } = await this.listInbox(userId, { read: false, limit: 200 });
613
+ return this.markRead(userId, notifications.map((n) => n.id));
614
+ }
615
+ /** Upsert a `read` receipt for one notification; returns 1 when it persisted. */
616
+ async upsertReadReceipt(data, userId, notificationId, at) {
617
+ const existing = await data.findOne(RECEIPT_OBJECT, {
618
+ where: { notification_id: notificationId, user_id: userId, channel: "inbox" },
619
+ fields: ["id"]
620
+ });
621
+ if (existing?.id) {
622
+ await data.update(RECEIPT_OBJECT, { state: "read", at }, { where: { id: existing.id } });
623
+ } else {
624
+ await data.insert(RECEIPT_OBJECT, {
625
+ notification_id: notificationId,
626
+ delivery_id: null,
627
+ user_id: userId,
628
+ channel: "inbox",
629
+ state: "read",
630
+ at,
631
+ created_at: at
632
+ });
633
+ }
634
+ return 1;
635
+ }
396
636
  /**
397
637
  * The single notification ingress. Writes the L2 event, resolves the
398
638
  * audience, and fans the result out to its channels. An unregistered
@@ -600,78 +840,6 @@ function actionUrlFor(input, payload) {
600
840
  return obj && id ? `/${obj}/${id}` : void 0;
601
841
  }
602
842
 
603
- // src/inbox-channel.ts
604
- var INBOX_OBJECT = "sys_inbox_message";
605
- var RECEIPT_OBJECT = "sys_notification_receipt";
606
- function createInboxChannel(opts) {
607
- const objectName = opts.objectName ?? INBOX_OBJECT;
608
- const receiptObject = opts.receiptObject ?? RECEIPT_OBJECT;
609
- const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
610
- async function writeDeliveredReceipt(ctx, data, r) {
611
- if (!r.notificationId) return;
612
- try {
613
- await data.insert(receiptObject, {
614
- notification_id: r.notificationId,
615
- delivery_id: null,
616
- user_id: r.userId,
617
- channel: "inbox",
618
- state: "delivered",
619
- at: r.at,
620
- organization_id: r.organizationId ?? null,
621
- created_at: r.at
622
- });
623
- } catch (err) {
624
- ctx.logger.warn(
625
- `[inbox] delivered receipt write failed for '${r.userId}' (${err.message}); inbox row stands`
626
- );
627
- }
628
- }
629
- return {
630
- id: "inbox",
631
- async send(ctx, delivery) {
632
- const data = opts.getData();
633
- const n = delivery.notification;
634
- if (!data) {
635
- ctx.logger.warn(
636
- `[inbox] no data engine registered; inbox row for '${delivery.recipient}' not persisted`
637
- );
638
- return { ok: true };
639
- }
640
- const userId = delivery.recipient;
641
- const at = now();
642
- const row = {
643
- user_id: userId,
644
- notification_id: n.notificationId ?? null,
645
- topic: n.topic,
646
- title: n.title,
647
- body_md: n.body,
648
- severity: n.severity ?? "info",
649
- action_url: n.actionUrl,
650
- organization_id: n.organizationId ?? null,
651
- created_at: at
652
- };
653
- let inboxId;
654
- try {
655
- const created = await data.insert(objectName, row);
656
- const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
657
- inboxId = id != null ? String(id) : void 0;
658
- } catch (err) {
659
- return { ok: false, error: `inbox insert failed: ${err.message}` };
660
- }
661
- await writeDeliveredReceipt(ctx, data, {
662
- notificationId: n.notificationId,
663
- userId,
664
- organizationId: n.organizationId,
665
- at
666
- });
667
- return { ok: true, externalId: inboxId };
668
- },
669
- classifyError(_err) {
670
- return "retryable";
671
- }
672
- };
673
- }
674
-
675
843
  // src/sql-outbox.ts
676
844
  var import_node_crypto = require("crypto");
677
845
 
@@ -737,9 +905,297 @@ var SqlNotificationOutbox = class {
737
905
  partition_key: hashPartition(input.notificationId, this.partitionCount),
738
906
  status: "pending",
739
907
  attempts: 0,
740
- // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
741
- // whose next_attempt_at is in the future.
742
- next_attempt_at: input.notBefore ?? null,
908
+ // Deferred dispatch (quiet-hours, P3): claim() skips pending rows
909
+ // whose next_attempt_at is in the future.
910
+ next_attempt_at: input.notBefore ?? null,
911
+ created_at: now,
912
+ updated_at: now
913
+ };
914
+ try {
915
+ await this.engine.insert(this.objectName, row);
916
+ return id;
917
+ } catch (err) {
918
+ const winner = await this.engine.findOne(this.objectName, { where: dedup, fields: ["id"] });
919
+ if (winner?.id) return String(winner.id);
920
+ throw err;
921
+ }
922
+ }
923
+ async claim(opts) {
924
+ const now = opts.now ?? Date.now();
925
+ await this.engine.update(
926
+ this.objectName,
927
+ { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
928
+ { where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
929
+ );
930
+ const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
931
+ const candidates = await this.engine.find(this.objectName, {
932
+ where: {
933
+ status: "pending",
934
+ ...partitionFilter,
935
+ $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
936
+ },
937
+ fields: ["id"],
938
+ limit: opts.limit
939
+ });
940
+ if (!candidates.length) return [];
941
+ const ids = candidates.map((c) => c.id);
942
+ await this.engine.update(
943
+ this.objectName,
944
+ { status: "in_flight", claimed_by: opts.nodeId, claimed_at: now, updated_at: now },
945
+ { where: { id: { $in: ids }, status: "pending" }, multi: true }
946
+ );
947
+ const claimed = await this.engine.find(this.objectName, {
948
+ where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
949
+ });
950
+ return claimed.map((r) => this.toRecord(r));
951
+ }
952
+ async ack(id, result) {
953
+ const current = await this.engine.findOne(this.objectName, {
954
+ where: { id },
955
+ fields: ["attempts"]
956
+ });
957
+ if (!current) return;
958
+ const now = Date.now();
959
+ let status;
960
+ let nextAttemptAt = null;
961
+ let error = null;
962
+ if (result.success) {
963
+ status = "success";
964
+ } else if (result.suppressed) {
965
+ status = "suppressed";
966
+ error = result.error ?? null;
967
+ } else if (result.dead) {
968
+ status = "dead";
969
+ error = result.error ?? null;
970
+ } else {
971
+ status = "pending";
972
+ nextAttemptAt = result.nextAttemptAt ?? null;
973
+ error = result.error ?? null;
974
+ }
975
+ await this.engine.update(
976
+ this.objectName,
977
+ {
978
+ status,
979
+ attempts: (current.attempts ?? 0) + 1,
980
+ last_attempted_at: now,
981
+ claimed_by: null,
982
+ claimed_at: null,
983
+ next_attempt_at: nextAttemptAt,
984
+ error,
985
+ updated_at: now
986
+ },
987
+ { where: { id }, multi: false }
988
+ );
989
+ }
990
+ async list(filter) {
991
+ const where = {};
992
+ if (filter?.status) where.status = filter.status;
993
+ if (filter?.notificationId) where.notification_id = filter.notificationId;
994
+ const rows = await this.engine.find(this.objectName, { where });
995
+ return rows.map((r) => this.toRecord(r));
996
+ }
997
+ toRecord(r) {
998
+ let payload = r.payload ?? {};
999
+ if (typeof payload === "string") {
1000
+ try {
1001
+ payload = JSON.parse(payload);
1002
+ } catch {
1003
+ payload = {};
1004
+ }
1005
+ }
1006
+ return {
1007
+ id: r.id,
1008
+ notificationId: r.notification_id,
1009
+ recipientId: r.recipient_id,
1010
+ channel: r.channel,
1011
+ topic: r.topic ?? void 0,
1012
+ payload,
1013
+ organizationId: r.organization_id ?? void 0,
1014
+ partitionKey: r.partition_key,
1015
+ status: r.status,
1016
+ attempts: r.attempts,
1017
+ claimedBy: r.claimed_by ?? void 0,
1018
+ claimedAt: r.claimed_at ?? void 0,
1019
+ nextAttemptAt: r.next_attempt_at ?? void 0,
1020
+ lastAttemptedAt: r.last_attempted_at ?? void 0,
1021
+ error: r.error ?? void 0,
1022
+ createdAt: r.created_at,
1023
+ updatedAt: r.updated_at
1024
+ };
1025
+ }
1026
+ };
1027
+
1028
+ // src/sql-http-outbox.ts
1029
+ var import_node_crypto2 = require("crypto");
1030
+
1031
+ // src/http-outbox.ts
1032
+ var HttpRedeliverError = class extends Error {
1033
+ constructor(message, code) {
1034
+ super(message);
1035
+ this.code = code;
1036
+ this.name = "HttpRedeliverError";
1037
+ }
1038
+ };
1039
+
1040
+ // src/objects/http-delivery.object.ts
1041
+ var import_data = require("@objectstack/spec/data");
1042
+ var HttpDelivery = import_data.ObjectSchema.create({
1043
+ name: "sys_http_delivery",
1044
+ label: "HTTP Delivery",
1045
+ pluralLabel: "HTTP Deliveries",
1046
+ icon: "globe",
1047
+ isSystem: true,
1048
+ managedBy: "system",
1049
+ userActions: { create: false, edit: false, delete: false, import: false },
1050
+ description: "Durable outbox row for one outbound-HTTP attempt (ADR-0018). Managed by @objectstack/service-messaging; do not write directly.",
1051
+ displayNameField: "id",
1052
+ titleFormat: "{label} \u2192 {url}",
1053
+ compactLayout: ["source", "url", "status", "attempts", "next_retry_at"],
1054
+ listViews: {
1055
+ recent: {
1056
+ type: "grid",
1057
+ name: "recent",
1058
+ label: "Recent",
1059
+ data: { provider: "object", object: "sys_http_delivery" },
1060
+ columns: ["source", "label", "url", "status", "attempts", "response_code", "updated_at"],
1061
+ sort: [{ field: "updated_at", order: "desc" }],
1062
+ pagination: { pageSize: 50 }
1063
+ },
1064
+ failures: {
1065
+ type: "grid",
1066
+ name: "failures",
1067
+ label: "Failures",
1068
+ data: { provider: "object", object: "sys_http_delivery" },
1069
+ columns: ["source", "url", "status", "attempts", "response_code", "error", "updated_at"],
1070
+ filter: [{ field: "status", operator: "in", value: ["failed", "dead"] }],
1071
+ sort: [{ field: "updated_at", order: "desc" }],
1072
+ pagination: { pageSize: 50 }
1073
+ },
1074
+ pending: {
1075
+ type: "grid",
1076
+ name: "pending",
1077
+ label: "Pending",
1078
+ data: { provider: "object", object: "sys_http_delivery" },
1079
+ columns: ["source", "url", "attempts", "next_retry_at", "updated_at"],
1080
+ filter: [{ field: "status", operator: "equals", value: "pending" }],
1081
+ sort: [{ field: "next_retry_at", order: "asc" }],
1082
+ pagination: { pageSize: 50 }
1083
+ }
1084
+ },
1085
+ fields: {
1086
+ id: import_data.Field.text({
1087
+ label: "Delivery ID",
1088
+ required: true,
1089
+ maxLength: 64,
1090
+ description: "UUID \u2014 also doubles as the receiver-side idempotency key"
1091
+ }),
1092
+ source: import_data.Field.text({
1093
+ label: "Source",
1094
+ required: true,
1095
+ maxLength: 32,
1096
+ description: "Provenance domain, e.g. 'webhook' | 'flow'. UNIQUE(source, dedup_key)."
1097
+ }),
1098
+ ref_id: import_data.Field.text({
1099
+ label: "Ref ID",
1100
+ required: true,
1101
+ maxLength: 128,
1102
+ description: "Partition/ordering anchor within source (webhook id, flow id, \u2026)"
1103
+ }),
1104
+ dedup_key: import_data.Field.text({
1105
+ label: "Dedup Key",
1106
+ required: true,
1107
+ maxLength: 191,
1108
+ description: "UNIQUE(source, dedup_key) for at-most-once enqueue"
1109
+ }),
1110
+ label: import_data.Field.text({
1111
+ label: "Label",
1112
+ required: false,
1113
+ maxLength: 191,
1114
+ description: "Diagnostic label / event type \u2014 surfaced on X-Objectstack-Event"
1115
+ }),
1116
+ url: import_data.Field.text({
1117
+ label: "Target URL",
1118
+ required: true,
1119
+ maxLength: 2048,
1120
+ description: "Snapshotted at enqueue so config edits do not rewrite live rows"
1121
+ }),
1122
+ method: import_data.Field.text({ label: "Method", required: false, maxLength: 10 }),
1123
+ headers_json: import_data.Field.textarea({ label: "Headers JSON", required: false }),
1124
+ signing_secret: import_data.Field.text({ label: "HMAC Secret", required: false, maxLength: 256 }),
1125
+ timeout_ms: import_data.Field.number({ label: "Timeout (ms)", required: false }),
1126
+ payload_json: import_data.Field.textarea({ label: "Payload JSON", required: true }),
1127
+ partition_key: import_data.Field.number({
1128
+ label: "Partition",
1129
+ required: true,
1130
+ description: "hash(ref_id) mod partitionCount \u2014 precomputed for cheap WHERE"
1131
+ }),
1132
+ status: import_data.Field.text({
1133
+ label: "Status",
1134
+ required: true,
1135
+ defaultValue: "pending",
1136
+ maxLength: 16,
1137
+ description: "pending | in_flight | success | failed | dead"
1138
+ }),
1139
+ attempts: import_data.Field.number({
1140
+ label: "Attempts",
1141
+ required: true,
1142
+ defaultValue: 0,
1143
+ description: "Number of attempts made so far"
1144
+ }),
1145
+ claimed_by: import_data.Field.text({ label: "Claimed By", required: false, maxLength: 128 }),
1146
+ claimed_at: import_data.Field.number({ label: "Claimed At (ms)", required: false }),
1147
+ next_retry_at: import_data.Field.number({ label: "Next Retry At (ms)", required: false }),
1148
+ last_attempted_at: import_data.Field.number({ label: "Last Attempted At (ms)", required: false }),
1149
+ response_code: import_data.Field.number({ label: "HTTP Status", required: false }),
1150
+ response_body: import_data.Field.textarea({ label: "Response Body (capped)", required: false }),
1151
+ error: import_data.Field.textarea({ label: "Error", required: false }),
1152
+ created_at: import_data.Field.number({ label: "Created At (ms)", required: true }),
1153
+ updated_at: import_data.Field.number({ label: "Updated At (ms)", required: true })
1154
+ },
1155
+ indexes: [
1156
+ { fields: ["source", "dedup_key"], unique: true },
1157
+ // Hot path: claim query
1158
+ { fields: ["status", "partition_key", "next_retry_at"] },
1159
+ // Reaper: scan stale in_flight rows by claimed_at
1160
+ { fields: ["status", "claimed_at"] },
1161
+ { fields: ["source", "ref_id"] }
1162
+ ]
1163
+ });
1164
+ var SYS_HTTP_DELIVERY = "sys_http_delivery";
1165
+
1166
+ // src/sql-http-outbox.ts
1167
+ var SqlHttpOutbox = class {
1168
+ constructor(engine, opts) {
1169
+ this.engine = engine;
1170
+ if (opts.partitionCount <= 0) {
1171
+ throw new Error("SqlHttpOutbox: partitionCount must be > 0");
1172
+ }
1173
+ this.objectName = opts.objectName ?? SYS_HTTP_DELIVERY;
1174
+ this.partitionCount = opts.partitionCount;
1175
+ }
1176
+ async enqueue(input) {
1177
+ const existing = await this.engine.findOne(this.objectName, {
1178
+ where: { source: input.source, dedup_key: input.dedupKey },
1179
+ fields: ["id"]
1180
+ });
1181
+ if (existing?.id) return existing.id;
1182
+ const id = (0, import_node_crypto2.randomUUID)();
1183
+ const now = Date.now();
1184
+ const row = {
1185
+ id,
1186
+ source: input.source,
1187
+ ref_id: input.refId,
1188
+ dedup_key: input.dedupKey,
1189
+ label: input.label,
1190
+ url: input.url,
1191
+ method: input.method ?? "POST",
1192
+ headers_json: input.headers ? JSON.stringify(input.headers) : void 0,
1193
+ signing_secret: input.signingSecret,
1194
+ timeout_ms: input.timeoutMs,
1195
+ payload_json: JSON.stringify(input.payload ?? null),
1196
+ partition_key: hashPartition(input.refId, this.partitionCount),
1197
+ status: "pending",
1198
+ attempts: 0,
743
1199
  created_at: now,
744
1200
  updated_at: now
745
1201
  };
@@ -747,8 +1203,11 @@ var SqlNotificationOutbox = class {
747
1203
  await this.engine.insert(this.objectName, row);
748
1204
  return id;
749
1205
  } catch (err) {
750
- const winner = await this.engine.findOne(this.objectName, { where: dedup, fields: ["id"] });
751
- if (winner?.id) return String(winner.id);
1206
+ const winner = await this.engine.findOne(this.objectName, {
1207
+ where: { source: input.source, dedup_key: input.dedupKey },
1208
+ fields: ["id"]
1209
+ });
1210
+ if (winner?.id) return winner.id;
752
1211
  throw err;
753
1212
  }
754
1213
  }
@@ -757,19 +1216,25 @@ var SqlNotificationOutbox = class {
757
1216
  await this.engine.update(
758
1217
  this.objectName,
759
1218
  { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
760
- { where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
1219
+ {
1220
+ where: {
1221
+ status: "in_flight",
1222
+ claimed_at: { $lt: now - opts.claimTtlMs }
1223
+ },
1224
+ multi: true
1225
+ }
761
1226
  );
762
1227
  const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
763
1228
  const candidates = await this.engine.find(this.objectName, {
764
1229
  where: {
765
1230
  status: "pending",
766
1231
  ...partitionFilter,
767
- $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
1232
+ $or: [{ next_retry_at: null }, { next_retry_at: { $lte: now } }]
768
1233
  },
769
1234
  fields: ["id"],
770
1235
  limit: opts.limit
771
1236
  });
772
- if (!candidates.length) return [];
1237
+ if (candidates.length === 0) return [];
773
1238
  const ids = candidates.map((c) => c.id);
774
1239
  await this.engine.update(
775
1240
  this.objectName,
@@ -779,7 +1244,7 @@ var SqlNotificationOutbox = class {
779
1244
  const claimed = await this.engine.find(this.objectName, {
780
1245
  where: { id: { $in: ids }, claimed_by: opts.nodeId, claimed_at: now, status: "in_flight" }
781
1246
  });
782
- return claimed.map((r) => this.toRecord(r));
1247
+ return claimed.map((r) => this.toDelivery(r));
783
1248
  }
784
1249
  async ack(id, result) {
785
1250
  const current = await this.engine.findOne(this.objectName, {
@@ -789,19 +1254,19 @@ var SqlNotificationOutbox = class {
789
1254
  if (!current) return;
790
1255
  const now = Date.now();
791
1256
  let status;
792
- let nextAttemptAt = null;
793
- let error = null;
1257
+ let nextRetryAt;
1258
+ let error;
794
1259
  if (result.success) {
795
1260
  status = "success";
796
- } else if (result.suppressed) {
797
- status = "suppressed";
798
- error = result.error ?? null;
1261
+ nextRetryAt = null;
1262
+ error = null;
799
1263
  } else if (result.dead) {
800
1264
  status = "dead";
1265
+ nextRetryAt = null;
801
1266
  error = result.error ?? null;
802
1267
  } else {
803
1268
  status = "pending";
804
- nextAttemptAt = result.nextAttemptAt ?? null;
1269
+ nextRetryAt = result.nextRetryAt ?? null;
805
1270
  error = result.error ?? null;
806
1271
  }
807
1272
  await this.engine.update(
@@ -812,7 +1277,9 @@ var SqlNotificationOutbox = class {
812
1277
  last_attempted_at: now,
813
1278
  claimed_by: null,
814
1279
  claimed_at: null,
815
- next_attempt_at: nextAttemptAt,
1280
+ response_code: result.httpStatus ?? null,
1281
+ response_body: result.responseBody ?? null,
1282
+ next_retry_at: nextRetryAt,
816
1283
  error,
817
1284
  updated_at: now
818
1285
  },
@@ -822,34 +1289,65 @@ var SqlNotificationOutbox = class {
822
1289
  async list(filter) {
823
1290
  const where = {};
824
1291
  if (filter?.status) where.status = filter.status;
825
- if (filter?.notificationId) where.notification_id = filter.notificationId;
1292
+ if (filter?.source) where.source = filter.source;
826
1293
  const rows = await this.engine.find(this.objectName, { where });
827
- return rows.map((r) => this.toRecord(r));
1294
+ return rows.map((r) => this.toDelivery(r));
828
1295
  }
829
- toRecord(r) {
830
- let payload = r.payload ?? {};
831
- if (typeof payload === "string") {
832
- try {
833
- payload = JSON.parse(payload);
834
- } catch {
835
- payload = {};
836
- }
1296
+ async redeliver(id) {
1297
+ const current = await this.engine.findOne(this.objectName, { where: { id } });
1298
+ if (!current) {
1299
+ throw new HttpRedeliverError(`Delivery row '${id}' not found`, "not_found");
1300
+ }
1301
+ if (current.status !== "success" && current.status !== "failed" && current.status !== "dead") {
1302
+ throw new HttpRedeliverError(
1303
+ `Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`,
1304
+ "not_eligible"
1305
+ );
837
1306
  }
1307
+ const now = Date.now();
1308
+ await this.engine.update(
1309
+ this.objectName,
1310
+ {
1311
+ status: "pending",
1312
+ attempts: 0,
1313
+ claimed_by: null,
1314
+ claimed_at: null,
1315
+ next_retry_at: null,
1316
+ last_attempted_at: null,
1317
+ response_code: null,
1318
+ response_body: null,
1319
+ error: null,
1320
+ updated_at: now
1321
+ },
1322
+ { where: { id, status: { $in: ["success", "failed", "dead"] } }, multi: false }
1323
+ );
1324
+ const after = await this.engine.findOne(this.objectName, { where: { id } });
1325
+ if (!after || after.status !== "pending") {
1326
+ throw new HttpRedeliverError(`Delivery row '${id}' state changed during redeliver`, "not_eligible");
1327
+ }
1328
+ return this.toDelivery(after);
1329
+ }
1330
+ toDelivery(r) {
838
1331
  return {
839
1332
  id: r.id,
840
- notificationId: r.notification_id,
841
- recipientId: r.recipient_id,
842
- channel: r.channel,
843
- topic: r.topic ?? void 0,
844
- payload,
845
- organizationId: r.organization_id ?? void 0,
846
- partitionKey: r.partition_key,
1333
+ source: r.source,
1334
+ refId: r.ref_id,
1335
+ dedupKey: r.dedup_key,
1336
+ label: r.label ?? void 0,
1337
+ url: r.url,
1338
+ method: r.method ?? void 0,
1339
+ headers: r.headers_json ? JSON.parse(r.headers_json) : void 0,
1340
+ signingSecret: r.signing_secret ?? void 0,
1341
+ timeoutMs: r.timeout_ms ?? void 0,
1342
+ payload: JSON.parse(r.payload_json),
847
1343
  status: r.status,
848
1344
  attempts: r.attempts,
849
1345
  claimedBy: r.claimed_by ?? void 0,
850
1346
  claimedAt: r.claimed_at ?? void 0,
851
- nextAttemptAt: r.next_attempt_at ?? void 0,
1347
+ nextRetryAt: r.next_retry_at ?? void 0,
852
1348
  lastAttemptedAt: r.last_attempted_at ?? void 0,
1349
+ responseCode: r.response_code ?? void 0,
1350
+ responseBody: r.response_body ?? void 0,
853
1351
  error: r.error ?? void 0,
854
1352
  createdAt: r.created_at,
855
1353
  updatedAt: r.updated_at
@@ -1004,6 +1502,237 @@ function stableNodeOffset(nodeId, partitionCount) {
1004
1502
  return Math.abs(h) % partitionCount;
1005
1503
  }
1006
1504
 
1505
+ // src/http-sender.ts
1506
+ var import_node_crypto3 = require("crypto");
1507
+ var DEFAULT_HTTP_TIMEOUT_MS = 15e3;
1508
+ var RESPONSE_BODY_CAP = 16 * 1024;
1509
+ async function sendOnce(delivery, fetchImpl) {
1510
+ const body = typeof delivery.payload === "string" ? delivery.payload : JSON.stringify(delivery.payload ?? null);
1511
+ const headers = {
1512
+ "Content-Type": "application/json",
1513
+ "User-Agent": "ObjectStack-Http/1.0",
1514
+ "X-Objectstack-Delivery": delivery.id,
1515
+ "X-Objectstack-Attempt": String(delivery.attempts + 1),
1516
+ ...delivery.label ? { "X-Objectstack-Event": delivery.label } : {},
1517
+ ...delivery.headers ?? {}
1518
+ };
1519
+ if (delivery.signingSecret) {
1520
+ const sig = (0, import_node_crypto3.createHmac)("sha256", delivery.signingSecret).update(body).digest("hex");
1521
+ headers["X-Objectstack-Signature"] = `sha256=${sig}`;
1522
+ }
1523
+ const timeoutMs = delivery.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1524
+ const controller = new AbortController();
1525
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1526
+ const start = Date.now();
1527
+ try {
1528
+ const res = await fetchImpl(delivery.url, {
1529
+ method: delivery.method ?? "POST",
1530
+ headers,
1531
+ body,
1532
+ signal: controller.signal
1533
+ });
1534
+ clearTimeout(timer);
1535
+ const responseText = await safeReadBody(res);
1536
+ const durationMs = Date.now() - start;
1537
+ if (res.ok) {
1538
+ return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
1539
+ }
1540
+ const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
1541
+ return {
1542
+ success: false,
1543
+ retriable,
1544
+ httpStatus: res.status,
1545
+ responseBody: responseText,
1546
+ error: `HTTP ${res.status}`,
1547
+ durationMs
1548
+ };
1549
+ } catch (err) {
1550
+ clearTimeout(timer);
1551
+ const durationMs = Date.now() - start;
1552
+ const e = err;
1553
+ const error = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
1554
+ return { success: false, retriable: true, error, durationMs };
1555
+ }
1556
+ }
1557
+ async function safeReadBody(res) {
1558
+ try {
1559
+ const text = await res.text();
1560
+ return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
1561
+ } catch {
1562
+ return void 0;
1563
+ }
1564
+ }
1565
+ function nextHttpRetryDelayMs(attemptsSoFar, rng = Math.random) {
1566
+ const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5, 216e5, 864e5];
1567
+ if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
1568
+ const base = SCHEDULE[attemptsSoFar - 1];
1569
+ const jitter = 0.8 + rng() * 0.4;
1570
+ return Math.floor(base * jitter);
1571
+ }
1572
+ function classifyAttempt(outcome, attemptsSoFar, now = Date.now(), rng) {
1573
+ if (outcome.success) return outcome;
1574
+ if (!outcome.retriable) {
1575
+ return {
1576
+ success: false,
1577
+ httpStatus: outcome.httpStatus,
1578
+ responseBody: outcome.responseBody,
1579
+ error: outcome.error,
1580
+ durationMs: outcome.durationMs,
1581
+ dead: true
1582
+ };
1583
+ }
1584
+ const delay = nextHttpRetryDelayMs(attemptsSoFar + 1, rng);
1585
+ if (delay === null) {
1586
+ return {
1587
+ success: false,
1588
+ httpStatus: outcome.httpStatus,
1589
+ responseBody: outcome.responseBody,
1590
+ error: outcome.error,
1591
+ durationMs: outcome.durationMs,
1592
+ dead: true
1593
+ };
1594
+ }
1595
+ return {
1596
+ success: false,
1597
+ httpStatus: outcome.httpStatus,
1598
+ responseBody: outcome.responseBody,
1599
+ error: outcome.error,
1600
+ durationMs: outcome.durationMs,
1601
+ nextRetryAt: now + delay
1602
+ };
1603
+ }
1604
+ function newDeliveryId() {
1605
+ return (0, import_node_crypto3.randomUUID)();
1606
+ }
1607
+
1608
+ // src/http-dispatcher.ts
1609
+ var SINGLE_NODE_CLUSTER2 = {
1610
+ lock: {
1611
+ async acquire() {
1612
+ return { release() {
1613
+ }, isHeld: () => true, renew() {
1614
+ } };
1615
+ }
1616
+ }
1617
+ };
1618
+ var HttpDispatcher = class {
1619
+ constructor(options) {
1620
+ this.running = false;
1621
+ const intervalMs = options.intervalMs ?? 500;
1622
+ const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
1623
+ this.opts = {
1624
+ nodeId: options.nodeId,
1625
+ outbox: options.outbox,
1626
+ cluster: options.cluster ?? SINGLE_NODE_CLUSTER2,
1627
+ partitionCount: options.partitionCount ?? 8,
1628
+ batchSize: options.batchSize ?? 32,
1629
+ intervalMs,
1630
+ lockTtlMs,
1631
+ claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
1632
+ fetchImpl: options.fetchImpl,
1633
+ rng: options.rng,
1634
+ now: options.now,
1635
+ logger: options.logger,
1636
+ onAttempt: options.onAttempt
1637
+ };
1638
+ }
1639
+ /** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
1640
+ start() {
1641
+ if (this.running) return;
1642
+ this.running = true;
1643
+ this.scheduleTick();
1644
+ this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
1645
+ this.timer.unref?.();
1646
+ }
1647
+ /** Stop the loop and wait for the in-flight tick to drain. */
1648
+ async stop() {
1649
+ if (!this.running) return;
1650
+ this.running = false;
1651
+ if (this.timer) {
1652
+ clearInterval(this.timer);
1653
+ this.timer = void 0;
1654
+ }
1655
+ if (this.inflightTick) {
1656
+ try {
1657
+ await this.inflightTick;
1658
+ } catch {
1659
+ }
1660
+ }
1661
+ }
1662
+ /** Run one full tick (all partitions). Exposed for deterministic tests. */
1663
+ async tick() {
1664
+ await this.runTick();
1665
+ }
1666
+ scheduleTick() {
1667
+ if (this.inflightTick) return;
1668
+ this.inflightTick = this.runTick().catch((err) => {
1669
+ this.opts.logger?.warn?.("http-dispatcher: tick failed", {
1670
+ nodeId: this.opts.nodeId,
1671
+ error: err?.message ?? String(err)
1672
+ });
1673
+ }).finally(() => {
1674
+ this.inflightTick = void 0;
1675
+ });
1676
+ }
1677
+ async runTick() {
1678
+ const partitionCount = this.opts.partitionCount;
1679
+ const offset = stableNodeOffset2(this.opts.nodeId, partitionCount);
1680
+ for (let step = 0; step < partitionCount; step++) {
1681
+ const i = (offset + step) % partitionCount;
1682
+ await this.runPartition(i);
1683
+ }
1684
+ }
1685
+ async runPartition(index) {
1686
+ const key = `http.dispatcher.partition.${index}`;
1687
+ const handle = await this.opts.cluster.lock.acquire(key, {
1688
+ ttlMs: this.opts.lockTtlMs,
1689
+ waitMs: 0
1690
+ });
1691
+ if (!handle) return;
1692
+ try {
1693
+ const claimed = await this.opts.outbox.claim({
1694
+ nodeId: this.opts.nodeId,
1695
+ limit: this.opts.batchSize,
1696
+ partition: { index, count: this.opts.partitionCount },
1697
+ claimTtlMs: this.opts.claimTtlMs,
1698
+ now: this.opts.now?.()
1699
+ });
1700
+ if (claimed.length === 0) return;
1701
+ await handle.renew?.(this.opts.lockTtlMs);
1702
+ for (const row of claimed) {
1703
+ if (handle.isHeld && !handle.isHeld()) break;
1704
+ await this.processRow(row);
1705
+ }
1706
+ } finally {
1707
+ await handle.release();
1708
+ }
1709
+ }
1710
+ async processRow(row) {
1711
+ const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
1712
+ if (!fetchImpl) {
1713
+ this.opts.logger?.warn?.("http-dispatcher: no fetch impl available", { rowId: row.id });
1714
+ await this.opts.outbox.ack(row.id, {
1715
+ success: false,
1716
+ error: "no fetch implementation",
1717
+ durationMs: 0,
1718
+ dead: true
1719
+ });
1720
+ return;
1721
+ }
1722
+ const outcome = await sendOnce(row, fetchImpl);
1723
+ const result = classifyAttempt(outcome, row.attempts, this.opts.now?.() ?? Date.now(), this.opts.rng);
1724
+ await this.opts.outbox.ack(row.id, result);
1725
+ this.opts.onAttempt?.(row, result.success);
1726
+ }
1727
+ };
1728
+ function stableNodeOffset2(nodeId, partitionCount) {
1729
+ let h = 0;
1730
+ for (let i = 0; i < nodeId.length; i++) {
1731
+ h = h * 31 + nodeId.charCodeAt(i) | 0;
1732
+ }
1733
+ return Math.abs(h) % partitionCount;
1734
+ }
1735
+
1007
1736
  // src/retention.ts
1008
1737
  var DEFAULT_RETENTION_TARGETS = [
1009
1738
  { object: RECEIPT_OBJECT, tsField: "created_at", format: "iso" },
@@ -1211,8 +1940,8 @@ function createEmailChannel(opts) {
1211
1940
  }
1212
1941
 
1213
1942
  // src/objects/inbox-message.object.ts
1214
- var import_data = require("@objectstack/spec/data");
1215
- var InboxMessage = import_data.ObjectSchema.create({
1943
+ var import_data2 = require("@objectstack/spec/data");
1944
+ var InboxMessage = import_data2.ObjectSchema.create({
1216
1945
  name: "sys_inbox_message",
1217
1946
  label: "Inbox Message",
1218
1947
  pluralLabel: "Inbox Messages",
@@ -1234,37 +1963,37 @@ var InboxMessage = import_data.ObjectSchema.create({
1234
1963
  }
1235
1964
  },
1236
1965
  fields: {
1237
- id: import_data.Field.text({
1966
+ id: import_data2.Field.text({
1238
1967
  label: "Inbox Message ID",
1239
1968
  required: true,
1240
1969
  readonly: true
1241
1970
  }),
1242
- user_id: import_data.Field.text({
1971
+ user_id: import_data2.Field.text({
1243
1972
  label: "Recipient User",
1244
1973
  required: true,
1245
1974
  searchable: true
1246
1975
  }),
1247
- notification_id: import_data.Field.text({
1976
+ notification_id: import_data2.Field.text({
1248
1977
  label: "Notification Event",
1249
1978
  searchable: true,
1250
1979
  description: "FK \u2192 sys_notification (the L2 event this row materializes)"
1251
1980
  }),
1252
- delivery_id: import_data.Field.text({
1981
+ delivery_id: import_data2.Field.text({
1253
1982
  label: "Delivery",
1254
1983
  description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
1255
1984
  }),
1256
- topic: import_data.Field.text({
1985
+ topic: import_data2.Field.text({
1257
1986
  label: "Topic",
1258
1987
  searchable: true
1259
1988
  }),
1260
- title: import_data.Field.text({
1989
+ title: import_data2.Field.text({
1261
1990
  label: "Title",
1262
1991
  required: true
1263
1992
  }),
1264
- body_md: import_data.Field.markdown({
1993
+ body_md: import_data2.Field.markdown({
1265
1994
  label: "Body"
1266
1995
  }),
1267
- severity: import_data.Field.select({
1996
+ severity: import_data2.Field.select({
1268
1997
  label: "Severity",
1269
1998
  options: [
1270
1999
  { label: "Info", value: "info" },
@@ -1272,10 +2001,10 @@ var InboxMessage = import_data.ObjectSchema.create({
1272
2001
  { label: "Critical", value: "critical" }
1273
2002
  ]
1274
2003
  }),
1275
- action_url: import_data.Field.text({
2004
+ action_url: import_data2.Field.text({
1276
2005
  label: "Action URL"
1277
2006
  }),
1278
- created_at: import_data.Field.datetime({
2007
+ created_at: import_data2.Field.datetime({
1279
2008
  label: "Created At",
1280
2009
  readonly: true
1281
2010
  })
@@ -1283,8 +2012,8 @@ var InboxMessage = import_data.ObjectSchema.create({
1283
2012
  });
1284
2013
 
1285
2014
  // src/objects/notification-receipt.object.ts
1286
- var import_data2 = require("@objectstack/spec/data");
1287
- var NotificationReceipt = import_data2.ObjectSchema.create({
2015
+ var import_data3 = require("@objectstack/spec/data");
2016
+ var NotificationReceipt = import_data3.ObjectSchema.create({
1288
2017
  name: "sys_notification_receipt",
1289
2018
  label: "Notification Receipt",
1290
2019
  pluralLabel: "Notification Receipts",
@@ -1295,43 +2024,43 @@ var NotificationReceipt = import_data2.ObjectSchema.create({
1295
2024
  titleFormat: "{state}",
1296
2025
  compactLayout: ["notification_id", "user_id", "channel", "state", "at"],
1297
2026
  fields: {
1298
- id: import_data2.Field.text({
2027
+ id: import_data3.Field.text({
1299
2028
  label: "Receipt ID",
1300
2029
  required: true,
1301
2030
  readonly: true
1302
2031
  }),
1303
- notification_id: import_data2.Field.text({
2032
+ notification_id: import_data3.Field.text({
1304
2033
  label: "Notification Event",
1305
2034
  required: true,
1306
2035
  searchable: true,
1307
2036
  description: "FK \u2192 sys_notification (L2 event)"
1308
2037
  }),
1309
- delivery_id: import_data2.Field.text({
2038
+ delivery_id: import_data3.Field.text({
1310
2039
  label: "Delivery",
1311
2040
  required: false,
1312
2041
  description: "FK \u2192 sys_notification_delivery (outbox row); null until P1"
1313
2042
  }),
1314
- user_id: import_data2.Field.text({
2043
+ user_id: import_data3.Field.text({
1315
2044
  label: "Recipient User",
1316
2045
  required: true,
1317
2046
  searchable: true
1318
2047
  }),
1319
- channel: import_data2.Field.text({
2048
+ channel: import_data3.Field.text({
1320
2049
  label: "Channel",
1321
2050
  required: true,
1322
2051
  description: "Channel id this receipt is for (inbox / email / push / \u2026)"
1323
2052
  }),
1324
- state: import_data2.Field.select(["delivered", "read", "clicked", "dismissed"], {
2053
+ state: import_data3.Field.select(["delivered", "read", "clicked", "dismissed"], {
1325
2054
  label: "State",
1326
2055
  required: true,
1327
2056
  defaultValue: "delivered"
1328
2057
  }),
1329
- at: import_data2.Field.datetime({
2058
+ at: import_data3.Field.datetime({
1330
2059
  label: "At",
1331
2060
  required: false,
1332
2061
  description: "When the receipt reached its current state"
1333
2062
  }),
1334
- created_at: import_data2.Field.datetime({
2063
+ created_at: import_data3.Field.datetime({
1335
2064
  label: "Created At",
1336
2065
  readonly: true
1337
2066
  })
@@ -1343,8 +2072,8 @@ var NotificationReceipt = import_data2.ObjectSchema.create({
1343
2072
  });
1344
2073
 
1345
2074
  // src/objects/notification-delivery.object.ts
1346
- var import_data3 = require("@objectstack/spec/data");
1347
- var NotificationDelivery = import_data3.ObjectSchema.create({
2075
+ var import_data4 = require("@objectstack/spec/data");
2076
+ var NotificationDelivery = import_data4.ObjectSchema.create({
1348
2077
  name: "sys_notification_delivery",
1349
2078
  label: "Notification Delivery",
1350
2079
  pluralLabel: "Notification Deliveries",
@@ -1355,34 +2084,34 @@ var NotificationDelivery = import_data3.ObjectSchema.create({
1355
2084
  titleFormat: "{channel} \u2192 {recipient_id}",
1356
2085
  compactLayout: ["notification_id", "recipient_id", "channel", "status", "attempts"],
1357
2086
  fields: {
1358
- id: import_data3.Field.text({ label: "Delivery ID", required: true, readonly: true }),
1359
- notification_id: import_data3.Field.text({
2087
+ id: import_data4.Field.text({ label: "Delivery ID", required: true, readonly: true }),
2088
+ notification_id: import_data4.Field.text({
1360
2089
  label: "Notification Event",
1361
2090
  required: true,
1362
2091
  searchable: true,
1363
2092
  description: "FK \u2192 sys_notification (L2 event)"
1364
2093
  }),
1365
- recipient_id: import_data3.Field.text({ label: "Recipient User", required: true, searchable: true }),
1366
- channel: import_data3.Field.text({ label: "Channel", required: true }),
1367
- topic: import_data3.Field.text({ label: "Topic", searchable: true }),
1368
- payload: import_data3.Field.json({
2094
+ recipient_id: import_data4.Field.text({ label: "Recipient User", required: true, searchable: true }),
2095
+ channel: import_data4.Field.text({ label: "Channel", required: true }),
2096
+ topic: import_data4.Field.text({ label: "Topic", searchable: true }),
2097
+ payload: import_data4.Field.json({
1369
2098
  label: "Payload",
1370
2099
  description: "Snapshot of the rendered notification content for dispatch."
1371
2100
  }),
1372
- status: import_data3.Field.select(["pending", "in_flight", "success", "failed", "dead", "suppressed"], {
2101
+ status: import_data4.Field.select(["pending", "in_flight", "success", "failed", "dead", "suppressed"], {
1373
2102
  label: "Status",
1374
2103
  required: true,
1375
2104
  defaultValue: "pending"
1376
2105
  }),
1377
- attempts: import_data3.Field.number({ label: "Attempts", defaultValue: 0 }),
1378
- partition_key: import_data3.Field.number({ label: "Partition Key", defaultValue: 0 }),
1379
- claimed_by: import_data3.Field.text({ label: "Claimed By", description: "Node id while in_flight" }),
1380
- claimed_at: import_data3.Field.number({ label: "Claimed At (ms)" }),
1381
- next_attempt_at: import_data3.Field.number({ label: "Next Attempt At (ms)" }),
1382
- last_attempted_at: import_data3.Field.number({ label: "Last Attempted At (ms)" }),
1383
- error: import_data3.Field.textarea({ label: "Error" }),
1384
- created_at: import_data3.Field.number({ label: "Created At (ms)", readonly: true }),
1385
- updated_at: import_data3.Field.number({ label: "Updated At (ms)" })
2106
+ attempts: import_data4.Field.number({ label: "Attempts", defaultValue: 0 }),
2107
+ partition_key: import_data4.Field.number({ label: "Partition Key", defaultValue: 0 }),
2108
+ claimed_by: import_data4.Field.text({ label: "Claimed By", description: "Node id while in_flight" }),
2109
+ claimed_at: import_data4.Field.number({ label: "Claimed At (ms)" }),
2110
+ next_attempt_at: import_data4.Field.number({ label: "Next Attempt At (ms)" }),
2111
+ last_attempted_at: import_data4.Field.number({ label: "Last Attempted At (ms)" }),
2112
+ error: import_data4.Field.textarea({ label: "Error" }),
2113
+ created_at: import_data4.Field.number({ label: "Created At (ms)", readonly: true }),
2114
+ updated_at: import_data4.Field.number({ label: "Updated At (ms)" })
1386
2115
  },
1387
2116
  indexes: [
1388
2117
  // Dedup: one delivery per (event, recipient, channel).
@@ -1396,8 +2125,8 @@ var NotificationDelivery = import_data3.ObjectSchema.create({
1396
2125
  });
1397
2126
 
1398
2127
  // src/objects/notification-preference.object.ts
1399
- var import_data4 = require("@objectstack/spec/data");
1400
- var NotificationPreference = import_data4.ObjectSchema.create({
2128
+ var import_data5 = require("@objectstack/spec/data");
2129
+ var NotificationPreference = import_data5.ObjectSchema.create({
1401
2130
  name: "sys_notification_preference",
1402
2131
  label: "Notification Preference",
1403
2132
  pluralLabel: "Notification Preferences",
@@ -1408,44 +2137,44 @@ var NotificationPreference = import_data4.ObjectSchema.create({
1408
2137
  titleFormat: "{user_id} \xB7 {topic} \xB7 {channel}",
1409
2138
  compactLayout: ["user_id", "topic", "channel", "enabled", "digest"],
1410
2139
  fields: {
1411
- id: import_data4.Field.text({ label: "Preference ID", required: true, readonly: true }),
1412
- user_id: import_data4.Field.text({
2140
+ id: import_data5.Field.text({ label: "Preference ID", required: true, readonly: true }),
2141
+ user_id: import_data5.Field.text({
1413
2142
  label: "User",
1414
2143
  required: true,
1415
2144
  searchable: true,
1416
2145
  description: "Recipient user id, or '*' for the admin-global default."
1417
2146
  }),
1418
- topic: import_data4.Field.text({
2147
+ topic: import_data5.Field.text({
1419
2148
  label: "Topic",
1420
2149
  required: true,
1421
2150
  searchable: true,
1422
2151
  defaultValue: "*",
1423
2152
  description: "Notification topic, or '*' for all topics."
1424
2153
  }),
1425
- channel: import_data4.Field.text({
2154
+ channel: import_data5.Field.text({
1426
2155
  label: "Channel",
1427
2156
  required: true,
1428
2157
  defaultValue: "*",
1429
2158
  description: "Channel id (inbox/email/push/\u2026), or '*' for all channels."
1430
2159
  }),
1431
- enabled: import_data4.Field.boolean({
2160
+ enabled: import_data5.Field.boolean({
1432
2161
  label: "Enabled",
1433
2162
  defaultValue: true,
1434
2163
  description: "When false, this (user, topic, channel) is muted."
1435
2164
  }),
1436
- digest: import_data4.Field.select(["none", "daily", "weekly"], {
2165
+ digest: import_data5.Field.select(["none", "daily", "weekly"], {
1437
2166
  label: "Digest",
1438
2167
  required: false,
1439
2168
  defaultValue: "none",
1440
2169
  description: "Batch cadence (P3 digest middleware)."
1441
2170
  }),
1442
- quiet_hours: import_data4.Field.json({
2171
+ quiet_hours: import_data5.Field.json({
1443
2172
  label: "Quiet Hours",
1444
2173
  required: false,
1445
2174
  description: "Optional { tz, start, end } window (P3 quiet-hours middleware)."
1446
2175
  }),
1447
- created_at: import_data4.Field.datetime({ label: "Created At", readonly: true }),
1448
- updated_at: import_data4.Field.datetime({ label: "Updated At", required: false })
2176
+ created_at: import_data5.Field.datetime({ label: "Created At", readonly: true }),
2177
+ updated_at: import_data5.Field.datetime({ label: "Updated At", required: false })
1449
2178
  },
1450
2179
  indexes: [
1451
2180
  { fields: ["user_id", "topic", "channel"], unique: true },
@@ -1454,8 +2183,8 @@ var NotificationPreference = import_data4.ObjectSchema.create({
1454
2183
  });
1455
2184
 
1456
2185
  // src/objects/notification-subscription.object.ts
1457
- var import_data5 = require("@objectstack/spec/data");
1458
- var NotificationSubscription = import_data5.ObjectSchema.create({
2186
+ var import_data6 = require("@objectstack/spec/data");
2187
+ var NotificationSubscription = import_data6.ObjectSchema.create({
1459
2188
  name: "sys_notification_subscription",
1460
2189
  label: "Notification Subscription",
1461
2190
  pluralLabel: "Notification Subscriptions",
@@ -1466,25 +2195,25 @@ var NotificationSubscription = import_data5.ObjectSchema.create({
1466
2195
  titleFormat: "{principal} \xB7 {topic}",
1467
2196
  compactLayout: ["topic", "principal", "enabled", "created_at"],
1468
2197
  fields: {
1469
- id: import_data5.Field.text({ label: "Subscription ID", required: true, readonly: true }),
1470
- topic: import_data5.Field.text({
2198
+ id: import_data6.Field.text({ label: "Subscription ID", required: true, readonly: true }),
2199
+ topic: import_data6.Field.text({
1471
2200
  label: "Topic",
1472
2201
  required: true,
1473
2202
  searchable: true,
1474
2203
  description: "Notification topic this principal subscribes to."
1475
2204
  }),
1476
- principal: import_data5.Field.text({
2205
+ principal: import_data6.Field.text({
1477
2206
  label: "Principal",
1478
2207
  required: true,
1479
2208
  searchable: true,
1480
2209
  description: "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | bare user id."
1481
2210
  }),
1482
- enabled: import_data5.Field.boolean({
2211
+ enabled: import_data6.Field.boolean({
1483
2212
  label: "Enabled",
1484
2213
  defaultValue: true,
1485
2214
  description: "When false, the subscription is inactive."
1486
2215
  }),
1487
- created_at: import_data5.Field.datetime({ label: "Created At", readonly: true })
2216
+ created_at: import_data6.Field.datetime({ label: "Created At", readonly: true })
1488
2217
  },
1489
2218
  indexes: [
1490
2219
  { fields: ["topic", "principal"], unique: true },
@@ -1493,8 +2222,8 @@ var NotificationSubscription = import_data5.ObjectSchema.create({
1493
2222
  });
1494
2223
 
1495
2224
  // src/objects/notification-template.object.ts
1496
- var import_data6 = require("@objectstack/spec/data");
1497
- var NotificationTemplate = import_data6.ObjectSchema.create({
2225
+ var import_data7 = require("@objectstack/spec/data");
2226
+ var NotificationTemplate = import_data7.ObjectSchema.create({
1498
2227
  name: "sys_notification_template",
1499
2228
  label: "Notification Template",
1500
2229
  pluralLabel: "Notification Templates",
@@ -1505,47 +2234,47 @@ var NotificationTemplate = import_data6.ObjectSchema.create({
1505
2234
  titleFormat: "{topic} \xB7 {channel} \xB7 {locale}",
1506
2235
  compactLayout: ["topic", "channel", "locale", "is_active"],
1507
2236
  fields: {
1508
- id: import_data6.Field.text({ label: "Template ID", required: true, readonly: true }),
1509
- topic: import_data6.Field.text({ label: "Topic", required: true, searchable: true }),
1510
- channel: import_data6.Field.text({
2237
+ id: import_data7.Field.text({ label: "Template ID", required: true, readonly: true }),
2238
+ topic: import_data7.Field.text({ label: "Topic", required: true, searchable: true }),
2239
+ channel: import_data7.Field.text({
1511
2240
  label: "Channel",
1512
2241
  required: true,
1513
2242
  defaultValue: "email",
1514
2243
  description: "Channel id this template renders for (email/inbox/push/\u2026)."
1515
2244
  }),
1516
- locale: import_data6.Field.text({
2245
+ locale: import_data7.Field.text({
1517
2246
  label: "Locale",
1518
2247
  required: true,
1519
2248
  defaultValue: "en",
1520
2249
  description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'."
1521
2250
  }),
1522
- version: import_data6.Field.number({
2251
+ version: import_data7.Field.number({
1523
2252
  label: "Version",
1524
2253
  required: false,
1525
2254
  defaultValue: 1
1526
2255
  }),
1527
- subject: import_data6.Field.text({
2256
+ subject: import_data7.Field.text({
1528
2257
  label: "Subject / Title",
1529
2258
  required: false,
1530
2259
  description: "Rendered into the email subject / inbox title. Supports {{ payload.x }}."
1531
2260
  }),
1532
- body: import_data6.Field.markdown({
2261
+ body: import_data7.Field.markdown({
1533
2262
  label: "Body",
1534
2263
  required: false,
1535
2264
  description: "Template body. Supports {{ payload.x }}. Interpreted per `format`."
1536
2265
  }),
1537
- format: import_data6.Field.select(["markdown", "html", "text", "mjml"], {
2266
+ format: import_data7.Field.select(["markdown", "html", "text", "mjml"], {
1538
2267
  label: "Body Format",
1539
2268
  required: false,
1540
2269
  defaultValue: "markdown"
1541
2270
  }),
1542
- is_active: import_data6.Field.boolean({
2271
+ is_active: import_data7.Field.boolean({
1543
2272
  label: "Active",
1544
2273
  defaultValue: true,
1545
2274
  description: "Only active templates are selected at render time."
1546
2275
  }),
1547
- created_at: import_data6.Field.datetime({ label: "Created At", readonly: true }),
1548
- updated_at: import_data6.Field.datetime({ label: "Updated At", required: false })
2276
+ created_at: import_data7.Field.datetime({ label: "Created At", readonly: true }),
2277
+ updated_at: import_data7.Field.datetime({ label: "Updated At", required: false })
1549
2278
  },
1550
2279
  indexes: [
1551
2280
  { fields: ["topic", "channel", "locale"] },
@@ -1588,6 +2317,7 @@ var MessagingServicePlugin = class {
1588
2317
  service.registerChannel(createInboxChannel({ getData }));
1589
2318
  }
1590
2319
  ctx.registerService("messaging", service);
2320
+ ctx.registerService("notification", service);
1591
2321
  ctx.getService("manifest").register({
1592
2322
  id: "com.objectstack.service.messaging",
1593
2323
  name: "Messaging Service",
@@ -1600,7 +2330,8 @@ var MessagingServicePlugin = class {
1600
2330
  NotificationDelivery,
1601
2331
  NotificationPreference,
1602
2332
  NotificationSubscription,
1603
- NotificationTemplate
2333
+ NotificationTemplate,
2334
+ HttpDelivery
1604
2335
  ],
1605
2336
  navigationContributions: [
1606
2337
  {
@@ -1647,7 +2378,7 @@ var MessagingServicePlugin = class {
1647
2378
  cluster = void 0;
1648
2379
  }
1649
2380
  this.dispatcher = new NotificationDispatcher({
1650
- nodeId: `notify-${process.pid}-${(0, import_node_crypto2.randomUUID)().slice(0, 8)}`,
2381
+ nodeId: `notify-${process.pid}-${(0, import_node_crypto4.randomUUID)().slice(0, 8)}`,
1651
2382
  outbox,
1652
2383
  channels: service,
1653
2384
  channelContext: { logger: ctx.logger },
@@ -1660,6 +2391,20 @@ var MessagingServicePlugin = class {
1660
2391
  ctx.logger.info(
1661
2392
  `[messaging] reliable delivery on (outbox + dispatcher, ${this.options.partitionCount} partitions${cluster ? ", clustered" : ", single-node"})`
1662
2393
  );
2394
+ const httpOutbox = new SqlHttpOutbox(engine, { partitionCount: this.options.partitionCount });
2395
+ service.setHttpOutbox(httpOutbox);
2396
+ this.httpDispatcher = new HttpDispatcher({
2397
+ nodeId: `http-${process.pid}-${(0, import_node_crypto4.randomUUID)().slice(0, 8)}`,
2398
+ outbox: httpOutbox,
2399
+ cluster,
2400
+ partitionCount: this.options.partitionCount,
2401
+ intervalMs: this.options.dispatchIntervalMs,
2402
+ logger: ctx.logger
2403
+ });
2404
+ this.httpDispatcher.start();
2405
+ ctx.logger.info(
2406
+ `[messaging] HTTP delivery on (sys_http_delivery outbox + dispatcher, ${this.options.partitionCount} partitions)`
2407
+ );
1663
2408
  });
1664
2409
  }
1665
2410
  if (this.options.retentionDays > 0 && typeof ctx.hook === "function") {
@@ -1687,6 +2432,8 @@ var MessagingServicePlugin = class {
1687
2432
  async stop() {
1688
2433
  await this.dispatcher?.stop();
1689
2434
  this.dispatcher = void 0;
2435
+ await this.httpDispatcher?.stop();
2436
+ this.httpDispatcher = void 0;
1690
2437
  if (this.retentionTimer) {
1691
2438
  clearInterval(this.retentionTimer);
1692
2439
  this.retentionTimer = void 0;
@@ -1695,7 +2442,7 @@ var MessagingServicePlugin = class {
1695
2442
  };
1696
2443
 
1697
2444
  // src/memory-outbox.ts
1698
- var import_node_crypto3 = require("crypto");
2445
+ var import_node_crypto5 = require("crypto");
1699
2446
  var MemoryNotificationOutbox = class {
1700
2447
  constructor(partitionCount = 8, clock = () => Date.now()) {
1701
2448
  this.partitionCount = partitionCount;
@@ -1708,7 +2455,7 @@ var MemoryNotificationOutbox = class {
1708
2455
  return r.id;
1709
2456
  }
1710
2457
  }
1711
- const id = (0, import_node_crypto3.randomUUID)();
2458
+ const id = (0, import_node_crypto5.randomUUID)();
1712
2459
  const now = this.clock();
1713
2460
  this.rows.set(id, {
1714
2461
  id,
@@ -1785,15 +2532,140 @@ var MemoryNotificationOutbox = class {
1785
2532
  return rows.map((r) => ({ ...r }));
1786
2533
  }
1787
2534
  };
2535
+
2536
+ // src/memory-http-outbox.ts
2537
+ var import_node_crypto6 = require("crypto");
2538
+ var MemoryHttpOutbox = class {
2539
+ constructor() {
2540
+ this.rows = /* @__PURE__ */ new Map();
2541
+ /** Dedup index keyed by `${source}::${dedupKey}` -> row id. */
2542
+ this.dedup = /* @__PURE__ */ new Map();
2543
+ }
2544
+ async enqueue(input) {
2545
+ const dedupKey = `${input.source}::${input.dedupKey}`;
2546
+ const existing = this.dedup.get(dedupKey);
2547
+ if (existing) return existing;
2548
+ const id = (0, import_node_crypto6.randomUUID)();
2549
+ const now = Date.now();
2550
+ const row = {
2551
+ id,
2552
+ source: input.source,
2553
+ refId: input.refId,
2554
+ dedupKey: input.dedupKey,
2555
+ label: input.label,
2556
+ url: input.url,
2557
+ method: input.method ?? "POST",
2558
+ headers: input.headers,
2559
+ signingSecret: input.signingSecret,
2560
+ timeoutMs: input.timeoutMs,
2561
+ payload: input.payload,
2562
+ status: "pending",
2563
+ attempts: 0,
2564
+ createdAt: now,
2565
+ updatedAt: now
2566
+ };
2567
+ this.rows.set(id, row);
2568
+ this.dedup.set(dedupKey, id);
2569
+ return id;
2570
+ }
2571
+ async claim(opts) {
2572
+ const now = opts.now ?? Date.now();
2573
+ const claimed = [];
2574
+ for (const row of this.rows.values()) {
2575
+ if (row.status === "in_flight" && row.claimedAt !== void 0 && now - row.claimedAt > opts.claimTtlMs) {
2576
+ row.status = "pending";
2577
+ row.claimedBy = void 0;
2578
+ row.claimedAt = void 0;
2579
+ row.updatedAt = now;
2580
+ }
2581
+ }
2582
+ for (const row of this.rows.values()) {
2583
+ if (claimed.length >= opts.limit) break;
2584
+ if (row.status !== "pending") continue;
2585
+ if (row.nextRetryAt !== void 0 && row.nextRetryAt > now) continue;
2586
+ if (opts.partition) {
2587
+ const p = hashPartition(row.refId, opts.partition.count);
2588
+ if (p !== opts.partition.index) continue;
2589
+ }
2590
+ row.status = "in_flight";
2591
+ row.claimedBy = opts.nodeId;
2592
+ row.claimedAt = now;
2593
+ row.updatedAt = now;
2594
+ claimed.push({ ...row });
2595
+ }
2596
+ return claimed;
2597
+ }
2598
+ async ack(id, result) {
2599
+ const row = this.rows.get(id);
2600
+ if (!row) return;
2601
+ const now = Date.now();
2602
+ row.attempts += 1;
2603
+ row.lastAttemptedAt = now;
2604
+ row.updatedAt = now;
2605
+ row.claimedBy = void 0;
2606
+ row.claimedAt = void 0;
2607
+ row.responseCode = result.httpStatus;
2608
+ row.responseBody = result.responseBody;
2609
+ let status;
2610
+ if (result.success) {
2611
+ status = "success";
2612
+ row.nextRetryAt = void 0;
2613
+ row.error = void 0;
2614
+ } else if (result.dead) {
2615
+ status = "dead";
2616
+ row.error = result.error;
2617
+ row.nextRetryAt = void 0;
2618
+ } else {
2619
+ status = "pending";
2620
+ row.error = result.error;
2621
+ row.nextRetryAt = result.nextRetryAt;
2622
+ }
2623
+ row.status = status;
2624
+ }
2625
+ async list(filter) {
2626
+ let all = Array.from(this.rows.values()).map((r) => ({ ...r }));
2627
+ if (filter?.status) all = all.filter((r) => r.status === filter.status);
2628
+ if (filter?.source) all = all.filter((r) => r.source === filter.source);
2629
+ return all;
2630
+ }
2631
+ async redeliver(id) {
2632
+ const row = this.rows.get(id);
2633
+ if (!row) {
2634
+ throw new HttpRedeliverError(`Delivery row '${id}' not found`, "not_found");
2635
+ }
2636
+ if (row.status !== "success" && row.status !== "failed" && row.status !== "dead") {
2637
+ throw new HttpRedeliverError(
2638
+ `Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
2639
+ "not_eligible"
2640
+ );
2641
+ }
2642
+ const now = Date.now();
2643
+ row.status = "pending";
2644
+ row.attempts = 0;
2645
+ row.claimedBy = void 0;
2646
+ row.claimedAt = void 0;
2647
+ row.nextRetryAt = void 0;
2648
+ row.error = void 0;
2649
+ row.responseCode = void 0;
2650
+ row.responseBody = void 0;
2651
+ row.updatedAt = now;
2652
+ return { ...row };
2653
+ }
2654
+ };
1788
2655
  // Annotate the CommonJS export names for ESM import in node:
1789
2656
  0 && (module.exports = {
2657
+ DEFAULT_HTTP_TIMEOUT_MS,
1790
2658
  DEFAULT_LOCALE,
1791
2659
  DEFAULT_RETENTION_TARGETS,
1792
2660
  DELIVERY_OBJECT,
1793
2661
  EMAIL_USER_OBJECT,
2662
+ HttpDelivery,
2663
+ HttpDispatcher,
2664
+ HttpRedeliverError,
1794
2665
  INBOX_OBJECT,
1795
2666
  InboxMessage,
1796
2667
  MEMBER_OBJECT,
2668
+ MemoryHttpOutbox,
1797
2669
  MemoryNotificationOutbox,
1798
2670
  MessagingService,
1799
2671
  MessagingServicePlugin,
@@ -1810,17 +2682,23 @@ var MemoryNotificationOutbox = class {
1810
2682
  PreferenceResolver,
1811
2683
  RECEIPT_OBJECT,
1812
2684
  RecipientResolver,
2685
+ SYS_HTTP_DELIVERY,
2686
+ SqlHttpOutbox,
1813
2687
  SqlNotificationOutbox,
1814
2688
  TEAM_MEMBER_OBJECT,
1815
2689
  TEMPLATE_OBJECT,
1816
2690
  USER_OBJECT,
1817
2691
  classifyDeliveryAttempt,
2692
+ classifyHttpAttempt,
1818
2693
  createEmailChannel,
1819
2694
  createInboxChannel,
1820
2695
  hashPartition,
1821
2696
  interpolate,
2697
+ newHttpDeliveryId,
2698
+ nextHttpRetryDelayMs,
1822
2699
  nextRetryDelayMs,
1823
2700
  quietHoursDeferral,
1824
- renderNotification
2701
+ renderNotification,
2702
+ sendHttpOnce
1825
2703
  });
1826
2704
  //# sourceMappingURL=index.cjs.map