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

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