@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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/messaging-service-plugin.ts
2
- import { randomUUID as randomUUID2 } from "crypto";
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, { where: dedup, fields: ["id"] });
691
- if (winner?.id) return String(winner.id);
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
- { where: { status: "in_flight", claimed_at: { $lt: now - opts.claimTtlMs } }, multi: true }
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: [{ next_attempt_at: null }, { next_attempt_at: { $lte: now } }]
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 (!candidates.length) return [];
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.toRecord(r));
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 nextAttemptAt = null;
733
- let error = null;
1186
+ let nextRetryAt;
1187
+ let error;
734
1188
  if (result.success) {
735
1189
  status = "success";
736
- } else if (result.suppressed) {
737
- status = "suppressed";
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
- nextAttemptAt = result.nextAttemptAt ?? null;
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
- next_attempt_at: nextAttemptAt,
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?.notificationId) where.notification_id = filter.notificationId;
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.toRecord(r));
1223
+ return rows.map((r) => this.toDelivery(r));
768
1224
  }
769
- toRecord(r) {
770
- let payload = r.payload ?? {};
771
- if (typeof payload === "string") {
772
- try {
773
- payload = JSON.parse(payload);
774
- } catch {
775
- payload = {};
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
- notificationId: r.notification_id,
781
- recipientId: r.recipient_id,
782
- channel: r.channel,
783
- topic: r.topic ?? void 0,
784
- payload,
785
- organizationId: r.organization_id ?? void 0,
786
- partitionKey: r.partition_key,
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
- nextAttemptAt: r.next_attempt_at ?? void 0,
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 = ObjectSchema.create({
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: Field.text({
1895
+ id: Field2.text({
1178
1896
  label: "Inbox Message ID",
1179
1897
  required: true,
1180
1898
  readonly: true
1181
1899
  }),
1182
- user_id: Field.text({
1900
+ user_id: Field2.text({
1183
1901
  label: "Recipient User",
1184
1902
  required: true,
1185
1903
  searchable: true
1186
1904
  }),
1187
- notification_id: Field.text({
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: Field.text({
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: Field.text({
1914
+ topic: Field2.text({
1197
1915
  label: "Topic",
1198
1916
  searchable: true
1199
1917
  }),
1200
- title: Field.text({
1918
+ title: Field2.text({
1201
1919
  label: "Title",
1202
1920
  required: true
1203
1921
  }),
1204
- body_md: Field.markdown({
1922
+ body_md: Field2.markdown({
1205
1923
  label: "Body"
1206
1924
  }),
1207
- severity: Field.select({
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: Field.text({
1933
+ action_url: Field2.text({
1216
1934
  label: "Action URL"
1217
1935
  }),
1218
- created_at: Field.datetime({
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 ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
1227
- var NotificationReceipt = ObjectSchema2.create({
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: Field2.text({
1956
+ id: Field3.text({
1239
1957
  label: "Receipt ID",
1240
1958
  required: true,
1241
1959
  readonly: true
1242
1960
  }),
1243
- notification_id: Field2.text({
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: Field2.text({
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: Field2.text({
1972
+ user_id: Field3.text({
1255
1973
  label: "Recipient User",
1256
1974
  required: true,
1257
1975
  searchable: true
1258
1976
  }),
1259
- channel: Field2.text({
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: Field2.select(["delivered", "read", "clicked", "dismissed"], {
1982
+ state: Field3.select(["delivered", "read", "clicked", "dismissed"], {
1265
1983
  label: "State",
1266
1984
  required: true,
1267
1985
  defaultValue: "delivered"
1268
1986
  }),
1269
- at: Field2.datetime({
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: Field2.datetime({
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 ObjectSchema3, Field as Field3 } from "@objectstack/spec/data";
1287
- var NotificationDelivery = ObjectSchema3.create({
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: Field3.text({ label: "Delivery ID", required: true, readonly: true }),
1299
- notification_id: Field3.text({
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: Field3.text({ label: "Recipient User", required: true, searchable: true }),
1306
- channel: Field3.text({ label: "Channel", required: true }),
1307
- topic: Field3.text({ label: "Topic", searchable: true }),
1308
- payload: Field3.json({
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: Field3.select(["pending", "in_flight", "success", "failed", "dead", "suppressed"], {
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: Field3.number({ label: "Attempts", defaultValue: 0 }),
1318
- partition_key: Field3.number({ label: "Partition Key", defaultValue: 0 }),
1319
- claimed_by: Field3.text({ label: "Claimed By", description: "Node id while in_flight" }),
1320
- claimed_at: Field3.number({ label: "Claimed At (ms)" }),
1321
- next_attempt_at: Field3.number({ label: "Next Attempt At (ms)" }),
1322
- last_attempted_at: Field3.number({ label: "Last Attempted At (ms)" }),
1323
- error: Field3.textarea({ label: "Error" }),
1324
- created_at: Field3.number({ label: "Created At (ms)", readonly: true }),
1325
- updated_at: Field3.number({ label: "Updated At (ms)" })
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 ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
1340
- var NotificationPreference = ObjectSchema4.create({
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: Field4.text({ label: "Preference ID", required: true, readonly: true }),
1352
- user_id: Field4.text({
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: Field4.text({
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: Field4.text({
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: Field4.boolean({
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: Field4.select(["none", "daily", "weekly"], {
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: Field4.json({
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: Field4.datetime({ label: "Created At", readonly: true }),
1388
- updated_at: Field4.datetime({ label: "Updated At", required: false })
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 ObjectSchema5, Field as Field5 } from "@objectstack/spec/data";
1398
- var NotificationSubscription = ObjectSchema5.create({
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: Field5.text({ label: "Subscription ID", required: true, readonly: true }),
1410
- topic: Field5.text({
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: Field5.text({
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: Field5.boolean({
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: Field5.datetime({ label: "Created At", readonly: true })
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 ObjectSchema6, Field as Field6 } from "@objectstack/spec/data";
1437
- var NotificationTemplate = ObjectSchema6.create({
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: Field6.text({ label: "Template ID", required: true, readonly: true }),
1449
- topic: Field6.text({ label: "Topic", required: true, searchable: true }),
1450
- channel: Field6.text({
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: Field6.text({
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: Field6.number({
2180
+ version: Field7.number({
1463
2181
  label: "Version",
1464
2182
  required: false,
1465
2183
  defaultValue: 1
1466
2184
  }),
1467
- subject: Field6.text({
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: Field6.markdown({
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: Field6.select(["markdown", "html", "text", "mjml"], {
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: Field6.boolean({
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: Field6.datetime({ label: "Created At", readonly: true }),
1488
- updated_at: Field6.datetime({ label: "Updated At", required: false })
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}-${randomUUID2().slice(0, 8)}`,
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 randomUUID3 } from "crypto";
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 = randomUUID3();
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