@h-ai/reach 0.1.0-alpha.66 → 0.1.0-alpha.68

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 CHANGED
@@ -4,16 +4,13 @@
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
+
7
9
  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.
8
10
 
9
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.
10
12
 
11
- ## Dependencies
12
-
13
- - `@h-ai/reldb`: optional persistence for delivery logs and templates; enabled automatically when initialized.
14
- - `@h-ai/cache`: optional distributed locks for DND delay; enabled automatically when initialized.
15
-
16
- ## Supported providers
13
+ ## Core features
17
14
 
18
15
  | Provider | Channel | Description |
19
16
  | ------------ | ------- | -------------------------------------------------- |
@@ -22,9 +19,16 @@ Template sends need only `template`, `to` and `vars`; the provider comes from th
22
19
  | `aliyun-sms` | SMS | Alibaba Cloud SMS through HTTP API, without an SDK |
23
20
  | `api` | General | HTTP API callback (generic webhook) |
24
21
 
25
- ## Quick start
22
+ ## Getting started
23
+
24
+ ### Dependencies
26
25
 
27
- ### Installation
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
+ - `@h-ai/cache`: optional distributed locks for DND delay; enabled automatically when initialized.
28
+
29
+ ### Quick start
30
+
31
+ #### Installation
28
32
 
29
33
  ```bash
30
34
  pnpm add @h-ai/reach
@@ -32,61 +36,44 @@ pnpm add @h-ai/reach
32
36
 
33
37
  Install `nodemailer` for email delivery.
34
38
 
35
- ### Initialize multiple providers
39
+ #### Initialize multiple providers
36
40
 
37
41
  ```ts
38
- import { cache } from '@h-ai/cache'
39
42
  import { reach } from '@h-ai/reach'
40
- import { reldb } from '@h-ai/reldb'
41
-
42
- // 1. 初始化可选依赖(按需)
43
- await reldb.init({ type: 'sqlite', database: './data.db' }) // 可选,启用发送日志与模板持久化
44
- await cache.init({ type: 'memory' }) // 可选,启用 DND delay 策略分布式锁
45
-
46
- // 2. 初始化触达模块(自动检测已初始化的 reldb/cache 单例)
47
- await reach.init({
48
- providers: [
49
- { name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com' },
50
- { name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '某某科技' },
51
- { name: 'webhook', type: 'api', url: 'https://api.example.com/notify' },
52
- ],
53
- templates: [
54
- { name: 'welcome_email', provider: 'email', subject: '欢迎 {userName}', body: '亲爱的 {userName},欢迎!' },
55
- { name: 'sms_code', provider: 'sms', body: '验证码: {code},{minutes} 分钟内有效。' },
56
- ],
57
- dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' },
58
- })
59
-
60
- // 通过 provider 字段选择发送渠道
61
- await reach.send({
62
- to: 'user@example.com',
63
- template: 'welcome_email',
64
- vars: { userName: '张三' },
65
- })
66
43
 
67
- // 发送短信(通过 extra 传递 Provider 特有参数)
68
- await reach.send({
69
- provider: 'sms',
70
- to: '13800138000',
71
- extra: { templateCode: 'SMS_123456' },
72
- vars: { code: '123456' },
44
+ const initialized = await reach.init({
45
+ providers: [{ name: 'local', type: 'console' }],
46
+ templates: [{ name: 'welcome', provider: 'local', subject: 'Welcome', body: 'Hello {userName}' }],
73
47
  })
74
-
75
- // 关闭所有 Provider
76
- await reach.close()
48
+ if (!initialized.success)
49
+ throw new Error(initialized.error.message)
50
+ try {
51
+ const result = await reach.send({
52
+ to: 'user@example.com',
53
+ template: 'welcome',
54
+ vars: { userName: 'Alice' },
55
+ })
56
+ if (!result.success)
57
+ throw new Error(result.error.message)
58
+ }
59
+ finally {
60
+ await reach.close()
61
+ }
77
62
  ```
78
63
 
64
+ This example uses the development Console provider. It records delivery metadata and does not send email or SMS. Configure SMTP, SMS or API providers below for real delivery; initialize optional persistence/shared cache when needed.
65
+
79
66
  `reach.config` returns a **redacted configuration snapshot**. Embedded credentials in `api.url` / `endpoint`, `headers.Authorization`, SMTP `pass`, SMS keys, and other secrets are hidden automatically.
80
67
 
81
- ## Configuration
68
+ ### Configuration
82
69
 
83
- ### Console (development/tests)
70
+ #### Console (development/tests)
84
71
 
85
72
  ```ts
86
73
  const config = { name: 'dev', type: 'console' }
87
74
  ```
88
75
 
89
- ### SMTP
76
+ #### SMTP
90
77
 
91
78
  | Field | Type | Required | Default | Description |
92
79
  | -------- | --------- | -------- | ------- | ----------------------- |
@@ -99,7 +86,7 @@ const config = { name: 'dev', type: 'console' }
99
86
  | `pass` | `string` | — | — | Authentication password |
100
87
  | `from` | `string` | ✅ | — | Sender address |
101
88
 
102
- ### Alibaba Cloud SMS
89
+ #### Alibaba Cloud SMS
103
90
 
104
91
  | Field | Type | Required | Default | Description |
105
92
  | ----------------- | -------- | -------- | ----------------------- | -------------------- |
@@ -110,20 +97,21 @@ const config = { name: 'dev', type: 'console' }
110
97
  | `signName` | `string` | ✅ | — | SMS signature |
111
98
  | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
112
99
 
113
- ### API callbacks
100
+ #### API callbacks
114
101
 
115
- | Field | Type | Required | Default | Description |
116
- | --------- | ------------------------ | -------- | ------- | ----------------------- |
117
- | `name` | `string` | ✅ | — | Provider name |
118
- | `type` | `string` | ✅ | — | Fixed `'api'` |
119
- | `url` | `string` | ✅ | — | HTTP(S) callback URL |
120
- | `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP method |
121
- | `headers` | `Record<string, string>` | — | — | Custom request headers |
122
- | `timeout` | `number` | — | `10000` | Timeout in milliseconds |
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 |
123
111
 
124
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.
125
113
 
126
- ### DND (do not disturb)
114
+ #### DND (do not disturb)
127
115
 
128
116
  | Field | Type | Required | Default | Description |
129
117
  | ---------- | ---------------------- | -------- | --------- | ------------------------------------- |
@@ -132,7 +120,33 @@ API callback addresses are trusted server configuration. Logs contain only the c
132
120
  | `start` | `string` | — | `00:00` | Start time (HH:mm) |
133
121
  | `end` | `string` | — | `00:00` | End time (HH:mm) |
134
122
 
135
- ## Error handling
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
+
149
+ ### Error handling
136
150
 
137
151
  All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`:
138
152
 
@@ -144,10 +158,10 @@ All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`
144
158
  | `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | Invalid recipient |
145
159
  | `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider not registered |
146
160
  | `HaiReachError.DND_BLOCKED` | `hai:reach:006` | Dropped by DND |
147
- | `HaiReachError.DND_DEFERRED` | `hai:reach:007` | Queued for DND delay |
148
161
  | `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | Not initialized |
149
162
  | `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | Unsupported type |
150
163
  | `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | Invalid configuration |
164
+ | `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | Permanent API rejection |
151
165
 
152
166
  ```ts
153
167
  const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
@@ -156,21 +170,55 @@ if (!result.success) {
156
170
  }
157
171
  ```
158
172
 
159
- ## Distributed locks
173
+ ### Distributed locks
174
+
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.
176
+
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.
160
209
 
161
- Across nodes, flushing pending DND-delay messages is protected by `@h-ai/cache` distributed locks, allowing only one node to flush at a time.
210
+ ## Production shared state
162
211
 
163
- - Uses `cache.lock.acquire('reach:flush-pending')` with a 300-second TTL.
164
- - Claims pending records in batches. In-progress records expire, allowing another node to take over after failure and reducing duplicate delivery risk.
165
- - Without initialized `@h-ai/cache`, distributed locking is disabled automatically; single-node operation continues.
166
- - A stable process-level owner identifier prevents releasing another owner's lock.
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)。
167
213
 
168
- ## Tests
214
+ ## Development and testing
169
215
 
170
216
  ```bash
171
217
  pnpm --filter @h-ai/reach test
172
218
  ```
173
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
+
174
222
  ## License
175
223
 
176
224
  Apache-2.0
package/README.ja.md CHANGED
@@ -4,16 +4,13 @@
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
+
7
9
  メール・SMS・API 通知など複数の Provider を同時登録できる通知モジュールです。テンプレートと Provider の対応で用途に応じて配信します。通知抑制(DND)と設定ファイルによるテンプレート定義を備えます。
8
10
 
9
11
  テンプレート送信は `template`、`to`、`vars` のみで利用でき、Provider はテンプレートから選ばれます。明示した `provider` は優先されます。直接送信は Provider が必須で、省略すると失敗 HaiResult を返します。
10
12
 
11
- ## 依存関係
12
-
13
- - `@h-ai/reldb`:送信ログ・テンプレートの永続化(任意)。初期化済みなら自動有効化。
14
- - `@h-ai/cache`:DND delay 用の分散ロック(任意)。初期化済みなら自動有効化。
15
-
16
- ## 対応 Provider
13
+ ## 主な機能
17
14
 
18
15
  | Provider | チャネル | 説明 |
19
16
  | ------------ | -------- | -------------------------------------------------- |
@@ -22,9 +19,16 @@
22
19
  | `aliyun-sms` | SMS | Alibaba Cloud SMS(HTTP API を直接使用、SDK 不要) |
23
20
  | `api` | 汎用 | HTTP API 通知(汎用 webhook) |
24
21
 
25
- ## クイックスタート
22
+ ## 使い始める
23
+
24
+ ### 依存関係
26
25
 
27
- ### インストール
26
+ - `@h-ai/reldb`:送信ログ・テンプレートの永続化。`dnd.enabled: true` と `strategy: delay` を使う場合は事前初期化とキューテーブルが必須で、利用できなければ `reach.init()` は `CONFIG_ERROR` を返します。それ以外の送信では任意です。
27
+ - `@h-ai/cache`:DND delay 用の分散ロック(任意)。初期化済みなら自動有効化。
28
+
29
+ ### クイックスタート
30
+
31
+ #### インストール
28
32
 
29
33
  ```bash
30
34
  pnpm add @h-ai/reach
@@ -32,61 +36,44 @@ pnpm add @h-ai/reach
32
36
 
33
37
  メール送信には `nodemailer` をインストールしてください。
34
38
 
35
- ### 複数 Provider の初期化
39
+ #### 複数 Provider の初期化
36
40
 
37
41
  ```ts
38
- import { cache } from '@h-ai/cache'
39
42
  import { reach } from '@h-ai/reach'
40
- import { reldb } from '@h-ai/reldb'
41
-
42
- // 1. 初始化可选依赖(按需)
43
- await reldb.init({ type: 'sqlite', database: './data.db' }) // 可选,启用发送日志与模板持久化
44
- await cache.init({ type: 'memory' }) // 可选,启用 DND delay 策略分布式锁
45
-
46
- // 2. 初始化触达模块(自动检测已初始化的 reldb/cache 单例)
47
- await reach.init({
48
- providers: [
49
- { name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com' },
50
- { name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '某某科技' },
51
- { name: 'webhook', type: 'api', url: 'https://api.example.com/notify' },
52
- ],
53
- templates: [
54
- { name: 'welcome_email', provider: 'email', subject: '欢迎 {userName}', body: '亲爱的 {userName},欢迎!' },
55
- { name: 'sms_code', provider: 'sms', body: '验证码: {code},{minutes} 分钟内有效。' },
56
- ],
57
- dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' },
58
- })
59
-
60
- // 通过 provider 字段选择发送渠道
61
- await reach.send({
62
- to: 'user@example.com',
63
- template: 'welcome_email',
64
- vars: { userName: '张三' },
65
- })
66
43
 
67
- // 发送短信(通过 extra 传递 Provider 特有参数)
68
- await reach.send({
69
- provider: 'sms',
70
- to: '13800138000',
71
- extra: { templateCode: 'SMS_123456' },
72
- vars: { code: '123456' },
44
+ const initialized = await reach.init({
45
+ providers: [{ name: 'local', type: 'console' }],
46
+ templates: [{ name: 'welcome', provider: 'local', subject: 'Welcome', body: 'Hello {userName}' }],
73
47
  })
74
-
75
- // 关闭所有 Provider
76
- await reach.close()
48
+ if (!initialized.success)
49
+ throw new Error(initialized.error.message)
50
+ try {
51
+ const result = await reach.send({
52
+ to: 'user@example.com',
53
+ template: 'welcome',
54
+ vars: { userName: 'Alice' },
55
+ })
56
+ if (!result.success)
57
+ throw new Error(result.error.message)
58
+ }
59
+ finally {
60
+ await reach.close()
61
+ }
77
62
  ```
78
63
 
64
+ この例は開発用 Console Provider で送信メタデータを記録し、実メールや SMS は送りません。実送信には下記の SMTP・SMS・API を設定し、必要に応じて永続化と共有キャッシュを初期化してください。
65
+
79
66
  `reach.config` は**機密情報をマスクした設定スナップショット**を返します。`api.url` / `endpoint` 内の認証情報、`headers.Authorization`、SMTP の `pass`、SMS の鍵などは自動的に非表示になります。
80
67
 
81
- ## 設定
68
+ ### 設定
82
69
 
83
- ### Console(開発・テスト用)
70
+ #### Console(開発・テスト用)
84
71
 
85
72
  ```ts
86
73
  const config = { name: 'dev', type: 'console' }
87
74
  ```
88
75
 
89
- ### SMTP
76
+ #### SMTP
90
77
 
91
78
  | フィールド | 型 | 必須 | 既定値 | 説明 |
92
79
  | ---------- | --------- | ---- | ------ | ----------------------- |
@@ -99,7 +86,7 @@ const config = { name: 'dev', type: 'console' }
99
86
  | `pass` | `string` | — | — | 認証パスワード |
100
87
  | `from` | `string` | ✅ | — | 送信元アドレス |
101
88
 
102
- ### Alibaba Cloud SMS
89
+ #### Alibaba Cloud SMS
103
90
 
104
91
  | フィールド | 型 | 必須 | 既定値 | 説明 |
105
92
  | ----------------- | -------- | ---- | ----------------------- | --------------------- |
@@ -110,20 +97,21 @@ const config = { name: 'dev', type: 'console' }
110
97
  | `signName` | `string` | ✅ | — | SMS 署名 |
111
98
  | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
112
99
 
113
- ### API 通知
100
+ #### API 通知
114
101
 
115
- | フィールド | 型 | 必須 | 既定値 | 説明 |
116
- | ---------- | ------------------------ | ---- | ------- | ------------------------ |
117
- | `name` | `string` | ✅ | — | Provider 名 |
118
- | `type` | `string` | ✅ | — | 固定値 `'api'` |
119
- | `url` | `string` | ✅ | — | HTTP(S) 通知 URL |
120
- | `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP メソッド |
121
- | `headers` | `Record<string, string>` | — | — | 独自のリクエストヘッダー |
122
- | `timeout` | `number` | — | `10000` | タイムアウト(ミリ秒) |
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` | 受信側が副作用と重複排除を原子的に永続化する場合のみ有効化 |
123
111
 
124
112
  API 通知先は信頼できるサーバー設定です。ログには origin のみを記録し、URL 認証情報、クエリ、宛先、外部の失敗応答本文は残しません。テナント・ユーザー入力の URL にはアプリでドメインの allowlist を設け、企業環境では出口プロキシやネットワーク方針でも内部メタデータへの接続を防いでください。
125
113
 
126
- ### DND(通知抑制)
114
+ #### DND(通知抑制)
127
115
 
128
116
  | フィールド | 型 | 必須 | 既定値 | 説明 |
129
117
  | ---------- | ---------------------- | ---- | --------- | --------------------------------------- |
@@ -132,7 +120,33 @@ API 通知先は信頼できるサーバー設定です。ログには origin
132
120
  | `start` | `string` | — | `00:00` | 開始時刻(HH:mm) |
133
121
  | `end` | `string` | — | `00:00` | 終了時刻(HH:mm) |
134
122
 
135
- ## エラー処理
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
+
149
+ ### エラー処理
136
150
 
137
151
  すべての操作は `HaiResult<T>` を返します。エラーコードは `HaiReachError` で定義します:
138
152
 
@@ -144,10 +158,10 @@ API 通知先は信頼できるサーバー設定です。ログには origin
144
158
  | `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 無効な宛先 |
145
159
  | `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider が未登録 |
146
160
  | `HaiReachError.DND_BLOCKED` | `hai:reach:006` | DND により破棄 |
147
- | `HaiReachError.DND_DEFERRED` | `hai:reach:007` | DND により一時保留 |
148
161
  | `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初期化 |
149
162
  | `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 未対応の種類 |
150
163
  | `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 設定エラー |
164
+ | `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API による恒久的な拒否 |
151
165
 
152
166
  ```ts
153
167
  const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
@@ -156,21 +170,55 @@ if (!result.success) {
156
170
  }
157
171
  ```
158
172
 
159
- ## 分散ロック
173
+ ### 分散ロック
174
+
175
+ 任意のキャッシュロックは重複走査を減らすためのもので、300 秒後には複数ノードが走査できます。正しさはデータベースの配信状態で保証します。1 回に最大 100 件を 1 件ずつ取得し、外部呼び出し前に有効な owner/lease を原子的に確認して sending を保存します。配信中はリース期間の 3 分の 1 ごとに更新し、期限切れ owner の復活・再送・結果確定を拒否します。DND 時間外は毎分復旧を試みます。
176
+
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 に残して調査し、復元だけで内容が直るとは考えないでください。
160
209
 
161
- 複数ノードでは DND delay の保留メッセージの flush を `@h-ai/cache` の分散ロックで保護し、同時に 1 ノードのみが実行します。
210
+ ## 本番の共有状態
162
211
 
163
- - `cache.lock.acquire('reach:flush-pending')` を使用し、TTL は 300 秒。
164
- - 保留レコードを一括で claim します。処理中レコードには期限があり、ノード異常終了後にほかのノードが引き継いで重複送信のリスクを抑えます。
165
- - `@h-ai/cache` が未初期化なら分散ロックは自動無効化し、単一ノードで動作します。
166
- - 安定したプロセス単位の owner 識別子で、ほかの所有者のロックの誤解除を防ぎます。
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)。
167
213
 
168
- ## テスト
214
+ ## 開発とテスト
169
215
 
170
216
  ```bash
171
217
  pnpm --filter @h-ai/reach test
172
218
  ```
173
219
 
174
- ## License
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
+
222
+ ## ライセンス
175
223
 
176
224
  Apache-2.0