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

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -1
  3. package/dist/adapters/d1.d.ts +146 -0
  4. package/dist/adapters/factory.d.ts +39 -0
  5. package/dist/adapters/interface.d.ts +137 -0
  6. package/dist/adapters/neon.d.ts +75 -0
  7. package/dist/adapters/pg.d.ts +77 -0
  8. package/dist/adapters/schema.d.ts +28 -0
  9. package/dist/adapters/schema.sqlite.d.ts +30 -0
  10. package/dist/{chunk-RGECD4OH.cjs → chunk-2JZ6UTQH.cjs} +23 -1
  11. package/dist/{chunk-5ENJGVZX.mjs → chunk-LT3BIWSN.mjs} +23 -1
  12. package/dist/{chunk-6WA3XDPP.mjs → chunk-ME6UG54W.mjs} +1264 -390
  13. package/dist/chunk-MKTK3ERH.cjs +2700 -0
  14. package/dist/cloudflare/single-user-worker.d.ts +4 -0
  15. package/dist/cloudflare.cjs +10 -2
  16. package/dist/cloudflare.d.ts +6 -3
  17. package/dist/cloudflare.mjs +9 -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/schedule-message.d.ts +3 -0
  25. package/dist/handlers/send-notifications.d.ts +3 -0
  26. package/dist/handlers/single-user-init.d.ts +13 -0
  27. package/dist/handlers/update-message.d.ts +3 -0
  28. package/dist/handlers/vapid-public-key.d.ts +19 -0
  29. package/dist/index.cjs +35 -30
  30. package/dist/index.d.cts +92 -766
  31. package/dist/index.d.ts +92 -766
  32. package/dist/index.mjs +26 -21
  33. package/dist/lib/agentic-fire.d.ts +61 -0
  34. package/dist/lib/client-state-store.d.ts +46 -0
  35. package/dist/lib/constant-time.d.ts +14 -0
  36. package/dist/lib/db-errors.d.ts +10 -0
  37. package/dist/lib/encryption.d.ts +48 -0
  38. package/dist/lib/llm.d.ts +1 -0
  39. package/dist/lib/message-processor.d.ts +47 -0
  40. package/dist/lib/request.d.ts +99 -0
  41. package/dist/lib/run-tick.d.ts +16 -0
  42. package/dist/lib/state-chunks.d.ts +55 -0
  43. package/dist/lib/validation.d.ts +67 -0
  44. package/dist/lib/version.d.ts +7 -0
  45. package/dist/lib/webcrypto-utils.d.ts +1 -0
  46. package/dist/lib/webpush-webcrypto.d.ts +61 -0
  47. package/dist/{neon-CU5N3CSW.mjs → neon-3WTZMLA6.mjs} +52 -1
  48. package/dist/{neon-BFUS25UX.cjs → neon-HOSUZYA4.cjs} +56 -5
  49. package/dist/{pg-QO6NKTGL.mjs → pg-456LAVPS.mjs} +51 -1
  50. package/dist/{pg-IIH3M4OM.cjs → pg-NGNJ3QHV.cjs} +55 -5
  51. package/dist/single-user.d.ts +76 -0
  52. package/dist/tenant/blob-store.d.ts +30 -0
  53. package/dist/tenant/context.d.ts +47 -0
  54. package/dist/tenant/single-user-context.d.ts +29 -0
  55. package/dist/tenant/token.d.ts +26 -0
  56. package/package.json +3 -3
  57. package/dist/chunk-S5DV7AUH.cjs +0 -1826
  58. package/dist/cloudflare-5Ryuxz10.d.cts +0 -2775
  59. package/dist/cloudflare-5Ryuxz10.d.ts +0 -2775
  60. package/dist/cloudflare.d.cts +0 -3
  61. package/dist/neon-CJl66EGy.d.cts +0 -251
  62. package/dist/neon-DYvGnCzx.d.ts +0 -251
  63. package/dist/pg-07S-u_H4.d.cts +0 -246
  64. package/dist/pg-Du-pN_UT.d.ts +0 -246
  65. package/dist/schema-C8OnYk6j.d.cts +0 -74
  66. 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
@@ -104,10 +104,77 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
104
104
  - 限制:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。
105
105
  - `update-message` 显式传 `splitPattern: null` 可重置回默认;不传则保留原值。
106
106
 
107
+ ## 一条 Web Push 能塞多少
108
+
109
+ 推送服务(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 字节算,不是字符数。
110
+
111
+ `sendWebPush` 会在发出去之前挡下超限的 payload,抛出 `err.code === 'PUSH_PAYLOAD_TOO_LARGE'` 的错误,消息里带实际字节数和上限。
112
+
113
+ 组 payload 之前想自己做预算,用导出的常量和工具函数,别写死魔法数字:
114
+
115
+ ```js
116
+ import { MAX_PUSH_PAYLOAD_BYTES, measurePushPayload } from '@rei-standard/amsg-server';
117
+
118
+ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify(push));
119
+ // remainingBytes = 还能再塞多少字节(已超限时为负)
120
+ ```
121
+
122
+ 装不下的内容(长文、附件详情)建议走旁路:正文存进 `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
+
124
+ ## Fire 时刻 hooks
125
+
126
+ 配上 `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」。
127
+
128
+ 三个 hook 拿到的 ctx 上都有这几个口子:
129
+
130
+ | ctx 上的口子 | 干什么 |
131
+ |---|---|
132
+ | `readState(ns)` / `writeState(ns, entries)` | 读写 `client_state`,和客户端 `GET/PUT /client-state` 是同一份数据 |
133
+ | `scheduleTask(options)` | 给同一个用户再建一条定时任务 |
134
+ | `scratch` | 本次 fire 的便签对象,三个 hook 共享同一个引用,fire 结束即丢弃 |
135
+
136
+ ### `ctx.scheduleTask(options)`
137
+
138
+ 角色在这次 fire 里给自己排一条后续任务:「这条发完,一个半小时后我再接着说一句」。建出来的是一条正常的任务行,到点由 cron 触发,用户全程离线也不影响。
139
+
140
+ ```js
141
+ const result = await ctx.scheduleTask({
142
+ firstSendTime: new Date(Date.now() + 90 * 60_000).toISOString(), // 必填,ISO 字符串
143
+ messageType: 'auto', // 可选,默认继承当前任务
144
+ recurrenceType: 'none', // 可选,默认 none
145
+ metadata: { beat: 'followup' }, // 可选,整体替换当前任务的 metadata(不深合并)
146
+ uuid: `fire-${ctx.task.id}-${ctx.task.nextSendAt}`, // 可选,默认随机
147
+ });
148
+ // → { created: true, id, uuid, nextSendAt }
149
+ // 或 { created: false, reason: 'duplicate', uuid }
150
+ ```
151
+
152
+ 凭据和投递配置(`pushSubscription` / `apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
153
+
154
+ 护栏:
155
+
156
+ | 护栏 | 阈值 / 规则 | 不满足时 | 为什么 |
157
+ |---|---|---|---|
158
+ | `firstSendTime` | 必填、能解析成合法时间、至少比现在晚 **60 秒** | `RangeError` | cron 一分钟一跳,排在 60 秒内等于让下一跳立刻捡走,容易变成自己触发自己的紧密循环 |
159
+ | `messageType` | 只收 `auto` / `prompted` / `fixed` | `TypeError` | `instant` 的语义是「建行的那一刻就投递」,那条路径归 `POST /schedule-message` 管;从 fire 里造这么一行,投递时机反而说不清 |
160
+ | `messageType: 'fixed'` | 必须有 `userMessage`(自己传或继承到) | `TypeError` | 固定文本任务没有正文,就是一条永远发空的任务 |
161
+ | 单次 fire 的建任务条数 | 默认 **2 条**,factory 配置 `maxScheduledTasksPerFire` 可调(`0` = 不许自排) | `RangeError` | 模型自排后续本质上是条能无限延伸的链,没有上限就没人按停止键 |
162
+ | `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
163
+ | 数据库适配器没有 `createTask` | — | 抛 `AGENTIC_SCHEDULE_UNSUPPORTED` | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
164
+
165
+ `recurrenceType` 沿用排程接口那套 `none` / `daily` / `weekly`,别的值抛 `TypeError`。参数不合法的调用不占建任务额度;uuid 撞车占(那条任务其实已经建出来了)。
166
+
167
+ `GET /capabilities` 的 features 里有 `agentic-schedule-task`,前端可以据此判断部署的 worker 认不认这条链路。
168
+
107
169
  ## 导出(新增)
108
170
 
109
- - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
171
+ - `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
172
  - `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
173
+ - `MAX_PUSH_PAYLOAD_BYTES` — 一条 push 的明文上限,3993 字节。
174
+ - `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES` — 推送服务的密文 body 上限(4096)与 aes128gcm 固定开销(103),上面那个数就是两者相减。
175
+ - `measurePushPayload(payload)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit }`。
176
+
177
+ 以上四个在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有。
111
178
 
112
179
  ## 一体化初始化流程
113
180
 
@@ -123,6 +190,22 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
123
190
  - `send-notifications`
124
191
  - `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
125
192
 
193
+ ## 触发任务时的占位
194
+
195
+ `send-notifications`(以及单用户 Worker 的 `scheduled()`)每条任务开跑前会先占位:在这一行的 `lease_until` 上写下「归我管到现在 + 租期为止」,本次投递期间别的 tick 领不走它;占位改到 0 行说明别人先领走了,本次直接跳过。cron 一分钟一跳而带工具的 AI 任务常常跑过一分钟,没有这层占位同一条任务会被相邻几跳重复触发。
196
+
197
+ 租约写在自己的列上,`next_send_at` 全程不动——任务列表读到的一直是用户设的那个时刻,循环任务也按它推进到下一次。投递收尾时租约就放掉,失败重试的退避(2 分钟起)不会被租期压住。
198
+
199
+ 领了任务的那一跳中途没了(Worker 被回收之类)就没人来放租约,这条任务要等租约到期才会被后面的 tick 接手。把租期设得比最慢的一次投递长一点即可。
200
+
201
+ 租期默认 10 分钟;配了 `totalTimeoutMs` 的话按它 + 2 分钟往上抬。想自己定就在 `runScheduledTick` 的 ctx(或单用户 Worker 的 config)里传 `claimLeaseMs`——注意 `createReiServer` 内置的 `/send-notifications` 处理器不透传这两个值,要调租期就自己调 `runScheduledTick`。`onBeforeFire` 里按次放宽的预算占位时看不到,那种情况也要显式设 `claimLeaseMs`。
202
+
203
+ 占位管的是定时触发这条路径。`messageType: 'instant'` 走的是「建行 → 当场投递」,不经过占位。
204
+
205
+ 内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
206
+
207
+ `lease_until` 是这次新加的列。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上;手工建表的看 `examples/cloudflare-single-user/schema.sql`。
208
+
126
209
  ## 导出 API(Exports)
127
210
 
128
211
  - `createReiServer`
@@ -134,6 +217,10 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
134
217
  - `encryptForStorage`
135
218
  - `decryptFromStorage`
136
219
  - `validateScheduleMessagePayload`
220
+ - `measurePushPayload`
221
+ - `MAX_PUSH_PAYLOAD_BYTES`
222
+ - `WEB_PUSH_MAX_BODY_BYTES`
223
+ - `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
137
224
  - `isValidISO8601`
138
225
  - `isValidUrl`
139
226
  - `isValidUUID`
@@ -0,0 +1,146 @@
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
+ }
@@ -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
+ };
@@ -0,0 +1,137 @@
1
+ export type TaskRow = {
2
+ id: number;
3
+ user_id: string;
4
+ uuid: string;
5
+ encrypted_payload: string;
6
+ message_type: string;
7
+ next_send_at: string;
8
+ status: string;
9
+ retry_count: number;
10
+ created_at: string;
11
+ updated_at: string;
12
+ };
13
+ export type InsertTaskParams = {
14
+ user_id: string;
15
+ uuid: string;
16
+ encrypted_payload: string;
17
+ next_send_at: string;
18
+ message_type: string;
19
+ };
20
+ export type InitSchemaResult = {
21
+ columnsCreated: number;
22
+ indexesCreated: number;
23
+ indexesFailed: number;
24
+ columns: any[];
25
+ indexes: any[];
26
+ };
27
+ export type DbAdapter = {
28
+ /**
29
+ * Create the scheduled_messages table and all indexes.
30
+ */
31
+ initSchema: () => Promise<InitSchemaResult>;
32
+ /**
33
+ * Drop the scheduled_messages table (CASCADE).
34
+ */
35
+ dropSchema: () => Promise<void>;
36
+ /**
37
+ * Insert a new task row and return the created record.
38
+ */
39
+ createTask: (params: InsertTaskParams) => Promise<TaskRow>;
40
+ /**
41
+ * Fetch a single pending task by uuid + user_id.
42
+ */
43
+ getTaskByUuid: (uuid: string, userId: string) => Promise<TaskRow | null>;
44
+ /**
45
+ * Fetch a single pending task by uuid only (used by instant processing).
46
+ */
47
+ getTaskByUuidOnly: (uuid: string) => Promise<TaskRow | null>;
48
+ /**
49
+ * Partially update a task row by its numeric id.
50
+ * 实现了 `claimTask` 的适配器还要认 `lease_until`(含写 null):投递收尾
51
+ * 时 runScheduledTick 用它把租约放掉。
52
+ */
53
+ updateTaskById: (taskId: number, updates: any) => Promise<TaskRow | null>;
54
+ /**
55
+ * Update a pending task's encrypted_payload (and optional index fields) by uuid + user_id.
56
+ */
57
+ updateTaskByUuid: (uuid: string, userId: string, encryptedPayload: string, extraFields?: any) => Promise<TaskRow | null>;
58
+ /**
59
+ * Delete a task by numeric id. Returns true if a row was affected.
60
+ */
61
+ deleteTaskById: (taskId: number) => Promise<boolean>;
62
+ /**
63
+ * Delete a task by uuid + user_id. Returns true if a row was affected.
64
+ */
65
+ deleteTaskByUuid: (uuid: string, userId: string) => Promise<boolean>;
66
+ /**
67
+ * Fetch pending tasks whose next_send_at <= NOW(), ordered ASC.
68
+ * 跳过租约还没到期的行(`lease_until` 为空或已过期才算待发)。
69
+ */
70
+ getPendingTasks: (limit?: number) => Promise<TaskRow[]>;
71
+ /**
72
+ * 领取一条到点的任务,返回是否领到。cron 每分钟一跳而一次投递可能跑几分
73
+ * 钟,runScheduledTick 靠它保证同一行同时只被一个 tick 跑。
74
+ * 三个条件同时成立才领得走:行仍是 pending;`lease_until` 为空或已过期
75
+ * (没别人正在跑);`next_send_at` 还等于 `expectedNextSendAt`(读这行时
76
+ * 拿到的值,用户中途改了排期就不发了)。领到就把 `lease_until` 写成
77
+ * `leaseUntil`,`next_send_at` 不动。
78
+ * 自定义适配器可以不实现(runScheduledTick 会退回不占位的行为,代价是慢
79
+ * 任务可能被下一跳重复触发)。
80
+ */
81
+ claimTask?: (taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date) => Promise<boolean>;
82
+ /**
83
+ * List tasks for a user with optional filters and pagination.
84
+ */
85
+ listTasks: (userId: string, opts: {
86
+ status?: string;
87
+ limit?: number;
88
+ offset?: number;
89
+ }) => Promise<{
90
+ tasks: TaskRow[];
91
+ total: number;
92
+ }>;
93
+ /**
94
+ * Delete completed / failed tasks older than `days` (default 7).
95
+ */
96
+ cleanupOldTasks: (days?: number) => Promise<number>;
97
+ /**
98
+ * Return the status string of a task (used to distinguish 404 from 409).
99
+ */
100
+ getTaskStatus: (uuid: string, userId: string) => Promise<string | null>;
101
+ /**
102
+ * (optional; single-user/D1 only) Batch upsert of client state, last-write-wins on updatedAt.
103
+ * `cleanups` 先于 upsert 在同一事务里删旧行,两种形态:带 `keyPrefix` 的按 key 前缀删(清理大值
104
+ * 旧写入留下的切片行,见 lib/state-chunks.js),带 `key` 的删这一个 key(删整条状态;前缀会连带
105
+ * 删掉同前缀的兄弟 key)。两种都只删 `updated_at <= updatedAt` 的行。自定义 adapter 忽略前缀形态
106
+ * 只损失存储卫生;忽略精确 key 形态则删不掉状态,`ctx.writeState()` 的删除会失效。
107
+ * `outcomes` 逐条报告 entries[i] 是否真的写入(缺席时调用方按物理行计数兜底)。
108
+ */
109
+ upsertClientState?: (userId: string, entries: Array<{
110
+ namespace: string;
111
+ key: string;
112
+ value: string;
113
+ updatedAt: number;
114
+ }>, cleanups?: Array<{
115
+ namespace: string;
116
+ key?: string;
117
+ keyPrefix?: string;
118
+ updatedAt: number;
119
+ }>) => Promise<{
120
+ upserted: number;
121
+ skipped: number;
122
+ outcomes?: boolean[];
123
+ }>;
124
+ /**
125
+ * (optional; single-user/D1 only) All entries of one namespace; values still encrypted.
126
+ */
127
+ getClientState?: (userId: string, namespace: string) => Promise<Array<{
128
+ namespace: string;
129
+ key: string;
130
+ value: string;
131
+ updated_at: number;
132
+ }>>;
133
+ /**
134
+ * (optional; single-user/D1 only) Delete every entry of this user; returns rows deleted.
135
+ */
136
+ clearClientState?: (userId: string) => Promise<number>;
137
+ };
@@ -0,0 +1,75 @@
1
+ export class NeonAdapter {
2
+ /** @param {string} connectionString */
3
+ constructor(connectionString: string);
4
+ /** @private */
5
+ private _connectionString;
6
+ /** @private */
7
+ private _sql;
8
+ /** @private */
9
+ private _getSql;
10
+ initSchema(): Promise<{
11
+ columnsCreated: number;
12
+ indexesCreated: number;
13
+ indexesFailed: number;
14
+ columns: {
15
+ table: string;
16
+ name: any;
17
+ type: any;
18
+ nullable: boolean;
19
+ }[];
20
+ indexes: ({
21
+ name: string;
22
+ status: string;
23
+ description: string;
24
+ critical: boolean;
25
+ error?: undefined;
26
+ } | {
27
+ name: string;
28
+ status: string;
29
+ description: string;
30
+ critical: boolean;
31
+ error: any;
32
+ })[];
33
+ }>;
34
+ dropSchema(): Promise<void>;
35
+ createTask(params: any): Promise<Record<string, any>>;
36
+ getTaskByUuid(uuid: any, userId: any): Promise<Record<string, any>>;
37
+ getTaskByUuidOnly(uuid: any): Promise<Record<string, any>>;
38
+ updateTaskById(taskId: any, updates: any): Promise<Record<string, any>>;
39
+ updateTaskByUuid(uuid: any, userId: any, encryptedPayload: any, extraFields: any): Promise<Record<string, any>>;
40
+ deleteTaskById(taskId: any): Promise<boolean>;
41
+ deleteTaskByUuid(uuid: any, userId: any): Promise<boolean>;
42
+ getPendingTasks(limit?: number): Promise<Record<string, any>[]>;
43
+ /**
44
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
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
+ * @param {number} taskId
64
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
65
+ * @param {string|Date} leaseUntil - 租期末尾
66
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
67
+ */
68
+ claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date): Promise<boolean>;
69
+ listTasks(userId: any, opts?: {}): Promise<{
70
+ tasks: Record<string, any>[];
71
+ total: number;
72
+ }>;
73
+ cleanupOldTasks(days?: number): Promise<number>;
74
+ getTaskStatus(uuid: any, userId: any): Promise<any>;
75
+ }
@@ -0,0 +1,77 @@
1
+ export class PgAdapter {
2
+ /** @param {string} connectionString */
3
+ constructor(connectionString: string);
4
+ /** @private */
5
+ private _connectionString;
6
+ /** @private */
7
+ private _pool;
8
+ /** @private */
9
+ private _getPool;
10
+ /** @private */
11
+ private _query;
12
+ initSchema(): Promise<{
13
+ columnsCreated: number;
14
+ indexesCreated: number;
15
+ indexesFailed: number;
16
+ columns: {
17
+ table: string;
18
+ name: any;
19
+ type: any;
20
+ nullable: boolean;
21
+ }[];
22
+ indexes: ({
23
+ name: string;
24
+ status: string;
25
+ description: string;
26
+ critical: boolean;
27
+ error?: undefined;
28
+ } | {
29
+ name: string;
30
+ status: string;
31
+ description: string;
32
+ critical: boolean;
33
+ error: any;
34
+ })[];
35
+ }>;
36
+ dropSchema(): Promise<void>;
37
+ createTask(params: any): Promise<any[]>;
38
+ getTaskByUuid(uuid: any, userId: any): Promise<any[]>;
39
+ getTaskByUuidOnly(uuid: any): Promise<any[]>;
40
+ updateTaskById(taskId: any, updates: any): Promise<any[]>;
41
+ updateTaskByUuid(uuid: any, userId: any, encryptedPayload: any, extraFields: any): Promise<any[]>;
42
+ deleteTaskById(taskId: any): Promise<boolean>;
43
+ deleteTaskByUuid(uuid: any, userId: any): Promise<boolean>;
44
+ getPendingTasks(limit?: number): Promise<any[][]>;
45
+ /**
46
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
47
+ * 本次投递期间别的 tick 领不走它。
48
+ *
49
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
50
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
51
+ *
52
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
53
+ * 过。WHERE 里的两个条件各管一件事:
54
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
55
+ * 也不会把行焊死,租约到期后自然可以被接手。
56
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
57
+ * 这一跳就不该再按旧时刻发。
58
+ *
59
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
60
+ * 要改表。
61
+ *
62
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
63
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
64
+ *
65
+ * @param {number} taskId
66
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
67
+ * @param {string|Date} leaseUntil - 租期末尾
68
+ * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
69
+ */
70
+ claimTask(taskId: number, expectedNextSendAt: string | Date, leaseUntil: string | Date): Promise<boolean>;
71
+ listTasks(userId: any, opts?: {}): Promise<{
72
+ tasks: any[][];
73
+ total: number;
74
+ }>;
75
+ cleanupOldTasks(days?: number): Promise<number>;
76
+ getTaskStatus(uuid: any, userId: any): Promise<any>;
77
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared SQL schema constants
3
+ */
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";
5
+ /**
6
+ * 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
7
+ * 后加的列要单独补。initSchema 每次都会跑一遍,Postgres 的 IF NOT EXISTS
8
+ * 让重复执行也没事。
9
+ */
10
+ export const MIGRATIONS: {
11
+ name: string;
12
+ sql: string;
13
+ description: string;
14
+ }[];
15
+ export const INDEXES: ({
16
+ name: string;
17
+ sql: string;
18
+ description: string;
19
+ critical?: undefined;
20
+ } | {
21
+ name: string;
22
+ sql: string;
23
+ description: string;
24
+ critical: boolean;
25
+ })[];
26
+ 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
+ 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
+ export const UPDATABLE_COLUMNS: Set<string>;