@h-ai/reach 0.1.0-alpha.67 → 0.1.0-alpha.69
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/README.en.md +76 -15
- package/README.ja.md +76 -15
- package/README.md +87 -26
- package/dist/index.d.ts +990 -155
- package/dist/index.js +568 -160
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { core, err, ok } from '@h-ai/core';
|
|
3
|
+
import process from 'process';
|
|
3
4
|
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
4
5
|
import { randomUUID, createHmac } from 'crypto';
|
|
5
6
|
import { createRequire } from 'module';
|
|
@@ -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
|
-
|
|
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,55 @@ 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
|
+
};
|
|
55
|
+
|
|
56
|
+
// messages/ja-JP.json
|
|
57
|
+
var ja_JP_default = {
|
|
58
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
59
|
+
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",
|
|
60
|
+
reach_initFailed: "\u901A\u77E5\u30E2\u30B8\u30E5\u30FC\u30EB\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
61
|
+
reach_unsupportedType: "\u672A\u5BFE\u5FDC\u306E\u901A\u77E5\u306E\u7A2E\u985E\u3067\u3059: {type}",
|
|
62
|
+
reach_sendFailed: "\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
63
|
+
reach_templateNotFound: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {template}",
|
|
64
|
+
reach_templateRenderFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u63CF\u753B\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
65
|
+
reach_invalidRecipient: "\u5B9B\u5148\u304C\u7121\u52B9\u3067\u3059: {recipient}",
|
|
66
|
+
reach_providerNotFound: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {provider}",
|
|
67
|
+
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",
|
|
68
|
+
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",
|
|
69
|
+
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",
|
|
70
|
+
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",
|
|
71
|
+
reach_sendRecordSaveFailed: "\u914D\u4FE1\u8A18\u9332\u306E\u6C38\u7D9A\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
|
|
72
|
+
reach_invalidStoredInteger: "\u914D\u4FE1\u8A18\u9332\u306E {field} \u306F\u5B89\u5168\u306A\u975E\u8CA0\u6574\u6570\u3067\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
73
|
+
reach_claimLost: "\u6709\u52B9\u306A\u914D\u4FE1\u30EA\u30FC\u30B9\u304C\u5931\u308F\u308C\u307E\u3057\u305F",
|
|
74
|
+
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",
|
|
75
|
+
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",
|
|
76
|
+
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",
|
|
77
|
+
reach_smsSendFailed: "SMS\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
78
|
+
reach_emailSendFailed: "\u30E1\u30FC\u30EB\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
79
|
+
reach_apiSendFailed: "API\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
80
|
+
reach_configError: "\u901A\u77E5\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
81
|
+
reach_config_nameRequired: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u540D\u306F\u5FC5\u9808\u3067\u3059",
|
|
82
|
+
reach_config_hostRequired: "SMTP\u30DB\u30B9\u30C8\u306F\u5FC5\u9808\u3067\u3059",
|
|
83
|
+
reach_config_fromRequired: "\u9001\u4FE1\u8005\u30A2\u30C9\u30EC\u30B9\uFF08from\uFF09\u306F\u5FC5\u9808\u3067\u3059",
|
|
84
|
+
reach_config_accessKeyIdRequired: "AccessKey ID\u306F\u5FC5\u9808\u3067\u3059",
|
|
85
|
+
reach_config_accessKeySecretRequired: "AccessKey Secret\u306F\u5FC5\u9808\u3067\u3059",
|
|
86
|
+
reach_config_signNameRequired: "SMS\u7F72\u540D\u306F\u5FC5\u9808\u3067\u3059",
|
|
87
|
+
reach_config_urlRequired: "\u30B3\u30FC\u30EB\u30D0\u30C3\u30AFURL\u306F\u5FC5\u9808\u3067\u3059",
|
|
88
|
+
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",
|
|
89
|
+
reach_config_templateNameRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u540D\u306F\u5FC5\u9808\u3067\u3059",
|
|
90
|
+
reach_config_templateProviderRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u306F\u5FC5\u9808\u3067\u3059",
|
|
91
|
+
reach_config_templateBodyRequired: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u672C\u6587\u306F\u5FC5\u9808\u3067\u3059",
|
|
92
|
+
reach_config_providersRequired: "\u5C11\u306A\u304F\u3068\u30821\u3064\u306E\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059",
|
|
93
|
+
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}",
|
|
94
|
+
reach_templateSaveFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
95
|
+
reach_templateDeleteFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u524A\u9664\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
96
|
+
reach_queueRetryInvalid: "maxRetryDelayMs \u306F retryDelayMs \u4EE5\u4E0A\u306B\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
97
|
+
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",
|
|
98
|
+
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"
|
|
43
99
|
};
|
|
44
100
|
|
|
45
101
|
// messages/zh-CN.json
|
|
@@ -55,7 +111,14 @@ var zh_CN_default = {
|
|
|
55
111
|
reach_providerNotFound: "Provider \u672A\u627E\u5230: {provider}",
|
|
56
112
|
reach_providerRequired: "\u672A\u6307\u5B9A Provider\uFF0C\u8BF7\u5728\u6D88\u606F\u6216\u6A21\u677F\u4E2D\u8BBE\u7F6E provider \u5B57\u6BB5",
|
|
57
113
|
reach_dndBlocked: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u4E22\u5F03",
|
|
58
|
-
|
|
114
|
+
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",
|
|
115
|
+
reach_dndEnqueueFailed: "\u6D88\u606F\u672A\u80FD\u53EF\u9760\u4FDD\u5B58\u5230\u5EF6\u8FDF\u961F\u5217\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
116
|
+
reach_sendRecordSaveFailed: "\u4FDD\u5B58\u89E6\u8FBE\u53D1\u9001\u8BB0\u5F55\u5931\u8D25",
|
|
117
|
+
reach_invalidStoredInteger: "\u53D1\u9001\u8BB0\u5F55\u7684 {field} \u4E0D\u662F\u5B89\u5168\u7684\u975E\u8D1F\u6574\u6570",
|
|
118
|
+
reach_claimLost: "\u6D88\u606F\u7684\u6709\u6548\u6295\u9012\u79DF\u7EA6\u5DF2\u4E22\u5931",
|
|
119
|
+
reach_unknownResolutionFailed: "\u672A\u77E5\u6295\u9012\u7ED3\u679C\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5176\u5B83\u64CD\u4F5C\u88C1\u5B9A",
|
|
120
|
+
reach_queueInputInvalid: "\u961F\u5217\u56DE\u6267\u6216\u4EBA\u5DE5\u88C1\u5B9A\u53C2\u6570\u65E0\u6548",
|
|
121
|
+
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
122
|
reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25: {error}",
|
|
60
123
|
reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25: {error}",
|
|
61
124
|
reach_apiSendFailed: "API \u56DE\u8C03\u53D1\u9001\u5931\u8D25: {error}",
|
|
@@ -74,13 +137,17 @@ var zh_CN_default = {
|
|
|
74
137
|
reach_config_providersRequired: "\u81F3\u5C11\u9700\u8981\u914D\u7F6E\u4E00\u4E2A Provider",
|
|
75
138
|
reach_templateDbInitFailed: "\u6A21\u677F\u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25: {error}",
|
|
76
139
|
reach_templateSaveFailed: "\u6A21\u677F\u4FDD\u5B58\u5931\u8D25: {error}",
|
|
77
|
-
reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}"
|
|
140
|
+
reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}",
|
|
141
|
+
reach_queueRetryInvalid: "maxRetryDelayMs \u4E0D\u80FD\u5C0F\u4E8E retryDelayMs",
|
|
142
|
+
reach_deadRequeueFailed: "\u6B7B\u4FE1\u5DF2\u53D8\u5316\u6216\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u540E\u6062\u590D",
|
|
143
|
+
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"
|
|
78
144
|
};
|
|
79
145
|
|
|
80
146
|
// src/reach-i18n.ts
|
|
81
147
|
var reachM = core.i18n.createMessageGetter({
|
|
82
148
|
"zh-CN": zh_CN_default,
|
|
83
|
-
"en-US": en_US_default
|
|
149
|
+
"en-US": en_US_default,
|
|
150
|
+
"ja-JP": ja_JP_default
|
|
84
151
|
});
|
|
85
152
|
|
|
86
153
|
// src/reach-config.ts
|
|
@@ -132,7 +199,9 @@ var ApiProviderConfigSchema = z.object({
|
|
|
132
199
|
/** 自定义请求头 */
|
|
133
200
|
headers: z.record(z.string(), z.string()).optional(),
|
|
134
201
|
/** 请求超时毫秒数(默认 10000) */
|
|
135
|
-
timeout: z.number().int().min(0).default(1e4)
|
|
202
|
+
timeout: z.number().int().min(0).default(1e4),
|
|
203
|
+
/** 接收端按 Idempotency-Key 原子持久去重,且覆盖所有重试与旧节点迟到请求时才启用;默认 false。 */
|
|
204
|
+
idempotent: z.boolean().default(false)
|
|
136
205
|
});
|
|
137
206
|
var ProviderConfigSchema = z.discriminatedUnion("type", [
|
|
138
207
|
ConsoleProviderConfigSchema,
|
|
@@ -140,10 +209,33 @@ var ProviderConfigSchema = z.discriminatedUnion("type", [
|
|
|
140
209
|
AliyunSmsProviderConfigSchema,
|
|
141
210
|
ApiProviderConfigSchema
|
|
142
211
|
]);
|
|
212
|
+
var ReachUnknownResolutionSchema = z.object({
|
|
213
|
+
status: z.enum(["sent", "discarded"]),
|
|
214
|
+
messageId: z.string().max(255).optional()
|
|
215
|
+
});
|
|
216
|
+
var ReachQueueIdSchema = z.string().uuid();
|
|
217
|
+
var ReachQueueConfigSchema = z.object({
|
|
218
|
+
/** 自动领取次数上限(含首次及崩溃恢复,默认 5,范围 1–100)。 */
|
|
219
|
+
maxAttempts: z.number().int().min(1).max(100).default(5),
|
|
220
|
+
/** 首次失败后的退避毫秒数(默认 60000,范围 1000–86400000)。 */
|
|
221
|
+
retryDelayMs: z.number().int().min(1e3).max(864e5).default(6e4),
|
|
222
|
+
/** 指数退避上限毫秒数,不得小于 retryDelayMs(默认 3600000)。 */
|
|
223
|
+
maxRetryDelayMs: z.number().int().min(1e3).max(864e5).default(36e5)
|
|
224
|
+
}).refine((value) => value.maxRetryDelayMs >= value.retryDelayMs, {
|
|
225
|
+
message: reachM("reach_queueRetryInvalid")
|
|
226
|
+
});
|
|
227
|
+
var ReachQueueListSchema = z.object({
|
|
228
|
+
status: z.enum(["dead", "unknown"]),
|
|
229
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
230
|
+
offset: z.number().int().min(0).max(1e6).default(0)
|
|
231
|
+
});
|
|
232
|
+
var ReachDeadRequeueSchema = z.object({
|
|
233
|
+
expectedUpdatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER - 1)
|
|
234
|
+
});
|
|
143
235
|
var DndConfigSchema = z.object({
|
|
144
236
|
/** 是否启用免打扰(默认 false) */
|
|
145
237
|
enabled: z.boolean().default(false),
|
|
146
|
-
/**
|
|
238
|
+
/** discard 丢弃;delay 要求数据库队列就绪并确认入队(默认 discard)。 */
|
|
147
239
|
strategy: z.enum(["discard", "delay"]).default("discard"),
|
|
148
240
|
/** 免打扰开始时间(HH:mm 格式) */
|
|
149
241
|
start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, reachM("reach_config_dndTimeInvalid")).default("00:00"),
|
|
@@ -166,7 +258,9 @@ var ReachConfigSchema = z.object({
|
|
|
166
258
|
/** 模板配置(通过配置文件注册) */
|
|
167
259
|
templates: z.array(TemplateConfigSchema).optional(),
|
|
168
260
|
/** 免打扰配置 */
|
|
169
|
-
dnd: DndConfigSchema.optional()
|
|
261
|
+
dnd: DndConfigSchema.optional(),
|
|
262
|
+
/** 可靠队列重试策略;每条消息入队时持久保存。 */
|
|
263
|
+
queue: ReachQueueConfigSchema.prefault({})
|
|
170
264
|
});
|
|
171
265
|
var ReachErrorInfo = {
|
|
172
266
|
SEND_FAILED: "001:500",
|
|
@@ -175,10 +269,10 @@ var ReachErrorInfo = {
|
|
|
175
269
|
INVALID_RECIPIENT: "004:400",
|
|
176
270
|
PROVIDER_NOT_FOUND: "005:500",
|
|
177
271
|
DND_BLOCKED: "006:403",
|
|
178
|
-
DND_DEFERRED: "007:202",
|
|
179
272
|
NOT_INITIALIZED: "010:500",
|
|
180
273
|
UNSUPPORTED_TYPE: "011:400",
|
|
181
|
-
CONFIG_ERROR: "012:500"
|
|
274
|
+
CONFIG_ERROR: "012:500",
|
|
275
|
+
DELIVERY_REJECTED: "020:422"
|
|
182
276
|
};
|
|
183
277
|
var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
|
|
184
278
|
|
|
@@ -209,6 +303,7 @@ function createAliyunSmsProvider() {
|
|
|
209
303
|
let smsConfig = null;
|
|
210
304
|
return {
|
|
211
305
|
name: "aliyun-sms",
|
|
306
|
+
supportsIdempotency: false,
|
|
212
307
|
async connect(config) {
|
|
213
308
|
if (config.type !== "aliyun-sms") {
|
|
214
309
|
return err(
|
|
@@ -295,6 +390,9 @@ function createApiProvider() {
|
|
|
295
390
|
let apiConfig = null;
|
|
296
391
|
return {
|
|
297
392
|
name: "api",
|
|
393
|
+
get supportsIdempotency() {
|
|
394
|
+
return apiConfig?.idempotent === true;
|
|
395
|
+
},
|
|
298
396
|
async connect(config) {
|
|
299
397
|
if (config.type !== "api") {
|
|
300
398
|
return err(
|
|
@@ -328,24 +426,26 @@ function createApiProvider() {
|
|
|
328
426
|
body: message.body,
|
|
329
427
|
template: message.template,
|
|
330
428
|
vars: message.vars,
|
|
331
|
-
extra: message.extra
|
|
429
|
+
extra: message.extra,
|
|
430
|
+
idempotencyKey: message.idempotencyKey
|
|
332
431
|
};
|
|
333
432
|
const controller = new AbortController();
|
|
334
433
|
const timeoutId = setTimeout(() => controller.abort(), apiConfig.timeout);
|
|
335
434
|
try {
|
|
435
|
+
const headers = new Headers({ "Content-Type": "application/json", ...apiConfig.headers });
|
|
436
|
+
if (message.idempotencyKey)
|
|
437
|
+
headers.set("Idempotency-Key", message.idempotencyKey);
|
|
336
438
|
const response = await fetch(apiConfig.url, {
|
|
337
439
|
method: apiConfig.method,
|
|
338
|
-
headers
|
|
339
|
-
"Content-Type": "application/json",
|
|
340
|
-
...apiConfig.headers
|
|
341
|
-
},
|
|
440
|
+
headers,
|
|
342
441
|
body: JSON.stringify(payload),
|
|
343
442
|
signal: controller.signal
|
|
344
443
|
});
|
|
345
444
|
if (!response.ok) {
|
|
346
445
|
logger2.warn("API callback returned non-OK status", { status: response.status });
|
|
446
|
+
const rejected = response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
|
|
347
447
|
return err(
|
|
348
|
-
HaiReachError.SEND_FAILED,
|
|
448
|
+
rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
|
|
349
449
|
reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}` } })
|
|
350
450
|
);
|
|
351
451
|
}
|
|
@@ -367,6 +467,7 @@ function createConsoleProvider() {
|
|
|
367
467
|
let connected = false;
|
|
368
468
|
return {
|
|
369
469
|
name: "console",
|
|
470
|
+
supportsIdempotency: true,
|
|
370
471
|
async connect(_config) {
|
|
371
472
|
connected = true;
|
|
372
473
|
logger3.info("Console provider connected");
|
|
@@ -408,6 +509,7 @@ function createSmtpProvider() {
|
|
|
408
509
|
let smtpConfig = null;
|
|
409
510
|
return {
|
|
410
511
|
name: "smtp",
|
|
512
|
+
supportsIdempotency: false,
|
|
411
513
|
async connect(config) {
|
|
412
514
|
if (config.type !== "smtp") {
|
|
413
515
|
return err(
|
|
@@ -468,7 +570,92 @@ function createSmtpProvider() {
|
|
|
468
570
|
}
|
|
469
571
|
};
|
|
470
572
|
}
|
|
471
|
-
var logger5 = core.logger.child({ module: "reach", scope: "
|
|
573
|
+
var logger5 = core.logger.child({ module: "reach", scope: "delivery" });
|
|
574
|
+
var DEFAULT_LEASE_MS = 10 * 60 * 1e3;
|
|
575
|
+
async function deliverClaimedMessage(row, owner, provider, repo, leaseMs = DEFAULT_LEASE_MS) {
|
|
576
|
+
const retrySafe = provider.supportsIdempotency;
|
|
577
|
+
if (!row.queueId || row.retrySafe && !retrySafe) {
|
|
578
|
+
await repo.markUnknown(row.id, owner);
|
|
579
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"));
|
|
580
|
+
}
|
|
581
|
+
if (!provider.isConnected()) {
|
|
582
|
+
await repo.failClaim(row, owner, "provider_unavailable");
|
|
583
|
+
return err(HaiReachError.NOT_INITIALIZED, reachM("reach_notInitialized"));
|
|
584
|
+
}
|
|
585
|
+
let message;
|
|
586
|
+
try {
|
|
587
|
+
message = {
|
|
588
|
+
provider: row.provider,
|
|
589
|
+
to: row.toAddr,
|
|
590
|
+
subject: row.subject ?? void 0,
|
|
591
|
+
body: row.body ?? void 0,
|
|
592
|
+
template: row.template ?? void 0,
|
|
593
|
+
vars: row.varsJson ? JSON.parse(row.varsJson) : void 0,
|
|
594
|
+
extra: row.extraJson ? JSON.parse(row.extraJson) : void 0,
|
|
595
|
+
idempotencyKey: row.queueId
|
|
596
|
+
};
|
|
597
|
+
} catch (cause) {
|
|
598
|
+
await repo.failClaim(row, owner, "invalid_message", true);
|
|
599
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"), cause);
|
|
600
|
+
}
|
|
601
|
+
const started = await repo.beginDelivery(row.id, owner, retrySafe, leaseMs);
|
|
602
|
+
if (!started.success)
|
|
603
|
+
return started;
|
|
604
|
+
let stopped = false;
|
|
605
|
+
let lost = false;
|
|
606
|
+
let renewing;
|
|
607
|
+
let timer;
|
|
608
|
+
const renew = async () => {
|
|
609
|
+
try {
|
|
610
|
+
const result = await repo.renewClaim(row.id, owner, leaseMs);
|
|
611
|
+
lost = !result.success;
|
|
612
|
+
} catch {
|
|
613
|
+
lost = true;
|
|
614
|
+
}
|
|
615
|
+
if (!stopped && !lost) {
|
|
616
|
+
timer = setTimeout(() => {
|
|
617
|
+
renewing = renew();
|
|
618
|
+
}, Math.max(1, Math.floor(leaseMs / 3)));
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
timer = setTimeout(() => {
|
|
622
|
+
renewing = renew();
|
|
623
|
+
}, Math.max(1, Math.floor(leaseMs / 3)));
|
|
624
|
+
let outcome;
|
|
625
|
+
try {
|
|
626
|
+
outcome = await provider.send(message);
|
|
627
|
+
} catch (cause) {
|
|
628
|
+
outcome = err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), cause);
|
|
629
|
+
} finally {
|
|
630
|
+
stopped = true;
|
|
631
|
+
clearTimeout(timer);
|
|
632
|
+
await renewing;
|
|
633
|
+
}
|
|
634
|
+
if (lost) {
|
|
635
|
+
if (!retrySafe)
|
|
636
|
+
await repo.markUnknown(row.id, owner);
|
|
637
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_claimLost"));
|
|
638
|
+
}
|
|
639
|
+
if (outcome.success && outcome.data.success) {
|
|
640
|
+
const marked = await repo.markSent(row.id, owner, outcome.data.messageId);
|
|
641
|
+
if (!marked.success && !retrySafe)
|
|
642
|
+
await repo.markUnknown(row.id, owner);
|
|
643
|
+
return marked;
|
|
644
|
+
}
|
|
645
|
+
if (!retrySafe) {
|
|
646
|
+
await repo.markUnknown(row.id, owner);
|
|
647
|
+
logger5.warn("Delivery outcome is unknown; automatic redelivery stopped", { queueId: row.queueId, provider: row.provider });
|
|
648
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), outcome.success ? void 0 : outcome.error);
|
|
649
|
+
}
|
|
650
|
+
const permanent = !outcome.success && [HaiReachError.DELIVERY_REJECTED.code, HaiReachError.INVALID_RECIPIENT.code].includes(outcome.error.code);
|
|
651
|
+
const released = await repo.failClaim(row, owner, permanent ? "permanent_rejection" : "transient_failure", permanent);
|
|
652
|
+
if (!released.success)
|
|
653
|
+
return released;
|
|
654
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), outcome.success ? void 0 : outcome.error);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/reach-send.ts
|
|
658
|
+
var logger6 = core.logger.child({ module: "reach", scope: "send" });
|
|
472
659
|
var FLUSH_LOCK_KEY = "hai:reach:flush-pending";
|
|
473
660
|
var FLUSH_LOCK_TTL = 300;
|
|
474
661
|
var FLUSH_BATCH_SIZE = 100;
|
|
@@ -520,12 +707,10 @@ async function preprocessMessage(message, templateRegistry2) {
|
|
|
520
707
|
};
|
|
521
708
|
return ok(processed);
|
|
522
709
|
}
|
|
523
|
-
async function saveSendRecord(repo, message, status, provider, messageId) {
|
|
524
|
-
if (!repo) {
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
710
|
+
async function saveSendRecord(repo, message, status, provider, messageId, queueId, queueConfig) {
|
|
527
711
|
try {
|
|
528
|
-
await repo.create({
|
|
712
|
+
const inserted = await repo.create({
|
|
713
|
+
queueId: queueId ?? null,
|
|
529
714
|
provider,
|
|
530
715
|
toAddr: message.to,
|
|
531
716
|
subject: message.subject ?? null,
|
|
@@ -534,11 +719,17 @@ async function saveSendRecord(repo, message, status, provider, messageId) {
|
|
|
534
719
|
varsJson: message.vars ? JSON.stringify(message.vars) : null,
|
|
535
720
|
extraJson: message.extra ? JSON.stringify(message.extra) : null,
|
|
536
721
|
status,
|
|
722
|
+
...queueConfig,
|
|
537
723
|
messageId: messageId ?? null,
|
|
538
724
|
createdAt: Date.now()
|
|
539
725
|
});
|
|
540
|
-
|
|
541
|
-
|
|
726
|
+
if (!inserted.success)
|
|
727
|
+
return inserted;
|
|
728
|
+
if (inserted.data.changes !== 1)
|
|
729
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"));
|
|
730
|
+
return ok(void 0);
|
|
731
|
+
} catch (cause) {
|
|
732
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_sendRecordSaveFailed"), cause);
|
|
542
733
|
}
|
|
543
734
|
}
|
|
544
735
|
var dndTimer = null;
|
|
@@ -549,11 +740,14 @@ function startDndScheduler(dndConfig2, providers2, repo) {
|
|
|
549
740
|
return;
|
|
550
741
|
}
|
|
551
742
|
schedulerContext = { dndConfig: dndConfig2, providers: providers2, repo };
|
|
743
|
+
const context = schedulerContext;
|
|
552
744
|
if (!isDndBlocked(dndConfig2)) {
|
|
553
745
|
flushPendingMessages(providers2, repo).catch((error) => {
|
|
554
|
-
|
|
746
|
+
logger6.warn("Failed to flush pending messages on init", { error });
|
|
747
|
+
}).finally(() => {
|
|
748
|
+
if (schedulerContext === context)
|
|
749
|
+
scheduleDndCheck();
|
|
555
750
|
});
|
|
556
|
-
scheduleDndCheck();
|
|
557
751
|
return;
|
|
558
752
|
}
|
|
559
753
|
scheduleFlushAtDndEnd();
|
|
@@ -561,30 +755,38 @@ function startDndScheduler(dndConfig2, providers2, repo) {
|
|
|
561
755
|
function scheduleFlushAtDndEnd() {
|
|
562
756
|
if (!schedulerContext)
|
|
563
757
|
return;
|
|
564
|
-
const
|
|
758
|
+
const context = schedulerContext;
|
|
759
|
+
const { dndConfig: dndConfig2, providers: providers2, repo } = context;
|
|
565
760
|
const delayMs = msUntilDndEnd(dndConfig2);
|
|
566
|
-
|
|
761
|
+
logger6.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
|
|
567
762
|
dndTimer = setTimeout(() => {
|
|
568
|
-
|
|
763
|
+
if (schedulerContext !== context)
|
|
764
|
+
return;
|
|
765
|
+
logger6.info("DND period ended, flushing pending messages");
|
|
569
766
|
flushPendingMessages(providers2, repo).catch((error) => {
|
|
570
|
-
|
|
767
|
+
logger6.error("Failed to flush pending messages", { error });
|
|
571
768
|
}).finally(() => {
|
|
572
|
-
|
|
769
|
+
if (schedulerContext === context)
|
|
770
|
+
scheduleDndCheck();
|
|
573
771
|
});
|
|
574
772
|
}, delayMs);
|
|
575
773
|
}
|
|
576
774
|
function scheduleDndCheck() {
|
|
577
775
|
if (!schedulerContext)
|
|
578
776
|
return;
|
|
579
|
-
const
|
|
777
|
+
const context = schedulerContext;
|
|
778
|
+
const { dndConfig: dndConfig2, providers: providers2, repo } = context;
|
|
580
779
|
const CHECK_INTERVAL = 60 * 1e3;
|
|
581
780
|
dndTimer = setTimeout(() => {
|
|
582
|
-
if (
|
|
781
|
+
if (schedulerContext !== context)
|
|
583
782
|
return;
|
|
584
783
|
if (isDndBlocked(dndConfig2)) {
|
|
585
784
|
scheduleFlushAtDndEnd();
|
|
586
785
|
} else {
|
|
587
|
-
|
|
786
|
+
flushPendingMessages(providers2, repo).catch((error) => logger6.warn("Failed to recover pending messages", { error })).finally(() => {
|
|
787
|
+
if (schedulerContext === context)
|
|
788
|
+
scheduleDndCheck();
|
|
789
|
+
});
|
|
588
790
|
}
|
|
589
791
|
}, CHECK_INTERVAL);
|
|
590
792
|
}
|
|
@@ -602,94 +804,59 @@ async function flushPendingMessages(providers2, repo) {
|
|
|
602
804
|
if (!repo) {
|
|
603
805
|
return;
|
|
604
806
|
}
|
|
605
|
-
let
|
|
606
|
-
const flushOwner = `${reachNodeId}:${
|
|
807
|
+
let lockOwner = null;
|
|
808
|
+
const flushOwner = `${reachNodeId}:${crypto.randomUUID()}`;
|
|
607
809
|
if (cache.isInitialized) {
|
|
608
|
-
const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL
|
|
810
|
+
const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL });
|
|
609
811
|
if (!lockResult.success) {
|
|
610
|
-
|
|
812
|
+
logger6.warn("Failed to acquire flush lock, continuing with row claims only", { error: lockResult.error.message });
|
|
611
813
|
} else if (!lockResult.data) {
|
|
612
|
-
|
|
814
|
+
logger6.info("Skipping flush, another node holds the lock");
|
|
613
815
|
return;
|
|
614
816
|
} else {
|
|
615
|
-
|
|
817
|
+
lockOwner = lockResult.data;
|
|
616
818
|
}
|
|
617
819
|
}
|
|
618
820
|
try {
|
|
619
|
-
|
|
821
|
+
const eligibleBefore = Date.now();
|
|
822
|
+
for (let processed = 0; processed < FLUSH_BATCH_SIZE; processed += 1) {
|
|
620
823
|
const claimResult = await repo.claimPendingBatch(flushOwner, {
|
|
621
|
-
limit:
|
|
622
|
-
lockMs: FLUSH_CLAIM_LOCK_MS
|
|
824
|
+
limit: 1,
|
|
825
|
+
lockMs: FLUSH_CLAIM_LOCK_MS,
|
|
826
|
+
eligibleBefore
|
|
623
827
|
});
|
|
624
828
|
if (!claimResult.success) {
|
|
625
|
-
|
|
829
|
+
logger6.warn("Failed to claim pending messages", { error: claimResult.error.message });
|
|
626
830
|
return;
|
|
627
831
|
}
|
|
628
832
|
if (claimResult.data.length === 0) {
|
|
629
833
|
return;
|
|
630
834
|
}
|
|
631
|
-
|
|
835
|
+
logger6.debug("Flushing pending messages", { count: claimResult.data.length });
|
|
632
836
|
for (const row of claimResult.data) {
|
|
633
837
|
const provider = providers2.get(row.provider);
|
|
634
838
|
if (!provider) {
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
if (!
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
let vars;
|
|
643
|
-
let extra;
|
|
644
|
-
try {
|
|
645
|
-
vars = row.varsJson ? JSON.parse(row.varsJson) : void 0;
|
|
646
|
-
extra = row.extraJson ? JSON.parse(row.extraJson) : void 0;
|
|
647
|
-
} catch {
|
|
648
|
-
logger5.warn("Failed to parse pending message JSON, releasing claim", { id: row.id });
|
|
649
|
-
const releaseResult2 = await repo.releaseClaim(row.id, flushOwner);
|
|
650
|
-
if (!releaseResult2.success) {
|
|
651
|
-
logger5.warn("Failed to release pending message claim", { id: row.id, error: releaseResult2.error.message });
|
|
839
|
+
logger6.warn("Provider not found for pending message, releasing claim", { provider: row.provider, id: row.id });
|
|
840
|
+
const releaseResult = await repo.failClaim(row, flushOwner, "provider_unavailable");
|
|
841
|
+
if (!releaseResult.success) {
|
|
842
|
+
logger6.warn("Failed to release pending message claim", { id: row.id, error: releaseResult.error.message });
|
|
652
843
|
}
|
|
653
844
|
continue;
|
|
654
845
|
}
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
subject: row.subject ?? void 0,
|
|
659
|
-
body: row.body ?? void 0,
|
|
660
|
-
template: row.template ?? void 0,
|
|
661
|
-
vars,
|
|
662
|
-
extra
|
|
663
|
-
};
|
|
664
|
-
const sendResult = await provider.send(message);
|
|
665
|
-
if (sendResult.success) {
|
|
666
|
-
const markResult = await repo.markSent(row.id, sendResult.data.messageId, void 0, flushOwner);
|
|
667
|
-
if (!markResult.success) {
|
|
668
|
-
logger5.warn("Failed to mark pending message as sent", { id: row.id, error: markResult.error.message });
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
logger5.debug("Pending message sent", { id: row.id, to: row.toAddr, provider: row.provider });
|
|
672
|
-
continue;
|
|
673
|
-
}
|
|
674
|
-
const releaseResult = await repo.releaseClaim(row.id, flushOwner);
|
|
675
|
-
if (!releaseResult.success) {
|
|
676
|
-
logger5.warn("Failed to release pending message claim after send failure", { id: row.id, error: releaseResult.error.message });
|
|
677
|
-
}
|
|
678
|
-
logger5.warn("Pending message send failed", { id: row.id, to: row.toAddr, error: sendResult.error.code });
|
|
679
|
-
}
|
|
680
|
-
if (claimResult.data.length < FLUSH_BATCH_SIZE) {
|
|
681
|
-
return;
|
|
846
|
+
const sent = await deliverClaimedMessage(row, flushOwner, provider, repo, FLUSH_CLAIM_LOCK_MS);
|
|
847
|
+
if (!sent.success)
|
|
848
|
+
logger6.warn("Pending message delivery did not commit", { id: row.id, code: sent.error.code });
|
|
682
849
|
}
|
|
683
850
|
}
|
|
684
851
|
} finally {
|
|
685
|
-
if (
|
|
686
|
-
await cache.lock.release(FLUSH_LOCK_KEY,
|
|
687
|
-
|
|
852
|
+
if (lockOwner) {
|
|
853
|
+
await cache.lock.release(FLUSH_LOCK_KEY, lockOwner).catch((error) => {
|
|
854
|
+
logger6.warn("Failed to release flush lock", { error });
|
|
688
855
|
});
|
|
689
856
|
}
|
|
690
857
|
}
|
|
691
858
|
}
|
|
692
|
-
async function executeSend(message, providers2, templateRegistry2, dndConfig2, repo) {
|
|
859
|
+
async function executeSend(message, providers2, templateRegistry2, dndConfig2, repo, queueConfig = ReachQueueConfigSchema.parse({})) {
|
|
693
860
|
if (!message.to) {
|
|
694
861
|
return err(
|
|
695
862
|
HaiReachError.INVALID_RECIPIENT,
|
|
@@ -716,43 +883,48 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
|
|
|
716
883
|
if (isDndBlocked(dndConfig2)) {
|
|
717
884
|
const strategy = dndConfig2?.strategy ?? "discard";
|
|
718
885
|
if (strategy === "delay") {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
886
|
+
if (!repo)
|
|
887
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_dndPersistenceRequired"));
|
|
888
|
+
const queueId = crypto.randomUUID();
|
|
889
|
+
const saved = await saveSendRecord(repo, preprocessed.data, "pending", providerName, void 0, queueId, queueConfig);
|
|
890
|
+
if (!saved.success) {
|
|
891
|
+
logger6.warn("Failed to persist deferred message", { provider: providerName, code: saved.error.code });
|
|
724
892
|
return err(
|
|
725
893
|
HaiReachError.SEND_FAILED,
|
|
726
|
-
reachM("
|
|
727
|
-
error
|
|
894
|
+
reachM("reach_dndEnqueueFailed"),
|
|
895
|
+
saved.error
|
|
728
896
|
);
|
|
729
897
|
}
|
|
730
|
-
|
|
898
|
+
logger6.info("Message persisted for DND delivery", { provider: providerName, queueId });
|
|
899
|
+
return ok({ success: true, deferred: true, queueId });
|
|
731
900
|
}
|
|
732
|
-
|
|
901
|
+
logger6.info("Message blocked by DND (discard strategy)", { provider: providerName, to: message.to });
|
|
733
902
|
return err(
|
|
734
903
|
HaiReachError.DND_BLOCKED,
|
|
735
904
|
reachM("reach_dndBlocked")
|
|
736
905
|
);
|
|
737
906
|
}
|
|
738
907
|
const provider = providers2.get(providerName);
|
|
739
|
-
|
|
908
|
+
logger6.debug("Sending message", {
|
|
740
909
|
provider: providerName,
|
|
741
910
|
to: preprocessed.data.to,
|
|
742
911
|
template: message.template
|
|
743
912
|
});
|
|
744
913
|
const result = await provider.send(preprocessed.data);
|
|
745
914
|
if (!result.success) {
|
|
746
|
-
|
|
915
|
+
logger6.warn("Message send failed", {
|
|
747
916
|
provider: providerName,
|
|
748
917
|
to: message.to,
|
|
749
918
|
error: result.error.code
|
|
750
919
|
});
|
|
751
920
|
return result;
|
|
752
921
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
922
|
+
if (repo) {
|
|
923
|
+
void saveSendRecord(repo, preprocessed.data, "sent", providerName, result.data.messageId).then((saved) => {
|
|
924
|
+
if (!saved.success)
|
|
925
|
+
logger6.warn("Failed to persist sent message log", { provider: providerName, code: saved.error.code });
|
|
926
|
+
});
|
|
927
|
+
}
|
|
756
928
|
return result;
|
|
757
929
|
}
|
|
758
930
|
function renderString(template, vars) {
|
|
@@ -850,7 +1022,10 @@ function createTemplateRegistry(repo) {
|
|
|
850
1022
|
var TABLE_NAME = "hai_reach_send_log";
|
|
851
1023
|
var DEFAULT_CLAIM_BATCH_SIZE = 100;
|
|
852
1024
|
var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
|
|
853
|
-
var CLAIMABLE_STATUS_WHERE = "(status = ? OR (status = ? AND (locked_until IS NULL OR locked_until < ?)))";
|
|
1025
|
+
var CLAIMABLE_STATUS_WHERE = "(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ?)) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ?)))";
|
|
1026
|
+
function claimableParams(now, eligibleBefore = now) {
|
|
1027
|
+
return ["pending", eligibleBefore, "processing", eligibleBefore, now, "sending", eligibleBefore, now];
|
|
1028
|
+
}
|
|
854
1029
|
function buildSendLogError(detail, cause) {
|
|
855
1030
|
return err(
|
|
856
1031
|
HaiReachError.SEND_FAILED,
|
|
@@ -858,7 +1033,69 @@ function buildSendLogError(detail, cause) {
|
|
|
858
1033
|
cause
|
|
859
1034
|
);
|
|
860
1035
|
}
|
|
1036
|
+
function normalizeSendLog(row) {
|
|
1037
|
+
const normalized = { ...row };
|
|
1038
|
+
for (const field of ["id", "createdAt", "updatedAt", "lockedUntil", "nextAttemptAt", "attemptCount", "maxAttempts", "retryDelayMs", "maxRetryDelayMs"]) {
|
|
1039
|
+
const value = row[field];
|
|
1040
|
+
if (value === void 0 && (field === "updatedAt" || field === "lockedUntil"))
|
|
1041
|
+
continue;
|
|
1042
|
+
const validType = typeof value === "number" || typeof value === "bigint" || typeof value === "string" && /^\d+$/.test(value);
|
|
1043
|
+
const integer = validType ? Number(value) : Number.NaN;
|
|
1044
|
+
if (!Number.isSafeInteger(integer) || integer < 0)
|
|
1045
|
+
return buildSendLogError(reachM("reach_invalidStoredInteger", { params: { field } }));
|
|
1046
|
+
Object.assign(normalized, { [field]: integer });
|
|
1047
|
+
}
|
|
1048
|
+
return ok(normalized);
|
|
1049
|
+
}
|
|
1050
|
+
function normalizeSendLogs(rows) {
|
|
1051
|
+
const normalized = [];
|
|
1052
|
+
for (const row of rows) {
|
|
1053
|
+
const result = normalizeSendLog(row);
|
|
1054
|
+
if (!result.success)
|
|
1055
|
+
return result;
|
|
1056
|
+
normalized.push(result.data);
|
|
1057
|
+
}
|
|
1058
|
+
return ok(normalized);
|
|
1059
|
+
}
|
|
861
1060
|
var SEND_LOG_FIELDS = [
|
|
1061
|
+
...[
|
|
1062
|
+
{ fieldName: "attemptCount", columnName: "attempt_count", value: 0 },
|
|
1063
|
+
{ fieldName: "maxAttempts", columnName: "max_attempts", value: 5 },
|
|
1064
|
+
{ fieldName: "retryDelayMs", columnName: "retry_delay_ms", value: 6e4 },
|
|
1065
|
+
{ fieldName: "maxRetryDelayMs", columnName: "max_retry_delay_ms", value: 36e5 },
|
|
1066
|
+
{ fieldName: "nextAttemptAt", columnName: "next_attempt_at", value: 0 }
|
|
1067
|
+
].map(({ fieldName, columnName, value }) => ({
|
|
1068
|
+
fieldName,
|
|
1069
|
+
columnName,
|
|
1070
|
+
def: { type: "BIGINT", notNull: true, defaultValue: value },
|
|
1071
|
+
select: true,
|
|
1072
|
+
create: true,
|
|
1073
|
+
update: true
|
|
1074
|
+
})),
|
|
1075
|
+
{
|
|
1076
|
+
fieldName: "lastFailure",
|
|
1077
|
+
columnName: "last_failure",
|
|
1078
|
+
def: { type: "TEXT" },
|
|
1079
|
+
select: true,
|
|
1080
|
+
create: true,
|
|
1081
|
+
update: true
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
fieldName: "retrySafe",
|
|
1085
|
+
columnName: "retry_safe",
|
|
1086
|
+
def: { type: "BOOLEAN", notNull: true, defaultValue: false },
|
|
1087
|
+
select: true,
|
|
1088
|
+
create: true,
|
|
1089
|
+
update: true
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
fieldName: "queueId",
|
|
1093
|
+
columnName: "queue_id",
|
|
1094
|
+
def: { type: "TEXT", unique: true },
|
|
1095
|
+
select: true,
|
|
1096
|
+
create: true,
|
|
1097
|
+
update: false
|
|
1098
|
+
},
|
|
862
1099
|
{
|
|
863
1100
|
fieldName: "id",
|
|
864
1101
|
columnName: "id",
|
|
@@ -950,7 +1187,7 @@ var SEND_LOG_FIELDS = [
|
|
|
950
1187
|
{
|
|
951
1188
|
fieldName: "lockedUntil",
|
|
952
1189
|
columnName: "locked_until",
|
|
953
|
-
def: { type: "
|
|
1190
|
+
def: { type: "BIGINT" },
|
|
954
1191
|
select: true,
|
|
955
1192
|
create: true,
|
|
956
1193
|
update: true
|
|
@@ -958,7 +1195,7 @@ var SEND_LOG_FIELDS = [
|
|
|
958
1195
|
{
|
|
959
1196
|
fieldName: "createdAt",
|
|
960
1197
|
columnName: "created_at",
|
|
961
|
-
def: { type: "
|
|
1198
|
+
def: { type: "BIGINT", notNull: true },
|
|
962
1199
|
select: true,
|
|
963
1200
|
create: true,
|
|
964
1201
|
update: false
|
|
@@ -966,7 +1203,7 @@ var SEND_LOG_FIELDS = [
|
|
|
966
1203
|
{
|
|
967
1204
|
fieldName: "updatedAt",
|
|
968
1205
|
columnName: "updated_at",
|
|
969
|
-
def: { type: "
|
|
1206
|
+
def: { type: "BIGINT", notNull: true },
|
|
970
1207
|
select: true,
|
|
971
1208
|
create: true,
|
|
972
1209
|
update: true
|
|
@@ -1004,6 +1241,31 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1004
1241
|
createTableIfNotExists: true
|
|
1005
1242
|
});
|
|
1006
1243
|
}
|
|
1244
|
+
/** 单条可空查询统一返回经过校验的毫秒 number。 */
|
|
1245
|
+
async findById(id, tx) {
|
|
1246
|
+
const result = await super.findById(id, tx);
|
|
1247
|
+
if (!result.success || result.data === null)
|
|
1248
|
+
return result;
|
|
1249
|
+
return normalizeSendLog(result.data);
|
|
1250
|
+
}
|
|
1251
|
+
/** 单条必需查询统一返回经过校验的毫秒 number。 */
|
|
1252
|
+
async getById(id, tx) {
|
|
1253
|
+
const result = await super.getById(id, tx);
|
|
1254
|
+
return result.success ? normalizeSendLog(result.data) : result;
|
|
1255
|
+
}
|
|
1256
|
+
/** 普通列表与 claim 回读共用同一 BIGINT 转换边界。 */
|
|
1257
|
+
async findAll(options, tx) {
|
|
1258
|
+
const result = await super.findAll(options, tx);
|
|
1259
|
+
return result.success ? normalizeSendLogs(result.data) : result;
|
|
1260
|
+
}
|
|
1261
|
+
/** 分页结果同样拒绝不安全的时间值,不返回经过舍入的毫秒数。 */
|
|
1262
|
+
async findPage(options, tx) {
|
|
1263
|
+
const result = await super.findPage(options, tx);
|
|
1264
|
+
if (!result.success)
|
|
1265
|
+
return result;
|
|
1266
|
+
const rows = normalizeSendLogs(result.data.items);
|
|
1267
|
+
return rows.success ? ok({ ...result.data, items: rows.data }) : rows;
|
|
1268
|
+
}
|
|
1007
1269
|
/** 获取所有待发送记录 */
|
|
1008
1270
|
async findPending(tx) {
|
|
1009
1271
|
const result = await this.findAll({ where: "status = ?", params: ["pending"], orderBy: "created_at ASC" }, tx);
|
|
@@ -1020,7 +1282,7 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1020
1282
|
* - 并发事务会直接跳过这些行,而不是重复命中同一批记录
|
|
1021
1283
|
* - 在 commit 前不会暴露给其它节点重复 claim
|
|
1022
1284
|
*/
|
|
1023
|
-
async claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit) {
|
|
1285
|
+
async claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit, eligibleBefore) {
|
|
1024
1286
|
const lockedRowsResult = await tx.query(
|
|
1025
1287
|
`SELECT id
|
|
1026
1288
|
FROM ${TABLE_NAME}
|
|
@@ -1028,14 +1290,14 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1028
1290
|
ORDER BY created_at ASC
|
|
1029
1291
|
LIMIT ?
|
|
1030
1292
|
FOR UPDATE SKIP LOCKED`,
|
|
1031
|
-
[
|
|
1293
|
+
[...claimableParams(now, eligibleBefore), limit]
|
|
1032
1294
|
);
|
|
1033
1295
|
if (!lockedRowsResult.success) {
|
|
1034
1296
|
throw lockedRowsResult.error;
|
|
1035
1297
|
}
|
|
1036
1298
|
const claimedIds = lockedRowsResult.data.map((row) => {
|
|
1037
1299
|
const id = typeof row.id === "number" ? row.id : Number(row.id);
|
|
1038
|
-
if (!Number.
|
|
1300
|
+
if (!Number.isSafeInteger(id)) {
|
|
1039
1301
|
throw new TypeError(`Invalid send log id returned when claiming pending batch: ${String(row.id)}`);
|
|
1040
1302
|
}
|
|
1041
1303
|
return id;
|
|
@@ -1046,7 +1308,7 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1046
1308
|
const placeholders = claimedIds.map(() => "?").join(", ");
|
|
1047
1309
|
const updateResult = await tx.execute(
|
|
1048
1310
|
`UPDATE ${TABLE_NAME}
|
|
1049
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1311
|
+
SET status = ?, processing_owner = ?, locked_until = ?, updated_at = ?, attempt_count = attempt_count + 1
|
|
1050
1312
|
WHERE id IN (${placeholders})`,
|
|
1051
1313
|
["processing", owner, lockedUntil, now, ...claimedIds]
|
|
1052
1314
|
);
|
|
@@ -1062,10 +1324,10 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1062
1324
|
* 即使多个节点读到了同一候选,只有第一个满足条件的 UPDATE 会成功,
|
|
1063
1325
|
* 从而避免同一行被两个节点同时 claim。
|
|
1064
1326
|
*/
|
|
1065
|
-
async claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit) {
|
|
1327
|
+
async claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit, eligibleBefore) {
|
|
1066
1328
|
const candidatesResult = await this.findAll({
|
|
1067
1329
|
where: CLAIMABLE_STATUS_WHERE,
|
|
1068
|
-
params:
|
|
1330
|
+
params: claimableParams(now, eligibleBefore),
|
|
1069
1331
|
orderBy: "created_at ASC",
|
|
1070
1332
|
limit
|
|
1071
1333
|
}, tx);
|
|
@@ -1076,9 +1338,9 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1076
1338
|
for (const row of candidatesResult.data) {
|
|
1077
1339
|
const updateResult = await tx.execute(
|
|
1078
1340
|
`UPDATE ${TABLE_NAME}
|
|
1079
|
-
SET status = ?, processing_owner = ?, locked_until = ?, updated_at =
|
|
1341
|
+
SET status = ?, processing_owner = ?, locked_until = ?, updated_at = ?, attempt_count = attempt_count + 1
|
|
1080
1342
|
WHERE id = ? AND ${CLAIMABLE_STATUS_WHERE}`,
|
|
1081
|
-
["processing", owner, lockedUntil, now, row.id,
|
|
1343
|
+
["processing", owner, lockedUntil, now, row.id, ...claimableParams(now, eligibleBefore)]
|
|
1082
1344
|
);
|
|
1083
1345
|
if (!updateResult.success) {
|
|
1084
1346
|
throw updateResult.error;
|
|
@@ -1108,6 +1370,7 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1108
1370
|
const limit = options?.limit ?? DEFAULT_CLAIM_BATCH_SIZE;
|
|
1109
1371
|
const lockMs = options?.lockMs ?? DEFAULT_CLAIM_LOCK_MS;
|
|
1110
1372
|
const lockedUntil = now + lockMs;
|
|
1373
|
+
const eligibleBefore = Math.min(now, options?.eligibleBefore ?? now);
|
|
1111
1374
|
const dbType = this.db.config?.type;
|
|
1112
1375
|
const txResult = await this.db.tx.begin();
|
|
1113
1376
|
if (!txResult.success) {
|
|
@@ -1115,7 +1378,24 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1115
1378
|
}
|
|
1116
1379
|
const tx = txResult.data;
|
|
1117
1380
|
try {
|
|
1118
|
-
const
|
|
1381
|
+
const unknown = await tx.execute(
|
|
1382
|
+
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
|
|
1383
|
+
WHERE status = ? AND retry_safe = FALSE AND (locked_until IS NULL OR locked_until <= ?)`,
|
|
1384
|
+
["unknown", now, "sending", now]
|
|
1385
|
+
);
|
|
1386
|
+
if (!unknown.success)
|
|
1387
|
+
throw unknown.error;
|
|
1388
|
+
const exhausted = await tx.execute(
|
|
1389
|
+
`UPDATE ${TABLE_NAME}
|
|
1390
|
+
SET status = ?, last_failure = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
|
|
1391
|
+
WHERE attempt_count >= max_attempts
|
|
1392
|
+
AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ?))
|
|
1393
|
+
OR (status = ? AND retry_safe = TRUE AND locked_until <= ?))`,
|
|
1394
|
+
["dead", "attempts_exhausted", now, "pending", "processing", now, "sending", now]
|
|
1395
|
+
);
|
|
1396
|
+
if (!exhausted.success)
|
|
1397
|
+
throw exhausted.error;
|
|
1398
|
+
const claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit, eligibleBefore) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit, eligibleBefore);
|
|
1119
1399
|
if (claimedIds.length === 0) {
|
|
1120
1400
|
const commitResult2 = await tx.commit();
|
|
1121
1401
|
if (!commitResult2.success) {
|
|
@@ -1134,43 +1414,95 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1134
1414
|
return buildSendLogError(error instanceof Error ? error.message : String(error), error);
|
|
1135
1415
|
}
|
|
1136
1416
|
}
|
|
1137
|
-
/**
|
|
1417
|
+
/** 显式取消未完成的领取;仍按失败退避,避免调用方绕过自动重试预算。 */
|
|
1138
1418
|
async releaseClaim(id, owner, tx) {
|
|
1419
|
+
const row = await this.getById(id, tx);
|
|
1420
|
+
if (!row.success)
|
|
1421
|
+
return row;
|
|
1422
|
+
return this.failClaim(row.data, owner, "transient_failure", false, tx);
|
|
1423
|
+
}
|
|
1424
|
+
/** 失败提交与 owner、次数和有效租约同时比较,旧 worker 不能更改新领取的退避策略。 */
|
|
1425
|
+
async failClaim(row, owner, reason, permanent = false, tx) {
|
|
1139
1426
|
const now = Date.now();
|
|
1427
|
+
const dead = permanent || row.attemptCount >= row.maxAttempts;
|
|
1428
|
+
const delay = Math.min(row.maxRetryDelayMs, row.retryDelayMs * 2 ** Math.min(row.attemptCount - 1, 30));
|
|
1140
1429
|
const result = await this.sql(tx).execute(
|
|
1141
1430
|
`UPDATE ${TABLE_NAME}
|
|
1142
|
-
SET status = ?, processing_owner = NULL, locked_until = NULL, updated_at = ?
|
|
1143
|
-
WHERE id = ? AND
|
|
1144
|
-
|
|
1431
|
+
SET status = ?, last_failure = ?, next_attempt_at = ?, processing_owner = NULL, locked_until = NULL, updated_at = ?
|
|
1432
|
+
WHERE id = ? AND processing_owner = ? AND locked_until > ? AND attempt_count = ?
|
|
1433
|
+
AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
|
|
1434
|
+
[dead ? "dead" : "pending", reason, dead ? 0 : now + delay, now, row.id, owner, now, row.attemptCount, "processing", "sending"]
|
|
1145
1435
|
);
|
|
1146
1436
|
if (!result.success) {
|
|
1147
1437
|
return buildSendLogError(result.error.message, result.error);
|
|
1148
1438
|
}
|
|
1149
1439
|
if (result.data.changes === 0) {
|
|
1150
|
-
return buildSendLogError(
|
|
1440
|
+
return buildSendLogError(reachM("reach_claimLost"));
|
|
1151
1441
|
}
|
|
1152
1442
|
return ok(void 0);
|
|
1153
1443
|
}
|
|
1154
|
-
/**
|
|
1155
|
-
async
|
|
1444
|
+
/** 仅当前 dead 版本可以恢复;保留 queueId,迟到的幂等 API 请求仍使用同一键。 */
|
|
1445
|
+
async requeueDead(queueId, expectedUpdatedAt) {
|
|
1446
|
+
const now = Math.max(Date.now(), expectedUpdatedAt + 1);
|
|
1447
|
+
const result = await this.sql().execute(
|
|
1448
|
+
`UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ?, last_failure = NULL, updated_at = ?
|
|
1449
|
+
WHERE queue_id = ? AND status = ? AND updated_at = ?`,
|
|
1450
|
+
["pending", now, now, queueId, "dead", expectedUpdatedAt]
|
|
1451
|
+
);
|
|
1452
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
|
|
1453
|
+
}
|
|
1454
|
+
/** 发送前持久化门闩;仅有效租约可从 processing 进入 sending。 */
|
|
1455
|
+
async beginDelivery(id, owner, retrySafe, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1456
|
+
const now = Date.now();
|
|
1457
|
+
const flag = this.db.config?.type === "postgresql" ? retrySafe : Number(retrySafe);
|
|
1458
|
+
const result = await this.sql().execute(
|
|
1459
|
+
`UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ?, updated_at = ?
|
|
1460
|
+
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ?`,
|
|
1461
|
+
["sending", flag, now + lockMs, now, id, "processing", owner, now]
|
|
1462
|
+
);
|
|
1463
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1464
|
+
}
|
|
1465
|
+
/** 续租不接受已经过期的 owner,防止暂停的旧进程恢复后重新取得发送权。 */
|
|
1466
|
+
async renewClaim(id, owner, lockMs = DEFAULT_CLAIM_LOCK_MS) {
|
|
1467
|
+
const now = Date.now();
|
|
1468
|
+
const result = await this.sql().execute(
|
|
1469
|
+
`UPDATE ${TABLE_NAME} SET locked_until = ?, updated_at = ?
|
|
1470
|
+
WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ?`,
|
|
1471
|
+
[now + lockMs, now, id, owner, "processing", "sending", now]
|
|
1472
|
+
);
|
|
1473
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1474
|
+
}
|
|
1475
|
+
/** 未知结果只能停止自动处理,不推断外部副作用已撤销。 */
|
|
1476
|
+
async markUnknown(id, owner) {
|
|
1477
|
+
const result = await this.sql().execute(
|
|
1478
|
+
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
|
|
1479
|
+
WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
|
|
1480
|
+
["unknown", Date.now(), id, owner, "processing", "sending"]
|
|
1481
|
+
);
|
|
1482
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1483
|
+
}
|
|
1484
|
+
/** 人工裁定的条件提交,不提供重新排队或重新发送操作。 */
|
|
1485
|
+
async resolveUnknown(queueId, resolution) {
|
|
1486
|
+
const result = await this.sql().execute(
|
|
1487
|
+
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ? WHERE queue_id = ? AND status = ?`,
|
|
1488
|
+
[resolution.status, resolution.messageId ?? null, Date.now(), queueId, "unknown"]
|
|
1489
|
+
);
|
|
1490
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
|
|
1491
|
+
}
|
|
1492
|
+
/** 只有仍持有 sending 有效租约的 owner 可以确认投递。 */
|
|
1493
|
+
async markSent(id, owner, messageId, tx) {
|
|
1156
1494
|
const now = Date.now();
|
|
1157
|
-
const params = ["sent", messageId ?? null, now, id];
|
|
1158
|
-
let whereClause = "id = ?";
|
|
1159
|
-
if (owner) {
|
|
1160
|
-
whereClause += " AND status = ? AND processing_owner = ?";
|
|
1161
|
-
params.push("processing", owner);
|
|
1162
|
-
}
|
|
1163
1495
|
const result = await this.sql(tx).execute(
|
|
1164
1496
|
`UPDATE ${TABLE_NAME}
|
|
1165
|
-
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, updated_at = ?
|
|
1166
|
-
WHERE
|
|
1167
|
-
|
|
1497
|
+
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ?
|
|
1498
|
+
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ?`,
|
|
1499
|
+
["sent", messageId ?? null, now, id, "sending", owner, now]
|
|
1168
1500
|
);
|
|
1169
1501
|
if (!result.success) {
|
|
1170
1502
|
return buildSendLogError(result.error.message, result.error);
|
|
1171
1503
|
}
|
|
1172
|
-
if (
|
|
1173
|
-
return buildSendLogError(
|
|
1504
|
+
if (result.data.changes === 0) {
|
|
1505
|
+
return buildSendLogError(reachM("reach_claimLost"));
|
|
1174
1506
|
}
|
|
1175
1507
|
return ok(void 0);
|
|
1176
1508
|
}
|
|
@@ -1347,7 +1679,7 @@ var DbTemplateRepository = class extends BaseReldbCrudRepository {
|
|
|
1347
1679
|
};
|
|
1348
1680
|
|
|
1349
1681
|
// src/reach-main.ts
|
|
1350
|
-
var
|
|
1682
|
+
var logger7 = core.logger.child({ module: "reach", scope: "main" });
|
|
1351
1683
|
var providers = /* @__PURE__ */ new Map();
|
|
1352
1684
|
var currentConfig = null;
|
|
1353
1685
|
var dndConfig;
|
|
@@ -1378,12 +1710,12 @@ async function tryInitSendLogRepo() {
|
|
|
1378
1710
|
}
|
|
1379
1711
|
const result = await createSendLogRepository(db);
|
|
1380
1712
|
if (!result.success) {
|
|
1381
|
-
|
|
1713
|
+
logger7.debug("Send log repository not initialized", { error: result.error.message });
|
|
1382
1714
|
return null;
|
|
1383
1715
|
}
|
|
1384
1716
|
return result.data;
|
|
1385
1717
|
} catch (error) {
|
|
1386
|
-
|
|
1718
|
+
logger7.debug("Send log repository not initialized", { error: error instanceof Error ? error.message : String(error) });
|
|
1387
1719
|
return null;
|
|
1388
1720
|
}
|
|
1389
1721
|
}
|
|
@@ -1395,12 +1727,12 @@ async function tryInitTemplateRepo() {
|
|
|
1395
1727
|
}
|
|
1396
1728
|
const result = await createTemplateRepository(db);
|
|
1397
1729
|
if (!result.success) {
|
|
1398
|
-
|
|
1730
|
+
logger7.debug("Template repository not initialized", { error: result.error.message });
|
|
1399
1731
|
return null;
|
|
1400
1732
|
}
|
|
1401
1733
|
return result.data;
|
|
1402
1734
|
} catch (error) {
|
|
1403
|
-
|
|
1735
|
+
logger7.debug("Template repository not initialized", { error: error instanceof Error ? error.message : String(error) });
|
|
1404
1736
|
return null;
|
|
1405
1737
|
}
|
|
1406
1738
|
}
|
|
@@ -1410,15 +1742,15 @@ var notInitialized = core.module.createNotInitializedKit(
|
|
|
1410
1742
|
);
|
|
1411
1743
|
async function doClose() {
|
|
1412
1744
|
if (providers.size === 0 && currentConfig === null) {
|
|
1413
|
-
|
|
1745
|
+
logger7.info("Reach module already closed, skipping");
|
|
1414
1746
|
return;
|
|
1415
1747
|
}
|
|
1416
|
-
|
|
1748
|
+
logger7.info("Closing reach module");
|
|
1417
1749
|
for (const [name, provider] of providers.entries()) {
|
|
1418
1750
|
try {
|
|
1419
1751
|
await provider.close();
|
|
1420
1752
|
} catch (error) {
|
|
1421
|
-
|
|
1753
|
+
logger7.error("Provider close failed", { name, error });
|
|
1422
1754
|
}
|
|
1423
1755
|
}
|
|
1424
1756
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -1430,17 +1762,17 @@ async function doClose() {
|
|
|
1430
1762
|
resetSendLogRepoSingleton();
|
|
1431
1763
|
resetTemplateRepoSingleton();
|
|
1432
1764
|
resetSendState();
|
|
1433
|
-
|
|
1765
|
+
logger7.info("Reach module closed");
|
|
1434
1766
|
}
|
|
1435
1767
|
async function doInit(config) {
|
|
1436
1768
|
if (providers.size > 0) {
|
|
1437
|
-
|
|
1769
|
+
logger7.warn("Reach module is already initialized, reinitializing");
|
|
1438
1770
|
await doClose();
|
|
1439
1771
|
}
|
|
1440
|
-
|
|
1772
|
+
logger7.info("Initializing reach module");
|
|
1441
1773
|
const parseResult = ReachConfigSchema.safeParse(config);
|
|
1442
1774
|
if (!parseResult.success) {
|
|
1443
|
-
|
|
1775
|
+
logger7.error("Reach config validation failed", { error: parseResult.error.message });
|
|
1444
1776
|
return err(
|
|
1445
1777
|
HaiReachError.CONFIG_ERROR,
|
|
1446
1778
|
reachM("reach_configError", { params: { error: parseResult.error.message } }),
|
|
@@ -1448,24 +1780,35 @@ async function doInit(config) {
|
|
|
1448
1780
|
);
|
|
1449
1781
|
}
|
|
1450
1782
|
const parsed = parseResult.data;
|
|
1783
|
+
if (core.module.requiresSharedState(process.env) && parsed.providers.some((provider) => provider.type === "console"))
|
|
1784
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_productionProviderRequired"));
|
|
1451
1785
|
try {
|
|
1786
|
+
let requiredQueue = null;
|
|
1787
|
+
if (parsed.dnd?.enabled && parsed.dnd.strategy === "delay") {
|
|
1788
|
+
if (!reldb.isInitialized)
|
|
1789
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
1790
|
+
const queue = await createSendLogRepository(reldb);
|
|
1791
|
+
if (!queue.success)
|
|
1792
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"), queue.error);
|
|
1793
|
+
requiredQueue = queue.data;
|
|
1794
|
+
}
|
|
1452
1795
|
const newProviders = /* @__PURE__ */ new Map();
|
|
1453
1796
|
for (const providerConfig of parsed.providers) {
|
|
1454
1797
|
const provider = createProvider(providerConfig);
|
|
1455
1798
|
const connectResult = await provider.connect(providerConfig);
|
|
1456
1799
|
if (!connectResult.success) {
|
|
1457
|
-
|
|
1800
|
+
logger7.error("Provider initialization failed", {
|
|
1458
1801
|
name: providerConfig.name,
|
|
1459
1802
|
type: providerConfig.type,
|
|
1460
1803
|
code: connectResult.error.code,
|
|
1461
1804
|
message: connectResult.error.message
|
|
1462
1805
|
});
|
|
1463
|
-
|
|
1806
|
+
logger7.info("Rolling back connected providers", { count: newProviders.size });
|
|
1464
1807
|
for (const [name, p] of newProviders.entries()) {
|
|
1465
1808
|
try {
|
|
1466
1809
|
await p.close();
|
|
1467
1810
|
} catch (rollbackError) {
|
|
1468
|
-
|
|
1811
|
+
logger7.error("Provider rollback close failed", { name, error: rollbackError });
|
|
1469
1812
|
}
|
|
1470
1813
|
}
|
|
1471
1814
|
return connectResult;
|
|
@@ -1480,18 +1823,18 @@ async function doInit(config) {
|
|
|
1480
1823
|
if (parsed.templates && templateRepo) {
|
|
1481
1824
|
const saveResult = await templateRegistry.saveBatch(parsed.templates);
|
|
1482
1825
|
if (!saveResult.success) {
|
|
1483
|
-
|
|
1826
|
+
logger7.warn("Failed to save config templates to database", { error: saveResult.error.message });
|
|
1484
1827
|
}
|
|
1485
1828
|
}
|
|
1486
|
-
sendLogRepo = await tryInitSendLogRepo();
|
|
1829
|
+
sendLogRepo = requiredQueue ?? await tryInitSendLogRepo();
|
|
1487
1830
|
if (dndConfig) {
|
|
1488
1831
|
startDndScheduler(dndConfig, providers, sendLogRepo);
|
|
1489
1832
|
}
|
|
1490
1833
|
const providerNames = parsed.providers.map((p) => p.name);
|
|
1491
|
-
|
|
1834
|
+
logger7.info("Reach module initialized", { providers: providerNames });
|
|
1492
1835
|
return ok(void 0);
|
|
1493
1836
|
} catch (error) {
|
|
1494
|
-
|
|
1837
|
+
logger7.error("Reach module initialization failed", { error });
|
|
1495
1838
|
return err(
|
|
1496
1839
|
HaiReachError.CONFIG_ERROR,
|
|
1497
1840
|
reachM("reach_initFailed", {
|
|
@@ -1501,10 +1844,75 @@ async function doInit(config) {
|
|
|
1501
1844
|
);
|
|
1502
1845
|
}
|
|
1503
1846
|
}
|
|
1847
|
+
function queueEntry(row) {
|
|
1848
|
+
return {
|
|
1849
|
+
queueId: row.queueId,
|
|
1850
|
+
provider: row.provider,
|
|
1851
|
+
status: row.status,
|
|
1852
|
+
updatedAt: row.updatedAt,
|
|
1853
|
+
messageId: row.messageId ?? void 0,
|
|
1854
|
+
attemptCount: row.attemptCount,
|
|
1855
|
+
maxAttempts: row.maxAttempts,
|
|
1856
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
1857
|
+
lastFailure: row.lastFailure ?? void 0
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1504
1860
|
var reach = {
|
|
1861
|
+
queue: {
|
|
1862
|
+
async get(queueId) {
|
|
1863
|
+
if (providers.size === 0)
|
|
1864
|
+
return notInitialized.result();
|
|
1865
|
+
if (!sendLogRepo)
|
|
1866
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
1867
|
+
if (!ReachQueueIdSchema.safeParse(queueId).success)
|
|
1868
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
|
|
1869
|
+
const rows = await sendLogRepo.findAll({ where: "queue_id = ?", params: [queueId], limit: 1 });
|
|
1870
|
+
if (!rows.success)
|
|
1871
|
+
return rows;
|
|
1872
|
+
const row = rows.data[0];
|
|
1873
|
+
return ok(row ? queueEntry(row) : null);
|
|
1874
|
+
},
|
|
1875
|
+
async list(options) {
|
|
1876
|
+
if (providers.size === 0)
|
|
1877
|
+
return notInitialized.result();
|
|
1878
|
+
if (!sendLogRepo)
|
|
1879
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
1880
|
+
const parsed = ReachQueueListSchema.safeParse(options);
|
|
1881
|
+
if (!parsed.success)
|
|
1882
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
|
|
1883
|
+
const rows = await sendLogRepo.findAll({
|
|
1884
|
+
where: "status = ? AND queue_id IS NOT NULL",
|
|
1885
|
+
params: [parsed.data.status],
|
|
1886
|
+
orderBy: "created_at ASC, id ASC",
|
|
1887
|
+
limit: parsed.data.limit,
|
|
1888
|
+
offset: parsed.data.offset
|
|
1889
|
+
});
|
|
1890
|
+
return rows.success ? ok(rows.data.map(queueEntry)) : rows;
|
|
1891
|
+
},
|
|
1892
|
+
async requeueDead(queueId, options) {
|
|
1893
|
+
if (providers.size === 0)
|
|
1894
|
+
return notInitialized.result();
|
|
1895
|
+
if (!sendLogRepo)
|
|
1896
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
1897
|
+
const parsed = ReachDeadRequeueSchema.safeParse(options);
|
|
1898
|
+
if (!ReachQueueIdSchema.safeParse(queueId).success || !parsed.success)
|
|
1899
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
|
|
1900
|
+
return sendLogRepo.requeueDead(queueId, parsed.data.expectedUpdatedAt);
|
|
1901
|
+
},
|
|
1902
|
+
async resolveUnknown(queueId, resolution) {
|
|
1903
|
+
if (providers.size === 0)
|
|
1904
|
+
return notInitialized.result();
|
|
1905
|
+
if (!sendLogRepo)
|
|
1906
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
1907
|
+
const parsed = ReachUnknownResolutionSchema.safeParse(resolution);
|
|
1908
|
+
if (!ReachQueueIdSchema.safeParse(queueId).success || !parsed.success)
|
|
1909
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
|
|
1910
|
+
return sendLogRepo.resolveUnknown(queueId, parsed.data);
|
|
1911
|
+
}
|
|
1912
|
+
},
|
|
1505
1913
|
async init(config) {
|
|
1506
1914
|
if (initInProgress) {
|
|
1507
|
-
|
|
1915
|
+
logger7.warn("Reach module init already in progress, skipping");
|
|
1508
1916
|
return err(
|
|
1509
1917
|
HaiReachError.CONFIG_ERROR,
|
|
1510
1918
|
reachM("reach_configError", { params: { error: "init already in progress" } })
|
|
@@ -1521,7 +1929,7 @@ var reach = {
|
|
|
1521
1929
|
if (providers.size === 0) {
|
|
1522
1930
|
return notInitialized.result();
|
|
1523
1931
|
}
|
|
1524
|
-
return executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo);
|
|
1932
|
+
return executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo, currentConfig.queue);
|
|
1525
1933
|
},
|
|
1526
1934
|
get template() {
|
|
1527
1935
|
return templateRegistry;
|
|
@@ -1537,6 +1945,6 @@ var reach = {
|
|
|
1537
1945
|
}
|
|
1538
1946
|
};
|
|
1539
1947
|
|
|
1540
|
-
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, SmtpProviderConfigSchema, TemplateConfigSchema, reach };
|
|
1948
|
+
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, ReachDeadRequeueSchema, ReachQueueConfigSchema, ReachQueueIdSchema, ReachQueueListSchema, ReachUnknownResolutionSchema, SmtpProviderConfigSchema, TemplateConfigSchema, reach };
|
|
1541
1949
|
//# sourceMappingURL=index.js.map
|
|
1542
1950
|
//# sourceMappingURL=index.js.map
|