@rei-standard/amsg-server 2.6.0-next.27 → 2.6.0-next.29

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
@@ -275,7 +275,7 @@ LLM API 凭据(`apiUrl` / `apiKey` / `primaryModel`)有两种给法:
275
275
  |---|---|
276
276
  | `PUT /llm-credentials` | 批量登记 / 覆盖。body =(加密后的)`{ credentials: [{ credId, value: { apiUrl, apiKey, primaryModel } }] }`,一批 ≤100 条,单用户 ≤500 行 |
277
277
  | `GET /llm-credentials` | 对账清单 `{ credentials: [{ credId, updatedAt }] }`。**凭据本体永远不回传** |
278
- | `DELETE /llm-credentials` | 删除。body =(加密后的)`{ credIds: [...] }` 或 `{ all: true }` |
278
+ | `DELETE /llm-credentials` | 删除。body =(加密后的)`{ credIds: [...] }` / `{ all: true }` / `{ credIdPrefix: 'char:<charId>/' }`,**三选一**,混着传返回 400 |
279
279
 
280
280
  客户端侧对应 `client.putLlmCredentials(credentials)` / `listLlmCredentials()` / `deleteLlmCredentials(opts)`。
281
281
 
@@ -582,6 +582,28 @@ clientStateTtl: {
582
582
 
583
583
  `GET /capabilities` 的 features 里有 `client-state-ttl`。
584
584
 
585
+ ## 清理云端数据:先对账,再按角色删
586
+
587
+ 整表全清(`DELETE /client-state` 不带参数、`DELETE /llm-credentials { all: true }`)之外,还有一组按名字来的口子,给「这个角色本地已经删了,云端那份也该走」这类收尾用。
588
+
589
+ | 端点 | 语义 |
590
+ |---|---|
591
+ | `GET /client-state/namespaces[?limit=<n>]` | 云端有哪些命名空间:`{ namespaces: [{ namespace, entryCount, byteSize, updatedAt }], truncated, limit }`(加密信封)。默认最多 200 条,被截断时 `truncated: true` |
592
+ | `DELETE /client-state?namespace=<ns>` | 只清这一个命名空间,连它的大值切片行一起。返回 `{ deleted, namespace }`;不带 `namespace` 参数仍是整表全清 |
593
+ | `DELETE /llm-credentials { credIdPrefix }` | 按 `cred_id` 前缀删。一个角色名下通常有 `char:<charId>/chat`、`/instant`、`/emotion` 几行,前缀一把清掉 |
594
+ | `DELETE /outbox` | 主动删收件箱的行:body =(加密后的)`{ messageIds: [...] }`(一次 ≤200 条)或 `{ all: true }`。在此之前只能等 cron 的 TTL 老化 |
595
+
596
+ 用法就是三步:拉一份 `GET /client-state/namespaces`,跟本地清单对一遍,本地已经没有的逐个 `DELETE /client-state?namespace=`。
597
+
598
+ 几件容易踩的事:
599
+
600
+ - **命名空间清单里没有保留命名空间。** 单条 value 超过 200KB 时库会把它切片存进一个内部命名空间,那是存储实现细节。统计把它折算进原命名空间:`byteSize` 和 `updatedAt` 算进去,`entryCount` 不算(切片是一个逻辑条目的几段)。按命名空间删也是连切片行一起删,不会留下读不出来的孤儿。
601
+ - **`byteSize` 是存储字节,不是原文字节。** 值落库前都加密过,这个数比明文大一截——它回答的是「这个命名空间在云端占多大地方」。
602
+ - **清单有上限。** `truncated: true` 时手上这份不是全集,别拿它反推「本地有、云端没有 = 可以删」。
603
+ - **`DELETE /outbox` 和 ack 是两回事。** ack 之后行还在(等 TTL 老化),只是不再被 `GET /outbox` 返回;删是把行拿掉,补收不回来——只在确认对完账之后用。
604
+ - **`credIdPrefix` 按字典序前缀匹配,不是通配符。** 前缀里的 `%` `_` `\` 都只是普通字符。
605
+ - 三条新端点各有自己的 feature 名:`client-state-namespaces`、`client-state-delete-namespace`、`llm-credentials-delete-prefix`、`outbox-delete`。老 worker 上探不到就走降级路径(例如退回整表全清,或者先提示用户更新后端)。内置适配器里只有 D1 实现,pg / neon 上这几条返回 501。
606
+
585
607
  ## 循环任务的时区(`tzId`)
586
608
 
587
609
  `daily` / `weekly` 任务可以带一个 IANA 时区 id:
@@ -275,6 +275,61 @@ export class D1Adapter {
275
275
  * @returns {Promise<number>} rows deleted
276
276
  */
277
277
  clearClientState(userId: string): Promise<number>;
278
+ /**
279
+ * 这个用户名下有哪些命名空间,各自几条、占多少字节、最后更新是什么时候
280
+ * (宿主对账「云端到底存了什么」用)。
281
+ *
282
+ * `foldPrefix` 传进来的是大值分块那个保留命名空间的前缀(见
283
+ * lib/state-chunks.js):以它开头的行不单独成一个命名空间,而是折算进
284
+ * 去掉前缀之后的那个原命名空间——保留命名空间是库的存储实现细节,宿主眼里
285
+ * 那些切片行就是原命名空间占掉的地方。折算口径:
286
+ * - `byte_size` / `updated_at` 算进去(存储确实占着、写入确实发生过);
287
+ * - `entry_count` 不算(切片是一个逻辑条目的几段,不是几个条目)。
288
+ * 不传 `foldPrefix` = 不折算,保留命名空间按普通命名空间原样列出来。
289
+ *
290
+ * 前缀由调用方传、不由适配器自己知道:分块是 lib 层的约定,适配器只照着折。
291
+ *
292
+ * 折算写在 SQL 里而不是取回来在 JS 里合,是因为有 `limit`:保留命名空间以
293
+ * \u001f 开头,BINARY 排序下排在所有正常命名空间前面,先取 limit 条再折算
294
+ * 的话额度会被切片命名空间吃光,正常命名空间一条都露不出来。
295
+ *
296
+ * `LENGTH(CAST(value AS BLOB))` 数的是字节不是字符——TEXT 上的 `LENGTH()`
297
+ * 按字符算,密文虽然是 ASCII 十六进制、两者相同,但换个存法就悄悄差一截。
298
+ *
299
+ * 这条语句要把该用户的 client_state 全扫一遍(GROUP BY 本来就得看每一行),
300
+ * 所以没为它单独加索引:它是宿主按需点开的对账口,不在 cron 路径上。别把它
301
+ * 塞进每分钟跑的东西里(理由见 adapters/schema.sqlite.js 的 CLIENT_STATE_INDEXES)。
302
+ *
303
+ * @param {string} userId
304
+ * @param {{ limit?: number, foldPrefix?: string|null }} [opts]
305
+ * @returns {Promise<Array<{ namespace: string, entry_count: number, byte_size: number, updated_at: number }>>}
306
+ * 按 namespace 升序,最多 `limit` 条。
307
+ */
308
+ listClientStateNamespaces(userId: string, { limit, foldPrefix }?: {
309
+ limit?: number;
310
+ foldPrefix?: string | null;
311
+ }): Promise<Array<{
312
+ namespace: string;
313
+ entry_count: number;
314
+ byte_size: number;
315
+ updated_at: number;
316
+ }>>;
317
+ /**
318
+ * 把这几个命名空间下这个用户的行一次删光(一次 batch = 一次事务)。
319
+ *
320
+ * 调用方传的是「原命名空间 + 它的切片保留命名空间」两个(见
321
+ * lib/state-chunks.js 的 chunkNamespaceFor):只删前者的话,大值那几行切片
322
+ * 留在库里成孤儿——读不出来、也不会被别的路径清掉。哪些命名空间算一组由调
323
+ * 用方决定,适配器只负责它们在同一个事务里删完。
324
+ *
325
+ * 条件是 `user_id = ? AND namespace = ?`,吃的是主键
326
+ * (user_id, namespace, key) 的前两列,不扫表。
327
+ *
328
+ * @param {string} userId
329
+ * @param {string[]} namespaces
330
+ * @returns {Promise<number>} 删掉的行数合计(含切片行)
331
+ */
332
+ deleteClientStateNamespaces(userId: string, namespaces: string[]): Promise<number>;
278
333
  /**
279
334
  * 这个用户当前登记的推送订阅(密文原样返回,解密在上层)。
280
335
  *
@@ -347,6 +402,32 @@ export class D1Adapter {
347
402
  * @returns {Promise<number>} 删掉的行数
348
403
  */
349
404
  deleteLlmCredentials(userId: string, credIds?: string[] | null): Promise<number>;
405
+ /**
406
+ * 按 cred_id 前缀删这个用户的凭据(宿主按角色清理:`char:<charId>/` 一把清
407
+ * 掉该角色名下的 chat / instant / emotion 几行)。
408
+ *
409
+ * **不用 LIKE。** 两条 D1 的限制在这儿各埋一个雷:
410
+ * - LIKE / GLOB 的 pattern 在 D1 上最长 50 字节(SQLite 默认 50000,官方文
411
+ * 档没写这一条)。`char:<uuid>/` 就是 42 字节,前缀里再多点东西、或者
412
+ * cred_id 用上契约允许的 128 字符,pattern 当场超限,整条语句报
413
+ * `LIKE or GLOB pattern too complex`——本地 better-sqlite3 上永远复现不
414
+ * 了,只有真实 D1 才炸。
415
+ * - 退一步「先 SELECT 出匹配的 cred_id 再按 id 批量删」也不是好路:单条语
416
+ * 句最多 100 个绑定参数,得自己切批,还平白多一个来回和一个「查完到删完
417
+ * 之间又写进来一行」的窗口。
418
+ * 走字典序范围(`cred_id >= 前缀 AND cred_id < 上界`)两条都绕开了:没有长度
419
+ * 上限,前缀里的 `%` `_` `\` 只是普通字符,一条语句三个绑定参数,而且直接吃
420
+ * (user_id, cred_id) 主键索引。上界算法见本文件顶部的 prefixRangeEnd。
421
+ *
422
+ * 前缀没有字典序上界时(prefixRangeEnd 返回空串,实际用不到——见那个函数的
423
+ * 说明)范围条件一行都匹配不上,删 0 行。宁可少删,也不能把别人的行带走。
424
+ *
425
+ * @param {string} userId
426
+ * @param {string} credIdPrefix - 非空前缀,空串由上层拒掉(空前缀 = 删全部,
427
+ * 那是 `deleteLlmCredentials(userId, null)` 的活儿,不能从这个口误伤进来)
428
+ * @returns {Promise<number>} 删掉的行数
429
+ */
430
+ deleteLlmCredentialsByPrefix(userId: string, credIdPrefix: string): Promise<number>;
350
431
  /**
351
432
  * 发送前把这一批 push 落进 outbox(一次 batch)。(user_id, message_id)
352
433
  * 唯一:重试同一 occurrence 带着同一批 messageId 再来时更新 payload、不加
@@ -431,6 +512,23 @@ export class D1Adapter {
431
512
  * @returns {Promise<number>} 本次真正被 ack 的行数
432
513
  */
433
514
  ackOutboxMessages(userId: string, messageIds: string[], ackedAt: number): Promise<number>;
515
+ /**
516
+ * 主动删 outbox 的行:数组删指定那几条,传 null 删这个用户的全部。
517
+ *
518
+ * 跟 `discardOutboxMessages` 是两件事:那个只撤「还没发出去的」,是取消 / 顶
519
+ * 替时的收尾;这个不看 delivered_at / acked_at,是宿主的清理口(对完账之后
520
+ * 把某几条、或者整个收件箱清掉)。在此之前 message_outbox 只能等 cron 的
521
+ * TTL(已签收 7 天 / 任何行 28 天)自己老化。
522
+ *
523
+ * 数组形态走 `_runInClauseWrite`:D1 单条语句最多 100 个绑定参数,`user_id`
524
+ * 占掉 1 个,所以一批最多 99 个 id,多了自动切批、整组仍在一个事务里(切开
525
+ * 之后「只删掉前 99 个」那种中间态比原问题更难查)。
526
+ *
527
+ * @param {string} userId
528
+ * @param {string[]|null} messageIds - null = 这个用户的全部
529
+ * @returns {Promise<number>} 删掉的行数
530
+ */
531
+ deleteOutboxMessages(userId: string, messageIds?: string[] | null): Promise<number>;
434
532
  /**
435
533
  * outbox 的例行清理(run-tick 每跳顺手调):已 ack 的行留短一些,未 ack 的
436
534
  * 也不无限留(Web Push TTL 上限四周,比它更老的推送谁也收不到了)。
@@ -180,6 +180,32 @@ export type DbAdapter = {
180
180
  * (optional; single-user/D1 only) Delete every entry of this user; returns rows deleted.
181
181
  */
182
182
  clearClientState?: (userId: string) => Promise<number>;
183
+ /**
184
+ * (可选;单用户/D1)这个用户名下有哪些命名空间,各自几条 / 占多少字节 / 最后
185
+ * 更新是什么时候。按 namespace 升序,最多 `limit` 条(调用方自己判断有没有被截断:
186
+ * 多要一条,回来的比 limit 多就说明还有)。
187
+ * `foldPrefix` 是大值分块那个保留命名空间的前缀(见 lib/state-chunks.js):以它
188
+ * 开头的行不单独列,字节数与最后更新时刻折算进去掉前缀后的原命名空间,条目数不
189
+ * 折算(切片是一个逻辑条目的几段)。折算必须在取 `limit` 之前做——保留命名空间以
190
+ * \u001f 开头,排在所有正常命名空间前面,先截断再折算会把额度全吃掉。
191
+ * 不实现 → `GET /client-state/namespaces` 返回 501。
192
+ */
193
+ listClientStateNamespaces?: (userId: string, opts?: {
194
+ limit?: number;
195
+ foldPrefix?: string | null;
196
+ }) => Promise<Array<{
197
+ namespace: string;
198
+ entry_count: number;
199
+ byte_size: number;
200
+ updated_at: number;
201
+ }>>;
202
+ /**
203
+ * (可选;单用户/D1)把这几个命名空间下这个用户的行一次删光,要在同一个事务里。
204
+ * 调用方传的是「原命名空间 + 它的切片保留命名空间」两个:只删前者会把大值的切片
205
+ * 行留成孤儿。返回删掉的行数合计。不实现 → `DELETE /client-state?namespace=` 返回
206
+ * 501(不带 namespace 的整表全清仍走 clearClientState,不受影响)。
207
+ */
208
+ deleteClientStateNamespaces?: (userId: string, namespaces: string[]) => Promise<number>;
183
209
  /**
184
210
  * (可选;单用户/D1)按命名空间清掉 `updated_at` 早于 `updatedBefore`(epoch 毫秒)
185
211
  * 的行,不限用户。宿主配了 `clientStateTtl` 时 runScheduledTick 每跳顺手调;
@@ -238,12 +264,22 @@ export type DbAdapter = {
238
264
  }>>;
239
265
  /**
240
266
  * 删凭据:数组删指定那几行,null 删全部。返回删掉的行数。
267
+ */
268
+ deleteLlmCredentials?: (userId: string, credIds: string[] | null) => Promise<number>;
269
+ /**
270
+ * (可选)按 cred_id 前缀删(宿主按角色清理:`char:<charId>/` 一把清掉该角色
271
+ * 名下的几行)。返回删掉的行数。这一个单独可选,不在下面那组「四个要么都实现」
272
+ * 里——不实现时 `DELETE /llm-credentials` 的 `credIdPrefix` 入参返回 501,另外
273
+ * 两种入参(`credIds` / `all`)照常。
274
+ * 实现时别用 LIKE:D1 把 LIKE / GLOB 的 pattern 压到 50 字节,`char:<uuid>/`
275
+ * 就已经 42 字节了,稍长一点整条语句报 `pattern too complex`。走字典序范围
276
+ * (`cred_id >= 前缀 AND cred_id < 上界`)没有长度上限,见 adapters/d1.js。
241
277
  *
242
278
  * llm_credentials 四个方法要么都实现、要么都不实现:缺任何一个,
243
279
  * `PUT/GET/DELETE /llm-credentials` 返回 501,带 `credRefs` 的
244
280
  * `POST /schedule-message` 也会被拒。内置的 D1 / pg / neon 适配器都实现了。
245
281
  */
246
- deleteLlmCredentials?: (userId: string, credIds: string[] | null) => Promise<number>;
282
+ deleteLlmCredentialsByPrefix?: (userId: string, credIdPrefix: string) => Promise<number>;
247
283
  /**
248
284
  * (可选)投递期间的租约续期(runScheduledTick 的心跳)。只在行仍是
249
285
  * pending 且 lease_until 非空时生效——收尾放掉租约之后,迟到的心跳不会把
@@ -307,6 +343,15 @@ export type DbAdapter = {
307
343
  * (可选;单用户/D1)客户端确认收到(POST /outbox/ack,幂等)。
308
344
  */
309
345
  ackOutboxMessages?: (userId: string, messageIds: string[], ackedAt: number) => Promise<number>;
346
+ /**
347
+ * (可选;单用户/D1)主动删行:数组删指定那几条,null 删这个用户的全部
348
+ * (`DELETE /outbox`)。与 `discardOutboxMessages` 分工不同——那个只撤还没发出
349
+ * 去的行(取消 / 顶替的收尾),这个不看 delivered_at / acked_at,是宿主对完账
350
+ * 之后的清理口。不实现 → `DELETE /outbox` 返回 501。
351
+ * 数组形态要按 D1 的 100 个绑定参数上限切批(`user_id` 占 1 个 → 一批最多 99
352
+ * 个 id),切开的几条仍要在一个事务里。
353
+ */
354
+ deleteOutboxMessages?: (userId: string, messageIds: string[] | null) => Promise<number>;
310
355
  /**
311
356
  * (可选;单用户/D1)outbox 例行清理(runScheduledTick 每跳顺手调)。
312
357
  *