@geoly-ai/social-hub-sdk 0.1.2 → 0.1.4

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/dist/index.js CHANGED
@@ -1,3 +1,85 @@
1
+ /** 自动翻页的页大小 —— contracts `listSubredditBlocklistsQuerySchema.limit` 的硬上限。 */
2
+ const BLOCKLIST_FULL_SCAN_PAGE_SIZE = 200;
3
+ /** 一致性重试的趟数上限;超出即抛错,绝不静默返回可能不一致的结果。 */
4
+ const BLOCKLIST_FULL_SCAN_MAX_ATTEMPTS = 3;
5
+ /**
6
+ * 按形状识别「分页列表返回体」并判断是否被截断。返回 undefined 表示不是该形状,
7
+ * 或者这一页已经是全部。
8
+ *
9
+ * 刻意**只**认 `items` + `total` 两个字段同时存在:仓库里 `total` 作为「条数」
10
+ * 的用法与 `items` 恒定成对出现(43 个端点),而把 `total` 用作金额/总分之类
11
+ * 其它语义的返回体不带 `items` 数组,不会误伤。
12
+ */
13
+ export function detectPartialList(path, body) {
14
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
15
+ return undefined;
16
+ }
17
+ const rec = body;
18
+ const { items, total, limit, offset } = rec;
19
+ if (!Array.isArray(items))
20
+ return undefined;
21
+ const totalRelation = typeof rec.totalRelation === "string" ? rec.totalRelation : undefined;
22
+ const hasMore = typeof rec.hasMore === "boolean" ? rec.hasMore : undefined;
23
+ // 条数必须是**非负整数**才当数用。`Number.isFinite` 放得太松:1.5 / -3 都能过,
24
+ // 而拿那种 total 算出来的「还缺几条」是纯噪音。
25
+ // `gte`(计数被封顶)与 `unavailable`(没有总数)下 `total` 不是精确条数,
26
+ // 不能参与 `items.length < total` 这个判据 —— 那会算出错误的缺口。
27
+ const totalIsExactCount = typeof total === "number" &&
28
+ Number.isInteger(total) &&
29
+ total >= 0 &&
30
+ totalRelation !== "gte" &&
31
+ totalRelation !== "unavailable";
32
+ // ⚠️ `hasMore` 优先:它是服务端的明示,封顶/未知总数时也成立。
33
+ // (0140 起 subreddit-intelligence 的评论样本就是这种形状 —— 只按
34
+ // `items.length < total` 判会整片漏掉。Codex 评审发现)
35
+ const truncated = hasMore === true || (totalIsExactCount && items.length < total);
36
+ if (!truncated)
37
+ return undefined;
38
+ return {
39
+ // 只留 pathname:query 里可能带 search=<用户关键词> 之类的输入(见类型注释)。
40
+ path: path.split("?")[0] ?? path,
41
+ itemsLength: items.length,
42
+ total: typeof total === "number" ? total : undefined,
43
+ totalRelation,
44
+ hasMore,
45
+ limit: typeof limit === "number" ? limit : undefined,
46
+ offset: typeof offset === "number" ? offset : undefined,
47
+ };
48
+ }
49
+ /**
50
+ * 「拿到的是一页、不是全量」的统一措辞。与仓库既有的 CSV 导出约定同源 ——
51
+ * 那边命中行数硬顶时必须在末行显式写 `mode=truncated`(见 contracts/src/common.ts),
52
+ * 一样是「绝不静默截断」。这里是同一条纪律在列表端点上的落点。
53
+ *
54
+ * ⚠️ 措辞刻意保持**通用**,两处不能写死(Codex 评审提出):
55
+ * - 不能写「用 --limit/--offset 翻页」—— `compliance blocklists-list` 这类安全闸
56
+ * 刻意**不提供**这两个参数(它自己自动翻页),照着提示做只会撞上「未知选项」;
57
+ * - 不能写「漏掉的是最早的那批」—— 那只对按时间倒序的 blocklist 成立,
58
+ * 别的端点排序不同,写死就是在编造事实。
59
+ */
60
+ export function formatPartialListWarning(info) {
61
+ const where = info.limit === undefined
62
+ ? ""
63
+ : ` (limit=${info.limit}${info.offset === undefined ? "" : `, offset=${info.offset}`})`;
64
+ const lines = [];
65
+ if (info.totalRelation === "gte") {
66
+ // 计数被封顶:total 只是下界,算「还缺几条」会给出一个假的确定数字。
67
+ lines.push(`[warn] mode=truncated: 这是一页,不是全量 —— 本页 items=${info.itemsLength};` +
68
+ `total=${info.total} 只是**下界**(totalRelation=gte,服务端封顶计数)${where}`);
69
+ }
70
+ else if (info.total === undefined || info.totalRelation === "unavailable") {
71
+ lines.push(`[warn] mode=truncated: 这是一页,不是全量 —— 本页 items=${info.itemsLength};` +
72
+ `服务端未给出总数,但明示 hasMore=true${where}`);
73
+ }
74
+ else {
75
+ lines.push(`[warn] mode=truncated: 这是一页,不是全量 —— items=${info.itemsLength} 但 total=${info.total}${where}`);
76
+ lines.push(` 缺 ${info.total - info.itemsLength} 条。`);
77
+ }
78
+ lines.push(` 路径: ${info.path}`);
79
+ lines.push(` ⚠️ 不要把这一页当成全量做判断。要全量:该命令若有分页参数就翻到 hasMore=false / 累计够 total 为止;`);
80
+ lines.push(` 没有分页参数的命令通常已默认自动翻页(安全类命令刻意不开分页口),此时请重跑并反馈。`);
81
+ return lines.join("\n");
82
+ }
1
83
  /** GET /health without API key. */
2
84
  export async function getSocialHubHealth(baseUrl) {
3
85
  const url = `${baseUrl.replace(/\/$/, "")}/health`;
@@ -214,7 +296,33 @@ export class SocialHubClient {
214
296
  const text = await this.requestText(path, init);
215
297
  if (init?.parseJson === false)
216
298
  return undefined;
217
- return (text ? JSON.parse(text) : {});
299
+ const body = (text ? JSON.parse(text) : {});
300
+ if (init?.suppressPartialListWarning !== true) {
301
+ this.notifyIfPartialList(path, body);
302
+ }
303
+ return body;
304
+ }
305
+ /**
306
+ * 所有 JSON 响应的**唯一**汇合点上做一次截断探测(见 {@link PartialListInfo})。
307
+ *
308
+ * 为什么钩在传输层而不是打印层:CLI 根本没有统一的打印函数 —— index.ts 里有 235 处
309
+ * 裸 `console.log(JSON.stringify(...))`,另有 79 处 `printResult`。逐个打补丁正是
310
+ * 这个缺陷已经复发四次的原因。而所有命令都必经 `fetchJson`。
311
+ */
312
+ notifyIfPartialList(path, body) {
313
+ const onPartialList = this.opts.onPartialList;
314
+ if (!onPartialList)
315
+ return;
316
+ const info = detectPartialList(path, body);
317
+ if (!info)
318
+ return;
319
+ try {
320
+ onPartialList(info);
321
+ }
322
+ catch {
323
+ // 告警 sink 抛错**不该**把一个成功的 HTTP 响应变成失败 —— 那是拿
324
+ // 「提醒你结果不完整」换掉了结果本身。(Codex 评审提出)
325
+ }
218
326
  }
219
327
  /** 与 `fetchJson` 同一条 base-url/鉴权/错误处理路径,但返回文本(CSV 等非 JSON 响应)。 */
220
328
  async fetchText(path, init) {
@@ -879,7 +987,12 @@ export class SocialHubClient {
879
987
  }
880
988
  throw new RegistryProposalSdkError(res.status, code, staleCode, message);
881
989
  }
882
- return (text ? JSON.parse(text) : {});
990
+ const body = (text ? JSON.parse(text) : {});
991
+ // ⚠️ 这条是 SDK 的**第二条** JSON 传输(自己 fetch,不走 fetchJson),
992
+ // 所以截断探测必须在这里再挂一次 —— 否则 brand-fit 那一批列表端点会整片
993
+ // 绕过统一收口,又变成「一页当全量」。(Codex 评审发现)
994
+ this.notifyIfPartialList(path, body);
995
+ return body;
883
996
  }
884
997
  async proposeRegistryChange(body) {
885
998
  return this.brandFitRequest(`/v1/system/brand-fit-registry/proposals`, {
@@ -2250,13 +2363,127 @@ export class SocialHubClient {
2250
2363
  async upsertSubredditRuleCache(teamId, subreddit, body) {
2251
2364
  return this.fetchJson(`${this.teamBase(teamId)}/subreddit-rule-caches/${encodeURIComponent(subreddit)}`, { method: "PUT", body: JSON.stringify(body) });
2252
2365
  }
2366
+ /**
2367
+ * ⚠️ **单页**,不是全量 —— 服务端按 `createdAt DESC` 排序,`limit` 上限 200、默认 100。
2368
+ *
2369
+ * 历史上这个方法**一个分页参数都不传**,于是永远只拿服务端默认的第一页 100 条,
2370
+ * 而同一个返回体里的 `total` 是 112 —— 调用方看到 `items` 就以为是全量,漏掉的
2371
+ * 恰恰是**最早加入**的那 12 条(排序是 DESC),往往是最确定该封的那批。
2372
+ *
2373
+ * 🔴 **板块准入这类安全判定不要用这个方法**,用
2374
+ * {@link SocialHubClient.listAllSystemSubredditBlocklists}(自动翻页 + 一致性校验)。
2375
+ * 这里保留分页版只是为了后台列表分页展示,以及不破坏已发布 SDK 的方法签名。
2376
+ */
2253
2377
  async listSystemSubredditBlocklists(params) {
2254
2378
  const q = buildQuery({
2255
2379
  kind: params?.kind,
2256
2380
  includeExpired: params?.includeExpired ? "true" : undefined,
2381
+ limit: params?.limit,
2382
+ offset: params?.offset,
2257
2383
  });
2258
2384
  return this.fetchJson(`/v1/system/subreddit-blocklists${q}`);
2259
2385
  }
2386
+ /**
2387
+ * 取**全量** system subreddit blocklist(自动翻页),供板块准入这类安全判定使用。
2388
+ *
2389
+ * 为什么必须有这个方法:分页版每页上限 200(contracts 硬上限,本次不改),所以
2390
+ * 「让调用方自己传一个够大的 limit」根本表达不了「全量」;而把正确性交给调用方
2391
+ * 记得传参,正是本次要消灭的失效模式。
2392
+ *
2393
+ * **翻页期间集合变了怎么办**:offset 分页**不是快照分页**,并发增删会导致跨页
2394
+ * 重复或跳过。口径是「要么给出可自证一致的结果,要么报错」,
2395
+ * **绝不静默拼一个可能不一致的结果**:
2396
+ * - 一趟内所有页的 `total` 必须一致、跨页不得出现重复行、去重后条数必须 === `total`;
2397
+ * - 任一条不满足 → 整趟从 `offset=0` 重跑,最多 {@link BLOCKLIST_FULL_SCAN_MAX_ATTEMPTS} 趟;
2398
+ * - 全部失败 → **抛错**,并带上每趟的**具体**原因(漂移 / 重复 / 空页 / 超预算)。
2399
+ *
2400
+ * 🔴 **这是尽力校验,不是快照证明 —— 不要把它当成后者**(Codex 评审提出)。
2401
+ * 反例:翻页期间「删一条 + 加一条」会让 `total` 纹丝不动、去重后条数也刚好对上,
2402
+ * 于是校验通过,而结果里含着已删的、缺着新加的。`expiresAt` 过滤同理 ——
2403
+ * 没人写库,集合也会随时间变化。要真正的快照语义需要服务端提供
2404
+ * as-of / ETag / keyset cursor;在那之前这里只能做到「**能查出来的**不一致一律
2405
+ * 拒绝」,查不出来的那一类仍会漏过去。所以本方法的承诺是
2406
+ * 「没报错 = 没发现不一致」,**不是**「保证是某一时刻的完整快照」。
2407
+ *
2408
+ * 抛错而不是「返回一个带 inconsistent 标记的结果」是有意的:这是安全闸的取数路径,
2409
+ * 「没有答案」是安全的(调用方重跑一次即可),而「一个可能不全、但看起来完整的答案」
2410
+ * 正是本次缺陷本身 —— 标记会被 `jq '.items'` 一秒钟丢掉。
2411
+ */
2412
+ async listAllSystemSubredditBlocklists(params) {
2413
+ const observed = [];
2414
+ for (let attempt = 1; attempt <= BLOCKLIST_FULL_SCAN_MAX_ATTEMPTS; attempt += 1) {
2415
+ const pass = await this.scanAllSystemSubredditBlocklistsOnce(params);
2416
+ if (pass.consistent) {
2417
+ return { items: pass.items, total: pass.total, attempts: attempt };
2418
+ }
2419
+ observed.push(`attempt ${attempt}: ${pass.problems.join("; ")}`);
2420
+ }
2421
+ throw new Error([
2422
+ `Refusing to return a possibly-incomplete blocklist: could not obtain a self-consistent`,
2423
+ `full page-through in ${BLOCKLIST_FULL_SCAN_MAX_ATTEMPTS} attempts (concurrent insert/delete).`,
2424
+ ...observed,
2425
+ `Re-run the command; do NOT treat a partial blocklist as the full one.`,
2426
+ ].join("\n"));
2427
+ }
2428
+ /** 一趟完整翻页;`consistent` 为假时调用方应整趟重来(见上)。 */
2429
+ async scanAllSystemSubredditBlocklistsOnce(params) {
2430
+ const byId = new Map();
2431
+ const totals = [];
2432
+ const problems = [];
2433
+ let offset = 0;
2434
+ let pages = 0;
2435
+ let fetched = 0;
2436
+ for (;;) {
2437
+ const q = buildQuery({
2438
+ kind: params?.kind,
2439
+ includeExpired: params?.includeExpired ? "true" : undefined,
2440
+ limit: BLOCKLIST_FULL_SCAN_PAGE_SIZE,
2441
+ offset,
2442
+ });
2443
+ // 直接走 fetchJson 而不是公开的 listSystemSubredditBlocklists:只有这里能传
2444
+ // 请求级的 suppress。翻页的中间页当然都 items < total,那是正常中间态、
2445
+ // 不是缺陷;抑制的只是 per-page 噪音,**完整性判定仍在本函数末尾照做**。
2446
+ const page = await this.fetchJson(`/v1/system/subreddit-blocklists${q}`, {
2447
+ suppressPartialListWarning: true,
2448
+ });
2449
+ pages += 1;
2450
+ totals.push(page.total);
2451
+ fetched += page.items.length;
2452
+ for (const row of page.items)
2453
+ byId.set(row.id, row);
2454
+ // 服务端返回空页 = 已经翻到尾(即使 total 说还有,也不能无限循环)。
2455
+ if (page.items.length === 0) {
2456
+ if (fetched < page.total) {
2457
+ problems.push(`empty page at offset=${offset} while total=${page.total} (only ${fetched} fetched)`);
2458
+ }
2459
+ break;
2460
+ }
2461
+ // ⚠️ 按服务端**实际返回**的条数推进,不是按自己请求的 limit ——
2462
+ // 服务端自身上限更低时(给的比要的少),按 limit 推进会整段跳过数据。
2463
+ offset += page.items.length;
2464
+ if (offset >= page.total)
2465
+ break;
2466
+ // 防御无限循环:页数上限按 total 推出来,再宽放一点。
2467
+ if (pages > Math.ceil(page.total / BLOCKLIST_FULL_SCAN_PAGE_SIZE) + 2) {
2468
+ problems.push(`page budget exceeded after ${pages} pages`);
2469
+ break;
2470
+ }
2471
+ }
2472
+ const total = totals[0] ?? 0;
2473
+ // 失败原因分开记,不要统一说成「total 变了」—— 重复行与空页是完全不同的
2474
+ // 服务端行为,混在一起报会让排障从第一步就走错方向。(Codex 评审提出)
2475
+ if (!totals.every((t) => t === total)) {
2476
+ problems.push(`total drifted across pages: [${totals.join(",")}]`);
2477
+ }
2478
+ const items = [...byId.values()];
2479
+ if (fetched !== items.length) {
2480
+ problems.push(`duplicate rows across pages: fetched=${fetched} unique=${items.length}`);
2481
+ }
2482
+ if (items.length !== total) {
2483
+ problems.push(`collected ${items.length} rows but total=${total}`);
2484
+ }
2485
+ return { items, total, consistent: problems.length === 0, problems };
2486
+ }
2260
2487
  async upsertSystemSubredditBlocklist(subreddit, body) {
2261
2488
  return this.fetchJson(`/v1/system/subreddit-blocklists/${encodeURIComponent(subreddit)}`, { method: "PUT", body: JSON.stringify(body) });
2262
2489
  }