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