@h-ai/reach 0.1.0-alpha.70 → 0.1.0-alpha.71
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 +7 -1
- package/README.ja.md +7 -1
- package/README.md +7 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +204 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.en.md
CHANGED
|
@@ -132,7 +132,7 @@ if (result.data.deferred) {
|
|
|
132
132
|
}
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
Queue columns `created_at`, `updated_at`, `locked_until`, and `next_attempt_at` store Unix milliseconds as `BIGINT` (64-bit `INTEGER` on SQLite). The Reach repository normalizes integer strings returned by drivers such as PostgreSQL into safe integer `number` values. Negative, fractional, or unsafe stored integers return `SEND_FAILED` instead of being silently rounded. Template creation/update `TIMESTAMP` fields map to `Date`. Update the complete table schema during deployment, including attempt counters, backoff policy, and failure classification;
|
|
135
|
+
Queue columns `created_at`, `updated_at`, `locked_until`, and `next_attempt_at` store Unix milliseconds as `BIGINT` (64-bit `INTEGER` on SQLite). The Reach repository normalizes integer strings returned by drivers such as PostgreSQL into safe integer `number` values. Negative, fractional, or unsafe stored integers return `SEND_FAILED` instead of being silently rounded. Template creation/update `TIMESTAMP` fields map to `Date`. Update the complete table schema during deployment, including attempt counters, backoff policy, and failure classification; module-owned versioned migrations handle supported legacy tables (below).
|
|
136
136
|
|
|
137
137
|
#### Durable retry policy
|
|
138
138
|
|
|
@@ -211,6 +211,12 @@ Only the matching dead version is restored; concurrent/stale requests fail, and
|
|
|
211
211
|
|
|
212
212
|
Production rejects console providers and requires SMTP, SMS or an actual API service. A log write is not a delivery receipt. Reconcile unknown outcomes from non-idempotent providers rather than claiming exactly-once delivery. Initialization checks apply when NODE_ENV or HAI_CORE_ENV is production; only explicit HAI_E2E=1/HAI_E2E_MOCK=1 tests are exempt. [State, concurrency and recovery (Chinese)](../../.agents/instructions/distributed-state.md)。
|
|
213
213
|
|
|
214
|
+
## Credential notifications and upgrades
|
|
215
|
+
|
|
216
|
+
Use `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` for passwords, OTPs and reset tokens. expiresAt is Unix milliseconds; stable operation keys must contain no credentials. A shared ledger stores only metadata receipts/status and redacted destinations, never bodies, variables or credential hashes. There is no delayed credential queue. Success returns receiptId; DND/expired messages are not sent, and uncertain delivery is never automatically repeated. Explicit development console output is isolated from persistent observers. Production Console restrictions remain. Normal messages may use deferPolicy: reject to avoid a second queue.
|
|
217
|
+
|
|
218
|
+
`migrateReach({ dryRun: true })` previews supported migrations, applied during repository initialization. Statuses/identifiers remain; legacy in-flight work becomes unknown. Legacy payloads are retired once and pending old payloads discarded. Back up and stop old writers before upgrading; never replay old credentials.
|
|
219
|
+
|
|
214
220
|
## Development and testing
|
|
215
221
|
|
|
216
222
|
```bash
|
package/README.ja.md
CHANGED
|
@@ -132,7 +132,7 @@ if (result.data.deferred) {
|
|
|
132
132
|
}
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date`
|
|
135
|
+
キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date` に対応します。デプロイ時に試行回数・退避設定・失敗分類を含むテーブル構造も更新してください。対応する旧表はモジュール所有のバージョン移行で更新します(下記)。
|
|
136
136
|
|
|
137
137
|
#### 永続化された再試行設定
|
|
138
138
|
|
|
@@ -211,6 +211,12 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
211
211
|
|
|
212
212
|
本番は console を拒否し、実際の SMTP・SMS・API サービスを必要とします。ログ書込は配信完了ではありません。冪等でないサービスの未知結果は照合が必要で、exactly-once は保証しません。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
|
|
213
213
|
|
|
214
|
+
## 認証情報の通知と更新
|
|
215
|
+
|
|
216
|
+
パスワード、OTP、リセットトークンは `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` を使います。expiresAt は Unix ミリ秒、操作キーに秘密値を含めません。共有台帳は本文・変数・認証値のハッシュを保存せず、脱機密化した宛先と受領状態のみを保持します。遅延キューに入れず、DND/期限切れは送信せず、不明な配信を自動再送しません。成功時は receiptId を返します。明示した開発用コンソール出力は永続観測から分離し、本番 Console 制限は維持します。通常通知は deferPolicy: reject で二重キューを防げます。
|
|
217
|
+
|
|
218
|
+
`migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
|
|
219
|
+
|
|
214
220
|
## 開発とテスト
|
|
215
221
|
|
|
216
222
|
```bash
|
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ if (result.data.deferred) {
|
|
|
132
132
|
}
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date
|
|
135
|
+
队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;旧表升级由模块维护的版本迁移负责,见下文。
|
|
136
136
|
|
|
137
137
|
#### 可靠队列重试
|
|
138
138
|
|
|
@@ -211,6 +211,12 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
|
211
211
|
|
|
212
212
|
生产拒绝 console Provider,须配置真实 SMTP/短信/API 服务。console 的日志成功不是外部投递成功;非幂等服务的未知结果必须核对,不能自动重发证明 exactly-once。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化发布成功前校验;仅 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试可豁免。 [状态、并发和恢复边界](../../.agents/instructions/distributed-state.md)。
|
|
213
213
|
|
|
214
|
+
## 凭据通知与旧库升级
|
|
215
|
+
|
|
216
|
+
发送密码、OTP 或重置令牌时使用 `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })`。expiresAt 是 Unix 毫秒,稳定操作键不可包含凭据。必须有共享发送账本:只持久化无载荷回执/状态,接收方脱敏,不存正文、变量或低熵凭据摘要,也不进入延迟队列。发送成功返回 receiptId;DND/过期不投递,异常或不明状态不自动重投。明确启用的开发控制台仍可显示业务允许的通知,持久采集观察器被隔离;生产 Console Provider 限制不变。普通通知可用 deferPolicy: reject 阻止二次排队。
|
|
217
|
+
|
|
218
|
+
`migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
|
|
219
|
+
|
|
214
220
|
## 开发测试
|
|
215
221
|
|
|
216
222
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as _h_ai_core from '@h-ai/core';
|
|
3
3
|
import { HaiResult } from '@h-ai/core';
|
|
4
|
+
import * as _h_ai_reldb from '@h-ai/reldb';
|
|
5
|
+
import { ReldbFunctions } from '@h-ai/reldb';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* @h-ai/reach — 配置 Schema
|
|
@@ -411,6 +413,7 @@ declare const HaiReachError: {
|
|
|
411
413
|
readonly UNSUPPORTED_TYPE: _h_ai_core.HaiErrorDef;
|
|
412
414
|
readonly CONFIG_ERROR: _h_ai_core.HaiErrorDef;
|
|
413
415
|
readonly DELIVERY_REJECTED: _h_ai_core.HaiErrorDef;
|
|
416
|
+
readonly MESSAGE_EXPIRED: _h_ai_core.HaiErrorDef;
|
|
414
417
|
};
|
|
415
418
|
/**
|
|
416
419
|
* 渠道语义标签email/sms/api;消息路由实际使用配置中的Provider名称。
|
|
@@ -449,6 +452,12 @@ interface ReachMessage {
|
|
|
449
452
|
extra?: Record<string, unknown>;
|
|
450
453
|
/** API 接收端幂等键;直接发送可指定,队列投递固定使用持久化 queueId。 */
|
|
451
454
|
idempotencyKey?: string;
|
|
455
|
+
/** 凭据通知只在内存中投递;正文、变量、收件人不持久化,不进入延迟或重投队列。 */
|
|
456
|
+
sensitive?: boolean;
|
|
457
|
+
/** sensitive=true 时必填的凭据截止时间(Unix 毫秒),不延长业务凭据有效期。 */
|
|
458
|
+
expiresAt?: number;
|
|
459
|
+
/** 遇到免打扰时交给 Reach 队列,或返回 DND_BLOCKED 让已有业务 outbox 延后;默认 queue。 */
|
|
460
|
+
deferPolicy?: 'queue' | 'reject';
|
|
452
461
|
}
|
|
453
462
|
/**
|
|
454
463
|
* 发送或可靠入队结果;deferred=true只证明持久pending写入成功,queueId用于后续查询,不代表已送达。
|
|
@@ -480,6 +489,8 @@ interface ReachMessage {
|
|
|
480
489
|
* ```
|
|
481
490
|
*/
|
|
482
491
|
type SendResult = {
|
|
492
|
+
/** 无载荷凭据回执,可通过 queue.get 查询;不代表进入延迟队列。 */
|
|
493
|
+
receiptId?: string;
|
|
483
494
|
/** 是否已完成 Provider 发送或可靠入队;具体状态同时检查 deferred。 */
|
|
484
495
|
success: boolean;
|
|
485
496
|
/** Provider 返回的消息 ID(如有) */
|
|
@@ -1298,4 +1309,16 @@ interface ReachFunctions extends SendOperations {
|
|
|
1298
1309
|
*/
|
|
1299
1310
|
declare const reach: ReachFunctions;
|
|
1300
1311
|
|
|
1301
|
-
|
|
1312
|
+
/**
|
|
1313
|
+
* 预检或升级 Reach 队列。旧凭据相关载荷清除,待发凭据丢弃,保留发送状态和历史标识。
|
|
1314
|
+
* @example
|
|
1315
|
+
* ```ts
|
|
1316
|
+
* import { migrateReach } from '@h-ai/reach'
|
|
1317
|
+
* const plan = await migrateReach({ dryRun: true })
|
|
1318
|
+
* ```
|
|
1319
|
+
*/
|
|
1320
|
+
declare function migrateReach(options?: {
|
|
1321
|
+
dryRun?: boolean;
|
|
1322
|
+
}, db?: ReldbFunctions): Promise<_h_ai_core.HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
|
|
1323
|
+
|
|
1324
|
+
export { type AliyunSmsProviderConfig, AliyunSmsProviderConfigSchema, type ApiProviderConfig, ApiProviderConfigSchema, type ConsoleProviderConfig, ConsoleProviderConfigSchema, type DndConfig, DndConfigSchema, HaiReachError, type ProviderConfig, ProviderConfigSchema, type ReachChannel, type ReachConfig, type ReachConfigInput, ReachConfigSchema, type ReachDeadRequeue, ReachDeadRequeueSchema, type ReachFunctions, type ReachMessage, type ReachQueueConfig, ReachQueueConfigSchema, type ReachQueueEntry, type ReachQueueFailure, ReachQueueIdSchema, type ReachQueueListOptions, ReachQueueListSchema, type ReachQueueOperations, type ReachQueueStatus, type ReachTemplate, type ReachTemplateRegistry, type ReachUnknownResolution, ReachUnknownResolutionSchema, type RenderedTemplate, type SendOperations, type SendResult, type SmtpProviderConfig, SmtpProviderConfigSchema, type TemplateConfig, TemplateConfigSchema, migrateReach, reach };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { core, err, ok } from '@h-ai/core';
|
|
3
3
|
import process from 'process';
|
|
4
|
-
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
5
|
-
import { randomUUID, createHmac } from 'crypto';
|
|
4
|
+
import { reldb, migrateSchema, schemaColumns, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
5
|
+
import { randomUUID, createHash, createHmac } from 'crypto';
|
|
6
6
|
import { createRequire } from 'module';
|
|
7
7
|
import { cache } from '@h-ai/cache';
|
|
8
8
|
|
|
@@ -50,7 +50,10 @@ var en_US_default = {
|
|
|
50
50
|
reach_templateDeleteFailed: "Template delete failed: {error}",
|
|
51
51
|
reach_queueRetryInvalid: "maxRetryDelayMs must be at least retryDelayMs",
|
|
52
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"
|
|
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"
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
// messages/ja-JP.json
|
|
@@ -95,7 +98,10 @@ var ja_JP_default = {
|
|
|
95
98
|
reach_templateDeleteFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u524A\u9664\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
96
99
|
reach_queueRetryInvalid: "maxRetryDelayMs \u306F retryDelayMs \u4EE5\u4E0A\u306B\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
97
100
|
reach_deadRequeueFailed: "\u914D\u4FE1\u4E0D\u80FD\u30E1\u30C3\u30BB\u30FC\u30B8\u304C\u5909\u66F4\u3055\u308C\u305F\u304B\u3001\u5B58\u5728\u3057\u307E\u305B\u3093\u3002\u5FA9\u5143\u3059\u308B\u524D\u306B\u72B6\u614B\u3092\u518D\u8AAD\u307F\u8FBC\u307F\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
98
|
-
reach_productionProviderRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u914D\u4FE1\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059\u3002console \u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A\u30C6\u30B9\u30C8\u30E2\u30FC\u30C9\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059"
|
|
101
|
+
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",
|
|
102
|
+
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",
|
|
103
|
+
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",
|
|
104
|
+
reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F"
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
// messages/zh-CN.json
|
|
@@ -140,7 +146,10 @@ var zh_CN_default = {
|
|
|
140
146
|
reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}",
|
|
141
147
|
reach_queueRetryInvalid: "maxRetryDelayMs \u4E0D\u80FD\u5C0F\u4E8E retryDelayMs",
|
|
142
148
|
reach_deadRequeueFailed: "\u6B7B\u4FE1\u5DF2\u53D8\u5316\u6216\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u540E\u6062\u590D",
|
|
143
|
-
reach_productionProviderRequired: "\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u914D\u7F6E\u771F\u5B9E\u89E6\u8FBE\u670D\u52A1\uFF1Bconsole \u4EC5\u53EF\u7528\u4E8E\u5F00\u53D1\u6216\u663E\u5F0F\u6D4B\u8BD5\u6A21\u5F0F"
|
|
149
|
+
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",
|
|
150
|
+
reach_sensitivePolicyRequired: "\u51ED\u636E\u901A\u77E5\u9700\u8981\u6570\u636E\u5E93\u56DE\u6267\u3001\u7A33\u5B9A\u5E42\u7B49\u952E\u548C\u6709\u6548\u671F",
|
|
151
|
+
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",
|
|
152
|
+
reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012"
|
|
144
153
|
};
|
|
145
154
|
|
|
146
155
|
// src/reach-i18n.ts
|
|
@@ -272,7 +281,8 @@ var ReachErrorInfo = {
|
|
|
272
281
|
NOT_INITIALIZED: "010:500",
|
|
273
282
|
UNSUPPORTED_TYPE: "011:400",
|
|
274
283
|
CONFIG_ERROR: "012:500",
|
|
275
|
-
DELIVERY_REJECTED: "020:422"
|
|
284
|
+
DELIVERY_REJECTED: "020:422",
|
|
285
|
+
MESSAGE_EXPIRED: "021:410"
|
|
276
286
|
};
|
|
277
287
|
var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
|
|
278
288
|
|
|
@@ -427,7 +437,8 @@ function createApiProvider() {
|
|
|
427
437
|
template: message.template,
|
|
428
438
|
vars: message.vars,
|
|
429
439
|
extra: message.extra,
|
|
430
|
-
idempotencyKey: message.idempotencyKey
|
|
440
|
+
idempotencyKey: message.idempotencyKey,
|
|
441
|
+
expiresAt: message.expiresAt
|
|
431
442
|
};
|
|
432
443
|
const controller = new AbortController();
|
|
433
444
|
const timeoutId = setTimeout(() => controller.abort(), apiConfig.timeout);
|
|
@@ -653,6 +664,66 @@ async function deliverClaimedMessage(row, owner, provider, repo, leaseMs = DEFAU
|
|
|
653
664
|
return released;
|
|
654
665
|
return err(HaiReachError.SEND_FAILED, reachM("reach_deliveryUnknown"), outcome.success ? void 0 : outcome.error);
|
|
655
666
|
}
|
|
667
|
+
function previousResult(row) {
|
|
668
|
+
return row.status === "sent" ? ok({ success: true, receiptId: row.queueId ?? void 0 }) : unconfirmed(row.queueId);
|
|
669
|
+
}
|
|
670
|
+
function unconfirmed(receiptId) {
|
|
671
|
+
return { success: false, error: { ...HaiReachError.SEND_FAILED, message: reachM("reach_sensitiveDeliveryUnconfirmed"), ext: { receiptId } } };
|
|
672
|
+
}
|
|
673
|
+
async function sendSensitive(message, provider, repo, blocked) {
|
|
674
|
+
if (!repo || !message.idempotencyKey?.trim() || message.idempotencyKey.length > 255 || !Number.isSafeInteger(message.expiresAt))
|
|
675
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_sensitivePolicyRequired"));
|
|
676
|
+
const hash = createHash("sha256").update(JSON.stringify([message.provider, message.to, message.idempotencyKey])).digest("hex");
|
|
677
|
+
const queueId = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-5${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
678
|
+
const find = () => repo.findAll({ where: "queue_id = ?", params: [queueId], limit: 1 });
|
|
679
|
+
const existing = await find();
|
|
680
|
+
if (!existing.success)
|
|
681
|
+
return existing;
|
|
682
|
+
if (existing.data[0])
|
|
683
|
+
return previousResult(existing.data[0]);
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
if (message.expiresAt <= now)
|
|
686
|
+
return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
|
|
687
|
+
const created = await repo.create({
|
|
688
|
+
queueId,
|
|
689
|
+
provider: message.provider,
|
|
690
|
+
toAddr: "[REDACTED]",
|
|
691
|
+
subject: null,
|
|
692
|
+
body: null,
|
|
693
|
+
template: null,
|
|
694
|
+
varsJson: null,
|
|
695
|
+
extraJson: null,
|
|
696
|
+
status: blocked ? "discarded" : "sending",
|
|
697
|
+
retrySafe: false,
|
|
698
|
+
createdAt: now,
|
|
699
|
+
updatedAt: now,
|
|
700
|
+
lockedUntil: Math.min(message.expiresAt, now + 6e5),
|
|
701
|
+
nextAttemptAt: 0
|
|
702
|
+
});
|
|
703
|
+
if (!created.success || created.data.changes !== 1) {
|
|
704
|
+
const previous = await find();
|
|
705
|
+
if (previous.success && previous.data[0])
|
|
706
|
+
return previousResult(previous.data[0]);
|
|
707
|
+
return unconfirmed(queueId);
|
|
708
|
+
}
|
|
709
|
+
if (blocked)
|
|
710
|
+
return err(HaiReachError.DND_BLOCKED, reachM("reach_dndBlocked"));
|
|
711
|
+
if (Date.now() >= message.expiresAt) {
|
|
712
|
+
await repo.settleSensitive(queueId, "discarded");
|
|
713
|
+
return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
|
|
714
|
+
}
|
|
715
|
+
let delivered;
|
|
716
|
+
try {
|
|
717
|
+
delivered = await core.context.run({ suppressLogObservers: true }, () => provider.send(message));
|
|
718
|
+
} catch {
|
|
719
|
+
delivered = err(HaiReachError.SEND_FAILED, reachM("reach_sensitiveDeliveryUnconfirmed"));
|
|
720
|
+
}
|
|
721
|
+
const success = delivered.success && delivered.data.success && !delivered.data.deferred;
|
|
722
|
+
const settled = await repo.settleSensitive(queueId, success ? "sent" : "unknown");
|
|
723
|
+
if (!settled.success || !success)
|
|
724
|
+
return unconfirmed(queueId);
|
|
725
|
+
return ok({ success: true, receiptId: queueId });
|
|
726
|
+
}
|
|
656
727
|
|
|
657
728
|
// src/reach-send.ts
|
|
658
729
|
var logger6 = core.logger.child({ module: "reach", scope: "send" });
|
|
@@ -880,7 +951,11 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
|
|
|
880
951
|
reachM("reach_providerNotFound", { params: { provider: providerName } })
|
|
881
952
|
);
|
|
882
953
|
}
|
|
954
|
+
if (preprocessed.data.sensitive)
|
|
955
|
+
return sendSensitive(preprocessed.data, providers2.get(providerName), repo, isDndBlocked(dndConfig2));
|
|
883
956
|
if (isDndBlocked(dndConfig2)) {
|
|
957
|
+
if (message.deferPolicy === "reject")
|
|
958
|
+
return err(HaiReachError.DND_BLOCKED, reachM("reach_dndBlocked"));
|
|
884
959
|
const strategy = dndConfig2?.strategy ?? "discard";
|
|
885
960
|
if (strategy === "delay") {
|
|
886
961
|
if (!repo)
|
|
@@ -1019,6 +1094,113 @@ function createTemplateRegistry(repo) {
|
|
|
1019
1094
|
}
|
|
1020
1095
|
};
|
|
1021
1096
|
}
|
|
1097
|
+
function migrateReach(options = {}, db = reldb) {
|
|
1098
|
+
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" };
|
|
1099
|
+
const steps = [{
|
|
1100
|
+
version: 1,
|
|
1101
|
+
name: "durable-delivery-ledger",
|
|
1102
|
+
async needed(sql) {
|
|
1103
|
+
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1104
|
+
return found.success ? ok(found.data.length > 0 && Object.keys(definitions).some((name) => !found.data.includes(name))) : found;
|
|
1105
|
+
},
|
|
1106
|
+
async apply(sql) {
|
|
1107
|
+
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1108
|
+
if (!found.success)
|
|
1109
|
+
return found;
|
|
1110
|
+
for (const [name, definition] of Object.entries(definitions)) {
|
|
1111
|
+
if (found.data.includes(name))
|
|
1112
|
+
continue;
|
|
1113
|
+
const added = await sql.execute(`ALTER TABLE hai_reach_send_log ADD COLUMN ${name} ${definition}`);
|
|
1114
|
+
if (!added.success)
|
|
1115
|
+
return added;
|
|
1116
|
+
}
|
|
1117
|
+
return ok(void 0);
|
|
1118
|
+
}
|
|
1119
|
+
}, {
|
|
1120
|
+
version: 2,
|
|
1121
|
+
name: "legacy-delivery-isolation",
|
|
1122
|
+
async needed(sql) {
|
|
1123
|
+
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1124
|
+
if (!found.success || !found.data.length)
|
|
1125
|
+
return found.success ? ok(false) : found;
|
|
1126
|
+
if (!found.data.includes("queue_id"))
|
|
1127
|
+
return ok(true);
|
|
1128
|
+
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");
|
|
1129
|
+
return row.success ? ok(Boolean(row.data)) : row;
|
|
1130
|
+
},
|
|
1131
|
+
async apply(sql) {
|
|
1132
|
+
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");
|
|
1133
|
+
if (!isolated.success)
|
|
1134
|
+
return isolated;
|
|
1135
|
+
while (true) {
|
|
1136
|
+
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");
|
|
1137
|
+
if (!rows.success)
|
|
1138
|
+
return rows;
|
|
1139
|
+
for (const row of rows.data) {
|
|
1140
|
+
const saved = await sql.execute("UPDATE hai_reach_send_log SET queue_id=? WHERE id=? AND queue_id IS NULL", [randomUUID(), row.id]);
|
|
1141
|
+
if (!saved.success)
|
|
1142
|
+
return saved;
|
|
1143
|
+
}
|
|
1144
|
+
if (rows.data.length < 100)
|
|
1145
|
+
return ok(void 0);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}, {
|
|
1149
|
+
version: 3,
|
|
1150
|
+
name: "credential-payload-retirement",
|
|
1151
|
+
async needed(sql) {
|
|
1152
|
+
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1153
|
+
if (!found.success || !found.data.length)
|
|
1154
|
+
return found.success ? ok(false) : found;
|
|
1155
|
+
const ledger = await schemaColumns(db, "hai_schema_migrations", sql);
|
|
1156
|
+
if (!ledger.success)
|
|
1157
|
+
return ledger;
|
|
1158
|
+
const version = ledger.data.length ? await sql.get("SELECT version FROM hai_schema_migrations WHERE module=?", ["reach"]) : ok(null);
|
|
1159
|
+
if (!version.success)
|
|
1160
|
+
return version;
|
|
1161
|
+
if (Number(version.data?.version ?? 0) >= 3)
|
|
1162
|
+
return ok(false);
|
|
1163
|
+
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");
|
|
1164
|
+
return row.success ? ok(Boolean(row.data)) : row;
|
|
1165
|
+
},
|
|
1166
|
+
async apply(sql) {
|
|
1167
|
+
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");
|
|
1168
|
+
}
|
|
1169
|
+
}, {
|
|
1170
|
+
version: 4,
|
|
1171
|
+
name: "unique-operation-receipts",
|
|
1172
|
+
async needed(sql) {
|
|
1173
|
+
const columns = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1174
|
+
if (!columns.success || !columns.data.length)
|
|
1175
|
+
return columns.success ? ok(false) : columns;
|
|
1176
|
+
if (!columns.data.includes("queue_id"))
|
|
1177
|
+
return ok(true);
|
|
1178
|
+
if (db.config?.type === "sqlite") {
|
|
1179
|
+
const indexes = await sql.query("PRAGMA index_list(hai_reach_send_log)");
|
|
1180
|
+
if (!indexes.success)
|
|
1181
|
+
return indexes;
|
|
1182
|
+
for (const index of indexes.data) {
|
|
1183
|
+
if (!index.unique || !/^\w+$/.test(index.name))
|
|
1184
|
+
continue;
|
|
1185
|
+
const fields = await sql.query(`PRAGMA index_info(${index.name})`);
|
|
1186
|
+
if (!fields.success)
|
|
1187
|
+
return fields;
|
|
1188
|
+
if (fields.data.length === 1 && fields.data[0]?.name === "queue_id")
|
|
1189
|
+
return ok(false);
|
|
1190
|
+
}
|
|
1191
|
+
return ok(true);
|
|
1192
|
+
}
|
|
1193
|
+
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"]);
|
|
1194
|
+
return found.success ? ok(!found.data) : found;
|
|
1195
|
+
},
|
|
1196
|
+
async apply(sql) {
|
|
1197
|
+
return sql.execute("CREATE UNIQUE INDEX uq_hai_reach_queue_v1 ON hai_reach_send_log(queue_id)");
|
|
1198
|
+
}
|
|
1199
|
+
}];
|
|
1200
|
+
return migrateSchema(db, "reach", steps, options);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// src/repositories/reach-repository-send-log.ts
|
|
1022
1204
|
var TABLE_NAME = "hai_reach_send_log";
|
|
1023
1205
|
var DEFAULT_CLAIM_BATCH_SIZE = 100;
|
|
1024
1206
|
var DEFAULT_CLAIM_LOCK_MS = 10 * 60 * 1e3;
|
|
@@ -1218,6 +1400,9 @@ function resetSendLogRepoSingleton() {
|
|
|
1218
1400
|
async function createSendLogRepository(db) {
|
|
1219
1401
|
if (sendLogRepoInstance && sendLogRepoSqlOps === db.sql)
|
|
1220
1402
|
return ok(sendLogRepoInstance);
|
|
1403
|
+
const migration = await migrateReach({}, db);
|
|
1404
|
+
if (!migration.success)
|
|
1405
|
+
return migration;
|
|
1221
1406
|
const repo = new DbSendLogRepository(db);
|
|
1222
1407
|
const initResult = await repo.count();
|
|
1223
1408
|
if (!initResult.success) {
|
|
@@ -1232,6 +1417,15 @@ async function createSendLogRepository(db) {
|
|
|
1232
1417
|
return ok(repo);
|
|
1233
1418
|
}
|
|
1234
1419
|
var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
1420
|
+
async settleSensitive(queueId, status, messageId) {
|
|
1421
|
+
const settled = await this.sql().execute(
|
|
1422
|
+
`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`,
|
|
1423
|
+
[status, messageId ?? null, Date.now(), queueId]
|
|
1424
|
+
);
|
|
1425
|
+
if (!settled.success)
|
|
1426
|
+
return settled;
|
|
1427
|
+
return settled.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_sensitiveDeliveryUnconfirmed"));
|
|
1428
|
+
}
|
|
1235
1429
|
constructor(db) {
|
|
1236
1430
|
super(db, {
|
|
1237
1431
|
table: TABLE_NAME,
|
|
@@ -1929,7 +2123,8 @@ var reach = {
|
|
|
1929
2123
|
if (providers.size === 0) {
|
|
1930
2124
|
return notInitialized.result();
|
|
1931
2125
|
}
|
|
1932
|
-
|
|
2126
|
+
const send = () => executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo, currentConfig.queue);
|
|
2127
|
+
return message.sensitive ? core.context.run({ suppressLogObservers: true }, send) : send();
|
|
1933
2128
|
},
|
|
1934
2129
|
get template() {
|
|
1935
2130
|
return templateRegistry;
|
|
@@ -1945,6 +2140,6 @@ var reach = {
|
|
|
1945
2140
|
}
|
|
1946
2141
|
};
|
|
1947
2142
|
|
|
1948
|
-
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, ReachDeadRequeueSchema, ReachQueueConfigSchema, ReachQueueIdSchema, ReachQueueListSchema, ReachUnknownResolutionSchema, SmtpProviderConfigSchema, TemplateConfigSchema, reach };
|
|
2143
|
+
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, ReachDeadRequeueSchema, ReachQueueConfigSchema, ReachQueueIdSchema, ReachQueueListSchema, ReachUnknownResolutionSchema, SmtpProviderConfigSchema, TemplateConfigSchema, migrateReach, reach };
|
|
1949
2144
|
//# sourceMappingURL=index.js.map
|
|
1950
2145
|
//# sourceMappingURL=index.js.map
|