@h-ai/reach 0.1.0-alpha9 → 1.0.0-beta.10

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,9 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { core, err, ok } from '@h-ai/core';
3
- import { randomUUID, createHmac } from 'crypto';
3
+ import process from 'process';
4
+ import { reldb, migrateSchema, schemaColumns, BaseReldbCrudRepository } from '@h-ai/reldb';
5
+ import { randomUUID, createHash, createHmac } from 'crypto';
4
6
  import { createRequire } from 'module';
5
7
  import { cache } from '@h-ai/cache';
6
- import { BaseReldbCrudRepository } from '@h-ai/reldb';
7
8
 
8
9
  // src/reach-config.ts
9
10
 
@@ -20,7 +21,14 @@ var en_US_default = {
20
21
  reach_providerNotFound: "Provider not found: {provider}",
21
22
  reach_providerRequired: "No provider specified, set provider field in message or template",
22
23
  reach_dndBlocked: "Message discarded by Do Not Disturb period",
23
- reach_dndDeferred: "Message deferred by Do Not Disturb period, will be sent after DND ends",
24
+ reach_dndPersistenceRequired: "DND delay requires an initialized database and a ready queue table",
25
+ reach_dndEnqueueFailed: "The message could not be reliably queued; try again later",
26
+ reach_sendRecordSaveFailed: "Failed to persist the reach send record",
27
+ reach_invalidStoredInteger: "Send record {field} is not a safe non-negative integer",
28
+ reach_claimLost: "The valid delivery lease was lost",
29
+ reach_unknownResolutionFailed: "Unknown delivery was not found or has already been resolved",
30
+ reach_queueInputInvalid: "Invalid queue receipt or manual resolution parameters",
31
+ reach_deliveryUnknown: "The external delivery outcome is unknown; manual review is required and automatic redelivery has stopped",
24
32
  reach_smsSendFailed: "SMS send failed: {error}",
25
33
  reach_emailSendFailed: "Email send failed: {error}",
26
34
  reach_apiSendFailed: "API callback send failed: {error}",
@@ -39,7 +47,71 @@ var en_US_default = {
39
47
  reach_config_providersRequired: "At least one provider is required",
40
48
  reach_templateDbInitFailed: "Template database initialization failed: {error}",
41
49
  reach_templateSaveFailed: "Template save failed: {error}",
42
- reach_templateDeleteFailed: "Template delete failed: {error}"
50
+ reach_templateDeleteFailed: "Template delete failed: {error}",
51
+ reach_queueRetryInvalid: "maxRetryDelayMs must be at least retryDelayMs",
52
+ reach_deadRequeueFailed: "Dead message changed or does not exist; reload its state before restoring",
53
+ reach_productionProviderRequired: "Production requires a delivery provider; console is available only in development or explicit test mode",
54
+ reach_sensitivePolicyRequired: "Credential notifications require a database receipt, stable idempotency key and expiry",
55
+ reach_sensitiveDeliveryUnconfirmed: "Credential delivery is unconfirmed and will not be retried automatically. Check the receipt or request new credentials.",
56
+ reach_messageExpired: "The credentials expired before delivery",
57
+ reach_apiResponseTooLarge: "API acknowledgement exceeds the 64 KiB limit",
58
+ reach_apiResponseInvalid: "API acknowledgement must be empty or a JSON object with an optional messageId string",
59
+ reach_apiRequestFailed: "API request failed or timed out; delivery is unconfirmed",
60
+ reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects",
61
+ reach_config_providerNameDuplicate: "Provider names must be unique"
62
+ };
63
+
64
+ // messages/ja-JP.json
65
+ var ja_JP_default = {
66
+ $schema: "https://inlang.com/schema/inlang-message-format",
67
+ reach_notInitialized: "\u901A\u77E5\u30E2\u30B8\u30E5\u30FC\u30EB\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Breach.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
68
+ reach_initFailed: "\u901A\u77E5\u30E2\u30B8\u30E5\u30FC\u30EB\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
69
+ reach_unsupportedType: "\u672A\u5BFE\u5FDC\u306E\u901A\u77E5\u306E\u7A2E\u985E\u3067\u3059: {type}",
70
+ reach_sendFailed: "\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
71
+ reach_templateNotFound: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {template}",
72
+ reach_templateRenderFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u63CF\u753B\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
73
+ reach_invalidRecipient: "\u5B9B\u5148\u304C\u7121\u52B9\u3067\u3059: {recipient}",
74
+ reach_providerNotFound: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {provider}",
75
+ reach_providerRequired: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u30E1\u30C3\u30BB\u30FC\u30B8\u307E\u305F\u306F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306Eprovider\u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044",
76
+ reach_dndBlocked: "\u901A\u77E5\u3092\u505C\u6B62\u3059\u308B\u6642\u9593\u5E2F\u306E\u305F\u3081\u3001\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u7834\u68C4\u3057\u307E\u3057\u305F",
77
+ reach_dndPersistenceRequired: "\u901A\u77E5\u5236\u9650\u6642\u9593\u5E2F\u306E\u914D\u4FE1\u5EF6\u671F\u306B\u306F\u521D\u671F\u5316\u6E08\u307F\u306E\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u3068\u6E96\u5099\u6E08\u307F\u306E\u30AD\u30E5\u30FC\u30C6\u30FC\u30D6\u30EB\u304C\u5FC5\u8981\u3067\u3059",
78
+ reach_dndEnqueueFailed: "\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u78BA\u5B9F\u306B\u30AD\u30E5\u30FC\u3078\u767B\u9332\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002\u3057\u3070\u3089\u304F\u3057\u3066\u304B\u3089\u518D\u8A66\u884C\u3057\u3066\u304F\u3060\u3055\u3044",
79
+ reach_sendRecordSaveFailed: "\u914D\u4FE1\u8A18\u9332\u306E\u6C38\u7D9A\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
80
+ reach_invalidStoredInteger: "\u914D\u4FE1\u8A18\u9332\u306E {field} \u306F\u5B89\u5168\u306A\u975E\u8CA0\u6574\u6570\u3067\u306F\u3042\u308A\u307E\u305B\u3093",
81
+ reach_claimLost: "\u6709\u52B9\u306A\u914D\u4FE1\u30EA\u30FC\u30B9\u304C\u5931\u308F\u308C\u307E\u3057\u305F",
82
+ reach_unknownResolutionFailed: "\u7D50\u679C\u4E0D\u660E\u306E\u914D\u4FE1\u304C\u898B\u3064\u304B\u3089\u306A\u3044\u304B\u3001\u65E2\u306B\u89E3\u6C7A\u3055\u308C\u3066\u3044\u307E\u3059",
83
+ reach_queueInputInvalid: "\u30AD\u30E5\u30FC\u306E\u53D7\u9818\u8A18\u9332\u307E\u305F\u306F\u624B\u52D5\u89E3\u6C7A\u306E\u30D1\u30E9\u30E1\u30FC\u30BF\u30FC\u304C\u7121\u52B9\u3067\u3059",
84
+ reach_deliveryUnknown: "\u5916\u90E8\u3078\u306E\u914D\u4FE1\u7D50\u679C\u306F\u4E0D\u660E\u3067\u3059\u3002\u624B\u52D5\u78BA\u8A8D\u304C\u5FC5\u8981\u306A\u305F\u3081\u3001\u81EA\u52D5\u518D\u914D\u4FE1\u3092\u505C\u6B62\u3057\u307E\u3057\u305F",
85
+ reach_smsSendFailed: "SMS\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
86
+ reach_emailSendFailed: "\u30E1\u30FC\u30EB\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
87
+ reach_apiSendFailed: "API\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
88
+ reach_configError: "\u901A\u77E5\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
89
+ reach_config_nameRequired: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u540D\u306F\u5FC5\u9808\u3067\u3059",
90
+ reach_config_hostRequired: "SMTP\u30DB\u30B9\u30C8\u306F\u5FC5\u9808\u3067\u3059",
91
+ reach_config_fromRequired: "\u9001\u4FE1\u8005\u30A2\u30C9\u30EC\u30B9\uFF08from\uFF09\u306F\u5FC5\u9808\u3067\u3059",
92
+ reach_config_accessKeyIdRequired: "AccessKey ID\u306F\u5FC5\u9808\u3067\u3059",
93
+ reach_config_accessKeySecretRequired: "AccessKey Secret\u306F\u5FC5\u9808\u3067\u3059",
94
+ reach_config_signNameRequired: "SMS\u7F72\u540D\u306F\u5FC5\u9808\u3067\u3059",
95
+ reach_config_urlRequired: "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AFURL\u306F\u5FC5\u9808\u3067\u3059",
96
+ reach_config_dndTimeInvalid: "\u6642\u523B\u5F62\u5F0F\u304C\u7121\u52B9\u3067\u3059\u3002HH:mm\uFF0824\u6642\u9593\u5236\uFF09\u3067\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044",
97
+ reach_config_templateNameRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u540D\u306F\u5FC5\u9808\u3067\u3059",
98
+ reach_config_templateProviderRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u306F\u5FC5\u9808\u3067\u3059",
99
+ reach_config_templateBodyRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u672C\u6587\u306F\u5FC5\u9808\u3067\u3059",
100
+ reach_config_providersRequired: "\u5C11\u306A\u304F\u3068\u30821\u3064\u306E\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059",
101
+ reach_templateDbInitFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
102
+ reach_templateSaveFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
103
+ reach_templateDeleteFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u524A\u9664\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
104
+ reach_queueRetryInvalid: "maxRetryDelayMs \u306F retryDelayMs \u4EE5\u4E0A\u306B\u3057\u3066\u304F\u3060\u3055\u3044",
105
+ reach_deadRequeueFailed: "\u914D\u4FE1\u4E0D\u80FD\u30E1\u30C3\u30BB\u30FC\u30B8\u304C\u5909\u66F4\u3055\u308C\u305F\u304B\u3001\u5B58\u5728\u3057\u307E\u305B\u3093\u3002\u5FA9\u5143\u3059\u308B\u524D\u306B\u72B6\u614B\u3092\u518D\u8AAD\u307F\u8FBC\u307F\u3057\u3066\u304F\u3060\u3055\u3044",
106
+ reach_productionProviderRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u914D\u4FE1\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059\u3002console \u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A\u30C6\u30B9\u30C8\u30E2\u30FC\u30C9\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059",
107
+ reach_sensitivePolicyRequired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u901A\u77E5\u306B\u306F\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u306E\u53D7\u9818\u8A18\u9332\u3001\u56FA\u5B9A\u306E\u51AA\u7B49\u30AD\u30FC\u3068\u6709\u52B9\u671F\u9650\u304C\u5FC5\u8981\u3067\u3059",
108
+ reach_sensitiveDeliveryUnconfirmed: "\u8A8D\u8A3C\u60C5\u5831\u306E\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3002\u81EA\u52D5\u518D\u9001\u305B\u305A\u3001\u53D7\u9818\u8A18\u9332\u306E\u78BA\u8A8D\u307E\u305F\u306F\u8A8D\u8A3C\u60C5\u5831\u306E\u518D\u767A\u884C\u304C\u5FC5\u8981\u3067\u3059\u3002",
109
+ reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F",
110
+ reach_apiResponseTooLarge: "API \u5FDC\u7B54\u304C 64 KiB \u306E\u4E0A\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
111
+ reach_apiResponseInvalid: "API \u5FDC\u7B54\u306F\u7A7A\u304B\u3001\u7701\u7565\u53EF\u80FD\u306A messageId \u6587\u5B57\u5217\u3092\u6301\u3064 JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
112
+ reach_apiRequestFailed: "API \u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F\u3002\u914D\u4FE1\u7D50\u679C\u306F\u672A\u78BA\u8A8D\u3067\u3059",
113
+ reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059",
114
+ reach_config_providerNameDuplicate: "Provider \u540D\u306F\u4E00\u610F\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059"
43
115
  };
44
116
 
45
117
  // messages/zh-CN.json
@@ -55,7 +127,14 @@ var zh_CN_default = {
55
127
  reach_providerNotFound: "Provider \u672A\u627E\u5230: {provider}",
56
128
  reach_providerRequired: "\u672A\u6307\u5B9A Provider\uFF0C\u8BF7\u5728\u6D88\u606F\u6216\u6A21\u677F\u4E2D\u8BBE\u7F6E provider \u5B57\u6BB5",
57
129
  reach_dndBlocked: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u4E22\u5F03",
58
- reach_dndDeferred: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u6682\u5B58\u5F85\u53D1\u9001",
130
+ reach_dndPersistenceRequired: "\u542F\u7528\u514D\u6253\u6270\u5EF6\u8FDF\u53D1\u9001\u9700\u8981\u5148\u521D\u59CB\u5316\u6570\u636E\u5E93\u5E76\u786E\u4FDD\u961F\u5217\u8868\u53EF\u7528",
131
+ reach_dndEnqueueFailed: "\u6D88\u606F\u672A\u80FD\u53EF\u9760\u4FDD\u5B58\u5230\u5EF6\u8FDF\u961F\u5217\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
132
+ reach_sendRecordSaveFailed: "\u4FDD\u5B58\u89E6\u8FBE\u53D1\u9001\u8BB0\u5F55\u5931\u8D25",
133
+ reach_invalidStoredInteger: "\u53D1\u9001\u8BB0\u5F55\u7684 {field} \u4E0D\u662F\u5B89\u5168\u7684\u975E\u8D1F\u6574\u6570",
134
+ reach_claimLost: "\u6D88\u606F\u7684\u6709\u6548\u6295\u9012\u79DF\u7EA6\u5DF2\u4E22\u5931",
135
+ reach_unknownResolutionFailed: "\u672A\u77E5\u6295\u9012\u7ED3\u679C\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5176\u5B83\u64CD\u4F5C\u88C1\u5B9A",
136
+ reach_queueInputInvalid: "\u961F\u5217\u56DE\u6267\u6216\u4EBA\u5DE5\u88C1\u5B9A\u53C2\u6570\u65E0\u6548",
137
+ reach_deliveryUnknown: "\u5916\u90E8\u6295\u9012\u7ED3\u679C\u672A\u77E5\uFF0C\u9700\u8981\u4EBA\u5DE5\u6838\u5BF9\uFF0C\u5DF2\u505C\u6B62\u81EA\u52A8\u91CD\u53D1",
59
138
  reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25: {error}",
60
139
  reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25: {error}",
61
140
  reach_apiSendFailed: "API \u56DE\u8C03\u53D1\u9001\u5931\u8D25: {error}",
@@ -74,13 +153,25 @@ var zh_CN_default = {
74
153
  reach_config_providersRequired: "\u81F3\u5C11\u9700\u8981\u914D\u7F6E\u4E00\u4E2A Provider",
75
154
  reach_templateDbInitFailed: "\u6A21\u677F\u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25: {error}",
76
155
  reach_templateSaveFailed: "\u6A21\u677F\u4FDD\u5B58\u5931\u8D25: {error}",
77
- reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}"
156
+ reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}",
157
+ reach_queueRetryInvalid: "maxRetryDelayMs \u4E0D\u80FD\u5C0F\u4E8E retryDelayMs",
158
+ reach_deadRequeueFailed: "\u6B7B\u4FE1\u5DF2\u53D8\u5316\u6216\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u540E\u6062\u590D",
159
+ reach_productionProviderRequired: "\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u914D\u7F6E\u771F\u5B9E\u89E6\u8FBE\u670D\u52A1\uFF1Bconsole \u4EC5\u53EF\u7528\u4E8E\u5F00\u53D1\u6216\u663E\u5F0F\u6D4B\u8BD5\u6A21\u5F0F",
160
+ reach_sensitivePolicyRequired: "\u51ED\u636E\u901A\u77E5\u9700\u8981\u6570\u636E\u5E93\u56DE\u6267\u3001\u7A33\u5B9A\u5E42\u7B49\u952E\u548C\u6709\u6548\u671F",
161
+ reach_sensitiveDeliveryUnconfirmed: "\u51ED\u636E\u6295\u9012\u5C1A\u672A\u786E\u8BA4\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u91CD\u590D\u53D1\u9001\uFF0C\u8BF7\u67E5\u8BE2\u56DE\u6267\u6216\u7533\u8BF7\u65B0\u51ED\u636E",
162
+ reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012",
163
+ reach_apiResponseTooLarge: "API \u56DE\u6267\u8D85\u8FC7 64 KiB \u4E0A\u9650",
164
+ reach_apiResponseInvalid: "API \u56DE\u6267\u5FC5\u987B\u4E3A\u7A7A\u6216\u542B\u53EF\u9009 messageId \u5B57\u7B26\u4E32\u7684 JSON \u5BF9\u8C61",
165
+ reach_apiRequestFailed: "API \u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
166
+ reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411",
167
+ reach_config_providerNameDuplicate: "Provider \u540D\u79F0\u4E0D\u80FD\u91CD\u590D"
78
168
  };
79
169
 
80
170
  // src/reach-i18n.ts
81
171
  var reachM = core.i18n.createMessageGetter({
82
172
  "zh-CN": zh_CN_default,
83
- "en-US": en_US_default
173
+ "en-US": en_US_default,
174
+ "ja-JP": ja_JP_default
84
175
  });
85
176
 
86
177
  // src/reach-config.ts
@@ -124,13 +215,21 @@ var ApiProviderConfigSchema = z.object({
124
215
  name: z.string().min(1, reachM("reach_config_nameRequired")),
125
216
  type: z.literal("api"),
126
217
  /** 回调 URL */
127
- url: z.string().min(1, reachM("reach_config_urlRequired")),
218
+ url: z.string().url(reachM("reach_config_urlRequired")).refine((value) => ["http:", "https:"].includes(new URL(value).protocol), {
219
+ message: reachM("reach_config_urlRequired")
220
+ }),
128
221
  /** HTTP 方法(默认 POST) */
129
222
  method: z.enum(["POST", "PUT"]).default("POST"),
130
223
  /** 自定义请求头 */
131
224
  headers: z.record(z.string(), z.string()).optional(),
132
225
  /** 请求超时毫秒数(默认 10000) */
133
- timeout: z.number().int().min(0).default(1e4)
226
+ timeout: z.number().int().min(0).default(1e4),
227
+ /** HTTP 重定向策略;默认拒绝。敏感发送始终拒绝,即使普通发送显式允许。 */
228
+ redirect: z.enum(["error", "follow"]).default("error"),
229
+ /** 强制 HTTPS;省略时生产敏感发送要求 HTTPS,显式 false 仅用于受信内网传输。 */
230
+ requireHttps: z.boolean().optional(),
231
+ /** 接收端按 Idempotency-Key 原子持久去重,且覆盖所有重试与旧节点迟到请求时才启用;默认 false。 */
232
+ idempotent: z.boolean().default(false)
134
233
  });
135
234
  var ProviderConfigSchema = z.discriminatedUnion("type", [
136
235
  ConsoleProviderConfigSchema,
@@ -138,10 +237,33 @@ var ProviderConfigSchema = z.discriminatedUnion("type", [
138
237
  AliyunSmsProviderConfigSchema,
139
238
  ApiProviderConfigSchema
140
239
  ]);
240
+ var ReachUnknownResolutionSchema = z.object({
241
+ status: z.enum(["sent", "discarded"]),
242
+ messageId: z.string().max(255).optional()
243
+ });
244
+ var ReachQueueIdSchema = z.string().uuid();
245
+ var ReachQueueConfigSchema = z.object({
246
+ /** 每条队列消息最多处理多少次,包含首次发送、失败重试和异常退出后的重新处理;默认 5,范围 1–100。 */
247
+ maxAttempts: z.number().int().min(1).max(100).default(5),
248
+ /** 第一次发送失败后,等待多少毫秒再试;默认 60000(1 分钟),范围 1000–86400000。 */
249
+ retryDelayMs: z.number().int().min(1e3).max(864e5).default(6e4),
250
+ /** 后续失败时等待时间逐次加倍,但不超过此值;默认 3600000(1 小时),不得小于 retryDelayMs。 */
251
+ maxRetryDelayMs: z.number().int().min(1e3).max(864e5).default(36e5)
252
+ }).refine((value) => value.maxRetryDelayMs >= value.retryDelayMs, {
253
+ message: reachM("reach_queueRetryInvalid")
254
+ });
255
+ var ReachQueueListSchema = z.object({
256
+ status: z.enum(["dead", "unknown"]),
257
+ limit: z.number().int().min(1).max(100).default(50),
258
+ offset: z.number().int().min(0).max(1e6).default(0)
259
+ });
260
+ var ReachDeadRequeueSchema = z.object({
261
+ expectedUpdatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER - 1)
262
+ });
141
263
  var DndConfigSchema = z.object({
142
264
  /** 是否启用免打扰(默认 false) */
143
265
  enabled: z.boolean().default(false),
144
- /** 免打扰策略:discard 丢弃 / delay 延时发送(默认 discard) */
266
+ /** discard 丢弃;delay 要求数据库队列就绪并确认入队(默认 discard)。 */
145
267
  strategy: z.enum(["discard", "delay"]).default("discard"),
146
268
  /** 免打扰开始时间(HH:mm 格式) */
147
269
  start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, reachM("reach_config_dndTimeInvalid")).default("00:00"),
@@ -160,11 +282,13 @@ var TemplateConfigSchema = z.object({
160
282
  });
161
283
  var ReachConfigSchema = z.object({
162
284
  /** Provider 配置列表 */
163
- providers: z.array(ProviderConfigSchema).min(1, reachM("reach_config_providersRequired")),
285
+ providers: z.array(ProviderConfigSchema).min(1, reachM("reach_config_providersRequired")).refine((providers2) => new Set(providers2.map((provider) => provider.name)).size === providers2.length, reachM("reach_config_providerNameDuplicate")),
164
286
  /** 模板配置(通过配置文件注册) */
165
287
  templates: z.array(TemplateConfigSchema).optional(),
166
288
  /** 免打扰配置 */
167
- dnd: DndConfigSchema.optional()
289
+ dnd: DndConfigSchema.optional(),
290
+ /** 可靠队列重试策略;每条消息入队时持久保存。 */
291
+ queue: ReachQueueConfigSchema.prefault({})
168
292
  });
169
293
  var ReachErrorInfo = {
170
294
  SEND_FAILED: "001:500",
@@ -173,15 +297,17 @@ var ReachErrorInfo = {
173
297
  INVALID_RECIPIENT: "004:400",
174
298
  PROVIDER_NOT_FOUND: "005:500",
175
299
  DND_BLOCKED: "006:403",
176
- DND_DEFERRED: "007:202",
177
300
  NOT_INITIALIZED: "010:500",
178
301
  UNSUPPORTED_TYPE: "011:400",
179
- CONFIG_ERROR: "012:500"
302
+ CONFIG_ERROR: "012:500",
303
+ DELIVERY_REJECTED: "020:422",
304
+ MESSAGE_EXPIRED: "021:410"
180
305
  };
181
306
  var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
182
307
 
183
308
  // src/providers/reach-provider-aliyun-sms.ts
184
309
  var logger = core.logger.child({ module: "reach", scope: "provider-aliyun-sms" });
310
+ var ISO_MILLISECONDS_SUFFIX_REGEX = /\.\d{3}Z$/;
185
311
  function toReachError(error) {
186
312
  return {
187
313
  code: HaiReachError.SEND_FAILED.code,
@@ -206,6 +332,7 @@ function createAliyunSmsProvider() {
206
332
  let smsConfig = null;
207
333
  return {
208
334
  name: "aliyun-sms",
335
+ supportsIdempotency: false,
209
336
  async connect(config) {
210
337
  if (config.type !== "aliyun-sms") {
211
338
  return err(
@@ -244,7 +371,7 @@ function createAliyunSmsProvider() {
244
371
  SignatureNonce: randomUUID(),
245
372
  SignatureVersion: "1.0",
246
373
  TemplateCode: templateCode ?? "",
247
- Timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
374
+ Timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(ISO_MILLISECONDS_SUFFIX_REGEX, "Z"),
248
375
  Version: "2017-05-25"
249
376
  };
250
377
  if (message.vars) {
@@ -272,19 +399,65 @@ function createAliyunSmsProvider() {
272
399
  };
273
400
  }
274
401
  var logger2 = core.logger.child({ module: "reach", scope: "provider-api" });
275
- function toReachError2(error) {
402
+ function getSafeEndpoint(url) {
403
+ try {
404
+ return new URL(url).origin;
405
+ } catch {
406
+ return "[invalid-url]";
407
+ }
408
+ }
409
+ function toReachError2() {
276
410
  return {
277
411
  code: HaiReachError.SEND_FAILED.code,
278
412
  message: reachM("reach_apiSendFailed", {
279
- params: { error: error instanceof Error ? error.message : String(error) }
280
- }),
281
- cause: error
413
+ params: { error: reachM("reach_apiRequestFailed") }
414
+ })
282
415
  };
283
416
  }
417
+ async function readAcknowledgement(response) {
418
+ const reader = response.body?.getReader();
419
+ let complete = false;
420
+ try {
421
+ let text = "";
422
+ let bytes = 0;
423
+ const decoder = new TextDecoder("utf-8", { fatal: true });
424
+ if (reader) {
425
+ while (true) {
426
+ const chunk = await reader.read();
427
+ if (chunk.done)
428
+ break;
429
+ bytes += chunk.value.byteLength;
430
+ if (bytes > 65536)
431
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseTooLarge"));
432
+ text += decoder.decode(chunk.value, { stream: true });
433
+ }
434
+ text += decoder.decode();
435
+ }
436
+ complete = true;
437
+ if (text.length === 0)
438
+ return ok({ success: true });
439
+ const result = JSON.parse(text);
440
+ if (!result || typeof result !== "object" || Array.isArray(result))
441
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
442
+ const messageId = "messageId" in result ? result.messageId : void 0;
443
+ if (messageId !== void 0 && (typeof messageId !== "string" || messageId.length > 255))
444
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
445
+ return ok({ success: true, ...messageId === void 0 ? {} : { messageId } });
446
+ } catch {
447
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
448
+ } finally {
449
+ if (!complete)
450
+ await reader?.cancel().catch(() => void 0);
451
+ reader?.releaseLock();
452
+ }
453
+ }
284
454
  function createApiProvider() {
285
455
  let apiConfig = null;
286
456
  return {
287
457
  name: "api",
458
+ get supportsIdempotency() {
459
+ return apiConfig?.idempotent === true;
460
+ },
288
461
  async connect(config) {
289
462
  if (config.type !== "api") {
290
463
  return err(
@@ -293,7 +466,11 @@ function createApiProvider() {
293
466
  );
294
467
  }
295
468
  apiConfig = config;
296
- logger2.info("API provider connected", { url: config.url, method: config.method });
469
+ if (config.requireHttps && new URL(config.url).protocol !== "https:" || config.requireHttps && config.redirect === "follow") {
470
+ apiConfig = null;
471
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
472
+ }
473
+ logger2.info("API provider connected", { endpoint: getSafeEndpoint(config.url), method: config.method });
297
474
  return ok(void 0);
298
475
  },
299
476
  async close() {
@@ -310,7 +487,10 @@ function createApiProvider() {
310
487
  reachM("reach_notInitialized")
311
488
  );
312
489
  }
313
- logger2.debug("Sending via API callback", { url: apiConfig.url, to: message.to });
490
+ logger2.debug("Sending via API callback", { endpoint: getSafeEndpoint(apiConfig.url) });
491
+ const requireHttps = apiConfig.requireHttps ?? (message.sensitive === true && core.module.requiresSharedState(process.env));
492
+ if (requireHttps && new URL(apiConfig.url).protocol !== "https:")
493
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
314
494
  try {
315
495
  const payload = {
316
496
  to: message.to,
@@ -318,37 +498,42 @@ function createApiProvider() {
318
498
  body: message.body,
319
499
  template: message.template,
320
500
  vars: message.vars,
321
- extra: message.extra
501
+ extra: message.extra,
502
+ idempotencyKey: message.idempotencyKey,
503
+ expiresAt: message.expiresAt
322
504
  };
323
505
  const controller = new AbortController();
324
506
  const timeoutId = setTimeout(() => controller.abort(), apiConfig.timeout);
325
507
  try {
508
+ const headers = new Headers({ "Content-Type": "application/json", ...apiConfig.headers });
509
+ if (message.idempotencyKey)
510
+ headers.set("Idempotency-Key", message.idempotencyKey);
326
511
  const response = await fetch(apiConfig.url, {
327
512
  method: apiConfig.method,
328
- headers: {
329
- "Content-Type": "application/json",
330
- ...apiConfig.headers
331
- },
513
+ headers,
332
514
  body: JSON.stringify(payload),
333
- signal: controller.signal
515
+ signal: controller.signal,
516
+ redirect: message.sensitive || apiConfig.redirect !== "follow" ? "manual" : "follow"
334
517
  });
335
518
  if (!response.ok) {
336
- const text = await response.text();
337
- logger2.warn("API callback returned non-OK status", { status: response.status, body: text });
519
+ await response.body?.cancel();
520
+ logger2.warn("API callback returned non-OK status", { status: response.status });
521
+ const rejected = response.status >= 300 && response.status < 400 || response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
338
522
  return err(
339
- HaiReachError.SEND_FAILED,
340
- reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}: ${text}` } })
523
+ rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
524
+ reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}` } })
341
525
  );
342
526
  }
343
- const result = await response.json();
344
- logger2.info("API callback sent", { to: message.to, messageId: result.messageId });
345
- return ok({ success: true, messageId: result.messageId });
527
+ const result = await readAcknowledgement(response);
528
+ if (result.success)
529
+ logger2.info("API callback accepted", { hasMessageId: result.data.messageId !== void 0 });
530
+ return result;
346
531
  } finally {
347
532
  clearTimeout(timeoutId);
348
533
  }
349
- } catch (error) {
350
- logger2.error("API callback send failed", { to: message.to, error });
351
- return err(toReachError2(error));
534
+ } catch {
535
+ logger2.error("API callback send failed", { errorCode: HaiReachError.SEND_FAILED.code });
536
+ return err(toReachError2());
352
537
  }
353
538
  }
354
539
  };
@@ -358,6 +543,7 @@ function createConsoleProvider() {
358
543
  let connected = false;
359
544
  return {
360
545
  name: "console",
546
+ supportsIdempotency: true,
361
547
  async connect(_config) {
362
548
  connected = true;
363
549
  logger3.info("Console provider connected");
@@ -374,11 +560,10 @@ function createConsoleProvider() {
374
560
  logger3.debug("Sending message via console", {
375
561
  provider: message.provider,
376
562
  to: message.to,
377
- subject: message.subject,
378
- body: message.body,
563
+ hasSubject: Boolean(message.subject),
564
+ bodyLength: message.body?.length ?? 0,
379
565
  template: message.template,
380
- vars: message.vars,
381
- extra: message.extra
566
+ variableNames: Object.keys(message.vars ?? {})
382
567
  });
383
568
  const messageId = `console-${Date.now()}`;
384
569
  return ok({ success: true, messageId });
@@ -400,6 +585,7 @@ function createSmtpProvider() {
400
585
  let smtpConfig = null;
401
586
  return {
402
587
  name: "smtp",
588
+ supportsIdempotency: false,
403
589
  async connect(config) {
404
590
  if (config.type !== "smtp") {
405
591
  return err(
@@ -460,7 +646,156 @@ function createSmtpProvider() {
460
646
  }
461
647
  };
462
648
  }
463
- var logger5 = core.logger.child({ module: "reach", scope: "send" });
649
+ var logger5 = core.logger.child({ module: "reach", scope: "delivery" });
650
+ var DEFAULT_LEASE_MS = 10 * 60 * 1e3;
651
+ async function deliverClaimedMessage(row, owner, provider, repo, leaseMs = DEFAULT_LEASE_MS) {
652
+ const retrySafe = provider.supportsIdempotency;
653
+ if (!row.queueId || row.retrySafe && !retrySafe) {
654
+ await repo.markUnknown(row.id, owner);
655
+ return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"));
656
+ }
657
+ if (!provider.isConnected()) {
658
+ await repo.failClaim(row, owner, "provider_unavailable");
659
+ return err(HaiReachError.NOT_INITIALIZED, reachM("reach_notInitialized"));
660
+ }
661
+ let message;
662
+ try {
663
+ message = {
664
+ provider: row.provider,
665
+ to: row.toAddr,
666
+ subject: row.subject ?? void 0,
667
+ body: row.body ?? void 0,
668
+ template: row.template ?? void 0,
669
+ vars: row.varsJson ? JSON.parse(row.varsJson) : void 0,
670
+ extra: row.extraJson ? JSON.parse(row.extraJson) : void 0,
671
+ idempotencyKey: row.queueId
672
+ };
673
+ } catch (cause) {
674
+ await repo.failClaim(row, owner, "invalid_message", true);
675
+ return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"), cause);
676
+ }
677
+ const started = await repo.beginDelivery(row.id, owner, retrySafe, leaseMs);
678
+ if (!started.success)
679
+ return started;
680
+ let stopped = false;
681
+ let lost = false;
682
+ let renewing;
683
+ let timer;
684
+ const renew = async () => {
685
+ try {
686
+ const result = await repo.renewClaim(row.id, owner, leaseMs);
687
+ lost = !result.success;
688
+ } catch {
689
+ lost = true;
690
+ }
691
+ if (!stopped && !lost) {
692
+ timer = setTimeout(() => {
693
+ renewing = renew();
694
+ }, Math.max(1, Math.floor(leaseMs / 3)));
695
+ }
696
+ };
697
+ timer = setTimeout(() => {
698
+ renewing = renew();
699
+ }, Math.max(1, Math.floor(leaseMs / 3)));
700
+ let outcome;
701
+ try {
702
+ outcome = await provider.send(message);
703
+ } catch (cause) {
704
+ outcome = err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), cause);
705
+ } finally {
706
+ stopped = true;
707
+ clearTimeout(timer);
708
+ await renewing;
709
+ }
710
+ if (lost) {
711
+ if (!retrySafe)
712
+ await repo.markUnknown(row.id, owner);
713
+ return err(HaiReachError.SEND_FAILED, reachM("reach_claimLost"));
714
+ }
715
+ if (outcome.success && outcome.data.success) {
716
+ const marked = await repo.markSent(row.id, owner, outcome.data.messageId);
717
+ if (!marked.success && !retrySafe)
718
+ await repo.markUnknown(row.id, owner);
719
+ return marked;
720
+ }
721
+ if (!retrySafe) {
722
+ await repo.markUnknown(row.id, owner);
723
+ logger5.warn("Delivery outcome is unknown; automatic redelivery stopped", { queueId: row.queueId, provider: row.provider });
724
+ return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), outcome.success ? void 0 : outcome.error);
725
+ }
726
+ const permanent = !outcome.success && [HaiReachError.DELIVERY_REJECTED.code, HaiReachError.INVALID_RECIPIENT.code].includes(outcome.error.code);
727
+ const released = await repo.failClaim(row, owner, permanent ? "permanent_rejection" : "transient_failure", permanent);
728
+ if (!released.success)
729
+ return released;
730
+ return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), outcome.success ? void 0 : outcome.error);
731
+ }
732
+ function previousResult(row) {
733
+ return row.status === "sent" ? ok({ success: true, receiptId: row.queueId ?? void 0 }) : unconfirmed(row.queueId);
734
+ }
735
+ function unconfirmed(receiptId) {
736
+ return { success: false, error: { ...HaiReachError.SEND_FAILED, message: reachM("reach_sensitiveDeliveryUnconfirmed"), ext: { receiptId } } };
737
+ }
738
+ async function sendSensitive(message, provider, repo, blocked) {
739
+ if (!repo || !message.idempotencyKey?.trim() || message.idempotencyKey.length > 255 || !Number.isSafeInteger(message.expiresAt))
740
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_sensitivePolicyRequired"));
741
+ const hash = createHash("sha256").update(JSON.stringify([message.provider, message.to, message.idempotencyKey])).digest("hex");
742
+ const queueId = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-5${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
743
+ const find = () => repo.findAll({ where: "queue_id = ?", params: [queueId], limit: 1 });
744
+ const existing = await find();
745
+ if (!existing.success)
746
+ return existing;
747
+ if (existing.data[0])
748
+ return previousResult(existing.data[0]);
749
+ const now = Date.now();
750
+ if (message.expiresAt <= now)
751
+ return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
752
+ const created = await repo.create({
753
+ queueId,
754
+ provider: message.provider,
755
+ toAddr: "[REDACTED]",
756
+ subject: null,
757
+ body: null,
758
+ template: null,
759
+ varsJson: null,
760
+ extraJson: null,
761
+ status: blocked ? "discarded" : "sending",
762
+ retrySafe: false,
763
+ createdAt: now,
764
+ updatedAt: now,
765
+ lockedUntil: Math.min(message.expiresAt, now + 6e5),
766
+ nextAttemptAt: 0
767
+ });
768
+ if (!created.success || created.data.changes !== 1) {
769
+ const previous = await find();
770
+ if (previous.success && previous.data[0])
771
+ return previousResult(previous.data[0]);
772
+ return unconfirmed(queueId);
773
+ }
774
+ if (blocked)
775
+ return err(HaiReachError.DND_BLOCKED, reachM("reach_dndBlocked"));
776
+ if (Date.now() >= message.expiresAt) {
777
+ await repo.settleSensitive(queueId, "discarded");
778
+ return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
779
+ }
780
+ let delivered;
781
+ try {
782
+ delivered = await core.context.run({ suppressLogObservers: true }, () => provider.send(message));
783
+ } catch {
784
+ delivered = err(HaiReachError.SEND_FAILED, reachM("reach_sensitiveDeliveryUnconfirmed"));
785
+ }
786
+ const success = delivered.success && delivered.data.success && !delivered.data.deferred;
787
+ const settled = await repo.settleSensitive(queueId, success ? "sent" : "unknown");
788
+ if (!settled.success || !success)
789
+ return unconfirmed(queueId);
790
+ return ok({ success: true, receiptId: queueId });
791
+ }
792
+
793
+ // src/reach-send.ts
794
+ var logger6 = core.logger.child({ module: "reach", scope: "send" });
795
+ var FLUSH_LOCK_KEY = "hai:reach:flush-pending";
796
+ var FLUSH_LOCK_TTL = 300;
797
+ var FLUSH_BATCH_SIZE = 100;
798
+ var FLUSH_CLAIM_LOCK_MS = 10 * 60 * 1e3;
464
799
  var reachNodeId = `reach:${crypto.randomUUID()}`;
465
800
  function parseTimeToMinutes(time) {
466
801
  const [h, m] = time.split(":").map(Number);
@@ -508,12 +843,10 @@ async function preprocessMessage(message, templateRegistry2) {
508
843
  };
509
844
  return ok(processed);
510
845
  }
511
- async function saveSendRecord(repo, message, status, provider, messageId) {
512
- if (!repo) {
513
- return;
514
- }
846
+ async function saveSendRecord(repo, message, status, provider, messageId, queueId, queueConfig) {
515
847
  try {
516
- await repo.create({
848
+ const inserted = await repo.create({
849
+ queueId: queueId ?? null,
517
850
  provider,
518
851
  toAddr: message.to,
519
852
  subject: message.subject ?? null,
@@ -522,11 +855,17 @@ async function saveSendRecord(repo, message, status, provider, messageId) {
522
855
  varsJson: message.vars ? JSON.stringify(message.vars) : null,
523
856
  extraJson: message.extra ? JSON.stringify(message.extra) : null,
524
857
  status,
858
+ ...queueConfig,
525
859
  messageId: messageId ?? null,
526
860
  createdAt: Date.now()
527
861
  });
528
- } catch {
529
- logger5.debug("Send record not saved (db module unavailable)");
862
+ if (!inserted.success)
863
+ return inserted;
864
+ if (inserted.data.changes !== 1)
865
+ return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"));
866
+ return ok(void 0);
867
+ } catch (cause) {
868
+ return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"), cause);
530
869
  }
531
870
  }
532
871
  var dndTimer = null;
@@ -537,11 +876,14 @@ function startDndScheduler(dndConfig2, providers2, repo) {
537
876
  return;
538
877
  }
539
878
  schedulerContext = { dndConfig: dndConfig2, providers: providers2, repo };
879
+ const context = schedulerContext;
540
880
  if (!isDndBlocked(dndConfig2)) {
541
881
  flushPendingMessages(providers2, repo).catch((error) => {
542
- logger5.warn("Failed to flush pending messages on init", { error });
882
+ logger6.warn("Failed to flush pending messages on init", { error });
883
+ }).finally(() => {
884
+ if (schedulerContext === context)
885
+ scheduleDndCheck();
543
886
  });
544
- scheduleDndCheck();
545
887
  return;
546
888
  }
547
889
  scheduleFlushAtDndEnd();
@@ -549,30 +891,38 @@ function startDndScheduler(dndConfig2, providers2, repo) {
549
891
  function scheduleFlushAtDndEnd() {
550
892
  if (!schedulerContext)
551
893
  return;
552
- const { dndConfig: dndConfig2, providers: providers2, repo } = schedulerContext;
894
+ const context = schedulerContext;
895
+ const { dndConfig: dndConfig2, providers: providers2, repo } = context;
553
896
  const delayMs = msUntilDndEnd(dndConfig2);
554
- logger5.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
897
+ logger6.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
555
898
  dndTimer = setTimeout(() => {
556
- logger5.info("DND period ended, flushing pending messages");
899
+ if (schedulerContext !== context)
900
+ return;
901
+ logger6.info("DND period ended, flushing pending messages");
557
902
  flushPendingMessages(providers2, repo).catch((error) => {
558
- logger5.error("Failed to flush pending messages", { error });
903
+ logger6.error("Failed to flush pending messages", { error });
559
904
  }).finally(() => {
560
- scheduleDndCheck();
905
+ if (schedulerContext === context)
906
+ scheduleDndCheck();
561
907
  });
562
908
  }, delayMs);
563
909
  }
564
910
  function scheduleDndCheck() {
565
911
  if (!schedulerContext)
566
912
  return;
567
- const { dndConfig: dndConfig2 } = schedulerContext;
913
+ const context = schedulerContext;
914
+ const { dndConfig: dndConfig2, providers: providers2, repo } = context;
568
915
  const CHECK_INTERVAL = 60 * 1e3;
569
916
  dndTimer = setTimeout(() => {
570
- if (!schedulerContext)
917
+ if (schedulerContext !== context)
571
918
  return;
572
919
  if (isDndBlocked(dndConfig2)) {
573
920
  scheduleFlushAtDndEnd();
574
921
  } else {
575
- scheduleDndCheck();
922
+ flushPendingMessages(providers2, repo).catch((error) => logger6.warn("Failed to recover pending messages", { error })).finally(() => {
923
+ if (schedulerContext === context)
924
+ scheduleDndCheck();
925
+ });
576
926
  }
577
927
  }, CHECK_INTERVAL);
578
928
  }
@@ -590,64 +940,64 @@ async function flushPendingMessages(providers2, repo) {
590
940
  if (!repo) {
591
941
  return;
592
942
  }
593
- const FLUSH_LOCK_KEY = "hai:reach:flush-pending";
594
- const FLUSH_LOCK_TTL = 60;
595
- let lockAcquired = false;
943
+ let lockOwner = null;
944
+ const flushOwner = `${reachNodeId}:${crypto.randomUUID()}`;
596
945
  if (cache.isInitialized) {
597
- const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL, owner: reachNodeId });
598
- if (lockResult.success && !lockResult.data) {
599
- logger5.info("Skipping flush, another node holds the lock");
946
+ const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL });
947
+ if (!lockResult.success) {
948
+ logger6.warn("Failed to acquire flush lock, continuing with row claims only", { error: lockResult.error.message });
949
+ } else if (!lockResult.data) {
950
+ logger6.info("Skipping flush, another node holds the lock");
600
951
  return;
952
+ } else {
953
+ lockOwner = lockResult.data;
601
954
  }
602
- lockAcquired = lockResult.success && lockResult.data;
603
955
  }
604
956
  try {
605
- const result = await repo.findPending();
606
- if (!result.success || !result.data.length) {
957
+ const clock = await repo.currentTime();
958
+ if (!clock.success) {
959
+ logger6.warn("Failed to read queue clock", { errorCode: clock.error.code });
607
960
  return;
608
961
  }
609
- logger5.info("Flushing pending messages", { count: result.data.length });
610
- for (const row of result.data) {
611
- const provider = providers2.get(row.provider);
612
- if (!provider) {
613
- logger5.warn("Provider not found for pending message, skipping", { provider: row.provider, id: row.id });
614
- continue;
962
+ const eligibleBefore = clock.data;
963
+ for (let processed = 0; processed < FLUSH_BATCH_SIZE; processed += 1) {
964
+ const claimResult = await repo.claimPendingBatch(flushOwner, {
965
+ limit: 1,
966
+ lockMs: FLUSH_CLAIM_LOCK_MS,
967
+ eligibleBefore
968
+ });
969
+ if (!claimResult.success) {
970
+ logger6.warn("Failed to claim pending messages", { error: claimResult.error.message });
971
+ return;
615
972
  }
616
- let vars;
617
- let extra;
618
- try {
619
- vars = row.varsJson ? JSON.parse(row.varsJson) : void 0;
620
- extra = row.extraJson ? JSON.parse(row.extraJson) : void 0;
621
- } catch {
622
- logger5.warn("Failed to parse pending message JSON, skipping", { id: row.id });
623
- continue;
973
+ if (claimResult.data.length === 0) {
974
+ return;
624
975
  }
625
- const message = {
626
- provider: row.provider,
627
- to: row.toAddr,
628
- subject: row.subject ?? void 0,
629
- body: row.body ?? void 0,
630
- template: row.template ?? void 0,
631
- vars,
632
- extra
633
- };
634
- const sendResult = await provider.send(message);
635
- if (sendResult.success) {
636
- await repo.markSent(row.id, sendResult.data.messageId);
637
- logger5.info("Pending message sent", { id: row.id, to: row.toAddr, provider: row.provider });
638
- } else {
639
- logger5.warn("Pending message send failed", { id: row.id, to: row.toAddr, error: sendResult.error.code });
976
+ logger6.debug("Flushing pending messages", { count: claimResult.data.length });
977
+ for (const row of claimResult.data) {
978
+ const provider = providers2.get(row.provider);
979
+ if (!provider) {
980
+ logger6.warn("Provider not found for pending message, releasing claim", { provider: row.provider, id: row.id });
981
+ const releaseResult = await repo.failClaim(row, flushOwner, "provider_unavailable");
982
+ if (!releaseResult.success) {
983
+ logger6.warn("Failed to release pending message claim", { id: row.id, error: releaseResult.error.message });
984
+ }
985
+ continue;
986
+ }
987
+ const sent = await deliverClaimedMessage(row, flushOwner, provider, repo, FLUSH_CLAIM_LOCK_MS);
988
+ if (!sent.success)
989
+ logger6.warn("Pending message delivery did not commit", { id: row.id, code: sent.error.code });
640
990
  }
641
991
  }
642
992
  } finally {
643
- if (lockAcquired) {
644
- await cache.lock.release(FLUSH_LOCK_KEY, reachNodeId).catch((error) => {
645
- logger5.warn("Failed to release flush lock", { error });
993
+ if (lockOwner) {
994
+ await cache.lock.release(FLUSH_LOCK_KEY, lockOwner).catch((error) => {
995
+ logger6.warn("Failed to release flush lock", { error });
646
996
  });
647
997
  }
648
998
  }
649
999
  }
650
- async function executeSend(message, providers2, templateRegistry2, dndConfig2, repo) {
1000
+ async function executeSend(message, providers2, templateRegistry2, dndConfig2, repo, queueConfig = ReachQueueConfigSchema.parse({})) {
651
1001
  if (!message.to) {
652
1002
  return err(
653
1003
  HaiReachError.INVALID_RECIPIENT,
@@ -671,51 +1021,60 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
671
1021
  reachM("reach_providerNotFound", { params: { provider: providerName } })
672
1022
  );
673
1023
  }
1024
+ if (preprocessed.data.sensitive)
1025
+ return sendSensitive(preprocessed.data, providers2.get(providerName), repo, isDndBlocked(dndConfig2));
674
1026
  if (isDndBlocked(dndConfig2)) {
1027
+ if (message.deferPolicy === "reject")
1028
+ return err(HaiReachError.DND_BLOCKED, reachM("reach_dndBlocked"));
675
1029
  const strategy = dndConfig2?.strategy ?? "discard";
676
1030
  if (strategy === "delay") {
677
- logger5.info("Message deferred by DND (delay strategy)", { provider: providerName, to: message.to });
678
- try {
679
- await saveSendRecord(repo ?? null, preprocessed.data, "pending", providerName);
680
- } catch (error) {
681
- logger5.warn("Failed to save deferred message to DB", { provider: providerName, to: message.to, error });
1031
+ if (!repo)
1032
+ return err(HaiReachError.SEND_FAILED, reachM("reach_dndPersistenceRequired"));
1033
+ const queueId = crypto.randomUUID();
1034
+ const saved = await saveSendRecord(repo, preprocessed.data, "pending", providerName, void 0, queueId, queueConfig);
1035
+ if (!saved.success) {
1036
+ logger6.warn("Failed to persist deferred message", { provider: providerName, code: saved.error.code });
682
1037
  return err(
683
1038
  HaiReachError.SEND_FAILED,
684
- reachM("reach_dndDeferred"),
685
- error
1039
+ reachM("reach_dndEnqueueFailed"),
1040
+ saved.error
686
1041
  );
687
1042
  }
688
- return ok({ success: true, deferred: true });
1043
+ logger6.info("Message persisted for DND delivery", { provider: providerName, queueId });
1044
+ return ok({ success: true, deferred: true, queueId });
689
1045
  }
690
- logger5.info("Message blocked by DND (discard strategy)", { provider: providerName, to: message.to });
1046
+ logger6.info("Message blocked by DND (discard strategy)", { provider: providerName, to: message.to });
691
1047
  return err(
692
1048
  HaiReachError.DND_BLOCKED,
693
1049
  reachM("reach_dndBlocked")
694
1050
  );
695
1051
  }
696
1052
  const provider = providers2.get(providerName);
697
- logger5.debug("Sending message", {
1053
+ logger6.debug("Sending message", {
698
1054
  provider: providerName,
699
1055
  to: preprocessed.data.to,
700
1056
  template: message.template
701
1057
  });
702
1058
  const result = await provider.send(preprocessed.data);
703
1059
  if (!result.success) {
704
- logger5.warn("Message send failed", {
1060
+ logger6.warn("Message send failed", {
705
1061
  provider: providerName,
706
1062
  to: message.to,
707
1063
  error: result.error.code
708
1064
  });
709
1065
  return result;
710
1066
  }
711
- saveSendRecord(repo ?? null, preprocessed.data, "sent", providerName, result.data.messageId).catch((error) => {
712
- logger5.warn("Failed to save send record", { provider: providerName, to: message.to, error });
713
- });
1067
+ if (repo) {
1068
+ void saveSendRecord(repo, preprocessed.data, "sent", providerName, result.data.messageId).then((saved) => {
1069
+ if (!saved.success)
1070
+ logger6.warn("Failed to persist sent message log", { provider: providerName, code: saved.error.code });
1071
+ });
1072
+ }
714
1073
  return result;
715
1074
  }
716
1075
  function renderString(template, vars) {
717
1076
  return template.replace(/\{(\w+)\}/g, (match, key) => {
718
- return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match;
1077
+ return Object.hasOwn(vars, key) ? vars[key] : match;
719
1078
  });
720
1079
  }
721
1080
  function renderTemplate(template, vars) {
@@ -805,8 +1164,192 @@ function createTemplateRegistry(repo) {
805
1164
  }
806
1165
  };
807
1166
  }
1167
+ function migrateReach(options = {}, db = reldb) {
1168
+ const definitions = { queue_id: db.config?.type === "mysql" ? "VARCHAR(255)" : "TEXT", retry_safe: "BOOLEAN NOT NULL DEFAULT FALSE", attempt_count: "BIGINT NOT NULL DEFAULT 0", max_attempts: "BIGINT NOT NULL DEFAULT 5", next_attempt_at: "BIGINT NOT NULL DEFAULT 0", retry_delay_ms: "BIGINT NOT NULL DEFAULT 60000", max_retry_delay_ms: "BIGINT NOT NULL DEFAULT 3600000", last_failure: "TEXT" };
1169
+ const steps = [{
1170
+ version: 1,
1171
+ name: "durable-delivery-ledger",
1172
+ async needed(sql) {
1173
+ const found = await schemaColumns(db, "hai_reach_send_log", sql);
1174
+ return found.success ? ok(found.data.length > 0 && Object.keys(definitions).some((name) => !found.data.includes(name))) : found;
1175
+ },
1176
+ async apply(sql) {
1177
+ const found = await schemaColumns(db, "hai_reach_send_log", sql);
1178
+ if (!found.success)
1179
+ return found;
1180
+ for (const [name, definition] of Object.entries(definitions)) {
1181
+ if (found.data.includes(name))
1182
+ continue;
1183
+ const added = await sql.execute(`ALTER TABLE hai_reach_send_log ADD COLUMN ${name} ${definition}`);
1184
+ if (!added.success)
1185
+ return added;
1186
+ }
1187
+ return ok(void 0);
1188
+ }
1189
+ }, {
1190
+ version: 2,
1191
+ name: "legacy-delivery-isolation",
1192
+ async needed(sql) {
1193
+ const found = await schemaColumns(db, "hai_reach_send_log", sql);
1194
+ if (!found.success || !found.data.length)
1195
+ return found.success ? ok(false) : found;
1196
+ if (!found.data.includes("queue_id"))
1197
+ return ok(true);
1198
+ const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE queue_id IS NULL AND status IN ('pending','processing','unknown') LIMIT 1");
1199
+ return row.success ? ok(Boolean(row.data)) : row;
1200
+ },
1201
+ async apply(sql) {
1202
+ const isolated = await sql.execute("UPDATE hai_reach_send_log SET status='unknown',processing_owner=NULL,locked_until=NULL WHERE status='processing' AND queue_id IS NULL");
1203
+ if (!isolated.success)
1204
+ return isolated;
1205
+ while (true) {
1206
+ const rows = await sql.query("SELECT id FROM hai_reach_send_log WHERE queue_id IS NULL AND status IN ('pending','unknown') ORDER BY id LIMIT 100");
1207
+ if (!rows.success)
1208
+ return rows;
1209
+ for (const row of rows.data) {
1210
+ const saved = await sql.execute("UPDATE hai_reach_send_log SET queue_id=? WHERE id=? AND queue_id IS NULL", [randomUUID(), row.id]);
1211
+ if (!saved.success)
1212
+ return saved;
1213
+ }
1214
+ if (rows.data.length < 100)
1215
+ return ok(void 0);
1216
+ }
1217
+ }
1218
+ }, {
1219
+ version: 3,
1220
+ name: "credential-payload-retirement",
1221
+ async needed(sql) {
1222
+ const found = await schemaColumns(db, "hai_reach_send_log", sql);
1223
+ if (!found.success || !found.data.length)
1224
+ return found.success ? ok(false) : found;
1225
+ const ledger = await schemaColumns(db, "hai_schema_migrations", sql);
1226
+ if (!ledger.success)
1227
+ return ledger;
1228
+ const version = ledger.data.length ? await sql.get("SELECT version FROM hai_schema_migrations WHERE module=?", ["reach"]) : ok(null);
1229
+ if (!version.success)
1230
+ return version;
1231
+ if (Number(version.data?.version ?? 0) >= 3)
1232
+ return ok(false);
1233
+ const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE subject IS NOT NULL OR body IS NOT NULL OR template IS NOT NULL OR vars_json IS NOT NULL OR extra_json IS NOT NULL LIMIT 1");
1234
+ return row.success ? ok(Boolean(row.data)) : row;
1235
+ },
1236
+ async apply(sql) {
1237
+ return sql.execute("UPDATE hai_reach_send_log SET body=NULL,subject=NULL,vars_json=NULL,extra_json=NULL,template=NULL,status=CASE WHEN status IN ('pending','processing') THEN 'discarded' WHEN status='sending' THEN 'unknown' ELSE status END,processing_owner=NULL,locked_until=NULL WHERE subject IS NOT NULL OR body IS NOT NULL OR template IS NOT NULL OR vars_json IS NOT NULL OR extra_json IS NOT NULL");
1238
+ }
1239
+ }, {
1240
+ version: 4,
1241
+ name: "unique-operation-receipts",
1242
+ async needed(sql) {
1243
+ const columns = await schemaColumns(db, "hai_reach_send_log", sql);
1244
+ if (!columns.success || !columns.data.length)
1245
+ return columns.success ? ok(false) : columns;
1246
+ if (!columns.data.includes("queue_id"))
1247
+ return ok(true);
1248
+ if (db.config?.type === "sqlite") {
1249
+ const indexes = await sql.query("PRAGMA index_list(hai_reach_send_log)");
1250
+ if (!indexes.success)
1251
+ return indexes;
1252
+ for (const index of indexes.data) {
1253
+ if (!index.unique || !/^\w+$/.test(index.name))
1254
+ continue;
1255
+ const fields = await sql.query(`PRAGMA index_info(${index.name})`);
1256
+ if (!fields.success)
1257
+ return fields;
1258
+ if (fields.data.length === 1 && fields.data[0]?.name === "queue_id")
1259
+ return ok(false);
1260
+ }
1261
+ return ok(true);
1262
+ }
1263
+ const found = db.config?.type === "mysql" ? await sql.get("SELECT index_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND non_unique=0 GROUP BY index_name HAVING COUNT(*)=1 AND MAX(column_name)=?", ["hai_reach_send_log", "queue_id"]) : await sql.get("SELECT i.indexrelid FROM pg_index i JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=ANY(i.indkey) WHERE t.relname=? AND n.nspname=current_schema() AND i.indisunique AND i.indnatts=1 AND a.attname=?", ["hai_reach_send_log", "queue_id"]);
1264
+ return found.success ? ok(!found.data) : found;
1265
+ },
1266
+ async apply(sql) {
1267
+ return sql.execute("CREATE UNIQUE INDEX uq_hai_reach_queue_v1 ON hai_reach_send_log(queue_id)");
1268
+ }
1269
+ }];
1270
+ return migrateSchema(db, "reach", steps, options);
1271
+ }
1272
+
1273
+ // src/repositories/reach-repository-send-log.ts
808
1274
  var TABLE_NAME = "hai_reach_send_log";
1275
+ var DEFAULT_CLAIM_BATCH_SIZE = 100;
1276
+ var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
1277
+ function claimableWhere(clock) {
1278
+ return `(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ${clock})) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ${clock})))`;
1279
+ }
1280
+ function claimableParams(eligibleBefore) {
1281
+ return ["pending", eligibleBefore, "processing", eligibleBefore, "sending", eligibleBefore];
1282
+ }
1283
+ function buildSendLogError(detail, cause) {
1284
+ return err(
1285
+ HaiReachError.SEND_FAILED,
1286
+ reachM("reach_sendFailed", { params: { error: detail } }),
1287
+ cause
1288
+ );
1289
+ }
1290
+ function normalizeSendLog(row) {
1291
+ const normalized = { ...row };
1292
+ for (const field of ["id", "createdAt", "updatedAt", "lockedUntil", "nextAttemptAt", "attemptCount", "maxAttempts", "retryDelayMs", "maxRetryDelayMs"]) {
1293
+ const value = row[field];
1294
+ if (value === void 0 && (field === "updatedAt" || field === "lockedUntil"))
1295
+ continue;
1296
+ const validType = typeof value === "number" || typeof value === "bigint" || typeof value === "string" && /^\d+$/.test(value);
1297
+ const integer = validType ? Number(value) : Number.NaN;
1298
+ if (!Number.isSafeInteger(integer) || integer < 0)
1299
+ return buildSendLogError(reachM("reach_invalidStoredInteger", { params: { field } }));
1300
+ Object.assign(normalized, { [field]: integer });
1301
+ }
1302
+ return ok(normalized);
1303
+ }
1304
+ function normalizeSendLogs(rows) {
1305
+ const normalized = [];
1306
+ for (const row of rows) {
1307
+ const result = normalizeSendLog(row);
1308
+ if (!result.success)
1309
+ return result;
1310
+ normalized.push(result.data);
1311
+ }
1312
+ return ok(normalized);
1313
+ }
809
1314
  var SEND_LOG_FIELDS = [
1315
+ ...[
1316
+ { fieldName: "attemptCount", columnName: "attempt_count", value: 0 },
1317
+ { fieldName: "maxAttempts", columnName: "max_attempts", value: 5 },
1318
+ { fieldName: "retryDelayMs", columnName: "retry_delay_ms", value: 6e4 },
1319
+ { fieldName: "maxRetryDelayMs", columnName: "max_retry_delay_ms", value: 36e5 },
1320
+ { fieldName: "nextAttemptAt", columnName: "next_attempt_at", value: 0 }
1321
+ ].map(({ fieldName, columnName, value }) => ({
1322
+ fieldName,
1323
+ columnName,
1324
+ def: { type: "BIGINT", notNull: true, defaultValue: value },
1325
+ select: true,
1326
+ create: true,
1327
+ update: true
1328
+ })),
1329
+ {
1330
+ fieldName: "lastFailure",
1331
+ columnName: "last_failure",
1332
+ def: { type: "TEXT" },
1333
+ select: true,
1334
+ create: true,
1335
+ update: true
1336
+ },
1337
+ {
1338
+ fieldName: "retrySafe",
1339
+ columnName: "retry_safe",
1340
+ def: { type: "BOOLEAN", notNull: true, defaultValue: false },
1341
+ select: true,
1342
+ create: true,
1343
+ update: true
1344
+ },
1345
+ {
1346
+ fieldName: "queueId",
1347
+ columnName: "queue_id",
1348
+ def: { type: "TEXT", unique: true },
1349
+ select: true,
1350
+ create: true,
1351
+ update: false
1352
+ },
810
1353
  {
811
1354
  fieldName: "id",
812
1355
  columnName: "id",
@@ -887,24 +1430,51 @@ var SEND_LOG_FIELDS = [
887
1430
  create: true,
888
1431
  update: true
889
1432
  },
1433
+ {
1434
+ fieldName: "processingOwner",
1435
+ columnName: "processing_owner",
1436
+ def: { type: "TEXT" },
1437
+ select: true,
1438
+ create: true,
1439
+ update: true
1440
+ },
1441
+ {
1442
+ fieldName: "lockedUntil",
1443
+ columnName: "locked_until",
1444
+ def: { type: "BIGINT" },
1445
+ select: true,
1446
+ create: true,
1447
+ update: true
1448
+ },
890
1449
  {
891
1450
  fieldName: "createdAt",
892
1451
  columnName: "created_at",
893
- def: { type: "TIMESTAMP", notNull: true },
1452
+ def: { type: "BIGINT", notNull: true },
894
1453
  select: true,
895
1454
  create: true,
896
1455
  update: false
1456
+ },
1457
+ {
1458
+ fieldName: "updatedAt",
1459
+ columnName: "updated_at",
1460
+ def: { type: "BIGINT", notNull: true },
1461
+ select: true,
1462
+ create: true,
1463
+ update: true
897
1464
  }
898
1465
  ];
899
1466
  var sendLogRepoInstance = null;
900
- var sendLogRepoDbConfig = null;
1467
+ var sendLogRepoSqlOps = null;
901
1468
  function resetSendLogRepoSingleton() {
902
1469
  sendLogRepoInstance = null;
903
- sendLogRepoDbConfig = null;
1470
+ sendLogRepoSqlOps = null;
904
1471
  }
905
1472
  async function createSendLogRepository(db) {
906
- if (sendLogRepoInstance && sendLogRepoDbConfig === db.config)
1473
+ if (sendLogRepoInstance && sendLogRepoSqlOps === db.sql)
907
1474
  return ok(sendLogRepoInstance);
1475
+ const migration = await migrateReach({}, db);
1476
+ if (!migration.success)
1477
+ return migration;
908
1478
  const repo = new DbSendLogRepository(db);
909
1479
  const initResult = await repo.count();
910
1480
  if (!initResult.success) {
@@ -915,10 +1485,31 @@ async function createSendLogRepository(db) {
915
1485
  );
916
1486
  }
917
1487
  sendLogRepoInstance = repo;
918
- sendLogRepoDbConfig = db.config;
1488
+ sendLogRepoSqlOps = db.sql;
919
1489
  return ok(repo);
920
1490
  }
921
1491
  var DbSendLogRepository = class extends BaseReldbCrudRepository {
1492
+ /** 共享后端取真正执行时钟;本地 SQLite 保留可控的单进程测试时钟。 */
1493
+ clockSql(localNow) {
1494
+ if (this.db.config?.type === "postgresql")
1495
+ return "CAST(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) AS BIGINT)";
1496
+ if (this.db.config?.type === "mysql")
1497
+ return "CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED)";
1498
+ return String(localNow ?? Date.now());
1499
+ }
1500
+ async currentTime() {
1501
+ const time = await this.sql().get(`SELECT ${this.clockSql()} AS now`);
1502
+ return time.success && time.data && Number.isSafeInteger(Number(time.data.now)) ? ok(Number(time.data.now)) : buildSendLogError(reachM("reach_claimLost"));
1503
+ }
1504
+ async settleSensitive(queueId, status, messageId) {
1505
+ const settled = await this.sql().execute(
1506
+ `UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, locked_until = NULL, updated_at = ? WHERE queue_id = ? AND status = 'sending' AND body IS NULL AND vars_json IS NULL AND retry_safe = FALSE`,
1507
+ [status, messageId ?? null, Date.now(), queueId]
1508
+ );
1509
+ if (!settled.success)
1510
+ return settled;
1511
+ return settled.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_sensitiveDeliveryUnconfirmed"));
1512
+ }
922
1513
  constructor(db) {
923
1514
  super(db, {
924
1515
  table: TABLE_NAME,
@@ -928,6 +1519,31 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
928
1519
  createTableIfNotExists: true
929
1520
  });
930
1521
  }
1522
+ /** 单条可空查询统一返回经过校验的毫秒 number。 */
1523
+ async findById(id, tx) {
1524
+ const result = await super.findById(id, tx);
1525
+ if (!result.success || result.data === null)
1526
+ return result;
1527
+ return normalizeSendLog(result.data);
1528
+ }
1529
+ /** 单条必需查询统一返回经过校验的毫秒 number。 */
1530
+ async getById(id, tx) {
1531
+ const result = await super.getById(id, tx);
1532
+ return result.success ? normalizeSendLog(result.data) : result;
1533
+ }
1534
+ /** 普通列表与 claim 回读共用同一 BIGINT 转换边界。 */
1535
+ async findAll(options, tx) {
1536
+ const result = await super.findAll(options, tx);
1537
+ return result.success ? normalizeSendLogs(result.data) : result;
1538
+ }
1539
+ /** 分页结果同样拒绝不安全的时间值,不返回经过舍入的毫秒数。 */
1540
+ async findPage(options, tx) {
1541
+ const result = await super.findPage(options, tx);
1542
+ if (!result.success)
1543
+ return result;
1544
+ const rows = normalizeSendLogs(result.data.items);
1545
+ return rows.success ? ok({ ...result.data, items: rows.data }) : rows;
1546
+ }
931
1547
  /** 获取所有待发送记录 */
932
1548
  async findPending(tx) {
933
1549
  const result = await this.findAll({ where: "status = ?", params: ["pending"], orderBy: "created_at ASC" }, tx);
@@ -936,11 +1552,238 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
936
1552
  }
937
1553
  return ok(result.data);
938
1554
  }
939
- /** 将记录标记为已发送 */
940
- async markSent(id, messageId, tx) {
941
- const result = await this.updateById(id, { status: "sent", messageId: messageId ?? null }, tx);
1555
+ /**
1556
+ * 使用数据库行锁 claim 一批记录。
1557
+ *
1558
+ * PostgreSQL / MySQL 支持 `FOR UPDATE SKIP LOCKED`:
1559
+ * - 当前事务锁住即将 claim 的行
1560
+ * - 并发事务会直接跳过这些行,而不是重复命中同一批记录
1561
+ * - 在 commit 前不会暴露给其它节点重复 claim
1562
+ */
1563
+ async claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) {
1564
+ const lockedRowsResult = await tx.query(
1565
+ `SELECT id
1566
+ FROM ${TABLE_NAME}
1567
+ WHERE ${claimableWhere(clock)}
1568
+ ORDER BY created_at ASC
1569
+ LIMIT ?
1570
+ FOR UPDATE SKIP LOCKED`,
1571
+ [...claimableParams(eligibleBefore), limit]
1572
+ );
1573
+ if (!lockedRowsResult.success) {
1574
+ throw lockedRowsResult.error;
1575
+ }
1576
+ const claimedIds = lockedRowsResult.data.map((row) => {
1577
+ const id = typeof row.id === "number" ? row.id : Number(row.id);
1578
+ if (!Number.isSafeInteger(id)) {
1579
+ throw new TypeError(`Invalid send log id returned when claiming pending batch: ${String(row.id)}`);
1580
+ }
1581
+ return id;
1582
+ });
1583
+ if (claimedIds.length === 0) {
1584
+ return [];
1585
+ }
1586
+ const placeholders = claimedIds.map(() => "?").join(", ");
1587
+ const updateResult = await tx.execute(
1588
+ `UPDATE ${TABLE_NAME}
1589
+ SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
1590
+ WHERE id IN (${placeholders})`,
1591
+ ["processing", owner, lockMs, ...claimedIds]
1592
+ );
1593
+ if (!updateResult.success) {
1594
+ throw updateResult.error;
1595
+ }
1596
+ return claimedIds;
1597
+ }
1598
+ /**
1599
+ * SQLite 等不支持 `SKIP LOCKED` 的数据库回退到 compare-and-set claim。
1600
+ *
1601
+ * 流程是:先按顺序读候选,再用带条件的 UPDATE 抢占每一行。
1602
+ * 即使多个节点读到了同一候选,只有第一个满足条件的 UPDATE 会成功,
1603
+ * 从而避免同一行被两个节点同时 claim。
1604
+ */
1605
+ async claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore) {
1606
+ const candidatesResult = await this.findAll({
1607
+ where: claimableWhere(clock),
1608
+ params: claimableParams(eligibleBefore),
1609
+ orderBy: "created_at ASC",
1610
+ limit
1611
+ }, tx);
1612
+ if (!candidatesResult.success) {
1613
+ throw candidatesResult.error;
1614
+ }
1615
+ const claimedIds = [];
1616
+ for (const row of candidatesResult.data) {
1617
+ const updateResult = await tx.execute(
1618
+ `UPDATE ${TABLE_NAME}
1619
+ SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
1620
+ WHERE id = ? AND ${claimableWhere(clock)}`,
1621
+ ["processing", owner, lockMs, row.id, ...claimableParams(eligibleBefore)]
1622
+ );
1623
+ if (!updateResult.success) {
1624
+ throw updateResult.error;
1625
+ }
1626
+ if (updateResult.data.changes > 0) {
1627
+ claimedIds.push(row.id);
1628
+ }
1629
+ }
1630
+ return claimedIds;
1631
+ }
1632
+ /** 根据已 claim 的 id 回读完整记录,统一返回给 flush 流程。 */
1633
+ async loadClaimedRows(ids, tx) {
1634
+ const placeholders = ids.map(() => "?").join(", ");
1635
+ const claimedRowsResult = await this.findAll({
1636
+ where: `id IN (${placeholders})`,
1637
+ params: ids,
1638
+ orderBy: "created_at ASC"
1639
+ }, tx);
1640
+ if (!claimedRowsResult.success) {
1641
+ throw claimedRowsResult.error;
1642
+ }
1643
+ return claimedRowsResult.data;
1644
+ }
1645
+ /** claim 一批待发送记录,避免多节点重复发送 */
1646
+ async claimPendingBatch(owner, options) {
1647
+ const time = await this.currentTime();
1648
+ if (!time.success)
1649
+ return time;
1650
+ const now = this.db.config?.type === "sqlite" ? options?.now ?? time.data : time.data;
1651
+ const clock = this.clockSql(now);
1652
+ const limit = options?.limit ?? DEFAULT_CLAIM_BATCH_SIZE;
1653
+ const lockMs = options?.lockMs ?? DEFAULT_CLAIM_LOCK_MS;
1654
+ const eligibleBefore = Math.min(now, options?.eligibleBefore ?? now);
1655
+ const dbType = this.db.config?.type;
1656
+ const txResult = await this.db.tx.begin();
1657
+ if (!txResult.success) {
1658
+ return buildSendLogError(txResult.error.message, txResult.error);
1659
+ }
1660
+ const tx = txResult.data;
1661
+ try {
1662
+ const unknown = await tx.execute(
1663
+ `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1664
+ WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <= ${clock})`,
1665
+ ["unknown", "sending"]
1666
+ );
1667
+ if (!unknown.success)
1668
+ throw unknown.error;
1669
+ const exhausted = await tx.execute(
1670
+ `UPDATE ${TABLE_NAME}
1671
+ SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1672
+ WHERE attempt_count >= max_attempts
1673
+ AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ${clock}))
1674
+ OR (status = ? AND retry_safe = TRUE AND locked_until <= ${clock}))`,
1675
+ ["dead", "attempts_exhausted", "pending", "processing", "sending"]
1676
+ );
1677
+ if (!exhausted.success)
1678
+ throw exhausted.error;
1679
+ const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, clock, lockMs, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, clock, lockMs, limit, eligibleBefore);
1680
+ if (claimedIds.length === 0) {
1681
+ const commitResult2 = await tx.commit();
1682
+ if (!commitResult2.success) {
1683
+ return buildSendLogError(commitResult2.error.message, commitResult2.error);
1684
+ }
1685
+ return ok([]);
1686
+ }
1687
+ const claimedRows = await this.loadClaimedRows(claimedIds, tx);
1688
+ const commitResult = await tx.commit();
1689
+ if (!commitResult.success) {
1690
+ return buildSendLogError(commitResult.error.message, commitResult.error);
1691
+ }
1692
+ return ok(claimedRows);
1693
+ } catch (error) {
1694
+ await tx.rollback();
1695
+ return buildSendLogError(error instanceof Error ? error.message : String(error), error);
1696
+ }
1697
+ }
1698
+ /** 显式取消未完成的领取;仍按失败退避,避免调用方绕过自动重试预算。 */
1699
+ async releaseClaim(id, owner, tx) {
1700
+ const row = await this.getById(id, tx);
1701
+ if (!row.success)
1702
+ return row;
1703
+ return this.failClaim(row.data, owner, "transient_failure", false, tx);
1704
+ }
1705
+ /** 失败提交与 owner、次数和有效租约同时比较,旧 worker 不能更改新领取的退避策略。 */
1706
+ async failClaim(row, owner, reason, permanent = false, tx) {
1707
+ const clock = this.clockSql();
1708
+ const dead = permanent || row.attemptCount >= row.maxAttempts;
1709
+ const delay = Math.min(row.maxRetryDelayMs, row.retryDelayMs * 2 ** Math.min(row.attemptCount - 1, 30));
1710
+ const result = await this.sql(tx).execute(
1711
+ `UPDATE ${TABLE_NAME}
1712
+ SET status = ?, last_failure = ?, next_attempt_at = ${dead ? "0" : `${clock} + ?`}, processing_owner = NULL, locked_until = NULL, updated_at = ${clock}
1713
+ WHERE id = ? AND processing_owner = ? AND locked_until > ${clock} AND attempt_count = ?
1714
+ AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
1715
+ [dead ? "dead" : "pending", reason, ...dead ? [] : [delay], row.id, owner, row.attemptCount, "processing", "sending"]
1716
+ );
942
1717
  if (!result.success) {
943
- return result;
1718
+ return buildSendLogError(result.error.message, result.error);
1719
+ }
1720
+ if (result.data.changes === 0) {
1721
+ return buildSendLogError(reachM("reach_claimLost"));
1722
+ }
1723
+ return ok(void 0);
1724
+ }
1725
+ /** 仅当前 dead 版本可以恢复;保留 queueId,迟到的幂等 API 请求仍使用同一键。 */
1726
+ async requeueDead(queueId, expectedUpdatedAt) {
1727
+ const clock = this.clockSql();
1728
+ const result = await this.sql().execute(
1729
+ `UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ${clock}, last_failure = NULL, updated_at = CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END
1730
+ WHERE queue_id = ? AND status = ? AND updated_at = ?`,
1731
+ ["pending", queueId, "dead", expectedUpdatedAt]
1732
+ );
1733
+ return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
1734
+ }
1735
+ /** 发送前持久化门闩;仅有效租约可从 processing 进入 sending。 */
1736
+ async beginDelivery(id, owner, retrySafe, lockMs = DEFAULT_CLAIM_LOCK_MS) {
1737
+ const clock = this.clockSql();
1738
+ const flag = this.db.config?.type === "postgresql" ? retrySafe : Number(retrySafe);
1739
+ const result = await this.sql().execute(
1740
+ `UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ${clock} + ?, updated_at = ${clock}
1741
+ WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
1742
+ ["sending", flag, lockMs, id, "processing", owner]
1743
+ );
1744
+ return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1745
+ }
1746
+ /** 续租不接受已经过期的 owner,防止暂停的旧进程恢复后重新取得发送权。 */
1747
+ async renewClaim(id, owner, lockMs = DEFAULT_CLAIM_LOCK_MS) {
1748
+ const clock = this.clockSql();
1749
+ const result = await this.sql().execute(
1750
+ `UPDATE ${TABLE_NAME} SET locked_until = ${clock} + ?, updated_at = ${clock}
1751
+ WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ${clock}`,
1752
+ [lockMs, id, owner, "processing", "sending"]
1753
+ );
1754
+ return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1755
+ }
1756
+ /** 未知结果只能停止自动处理,不推断外部副作用已撤销。 */
1757
+ async markUnknown(id, owner) {
1758
+ const result = await this.sql().execute(
1759
+ `UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${this.clockSql()}
1760
+ WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
1761
+ ["unknown", id, owner, "processing", "sending"]
1762
+ );
1763
+ return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
1764
+ }
1765
+ /** 人工裁定的条件提交,不提供重新排队或重新发送操作。 */
1766
+ async resolveUnknown(queueId, resolution) {
1767
+ const result = await this.sql().execute(
1768
+ `UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ${this.clockSql()} WHERE queue_id = ? AND status = ?`,
1769
+ [resolution.status, resolution.messageId ?? null, queueId, "unknown"]
1770
+ );
1771
+ return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
1772
+ }
1773
+ /** 只有仍持有 sending 有效租约的 owner 可以确认投递。 */
1774
+ async markSent(id, owner, messageId, tx) {
1775
+ const clock = this.clockSql();
1776
+ const result = await this.sql(tx).execute(
1777
+ `UPDATE ${TABLE_NAME}
1778
+ SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
1779
+ WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
1780
+ ["sent", messageId ?? null, id, "sending", owner]
1781
+ );
1782
+ if (!result.success) {
1783
+ return buildSendLogError(result.error.message, result.error);
1784
+ }
1785
+ if (result.data.changes === 0) {
1786
+ return buildSendLogError(reachM("reach_claimLost"));
944
1787
  }
945
1788
  return ok(void 0);
946
1789
  }
@@ -1005,13 +1848,13 @@ var TEMPLATE_FIELDS = [
1005
1848
  }
1006
1849
  ];
1007
1850
  var templateRepoInstance = null;
1008
- var templateRepoDbConfig = null;
1851
+ var templateRepoSqlOps = null;
1009
1852
  function resetTemplateRepoSingleton() {
1010
1853
  templateRepoInstance = null;
1011
- templateRepoDbConfig = null;
1854
+ templateRepoSqlOps = null;
1012
1855
  }
1013
1856
  async function createTemplateRepository(db) {
1014
- if (templateRepoInstance && templateRepoDbConfig === db.config)
1857
+ if (templateRepoInstance && templateRepoSqlOps === db.sql)
1015
1858
  return ok(templateRepoInstance);
1016
1859
  const repo = new DbTemplateRepository(db);
1017
1860
  const initResult = await repo.count();
@@ -1023,7 +1866,7 @@ async function createTemplateRepository(db) {
1023
1866
  );
1024
1867
  }
1025
1868
  templateRepoInstance = repo;
1026
- templateRepoDbConfig = db.config;
1869
+ templateRepoSqlOps = db.sql;
1027
1870
  return ok(repo);
1028
1871
  }
1029
1872
  function toReachTemplate(stored) {
@@ -1117,7 +1960,7 @@ var DbTemplateRepository = class extends BaseReldbCrudRepository {
1117
1960
  };
1118
1961
 
1119
1962
  // src/reach-main.ts
1120
- var logger6 = core.logger.child({ module: "reach", scope: "main" });
1963
+ var logger7 = core.logger.child({ module: "reach", scope: "main" });
1121
1964
  var providers = /* @__PURE__ */ new Map();
1122
1965
  var currentConfig = null;
1123
1966
  var dndConfig;
@@ -1125,6 +1968,8 @@ var templateRegistry = createTemplateRegistry();
1125
1968
  var sendLogRepo = null;
1126
1969
  var templateRepo = null;
1127
1970
  var initInProgress = false;
1971
+ var initCompletion = null;
1972
+ var closeInProgress = null;
1128
1973
  function createProvider(config) {
1129
1974
  switch (config.type) {
1130
1975
  case "console":
@@ -1138,19 +1983,7 @@ function createProvider(config) {
1138
1983
  }
1139
1984
  }
1140
1985
  async function tryGetDb() {
1141
- try {
1142
- const dbModuleName = "@h-ai/reldb";
1143
- const mod = await import(
1144
- /* @vite-ignore */
1145
- dbModuleName
1146
- );
1147
- if (!mod.reldb.isInitialized) {
1148
- return null;
1149
- }
1150
- return mod.reldb;
1151
- } catch {
1152
- return null;
1153
- }
1986
+ return reldb.isInitialized ? reldb : null;
1154
1987
  }
1155
1988
  async function tryInitSendLogRepo() {
1156
1989
  try {
@@ -1160,12 +1993,12 @@ async function tryInitSendLogRepo() {
1160
1993
  }
1161
1994
  const result = await createSendLogRepository(db);
1162
1995
  if (!result.success) {
1163
- logger6.debug("Send log repository not initialized", { error: result.error.message });
1996
+ logger7.debug("Send log repository not initialized", { error: result.error.message });
1164
1997
  return null;
1165
1998
  }
1166
1999
  return result.data;
1167
2000
  } catch (error) {
1168
- logger6.debug("Send log repository not initialized", { error: error instanceof Error ? error.message : String(error) });
2001
+ logger7.debug("Send log repository not initialized", { error: error instanceof Error ? error.message : String(error) });
1169
2002
  return null;
1170
2003
  }
1171
2004
  }
@@ -1177,12 +2010,12 @@ async function tryInitTemplateRepo() {
1177
2010
  }
1178
2011
  const result = await createTemplateRepository(db);
1179
2012
  if (!result.success) {
1180
- logger6.debug("Template repository not initialized", { error: result.error.message });
2013
+ logger7.debug("Template repository not initialized", { error: result.error.message });
1181
2014
  return null;
1182
2015
  }
1183
2016
  return result.data;
1184
2017
  } catch (error) {
1185
- logger6.debug("Template repository not initialized", { error: error instanceof Error ? error.message : String(error) });
2018
+ logger7.debug("Template repository not initialized", { error: error instanceof Error ? error.message : String(error) });
1186
2019
  return null;
1187
2020
  }
1188
2021
  }
@@ -1192,15 +2025,15 @@ var notInitialized = core.module.createNotInitializedKit(
1192
2025
  );
1193
2026
  async function doClose() {
1194
2027
  if (providers.size === 0 && currentConfig === null) {
1195
- logger6.info("Reach module already closed, skipping");
2028
+ logger7.info("Reach module already closed, skipping");
1196
2029
  return;
1197
2030
  }
1198
- logger6.info("Closing reach module");
2031
+ logger7.info("Closing reach module");
1199
2032
  for (const [name, provider] of providers.entries()) {
1200
2033
  try {
1201
2034
  await provider.close();
1202
2035
  } catch (error) {
1203
- logger6.error("Provider close failed", { name, error });
2036
+ logger7.error("Provider close failed", { name, error });
1204
2037
  }
1205
2038
  }
1206
2039
  providers = /* @__PURE__ */ new Map();
@@ -1212,17 +2045,17 @@ async function doClose() {
1212
2045
  resetSendLogRepoSingleton();
1213
2046
  resetTemplateRepoSingleton();
1214
2047
  resetSendState();
1215
- logger6.info("Reach module closed");
2048
+ logger7.info("Reach module closed");
1216
2049
  }
1217
2050
  async function doInit(config) {
1218
2051
  if (providers.size > 0) {
1219
- logger6.warn("Reach module is already initialized, reinitializing");
2052
+ logger7.warn("Reach module is already initialized, reinitializing");
1220
2053
  await doClose();
1221
2054
  }
1222
- logger6.info("Initializing reach module");
2055
+ logger7.info("Initializing reach module");
1223
2056
  const parseResult = ReachConfigSchema.safeParse(config);
1224
2057
  if (!parseResult.success) {
1225
- logger6.error("Reach config validation failed", { error: parseResult.error.message });
2058
+ logger7.error("Reach config validation failed", { error: parseResult.error.message });
1226
2059
  return err(
1227
2060
  HaiReachError.CONFIG_ERROR,
1228
2061
  reachM("reach_configError", { params: { error: parseResult.error.message } }),
@@ -1230,24 +2063,35 @@ async function doInit(config) {
1230
2063
  );
1231
2064
  }
1232
2065
  const parsed = parseResult.data;
2066
+ if (core.module.requiresSharedState(process.env) && parsed.providers.some((provider) => provider.type === "console"))
2067
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_productionProviderRequired"));
1233
2068
  try {
2069
+ let requiredQueue = null;
2070
+ if (parsed.dnd?.enabled && parsed.dnd.strategy === "delay") {
2071
+ if (!reldb.isInitialized)
2072
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
2073
+ const queue = await createSendLogRepository(reldb);
2074
+ if (!queue.success)
2075
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"), queue.error);
2076
+ requiredQueue = queue.data;
2077
+ }
1234
2078
  const newProviders = /* @__PURE__ */ new Map();
1235
2079
  for (const providerConfig of parsed.providers) {
1236
2080
  const provider = createProvider(providerConfig);
1237
2081
  const connectResult = await provider.connect(providerConfig);
1238
2082
  if (!connectResult.success) {
1239
- logger6.error("Provider initialization failed", {
2083
+ logger7.error("Provider initialization failed", {
1240
2084
  name: providerConfig.name,
1241
2085
  type: providerConfig.type,
1242
2086
  code: connectResult.error.code,
1243
2087
  message: connectResult.error.message
1244
2088
  });
1245
- logger6.info("Rolling back connected providers", { count: newProviders.size });
2089
+ logger7.info("Rolling back connected providers", { count: newProviders.size });
1246
2090
  for (const [name, p] of newProviders.entries()) {
1247
2091
  try {
1248
2092
  await p.close();
1249
2093
  } catch (rollbackError) {
1250
- logger6.error("Provider rollback close failed", { name, error: rollbackError });
2094
+ logger7.error("Provider rollback close failed", { name, error: rollbackError });
1251
2095
  }
1252
2096
  }
1253
2097
  return connectResult;
@@ -1262,18 +2106,18 @@ async function doInit(config) {
1262
2106
  if (parsed.templates && templateRepo) {
1263
2107
  const saveResult = await templateRegistry.saveBatch(parsed.templates);
1264
2108
  if (!saveResult.success) {
1265
- logger6.warn("Failed to save config templates to database", { error: saveResult.error.message });
2109
+ logger7.warn("Failed to save config templates to database", { error: saveResult.error.message });
1266
2110
  }
1267
2111
  }
1268
- sendLogRepo = await tryInitSendLogRepo();
2112
+ sendLogRepo = requiredQueue ?? await tryInitSendLogRepo();
1269
2113
  if (dndConfig) {
1270
2114
  startDndScheduler(dndConfig, providers, sendLogRepo);
1271
2115
  }
1272
2116
  const providerNames = parsed.providers.map((p) => p.name);
1273
- logger6.info("Reach module initialized", { providers: providerNames });
2117
+ logger7.info("Reach module initialized", { providers: providerNames });
1274
2118
  return ok(void 0);
1275
2119
  } catch (error) {
1276
- logger6.error("Reach module initialization failed", { error });
2120
+ logger7.error("Reach module initialization failed", { error });
1277
2121
  return err(
1278
2122
  HaiReachError.CONFIG_ERROR,
1279
2123
  reachM("reach_initFailed", {
@@ -1283,10 +2127,75 @@ async function doInit(config) {
1283
2127
  );
1284
2128
  }
1285
2129
  }
2130
+ function queueEntry(row) {
2131
+ return {
2132
+ queueId: row.queueId,
2133
+ provider: row.provider,
2134
+ status: row.status,
2135
+ updatedAt: row.updatedAt,
2136
+ messageId: row.messageId ?? void 0,
2137
+ attemptCount: row.attemptCount,
2138
+ maxAttempts: row.maxAttempts,
2139
+ nextAttemptAt: row.nextAttemptAt,
2140
+ lastFailure: row.lastFailure ?? void 0
2141
+ };
2142
+ }
1286
2143
  var reach = {
2144
+ queue: {
2145
+ async get(queueId) {
2146
+ if (providers.size === 0)
2147
+ return notInitialized.result();
2148
+ if (!sendLogRepo)
2149
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
2150
+ if (!ReachQueueIdSchema.safeParse(queueId).success)
2151
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
2152
+ const rows = await sendLogRepo.findAll({ where: "queue_id = ?", params: [queueId], limit: 1 });
2153
+ if (!rows.success)
2154
+ return rows;
2155
+ const row = rows.data[0];
2156
+ return ok(row ? queueEntry(row) : null);
2157
+ },
2158
+ async list(options) {
2159
+ if (providers.size === 0)
2160
+ return notInitialized.result();
2161
+ if (!sendLogRepo)
2162
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
2163
+ const parsed = ReachQueueListSchema.safeParse(options);
2164
+ if (!parsed.success)
2165
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
2166
+ const rows = await sendLogRepo.findAll({
2167
+ where: "status = ? AND queue_id IS NOT NULL",
2168
+ params: [parsed.data.status],
2169
+ orderBy: "created_at ASC, id ASC",
2170
+ limit: parsed.data.limit,
2171
+ offset: parsed.data.offset
2172
+ });
2173
+ return rows.success ? ok(rows.data.map(queueEntry)) : rows;
2174
+ },
2175
+ async requeueDead(queueId, options) {
2176
+ if (providers.size === 0)
2177
+ return notInitialized.result();
2178
+ if (!sendLogRepo)
2179
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
2180
+ const parsed = ReachDeadRequeueSchema.safeParse(options);
2181
+ if (!ReachQueueIdSchema.safeParse(queueId).success || !parsed.success)
2182
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
2183
+ return sendLogRepo.requeueDead(queueId, parsed.data.expectedUpdatedAt);
2184
+ },
2185
+ async resolveUnknown(queueId, resolution) {
2186
+ if (providers.size === 0)
2187
+ return notInitialized.result();
2188
+ if (!sendLogRepo)
2189
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
2190
+ const parsed = ReachUnknownResolutionSchema.safeParse(resolution);
2191
+ if (!ReachQueueIdSchema.safeParse(queueId).success || !parsed.success)
2192
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
2193
+ return sendLogRepo.resolveUnknown(queueId, parsed.data);
2194
+ }
2195
+ },
1287
2196
  async init(config) {
1288
- if (initInProgress) {
1289
- logger6.warn("Reach module init already in progress, skipping");
2197
+ if (initInProgress || closeInProgress) {
2198
+ logger7.warn("Reach module init already in progress, skipping");
1290
2199
  return err(
1291
2200
  HaiReachError.CONFIG_ERROR,
1292
2201
  reachM("reach_configError", { params: { error: "init already in progress" } })
@@ -1294,31 +2203,44 @@ var reach = {
1294
2203
  }
1295
2204
  initInProgress = true;
1296
2205
  try {
1297
- return await doInit(config);
2206
+ initCompletion = doInit(config);
2207
+ return await initCompletion;
1298
2208
  } finally {
1299
2209
  initInProgress = false;
2210
+ initCompletion = null;
1300
2211
  }
1301
2212
  },
1302
2213
  async send(message) {
1303
2214
  if (providers.size === 0) {
1304
2215
  return notInitialized.result();
1305
2216
  }
1306
- return executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo);
2217
+ const send = () => executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo, currentConfig.queue);
2218
+ return message.sensitive ? core.context.run({ suppressLogObservers: true }, send) : send();
1307
2219
  },
1308
2220
  get template() {
1309
2221
  return templateRegistry;
1310
2222
  },
1311
2223
  get config() {
1312
- return currentConfig;
2224
+ return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
1313
2225
  },
1314
2226
  get isInitialized() {
1315
2227
  return providers.size > 0;
1316
2228
  },
1317
2229
  async close() {
1318
- await doClose();
2230
+ if (closeInProgress)
2231
+ return closeInProgress;
2232
+ closeInProgress = (async () => {
2233
+ await initCompletion;
2234
+ await doClose();
2235
+ })();
2236
+ try {
2237
+ await closeInProgress;
2238
+ } finally {
2239
+ closeInProgress = null;
2240
+ }
1319
2241
  }
1320
2242
  };
1321
2243
 
1322
- export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, SmtpProviderConfigSchema, TemplateConfigSchema, reach };
2244
+ export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, ReachDeadRequeueSchema, ReachQueueConfigSchema, ReachQueueIdSchema, ReachQueueListSchema, ReachUnknownResolutionSchema, SmtpProviderConfigSchema, TemplateConfigSchema, migrateReach, reach };
1323
2245
  //# sourceMappingURL=index.js.map
1324
2246
  //# sourceMappingURL=index.js.map