@modusensus/dsh-mneme 0.9.4 → 0.10.0

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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
@@ -0,0 +1,756 @@
1
+ // receive(入库 / 整包退出)的 HTTP 口径 + 两张皮的装配与反分叉锁。
2
+ //
3
+ // 三类回归,分三段(按标题顺序,不数条数——数出来的数字会比正文先过期):
4
+ // 口径层——这张脸有两个入口(宿主内面板路由、standalone Bearer 数据面),任何一处松掉
5
+ // 都会同时污染两侧,所以边界跑全量:body 解析、干跑默认、错误码 → 状态码的映射(含一条
6
+ // 从 share.js 源码推码集的静态锁,第 9 个码进来时它要能同时抓「没登记」和「表里的死条目」)、
7
+ // 503 与 500 的分工,以及「这一层从不抛」——连 warn 自己坏也算在内。
8
+ // 面板路由层——锁的是共用层管不着的那半:policy 归类、非 POST 的 405、readBody 返回 null
9
+ // 时不许再写一次响应(#410 那一类双写)、以及「500 必须留下日志迹,而记日志那一环自己坏了
10
+ // 也得回一个 500」。
11
+ // 数据面与跨面——C6 把 Bearer 那半接上之后才补的:数据面自己的 .catch(#410 的形状在数据面
12
+ // 才真的要命:链上没人返回 promise,输不掉就一个字节都不回)与一条**跨面**反分叉静态锁
13
+ // (两面都调同一批共用 builder)。src() helper 被三条静态锁各自消费——口径层的码集推导锁、
14
+ // 「共用层只守卫 warn 一次」那把,与末尾这条跨面锁;本段说的 .catch 全是行为咬的,不扫源码。
15
+ // 车道 A 的同款先例是 test/share-http.test.js 末尾那条,它也是两张皮都接上之后才补的。
16
+ //
17
+ // 装配台照 test/share-routes.test.js 的 shareSetup(同一套 api-routes-gaps 形状)并
18
+ // **落文件库**:收侧吃的是真实 store 的两列身份与部分 UNIQUE 索引,`:memory:` 那个口径
19
+ // 摸不到真的行;真装配 createApi 才锁得到 policy 与 readBody 这两处只在这里存在的东西。
20
+ import test from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { EventEmitter } from "node:events";
23
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { createStore } from "../src/store.js";
28
+ import { createService } from "../src/service.js";
29
+ import { createSettings } from "../src/settings.js";
30
+ import { createApi } from "../src/api.js";
31
+ import { createStandaloneApi } from "../src/api-standalone.js";
32
+ import { deriveExternalId, SHARE_KIND, validateSharePack } from "../src/share.js";
33
+ import {
34
+ parseReceiveBody,
35
+ receiveReply,
36
+ parseUndoBody,
37
+ undoReply,
38
+ SHARE_IMPORT_ERRORS,
39
+ SHARE_IMPORT_UNAVAILABLE
40
+ } from "../src/share-http.js";
41
+
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+ const src = (f) => readFileSync(resolve(here, "../src", f), "utf8");
44
+ const RECEIVE_PATH = "/api/dsh-mneme/receive";
45
+ const UNDO_PATH = "/api/dsh-mneme/receive/undo";
46
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
47
+ const PACK_ID = "f0f0f0f0f0f0f0f0";
48
+
49
+ // over 排在派生之后(照 test/share-validate.test.js 的同款夹具):要造
50
+ // external-id-mismatch 就得让 external_id 这一格被换成别的值,而其余字段仍然合格。
51
+ const entry = (over = {}) => {
52
+ const base = { type: "decision", title: "t", content: "c", tags: [], importance: 3, content_hash: "h" };
53
+ return { ...base, external_id: deriveExternalId(PACK_ID, base.type, base.title, base.content_hash), ...over };
54
+ };
55
+ const packOf = (memories, over = {}) => ({
56
+ kind: SHARE_KIND, pack_id: PACK_ID, alias: "桉", generated_at: "x", source_version: "0.9.4",
57
+ report: { included: memories.length, skipped: {}, skip_list: [], deduped: 0 }, memories, ...over
58
+ });
59
+ const goodPack = () => packOf([entry()]);
60
+
61
+ // --- 口径层 -------------------------------------------------------------------
62
+
63
+ test("the body must carry a pack; a bare pack is refused", () => {
64
+ // 空 body 与「没带包」在这里是两件事:出包侧空 body 是合法的(整库口径),收侧空 body
65
+ // 一个字节都没得写,静默当成 `{}` 再往下走只会拿到一个看不懂的 400。
66
+ for (const empty of ["", " ", undefined, null]) {
67
+ assert.equal(parseReceiveBody(empty).error, "invalid-body", `body=${JSON.stringify(empty)} 没有包`);
68
+ }
69
+ assert.equal(parseReceiveBody("{oops").error, "invalid-json", "发坏了 ≠ 发错了形状");
70
+ for (const wrong of ["[1,2]", "null", '"x"']) {
71
+ assert.equal(parseReceiveBody(wrong).error, "invalid-body", `${wrong} 顶层就不是对象`);
72
+ }
73
+ assert.equal(parseReceiveBody("{}").error, "missing-pack");
74
+ for (const noPack of ['{"pack":null}', '{"pack":"abc"}', '{"pack":123}']) {
75
+ assert.equal(parseReceiveBody(noPack).error, "missing-pack", `${noPack} 的 pack 那一格不是对象`);
76
+ }
77
+ const written = parseReceiveBody(JSON.stringify({ pack: goodPack(), confirm: true }));
78
+ assert.equal(written.ok, true);
79
+ assert.equal(written.dryRun, false, "confirm:true means really write");
80
+ assert.equal(parseReceiveBody(JSON.stringify({ pack: goodPack() })).dryRun, true, "no confirm means dry-run");
81
+ // confirm 只认真布尔(同 readReclaimFields 的 confirm)。字符串 "true" 不是点头——
82
+ // 这一面写的是收件人的库,把「像是点了头」读成点头就是替她决定。
83
+ assert.equal(parseReceiveBody(JSON.stringify({ pack: goodPack(), confirm: "true" })).dryRun, true);
84
+ // 第二个「要不要写」的旋钮不存在。reclaim 的 body 形状是 {dryRun:false,confirm:true},
85
+ // 手熟的人会顺手把 dryRun 一起发来:这里必须读成「没点头」,而不是 dryRun:false 覆盖默认。
86
+ assert.equal(parseReceiveBody(JSON.stringify({ pack: goodPack(), dryRun: false })).dryRun, true,
87
+ "dryRun 不是收侧的旋钮:只有 confirm 才写");
88
+ // 包本身不在这里判:整包闸门住在 share.js、由 service 调(判据只许有一处)。
89
+ // 一个数组当 pack 照样往下交——本层若自己校验一次,两张皮就各有一套合格标准了。
90
+ const arrayPack = parseReceiveBody('{"pack":[]}');
91
+ assert.equal(arrayPack.ok, true);
92
+ assert.deepEqual(arrayPack.pack, []);
93
+ });
94
+
95
+ test("receive without confirm answers the dry-run shape, with confirm the same shape marked applied", () => {
96
+ const calls = [];
97
+ const service = {
98
+ importSharePack: (pack, opts) => {
99
+ calls.push({ pack, opts });
100
+ return { pack_id: pack.pack_id, alias: pack.alias, created: 1, skipped_existing: 0, unchanged: 0, title_conflicts: [], conflicts_checked: 1 };
101
+ }
102
+ };
103
+ const pack = goodPack();
104
+ const out = receiveReply({ service, pack });
105
+ assert.equal(out.status, 200);
106
+ assert.equal(out.body.created, 1);
107
+ assert.equal(out.body.dry_run, true, "没给 dryRun 就是干跑,答复要自己承认没写");
108
+ assert.deepEqual(calls, [{ pack, opts: { dryRun: true } }], "干跑要作为 opts 交给 service,不是本层自己算数");
109
+ // 不加包装层:对面(CLI 的打印器)直接读这几格,套一层 {report} 会打断它。
110
+ for (const key of ["pack_id", "alias", "created", "skipped_existing", "unchanged", "title_conflicts", "conflicts_checked"]) {
111
+ assert.ok(key in out.body, `${key} must pass through untouched`);
112
+ }
113
+ const applied = receiveReply({ service, pack, dryRun: false });
114
+ assert.equal(applied.body.dry_run, false);
115
+ // title_conflicts 是 advisory 读数、conflicts_checked 只是包里的条数:本层只透传,
116
+ // 不许在这儿把它读成「无冲突」再补一个布尔——那等于把「这批里没有新撞名」说成「库里没有同名行」。
117
+ assert.deepEqual(applied.body.title_conflicts, []);
118
+ assert.equal(applied.body.conflicts_checked, 1);
119
+ // 本层不校验包:一个闸门一眼看穿的坏包也要原样交给 service,由它抛带码的错、由码表回 400。
120
+ const junk = { kind: "nope" };
121
+ assert.equal(validateSharePack(junk).ok, false, "前提:这个包在闸门那儿确实不合格");
122
+ const junkOut = receiveReply({ service, pack: junk, dryRun: false });
123
+ assert.equal(junkOut.status, 200, "service 说成了,本层就不许自己先拒——判据只许有一处");
124
+ assert.equal(calls.at(-1).pack, junk);
125
+ });
126
+
127
+ test("every import error code maps to exactly one status", () => {
128
+ // 码集不重抄一份清单。FIXTURES 的键**由两处推出来**:一张是映射表
129
+ // SHARE_IMPORT_ERRORS,另一处是把闸门(src/share.js 的 validateSharePack)源码里的
130
+ // error 字面量扫出来。为什么两处都要:只对着表写夹具,闸门哪天多一格而表忘了登记,
131
+ // 全套用例照样绿——那个新码从此静默落进「500 + 日志」,把发件人的坏包报成我们的故障
132
+ // (C5 评审点的名)。反过来只对着闸门扫、不看表,表里的死码又没人管。
133
+ const gate = src("share.js");
134
+ const at = gate.indexOf("export function validateSharePack(");
135
+ assert.ok(at > 0, "src/share.js 里应有 validateSharePack 这一段可扫");
136
+ const region = gate.slice(at);
137
+ assert.ok(region.includes("{ ok: true, pack"), "扫到的那一段确实止于 validateSharePack 的放行分支");
138
+ const gateCodes = [...new Set([...region.matchAll(/error:\s*"([a-z-]+)"/g)].map((m) => m[1]))];
139
+
140
+ // over 排在派生之后(见上面的 entry 注释):要造 external-id-mismatch 就得让 external_id
141
+ // 这一格被换成别的值,而其余字段仍然合格。
142
+ const FIXTURES = {
143
+ "invalid-kind": () => packOf([entry()], { kind: "nope" }),
144
+ "invalid-pack-id": () => packOf([entry()], { pack_id: "nope" }),
145
+ "invalid-memories": () => packOf([entry()], { memories: "x" }),
146
+ "empty-pack": () => packOf([]),
147
+ "invalid-type": () => packOf([entry({ type: "summary" })]),
148
+ "invalid-entry": () => packOf([entry({ title: " " })]),
149
+ "external-id-mismatch": () => packOf([entry({ external_id: "0000000000000000" })]),
150
+ "duplicate-external-id": () => packOf([entry(), entry()])
151
+ };
152
+ const required = [...new Set([...Object.keys(SHARE_IMPORT_ERRORS), ...gateCodes])].sort();
153
+ assert.deepEqual(Object.keys(FIXTURES).sort(), required,
154
+ "每一个码(表里的 + 闸门里抛的)都要有一条真夹具,一条都不许多;新码进来时先把这条改红,别绕过去");
155
+
156
+ for (const code of required) {
157
+ assert.equal(SHARE_IMPORT_ERRORS[code], 400, `${code} 必须在映射表里登记成 400:闸门能抛它而表里没有,它就落成 500`);
158
+ assert.equal(validateSharePack(FIXTURES[code]()).error, code, `闸门该回 ${code}`);
159
+ const throwing = {
160
+ importSharePack: () => {
161
+ const e = new Error(`share import: ${code}`);
162
+ e.code = code;
163
+ throw e;
164
+ }
165
+ };
166
+ const out = receiveReply({ service: throwing, pack: goodPack(), dryRun: false });
167
+ assert.equal(out.status, 400, `${code} must answer 400`);
168
+ assert.deepEqual(out.body, { error: code }, `${code} 的答复只有一个码`);
169
+ }
170
+ assert.ok(Object.isFrozen(SHARE_IMPORT_ERRORS), "两张皮共用一张表,运行时谁都不许改它");
171
+ // detail 是闸门给的那一格(哪个字段、哪个坏类型),只转发不加工;没有就不造这一格。
172
+ const detailed = receiveReply({
173
+ service: { importSharePack: () => { const e = new Error("x"); e.code = "invalid-entry"; e.detail = "content"; throw e; } },
174
+ pack: goodPack(), dryRun: false
175
+ });
176
+ assert.deepEqual(detailed.body, { error: "invalid-entry", detail: "content" });
177
+ // 不在表里的码不许混进 400,也不许只回个状态码就走:认不出的码只剩日志这一条线索。
178
+ const logs = [];
179
+ const unknown = receiveReply({
180
+ service: { importSharePack: () => { const e = new Error("database is locked"); e.code = "SQLITE_BUSY"; throw e; } },
181
+ pack: goodPack(), dryRun: false, warn: (m) => logs.push(String(m))
182
+ });
183
+ assert.equal(unknown.status, 500);
184
+ assert.deepEqual(unknown.body, { error: "internal" });
185
+ assert.equal(logs.length, 1);
186
+ });
187
+
188
+ test("a service that cannot receive answers unavailable, not 500", () => {
189
+ // 「这条路没接」与「我发坏了」必须分得开:headless/旧装配里没有这两个方法,
190
+ // 报 500 是把接线问题伪装成服务端故障,报 400 是把对面打发去改一个没错的包。
191
+ const cases = [
192
+ ["receive", () => receiveReply({ service: {}, pack: goodPack() })],
193
+ ["undo", () => undoReply({ service: {}, packId: PACK_ID })],
194
+ ["receive/undefined", () => receiveReply({ service: undefined, pack: goodPack() })],
195
+ ["undo/wrong-shape", () => undoReply({ service: { deleteSharePack: "not-a-function" }, packId: PACK_ID })]
196
+ ];
197
+ for (const [name, run] of cases) {
198
+ const out = run();
199
+ assert.equal(out.status, 503, `${name} 该回 503`);
200
+ assert.deepEqual(out.body, { error: "share-import-unavailable" }, "对面按这一个码分支");
201
+ assert.equal(out.body.error, SHARE_IMPORT_UNAVAILABLE);
202
+ }
203
+ });
204
+
205
+ test("an unexpected throw is logged and answered as internal", () => {
206
+ const logs = [];
207
+ const boom = (message) => ({
208
+ importSharePack: () => { throw new Error(message); },
209
+ deleteSharePack: () => { throw new Error(message); }
210
+ });
211
+ const out = receiveReply({ service: boom("database is locked"), pack: goodPack(), dryRun: false, warn: (m) => logs.push(String(m)) });
212
+ assert.equal(out.status, 500);
213
+ assert.deepEqual(out.body, { error: "internal" }, "对外只有一个码,不带原始错误文本");
214
+ assert.equal(logs.length, 1, "a swallowed 500 must leave a trace");
215
+ assert.match(logs[0], /share import failed/);
216
+ assert.match(logs[0], /database is locked/, "原始文本留在日志里,否则现场无从下手");
217
+ // 退出那一侧同一件事,文案不同(查的人按这两条认是哪一面炸的),对外同样只有一个码。
218
+ const undoLogs = [];
219
+ const undone = undoReply({ service: boom("no such column"), packId: PACK_ID, dryRun: false, warn: (m) => undoLogs.push(String(m)) });
220
+ assert.deepEqual(undone, { status: 500, body: { error: "internal" } });
221
+ assert.match(undoLogs[0], /share undo failed/);
222
+ // 没有 logger 也不许把异常漏出去:#410 的形态就是客户端拿到「一个字节都没回」。
223
+ assert.equal(receiveReply({ service: boom("x"), pack: goodPack(), dryRun: false }).status, 500);
224
+ assert.equal(undoReply({ service: boom("x"), packId: PACK_ID }).status, 500);
225
+ });
226
+
227
+ test("a warn that throws is the layer's problem, not the surface's", () => {
228
+ // 「这一层从不抛」必须是**层的契约**,不是每个调用面的运气(C5 评审裁定):日志那一环
229
+ // 自己坏时,已经算好的 500 不许跟着一起漏出去——从 catch 块里再抛出去就是没人接的
230
+ // rejection,对面拿到的不是 500 而是「一个字节都没回」(#410 的原始形态)。
231
+ // 出包那侧的 shareReply 过去是裸调,本条同时把三个构造器钉成同一种脾气:不对称的地方
232
+ // 就是下一个读者会照抄的那一处。
233
+ const boom = (message) => ({
234
+ importSharePack: () => { throw new Error(message); },
235
+ deleteSharePack: () => { throw new Error(message); }
236
+ });
237
+ const screaming = () => { throw new Error("log sink on fire"); };
238
+ assert.deepEqual(receiveReply({ service: boom("database is locked"), pack: goodPack(), dryRun: false, warn: screaming }),
239
+ { status: 500, body: { error: "internal" } }, "日志环坏了也要把 500 交出去");
240
+ assert.deepEqual(undoReply({ service: boom("disk i/o error"), packId: PACK_ID, dryRun: false, warn: screaming }),
241
+ { status: 500, body: { error: "internal" } });
242
+ // 认得出的码那一支也要走同一把守卫:它不记日志,但「构造器从不抛」不分工况。
243
+ const coded = () => {
244
+ const e = new Error("x");
245
+ e.code = "invalid-kind";
246
+ throw e;
247
+ };
248
+ assert.deepEqual(receiveReply({ service: { importSharePack: coded }, pack: goodPack(), dryRun: false, warn: screaming }),
249
+ { status: 400, body: { error: "invalid-kind" } });
250
+ });
251
+
252
+ test("the shared layer guards warn exactly once, for all three builders", () => {
253
+ // 静态锁,盯的是**对称**而不是某一处实现:本层只许出现一次裸的 warn 调用(就在守卫
254
+ // 函数体里),三个构造器各自都必须经守卫调用它。将来谁加第四个构造器时裸写 warn,
255
+ // 或者把某一侧的守卫「优化」掉,这条会红——而 behavioural 的用例只会告诉它「反正
256
+ // 面板有 .catch 兜着」,那是把层的契约推给面。
257
+ const layer = src("share-http.js");
258
+ assert.equal((layer.match(/warn\?\.\(/g) ?? []).length, 1, "裸的 warn?.( 只许住在守卫里");
259
+ assert.equal((layer.match(/warnSafely\(/g) ?? []).length, 4, "一处定义 + shareReply / receiveReply / undoReply 三处调用");
260
+ });
261
+
262
+ test("undo takes a pack_id and answers the same shape", () => {
263
+ // 顶层形状与「有没有 id」是两个错,与 receive 侧同一口径(invalid-json /
264
+ // invalid-body / 缺字段),三个码让对面分得清是自己发坏了还是没填。
265
+ assert.equal(parseUndoBody("").error, "invalid-body");
266
+ assert.equal(parseUndoBody("{oops").error, "invalid-json");
267
+ for (const wrong of ["[1,2]", "null"]) {
268
+ assert.equal(parseUndoBody(wrong).error, "invalid-body", `${wrong} 顶层就不是对象`);
269
+ }
270
+ assert.equal(parseUndoBody("{}").error, "missing-pack-id");
271
+ assert.equal(parseUndoBody('{"packId":" "}').error, "missing-pack-id", "空白 id 不是要收起的那一批");
272
+ assert.equal(parseUndoBody('{"packId":123}').error, "missing-pack-id", "非字符串不进删除那条路");
273
+ const parsed = parseUndoBody(JSON.stringify({ packId: ` ${PACK_ID} `, confirm: true }));
274
+ assert.equal(parsed.packId, PACK_ID, "在这里 trim,与 deleteSharePack 首行同一把尺");
275
+ assert.equal(parsed.dryRun, false);
276
+ assert.equal(parseUndoBody(JSON.stringify({ packId: PACK_ID })).dryRun, true, "缺 confirm 同样是干跑");
277
+ const calls = [];
278
+ const service = {
279
+ deleteSharePack: (packId, opts) => {
280
+ calls.push({ packId, opts });
281
+ return { pack_id: packId, deleted: 2, sample: [{ type: "decision", title: "t" }] };
282
+ }
283
+ };
284
+ const out = undoReply({ service, packId: PACK_ID, dryRun: true });
285
+ assert.equal(out.status, 200);
286
+ assert.equal(out.body.deleted, 2);
287
+ assert.equal(out.body.dry_run, true);
288
+ assert.deepEqual(calls, [{ packId: PACK_ID, opts: { dryRun: true } }]);
289
+ assert.ok(Array.isArray(out.body.sample), "sample 是前缀清单,deleted 才是全集");
290
+ // pack_id 这一格是 service 的读数,不是请求的回显:非字符串/空白那一支它回 ""。
291
+ // 本层不许替它兜底或改写,否则「收起的是哪一批」就有了第二个答案。
292
+ assert.equal(out.body.pack_id, PACK_ID);
293
+ });
294
+
295
+ // --- 面板路由层 ---------------------------------------------------------------
296
+
297
+ class FakeRes extends EventEmitter {
298
+ constructor({ failFirstHead = false } = {}) {
299
+ super();
300
+ this.statusCode = 0;
301
+ this.body = "";
302
+ // writeHead 计数:双写响应的故障形态是「第二次 writeHead 盖掉第一次的状态码」,
303
+ // 只看最终 statusCode 抓不住(413 之后又被 400 盖掉时 body 也换了),要数次数。
304
+ this.heads = 0;
305
+ // 一次性坏掉的 sink:真 socket 上「一个响应两写」的现场形态就是第二次 writeHead 抛
306
+ // ERR_HTTP_HEADERS_SENT(Node 在 writeHead 里就把头发出去了)。这里让第一次抛、第二次
307
+ // 正常,为的是把「promise 链上的错误有没有人接住」这件事单独问出来——它不计数,那一次
308
+ // 什么都没写出去。
309
+ this.failFirstHead = failFirstHead;
310
+ // 数据面的 handler 是 `void readBody(...).then(...)`,它不返回 promise,测试没法
311
+ // await 到链尾——答复写完的这一声就是唯一的等待点(配一个超时竞速,缺 .catch 时
312
+ // 用例要红得干脆,而不是把整个文件挂死在无限超时上)。
313
+ this.settled = new Promise((resolve) => { this.settle = resolve; });
314
+ }
315
+ writeHead(code) {
316
+ if (this.failFirstHead) {
317
+ this.failFirstHead = false;
318
+ throw new Error("sink on fire");
319
+ }
320
+ this.statusCode = code;
321
+ this.heads += 1;
322
+ return this;
323
+ }
324
+ end(text) {
325
+ this.body = text ?? "";
326
+ this.emit("end");
327
+ this.settle?.("ended");
328
+ return this;
329
+ }
330
+ }
331
+
332
+ function receiveSetup(apiToken = "secret-token", { logger = null } = {}) {
333
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-receive-api-"));
334
+ const store = createStore(join(dir, "memory.db"));
335
+ const service = createService({ store, mirror: null, config: {} });
336
+ const settings = createSettings(store.db);
337
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
338
+ const routes = [];
339
+ const ctx = { logger, webServer: { register(route) { routes.push(route); return () => {}; } } };
340
+ createApi(ctx, service, settings, commands, null, undefined, apiToken, null, null);
341
+ return {
342
+ store, service, routes,
343
+ // 闭包拿 store,不用 this:裸函数调用下 this 是 undefined,异常被吞掉就会变成
344
+ // 「库没关就删目录」的 EPERM(清理失败还说不清原因)。
345
+ close() {
346
+ try { store.close(); } catch { /* already closed */ }
347
+ rmSync(dir, { recursive: true, force: true });
348
+ }
349
+ };
350
+ }
351
+
352
+ /**
353
+ * 字节必须在 listener 挂上之后再 emit:EventEmitter 不排队,在 `route.handler` 里
354
+ * readBody 注册 data/end 之前 emit 等于什么都没发,promise 永不 resolve,用例会挂到
355
+ * 超时——失败原因还完全看不出跟入库有关。
356
+ */
357
+ function receiveReq({ method = "POST", url = RECEIVE_PATH, body = "", authorized = true } = {}) {
358
+ const req = new EventEmitter();
359
+ req.method = method;
360
+ req.url = url;
361
+ req.headers = authorized ? { authorization: "Bearer secret-token" } : {};
362
+ process.nextTick(() => {
363
+ req.emit("data", Buffer.from(body));
364
+ req.emit("end");
365
+ });
366
+ return req;
367
+ }
368
+
369
+ async function call(route, opts = {}) {
370
+ const { failFirstHead = false, ...request } = opts;
371
+ const res = new FakeRes({ failFirstHead });
372
+ // 把 rejection 收成读数而不是让它冒出去:面板 handler 的形状是
373
+ // `try { return readBody(...).then(...) } catch`,外层 try 在 .then 的回调里早已退栈,
374
+ // 回调里抛出(或哪天 .catch 被删)时这里拿到的是一条看不懂的原始错误。收成字段后断言
375
+ // 才说得出「回调里的错没人接 = 一个字节都没回」(#410)。
376
+ let rejection = null;
377
+ try {
378
+ await route.handler(receiveReq(request), res);
379
+ } catch (error) {
380
+ rejection = String(error);
381
+ }
382
+ return {
383
+ status: res.statusCode,
384
+ body: res.body ? JSON.parse(res.body) : null,
385
+ heads: res.heads,
386
+ rejection
387
+ };
388
+ }
389
+
390
+ function receiveRoute(setup, path) {
391
+ const route = setup.routes.find((r) => r.path === path);
392
+ assert.ok(route, `${path} must be registered`);
393
+ return route;
394
+ }
395
+
396
+ const rowsOf = (store) => store.list({ limit: null });
397
+
398
+ test("both receive routes register as token-write and gate the write behind the token", async () => {
399
+ const s = receiveSetup();
400
+ try {
401
+ for (const path of [RECEIVE_PATH, UNDO_PATH]) {
402
+ const route = receiveRoute(s, path);
403
+ assert.equal(route.kind, "exact");
404
+ // 写库的一面,与 /import 同门;漏声明 policy 在装配期就抛,这条锁的是「归对那一类」。
405
+ assert.equal(route.policy, "token-write", path);
406
+ }
407
+ const receive = receiveRoute(s, RECEIVE_PATH);
408
+ const anon = await call(receive, { body: JSON.stringify({ pack: goodPack(), confirm: true }), authorized: false });
409
+ assert.equal(anon.status, 401, "POST 不带 token 一步都不许往下走");
410
+ assert.equal(rowsOf(s.store).length, 0, "被拒的请求根本进不到 handler");
411
+ // token-write 的 GET 分支不鉴权:它是「进 handler 拿 405」这条路存在的唯一理由。
412
+ // 哪天有人把它收紧成 token(更安全的外观),这里会从 405 变 401——两张皮的差别
413
+ // 就多出一处鉴权之外的东西(#411),这条断言就是那声报警。
414
+ const anonGet = await call(receive, { method: "GET", authorized: false });
415
+ assert.equal(anonGet.status, 405, `GET 不带 token 也要走到 405,got ${anonGet.status}`);
416
+ } finally { s.close(); }
417
+ });
418
+
419
+ test("receive through the panel previews by default and writes only on confirm", async () => {
420
+ const s = receiveSetup();
421
+ try {
422
+ const route = receiveRoute(s, RECEIVE_PATH);
423
+ const dry = await call(route, { body: JSON.stringify({ pack: goodPack() }) });
424
+ assert.equal(dry.status, 200);
425
+ assert.equal(dry.body.dry_run, true);
426
+ assert.equal(dry.body.created, 1);
427
+ assert.equal(dry.body.conflicts_checked, 1);
428
+ assert.ok(Array.isArray(dry.body.title_conflicts), "撞名读数要透传出去:那是「不改别人」唯一的可见性");
429
+ assert.equal(rowsOf(s.store).length, 0, "一个字节都没点,一个字都不许写");
430
+ const applied = await call(route, { body: JSON.stringify({ pack: goodPack(), confirm: true }) });
431
+ assert.equal(applied.body.dry_run, false);
432
+ assert.equal(applied.body.created, 1);
433
+ assert.equal(applied.body.pack_id, PACK_ID);
434
+ const rows = rowsOf(s.store);
435
+ assert.equal(rows.length, 1);
436
+ assert.equal(rows[0].share_pack_id, PACK_ID, "落的是带包身份的外来行,退出与守卫都靠这一格");
437
+ assert.equal(rows[0].source, "imported");
438
+ const again = await call(route, { body: JSON.stringify({ pack: goodPack(), confirm: true }) });
439
+ assert.equal(again.body.created, 0);
440
+ assert.equal(again.body.skipped_existing, 1, "幂等是这条路由的常态用法,不是 service 的私事");
441
+ assert.equal(rowsOf(s.store).length, 1, "重跑一行都不多");
442
+ } finally { s.close(); }
443
+ });
444
+
445
+ test("a non-POST on the receive routes answers 405, not 404", async () => {
446
+ const s = receiveSetup();
447
+ try {
448
+ for (const path of [RECEIVE_PATH, UNDO_PATH]) {
449
+ for (const method of ["GET", "PUT", "DELETE"]) {
450
+ const out = await call(receiveRoute(s, path), { method, url: path, body: "" });
451
+ assert.equal(out.status, 405, `${method} ${path} got ${out.status}`);
452
+ assert.deepEqual(out.body, { error: "method-not-allowed" });
453
+ }
454
+ }
455
+ assert.equal(rowsOf(s.store).length, 0, "405 那几条一个字节都没写");
456
+ // 这**故意**与隔壁 /maintenance/reclaim 不同(那边非 GET/POST 掉到末尾 404;GET 是它
457
+ // 自己的面板预览入口,另一回事)。理由在 #411:每条 receive 都还有个数据面双胞胎(C6),
458
+ // 那里只认 405,状态码差一次就是第三处差别。
459
+ const reclaim = receiveRoute(s, "/api/dsh-mneme/maintenance/reclaim");
460
+ assert.equal((await call(reclaim, { method: "PUT", url: "/api/dsh-mneme/maintenance/reclaim" })).status, 404,
461
+ "邻居仍是 404:这条锁的不是全局面,是 receive 这两条与它们双胞胎的一致性");
462
+ } finally { s.close(); }
463
+ });
464
+
465
+ test("an oversize body answers 413 exactly once on both receive routes", async () => {
466
+ const s = receiveSetup();
467
+ try {
468
+ for (const path of [RECEIVE_PATH, UNDO_PATH]) {
469
+ // readBody 超限时自己答 413 并把 text 解析成 null。handler 少了 `if (text === null) return`
470
+ // 就会拿 null 继续走:两侧都是 400,于是**一个响应两写**,客户端按状态码分支看到的
471
+ // 是最后那一次(#410 修过的同一类故障)。
472
+ const out = await call(receiveRoute(s, path), { url: path, body: "x".repeat(MAX_BODY_BYTES + 1) });
473
+ assert.equal(out.heads, 1, `${path} 超限时只许有一次响应写`);
474
+ assert.equal(out.status, 413);
475
+ assert.equal(out.body.error, "payload-too-large");
476
+ }
477
+ assert.equal(rowsOf(s.store).length, 0);
478
+ } finally { s.close(); }
479
+ });
480
+
481
+ test("the receive routes answer the gate's code, a logged 500, and unavailable", async () => {
482
+ const warnings = [];
483
+ const s = receiveSetup("secret-token", { logger: { warn: (m) => warnings.push(String(m)) } });
484
+ try {
485
+ const receive = receiveRoute(s, RECEIVE_PATH);
486
+ const undo = receiveRoute(s, UNDO_PATH);
487
+ // 闸门住在 service:本层不重跑校验,只把带码的错映射成状态码(判据只许有一处)。
488
+ const rejected = await call(receive, { body: JSON.stringify({ pack: packOf([entry()], { kind: "nope" }), confirm: true }) });
489
+ assert.equal(rejected.status, 400);
490
+ assert.equal(rejected.body.error, "invalid-kind");
491
+ assert.equal(rowsOf(s.store).length, 0, "整包不合格 = 一行都不落");
492
+ // 坏 body 与坏包是两个形状,都是 400 但码不同:调用方据此知道改哪一头。
493
+ assert.equal((await call(receive, { body: "[1,2]" })).body.error, "invalid-body");
494
+ assert.equal((await call(undo, { body: "{}" })).body.error, "missing-pack-id");
495
+ // 抛错兜在共用层里,但日志迹要接到宿主 logger——不接就是一条静默的 500,现场只剩 internal。
496
+ s.service.importSharePack = () => { throw new Error("database is locked"); };
497
+ s.service.deleteSharePack = () => { throw new Error("disk i/o error"); };
498
+ const boom = await call(receive, { body: JSON.stringify({ pack: goodPack(), confirm: true }) });
499
+ assert.equal(boom.status, 500);
500
+ assert.deepEqual(boom.body, { error: "internal" }, "对外不带原始错误文本");
501
+ assert.match(warnings.join(" "), /share import failed/);
502
+ assert.match(warnings.join(" "), /database is locked/, "原始文本要留在日志里");
503
+ const boomUndo = await call(undo, { body: JSON.stringify({ packId: PACK_ID, confirm: true }) });
504
+ assert.equal(boomUndo.status, 500);
505
+ assert.match(warnings.join(" "), /share undo failed/);
506
+ // 方法整个不在(headless/旧装配)是 503,不是 500:这条路根本没接线。
507
+ delete s.service.importSharePack;
508
+ delete s.service.deleteSharePack;
509
+ for (const [path, body] of [
510
+ [RECEIVE_PATH, JSON.stringify({ pack: goodPack(), confirm: true })],
511
+ [UNDO_PATH, JSON.stringify({ packId: PACK_ID, confirm: true })]
512
+ ]) {
513
+ const out = await call(receiveRoute(s, path), { url: path, body });
514
+ assert.equal(out.status, 503, path);
515
+ assert.deepEqual(out.body, { error: "share-import-unavailable" });
516
+ }
517
+ } finally { s.close(); }
518
+ });
519
+
520
+ test("a logger that throws on the 500 path still gets exactly one reply", async () => {
521
+ // 这一条问的是**端到端**:日志那一环坏掉时,宿主这张脸仍须恰好回一次 500。
522
+ // C6 把守卫还回共用层(warnSafely)之后,它不再顺带证明面板的 .catch——回调里根本
523
+ // 不会再抛出来了。.catch 由下面那条「sink 自己坏」的用例单独咬(那是它唯一还能被
524
+ // 走到的路径),两层各挡一类,谁都不是装饰。
525
+ const s = receiveSetup("secret-token", { logger: { warn: () => { throw new Error("log sink on fire"); } } });
526
+ try {
527
+ s.service.importSharePack = () => { throw new Error("database is locked"); };
528
+ s.service.deleteSharePack = () => { throw new Error("disk i/o error"); };
529
+ for (const [path, body] of [
530
+ [RECEIVE_PATH, JSON.stringify({ pack: goodPack(), confirm: true })],
531
+ [UNDO_PATH, JSON.stringify({ packId: PACK_ID, confirm: true })]
532
+ ]) {
533
+ const out = await call(receiveRoute(s, path), { url: path, body });
534
+ assert.equal(out.heads, 1, `${path} 日志故障也只许写一次响应`);
535
+ assert.equal(out.status, 500, `${path} 日志故障不许把答复一起带走`);
536
+ assert.deepEqual(out.body, { error: "internal" });
537
+ assert.equal(out.rejection, null, `${path} 的 promise 链不该把错误漏给调用方`);
538
+ }
539
+ assert.equal(rowsOf(s.store).length, 0);
540
+ } finally { s.close(); }
541
+ });
542
+
543
+ test("a sink that fails mid-write is caught by the chain's own .catch", async () => {
544
+ // 共用层现在从不抛,日志也被守卫包住,所以 .catch 唯一还能被走到的路径是**面自己**
545
+ // 在写出答复时抛:sendJson 先 writeHead 再 end(JSON.stringify(...)),真 socket 上
546
+ // 「一个响应两写」的第二次 writeHead 就抛在这里(Node 的 writeHead 当场把头发出去,
547
+ // 所以第二次写会撞上 ERR_HTTP_HEADERS_SENT)。这类抛错发生在 .then 的回调里,外层那个
548
+ // 同步 try 早已退栈——没有 .catch 就是一个没人接的 rejection、客户端一个字节都收不到
549
+ // (#410)。C5 的第 13 条不再能咬它,这条是它独立的锁。
550
+ const s = receiveSetup();
551
+ try {
552
+ const receive = receiveRoute(s, RECEIVE_PATH);
553
+ const undo = receiveRoute(s, UNDO_PATH);
554
+ // 两条都走干跑:这条用例问的只有「答复阶段抛出的错谁接」,不想把「写已经落了、
555
+ // 回给对面却失败」这件事混进同一句断言里(那是 C7 CLI 重入时要面对的另一种形状)。
556
+ const cases = [
557
+ [receive, RECEIVE_PATH, JSON.stringify({ pack: goodPack() })],
558
+ [undo, UNDO_PATH, JSON.stringify({ packId: PACK_ID })]
559
+ ];
560
+ for (const [route, path, body] of cases) {
561
+ const out = await call(route, { url: path, body, failFirstHead: true });
562
+ assert.equal(out.rejection, null, `${path} 回调里抛出的错必须由 .catch 接住`);
563
+ assert.equal(out.status, 500, `${path} sink 坏了也要把答复交出去`);
564
+ assert.deepEqual(out.body, { error: "internal" });
565
+ assert.equal(out.heads, 1, "第一次 writeHead 抛的那一次什么都没写出去,成功的只许有一次");
566
+ }
567
+ assert.equal(rowsOf(s.store).length, 0);
568
+ } finally { s.close(); }
569
+ });
570
+
571
+ test("undo through the panel counts before it deletes and needs confirm to delete", async () => {
572
+ const s = receiveSetup();
573
+ try {
574
+ const receive = receiveRoute(s, RECEIVE_PATH);
575
+ const undo = receiveRoute(s, UNDO_PATH);
576
+ const nothing = await call(undo, { body: "{}" });
577
+ assert.equal(nothing.status, 400);
578
+ assert.equal(nothing.body.error, "missing-pack-id", "没有 id 就不许碰删除那条路");
579
+ await call(receive, { body: JSON.stringify({ pack: goodPack(), confirm: true }) });
580
+ assert.equal(rowsOf(s.store).length, 1);
581
+ const preview = await call(undo, { body: JSON.stringify({ packId: PACK_ID }) });
582
+ assert.equal(preview.status, 200);
583
+ assert.equal(preview.body.deleted, 1);
584
+ assert.equal(preview.body.dry_run, true);
585
+ assert.equal(preview.body.sample.length, 1, "清点清单是给人核对「收起的是哪一批」的");
586
+ assert.equal(rowsOf(s.store).length, 1, "预览那一次一行都没删");
587
+ const gone = await call(undo, { body: JSON.stringify({ packId: PACK_ID, confirm: true }) });
588
+ assert.equal(gone.body.dry_run, false);
589
+ assert.equal(gone.body.deleted, 1);
590
+ assert.equal(rowsOf(s.store).length, 0, "退出是物删,不归档");
591
+ const again = await call(undo, { body: JSON.stringify({ packId: PACK_ID, confirm: true }) });
592
+ assert.equal(again.status, 200);
593
+ assert.equal(again.body.deleted, 0, "重跑说 0,不报错");
594
+ } finally { s.close(); }
595
+ });
596
+
597
+ // --- Bearer 数据面(src/api-standalone.js)-----------------------------------
598
+ //
599
+ // 这一面没有 policy 名:除 GET /health 外每条路由都要自己的 Bearer token(面板那半的
600
+ // token-write 在这里压根不存在)。真 HTTP 照 test/standalone-api.test.js:18 与车道 A 的
601
+ // 起法(port 0 + ready + api.token + server.close),库仍要落文件——收侧吃的是
602
+ // share_pack_id / external_id 那两列与部分 UNIQUE 索引,`:memory:` 摸不到真的行。
603
+ // 偶发的 Windows EACCES(保留端口段)是环境性的,重跑即可,不改代码去迁就它。
604
+
605
+ function standaloneSetup() {
606
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-receive-std-"));
607
+ const store = createStore(join(dir, "memory.db"));
608
+ const service = createService({ store, mirror: null, config: {} });
609
+ const settings = createSettings(store.db);
610
+ const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
611
+ return api.ready.then(() => {
612
+ const handlers = api.server.listeners("request");
613
+ assert.equal(handlers.length, 1, "这台面上只许挂着一个 request handler,否则下面那条锁问不出是谁");
614
+ return {
615
+ api, store, service,
616
+ base: `http://127.0.0.1:${api.port}`,
617
+ auth: { authorization: `Bearer ${api.token}` },
618
+ handler: handlers[0],
619
+ close() {
620
+ api.server.closeIdleConnections?.();
621
+ api.server.close();
622
+ try { store.close(); } catch { /* already closed */ }
623
+ rmSync(dir, { recursive: true, force: true });
624
+ }
625
+ };
626
+ });
627
+ }
628
+
629
+ /** 喂给 mounted handler 的假 req:与真 IncomingMessage 一样是 EventEmitter。 */
630
+ function standaloneReq({ method = "POST", url = "/receive", body = "", auth = null }) {
631
+ const req = new EventEmitter();
632
+ req.method = method;
633
+ req.url = url;
634
+ req.headers = auth ? { authorization: auth } : {};
635
+ process.nextTick(() => {
636
+ req.emit("data", Buffer.from(body));
637
+ req.emit("end");
638
+ });
639
+ return req;
640
+ }
641
+
642
+ test("the data plane gates receive, lands a pack and undoes it", async () => {
643
+ const h = await standaloneSetup();
644
+ // 一条死线,不是装饰:路由整个没接上时(变异探针实测——把非 POST 的 405 那一支删掉)
645
+ // server 一个字都不答、连接一直悬着,用例要挂满 305 秒才红,那是在拖整条 CI 腿而不是
646
+ // 在报回归。超时之后断言「必须得到答复」这一句,比一个 TimeoutError 堆栈有用得多。
647
+ const viaHttp = async (p, init = {}) => {
648
+ const res = await fetch(`${h.base}${p}`, { ...init, signal: AbortSignal.timeout(3000) }).catch(() => null);
649
+ assert.ok(res, `${init.method ?? "GET"} ${p}:数据面必须在 3s 内给出答复(挂着不答 = 对面永远等不到)`);
650
+ return res;
651
+ };
652
+ try {
653
+ const packJson = JSON.stringify({ pack: goodPack() });
654
+ // 本面除 /health 外一律要 Bearer:没有 policy 名可绕,401 是唯一的前门。
655
+ for (const [path, body] of [["/receive", packJson], ["/receive/undo", JSON.stringify({ packId: PACK_ID })]]) {
656
+ const anon = await viaHttp(path, { method: "POST", body });
657
+ assert.equal(anon.status, 401, `POST ${path} 不带 token 一步都不许往下走`);
658
+ assert.deepEqual(await anon.json(), { error: "unauthorized" });
659
+ }
660
+
661
+ const dry = await viaHttp("/receive", { method: "POST", headers: h.auth, body: packJson });
662
+ assert.equal(dry.status, 200);
663
+ const dryBody = await dry.json();
664
+ assert.equal(dryBody.dry_run, true);
665
+ assert.equal(dryBody.created, 1);
666
+ assert.equal(rowsOf(h.store).length, 0, "没点确认就一个字都不许写");
667
+
668
+ const real = await viaHttp("/receive", { method: "POST", headers: h.auth, body: JSON.stringify({ pack: goodPack(), confirm: true }) });
669
+ assert.equal((await real.json()).created, 1);
670
+ assert.equal(rowsOf(h.store).length, 1);
671
+ assert.equal(rowsOf(h.store)[0].share_pack_id, PACK_ID, "落的必须是带包身份的外来行");
672
+
673
+ const again = await viaHttp("/receive", { method: "POST", headers: h.auth, body: JSON.stringify({ pack: goodPack(), confirm: true }) });
674
+ const againBody = await again.json();
675
+ assert.equal(againBody.created, 0, "幂等要穿过 HTTP 这一层,不只是 service 内部");
676
+ assert.equal(againBody.skipped_existing, 1);
677
+ assert.equal(rowsOf(h.store).length, 1, "重跑一行都不多");
678
+
679
+ // 非 POST 一律 405,与面板逐字同款(#411:两张皮只许差在鉴权、路径与面板独有的
680
+ // disposition/logger,状态码不许是第四处差别)。带着 token 走一遍,问的正是
681
+ // 「405 这一支不藏在 401 后面」。
682
+ for (const [path, method] of [["/receive", "GET"], ["/receive", "PUT"], ["/receive/undo", "GET"], ["/receive/undo", "DELETE"]]) {
683
+ const out = await viaHttp(path, { method, headers: h.auth });
684
+ assert.equal(out.status, 405, `${method} ${path} 必须回 405,got ${out.status}`);
685
+ assert.deepEqual(await out.json(), { error: "method-not-allowed" });
686
+ }
687
+
688
+ const badJson = await viaHttp("/receive", { method: "POST", headers: h.auth, body: "{oops" });
689
+ assert.equal(badJson.status, 400);
690
+ assert.equal((await badJson.json()).error, "invalid-json", "发坏了 ≠ 发错了形状 ≠ 没带包");
691
+ const badKind = await viaHttp("/receive", { method: "POST", headers: h.auth, body: JSON.stringify({ pack: { kind: "nope" } }) });
692
+ assert.equal(badKind.status, 400);
693
+ assert.equal((await badKind.json()).error, "invalid-kind", "闸门那半的码也要从这张脸上原样出来");
694
+ const badUndo = await viaHttp("/receive/undo", { method: "POST", headers: h.auth, body: "{}" });
695
+ assert.equal(badUndo.status, 400);
696
+ assert.equal((await badUndo.json()).error, "missing-pack-id");
697
+
698
+ const preview = await viaHttp("/receive/undo", { method: "POST", headers: h.auth, body: JSON.stringify({ packId: PACK_ID }) });
699
+ const previewBody = await preview.json();
700
+ assert.equal(previewBody.deleted, 1);
701
+ assert.equal(previewBody.dry_run, true);
702
+ assert.equal(rowsOf(h.store).length, 1, "预览那一次一行都没删");
703
+ const gone = await viaHttp("/receive/undo", { method: "POST", headers: h.auth, body: JSON.stringify({ packId: PACK_ID, confirm: true }) });
704
+ assert.equal((await gone.json()).deleted, 1);
705
+ assert.equal(rowsOf(h.store).length, 0, "退出是物删,不归档");
706
+
707
+ // 超限那一支:readBody 自己答 413 并把 text 解析成 null,handler 少那句 return 就会
708
+ // 在已经发出的响应上再写一次(真 socket 上第二次 writeHead 抛 ERR_HTTP_HEADERS_SENT)。
709
+ // 这里按状态码与答复体核对口径与面板一致(本面 sendJson 没有 headers 参数,也就
710
+ // 没有 Content-Disposition 可锁)。
711
+ const oversize = await viaHttp("/receive", { method: "POST", headers: h.auth, body: "x".repeat(MAX_BODY_BYTES + 1) });
712
+ assert.equal(oversize.status, 413);
713
+ assert.equal((await oversize.json()).error, "payload-too-large");
714
+ assert.equal(oversize.headers.get("content-disposition"), null, "本面不出附件头");
715
+ } finally { await h.close(); }
716
+ });
717
+
718
+ test("the data plane's own .catch answers when the sink fails mid-write", async () => {
719
+ // 共用层从不抛、日志也已被守卫吞掉,所以本面这条链还能被走到的只剩**面自己**写答复
720
+ // 那一步。真 socket 在这里帮不上忙(writeHead 一抛,头就已经发出去了,第二次写只会
721
+ // 再抛一次),所以直接驱动挂在这个 server 上的那个 handler,让假 sink 第一次抛、
722
+ // 第二次正常:能等到答复,就证明 .catch 在位。等不到(竞速输给超时)就是 #410 的
723
+ // 「一个字节都没回」——那正是这份文件历史上反复出现的那一类故障。
724
+ const h = await standaloneSetup();
725
+ try {
726
+ for (const [path, body] of [
727
+ ["/receive", JSON.stringify({ pack: goodPack() })],
728
+ ["/receive/undo", JSON.stringify({ packId: PACK_ID })]
729
+ ]) {
730
+ const res = new FakeRes({ failFirstHead: true });
731
+ h.handler(standaloneReq({ url: path, body, auth: `Bearer ${h.api.token}` }), res);
732
+ const settled = await Promise.race([res.settled, new Promise((r) => setTimeout(() => r("timeout"), 3000))]);
733
+ assert.equal(settled, "ended", `${path} 的 promise 链必须由 .catch 接住,否则永远等不到答复`);
734
+ assert.equal(res.statusCode, 500, `${path} sink 坏了也要把答复交出去`);
735
+ assert.deepEqual(res.body ? JSON.parse(res.body) : null, { error: "internal" });
736
+ assert.equal(res.heads, 1, "抛掉的那一次没写出去,成功的只许有一次");
737
+ }
738
+ assert.equal(rowsOf(h.store).length, 0);
739
+ } finally { await h.close(); }
740
+ });
741
+
742
+ // 两张皮都接上之后才补得了这一条(C5 时数据面还没有 /receive,锁无可锁)。车道 A 的
743
+ // 同款先例在 test/share-http.test.js 末尾。它管的是**方向**:上一段静态锁说「口径字面量
744
+ // 不许出现在面上」,这一条说反面——共用构造器必须真的被两侧各调一次。#411 那次的形状是
745
+ // 面板侧修好了、数据面停在复制的旧形态,而复制出去的代码不会留下任何让静态检查发现的痕迹。
746
+ test("both surfaces call the shared receive builders", () => {
747
+ for (const file of ["api.js", "api-standalone.js"]) {
748
+ const text = src(file);
749
+ for (const needle of ["receiveReply(", "undoReply(", "parseReceiveBody(", "parseUndoBody("]) {
750
+ assert.ok(text.includes(needle), `${file} must call ${needle}`);
751
+ }
752
+ assert.ok(!text.includes("share-import-unavailable"), `${file} must not re-implement the unavailable literal`);
753
+ assert.ok(!text.includes("invalid-kind"), `${file} must not re-implement an import code`);
754
+ assert.ok(!text.includes("missing-pack"), `${file} must not re-implement a parse code`);
755
+ }
756
+ });