@h-ai/reach 0.1.0-alpha.67 → 0.1.0-alpha.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +76 -15
- package/README.ja.md +76 -15
- package/README.md +87 -26
- package/dist/index.d.ts +990 -155
- package/dist/index.js +568 -160
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/README.en.md
CHANGED
|
@@ -23,7 +23,7 @@ Template sends need only `template`, `to` and `vars`; the provider comes from th
|
|
|
23
23
|
|
|
24
24
|
### Dependencies
|
|
25
25
|
|
|
26
|
-
- `@h-ai/reldb`:
|
|
26
|
+
- `@h-ai/reldb`: persistence for delivery logs and templates. With `dnd.enabled: true` and `strategy: delay`, initialize it first and ensure the queue table is available; otherwise `reach.init()` returns `CONFIG_ERROR`. Optional for other sends.
|
|
27
27
|
- `@h-ai/cache`: optional distributed locks for DND delay; enabled automatically when initialized.
|
|
28
28
|
|
|
29
29
|
### Quick start
|
|
@@ -99,14 +99,15 @@ const config = { name: 'dev', type: 'console' }
|
|
|
99
99
|
|
|
100
100
|
#### API callbacks
|
|
101
101
|
|
|
102
|
-
| Field
|
|
103
|
-
|
|
|
104
|
-
| `name`
|
|
105
|
-
| `type`
|
|
106
|
-
| `url`
|
|
107
|
-
| `method`
|
|
108
|
-
| `headers`
|
|
109
|
-
| `timeout`
|
|
102
|
+
| Field | Type | Required | Default | Description |
|
|
103
|
+
| ------------ | ------------------------ | -------- | ------- | ------------------------------------------------------------------------------------ |
|
|
104
|
+
| `name` | `string` | ✅ | — | Provider name |
|
|
105
|
+
| `type` | `string` | ✅ | — | Fixed `'api'` |
|
|
106
|
+
| `url` | `string` | ✅ | — | HTTP(S) callback URL |
|
|
107
|
+
| `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP method |
|
|
108
|
+
| `headers` | `Record<string, string>` | — | — | Custom request headers |
|
|
109
|
+
| `timeout` | `number` | — | `10000` | Timeout in milliseconds |
|
|
110
|
+
| `idempotent` | `boolean` | — | `false` | Enable only when the receiver atomically persists deduplication with its side effect |
|
|
110
111
|
|
|
111
112
|
API callback addresses are trusted server configuration. Logs contain only the callback origin, not URL credentials, query parameters, recipients, or third-party failure bodies. For tenant/user-supplied addresses, applications must enforce a domain allowlist; enterprise deployments should also block internal metadata access through egress proxies or network policies.
|
|
112
113
|
|
|
@@ -119,6 +120,32 @@ API callback addresses are trusted server configuration. Logs contain only the c
|
|
|
119
120
|
| `start` | `string` | — | `00:00` | Start time (HH:mm) |
|
|
120
121
|
| `end` | `string` | — | `00:00` | End time (HH:mm) |
|
|
121
122
|
|
|
123
|
+
`delay` returns a successful `HaiResult` containing `{ success: true, deferred: true, queueId }` only after the database confirms the insert. This means queued, awaiting delivery. The receipt UUID is stored in `hai_reach_send_log.queue_id`; its row tracks `status` (`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`). Missing storage, disconnection, or insertion failure never produces deferred success; enqueue failures return `SEND_FAILED`. After restart, another node connected to the same database with the same Provider name can resume delivery outside DND. Optional log failures after an immediate send do not report an already delivered message as failed.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const result = await reach.send(message)
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new Error(result.error.message)
|
|
129
|
+
if (result.data.deferred) {
|
|
130
|
+
// Save the receipt for tracking; delivery is still pending.
|
|
131
|
+
await savePendingReceipt(result.data.queueId)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
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; the framework does not migrate old tables automatically.
|
|
136
|
+
|
|
137
|
+
#### Durable retry policy
|
|
138
|
+
|
|
139
|
+
The `queue` policy is persisted with each queued message. A restart or another node's changed configuration cannot reset an existing message's budget.
|
|
140
|
+
|
|
141
|
+
| Setting | Default | Bounds |
|
|
142
|
+
| ----------------------- | --------- | -------------------------------------------------- |
|
|
143
|
+
| `queue.maxAttempts` | `5` | 1–100, including initial claims and crash recovery |
|
|
144
|
+
| `queue.retryDelayMs` | `60000` | Initial backoff, 1000–86400000 milliseconds |
|
|
145
|
+
| `queue.maxRetryDelayMs` | `3600000` | Same bounds, at least retryDelayMs |
|
|
146
|
+
|
|
147
|
+
Every claim atomically increments the stored `attemptCount`. Transient failures become eligible at `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))`; the minute-based scan outside DND can deliver later. A round handles at most 100 messages using a fixed eligibility cutoff, so failed messages cannot reenter that round. Missing/disconnected providers, network errors, and HTTP 408/425/429/5xx allow bounded retry. Invalid stored JSON and other HTTP 4xx from an idempotent API go directly to `dead`. Exhaustion, including a crash after the last claim, also produces dead. In-flight non-idempotent requests still become `unknown` regardless of remaining attempts.
|
|
148
|
+
|
|
122
149
|
### Error handling
|
|
123
150
|
|
|
124
151
|
All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`:
|
|
@@ -131,10 +158,10 @@ All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`
|
|
|
131
158
|
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | Invalid recipient |
|
|
132
159
|
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider not registered |
|
|
133
160
|
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | Dropped by DND |
|
|
134
|
-
| `HaiReachError.DND_DEFERRED` | `hai:reach:007` | Queued for DND delay |
|
|
135
161
|
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | Not initialized |
|
|
136
162
|
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | Unsupported type |
|
|
137
163
|
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | Invalid configuration |
|
|
164
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | Permanent API rejection |
|
|
138
165
|
|
|
139
166
|
```ts
|
|
140
167
|
const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
|
|
@@ -145,12 +172,44 @@ if (!result.success) {
|
|
|
145
172
|
|
|
146
173
|
### Distributed locks
|
|
147
174
|
|
|
148
|
-
|
|
175
|
+
The optional cache lock reduces duplicate scans; nodes can scan concurrently after its 300-second TTL. Database delivery states provide correctness. A round claims at most 100 messages, one immediately before each delivery. An atomic owner/lease check persists `sending` before the external call; in-flight leases renew every third of their duration. Expired owners cannot revive leases, start another delivery, or commit results. Recovery continues every minute outside DND.
|
|
149
176
|
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
177
|
+
- States follow `pending → processing → sending → sent`. Expired processing records can be claimed safely.
|
|
178
|
+
- Only API providers configured with `idempotent: true` can automatically retry in-flight requests. This declares receiver capability. Every attempt uses the persistent queueId as both the `Idempotency-Key` header and `idempotencyKey` payload field. The receiver must atomically commit deduplication and side effects. Retention must cover late requests from resumed old workers; if lateness is unbounded, deduplication records must not expire. The same Provider name across nodes must use the same target and deduplication store.
|
|
179
|
+
- SMTP, Aliyun SMS, and default API providers have no external idempotency guarantee. Failure, exceptions, or lease loss after sending begins produce an unknown outcome. Expired non-idempotent sends become `unknown` and are never automatically resent. Disconnected providers use persisted backoff; invalid stored JSON goes directly to dead before any external call. Console produces development metadata only.
|
|
180
|
+
- Lease renewal, owner checks, and cancellation cannot undo an email, SMS, or HTTP side effect. The module provides no general exactly-once guarantee.
|
|
181
|
+
|
|
182
|
+
### Reviewing unknown outcomes
|
|
183
|
+
|
|
184
|
+
Trusted server administration can call `reach.queue.get(queueId)` to inspect `pending / processing / sending / unknown / sent / discarded / dead` without recipient or body data. For unknown outcomes, stop or isolate the old worker and verify Provider delivery records first. Then resolve to sent or discarded. UUID/status validation and a conditional update allow only one concurrent resolution; this never sends again. Discarding cannot cancel requests already started. Create a replacement only after confirming the old request can no longer take effect.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const state = await reach.queue.get(queueId)
|
|
188
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
+
if (!resolved.success)
|
|
190
|
+
throw new Error(resolved.error.message)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Inspecting and restoring dead messages
|
|
194
|
+
|
|
195
|
+
Trusted server administration can call `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })`, or list `unknown`. Pages contain at most 100 records and offset is bounded by 1000000. Get/list return status, attemptCount, maxAttempts, nextAttemptAt, lastFailure, and updatedAt without recipients, bodies, or owner credentials. Fix the root cause, read the latest dead version, and conditionally restore it:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const state = await reach.queue.get(queueId)
|
|
199
|
+
if (!state.success)
|
|
200
|
+
throw new Error(state.error.message)
|
|
201
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
+
if (!restored.success)
|
|
204
|
+
throw new Error(restored.error.message)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Only the matching dead version is restored; concurrent/stale requests fail, and unknown cannot be requeued. Restoration resets attempts while retaining queueId, the original policy, and receiver idempotency requirements. Dead means automatic processing stopped, not that no external effect occurred: late requests from an earlier idempotent API worker may still arrive. Confirm durable receiver deduplication before restoring. Leave irreparable payloads in dead for investigation; restoration does not repair their content.
|
|
209
|
+
|
|
210
|
+
## Production shared state
|
|
211
|
+
|
|
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)。
|
|
154
213
|
|
|
155
214
|
## Development and testing
|
|
156
215
|
|
|
@@ -158,6 +217,8 @@ Across nodes, flushing pending DND-delay messages is protected by `@h-ai/cache`
|
|
|
158
217
|
pnpm --filter @h-ai/reach test
|
|
159
218
|
```
|
|
160
219
|
|
|
220
|
+
`tests/reach-repository-send-log-databases.test.ts` starts isolated PostgreSQL 16.15 / MySQL 8.4.11 containers through Testcontainers and removes them afterward. A reachable Docker API is required. For Podman, point `DOCKER_HOST` at its compatible socket; rootless environments can set `TESTCONTAINERS_RYUK_DISABLED=true` as required by Testcontainers.
|
|
221
|
+
|
|
161
222
|
## License
|
|
162
223
|
|
|
163
224
|
Apache-2.0
|
package/README.ja.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
### 依存関係
|
|
25
25
|
|
|
26
|
-
- `@h-ai/reldb
|
|
26
|
+
- `@h-ai/reldb`:送信ログ・テンプレートの永続化。`dnd.enabled: true` と `strategy: delay` を使う場合は事前初期化とキューテーブルが必須で、利用できなければ `reach.init()` は `CONFIG_ERROR` を返します。それ以外の送信では任意です。
|
|
27
27
|
- `@h-ai/cache`:DND delay 用の分散ロック(任意)。初期化済みなら自動有効化。
|
|
28
28
|
|
|
29
29
|
### クイックスタート
|
|
@@ -99,14 +99,15 @@ const config = { name: 'dev', type: 'console' }
|
|
|
99
99
|
|
|
100
100
|
#### API 通知
|
|
101
101
|
|
|
102
|
-
| フィールド
|
|
103
|
-
|
|
|
104
|
-
| `name`
|
|
105
|
-
| `type`
|
|
106
|
-
| `url`
|
|
107
|
-
| `method`
|
|
108
|
-
| `headers`
|
|
109
|
-
| `timeout`
|
|
102
|
+
| フィールド | 型 | 必須 | 既定値 | 説明 |
|
|
103
|
+
| ------------ | ------------------------ | ---- | ------- | ---------------------------------------------------------- |
|
|
104
|
+
| `name` | `string` | ✅ | — | Provider 名 |
|
|
105
|
+
| `type` | `string` | ✅ | — | 固定値 `'api'` |
|
|
106
|
+
| `url` | `string` | ✅ | — | HTTP(S) 通知 URL |
|
|
107
|
+
| `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP メソッド |
|
|
108
|
+
| `headers` | `Record<string, string>` | — | — | 独自のリクエストヘッダー |
|
|
109
|
+
| `timeout` | `number` | — | `10000` | タイムアウト(ミリ秒) |
|
|
110
|
+
| `idempotent` | `boolean` | — | `false` | 受信側が副作用と重複排除を原子的に永続化する場合のみ有効化 |
|
|
110
111
|
|
|
111
112
|
API 通知先は信頼できるサーバー設定です。ログには origin のみを記録し、URL 認証情報、クエリ、宛先、外部の失敗応答本文は残しません。テナント・ユーザー入力の URL にはアプリでドメインの allowlist を設け、企業環境では出口プロキシやネットワーク方針でも内部メタデータへの接続を防いでください。
|
|
112
113
|
|
|
@@ -119,6 +120,32 @@ API 通知先は信頼できるサーバー設定です。ログには origin
|
|
|
119
120
|
| `start` | `string` | — | `00:00` | 開始時刻(HH:mm) |
|
|
120
121
|
| `end` | `string` | — | `00:00` | 終了時刻(HH:mm) |
|
|
121
122
|
|
|
123
|
+
`delay` はデータベースへの挿入成功を確認した後だけ、`{ success: true, deferred: true, queueId }` を含む成功 `HaiResult` を返します。これはキューへの保存完了を表し、配信はまだ完了していません。受付 UUID は `hai_reach_send_log.queue_id` に保存され、同じ行の `status`(`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`)で追跡できます。ストレージ未準備、切断、挿入失敗では deferred 成功を返さず、キューへの保存失敗は `SEND_FAILED` です。再起動後、同じデータベースに接続して同名 Provider を設定した別ノードが DND 時間外に配信を再開できます。即時送信後の任意ログの失敗によって、配信済みメッセージを失敗扱いにはしません。
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const result = await reach.send(message)
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new Error(result.error.message)
|
|
129
|
+
if (result.data.deferred) {
|
|
130
|
+
// 追跡用の受付 ID を保存します。配信はまだ完了していません。
|
|
131
|
+
await savePendingReceipt(result.data.queueId)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date` に対応します。デプロイ時に試行回数・退避設定・失敗分類を含むテーブル構造も更新してください。旧テーブルの自動移行は行いません。
|
|
136
|
+
|
|
137
|
+
#### 永続化された再試行設定
|
|
138
|
+
|
|
139
|
+
`queue` 設定は各メッセージの保存時に永続化され、再起動や別ノードの設定変更では既存の予算をリセットしません。
|
|
140
|
+
|
|
141
|
+
| 設定 | 既定値 | 範囲 |
|
|
142
|
+
| ----------------------- | --------- | ------------------------------------- |
|
|
143
|
+
| `queue.maxAttempts` | `5` | 1–100、初回取得とクラッシュ復旧を含む |
|
|
144
|
+
| `queue.retryDelayMs` | `60000` | 初回退避、1000–86400000 ミリ秒 |
|
|
145
|
+
| `queue.maxRetryDelayMs` | `3600000` | 同じ範囲、retryDelayMs 以上 |
|
|
146
|
+
|
|
147
|
+
取得ごとに保存済み `attemptCount` を原子的に増やします。一時的失敗は `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 以降に再取得でき、DND 外の毎分走査によって実際の送信はさらに遅れる場合があります。1 回の走査は最大 100 件で取得資格の時刻を固定するため、失敗したメッセージが同じ回を繰り返し占有しません。Provider 未設定・未接続、ネットワーク障害、HTTP 408/425/429/5xx は有限回再試行します。不正な保存 JSON と冪等 API の他の HTTP 4xx は直ちに `dead`、上限到達や最後の取得後のクラッシュも dead になります。冪等性のない配信中リクエストは残り回数によらず `unknown` に隔離します。
|
|
148
|
+
|
|
122
149
|
### エラー処理
|
|
123
150
|
|
|
124
151
|
すべての操作は `HaiResult<T>` を返します。エラーコードは `HaiReachError` で定義します:
|
|
@@ -131,10 +158,10 @@ API 通知先は信頼できるサーバー設定です。ログには origin
|
|
|
131
158
|
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 無効な宛先 |
|
|
132
159
|
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider が未登録 |
|
|
133
160
|
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | DND により破棄 |
|
|
134
|
-
| `HaiReachError.DND_DEFERRED` | `hai:reach:007` | DND により一時保留 |
|
|
135
161
|
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初期化 |
|
|
136
162
|
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 未対応の種類 |
|
|
137
163
|
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 設定エラー |
|
|
164
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API による恒久的な拒否 |
|
|
138
165
|
|
|
139
166
|
```ts
|
|
140
167
|
const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
|
|
@@ -145,12 +172,44 @@ if (!result.success) {
|
|
|
145
172
|
|
|
146
173
|
### 分散ロック
|
|
147
174
|
|
|
148
|
-
|
|
175
|
+
任意のキャッシュロックは重複走査を減らすためのもので、300 秒後には複数ノードが走査できます。正しさはデータベースの配信状態で保証します。1 回に最大 100 件を 1 件ずつ取得し、外部呼び出し前に有効な owner/lease を原子的に確認して sending を保存します。配信中はリース期間の 3 分の 1 ごとに更新し、期限切れ owner の復活・再送・結果確定を拒否します。DND 時間外は毎分復旧を試みます。
|
|
149
176
|
|
|
150
|
-
- `
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
177
|
+
- 状態は `pending → processing → sending → sent`。期限切れ processing は安全に再取得できます。
|
|
178
|
+
- `idempotent: true` の API のみ配信中リクエストを自動再試行します。これは受信側の能力宣言です。各試行の `Idempotency-Key` ヘッダーと `idempotencyKey` 本文は永続化された queueId を使い、受信側が重複排除と副作用を原子的に確定する必要があります。古い worker の遅延復帰も保持期間に含め、最大遅延を限定できない場合は重複排除レコードを失効させないでください。同名 Provider は同じ送信先と重複排除ストアを使います。
|
|
179
|
+
- SMTP、Aliyun SMS、既定の API には外部冪等性の保証がありません。sending 開始後の失敗・例外・リース喪失は結果不明として扱い、期限切れは unknown に隔離して自動再送しません。未接続 Provider は保存済み退避設定に従い、不正な保存 JSON は外部呼び出し前に dead へ移します。Console は開発用メタデータのみを出力します。
|
|
180
|
+
- リース更新、owner 条件、キャンセルでは既に開始した外部副作用を取り消せません。汎用の exactly-once は保証しません。
|
|
181
|
+
|
|
182
|
+
### 結果不明の手動確認
|
|
183
|
+
|
|
184
|
+
信頼されたサーバー管理処理で `reach.queue.get(queueId)` を使い、宛先や本文を含まない `pending / processing / sending / unknown / sent / discarded / dead` 状態を確認します。unknown は古い worker を停止・隔離し、外部の配信記録と照合してから sent または discarded に裁定します。UUID・状態の検証と条件付き更新により並行裁定は 1 件だけ成功し、再送はしません。discarded は開始済みリクエストを取り消しません。補充送信は古いリクエストが今後作用しないことを確認した後だけ行ってください。
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const state = await reach.queue.get(queueId)
|
|
188
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
+
if (!resolved.success)
|
|
190
|
+
throw new Error(resolved.error.message)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### デッドレターの照会と復元
|
|
194
|
+
|
|
195
|
+
信頼されたサーバー管理処理で `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` を使用します。unknown も照会でき、1 ページ最大 100 件、offset 上限は 1000000 です。get/list は status、attemptCount、maxAttempts、nextAttemptAt、lastFailure、updatedAt のみを返し、宛先・本文・owner 資格情報は含みません。原因を修復後、最新 dead を読み直して条件付きで復元します。
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const state = await reach.queue.get(queueId)
|
|
199
|
+
if (!state.success)
|
|
200
|
+
throw new Error(state.error.message)
|
|
201
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
+
if (!restored.success)
|
|
204
|
+
throw new Error(restored.error.message)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
一致する dead バージョンのみ復元し、並行・古い要求は失敗、unknown は復元できません。回数をリセットしますが queueId・保存済み設定・受信側の冪等性要件は保持します。dead は自動処理の停止を意味し、副作用がなかった証明にはなりません。古い冪等 API 要求が遅れて届く可能性があるため、復元前にも受信側の永続的な重複排除を確認してください。修復不能なペイロードは dead に残して調査し、復元だけで内容が直るとは考えないでください。
|
|
209
|
+
|
|
210
|
+
## 本番の共有状態
|
|
211
|
+
|
|
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)。
|
|
154
213
|
|
|
155
214
|
## 開発とテスト
|
|
156
215
|
|
|
@@ -158,6 +217,8 @@ if (!result.success) {
|
|
|
158
217
|
pnpm --filter @h-ai/reach test
|
|
159
218
|
```
|
|
160
219
|
|
|
220
|
+
`tests/reach-repository-send-log-databases.test.ts` は Testcontainers で隔離した PostgreSQL 16.15 / MySQL 8.4.11 を起動し、終了後に削除します。利用可能な Docker API が必要です。Podman では `DOCKER_HOST` を互換ソケットに向け、rootless 環境では Testcontainers の要件に従って `TESTCONTAINERS_RYUK_DISABLED=true` を設定してください。
|
|
221
|
+
|
|
161
222
|
## ライセンス
|
|
162
223
|
|
|
163
224
|
Apache-2.0
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
### 依赖
|
|
25
25
|
|
|
26
|
-
- `@h-ai/reldb` —
|
|
26
|
+
- `@h-ai/reldb` — 数据库(发送日志与模板持久化);启用 `dnd.enabled: true` 与 `strategy: delay` 时必须先初始化且队列表可用,否则 `reach.init()` 返回 `CONFIG_ERROR`。其他发送场景可选
|
|
27
27
|
- `@h-ai/cache` — 缓存(DND delay 策略分布式锁),可选;已初始化时自动启用分布式锁
|
|
28
28
|
|
|
29
29
|
### 快速开始
|
|
@@ -99,14 +99,15 @@ const config = { name: 'dev', type: 'console' }
|
|
|
99
99
|
|
|
100
100
|
#### API 回调
|
|
101
101
|
|
|
102
|
-
| 字段
|
|
103
|
-
|
|
|
104
|
-
| `name`
|
|
105
|
-
| `type`
|
|
106
|
-
| `url`
|
|
107
|
-
| `method`
|
|
108
|
-
| `headers`
|
|
109
|
-
| `timeout`
|
|
102
|
+
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
|
103
|
+
| ------------ | ------------------------ | ---- | ------- | -------------------------------------------- |
|
|
104
|
+
| `name` | `string` | ✅ | — | Provider 名称 |
|
|
105
|
+
| `type` | `string` | ✅ | — | 固定 `'api'` |
|
|
106
|
+
| `url` | `string` | ✅ | — | HTTP(S) 回调 URL |
|
|
107
|
+
| `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP 方法 |
|
|
108
|
+
| `headers` | `Record<string, string>` | — | — | 自定义请求头 |
|
|
109
|
+
| `timeout` | `number` | — | `10000` | 超时毫秒数 |
|
|
110
|
+
| `idempotent` | `boolean` | — | `false` | 接收端具有持久化、原子副作用去重能力时才启用 |
|
|
110
111
|
|
|
111
112
|
API 回调地址属于服务端可信配置。日志只记录回调 origin,不记录 URL 凭据、查询参数、收件人或第三方失败响应体。若回调地址来自租户/用户输入,应用层必须配置域名 allowlist;企业部署还应通过出口代理或网络策略阻止访问内网元数据地址。
|
|
112
113
|
|
|
@@ -119,22 +120,48 @@ API 回调地址属于服务端可信配置。日志只记录回调 origin,不
|
|
|
119
120
|
| `start` | `string` | — | `00:00` | 开始时间(HH:mm 格式) |
|
|
120
121
|
| `end` | `string` | — | `00:00` | 结束时间(HH:mm 格式) |
|
|
121
122
|
|
|
123
|
+
`delay` 仅在消息写入数据库并确认成功后返回 `HaiResult` 成功,结果为 `{ success: true, deferred: true, queueId }`;这表示已入队、尚未投递。`queueId` 是持久化回执 UUID,对应 `hai_reach_send_log.queue_id`,可查询该记录的 `status`(`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`)。缺少数据库、断连、插入失败均不会返回 deferred 成功,发送时返回 `SEND_FAILED`。节点重启后,只要连接同一数据库、配置同名 Provider,便可在 DND 时段外恢复投递。即时发送成功后的普通日志保存仍为可选,不会因日志失败把已投递的消息报告为失败。
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const result = await reach.send(message)
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new Error(result.error.message)
|
|
129
|
+
if (result.data.deferred) {
|
|
130
|
+
// 保存 queueId 供后台跟踪;此时不能向用户显示“已送达”。
|
|
131
|
+
await savePendingReceipt(result.data.queueId)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;框架不自动迁移旧表。
|
|
136
|
+
|
|
137
|
+
#### 可靠队列重试
|
|
138
|
+
|
|
139
|
+
`queue` 策略在入队时随每条消息持久保存;其它节点及重启后的配置变化不会重置已有消息预算。
|
|
140
|
+
|
|
141
|
+
| 配置 | 默认值 | 约束 |
|
|
142
|
+
| ----------------------- | --------- | ------------------------------- |
|
|
143
|
+
| `queue.maxAttempts` | `5` | 1–100,含首次领取和崩溃恢复 |
|
|
144
|
+
| `queue.retryDelayMs` | `60000` | 首次退避,1000–86400000 毫秒 |
|
|
145
|
+
| `queue.maxRetryDelayMs` | `3600000` | 同上范围,且不小于 retryDelayMs |
|
|
146
|
+
|
|
147
|
+
每次领取在数据库原子递增 `attemptCount`。暂时失败在 `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 之后才有资格再次领取;DND 外每分钟扫描,因此实际发送可能更晚。一轮最多处理 100 条,并固定本轮资格时间;失败的消息在该轮不会重新入选。未连接/缺失 Provider、网络错误、HTTP 408/425/429/5xx 可以有限重试;持久化载荷解析失败、幂等 API 的其他 HTTP 4xx 直接进入 `dead`。超出次数或最后一次领取后崩溃也进入 `dead`。非幂等在途请求仍优先进入 `unknown`,不因剩余预算而重发。
|
|
148
|
+
|
|
122
149
|
### 错误处理
|
|
123
150
|
|
|
124
151
|
所有操作返回 `HaiResult<T>`,错误码定义在 `HaiReachError`:
|
|
125
152
|
|
|
126
|
-
| 错误码 | code | 说明
|
|
127
|
-
| -------------------------------------- | --------------- |
|
|
128
|
-
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败
|
|
129
|
-
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板不存在
|
|
130
|
-
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败
|
|
131
|
-
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方
|
|
132
|
-
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未注册
|
|
133
|
-
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃
|
|
134
|
-
| `HaiReachError.
|
|
135
|
-
| `HaiReachError.
|
|
136
|
-
| `HaiReachError.
|
|
137
|
-
| `HaiReachError.
|
|
153
|
+
| 错误码 | code | 说明 |
|
|
154
|
+
| -------------------------------------- | --------------- | ---------------- |
|
|
155
|
+
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败 |
|
|
156
|
+
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板不存在 |
|
|
157
|
+
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败 |
|
|
158
|
+
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方 |
|
|
159
|
+
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未注册 |
|
|
160
|
+
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃 |
|
|
161
|
+
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初始化 |
|
|
162
|
+
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 不支持的类型 |
|
|
163
|
+
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 配置错误 |
|
|
164
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API 明确永久拒绝 |
|
|
138
165
|
|
|
139
166
|
```ts
|
|
140
167
|
const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
|
|
@@ -145,12 +172,44 @@ if (!result.success) {
|
|
|
145
172
|
|
|
146
173
|
### 分布式锁
|
|
147
174
|
|
|
148
|
-
|
|
175
|
+
缓存锁只减少重复扫描,300 秒后可能有多个节点同时扫描;正确性由数据库投递状态保证。每轮最多逐条领取 100 条,实际发送前原子校验有效 owner 并写入 `sending`,在途每隔租约的三分之一续租。过期 owner 不能复活租约、开始新投递或提交结果。DND 时段外每分钟继续恢复遗留队列。
|
|
149
176
|
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
177
|
+
- 状态为 `pending → processing → sending → sent`;`processing` 过期可安全重领。
|
|
178
|
+
- API 只有配置 `idempotent: true` 才允许重领在途消息。这是对接收端能力的声明,不会凭空实现幂等。每次请求的 `Idempotency-Key` 请求头和 `idempotencyKey` 正文字段均固定为持久化 `queueId`;接收端必须把去重记录和副作用原子提交。去重记录必须覆盖所有迟到请求,包括暂停后恢复的旧节点;无法界定最长迟到时间时不能过期删除。各节点同名 Provider 必须连接同一目标和幂等存储。
|
|
179
|
+
- SMTP、阿里云短信、默认 API 没有外部幂等保证。已经进入 `sending` 后的失败、异常或租约丢失按未知结果处理,过期后转为 `unknown`,新消费者不会自动重发。未连接 Provider 按持久策略退避;发送前解析失败直接进入 dead。Console 仅输出开发元数据,不产生真实投递。
|
|
180
|
+
- 续租、数据库 owner 条件和取消信号都不能撤销已发出的邮件、短信或 HTTP 副作用;模块不提供通用 exactly-once 保证。
|
|
181
|
+
|
|
182
|
+
### 未知结果人工核对
|
|
183
|
+
|
|
184
|
+
可信服务端管理流程使用 `reach.queue.get(queueId)` 查询 `pending / processing / sending / unknown / sent / discarded / dead`;结果不含正文或收件人。出现 unknown 时先停止或隔离旧 worker,按 queueId、Provider 日志和第三方投递记录核对,再调用以下裁定接口。接口校验 UUID 和状态,只能把 unknown 条件更新为 sent 或 discarded,并发裁定仅一个成功;不会重新发送。discarded 只终止后续处理,不能撤销已经开始的请求。如需补发,须先确认旧请求不可能继续生效,再显式创建新消息。
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const state = await reach.queue.get(queueId)
|
|
188
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
+
if (!resolved.success)
|
|
190
|
+
throw new Error(resolved.error.message)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 死信查询与恢复
|
|
194
|
+
|
|
195
|
+
可信服务端可用 `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` 查询死信,也可查询 `unknown`;每页最多 100 条,offset 上限 1000000。get/list 返回状态、attemptCount、maxAttempts、nextAttemptAt、lastFailure 和 updatedAt,不含正文、收件人及 owner。修复 Provider 配置或消息根因后,查询最新 dead 版本再恢复:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const state = await reach.queue.get(queueId)
|
|
199
|
+
if (!state.success)
|
|
200
|
+
throw new Error(state.error.message)
|
|
201
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
+
if (!restored.success)
|
|
204
|
+
throw new Error(restored.error.message)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
恢复仅条件更新同一版本的 dead;并发/过时请求失败,unknown 无法恢复。它重置领取次数并保留 queueId、原退避策略及接收端幂等要求。dead 仅表示停止自动处理,不证明外部副作用未发生;曾开始的幂等 API 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
|
|
209
|
+
|
|
210
|
+
## 生产共享状态
|
|
211
|
+
|
|
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)。
|
|
154
213
|
|
|
155
214
|
## 开发测试
|
|
156
215
|
|
|
@@ -158,6 +217,8 @@ if (!result.success) {
|
|
|
158
217
|
pnpm --filter @h-ai/reach test
|
|
159
218
|
```
|
|
160
219
|
|
|
220
|
+
跨库时间回归用例 `tests/reach-repository-send-log-databases.test.ts` 通过 Testcontainers 启动隔离 PostgreSQL 16.15 / MySQL 8.4.11,并在结束时清理容器;须提供可用的 Docker API。使用 Podman 时,将 `DOCKER_HOST` 指向其兼容 socket;rootless 环境可按 Testcontainers 要求设置 `TESTCONTAINERS_RYUK_DISABLED=true`。
|
|
221
|
+
|
|
161
222
|
## 许可证
|
|
162
223
|
|
|
163
224
|
Apache-2.0
|