@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/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
- 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,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
- reach_dndDeferred: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u6682\u5B58\u5F85\u53D1\u9001",
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
- /** 免打扰策略:discard 丢弃 / delay 延时发送(默认 discard) */
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: "send" });
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
- } catch {
541
- logger5.debug("Send record not saved (db module unavailable)");
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
- logger5.warn("Failed to flush pending messages on init", { error });
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 { dndConfig: dndConfig2, providers: providers2, repo } = schedulerContext;
758
+ const context = schedulerContext;
759
+ const { dndConfig: dndConfig2, providers: providers2, repo } = context;
565
760
  const delayMs = msUntilDndEnd(dndConfig2);
566
- logger5.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
761
+ logger6.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
567
762
  dndTimer = setTimeout(() => {
568
- logger5.info("DND period ended, flushing pending messages");
763
+ if (schedulerContext !== context)
764
+ return;
765
+ logger6.info("DND period ended, flushing pending messages");
569
766
  flushPendingMessages(providers2, repo).catch((error) => {
570
- logger5.error("Failed to flush pending messages", { error });
767
+ logger6.error("Failed to flush pending messages", { error });
571
768
  }).finally(() => {
572
- scheduleDndCheck();
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 { dndConfig: dndConfig2 } = schedulerContext;
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 (!schedulerContext)
781
+ if (schedulerContext !== context)
583
782
  return;
584
783
  if (isDndBlocked(dndConfig2)) {
585
784
  scheduleFlushAtDndEnd();
586
785
  } else {
587
- scheduleDndCheck();
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 lockAcquired = false;
606
- const flushOwner = `${reachNodeId}:${Date.now()}`;
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, owner: reachNodeId });
810
+ const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL });
609
811
  if (!lockResult.success) {
610
- logger5.warn("Failed to acquire flush lock, continuing with row claims only", { error: lockResult.error.message });
812
+ logger6.warn("Failed to acquire flush lock, continuing with row claims only", { error: lockResult.error.message });
611
813
  } else if (!lockResult.data) {
612
- logger5.info("Skipping flush, another node holds the lock");
814
+ logger6.info("Skipping flush, another node holds the lock");
613
815
  return;
614
816
  } else {
615
- lockAcquired = true;
817
+ lockOwner = lockResult.data;
616
818
  }
617
819
  }
618
820
  try {
619
- while (true) {
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: FLUSH_BATCH_SIZE,
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
- logger5.warn("Failed to claim pending messages", { error: claimResult.error.message });
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
- logger5.debug("Flushing pending messages", { count: claimResult.data.length });
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
- logger5.warn("Provider not found for pending message, releasing claim", { provider: row.provider, id: row.id });
636
- const releaseResult2 = await repo.releaseClaim(row.id, flushOwner);
637
- if (!releaseResult2.success) {
638
- logger5.warn("Failed to release pending message claim", { id: row.id, error: releaseResult2.error.message });
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 message = {
656
- provider: row.provider,
657
- to: row.toAddr,
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 (lockAcquired) {
686
- await cache.lock.release(FLUSH_LOCK_KEY, reachNodeId).catch((error) => {
687
- logger5.warn("Failed to release flush lock", { error });
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
- logger5.info("Message deferred by DND (delay strategy)", { provider: providerName, to: message.to });
720
- try {
721
- await saveSendRecord(repo ?? null, preprocessed.data, "pending", providerName);
722
- } catch (error) {
723
- logger5.warn("Failed to save deferred message to DB", { provider: providerName, to: message.to, error });
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("reach_dndDeferred"),
727
- error
894
+ reachM("reach_dndEnqueueFailed"),
895
+ saved.error
728
896
  );
729
897
  }
730
- return ok({ success: true, deferred: true });
898
+ logger6.info("Message persisted for DND delivery", { provider: providerName, queueId });
899
+ return ok({ success: true, deferred: true, queueId });
731
900
  }
732
- logger5.info("Message blocked by DND (discard strategy)", { provider: providerName, to: message.to });
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
- logger5.debug("Sending message", {
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
- logger5.warn("Message send failed", {
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
- saveSendRecord(repo ?? null, preprocessed.data, "sent", providerName, result.data.messageId).catch((error) => {
754
- logger5.warn("Failed to save send record", { provider: providerName, to: message.to, error });
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: "INTEGER" },
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: "TIMESTAMP", notNull: true },
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: "INTEGER", notNull: true },
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
- ["pending", "processing", now, limit]
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.isInteger(id)) {
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: ["pending", "processing", now],
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, "pending", "processing", now]
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 claimedIds = dbType === "postgresql" || dbType === "mysql" ? await this.claimPendingIdsWithRowLocks(tx, owner, now, lockedUntil, limit) : await this.claimPendingIdsWithConditionalUpdates(tx, owner, now, lockedUntil, limit);
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
- /** 释放一条已 claim 的记录,供后续批次重新发送 */
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 status = ? AND processing_owner = ?`,
1144
- ["pending", now, id, "processing", owner]
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(`Send log ${id} claim is no longer owned by ${owner}`);
1440
+ return buildSendLogError(reachM("reach_claimLost"));
1151
1441
  }
1152
1442
  return ok(void 0);
1153
1443
  }
1154
- /** 将记录标记为已发送 */
1155
- async markSent(id, messageId, tx, owner) {
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 ${whereClause}`,
1167
- params
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 (owner && result.data.changes === 0) {
1173
- return buildSendLogError(`Send log ${id} claim is no longer owned by ${owner}`);
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 logger6 = core.logger.child({ module: "reach", scope: "main" });
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
- logger6.debug("Send log repository not initialized", { error: result.error.message });
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
- logger6.debug("Send log repository not initialized", { error: error instanceof Error ? error.message : String(error) });
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
- logger6.debug("Template repository not initialized", { error: result.error.message });
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
- logger6.debug("Template repository not initialized", { error: error instanceof Error ? error.message : String(error) });
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
- logger6.info("Reach module already closed, skipping");
1745
+ logger7.info("Reach module already closed, skipping");
1414
1746
  return;
1415
1747
  }
1416
- logger6.info("Closing reach module");
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
- logger6.error("Provider close failed", { name, error });
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
- logger6.info("Reach module closed");
1765
+ logger7.info("Reach module closed");
1434
1766
  }
1435
1767
  async function doInit(config) {
1436
1768
  if (providers.size > 0) {
1437
- logger6.warn("Reach module is already initialized, reinitializing");
1769
+ logger7.warn("Reach module is already initialized, reinitializing");
1438
1770
  await doClose();
1439
1771
  }
1440
- logger6.info("Initializing reach module");
1772
+ logger7.info("Initializing reach module");
1441
1773
  const parseResult = ReachConfigSchema.safeParse(config);
1442
1774
  if (!parseResult.success) {
1443
- logger6.error("Reach config validation failed", { error: parseResult.error.message });
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
- logger6.error("Provider initialization failed", {
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
- logger6.info("Rolling back connected providers", { count: newProviders.size });
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
- logger6.error("Provider rollback close failed", { name, error: rollbackError });
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
- logger6.warn("Failed to save config templates to database", { error: saveResult.error.message });
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
- logger6.info("Reach module initialized", { providers: providerNames });
1834
+ logger7.info("Reach module initialized", { providers: providerNames });
1492
1835
  return ok(void 0);
1493
1836
  } catch (error) {
1494
- logger6.error("Reach module initialization failed", { error });
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
- logger6.warn("Reach module init already in progress, skipping");
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