@h-ai/reach 0.1.0-alpha9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import * as _h_ai_core from '@h-ai/core';
3
3
  import { HaiResult } from '@h-ai/core';
4
+ import * as _h_ai_reldb from '@h-ai/reldb';
5
+ import { ReldbFunctions } from '@h-ai/reldb';
4
6
 
5
7
  /**
6
8
  * @h-ai/reach — 配置 Schema
@@ -10,27 +12,25 @@ import { HaiResult } from '@h-ai/core';
10
12
  */
11
13
 
12
14
  /**
13
- * Console Provider 配置(开发/测试用,将消息输出到日志)
15
+ * 开发/测试日志适配器配置;不证明外部投递,生产共享状态守卫拒绝console。
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { ConsoleProviderConfigSchema } from '@h-ai/reach'
20
+ * const config = ConsoleProviderConfigSchema.parse({ name: 'dev-log', type: 'console' })
21
+ * ```
14
22
  */
15
23
  declare const ConsoleProviderConfigSchema: z.ZodObject<{
16
24
  name: z.ZodString;
17
25
  type: z.ZodLiteral<"console">;
18
26
  }, z.core.$strip>;
19
27
  /**
20
- * SMTP 邮件 Provider 配置
28
+ * SMTP配置,默认TLS/465;凭据来自私有环境,SMTP没有接收端持久幂等能力声明。
21
29
  *
22
30
  * @example
23
31
  * ```ts
24
- * {
25
- * name: 'email',
26
- * type: 'smtp',
27
- * host: 'smtp.example.com',
28
- * port: 465,
29
- * secure: true,
30
- * user: 'noreply@example.com',
31
- * pass: 'password',
32
- * from: 'noreply@example.com'
33
- * }
32
+ * import { SmtpProviderConfigSchema } from '@h-ai/reach'
33
+ * const config = SmtpProviderConfigSchema.parse({ name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com', user: process.env.HAI_REACH_PROVIDERS_0_USER, pass: process.env.HAI_REACH_PROVIDERS_0_PASS })
34
34
  * ```
35
35
  */
36
36
  declare const SmtpProviderConfigSchema: z.ZodObject<{
@@ -44,18 +44,12 @@ declare const SmtpProviderConfigSchema: z.ZodObject<{
44
44
  from: z.ZodString;
45
45
  }, z.core.$strip>;
46
46
  /**
47
- * 阿里云短信 Provider 配置(通过 HTTP API 调用,无需 SDK)
47
+ * 阿里云短信POP API配置;Secret只在服务端私有注入,发送需Provider模板编码等扩展参数。
48
48
  *
49
49
  * @example
50
50
  * ```ts
51
- * {
52
- * name: 'sms',
53
- * type: 'aliyun-sms',
54
- * accessKeyId: 'LTAI...',
55
- * accessKeySecret: '...',
56
- * signName: '某某科技',
57
- * endpoint: 'dysmsapi.aliyuncs.com'
58
- * }
51
+ * import { AliyunSmsProviderConfigSchema } from '@h-ai/reach'
52
+ * const config = AliyunSmsProviderConfigSchema.parse({ name: 'sms', type: 'aliyun-sms', accessKeyId: process.env.HAI_REACH_PROVIDERS_0_ACCESSKEYID, accessKeySecret: process.env.HAI_REACH_PROVIDERS_0_ACCESSKEYSECRET, signName: process.env.HAI_REACH_PROVIDERS_0_SIGNNAME })
59
53
  * ```
60
54
  */
61
55
  declare const AliyunSmsProviderConfigSchema: z.ZodObject<{
@@ -67,17 +61,13 @@ declare const AliyunSmsProviderConfigSchema: z.ZodObject<{
67
61
  endpoint: z.ZodDefault<z.ZodString>;
68
62
  }, z.core.$strip>;
69
63
  /**
70
- * API 回调 Provider 配置(通用 HTTP 回调)
64
+ * HTTP回调配置;idempotent默认false,只有接收端按稳定Idempotency-Key原子持久去重并覆盖旧请求迟到窗口才允许true。
65
+ * 写入该布尔值或发送请求头本身不证明接收端具备此能力。
71
66
  *
72
67
  * @example
73
68
  * ```ts
74
- * {
75
- * name: 'webhook',
76
- * type: 'api',
77
- * url: 'https://api.example.com/notify',
78
- * method: 'POST',
79
- * headers: { 'Authorization': 'Bearer xxx' }
80
- * }
69
+ * import { ApiProviderConfigSchema } from '@h-ai/reach'
70
+ * const config = ApiProviderConfigSchema.parse({ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false })
81
71
  * ```
82
72
  */
83
73
  declare const ApiProviderConfigSchema: z.ZodObject<{
@@ -90,9 +80,21 @@ declare const ApiProviderConfigSchema: z.ZodObject<{
90
80
  }>>;
91
81
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
92
82
  timeout: z.ZodDefault<z.ZodNumber>;
83
+ redirect: z.ZodDefault<z.ZodEnum<{
84
+ error: "error";
85
+ follow: "follow";
86
+ }>>;
87
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
88
+ idempotent: z.ZodDefault<z.ZodBoolean>;
93
89
  }, z.core.$strip>;
94
90
  /**
95
- * 单个 Provider 配置 Schema(判别联合体)
91
+ * 按type区分内置Provider配置,不把name当协议类型;name用于模板/消息路由。
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { ProviderConfigSchema } from '@h-ai/reach'
96
+ * const config = ProviderConfigSchema.parse({ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false })
97
+ * ```
96
98
  */
97
99
  declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
98
100
  name: z.ZodString;
@@ -123,34 +125,150 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
123
125
  }>>;
124
126
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
125
127
  timeout: z.ZodDefault<z.ZodNumber>;
128
+ redirect: z.ZodDefault<z.ZodEnum<{
129
+ error: "error";
130
+ follow: "follow";
131
+ }>>;
132
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
133
+ idempotent: z.ZodDefault<z.ZodBoolean>;
126
134
  }, z.core.$strip>], "type">;
127
- /** 单个 Provider 配置类型(parse 后) */
135
+ /**
136
+ * 由ProviderConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * import { ProviderConfigSchema, type ProviderConfig } from '@h-ai/reach'
141
+ * const config: ProviderConfig = ProviderConfigSchema.parse({ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false })
142
+ * ```
143
+ */
128
144
  type ProviderConfig = z.infer<typeof ProviderConfigSchema>;
129
- /** Console 配置类型 */
145
+ /**
146
+ * 由ConsoleProviderConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * import { ConsoleProviderConfigSchema, type ConsoleProviderConfig } from '@h-ai/reach'
151
+ * const config: ConsoleProviderConfig = ConsoleProviderConfigSchema.parse({ name: 'dev-log', type: 'console' })
152
+ * ```
153
+ */
130
154
  type ConsoleProviderConfig = z.infer<typeof ConsoleProviderConfigSchema>;
131
- /** SMTP 配置类型 */
155
+ /**
156
+ * 由SmtpProviderConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * import { SmtpProviderConfigSchema, type SmtpProviderConfig } from '@h-ai/reach'
161
+ * const config: SmtpProviderConfig = SmtpProviderConfigSchema.parse({ name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com', user: process.env.HAI_REACH_PROVIDERS_0_USER, pass: process.env.HAI_REACH_PROVIDERS_0_PASS })
162
+ * ```
163
+ */
132
164
  type SmtpProviderConfig = z.infer<typeof SmtpProviderConfigSchema>;
133
- /** 阿里云短信配置类型 */
165
+ /**
166
+ * 由AliyunSmsProviderConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * import { AliyunSmsProviderConfigSchema, type AliyunSmsProviderConfig } from '@h-ai/reach'
171
+ * const config: AliyunSmsProviderConfig = AliyunSmsProviderConfigSchema.parse({ name: 'sms', type: 'aliyun-sms', accessKeyId: process.env.HAI_REACH_PROVIDERS_0_ACCESSKEYID, accessKeySecret: process.env.HAI_REACH_PROVIDERS_0_ACCESSKEYSECRET, signName: process.env.HAI_REACH_PROVIDERS_0_SIGNNAME })
172
+ * ```
173
+ */
134
174
  type AliyunSmsProviderConfig = z.infer<typeof AliyunSmsProviderConfigSchema>;
135
- /** API 回调配置类型 */
175
+ /**
176
+ * 由ApiProviderConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * import { ApiProviderConfigSchema, type ApiProviderConfig } from '@h-ai/reach'
181
+ * const config: ApiProviderConfig = ApiProviderConfigSchema.parse({ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false })
182
+ * ```
183
+ */
136
184
  type ApiProviderConfig = z.infer<typeof ApiProviderConfigSchema>;
137
185
  /**
138
- * 免打扰时间段配置
186
+ * 人工处置unknown的结构边界,仅sent/discarded,不提供重发;应用须先校验运维权限并隔离旧worker/核对外部效果。
139
187
  *
140
- * 在指定的时间段内,根据策略处理消息:
141
- * - `discard`:直接丢弃,返回 DND_BLOCKED 错误
142
- * - `delay`:暂存到数据库(状态为 pending),DND 结束后由定时任务集中发送
188
+ * @example
189
+ * ```ts
190
+ * import { ReachUnknownResolutionSchema } from '@h-ai/reach'
191
+ * const config = ReachUnknownResolutionSchema.parse({ status: 'discarded' })
192
+ * ```
193
+ */
194
+ declare const ReachUnknownResolutionSchema: z.ZodObject<{
195
+ status: z.ZodEnum<{
196
+ sent: "sent";
197
+ discarded: "discarded";
198
+ }>;
199
+ messageId: z.ZodOptional<z.ZodString>;
200
+ }, z.core.$strip>;
201
+ /**
202
+ * 持久入队回执UUID格式;合法UUID不代表存在,也不授予队列运维权限。
143
203
  *
144
- * 时间使用 HH:mm 格式(24 小时制),支持跨午夜(如 22:00-08:00)。
204
+ * @example
205
+ * ```ts
206
+ * import { ReachQueueIdSchema } from '@h-ai/reach'
207
+ * const config = ReachQueueIdSchema.parse('00000000-0000-4000-8000-000000000001')
208
+ * ```
209
+ */
210
+ declare const ReachQueueIdSchema: z.ZodString;
211
+ /**
212
+ * 可靠队列重试策略,入队时随消息持久保存;maxAttempts含首次与崩溃恢复,指数退避有上限。
145
213
  *
146
214
  * @example
147
215
  * ```ts
148
- * {
149
- * enabled: true,
150
- * strategy: 'delay',
151
- * start: '22:00',
152
- * end: '08:00'
153
- * }
216
+ * import { ReachQueueConfigSchema } from '@h-ai/reach'
217
+ * const config = ReachQueueConfigSchema.parse({ maxAttempts: 5, retryDelayMs: 60000, maxRetryDelayMs: 3600000 })
218
+ * ```
219
+ */
220
+ declare const ReachQueueConfigSchema: z.ZodObject<{
221
+ maxAttempts: z.ZodDefault<z.ZodNumber>;
222
+ retryDelayMs: z.ZodDefault<z.ZodNumber>;
223
+ maxRetryDelayMs: z.ZodDefault<z.ZodNumber>;
224
+ }, z.core.$strip>;
225
+ /**
226
+ * 由ReachQueueConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * import { ReachQueueConfigSchema, type ReachQueueConfig } from '@h-ai/reach'
231
+ * const config: ReachQueueConfig = ReachQueueConfigSchema.parse({ maxAttempts: 5, retryDelayMs: 60000, maxRetryDelayMs: 3600000 })
232
+ * ```
233
+ */
234
+ type ReachQueueConfig = z.infer<typeof ReachQueueConfigSchema>;
235
+ /**
236
+ * 仅分页列出dead/unknown元数据,限制limit/offset;不返回正文/收件人,不替代服务端运维授权。
237
+ *
238
+ * @example
239
+ * ```ts
240
+ * import { ReachQueueListSchema } from '@h-ai/reach'
241
+ * const config = ReachQueueListSchema.parse({ status: 'dead', limit: 10, offset: 0 })
242
+ * ```
243
+ */
244
+ declare const ReachQueueListSchema: z.ZodObject<{
245
+ status: z.ZodEnum<{
246
+ unknown: "unknown";
247
+ dead: "dead";
248
+ }>;
249
+ limit: z.ZodDefault<z.ZodNumber>;
250
+ offset: z.ZodDefault<z.ZodNumber>;
251
+ }, z.core.$strip>;
252
+ /**
253
+ * 恢复dead所需的最近updatedAt条件输入,阻止过时/并发恢复;unknown禁止此操作。
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * import { ReachDeadRequeueSchema } from '@h-ai/reach'
258
+ * const config = ReachDeadRequeueSchema.parse({ expectedUpdatedAt: 1730000000000 })
259
+ * ```
260
+ */
261
+ declare const ReachDeadRequeueSchema: z.ZodObject<{
262
+ expectedUpdatedAt: z.ZodNumber;
263
+ }, z.core.$strip>;
264
+ /**
265
+ * 本机时区的HH:mm免打扰配置,支持跨午夜;start=end表示不拦截。
266
+ * discard返回DND_BLOCKED;delay要求数据库队列就绪且pending写入成功才返回deferred回执。
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * import { DndConfigSchema } from '@h-ai/reach'
271
+ * const config = DndConfigSchema.parse({ enabled: true, strategy: 'delay', start: '22:00', end: '08:00' })
154
272
  * ```
155
273
  */
156
274
  declare const DndConfigSchema: z.ZodObject<{
@@ -162,18 +280,23 @@ declare const DndConfigSchema: z.ZodObject<{
162
280
  start: z.ZodDefault<z.ZodString>;
163
281
  end: z.ZodDefault<z.ZodString>;
164
282
  }, z.core.$strip>;
165
- /** DND 配置类型 */
283
+ /**
284
+ * 由DndConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * import { DndConfigSchema, type DndConfig } from '@h-ai/reach'
289
+ * const config: DndConfig = DndConfigSchema.parse({ enabled: true, strategy: 'delay', start: '22:00', end: '08:00' })
290
+ * ```
291
+ */
166
292
  type DndConfig = z.infer<typeof DndConfigSchema>;
167
293
  /**
168
- * 模板配置 Schema(通过配置文件定义模板)
294
+ * 绑定Provider名称的持久模板配置,{key}仅做文本替换;需要先初始化数据库,不提供HTML消毒。
169
295
  *
170
296
  * @example
171
- * ```yaml
172
- * templates:
173
- * - name: verification_code
174
- * provider: email
175
- * subject: "验证码: {code}"
176
- * body: "您的验证码是 {code},有效期 {minutes} 分钟。"
297
+ * ```ts
298
+ * import { TemplateConfigSchema } from '@h-ai/reach'
299
+ * const config = TemplateConfigSchema.parse({ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' })
177
300
  * ```
178
301
  */
179
302
  declare const TemplateConfigSchema: z.ZodObject<{
@@ -182,13 +305,24 @@ declare const TemplateConfigSchema: z.ZodObject<{
182
305
  subject: z.ZodOptional<z.ZodString>;
183
306
  body: z.ZodString;
184
307
  }, z.core.$strip>;
185
- /** 模板配置类型 */
308
+ /**
309
+ * 由TemplateConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * import { TemplateConfigSchema, type TemplateConfig } from '@h-ai/reach'
314
+ * const config: TemplateConfig = TemplateConfigSchema.parse({ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' })
315
+ * ```
316
+ */
186
317
  type TemplateConfig = z.infer<typeof TemplateConfigSchema>;
187
318
  /**
188
- * 触达模块配置 Schema
319
+ * 模块配置Schema,解析Provider/模板/DND及持久重试默认值;Schema校验不证明外部服务可用或接收端幂等。
189
320
  *
190
- * 接受一个 Provider 配置数组,支持同时注册多个 Provider。
191
- * 支持通过配置文件定义模板和免打扰时间段。
321
+ * @example
322
+ * ```ts
323
+ * import { ReachConfigSchema } from '@h-ai/reach'
324
+ * const config = ReachConfigSchema.parse({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
325
+ * ```
192
326
  */
193
327
  declare const ReachConfigSchema: z.ZodObject<{
194
328
  providers: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -220,6 +354,12 @@ declare const ReachConfigSchema: z.ZodObject<{
220
354
  }>>;
221
355
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
222
356
  timeout: z.ZodDefault<z.ZodNumber>;
357
+ redirect: z.ZodDefault<z.ZodEnum<{
358
+ error: "error";
359
+ follow: "follow";
360
+ }>>;
361
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
362
+ idempotent: z.ZodDefault<z.ZodBoolean>;
223
363
  }, z.core.$strip>], "type">>;
224
364
  templates: z.ZodOptional<z.ZodArray<z.ZodObject<{
225
365
  name: z.ZodString;
@@ -236,14 +376,47 @@ declare const ReachConfigSchema: z.ZodObject<{
236
376
  start: z.ZodDefault<z.ZodString>;
237
377
  end: z.ZodDefault<z.ZodString>;
238
378
  }, z.core.$strip>>;
379
+ queue: z.ZodPrefault<z.ZodObject<{
380
+ maxAttempts: z.ZodDefault<z.ZodNumber>;
381
+ retryDelayMs: z.ZodDefault<z.ZodNumber>;
382
+ maxRetryDelayMs: z.ZodDefault<z.ZodNumber>;
383
+ }, z.core.$strip>>;
239
384
  }, z.core.$strip>;
240
- /** 触达模块配置类型(parse 后) */
385
+ /**
386
+ * 由ReachConfigSchema解析后的配置类型,默认值已经填充;与输入类型区分,秘密字段不写日志。
387
+ *
388
+ * @example
389
+ * ```ts
390
+ * import { ReachConfigSchema, type ReachConfig } from '@h-ai/reach'
391
+ * const config: ReachConfig = ReachConfigSchema.parse({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
392
+ * ```
393
+ */
241
394
  type ReachConfig = z.infer<typeof ReachConfigSchema>;
242
395
  /**
243
- * 触达模块配置输入类型
396
+ * reach.init接收的配置输入,queue默认值可省略;生产选真实Provider、共享数据库,不能用console假定通知完成。
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * import { ReachConfigSchema, type ReachConfigInput } from '@h-ai/reach'
401
+ * const input: ReachConfigInput = { providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } }
402
+ * const parsed = ReachConfigSchema.parse(input)
403
+ * ```
244
404
  */
245
405
  type ReachConfigInput = z.input<typeof ReachConfigSchema>;
246
406
 
407
+ /**
408
+ * Reach标准错误集合;DND拒绝、发送失败与配置错误均通过HaiResult判断,不把失败当已入队。
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * import { reach, HaiReachError } from '@h-ai/reach'
413
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', body: 'hello' })
414
+ * if (!result.success && result.error.code === HaiReachError.NOT_INITIALIZED.code) {
415
+ * // 先执行依赖初始化,不能把未初始化失败当作触达成功。
416
+ * const message = result.error.message
417
+ * }
418
+ * ```
419
+ */
247
420
  declare const HaiReachError: {
248
421
  readonly SEND_FAILED: _h_ai_core.HaiErrorDef;
249
422
  readonly TEMPLATE_NOT_FOUND: _h_ai_core.HaiErrorDef;
@@ -251,46 +424,35 @@ declare const HaiReachError: {
251
424
  readonly INVALID_RECIPIENT: _h_ai_core.HaiErrorDef;
252
425
  readonly PROVIDER_NOT_FOUND: _h_ai_core.HaiErrorDef;
253
426
  readonly DND_BLOCKED: _h_ai_core.HaiErrorDef;
254
- readonly DND_DEFERRED: _h_ai_core.HaiErrorDef;
255
427
  readonly NOT_INITIALIZED: _h_ai_core.HaiErrorDef;
256
428
  readonly UNSUPPORTED_TYPE: _h_ai_core.HaiErrorDef;
257
429
  readonly CONFIG_ERROR: _h_ai_core.HaiErrorDef;
430
+ readonly DELIVERY_REJECTED: _h_ai_core.HaiErrorDef;
431
+ readonly MESSAGE_EXPIRED: _h_ai_core.HaiErrorDef;
258
432
  };
259
433
  /**
260
- * 触达渠道类型
434
+ * 渠道语义标签email/sms/api;消息路由实际使用配置中的Provider名称。
261
435
  *
262
- * - `email` — 邮件
263
- * - `sms` — 短信
264
- * - `api` — API 回调
436
+ * @example
437
+ * ```ts
438
+ * import type { ReachChannel } from '@h-ai/reach'
439
+ * const channel: ReachChannel = 'api'
440
+ * ```
265
441
  */
266
442
  type ReachChannel = 'email' | 'sms' | 'api';
267
443
  /**
268
- * 触达消息(发送请求)
269
- *
270
- * 通过 `provider` 字段指定目标 Provider,通过 `template` 字段指定模板。
444
+ * 直接发送必须指定Provider名和内容;模板发送可由持久模板推导路由,vars做文本替换。
445
+ * 队列幂等键固定为持久queueId;直接调用的业务重试由应用提供稳定idempotencyKey并核验接收端契约。
271
446
  *
272
447
  * @example
273
448
  * ```ts
274
- * // 使用模板发送邮件
275
- * const msg: ReachMessage = {
276
- * provider: 'email',
277
- * to: 'user@example.com',
278
- * template: 'welcome',
279
- * vars: { userName: '张三' },
280
- * }
281
- *
282
- * // 使用模板发送短信
283
- * const msg: ReachMessage = {
284
- * provider: 'sms',
285
- * to: '13800138000',
286
- * template: 'verification_code',
287
- * vars: { code: '123456' },
288
- * }
449
+ * import type { ReachMessage } from '@h-ai/reach'
450
+ * const message: ReachMessage = { provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } }
289
451
  * ```
290
452
  */
291
453
  interface ReachMessage {
292
- /** 目标 Provider 名称(对应 Provider 配置中的 name) */
293
- provider: string;
454
+ /** 目标 Provider 名称;模板发送可省略,直接发送必须提供。 */
455
+ provider?: string;
294
456
  /** 接收方(邮箱地址或手机号) */
295
457
  to: string;
296
458
  /** 邮件主题(直接发送时使用) */
@@ -303,30 +465,410 @@ interface ReachMessage {
303
465
  vars?: Record<string, string>;
304
466
  /** Provider 扩展参数(如短信 Provider 的模板编码、签名等) */
305
467
  extra?: Record<string, unknown>;
468
+ /** API 接收端幂等键;直接发送可指定,队列投递固定使用持久化 queueId。 */
469
+ idempotencyKey?: string;
470
+ /** 凭据通知只在内存中投递;正文、变量、收件人不持久化,不进入延迟或重投队列。 */
471
+ sensitive?: boolean;
472
+ /** sensitive=true 时必填的凭据截止时间(Unix 毫秒),不延长业务凭据有效期。 */
473
+ expiresAt?: number;
474
+ /** 遇到免打扰时交给 Reach 队列,或返回 DND_BLOCKED 让已有业务 outbox 延后;默认 queue。 */
475
+ deferPolicy?: 'queue' | 'reject';
306
476
  }
307
477
  /**
308
- * 发送结果
478
+ * 发送或可靠入队结果;deferred=true只证明持久pending写入成功,queueId用于后续查询,不代表已送达。
479
+ *
480
+ * @example
481
+ * ```ts
482
+ * import { reach, type SendResult } from '@h-ai/reach'
483
+ * const { reldb } = await import('@h-ai/reldb')
484
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
485
+ * if (!connected.success) throw new Error(connected.error.message)
486
+ * try {
487
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
488
+ * if (!initialized.success) throw new Error(initialized.error.message)
489
+ * try {
490
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', body: 'hello' })
491
+ * if (!result.success) throw new Error(result.error.message)
492
+ * const outcome: SendResult = result.data
493
+ * if (outcome.deferred) {
494
+ * const queued = await reach.queue.get(outcome.queueId)
495
+ * if (!queued.success) throw new Error(queued.error.message)
496
+ * }
497
+ * }
498
+ * finally { await reach.close() }
499
+ * }
500
+ * finally {
501
+ * const closed = await reldb.close()
502
+ * if (!closed.success) throw new Error(closed.error.message)
503
+ * }
504
+ * ```
309
505
  */
310
- interface SendResult {
311
- /** 是否发送成功 */
506
+ type SendResult = {
507
+ /** 无载荷凭据回执,可通过 queue.get 查询;不代表进入延迟队列。 */
508
+ receiptId?: string;
509
+ /** 是否已完成 Provider 发送或可靠入队;具体状态同时检查 deferred。 */
312
510
  success: boolean;
313
511
  /** Provider 返回的消息 ID(如有) */
314
512
  messageId?: string;
315
- /** 是否被延时(DND delay 策略暂存,待 DND 结束后发送) */
316
- deferred?: boolean;
513
+ } & ({
514
+ /** 已可靠入队,尚未投递。 */
515
+ deferred: true;
516
+ /** 持久化回执 UUID,对应 hai_reach_send_log.queue_id。 */
517
+ queueId: string;
518
+ } | {
519
+ /** Provider 即时发送结果,不含入队回执。 */
520
+ deferred?: false;
521
+ queueId?: never;
522
+ });
523
+ /**
524
+ * 持久队列状态;sending已开始外部效果,非幂等失败/失租约进unknown禁止自动重发;dead停止自动处理但可条件恢复。
525
+ *
526
+ * @example
527
+ * ```ts
528
+ * import type { ReachQueueStatus } from '@h-ai/reach'
529
+ * const status: ReachQueueStatus = 'unknown'
530
+ * ```
531
+ */
532
+ type ReachQueueStatus = 'pending' | 'processing' | 'sending' | 'unknown' | 'sent' | 'discarded' | 'dead';
533
+ /**
534
+ * 有界失败分类,不保存Provider响应正文、凭据或收件人;用于修复根因和处置死信。
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * import type { ReachQueueFailure } from '@h-ai/reach'
539
+ * const failure: ReachQueueFailure = 'provider_unavailable'
540
+ * ```
541
+ */
542
+ type ReachQueueFailure = 'provider_unavailable' | 'invalid_message' | 'permanent_rejection' | 'transient_failure' | 'attempts_exhausted';
543
+ /**
544
+ * 不含消息正文/收件人的队列运维快照;时间为安全整数Unix毫秒,计数/策略来自持久消息。
545
+ *
546
+ * @example
547
+ * ```ts
548
+ * import { reach, type ReachQueueEntry } from '@h-ai/reach'
549
+ * const { reldb } = await import('@h-ai/reldb')
550
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
551
+ * if (!connected.success) throw new Error(connected.error.message)
552
+ * try {
553
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
554
+ * if (!initialized.success) throw new Error(initialized.error.message)
555
+ * try {
556
+ * const listed = await reach.queue.list({ status: 'dead', limit: 10 })
557
+ * if (!listed.success) throw new Error(listed.error.message)
558
+ * const entry: ReachQueueEntry | undefined = listed.data[0]
559
+ * if (entry) { const attempts = entry.attemptCount }
560
+ * }
561
+ * finally { await reach.close() }
562
+ * }
563
+ * finally {
564
+ * const closed = await reldb.close()
565
+ * if (!closed.success) throw new Error(closed.error.message)
566
+ * }
567
+ * ```
568
+ */
569
+ interface ReachQueueEntry {
570
+ /** 持久化入队回执。 */
571
+ queueId: string;
572
+ /** Provider 配置名称。 */
573
+ provider: string;
574
+ /** 当前处理状态。 */
575
+ status: ReachQueueStatus;
576
+ /** 最近更新时间(Unix 毫秒)。 */
577
+ updatedAt?: number;
578
+ /** 外部 Provider 确认的消息编号。 */
579
+ messageId?: string;
580
+ /** 已领取次数;节点崩溃后的重新领取也计数。 */
581
+ attemptCount: number;
582
+ /** 此条消息持久保存的自动领取上限。 */
583
+ maxAttempts: number;
584
+ /** 下次允许自动领取的 Unix 毫秒时间;终态为 0。 */
585
+ nextAttemptAt: number;
586
+ /** 最近失败分类,便于可信运维流程排查与修复。 */
587
+ lastFailure?: ReachQueueFailure;
317
588
  }
318
589
  /**
319
- * 触达模板定义
590
+ * 仅列出需人工处理的dead/unknown;分页有界,不提供凭据或替代运维权限。
320
591
  *
321
- * 每个模板绑定到一个 Provider,发送时按模板中的 `provider` 路由。
592
+ * @example
593
+ * ```ts
594
+ * import { reach, type ReachQueueListOptions } from '@h-ai/reach'
595
+ * const { reldb } = await import('@h-ai/reldb')
596
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
597
+ * if (!connected.success) throw new Error(connected.error.message)
598
+ * try {
599
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
600
+ * if (!initialized.success) throw new Error(initialized.error.message)
601
+ * try {
602
+ * const options: ReachQueueListOptions = { status: 'unknown', limit: 10, offset: 0 }
603
+ * const listed = await reach.queue.list(options)
604
+ * if (!listed.success) throw new Error(listed.error.message)
605
+ * }
606
+ * finally { await reach.close() }
607
+ * }
608
+ * finally {
609
+ * const closed = await reldb.close()
610
+ * if (!closed.success) throw new Error(closed.error.message)
611
+ * }
612
+ * ```
613
+ */
614
+ interface ReachQueueListOptions {
615
+ /** 需要人工处理的状态。 */
616
+ status: 'dead' | 'unknown';
617
+ /** 每页 1–100 条,默认 50。 */
618
+ limit?: number;
619
+ /** 跳过条数,默认 0,上限 1000000。 */
620
+ offset?: number;
621
+ }
622
+ /**
623
+ * dead条件恢复输入;expectedUpdatedAt必须来自最近快照,不能用本机当前时间冒充版本。
624
+ * 先修根因并确认旧执行与接收端幂等边界,unknown不能重排。
625
+ *
626
+ * @example
627
+ * ```ts
628
+ * import { reach, type ReachDeadRequeue } from '@h-ai/reach'
629
+ * const { reldb } = await import('@h-ai/reldb')
630
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
631
+ * if (!connected.success) throw new Error(connected.error.message)
632
+ * try {
633
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
634
+ * if (!initialized.success) throw new Error(initialized.error.message)
635
+ * try {
636
+ * const listed = await reach.queue.list({ status: 'dead', limit: 10 })
637
+ * if (!listed.success) throw new Error(listed.error.message)
638
+ * const entry = listed.data[0]
639
+ * if (entry && entry.updatedAt !== undefined) {
640
+ * const options: ReachDeadRequeue = { expectedUpdatedAt: entry.updatedAt }
641
+ * // 此调用仅在受信运维流程核验完成后执行。
642
+ * const restored = await reach.queue.requeueDead(entry.queueId, options)
643
+ * if (!restored.success) throw new Error(restored.error.message)
644
+ * }
645
+ * }
646
+ * finally { await reach.close() }
647
+ * }
648
+ * finally {
649
+ * const closed = await reldb.close()
650
+ * if (!closed.success) throw new Error(closed.error.message)
651
+ * }
652
+ * ```
653
+ */
654
+ interface ReachDeadRequeue {
655
+ /** 必须等于最近查询的更新时间,阻止并发及过时请求。 */
656
+ expectedUpdatedAt: number;
657
+ }
658
+ /**
659
+ * unknown人工裁定,不提供自动重发;先校验运维权限、停止/隔离旧worker并核对Provider实际记录。
660
+ * discarded只终止后续处理,不能撤销已开始的外部请求或已发送消息。
322
661
  *
323
662
  * @example
324
663
  * ```ts
325
- * const template: ReachTemplate = {
326
- * name: 'verification_code',
327
- * provider: 'email',
328
- * subject: '验证码: {code}',
329
- * body: '您的验证码是 {code},有效期 {minutes} 分钟。',
664
+ * import { reach, type ReachUnknownResolution } from '@h-ai/reach'
665
+ * const { reldb } = await import('@h-ai/reldb')
666
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
667
+ * if (!connected.success) throw new Error(connected.error.message)
668
+ * try {
669
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
670
+ * if (!initialized.success) throw new Error(initialized.error.message)
671
+ * try {
672
+ * const listed = await reach.queue.list({ status: 'unknown', limit: 10 })
673
+ * if (!listed.success) throw new Error(listed.error.message)
674
+ * const entry = listed.data[0]
675
+ * if (entry) {
676
+ * // 可信运维前置:旧worker已隔离且外部投递记录已核对。
677
+ * const resolution: ReachUnknownResolution = { status: 'discarded' }
678
+ * const resolved = await reach.queue.resolveUnknown(entry.queueId, resolution)
679
+ * if (!resolved.success) throw new Error(resolved.error.message)
680
+ * }
681
+ * }
682
+ * finally { await reach.close() }
683
+ * }
684
+ * finally {
685
+ * const closed = await reldb.close()
686
+ * if (!closed.success) throw new Error(closed.error.message)
687
+ * }
688
+ * ```
689
+ */
690
+ interface ReachUnknownResolution {
691
+ /** 经外部核对确认已投递,或终止后续处理。discarded 不撤销已经开始的外部请求。 */
692
+ status: 'sent' | 'discarded';
693
+ /** 确认已投递时,可记录 Provider 消息编号,最多 255 个字符。 */
694
+ messageId?: string;
695
+ }
696
+ /**
697
+ * 受信服务端队列运维入口,需应用先授权;只查询安全元数据,恢复/裁定均由数据库条件提交。
698
+ *
699
+ * @example
700
+ * ```ts
701
+ * import { reach } from '@h-ai/reach'
702
+ * const { reldb } = await import('@h-ai/reldb')
703
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
704
+ * if (!connected.success) throw new Error(connected.error.message)
705
+ * try {
706
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
707
+ * if (!initialized.success) throw new Error(initialized.error.message)
708
+ * try {
709
+ * const listed = await reach.queue.list({ status: 'unknown', limit: 10 })
710
+ * if (!listed.success) throw new Error(listed.error.message)
711
+ * const count = listed.data.length
712
+ * }
713
+ * finally { await reach.close() }
714
+ * }
715
+ * finally {
716
+ * const closed = await reldb.close()
717
+ * if (!closed.success) throw new Error(closed.error.message)
718
+ * }
719
+ * ```
720
+ */
721
+ interface ReachQueueOperations {
722
+ /**
723
+ * 按回执查询队列状态。
724
+ * @param queueId - 持久UUID回执,不代替运维权限。
725
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
726
+ *
727
+ * @example
728
+ * ```ts
729
+ * import { reach } from '@h-ai/reach'
730
+ * const { reldb } = await import('@h-ai/reldb')
731
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
732
+ * if (!connected.success) throw new Error(connected.error.message)
733
+ * try {
734
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
735
+ * if (!initialized.success) throw new Error(initialized.error.message)
736
+ * try {
737
+ * const result = await reach.queue.get('00000000-0000-4000-8000-000000000001')
738
+ * if (!result.success) throw new Error(result.error.message)
739
+ * if (result.data) { const status = result.data.status }
740
+ * }
741
+ * finally { await reach.close() }
742
+ * }
743
+ * finally {
744
+ * const closed = await reldb.close()
745
+ * if (!closed.success) throw new Error(closed.error.message)
746
+ * }
747
+ * ```
748
+ */
749
+ get: (queueId: string) => Promise<HaiResult<ReachQueueEntry | null>>;
750
+ /**
751
+ * 分页查询死信或未知结果,仅返回运维元数据。
752
+ * @param options - 实际操作输入,按本方法约束校验。
753
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
754
+ *
755
+ * @example
756
+ * ```ts
757
+ * import { reach } from '@h-ai/reach'
758
+ * const { reldb } = await import('@h-ai/reldb')
759
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
760
+ * if (!connected.success) throw new Error(connected.error.message)
761
+ * try {
762
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
763
+ * if (!initialized.success) throw new Error(initialized.error.message)
764
+ * try {
765
+ * const listed = await reach.queue.list({ status: 'unknown', limit: 10 })
766
+ * if (!listed.success) throw new Error(listed.error.message)
767
+ * const count = listed.data.length
768
+ * }
769
+ * finally { await reach.close() }
770
+ * }
771
+ * finally {
772
+ * const closed = await reldb.close()
773
+ * if (!closed.success) throw new Error(closed.error.message)
774
+ * }
775
+ * ```
776
+ */
777
+ list: (options: ReachQueueListOptions) => Promise<HaiResult<ReachQueueEntry[]>>;
778
+ /**
779
+ * 修复根因并确认接收端幂等契约后恢复 dead;重置计数,保留 queueId/策略。unknown 不能重排。
780
+ * @param queueId - 持久UUID回执,不代替运维权限。
781
+ * @param options - 实际操作输入,按本方法约束校验。
782
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
783
+ *
784
+ * @example
785
+ * ```ts
786
+ * import { reach, type ReachDeadRequeue } from '@h-ai/reach'
787
+ * const { reldb } = await import('@h-ai/reldb')
788
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
789
+ * if (!connected.success) throw new Error(connected.error.message)
790
+ * try {
791
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
792
+ * if (!initialized.success) throw new Error(initialized.error.message)
793
+ * try {
794
+ * const listed = await reach.queue.list({ status: 'dead', limit: 10 })
795
+ * if (!listed.success) throw new Error(listed.error.message)
796
+ * const entry = listed.data[0]
797
+ * if (entry && entry.updatedAt !== undefined) {
798
+ * const options: ReachDeadRequeue = { expectedUpdatedAt: entry.updatedAt }
799
+ * // 此调用仅在受信运维流程核验完成后执行。
800
+ * const restored = await reach.queue.requeueDead(entry.queueId, options)
801
+ * if (!restored.success) throw new Error(restored.error.message)
802
+ * }
803
+ * }
804
+ * finally { await reach.close() }
805
+ * }
806
+ * finally {
807
+ * const closed = await reldb.close()
808
+ * if (!closed.success) throw new Error(closed.error.message)
809
+ * }
810
+ * ```
811
+ */
812
+ requeueDead: (queueId: string, options: ReachDeadRequeue) => Promise<HaiResult<void>>;
813
+ /**
814
+ * 停止/隔离旧 worker 并核对外部投递记录后,裁定 unknown;不重新发送。
815
+ * @param queueId - 持久UUID回执,不代替运维权限。
816
+ * @param resolution - 实际操作输入,按本方法约束校验。
817
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
818
+ *
819
+ * @example
820
+ * ```ts
821
+ * import { reach, type ReachUnknownResolution } from '@h-ai/reach'
822
+ * const { reldb } = await import('@h-ai/reldb')
823
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
824
+ * if (!connected.success) throw new Error(connected.error.message)
825
+ * try {
826
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
827
+ * if (!initialized.success) throw new Error(initialized.error.message)
828
+ * try {
829
+ * const listed = await reach.queue.list({ status: 'unknown', limit: 10 })
830
+ * if (!listed.success) throw new Error(listed.error.message)
831
+ * const entry = listed.data[0]
832
+ * if (entry) {
833
+ * // 可信运维前置:旧worker已隔离且外部投递记录已核对。
834
+ * const resolution: ReachUnknownResolution = { status: 'discarded' }
835
+ * const resolved = await reach.queue.resolveUnknown(entry.queueId, resolution)
836
+ * if (!resolved.success) throw new Error(resolved.error.message)
837
+ * }
838
+ * }
839
+ * finally { await reach.close() }
840
+ * }
841
+ * finally {
842
+ * const closed = await reldb.close()
843
+ * if (!closed.success) throw new Error(closed.error.message)
844
+ * }
845
+ * ```
846
+ */
847
+ resolveUnknown: (queueId: string, resolution: ReachUnknownResolution) => Promise<HaiResult<void>>;
848
+ }
849
+ /**
850
+ * 持久文本模板,provider是配置名称,subject/body支持{key}替换;未提供的占位符保留原文。
851
+ * 不提供HTML转义或消毒,渲染结果的最终安全上下文由使用方处理。
852
+ *
853
+ * @example
854
+ * ```ts
855
+ * import { reach, type ReachTemplate } from '@h-ai/reach'
856
+ * const { reldb } = await import('@h-ai/reldb')
857
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
858
+ * if (!connected.success) throw new Error(connected.error.message)
859
+ * try {
860
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
861
+ * if (!initialized.success) throw new Error(initialized.error.message)
862
+ * try {
863
+ * const template: ReachTemplate = { name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }
864
+ * const saved = await reach.template.save(template)
865
+ * if (!saved.success) throw new Error(saved.error.message)
866
+ * }
867
+ * finally { await reach.close() }
868
+ * }
869
+ * finally {
870
+ * const closed = await reldb.close()
871
+ * if (!closed.success) throw new Error(closed.error.message)
330
872
  * }
331
873
  * ```
332
874
  */
@@ -341,7 +883,30 @@ interface ReachTemplate {
341
883
  body: string;
342
884
  }
343
885
  /**
344
- * 渲染后的模板
886
+ * 渲染后的主题/正文;文本替换不等于HTML安全,可选subject按原模板保留。
887
+ *
888
+ * @example
889
+ * ```ts
890
+ * import { reach, type RenderedTemplate } from '@h-ai/reach'
891
+ * const { reldb } = await import('@h-ai/reldb')
892
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
893
+ * if (!connected.success) throw new Error(connected.error.message)
894
+ * try {
895
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
896
+ * if (!initialized.success) throw new Error(initialized.error.message)
897
+ * try {
898
+ * const rendered = await reach.template.render('welcome', { userName: 'Alice' })
899
+ * if (!rendered.success) throw new Error(rendered.error.message)
900
+ * const value: RenderedTemplate = rendered.data
901
+ * const body = value.body
902
+ * }
903
+ * finally { await reach.close() }
904
+ * }
905
+ * finally {
906
+ * const closed = await reldb.close()
907
+ * if (!closed.success) throw new Error(closed.error.message)
908
+ * }
909
+ * ```
345
910
  */
346
911
  interface RenderedTemplate {
347
912
  /** 渲染后的主题 */
@@ -350,7 +915,35 @@ interface RenderedTemplate {
350
915
  body: string;
351
916
  }
352
917
  /**
353
- * 发送操作接口
918
+ * 统一发送操作;失败HaiResult不表示已发送,成功结果仍检查内部success与deferred。
919
+ * 即时外部发送成功不因可选日志失败而转失败,防调用方重复发送;延迟入队必须确认持久写入。
920
+ *
921
+ * @example
922
+ * ```ts
923
+ * import { reach } from '@h-ai/reach'
924
+ * const { reldb } = await import('@h-ai/reldb')
925
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
926
+ * if (!connected.success) throw new Error(connected.error.message)
927
+ * try {
928
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
929
+ * if (!initialized.success) throw new Error(initialized.error.message)
930
+ * try {
931
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
932
+ * if (!result.success) throw new Error(result.error.message)
933
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
934
+ * if (result.data.deferred) {
935
+ * const queueId = result.data.queueId
936
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
937
+ * }
938
+ * else { const providerMessageId = result.data.messageId }
939
+ * }
940
+ * finally { await reach.close() }
941
+ * }
942
+ * finally {
943
+ * const closed = await reldb.close()
944
+ * if (!closed.success) throw new Error(closed.error.message)
945
+ * }
946
+ * ```
354
947
  */
355
948
  interface SendOperations {
356
949
  /**
@@ -361,94 +954,334 @@ interface SendOperations {
361
954
  *
362
955
  * @example
363
956
  * ```ts
364
- * const result = await reach.send({
365
- * provider: 'email',
366
- * to: 'user@example.com',
367
- * template: 'welcome',
368
- * vars: { userName: '张三' },
369
- * })
957
+ * import { reach } from '@h-ai/reach'
958
+ * const { reldb } = await import('@h-ai/reldb')
959
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
960
+ * if (!connected.success) throw new Error(connected.error.message)
961
+ * try {
962
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
963
+ * if (!initialized.success) throw new Error(initialized.error.message)
964
+ * try {
965
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
966
+ * if (!result.success) throw new Error(result.error.message)
967
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
968
+ * if (result.data.deferred) {
969
+ * const queueId = result.data.queueId
970
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
971
+ * }
972
+ * else { const providerMessageId = result.data.messageId }
973
+ * }
974
+ * finally { await reach.close() }
975
+ * }
976
+ * finally {
977
+ * const closed = await reldb.close()
978
+ * if (!closed.success) throw new Error(closed.error.message)
979
+ * }
370
980
  * ```
371
981
  */
372
982
  send: (message: ReachMessage) => Promise<HaiResult<SendResult>>;
373
983
  }
374
984
  /**
375
- * 模板注册表接口
985
+ * 数据库模板操作;无数据库时不回退进程模板缓存,resolve失败、list为空,保存/删除返回失败。
986
+ * saveBatch并发逐条upsert,失败时不承诺其它项回滚;需要原子业务批次时由受控事务流程实现。
376
987
  *
377
- * 所有模板通过数据库持久化存储,避免节点间状态不一致。
378
- * 配置文件中的模板在 init 时写入数据库。
988
+ * @example
989
+ * ```ts
990
+ * import { reach } from '@h-ai/reach'
991
+ * const { reldb } = await import('@h-ai/reldb')
992
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
993
+ * if (!connected.success) throw new Error(connected.error.message)
994
+ * try {
995
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
996
+ * if (!initialized.success) throw new Error(initialized.error.message)
997
+ * try {
998
+ * const saved = await reach.template.save({ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' })
999
+ * if (!saved.success) throw new Error(saved.error.message)
1000
+ * const rendered = await reach.template.render('welcome', { userName: 'Alice' })
1001
+ * if (!rendered.success) throw new Error(rendered.error.message)
1002
+ * }
1003
+ * finally { await reach.close() }
1004
+ * }
1005
+ * finally {
1006
+ * const closed = await reldb.close()
1007
+ * if (!closed.success) throw new Error(closed.error.message)
1008
+ * }
1009
+ * ```
379
1010
  */
380
1011
  interface ReachTemplateRegistry {
381
- /** 按名称解析模板 */
1012
+ /**
1013
+ * 按名称解析模板
1014
+ * @param name - 实际操作输入,按本方法约束校验。
1015
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1016
+ *
1017
+ * @example
1018
+ * ```ts
1019
+ * import { reach } from '@h-ai/reach'
1020
+ * const { reldb } = await import('@h-ai/reldb')
1021
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1022
+ * if (!connected.success) throw new Error(connected.error.message)
1023
+ * try {
1024
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1025
+ * if (!initialized.success) throw new Error(initialized.error.message)
1026
+ * try {
1027
+ * const result = await reach.template.resolve('welcome')
1028
+ * if (!result.success) throw new Error(result.error.message)
1029
+ * const providerName = result.data.provider
1030
+ * }
1031
+ * finally { await reach.close() }
1032
+ * }
1033
+ * finally {
1034
+ * const closed = await reldb.close()
1035
+ * if (!closed.success) throw new Error(closed.error.message)
1036
+ * }
1037
+ * ```
1038
+ */
382
1039
  resolve: (name: string) => Promise<HaiResult<ReachTemplate>>;
383
- /** 保存模板到数据库(upsert) */
1040
+ /**
1041
+ * 保存模板到数据库(upsert)
1042
+ * @param template - 实际操作输入,按本方法约束校验。
1043
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1044
+ *
1045
+ * @example
1046
+ * ```ts
1047
+ * import { reach, type ReachTemplate } from '@h-ai/reach'
1048
+ * const { reldb } = await import('@h-ai/reldb')
1049
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1050
+ * if (!connected.success) throw new Error(connected.error.message)
1051
+ * try {
1052
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1053
+ * if (!initialized.success) throw new Error(initialized.error.message)
1054
+ * try {
1055
+ * const template: ReachTemplate = { name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }
1056
+ * const saved = await reach.template.save(template)
1057
+ * if (!saved.success) throw new Error(saved.error.message)
1058
+ * }
1059
+ * finally { await reach.close() }
1060
+ * }
1061
+ * finally {
1062
+ * const closed = await reldb.close()
1063
+ * if (!closed.success) throw new Error(closed.error.message)
1064
+ * }
1065
+ * ```
1066
+ */
384
1067
  save: (template: ReachTemplate) => Promise<HaiResult<void>>;
385
- /** 批量保存模板到数据库 */
1068
+ /**
1069
+ * 批量保存模板到数据库
1070
+ * @param templates - 实际操作输入,按本方法约束校验。
1071
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1072
+ *
1073
+ * @example
1074
+ * ```ts
1075
+ * import { reach } from '@h-ai/reach'
1076
+ * const { reldb } = await import('@h-ai/reldb')
1077
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1078
+ * if (!connected.success) throw new Error(connected.error.message)
1079
+ * try {
1080
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1081
+ * if (!initialized.success) throw new Error(initialized.error.message)
1082
+ * try {
1083
+ * const result = await reach.template.saveBatch([{ name: 'receipt', provider: 'webhook', body: 'Receipt {orderId}' }, { name: 'reminder', provider: 'webhook', body: 'Reminder {orderId}' }])
1084
+ * if (!result.success) throw new Error(result.error.message)
1085
+ * // 非原子批次;失败时应核对已保存条目,不假定整体回滚。
1086
+ * }
1087
+ * finally { await reach.close() }
1088
+ * }
1089
+ * finally {
1090
+ * const closed = await reldb.close()
1091
+ * if (!closed.success) throw new Error(closed.error.message)
1092
+ * }
1093
+ * ```
1094
+ */
386
1095
  saveBatch: (templates: ReachTemplate[]) => Promise<HaiResult<void>>;
387
- /** 从数据库删除模板 */
1096
+ /**
1097
+ * 从数据库删除模板
1098
+ * @param name - 实际操作输入,按本方法约束校验。
1099
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1100
+ *
1101
+ * @example
1102
+ * ```ts
1103
+ * import { reach } from '@h-ai/reach'
1104
+ * const { reldb } = await import('@h-ai/reldb')
1105
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1106
+ * if (!connected.success) throw new Error(connected.error.message)
1107
+ * try {
1108
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1109
+ * if (!initialized.success) throw new Error(initialized.error.message)
1110
+ * try {
1111
+ * const result = await reach.template.remove('welcome')
1112
+ * if (!result.success) throw new Error(result.error.message)
1113
+ * }
1114
+ * finally { await reach.close() }
1115
+ * }
1116
+ * finally {
1117
+ * const closed = await reldb.close()
1118
+ * if (!closed.success) throw new Error(closed.error.message)
1119
+ * }
1120
+ * ```
1121
+ */
388
1122
  remove: (name: string) => Promise<HaiResult<void>>;
389
- /** 列出所有模板 */
1123
+ /**
1124
+ * 列出所有模板
1125
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1126
+ *
1127
+ * @example
1128
+ * ```ts
1129
+ * import { reach } from '@h-ai/reach'
1130
+ * const { reldb } = await import('@h-ai/reldb')
1131
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1132
+ * if (!connected.success) throw new Error(connected.error.message)
1133
+ * try {
1134
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1135
+ * if (!initialized.success) throw new Error(initialized.error.message)
1136
+ * try {
1137
+ * const result = await reach.template.list()
1138
+ * if (!result.success) throw new Error(result.error.message)
1139
+ * const count = result.data.length
1140
+ * }
1141
+ * finally { await reach.close() }
1142
+ * }
1143
+ * finally {
1144
+ * const closed = await reldb.close()
1145
+ * if (!closed.success) throw new Error(closed.error.message)
1146
+ * }
1147
+ * ```
1148
+ */
390
1149
  list: () => Promise<HaiResult<ReachTemplate[]>>;
391
- /** 渲染模板(从数据库解析后渲染) */
1150
+ /**
1151
+ * 渲染模板(从数据库解析后渲染)
1152
+ * @param name - 实际操作输入,按本方法约束校验。
1153
+ * @param vars - 实际操作输入,按本方法约束校验。
1154
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1155
+ *
1156
+ * @example
1157
+ * ```ts
1158
+ * import { reach, type RenderedTemplate } from '@h-ai/reach'
1159
+ * const { reldb } = await import('@h-ai/reldb')
1160
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1161
+ * if (!connected.success) throw new Error(connected.error.message)
1162
+ * try {
1163
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1164
+ * if (!initialized.success) throw new Error(initialized.error.message)
1165
+ * try {
1166
+ * const rendered = await reach.template.render('welcome', { userName: 'Alice' })
1167
+ * if (!rendered.success) throw new Error(rendered.error.message)
1168
+ * const value: RenderedTemplate = rendered.data
1169
+ * const body = value.body
1170
+ * }
1171
+ * finally { await reach.close() }
1172
+ * }
1173
+ * finally {
1174
+ * const closed = await reldb.close()
1175
+ * if (!closed.success) throw new Error(closed.error.message)
1176
+ * }
1177
+ * ```
1178
+ */
392
1179
  render: (name: string, vars: Record<string, string>) => Promise<HaiResult<RenderedTemplate>>;
393
1180
  }
394
1181
  /**
395
- * 触达模块函数接口
1182
+ * Reach生命周期、模板、发送及受信队列运维能力;先初始化数据库,再检查reach.init结果,反序close。
1183
+ * API Provider init只建立适配器配置,不探测远端可用性;实际send结果才能判断本次投递。
396
1184
  *
397
1185
  * @example
398
1186
  * ```ts
399
1187
  * import { reach } from '@h-ai/reach'
400
- *
401
- * // 初始化(同时注册多个 Provider)
402
- * await reach.init({
403
- * providers: [
404
- * { name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'no-reply@example.com' },
405
- * { name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '...' },
406
- * ],
407
- * })
408
- *
409
- * // 保存模板(绑定到 Provider)
410
- * await reach.template.save({
411
- * name: 'welcome',
412
- * provider: 'email',
413
- * subject: '欢迎 {userName}',
414
- * body: '亲爱的 {userName},欢迎使用我们的服务!',
415
- * })
416
- *
417
- * // 发送(指定 Provider)
418
- * await reach.send({ provider: 'email', to: 'user@example.com', template: 'welcome', vars: { userName: '张三' } })
419
- *
420
- * // 关闭
421
- * await reach.close()
1188
+ * const { reldb } = await import('@h-ai/reldb')
1189
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1190
+ * if (!connected.success) throw new Error(connected.error.message)
1191
+ * try {
1192
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1193
+ * if (!initialized.success) throw new Error(initialized.error.message)
1194
+ * try {
1195
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
1196
+ * if (!result.success) throw new Error(result.error.message)
1197
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
1198
+ * if (result.data.deferred) {
1199
+ * const queueId = result.data.queueId
1200
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
1201
+ * }
1202
+ * else { const providerMessageId = result.data.messageId }
1203
+ * }
1204
+ * finally { await reach.close() }
1205
+ * }
1206
+ * finally {
1207
+ * const closed = await reldb.close()
1208
+ * if (!closed.success) throw new Error(closed.error.message)
1209
+ * }
422
1210
  * ```
423
1211
  */
424
1212
  interface ReachFunctions extends SendOperations {
425
- /** 初始化触达模块(注册多个 Provider) */
1213
+ /** 持久化队列查询、死信恢复与未知结果人工裁定。 */
1214
+ readonly queue: ReachQueueOperations;
1215
+ /**
1216
+ * 初始化触达模块(注册多个 Provider)
1217
+ * @param config - 实际操作输入,按本方法约束校验。
1218
+ * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1219
+ *
1220
+ * @example
1221
+ * ```ts
1222
+ * import { reach } from '@h-ai/reach'
1223
+ * const { reldb } = await import('@h-ai/reldb')
1224
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1225
+ * if (!connected.success) throw new Error(connected.error.message)
1226
+ * try {
1227
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1228
+ * if (!initialized.success) throw new Error(initialized.error.message)
1229
+ * try {
1230
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
1231
+ * if (!result.success) throw new Error(result.error.message)
1232
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
1233
+ * if (result.data.deferred) {
1234
+ * const queueId = result.data.queueId
1235
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
1236
+ * }
1237
+ * else { const providerMessageId = result.data.messageId }
1238
+ * }
1239
+ * finally { await reach.close() }
1240
+ * }
1241
+ * finally {
1242
+ * const closed = await reldb.close()
1243
+ * if (!closed.success) throw new Error(closed.error.message)
1244
+ * }
1245
+ * ```
1246
+ */
426
1247
  init: (config: ReachConfigInput) => Promise<HaiResult<void>>;
427
- /** 当前配置(未初始化时为 null) */
1248
+ /** 当前脱敏后的配置快照(未初始化时为 null) */
428
1249
  readonly config: ReachConfig | null;
429
1250
  /** 是否已初始化 */
430
1251
  readonly isInitialized: boolean;
431
1252
  /** 模板注册表 */
432
1253
  readonly template: ReachTemplateRegistry;
433
- /** 关闭连接并释放资源 */
434
- close: () => Promise<void>;
435
- }
436
- /**
437
- * 触达 Provider 接口
438
- *
439
- * 由具体实现(console / smtp / aliyun-sms / api)提供。
440
- */
441
- interface ReachProvider {
442
- /** Provider 名称 */
443
- readonly name: string;
444
- /** 连接/初始化 Provider */
445
- connect: (config: ProviderConfig) => Promise<HaiResult<void>>;
446
- /** 发送消息 */
447
- send: (message: ReachMessage) => Promise<HaiResult<SendResult>>;
448
- /** 关闭连接 */
1254
+ /**
1255
+ * 关闭连接并释放资源
1256
+ *
1257
+ * @example
1258
+ * ```ts
1259
+ * import { reach } from '@h-ai/reach'
1260
+ * const { reldb } = await import('@h-ai/reldb')
1261
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1262
+ * if (!connected.success) throw new Error(connected.error.message)
1263
+ * try {
1264
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1265
+ * if (!initialized.success) throw new Error(initialized.error.message)
1266
+ * try {
1267
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
1268
+ * if (!result.success) throw new Error(result.error.message)
1269
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
1270
+ * if (result.data.deferred) {
1271
+ * const queueId = result.data.queueId
1272
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
1273
+ * }
1274
+ * else { const providerMessageId = result.data.messageId }
1275
+ * }
1276
+ * finally { await reach.close() }
1277
+ * }
1278
+ * finally {
1279
+ * const closed = await reldb.close()
1280
+ * if (!closed.success) throw new Error(closed.error.message)
1281
+ * }
1282
+ * ```
1283
+ */
449
1284
  close: () => Promise<void>;
450
- /** 是否已连接 */
451
- isConnected: () => boolean;
452
1285
  }
453
1286
 
454
1287
  /**
@@ -459,10 +1292,48 @@ interface ReachProvider {
459
1292
  */
460
1293
 
461
1294
  /**
462
- * 触达服务对象
1295
+ * 邮件/短信/API触达统一入口;先检查init与send的HaiResult,成功后还区分deferred和即时Provider结果。
1296
+ * 模板与delay队列使用已初始化数据库,生产共享后端;close停止本地调度/关闭Provider,不撤销已发外部请求。
463
1297
  *
464
- * 统一的用户触达入口,支持同时使用邮件、短信、API 回调等多种 Provider。
1298
+ * @example
1299
+ * ```ts
1300
+ * import { reach } from '@h-ai/reach'
1301
+ * const { reldb } = await import('@h-ai/reldb')
1302
+ * const connected = await reldb.init({ type: 'postgresql', host: 'db', port: 5432, database: 'hai', user: 'postgres', password: process.env.HAI_DB_PASSWORD })
1303
+ * if (!connected.success) throw new Error(connected.error.message)
1304
+ * try {
1305
+ * const initialized = await reach.init({ providers: [{ name: 'webhook', type: 'api', url: 'https://notify.example.com/events', idempotent: false }], templates: [{ name: 'welcome', provider: 'webhook', subject: 'Welcome {userName}', body: 'Hello {userName}' }], dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' } })
1306
+ * if (!initialized.success) throw new Error(initialized.error.message)
1307
+ * try {
1308
+ * const result = await reach.send({ provider: 'webhook', to: 'user@example.com', template: 'welcome', vars: { userName: 'Alice' } })
1309
+ * if (!result.success) throw new Error(result.error.message)
1310
+ * if (!result.data.success) throw new Error('Delivery was not accepted')
1311
+ * if (result.data.deferred) {
1312
+ * const queueId = result.data.queueId
1313
+ * // 持久入队成功,尚未证明接收端已完成外部投递。
1314
+ * }
1315
+ * else { const providerMessageId = result.data.messageId }
1316
+ * }
1317
+ * finally { await reach.close() }
1318
+ * }
1319
+ * finally {
1320
+ * const closed = await reldb.close()
1321
+ * if (!closed.success) throw new Error(closed.error.message)
1322
+ * }
1323
+ * ```
465
1324
  */
466
1325
  declare const reach: ReachFunctions;
467
1326
 
468
- export { type AliyunSmsProviderConfig, AliyunSmsProviderConfigSchema, type ApiProviderConfig, ApiProviderConfigSchema, type ConsoleProviderConfig, ConsoleProviderConfigSchema, type DndConfig, DndConfigSchema, HaiReachError, type ProviderConfig, ProviderConfigSchema, type ReachChannel, type ReachConfig, type ReachConfigInput, ReachConfigSchema, type ReachFunctions, type ReachMessage, type ReachProvider, type ReachTemplate, type ReachTemplateRegistry, type RenderedTemplate, type SendOperations, type SendResult, type SmtpProviderConfig, SmtpProviderConfigSchema, type TemplateConfig, TemplateConfigSchema, reach };
1327
+ /**
1328
+ * 预检或升级 Reach 队列。旧凭据相关载荷清除,待发凭据丢弃,保留发送状态和历史标识。
1329
+ * @example
1330
+ * ```ts
1331
+ * import { migrateReach } from '@h-ai/reach'
1332
+ * const plan = await migrateReach({ dryRun: true })
1333
+ * ```
1334
+ */
1335
+ declare function migrateReach(options?: {
1336
+ dryRun?: boolean;
1337
+ }, db?: ReldbFunctions): Promise<_h_ai_core.HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
1338
+
1339
+ export { type AliyunSmsProviderConfig, AliyunSmsProviderConfigSchema, type ApiProviderConfig, ApiProviderConfigSchema, type ConsoleProviderConfig, ConsoleProviderConfigSchema, type DndConfig, DndConfigSchema, HaiReachError, type ProviderConfig, ProviderConfigSchema, type ReachChannel, type ReachConfig, type ReachConfigInput, ReachConfigSchema, type ReachDeadRequeue, ReachDeadRequeueSchema, type ReachFunctions, type ReachMessage, type ReachQueueConfig, ReachQueueConfigSchema, type ReachQueueEntry, type ReachQueueFailure, ReachQueueIdSchema, type ReachQueueListOptions, ReachQueueListSchema, type ReachQueueOperations, type ReachQueueStatus, type ReachTemplate, type ReachTemplateRegistry, type ReachUnknownResolution, ReachUnknownResolutionSchema, type RenderedTemplate, type SendOperations, type SendResult, type SmtpProviderConfig, SmtpProviderConfigSchema, type TemplateConfig, TemplateConfigSchema, migrateReach, reach };