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