dsh-m 0.9.19 → 0.9.20

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,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.20 变更:latest 探测缓存退回纯内存(重启即失效)+ mutation 定向失效(ADR-0006)
12
+
13
+ - **事故复盘落地(2026-10-03 凌晨)**:0.9.14 起 latest 探测缓存 write-through 落盘、跨重启存活,失效通道只有 TTL 一条——00:22:58 缓存写入后,00:27–01:05 连发四版、01:07 重启 DSH、01:13 重开面板全部吃到陈旧值,卡片与 `dshm_outdated` 双双误报「已是最新」。本次推翻该设计,决策与取舍全文见 `docs/adr/0006-latest-cache-memory-only.md`。
14
+ - **缓存退回纯内存**:latest 探测结果只存内存 Map + TTL(`cacheTtlMin`,默认 60 分钟),**重启即失效**——「发完版重启一下就能看到」重新成立。代价是重启后首轮受限重探(当前页条目、8 并发 + deadline 兜底,TTL 内只付一次);registry/社区**目录正文**的落盘缓存与 SWR 不受影响,页面骨架照旧秒开。0.9.14 遗留的 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫。
15
+ - **mutation 定向失效**:install/upgrade/uninstall 事务**成功点**按 itemId 作废该条目全部 registryKey 缓存变体(浏览页键 / 已装页 matched 键 / npm-only 键)——升级后卡片不再出现「已装新版 / 最新旧版」自相矛盾;`dshm_outdated` 对刚升级包诚实。只失效不回写(故意装旧版时回写会伪造 latest=已装);事务回滚路径零失效;卸载对 github 源条目的 `gh:` 键尽力而为(pkg 名不可逆推 repo,交由 TTL 自然过期)。
16
+ - **不做项留痕**:force 穿透探测缓存与浏览页 GitHub 预算对齐经主人拍板暂缓——前者残余盲区仅「不重启 + TTL 内」窗口,后者对现行全 npm 源精选清单收益为零(ADR-0006 §Considered Options)。
17
+
11
18
  ### 0.9.19 变更:供应链等待期「锁文件校验」实证修复——委派前预检 + 全量违规解析 + 账实分裂明示
12
19
 
13
20
  - **根因(本机 2026-10-03 00:15 实证)**:pnpm 11.7 对 desktop profile 做**锁文件级**供应链校验(`Verifying lockfile against supply-chain policies (180 entries)`),而它给每次成功安装自动追加的 `minimumReleaseAgeExclude` **非 scoped 独立精确条目不被这次校验认可**(`dsh-m@0.9.18` 即被拒;scoped 的 `'@iasiv5/dsh-quota-watch@0.1.13'` 则认可)——装过一个「太新」版本后,**等待期内任何官方包操作都会被这个旁包条目拦死**,与本次目标无关;且校验失败前目标包已被写入 node_modules、官方管理器只回滚 manifest/lockfile → 界面显示新版 active、操作记录却是失败(**「账实分裂」**),下次包操作还会把插件静默回退。
@@ -223,6 +230,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
223
230
 
224
231
  ## English
225
232
 
233
+ ### Changed in 0.9.20 — latest probe cache back to memory-only (restart invalidates) + mutation-scoped invalidation (ADR-0006)
234
+
235
+ - **Post-mortem landed (night of 2026-10-03)**: since 0.9.14 the latest-version probe cache was written through to disk and survived restarts, with TTL as the only invalidation channel — after a cache write at 00:22:58, four releases (00:27–01:05), a DSH restart (01:07) and a panel reopen (01:13) all served the stale value; cards and `dshm_outdated` both wrongly reported "up to date". That design is overturned; the full decision record lives in `docs/adr/0006-latest-cache-memory-only.md`.
236
+ - **Memory-only cache**: probe results now live in an in-memory Map + TTL (`cacheTtlMin`, default 60) — **a DSH restart drops them**, so "publish, restart, see the new version" works again. The cost is one bounded re-probe wave on the first open after a restart (current page, 8 workers + deadline cap, paid once per TTL window); the registry/community **catalog body** disk cache and SWR are untouched, so the page skeleton still opens instantly. Leftover inert `latest/<ns>.json` envelopes from 0.9.14 are swept once, best-effort, before the probe segment.
237
+ - **Mutation-scoped invalidation**: on **success** of install/upgrade/uninstall, the entry's cached latest value is invalidated across all registryKey variants (browse key / installed matched key / npm-only key) by itemId — no more "installed new / latest old" self-contradicting cards; `dshm_outdated` is honest about freshly upgraded packages. Invalidate-only, no writeback (a deliberate old-version install would otherwise fabricate latest = installed); rollback paths never invalidate; uninstall invalidates npm keys on a best-effort basis (a `gh:` key cannot be reconstructed from a pkg name and expires via TTL).
238
+ - **Not done, on record**: force passthrough of the probe cache and browse-page GitHub budget alignment were deferred by the owner — the former's residual blind spot is only the "no restart, within TTL" window; the latter has zero benefit for today's all-npm curated list (ADR-0006 §Considered Options).
239
+
226
240
  ### Changed in 0.9.19 — supply-chain release-age: pre-delegation gate, full violation parsing, split-state disclosure
227
241
 
228
242
  - **Root cause (observed 2026-10-03 00:15 on this machine)**: pnpm 11.7 runs a **lockfile-wide** supply-chain verification on desktop installs (`Verifying lockfile against supply-chain policies (180 entries)`), and the standalone exact `minimumReleaseAgeExclude` entries it auto-appends after each successful install are **not honored by that verification when unscoped** (`dsh-m@0.9.18` flagged; scoped `'@iasiv5/dsh-quota-watch@0.1.13'` honored) — so one freshly installed version bricks every package operation for 24h, regardless of the current target. The target package gets written into node_modules before the verification fails while the official manager rolls back only manifest/lockfile → **"split state"**: the UI shows the new version active, the operation record says failed, and the next operation silently downgrades the plugin.
package/README.en.md CHANGED
@@ -119,7 +119,7 @@ When the registry is unavailable, `registry` / `search` / `outdated` print the c
119
119
  <p><sub>Community catalog · the default landing zone: zone tabs, persistent search, and counted open category chips</sub></p>
120
120
  </div>
121
121
 
122
- - **Fetch chain**: dist-tags probe → jsDelivr pinned fetch → npmmirror → unpkg fallback; unchanged versions are not re-fetched, probes are skipped within TTL. On failure it falls back to the runtime cache under `<cache dir>/awesome/` and **explicitly labels it "cached snapshot"** — stale data never masquerades as fresh.
122
+ - **Fetch chain**: dist-tags probe → jsDelivr pinned fetch → npmmirror → unpkg fallback; unchanged versions are not re-fetched, probes are skipped within TTL. Latest-version probes use a memory-only cache: it is dropped on every DSH restart (publish a new version, restart, and the new version shows up), and a plugin's cached entry is invalidated immediately after a successful install/upgrade/uninstall via dsh-m. On failure it falls back to the runtime cache under `<cache dir>/awesome/` and **explicitly labels it "cached snapshot"** — stale data never masquerades as fresh.
123
123
  - **Switch & pin**: `communityCatalog` toggle in Settings (default on, live), `communityCatalogPin` to lock the catalog version (exact semver); CLI opt-out with `DSHM_COMMUNITY_CATALOG=0`, pin with `DSHM_COMMUNITY_CATALOG_PIN`.
124
124
  - **Categories & search**: community categories are an open set (23 known categories with bilingual labels — Chinese maintained in-repo as the single source of truth, English from the upstream catalog, served per UI language); search runs a relevance-weighted pipeline (Chinese + English); the community zone offers a sort switch (downloads/stars/added × asc/desc, default downloads-desc; missing downloads ≠ zero downloads).
125
125
  - **Install semantics**: community entries go through the same install path as curated entries (npm exact version / GitHub pinned commit SHA); the browse page probes npm entries only — GitHub entries are not probed at page level (anonymous 60 req/h quota is uncontrollable).
package/README.md CHANGED
@@ -119,7 +119,7 @@ dshm restart --yes
119
119
  <p><sub>社区清单 · 默认落地分区:分区 tabs、常驻搜索与带计数的开放分类 chips</sub></p>
120
120
  </div>
121
121
 
122
- - **数据获取**:dist-tags 探测最新版本 → jsDelivr 按精确版本直取 → npmmirror → unpkg 三线路兜底;版本未变不重拉正文,TTL 内跳过探测。失败回落 `<缓存目录>/awesome/` 运行时缓存先展示(先回缓存、后台自愈)——过期数据绝不冒充最新;缓存状态(缓存快照/目录版本/获取线路/更新时间)在**设置页**可见,浏览页不打扰。
122
+ - **数据获取**:dist-tags 探测最新版本 → jsDelivr 按精确版本直取 → npmmirror → unpkg 三线路兜底;版本未变不重拉正文,TTL 内跳过探测。「最新版本」探测结果为纯内存缓存:**重启 DSH 即失效**(发版后重启即可看到新版本),经 dsh-m 安装/升级/卸载成功后该条目缓存立即作废。失败回落 `<缓存目录>/awesome/` 运行时缓存先展示(先回缓存、后台自愈)——过期数据绝不冒充最新;缓存状态(缓存快照/目录版本/获取线路/更新时间)在**设置页**可见,浏览页不打扰。
123
123
  - **开关与锁定**:设置页 `communityCatalog` 开关(默认开,live 生效)、`communityCatalogPin` 可锁定目录版本(精确 semver);CLI 用 `DSHM_COMMUNITY_CATALOG=0` 退出、`DSHM_COMMUNITY_CATALOG_PIN` 锁版本。
124
124
  - **分类与搜索**:社区分类是开放集(已知 23 个分类带中英双语标签:中文由包内单一事实源维护、英文取上游目录,按界面语言下发);搜索走相关性加权管线(中英文双匹配);社区区排序可切(下载量/Star/收录日期 × 升降,默认下载量降序,无下载量 ≠ 0 下载)。
125
125
  - **安装语义**:社区条目与精选条目走同一安装链路(npm 锁精确版本 / GitHub 锁 commit SHA);市场浏览页只对 npm 条目做更新探测,GitHub 条目不做页面级探测(匿名配额 60 次/小时不可控)。
package/docs/DESIGN.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
22
22
  - **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
23
23
  - **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
24
- - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存整体迁入 `latest-cache.ts`:内存 Map + `<cacheRoot>/latest/<ns>.json` 信封 write-through(`latest/` 在 nsDir 之外,规避 `pruneCaches` 顶层 `*.json` 清扫;懒 seed、损坏/版本不符整弃、24h 硬上限淘汰),跨服务重启存活。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
24
+ - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
25
25
  - **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
26
26
 
27
27
  ### 2.2 Schema(v1 定稿,严格校验)
@@ -1,23 +1,29 @@
1
1
  /**
2
- * 0.9.14 Task 4a:latest 探测缓存的唯一 owner——内存 Map + 磁盘信封层(write-through)。
3
- * 磁盘文件 `<cacheRoot(profile)>/latest/<namespace>.json`:置于 nsDir 之外——registry.ts
4
- * pruneCaches 只清扫 `<ns>/` 顶层 *.json(keep 集不含 latest),放 nsDir 内会被每次
5
- * registry 刷新成功后的 prune 删除。信封 { version, namespace, entries };损坏/版本不符
6
- * 整文件弃;全部磁盘操作静默失败(缓存是优化不是功能)。Task 4b 由 market.ts 接线
7
- * (探测段前 ensureLatestCacheSeeded + writeLatestCache write-through 迁移)。
2
+ * 0.9.20:latest 探测缓存的唯一 owner——**纯内存 Map + TTL**(ADR-0006)。
3
+ * 设计立场:缓存是优化不是功能;「重启即失效」是特性而非缺陷。0.9.14 的磁盘信封层
4
+ * (write-through 落盘 + 懒 seed,跨重启存活)在 2026-10-03 事故中被证伪为负债:
5
+ * 发版窗口内重启/重开面板全部吃到陈旧 latest(唯一失效通道只剩 TTL),卡片与
6
+ * `dshm_outdated` 双双误报「已是最新」。故磁盘层整体退役——重启变回可靠逃生门,
7
+ * 代价是重启后首轮受限重探(8 并发 + deadline 兜底,TTL 内只付一次)。
8
+ * 0.9.14 遗留的 `<cacheRoot>/latest/<ns>.json` 惰性文件由 ensureLatestCacheSwept
9
+ * 在探测段一次性 best-effort 清扫(每 (namespace,profile) 记忆化,失败静默)。
10
+ * mutation 协作(0.9.20 ①):install/upgrade/uninstall 成功后由 market.ts 调
11
+ * invalidateLatestCache 按 itemId 定向作废(只失效不回写:故意装旧版时回写会伪造
12
+ * latest=已装版本;registry 真值交给下一次探测)。
8
13
  */
9
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
14
+ import { rm } from 'node:fs/promises';
10
15
  import { join } from 'node:path';
11
16
  import { WEB_PROFILE, cacheRoot } from './env.js';
12
- export const LATEST_CACHE_VERSION = 1;
13
17
  const latestCache = new Map();
14
18
  const LATEST_CACHE_MAX = 5000;
15
- /** seed 硬上限淘汰窗口:超过 24h 的条目不再灌内存(write/seed 双侧一致) */
16
- const SEED_MAX_AGE_MS = 24 * 60 * 60_000;
19
+ /** 缓存键第三段(itemId):浏览页键 / 已装页 matched 键 / npm-only 键共用同一尾段,定向失效按它匹配。 */
20
+ export function latestItemId(item) {
21
+ return item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
22
+ }
17
23
  export function latestCacheKey(namespace, registryKey, item) {
18
- const id = item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
19
- return `${namespace}|${registryKey}|${id}`;
24
+ return `${namespace}|${registryKey}|${latestItemId(item)}`;
20
25
  }
26
+ /** TTL 过期即从内存删除条目并返回 null(过期值不复活、不外泄;不得以 ttlMin=0 当「force 穿透」用——会先删旧值)。 */
21
27
  export function readLatestCache(key, ttlMin) {
22
28
  const entry = latestCache.get(key);
23
29
  if (!entry)
@@ -28,112 +34,48 @@ export function readLatestCache(key, ttlMin) {
28
34
  }
29
35
  return entry.value;
30
36
  }
31
- function latestDir(profile) {
32
- return join(cacheRoot(profile), 'latest');
33
- }
34
- function latestFile(namespace, profile) {
35
- return join(latestDir(profile), `${namespace}.json`);
36
- }
37
- let persistChain = Promise.resolve();
38
- /** 入队时快照单条(key+entry):flush 晚于内存 reset 也不丢数据;串行链防并发读改写互相覆盖。 */
39
- function queuePersist(key, namespace, profile, entry) {
40
- persistChain = persistChain.then(() => persistEnvelope(key, namespace, profile, entry)).catch(() => undefined);
41
- }
42
- async function persistEnvelope(key, namespace, profile, entry) {
43
- let entries = {};
44
- try {
45
- const parsed = JSON.parse(await readFile(latestFile(namespace, profile), 'utf8'));
46
- if (parsed && typeof parsed === 'object' &&
47
- parsed.version === LATEST_CACHE_VERSION && parsed.namespace === namespace &&
48
- parsed.entries && typeof parsed.entries === 'object') {
49
- entries = parsed.entries;
50
- }
51
- }
52
- catch {
53
- // 无/损坏信封 → 从本条重建(静默)
54
- }
55
- const floor = Date.now() - SEED_MAX_AGE_MS;
56
- for (const k of Object.keys(entries)) {
57
- const e = entries[k];
58
- if (!e || typeof e !== 'object' || typeof e.at !== 'number' || !Number.isFinite(e.at) || e.at < floor)
59
- delete entries[k];
60
- }
61
- entries[key] = entry;
62
- const envelope = { version: LATEST_CACHE_VERSION, namespace, entries };
63
- await mkdir(latestDir(profile), { recursive: true });
64
- const file = latestFile(namespace, profile);
65
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
66
- await writeFile(tmp, JSON.stringify(envelope));
67
- await rename(tmp, file);
68
- }
69
- /** 内存 set + write-through:ns 自 key 首段 `|` 前缀解析定位 `<ns>.json` 信封;profile 缺省 web(0.9.0 双 profile 段约定,调用方可显式传)。 */
70
- export function writeLatestCache(key, value, profile = WEB_PROFILE) {
37
+ /** 内存 set(超上限淘汰最旧);纯内存,无任何落盘。 */
38
+ export function writeLatestCache(key, value) {
71
39
  if (latestCache.size >= LATEST_CACHE_MAX) {
72
40
  const oldest = latestCache.keys().next().value;
73
41
  if (oldest !== undefined)
74
42
  latestCache.delete(oldest);
75
43
  }
76
44
  latestCache.set(key, { at: Date.now(), value });
77
- const ns = key.split('|', 1)[0];
78
- if (ns !== 'host' && ns !== 'cli')
79
- return;
80
- const entry = latestCache.get(key);
81
- if (!entry)
82
- return;
83
- queuePersist(key, ns, profile, entry);
84
45
  }
85
- // ---------- 懒 seed(每 (namespace,profile) 记忆化一次) ----------
86
- const seeded = new Map();
87
- export async function ensureLatestCacheSeeded(opts = {}) {
46
+ /**
47
+ * 0.9.20 ①:mutation 成功后的定向失效——按 itemId 尾段作废该条目在指定 namespace 下的
48
+ * 全部 registryKey 变体(浏览页键、已装页 matched 键、npm-only 键共用同一 itemId)。
49
+ * 返回删除条数(测试断言用)。
50
+ */
51
+ export function invalidateLatestCache(namespace, itemId) {
52
+ const prefix = `${namespace}|`;
53
+ const suffix = `|${itemId}`;
54
+ let removed = 0;
55
+ for (const key of [...latestCache.keys()]) {
56
+ if (key.startsWith(prefix) && key.endsWith(suffix)) {
57
+ latestCache.delete(key);
58
+ removed += 1;
59
+ }
60
+ }
61
+ return removed;
62
+ }
63
+ // ---------- 0.9.14 遗留磁盘信封的一次性清扫(ADR-0006) ----------
64
+ const swept = new Map();
65
+ /** 探测段前调用:删除遗留 `<cacheRoot>/latest/<ns>.json`(退役后的惰性文件,删失败静默);每 (namespace,profile) 记忆化一次。 */
66
+ export async function ensureLatestCacheSwept(opts = {}) {
88
67
  const namespace = opts.namespace ?? 'host';
89
68
  const profile = opts.profile ?? WEB_PROFILE;
90
69
  const memoKey = `${namespace}§${profile}`;
91
- let task = seeded.get(memoKey);
70
+ let task = swept.get(memoKey);
92
71
  if (!task) {
93
- task = (async () => {
94
- await persistChain.catch(() => undefined); // 先排空 write-through 队列(写→reset→seed 顺序亦确定成立)
95
- await seedFromDisk(namespace, profile);
96
- })().catch(() => undefined);
97
- seeded.set(memoKey, task);
72
+ task = rm(join(cacheRoot(profile), 'latest', `${namespace}.json`), { force: true }).catch(() => undefined);
73
+ swept.set(memoKey, task);
98
74
  }
99
75
  await task;
100
76
  }
101
- async function seedFromDisk(namespace, profile) {
102
- let raw;
103
- try {
104
- raw = await readFile(latestFile(namespace, profile), 'utf8');
105
- }
106
- catch {
107
- return; // 无信封 = 首次运行;静默
108
- }
109
- let envelope;
110
- try {
111
- const parsed = JSON.parse(raw);
112
- if (!parsed || typeof parsed !== 'object')
113
- return;
114
- if (parsed.version !== LATEST_CACHE_VERSION || parsed.namespace !== namespace)
115
- return;
116
- if (!parsed.entries || typeof parsed.entries !== 'object')
117
- return;
118
- envelope = parsed;
119
- }
120
- catch {
121
- return; // 损坏整文件弃(静默,行为退化为现状探测重放)
122
- }
123
- const floor = Date.now() - SEED_MAX_AGE_MS;
124
- for (const [key, entry] of Object.entries(envelope.entries)) {
125
- if (!entry || typeof entry !== 'object')
126
- continue;
127
- if (typeof entry.at !== 'number' || !Number.isFinite(entry.at) || entry.at < floor)
128
- continue;
129
- if (!entry.value || typeof entry.value !== 'object')
130
- continue;
131
- if (!latestCache.has(key))
132
- latestCache.set(key, entry);
133
- }
134
- }
135
- /** 测试钩子:清内存 Map + seed 记忆化(dsh-version.ts resetDshVersionCacheForTest 同款先例)。 */
77
+ /** 测试钩子:清内存 Map + 清扫记忆化(dsh-version.ts resetDshVersionCacheForTest 同款先例)。 */
136
78
  export function resetLatestCacheForTest() {
137
79
  latestCache.clear();
138
- seeded.clear();
80
+ swept.clear();
139
81
  }
@@ -23,7 +23,7 @@ import { adaptCommunityCatalog } from './community-adapter.js';
23
23
  import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
24
24
  import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
25
25
  import { HttpError } from './httpx.js';
26
- import { ensureLatestCacheSeeded, latestCacheKey, readLatestCache, writeLatestCache } from './latest-cache.js';
26
+ import { ensureLatestCacheSwept, invalidateLatestCache, latestCacheKey, latestItemId, readLatestCache, writeLatestCache } from './latest-cache.js';
27
27
  import { verifyInstalledAdditions } from './install-guard.js';
28
28
  import { fetchTextLimited } from './httpx.js';
29
29
  import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
@@ -139,6 +139,13 @@ function deadlineRace(task, ms) {
139
139
  });
140
140
  });
141
141
  }
142
+ // ---------- latest TTL cache ----------
143
+ // 0.9.20:纯内存 TTL 缓存(ADR-0006,推翻 0.9.14 的落盘跨重启存活——重启即失效是特性);
144
+ // mutation 成功后的定向失效走 invalidateLatestForEntry(只失效不回写)。
145
+ /** 0.9.20 ①:install/upgrade/uninstall 成功点调用的定向失效——registry 真值交给下一次探测。 */
146
+ function invalidateLatestForEntry(namespace, entry) {
147
+ invalidateLatestCache(namespace ?? 'host', latestItemId(entry));
148
+ }
142
149
  function probeTask(item, deps, timeoutMs, signal) {
143
150
  if (item.source === 'npm' && item.npm) {
144
151
  return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
@@ -509,8 +516,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
509
516
  let latestComplete = true;
510
517
  let latestTimedOut = false;
511
518
  if (withLatest && items.length > 0) {
512
- // 0.9.14:latest 探测缓存已落盘——探测段前装载/排空磁盘信封(重启后零重放)
513
- await ensureLatestCacheSeeded({ namespace, profile: opts.profile });
519
+ // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
520
+ await ensureLatestCacheSwept({ namespace, profile: opts.profile });
514
521
  // 先吃 cache 命中
515
522
  const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
516
523
  for (const item of items) {
@@ -540,7 +547,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
540
547
  const outcome = await probeWithBudget(task, perBudget, signal);
541
548
  if (outcome.ok && outcome.value) {
542
549
  applyProbe(item, outcome.value);
543
- writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value, opts.profile);
550
+ writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
544
551
  }
545
552
  else if (outcome.timeout) {
546
553
  item.latestError = '更新检查未完成:超时';
@@ -629,9 +636,9 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
629
636
  }
630
637
  const registryState = stateOf(loaded);
631
638
  matchInstalled(items, community.merged);
632
- // 0.9.14:latest 探测缓存已落盘——探测段前装载/排空磁盘信封
633
- await ensureLatestCacheSeeded({ namespace, profile: opts.profile });
634
- await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000, profile: opts.profile });
639
+ // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
640
+ await ensureLatestCacheSwept({ namespace, profile: opts.profile });
641
+ await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
635
642
  return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
636
643
  }
637
644
  /** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
@@ -685,7 +692,7 @@ async function probeLatest(items, ctx, d, rt) {
685
692
  }
686
693
  if (value) {
687
694
  applyProbe(item, value);
688
- writeLatestCache(cacheKey, value, rt.profile);
695
+ writeLatestCache(cacheKey, value);
689
696
  }
690
697
  }
691
698
  catch (err) {
@@ -1087,6 +1094,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1087
1094
  ...(prior.kind === 'dependency' ? { priorPkgs: [pkg] } : {}),
1088
1095
  });
1089
1096
  if (guard.unavailable.length > 0) {
1097
+ // 0.9.20 ①:事务已 committed(守卫不可定 fail-open 亦是成功态)→ 定向作废该条目 latest 缓存
1098
+ invalidateLatestForEntry(opts.namespace, entry);
1090
1099
  return {
1091
1100
  id: entry.id,
1092
1101
  pkg: result.pkg ?? pkg,
@@ -1118,6 +1127,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1118
1127
  });
1119
1128
  }
1120
1129
  const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
1130
+ // 0.9.20 ①:安装成功 → 定向作废该条目 latest 缓存(只失效不回写,ADR-0006)
1131
+ invalidateLatestForEntry(opts.namespace, entry);
1121
1132
  return {
1122
1133
  id: entry.id,
1123
1134
  pkg: result.pkg ?? pkg,
@@ -1192,6 +1203,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1192
1203
  ...(prior.kind === 'dependency' ? { priorPkgs: [realKey] } : {}),
1193
1204
  });
1194
1205
  if (guard.unavailable.length > 0) {
1206
+ // 0.9.20 ①:事务已 committed(守卫不可定 fail-open 亦是成功态)→ 定向作废该条目 latest 缓存
1207
+ invalidateLatestForEntry(opts.namespace, entry);
1195
1208
  const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
1196
1209
  return {
1197
1210
  id: entry.id,
@@ -1222,6 +1235,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1222
1235
  });
1223
1236
  }
1224
1237
  const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
1238
+ // 0.9.20 ①:安装成功 → 定向作废该条目 latest 缓存(只失效不回写,ADR-0006)
1239
+ invalidateLatestForEntry(opts.namespace, entry);
1225
1240
  return {
1226
1241
  id: entry.id,
1227
1242
  pkg: realKey,
@@ -1251,6 +1266,9 @@ async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
1251
1266
  const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
1252
1267
  if (!result.ok)
1253
1268
  throw new TransactionError(result);
1269
+ // 0.9.20 ①:卸载成功 → 尽力作废 npm-only 键(github 条目的 gh: 键无法由 pkg 名重构,
1270
+ // 交由 TTL 自然过期——卸载后已装列表无此条目,不存在「已装/最新」自相矛盾场景)
1271
+ invalidateLatestCache(opts.namespace ?? 'host', `npm:${pkg}`);
1254
1272
  const orphaned = result.orphanedPatchFiles ?? [];
1255
1273
  const leftovers = [...new Set([...leftoverCandidates(pkg), ...orphaned])];
1256
1274
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",