@rei-standard/amsg-server 2.6.0-next.11 → 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 CHANGED
@@ -43,6 +43,7 @@ const rei = await createReiServer({
43
43
  // PUT /api/v1/update-message -> rei.handlers.updateMessage.PUT
44
44
  // DELETE /api/v1/cancel-message -> rei.handlers.cancelMessage.DELETE
45
45
  // GET /api/v1/messages -> rei.handlers.messages.GET
46
+ // GET /api/v1/message?id={uuid} -> rei.handlers.getMessage.GET
46
47
  // PUT /api/v1/push-subscription -> rei.handlers.pushSubscription.PUT
47
48
  // GET /api/v1/push-subscription -> rei.handlers.pushSubscription.GET
48
49
  // DELETE /api/v1/push-subscription -> rei.handlers.pushSubscription.DELETE
@@ -159,6 +160,27 @@ const message = body.length <= remainingBytes ? body : body.slice(0, remainingBy
159
160
 
160
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)。
161
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
+
162
184
  ## 推送自带任务身份
163
185
 
164
186
  每条从任务行发出去的 push(冻结 prompt 路径和 fire-time hook 路径都算)顶层带这四个字段:
@@ -203,12 +225,24 @@ hook 在 `pushPayloads` 里自己写了这几个字段的话会被库覆盖:
203
225
  | hook | 什么时候调 | 载荷 |
204
226
  |---|---|---|
205
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 }` |
206
229
  | `onStaleSkip` | 任务错过触发时刻超过 60 分钟、这一次(或这几次)不再补发 | `{ reason, action, metadata, recurrenceType, occurrenceMs, skippedCount, skippedOccurrences, skippedTruncated, nextSendAt, readState, writeState }` |
207
230
 
208
- 两个 hook 都自带 `readState` / `writeState`,作用于当前用户的 `client_state`,语义与 fire 级那套一致。`onStaleSkip` 尤其需要:服务停摆恢复后的第一跳里可能一次 fire 都没跑过,而那正是它要留痕迹的时候。
231
+ 三个 hook 都自带 `readState` / `writeState`,作用于当前用户的 `client_state`,语义与 fire 级那套一致。`onStaleSkip` 尤其需要:服务停摆恢复后的第一跳里可能一次 fire 都没跑过,而那正是它要留痕迹的时候。
209
232
 
210
233
  `onAfterSend` 的 `scratch` 与本次 fire 的 `onBeforeFire` / `onLLMOutput` 是同一个引用——「这次生成了哪几段正文」之类的上下文直接从这里读,不用自建按任务分格的登记表。全部成功时 `error` 为 `null`;第 k 段失败时 `sentCount = k`、`error` 带原始错误,且在错误往上抛之前调用完。
211
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
+
212
246
  `onStaleSkip` 的 `action` 分两种:
213
247
 
214
248
  - `expired` —— 一次性任务,这一次永远不会补发了,行已标 `failed`。
@@ -275,7 +309,7 @@ const result = await ctx.scheduleTask({
275
309
 
276
310
  ## 端点鉴权
277
311
 
278
- - `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`
312
+ - `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`、`message`
279
313
  - `Authorization: Bearer <tenantToken>`
280
314
  - `send-notifications`
281
315
  - `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
@@ -315,7 +349,38 @@ await client.scheduleMessage({
315
349
 
316
350
  内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
317
351
 
318
- `lease_until` 是这次新加的列。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上;手工建表的看 `examples/cloudflare-single-user/schema.sql`。
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` 一并建。
319
384
 
320
385
  ## 导出 API(Exports)
321
386
 
@@ -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
- * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
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;
@@ -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`(含写 null):投递收尾
51
- * 时 runScheduledTick 用它把租约放掉。
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
- * 跳过租约还没到期的行(`lease_until` 为空或已过期才算待发)。
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
  */
@@ -60,12 +60,19 @@ 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
- * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
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;
@@ -62,12 +62,19 @@ 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
- * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
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;
@@ -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
@@ -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 每次都会跑一遍,列已经在了就跳过。
@@ -8,6 +8,8 @@ var TABLE_SQL = `
8
8
  message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
9
9
  next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
10
10
  lease_until TIMESTAMP WITH TIME ZONE,
11
+ retry_after TIMESTAMP WITH TIME ZONE,
12
+ serialize_group VARCHAR(64),
11
13
  status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
12
14
  retry_count INTEGER DEFAULT 0,
13
15
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -19,6 +21,16 @@ var MIGRATIONS = [
19
21
  name: "add_lease_until",
20
22
  sql: "ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS lease_until TIMESTAMP WITH TIME ZONE",
21
23
  description: "Task claim lease (2.6.0)"
24
+ },
25
+ {
26
+ name: "add_retry_after",
27
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS retry_after TIMESTAMP WITH TIME ZONE",
28
+ description: "Retry backoff, held apart from the claim lease (2.6.0)"
29
+ },
30
+ {
31
+ name: "add_serialize_group",
32
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS serialize_group VARCHAR(64)",
33
+ description: "Serialization group for runScheduledTick serializeBy (2.6.0)"
22
34
  }
23
35
  ];
24
36
  var INDEXES = [
@@ -56,6 +68,13 @@ var INDEXES = [
56
68
  WHERE uuid IS NOT NULL`,
57
69
  description: "UUID uniqueness guard",
58
70
  critical: true
71
+ },
72
+ {
73
+ name: "idx_serialize_group_lease",
74
+ sql: `CREATE INDEX IF NOT EXISTS idx_serialize_group_lease
75
+ ON scheduled_messages (serialize_group, lease_until)
76
+ WHERE serialize_group IS NOT NULL AND status = 'pending'`,
77
+ description: "Serialization group busy-check index (claimTask)"
59
78
  }
60
79
  ];
61
80
  var PUSH_SUBSCRIPTION_TABLE_SQL = `
@@ -85,6 +104,8 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
85
104
  "message_type",
86
105
  "next_send_at",
87
106
  "lease_until",
107
+ "retry_after",
108
+ "serialize_group",
88
109
  "status",
89
110
  "retry_count",
90
111
  "created_at",