@rei-standard/amsg-server 2.6.0-next.13 → 2.6.0-next.16
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 +109 -1
- package/dist/adapters/d1.d.ts +130 -0
- package/dist/adapters/interface.d.ts +67 -0
- package/dist/adapters/neon.d.ts +20 -58
- package/dist/adapters/pg-shared.d.ts +114 -0
- package/dist/adapters/pg.d.ts +20 -58
- package/dist/adapters/schema.d.ts +1 -1
- package/dist/adapters/schema.sqlite.d.ts +15 -1
- package/dist/{chunk-B2FDV7YK.mjs → chunk-34I2YSWE.mjs} +10 -0
- package/dist/{chunk-5IOYCCFD.cjs → chunk-H4V7OXHE.cjs} +1326 -384
- package/dist/{chunk-TV7N3RTH.mjs → chunk-KVHR3RCU.mjs} +1324 -382
- package/dist/{chunk-V2SDWGUB.cjs → chunk-OUPM4HAV.cjs} +10 -0
- package/dist/chunk-TFFBMYA2.cjs +95 -0
- package/dist/chunk-UYROU4L3.mjs +95 -0
- package/dist/cloudflare/single-user-worker.d.ts +35 -2
- package/dist/cloudflare.cjs +17 -2
- package/dist/cloudflare.d.ts +3 -1
- package/dist/cloudflare.mjs +18 -3
- package/dist/handlers/outbox.d.ts +7 -0
- package/dist/index.cjs +72 -36
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +53 -17
- package/dist/lib/client-state-store.d.ts +18 -3
- package/dist/lib/errors.d.ts +93 -0
- package/dist/lib/message-processor.d.ts +8 -2
- package/dist/lib/outbox-store.d.ts +33 -0
- package/dist/lib/push-subscription-store.d.ts +10 -2
- package/dist/lib/request.d.ts +36 -0
- package/dist/lib/run-tick.d.ts +38 -0
- package/dist/lib/schema-version.d.ts +50 -0
- package/dist/lib/validation.d.ts +1 -7
- package/dist/{neon-3ASBLLCM.cjs → neon-G3G4TDAY.cjs} +52 -112
- package/dist/{neon-CUIGAB2T.mjs → neon-GBUF4G76.mjs} +44 -104
- package/dist/{pg-IEZTAR43.cjs → pg-C3JZKBUM.cjs} +49 -112
- package/dist/{pg-KMJPL6QV.mjs → pg-MRXP5MHJ.mjs} +41 -104
- package/dist/single-user.d.ts +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -382,6 +382,108 @@ await runScheduledTick({
|
|
|
382
382
|
|
|
383
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` 一并建。
|
|
384
384
|
|
|
385
|
+
## 表结构自查(`getSchemaVersion` / `ensureSchema`)
|
|
386
|
+
|
|
387
|
+
建表语句是 `CREATE TABLE IF NOT EXISTS`,已经存在的表不会被改动,后加的列靠 `initSchema()` 里的 ALTER 补。升级到新版之后没人再跑一次建表的话,这个部署的表就停在旧形状上——cron 每分钟挂在缺的那一列上,任务一条都不发,而前端界面一切正常。
|
|
388
|
+
|
|
389
|
+
这两个函数把「我需要什么 / 现在是什么 / 帮我补齐」露出来:
|
|
390
|
+
|
|
391
|
+
```js
|
|
392
|
+
import { getSchemaVersion, ensureSchema } from '@rei-standard/amsg-server/cloudflare';
|
|
393
|
+
|
|
394
|
+
const state = await getSchemaVersion(db);
|
|
395
|
+
// → { current: '2.6.0' | null, required: '2.6.0', ok: true | false, missing: [] }
|
|
396
|
+
|
|
397
|
+
if (!state.ok) {
|
|
398
|
+
const fixed = await ensureSchema(db); // 建表 + 补列 + 建索引,重复调没事
|
|
399
|
+
// → { current, required, ok, missing, migrated: true, schema }
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
| 字段 | 是什么 |
|
|
404
|
+
|---|---|
|
|
405
|
+
| `required` | 这一版代码需要的表结构版本(导出常量 `SCHEMA_VERSION`)。表结构自己的版本号,只在表 / 列 / 关键索引变化时抬,与包版本各走各的 |
|
|
406
|
+
| `current` | 活库当前满足的版本:够用就是 `required` 那个值,缺东西就是 `null`(只知道不够用,不知道它停在哪一版) |
|
|
407
|
+
| `ok` | 需要的表 / 列 / 关键索引是不是都在 |
|
|
408
|
+
| `missing` | 缺什么,形如 `table:message_outbox` / `column:scheduled_messages.last_error` / `index:uidx_uuid`。整张表缺席时只报这一张表,不逐列展开 |
|
|
409
|
+
| `migrated`(只有 `ensureSchema` 有) | 这次有没有真的跑 `initSchema()`。本来就够用 → `false`,`schema` 也是 `null` |
|
|
410
|
+
|
|
411
|
+
「需要什么」是从建表语句里解析出来的,不是另抄一份清单:抄的那份漏掉新列的话,自查会对着一个缺列的库回「一切正常」。
|
|
412
|
+
|
|
413
|
+
什么时候调、缺了怎么提示用户,由宿主决定——库不会在每次请求里偷偷迁移。`POST /init-tenant` 的行为一点没变(它内部做的就是 `initSchema()`)。
|
|
414
|
+
|
|
415
|
+
单用户 Worker 上有走 `env` 的同名方法:`worker.getSchemaVersion(env)` / `worker.ensureSchema(env)`,省得自己再造一次适配器。
|
|
416
|
+
|
|
417
|
+
自查要求适配器实现 `describeSchema()`(活库里现在有哪些表 / 列 / 索引,只读)。内置适配器里目前只有 D1 实现了;别的适配器调这两个函数会抛错,而不是假装一切正常。
|
|
418
|
+
|
|
419
|
+
## 只跑指定那一条任务(`runTask`)
|
|
420
|
+
|
|
421
|
+
`scheduled()` 的语义是「扫一遍所有到期任务」。刚落库的任务想立刻跑起来时,触发一次全量扫描是能达到目的,但那样多个执行者会去扫同一批任务,宿主只能退回单实例串行才不重复发。
|
|
422
|
+
|
|
423
|
+
`runTask` 只跑指定那一条,走的是 cron 完全同一条投递链(占位、租约心跳、过期守卫、失败重试 / 终态、hook 全套):
|
|
424
|
+
|
|
425
|
+
```js
|
|
426
|
+
// 单用户 Worker:从 env 拿库和配置
|
|
427
|
+
const result = await worker.runTask(uuid, env);
|
|
428
|
+
|
|
429
|
+
// 自己攒 ctx 的宿主(和 runScheduledTick 同一份 ctx)
|
|
430
|
+
import { runTask } from '@rei-standard/amsg-server/cloudflare';
|
|
431
|
+
const result = await runTask(ctx, uuid);
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
跑起来了是 `{ ran: true, summary }`(`summary` 与 `runScheduledTick` 的返回同构,`totalTasks` 恒为 1)。不跑的几种情形分开回报,宿主不用猜:
|
|
435
|
+
|
|
436
|
+
| `reason` | 什么意思 | 附带 |
|
|
437
|
+
|---|---|---|
|
|
438
|
+
| `not_found` | 没有这条 uuid。一次性任务发完即删,所以「已经发完了」的那条也落在这里 | — |
|
|
439
|
+
| `already_settled` | 行还在,但已经是终态 | `status`(`sent` / `failed`) |
|
|
440
|
+
| `not_due` | 还没到触发时刻 | `nextSendAt` |
|
|
441
|
+
| `retry_pending` | 上次投递失败,还在退避窗口里 | `retryAfter` |
|
|
442
|
+
| `not_configured` | VAPID / webpush 没配齐,跑了也只是白扣这条任务一次重试(只有 `worker.runTask` 有这一种) | — |
|
|
443
|
+
|
|
444
|
+
这个入口只是换了个触发器,不是绕过排期的后门:没到点、在退避窗口里的都不跑。`already_settled` 要求适配器实现 `getTaskStatusByUuidOnly(uuid)`(D1 / pg / neon 都实现了);不实现的自定义适配器把这种情况并进 `not_found`。
|
|
445
|
+
|
|
446
|
+
## 出错时的真实原因
|
|
447
|
+
|
|
448
|
+
`fetch()` 兜底的 500 一直是 `{ success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }`。这两个字段一个没动,真因加在 `error.cause` 上:
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
{
|
|
452
|
+
"success": false,
|
|
453
|
+
"error": {
|
|
454
|
+
"code": "INTERNAL_ERROR",
|
|
455
|
+
"message": "服务器内部错误",
|
|
456
|
+
"cause": {
|
|
457
|
+
"stage": "request",
|
|
458
|
+
"name": "Error",
|
|
459
|
+
"message": "D1_ERROR: no such table: message_outbox"
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
| 字段 | 是什么 |
|
|
466
|
+
|---|---|
|
|
467
|
+
| `stage` | 在哪一段炸的:`config` = 构建配置时(少了 binding、环境变量丢了),`request` = 路由或处理器抛错 |
|
|
468
|
+
| `name` | 错误类型(`error.name`,认不出来时是 `Error`) |
|
|
469
|
+
| `message` | 错误消息,长得像凭据的串已遮掉、超长截断到 500 字符 |
|
|
470
|
+
| `code` | 错误自带的 `code` 字符串,有才带 |
|
|
471
|
+
|
|
472
|
+
只带错误类型和消息文本:密钥、用户数据、任务正文都不在 `error.message` 上,也不往这里放。
|
|
473
|
+
|
|
474
|
+
cron 那条路上没有调用方能读到响应,所以另开两个出口:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
export default createSingleUserCloudflareWorker(buildConfig, {
|
|
478
|
+
onError({ stage, error, cause, path }) {
|
|
479
|
+
// fetch / cron 任何一段出错都会调一次(best-effort,自身抛错只记日志)
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- `onError` 放在工厂的第二个参数上,而不是 `buildConfig` 的返回值里:`buildConfig` 自己抛错时配置里的东西一个都读不到,而那恰恰是最需要被看见的一种故障。`stage` 在 cron 路径上是 `config`(配置构建失败 / VAPID 没配齐)或 `tick`(那一跳抛错)。VAPID 没配齐这一支没有异常对象,`error` 为 `null`、`cause.name` 是 `VapidNotConfigured`。
|
|
485
|
+
- `scheduled()` 现在有返回值:`{ ok: true, summary }` 或 `{ ok: false, cause }`。Cloudflare 不看它,是给「自己包一层再转调 `scheduled`」的宿主和测试用的。
|
|
486
|
+
|
|
385
487
|
## 导出 API(Exports)
|
|
386
488
|
|
|
387
489
|
包根(`@rei-standard/amsg-server`):
|
|
@@ -391,6 +493,10 @@ await runScheduledTick({
|
|
|
391
493
|
- `createSingleUserCloudflareWorker` — 单用户 Cloudflare Worker 一键装配(`fetch` + `scheduled` 两个入口)
|
|
392
494
|
- `createAdapter` / `createD1Adapter` — pg·neon / Cloudflare D1 数据库适配器
|
|
393
495
|
- `runScheduledTick` — 手动触发一轮到期任务投递(自定义 cron 宿主、要调 `claimLeaseMs` 时用)
|
|
496
|
+
- `runTask` — 只跑指定那一条任务(与 cron 同一条投递链)
|
|
497
|
+
- `getSchemaVersion` / `ensureSchema` / `SCHEMA_VERSION` — 表结构自查与补齐
|
|
498
|
+
- `summarizeErrorCause` — 把异常压成响应体里 `error.cause` 那个形状(自己包一层路由、想回同样形状时用同一份)
|
|
499
|
+
- `NonRetryableError` / `isNonRetryableError` — hook 侧标注「重试也好不了」的失败
|
|
394
500
|
- `createWebCryptoWebPush` — 纯 Web Crypto 的 Web Push 发送器(不依赖 `web-push` 包)
|
|
395
501
|
- `createTenantToken` / `verifyTenantToken`
|
|
396
502
|
- `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
|
|
@@ -401,7 +507,9 @@ await runScheduledTick({
|
|
|
401
507
|
|
|
402
508
|
`@rei-standard/amsg-server/cloudflare` 子路径 —— 只含「单用户 + D1 + Web Crypto 推送」这条子图,不引用多租户装配线和 pg / neon / `web-push`,所以 D1-only 安装(不装可选数据库 peer)也能干净打包,Worker 不需要 `nodejs_compat` 兼容 flag:
|
|
403
509
|
|
|
404
|
-
- `createSingleUserCloudflareWorker` / `createSingleUserServer` / `createD1Adapter` / `runScheduledTick`
|
|
510
|
+
- `createSingleUserCloudflareWorker` / `createSingleUserServer` / `createD1Adapter` / `runScheduledTick` / `runTask`
|
|
511
|
+
- `getSchemaVersion` / `ensureSchema` / `SCHEMA_VERSION`
|
|
512
|
+
- `summarizeErrorCause` / `NonRetryableError` / `isNonRetryableError`
|
|
405
513
|
- `createWebCryptoWebPush` / `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
|
|
406
514
|
- `deriveUserEncryptionKey` / `decryptPayload` / `encryptForStorage` / `decryptFromStorage`
|
|
407
515
|
|
package/dist/adapters/d1.d.ts
CHANGED
|
@@ -36,10 +36,47 @@ export class D1Adapter {
|
|
|
36
36
|
error: any;
|
|
37
37
|
})[];
|
|
38
38
|
}>;
|
|
39
|
+
/**
|
|
40
|
+
* 活库里现在实际有哪些表 / 列 / 索引。
|
|
41
|
+
*
|
|
42
|
+
* 只读不写,纯粹如实回报:拿它跟这一版需要的清单对照的活儿在
|
|
43
|
+
* lib/schema-version.js(`getSchemaVersion` / `ensureSchema`)。库升级后表结
|
|
44
|
+
* 构变了而老部署没跑过 initSchema 时,cron 会每分钟静默挂在缺的那一列上,
|
|
45
|
+
* 界面上一切正常——这个方法就是让宿主查得出来。
|
|
46
|
+
*
|
|
47
|
+
* @returns {Promise<{ tables: Record<string, string[]>, indexes: string[] }>}
|
|
48
|
+
*/
|
|
49
|
+
describeSchema(): Promise<{
|
|
50
|
+
tables: Record<string, string[]>;
|
|
51
|
+
indexes: string[];
|
|
52
|
+
}>;
|
|
39
53
|
dropSchema(): Promise<void>;
|
|
40
54
|
createTask(params: any): Promise<any>;
|
|
55
|
+
/**
|
|
56
|
+
* 建新任务的同时取消旧的那条(`POST /schedule-message` 的 supersedesUuid)。
|
|
57
|
+
*
|
|
58
|
+
* 两条语句走一次 batch(D1 的隐式事务 + 单次网络往返):不会出现「旧的删了、
|
|
59
|
+
* 新的没建成」的中间态——INSERT 撞 uuid 唯一索引时整个 batch 回滚,旧行原样
|
|
60
|
+
* 留着,调用方按既有的 409 冲突路径处理。
|
|
61
|
+
*
|
|
62
|
+
* @param {import('./interface.js').InsertTaskParams} params
|
|
63
|
+
* @param {string} supersedesUuid - 要取消的旧任务 uuid(同一 user_id 下)
|
|
64
|
+
* @returns {Promise<Object>} createTask 的返回行 + `superseded`(旧行是否
|
|
65
|
+
* 真的被删掉;false = 旧行本就不存在)
|
|
66
|
+
*/
|
|
67
|
+
createTaskSuperseding(params: import("./interface.js").InsertTaskParams, supersedesUuid: string): Promise<any>;
|
|
41
68
|
getTaskByUuid(uuid: any, userId: any): Promise<any>;
|
|
42
69
|
getTaskByUuidOnly(uuid: any): Promise<any>;
|
|
70
|
+
/**
|
|
71
|
+
* 这条 uuid 现在是什么状态——不限用户,也不限状态(getTaskByUuidOnly 只看
|
|
72
|
+
* pending 行)。`runTask` 用它把「这条已经跑完了」和「压根没这条」分开回报。
|
|
73
|
+
*
|
|
74
|
+
* @param {string} uuid
|
|
75
|
+
* @returns {Promise<{ status: string }|null>}
|
|
76
|
+
*/
|
|
77
|
+
getTaskStatusByUuidOnly(uuid: string): Promise<{
|
|
78
|
+
status: string;
|
|
79
|
+
} | null>;
|
|
43
80
|
updateTaskById(taskId: any, updates: any): Promise<any>;
|
|
44
81
|
updateTaskByUuid(uuid: any, userId: any, encryptedPayload: any, extraFields: any): Promise<{
|
|
45
82
|
uuid: any;
|
|
@@ -83,12 +120,34 @@ export class D1Adapter {
|
|
|
83
120
|
* 有任务正在跑、排期被改过、或行已不是 pending
|
|
84
121
|
*/
|
|
85
122
|
claimTask(taskId: number, expectedNextSendAt: string, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
123
|
+
/**
|
|
124
|
+
* 投递期间的租约续期(run-tick 的心跳用)。只在行仍是 pending 且确实持有
|
|
125
|
+
* 租约(lease_until 非空)时生效——收尾把租约放掉之后,迟到的心跳不会把
|
|
126
|
+
* 租约复活。
|
|
127
|
+
*
|
|
128
|
+
* @param {number} taskId
|
|
129
|
+
* @param {string|Date} leaseUntil - 新的租期末尾
|
|
130
|
+
* @returns {Promise<boolean>} true = 续上了
|
|
131
|
+
*/
|
|
132
|
+
renewTaskLease(taskId: number, leaseUntil: string | Date): Promise<boolean>;
|
|
86
133
|
listTasks(userId: any, opts?: {}): Promise<{
|
|
87
134
|
tasks: any;
|
|
88
135
|
total: number;
|
|
89
136
|
}>;
|
|
90
137
|
cleanupOldTasks(days?: number): Promise<any>;
|
|
91
138
|
getTaskStatus(uuid: any, userId: any): Promise<any>;
|
|
139
|
+
/**
|
|
140
|
+
* 状态 + 失败摘要(GET /message 用它把「为什么失败」透给已失败的行——那些
|
|
141
|
+
* 行 getTaskByUuid 读不到,payload 里的 lastError 也就够不着了)。
|
|
142
|
+
*
|
|
143
|
+
* @param {string} uuid
|
|
144
|
+
* @param {string} userId
|
|
145
|
+
* @returns {Promise<{ status: string, last_error: string|null }|null>}
|
|
146
|
+
*/
|
|
147
|
+
getTaskStatusInfo(uuid: string, userId: string): Promise<{
|
|
148
|
+
status: string;
|
|
149
|
+
last_error: string | null;
|
|
150
|
+
} | null>;
|
|
92
151
|
/**
|
|
93
152
|
* Batch upsert. Last-write-wins per (namespace, key): an entry older
|
|
94
153
|
* than the stored row (updatedAt strictly lower) is skipped; equal or
|
|
@@ -178,4 +237,75 @@ export class D1Adapter {
|
|
|
178
237
|
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
179
238
|
*/
|
|
180
239
|
deletePushSubscription(userId: string): Promise<boolean>;
|
|
240
|
+
/**
|
|
241
|
+
* 发送前把这一批 push 落进 outbox(一次 batch)。(user_id, message_id)
|
|
242
|
+
* 唯一:重试同一 occurrence 带着同一批 messageId 再来时更新 payload、不加
|
|
243
|
+
* 第二行;客户端已经 ack 过的行不动(ack 是终态,重试不该把它拉回未读)。
|
|
244
|
+
*
|
|
245
|
+
* @param {string} userId
|
|
246
|
+
* @param {Array<{ message_id: string, task_uuid?: string|null, session_id?: string|null,
|
|
247
|
+
* message_index?: number|null, total_messages?: number|null, payload: string, created_at: number }>} rows
|
|
248
|
+
* `payload` 是整条 push JSON 的 encryptForStorage 密文。
|
|
249
|
+
* @returns {Promise<number>} 实际写入/更新的行数
|
|
250
|
+
*/
|
|
251
|
+
appendOutboxMessages(userId: string, rows: Array<{
|
|
252
|
+
message_id: string;
|
|
253
|
+
task_uuid?: string | null;
|
|
254
|
+
session_id?: string | null;
|
|
255
|
+
message_index?: number | null;
|
|
256
|
+
total_messages?: number | null;
|
|
257
|
+
payload: string;
|
|
258
|
+
created_at: number;
|
|
259
|
+
}>): Promise<number>;
|
|
260
|
+
/**
|
|
261
|
+
* 把这一批标记为「Web Push 已发出」。发送失败的段不标——delivered_at 为
|
|
262
|
+
* null 的行正是客户端最需要拉的那部分。
|
|
263
|
+
*
|
|
264
|
+
* @param {string} userId
|
|
265
|
+
* @param {string[]} messageIds
|
|
266
|
+
* @param {number} deliveredAt - epoch 毫秒
|
|
267
|
+
* @returns {Promise<number>}
|
|
268
|
+
*/
|
|
269
|
+
markOutboxDelivered(userId: string, messageIds: string[], deliveredAt: number): Promise<number>;
|
|
270
|
+
/**
|
|
271
|
+
* 未 ack 的行(id 升序,游标翻页)。payload 仍是密文,解密在 handler。
|
|
272
|
+
*
|
|
273
|
+
* @param {string} userId
|
|
274
|
+
* @param {number} sinceId - 上一页游标(0 = 从头)
|
|
275
|
+
* @param {number} limit
|
|
276
|
+
* @returns {Promise<Array<{ id: number, message_id: string, task_uuid: string|null,
|
|
277
|
+
* session_id: string|null, message_index: number|null, total_messages: number|null,
|
|
278
|
+
* payload: string, created_at: number, delivered_at: number|null }>>}
|
|
279
|
+
*/
|
|
280
|
+
listUnackedOutbox(userId: string, sinceId?: number, limit?: number): Promise<Array<{
|
|
281
|
+
id: number;
|
|
282
|
+
message_id: string;
|
|
283
|
+
task_uuid: string | null;
|
|
284
|
+
session_id: string | null;
|
|
285
|
+
message_index: number | null;
|
|
286
|
+
total_messages: number | null;
|
|
287
|
+
payload: string;
|
|
288
|
+
created_at: number;
|
|
289
|
+
delivered_at: number | null;
|
|
290
|
+
}>>;
|
|
291
|
+
/**
|
|
292
|
+
* 客户端确认收到这一批(幂等:已 ack 的行再 ack 不动)。
|
|
293
|
+
*
|
|
294
|
+
* @param {string} userId
|
|
295
|
+
* @param {string[]} messageIds
|
|
296
|
+
* @param {number} ackedAt - epoch 毫秒
|
|
297
|
+
* @returns {Promise<number>} 本次真正被 ack 的行数
|
|
298
|
+
*/
|
|
299
|
+
ackOutboxMessages(userId: string, messageIds: string[], ackedAt: number): Promise<number>;
|
|
300
|
+
/**
|
|
301
|
+
* outbox 的例行清理(run-tick 每跳顺手调):已 ack 的行留短一些,未 ack 的
|
|
302
|
+
* 也不无限留(Web Push TTL 上限四周,比它更老的推送谁也收不到了)。
|
|
303
|
+
*
|
|
304
|
+
* @param {{ ackedBeforeMs?: number, allBeforeMs?: number }} opts - epoch 毫秒阈值
|
|
305
|
+
* @returns {Promise<number>} 删掉的行数
|
|
306
|
+
*/
|
|
307
|
+
cleanupOutbox({ ackedBeforeMs, allBeforeMs }?: {
|
|
308
|
+
ackedBeforeMs?: number;
|
|
309
|
+
allBeforeMs?: number;
|
|
310
|
+
}): Promise<number>;
|
|
181
311
|
}
|
|
@@ -29,6 +29,15 @@ export type DbAdapter = {
|
|
|
29
29
|
* Create the scheduled_messages table and all indexes.
|
|
30
30
|
*/
|
|
31
31
|
initSchema: () => Promise<InitSchemaResult>;
|
|
32
|
+
/**
|
|
33
|
+
* (可选)活库里现在实际有哪些表 / 列 / 索引,只读不写。
|
|
34
|
+
* `getSchemaVersion` / `ensureSchema`(lib/schema-version.js)拿它跟这一版
|
|
35
|
+
* 需要的清单对照;不实现的适配器调那两个函数会抛错。内置只有 D1 实现。
|
|
36
|
+
*/
|
|
37
|
+
describeSchema?: () => Promise<{
|
|
38
|
+
tables: Record<string, string[]>;
|
|
39
|
+
indexes: string[];
|
|
40
|
+
}>;
|
|
32
41
|
/**
|
|
33
42
|
* Drop the scheduled_messages table (CASCADE).
|
|
34
43
|
*/
|
|
@@ -45,6 +54,14 @@ export type DbAdapter = {
|
|
|
45
54
|
* Fetch a single pending task by uuid only (used by instant processing).
|
|
46
55
|
*/
|
|
47
56
|
getTaskByUuidOnly: (uuid: string) => Promise<TaskRow | null>;
|
|
57
|
+
/**
|
|
58
|
+
* (可选)这条 uuid 现在是什么状态——不限用户,也不限状态。`runTask` 用它
|
|
59
|
+
* 把「这条已经跑完进终态了」和「压根没这条」分开回报;不实现时两种都归
|
|
60
|
+
* `not_found`。
|
|
61
|
+
*/
|
|
62
|
+
getTaskStatusByUuidOnly?: (uuid: string) => Promise<{
|
|
63
|
+
status: string;
|
|
64
|
+
} | null>;
|
|
48
65
|
/**
|
|
49
66
|
* Partially update a task row by its numeric id.
|
|
50
67
|
* 实现了 `claimTask` 的适配器还要认 `lease_until` 和 `retry_after`(含写
|
|
@@ -162,4 +179,54 @@ export type DbAdapter = {
|
|
|
162
179
|
* (建了也永远发不出去)。内置的 D1 / pg / neon 适配器都实现了。
|
|
163
180
|
*/
|
|
164
181
|
deletePushSubscription?: (userId: string) => Promise<boolean>;
|
|
182
|
+
/**
|
|
183
|
+
* (可选)投递期间的租约续期(runScheduledTick 的心跳)。只在行仍是
|
|
184
|
+
* pending 且 lease_until 非空时生效——收尾放掉租约之后,迟到的心跳不会把
|
|
185
|
+
* 它复活。不实现 → 心跳自动关闭,退回一次性长租约(claimLeaseMs)。
|
|
186
|
+
*/
|
|
187
|
+
renewTaskLease?: (taskId: number, leaseUntil: string | Date) => Promise<boolean>;
|
|
188
|
+
/**
|
|
189
|
+
* (可选)状态 + last_error 列(脱敏失败摘要的 JSON 串)。GET /message 用
|
|
190
|
+
* 它把「为什么失败」透给已失败的行;不实现时退回 getTaskStatus(409 里就
|
|
191
|
+
* 没有 lastError)。
|
|
192
|
+
*/
|
|
193
|
+
getTaskStatusInfo?: (uuid: string, userId: string) => Promise<{
|
|
194
|
+
status: string;
|
|
195
|
+
last_error: string | null;
|
|
196
|
+
} | null>;
|
|
197
|
+
/**
|
|
198
|
+
* (可选)建新任务的同一事务里取消旧的那条(POST /schedule-message 的
|
|
199
|
+
* supersedesUuid)。不实现时 handler 退回「先删再建」两步(失去原子性)。
|
|
200
|
+
*/
|
|
201
|
+
createTaskSuperseding?: (params: InsertTaskParams, supersedesUuid: string) => Promise<TaskRow & {
|
|
202
|
+
superseded: boolean;
|
|
203
|
+
}>;
|
|
204
|
+
/**
|
|
205
|
+
* (可选;单用户/D1)push 发送前把整批落进 message_outbox(密文 payload),
|
|
206
|
+
* (user_id, message_id) 冲突时更新未 ack 的行、不动已 ack 的。
|
|
207
|
+
*/
|
|
208
|
+
appendOutboxMessages?: (userId: string, rows: Array<any>) => Promise<number>;
|
|
209
|
+
/**
|
|
210
|
+
* (可选;单用户/D1)把发出去的段标 delivered_at。
|
|
211
|
+
*/
|
|
212
|
+
markOutboxDelivered?: (userId: string, messageIds: string[], deliveredAt: number) => Promise<number>;
|
|
213
|
+
/**
|
|
214
|
+
* (可选;单用户/D1)未 ack 的行,id 升序游标翻页(GET /outbox)。
|
|
215
|
+
*/
|
|
216
|
+
listUnackedOutbox?: (userId: string, sinceId: number, limit: number) => Promise<Array<any>>;
|
|
217
|
+
/**
|
|
218
|
+
* (可选;单用户/D1)客户端确认收到(POST /outbox/ack,幂等)。
|
|
219
|
+
*/
|
|
220
|
+
ackOutboxMessages?: (userId: string, messageIds: string[], ackedAt: number) => Promise<number>;
|
|
221
|
+
/**
|
|
222
|
+
* (可选;单用户/D1)outbox 例行清理(runScheduledTick 每跳顺手调)。
|
|
223
|
+
*
|
|
224
|
+
* outbox 五个方法要么都实现、要么都不实现:缺写入侧的(append / mark),
|
|
225
|
+
* 发送链路静默跳过落行;缺读取侧的(list / ack),`GET /outbox` 与
|
|
226
|
+
* `POST /outbox/ack` 返回 501。内置只有 D1 实现(与 client_state 同待遇)。
|
|
227
|
+
*/
|
|
228
|
+
cleanupOutbox?: (opts: {
|
|
229
|
+
ackedBeforeMs?: number;
|
|
230
|
+
allBeforeMs?: number;
|
|
231
|
+
}) => Promise<number>;
|
|
165
232
|
};
|
package/dist/adapters/neon.d.ts
CHANGED
|
@@ -35,75 +35,37 @@ export class NeonAdapter {
|
|
|
35
35
|
createTask(params: any): Promise<Record<string, any>>;
|
|
36
36
|
getTaskByUuid(uuid: any, userId: any): Promise<Record<string, any>>;
|
|
37
37
|
getTaskByUuidOnly(uuid: any): Promise<Record<string, any>>;
|
|
38
|
+
/**
|
|
39
|
+
* 这条 uuid 现在是什么状态——不限用户,也不限状态(上面那个只看 pending
|
|
40
|
+
* 行)。`runTask` 用它把「这条已经跑完了」和「压根没这条」分开回报。
|
|
41
|
+
*
|
|
42
|
+
* @param {string} uuid
|
|
43
|
+
* @returns {Promise<{ status: string }|null>}
|
|
44
|
+
*/
|
|
45
|
+
getTaskStatusByUuidOnly(uuid: string): Promise<{
|
|
46
|
+
status: string;
|
|
47
|
+
} | null>;
|
|
38
48
|
updateTaskById(taskId: any, updates: any): Promise<Record<string, any>>;
|
|
39
49
|
updateTaskByUuid(uuid: any, userId: any, encryptedPayload: any, extraFields: any): Promise<Record<string, any>>;
|
|
40
50
|
deleteTaskById(taskId: any): Promise<boolean>;
|
|
41
51
|
deleteTaskByUuid(uuid: any, userId: any): Promise<boolean>;
|
|
42
52
|
getPendingTasks(limit?: number): Promise<Record<string, any>[]>;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* 本次投递期间别的 tick 领不走它。
|
|
46
|
-
*
|
|
47
|
-
* 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
|
|
48
|
-
* 任务列表要读它、循环任务推进下一次也要拿它当基准。
|
|
49
|
-
*
|
|
50
|
-
* 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
|
|
51
|
-
* 过。WHERE 里的两个条件各管一件事:
|
|
52
|
-
* - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
|
|
53
|
-
* 也不会把行焊死,租约到期后自然可以被接手。
|
|
54
|
-
* - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
|
|
55
|
-
* 这一跳就不该再按旧时刻发。
|
|
56
|
-
*
|
|
57
|
-
* 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
|
|
58
|
-
* 要改表。
|
|
59
|
-
*
|
|
60
|
-
* 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
|
|
61
|
-
* 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
|
|
62
|
-
*
|
|
63
|
-
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
64
|
-
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
65
|
-
* 里完成,「先查再占」的空档天然不存在。分组门只看租约,不看
|
|
66
|
-
* `retry_after`:等着重试的任务其实闲着,不该把同分组的其他任务一起堵住。
|
|
67
|
-
*
|
|
68
|
-
* @param {number} taskId
|
|
69
|
-
* @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
70
|
-
* @param {string|Date} leaseUntil - 租期末尾
|
|
71
|
-
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
72
|
-
* @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、同分组
|
|
73
|
-
* 有任务正在跑、排期被改过、或行已不是 pending
|
|
74
|
-
*/
|
|
75
|
-
claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
53
|
+
claimTask(taskId: any, expectedNextSendAt: any, leaseUntil: any, serializeGroup?: any): Promise<boolean>;
|
|
54
|
+
renewTaskLease(taskId: any, leaseUntil: any): Promise<boolean>;
|
|
76
55
|
listTasks(userId: any, opts?: {}): Promise<{
|
|
77
56
|
tasks: Record<string, any>[];
|
|
78
57
|
total: number;
|
|
79
58
|
}>;
|
|
80
59
|
cleanupOldTasks(days?: number): Promise<number>;
|
|
81
60
|
getTaskStatus(uuid: any, userId: any): Promise<any>;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
*/
|
|
88
|
-
getPushSubscription(userId: string): Promise<{
|
|
61
|
+
getTaskStatusInfo(uuid: any, userId: any): Promise<{
|
|
62
|
+
status: string;
|
|
63
|
+
last_error: string | null;
|
|
64
|
+
}>;
|
|
65
|
+
getPushSubscription(userId: any): Promise<{
|
|
89
66
|
subscription: string;
|
|
90
67
|
updated_at: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
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>;
|
|
68
|
+
}>;
|
|
69
|
+
upsertPushSubscription(userId: any, encryptedSubscription: any, updatedAt: any): Promise<boolean>;
|
|
70
|
+
deletePushSubscription(userId: any): Promise<boolean>;
|
|
109
71
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg / neon 共用的 Postgres 查询实现。
|
|
3
|
+
*
|
|
4
|
+
* 两个适配器只在「怎么把 SQL 发出去」上不同(pg 的连接池 vs neon 的 HTTP
|
|
5
|
+
* 驱动),SQL 与并发语义必须逐字一致——各自复制一份的话,修一边漏一边就会让
|
|
6
|
+
* 两种 Postgres 部署的串行化行为静默分歧,而各自的测试还都是绿的。所以这里
|
|
7
|
+
* 按执行器参数化:适配器只递一个 `query(text, params) → rows`。
|
|
8
|
+
*
|
|
9
|
+
* D1(SQLite 方言、单写者、ISO TEXT 时间戳)不走这份实现。
|
|
10
|
+
*
|
|
11
|
+
* @typedef {(text: string, params?: any[]) => Promise<any[]>} PgQuery
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
|
|
15
|
+
* 本次投递期间别的 tick 领不走它。
|
|
16
|
+
*
|
|
17
|
+
* 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
|
|
18
|
+
* 任务列表要读它、循环任务推进下一次也要拿它当基准。
|
|
19
|
+
*
|
|
20
|
+
* 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
|
|
21
|
+
* 过。WHERE 里的两个条件各管一件事:
|
|
22
|
+
* - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
|
|
23
|
+
* 也不会把行焊死,租约到期后自然可以被接手。
|
|
24
|
+
* - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
|
|
25
|
+
* 这一跳就不该再按旧时刻发。
|
|
26
|
+
*
|
|
27
|
+
* 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
|
|
28
|
+
* 要改表。
|
|
29
|
+
*
|
|
30
|
+
* 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
|
|
31
|
+
* 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
|
|
32
|
+
*
|
|
33
|
+
* 带 serializeGroup 时多一道分组门:同一分组里已经有别的行拿着未到期的租
|
|
34
|
+
* 约,这条就领不走(同一分组同时只跑一条)。判定和写租约在同一条 UPDATE
|
|
35
|
+
* 里完成——但那只对「抢同一行」成立;READ COMMITTED 下两个并发 tick 各领
|
|
36
|
+
* 同组的**不同**行时,各自的 NOT EXISTS 子查询都看不到对方尚未提交的租约,
|
|
37
|
+
* 也没有行锁冲突逼它重查(写偏斜)。所以占位成功后再回头查一次:真撞上了
|
|
38
|
+
* 就把自己刚写的租约放掉、这一跳不跑——两边都让也没事,行保持 pending,
|
|
39
|
+
* 下一跳重试。分组门只看租约,不看 `retry_after`:等着重试的任务其实闲着,
|
|
40
|
+
* 不该把同分组的其他任务一起堵住。
|
|
41
|
+
*
|
|
42
|
+
* @param {PgQuery} query
|
|
43
|
+
* @param {number} taskId
|
|
44
|
+
* @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
45
|
+
* @param {string|Date} leaseUntil - 租期末尾
|
|
46
|
+
* @param {string|null} [serializeGroup] - 串行分组标识;空表示不参与分组串行
|
|
47
|
+
* @returns {Promise<boolean>} true = 领到了;false = 已被别人领走、同分组有
|
|
48
|
+
* 任务正在跑、排期被改过、或行已不是 pending
|
|
49
|
+
*/
|
|
50
|
+
export function claimTask(query: PgQuery, taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date, serializeGroup?: string | null): Promise<boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* 投递期间的租约续期(run-tick 的心跳用)。只在行仍是 pending 且确实持有
|
|
53
|
+
* 租约(lease_until 非空)时生效——收尾把租约放掉之后,迟到的心跳不会把
|
|
54
|
+
* 租约复活。
|
|
55
|
+
*
|
|
56
|
+
* @param {PgQuery} query
|
|
57
|
+
* @param {number} taskId
|
|
58
|
+
* @param {string|Date} leaseUntil - 新的租期末尾
|
|
59
|
+
* @returns {Promise<boolean>} true = 续上了
|
|
60
|
+
*/
|
|
61
|
+
export function renewTaskLease(query: PgQuery, taskId: number, leaseUntil: string | Date): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* 状态 + 失败摘要(GET /message 用它把「为什么失败」透给已失败的行)。
|
|
64
|
+
*
|
|
65
|
+
* @param {PgQuery} query
|
|
66
|
+
* @param {string} uuid
|
|
67
|
+
* @param {string} userId
|
|
68
|
+
* @returns {Promise<{ status: string, last_error: string|null }|null>}
|
|
69
|
+
*/
|
|
70
|
+
export function getTaskStatusInfo(query: PgQuery, uuid: string, userId: string): Promise<{
|
|
71
|
+
status: string;
|
|
72
|
+
last_error: string | null;
|
|
73
|
+
} | null>;
|
|
74
|
+
/**
|
|
75
|
+
* 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
|
|
76
|
+
*
|
|
77
|
+
* @param {PgQuery} query
|
|
78
|
+
* @param {string} userId
|
|
79
|
+
* @returns {Promise<{ subscription: string, updated_at: number }|null>}
|
|
80
|
+
*/
|
|
81
|
+
export function getPushSubscription(query: PgQuery, userId: string): Promise<{
|
|
82
|
+
subscription: string;
|
|
83
|
+
updated_at: number;
|
|
84
|
+
} | null>;
|
|
85
|
+
/**
|
|
86
|
+
* 覆盖写这个用户的订阅。一个用户一行,没有 last-write-wins 之类的比较——
|
|
87
|
+
* 客户端拿到的新订阅永远比旧的有效,旧的那份只会 410。
|
|
88
|
+
*
|
|
89
|
+
* @param {PgQuery} query
|
|
90
|
+
* @param {string} userId
|
|
91
|
+
* @param {string} encryptedSubscription
|
|
92
|
+
* @param {number} updatedAt - epoch 毫秒
|
|
93
|
+
* @returns {Promise<boolean>}
|
|
94
|
+
*/
|
|
95
|
+
export function upsertPushSubscription(query: PgQuery, userId: string, encryptedSubscription: string, updatedAt: number): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* 删掉这个用户的订阅(设置页「停止接收推送」)。
|
|
98
|
+
*
|
|
99
|
+
* @param {PgQuery} query
|
|
100
|
+
* @param {string} userId
|
|
101
|
+
* @returns {Promise<boolean>} true = 确实删掉了一行
|
|
102
|
+
*/
|
|
103
|
+
export function deletePushSubscription(query: PgQuery, userId: string): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* pg / neon 共用的 Postgres 查询实现。
|
|
106
|
+
*
|
|
107
|
+
* 两个适配器只在「怎么把 SQL 发出去」上不同(pg 的连接池 vs neon 的 HTTP
|
|
108
|
+
* 驱动),SQL 与并发语义必须逐字一致——各自复制一份的话,修一边漏一边就会让
|
|
109
|
+
* 两种 Postgres 部署的串行化行为静默分歧,而各自的测试还都是绿的。所以这里
|
|
110
|
+
* 按执行器参数化:适配器只递一个 `query(text, params) → rows`。
|
|
111
|
+
*
|
|
112
|
+
* D1(SQLite 方言、单写者、ISO TEXT 时间戳)不走这份实现。
|
|
113
|
+
*/
|
|
114
|
+
export type PgQuery = (text: string, params?: any[]) => Promise<any[]>;
|