@rei-standard/amsg-server 2.6.0-next.1 → 2.6.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -23
  3. package/dist/adapters/d1.d.ts +173 -0
  4. package/dist/adapters/factory.d.ts +39 -0
  5. package/dist/adapters/interface.d.ts +158 -0
  6. package/dist/adapters/neon.d.ts +102 -0
  7. package/dist/adapters/pg.d.ts +104 -0
  8. package/dist/adapters/schema.d.ts +29 -0
  9. package/dist/adapters/schema.sqlite.d.ts +31 -0
  10. package/dist/{chunk-RGECD4OH.cjs → chunk-4SIN2R66.cjs} +31 -1
  11. package/dist/chunk-HZDUURIR.mjs +3211 -0
  12. package/dist/chunk-QGJSWMQP.cjs +3211 -0
  13. package/dist/{chunk-5ENJGVZX.mjs → chunk-WAFOSZL3.mjs} +31 -1
  14. package/dist/cloudflare/single-user-worker.d.ts +4 -0
  15. package/dist/cloudflare.cjs +20 -2
  16. package/dist/cloudflare.d.ts +7 -3
  17. package/dist/cloudflare.mjs +19 -1
  18. package/dist/handlers/cancel-message.d.ts +3 -0
  19. package/dist/handlers/capabilities.d.ts +4 -0
  20. package/dist/handlers/client-state.d.ts +7 -0
  21. package/dist/handlers/get-user-key.d.ts +3 -0
  22. package/dist/handlers/init-tenant.d.ts +23 -0
  23. package/dist/handlers/messages.d.ts +3 -0
  24. package/dist/handlers/push-subscription.d.ts +5 -0
  25. package/dist/handlers/schedule-message.d.ts +3 -0
  26. package/dist/handlers/send-notifications.d.ts +3 -0
  27. package/dist/handlers/single-user-init.d.ts +13 -0
  28. package/dist/handlers/update-message.d.ts +3 -0
  29. package/dist/handlers/vapid-public-key.d.ts +19 -0
  30. package/dist/index.cjs +47 -30
  31. package/dist/index.d.cts +99 -766
  32. package/dist/index.d.ts +99 -766
  33. package/dist/index.mjs +39 -22
  34. package/dist/lib/agentic-fire.d.ts +88 -0
  35. package/dist/lib/client-state-store.d.ts +46 -0
  36. package/dist/lib/constant-time.d.ts +14 -0
  37. package/dist/lib/db-errors.d.ts +10 -0
  38. package/dist/lib/encryption.d.ts +48 -0
  39. package/dist/lib/llm.d.ts +1 -0
  40. package/dist/lib/message-processor.d.ts +47 -0
  41. package/dist/lib/push-subscription-store.d.ts +63 -0
  42. package/dist/lib/recurrence.d.ts +51 -0
  43. package/dist/lib/request.d.ts +99 -0
  44. package/dist/lib/run-tick.d.ts +53 -0
  45. package/dist/lib/state-accessors.d.ts +70 -0
  46. package/dist/lib/state-chunks.d.ts +55 -0
  47. package/dist/lib/task-projection.d.ts +64 -0
  48. package/dist/lib/validation.d.ts +68 -0
  49. package/dist/lib/version.d.ts +7 -0
  50. package/dist/lib/webcrypto-utils.d.ts +1 -0
  51. package/dist/lib/webpush-webcrypto.d.ts +78 -0
  52. package/dist/{neon-BFUS25UX.cjs → neon-2CS4TLNS.cjs} +111 -5
  53. package/dist/{neon-CU5N3CSW.mjs → neon-YUKD2B77.mjs} +107 -1
  54. package/dist/{pg-QO6NKTGL.mjs → pg-JNQ6VMXC.mjs} +103 -1
  55. package/dist/{pg-IIH3M4OM.cjs → pg-PM5RYRWH.cjs} +107 -5
  56. package/dist/single-user.d.ts +81 -0
  57. package/dist/tenant/blob-store.d.ts +30 -0
  58. package/dist/tenant/context.d.ts +47 -0
  59. package/dist/tenant/single-user-context.d.ts +29 -0
  60. package/dist/tenant/token.d.ts +26 -0
  61. package/package.json +3 -3
  62. package/dist/chunk-6WA3XDPP.mjs +0 -1826
  63. package/dist/chunk-S5DV7AUH.cjs +0 -1826
  64. package/dist/cloudflare-5Ryuxz10.d.cts +0 -2775
  65. package/dist/cloudflare-5Ryuxz10.d.ts +0 -2775
  66. package/dist/cloudflare.d.cts +0 -3
  67. package/dist/neon-CJl66EGy.d.cts +0 -251
  68. package/dist/neon-DYvGnCzx.d.ts +0 -251
  69. package/dist/pg-07S-u_H4.d.cts +0 -246
  70. package/dist/pg-Du-pN_UT.d.ts +0 -246
  71. package/dist/schema-C8OnYk6j.d.cts +0 -74
  72. package/dist/schema-C8OnYk6j.d.ts +0 -74
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReiStandard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,14 +2,7 @@
2
2
 
3
3
  `@rei-standard/amsg-server` 是 ReiStandard 主动消息标准的服务端 SDK:Blob 租户配置、`tenantToken` / `cronToken` 鉴权、标准路由处理器。API 规范见 [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)。
4
4
 
5
- ## v2.0.1 变更摘要
6
-
7
- - 初始化流程合并为 `POST /api/v1/init-tenant`
8
- - 移除旧端点:`init-database`、`init-master-key`
9
- - 业务端点统一使用 `Authorization: Bearer <tenantToken>`
10
- - `send-notifications` 支持 `cronToken`(Header 或 query token)
11
-
12
- 2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略)。其中 `splitPattern` 是 server 调度任务的持久化配置;`amsg-instant` 0.8.0 起改为 hook 内自定义 split 函数 + `pushPayloads`。
5
+ 历史变更见各版本 [CHANGELOG](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/CHANGELOG.md)。2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略)。其中 `splitPattern` 是 server 调度任务的持久化配置;`amsg-instant` 0.8.0 起改为 hook 内自定义 split 函数 + `pushPayloads`。
13
6
 
14
7
  ## 安装
15
8
 
@@ -50,6 +43,9 @@ const rei = await createReiServer({
50
43
  // PUT /api/v1/update-message -> rei.handlers.updateMessage.PUT
51
44
  // DELETE /api/v1/cancel-message -> rei.handlers.cancelMessage.DELETE
52
45
  // GET /api/v1/messages -> rei.handlers.messages.GET
46
+ // PUT /api/v1/push-subscription -> rei.handlers.pushSubscription.PUT
47
+ // GET /api/v1/push-subscription -> rei.handlers.pushSubscription.GET
48
+ // DELETE /api/v1/push-subscription -> rei.handlers.pushSubscription.DELETE
53
49
  ```
54
50
 
55
51
  ## 关于 `messageType: 'instant'`
@@ -104,10 +100,171 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
104
100
  - 限制:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。
105
101
  - `update-message` 显式传 `splitPattern: null` 可重置回默认;不传则保留原值。
106
102
 
103
+ ## 一条 Web Push 能塞多少
104
+
105
+ 推送服务(FCM / APNs / Mozilla autopush)限的是**加密后** body 的 4096 字节,超了当场 413 拒收,用户什么也收不到。明文额度要把 aes128gcm 的固定开销减掉——header 86(salt 16 + record size 4 + keyid 长度 1 + 应用服务器公钥 65)+ 填充分隔符 1 + GCM auth tag 16 = 103 字节——所以**一条 push 的 payload 上限是 3993 字节**,按 UTF-8 字节算,不是字符数。
106
+
107
+ `sendWebPush` 会在发出去之前挡下超限的 payload,抛出 `err.code === 'PUSH_PAYLOAD_TOO_LARGE'` 的错误,消息里带实际字节数和上限。
108
+
109
+ 组 payload 之前想自己做预算,用导出的常量和工具函数,别写死魔法数字:
110
+
111
+ ```js
112
+ import { MAX_PUSH_PAYLOAD_BYTES, measurePushPayload } from '@rei-standard/amsg-server';
113
+
114
+ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify(push));
115
+ // remainingBytes = 还能再塞多少字节(已超限时为负)
116
+ ```
117
+
118
+ ### 信封预留:fire-time hook 组 payload 时要多留一截
119
+
120
+ hook 把 `pushPayloads` 交还给库之后,库还会往每条 push 上补一批「这是谁、第几条、什么时候」的字段:
121
+
122
+ ```
123
+ messageId / sessionId / timestamp / messageIndex / totalMessages
124
+ taskId / taskUuid / recurrenceType / occurrenceMs
125
+ ```
126
+
127
+ 也就是说 hook 手里量到的不是最终 payload。这批字段占的字节由导出的 `PUSH_ENVELOPE_RESERVED_BYTES`(384 字节,含 JSON 的引号逗号,按 uuid ≤ 64 字符算)兜住;`measurePushPayload` 传 `{ reserveEnvelope: true }` 就是「库补完字段之后还装得下」的口径:
128
+
129
+ ```js
130
+ import { measurePushPayload, PUSH_ENVELOPE_RESERVED_BYTES } from '@rei-standard/amsg-server';
131
+
132
+ const { remainingBytes } = measurePushPayload(
133
+ JSON.stringify({ ...basePush, message: '' }),
134
+ { reserveEnvelope: true }
135
+ );
136
+ const message = body.length <= remainingBytes ? body : body.slice(0, remainingBytes);
137
+ ```
138
+
139
+ 用比 64 字符更长的 uuid(`scheduleTask` 允许传任意字符串)就自己再多留一点。
140
+
141
+ ## 推送订阅(用户级)
142
+
143
+ 推送订阅一个用户存一份,任务行不携带它,到点投递时现读。用户清了站点数据、重装了 PWA、或者推送服务轮换了 endpoint 之后,覆盖这一份就够了——所有已排的任务,包括角色在 fire 里给自己排的、客户端根本不知道存在的那些,下次触发读到的都是新订阅。
144
+
145
+ | 端点 | 语义 |
146
+ |---|---|
147
+ | `PUT /push-subscription` | 登记 / 覆盖。body =(加密后的)`{ subscription, updatedAt? }`,`subscription` 至少要有非空 `endpoint` |
148
+ | `GET /push-subscription` | `{ exists, updatedAt, endpoint }`。不含订阅的密钥部分——判断「登记过没有、是不是我手里这一个」用 `endpoint` 就够 |
149
+ | `DELETE /push-subscription` | 删掉(设置页的「停止接收推送」) |
150
+
151
+ 客户端侧对应 `client.putPushSubscription(subscription)` / `getPushSubscription()` / `deletePushSubscription()`。什么时候调 PUT:`subscribePush()` 拿到订阅之后一次,之后每次应用启动确认订阅仍然有效时再一次(幂等覆盖)。
152
+
153
+ 配套的约束:
154
+
155
+ - `POST /schedule-message` 在这个用户还没登记订阅时返回 `409 PUSH_SUBSCRIPTION_MISSING`——建了也永远发不出去,早点说清楚比让它烂在库里强。
156
+ - `POST /schedule-message` 和 `PUT /update-message` 都不收 `pushSubscription` 字段(带了返回 `400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`):静默丢弃会让人以为「这条任务用的是我传的这个订阅」。
157
+ - 投递时读不到订阅(没登记 / 被删了)→ 任务按投递失败处理,原因记进 payload 的 `lastError`,`GET /messages` 上看得见。
158
+ - 数据库侧是 `push_subscriptions` 表(`user_id` 主键,`subscription` 密文,`updated_at` epoch 毫秒)。内置的 D1 / pg / neon 适配器都实现了,`initSchema()` 会建表。自定义适配器要补 `getPushSubscription` / `upsertPushSubscription` / `deletePushSubscription` 三个方法,缺任何一个这几个端点返回 501。
159
+
160
+ 装不下的内容(长文、附件详情)建议走旁路:正文存进 `client_state`,push 里只带一个引用键,客户端上线后用 `GET /client-state` 取回。单用户 Worker 的 fire-time hook 用 `ctx.writeState()` 写,见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md)。
161
+
162
+ ## 推送自带任务身份
163
+
164
+ 每条从任务行发出去的 push(冻结 prompt 路径和 fire-time hook 路径都算)顶层带这四个字段:
165
+
166
+ | 字段 | 是什么 |
167
+ |---|---|
168
+ | `taskId` | 任务行 id;没有行的 in-server instant 路径为 `null` |
169
+ | `taskUuid` | 任务 uuid(排程方选的那个) |
170
+ | `recurrenceType` | `none` / `daily` / `weekly` —— 这条任务还会不会再来 |
171
+ | `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
172
+
173
+ 客户端据此认领任务:角色在 fire 里给自己排的任务,客户端从没见过它,靠这四个字段就能把它记进面板、让用户取消得掉。放在顶层而不是 `metadata` 里——`metadata` 是调用方自己的地盘,库不往里写。
174
+
175
+ hook 在 `pushPayloads` 里自己写了这几个字段的话会被库覆盖:它们描述的是任务行的事实,不是内容。
176
+
177
+ ## Fire 时刻 hooks
178
+
179
+ 配上 `hooks: { onBeforeFire, onLLMOutput, executeToolCalls }` 之后,AI 类任务的 prompt 不再是排程那一刻冻结的文本,而是 cron 触发时现场组装,工具也在服务端就地跑完,全程不需要客户端在线。完整用法见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md) 的「Fire 时刻 hooks」。
180
+
181
+ 三个 hook 拿到的 ctx 上都有这几个口子:
182
+
183
+ | ctx 上的口子 | 干什么 |
184
+ |---|---|
185
+ | `readState(ns)` / `writeState(ns, entries)` | 读写 `client_state`,和客户端 `GET/PUT /client-state` 是同一份数据 |
186
+ | `scheduleTask(options)` | 给同一个用户再建一条定时任务 |
187
+ | `scratch` | 本次 fire 的便签对象,三个 hook 加上发送后的 `onAfterSend` 共享同一个引用,fire 结束即丢弃 |
188
+
189
+ `onLLMOutput` / `executeToolCalls` 的 ctx 上另外带着任务身份:
190
+
191
+ | 字段 | 是什么 |
192
+ |---|---|
193
+ | `taskId` | 任务行 id |
194
+ | `taskUuid` | 任务 uuid(排程方选的那个) |
195
+ | `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
196
+
197
+ `sessionId` 是给日志和去重用的不透明字符串,格式随版本变,别拆它拿上面这些值。
198
+
199
+ ### config 级 hook
200
+
201
+ 这两个挂在 worker 工厂 config 的顶层(不在 `hooks` 里):
202
+
203
+ | hook | 什么时候调 | 载荷 |
204
+ |---|---|---|
205
+ | `onAfterSend` | fire 的 pushPayloads 逐段发完,或中途发挂 | `{ task, sentCount, total, error, scratch, readState, writeState }` |
206
+ | `onStaleSkip` | 任务错过触发时刻超过 60 分钟、这一次(或这几次)不再补发 | `{ reason, action, metadata, recurrenceType, occurrenceMs, skippedCount, skippedOccurrences, skippedTruncated, nextSendAt, readState, writeState }` |
207
+
208
+ 两个 hook 都自带 `readState` / `writeState`,作用于当前用户的 `client_state`,语义与 fire 级那套一致。`onStaleSkip` 尤其需要:服务停摆恢复后的第一跳里可能一次 fire 都没跑过,而那正是它要留痕迹的时候。
209
+
210
+ `onAfterSend` 的 `scratch` 与本次 fire 的 `onBeforeFire` / `onLLMOutput` 是同一个引用——「这次生成了哪几段正文」之类的上下文直接从这里读,不用自建按任务分格的登记表。全部成功时 `error` 为 `null`;第 k 段失败时 `sentCount = k`、`error` 带原始错误,且在错误往上抛之前调用完。
211
+
212
+ `onStaleSkip` 的 `action` 分两种:
213
+
214
+ - `expired` —— 一次性任务,这一次永远不会补发了,行已标 `failed`。
215
+ - `fast_forwarded` —— 循环任务,攒下的这几次都跳过,排期已快进到 `nextSendAt`,行仍是 `pending`,下一次照常触发。
216
+
217
+ `skippedCount` 是一共跳过几次(含名义那一次),`skippedOccurrences` 是被跳过的名义时刻列表(epoch 毫秒);超过 32 次时只给首末两个并把 `skippedTruncated` 置 `true`。两种 action 都会把原因写进 payload 的 `lastError`,`GET /messages` 上看得见。
218
+
219
+ 两个 hook 都是 best-effort:自身抛错只记日志,不影响主流程。
220
+
221
+ ### `ctx.scheduleTask(options)`
222
+
223
+ 角色在这次 fire 里给自己排一条后续任务:「这条发完,一个半小时后我再接着说一句」。建出来的是一条正常的任务行,到点由 cron 触发,用户全程离线也不影响。
224
+
225
+ ```js
226
+ const result = await ctx.scheduleTask({
227
+ firstSendTime: new Date(Date.now() + 90 * 60_000).toISOString(), // 必填,ISO 字符串
228
+ messageType: 'auto', // 可选,默认继承当前任务
229
+ recurrenceType: 'none', // 可选,默认 none
230
+ tzId: 'Asia/Tokyo', // 可选,默认继承当前任务;循环推进按这个时区的墙钟走
231
+ metadata: { beat: 'followup' }, // 可选,整体替换当前任务的 metadata(不深合并)
232
+ uuid: `fire-${ctx.taskId}-${ctx.occurrenceMs}`, // 可选,默认随机
233
+ });
234
+ // → { created: true, id, uuid, nextSendAt }
235
+ // 或 { created: false, reason: 'duplicate', uuid, task }
236
+ ```
237
+
238
+ 撞 uuid 时 `task` 是那条**已经存在的任务行**的投影,形状与 `GET /messages` 列出来的一样(`{ id, uuid, contactName, messageType, messageSubtype, nextSendAt, recurrenceType, tzId, status, retryCount, createdAt, updatedAt, charId, clientTaskId, lastError }`,不含任何凭据)。用确定性 uuid 做重试幂等时,重跑那轮靠它把这条任务记进自己的账本、随 push 带回客户端认领——否则这条任务只活在数据库里,面板列不出、用户取消不了,却照样到点触发。行读不回来(已经不是 pending)→ `task` 为 `null`。
239
+
240
+ 凭据和投递配置(`apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` / `tzId` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。推送订阅是用户级的一份,任务不携带、也不用继承。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
241
+
242
+ 护栏:
243
+
244
+ | 护栏 | 阈值 / 规则 | 不满足时 | 为什么 |
245
+ |---|---|---|---|
246
+ | `firstSendTime` | 必填、能解析成合法时间、至少比现在晚 **60 秒** | `RangeError` | cron 一分钟一跳,排在 60 秒内等于让下一跳立刻捡走,容易变成自己触发自己的紧密循环 |
247
+ | `messageType` | 只收 `auto` / `prompted` / `fixed` | `TypeError` | `instant` 的语义是「建行的那一刻就投递」,那条路径归 `POST /schedule-message` 管;从 fire 里造这么一行,投递时机反而说不清 |
248
+ | `messageType: 'fixed'` | 必须有 `userMessage`(自己传或继承到) | `TypeError` | 固定文本任务没有正文,就是一条永远发空的任务 |
249
+ | 单次 fire 的建任务条数 | 默认 **2 条**,factory 配置 `maxScheduledTasksPerFire` 可调(`0` = 不许自排) | `RangeError` | 模型自排后续本质上是条能无限延伸的链,没有上限就没人按停止键 |
250
+ | `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid, task }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
251
+ | `tzId` | 可用的 IANA 时区 id,或 `null` | `TypeError` | 认不出来的时区会让循环推进悄悄退回 UTC,用户设的钟点从此对不上 |
252
+ | 数据库适配器没有 `createTask` | — | 抛 `AGENTIC_SCHEDULE_UNSUPPORTED` | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
253
+
254
+ `recurrenceType` 沿用排程接口那套 `none` / `daily` / `weekly`,别的值抛 `TypeError`。参数不合法的调用不占建任务额度;uuid 撞车占(那条任务其实已经建出来了)。
255
+
256
+ `GET /capabilities` 的 features 里有 `agentic-schedule-task`,前端可以据此判断部署的 worker 认不认这条链路。
257
+
107
258
  ## 导出(新增)
108
259
 
109
- - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
260
+ - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。形状规则统一在 `@rei-standard/amsg-shared` 的 `validateLlmMessagesShape`,和 `@rei-standard/amsg-instant` 共用同一实现(含 agentic 会话:assistant 带 `tool_calls` 时 content 可空、`role:'tool'` 要求 `tool_call_id`)。
110
261
  - `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
262
+ - `MAX_PUSH_PAYLOAD_BYTES` — 一条 push 的明文上限,3993 字节。
263
+ - `PUSH_ENVELOPE_RESERVED_BYTES` — 库在 hook 交还 payload 之后还要补的那批字段占的字节上界,384。
264
+ - `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES` — 推送服务的密文 body 上限(4096)与 aes128gcm 固定开销(103),上面那个数就是两者相减。
265
+ - `measurePushPayload(payload)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit }`。
266
+
267
+ 以上几个在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有。
111
268
 
112
269
  ## 一体化初始化流程
113
270
 
@@ -123,21 +280,65 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
123
280
  - `send-notifications`
124
281
  - `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
125
282
 
283
+ ## 循环任务的时区(`tzId`)
284
+
285
+ `daily` / `weekly` 任务可以带一个 IANA 时区 id:
286
+
287
+ ```js
288
+ await client.scheduleMessage({
289
+ contactName: 'Rei',
290
+ messageType: 'auto',
291
+ firstSendTime: '2026-03-07T13:00:00.000Z', // 纽约当地 08:00
292
+ recurrenceType: 'daily',
293
+ tzId: 'America/New_York',
294
+ // …
295
+ });
296
+ ```
297
+
298
+ 带了 `tzId` 的任务按**那个时区的墙钟**推进:日期 +1 天 / +7 天,钟点原样保留。用户设的「每天早八点」在夏令时切换前后都还是早八点。不带 `tzId` 的任务按 UTC 推进(等价于固定 +24h / +7×24h)。
299
+
300
+ `PUT /update-message` 也认这个字段:传时区 id 换一个,传 `null` 改回按 UTC 推进(`hasOwnProperty` 判断,不会被吞掉)。`GET /messages` 每条任务多返回一个 `tzId`(没设 → `null`)。
301
+
302
+ 两个边界情况的收敛规则:春令时被跳过的墙钟(例如纽约 2:30 不存在)落到切换之后的等价时刻(当地 3:30);秋令时重复出现的墙钟(当地 1:30 出现两次)取其中一个,不触发两次。时区换算全部走 `Intl`,不手搓偏移加减。
303
+
304
+ ## 触发任务时的占位
305
+
306
+ `send-notifications`(以及单用户 Worker 的 `scheduled()`)每条任务开跑前会先占位:在这一行的 `lease_until` 上写下「归我管到现在 + 租期为止」,本次投递期间别的 tick 领不走它;占位改到 0 行说明别人先领走了,本次直接跳过。cron 一分钟一跳而带工具的 AI 任务常常跑过一分钟,没有这层占位同一条任务会被相邻几跳重复触发。
307
+
308
+ 租约写在自己的列上,`next_send_at` 全程不动——任务列表读到的一直是用户设的那个时刻,循环任务也按它推进到下一次。投递收尾时租约就放掉,失败重试的退避(2 分钟起)不会被租期压住。
309
+
310
+ 领了任务的那一跳中途没了(Worker 被回收之类)就没人来放租约,这条任务要等租约到期才会被后面的 tick 接手。把租期设得比最慢的一次投递长一点即可。
311
+
312
+ 租期默认 10 分钟;配了 `totalTimeoutMs` 的话按它 + 2 分钟往上抬。想自己定就在 `runScheduledTick` 的 ctx(或单用户 Worker 的 config)里传 `claimLeaseMs`——注意 `createReiServer` 内置的 `/send-notifications` 处理器不透传这两个值,要调租期就自己调 `runScheduledTick`。`onBeforeFire` 里按次放宽的预算占位时看不到,那种情况也要显式设 `claimLeaseMs`。
313
+
314
+ 占位管的是定时触发这条路径。`messageType: 'instant'` 走的是「建行 → 当场投递」,不经过占位。
315
+
316
+ 内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
317
+
318
+ `lease_until` 是这次新加的列。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上;手工建表的看 `examples/cloudflare-single-user/schema.sql`。
319
+
126
320
  ## 导出 API(Exports)
127
321
 
128
- - `createReiServer`
129
- - `createAdapter`
130
- - `createTenantToken`
131
- - `verifyTenantToken`
132
- - `deriveUserEncryptionKey`
133
- - `decryptPayload`
134
- - `encryptForStorage`
135
- - `decryptFromStorage`
136
- - `validateScheduleMessagePayload`
137
- - `isValidISO8601`
138
- - `isValidUrl`
139
- - `isValidUUID`
140
- - `isValidUUIDv4`
322
+ 包根(`@rei-standard/amsg-server`):
323
+
324
+ - `createReiServer` — 多租户装配线(标准路由处理器全家)
325
+ - `createSingleUserServer` — 单用户装配线:没有租户概念,不需要 Blob 租户配置和 tenantToken 体系
326
+ - `createSingleUserCloudflareWorker` — 单用户 Cloudflare Worker 一键装配(`fetch` + `scheduled` 两个入口)
327
+ - `createAdapter` / `createD1Adapter` — pg·neon / Cloudflare D1 数据库适配器
328
+ - `runScheduledTick` — 手动触发一轮到期任务投递(自定义 cron 宿主、要调 `claimLeaseMs` 时用)
329
+ - `createWebCryptoWebPush` — 纯 Web Crypto 的 Web Push 发送器(不依赖 `web-push` 包)
330
+ - `createTenantToken` / `verifyTenantToken`
331
+ - `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
332
+ - `validateScheduleMessagePayload` / `validateLlmMessagesArray` / `validateSplitPattern` / `validateAvatarUrl`
333
+ - `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `PUSH_ENVELOPE_RESERVED_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
334
+ - `isValidISO8601` / `isValidUrl` / `isValidUUID` / `isValidUUIDv4` / `isValidTimeZoneId`
335
+ - `advanceOccurrence` / `nextFutureOccurrence` / `planNextOccurrence` — 循环任务的时区感知推进(宿主想自己算「下次什么时候」时用同一份实现)
336
+
337
+ `@rei-standard/amsg-server/cloudflare` 子路径 —— 只含「单用户 + D1 + Web Crypto 推送」这条子图,不引用多租户装配线和 pg / neon / `web-push`,所以 D1-only 安装(不装可选数据库 peer)也能干净打包,Worker 不需要 `nodejs_compat` 兼容 flag:
338
+
339
+ - `createSingleUserCloudflareWorker` / `createSingleUserServer` / `createD1Adapter` / `runScheduledTick`
340
+ - `createWebCryptoWebPush` / `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
341
+ - `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
141
342
 
142
343
  ## 运行环境与要求
143
344
 
@@ -180,7 +381,7 @@ PUBLIC_BASE_URL=https://your-domain.com
180
381
  VERCEL_PROTECTION_BYPASS=YOUR_BYPASS_KEY
181
382
  ```
182
383
 
183
- Vercel 部署配置可参考 [`examples/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/examples/vercel.json.example)。
384
+ 函数超时、响应头这类 Vercel 配置的写法可参考 [`tests/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/tests/vercel.json.example)(它是 `tests/` 健康检查端点的部署配置,不是本包的部署模板);环境变量用 `vercel env add` 或控制台配置,不写进 `vercel.json`。
184
385
 
185
386
  ## 相关链接(绝对 URL)
186
387
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Create a D1 adapter from a Cloudflare D1 binding (env.DB).
3
+ * @param {{ prepare: (sql: string) => any }} db
4
+ * @returns {import('./interface.js').DbAdapter}
5
+ */
6
+ export function createD1Adapter(db: {
7
+ prepare: (sql: string) => any;
8
+ }): import("./interface.js").DbAdapter;
9
+ export class D1Adapter {
10
+ /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
11
+ constructor(db: {
12
+ prepare: (sql: string) => any;
13
+ });
14
+ /** @private */
15
+ private _db;
16
+ /** @private */
17
+ private _now;
18
+ /** @private */
19
+ private _iso;
20
+ initSchema(): Promise<{
21
+ columnsCreated: number;
22
+ indexesCreated: number;
23
+ indexesFailed: number;
24
+ columns: any[];
25
+ indexes: ({
26
+ name: string;
27
+ status: string;
28
+ description: string;
29
+ critical: boolean;
30
+ error?: undefined;
31
+ } | {
32
+ name: string;
33
+ status: string;
34
+ description: string;
35
+ critical: boolean;
36
+ error: any;
37
+ })[];
38
+ }>;
39
+ dropSchema(): Promise<void>;
40
+ createTask(params: any): Promise<any>;
41
+ getTaskByUuid(uuid: any, userId: any): Promise<any>;
42
+ getTaskByUuidOnly(uuid: any): Promise<any>;
43
+ updateTaskById(taskId: any, updates: any): Promise<any>;
44
+ updateTaskByUuid(uuid: any, userId: any, encryptedPayload: any, extraFields: any): Promise<{
45
+ uuid: any;
46
+ updated_at: string;
47
+ }>;
48
+ deleteTaskById(taskId: any): Promise<boolean>;
49
+ deleteTaskByUuid(uuid: any, userId: any): Promise<boolean>;
50
+ getPendingTasks(limit?: number): Promise<any>;
51
+ /**
52
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
53
+ * 本次投递期间别的 tick 领不走它。
54
+ *
55
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
56
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
57
+ *
58
+ * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
59
+ * WHERE 里的两个条件各管一件事:
60
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
61
+ * 也不会把行焊死,租约到期后自然可以被接手。
62
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
63
+ * 这一跳就不该再按旧时刻发。
64
+ *
65
+ * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
66
+ * CHECK (status IN ('pending','sent','failed')),加值要重建表。
67
+ *
68
+ * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
69
+ * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
70
+ * 远领不到。
71
+ *
72
+ * @param {number} taskId
73
+ * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
74
+ * @param {string|Date} leaseUntil - 租期末尾
75
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
76
+ */
77
+ claimTask(taskId: number, expectedNextSendAt: string, leaseUntil: string | Date): Promise<boolean>;
78
+ listTasks(userId: any, opts?: {}): Promise<{
79
+ tasks: any;
80
+ total: number;
81
+ }>;
82
+ cleanupOldTasks(days?: number): Promise<any>;
83
+ getTaskStatus(uuid: any, userId: any): Promise<any>;
84
+ /**
85
+ * Batch upsert. Last-write-wins per (namespace, key): an entry older
86
+ * than the stored row (updatedAt strictly lower) is skipped; equal or
87
+ * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
88
+ *
89
+ * `cleanups` 是删除项:在同一 batch 里先于 upsert 执行,`updated_at <= ?`
90
+ * 条件保证陈旧批次删不动更新写入的行。两种形态——
91
+ * - `{ namespace, keyPrefix, updatedAt }` 删 key 前缀下的所有行,用来清掉
92
+ * 大值旧写入留下的切片行(见 lib/state-chunks.js);
93
+ * - `{ namespace, key, updatedAt }` 删这一个 key,用来删整条状态(前缀会
94
+ * 连带删掉同前缀的兄弟 key,删单条必须走精确匹配)。
95
+ *
96
+ * 删掉一整条状态就是这两种各来一条(切片行走前缀、根行走精确 key),
97
+ * `entries` 传空数组即可——见 lib/client-state-store.js。
98
+ *
99
+ * Uses D1's batch() — one network round trip for the whole set (implicit
100
+ * transaction). The client calls this endpoint inside its few-seconds
101
+ * background window, so N sequential round trips could eat the whole
102
+ * window. Bindings without batch() (e.g. the sqlite test shim, custom
103
+ * adapters) fall back to a sequential loop.
104
+ *
105
+ * @param {string} userId
106
+ * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
107
+ * @param {Array<{ namespace: string, key?: string, keyPrefix?: string, updatedAt: number }>} [cleanups]
108
+ * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
109
+ * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
110
+ */
111
+ upsertClientState(userId: string, entries: Array<{
112
+ namespace: string;
113
+ key: string;
114
+ value: string;
115
+ updatedAt: number;
116
+ }>, cleanups?: Array<{
117
+ namespace: string;
118
+ key?: string;
119
+ keyPrefix?: string;
120
+ updatedAt: number;
121
+ }>): Promise<{
122
+ upserted: number;
123
+ skipped: number;
124
+ outcomes: boolean[];
125
+ }>;
126
+ /**
127
+ * All entries of one namespace (values still encrypted).
128
+ *
129
+ * @param {string} userId
130
+ * @param {string} namespace
131
+ * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
132
+ */
133
+ getClientState(userId: string, namespace: string): Promise<Array<{
134
+ namespace: string;
135
+ key: string;
136
+ value: string;
137
+ updated_at: number;
138
+ }>>;
139
+ /**
140
+ * Wipe every entry of this user.
141
+ *
142
+ * @param {string} userId
143
+ * @returns {Promise<number>} rows deleted
144
+ */
145
+ clearClientState(userId: string): Promise<number>;
146
+ /**
147
+ * 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
148
+ *
149
+ * @param {string} userId
150
+ * @returns {Promise<{ subscription: string, updated_at: number }|null>}
151
+ */
152
+ getPushSubscription(userId: string): Promise<{
153
+ subscription: string;
154
+ updated_at: number;
155
+ } | null>;
156
+ /**
157
+ * 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
158
+ * 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
159
+ *
160
+ * @param {string} userId
161
+ * @param {string} encryptedSubscription
162
+ * @param {number} updatedAt - epoch 毫秒
163
+ * @returns {Promise<boolean>}
164
+ */
165
+ upsertPushSubscription(userId: string, encryptedSubscription: string, updatedAt: number): Promise<boolean>;
166
+ /**
167
+ * 删掉这个用户的订阅(设置页「停止接收推送」)。
168
+ *
169
+ * @param {string} userId
170
+ * @returns {Promise<boolean>} true = 确实删掉了一行
171
+ */
172
+ deletePushSubscription(userId: string): Promise<boolean>;
173
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Adapter Factory
3
+ *
4
+ * Creates a database adapter instance based on the supplied configuration.
5
+ *
6
+ * @typedef {'neon'|'pg'} DriverName
7
+ *
8
+ * @typedef {Object} AdapterConfig
9
+ * @property {DriverName} driver - Which database driver to use.
10
+ * @property {string} connectionString - Database connection URL.
11
+ */
12
+ /**
13
+ * Create a database adapter.
14
+ *
15
+ * @param {AdapterConfig} config
16
+ * @returns {Promise<import('./interface.js').DbAdapter>}
17
+ */
18
+ export function createAdapter(config: AdapterConfig): Promise<import("./interface.js").DbAdapter>;
19
+ /**
20
+ * Adapter Factory
21
+ *
22
+ * Creates a database adapter instance based on the supplied configuration.
23
+ */
24
+ export type DriverName = "neon" | "pg";
25
+ /**
26
+ * Adapter Factory
27
+ *
28
+ * Creates a database adapter instance based on the supplied configuration.
29
+ */
30
+ export type AdapterConfig = {
31
+ /**
32
+ * - Which database driver to use.
33
+ */
34
+ driver: DriverName;
35
+ /**
36
+ * - Database connection URL.
37
+ */
38
+ connectionString: string;
39
+ };