@rei-standard/amsg-server 2.6.0-next.10 → 2.6.0-next.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -34
- package/dist/adapters/d1.d.ts +37 -2
- package/dist/adapters/interface.d.ts +33 -5
- package/dist/adapters/neon.d.ts +36 -2
- package/dist/adapters/pg.d.ts +36 -2
- package/dist/adapters/schema.d.ts +13 -1
- package/dist/adapters/schema.sqlite.d.ts +5 -1
- package/dist/{chunk-LT3BIWSN.mjs → chunk-B2FDV7YK.mjs} +29 -0
- package/dist/{chunk-ME6UG54W.mjs → chunk-RRWCPPOY.mjs} +959 -199
- package/dist/{chunk-MKTK3ERH.cjs → chunk-UWAVFCGG.cjs} +963 -203
- package/dist/{chunk-2JZ6UTQH.cjs → chunk-V2SDWGUB.cjs} +30 -1
- package/dist/cloudflare.cjs +12 -2
- package/dist/cloudflare.d.ts +2 -1
- package/dist/cloudflare.mjs +11 -1
- package/dist/handlers/get-message.d.ts +3 -0
- package/dist/handlers/push-subscription.d.ts +5 -0
- package/dist/index.cjs +34 -20
- package/dist/index.d.cts +13 -2
- package/dist/index.d.ts +13 -2
- package/dist/index.mjs +18 -4
- package/dist/lib/agentic-fire.d.ts +47 -0
- package/dist/lib/push-subscription-store.d.ts +63 -0
- package/dist/lib/recurrence.d.ts +51 -0
- package/dist/lib/run-tick.d.ts +52 -0
- package/dist/lib/state-accessors.d.ts +70 -0
- package/dist/lib/task-projection.d.ts +77 -0
- package/dist/lib/validation.d.ts +2 -1
- package/dist/lib/webpush-webcrypto.d.ts +23 -6
- package/dist/{neon-HOSUZYA4.cjs → neon-3ASBLLCM.cjs} +91 -14
- package/dist/{neon-3WTZMLA6.mjs → neon-CUIGAB2T.mjs} +84 -7
- package/dist/{pg-NGNJ3QHV.cjs → pg-IEZTAR43.cjs} +88 -14
- package/dist/{pg-456LAVPS.mjs → pg-KMJPL6QV.mjs} +81 -7
- package/dist/single-user.d.ts +9 -0
- package/package.json +2 -2
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
|
-
|
|
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,10 @@ 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
|
+
// GET /api/v1/message?id={uuid} -> rei.handlers.getMessage.GET
|
|
47
|
+
// PUT /api/v1/push-subscription -> rei.handlers.pushSubscription.PUT
|
|
48
|
+
// GET /api/v1/push-subscription -> rei.handlers.pushSubscription.GET
|
|
49
|
+
// DELETE /api/v1/push-subscription -> rei.handlers.pushSubscription.DELETE
|
|
53
50
|
```
|
|
54
51
|
|
|
55
52
|
## 关于 `messageType: 'instant'`
|
|
@@ -119,8 +116,86 @@ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify
|
|
|
119
116
|
// remainingBytes = 还能再塞多少字节(已超限时为负)
|
|
120
117
|
```
|
|
121
118
|
|
|
119
|
+
### 信封预留:fire-time hook 组 payload 时要多留一截
|
|
120
|
+
|
|
121
|
+
hook 把 `pushPayloads` 交还给库之后,库还会往每条 push 上补一批「这是谁、第几条、什么时候」的字段:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
messageId / sessionId / timestamp / messageIndex / totalMessages
|
|
125
|
+
taskId / taskUuid / recurrenceType / occurrenceMs
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
也就是说 hook 手里量到的不是最终 payload。这批字段占的字节由导出的 `PUSH_ENVELOPE_RESERVED_BYTES`(384 字节,含 JSON 的引号逗号,按 uuid ≤ 64 字符算)兜住;`measurePushPayload` 传 `{ reserveEnvelope: true }` 就是「库补完字段之后还装得下」的口径:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { measurePushPayload, PUSH_ENVELOPE_RESERVED_BYTES } from '@rei-standard/amsg-server';
|
|
132
|
+
|
|
133
|
+
const { remainingBytes } = measurePushPayload(
|
|
134
|
+
JSON.stringify({ ...basePush, message: '' }),
|
|
135
|
+
{ reserveEnvelope: true }
|
|
136
|
+
);
|
|
137
|
+
const message = body.length <= remainingBytes ? body : body.slice(0, remainingBytes);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
用比 64 字符更长的 uuid(`scheduleTask` 允许传任意字符串)就自己再多留一点。
|
|
141
|
+
|
|
142
|
+
## 推送订阅(用户级)
|
|
143
|
+
|
|
144
|
+
推送订阅一个用户存一份,任务行不携带它,到点投递时现读。用户清了站点数据、重装了 PWA、或者推送服务轮换了 endpoint 之后,覆盖这一份就够了——所有已排的任务,包括角色在 fire 里给自己排的、客户端根本不知道存在的那些,下次触发读到的都是新订阅。
|
|
145
|
+
|
|
146
|
+
| 端点 | 语义 |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `PUT /push-subscription` | 登记 / 覆盖。body =(加密后的)`{ subscription, updatedAt? }`,`subscription` 至少要有非空 `endpoint` |
|
|
149
|
+
| `GET /push-subscription` | `{ exists, updatedAt, endpoint }`。不含订阅的密钥部分——判断「登记过没有、是不是我手里这一个」用 `endpoint` 就够 |
|
|
150
|
+
| `DELETE /push-subscription` | 删掉(设置页的「停止接收推送」) |
|
|
151
|
+
|
|
152
|
+
客户端侧对应 `client.putPushSubscription(subscription)` / `getPushSubscription()` / `deletePushSubscription()`。什么时候调 PUT:`subscribePush()` 拿到订阅之后一次,之后每次应用启动确认订阅仍然有效时再一次(幂等覆盖)。
|
|
153
|
+
|
|
154
|
+
配套的约束:
|
|
155
|
+
|
|
156
|
+
- `POST /schedule-message` 在这个用户还没登记订阅时返回 `409 PUSH_SUBSCRIPTION_MISSING`——建了也永远发不出去,早点说清楚比让它烂在库里强。
|
|
157
|
+
- `POST /schedule-message` 和 `PUT /update-message` 都不收 `pushSubscription` 字段(带了返回 `400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`):静默丢弃会让人以为「这条任务用的是我传的这个订阅」。
|
|
158
|
+
- 投递时读不到订阅(没登记 / 被删了)→ 任务按投递失败处理,原因记进 payload 的 `lastError`,`GET /messages` 上看得见。
|
|
159
|
+
- 数据库侧是 `push_subscriptions` 表(`user_id` 主键,`subscription` 密文,`updated_at` epoch 毫秒)。内置的 D1 / pg / neon 适配器都实现了,`initSchema()` 会建表。自定义适配器要补 `getPushSubscription` / `upsertPushSubscription` / `deletePushSubscription` 三个方法,缺任何一个这几个端点返回 501。
|
|
160
|
+
|
|
122
161
|
装不下的内容(长文、附件详情)建议走旁路:正文存进 `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)。
|
|
123
162
|
|
|
163
|
+
## 读一条任务:列表 vs 单条
|
|
164
|
+
|
|
165
|
+
| 端点 | 给什么 |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `GET /messages` | 任务列表。每条只带 `charId` / `clientTaskId` 两个 `metadata` 子字段 |
|
|
168
|
+
| `GET /message?id=<uuid>` | 单条任务。同样的形状,外加**完整的 `metadata`** |
|
|
169
|
+
|
|
170
|
+
什么时候需要单条:`PUT /update-message` 对 `metadata` 是**整体替换**(不深合并),所以「只改 metadata 里的一个键」必须先把完整的那份读回来,改完再整份传上去;只传一部分会把宿主存在里面的其余键(任务指令、锚点时间戳、过期策略之类)一起冲掉。列表不带整份 metadata,是因为一页最多 100 条,每条都驮着它会把响应撑得很大,而列表要的只是「有哪些任务」。
|
|
171
|
+
|
|
172
|
+
`GET /message` 只读得到还没发出去的任务;已完成 / 已失败的返回 `409 TASK_ALREADY_COMPLETED`,不存在返回 `404 TASK_NOT_FOUND`(与 `PUT /update-message` 同一口径)。响应和列表一样是加密的,客户端侧对应 `client.getMessage(uuid)`。
|
|
173
|
+
|
|
174
|
+
## 更新任务时能改哪些字段
|
|
175
|
+
|
|
176
|
+
`PUT /update-message` 的可写字段:`contactName` / `avatarUrl` / `userMessage` / `completePrompt` / `messages` / `nextSendAt` / `recurrenceType` / `tzId` / `metadata` / `maxTokens` / `temperature` / `splitPattern`,以及凭据三件套 `apiUrl` / `apiKey` / `primaryModel`。
|
|
177
|
+
|
|
178
|
+
- `contactName` 必须是非空字符串(口径与排程时一致),空串 / `null` / 非字符串一律 `400`。用户给角色改了名之后,之前排的任务推送出来的通知标题(「来自 <contactName>」)靠它跟着改。
|
|
179
|
+
- `metadata` 是整体替换,不深合并——只改一个子字段的读-改-写流程见上一节。
|
|
180
|
+
- `avatarUrl` 显式传 `null` 是「不改」而不是「清空」(§6.2 的软清空策略要求非法头像被摘掉时保留旧头像,「摘掉」和「传了个 null」在这一层是同一件事)。
|
|
181
|
+
- 凭据三件套传 `null` 同样只是忽略:清掉任何一个,任务到点就发不出去。
|
|
182
|
+
- `pushSubscription` 不收(`400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`),它是用户级的一份,走 `PUT /push-subscription`。
|
|
183
|
+
|
|
184
|
+
## 推送自带任务身份
|
|
185
|
+
|
|
186
|
+
每条从任务行发出去的 push(冻结 prompt 路径和 fire-time hook 路径都算)顶层带这四个字段:
|
|
187
|
+
|
|
188
|
+
| 字段 | 是什么 |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `taskId` | 任务行 id;没有行的 in-server instant 路径为 `null` |
|
|
191
|
+
| `taskUuid` | 任务 uuid(排程方选的那个) |
|
|
192
|
+
| `recurrenceType` | `none` / `daily` / `weekly` —— 这条任务还会不会再来 |
|
|
193
|
+
| `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
|
|
194
|
+
|
|
195
|
+
客户端据此认领任务:角色在 fire 里给自己排的任务,客户端从没见过它,靠这四个字段就能把它记进面板、让用户取消得掉。放在顶层而不是 `metadata` 里——`metadata` 是调用方自己的地盘,库不往里写。
|
|
196
|
+
|
|
197
|
+
hook 在 `pushPayloads` 里自己写了这几个字段的话会被库覆盖:它们描述的是任务行的事实,不是内容。
|
|
198
|
+
|
|
124
199
|
## Fire 时刻 hooks
|
|
125
200
|
|
|
126
201
|
配上 `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」。
|
|
@@ -131,7 +206,51 @@ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify
|
|
|
131
206
|
|---|---|
|
|
132
207
|
| `readState(ns)` / `writeState(ns, entries)` | 读写 `client_state`,和客户端 `GET/PUT /client-state` 是同一份数据 |
|
|
133
208
|
| `scheduleTask(options)` | 给同一个用户再建一条定时任务 |
|
|
134
|
-
| `scratch` | 本次 fire 的便签对象,三个 hook 共享同一个引用,fire 结束即丢弃 |
|
|
209
|
+
| `scratch` | 本次 fire 的便签对象,三个 hook 加上发送后的 `onAfterSend` 共享同一个引用,fire 结束即丢弃 |
|
|
210
|
+
|
|
211
|
+
`onLLMOutput` / `executeToolCalls` 的 ctx 上另外带着任务身份:
|
|
212
|
+
|
|
213
|
+
| 字段 | 是什么 |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `taskId` | 任务行 id |
|
|
216
|
+
| `taskUuid` | 任务 uuid(排程方选的那个) |
|
|
217
|
+
| `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
|
|
218
|
+
|
|
219
|
+
`sessionId` 是给日志和去重用的不透明字符串,格式随版本变,别拆它拿上面这些值。
|
|
220
|
+
|
|
221
|
+
### config 级 hook
|
|
222
|
+
|
|
223
|
+
这两个挂在 worker 工厂 config 的顶层(不在 `hooks` 里):
|
|
224
|
+
|
|
225
|
+
| hook | 什么时候调 | 载荷 |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `onAfterSend` | fire 的 pushPayloads 逐段发完,或中途发挂 | `{ task, sentCount, total, error, scratch, readState, writeState }` |
|
|
228
|
+
| `onFireSettled` | 一次 fire 收尾——只要 `onBeforeFire` 被调用过,什么结局都调一次 | `{ task, status, skipReason, sentCount, total, iterations, error, scratch, readState, writeState }` |
|
|
229
|
+
| `onStaleSkip` | 任务错过触发时刻超过 60 分钟、这一次(或这几次)不再补发 | `{ reason, action, metadata, recurrenceType, occurrenceMs, skippedCount, skippedOccurrences, skippedTruncated, nextSendAt, readState, writeState }` |
|
|
230
|
+
|
|
231
|
+
三个 hook 都自带 `readState` / `writeState`,作用于当前用户的 `client_state`,语义与 fire 级那套一致。`onStaleSkip` 尤其需要:服务停摆恢复后的第一跳里可能一次 fire 都没跑过,而那正是它要留痕迹的时候。
|
|
232
|
+
|
|
233
|
+
`onAfterSend` 的 `scratch` 与本次 fire 的 `onBeforeFire` / `onLLMOutput` 是同一个引用——「这次生成了哪几段正文」之类的上下文直接从这里读,不用自建按任务分格的登记表。全部成功时 `error` 为 `null`;第 k 段失败时 `sentCount = k`、`error` 带原始错误,且在错误往上抛之前调用完。
|
|
234
|
+
|
|
235
|
+
`onFireSettled` 是「这次 fire 结束了」这一个信号,`status` 说明结局:
|
|
236
|
+
|
|
237
|
+
| status | 什么时候 |
|
|
238
|
+
|---|---|
|
|
239
|
+
| `sent` | pushPayloads 全部发完(`sentCount === total`) |
|
|
240
|
+
| `skipped` | 这次不发。`skipReason` 区分是 `onBeforeFire` 直接 `{ skip: true }`(`'before-fire'`)还是模型跑完后判定不发(`'skip-push'`) |
|
|
241
|
+
| `failed` | 链路抛错,`error` 带原始错误。发到第 k 段挂了也是这个:`sentCount = k`、`total` 是原本要发的段数 |
|
|
242
|
+
| `not-handled` | `onBeforeFire` 返回 `null`,这条任务交还给排程时冻结的 prompt 老链路。那条链路不归 fire hook 管,它后面发没发出去不体现在这里 |
|
|
243
|
+
|
|
244
|
+
跟 `onAfterSend` 的分工:`onAfterSend` 只走「有 push 要发」这条路,所以 hook 判断这次不用说话、或者链路中途抛错时它不会被调到——「开始时占点什么、结束时放掉」的写法要挂 `onFireSettled`(fire 里已经用 `ctx.scheduleTask` 建出来的任务,不记账就成了只活在数据库里的幽灵任务;fire 开头拿的锁,没有可靠释放点就只能等 TTL)。正常发完时两个都会调,`onAfterSend` 在前。`scratch` 是同一个引用。没配 hooks 的部署、以及不需要 LLM 的固定文本任务不走 fire 这条路径,两个都不会调。
|
|
245
|
+
|
|
246
|
+
`onStaleSkip` 的 `action` 分两种:
|
|
247
|
+
|
|
248
|
+
- `expired` —— 一次性任务,这一次永远不会补发了,行已标 `failed`。
|
|
249
|
+
- `fast_forwarded` —— 循环任务,攒下的这几次都跳过,排期已快进到 `nextSendAt`,行仍是 `pending`,下一次照常触发。
|
|
250
|
+
|
|
251
|
+
`skippedCount` 是一共跳过几次(含名义那一次),`skippedOccurrences` 是被跳过的名义时刻列表(epoch 毫秒);超过 32 次时只给首末两个并把 `skippedTruncated` 置 `true`。两种 action 都会把原因写进 payload 的 `lastError`,`GET /messages` 上看得见。
|
|
252
|
+
|
|
253
|
+
两个 hook 都是 best-effort:自身抛错只记日志,不影响主流程。
|
|
135
254
|
|
|
136
255
|
### `ctx.scheduleTask(options)`
|
|
137
256
|
|
|
@@ -142,14 +261,17 @@ const result = await ctx.scheduleTask({
|
|
|
142
261
|
firstSendTime: new Date(Date.now() + 90 * 60_000).toISOString(), // 必填,ISO 字符串
|
|
143
262
|
messageType: 'auto', // 可选,默认继承当前任务
|
|
144
263
|
recurrenceType: 'none', // 可选,默认 none
|
|
264
|
+
tzId: 'Asia/Tokyo', // 可选,默认继承当前任务;循环推进按这个时区的墙钟走
|
|
145
265
|
metadata: { beat: 'followup' }, // 可选,整体替换当前任务的 metadata(不深合并)
|
|
146
|
-
uuid: `fire-${ctx.
|
|
266
|
+
uuid: `fire-${ctx.taskId}-${ctx.occurrenceMs}`, // 可选,默认随机
|
|
147
267
|
});
|
|
148
268
|
// → { created: true, id, uuid, nextSendAt }
|
|
149
|
-
// 或 { created: false, reason: 'duplicate', uuid }
|
|
269
|
+
// 或 { created: false, reason: 'duplicate', uuid, task }
|
|
150
270
|
```
|
|
151
271
|
|
|
152
|
-
|
|
272
|
+
撞 uuid 时 `task` 是那条**已经存在的任务行**的投影,形状与 `GET /messages` 列出来的一样(`{ id, uuid, contactName, messageType, messageSubtype, nextSendAt, recurrenceType, tzId, status, retryCount, createdAt, updatedAt, charId, clientTaskId, lastError }`,不含任何凭据)。用确定性 uuid 做重试幂等时,重跑那轮靠它把这条任务记进自己的账本、随 push 带回客户端认领——否则这条任务只活在数据库里,面板列不出、用户取消不了,却照样到点触发。行读不回来(已经不是 pending)→ `task` 为 `null`。
|
|
273
|
+
|
|
274
|
+
凭据和投递配置(`apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` / `tzId` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。推送订阅是用户级的一份,任务不携带、也不用继承。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
|
|
153
275
|
|
|
154
276
|
护栏:
|
|
155
277
|
|
|
@@ -159,7 +281,8 @@ const result = await ctx.scheduleTask({
|
|
|
159
281
|
| `messageType` | 只收 `auto` / `prompted` / `fixed` | `TypeError` | `instant` 的语义是「建行的那一刻就投递」,那条路径归 `POST /schedule-message` 管;从 fire 里造这么一行,投递时机反而说不清 |
|
|
160
282
|
| `messageType: 'fixed'` | 必须有 `userMessage`(自己传或继承到) | `TypeError` | 固定文本任务没有正文,就是一条永远发空的任务 |
|
|
161
283
|
| 单次 fire 的建任务条数 | 默认 **2 条**,factory 配置 `maxScheduledTasksPerFire` 可调(`0` = 不许自排) | `RangeError` | 模型自排后续本质上是条能无限延伸的链,没有上限就没人按停止键 |
|
|
162
|
-
| `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
|
|
284
|
+
| `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid, task }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
|
|
285
|
+
| `tzId` | 可用的 IANA 时区 id,或 `null` | `TypeError` | 认不出来的时区会让循环推进悄悄退回 UTC,用户设的钟点从此对不上 |
|
|
163
286
|
| 数据库适配器没有 `createTask` | — | 抛 `AGENTIC_SCHEDULE_UNSUPPORTED` | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
|
|
164
287
|
|
|
165
288
|
`recurrenceType` 沿用排程接口那套 `none` / `daily` / `weekly`,别的值抛 `TypeError`。参数不合法的调用不占建任务额度;uuid 撞车占(那条任务其实已经建出来了)。
|
|
@@ -171,10 +294,11 @@ const result = await ctx.scheduleTask({
|
|
|
171
294
|
- `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。形状规则统一在 `@rei-standard/amsg-shared` 的 `validateLlmMessagesShape`,和 `@rei-standard/amsg-instant` 共用同一实现(含 agentic 会话:assistant 带 `tool_calls` 时 content 可空、`role:'tool'` 要求 `tool_call_id`)。
|
|
172
295
|
- `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
|
|
173
296
|
- `MAX_PUSH_PAYLOAD_BYTES` — 一条 push 的明文上限,3993 字节。
|
|
297
|
+
- `PUSH_ENVELOPE_RESERVED_BYTES` — 库在 hook 交还 payload 之后还要补的那批字段占的字节上界,384。
|
|
174
298
|
- `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES` — 推送服务的密文 body 上限(4096)与 aes128gcm 固定开销(103),上面那个数就是两者相减。
|
|
175
299
|
- `measurePushPayload(payload)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit }`。
|
|
176
300
|
|
|
177
|
-
|
|
301
|
+
以上几个在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有。
|
|
178
302
|
|
|
179
303
|
## 一体化初始化流程
|
|
180
304
|
|
|
@@ -185,11 +309,32 @@ const result = await ctx.scheduleTask({
|
|
|
185
309
|
|
|
186
310
|
## 端点鉴权
|
|
187
311
|
|
|
188
|
-
- `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`
|
|
312
|
+
- `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`、`message`
|
|
189
313
|
- `Authorization: Bearer <tenantToken>`
|
|
190
314
|
- `send-notifications`
|
|
191
315
|
- `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
|
|
192
316
|
|
|
317
|
+
## 循环任务的时区(`tzId`)
|
|
318
|
+
|
|
319
|
+
`daily` / `weekly` 任务可以带一个 IANA 时区 id:
|
|
320
|
+
|
|
321
|
+
```js
|
|
322
|
+
await client.scheduleMessage({
|
|
323
|
+
contactName: 'Rei',
|
|
324
|
+
messageType: 'auto',
|
|
325
|
+
firstSendTime: '2026-03-07T13:00:00.000Z', // 纽约当地 08:00
|
|
326
|
+
recurrenceType: 'daily',
|
|
327
|
+
tzId: 'America/New_York',
|
|
328
|
+
// …
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
带了 `tzId` 的任务按**那个时区的墙钟**推进:日期 +1 天 / +7 天,钟点原样保留。用户设的「每天早八点」在夏令时切换前后都还是早八点。不带 `tzId` 的任务按 UTC 推进(等价于固定 +24h / +7×24h)。
|
|
333
|
+
|
|
334
|
+
`PUT /update-message` 也认这个字段:传时区 id 换一个,传 `null` 改回按 UTC 推进(`hasOwnProperty` 判断,不会被吞掉)。`GET /messages` 每条任务多返回一个 `tzId`(没设 → `null`)。
|
|
335
|
+
|
|
336
|
+
两个边界情况的收敛规则:春令时被跳过的墙钟(例如纽约 2:30 不存在)落到切换之后的等价时刻(当地 3:30);秋令时重复出现的墙钟(当地 1:30 出现两次)取其中一个,不触发两次。时区换算全部走 `Intl`,不手搓偏移加减。
|
|
337
|
+
|
|
193
338
|
## 触发任务时的占位
|
|
194
339
|
|
|
195
340
|
`send-notifications`(以及单用户 Worker 的 `scheduled()`)每条任务开跑前会先占位:在这一行的 `lease_until` 上写下「归我管到现在 + 租期为止」,本次投递期间别的 tick 领不走它;占位改到 0 行说明别人先领走了,本次直接跳过。cron 一分钟一跳而带工具的 AI 任务常常跑过一分钟,没有这层占位同一条任务会被相邻几跳重复触发。
|
|
@@ -204,27 +349,61 @@ const result = await ctx.scheduleTask({
|
|
|
204
349
|
|
|
205
350
|
内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
|
|
206
351
|
|
|
207
|
-
`lease_until`
|
|
352
|
+
投递失败的退避记在 `retry_after` 上,租约同时放掉。两件事分两列记:`lease_until` 只表示「这条正在跑」,`retry_after` 表示「这条没在跑,在等重试」。挤在一列的话,下面的分组串行会把一条正在退避、其实闲着的任务当成「这一组忙着」,同组别的任务白等一轮退避(最长 6 分钟)。
|
|
353
|
+
|
|
354
|
+
## 同一分组的任务不并发(`serializeBy`)
|
|
355
|
+
|
|
356
|
+
同一个角色可能有好几条定时任务。撞在一起并发跑的话,用户一口气收到两条互不知情的消息;宿主在 hook 里维护的「我刚才说过什么」台账通常是读进内存 → 改 → 整份写回,两条各改各的再写回,后写的必然盖掉前面那条。
|
|
357
|
+
|
|
358
|
+
`runScheduledTick`(以及单用户 Worker 的 config)收一个可选的 `serializeBy`:
|
|
359
|
+
|
|
360
|
+
```js
|
|
361
|
+
await runScheduledTick({
|
|
362
|
+
// ...其余 ctx
|
|
363
|
+
serializeBy: (task) => task.metadata?.charId ?? null,
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
- 参数是与 `onBeforeFire` 的 `ctx.task` 同一份的只读任务视图(凭据已剔除)。
|
|
368
|
+
- 返回 `null` / 空串、或者不配这个函数 → 这条任务不参与串行,行为与以前完全一致。
|
|
369
|
+
- 同一分组同时只放行一条,**跨跳也算**:上一跳的 fire 还拿着租约时,下一跳捞到同组的另一条也不放行。一次 fire 常常跑十几秒到几分钟,只挡同一跳是不够的。
|
|
370
|
+
- 同一跳内同组放行的是**到点更早**的那条;跑完之后不补跑同组剩下的,留给下一跳。
|
|
371
|
+
- 被拦下的任务是**推迟不是丢弃**:`next_send_at` / `status` / `retry_count` 一个字段都不会被动,下一跳原样再捞一次。条数记在 `details.serializeSkippedTasks`(同一跳内拦下的)和 `details.claimSkippedTasks`(跨跳拦下的)里。
|
|
372
|
+
- `serializeBy` 自身抛错时这条任务这一跳不跑:分不清它属于哪一组,就不该冒着破坏台账的风险跑下去。
|
|
373
|
+
- 正在等重试的任务不算「这一组忙着」(退避记在 `retry_after` 上,租约已经放掉)。
|
|
374
|
+
|
|
375
|
+
判定和占位是同一条 `UPDATE`:先查「这一组忙不忙」再占位的话,两个 tick 的查询会双双在对方占位之前返回「不忙」。分组 key 不明文落库——库拿它和该用户的存储密钥做一次 HMAC,`serialize_group` 列存的是那个派生值。
|
|
376
|
+
|
|
377
|
+
自定义适配器实现了 `claimTask` 但忽略第四个参数的,分组串行退化成只在同一跳内生效;完全没实现 `claimTask` 的同理。
|
|
378
|
+
|
|
379
|
+
`createReiServer` 内置的 `/send-notifications` 处理器不透传 `serializeBy`(和 `claimLeaseMs` 一样),多租户部署要用就自己调 `runScheduledTick`。单用户 Worker 直接在 config 里写。
|
|
380
|
+
|
|
381
|
+
## 任务表用到的三列
|
|
382
|
+
|
|
383
|
+
`lease_until` / `retry_after` / `serialize_group`(都可空)。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上,跑几次都没事;手工维护表结构的看 `examples/cloudflare-single-user/schema.sql`,Postgres 侧对应三句 `ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS …`。分组串行还多一个索引 `idx_serialize_group_lease`,`initSchema` 一并建。
|
|
208
384
|
|
|
209
385
|
## 导出 API(Exports)
|
|
210
386
|
|
|
211
|
-
-
|
|
212
|
-
|
|
213
|
-
- `
|
|
214
|
-
- `
|
|
215
|
-
- `
|
|
216
|
-
- `
|
|
217
|
-
- `
|
|
218
|
-
- `
|
|
219
|
-
- `
|
|
220
|
-
- `
|
|
221
|
-
- `
|
|
222
|
-
- `WEB_PUSH_MAX_BODY_BYTES`
|
|
223
|
-
- `
|
|
224
|
-
- `
|
|
225
|
-
|
|
226
|
-
- `
|
|
227
|
-
|
|
387
|
+
包根(`@rei-standard/amsg-server`):
|
|
388
|
+
|
|
389
|
+
- `createReiServer` — 多租户装配线(标准路由处理器全家)
|
|
390
|
+
- `createSingleUserServer` — 单用户装配线:没有租户概念,不需要 Blob 租户配置和 tenantToken 体系
|
|
391
|
+
- `createSingleUserCloudflareWorker` — 单用户 Cloudflare Worker 一键装配(`fetch` + `scheduled` 两个入口)
|
|
392
|
+
- `createAdapter` / `createD1Adapter` — pg·neon / Cloudflare D1 数据库适配器
|
|
393
|
+
- `runScheduledTick` — 手动触发一轮到期任务投递(自定义 cron 宿主、要调 `claimLeaseMs` 时用)
|
|
394
|
+
- `createWebCryptoWebPush` — 纯 Web Crypto 的 Web Push 发送器(不依赖 `web-push` 包)
|
|
395
|
+
- `createTenantToken` / `verifyTenantToken`
|
|
396
|
+
- `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
|
|
397
|
+
- `validateScheduleMessagePayload` / `validateLlmMessagesArray` / `validateSplitPattern` / `validateAvatarUrl`
|
|
398
|
+
- `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `PUSH_ENVELOPE_RESERVED_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
|
|
399
|
+
- `isValidISO8601` / `isValidUrl` / `isValidUUID` / `isValidUUIDv4` / `isValidTimeZoneId`
|
|
400
|
+
- `advanceOccurrence` / `nextFutureOccurrence` / `planNextOccurrence` — 循环任务的时区感知推进(宿主想自己算「下次什么时候」时用同一份实现)
|
|
401
|
+
|
|
402
|
+
`@rei-standard/amsg-server/cloudflare` 子路径 —— 只含「单用户 + D1 + Web Crypto 推送」这条子图,不引用多租户装配线和 pg / neon / `web-push`,所以 D1-only 安装(不装可选数据库 peer)也能干净打包,Worker 不需要 `nodejs_compat` 兼容 flag:
|
|
403
|
+
|
|
404
|
+
- `createSingleUserCloudflareWorker` / `createSingleUserServer` / `createD1Adapter` / `runScheduledTick`
|
|
405
|
+
- `createWebCryptoWebPush` / `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
|
|
406
|
+
- `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
|
|
228
407
|
|
|
229
408
|
## 运行环境与要求
|
|
230
409
|
|
|
@@ -267,7 +446,7 @@ PUBLIC_BASE_URL=https://your-domain.com
|
|
|
267
446
|
VERCEL_PROTECTION_BYPASS=YOUR_BYPASS_KEY
|
|
268
447
|
```
|
|
269
448
|
|
|
270
|
-
Vercel
|
|
449
|
+
函数超时、响应头这类 Vercel 配置的写法可参考 [`tests/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/tests/vercel.json.example)(它是 `tests/` 健康检查端点的部署配置,不是本包的部署模板);环境变量用 `vercel env add` 或控制台配置,不写进 `vercel.json`。
|
|
271
450
|
|
|
272
451
|
## 相关链接(绝对 URL)
|
|
273
452
|
|
package/dist/adapters/d1.d.ts
CHANGED
|
@@ -69,12 +69,20 @@ export class D1Adapter {
|
|
|
69
69
|
* 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
|
|
70
70
|
* 远领不到。
|
|
71
71
|
*
|
|
72
|
+
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
73
|
+
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
74
|
+
* 里完成,「先查再占」的空档天然不存在——两个 tick 同时来,只有一个改得动
|
|
75
|
+
* 行。分组门只看租约,不看 `retry_after`:等着重试的任务其实闲着,不该把
|
|
76
|
+
* 同分组的其他任务一起堵住。
|
|
77
|
+
*
|
|
72
78
|
* @param {number} taskId
|
|
73
79
|
* @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
74
80
|
* @param {string|Date} leaseUntil - 租期末尾
|
|
75
|
-
* @
|
|
81
|
+
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
82
|
+
* @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、同分组
|
|
83
|
+
* 有任务正在跑、排期被改过、或行已不是 pending
|
|
76
84
|
*/
|
|
77
|
-
claimTask(taskId: number, expectedNextSendAt: string, leaseUntil: string | Date): Promise<boolean>;
|
|
85
|
+
claimTask(taskId: number, expectedNextSendAt: string, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
78
86
|
listTasks(userId: any, opts?: {}): Promise<{
|
|
79
87
|
tasks: any;
|
|
80
88
|
total: number;
|
|
@@ -143,4 +151,31 @@ export class D1Adapter {
|
|
|
143
151
|
* @returns {Promise<number>} rows deleted
|
|
144
152
|
*/
|
|
145
153
|
clearClientState(userId: string): Promise<number>;
|
|
154
|
+
/**
|
|
155
|
+
* 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
|
|
156
|
+
*
|
|
157
|
+
* @param {string} userId
|
|
158
|
+
* @returns {Promise<{ subscription: string, updated_at: number }|null>}
|
|
159
|
+
*/
|
|
160
|
+
getPushSubscription(userId: string): Promise<{
|
|
161
|
+
subscription: string;
|
|
162
|
+
updated_at: number;
|
|
163
|
+
} | null>;
|
|
164
|
+
/**
|
|
165
|
+
* 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
|
|
166
|
+
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
167
|
+
*
|
|
168
|
+
* @param {string} userId
|
|
169
|
+
* @param {string} encryptedSubscription
|
|
170
|
+
* @param {number} updatedAt - epoch 毫秒
|
|
171
|
+
* @returns {Promise<boolean>}
|
|
172
|
+
*/
|
|
173
|
+
upsertPushSubscription(userId: string, encryptedSubscription: string, updatedAt: number): Promise<boolean>;
|
|
174
|
+
/**
|
|
175
|
+
* 删掉这个用户的订阅(设置页「停止接收推送」)。
|
|
176
|
+
*
|
|
177
|
+
* @param {string} userId
|
|
178
|
+
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
179
|
+
*/
|
|
180
|
+
deletePushSubscription(userId: string): Promise<boolean>;
|
|
146
181
|
}
|
|
@@ -47,8 +47,9 @@ export type DbAdapter = {
|
|
|
47
47
|
getTaskByUuidOnly: (uuid: string) => Promise<TaskRow | null>;
|
|
48
48
|
/**
|
|
49
49
|
* Partially update a task row by its numeric id.
|
|
50
|
-
* 实现了 `claimTask` 的适配器还要认 `lease_until`(含写
|
|
51
|
-
*
|
|
50
|
+
* 实现了 `claimTask` 的适配器还要认 `lease_until` 和 `retry_after`(含写
|
|
51
|
+
* null):投递收尾时 runScheduledTick 用前者把租约放掉,用后者写/清投递失败
|
|
52
|
+
* 的退避时刻。
|
|
52
53
|
*/
|
|
53
54
|
updateTaskById: (taskId: number, updates: any) => Promise<TaskRow | null>;
|
|
54
55
|
/**
|
|
@@ -65,7 +66,8 @@ export type DbAdapter = {
|
|
|
65
66
|
deleteTaskByUuid: (uuid: string, userId: string) => Promise<boolean>;
|
|
66
67
|
/**
|
|
67
68
|
* Fetch pending tasks whose next_send_at <= NOW(), ordered ASC.
|
|
68
|
-
*
|
|
69
|
+
* 跳过两种还不该动的行:租约没到期的(`lease_until`,有人正在跑)、退避没到
|
|
70
|
+
* 点的(`retry_after`,上次投递失败在等重试)。两列都是空或已过期才算待发。
|
|
69
71
|
*/
|
|
70
72
|
getPendingTasks: (limit?: number) => Promise<TaskRow[]>;
|
|
71
73
|
/**
|
|
@@ -75,10 +77,15 @@ export type DbAdapter = {
|
|
|
75
77
|
* (没别人正在跑);`next_send_at` 还等于 `expectedNextSendAt`(读这行时
|
|
76
78
|
* 拿到的值,用户中途改了排期就不发了)。领到就把 `lease_until` 写成
|
|
77
79
|
* `leaseUntil`,`next_send_at` 不动。
|
|
80
|
+
* 传了非空 `serializeGroup` 时还多一个条件:同一分组里没有别的行拿着未到期
|
|
81
|
+
* 的租约(同一分组同时只跑一条,`runScheduledTick` 的 `serializeBy` 用)。
|
|
82
|
+
* 领到时把这个值写进 `serialize_group` 列。判定和占位必须在同一条语句里,
|
|
83
|
+
* 「先查再占」中间的空档会让两个 tick 同时进同一个分组。
|
|
78
84
|
* 自定义适配器可以不实现(runScheduledTick 会退回不占位的行为,代价是慢
|
|
79
|
-
*
|
|
85
|
+
* 任务可能被下一跳重复触发);实现了但忽略第四个参数的,分组串行退化成只在
|
|
86
|
+
* 同一跳内生效。
|
|
80
87
|
*/
|
|
81
|
-
claimTask?: (taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date) => Promise<boolean>;
|
|
88
|
+
claimTask?: (taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date, serializeGroup?: string | null) => Promise<boolean>;
|
|
82
89
|
/**
|
|
83
90
|
* List tasks for a user with optional filters and pagination.
|
|
84
91
|
*/
|
|
@@ -134,4 +141,25 @@ export type DbAdapter = {
|
|
|
134
141
|
* (optional; single-user/D1 only) Delete every entry of this user; returns rows deleted.
|
|
135
142
|
*/
|
|
136
143
|
clearClientState?: (userId: string) => Promise<number>;
|
|
144
|
+
/**
|
|
145
|
+
* 这个用户当前登记的 Web Push 订阅(`subscription` 是密文,解密在上层)。没有登记过 → null。
|
|
146
|
+
* 一个用户一份:任务行不携带订阅,到点投递时读这里。
|
|
147
|
+
*/
|
|
148
|
+
getPushSubscription?: (userId: string) => Promise<{
|
|
149
|
+
subscription: string;
|
|
150
|
+
updated_at: number;
|
|
151
|
+
} | null>;
|
|
152
|
+
/**
|
|
153
|
+
* 覆盖写这个用户的订阅(`updatedAt` 是 epoch 毫秒)。没有 last-write-wins 比较——
|
|
154
|
+
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
155
|
+
*/
|
|
156
|
+
upsertPushSubscription?: (userId: string, encryptedSubscription: string, updatedAt: number) => Promise<boolean>;
|
|
157
|
+
/**
|
|
158
|
+
* 删掉这个用户的订阅;返回是否真的删掉了一行。
|
|
159
|
+
*
|
|
160
|
+
* 上面三个方法要么都实现、要么都不实现:缺任何一个,`PUT/GET/DELETE
|
|
161
|
+
* /push-subscription` 返回 501,`POST /schedule-message` 也会拒绝建任务
|
|
162
|
+
* (建了也永远发不出去)。内置的 D1 / pg / neon 适配器都实现了。
|
|
163
|
+
*/
|
|
164
|
+
deletePushSubscription?: (userId: string) => Promise<boolean>;
|
|
137
165
|
};
|
package/dist/adapters/neon.d.ts
CHANGED
|
@@ -60,16 +60,50 @@ export class NeonAdapter {
|
|
|
60
60
|
* 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
|
|
61
61
|
* 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
|
|
62
62
|
*
|
|
63
|
+
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
64
|
+
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
65
|
+
* 里完成,「先查再占」的空档天然不存在。分组门只看租约,不看
|
|
66
|
+
* `retry_after`:等着重试的任务其实闲着,不该把同分组的其他任务一起堵住。
|
|
67
|
+
*
|
|
63
68
|
* @param {number} taskId
|
|
64
69
|
* @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
65
70
|
* @param {string|Date} leaseUntil - 租期末尾
|
|
66
|
-
* @
|
|
71
|
+
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
72
|
+
* @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、同分组
|
|
73
|
+
* 有任务正在跑、排期被改过、或行已不是 pending
|
|
67
74
|
*/
|
|
68
|
-
claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date): Promise<boolean>;
|
|
75
|
+
claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
69
76
|
listTasks(userId: any, opts?: {}): Promise<{
|
|
70
77
|
tasks: Record<string, any>[];
|
|
71
78
|
total: number;
|
|
72
79
|
}>;
|
|
73
80
|
cleanupOldTasks(days?: number): Promise<number>;
|
|
74
81
|
getTaskStatus(uuid: any, userId: any): Promise<any>;
|
|
82
|
+
/**
|
|
83
|
+
* 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
|
|
84
|
+
*
|
|
85
|
+
* @param {string} userId
|
|
86
|
+
* @returns {Promise<{ subscription: string, updated_at: number }|null>}
|
|
87
|
+
*/
|
|
88
|
+
getPushSubscription(userId: string): Promise<{
|
|
89
|
+
subscription: string;
|
|
90
|
+
updated_at: number;
|
|
91
|
+
} | null>;
|
|
92
|
+
/**
|
|
93
|
+
* 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
|
|
94
|
+
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
95
|
+
*
|
|
96
|
+
* @param {string} userId
|
|
97
|
+
* @param {string} encryptedSubscription
|
|
98
|
+
* @param {number} updatedAt - epoch 毫秒
|
|
99
|
+
* @returns {Promise<boolean>}
|
|
100
|
+
*/
|
|
101
|
+
upsertPushSubscription(userId: string, encryptedSubscription: string, updatedAt: number): Promise<boolean>;
|
|
102
|
+
/**
|
|
103
|
+
* 删掉这个用户的订阅(设置页「停止接收推送」)。
|
|
104
|
+
*
|
|
105
|
+
* @param {string} userId
|
|
106
|
+
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
107
|
+
*/
|
|
108
|
+
deletePushSubscription(userId: string): Promise<boolean>;
|
|
75
109
|
}
|
package/dist/adapters/pg.d.ts
CHANGED
|
@@ -62,16 +62,50 @@ export class PgAdapter {
|
|
|
62
62
|
* 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
|
|
63
63
|
* 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
|
|
64
64
|
*
|
|
65
|
+
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
66
|
+
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
67
|
+
* 里完成,「先查再占」的空档天然不存在。分组门只看租约,不看
|
|
68
|
+
* `retry_after`:等着重试的任务其实闲着,不该把同分组的其他任务一起堵住。
|
|
69
|
+
*
|
|
65
70
|
* @param {number} taskId
|
|
66
71
|
* @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
67
72
|
* @param {string|Date} leaseUntil - 租期末尾
|
|
68
|
-
* @
|
|
73
|
+
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
74
|
+
* @returns {Promise<boolean>} true = 领到了;false = 已被别人领走、同分组有
|
|
75
|
+
* 任务正在跑、或行已不是 pending
|
|
69
76
|
*/
|
|
70
|
-
claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date): Promise<boolean>;
|
|
77
|
+
claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
71
78
|
listTasks(userId: any, opts?: {}): Promise<{
|
|
72
79
|
tasks: any[][];
|
|
73
80
|
total: number;
|
|
74
81
|
}>;
|
|
75
82
|
cleanupOldTasks(days?: number): Promise<number>;
|
|
76
83
|
getTaskStatus(uuid: any, userId: any): Promise<any>;
|
|
84
|
+
/**
|
|
85
|
+
* 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
|
|
86
|
+
*
|
|
87
|
+
* @param {string} userId
|
|
88
|
+
* @returns {Promise<{ subscription: string, updated_at: number }|null>}
|
|
89
|
+
*/
|
|
90
|
+
getPushSubscription(userId: string): Promise<{
|
|
91
|
+
subscription: string;
|
|
92
|
+
updated_at: number;
|
|
93
|
+
} | null>;
|
|
94
|
+
/**
|
|
95
|
+
* 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
|
|
96
|
+
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
97
|
+
*
|
|
98
|
+
* @param {string} userId
|
|
99
|
+
* @param {string} encryptedSubscription
|
|
100
|
+
* @param {number} updatedAt - epoch 毫秒
|
|
101
|
+
* @returns {Promise<boolean>}
|
|
102
|
+
*/
|
|
103
|
+
upsertPushSubscription(userId: string, encryptedSubscription: string, updatedAt: number): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* 删掉这个用户的订阅(设置页「停止接收推送」)。
|
|
106
|
+
*
|
|
107
|
+
* @param {string} userId
|
|
108
|
+
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
109
|
+
*/
|
|
110
|
+
deletePushSubscription(userId: string): Promise<boolean>;
|
|
77
111
|
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared SQL schema constants
|
|
3
|
+
*
|
|
4
|
+
* 定时触发用的三列各管一件事,别把它们混着读(见 lib/run-tick.js):
|
|
5
|
+
* - `lease_until`:**这条正在跑**。某个 tick 占位时写「归我管到什么时候」,
|
|
6
|
+
* 投递收尾时放掉。捞取待发任务时跳过租约未到期的行。
|
|
7
|
+
* - `retry_after`:**这条没在跑,等着重试**。投递失败的退避时刻,到点之前
|
|
8
|
+
* 捞不到这行。跟租约分开写,是因为「正在跑」会挡住同分组的其他任务,而
|
|
9
|
+
* 一条正在等重试的任务其实闲着,不该连累别人。
|
|
10
|
+
* - `serialize_group`:这条属于哪个串行分组(`runScheduledTick` 的
|
|
11
|
+
* `serializeBy` 算出来的,占位时一起写)。同一分组同时只放行一条。存的是
|
|
12
|
+
* 派生值而不是宿主给的原始 key —— 原始 key 往往是角色 id 之类的宿主数据,
|
|
13
|
+
* 任务内容都是密文落库的,这一列不该成为它的明文出口。
|
|
3
14
|
*/
|
|
4
|
-
export const TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS scheduled_messages (\n id SERIAL PRIMARY KEY,\n user_id VARCHAR(255) NOT NULL,\n uuid VARCHAR(36),\n encrypted_payload TEXT NOT NULL,\n message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),\n next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,\n lease_until TIMESTAMP WITH TIME ZONE,\n status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),\n retry_count INTEGER DEFAULT 0,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n";
|
|
15
|
+
export const TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS scheduled_messages (\n id SERIAL PRIMARY KEY,\n user_id VARCHAR(255) NOT NULL,\n uuid VARCHAR(36),\n encrypted_payload TEXT NOT NULL,\n message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),\n next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,\n lease_until TIMESTAMP WITH TIME ZONE,\n retry_after TIMESTAMP WITH TIME ZONE,\n serialize_group VARCHAR(64),\n status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),\n retry_count INTEGER DEFAULT 0,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n )\n";
|
|
5
16
|
/**
|
|
6
17
|
* 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
|
|
7
18
|
* 后加的列要单独补。initSchema 每次都会跑一遍,Postgres 的 IF NOT EXISTS
|
|
@@ -23,6 +34,7 @@ export const INDEXES: ({
|
|
|
23
34
|
description: string;
|
|
24
35
|
critical: boolean;
|
|
25
36
|
})[];
|
|
37
|
+
export const PUSH_SUBSCRIPTION_TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS push_subscriptions (\n user_id VARCHAR(255) PRIMARY KEY,\n subscription TEXT NOT NULL,\n updated_at BIGINT NOT NULL\n )\n";
|
|
26
38
|
export const VERIFY_TABLE_SQL: "\n SELECT table_name\n FROM information_schema.tables\n WHERE table_schema = 'public'\n AND table_name = 'scheduled_messages'\n";
|
|
27
39
|
export const COLUMNS_SQL: "\n SELECT column_name, data_type, is_nullable\n FROM information_schema.columns\n WHERE table_schema = 'public'\n AND table_name = 'scheduled_messages'\n ORDER BY ordinal_position\n";
|
|
28
40
|
export const UPDATABLE_COLUMNS: Set<string>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite (Cloudflare D1) dialect schema for scheduled_messages.
|
|
3
3
|
*
|
|
4
|
+
* 定时触发用的 `lease_until` / `retry_after` / `serialize_group` 三列分别是什么
|
|
5
|
+
* 意思,写在 Postgres 那份 schema 的文件头(adapters/schema.js)。
|
|
6
|
+
*
|
|
4
7
|
* Differences from the Postgres schema (adapters/schema.js):
|
|
5
8
|
* - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL)
|
|
6
9
|
* - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE)
|
|
@@ -11,7 +14,7 @@
|
|
|
11
14
|
* Index entries mirror the Postgres INDEXES shape ({ name, sql, description,
|
|
12
15
|
* critical }) so both adapters' initSchema() return the same index metadata.
|
|
13
16
|
*/
|
|
14
|
-
export const SQLITE_TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS scheduled_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n uuid TEXT,\n encrypted_payload TEXT NOT NULL,\n message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),\n next_send_at TEXT NOT NULL,\n lease_until TEXT,\n status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),\n retry_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )\n";
|
|
17
|
+
export const SQLITE_TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS scheduled_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id TEXT NOT NULL,\n uuid TEXT,\n encrypted_payload TEXT NOT NULL,\n message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),\n next_send_at TEXT NOT NULL,\n lease_until TEXT,\n retry_after TEXT,\n serialize_group TEXT,\n status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),\n retry_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )\n";
|
|
15
18
|
/**
|
|
16
19
|
* 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
|
|
17
20
|
* 后加的列要单独补。initSchema 每次都会跑一遍,列已经在了就跳过。
|
|
@@ -28,3 +31,4 @@ export const SQLITE_INDEXES: {
|
|
|
28
31
|
critical: boolean;
|
|
29
32
|
}[];
|
|
30
33
|
export const CLIENT_STATE_TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS client_state (\n user_id TEXT NOT NULL,\n namespace TEXT NOT NULL,\n key TEXT NOT NULL,\n value TEXT NOT NULL,\n updated_at INTEGER NOT NULL,\n PRIMARY KEY (user_id, namespace, key)\n )\n";
|
|
34
|
+
export const PUSH_SUBSCRIPTION_TABLE_SQL: "\n CREATE TABLE IF NOT EXISTS push_subscriptions (\n user_id TEXT PRIMARY KEY,\n subscription TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n )\n";
|