dsh-m 0.9.84 → 0.9.87

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/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.87 性能:热路径四连——已装枚举并行化 + patchRows 按需采集 + 收藏 stale 批量化 + 设置页社区 summary 接入 L2
12
+
13
+ - **问题(2026-10-15 架构走查 + 独立复核)**:市场查询热路径上仍有三处重复支付——① `listInstalledPlugins` 逐依赖**串行**双文件读(package.json + cordis.patch.yml),每次查询/翻页/每次防抖搜索提交都全量重跑(14 依赖实测 ~11ms,占暖缓存查询近半预算);② 收藏分区 stale 检测对**每个收藏发一整条 market 查询**(≤8 并发扇出):回答一个布尔值却付全管线(枚举+社区+4445 条评分+排序+分页),20 收藏 ≈ 3 批 × 全管线;且旧路径存在 curatedFirst 挤出边界误判——primary 域同词命中 ≥8 条时 id-exact 社区条目被前置挤出前 8,**误判 stale**;③ 设置页 registry 方法背后的 `getCommunitySummary` 走无 memo 的全量 adapt+merge(~4400 条,每次打开 ~10-50ms CPU),而同身份的 L2 物化代(ADR-0016)近在咫尺。
14
+ - **方案(热路径四连)**:① 枚举并行化——每依赖分类独立无跨依赖状态,`Promise.all` 保序折叠(输出仍按依赖键字典序),新增可选 `{ patchRows: false }`:市场热路径不再付每插件 ~0.55ms 的 YAML CST 解析(patchRows 仅 enablement/toggle 消费,已装页路径保持缺省采集;退出时**省略字段**而非伪造 `readable:false`);② 批量 id 存在性——core 新增 `existingMarketIds`(三不原则:不枚举已装/不探测 latest/不分页;merged 域原始 id Set 判定,与旧搜索扇出除 curatedFirst 边界外逐点一致,该边界上属**修复**)、host 新 method `market-ids-exist`(ids ≤500 校验 400)、客户端单发批量 + 失败保守全 stale;③ `getCommunitySummary` 改道 `mergedOutcome`(同身份免 adapt+merge,「summary 永不入代」保持;registry 传 `snap.loaded` 整体,namespace/profile 显式缺省与改道前缓存路径逐字节同路——保 parity 优先于共享代的自觉取舍);④ `toMarketItem` 逐页线性扫描改每查询一次已装索引(byPkg/byName/byGithub 三域 first-wins + min-ord,与 `matchInstalledByEntry` 逐点等价,随机 fuzz 640 条零不一致),`GITHUB_SPEC_RE` 导出为单一事实源。
15
+ - **验证**:1472 项测试全绿(净增 7 例:枚举排序钉住/patchRows 退出对照/FP5 匹配等价四路径/existingMarketIds 四例含 L2 命中钉与 curatedFirst 边界钉/host method 正常+400/C3 源串锚迁移)、typecheck 零错误;实测(保守口径):枚举市场路径 10.55ms → 2.08ms(5.1×)、`listMarket` cache-only 端到端中位 ~28ms → 5-7ms、`existingMarketIds`(20 id) 2.52ms/次;两份实施计划均经独立评审 Agent 多轮对齐(FP1/FP5 两轮零保留通过;FP2/FP3+发版两轮 7+1 条意见全闭环)。
16
+
17
+ ### 0.9.86 性能:packument 访问器——registry 元数据属主模块,登记网络腿出锁(钢人 C2 / ADR-0019)
18
+
19
+ - **问题(钢人报告 C2 节)**:「registry 元数据」没有 owning module——登记(Register)只为读一个发布时戳,却在官方文件锁内发起 8MiB 级全量 packument 网络取(npmjs 首腿 5s 超时 + 生效源备腿至 12s),镜像机器装机的白等直接计入关键路径;B3 预热再经无缓存通道把同一全量取付第二次;三 adapter 各自取、各自缓存(或没有)。
20
+ - **修复(ADR-0019)**:新属主模块 `src/core/packument.ts`——一次取回派生视图包 `{times, versions, distTags, readme, repo}`,按 (pkg, 生效源) 键缓存(TTL 5min / single-flight / cap=4 / 失败与 regex-invalid 负缓存同 TTL / onRouteSwitch 作废 + 生效源入键双保险)。登记 times **出锁**:发布时戳是不可变历史事实,锁外预取零 TOCTOU,锁体回归纯 fs(锁前存在性预读保「文件缺席零探测」钉子);预检/登记共享 single-flight;B3 预热改 `warmPackumentFor`(bypass 缓存 + 生效源单腿真发暖 CDN + 回填);readme 兜底腿同款单腿带缓存面(终末错误透传保全)。latest 小端点与 ADR-0006 制度不并入;四函数 + `extractGithubRepo` 迁入属主、versions.ts 原名 re-export(import 位零改动)。
21
+ - **验证**:1462 项测试全绿(访问器 18 例新增:两腿超时收紧断言/single-flight 哨兵/负缓存/cap/路由作废/warm 单腿 bypass+回填/Active 面负缓存视 miss/readme 阶梯三态含 URL host 锚/锁内零网络时序断言)、typecheck 零错误;真机冒烟:访问器冷 143–269ms / 热 0ms / warm bypass 真发 29ms,登记真实 wire(临时 profile + 真实 young 版本)applied:true 111ms + 幂等 up-to-date 3ms;实施计划经独立评审三轮(13 条)+ 执行评审两轮(4 条建议全收口)。
22
+
23
+ ### 0.9.85 优化:版本号迁「关于」链接行尾 + 头部自升级入本地操作泵(两则真机反馈)
24
+
25
+ - **① 版本号迁位(主人定稿)**:0.9.84 把插件版本号挂在「关于」标题行 sub(弱化灰字),真机对照后主人拍板迁至**链接行尾**(GitHub · 问题反馈 · npm · v0.9.85)——与外向元数据同组更协调,标题行回归纯净。缺失(ping 失败 → null)时整段不渲染,不留悬空分隔点;新增 `.dshm-links-ver` 弱化样式(label-secondary + tabular-nums)。不放升级入口的裁决不变。
26
+ - **② 头部自升级漏记根治(主人真机反馈:⬆ 升级在操作日志无记录)**:根因是渠道两不沾——host-api `self-upgrade` 显式 `channel: 'panel'`(ADR-0018「面板通道零外部日志」不变量,防漏标落 external),而头部 chip 的 `doUpgrade` 直接 `api("self-upgrade")` 绕过本地操作泵,两头都不记。修复:chip 接线 `ops`(面板操作账本)并入泵 `ops.runOp("upgrade", "dsh-m", …)`,执行器回传 `opMeta {from,to}` 落账 → 已结束列表渲染「升级 dsh-m vX→vY」;SSR/探针无 `ops` 时回退直连 api。`stillApplies` 语义安全:dsh-m 恒已装 → 恒 applies,不触发 superseded。全家族盘点:安装/升级/卸载/开关/清单源/社区开关/「全部更新」(逐条 runOp 入队)均无漏记,自升级是唯一缺口。
27
+ - **验证**:1443 项测试全绿(0.9.84 基线 1440 + 新增源锚:about-version 迁位改写与 render-smoke 自升级入泵)、typecheck 零错误;F1 族就绪门连续三跑全量稳定(曾现一次 657/42 异常未再复现,归因共享机瞬时载荷,已留日志)。
28
+
11
29
  ### 0.9.84 优化:设置页「关于」标题行展示插件版本号
12
30
 
13
31
  - **问题(主人真机反馈)**:0.9.83 起窄容器下头部版本 chip 常态隐藏(共识 Q7——手机头部宽度让位给还原/关闭),插件自身版本号一度无处可查(设置页只有社区清单的「目录版本」)。
@@ -597,6 +615,24 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
597
615
 
598
616
  ## English
599
617
 
618
+ ### Performance in 0.9.87 — hot-path quartet: parallel installed enumeration, on-demand patchRows, batched favorites stale check, settings community summary via L2
619
+
620
+ - **Problem (2026-10-15 architecture walk + independent audit)**: three recurring costs remained on the market-query hot path - (1) `listInstalledPlugins` read each dependency's two files (package.json + cordis.patch.yml) **strictly serially**, re-running in full on every query / page turn / debounced search commit (~11ms for 14 deps, nearly half the warm-cache query budget); (2) the favorites stale check fired **one full market query per favorite** (fan-out of 8): a single boolean answer paid the entire pipeline (enumeration + community + scoring 4,445 entries + sort + paging), 20 favorites ≈ 3 batches × full pipeline; the old path also had a curatedFirst squeeze-out false-stale edge - with ≥8 primary same-token hits the id-exact community entry got pushed past the page-8 window; (3) the settings-page registry method's `getCommunitySummary` ran an unmemoized full adapt+merge (~4,400 entries, ~10-50ms CPU per open) while the same-identity L2 materialized generation (ADR-0016) sat one function away.
621
+ - **Fix (hot-path quartet)**: (1) parallel enumeration - per-dependency classification is independent, `Promise.all` folds in key order (output stays sorted by dependency key); new opt-out `{ patchRows: false }` spares the market path the ~0.55ms/plugin YAML CST parse (patchRows is only consumed by enablement/toggle, which keep the default; opting out **omits the field** instead of faking `readable:false`); (2) batch id-existence - new core `existingMarketIds` (three no's: no installed enumeration / no latest probing / no paging; raw-id Set membership over the merged domain, pointwise identical to the old search fan-out except the curatedFirst edge, where it is a **fix**), new host method `market-ids-exist` (ids ≤500, 400 on malformed), client sends one batch call and falls back to all-stale on error; (3) `getCommunitySummary` now routes through `mergedOutcome` (same identity skips adapt+merge; "summary never enters the generation" preserved; receives the whole `snap.loaded`, explicit namespace/profile defaults byte-identical to the pre-change cache paths - a deliberate parity-over-generation-sharing trade-off); (4) `toMarketItem`'s per-page linear scan became a once-per-query installed index (byPkg/byName/byGithub first-wins + min-ord, pointwise equal to `matchInstalledByEntry`, zero mismatches over a 640-entry random fuzz), with `GITHUB_SPEC_RE` exported as the single source of truth.
622
+ - **Verification**: 1,472 tests green (+7: enumeration ordering pin / patchRows opt-out contrast / FP5 four-path match equivalence / four existingMarketIds cases incl. L2-hit and curatedFirst-edge pins / host method ok+400 / migrated C3 source anchor), typecheck clean; measured (conservative baseline): market-path enumeration 10.55ms → 2.08ms (5.1×), `listMarket` cache-only end-to-end median ~28ms → 5-7ms, `existingMarketIds` (20 ids) 2.52ms/call; both implementation plans aligned over multiple independent review rounds (FP1/FP5 two rounds, zero reservations; FP2/FP3+release two rounds, 7+1 findings all closed).
623
+
624
+ ### Performance in 0.9.86 — packument accessor: registry metadata gets an owning module, register's network leg leaves the lock (steelman C2 / ADR-0019)
625
+
626
+ - **Problem (steelman report, card C2)**: "registry metadata" had no owning module - Register fetched an 8MiB-scale full packument INSIDE the official file lock just to read one publish timestamp (npmjs first leg 5s timeout + effective-source leg up to 12s), putting mirror machines' white-wait directly on the install critical path; the B3 prewarm paid the same full fetch a second time through an uncached channel; three adapters each fetched, interpreted and cached (or not) independently.
627
+ - **Fix (ADR-0019)**: new owning module `src/core/packument.ts` - one fetch derives a view bundle `{times, versions, distTags, readme, repo}`, cached under (pkg, effective registry) keys (TTL 5min / single-flight / cap=4 / failures and regex-invalids negative-cached for the same TTL / onRouteSwitch invalidation + registry-in-key double insurance). Register's timestamp fetch **leaves the lock**: publish timestamps are immutable historical facts (zero TOCTOU), the lock body is pure fs again (a cheap pre-lock existence read preserves the "local check first" pin). Precheck and register share single-flight; B3 prewarm uses `warmPackumentFor` (cache-bypassing + effective-source single-leg real request to warm the CDN + backfill); the readme fallback leg shares the same single-leg cached face (final-leg error passthrough preserved). The latest small endpoint and ADR-0006 stay out of scope; four functions + `extractGithubRepo` moved into the owner, versions.ts re-exports under the original names (zero import-site changes).
628
+ - **Verification**: 1462 tests green (18 accessor cases added: two-leg timeout-tightening assertions / single-flight sentinel / negative cache / cap / route invalidation / warm single-leg bypass+backfill / Active-face negative-cache-as-miss / readme ladder three states with URL host anchors / in-lock zero-network timing assertion), typecheck clean; live smoke: accessor cold 143-269ms / warm 0ms / warm bypass real request 29ms, register real-wire (temp profile + genuinely young version) applied:true in 111ms + idempotent up-to-date in 3ms; the plan closed over three independent review rounds (13 findings) + two execution review rounds (4 suggestions).
629
+
630
+ ### 0.9.85 Enhancement — version moves to the About links row + head self-upgrade enters the local ops pump (two real-device reports)
631
+
632
+ - **① Version relocation (owner's call)**: 0.9.84 hung the plugin version on the "About" title-row sub (muted small text); after on-device comparison the owner decided to move it to the **links row tail** (GitHub · Issues · npm · v0.9.85) — grouped with the outward metadata, the title row stays clean. Missing version (ping failure → null) renders nothing, no dangling separator; new `.dshm-links-ver` muted style (label-secondary + tabular-nums). The no-upgrade-entry ruling stands.
633
+ - **② Head self-upgrade log gap cured (owner's real-device report: ⬆ upgrades were absent from the operations log)**: the root cause fell between two ledgers — host-api `self-upgrade` explicitly passes `channel: 'panel'` (ADR-0018 invariant "panel channel writes zero external records", guarding against mislabelled external rows), while the head chip's `doUpgrade` called `api("self-upgrade")` directly, bypassing the local ops pump — so neither recorded it. Fix: the chip receives `ops` (the panel's ledger) and goes through `ops.runOp("upgrade", "dsh-m", …)`; the executor returns `opMeta {from,to}` so the ended list renders "升级 dsh-m vX→vY"; SSR/probes without `ops` fall back to the direct api call. `stillApplies` is semantically safe: dsh-m is always installed → always applies, no superseded. Family audit: install/upgrade/uninstall/toggle/registry-source/community-toggle and "Update all" (per-item runOp enqueue) all record correctly — self-upgrade was the only gap.
634
+ - **Verification**: 1443 tests green (0.9.84 baseline 1440 + new source anchors: about-version relocation rewrite and render-smoke self-upgrade pump), typecheck clean; the F1-family readiness gate held stable across three consecutive full runs (one anomalous 657/42 run never reproduced — attributed to transient load on this shared box, logs archived).
635
+
600
636
  ### 0.9.84 Enhancement — plugin version shown on the Settings "About" title row
601
637
 
602
638
  - **Problem (owner's real-device feedback)**: since 0.9.83 the head version chip stays hidden in its idle state on narrow containers (consensus Q7 — phone head width goes to the restore/close buttons), leaving the plugin's own version nowhere to check (the settings page only had the community catalog's 目录版本).
package/lib/client.js CHANGED
@@ -2913,6 +2913,7 @@ button.dshm-badge:hover{filter:brightness(.95)}
2913
2913
  .dshm-links{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:7px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb);font-size:11px;color:var(--dsw-alias-label-caption,#6b7280)}
2914
2914
  .dshm-links-k,.dshm-links-sep{color:var(--dsw-alias-label-caption,#9ca3af)}
2915
2915
  .dshm-links a{color:var(--dsw-alias-state-business-primary,#4d6bfe);text-decoration:none;font-weight:500}
2916
+ .dshm-links-ver{font-weight:500;color:var(--dsw-alias-label-secondary,#4b5563);font-variant-numeric:tabular-nums}
2916
2917
  .dshm-links a:hover{text-decoration:underline}
2917
2918
  .dshm-prog{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#4b5563)}
2918
2919
  .dshm-prog .bar{flex:1;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-2,rgba(38,49,72,.08));overflow:hidden;min-width:80px}
@@ -4799,12 +4800,11 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4799
4800
  state && state.errors && state.errors.length ? h("div", { className: "dshm-err" }, `${lookup("settings.remotehint")}\uFF1A${state.errors.slice(0, 5).join("\uFF1B")}`) : null
4800
4801
  ),
4801
4802
  // ③ 关于:当前定位 + 仓库/反馈链接(dsh-m 自身卡已按主人要求移除——升级走市场卡片/CLI,self-check/self-upgrade API 保留)。
4802
- // 版本号挂标题行既有 sub 槽(0.9.84 主人真机反馈:窄容器头部 chip 常态隐藏后插件版本无处可查)——
4803
- // 零新增行/块/CSS/i18n;缺失(ping 失败 → null)时槽位自然为空,与头部 chip「失败不留占位」同语义;
4804
- // 不放升级入口——头部 ⬆ + 市场卡片/CLI 所有权不变(评审 E 轮对齐)。
4803
+ // 版本号挂链接行尾(0.9.85 主人定稿:0.9.84 曾挂标题 sub,真机对照后迁此——与 GitHub/npm 等
4804
+ // 外向元数据同组更协调);缺失(ping 失败 → null)时整段不渲染,不留悬空分隔点;
4805
+ // 不放升级入口——头部 ⬆ + 市场卡片/CLI 所有权不变。
4805
4806
  Section(
4806
4807
  lookup("settings.about"),
4807
- version ? `v${version}` : null,
4808
4808
  h("div", { className: "dshm-hint" }, lookup("settings.about.text")),
4809
4809
  h(
4810
4810
  "div",
@@ -4813,7 +4813,11 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4813
4813
  h("span", { className: "dshm-links-sep" }, "\xB7"),
4814
4814
  h(ExtLink, { href: "https://github.com/iasiv5/dsh-m/issues" }, lookup("settings.about.issues")),
4815
4815
  h("span", { className: "dshm-links-sep" }, "\xB7"),
4816
- h(ExtLink, { href: "https://www.npmjs.com/package/dsh-m" }, lookup("settings.about.npm"))
4816
+ h(ExtLink, { href: "https://www.npmjs.com/package/dsh-m" }, lookup("settings.about.npm")),
4817
+ version ? [
4818
+ h("span", { className: "dshm-links-sep" }, "\xB7"),
4819
+ h("span", { className: "dshm-links-ver" }, `v${version}`)
4820
+ ] : null
4817
4821
  )
4818
4822
  ),
4819
4823
  // ④ 操作记录:恒通宽卡(第二批搬迁),进行中常驻 + 已结束滚动区,空态 hint 导览
@@ -4897,15 +4901,14 @@ function FavoriteZone({ favorites, onOpen }) {
4897
4901
  if (!list.length) return;
4898
4902
  const timer = setTimeout(() => {
4899
4903
  void (async () => {
4900
- const lookupLive = async (fav) => {
4901
- try {
4902
- const res = await api("market", { query: fav.id, source: "all", limit: 8, probeMode: "cache-only" });
4903
- const items = res && Array.isArray(res.items) ? res.items : [];
4904
- return items.some((x) => x && x.id === fav.id);
4905
- } catch {
4906
- return false;
4907
- }
4908
- };
4904
+ let present = /* @__PURE__ */ new Set();
4905
+ try {
4906
+ const res = await api("market-ids-exist", { ids: list.map((f) => f.id) });
4907
+ if (res && Array.isArray(res.present)) present = new Set(res.present);
4908
+ } catch {
4909
+ present = /* @__PURE__ */ new Set();
4910
+ }
4911
+ const lookupLive = async (fav) => present.has(fav.id);
4909
4912
  const { stale } = await partitionStale2(list, lookupLive);
4910
4913
  if (live) setCheck({ staleIds: stale.map((f) => f.id) });
4911
4914
  })();
@@ -5081,7 +5084,7 @@ var TABS = [
5081
5084
  ["installed", "tab.installed", "installed"],
5082
5085
  ["settings", "tab.settings", null]
5083
5086
  ];
5084
- function DshmVersionChip({ version, notify, initialCheck }) {
5087
+ function DshmVersionChip({ version, notify, initialCheck, ops }) {
5085
5088
  const [check, setCheck] = useState(
5086
5089
  () => initialCheck !== void 0 ? initialCheck : typeof window !== "undefined" && window.localStorage ? readSelfCheckCache2(window.localStorage, { version }) : null
5087
5090
  );
@@ -5105,7 +5108,10 @@ function DshmVersionChip({ version, notify, initialCheck }) {
5105
5108
  if (busy) return;
5106
5109
  setBusy(true);
5107
5110
  try {
5108
- const res = await api("self-upgrade");
5111
+ const res = await (ops ? ops.runOp("upgrade", "dsh-m", async () => {
5112
+ const r = await api("self-upgrade");
5113
+ return { ...r, opMeta: { from: version, to: r.version || "latest" } };
5114
+ }, { npm: "dsh-m" }) : api("self-upgrade"));
5109
5115
  clearSelfCheckCache2(typeof window !== "undefined" && window.localStorage ? window.localStorage : null);
5110
5116
  setCheck({ current: version, latest: null, outdated: false, ahead: false });
5111
5117
  notify({
@@ -5588,7 +5594,7 @@ function MarketPanel({ onClose }) {
5588
5594
  )
5589
5595
  ),
5590
5596
  h("span", { className: "dshm-spacer" }),
5591
- pluginVersion ? h(DshmVersionChip, { version: pluginVersion, notify }) : null,
5597
+ pluginVersion ? h(DshmVersionChip, { version: pluginVersion, notify, ops }) : null,
5592
5598
  profile && profile.kind !== "web" ? h("span", { className: "dshm-dshchip", title: lookup("profile.chipTitle", { name: profile.name }) }, profile.name) : null,
5593
5599
  // 窗口控制组(0.7.7):最大化/还原 + 关闭,连体设计(系统化窗口按钮)
5594
5600
  h(
@@ -8,10 +8,10 @@
8
8
  */
9
9
  import { mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
10
10
  import { isAbsolute, join, relative } from 'node:path';
11
- import { cacheRoot } from './env.js';
11
+ import { cacheRoot, WEB_PROFILE } from './env.js';
12
12
  import { decodeUtf8Fatal, fetchBytesLimited, describeFetchFailure } from './httpx.js';
13
13
  import { isExactVersion, npmLatest } from './versions.js';
14
- import { communityOutcome } from './merged-market.js';
14
+ import { mergedOutcome } from './merged-market.js';
15
15
  export const COMMUNITY_NPM_PACKAGE = 'dsh-plugin-catalog';
16
16
  export const MAX_COMMUNITY_BYTES = 32 * 1024 * 1024;
17
17
  export const MAX_COMMUNITY_ENTRIES = 30_000;
@@ -448,16 +448,29 @@ export async function fetchCommunityCatalog(cfg = {}, opts = {}) {
448
448
  }
449
449
  }
450
450
  /**
451
- * 单一深接口(计划 Task 6):fetch → adapt → merge 计数 → CommunityRegistrySummary。
452
- * - primaryEntries 直接取本次 registry snapshot 的 primary plugins(displaced 与本次响应同代,
451
+ * 单一深接口(计划 Task 6):fetch → mergedOutcome(L2 代存储)→ CommunityRegistrySummary。
452
+ * - 0.9.87 FP3:改道 mergedOutcome——同身份(registry 代 + 目录 version)命中即免全量
453
+ * adapt+merge(每次设置页打开 ~10-50ms CPU),summary 仍由当次 state 现算(「summary
454
+ * 永不入代」不变量保持);失败/deadline/unavailable/disabled 形态透传不落代;
455
+ * - registry 直接取本次 registry snapshot 的 LoadedRegistry(displaced 与本次响应同代,
453
456
  * 不重复加载主清单;内部禁止调用 loadRegistry——单一来源约束);
457
+ * - namespace/profile 显式透传:缺省 'host'/WEB_PROFILE,与改道前 fetchCommunityCatalog/
458
+ * awesomeDir 的隐式默认逐字节同路(缓存目录恒 web 段——**自觉取舍**:保 parity 优先于
459
+ * 与 market 臂共享 L2 代,desktop host 下各建各代是接受的成本,勿当「漏传」修复);
454
460
  * - 3s 是本 waiter 的等待上限(waiter-scoped race):到点未 materialize → unavailable +
455
461
  * 「社区目录状态获取超时」errors,调用方主响应照常;共享 flight 按 30s hard cap 继续;
456
462
  * - 数据源 cache-first:fetchCommunityCatalog 的版本缓存 + TTL 保证 registry 刷新无重复网络压力。
457
463
  */
458
- export async function getCommunitySummary(primaryEntries, cfg = {}, opts) {
459
- const task = fetchCommunityCatalog(cfg, { signal: opts.signal, force: opts.force });
460
- const outcome = await communityOutcome(task, opts.deadlineAt, primaryEntries);
464
+ export async function getCommunitySummary(registry, cfg = {}, opts) {
465
+ const task = fetchCommunityCatalog(cfg, { namespace: opts.namespace, signal: opts.signal, force: opts.force, profile: opts.profile });
466
+ const outcome = await mergedOutcome({
467
+ namespace: opts.namespace ?? 'host',
468
+ profile: opts.profile ?? WEB_PROFILE,
469
+ cfg,
470
+ registry,
471
+ communityTask: task,
472
+ deadlineAt: opts.deadlineAt,
473
+ });
461
474
  return outcome.summary;
462
475
  }
463
476
  // 0.9.14 SWR:过期触发即登记的后台自愈 flight(waiter 隔离;flight 本体走 fetchCommunityCatalog
@@ -231,16 +231,26 @@ export async function registerExclusion(profileDir, target, deps = {}) {
231
231
  });
232
232
  const file = join(profileDir, WS_FILE);
233
233
  try {
234
+ // 锁前廉价存在性预读(ADR-0019,评审 G1-1):文件缺席零探测直返 no-file——
235
+ // 「本地检查先行」契约(钉子测试 + 缺省绑定下测试离线可跑)双保。
236
+ try {
237
+ await doRead(file);
238
+ }
239
+ catch {
240
+ return { applied: false, reason: 'no-file' };
241
+ }
242
+ // times 出锁(ADR-0019):发布时戳是不可变历史事实,锁外预取零 TOCTOU——镜像机器
243
+ // 装机不再在官方文件锁内白等网络腿(最多 5s 首腿 + 12s 备腿);取数失败 → not-young
244
+ // (fail-open 口径不变,ADR-0009 判定分支逐字未动)。
245
+ const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
234
246
  return await withFileLock(join(profileDir, 'package.json'), async () => {
235
- // 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
236
247
  let yaml;
237
248
  try {
238
- yaml = await doRead(file);
249
+ yaml = await doRead(file); // 锁内重读续走:锁保护的是写,读的 TOCTOU 无害
239
250
  }
240
251
  catch {
241
252
  return { applied: false, reason: 'no-file' };
242
253
  }
243
- const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
244
254
  const now = deps.nowMs ?? Date.now();
245
255
  const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
246
256
  if (!Number.isFinite(publishedAt))
@@ -10,7 +10,7 @@
10
10
  import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
11
11
  import { resolveDshVersion, readLauncherPackageVersion } from './dsh-version.js';
12
12
  import { runDoctor } from './doctor.js';
13
- import { listInstalledWithMeta, listMarket, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
13
+ import { listInstalledWithMeta, listMarket, existingMarketIds, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
14
14
  import { runProfileTransaction, TransactionError } from './profile-transaction.js';
15
15
  import { listExternalOperations, clearExternalOperations, removeExternalOperation } from './external-journal.js';
16
16
  import { readInstalledPluginReadme } from './installed.js';
@@ -212,6 +212,7 @@ export function createApiDispatcher(ctx) {
212
212
  const d = {
213
213
  listMarket: ctx.deps?.listMarket ?? listMarket,
214
214
  listInstalledWithMeta: ctx.deps?.listInstalledWithMeta ?? listInstalledWithMeta,
215
+ existingMarketIds: ctx.deps?.existingMarketIds ?? existingMarketIds,
215
216
  installFromRegistry: ctx.deps?.installFromRegistry ?? installFromRegistry,
216
217
  uninstallPlugin: ctx.deps?.uninstallPlugin ?? uninstallPlugin,
217
218
  upgradePlugin: ctx.deps?.upgradePlugin ?? upgradePlugin,
@@ -323,10 +324,14 @@ export function createApiDispatcher(ctx) {
323
324
  // 0.9.11:不再透传 force——「强制刷新」语义是精选清单(registry 链),社区目录走自己的
324
325
  // TTL/共享 flight;此前 force 连坐会在弱网下把社区卡打成 3s 超时的「不可用」占位
325
326
  // (Windows 实机 2026-10-01:点强制刷新 → 社区卡「获取超时」,flight 后台自愈前一直报错)。
326
- const community = await d.getCommunitySummary(snap.loaded.registry.plugins, cfg(), {
327
+ // 0.9.87 FP3:getCommunitySummary 改道 mergedOutcome(L2)——registry 传 snap.loaded
328
+ // 整体(身份键需要 configuredAddress/fetchedAt);namespace 'host' + profile 缺省
329
+ // (WEB_PROFILE)与改道前缓存路径逐字节同路。
330
+ const community = await d.getCommunitySummary(snap.loaded, cfg(), {
327
331
  force: false,
328
332
  signal,
329
333
  deadlineAt: Date.now() + 3_000,
334
+ namespace: 'host',
330
335
  });
331
336
  payload = { plugins: snap.loaded.registry.plugins, registryState: snap.loaded, community };
332
337
  break;
@@ -421,6 +426,22 @@ export function createApiDispatcher(ctx) {
421
426
  payload = { ...result };
422
427
  break;
423
428
  }
429
+ case 'market-ids-exist': {
430
+ // 0.9.87 FP2:收藏 stale 检测批量化——一次批量 id 存在性查询取代逐收藏完整 market 扇出。
431
+ // 校验:非空字符串数组 ≤500(trim 后透传);违例 400(与 source/sort/probeMode 同款不静默吞)。
432
+ const rawIds = body.ids;
433
+ if (!Array.isArray(rawIds) || rawIds.length > 500 || rawIds.some((v) => typeof v !== 'string' || v.trim() === '')) {
434
+ throw new ApiProtocolError(400, '非法 ids: 需非空字符串数组(≤500)');
435
+ }
436
+ const ids = rawIds.map((v) => v.trim());
437
+ const result = await d.existingMarketIds(ids, cfg(), {
438
+ namespace: 'host',
439
+ profile: profile.name,
440
+ signal,
441
+ });
442
+ payload = { present: result.present };
443
+ break;
444
+ }
424
445
  case 'installed': {
425
446
  await ctx.controller.ensureReady();
426
447
  // 0.9.5 双 profile 读路径接线(实机回归 2026-10-01):desktop 下已装列表此前
@@ -146,41 +146,42 @@ function sanitizePkgJson(raw, fallbackName) {
146
146
  githubRepo: githubRepoFromRepository(raw.repository),
147
147
  };
148
148
  }
149
- /** 枚举 web profile 已安装插件(只读)。complete:false 时 items/others 为 partial 结果(已确认部分保留)。 */
150
- export async function listInstalledPlugins(profileDir = webProfileDir()) {
151
- const root = resolve(profileDir);
152
- const result = await readProfileDepsResult(root);
153
- let complete = result.complete;
154
- const deps = result.deps;
155
- const items = [];
156
- let others = 0;
157
- for (const pkg of Object.keys(deps).sort()) {
158
- const spec = deps[pkg];
159
- const dir = resolvePluginDir(root, pkg, spec);
160
- if (!dir) {
161
- // 依赖键不安全、目录无法解析 → 无法判断(不冒充非 DSH)
162
- complete = false;
163
- continue;
164
- }
165
- const rawResult = await readPkgJsonResult(dir);
166
- if (!rawResult.ok) {
167
- // package.json 缺失/不可读/坏 JSON/根非对象 → 无法判断
168
- complete = false;
169
- continue;
170
- }
171
- const raw = rawResult.value;
172
- if (!('dsh' in raw)) {
173
- // 确认非 DSH 依赖
174
- others += 1;
175
- continue;
176
- }
177
- const info = sanitizePkgJson(raw, pkg);
178
- // 包内补丁层行枚举(开关粒度判定原料):缺失 → readable:false(按多行 → Bundle 级处理)
179
- const patchText = await readTextLimited(join(dir, 'cordis.patch.yml'), PATCH_MAX_BYTES);
180
- const patchRows = patchText
181
- ? readBundlePatchRows(patchText.text)
182
- : { inserts: [], configRows: 0, readable: false };
183
- items.push({
149
+ async function classifyDep(root, pkg, spec, wantPatch) {
150
+ const dir = resolvePluginDir(root, pkg, spec);
151
+ if (!dir)
152
+ return 'unknown';
153
+ const rawResult = await readPkgJsonResult(dir);
154
+ if (!rawResult.ok)
155
+ return 'unknown';
156
+ const raw = rawResult.value;
157
+ if (!('dsh' in raw))
158
+ return 'other';
159
+ const info = sanitizePkgJson(raw, pkg);
160
+ // 包内补丁层行枚举(开关粒度判定原料):缺失 → readable:false(按多行 → Bundle 级处理)。
161
+ // wantPatch=false(市场查询热路径)省略 patchRows 字段——不伪造 readable:false(那是
162
+ // 「多行插件」的语义谎报);消费方(enablement/toggle,listInstalledWithMeta 路径)全程 ?. 链。
163
+ if (!wantPatch) {
164
+ return {
165
+ item: {
166
+ pkg,
167
+ name: info.name,
168
+ version: info.version,
169
+ description: info.description,
170
+ homepage: info.homepage,
171
+ spec,
172
+ source: parseSpecSource(spec),
173
+ dsh: true,
174
+ path: dir,
175
+ githubRepo: info.githubRepo,
176
+ },
177
+ };
178
+ }
179
+ const patchText = await readTextLimited(join(dir, 'cordis.patch.yml'), PATCH_MAX_BYTES);
180
+ const patchRows = patchText
181
+ ? readBundlePatchRows(patchText.text)
182
+ : { inserts: [], configRows: 0, readable: false };
183
+ return {
184
+ item: {
184
185
  pkg,
185
186
  name: info.name,
186
187
  version: info.version,
@@ -190,9 +191,39 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
190
191
  source: parseSpecSource(spec),
191
192
  dsh: true,
192
193
  path: dir,
193
- githubRepo: githubRepoFromRepository(raw.repository),
194
+ githubRepo: info.githubRepo,
194
195
  patchRows,
195
- });
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * 枚举 web profile 已安装插件(只读)。complete:false 时 items/others 为 partial 结果(已确认部分保留)。
201
+ * opts.patchRows=false:跳过每插件的 cordis.patch.yml 读取与 YAML 解析(热路径省 ~0.55ms/插件),
202
+ * 产出的 item 不带 patchRows 字段——仅供不需要开关粒度原料的调用方(市场查询)使用。
203
+ */
204
+ export async function listInstalledPlugins(profileDir = webProfileDir(), opts) {
205
+ const root = resolve(profileDir);
206
+ const result = await readProfileDepsResult(root);
207
+ let complete = result.complete;
208
+ const deps = result.deps;
209
+ const wantPatch = opts?.patchRows !== false;
210
+ // 每依赖文件读并发执行(原串行 for-await 是热路径上每次市场查询都重复支付的一半预算,
211
+ // 14 依赖实测 IO 串行 ~11ms → 并行 ~2.5ms);Promise.all 保序,输出仍按依赖键字典序。
212
+ const keys = Object.keys(deps).sort();
213
+ const classified = await Promise.all(keys.map((pkg) => classifyDep(root, pkg, deps[pkg], wantPatch)));
214
+ const items = [];
215
+ let others = 0;
216
+ for (const c of classified) {
217
+ if (c === 'unknown') {
218
+ // 依赖键不安全、目录无法解析、package.json 缺失/不可读/坏 JSON → 无法判断(不冒充非 DSH)
219
+ complete = false;
220
+ }
221
+ else if (c === 'other') {
222
+ others += 1;
223
+ }
224
+ else {
225
+ items.push(c.item);
226
+ }
196
227
  }
197
228
  return { items, others, complete, profileDir: root };
198
229
  }
@@ -22,7 +22,7 @@ import { fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './commun
22
22
  // 0.9.68(ADR-0016):合并层迁入 merged-market.ts(communityOutcome/mergeRegistries/communitySummary/
23
23
  // deadlineRace/matchInstalledByEntry/isCommunityEntry/COMMUNITY_CATEGORY_LABELS)——market↔community
24
24
  // import 环自此断开;re-export 保既有消费方(tools.ts 的类型、profile-ops 的 matchInstalledByEntry)零改动。
25
- import { communityOutcome, communityTimeoutSummary, withFallbackLookups, deadlineRace, isCommunityEntry, matchInstalledByEntry, mergedOutcome, } from './merged-market.js';
25
+ import { communityOutcome, communityTimeoutSummary, withFallbackLookups, deadlineRace, isCommunityEntry, matchInstalledByEntry, mergedOutcome, GITHUB_SPEC_RE, } from './merged-market.js';
26
26
  export { communityOutcome, matchInstalledByEntry } from './merged-market.js';
27
27
  import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
28
28
  import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
@@ -229,9 +229,52 @@ function sortEntries(entries, sort) {
229
229
  return (aa < ab ? -1 : aa > ab ? 1 : 0) * dirMul || nameCmp(a, b);
230
230
  });
231
231
  }
232
+ function buildInstalledIndex(installedItems) {
233
+ const byPkg = new Map();
234
+ const byName = new Map();
235
+ const byGithub = new Map();
236
+ installedItems.forEach((inst, ord) => {
237
+ const slot = { inst, ord };
238
+ if (!byPkg.has(inst.pkg))
239
+ byPkg.set(inst.pkg, slot);
240
+ if (!byName.has(inst.name))
241
+ byName.set(inst.name, slot);
242
+ if (inst.source === 'github') {
243
+ const m = GITHUB_SPEC_RE.exec(inst.spec);
244
+ if (m && !byGithub.has(m[1]))
245
+ byGithub.set(m[1], slot);
246
+ }
247
+ });
248
+ return { byPkg, byName, byGithub };
249
+ }
250
+ /** 三候选(byPkg[npm] / byName[npm] / byGithub[github],truthiness 守卫与旧谓词一致)取 ord 最小者胜。 */
251
+ function lookupInstalledByIndex(index, entry) {
252
+ const candidates = [];
253
+ if (entry.npm) {
254
+ const byPkg = index.byPkg.get(entry.npm);
255
+ if (byPkg)
256
+ candidates.push(byPkg);
257
+ const byName = index.byName.get(entry.npm);
258
+ if (byName)
259
+ candidates.push(byName);
260
+ }
261
+ if (entry.github) {
262
+ const byRepo = index.byGithub.get(entry.github);
263
+ if (byRepo)
264
+ candidates.push(byRepo);
265
+ }
266
+ if (candidates.length === 0)
267
+ return undefined;
268
+ let best = candidates[0];
269
+ for (let i = 1; i < candidates.length; i++) {
270
+ if (candidates[i].ord < best.ord)
271
+ best = candidates[i];
272
+ }
273
+ return best.inst;
274
+ }
232
275
  /** 合并条目 → MarketItem:社区条目带 community 标记与旁路字段。 */
233
- function toMarketItem(entry, installedItems) {
234
- const inst = matchInstalledByEntry(entry, installedItems);
276
+ function toMarketItem(entry, installedIndex) {
277
+ const inst = lookupInstalledByIndex(installedIndex, entry);
235
278
  const item = { ...entry, installed: Boolean(inst), outdated: false };
236
279
  if (inst) {
237
280
  item.installedPkg = inst.pkg;
@@ -289,7 +332,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
289
332
  const signal = opts.signal;
290
333
  const remaining = () => deadlineAt - Date.now();
291
334
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
292
- const installedTask = d.listInstalledPlugins(opts.profileDir).catch(() => null);
335
+ const installedTask = d.listInstalledPlugins(opts.profileDir, { patchRows: false }).catch(() => null);
293
336
  // 社区 flight 并发启动(source=primary 零调用;0.7.0 Task 7:primaryOnly 字段已删除);共享 loader 不接收调用者 deadline——
294
337
  // listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
295
338
  const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
@@ -393,7 +436,9 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
393
436
  let offset = normalizeOffset(opts.offset);
394
437
  if (total > 0 && offset >= total)
395
438
  offset = Math.floor((total - 1) / limit) * limit;
396
- const items = ordered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
439
+ // 0.9.68(ADR-0016 决策 5 同款思路,FP5 2026-10-15):已装索引每查询建一次,页内条目 O(1) 查表
440
+ const installedIndex = buildInstalledIndex(installedItems);
441
+ const items = ordered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedIndex));
397
442
  let latestComplete = true;
398
443
  let latestTimedOut = false;
399
444
  if (withLatest && items.length > 0) {
@@ -494,6 +539,64 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
494
539
  community: community.summary,
495
540
  };
496
541
  }
542
+ /**
543
+ * 批量 id 存在性(收藏 stale 检测专用,FP2):一次装载 registry+社区、经物化合并市场(L2,
544
+ * 同身份与 listMarket 共享代)对 **merged 域**(主清单 + 存活社区条目,不含 displaced——与
545
+ * 今日 source:'all' 搜索域一致)建原始 id Set,按输入序过滤返回。
546
+ *
547
+ * 三不原则(与 listMarket 的接口差异,也是性能来源):不枚举已装、不探测 latest、不分页。
548
+ *
549
+ * 语义对照(旧路径 = 逐收藏 `market {query:id, source:'all', limit:8, probeMode:'cache-only'}`
550
+ * + items.some(id)):除一处自觉差异外逐点一致——**curatedFirst 挤出边界**:旧搜索页路径在
551
+ * primary 域同字段全词命中 ≥limit 条时会把 id-exact 的社区条目挤出前 8(误判 stale),本接口
552
+ * 的 Set 判定在该边界判 live(=「已从目录消失」的正确语义),属修复而非回归,勿改回搜索页行为。
553
+ *
554
+ * 失败语义:registry 装载 reject → 原样抛出(host-api 统一 500 → 客户端 catch 空 Set 全 stale,
555
+ * 与今日 market 同路径等价);deadline 逃逸 → `{ present: [] }`(同今日超时空页 → 全 stale);
556
+ * 社区 unavailable/disabled → merged=primary-only → 社区收藏判 stale(同今日)。
557
+ */
558
+ export async function existingMarketIds(ids, cfg = {}, opts = {}, deps) {
559
+ const list = Array.isArray(ids) ? ids : [];
560
+ if (list.length === 0)
561
+ return { present: [] };
562
+ const d = {
563
+ loadRegistry: deps?.loadRegistry ?? defaultLoadRegistry,
564
+ fetchCommunityCatalog: deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog,
565
+ };
566
+ const deadlineMs = typeof opts.deadlineMs === 'number' && Number.isFinite(opts.deadlineMs) && opts.deadlineMs > 0
567
+ ? opts.deadlineMs
568
+ : DEFAULT_DEADLINE_MS;
569
+ const deadlineAt = Date.now() + deadlineMs;
570
+ const namespace = opts.namespace ?? 'host';
571
+ const signal = opts.signal;
572
+ const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
573
+ const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
574
+ let loaded;
575
+ try {
576
+ loaded = await deadlineRace(registryTask, deadlineAt - Date.now());
577
+ }
578
+ catch (err) {
579
+ void communityTask.catch(() => undefined);
580
+ throw err;
581
+ }
582
+ if (loaded === 'deadline') {
583
+ void communityTask.catch(() => undefined);
584
+ return { present: [] };
585
+ }
586
+ const community = await mergedOutcome({
587
+ namespace,
588
+ profile: opts.profile ?? WEB_PROFILE,
589
+ cfg,
590
+ registry: loaded,
591
+ communityTask,
592
+ deadlineAt,
593
+ });
594
+ const knownIds = new Set(community.merged.map((entry) => entry.id));
595
+ const present = list
596
+ .map((id) => (typeof id === 'string' ? id.trim() : ''))
597
+ .filter((id) => id !== '' && knownIds.has(id));
598
+ return { present };
599
+ }
497
600
  // ---------- 已装列表 ----------
498
601
  export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
499
602
  const d = { ...marketDeps(), ...deps };
@@ -54,7 +54,7 @@ export function matchInstalledByEntry(entry, installed) {
54
54
  if (entry.npm && it.name === entry.npm)
55
55
  return true;
56
56
  if (entry.github && it.source === 'github') {
57
- const m = /^github:([^#]+)/.exec(it.spec);
57
+ const m = GITHUB_SPEC_RE.exec(it.spec);
58
58
  if (m && m[1] === entry.github)
59
59
  return true;
60
60
  }
@@ -158,7 +158,8 @@ function buildMatchIndex(domain) {
158
158
  });
159
159
  return { byNpm, byGithub, byId };
160
160
  }
161
- const GITHUB_SPEC_RE = /^github:([^#]+)/;
161
+ /** github spec 的 owner/repo 提取(matchInstalledByEntry 与 market.ts 已装索引共用单一事实源;2026-10-15 导出)。 */
162
+ export const GITHUB_SPEC_RE = /^github:([^#]+)/;
162
163
  /** 三候选(npm[pkg] / npm[name] / github[spec](仅 source==='github'))取域序最前者胜——与 matchInstalledByEntry 逐点等价。 */
163
164
  function lookupInIndex(index, installed) {
164
165
  const candidates = [];