@h-ai/reach 1.0.0-beta.19 → 1.0.0-beta.20

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.
Files changed (4) hide show
  1. package/README.en.md +111 -76
  2. package/README.ja.md +110 -75
  3. package/README.md +110 -75
  4. package/package.json +4 -4
package/README.en.md CHANGED
@@ -4,30 +4,15 @@
4
4
 
5
5
  [Framework home](../../README.en.md) · Related modules: [@h-ai/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
6
6
 
7
- ## Introduction
8
-
9
7
  A notification module supporting multiple providers (email, SMS, API callbacks) at once. Templates bind to providers for scenario-based routing. Includes do-not-disturb (DND) policies and configuration-defined templates.
10
8
 
11
- Template sends need only `template`, `to` and `vars`; the provider comes from the template. An explicit `provider` still overrides it. Direct sends require a provider; omitting it returns a failed HaiResult.
12
-
13
- ## Core features
14
-
15
- | Provider | Channel | Description |
16
- | ------------ | ------- | -------------------------------------------------- |
17
- | `console` | General | Console output (development/tests) |
18
- | `smtp` | Email | SMTP email (requires nodemailer) |
19
- | `aliyun-sms` | SMS | Alibaba Cloud SMS through HTTP API, without an SDK |
20
- | `api` | General | HTTP API callback (generic webhook) |
21
-
22
- ## Getting started
9
+ ## Quick Start
23
10
 
24
11
  ### Dependencies
25
12
 
26
13
  - `@h-ai/reldb`: persistence for delivery logs and templates. Initialize it before configuring nonempty `templates` or DND delay. Failed template persistence or required table preparation makes `reach.init()` return `CONFIG_ERROR` and close candidate providers. Optional for immediate sends without templates or delayed queues.
27
14
  - `@h-ai/cache`: optional distributed locks for DND delay; enabled automatically when initialized.
28
15
 
29
- ### Quick start
30
-
31
16
  #### Installation
32
17
 
33
18
  ```bash
@@ -103,11 +88,110 @@ Provider failures retain the complete module error definition, including HTTP st
103
88
 
104
89
  Each template send uses one template snapshot for its subject, body and default provider, keeping concurrent edits from mixing versions. An explicit `provider` still overrides the template route.
105
90
 
106
- ### Configuration
91
+ ## Features
92
+
93
+ Template sends need only `template`, `to` and `vars`; the provider comes from the template. An explicit `provider` still overrides it. Direct sends require a provider; omitting it returns a failed HaiResult.
94
+
95
+ ### Operations and results
96
+
97
+ Use the complete initialization flow in this section first. The host validates and authorizes example business values; check success before reading data from asynchronous domain results. Here void means completion without data, null means absent; synchronous factories/tools return the listed type directly.
98
+
99
+ name identifies a template, vars replacement values, template its provider/body/subject definition. queueId is the persisted UUID; recovery/resolution includes the queried version and verified operator decision, never an untrusted claim.
100
+
101
+ #### reach
102
+
103
+ Send takes provider/template, recipients and vars and yields SendResult (sent/queued); acceptance is not final delivery, reconcile unknown outcomes first.
104
+
105
+ | Entry | Input parameters | Successful result | Call example |
106
+ | ------------ | ---------------- | ----------------- | -------------------------------------------------------------------------------------------- |
107
+ | `reach.send` | message | `SendResult` | `await reach.send({ template: 'welcome', to: 'user@example.com', vars: { name: 'Alice' } })` |
108
+
109
+ #### reach.template
110
+
111
+ Templates: resolve name yields a template; save object/array or remove name yields void; list yields templates; render name/vars yields body, subject and provider.
112
+
113
+ | Entry | Input parameters | Successful result | Call example |
114
+ | -------------------------- | ---------------- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
115
+ | `reach.template.resolve` | name | `ReachTemplate` | `await reach.template.resolve('welcome')` |
116
+ | `reach.template.save` | template | `void` | `await reach.template.save({ name: 'welcome', provider: 'mail', body: 'Hello {{name}}', subject: 'Welcome' })` |
117
+ | `reach.template.saveBatch` | templates | `void` | `await reach.template.saveBatch([{ name: 'welcome', provider: 'mail', body: 'Hello {{name}}' }])` |
118
+ | `reach.template.remove` | name | `void` | `await reach.template.remove('welcome')` |
119
+ | `reach.template.list` | No arguments | `ReachTemplate[]` | `await reach.template.list()` |
120
+ | `reach.template.render` | name, vars | `RenderedTemplate` | `await reach.template.render('welcome', { name: 'Alice' })` |
121
+
122
+ #### reach.queue
123
+
124
+ Operations query queueId or filtered states: get yields entry/null, list entries. Requeue dead requires expected version/policy; resolve unknown requires a verified decision; success is void, not automatic replay.
125
+
126
+ | Entry | Input parameters | Successful result | Call example |
127
+ | ---------------------------- | ------------------- | ------------------------- | ------------------------------------------------------------- |
128
+ | `reach.queue.get` | queueId | `ReachQueueEntry \| null` | `await reach.queue.get(queueId)` |
129
+ | `reach.queue.list` | options | `ReachQueueEntry[]` | `await reach.queue.list({ status: 'dead', limit: 20 })` |
130
+ | `reach.queue.requeueDead` | queueId, options | `void` | `await reach.queue.requeueDead(queueId, requeueDecision)` |
131
+ | `reach.queue.resolveUnknown` | queueId, resolution | `void` | `await reach.queue.resolveUnknown(queueId, verifiedDecision)` |
132
+
133
+ ### Distributed locks
134
+
135
+ 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.
136
+
137
+ - States follow `pending → processing → sending → sent`. Expired processing records can be claimed safely.
138
+ - 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.
139
+ - 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.
140
+ - Lease renewal, owner checks, and cancellation cannot undo an email, SMS, or HTTP side effect. The module provides no general exactly-once guarantee.
141
+
142
+ ### Reviewing unknown outcomes
143
+
144
+ 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.
145
+
146
+ ```ts
147
+ const state = await reach.queue.get(queueId)
148
+ const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
149
+ if (!resolved.success)
150
+ throw new Error(resolved.error.message)
151
+ ```
152
+
153
+ ### Inspecting and restoring dead messages
154
+
155
+ 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:
156
+
157
+ ```ts
158
+ const state = await reach.queue.get(queueId)
159
+ if (!state.success)
160
+ throw new Error(state.error.message)
161
+ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
162
+ const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
163
+ if (!restored.success)
164
+ throw new Error(restored.error.message)
165
+ }
166
+ ```
167
+
168
+ 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.
169
+
170
+ ### Production shared state
171
+
172
+ 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).
173
+
174
+ ### Credential notifications and persistence setup
175
+
176
+ 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.
177
+
178
+ `migrateReach({ dryRun: true })` previews persistence preparation performed during repository initialization. Stop on failure, back up and stop conflicting writers first, preserve the sending ledger and never treat schema preparation as permission to replay credentials.
179
+
180
+ ### API acknowledgements and destination boundaries
181
+
182
+ An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
183
+
184
+ API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
185
+
186
+ PostgreSQL/MySQL queue claims, renewals and final acknowledgements use the database execution clock inside SQL; batch cutoffs also come from the database. Node clock skew and row-lock waits cannot revive an expired owner. Local SQLite remains a development/test backend with a controllable test clock.
187
+
188
+ ## Configuration
189
+
190
+ Lifecycle: init takes backend/business configuration; use the module only after success. Await close before releasing borrowed dependencies. Follow the quick-start example and public signatures for exact return values.
107
191
 
108
192
  Provider names must be unique; duplicates fail with CONFIG_ERROR before connections open. `close()` waits for initialization and releases its providers, preventing late publication after shutdown.
109
193
 
110
- #### Console (development/tests)
194
+ ### Console (development/tests)
111
195
 
112
196
  ```ts
113
197
  const config = { name: 'dev', type: 'console' }
@@ -189,7 +273,7 @@ if (result.data.deferred) {
189
273
  }
190
274
  ```
191
275
 
192
- 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).
276
+ 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 persistence preparation maintains that structure (below).
193
277
 
194
278
  #### Durable retry policy
195
279
 
@@ -203,7 +287,13 @@ The `queue` policy is persisted with each queued message. A restart or another n
203
287
 
204
288
  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.
205
289
 
206
- ### Error handling
290
+ The host loads and validates configuration before passing it to this module. Keep secrets in private server environment files. Select the backend, capacity, deadlines and logging for the actual use case; reinitialize and check the result after changes.
291
+
292
+ [Per-option purpose, use cases and examples](../../docs/usage/configuration/operations.md).
293
+
294
+ ## Error Handling
295
+
296
+ Check result.success for domain operations. Handle failures with the module error definitions and safe messages; retain codes, correlation IDs and sanitized causes in server logs. Synchronous tools, factories, stream iteration and lifecycle calls follow their own signatures. A completed Promise alone is not business success. See each feature for recovery limits.
207
297
 
208
298
  All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`:
209
299
 
@@ -227,62 +317,7 @@ if (!result.success) {
227
317
  }
228
318
  ```
229
319
 
230
- ### Distributed locks
231
-
232
- 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.
233
-
234
- - States follow `pending → processing → sending → sent`. Expired processing records can be claimed safely.
235
- - 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.
236
- - 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.
237
- - Lease renewal, owner checks, and cancellation cannot undo an email, SMS, or HTTP side effect. The module provides no general exactly-once guarantee.
238
-
239
- ### Reviewing unknown outcomes
240
-
241
- 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.
242
-
243
- ```ts
244
- const state = await reach.queue.get(queueId)
245
- const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
246
- if (!resolved.success)
247
- throw new Error(resolved.error.message)
248
- ```
249
-
250
- ### Inspecting and restoring dead messages
251
-
252
- 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:
253
-
254
- ```ts
255
- const state = await reach.queue.get(queueId)
256
- if (!state.success)
257
- throw new Error(state.error.message)
258
- if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
259
- const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
260
- if (!restored.success)
261
- throw new Error(restored.error.message)
262
- }
263
- ```
264
-
265
- 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.
266
-
267
- ## Production shared state
268
-
269
- 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)。
270
-
271
- ## Credential notifications and upgrades
272
-
273
- 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.
274
-
275
- `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.
276
-
277
- ## API acknowledgements and destination boundaries
278
-
279
- An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
280
-
281
- API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
282
-
283
- PostgreSQL/MySQL queue claims, renewals and final acknowledgements use the database execution clock inside SQL; batch cutoffs also come from the database. Node clock skew and row-lock waits cannot revive an expired owner. Local SQLite remains a development/test backend with a controllable test clock.
284
-
285
- ## Development and testing
320
+ ## Development
286
321
 
287
322
  ```bash
288
323
  pnpm --filter @h-ai/reach test
package/README.ja.md CHANGED
@@ -4,30 +4,15 @@
4
4
 
5
5
  [フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/cache](../cache/README.ja.md) · [@h-ai/core](../core/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
6
6
 
7
- ## 概要
8
-
9
7
  メール・SMS・API 通知など複数の Provider を同時登録できる通知モジュールです。テンプレートと Provider の対応で用途に応じて配信します。通知抑制(DND)と設定ファイルによるテンプレート定義を備えます。
10
8
 
11
- テンプレート送信は `template`、`to`、`vars` のみで利用でき、Provider はテンプレートから選ばれます。明示した `provider` は優先されます。直接送信は Provider が必須で、省略すると失敗 HaiResult を返します。
12
-
13
- ## 主な機能
14
-
15
- | Provider | チャネル | 説明 |
16
- | ------------ | -------- | -------------------------------------------------- |
17
- | `console` | 汎用 | コンソール出力(開発・テスト用) |
18
- | `smtp` | メール | SMTP メール(nodemailer が必要) |
19
- | `aliyun-sms` | SMS | Alibaba Cloud SMS(HTTP API を直接使用、SDK 不要) |
20
- | `api` | 汎用 | HTTP API 通知(汎用 webhook) |
21
-
22
- ## 使い始める
9
+ ## クイックスタート
23
10
 
24
11
  ### 依存関係
25
12
 
26
13
  - `@h-ai/reldb`:送信ログ・テンプレートの永続化。空でない `templates` または DND delay を設定する前に初期化します。テンプレートの保存や必須テーブルの準備に失敗した場合、`reach.init()` は候補 Provider を閉じて `CONFIG_ERROR` を返します。テンプレート・延期キューを使わない即時送信では任意です。
27
14
  - `@h-ai/cache`:DND delay 用の分散ロック(任意)。初期化済みなら自動有効化。
28
15
 
29
- ### クイックスタート
30
-
31
16
  #### インストール
32
17
 
33
18
  ```bash
@@ -103,11 +88,110 @@ Provider の失敗では HTTP ステータスを含む完全なモジュール
103
88
 
104
89
  テンプレート送信は一つのスナップショットから件名・本文・既定 Provider を決め、並行編集による異なる版の混在を防ぎます。明示的な `provider` は従来どおり優先します。
105
90
 
106
- ### 設定
91
+ ## 機能
92
+
93
+ テンプレート送信は `template`、`to`、`vars` のみで利用でき、Provider はテンプレートから選ばれます。明示した `provider` は優先されます。直接送信は Provider が必須で、省略すると失敗 HaiResult を返します。
94
+
95
+ ### 操作と入出力
96
+
97
+ 本節の完全な初期化手順を先に実行します。例の業務値はホストが検証・認可し、非同期領域結果は success を確認してから data を読みます。void はデータなし完了、null は不存在、同期ファクトリー/ツールは表の型を直接返します。
98
+
99
+ name はテンプレート名、vars は置換値、template は provider/body/subject の定義。queueId は永続 UUID、回復/裁定には取得版と確認済み判断を使い、外部申告を信頼しない。
100
+
101
+ #### reach
102
+
103
+ provider/template・宛先・変数から sent/queued 等の SendResult。受理は最終到達ではなく、不明結果は先に照合。
104
+
105
+ | 入口 | 入力引数 | 成功結果 | 呼出し例 |
106
+ | ------------ | -------- | ------------ | -------------------------------------------------------------------------------------------- |
107
+ | `reach.send` | message | `SendResult` | `await reach.send({ template: 'welcome', to: 'user@example.com', vars: { name: 'Alice' } })` |
108
+
109
+ #### reach.template
110
+
111
+ テンプレート。resolve は名前から定義、保存は定義/配列・削除は名前から void、list は配列、render は名前/vars から body・subject・provider。
112
+
113
+ | 入口 | 入力引数 | 成功結果 | 呼出し例 |
114
+ | -------------------------- | ---------- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
115
+ | `reach.template.resolve` | name | `ReachTemplate` | `await reach.template.resolve('welcome')` |
116
+ | `reach.template.save` | template | `void` | `await reach.template.save({ name: 'welcome', provider: 'mail', body: 'Hello {{name}}', subject: 'Welcome' })` |
117
+ | `reach.template.saveBatch` | templates | `void` | `await reach.template.saveBatch([{ name: 'welcome', provider: 'mail', body: 'Hello {{name}}' }])` |
118
+ | `reach.template.remove` | name | `void` | `await reach.template.remove('welcome')` |
119
+ | `reach.template.list` | 引数なし | `ReachTemplate[]` | `await reach.template.list()` |
120
+ | `reach.template.render` | name, vars | `RenderedTemplate` | `await reach.template.render('welcome', { name: 'Alice' })` |
121
+
122
+ #### reach.queue
123
+
124
+ 運用は queueId または状態条件で項目/null・配列を照会。dead の再投入には期待版/方針、unknown の裁定には確認証拠が必要で成功は void。無条件再送しない。
125
+
126
+ | 入口 | 入力引数 | 成功結果 | 呼出し例 |
127
+ | ---------------------------- | ------------------- | ------------------------- | ------------------------------------------------------------- |
128
+ | `reach.queue.get` | queueId | `ReachQueueEntry \| null` | `await reach.queue.get(queueId)` |
129
+ | `reach.queue.list` | options | `ReachQueueEntry[]` | `await reach.queue.list({ status: 'dead', limit: 20 })` |
130
+ | `reach.queue.requeueDead` | queueId, options | `void` | `await reach.queue.requeueDead(queueId, requeueDecision)` |
131
+ | `reach.queue.resolveUnknown` | queueId, resolution | `void` | `await reach.queue.resolveUnknown(queueId, verifiedDecision)` |
132
+
133
+ ### 分散ロック
134
+
135
+ 任意のキャッシュロックは重複走査を減らすためのもので、300 秒後には複数ノードが走査できます。正しさはデータベースの配信状態で保証します。1 回に最大 100 件を 1 件ずつ取得し、外部呼び出し前に有効な owner/lease を原子的に確認して sending を保存します。配信中はリース期間の 3 分の 1 ごとに更新し、期限切れ owner の復活・再送・結果確定を拒否します。DND 時間外は毎分復旧を試みます。
136
+
137
+ - 状態は `pending → processing → sending → sent`。期限切れ processing は安全に再取得できます。
138
+ - `idempotent: true` の API のみ配信中リクエストを自動再試行します。これは受信側の能力宣言です。各試行の `Idempotency-Key` ヘッダーと `idempotencyKey` 本文は永続化された queueId を使い、受信側が重複排除と副作用を原子的に確定する必要があります。古い worker の遅延復帰も保持期間に含め、最大遅延を限定できない場合は重複排除レコードを失効させないでください。同名 Provider は同じ送信先と重複排除ストアを使います。
139
+ - SMTP、Aliyun SMS、既定の API には外部冪等性の保証がありません。sending 開始後の失敗・例外・リース喪失は結果不明として扱い、期限切れは unknown に隔離して自動再送しません。未接続 Provider は保存済み退避設定に従い、不正な保存 JSON は外部呼び出し前に dead へ移します。Console は開発用メタデータのみを出力します。
140
+ - リース更新、owner 条件、キャンセルでは既に開始した外部副作用を取り消せません。汎用の exactly-once は保証しません。
141
+
142
+ ### 結果不明の手動確認
143
+
144
+ 信頼されたサーバー管理処理で `reach.queue.get(queueId)` を使い、宛先や本文を含まない `pending / processing / sending / unknown / sent / discarded / dead` 状態を確認します。unknown は古い worker を停止・隔離し、外部の配信記録と照合してから sent または discarded に裁定します。UUID・状態の検証と条件付き更新により並行裁定は 1 件だけ成功し、再送はしません。discarded は開始済みリクエストを取り消しません。補充送信は古いリクエストが今後作用しないことを確認した後だけ行ってください。
145
+
146
+ ```ts
147
+ const state = await reach.queue.get(queueId)
148
+ const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
149
+ if (!resolved.success)
150
+ throw new Error(resolved.error.message)
151
+ ```
152
+
153
+ ### デッドレターの照会と復元
154
+
155
+ 信頼されたサーバー管理処理で `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` を使用します。unknown も照会でき、1 ページ最大 100 件、offset 上限は 1000000 です。get/list は status、attemptCount、maxAttempts、nextAttemptAt、lastFailure、updatedAt のみを返し、宛先・本文・owner 資格情報は含みません。原因を修復後、最新 dead を読み直して条件付きで復元します。
156
+
157
+ ```ts
158
+ const state = await reach.queue.get(queueId)
159
+ if (!state.success)
160
+ throw new Error(state.error.message)
161
+ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
162
+ const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
163
+ if (!restored.success)
164
+ throw new Error(restored.error.message)
165
+ }
166
+ ```
167
+
168
+ 一致する dead バージョンのみ復元し、並行・古い要求は失敗、unknown は復元できません。回数をリセットしますが queueId・保存済み設定・受信側の冪等性要件は保持します。dead は自動処理の停止を意味し、副作用がなかった証明にはなりません。古い冪等 API 要求が遅れて届く可能性があるため、復元前にも受信側の永続的な重複排除を確認してください。修復不能なペイロードは dead に残して調査し、復元だけで内容が直るとは考えないでください。
169
+
170
+ ### 本番の共有状態
171
+
172
+ 本番は console を拒否し、実際の SMTP・SMS・API サービスを必要とします。ログ書込は配信完了ではありません。冪等でないサービスの未知結果は照合が必要で、exactly-once は保証しません。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md).
173
+
174
+ ### 認証情報の通知と永続化の準備
175
+
176
+ パスワード、OTP、リセットトークンは `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` を使います。expiresAt は Unix ミリ秒、操作キーに秘密値を含めません。共有台帳は本文・変数・認証値のハッシュを保存せず、脱機密化した宛先と受領状態のみを保持します。遅延キューに入れず、DND/期限切れは送信せず、不明な配信を自動再送しません。成功時は receiptId を返します。明示した開発用コンソール出力は永続観測から分離し、本番 Console 制限は維持します。通常通知は deferPolicy: reject で二重キューを防げます。
177
+
178
+ `migrateReach({ dryRun: true })` で永続構造を確認し、リポジトリ初期化で準備します。失敗時は停止し、事前バックアップと競合書込の停止を行い、送信台帳を保持します。構造準備を資格情報の再送許可と解釈しないでください。
179
+
180
+ ### API 応答と送信先の境界
181
+
182
+ 204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
183
+
184
+ API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
185
+
186
+ PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
187
+
188
+ ## 設定
189
+
190
+ ライフサイクル。init にバックエンド/業務設定を渡し成功後に使用します。借用依存先の解放前に close の完了を待ち、正確な戻り値は例と公開署名に従います。
107
191
 
108
192
  Provider 名は一意である必要があり、重複は接続前に CONFIG_ERROR を返します。`close()` は初期化を待って Provider を解放し、終了後の状態やタイマーの再公開を防ぎます。
109
193
 
110
- #### Console(開発・テスト用)
194
+ ### Console(開発・テスト用)
111
195
 
112
196
  ```ts
113
197
  const config = { name: 'dev', type: 'console' }
@@ -189,7 +273,7 @@ if (result.data.deferred) {
189
273
  }
190
274
  ```
191
275
 
192
- キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date` に対応します。デプロイ時に試行回数・退避設定・失敗分類を含むテーブル構造も更新してください。対応する旧表はモジュール所有のバージョン移行で更新します(下記)。
276
+ キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date` に対応します。デプロイ時に試行回数・退避設定・失敗分類を含むテーブル構造も更新してください。構造はモジュールの永続化準備処理が管理します(下記)。
193
277
 
194
278
  #### 永続化された再試行設定
195
279
 
@@ -203,7 +287,13 @@ if (result.data.deferred) {
203
287
 
204
288
  取得ごとに保存済み `attemptCount` を原子的に増やします。一時的失敗は `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 以降に再取得でき、DND 外の毎分走査によって実際の送信はさらに遅れる場合があります。1 回の走査は最大 100 件で取得資格の時刻を固定するため、失敗したメッセージが同じ回を繰り返し占有しません。Provider 未設定・未接続、ネットワーク障害、HTTP 408/425/429/5xx は有限回再試行します。不正な保存 JSON と冪等 API の他の HTTP 4xx は直ちに `dead`、上限到達や最後の取得後のクラッシュも dead になります。冪等性のない配信中リクエストは残り回数によらず `unknown` に隔離します。
205
289
 
206
- ### エラー処理
290
+ 設定はホストが読込・検証してから渡します。秘密はサーバーの非公開環境に保存し、利用場面に応じてバックエンド・容量・期限・ログを選びます。変更後は再初期化の結果を確認してください。
291
+
292
+ [項目別の用途・適用場面・例](../../docs/usage/configuration/operations.md).
293
+
294
+ ## エラー処理
295
+
296
+ 領域操作は result.success を確認し、失敗時はモジュールのエラー定義と安全な説明を使います。コード・関連 ID・秘匿した原因をサーバーログに残します。同期ツール・ファクトリー・ストリーム反復・ライフサイクルは各署名に従い、Promise 完了だけで成功と判断しません。回復条件は各機能を参照してください。
207
297
 
208
298
  すべての操作は `HaiResult<T>` を返します。エラーコードは `HaiReachError` で定義します:
209
299
 
@@ -227,61 +317,6 @@ if (!result.success) {
227
317
  }
228
318
  ```
229
319
 
230
- ### 分散ロック
231
-
232
- 任意のキャッシュロックは重複走査を減らすためのもので、300 秒後には複数ノードが走査できます。正しさはデータベースの配信状態で保証します。1 回に最大 100 件を 1 件ずつ取得し、外部呼び出し前に有効な owner/lease を原子的に確認して sending を保存します。配信中はリース期間の 3 分の 1 ごとに更新し、期限切れ owner の復活・再送・結果確定を拒否します。DND 時間外は毎分復旧を試みます。
233
-
234
- - 状態は `pending → processing → sending → sent`。期限切れ processing は安全に再取得できます。
235
- - `idempotent: true` の API のみ配信中リクエストを自動再試行します。これは受信側の能力宣言です。各試行の `Idempotency-Key` ヘッダーと `idempotencyKey` 本文は永続化された queueId を使い、受信側が重複排除と副作用を原子的に確定する必要があります。古い worker の遅延復帰も保持期間に含め、最大遅延を限定できない場合は重複排除レコードを失効させないでください。同名 Provider は同じ送信先と重複排除ストアを使います。
236
- - SMTP、Aliyun SMS、既定の API には外部冪等性の保証がありません。sending 開始後の失敗・例外・リース喪失は結果不明として扱い、期限切れは unknown に隔離して自動再送しません。未接続 Provider は保存済み退避設定に従い、不正な保存 JSON は外部呼び出し前に dead へ移します。Console は開発用メタデータのみを出力します。
237
- - リース更新、owner 条件、キャンセルでは既に開始した外部副作用を取り消せません。汎用の exactly-once は保証しません。
238
-
239
- ### 結果不明の手動確認
240
-
241
- 信頼されたサーバー管理処理で `reach.queue.get(queueId)` を使い、宛先や本文を含まない `pending / processing / sending / unknown / sent / discarded / dead` 状態を確認します。unknown は古い worker を停止・隔離し、外部の配信記録と照合してから sent または discarded に裁定します。UUID・状態の検証と条件付き更新により並行裁定は 1 件だけ成功し、再送はしません。discarded は開始済みリクエストを取り消しません。補充送信は古いリクエストが今後作用しないことを確認した後だけ行ってください。
242
-
243
- ```ts
244
- const state = await reach.queue.get(queueId)
245
- const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
246
- if (!resolved.success)
247
- throw new Error(resolved.error.message)
248
- ```
249
-
250
- ### デッドレターの照会と復元
251
-
252
- 信頼されたサーバー管理処理で `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` を使用します。unknown も照会でき、1 ページ最大 100 件、offset 上限は 1000000 です。get/list は status、attemptCount、maxAttempts、nextAttemptAt、lastFailure、updatedAt のみを返し、宛先・本文・owner 資格情報は含みません。原因を修復後、最新 dead を読み直して条件付きで復元します。
253
-
254
- ```ts
255
- const state = await reach.queue.get(queueId)
256
- if (!state.success)
257
- throw new Error(state.error.message)
258
- if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
259
- const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
260
- if (!restored.success)
261
- throw new Error(restored.error.message)
262
- }
263
- ```
264
-
265
- 一致する dead バージョンのみ復元し、並行・古い要求は失敗、unknown は復元できません。回数をリセットしますが queueId・保存済み設定・受信側の冪等性要件は保持します。dead は自動処理の停止を意味し、副作用がなかった証明にはなりません。古い冪等 API 要求が遅れて届く可能性があるため、復元前にも受信側の永続的な重複排除を確認してください。修復不能なペイロードは dead に残して調査し、復元だけで内容が直るとは考えないでください。
266
-
267
- ## 本番の共有状態
268
-
269
- 本番は console を拒否し、実際の SMTP・SMS・API サービスを必要とします。ログ書込は配信完了ではありません。冪等でないサービスの未知結果は照合が必要で、exactly-once は保証しません。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
270
-
271
- ## 認証情報の通知と更新
272
-
273
- パスワード、OTP、リセットトークンは `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` を使います。expiresAt は Unix ミリ秒、操作キーに秘密値を含めません。共有台帳は本文・変数・認証値のハッシュを保存せず、脱機密化した宛先と受領状態のみを保持します。遅延キューに入れず、DND/期限切れは送信せず、不明な配信を自動再送しません。成功時は receiptId を返します。明示した開発用コンソール出力は永続観測から分離し、本番 Console 制限は維持します。通常通知は deferPolicy: reject で二重キューを防げます。
274
-
275
- `migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
276
-
277
- ## API 応答と送信先の境界
278
-
279
- 204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
280
-
281
- API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
282
-
283
- PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
284
-
285
320
  ## 開発とテスト
286
321
 
287
322
  ```bash
package/README.md CHANGED
@@ -4,30 +4,15 @@
4
4
 
5
5
  [框架首页](../../README.md) · 相关模块:[@h-ai/cache](../cache/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/reldb](../reldb/README.md)
6
6
 
7
- ## 简介
8
-
9
7
  用户触达模块,支持同时注册多个 Provider(邮件、短信、API 回调等),通过模板绑定 Provider 实现按场景路由发送。内置免打扰(DND)机制,支持通过配置文件定义模板。
10
8
 
11
- 模板发送只需 `template`、`to` 和 `vars`,Provider 默认来自模板;显式 `provider` 仍可覆盖。无模板的直接发送必须提供 Provider,缺少时返回失败 HaiResult。
12
-
13
- ## 核心功能清单
14
-
15
- | Provider | 渠道 | 说明 |
16
- | ------------ | ---- | ------------------------------------------- |
17
- | `console` | 通用 | 控制台输出(开发/测试用) |
18
- | `smtp` | 邮件 | SMTP 协议发送邮件(需安装 nodemailer) |
19
- | `aliyun-sms` | 短信 | 阿里云短信服务(直接调用 HTTP API,无 SDK) |
20
- | `api` | 通用 | HTTP API 回调(通用 webhook) |
21
-
22
- ## 使用入门
9
+ ## 快速开始
23
10
 
24
11
  ### 依赖
25
12
 
26
13
  - `@h-ai/reldb` — 数据库(发送日志与模板持久化);配置非空 `templates` 或启用 DND delay 时必须先初始化,模板保存或必需表准备失败时 `reach.init()` 返回 `CONFIG_ERROR` 并关闭候选 Provider。不使用模板/延迟队列的即时发送可选
27
14
  - `@h-ai/cache` — 缓存(DND delay 策略分布式锁),可选;已初始化时自动启用分布式锁
28
15
 
29
- ### 快速开始
30
-
31
16
  #### 安装
32
17
 
33
18
  ```bash
@@ -103,11 +88,110 @@ Provider 失败保留完整模块错误定义(含 HTTP 状态),SMTP/短信
103
88
 
104
89
  一次模板发送使用同一个模板快照渲染主题、正文并选择默认 Provider,避免并发编辑时混用不同版本。显式 `provider` 仍可覆盖模板渠道。
105
90
 
106
- ### 配置
91
+ ## 功能
92
+
93
+ 模板发送只需 `template`、`to` 和 `vars`,Provider 默认来自模板;显式 `provider` 仍可覆盖。无模板的直接发送必须提供 Provider,缺少时返回失败 HaiResult。
94
+
95
+ ### 功能输入输出
96
+
97
+ 以下片段依赖本节完整初始化流程。示例中的业务值由宿主校验和授权后传入;异步领域结果先判断 success,再读取 data。表中 void 表示操作已完成且没有返回数据,null 表示不存在;同步工厂/工具直接返回表中类型。
98
+
99
+ name 是模板名称,vars 是替换变量,template 是包含 provider/body/subject 的定义;queueId 使用持久化 UUID,恢复/裁定输入包含已查询版本与人工决定,不得由外部请求自报。
100
+
101
+ #### reach
102
+
103
+ 通知输入 provider 或 template、接收方及变量,返回 sent/queued 等 SendResult;受理不等于最终送达,未知结果先核对。
104
+
105
+ | 入口 | 输入参数 | 成功结果 | 调用示例 |
106
+ | ------------ | -------- | ------------ | -------------------------------------------------------------------------------------------- |
107
+ | `reach.send` | message | `SendResult` | `await reach.send({ template: 'welcome', to: 'user@example.com', vars: { name: 'Alice' } })` |
108
+
109
+ #### reach.template
110
+
111
+ 模板管理:name 得到模板,模板对象/数组保存或名称删除返回 void,list 返回模板数组,render 输入 name/vars 返回 body、subject 和 provider。
112
+
113
+ | 入口 | 输入参数 | 成功结果 | 调用示例 |
114
+ | -------------------------- | ---------- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
115
+ | `reach.template.resolve` | name | `ReachTemplate` | `await reach.template.resolve('welcome')` |
116
+ | `reach.template.save` | template | `void` | `await reach.template.save({ name: 'welcome', provider: 'mail', body: 'Hello {{name}}', subject: 'Welcome' })` |
117
+ | `reach.template.saveBatch` | templates | `void` | `await reach.template.saveBatch([{ name: 'welcome', provider: 'mail', body: 'Hello {{name}}' }])` |
118
+ | `reach.template.remove` | name | `void` | `await reach.template.remove('welcome')` |
119
+ | `reach.template.list` | 无参数 | `ReachTemplate[]` | `await reach.template.list()` |
120
+ | `reach.template.render` | name, vars | `RenderedTemplate` | `await reach.template.render('welcome', { name: 'Alice' })` |
121
+
122
+ #### reach.queue
123
+
124
+ 运维按 queueId 查询或筛选状态列表;get 返回项/null,list 返回数组。恢复 dead 需期望版本/策略,unknown 需人工证据裁定,成功 void;不能用重试抹去未知副作用。
125
+
126
+ | 入口 | 输入参数 | 成功结果 | 调用示例 |
127
+ | ---------------------------- | ------------------- | ------------------------- | ------------------------------------------------------------- |
128
+ | `reach.queue.get` | queueId | `ReachQueueEntry \| null` | `await reach.queue.get(queueId)` |
129
+ | `reach.queue.list` | options | `ReachQueueEntry[]` | `await reach.queue.list({ status: 'dead', limit: 20 })` |
130
+ | `reach.queue.requeueDead` | queueId, options | `void` | `await reach.queue.requeueDead(queueId, requeueDecision)` |
131
+ | `reach.queue.resolveUnknown` | queueId, resolution | `void` | `await reach.queue.resolveUnknown(queueId, verifiedDecision)` |
132
+
133
+ ### 分布式锁
134
+
135
+ 缓存锁只减少重复扫描,300 秒后可能有多个节点同时扫描;正确性由数据库投递状态保证。每轮最多逐条领取 100 条,实际发送前原子校验有效 owner 并写入 `sending`,在途每隔租约的三分之一续租。过期 owner 不能复活租约、开始新投递或提交结果。DND 时段外每分钟继续恢复遗留队列。
136
+
137
+ - 状态为 `pending → processing → sending → sent`;`processing` 过期可安全重领。
138
+ - API 只有配置 `idempotent: true` 才允许重领在途消息。这是对接收端能力的声明,不会凭空实现幂等。每次请求的 `Idempotency-Key` 请求头和 `idempotencyKey` 正文字段均固定为持久化 `queueId`;接收端必须把去重记录和副作用原子提交。去重记录必须覆盖所有迟到请求,包括暂停后恢复的旧节点;无法界定最长迟到时间时不能过期删除。各节点同名 Provider 必须连接同一目标和幂等存储。
139
+ - SMTP、阿里云短信、默认 API 没有外部幂等保证。已经进入 `sending` 后的失败、异常或租约丢失按未知结果处理,过期后转为 `unknown`,新消费者不会自动重发。未连接 Provider 按持久策略退避;发送前解析失败直接进入 dead。Console 仅输出开发元数据,不产生真实投递。
140
+ - 续租、数据库 owner 条件和取消信号都不能撤销已发出的邮件、短信或 HTTP 副作用;模块不提供通用 exactly-once 保证。
141
+
142
+ ### 未知结果人工核对
143
+
144
+ 可信服务端管理流程使用 `reach.queue.get(queueId)` 查询 `pending / processing / sending / unknown / sent / discarded / dead`;结果不含正文或收件人。出现 unknown 时先停止或隔离旧 worker,按 queueId、Provider 日志和第三方投递记录核对,再调用以下裁定接口。接口校验 UUID 和状态,只能把 unknown 条件更新为 sent 或 discarded,并发裁定仅一个成功;不会重新发送。discarded 只终止后续处理,不能撤销已经开始的请求。如需补发,须先确认旧请求不可能继续生效,再显式创建新消息。
145
+
146
+ ```ts
147
+ const state = await reach.queue.get(queueId)
148
+ const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
149
+ if (!resolved.success)
150
+ throw new Error(resolved.error.message)
151
+ ```
152
+
153
+ ### 死信查询与恢复
154
+
155
+ 可信服务端可用 `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` 查询死信,也可查询 `unknown`;每页最多 100 条,offset 上限 1000000。get/list 返回状态、attemptCount、maxAttempts、nextAttemptAt、lastFailure 和 updatedAt,不含正文、收件人及 owner。修复 Provider 配置或消息根因后,查询最新 dead 版本再恢复:
156
+
157
+ ```ts
158
+ const state = await reach.queue.get(queueId)
159
+ if (!state.success)
160
+ throw new Error(state.error.message)
161
+ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
162
+ const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
163
+ if (!restored.success)
164
+ throw new Error(restored.error.message)
165
+ }
166
+ ```
167
+
168
+ 恢复仅条件更新同一版本的 dead;并发/过时请求失败,unknown 无法恢复。它重置领取次数并保留 queueId、原退避策略及接收端幂等要求。dead 仅表示停止自动处理,不证明外部副作用未发生;曾开始的幂等 API 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
169
+
170
+ ### 生产共享状态
171
+
172
+ 生产拒绝 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).
173
+
174
+ ### 凭据通知与持久化准备
175
+
176
+ 发送密码、OTP 或重置令牌时使用 `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })`。expiresAt 是 Unix 毫秒,稳定操作键不可包含凭据。必须有共享发送账本:只持久化无载荷回执/状态,接收方脱敏,不存正文、变量或低熵凭据摘要,也不进入延迟队列。发送成功返回 receiptId;DND/过期不投递,异常或不明状态不自动重投。明确启用的开发控制台仍可显示业务允许的通知,持久采集观察器被隔离;生产 Console Provider 限制不变。普通通知可用 deferPolicy: reject 阻止二次排队。
177
+
178
+ `migrateReach({ dryRun: true })` 预检持久结构,仓储初始化执行支持的准备步骤;失败时停止初始化。执行前备份并停止冲突写入,保留发送账本,不把结构准备作为重新发送凭据的理由。
179
+
180
+ ### API 回执与目标边界
181
+
182
+ 空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
183
+
184
+ API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
185
+
186
+ PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
187
+
188
+ ## 配置
189
+
190
+ 生命周期:init 输入后端/业务配置,成功后才能使用;close 在释放借用依赖前等待完成。返回值以快速开始示例和公开签名为准。
107
191
 
108
192
  Provider 名称必须唯一,重复配置在连接前返回 CONFIG_ERROR。`close()` 等待在途初始化并回收其 Provider;关闭返回后不会迟到发布模块或定时器。
109
193
 
110
- #### Console(开发/测试)
194
+ ### Console(开发/测试)
111
195
 
112
196
  ```ts
113
197
  const config = { name: 'dev', type: 'console' }
@@ -189,7 +273,7 @@ if (result.data.deferred) {
189
273
  }
190
274
  ```
191
275
 
192
- 队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;旧表升级由模块维护的版本迁移负责,见下文。
276
+ 队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;结构准备由模块维护,见下文持久化准备。
193
277
 
194
278
  #### 可靠队列重试
195
279
 
@@ -203,7 +287,13 @@ if (result.data.deferred) {
203
287
 
204
288
  每次领取在数据库原子递增 `attemptCount`。暂时失败在 `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 之后才有资格再次领取;DND 外每分钟扫描,因此实际发送可能更晚。一轮最多处理 100 条,并固定本轮资格时间;失败的消息在该轮不会重新入选。未连接/缺失 Provider、网络错误、HTTP 408/425/429/5xx 可以有限重试;持久化载荷解析失败、幂等 API 的其他 HTTP 4xx 直接进入 `dead`。超出次数或最后一次领取后崩溃也进入 `dead`。非幂等在途请求仍优先进入 `unknown`,不因剩余预算而重发。
205
289
 
206
- ### 错误处理
290
+ 配置由宿主加载和校验,再传入本模块;私密值放服务端私有环境,不传到浏览器。按实际使用场景逐项选择后端、容量、期限和日志策略,修改后重新初始化并核验成功结果。
291
+
292
+ [逐项配置说明、适用场景和示例](../../docs/usage/configuration/operations.md).
293
+
294
+ ## 错误处理
295
+
296
+ 领域操作先判断 `result.success`,失败按本模块错误定义与安全提示处理,服务端保留错误码、关联编号和脱敏原因。同步工具、工厂、流迭代及生命周期按各自签名处理,不把 Promise 完成视为业务成功。具体失败与恢复边界见对应功能。
207
297
 
208
298
  所有操作返回 `HaiResult<T>`,错误码定义在 `HaiReachError`:
209
299
 
@@ -227,61 +317,6 @@ if (!result.success) {
227
317
  }
228
318
  ```
229
319
 
230
- ### 分布式锁
231
-
232
- 缓存锁只减少重复扫描,300 秒后可能有多个节点同时扫描;正确性由数据库投递状态保证。每轮最多逐条领取 100 条,实际发送前原子校验有效 owner 并写入 `sending`,在途每隔租约的三分之一续租。过期 owner 不能复活租约、开始新投递或提交结果。DND 时段外每分钟继续恢复遗留队列。
233
-
234
- - 状态为 `pending → processing → sending → sent`;`processing` 过期可安全重领。
235
- - API 只有配置 `idempotent: true` 才允许重领在途消息。这是对接收端能力的声明,不会凭空实现幂等。每次请求的 `Idempotency-Key` 请求头和 `idempotencyKey` 正文字段均固定为持久化 `queueId`;接收端必须把去重记录和副作用原子提交。去重记录必须覆盖所有迟到请求,包括暂停后恢复的旧节点;无法界定最长迟到时间时不能过期删除。各节点同名 Provider 必须连接同一目标和幂等存储。
236
- - SMTP、阿里云短信、默认 API 没有外部幂等保证。已经进入 `sending` 后的失败、异常或租约丢失按未知结果处理,过期后转为 `unknown`,新消费者不会自动重发。未连接 Provider 按持久策略退避;发送前解析失败直接进入 dead。Console 仅输出开发元数据,不产生真实投递。
237
- - 续租、数据库 owner 条件和取消信号都不能撤销已发出的邮件、短信或 HTTP 副作用;模块不提供通用 exactly-once 保证。
238
-
239
- ### 未知结果人工核对
240
-
241
- 可信服务端管理流程使用 `reach.queue.get(queueId)` 查询 `pending / processing / sending / unknown / sent / discarded / dead`;结果不含正文或收件人。出现 unknown 时先停止或隔离旧 worker,按 queueId、Provider 日志和第三方投递记录核对,再调用以下裁定接口。接口校验 UUID 和状态,只能把 unknown 条件更新为 sent 或 discarded,并发裁定仅一个成功;不会重新发送。discarded 只终止后续处理,不能撤销已经开始的请求。如需补发,须先确认旧请求不可能继续生效,再显式创建新消息。
242
-
243
- ```ts
244
- const state = await reach.queue.get(queueId)
245
- const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
246
- if (!resolved.success)
247
- throw new Error(resolved.error.message)
248
- ```
249
-
250
- ### 死信查询与恢复
251
-
252
- 可信服务端可用 `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` 查询死信,也可查询 `unknown`;每页最多 100 条,offset 上限 1000000。get/list 返回状态、attemptCount、maxAttempts、nextAttemptAt、lastFailure 和 updatedAt,不含正文、收件人及 owner。修复 Provider 配置或消息根因后,查询最新 dead 版本再恢复:
253
-
254
- ```ts
255
- const state = await reach.queue.get(queueId)
256
- if (!state.success)
257
- throw new Error(state.error.message)
258
- if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
259
- const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
260
- if (!restored.success)
261
- throw new Error(restored.error.message)
262
- }
263
- ```
264
-
265
- 恢复仅条件更新同一版本的 dead;并发/过时请求失败,unknown 无法恢复。它重置领取次数并保留 queueId、原退避策略及接收端幂等要求。dead 仅表示停止自动处理,不证明外部副作用未发生;曾开始的幂等 API 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
266
-
267
- ## 生产共享状态
268
-
269
- 生产拒绝 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)。
270
-
271
- ## 凭据通知与旧库升级
272
-
273
- 发送密码、OTP 或重置令牌时使用 `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })`。expiresAt 是 Unix 毫秒,稳定操作键不可包含凭据。必须有共享发送账本:只持久化无载荷回执/状态,接收方脱敏,不存正文、变量或低熵凭据摘要,也不进入延迟队列。发送成功返回 receiptId;DND/过期不投递,异常或不明状态不自动重投。明确启用的开发控制台仍可显示业务允许的通知,持久采集观察器被隔离;生产 Console Provider 限制不变。普通通知可用 deferPolicy: reject 阻止二次排队。
274
-
275
- `migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
276
-
277
- ## API 回执与目标边界
278
-
279
- 空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
280
-
281
- API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
282
-
283
- PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
284
-
285
320
  ## 开发测试
286
321
 
287
322
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/reach",
3
3
  "type": "module",
4
- "version": "1.0.0-beta.19",
4
+ "version": "1.0.0-beta.20",
5
5
  "description": "Hai Framework user reach module for email, SMS, and webhook channels.",
6
6
  "license": "Apache-2.0",
7
7
  "exports": {
@@ -23,9 +23,9 @@
23
23
  "node": "^22.23.4 || ^24.22.1 || ^26.12.1"
24
24
  },
25
25
  "dependencies": {
26
- "@h-ai/cache": "1.0.0-beta.19",
27
- "@h-ai/core": "1.0.0-beta.19",
28
- "@h-ai/reldb": "1.0.0-beta.19",
26
+ "@h-ai/cache": "1.0.0-beta.20",
27
+ "@h-ai/core": "1.0.0-beta.20",
28
+ "@h-ai/reldb": "1.0.0-beta.20",
29
29
  "zod": "^4.6.7"
30
30
  },
31
31
  "optionalDependencies": {