@oj-module/cli 0.2.66 → 0.2.67

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 (187) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +30 -1
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +38 -12
  7. package/devkit/publish-walkthrough.md +110 -37
  8. package/devkit/wecom-internal-app.md +7 -2
  9. package/devkit/wecom-multi-corp-links.md +163 -0
  10. package/package.json +2 -2
  11. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  12. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
  13. package/shell-dist/index.html +4 -4
  14. package/shell-dist/versions.json +3 -3
  15. package/src/__tests__/publish-keygen.test.ts +157 -0
  16. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  19. package/src/bundle.ts +8 -3
  20. package/src/cli.ts +17 -5
  21. package/src/dev.ts +15 -10
  22. package/src/install.ts +11 -4
  23. package/src/keygen.ts +54 -0
  24. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  25. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  26. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  27. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  28. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  29. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  30. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  31. package/src/manifest-contract.ts +91 -8
  32. package/src/market/bootstrap.ts +26 -2
  33. package/src/market/command.ts +1 -0
  34. package/src/market/publish.ts +5 -1
  35. package/src/market/walkthrough.ts +24 -8
  36. package/src/site.ts +2 -0
  37. package/src/usage.ts +8 -4
  38. package/src/vendor.ts +5 -1
  39. package/templates/config.yaml +4 -2
  40. package/templates/init.include.yaml +8 -3
  41. package/templates/openapi.json +460 -1
  42. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  43. package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
  44. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  45. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  46. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  47. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  48. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  49. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  50. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  51. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  52. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  53. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  54. package/templates/src/app/api/_platform/market/api.ts +235 -10
  55. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  56. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  57. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  58. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  59. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  60. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  61. package/templates/src/app/api/_platform/schema.yaml +18 -0
  62. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  63. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  64. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  65. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  66. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  67. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  68. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  69. package/templates/src/app/api/home/seed.pg.sql +71 -0
  70. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  71. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  72. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  73. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  74. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  75. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  76. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  77. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  78. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  79. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  80. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  81. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  82. package/templates/src/app/api/publish/api.ts +349 -0
  83. package/templates/src/app/api/publish/manifest.yaml +8 -0
  84. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  85. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  86. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  87. package/templates/src/app/api/publish/schema.yaml +33 -0
  88. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  90. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  91. package/templates/src/app/web/entry.ts +9 -5
  92. package/templates/src/app/web/home/routes.ts +5 -0
  93. package/templates/src/app/web/login/locales/en-US.json +7 -1
  94. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  95. package/templates/src/app/web/login/pages/login.tsx +79 -86
  96. package/templates/src/app/web/login/routes.ts +54 -1
  97. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  98. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  99. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  100. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  101. package/templates/src/app/web/system/locales/en-US.json +1 -8
  102. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  103. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  104. package/templates/src/app/web/system/routes.ts +0 -6
  105. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  106. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  107. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  108. package/templates/src/oj-market/web/api/client.ts +3 -0
  109. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  110. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  111. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  112. package/templates/src/oj-market/web/entry.ts +31 -2
  113. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  114. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  115. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  116. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  117. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  118. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  119. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  120. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  121. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  122. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  123. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  124. package/templates/src/oj-market/web/types.ts +30 -0
  125. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  126. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  127. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  128. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  129. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  130. package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
  131. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  132. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  133. package/templates/tests/publish-credential.spec.ts +354 -0
  134. package/templates/tests/publish-identity.spec.ts +44 -0
  135. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  136. package/templates/tests/wecom-landing.spec.ts +63 -1
  137. package/templates/tsconfig.paths.json +1 -0
  138. package/templates/web.config.ts +6 -4
  139. package/vendor/host-versions.json +3 -3
  140. package/templates/seed.mysql.sql +0 -6
  141. package/templates/seed.pg.sql +0 -6
  142. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  143. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  144. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  145. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  146. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  147. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  148. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  149. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  150. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  151. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  152. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  153. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  154. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  155. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  156. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  157. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  158. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  159. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  160. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  161. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  162. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  163. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  164. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  165. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  166. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  168. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  169. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  170. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  171. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  172. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  173. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  174. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  175. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  176. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  177. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  178. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  179. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  180. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  181. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  182. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  183. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  184. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  185. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  186. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  187. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -0,0 +1,207 @@
1
+ // BDD:安装补偿清单(P0 §3.5)。锁后失败把站点恢复到「装前代」——旧代继续服务、任务 partial+可重试。
2
+ // 直接驱动 compensate()(不重跑整个状态机),配真实内存库夹具(installDb)+ 真实临时文件系统 +
3
+ // stub reload 面,覆盖:完整回滚、幂等、部分失败仍尽力回滚、匿名面不随失败放大(验收 4)。
4
+ //
5
+ // 资产回滚原语:removeAnonymousPaths(text-edit)/ revertLock(lock)/ removeModulesJsonEntry
6
+ // (deploy-web)/ revertRegistrations(uninstall,复用卸载事务)。各原语幂等(缺则 no-op),
7
+ // 故补偿可安全被重试端点反复调用。
8
+
9
+ import * as fsp from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { atomicWriteText } from "../../src/app/api/_platform/_lifecycle/atomic";
14
+ import { compensate } from "../../src/app/api/_platform/_lifecycle/compensate";
15
+ import { parseLock, writeLock } from "../../src/app/api/_platform/_lifecycle/lock";
16
+ import { appendAnonymousPaths, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
17
+ import { installDb } from "../fixture";
18
+
19
+ const g = globalThis as any;
20
+
21
+ const BASE_CONFIG = "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n - /system/login-methods\n";
22
+
23
+ function nodeFsFace() {
24
+ return {
25
+ writeFile: (p: string, d: any) => fsp.writeFile(p, d),
26
+ readFile: (p: string) => fsp.readFile(p),
27
+ mkdir: (p: string, o: any) => fsp.mkdir(p, o),
28
+ remove: (p: string, o: any) => fsp.rm(p, o),
29
+ rename: (o: string, n: string) => fsp.rename(o, n),
30
+ copyFile: (s: string, d: string) => fsp.copyFile(s, d),
31
+ readdir: (p: string) => fsp.readdir(p),
32
+ };
33
+ }
34
+
35
+ let root: string;
36
+ let configPath: string;
37
+ let previewPath: string;
38
+ let sharedRoot: string;
39
+ let lockPath: string;
40
+ let modulesJsonPath: string;
41
+ let dbo: any;
42
+ let reloadCalls: number;
43
+ let reloadThrow: boolean;
44
+
45
+ const LOCK_BEFORE: Record<string, string> = { existing: "1.0.0" };
46
+
47
+ /** 把站点摆成「demo-solution 已锁后落位」状态,返回本次新增的匿名路径(= 补偿的输入 deferredAnonymous) */
48
+ async function seedInstalled(anonPaths: string[]): Promise<string[]> {
49
+ const added = appendAnonymousPaths(BASE_CONFIG, anonPaths);
50
+ await atomicWriteText(configPath, added.content);
51
+ await atomicWriteText(previewPath, added.content);
52
+ // 锁:装前快照 + 本次模块条目
53
+ await writeLock(lockPath, { ...LOCK_BEFORE, "demo-solution": "0.1.0" });
54
+ // modules.json:含本次模块条目
55
+ await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
56
+ // DB:路由/角色/权限各一行为 demo-solution(registering 段已落位)
57
+ await g.db.table("sys_solution_route").insert({ path: "/demo-solution", module: "demo-solution", category: "main" }).run();
58
+ await g.db.table("sys_role").insert({ name: "demo-solution.admin", module: "demo-solution" }).run();
59
+ await g.db.table("sys_permission").insert({ code: "demo-solution.view", module: "demo-solution" }).run();
60
+ return added.added;
61
+ }
62
+
63
+ function countAnon(cfg: string): number {
64
+ const lines = cfg.split("\n");
65
+ const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
66
+ if (idx === -1)
67
+ return 0;
68
+ let n = 0;
69
+ for (let i = idx + 1; i < lines.length; i++) {
70
+ const line = lines[i]!;
71
+ if (line.trim() === "" || /^\s*#/.test(line))
72
+ continue;
73
+ if (!line.replace(/#.*$/, "").trim().startsWith("- "))
74
+ break;
75
+ n++;
76
+ }
77
+ return n;
78
+ }
79
+
80
+ beforeEach(async () => {
81
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-compensate-"));
82
+ configPath = join(root, "config.yaml");
83
+ previewPath = join(root, ".ojm-preview.yaml");
84
+ sharedRoot = join(root, "modules");
85
+ lockPath = join(sharedRoot, "manifests.yaml");
86
+ modulesJsonPath = join(root, "dist", "modules.json");
87
+ await fsp.mkdir(sharedRoot, { recursive: true });
88
+ await fsp.mkdir(join(root, "dist"), { recursive: true });
89
+ g.fs = nodeFsFace();
90
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
91
+ g.http = {};
92
+ dbo = installDb();
93
+ reloadCalls = 0;
94
+ reloadThrow = false;
95
+ });
96
+
97
+ afterEach(async () => {
98
+ g.fs = undefined;
99
+ g.http = undefined;
100
+ g.db = undefined;
101
+ await fsp.rm(root, { recursive: true, force: true });
102
+ });
103
+
104
+ function makeInput(deferredAnonymous: string[]): any {
105
+ return {
106
+ name: "demo-solution",
107
+ version: "0.1.0",
108
+ configPath,
109
+ deferredAnonymous,
110
+ sharedRoot,
111
+ modulesJsonPath,
112
+ lockBefore: LOCK_BEFORE,
113
+ lockExisted: true,
114
+ reload: {
115
+ reloadConfig: async () => {
116
+ reloadCalls++;
117
+ if (reloadThrow)
118
+ throw new Error("reload 模拟失败");
119
+ return { generation: 2, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] };
120
+ },
121
+ },
122
+ };
123
+ }
124
+
125
+ describe("compensate 完整回滚到装前代", () => {
126
+ it("锁/config匿名/modules.json/DB 全部回退,旧代仍服务(reload 已调用)", async () => {
127
+ const added = await seedInstalled(["demo-solution/ping", "demo-solution/health"]);
128
+ const r = await compensate(makeInput(added));
129
+
130
+ expect(r.allOk).toBe(true);
131
+ expect(r.steps.map(s => s.step)).toEqual(["config.anonymous", "lock", "modules.json", "db.registrations", "reload"]);
132
+
133
+ // 锁回到装前快照(无 demo-solution)
134
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
135
+ // config:新增匿名条目被精确移除,基线两条保留;seq 不被回滚(单调无害)
136
+ const cfg = await fsp.readFile(configPath, "utf8");
137
+ expect(cfg).toContain("- /auth/login");
138
+ expect(cfg).toContain("- /system/login-methods");
139
+ expect(cfg).not.toContain("demo-solution/ping");
140
+ expect(cfg).not.toContain("demo-solution/health");
141
+ expect(countAnon(cfg)).toBe(2);
142
+ // preview 派生同样被清理
143
+ const prev = await fsp.readFile(previewPath, "utf8");
144
+ expect(prev).not.toContain("demo-solution/ping");
145
+ // modules.json 无 demo-solution 条目
146
+ const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
147
+ expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
148
+ // DB 注册被级联清掉
149
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
150
+ expect(dbo.prepare("SELECT * FROM sys_role WHERE module='demo-solution'").all()).toHaveLength(0);
151
+ expect(dbo.prepare("SELECT * FROM sys_permission WHERE module='demo-solution'").all()).toHaveLength(0);
152
+ // reload 被调用一次(活动代回到装前态)
153
+ expect(reloadCalls).toBe(1);
154
+ });
155
+ });
156
+
157
+ describe("compensate 幂等(可重试)", () => {
158
+ it("连续两次补偿:第二次全 no-op,终态不变,reload 仍各调用一次", async () => {
159
+ const added = await seedInstalled(["demo-solution/ping"]);
160
+ const first = await compensate(makeInput(added));
161
+ const second = await compensate(makeInput(added));
162
+ expect(first.allOk).toBe(true);
163
+ expect(second.allOk).toBe(true);
164
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
165
+ expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
166
+ const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
167
+ expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
168
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
169
+ expect(reloadCalls).toBe(2); // 两次各触发一次 reload(幂等但不跳过 reload)
170
+ });
171
+ });
172
+
173
+ describe("compensate 部分失败仍尽力回滚", () => {
174
+ it("reload 步抛错 → 该步标记失败、其余四步仍生效、allOk=false", async () => {
175
+ const added = await seedInstalled(["demo-solution/ping"]);
176
+ reloadThrow = true;
177
+ const r = await compensate(makeInput(added));
178
+
179
+ expect(r.allOk).toBe(false);
180
+ const reloadStep = r.steps.find(s => s.step === "reload")!;
181
+ expect(reloadStep.ok).toBe(false);
182
+ // 其余步成功
183
+ for (const s of r.steps.filter(s => s.step !== "reload")) {
184
+ expect(s.ok).toBe(true);
185
+ }
186
+ // 即便 reload 失败,已落位资产仍被回退
187
+ expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
188
+ expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
189
+ expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
190
+ });
191
+ });
192
+
193
+ describe("compensate 匿名面不随失败放大(验收 4)", () => {
194
+ it("同一方案连续两次失败安装:config 匿名条目数不增长", async () => {
195
+ // 第一次失败:装前基线 2 条,新增 1 条后补偿
196
+ const added = await seedInstalled(["demo-solution/ping"]);
197
+ await compensate(makeInput(added));
198
+ const afterFirst = await fsp.readFile(configPath, "utf8");
199
+ expect(countAnon(afterFirst)).toBe(2); // 回到基线
200
+
201
+ // 第二次失败:再次以同样 deferredAnonymous 补偿(模拟又失败一次)
202
+ await compensate(makeInput(added));
203
+ const afterSecond = await fsp.readFile(configPath, "utf8");
204
+ expect(countAnon(afterSecond)).toBe(2); // 不增长
205
+ expect(removeAnonymousPaths(afterSecond, added).removed).toEqual([]); // 已无残留可删
206
+ });
207
+ });
@@ -1,7 +1,7 @@
1
1
  // BDD:部署/热更/冒烟三步(P1-9,十阶段 stage 8~10)。
2
2
  // deploy-web = staging/web 产物树真复制到站点 modules 目录 + modules.json 读-改-原子写 upsert
3
3
  // (D13:读者永不见半截 JSON;R30:entry/css 路径 POSIX 分隔符序列化;同名整体替换、他条目原样保留);
4
- // reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 改写未生效判失败(R18 终态断言),
4
+ // reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 安装幂等成功 / 卸载恒严格(R18 修订——加载靠锁 diff 触发,不再依赖 vars 哨兵),
5
5
  // expected 段键(缺省 ["vars"],B-11 reload 触发面)须逐一 ∈ applied;
6
6
  // smoke = 探活(health 2xx / 缺省首个非匿名路由连通即可)+ integrity(部署落位后重算 sha384 vs
7
7
  // modules.json 条目,R10 包内容口径;缺失 fail-closed)+ 注册一致性(升级态「包含非相等」:
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
21
  import { deployWeb, posixPath, upsertModulesJson } from "../../src/app/api/_platform/_lifecycle/steps/deploy-web";
22
- import { reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
22
+ import { assertReloadApplied, reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
23
23
  import { probeUrlOf, smoke } from "../../src/app/api/_platform/_lifecycle/steps/smoke";
24
24
 
25
25
  const g = globalThis as any;
@@ -260,31 +260,42 @@ describe("deployWeb(stage=deploying:staging/web 树 → 站点 modules 目
260
260
  });
261
261
  });
262
262
 
263
- describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
263
+ describe("reloadAndAssert(stage=reloading:R18 修订——安装 unchanged=幂等成功且默认不强制模块符号;卸载 unchanged/缺 -name@ver=严格失败;force+unchanged=失败)", () => {
264
264
  function rlCtx(result?: ReloadResult, throws = false): ReloadContext {
265
265
  const face: ReloadFace = {
266
266
  reloadConfig: throws
267
267
  ? async () => {
268
268
  throw new Error("server.reloadConfig: reload in progress (busy)");
269
269
  }
270
- : async () => result ?? { generation: 8, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] },
270
+ : async () => result ?? { generation: 8, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: [], kept_listener: [] },
271
271
  };
272
272
  return { manifest: solManifest(), name: "demo-solution", version: "0.1.0", state: stateStore(), reload: face };
273
273
  }
274
274
 
275
- it("成功:applied 含 vars(缺省 expected)→ ok=true,message 含 generation", async () => {
275
+ it("成功:applied 含 +name@ver(api 模块装配)→ ok=true,message 含 generation", async () => {
276
276
  const r = await reloadAndAssert(rlCtx());
277
277
  expect(r.stage).toBe("reloading");
278
278
  expect(r.ok).toBe(true);
279
279
  expect(r.message).toContain("8");
280
280
  });
281
281
 
282
- it("unchanged → ok=false(R18:幂等回显 = config 改写未生效)", async () => {
282
+ it("unchanged → ok=true(R18 修订:安装幂等成功 = 已是最新装配,不再视为失败)", async () => {
283
283
  const r = await reloadAndAssert(rlCtx({ generation: 7, unchanged: true, applied: [] }));
284
+ expect(r.ok).toBe(true);
285
+ });
286
+
287
+ it("force + unchanged → ok=false(--force 要求强制重装配却无变更可重做)", async () => {
288
+ const r = await reloadAndAssert({ ...rlCtx({ generation: 7, unchanged: true, applied: [] }), force: true });
284
289
  expect(r.ok).toBe(false);
285
290
  expect(r.message).toContain("unchanged");
286
291
  });
287
292
 
293
+ it("卸载 unchanged → ok=false(锁未摘除 = 卸载空转,恒严格)", async () => {
294
+ const r = assertReloadApplied({ generation: 7, unchanged: true, applied: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
295
+ expect(r.ok).toBe(false);
296
+ expect(r.message).toContain("卸载空转");
297
+ });
298
+
288
299
  it("face 抛错(并发 busy)→ ok=false retryable=true", async () => {
289
300
  const r = await reloadAndAssert(rlCtx(undefined, true));
290
301
  expect(r.ok).toBe(false);
@@ -292,10 +303,15 @@ describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
292
303
  expect(r.message).toContain("busy");
293
304
  });
294
305
 
295
- it("expected 段键未生效:applied 缺 vars → ok=false(R18 以实际生效值断言)", async () => {
296
- const r = await reloadAndAssert({ ...rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }), expected: ["mounts", "vars"] });
306
+ it("安装不再强制 +name@ver(R18 修订:applied 空属正常幂等态,挂载真通交由 smoke 段行为化校验)", async () => {
307
+ const r = await reloadAndAssert(rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }));
308
+ expect(r.ok).toBe(true);
309
+ });
310
+
311
+ it("卸载缺 -name@ver → ok=false(卸载须显式拆装,锁未摘除即空转回归拦截点)", async () => {
312
+ const r = assertReloadApplied({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
297
313
  expect(r.ok).toBe(false);
298
- expect(r.message).toContain("vars");
314
+ expect(r.message).toContain("demo-solution@0.1.0");
299
315
  });
300
316
 
301
317
  it("返回形状违约(applied 非数组)→ ok=false retryable=false", async () => {
@@ -337,15 +353,13 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
337
353
  expect(r.message).toContain("不计入失败");
338
354
  });
339
355
 
340
- it("health 401 与否声明 anonymous 无关(统一 deferred,不放宽真失败靠非 401)", async () => {
341
- // 旧语义按 deferredAnonymous 白名单区分是否 deferred;该字段从未被调用方赋值(死代码),
342
- // 且设计明确「health 不强制、无凭证预期 401」,故改为 health 401 一律 deferred。
343
- // 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
356
+ it("health 401 与是否声明 anonymous 无关(统一 deferred,真失败靠非 401 兜底)", async () => {
357
+ // 设计明确「health 不强制、无凭证预期 401」,故 health 401 一律 deferred,
358
+ // 真失败仍由非 401(如 500,见下条)兜底。
344
359
  await seedDeployed();
345
360
  const r = await smoke(smCtx({
346
361
  manifest: solManifest({ health: "/demo-solution/ping" }),
347
362
  fetch: async () => ({ ok: false, status: 401 }),
348
- deferredAnonymous: ["/demo-solution/other"],
349
363
  }));
350
364
  expect(r.ok).toBe(true);
351
365
  expect(r.message).toContain("restart_pending");
@@ -394,6 +408,16 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
394
408
  }));
395
409
  expect(r.ok).toBe(true);
396
410
  });
411
+
412
+ it("无 health:首个非匿名路由 404 → 探活失败(P0 §3.5:首个路由 404/连通失败 = 冒烟失败,避免假成功)", async () => {
413
+ await seedDeployed();
414
+ const r = await smoke(smCtx({
415
+ fetch: async () => ({ ok: false, status: 404 }),
416
+ }));
417
+ expect(r.ok).toBe(false);
418
+ expect(r.message).toContain("探活");
419
+ expect(r.message).toContain("404");
420
+ });
397
421
  });
398
422
 
399
423
  describe("smoke integrity(R10:部署落位后重算 sha384 vs modules.json)", () => {
@@ -156,7 +156,7 @@ function makeReq(overrides: Partial<InstallRequest> = {}): InstallRequest {
156
156
  configPath: join(root, "config.yaml"),
157
157
  fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer }),
158
158
  exec: bundleExec(),
159
- reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }) },
159
+ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }) },
160
160
  sha256Hex: d => createHash("sha256").update(d).digest("hex"),
161
161
  verifyEd25519: nodeVerifyEd25519,
162
162
  trustPath,
@@ -181,8 +181,8 @@ beforeEach(async () => {
181
181
  modulesJsonPath = join(root, "dist", "modules.json");
182
182
  dbPath = join(root, "db.sqlite");
183
183
  await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
184
- // 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
185
- await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
184
+ // 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
185
+ await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
186
186
  g.fs = nodeFsFace();
187
187
  g.yaml = { parse: async (t: string) => JSON.parse(t) };
188
188
  g.http = {};
@@ -226,9 +226,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
226
226
  await fsp.access(join(versionDir, "routes.js"));
227
227
  await fsp.access(join(versionDir, "migrations", "0001__init.sql"));
228
228
  await expect(fsp.access(join(versionDir, "web"))).rejects.toThrow();
229
- // reloading 段触发变更已落盘:vars.ojm_install_seq 自增(B-11,PRD E5 口径)
229
+ // reloading 段仅做 anonymous_paths 接线(幂等追加;不再依赖 vars 哨兵触发 reload)
230
230
  const cfg = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
231
- expect(cfg).toContain("ojm_install_seq: 1");
232
231
  // C2-26 anonymous 运行时接线(P2-15③):方案匿名端点补进 auth.anonymous_paths(幂等追加)
233
232
  expect(cfg).toContain("- /demo-solution/ping");
234
233
  // web 产物落位 + modules.json 条目(R10 integrity 为包内容重算值)
@@ -260,10 +259,10 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
260
259
  expect(await getTask(taskDir, "nope")).toBeNull();
261
260
  });
262
261
 
263
- it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起自增(否则 reload 恒 unchanged)", async () => {
262
+ it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起接线(否则 reload 恒 unchanged)", async () => {
264
263
  // ojm preview 派生出来的那份才是服务端在读的 config(spa 挂载 + platform.root 改写)
265
264
  const derived = join(root, ".ojm-preview.yaml");
266
- await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
265
+ await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
267
266
 
268
267
  const t = await runInstall(makeReq());
269
268
  expect(t.status).toBe("done");
@@ -271,10 +270,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
271
270
  const source = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
272
271
  const preview = new TextDecoder().decode(await fsp.readFile(derived));
273
272
  // 源 config 仍是真相(preview 重启后按它重建派生文件)
274
- expect(source).toContain("ojm_install_seq: 1");
275
273
  expect(source).toContain("- /demo-solution/ping");
276
274
  // 派生文件也要变:reloadConfig 按它的文档 sha 判 unchanged
277
- expect(preview).toContain("ojm_install_seq: 1");
278
275
  expect(preview).toContain("- /demo-solution/ping");
279
276
  });
280
277
 
@@ -1313,3 +1310,25 @@ describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase
1313
1310
  expect(t.stage).toBe("smoke");
1314
1311
  });
1315
1312
  });
1313
+
1314
+ describe("Phase 3:无守护 partial 判定(P0 §3.6 / 验收 15)", () => {
1315
+ it("热更返回待重启段非空 → 装完标 partial + restartRequired(不判 done,避免假成功)", async () => {
1316
+ // 无守护形态(P4 supervisor 未实施):匿名接线等 restart_pending 段需宿主重启才完全生效,
1317
+ // 不应报 done 误导。reload 返回 restart_pending 非空即标 partial + restartRequired。
1318
+ const t = await runInstall(makeReq({
1319
+ reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: ["auth"], kept_listener: [] }) },
1320
+ }));
1321
+ expect(t.status).toBe("partial");
1322
+ expect(t.restartRequired).toBe(true);
1323
+ expect(t.message).toContain("待重启段");
1324
+ expect(t.message).toContain("重启");
1325
+ // 安装产物已落位(非失败语义②补偿):锁/版本目录仍在,任务非 failed
1326
+ expect(t.stage).not.toBe("failed");
1327
+ });
1328
+
1329
+ it("热更待重启段为空 → 正常 done(restartRequired 不置位)", async () => {
1330
+ const t = await runInstall(makeReq());
1331
+ expect(t.status).toBe("done");
1332
+ expect(t.restartRequired).toBeUndefined();
1333
+ });
1334
+ });
@@ -108,10 +108,12 @@ const CATALOG = JSON.stringify({
108
108
  { name: "wiki", shards: [1] },
109
109
  ],
110
110
  });
111
+ /** 最新版详细描述(C2-05;喂给 index 聚合断言,验证卡片能读到方案「提供什么」) */
112
+ const LATEST_DESCRIPTION = "演示方案:提供示例 CRUD 与菜单路由,覆盖安装、升级、回滚与历史留痕全链路。";
111
113
  /** 分片正文(纯内容无 meta:版本条目即 Form B manifest——原 manifests.yaml 聚合清单并入分片) */
112
114
  const SHARD_1 = JSON.stringify({
113
115
  solutions: [
114
- { name: "demo-solution", latest: "0.2.0", versions: [manifestFixture("0.2.0"), manifestFixture("0.1.0")] },
116
+ { name: "demo-solution", latest: "0.2.0", versions: [{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION }, manifestFixture("0.1.0")] },
115
117
  { name: "wiki", latest: "1.3.0", versions: [{ name: "wiki", version: "1.3.0", archive: { file: "wiki-1.3.0.tgz", sha256: "x", size: 1 } }] },
116
118
  ],
117
119
  });
@@ -205,9 +207,15 @@ function bundleExec(): ExecFace {
205
207
  };
206
208
  }
207
209
 
208
- const reloadStub: ReloadFace = {
209
- reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] }),
210
- };
210
+ // reload 桩:按被安装版本产出 applied 模块符号(+name@ver,v0.1.122 双符号契约),
211
+ // 供 reloadAndAssert 终态校验(R18)。默认 0.1.0 覆盖绝大多数用例;跨片索引用例(REG-CS5)
212
+ // 安装 1.0.2499 / 1.0.0,须按实装版本注入对应桩,否则符号不匹配 → reloading 段判 partial。
213
+ function reloadStubFor(version: string): ReloadFace {
214
+ return {
215
+ reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }),
216
+ };
217
+ }
218
+ const reloadStub: ReloadFace = reloadStubFor("0.1.0");
211
219
 
212
220
  function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
213
221
  return {
@@ -247,8 +255,8 @@ beforeEach(async () => {
247
255
  modulesJsonPath = join(root, "dist", "modules.json");
248
256
  dbPath = join(root, "db.sqlite");
249
257
  await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
250
- // 站点 config.yaml 夹具(reloading 段 vars.ojm_install_seq 自增消费面;模板 config.yaml 随发形态)
251
- await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
258
+ // 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
259
+ await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
252
260
  g.fs = nodeFsFace();
253
261
  dbo = installDb();
254
262
  dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
@@ -819,7 +827,7 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
819
827
  expect(String(last?.msg)).toContain("未配置市场地址");
820
828
  });
821
829
 
822
- it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
830
+ it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/publisher_key/license/summary/description/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
823
831
  const env = makeEnv();
824
832
  const state = await createStateStore(env.statePath);
825
833
  await state.write({
@@ -835,9 +843,9 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
835
843
  // REG-CS5④:读面不再发 cache-control: no-cache(服务端缓存头 + 条件请求承载新鲜度)
836
844
  expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
837
845
  expect(last?.data).toEqual([
838
- { name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", license: "MIT", trust_state: "trusted", key_id: "test.publisher", operator: null },
839
- { name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
840
- { name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
846
+ { name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", publisher_key: "test.publisher", license: "MIT", summary: "演示方案", description: LATEST_DESCRIPTION, trust_state: "trusted", key_id: "test.publisher", operator: null },
847
+ { name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
848
+ { name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
841
849
  ]);
842
850
  });
843
851
 
@@ -1190,7 +1198,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
1190
1198
  }
1191
1199
 
1192
1200
  it("BDD① 装箱分片:2500 版本跨 3 片,latest 安装只拉最高片(≤ 1 catalog + 1 shard)→ done", async () => {
1193
- const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
1201
+ const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.2499") }));
1194
1202
  fetchCalls.length = 0;
1195
1203
  g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
1196
1204
  await api.install.post();
@@ -1208,7 +1216,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
1208
1216
  });
1209
1217
 
1210
1218
  it("BDD① 回落低片:显式指定最低版本 → 先试最高片未中,逐片回落到 1 号片命中 → done", async () => {
1211
- const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
1219
+ const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.0") }));
1212
1220
  fetchCalls.length = 0;
1213
1221
  g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "1.0.0", source: "cdn" } };
1214
1222
  await api.install.post();
@@ -1554,3 +1562,147 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
1554
1562
  }
1555
1563
  });
1556
1564
  });
1565
+
1566
+ // ── 发布历史(GET /market/releases)────────────────────────────────────────────
1567
+ // 数据源是签名索引本身:每次读先把索引里出现过的 product@version 幂等补进 market_release,
1568
+ // 再按发布时间倒序分页。索引读不到不阻断查询(返回已入库行 + syncError)。
1569
+
1570
+ interface ReleasePageData {
1571
+ list: Array<{ id: number, product: string, version: string, published_at: number, published_at_source: string, yanked: number }>
1572
+ total: number
1573
+ page: number
1574
+ size: number
1575
+ syncError: string | null
1576
+ }
1577
+
1578
+ /** HEAD 可读 Last-Modified 的 CDN 面(其余请求复用 cdnFetch 的完整夹具) */
1579
+ function headAwareFetch(lastModified: string): FetchFace {
1580
+ return (async (url: string, init?: { method?: string }) => {
1581
+ if (init?.method === "HEAD") {
1582
+ return {
1583
+ ok: true,
1584
+ status: 200,
1585
+ arrayBuffer: async () => new ArrayBuffer(0),
1586
+ headers: { get: (name: string) => name.toLowerCase() === "last-modified" ? lastModified : null },
1587
+ };
1588
+ }
1589
+ return cdnFetch(url);
1590
+ }) as FetchFace;
1591
+ }
1592
+
1593
+ /** 换一版分片正文(并按其字节重签,seq 与 catalog 同代),用于构造「索引说已下线」) */
1594
+ function shardWithYanked(): { body: string, sig: string } {
1595
+ const body = JSON.stringify({
1596
+ solutions: [{
1597
+ name: "demo-solution",
1598
+ latest: "0.2.0",
1599
+ versions: [
1600
+ { ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION },
1601
+ { ...manifestFixture("0.1.0"), yanked: true },
1602
+ ],
1603
+ }],
1604
+ });
1605
+ return { body, sig: JSON.stringify(signIndex("shard", SEQ, body, TEST_KEY.privateKeyPem, "test.publisher")) };
1606
+ }
1607
+
1608
+ describe("GET /market/releases(发布历史:索引对账落库 + 分页)", () => {
1609
+ it("首读把索引里全部 product@version 补进历史:产品/版本/发布时间齐全,无 HEAD 头时回落索引 issued_at(source=index)", async () => {
1610
+ const api = createMarketApi(makeEnv());
1611
+ g.http = { user: { id: 1 } };
1612
+ await api.releases.get();
1613
+ expect(last?.status).toBe(200);
1614
+ const page = last?.data as ReleasePageData;
1615
+ expect(page.syncError).toBeNull();
1616
+ expect(page.total).toBe(3);
1617
+ expect(page.list.map(r => `${r.product}@${r.version}`)).toEqual(["wiki@1.3.0", "demo-solution@0.1.0", "demo-solution@0.2.0"]);
1618
+ expect(page.list.every(r => r.published_at === ISSUED_AT && r.published_at_source === "index")).toBe(true);
1619
+ // 每行先试 HEAD(拿真正的落 CDN 时刻),失败才回落——不是完全不问源
1620
+ expect(fetchInits.some(i => (i as { method?: string } | undefined)?.method === "HEAD")).toBe(true);
1621
+ });
1622
+
1623
+ it("HEAD 拿得到 Last-Modified → 用文件时间并标 source=file(索引时间只作兜底,不冒充精确)", async () => {
1624
+ const api = createMarketApi(makeEnv({ fetch: headAwareFetch("Wed, 01 Oct 2025 00:00:00 GMT") }));
1625
+ g.http = { user: { id: 1 } };
1626
+ await api.releases.get();
1627
+ expect(last?.status).toBe(200);
1628
+ const page = last?.data as ReleasePageData;
1629
+ const expected = Math.floor(Date.parse("Wed, 01 Oct 2025 00:00:00 GMT") / 1000);
1630
+ expect(page.list.every(r => r.published_at === expected && r.published_at_source === "file")).toBe(true);
1631
+ });
1632
+
1633
+ it("幂等 + append-only:二次读不重复插入,已入库的 published_at 不被索引时间改写", async () => {
1634
+ // 预置一行「上次记录的历史」:发布时间是事实,哪怕与本次索引的时间不同也不该被刷新
1635
+ dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
1636
+ .run("demo-solution", "0.2.0", 1_600_000_000, "file", 0, 1_600_000_000);
1637
+ const api = createMarketApi(makeEnv());
1638
+ g.http = { user: { id: 1 } };
1639
+ await api.releases.get();
1640
+ await api.releases.get();
1641
+ expect(last?.status).toBe(200);
1642
+ const page = last?.data as ReleasePageData;
1643
+ expect(page.total).toBe(3);
1644
+ const rows = dbo.prepare("SELECT product, version, published_at FROM market_release ORDER BY product, version").all() as Array<{ product: string, version: string, published_at: number }>;
1645
+ expect(rows).toHaveLength(3);
1646
+ expect(rows.find(r => r.product === "demo-solution" && r.version === "0.2.0")?.published_at).toBe(1_600_000_000);
1647
+ });
1648
+
1649
+ it("yanked 跟随索引(0→1 与 1→0 双向):下线是当前状态,不是历史事实的改写", async () => {
1650
+ // 1→0:库里记着已下线,索引又回到在架
1651
+ dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
1652
+ .run("demo-solution", "0.2.0", ISSUED_AT, "index", 1, ISSUED_AT);
1653
+ const api = createMarketApi(makeEnv());
1654
+ g.http = { user: { id: 1 } };
1655
+ await api.releases.get();
1656
+ expect(last?.status).toBe(200);
1657
+ expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.2.0") as { yanked: number }).yanked).toBe(0);
1658
+
1659
+ // 0→1:换一版已签名的分片,其中 0.1.0 标了 yanked
1660
+ const yanked = shardWithYanked();
1661
+ const fetchFn = (async (url: string) => {
1662
+ if (String(url).endsWith("shards/1.yaml.sig"))
1663
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.sig).buffer as ArrayBuffer };
1664
+ if (String(url).endsWith("shards/1.yaml"))
1665
+ return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.body).buffer as ArrayBuffer };
1666
+ return cdnFetch(url);
1667
+ }) as FetchFace;
1668
+ const api2 = createMarketApi(makeEnv({ fetch: fetchFn }));
1669
+ g.http = { user: { id: 1 } };
1670
+ await api2.releases.get();
1671
+ expect(last?.status).toBe(200);
1672
+ expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.1.0") as { yanked: number }).yanked).toBe(1);
1673
+ });
1674
+
1675
+ it("索引读不到(验签失败)→ 200 + 已入库历史 + syncError 人话(观测面不阻断页面)", async () => {
1676
+ const api = createMarketApi(makeEnv());
1677
+ g.http = { user: { id: 1 } };
1678
+ await api.releases.get();
1679
+ expect((last?.data as ReleasePageData).total).toBe(3);
1680
+
1681
+ const brokenFetch = (async (url: string) => {
1682
+ if (String(url).endsWith("shards/1.yaml.sig"))
1683
+ return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
1684
+ return cdnFetch(url);
1685
+ }) as FetchFace;
1686
+ const api2 = createMarketApi(makeEnv({ fetch: brokenFetch }));
1687
+ g.http = { user: { id: 1 } };
1688
+ await api2.releases.get();
1689
+ expect(last?.status).toBe(200);
1690
+ const page = last?.data as ReleasePageData;
1691
+ expect(page.total).toBe(3);
1692
+ expect(page.syncError).toContain("index_sig_missing");
1693
+ });
1694
+
1695
+ it("门与分页:匿名 401;page/limit 透传并按发布时间倒序取页", async () => {
1696
+ const api = createMarketApi(makeEnv());
1697
+ g.http = {};
1698
+ await api.releases.get();
1699
+ expect(last?.status).toBe(401);
1700
+
1701
+ g.http = { user: { id: 1 }, query: { page: "2", limit: "1" } };
1702
+ await api.releases.get();
1703
+ expect(last?.status).toBe(200);
1704
+ const page = last?.data as ReleasePageData;
1705
+ expect(page).toMatchObject({ page: 2, size: 1, total: 3 });
1706
+ expect(page.list).toHaveLength(1);
1707
+ });
1708
+ });