@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -402
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3394
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -584
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5662
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -0
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +15 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -204
  162. package/scripts/release-notes.mjs +164 -161
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3394
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1317
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -584
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5662
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -0
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2988
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4184
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -0
  318. package/test/conflict-ledger.test.js +242 -0
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -320
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -0
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -209
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -211
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -106
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -494
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -0
  407. package/test/panel-tokens.test.js +245 -0
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -136
  453. package/test/release-notes.test.js +265 -240
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -178
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1418
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -627
  529. package/test/wiki-claims.test.js +194 -0
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -0
  535. package/test/wiki-queries.test.js +398 -277
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,756 +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
- });
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
+ });