@openlup/platform 0.16.0 → 0.17.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.
Files changed (296) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +285 -5
  3. package/dist/communications/accountingDocumentDelivery.d.ts +35 -0
  4. package/dist/communications/accountingDocumentDelivery.js +33 -0
  5. package/dist/communications/authEmailError.d.ts +5 -0
  6. package/dist/communications/authEmailError.js +8 -0
  7. package/dist/communications/authEmailPorts.d.ts +2 -0
  8. package/dist/communications/authEmailPorts.js +1 -0
  9. package/dist/communications/authSendEmailHook.d.ts +35 -0
  10. package/dist/communications/authSendEmailHook.js +250 -0
  11. package/dist/communications/capturedTransactionalDelivery.d.ts +13 -0
  12. package/dist/communications/capturedTransactionalDelivery.js +14 -0
  13. package/dist/communications/communicationSync.d.ts +11 -0
  14. package/dist/communications/communicationSync.js +79 -0
  15. package/dist/communications/communicationSyncWorker.d.ts +68 -0
  16. package/dist/communications/communicationSyncWorker.js +218 -0
  17. package/dist/communications/consentPolicy.d.ts +19 -0
  18. package/dist/communications/consentPolicy.js +43 -0
  19. package/dist/communications/consentPurposeRegistry.d.ts +4 -0
  20. package/dist/communications/consentPurposeRegistry.js +24 -0
  21. package/dist/communications/contracts/abandonedCart.d.ts +27 -0
  22. package/dist/communications/contracts/abandonedCart.js +25 -0
  23. package/dist/communications/contracts/authEmail.d.ts +62 -0
  24. package/dist/communications/contracts/authEmail.js +1 -0
  25. package/dist/communications/contracts/backInStock.d.ts +28 -0
  26. package/dist/communications/contracts/backInStock.js +28 -0
  27. package/dist/communications/contracts/blocks.d.ts +24 -0
  28. package/dist/communications/contracts/blocks.js +23 -0
  29. package/dist/communications/contracts/checkoutExpired.d.ts +22 -0
  30. package/dist/communications/contracts/checkoutExpired.js +25 -0
  31. package/dist/communications/contracts/checkoutRecovery.d.ts +26 -0
  32. package/dist/communications/contracts/checkoutRecovery.js +37 -0
  33. package/dist/communications/contracts/commerceEmailContent.d.ts +176 -0
  34. package/dist/communications/contracts/commerceEmailContent.js +1 -0
  35. package/dist/communications/contracts/commerceEngagementEmailContent.d.ts +95 -0
  36. package/dist/communications/contracts/commerceEngagementEmailContent.js +1 -0
  37. package/dist/communications/contracts/consent.d.ts +12 -0
  38. package/dist/communications/contracts/consent.js +1 -0
  39. package/dist/communications/contracts/email.d.ts +182 -0
  40. package/dist/communications/contracts/email.js +1 -0
  41. package/dist/communications/contracts/guideCard.d.ts +3 -0
  42. package/dist/communications/contracts/guideCard.js +26 -0
  43. package/dist/communications/contracts/index.d.ts +51 -0
  44. package/dist/communications/contracts/index.js +42 -0
  45. package/dist/communications/contracts/inquiryContent.d.ts +32 -0
  46. package/dist/communications/contracts/inquiryContent.js +33 -0
  47. package/dist/communications/contracts/invoiceDocumentContent.d.ts +26 -0
  48. package/dist/communications/contracts/invoiceDocumentContent.js +36 -0
  49. package/dist/communications/contracts/marketingAutomation.d.ts +52 -0
  50. package/dist/communications/contracts/marketingAutomation.js +1 -0
  51. package/dist/communications/contracts/newsletter.d.ts +63 -0
  52. package/dist/communications/contracts/newsletter.js +1 -0
  53. package/dist/communications/contracts/newsletterWebhook.d.ts +26 -0
  54. package/dist/communications/contracts/newsletterWebhook.js +1 -0
  55. package/dist/communications/contracts/optOutContent.d.ts +11 -0
  56. package/dist/communications/contracts/optOutContent.js +17 -0
  57. package/dist/communications/contracts/orderCanceled.d.ts +25 -0
  58. package/dist/communications/contracts/orderCanceled.js +27 -0
  59. package/dist/communications/contracts/orderDraft.d.ts +46 -0
  60. package/dist/communications/contracts/orderDraft.js +46 -0
  61. package/dist/communications/contracts/orderPaid.d.ts +53 -0
  62. package/dist/communications/contracts/orderPaid.js +71 -0
  63. package/dist/communications/contracts/orderRefunded.d.ts +24 -0
  64. package/dist/communications/contracts/orderRefunded.js +27 -0
  65. package/dist/communications/contracts/paymentFailed.d.ts +24 -0
  66. package/dist/communications/contracts/paymentFailed.js +24 -0
  67. package/dist/communications/contracts/publicIntake.d.ts +49 -0
  68. package/dist/communications/contracts/publicIntake.js +1 -0
  69. package/dist/communications/contracts/render.d.ts +2 -0
  70. package/dist/communications/contracts/render.js +229 -0
  71. package/dist/communications/contracts/reorderReminder.d.ts +24 -0
  72. package/dist/communications/contracts/reorderReminder.js +24 -0
  73. package/dist/communications/contracts/returnEmails.d.ts +25 -0
  74. package/dist/communications/contracts/returnEmails.js +62 -0
  75. package/dist/communications/contracts/reviewEffects.d.ts +26 -0
  76. package/dist/communications/contracts/reviewEffects.js +25 -0
  77. package/dist/communications/contracts/reviewRequest.d.ts +26 -0
  78. package/dist/communications/contracts/reviewRequest.js +24 -0
  79. package/dist/communications/contracts/roleNotificationContent.d.ts +25 -0
  80. package/dist/communications/contracts/roleNotificationContent.js +46 -0
  81. package/dist/communications/contracts/shipmentDelivered.d.ts +23 -0
  82. package/dist/communications/contracts/shipmentDelivered.js +24 -0
  83. package/dist/communications/contracts/shipmentDispatched.d.ts +27 -0
  84. package/dist/communications/contracts/shipmentDispatched.js +35 -0
  85. package/dist/communications/contracts/shipmentException.d.ts +21 -0
  86. package/dist/communications/contracts/shipmentException.js +26 -0
  87. package/dist/communications/contracts/subscriptionActivationActionRequired.d.ts +17 -0
  88. package/dist/communications/contracts/subscriptionActivationActionRequired.js +22 -0
  89. package/dist/communications/contracts/subscriptionAddressChanged.d.ts +19 -0
  90. package/dist/communications/contracts/subscriptionAddressChanged.js +25 -0
  91. package/dist/communications/contracts/subscriptionCancelled.d.ts +19 -0
  92. package/dist/communications/contracts/subscriptionCancelled.js +25 -0
  93. package/dist/communications/contracts/subscriptionCycleSkipped.d.ts +25 -0
  94. package/dist/communications/contracts/subscriptionCycleSkipped.js +40 -0
  95. package/dist/communications/contracts/subscriptionDeliveryRescheduled.d.ts +25 -0
  96. package/dist/communications/contracts/subscriptionDeliveryRescheduled.js +39 -0
  97. package/dist/communications/contracts/subscriptionEmailContent.d.ts +145 -0
  98. package/dist/communications/contracts/subscriptionEmailContent.js +1 -0
  99. package/dist/communications/contracts/subscriptionPackageChanged.d.ts +20 -0
  100. package/dist/communications/contracts/subscriptionPackageChanged.js +26 -0
  101. package/dist/communications/contracts/subscriptionPauseReminder.d.ts +23 -0
  102. package/dist/communications/contracts/subscriptionPauseReminder.js +29 -0
  103. package/dist/communications/contracts/subscriptionPaused.d.ts +21 -0
  104. package/dist/communications/contracts/subscriptionPaused.js +25 -0
  105. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +34 -0
  106. package/dist/communications/contracts/subscriptionPaymentExpired.js +37 -0
  107. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +49 -0
  108. package/dist/communications/contracts/subscriptionPaymentFailed.js +56 -0
  109. package/dist/communications/contracts/subscriptionPaymentRecovered.d.ts +19 -0
  110. package/dist/communications/contracts/subscriptionPaymentRecovered.js +22 -0
  111. package/dist/communications/contracts/subscriptionRenewalAtRisk.d.ts +36 -0
  112. package/dist/communications/contracts/subscriptionRenewalAtRisk.js +36 -0
  113. package/dist/communications/contracts/subscriptionRenewalUpcoming.d.ts +49 -0
  114. package/dist/communications/contracts/subscriptionRenewalUpcoming.js +43 -0
  115. package/dist/communications/contracts/subscriptionResumed.d.ts +23 -0
  116. package/dist/communications/contracts/subscriptionResumed.js +25 -0
  117. package/dist/communications/contracts/subscriptionWelcome.d.ts +44 -0
  118. package/dist/communications/contracts/subscriptionWelcome.js +45 -0
  119. package/dist/communications/contracts/subscriptionWinback.d.ts +21 -0
  120. package/dist/communications/contracts/subscriptionWinback.js +28 -0
  121. package/dist/communications/contracts/transactionalDelivery.d.ts +42 -0
  122. package/dist/communications/contracts/transactionalDelivery.js +1 -0
  123. package/dist/communications/contracts/types.d.ts +46 -0
  124. package/dist/communications/contracts/types.js +1 -0
  125. package/dist/communications/contribution.d.ts +10 -0
  126. package/dist/communications/contribution.js +24 -0
  127. package/dist/communications/deliveryReconciliation.d.ts +64 -0
  128. package/dist/communications/deliveryReconciliation.js +60 -0
  129. package/dist/communications/emailEgress.d.ts +38 -0
  130. package/dist/communications/emailEgress.js +100 -0
  131. package/dist/communications/emailHealthAssessment.d.ts +39 -0
  132. package/dist/communications/emailHealthAssessment.js +59 -0
  133. package/dist/communications/emailHealthMetricsPort.d.ts +17 -0
  134. package/dist/communications/emailHealthMetricsPort.js +1 -0
  135. package/dist/communications/emailNotificationControl.d.ts +27 -0
  136. package/dist/communications/emailNotificationControl.js +10 -0
  137. package/dist/communications/emailObservation.d.ts +28 -0
  138. package/dist/communications/emailObservation.js +23 -0
  139. package/dist/communications/emailTransport.d.ts +82 -0
  140. package/dist/communications/emailTransport.js +95 -0
  141. package/dist/communications/errors.d.ts +15 -0
  142. package/dist/communications/errors.js +30 -0
  143. package/dist/communications/index.d.ts +46 -0
  144. package/dist/communications/index.js +38 -0
  145. package/dist/communications/integrationEventSignature.d.ts +15 -0
  146. package/dist/communications/integrationEventSignature.js +37 -0
  147. package/dist/communications/managedTransactionalDeliveryStore.d.ts +12 -0
  148. package/dist/communications/managedTransactionalDeliveryStore.js +49 -0
  149. package/dist/communications/nativeOperations.d.ts +79 -0
  150. package/dist/communications/nativeOperations.js +3 -0
  151. package/dist/communications/newsletterIntegrationContracts.d.ts +130 -0
  152. package/dist/communications/newsletterIntegrationContracts.js +128 -0
  153. package/dist/communications/newsletterWebhookPort.d.ts +11 -0
  154. package/dist/communications/newsletterWebhookPort.js +84 -0
  155. package/dist/communications/newsletterWebhookProcessing.d.ts +17 -0
  156. package/dist/communications/newsletterWebhookProcessing.js +71 -0
  157. package/dist/communications/originPolicy.d.ts +40 -0
  158. package/dist/communications/originPolicy.js +100 -0
  159. package/dist/communications/postgresCommunicationSync.d.ts +79 -0
  160. package/dist/communications/postgresCommunicationSync.js +111 -0
  161. package/dist/communications/postgresConsentEvaluator.d.ts +19 -0
  162. package/dist/communications/postgresConsentEvaluator.js +43 -0
  163. package/dist/communications/postgresDeliveryReconciliation.d.ts +3 -0
  164. package/dist/communications/postgresDeliveryReconciliation.js +113 -0
  165. package/dist/communications/postgresNativeOperations.d.ts +13 -0
  166. package/dist/communications/postgresNativeOperations.js +63 -0
  167. package/dist/communications/postgresNotificationRecipients.d.ts +37 -0
  168. package/dist/communications/postgresNotificationRecipients.js +60 -0
  169. package/dist/communications/postgresTransactionalDeliveryStore.d.ts +7 -0
  170. package/dist/communications/postgresTransactionalDeliveryStore.js +55 -0
  171. package/dist/communications/preferencesToken.d.ts +10 -0
  172. package/dist/communications/preferencesToken.js +40 -0
  173. package/dist/communications/publicPreferenceToken.d.ts +24 -0
  174. package/dist/communications/publicPreferenceToken.js +53 -0
  175. package/dist/communications/shipmentNotifications.d.ts +34 -0
  176. package/dist/communications/shipmentNotifications.js +61 -0
  177. package/dist/communications/sql.d.ts +26 -0
  178. package/dist/communications/sql.js +7 -0
  179. package/dist/communications/standardWebhooks.d.ts +14 -0
  180. package/dist/communications/standardWebhooks.js +73 -0
  181. package/dist/communications/status.d.ts +11 -0
  182. package/dist/communications/status.js +36 -0
  183. package/dist/communications/transactionalDelivery.d.ts +19 -0
  184. package/dist/communications/transactionalDelivery.js +96 -0
  185. package/dist/communications/transactionalDeliveryIdentifier.d.ts +3 -0
  186. package/dist/communications/transactionalDeliveryIdentifier.js +6 -0
  187. package/dist/communications/unsubscribeToken.d.ts +77 -0
  188. package/dist/communications/unsubscribeToken.js +150 -0
  189. package/dist/communications/validation.d.ts +2 -0
  190. package/dist/communications/validation.js +3 -0
  191. package/dist/http/index.d.ts +1 -0
  192. package/dist/http/index.js +1 -0
  193. package/dist/http/webhookSignature.d.ts +42 -0
  194. package/dist/http/webhookSignature.js +86 -0
  195. package/package.json +12 -2
  196. package/release-gates.json +41 -8
  197. package/sql/20260807190000_comms_outbox_rail.sql +329 -0
  198. package/sql/20260810120001_transactional_delivery_receipts.sql +209 -0
  199. package/sql/20260813120000_communications_control_plane.sql +568 -0
  200. package/sql/20261019200000_communications_native_operations.sql +68 -0
  201. package/src/communications/accountingDocumentDelivery.ts +63 -0
  202. package/src/communications/authEmailError.ts +10 -0
  203. package/src/communications/authEmailPorts.ts +2 -0
  204. package/src/communications/authSendEmailHook.ts +310 -0
  205. package/src/communications/capturedTransactionalDelivery.ts +29 -0
  206. package/src/communications/communicationSync.ts +96 -0
  207. package/src/communications/communicationSyncWorker.ts +302 -0
  208. package/src/communications/consentPolicy.ts +82 -0
  209. package/src/communications/consentPurposeRegistry.ts +31 -0
  210. package/src/communications/contracts/abandonedCart.ts +64 -0
  211. package/src/communications/contracts/authEmail.ts +73 -0
  212. package/src/communications/contracts/backInStock.ts +69 -0
  213. package/src/communications/contracts/blocks.ts +47 -0
  214. package/src/communications/contracts/checkoutExpired.ts +62 -0
  215. package/src/communications/contracts/checkoutRecovery.ts +68 -0
  216. package/src/communications/contracts/commerceEmailContent.ts +189 -0
  217. package/src/communications/contracts/commerceEngagementEmailContent.ts +106 -0
  218. package/src/communications/contracts/consent.ts +13 -0
  219. package/src/communications/contracts/email.ts +210 -0
  220. package/src/communications/contracts/guideCard.ts +34 -0
  221. package/src/communications/contracts/index.ts +51 -0
  222. package/src/communications/contracts/inquiryContent.ts +53 -0
  223. package/src/communications/contracts/invoiceDocumentContent.ts +57 -0
  224. package/src/communications/contracts/marketingAutomation.ts +45 -0
  225. package/src/communications/contracts/newsletter.ts +78 -0
  226. package/src/communications/contracts/newsletterWebhook.ts +23 -0
  227. package/src/communications/contracts/optOutContent.ts +23 -0
  228. package/src/communications/contracts/orderCanceled.ts +64 -0
  229. package/src/communications/contracts/orderDraft.ts +112 -0
  230. package/src/communications/contracts/orderPaid.ts +147 -0
  231. package/src/communications/contracts/orderRefunded.ts +63 -0
  232. package/src/communications/contracts/paymentFailed.ts +59 -0
  233. package/src/communications/contracts/publicIntake.ts +53 -0
  234. package/src/communications/contracts/render.ts +271 -0
  235. package/src/communications/contracts/reorderReminder.ts +59 -0
  236. package/src/communications/contracts/returnEmails.ts +113 -0
  237. package/src/communications/contracts/reviewEffects.ts +60 -0
  238. package/src/communications/contracts/reviewRequest.ts +59 -0
  239. package/src/communications/contracts/roleNotificationContent.ts +65 -0
  240. package/src/communications/contracts/shipmentDelivered.ts +59 -0
  241. package/src/communications/contracts/shipmentDispatched.ts +77 -0
  242. package/src/communications/contracts/shipmentException.ts +62 -0
  243. package/src/communications/contracts/subscriptionActivationActionRequired.ts +37 -0
  244. package/src/communications/contracts/subscriptionAddressChanged.ts +56 -0
  245. package/src/communications/contracts/subscriptionCancelled.ts +57 -0
  246. package/src/communications/contracts/subscriptionCycleSkipped.ts +80 -0
  247. package/src/communications/contracts/subscriptionDeliveryRescheduled.ts +79 -0
  248. package/src/communications/contracts/subscriptionEmailContent.ts +163 -0
  249. package/src/communications/contracts/subscriptionPackageChanged.ts +59 -0
  250. package/src/communications/contracts/subscriptionPauseReminder.ts +63 -0
  251. package/src/communications/contracts/subscriptionPaused.ts +58 -0
  252. package/src/communications/contracts/subscriptionPaymentExpired.ts +83 -0
  253. package/src/communications/contracts/subscriptionPaymentFailed.ts +117 -0
  254. package/src/communications/contracts/subscriptionPaymentRecovered.ts +50 -0
  255. package/src/communications/contracts/subscriptionRenewalAtRisk.ts +85 -0
  256. package/src/communications/contracts/subscriptionRenewalUpcoming.ts +103 -0
  257. package/src/communications/contracts/subscriptionResumed.ts +60 -0
  258. package/src/communications/contracts/subscriptionWelcome.ts +102 -0
  259. package/src/communications/contracts/subscriptionWinback.ts +62 -0
  260. package/src/communications/contracts/transactionalDelivery.ts +43 -0
  261. package/src/communications/contracts/types.ts +58 -0
  262. package/src/communications/contribution.ts +33 -0
  263. package/src/communications/deliveryReconciliation.ts +108 -0
  264. package/src/communications/emailEgress.ts +111 -0
  265. package/src/communications/emailHealthAssessment.ts +106 -0
  266. package/src/communications/emailHealthMetricsPort.ts +17 -0
  267. package/src/communications/emailNotificationControl.ts +28 -0
  268. package/src/communications/emailObservation.ts +43 -0
  269. package/src/communications/emailTransport.ts +167 -0
  270. package/src/communications/errors.ts +34 -0
  271. package/src/communications/index.ts +46 -0
  272. package/src/communications/integrationEventSignature.ts +49 -0
  273. package/src/communications/managedTransactionalDeliveryStore.ts +59 -0
  274. package/src/communications/nativeOperations.ts +40 -0
  275. package/src/communications/newsletterIntegrationContracts.ts +154 -0
  276. package/src/communications/newsletterWebhookPort.ts +104 -0
  277. package/src/communications/newsletterWebhookProcessing.ts +102 -0
  278. package/src/communications/originPolicy.ts +151 -0
  279. package/src/communications/postgresCommunicationSync.ts +127 -0
  280. package/src/communications/postgresConsentEvaluator.ts +49 -0
  281. package/src/communications/postgresDeliveryReconciliation.ts +101 -0
  282. package/src/communications/postgresNativeOperations.ts +55 -0
  283. package/src/communications/postgresNotificationRecipients.ts +57 -0
  284. package/src/communications/postgresTransactionalDeliveryStore.ts +79 -0
  285. package/src/communications/preferencesToken.ts +62 -0
  286. package/src/communications/publicPreferenceToken.ts +68 -0
  287. package/src/communications/shipmentNotifications.ts +78 -0
  288. package/src/communications/sql.ts +7 -0
  289. package/src/communications/standardWebhooks.ts +84 -0
  290. package/src/communications/status.ts +51 -0
  291. package/src/communications/transactionalDelivery.ts +114 -0
  292. package/src/communications/transactionalDeliveryIdentifier.ts +9 -0
  293. package/src/communications/unsubscribeToken.ts +186 -0
  294. package/src/communications/validation.ts +3 -0
  295. package/src/http/index.ts +1 -0
  296. package/src/http/webhookSignature.ts +96 -0
@@ -0,0 +1,154 @@
1
+ import type { CommunicationPurposeRegistry } from "./contracts/consent.js";
2
+ import { z } from "./validation.js";
3
+
4
+ export const NEWSLETTER_MARKETING_PURPOSES = [
5
+ "marketing_launch_offer",
6
+ "marketing_newsletter",
7
+ ] as const;
8
+
9
+ export const NEWSLETTER_SYNC_EVENT_TYPES = [
10
+ "subscribe",
11
+ "update",
12
+ "unsubscribe",
13
+ "suppress",
14
+ ] as const;
15
+
16
+ export const NEWSLETTER_WEBHOOK_EVENT_TYPES = [
17
+ ...NEWSLETTER_SYNC_EVENT_TYPES,
18
+ "complaint",
19
+ "bounce",
20
+ "email_change",
21
+ "profile_update",
22
+ "ignored",
23
+ ] as const;
24
+
25
+ export const NEWSLETTER_PROVIDER_CAPABILITY_KEYS = [
26
+ "subscribe",
27
+ "unsubscribe",
28
+ "suppress",
29
+ "unsuppress",
30
+ "doubleOptIn",
31
+ "emailChange",
32
+ "listMembership",
33
+ "webhookIdempotency",
34
+ "retryAfter",
35
+ ] as const;
36
+
37
+ import type { NewsletterMarketingPurpose } from "./contracts/newsletter.js";
38
+ import type { NewsletterProviderKind, NewsletterSyncEventType, NewsletterWebhookEventType, NewsletterProviderCapabilityKey, NewsletterProviderCapabilities, NewsletterWebhookEvent, NewsletterSyncEvent } from "./contracts/newsletter.js";
39
+ export type { NewsletterProviderKind, NewsletterMarketingPurpose, NewsletterSyncEventType, NewsletterWebhookEventType, NewsletterProviderCapabilityKey, NewsletterProviderCapabilities, NewsletterWebhookEvent, NewsletterSyncEvent } from "./contracts/newsletter.js";
40
+ export const defaultNewsletterProviderCapabilities: NewsletterProviderCapabilities = {
41
+ subscribe: false,
42
+ unsubscribe: false,
43
+ suppress: false,
44
+ unsuppress: false,
45
+ doubleOptIn: false,
46
+ emailChange: false,
47
+ listMembership: false,
48
+ webhookIdempotency: false,
49
+ retryAfter: false,
50
+ };
51
+
52
+ export const fullNewsletterProviderCapabilities: NewsletterProviderCapabilities = {
53
+ subscribe: true,
54
+ unsubscribe: true,
55
+ suppress: true,
56
+ unsuppress: true,
57
+ doubleOptIn: true,
58
+ emailChange: true,
59
+ listMembership: true,
60
+ webhookIdempotency: true,
61
+ retryAfter: true,
62
+ };
63
+
64
+ export const newsletterProviderKindSchema = z
65
+ .string()
66
+ .trim()
67
+ .min(1)
68
+ .max(80)
69
+ .regex(/^[a-z0-9][a-z0-9_-]*$/);
70
+
71
+ export const newsletterMarketingPurposeSchema = z.enum(NEWSLETTER_MARKETING_PURPOSES);
72
+ export const newsletterSyncEventTypeSchema = z.enum(NEWSLETTER_SYNC_EVENT_TYPES);
73
+ export const newsletterWebhookEventTypeSchema = z.enum(NEWSLETTER_WEBHOOK_EVENT_TYPES);
74
+
75
+ export const newsletterProviderCapabilitiesSchema = z.object({
76
+ subscribe: z.boolean(),
77
+ unsubscribe: z.boolean(),
78
+ suppress: z.boolean(),
79
+ unsuppress: z.boolean(),
80
+ doubleOptIn: z.boolean(),
81
+ emailChange: z.boolean(),
82
+ listMembership: z.boolean(),
83
+ webhookIdempotency: z.boolean(),
84
+ retryAfter: z.boolean(),
85
+ });
86
+
87
+ export const canonicalNewsletterWebhookEventSchema = z.object({
88
+ providerKind: newsletterProviderKindSchema,
89
+ providerEventId: z.string().trim().min(1).max(200),
90
+ eventType: newsletterWebhookEventTypeSchema,
91
+ email: z.string().trim().email().nullable().optional(),
92
+ remoteProfileId: z.string().trim().min(1).max(200).nullable().optional(),
93
+ remoteListId: z.string().trim().min(1).max(200).nullable().optional(),
94
+ purpose: newsletterMarketingPurposeSchema.nullable().optional(),
95
+ explicitOptInEvidence: z.boolean().optional(),
96
+ doubleOptInStatus: z.enum(["confirmed", "pending", "unknown"]).optional(),
97
+ occurredAt: z.string().datetime({ offset: true }).nullable().optional(),
98
+ payload: z.record(z.string(), z.unknown()).optional(),
99
+ });
100
+
101
+ export type CanonicalNewsletterWebhookEvent = z.infer<
102
+ typeof canonicalNewsletterWebhookEventSchema
103
+ >;
104
+
105
+ export function normalizeCanonicalNewsletterWebhookEvent<Purpose extends string = NewsletterMarketingPurpose>(
106
+ event: Omit<CanonicalNewsletterWebhookEvent, "purpose"> & { purpose?: Purpose | null },
107
+ ): NewsletterWebhookEvent<Purpose> {
108
+ return {
109
+ providerKind: event.providerKind,
110
+ providerEventId: event.providerEventId,
111
+ eventType: event.eventType,
112
+ email: event.email ?? null,
113
+ remoteProfileId: event.remoteProfileId ?? null,
114
+ remoteListId: event.remoteListId ?? null,
115
+ purpose: event.purpose ?? null,
116
+ explicitOptInEvidence: event.explicitOptInEvidence === true,
117
+ doubleOptInStatus: event.doubleOptInStatus ?? "unknown",
118
+ occurredAt: event.occurredAt ?? null,
119
+ rawPayload: event.payload ?? {},
120
+ };
121
+ }
122
+
123
+ export function requiredNewsletterCapabilityForEvent(
124
+ eventType: NewsletterSyncEventType,
125
+ ): NewsletterProviderCapabilityKey {
126
+ if (eventType === "subscribe") return "subscribe";
127
+ if (eventType === "unsubscribe") return "unsubscribe";
128
+ if (eventType === "suppress") return "suppress";
129
+ return "listMembership";
130
+ }
131
+
132
+ export function supportsNewsletterSyncEvent(
133
+ capabilities: NewsletterProviderCapabilities,
134
+ eventType: NewsletterSyncEventType,
135
+ ): boolean {
136
+ return capabilities[requiredNewsletterCapabilityForEvent(eventType)] === true;
137
+ }
138
+
139
+ /** Explicit registry binding; legacy schemas above retain their two-purpose default. */
140
+ export function createNewsletterIntegrationSchemas<Purpose extends string>(registry: CommunicationPurposeRegistry<Purpose>) {
141
+ const purpose = z.custom<Purpose>((value) => registry.has(value), "unregistered_communication_purpose");
142
+ return {
143
+ purpose,
144
+ webhookEvent: canonicalNewsletterWebhookEventSchema.extend({ purpose: purpose.nullable().optional() }),
145
+ syncEvent: z.object({
146
+ id: z.string().min(1), claimToken: z.string().min(1), contactId: z.string().min(1),
147
+ normalizedEmail: z.string().email(), eventType: newsletterSyncEventTypeSchema,
148
+ purpose, payload: z.record(z.string(), z.unknown()), attemptCount: z.number().int().min(0),
149
+ originProviderKind: newsletterProviderKindSchema.nullable().optional(),
150
+ originProviderEventId: z.string().nullable().optional(), remoteProfileId: z.string().nullable().optional(),
151
+ remoteListId: z.string().nullable().optional(),
152
+ }),
153
+ };
154
+ }
@@ -0,0 +1,104 @@
1
+ import type {
2
+ NewsletterWebhookPort,
3
+ NormalizedNewsletterWebhookEvent,
4
+ } from "./contracts/newsletterWebhook.js";
5
+
6
+ export interface NewsletterWebhookRpcClient {
7
+ rpc(
8
+ functionName: string,
9
+ args: Record<string, unknown>,
10
+ ): PromiseLike<{ data: unknown; error: { code?: string; message?: string } | null }>;
11
+ }
12
+
13
+ export function createSupabaseNewsletterWebhookPort(
14
+ client: NewsletterWebhookRpcClient,
15
+ ): NewsletterWebhookPort {
16
+ return {
17
+ async recordProviderEvent(input) {
18
+ const { data, error } = await client.rpc("communication_record_provider_event", {
19
+ p_provider_kind: input.providerKind,
20
+ p_provider_event_id: input.providerEventId,
21
+ p_event_type: input.eventType,
22
+ p_email: input.email,
23
+ p_remote_profile_id: input.remoteProfileId,
24
+ p_purpose: input.purpose,
25
+ p_state: input.state,
26
+ p_payload: sanitizePayload(input.rawPayload),
27
+ p_metadata: providerEventMetadata(input),
28
+ p_processing_status: input.processingStatus,
29
+ });
30
+ if (error) throw rpcFailure("communication_record_provider_event", error);
31
+ const record = isRecord(data) ? data : {};
32
+ return {
33
+ providerEventId: readString(record.providerEventId) ?? "",
34
+ contactId: readString(record.contactId),
35
+ inserted: record.inserted === true,
36
+ };
37
+ },
38
+
39
+ async recordPermission(input) {
40
+ const { error } = await client.rpc("communication_record_permission_event", {
41
+ p_contact_id: input.contactId,
42
+ p_purpose: input.purpose,
43
+ p_state: input.state,
44
+ p_source: "newsletter_provider_webhook",
45
+ p_source_ref: {
46
+ providerKind: input.providerKind,
47
+ providerEventId: input.providerEventId,
48
+ },
49
+ p_reason: input.reason,
50
+ p_metadata: {
51
+ ...input.metadata,
52
+ actorType: "provider_webhook",
53
+ captureMethod: "newsletter_webhook",
54
+ originProviderKind: input.providerKind,
55
+ originProviderEventId: input.providerEventId,
56
+ },
57
+ p_captured_at: new Date().toISOString(),
58
+ p_create_sync_event: true,
59
+ });
60
+ if (error) throw rpcFailure("communication_record_permission_event", error);
61
+ },
62
+
63
+ async markProviderEvent(input) {
64
+ const { error } = await client.rpc("communication_mark_provider_event_processed", {
65
+ p_provider_event_id: input.providerEventId,
66
+ p_processing_status: input.status,
67
+ p_metadata: input.metadata,
68
+ });
69
+ if (error) throw rpcFailure("communication_mark_provider_event_processed", error);
70
+ },
71
+ };
72
+ }
73
+
74
+ function providerEventMetadata(
75
+ input: NormalizedNewsletterWebhookEvent & { processingStatus: "received" | "ignored" },
76
+ ): Record<string, unknown> {
77
+ return {
78
+ occurredAt: input.occurredAt,
79
+ explicitOptInEvidence: input.explicitOptInEvidence,
80
+ doubleOptInStatus: input.doubleOptInStatus,
81
+ remoteListId: input.remoteListId,
82
+ processingStatus: input.processingStatus,
83
+ };
84
+ }
85
+
86
+ function sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
87
+ const sanitized = { ...payload };
88
+ delete sanitized.apiKey;
89
+ delete sanitized.token;
90
+ delete sanitized.secret;
91
+ return sanitized;
92
+ }
93
+
94
+ function rpcFailure(rpcName: string, error: { code?: string; message?: string }): Error {
95
+ return new Error(`${rpcName}_failed: ${error.message ?? error.code ?? "unknown"}`);
96
+ }
97
+
98
+ function isRecord(value: unknown): value is Record<string, unknown> {
99
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
100
+ }
101
+
102
+ function readString(value: unknown): string | null {
103
+ return typeof value === "string" && value.trim() ? value.trim() : null;
104
+ }
@@ -0,0 +1,102 @@
1
+ import type { NewsletterMarketingPurpose, NewsletterWebhookEvent } from "./contracts/newsletter.js";
2
+ import type { NewsletterWebhookPort } from "./contracts/newsletterWebhook.js";
3
+ import type { CommunicationPurposeRegistry } from "./contracts/consent.js";
4
+
5
+ export interface NewsletterWebhookProcessResult {
6
+ provider: string;
7
+ providerEventId: string;
8
+ replayed?: boolean;
9
+ status?: "processed" | "ignored";
10
+ reason?: string;
11
+ localState?: "granted" | "suppressed";
12
+ }
13
+
14
+ const MARKETING_PURPOSES = ["marketing_launch_offer", "marketing_newsletter"] as const;
15
+
16
+ export async function processNewsletterWebhookEvent<Purpose extends string = NewsletterMarketingPurpose>(input: {
17
+ providerKind: string;
18
+ event: NewsletterWebhookEvent<Purpose>;
19
+ port: NewsletterWebhookPort<Purpose | NewsletterMarketingPurpose>;
20
+ registry?: CommunicationPurposeRegistry<Purpose>;
21
+ }): Promise<NewsletterWebhookProcessResult> {
22
+ const { providerKind, event, port } = input;
23
+ if (input.registry && event.purpose !== null && !input.registry.has(event.purpose)) {
24
+ throw new Error("unregistered_communication_purpose");
25
+ }
26
+ const action = localAction(event);
27
+ const providerEvent = await port.recordProviderEvent({
28
+ ...event,
29
+ state: action.state,
30
+ processingStatus: action.kind === "ignore" ? "ignored" : "received",
31
+ });
32
+
33
+ if (!providerEvent.inserted) {
34
+ return { provider: providerKind, providerEventId: event.providerEventId, replayed: true };
35
+ }
36
+
37
+ if (action.kind === "ignore" || !providerEvent.contactId) {
38
+ await port.markProviderEvent({
39
+ providerEventId: providerEvent.providerEventId,
40
+ status: "ignored",
41
+ metadata: { reason: action.reason },
42
+ });
43
+ return {
44
+ provider: providerKind,
45
+ providerEventId: event.providerEventId,
46
+ status: "ignored",
47
+ reason: action.reason,
48
+ };
49
+ }
50
+
51
+ for (const purpose of event.purpose ? [event.purpose] : MARKETING_PURPOSES) {
52
+ await port.recordPermission({
53
+ contactId: providerEvent.contactId,
54
+ providerKind,
55
+ providerEventId: event.providerEventId,
56
+ purpose,
57
+ state: action.state,
58
+ reason: action.reason,
59
+ metadata: {
60
+ source: "newsletter_provider_webhook",
61
+ providerKind,
62
+ providerEventId: event.providerEventId,
63
+ remoteProfileId: event.remoteProfileId,
64
+ remoteListId: event.remoteListId,
65
+ doubleOptInStatus: event.doubleOptInStatus,
66
+ explicitOptInEvidence: event.explicitOptInEvidence,
67
+ },
68
+ });
69
+ }
70
+
71
+ await port.markProviderEvent({
72
+ providerEventId: providerEvent.providerEventId,
73
+ status: "processed",
74
+ metadata: { localState: action.state, reason: action.reason },
75
+ });
76
+
77
+ return {
78
+ provider: providerKind,
79
+ providerEventId: event.providerEventId,
80
+ status: "processed",
81
+ localState: action.state,
82
+ };
83
+ }
84
+
85
+ function localAction<Purpose extends string>(
86
+ event: NewsletterWebhookEvent<Purpose>,
87
+ ): { kind: "apply"; state: "granted" | "suppressed"; reason: string } | {
88
+ kind: "ignore";
89
+ state: null;
90
+ reason: string;
91
+ } {
92
+ if (["unsubscribe", "suppress", "complaint"].includes(event.eventType)) {
93
+ return { kind: "apply", state: "suppressed", reason: `provider_${event.eventType}` };
94
+ }
95
+ if (event.eventType === "subscribe") {
96
+ if (event.explicitOptInEvidence || event.doubleOptInStatus === "confirmed") {
97
+ return { kind: "apply", state: "granted", reason: "provider_verified_opt_in" };
98
+ }
99
+ return { kind: "ignore", state: null, reason: "provider_subscribe_without_verified_opt_in" };
100
+ }
101
+ return { kind: "ignore", state: null, reason: "provider_event_not_permission_mutating" };
102
+ }
@@ -0,0 +1,151 @@
1
+ export type EmailOriginSource = "explicit" | "default";
2
+ export type EmailEnvironment =
3
+ | "hidden_preview"
4
+ | "preview"
5
+ | "staging"
6
+ | "production"
7
+ | "production_or_default"
8
+ | "unknown";
9
+ export type EmailOriginPolicyError =
10
+ | "hidden_preview_origin_required"
11
+ | "hidden_preview_origin_must_not_be_production"
12
+ | "invalid_email_origin";
13
+
14
+ export interface ResolvedEmailOrigin {
15
+ origin: string;
16
+ source: EmailOriginSource;
17
+ environment: EmailEnvironment;
18
+ production: boolean;
19
+ }
20
+
21
+ export type EmailOriginResolution =
22
+ | { ok: true; origin: string; source: EmailOriginSource; resolved: ResolvedEmailOrigin }
23
+ | { ok: false; error: EmailOriginPolicyError };
24
+
25
+ export interface EmailOriginInput {
26
+ /** Brand/configured default origin used when no explicit origin is set. */
27
+ defaultOrigin: string;
28
+ /** Hosts treated as production by the policy (brand-supplied). */
29
+ productionEmailHosts: string[];
30
+ explicitBaseUrl?: string | null;
31
+ customerAuthRedirectOrigin?: string | null;
32
+ siteUrl?: string | null;
33
+ hiddenPreviewEnabled?: boolean;
34
+ environment?: string | null;
35
+ }
36
+
37
+ export const EMAIL_DEFAULT_ORIGIN_ENV = "EMAIL_DEFAULT_ORIGIN";
38
+ export const EMAIL_PRODUCTION_ORIGIN_HOSTS_ENV = "EMAIL_PRODUCTION_ORIGIN_HOSTS";
39
+ export const COMMS_PRODUCTION_PROJECT_REF_ENV = "COMMS_PRODUCTION_PROJECT_REF";
40
+
41
+ export interface EmailOriginConfiguration {
42
+ defaultOrigin: string;
43
+ productionEmailHosts: string[];
44
+ }
45
+
46
+ function clean(value: string | null | undefined): string | null {
47
+ const trimmed = value?.trim();
48
+ return trimmed ? trimmed : null;
49
+ }
50
+
51
+ function resolveEnvironment(input: EmailOriginInput): EmailEnvironment {
52
+ if (input.hiddenPreviewEnabled) return "hidden_preview";
53
+ const value = clean(input.environment)?.toLowerCase().replace(/-/g, "_");
54
+ if (
55
+ value === "hidden_preview" ||
56
+ value === "preview" ||
57
+ value === "staging" ||
58
+ value === "production" ||
59
+ value === "production_or_default"
60
+ ) {
61
+ return value;
62
+ }
63
+ return "production_or_default";
64
+ }
65
+
66
+ export function normalizeEmailOrigin(value: string): string | null {
67
+ try {
68
+ const url = new URL(value);
69
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
70
+ if (!url.hostname) return null;
71
+ return url.origin.replace(/\/+$/, "");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export function isProductionEmailOrigin(origin: string, productionHosts: string[]): boolean {
78
+ try {
79
+ return productionHosts.includes(new URL(origin).hostname.toLowerCase());
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ export function readEmailOriginConfiguration(
86
+ env: Record<string, string | undefined>,
87
+ fallback: EmailOriginConfiguration,
88
+ ): EmailOriginConfiguration {
89
+ const configuredDefault = env[EMAIL_DEFAULT_ORIGIN_ENV];
90
+ const configuredHosts = env[EMAIL_PRODUCTION_ORIGIN_HOSTS_ENV];
91
+ return {
92
+ defaultOrigin: configuredDefault === undefined ? fallback.defaultOrigin : configuredDefault,
93
+ productionEmailHosts: configuredHosts === undefined
94
+ ? fallback.productionEmailHosts
95
+ : configuredHosts
96
+ .split(/[,\s;]+/)
97
+ .map((host) => host.trim().toLowerCase())
98
+ .filter(Boolean),
99
+ };
100
+ }
101
+
102
+ function requiresExplicitNonProductionOrigin(environment: EmailEnvironment): boolean {
103
+ return environment === "hidden_preview" || environment === "preview" || environment === "staging";
104
+ }
105
+
106
+ export function resolveEmailOrigin(input: EmailOriginInput): EmailOriginResolution {
107
+ const environment = resolveEnvironment(input);
108
+ const explicit =
109
+ clean(input.explicitBaseUrl) ??
110
+ clean(input.customerAuthRedirectOrigin) ??
111
+ clean(input.siteUrl);
112
+
113
+ if (!explicit) {
114
+ if (requiresExplicitNonProductionOrigin(environment)) {
115
+ return { ok: false, error: "hidden_preview_origin_required" };
116
+ }
117
+ const defaultOrigin = normalizeEmailOrigin(input.defaultOrigin);
118
+ if (!defaultOrigin) return { ok: false, error: "invalid_email_origin" };
119
+ return {
120
+ ok: true,
121
+ origin: defaultOrigin,
122
+ source: "default",
123
+ resolved: {
124
+ origin: defaultOrigin,
125
+ source: "default",
126
+ environment,
127
+ production: isProductionEmailOrigin(defaultOrigin, input.productionEmailHosts),
128
+ },
129
+ };
130
+ }
131
+
132
+ const normalized = normalizeEmailOrigin(explicit);
133
+ if (!normalized) return { ok: false, error: "invalid_email_origin" };
134
+
135
+ const production = isProductionEmailOrigin(normalized, input.productionEmailHosts);
136
+ if (requiresExplicitNonProductionOrigin(environment) && production) {
137
+ return { ok: false, error: "hidden_preview_origin_must_not_be_production" };
138
+ }
139
+
140
+ return {
141
+ ok: true,
142
+ origin: normalized,
143
+ source: "explicit",
144
+ resolved: {
145
+ origin: normalized,
146
+ source: "explicit",
147
+ environment,
148
+ production,
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,127 @@
1
+ import type { SqlExecutor } from "@openlup/core/readiness";
2
+ import type { PostgresDataGatewayEnv } from "../data/index.js";
3
+ import type { CommunicationSyncStore } from "./communicationSyncWorker.js";
4
+ import type { NewsletterSyncEvent, NewsletterSyncEventType, NewsletterMarketingPurpose } from "./contracts/newsletter.js";
5
+ import type { NativeCommunicationsOptions } from "./nativeOperations.js";
6
+ import { nativeEmail, nativeFingerprint, nativeInteger, nativeJson, nativeStamp, nativeText, nativeTransactions } from "./postgresNativeOperations.js";
7
+
8
+ /** Structural seam for the existing outbox store, scoped to this operation's transaction. */
9
+ export interface NativeSyncRail {
10
+ claimBatch(input: { eventTypes: string[]; knownEventTypes: string[]; batchSize: number; visibilitySeconds: number; maxAttempts: number }): Promise<{ id: string; attempts: number; metadata: Record<string, unknown> }[]>;
11
+ markProcessed(input: { eventId: string; claimToken: string; metadata?: Record<string, unknown> }): Promise<{ applied: boolean }>;
12
+ markFailed(input: { eventId: string; claimToken: string; error: string; outcome: "retry" | "discard"; baseDelaySeconds: number; maxDelaySeconds: number; maxAttempts: number; snoozeSeconds: number }): Promise<{ status: string }>;
13
+ }
14
+ export interface NativeSyncRegistration {
15
+ sourceKey: string; contactId: string; normalizedEmail: string; eventType: NewsletterSyncEventType;
16
+ purpose: NewsletterMarketingPurpose; payload?: Record<string, unknown>;
17
+ originProviderKind?: string | null; originProviderEventId?: string | null;
18
+ remoteProfileId?: string | null; remoteListId?: string | null;
19
+ }
20
+ export interface NativeSyncOptions extends NativeCommunicationsOptions {
21
+ providerKind: string;
22
+ rail: (db: SqlExecutor) => NativeSyncRail;
23
+ }
24
+ export interface NativeCommunicationSyncStore extends CommunicationSyncStore {
25
+ registerWork(input: NativeSyncRegistration): Promise<{ id: string }>;
26
+ registerPreference(input: {email:string;purpose:"tester_program"|NewsletterMarketingPurpose;state:"denied"|"suppressed"}): Promise<{updated:true;email:string;purpose:"tester_program"|NewsletterMarketingPurpose;state:"denied"|"suppressed";workId:string|null;alreadySuppressed:boolean}>;
27
+ close(): Promise<void>;
28
+ }
29
+ export const nativeSyncEventType = (binding: string): string => `communication.native.sync.${nativeFingerprint(nativeText(binding))}`;
30
+ function nativeSyncRegistration(options: Pick<NativeSyncOptions,"binding"|"providerKind">, input: NativeSyncRegistration): (db: SqlExecutor) => Promise<{id:string}> {
31
+ const binding=nativeText(options.binding),provider=nativeText(options.providerKind),eventType=nativeSyncEventType(binding);
32
+ const email = nativeEmail(input.normalizedEmail), source = nativeText(input.sourceKey), contact = nativeText(input.contactId);
33
+ if (!["subscribe","update","unsubscribe","suppress"].includes(input.eventType) || !["marketing_launch_offer","marketing_newsletter"].includes(input.purpose)) throw new Error("native_sync_event_invalid");
34
+ const payload = nativeJson(input.payload ?? {});
35
+ const refs = [input.originProviderKind ?? null,input.originProviderEventId ?? null,input.remoteProfileId ?? null,input.remoteListId ?? null].map(value => value === null ? null : nativeText(value));
36
+ const fact = nativeFingerprint(JSON.stringify([provider,contact,email,input.eventType,input.purpose,payload,...refs]));
37
+ // Reuse the rail's ordering barrier for successive preferences of one contact/target.
38
+ const aggregate = nativeFingerprint(JSON.stringify([binding,provider,contact])).slice(0,32);
39
+ return async db => {
40
+ // One rail identity for each source event and exact target binding.
41
+ await db.query(`INSERT INTO public.outbox_events (aggregate_type,aggregate_id,event_type,idempotency_key,payload)
42
+ VALUES ('communication_native_sync',$4::uuid,$1,$2,$3::jsonb) ON CONFLICT (event_type,idempotency_key) DO NOTHING`, [eventType,source,payload,aggregate]);
43
+ const work = (await db.query("SELECT id FROM public.outbox_events WHERE event_type=$1 AND idempotency_key=$2 FOR UPDATE", [eventType,source])).rows[0]!;
44
+ await db.query(`INSERT INTO public.communication_native_sync_state
45
+ (work_id,binding,provider_kind,contact_id,normalized_email,purpose,event_kind,origin_provider_kind,origin_provider_event_id,remote_profile_id,remote_list_id,command_fingerprint)
46
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (work_id) DO NOTHING`, [work.id,binding,provider,contact,email,input.purpose,input.eventType,...refs,fact]);
47
+ const state = (await db.query("SELECT command_fingerprint FROM public.communication_native_sync_state WHERE work_id=$1", [work.id])).rows[0];
48
+ if (state?.command_fingerprint !== fact) throw new Error("native_sync_registration_conflict");
49
+ return { id: nativeText(work.id) };
50
+ };
51
+ }
52
+ /** Trusted host transaction seam; registration validation remains shared with the standalone store. */
53
+ export async function registerNativeSyncWorkInTransaction(db: SqlExecutor, options: Pick<NativeSyncOptions,"binding"|"providerKind">, input: NativeSyncRegistration): Promise<{id:string}> {
54
+ return nativeSyncRegistration(options,input)(db);
55
+ }
56
+
57
+ /** No sidecar claim/lease/drainer. Only runCommunicationSyncDispatch executes membership effects. */
58
+ export function createPostgresCommunicationSyncStore(env: PostgresDataGatewayEnv, options: NativeSyncOptions): NativeCommunicationSyncStore {
59
+ const binding = nativeText(options.binding), provider = nativeText(options.providerKind), eventType = nativeSyncEventType(binding);
60
+ const lane = nativeTransactions(env, options, ["public.communication_native_sync_state","public.outbox_events","public.communication_recipient_consents"]);
61
+ const checkProvider = (value: string) => { if (value !== provider) throw new Error("native_sync_binding_conflict"); };
62
+ return {
63
+ close: lane.close,
64
+ async registerWork(input) {
65
+ const register=nativeSyncRegistration(options,input);
66
+ return lane.run(db => register(db as SqlExecutor));
67
+ },
68
+ registerPreference(input) {
69
+ const email=nativeEmail(input.email),fingerprint=nativeFingerprint(email),purpose=nativeText(input.purpose);
70
+ if (!["tester_program","marketing_launch_offer","marketing_newsletter"].includes(purpose) || !["denied","suppressed"].includes(input.state)) throw new Error("native_preference_invalid");
71
+ return lane.run(async db=>{
72
+ await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))",[JSON.stringify([fingerprint,purpose])]);
73
+ const old=(await db.query("SELECT state,updated_at FROM public.communication_recipient_consents WHERE recipient_fingerprint=$1 AND purpose=$2 FOR UPDATE",[fingerprint,purpose])).rows[0];
74
+ const row=old?.state===input.state?old:(await db.query(`INSERT INTO public.communication_recipient_consents (recipient_fingerprint,purpose,state)
75
+ VALUES ($1,$2,$3) ON CONFLICT (recipient_fingerprint,purpose) DO UPDATE SET state=excluded.state,updated_at=clock_timestamp() RETURNING state,updated_at`,[fingerprint,purpose,input.state])).rows[0]!;
76
+ let workId:string|null=null;
77
+ if(purpose!=="tester_program"){
78
+ const sourceKey=nativeFingerprint(JSON.stringify([fingerprint,purpose,input.state,nativeStamp(row.updated_at)]));
79
+ workId=(await registerNativeSyncWorkInTransaction(db as SqlExecutor,options,{sourceKey,contactId:fingerprint,normalizedEmail:email,eventType:input.state==="suppressed"?"suppress":"unsubscribe",purpose:purpose as NewsletterMarketingPurpose})).id;
80
+ }
81
+ return {updated:true as const,email,purpose:purpose as "tester_program"|NewsletterMarketingPurpose,state:input.state,workId,alreadySuppressed:old?.state===input.state};
82
+ });
83
+ },
84
+ claimBatch(input) {
85
+ checkProvider(input.providerKind);
86
+ nativeInteger(input.batchSize,1,100); nativeInteger(input.visibilitySeconds,60,3600); nativeInteger(input.maxAttempts,1,20);
87
+ return lane.run(async db => {
88
+ const work = await options.rail(db as SqlExecutor).claimBatch({ eventTypes: [eventType], knownEventTypes: [eventType], batchSize: input.batchSize,visibilitySeconds: input.visibilitySeconds,maxAttempts: input.maxAttempts });
89
+ const events: NewsletterSyncEvent[] = [];
90
+ for (const row of work) {
91
+ const state = (await db.query("UPDATE public.communication_native_sync_state SET max_attempts=$4 WHERE work_id=$1 AND binding=$2 AND provider_kind=$3 RETURNING *", [row.id,binding,provider,input.maxAttempts])).rows[0];
92
+ if (!state) throw new Error("native_sync_sidecar_missing");
93
+ const payload = (await db.query("SELECT payload FROM public.outbox_events WHERE id=$1", [row.id])).rows[0]!.payload;
94
+ nativeJson(payload);
95
+ events.push({ id: nativeText(row.id), claimToken: nativeText(row.metadata.claimToken), contactId: nativeText(state.contact_id), normalizedEmail: nativeEmail(state.normalized_email), eventType: state.event_kind as NewsletterSyncEventType,
96
+ purpose: state.purpose as NewsletterMarketingPurpose,payload: payload as Record<string,unknown>, attemptCount: nativeInteger(row.attempts-1,0,19),
97
+ originProviderKind: state.origin_provider_kind as string | null,originProviderEventId: state.origin_provider_event_id as string | null,remoteProfileId: state.remote_profile_id as string | null,remoteListId: state.remote_list_id as string | null });
98
+ }
99
+ return events;
100
+ });
101
+ },
102
+ markSent(input) {
103
+ checkProvider(input.providerKind);
104
+ const summary = nativeJson(input.responseSummary ?? {}), remote = input.remoteProfileId == null ? null : nativeText(input.remoteProfileId);
105
+ return lane.run(async db => {
106
+ const row = (await db.query(`SELECT e.id FROM public.outbox_events e JOIN public.communication_native_sync_state s ON s.work_id=e.id
107
+ WHERE e.id=$1 AND s.binding=$2 AND s.provider_kind=$3 AND e.event_type=$4 AND e.status='processing' AND e.metadata->>'claimToken'=$5 FOR UPDATE OF e,s`, [input.eventId,binding,provider,eventType,input.claimToken])).rows[0];
108
+ if (!row) return false;
109
+ if (!(await options.rail(db as SqlExecutor).markProcessed({ eventId: input.eventId,claimToken: input.claimToken })).applied) throw new Error("native_sync_ack_lost");
110
+ await db.query("UPDATE public.communication_native_sync_state SET remote_profile_id=$2,response_summary=$3::jsonb WHERE work_id=$1", [input.eventId,remote,summary]);
111
+ return true;
112
+ });
113
+ },
114
+ markFailed(input) {
115
+ checkProvider(input.providerKind); nativeInteger(input.backoffSeconds,0,86400);
116
+ return lane.run(async db => {
117
+ const row = (await db.query(`SELECT s.max_attempts FROM public.outbox_events e JOIN public.communication_native_sync_state s ON s.work_id=e.id
118
+ WHERE e.id=$1 AND s.binding=$2 AND s.provider_kind=$3 AND e.event_type=$4 AND e.status='processing' AND e.metadata->>'claimToken'=$5 FOR UPDATE OF e,s`, [input.eventId,binding,provider,eventType,input.claimToken])).rows[0];
119
+ if (!row) return false;
120
+ const result = await options.rail(db as SqlExecutor).markFailed({ eventId: input.eventId,claimToken: input.claimToken,error: nativeText(input.error,2000),outcome: input.retry ? "retry" : "discard",baseDelaySeconds: input.backoffSeconds,maxDelaySeconds: input.backoffSeconds,maxAttempts: nativeInteger(Number(row.max_attempts),1,20),snoozeSeconds: 300 });
121
+ if (!["failed","discarded"].includes(result.status)) throw new Error("native_sync_ack_lost");
122
+ await db.query("UPDATE public.communication_native_sync_state SET response_summary=$2::jsonb WHERE work_id=$1", [input.eventId,JSON.stringify({ status: result.status })]);
123
+ return true;
124
+ });
125
+ },
126
+ };
127
+ }