@things-factory/headless-twin 10.0.18 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +10 -1
  2. package/dist-server/engine/canonical-ingest.js +110 -33
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/event-time.d.ts +25 -0
  5. package/dist-server/engine/event-time.js +98 -0
  6. package/dist-server/engine/event-time.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +23 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/ingest-dedupe.d.ts +18 -6
  11. package/dist-server/engine/ingest-dedupe.js +201 -15
  12. package/dist-server/engine/ingest-dedupe.js.map +1 -1
  13. package/dist-server/engine/ingest-health.d.ts +60 -1
  14. package/dist-server/engine/ingest-health.js +43 -8
  15. package/dist-server/engine/ingest-health.js.map +1 -1
  16. package/dist-server/engine/kpi-fold.d.ts +19 -0
  17. package/dist-server/engine/kpi-fold.js +4 -1
  18. package/dist-server/engine/kpi-fold.js.map +1 -1
  19. package/dist-server/engine/kpi-query.js +23 -3
  20. package/dist-server/engine/kpi-query.js.map +1 -1
  21. package/dist-server/engine/oee-accumulator.d.ts +35 -2
  22. package/dist-server/engine/oee-accumulator.js +56 -4
  23. package/dist-server/engine/oee-accumulator.js.map +1 -1
  24. package/dist-server/engine/twin-engine.d.ts +72 -3
  25. package/dist-server/engine/twin-engine.js +118 -5
  26. package/dist-server/engine/twin-engine.js.map +1 -1
  27. package/dist-server/index.js +21 -0
  28. package/dist-server/index.js.map +1 -1
  29. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
  30. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
  31. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
  32. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
  33. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
  34. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
  35. package/dist-server/migrations/index.js +5 -1
  36. package/dist-server/migrations/index.js.map +1 -1
  37. package/dist-server/routes.js +19 -16
  38. package/dist-server/routes.js.map +1 -1
  39. package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
  40. package/dist-server/service/actuation/actuation-advisor.js +53 -0
  41. package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
  42. package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
  43. package/dist-server/service/actuation/actuation-outcome.js +34 -0
  44. package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
  45. package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
  46. package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
  47. package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
  48. package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
  49. package/dist-server/service/actuation/actuation-rule.js +130 -0
  50. package/dist-server/service/actuation/actuation-rule.js.map +1 -0
  51. package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
  52. package/dist-server/service/actuation/approval-gateway.js +14 -0
  53. package/dist-server/service/actuation/approval-gateway.js.map +1 -0
  54. package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
  55. package/dist-server/service/actuation/catch-up-effects.js +120 -0
  56. package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
  57. package/dist-server/service/actuation/command-dispatcher.d.ts +230 -0
  58. package/dist-server/service/actuation/command-dispatcher.js +269 -0
  59. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  60. package/dist-server/service/actuation/command-store.d.ts +11 -0
  61. package/dist-server/service/actuation/command-store.js +219 -0
  62. package/dist-server/service/actuation/command-store.js.map +1 -0
  63. package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
  64. package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
  65. package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
  66. package/dist-server/service/actuation/effect-match.d.ts +53 -0
  67. package/dist-server/service/actuation/effect-match.js +65 -0
  68. package/dist-server/service/actuation/effect-match.js.map +1 -0
  69. package/dist-server/service/actuation/index.d.ts +25 -0
  70. package/dist-server/service/actuation/index.js +30 -0
  71. package/dist-server/service/actuation/index.js.map +1 -0
  72. package/dist-server/service/actuation/retry-plan.d.ts +63 -0
  73. package/dist-server/service/actuation/retry-plan.js +99 -0
  74. package/dist-server/service/actuation/retry-plan.js.map +1 -0
  75. package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
  76. package/dist-server/service/actuation/rule-evaluate.js +205 -0
  77. package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
  78. package/dist-server/service/actuation/rule-loop.d.ts +5 -0
  79. package/dist-server/service/actuation/rule-loop.js +156 -0
  80. package/dist-server/service/actuation/rule-loop.js.map +1 -0
  81. package/dist-server/service/actuation/rule-runner.d.ts +52 -0
  82. package/dist-server/service/actuation/rule-runner.js +135 -0
  83. package/dist-server/service/actuation/rule-runner.js.map +1 -0
  84. package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
  85. package/dist-server/service/actuation/rule-wiring.js +80 -0
  86. package/dist-server/service/actuation/rule-wiring.js.map +1 -0
  87. package/dist-server/service/actuation/settle-effects.d.ts +21 -0
  88. package/dist-server/service/actuation/settle-effects.js +101 -0
  89. package/dist-server/service/actuation/settle-effects.js.map +1 -0
  90. package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
  91. package/dist-server/service/actuation/sweep-dispatch.js +125 -0
  92. package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
  93. package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
  94. package/dist-server/service/actuation/twin-command-resolver.js +406 -0
  95. package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
  96. package/dist-server/service/actuation/twin-command.d.ts +111 -0
  97. package/dist-server/service/actuation/twin-command.js +170 -0
  98. package/dist-server/service/actuation/twin-command.js.map +1 -0
  99. package/dist-server/service/actuation/twin-now.d.ts +41 -0
  100. package/dist-server/service/actuation/twin-now.js +65 -0
  101. package/dist-server/service/actuation/twin-now.js.map +1 -0
  102. package/dist-server/service/index.d.ts +5 -2
  103. package/dist-server/service/index.js +28 -17
  104. package/dist-server/service/index.js.map +1 -1
  105. package/dist-server/service/reference/actuation-routing.d.ts +60 -0
  106. package/dist-server/service/reference/actuation-routing.js +126 -0
  107. package/dist-server/service/reference/actuation-routing.js.map +1 -0
  108. package/dist-server/service/reference/actuation-target.d.ts +65 -0
  109. package/dist-server/service/reference/actuation-target.js +94 -0
  110. package/dist-server/service/reference/actuation-target.js.map +1 -0
  111. package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
  112. package/dist-server/service/reference/connection-portability-resolver.js +181 -0
  113. package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
  114. package/dist-server/service/reference/connection-portability.d.ts +138 -0
  115. package/dist-server/service/reference/connection-portability.js +230 -0
  116. package/dist-server/service/reference/connection-portability.js.map +1 -0
  117. package/dist-server/service/reference/fill-cadence.d.ts +17 -0
  118. package/dist-server/service/reference/fill-cadence.js +31 -0
  119. package/dist-server/service/reference/fill-cadence.js.map +1 -0
  120. package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
  121. package/dist-server/service/reference/fill-dedupe.js +63 -0
  122. package/dist-server/service/reference/fill-dedupe.js.map +1 -0
  123. package/dist-server/service/reference/fill-loop.d.ts +35 -0
  124. package/dist-server/service/reference/fill-loop.js +187 -0
  125. package/dist-server/service/reference/fill-loop.js.map +1 -0
  126. package/dist-server/service/reference/fill-span.d.ts +10 -0
  127. package/dist-server/service/reference/fill-span.js +37 -0
  128. package/dist-server/service/reference/fill-span.js.map +1 -0
  129. package/dist-server/service/reference/hook-contract.d.ts +149 -22
  130. package/dist-server/service/reference/hook-contract.js +236 -23
  131. package/dist-server/service/reference/hook-contract.js.map +1 -1
  132. package/dist-server/service/reference/hook-rejected.d.ts +25 -0
  133. package/dist-server/service/reference/hook-rejected.js +48 -0
  134. package/dist-server/service/reference/hook-rejected.js.map +1 -0
  135. package/dist-server/service/reference/hook-store.d.ts +3 -0
  136. package/dist-server/service/reference/hook-store.js +28 -0
  137. package/dist-server/service/reference/hook-store.js.map +1 -0
  138. package/dist-server/service/reference/index.d.ts +4 -1
  139. package/dist-server/service/reference/index.js +5 -1
  140. package/dist-server/service/reference/index.js.map +1 -1
  141. package/dist-server/service/reference/live-cadence.d.ts +68 -0
  142. package/dist-server/service/reference/live-cadence.js +44 -0
  143. package/dist-server/service/reference/live-cadence.js.map +1 -0
  144. package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
  145. package/dist-server/service/reference/live-feed-lease.js +56 -0
  146. package/dist-server/service/reference/live-feed-lease.js.map +1 -0
  147. package/dist-server/service/reference/live-ingest.d.ts +21 -0
  148. package/dist-server/service/reference/live-ingest.js +66 -0
  149. package/dist-server/service/reference/live-ingest.js.map +1 -0
  150. package/dist-server/service/reference/reference-adapter.d.ts +355 -2
  151. package/dist-server/service/reference/reference-adapter.js +19 -1
  152. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  153. package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
  154. package/dist-server/service/reference/reference-fill-run.js +142 -0
  155. package/dist-server/service/reference/reference-fill-run.js.map +1 -0
  156. package/dist-server/service/reference/reference-fill.d.ts +90 -0
  157. package/dist-server/service/reference/reference-fill.js +141 -0
  158. package/dist-server/service/reference/reference-fill.js.map +1 -0
  159. package/dist-server/service/reference/reference-hook.d.ts +64 -6
  160. package/dist-server/service/reference/reference-hook.js +265 -27
  161. package/dist-server/service/reference/reference-hook.js.map +1 -1
  162. package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
  163. package/dist-server/service/reference/reference-link-groups.js +15 -0
  164. package/dist-server/service/reference/reference-link-groups.js.map +1 -0
  165. package/dist-server/service/reference/reference-links.d.ts +14 -0
  166. package/dist-server/service/reference/reference-links.js +73 -0
  167. package/dist-server/service/reference/reference-links.js.map +1 -0
  168. package/dist-server/service/reference/reference-live.d.ts +1 -0
  169. package/dist-server/service/reference/reference-live.js +32 -6
  170. package/dist-server/service/reference/reference-live.js.map +1 -1
  171. package/dist-server/service/reference/reference-master.d.ts +56 -2
  172. package/dist-server/service/reference/reference-master.js +33 -3
  173. package/dist-server/service/reference/reference-master.js.map +1 -1
  174. package/dist-server/service/reference/reference-resolver.d.ts +27 -0
  175. package/dist-server/service/reference/reference-resolver.js +79 -1
  176. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  177. package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
  178. package/dist-server/service/twin-event/twin-event-keys.js +36 -4
  179. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  180. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  181. package/dist-server/service/twin-event/twin-event.js +11 -2
  182. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  183. package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
  184. package/dist-server/service/twin-model/choose-recipe.js +33 -0
  185. package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
  186. package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
  187. package/dist-server/service/twin-model/external-resolver.js +109 -0
  188. package/dist-server/service/twin-model/external-resolver.js.map +1 -0
  189. package/dist-server/service/twin-model/project-structure.js +16 -3
  190. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  191. package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
  192. package/dist-server/service/twin-model/twin-equipment.js +10 -0
  193. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  194. package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
  195. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  196. package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
  197. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  198. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  199. package/dist-server/service/twin-space/twin-space.d.ts +29 -0
  200. package/dist-server/service/twin-space/twin-space.js +13 -0
  201. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  202. package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
  203. package/dist-server/service/twin-subject/event-subjects.js +8 -1
  204. package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
  205. package/dist-shared/entity-delta.js +1 -1
  206. package/dist-shared/entity-delta.js.map +1 -1
  207. package/package.json +7 -7
  208. package/server/engine/canonical-ingest.ts +119 -33
  209. package/server/engine/event-time.ts +96 -0
  210. package/server/engine/index.ts +20 -0
  211. package/server/engine/ingest-dedupe.ts +203 -27
  212. package/server/engine/ingest-health.ts +91 -6
  213. package/server/engine/kpi-fold.ts +24 -1
  214. package/server/engine/kpi-query.ts +23 -4
  215. package/server/engine/oee-accumulator.ts +80 -8
  216. package/server/engine/twin-engine.ts +119 -5
  217. package/server/index.ts +23 -0
  218. package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
  219. package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
  220. package/server/migrations/index.ts +5 -1
  221. package/server/routes.ts +19 -20
  222. package/server/service/actuation/actuation-advisor.ts +124 -0
  223. package/server/service/actuation/actuation-outcome.ts +97 -0
  224. package/server/service/actuation/actuation-rule-resolver.ts +147 -0
  225. package/server/service/actuation/actuation-rule.ts +139 -0
  226. package/server/service/actuation/approval-gateway.ts +44 -0
  227. package/server/service/actuation/catch-up-effects.ts +152 -0
  228. package/server/service/actuation/command-dispatcher.ts +511 -0
  229. package/server/service/actuation/command-store.ts +236 -0
  230. package/server/service/actuation/dispatch-after-approval.ts +71 -0
  231. package/server/service/actuation/effect-match.ts +124 -0
  232. package/server/service/actuation/index.ts +27 -0
  233. package/server/service/actuation/retry-plan.ts +145 -0
  234. package/server/service/actuation/rule-evaluate.ts +352 -0
  235. package/server/service/actuation/rule-loop.ts +160 -0
  236. package/server/service/actuation/rule-runner.ts +216 -0
  237. package/server/service/actuation/rule-wiring.ts +84 -0
  238. package/server/service/actuation/settle-effects.ts +115 -0
  239. package/server/service/actuation/sweep-dispatch.ts +179 -0
  240. package/server/service/actuation/twin-command-resolver.ts +387 -0
  241. package/server/service/actuation/twin-command.ts +232 -0
  242. package/server/service/actuation/twin-now.ts +97 -0
  243. package/server/service/index.ts +11 -0
  244. package/server/service/reference/actuation-routing.ts +165 -0
  245. package/server/service/reference/actuation-target.ts +163 -0
  246. package/server/service/reference/connection-portability-resolver.ts +171 -0
  247. package/server/service/reference/connection-portability.ts +285 -0
  248. package/server/service/reference/fill-cadence.ts +29 -0
  249. package/server/service/reference/fill-dedupe.ts +66 -0
  250. package/server/service/reference/fill-loop.ts +153 -0
  251. package/server/service/reference/fill-span.ts +31 -0
  252. package/server/service/reference/hook-contract.ts +280 -22
  253. package/server/service/reference/hook-rejected.ts +53 -0
  254. package/server/service/reference/hook-store.ts +28 -0
  255. package/server/service/reference/index.ts +5 -1
  256. package/server/service/reference/live-cadence.ts +129 -0
  257. package/server/service/reference/live-feed-lease.ts +56 -0
  258. package/server/service/reference/live-ingest.ts +94 -0
  259. package/server/service/reference/reference-adapter.ts +392 -5
  260. package/server/service/reference/reference-fill-run.ts +165 -0
  261. package/server/service/reference/reference-fill.ts +223 -0
  262. package/server/service/reference/reference-hook.ts +355 -32
  263. package/server/service/reference/reference-link-groups.ts +21 -0
  264. package/server/service/reference/reference-links.ts +77 -0
  265. package/server/service/reference/reference-live.ts +30 -5
  266. package/server/service/reference/reference-master.ts +90 -5
  267. package/server/service/reference/reference-resolver.ts +77 -2
  268. package/server/service/twin-event/twin-event-keys.ts +37 -4
  269. package/server/service/twin-event/twin-event.ts +18 -1
  270. package/server/service/twin-model/choose-recipe.ts +31 -0
  271. package/server/service/twin-model/external-resolver.ts +161 -0
  272. package/server/service/twin-model/project-structure.ts +16 -3
  273. package/server/service/twin-model/twin-equipment.ts +22 -0
  274. package/server/service/twin-model/twin-lineage-query.ts +82 -1
  275. package/server/service/twin-model/twin-model-item-query.ts +95 -4
  276. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  277. package/server/service/twin-space/twin-space.ts +39 -0
  278. package/server/service/twin-subject/event-subjects.ts +8 -1
  279. package/shared/entity-delta.ts +1 -1
  280. package/test/ack-shape-alignment.test.ts +97 -0
  281. package/test/actuation-approval-door.test.ts +421 -0
  282. package/test/actuation-dispatch.test.ts +209 -0
  283. package/test/actuation-effect.test.ts +162 -0
  284. package/test/actuation-outcome.test.ts +152 -0
  285. package/test/actuation-rule-runner.test.ts +281 -0
  286. package/test/actuation-rule.test.ts +266 -0
  287. package/test/actuation-seam.test.ts +249 -0
  288. package/test/actuation-settle.test.ts +164 -0
  289. package/test/actuation-target.test.ts +145 -0
  290. package/test/canonical-ingest-vocabularies.test.ts +49 -2
  291. package/test/capability-mapping.test.ts +1 -1
  292. package/test/choose-recipe.test.ts +117 -0
  293. package/test/command-store-writes-columns.test.ts +126 -0
  294. package/test/connection-portability-doors.test.ts +87 -0
  295. package/test/connector-capability-declaration.test.ts +54 -4
  296. package/test/declaration-reaches-model.test.ts +43 -0
  297. package/test/dedupe-transformation.test.ts +112 -0
  298. package/test/entity-delta.test.ts +1 -1
  299. package/test/event-subjects.test.ts +2 -1
  300. package/test/external-resolver.test.ts +171 -0
  301. package/test/fill-dedupe.test.ts +59 -0
  302. package/test/fill-loop.test.ts +35 -0
  303. package/test/first-envelope.test.ts +153 -0
  304. package/test/fold-shift-dst.test.ts +110 -0
  305. package/test/home-in-area.test.ts +90 -0
  306. package/test/hook-rejected-shape.test.ts +86 -0
  307. package/test/hook-response-sum.test.ts +193 -0
  308. package/test/hook-sequence.test.ts +333 -0
  309. package/test/ingest-dedupe.test.ts +56 -0
  310. package/test/ingest-expected-quiet.test.ts +111 -0
  311. package/test/ingest-idempotent.test.ts +79 -0
  312. package/test/ingest-reconcile-four-ways.test.ts +97 -0
  313. package/test/ingest-rules-header.test.ts +100 -0
  314. package/test/ingest-window-rolls-unobserved.test.ts +72 -0
  315. package/test/intake-mapping-parity.test.ts +175 -0
  316. package/test/live-cadence.test.ts +170 -0
  317. package/test/live-feed-lease.test.ts +63 -0
  318. package/test/live-mirror-parity.test.ts +1 -1
  319. package/test/oee-accumulator.test.ts +3 -3
  320. package/test/oee-site-calendar.test.ts +146 -0
  321. package/test/order-and-transaction-split.test.ts +107 -0
  322. package/test/reference-fill.test.ts +235 -0
  323. package/test/reference-hook.test.ts +114 -23
  324. package/test/reference-links.test.ts +56 -0
  325. package/test/retry-plan.test.ts +139 -0
  326. package/test/sweep-dispatch.test.ts +191 -0
  327. package/test/twin-event-keys.test.ts +20 -6
  328. package/test/twin-model-item-db.test.ts +6 -6
  329. package/test/twin-model-tree-db.test.ts +6 -6
  330. package/tsconfig.shared.tsbuildinfo +1 -1
  331. package/tsconfig.tsbuildinfo +1 -1
@@ -12,7 +12,18 @@
12
12
  */
13
13
  import { test } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
- import { HOOK_STATUS, secretFromHeaders, secretMatches, producedInstance } from '../server/service/reference/hook-contract.js'
15
+
16
+ import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract'
17
+ import { signWebhook, WEBHOOK_HEADER } from '@operato/ops-contract/webhook'
18
+
19
+ import {
20
+ authorizeHook,
21
+ producedInstance,
22
+ secretFromHeaders,
23
+ secretMatches,
24
+ seqOf,
25
+ withSeq
26
+ } from '../server/service/reference/hook-contract.js'
16
27
 
17
28
  test('★ 비밀값을 두 가지 방식으로 받는다 — 밀어 주는 쪽의 관습이 갈린다', () => {
18
29
  assert.equal(secretFromHeaders({ 'x-twin-hook-secret': 'abc' }), 'abc')
@@ -54,33 +65,113 @@ test('★ 그 연결이 만든 트윈만 받는다 — 남의 트윈에 밀어
54
65
  assert.equal(producedInstance({}, 'twin-a'), undefined)
55
66
  })
56
67
 
57
- test('★ 응답 코드가 재시도로 풀리는 것과 그렇지 않은 것을 가른다', () => {
68
+ test('★ 응답 코드 표가 계약에 있다 — 여기에 두 번째 벌을 만들지 않는다', () => {
58
69
  /*
59
- * 재시도해도 같은 것: 비밀값이 다르다 · 모르는 대상 · 옮길 수 없는 본문 · 커넥터가 훅을 모른다
60
- * 사람이 손대야 하는 것: 트윈이 돌지 않는다
61
- * 재시도하면 될 수 있는 것: 우리 쪽 오류
70
+ * 예전에는 이 표가 훅 규약 파일에 있었고 보내는 쪽(operato-plant)에도 있었다. 그래서 같은 409 를 양쪽이
71
+ * 다른 뜻으로 썼다 — 여기서는 「트윈이 안 돈다」, 저쪽에서는 「번호가 비었다」였다.
72
+ *
73
+ * 값을 여기서 다시 단정하지 않는다. 계약이 정본이고 그 시험은 계약 쪽에 있다. 여기서 지키는 것은
74
+ * **이 파일이 계약을 쓰고 있는가**다.
62
75
  */
63
- assert.equal(HOOK_STATUS.ok, 200)
64
- assert.equal(HOOK_STATUS.badPayload, 400)
65
- assert.equal(HOOK_STATUS.badSecret, 401)
66
- assert.equal(HOOK_STATUS.unknownTarget, 404)
67
- assert.equal(HOOK_STATUS.notLive, 409)
68
- assert.equal(HOOK_STATUS.failed, 500)
69
- assert.equal(HOOK_STATUS.unsupported, 501)
70
-
71
- /* 재시도해도 같은 것에 5xx 를 주면 안 된다 — 그쪽이 영원히 다시 보낸다. */
72
- for (const code of [HOOK_STATUS.badPayload, HOOK_STATUS.badSecret, HOOK_STATUS.unknownTarget, HOOK_STATUS.notLive]) {
73
- assert.ok(code < 500, `${code} 는 4xx 여야 한다`)
74
- }
75
- /* 우리 쪽 문제에는 5xx 를 준다 — 그쪽이 다시 보내야 그 사실이 살아남는다. */
76
- assert.ok(HOOK_STATUS.failed >= 500)
76
+ assert.equal(WEBHOOK_STATUS.sequenceGap, 409, '409 는 번호가 비었다는 뜻이다')
77
+ assert.equal(WEBHOOK_STATUS.notLive, 503, '트윈이 안 도는 것은 503 이다')
77
78
  })
78
79
 
79
80
  test('★ 트윈이 돌지 않으면 200 을 주지 않는다 — 그쪽이 그 사실을 버린다', () => {
80
81
  /*
81
- * 200 은 「받아서 반영했다」는 뜻입니다. 트윈이 실시간으로 돌지 않아 한 건도 반영되지 않았는데
82
- * 200 을 주면, 밀어 주는 쪽은 전달된 줄 알고 그 사실을 버립니다. 다시 보내 주지도 않습니다.
82
+ * 200 은 「받아서 반영했다」는 뜻이다. 트윈이 실시간으로 돌지 않아 한 건도 반영되지 않았는데 200 을
83
+ * 주면, 밀어 주는 쪽은 전달된 줄 알고 그 사실을 버린다.
84
+ *
85
+ * 그렇다고 4xx 를 주면 「보낸 요청이 잘못됐다」는 뜻이라 그쪽이 포기할 근거가 된다. 트윈은 누가
86
+ * 띄우면 다시 도므로 들고 기다리는 것이 맞다.
87
+ */
88
+ assert.notEqual(WEBHOOK_STATUS.notLive, WEBHOOK_STATUS.ok)
89
+ assert.equal(webhookSenderAction(WEBHOOK_STATUS.notLive), 'wait')
90
+ })
91
+
92
+ /* ── 인증 — 비밀값과 서명 ─────────────────────────────────────────────────── */
93
+
94
+ const SECRET = 'shared-secret'
95
+ const NOW = Date.parse('2026-08-31T02:00:00.000Z')
96
+ const TS = '2026-08-31T02:00:00.000Z'
97
+ const BODY = '{"batch":[{"seq":1,"scope":"EVENTS","record":{}}]}'
98
+
99
+ test('★ 서명이 오면 서명을 본다', () => {
100
+ const headers = {
101
+ [WEBHOOK_HEADER.timestamp]: TS,
102
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY)
103
+ }
104
+ assert.deepEqual(authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW }), {
105
+ ok: true,
106
+ method: 'signature'
107
+ })
108
+ })
109
+
110
+ test('★ 서명이 없으면 비밀값을 본다 — 이미 붙어 있는 커넥터가 그 방식이다', () => {
111
+ const got = authorizeHook({ headers: { 'x-twin-hook-secret': SECRET }, rawBody: BODY, secret: SECRET, nowMs: NOW })
112
+ assert.deepEqual(got, { ok: true, method: 'secret' })
113
+ })
114
+
115
+ test('★ 서명이 왔는데 받은 바이트가 없으면 거절한다 — 확인할 수 없는 것을 통과시키지 않는다', () => {
116
+ /*
117
+ * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.
118
+ * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.
83
119
  */
84
- assert.notEqual(HOOK_STATUS.notLive, HOOK_STATUS.ok)
85
- assert.equal(HOOK_STATUS.notLive, 409)
120
+ const headers = {
121
+ [WEBHOOK_HEADER.timestamp]: TS,
122
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, TS, BODY)
123
+ }
124
+ const got = authorizeHook({ headers, rawBody: undefined, secret: SECRET, nowMs: NOW })
125
+ assert.equal(got.ok, false)
126
+ assert.match((got as any).reason, /raw body/)
127
+ })
128
+
129
+ test('★ 서명이 왔으면 비밀값 헤더로 넘어가지 않는다 — 맞는 비밀값을 함께 실어도 서명이 틀리면 거절이다', () => {
130
+ const headers = {
131
+ 'x-twin-hook-secret': SECRET,
132
+ [WEBHOOK_HEADER.timestamp]: TS,
133
+ [WEBHOOK_HEADER.signature]: 'deadbeef'
134
+ }
135
+ const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW })
136
+ assert.equal(got.ok, false)
137
+ })
138
+
139
+ test('거절 사유가 무엇을 고칠지 말한다', () => {
140
+ const stale = new Date(NOW - 10 * 60_000).toISOString()
141
+ const headers = {
142
+ [WEBHOOK_HEADER.timestamp]: stale,
143
+ [WEBHOOK_HEADER.signature]: signWebhook(SECRET, stale, BODY)
144
+ }
145
+ const got = authorizeHook({ headers, rawBody: BODY, secret: SECRET, nowMs: NOW })
146
+ assert.deepEqual(got, { ok: false, reason: 'signature expired' })
147
+ })
148
+
149
+ /* ── 커서에 번호를 적는 자리 ──────────────────────────────────────────────── */
150
+
151
+ test('★ 번호는 폴링이 쓰는 커서와 같은 자리에 산다 — 진행 위치를 두 벌로 두지 않는다', () => {
152
+ const before = { streams: { EVENTS: { since: '2026-08-30T00:00:00.000Z', seen: ['a'] } }, firstAttachedAt: 'x' }
153
+ const after = withSeq(before, 'EVENTS', 918342)
154
+
155
+ assert.equal(seqOf(after, 'EVENTS'), 918342)
156
+ assert.equal((after as any).streams.EVENTS.since, before.streams.EVENTS.since, 'since 를 지우지 않는다')
157
+ assert.deepEqual((after as any).streams.EVENTS.seen, ['a'], 'seen 을 지우지 않는다')
158
+ assert.equal((after as any).firstAttachedAt, 'x', '커서의 다른 항목을 지우지 않는다')
159
+ })
160
+
161
+ test('★ 다른 흐름의 번호를 건드리지 않는다 — 한 단위를 올리면서 다른 단위를 되돌리면 그 구간이 다시 온다', () => {
162
+ const before = { streams: { EVENTS: { seq: 10 }, MASTER: { seq: 4 } } }
163
+ const after = withSeq(before, 'EVENTS', 11)
164
+ assert.equal(seqOf(after, 'EVENTS'), 11)
165
+ assert.equal(seqOf(after, 'MASTER'), 4)
166
+ })
167
+
168
+ test('번호가 없는 흐름은 「모른다」다 — 0 으로 메우지 않는다', () => {
169
+ assert.equal(seqOf(undefined, 'EVENTS'), undefined)
170
+ assert.equal(seqOf({}, 'EVENTS'), undefined)
171
+ assert.equal(seqOf({ streams: {} }, 'EVENTS'), undefined)
172
+ assert.equal(seqOf({ streams: { EVENTS: { since: 'x' } } }, 'EVENTS'), undefined)
173
+ })
174
+
175
+ test('커서가 없던 연결에도 번호를 적을 수 있다', () => {
176
+ assert.equal(seqOf(withSeq(undefined, 'EVENTS', 1), 'EVENTS'), 1)
86
177
  })
@@ -0,0 +1,56 @@
1
+ /*
2
+ * 원본에만 있는 관계를 화면에 낼 수 있는지 판정하는 부분.
3
+ *
4
+ * 조회 자체는 DB 와 어댑터를 물어야 돌아가므로 테스트가 다루지 않는다. 여기서 확인하는 것은
5
+ * 원본이 준 값을 그대로 믿지 않는다는 것이다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { usableLinkGroups } from '../server/service/reference/reference-link-groups.js'
11
+
12
+ test('정상 그룹은 그대로 남는다', () => {
13
+ const got = usableLinkGroups([
14
+ { via: 'mes.rel.report', from: 'productionReports', tense: 'past', ids: ['PR-1'], total: 2 }
15
+ ])
16
+ assert.equal(got.length, 1)
17
+ assert.equal(got[0].tense, 'past')
18
+ })
19
+
20
+ test('★ 전체 수가 없는 그룹은 버린다 — 화면이 목록 길이를 전체 수로 쓰게 된다', () => {
21
+ /*
22
+ * 원본이 20개만 보내고 전체 수를 빼면, 실제 200건이 화면에 20건으로 표시된다. 오류가 나지
23
+ * 않으므로 아무도 알아채지 못한다.
24
+ */
25
+ const got = usableLinkGroups([{ via: 'mes.rel.report', from: 'x', ids: ['a', 'b'] } as any])
26
+ assert.equal(got.length, 0)
27
+ })
28
+
29
+ test('전체 수가 0 인 그룹은 버린다 — 원본이 그 축을 취급하지 않는다는 뜻이다', () => {
30
+ assert.equal(usableLinkGroups([{ via: 'v', from: 'f', ids: [], total: 0 }]).length, 0)
31
+ })
32
+
33
+ test('이름이 없는 그룹은 버린다 — 화면에 이름 없는 줄이 된다', () => {
34
+ const got = usableLinkGroups([
35
+ { via: '', from: 'f', ids: ['a'], total: 1 },
36
+ { via: ' ', from: 'f', ids: ['a'], total: 1 },
37
+ { from: 'f', ids: ['a'], total: 1 } as any
38
+ ])
39
+ assert.equal(got.length, 0)
40
+ })
41
+
42
+ test('원본이 아무것도 안 주거나 목록에 빈 값이 섞여도 견딘다', () => {
43
+ assert.deepEqual(usableLinkGroups(undefined), [])
44
+ assert.deepEqual(usableLinkGroups(null), [])
45
+ assert.equal(usableLinkGroups([null as any, undefined as any]).length, 0)
46
+ })
47
+
48
+ test('★ 이 테스트가 실제로 무언가를 확인한다 — 통과와 탈락이 둘 다 있어야 한다', () => {
49
+ const got = usableLinkGroups([
50
+ { via: 'ok', from: 'f', ids: ['a'], total: 1 },
51
+ { via: 'no-total', from: 'f', ids: ['a'] } as any,
52
+ { via: '', from: 'f', ids: ['a'], total: 5 }
53
+ ])
54
+ assert.equal(got.length, 1, '통과한 것이 하나여야 한다')
55
+ assert.equal(got[0].via, 'ok')
56
+ })
@@ -0,0 +1,139 @@
1
+ /*
2
+ * ★ **다시 넘길 것인가, 언제인가** — 일꾼의 판단 전부.
3
+ *
4
+ * ── 왜 이 판단이 필요한가 (2026-09-04) ─────────────────────────────────────
5
+ * 승인 직후에 한 번 보내는 것만 있었다. 그래서 **커밋과 발신 사이에 프로세스가 죽으면** 그
6
+ * 조치는 `approved` 로 남고 스스로 나가지 않았다. 그리고 실패한 것을 다시 보내는 것은 사람이
7
+ * 시켜야 했다.
8
+ *
9
+ * ── 실패가 다 같지 않다 (2026-09-02 실측) ──────────────────────────────────
10
+ * 저쪽 인증 정보가 없어 503 이었던 것(고치면 됨)과 `ITEM-1` 이 400 이었던 것(고쳐도 이 조치로는
11
+ * 안 됨)이 같은 모양이었다. 다 다시 하면 없는 자재를 끝없이 두드리고, 다 포기하면 저쪽을 고쳐
12
+ * 놓고도 안 나간다.
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import {
18
+ AFTER_FIX_PERIOD_MS,
19
+ AGAIN_BACKOFF_MS,
20
+ retryDecisionOf
21
+ } from '../server/service/actuation/retry-plan.js'
22
+
23
+ const NOW = 1_800_000_000_000
24
+ const at = (over: Record<string, unknown> = {}) =>
25
+ retryDecisionOf({ state: 'failed', dispatchable: true, nowMs: NOW, ...over } as any)
26
+
27
+ /* ── 첫 시도 — 승인 직후 발신이 놓친 것을 줍는다 ─────────────────────────── */
28
+
29
+ test('★ 승인됐는데 한 번도 안 나간 것은 바로 집는다 — 그 창이 이 일꾼의 존재 이유다', () => {
30
+ const d = at({ state: 'approved', attempts: 0 })
31
+
32
+ assert.equal(d.take, true)
33
+ assert.equal(d.attempt, 1)
34
+ })
35
+
36
+ test('★ 첫 시도는 멱등을 묻지 않는다 — 한 번뿐이라 두 건이 설 수 없다', () => {
37
+ assert.equal(at({ state: 'approved', attempts: 0, idempotent: false }).take, true)
38
+ })
39
+
40
+ test('★ 첫 시도는 어댑터의 답을 묻지 않는다 — 아직 아무 답도 하지 않았다', () => {
41
+ assert.equal(at({ state: 'approved' }).take, true)
42
+ })
43
+
44
+ /* ── 재시도 — 모르면 넘기지 않는다 ──────────────────────────────────────── */
45
+
46
+ test('★ 어댑터가 다시 될 일인지 말하지 않았으면 집지 않는다 — 「never」로 접지도 않는다', () => {
47
+ /*
48
+ * 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이 포기한다. 그래서 집지 않고 목록에
49
+ * 「모름」으로 남긴다 — 사람이 보고 정한다.
50
+ */
51
+ const d = at({ attempts: 1, idempotent: true })
52
+
53
+ assert.equal(d.take, false)
54
+ assert.equal(d.skip, 'outlook-unknown')
55
+ })
56
+
57
+ test('★ 멱등을 선언하지 않은 커넥터는 재시도하지 않는다 — 현장에 지시가 두 건 서면 안 된다', () => {
58
+ const d = at({ attempts: 1, retry: 'again' })
59
+
60
+ assert.equal(d.take, false)
61
+ assert.equal(d.skip, 'not-idempotent')
62
+ })
63
+
64
+ test('★ 영원히 안 될 것은 집지 않는다 — 사람이 새 조치를 내야 한다', () => {
65
+ const d = at({ attempts: 1, retry: 'never', idempotent: true })
66
+
67
+ assert.equal(d.take, false)
68
+ assert.equal(d.skip, 'never', '우리 쪽에 고칠 것이 없다는 뜻이다')
69
+ })
70
+
71
+ /* ── `again` — 짧게 세 번, 그다음 접는다 ────────────────────────────────── */
72
+
73
+ test('★ 물러서면서 세 번 한다', () => {
74
+ for (const [i, expected] of AGAIN_BACKOFF_MS.entries()) {
75
+ const d = at({ attempts: i, retry: 'again', idempotent: true, state: i === 0 ? 'approved' : 'failed' })
76
+
77
+ assert.equal(d.take, true, `${i + 1}번째를 안 했다`)
78
+ assert.equal(d.attempt, i + 1)
79
+ assert.equal(Date.parse(d.nextAttemptAt!) - NOW, expected, `${i + 1}번째 물러섬이 다르다`)
80
+ }
81
+ })
82
+
83
+ test('★ 세 번을 다 쓰면 접는다 — 끝없이 두드리는 것은 소음이다', () => {
84
+ const d = at({ attempts: AGAIN_BACKOFF_MS.length, retry: 'again', idempotent: true })
85
+
86
+ assert.equal(d.take, false)
87
+ assert.equal(d.skip, 'attempts-spent')
88
+ })
89
+
90
+ /* ── `after-fix` — 접지 않는다. 이 파일에서 가장 중요한 것 ──────────────── */
91
+
92
+ test('★ 설정 문제는 횟수로 접지 않는다 — 고쳐 놓고도 손으로 눌러야 하면 안 된다', () => {
93
+ /*
94
+ * 2026-09-02 사고가 그 모양이었다: 저쪽이 고쳐진 뒤 사람이 손으로 다시 넘겨서 나갔다.
95
+ * `after-fix` 는 재시도가 아니라 **기다리는 것**이다.
96
+ */
97
+ for (const attempts of [1, 3, 10, 100]) {
98
+ const d = at({ attempts, retry: 'after-fix', idempotent: true })
99
+
100
+ assert.equal(d.take, true, `${attempts}번째에 접었다`)
101
+ assert.equal(Date.parse(d.nextAttemptAt!) - NOW, AFTER_FIX_PERIOD_MS, '간격이 늘어났다')
102
+ }
103
+ })
104
+
105
+ /* ── 물러섬을 표에서 읽는다 ─────────────────────────────────────────────── */
106
+
107
+ test('★ 아직 이르면 기다린다', () => {
108
+ const d = at({ attempts: 1, retry: 'again', idempotent: true, nextAttemptAt: new Date(NOW + 10_000).toISOString() })
109
+
110
+ assert.equal(d.take, false)
111
+ assert.equal(d.skip, 'backing-off')
112
+ })
113
+
114
+ test('★ 때가 되면 집는다', () => {
115
+ const d = at({ attempts: 1, retry: 'again', idempotent: true, nextAttemptAt: new Date(NOW - 1).toISOString() })
116
+
117
+ assert.equal(d.take, true)
118
+ })
119
+
120
+ test('★ 읽을 수 없는 시각으로 막지 않는다 — 막으면 그 조치가 표에 갇힌다', () => {
121
+ const d = at({ attempts: 1, retry: 'again', idempotent: true, nextAttemptAt: '언젠가' })
122
+
123
+ assert.equal(d.take, true)
124
+ })
125
+
126
+ /* ── 넘길 수 없는 상태 ──────────────────────────────────────────────────── */
127
+
128
+ test('★ 종착이면 아무것도 하지 않는다', () => {
129
+ const d = at({ state: 'acked', dispatchable: false, attempts: 1, retry: 'again', idempotent: true })
130
+
131
+ assert.equal(d.take, false)
132
+ assert.equal(d.skip, 'not-dispatchable')
133
+ })
134
+
135
+ test('넘길 수 없으면 다른 어떤 값도 그것을 뒤집지 않는다', () => {
136
+ for (const retry of ['again', 'after-fix', 'never', undefined]) {
137
+ assert.equal(at({ dispatchable: false, attempts: 0, retry, idempotent: true }).skip, 'not-dispatchable')
138
+ }
139
+ })
@@ -0,0 +1,191 @@
1
+ /*
2
+ * ★ **그물이 실제로 줍는가** — 배선.
3
+ *
4
+ * 판단은 순수 함수가 지킨다(`retry-plan.test.ts`). 여기서 보는 것은 **그 답이 실제 경로에 서
5
+ * 있는가**다: 표를 읽었나, 어댑터를 불렀나, 시도 횟수와 다음 시각을 적었나, 한 조치가 터져도
6
+ * 다음을 보나.
7
+ *
8
+ * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 오늘 하루 그 부류를 여러 번
9
+ * 고쳤다. 그물이 그 부류로 나면 **승인된 조치가 조용히 안 나간다.**
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+
14
+ import { sweepPendingDispatch } from '../server/service/actuation/sweep-dispatch.js'
15
+ import type { CommandStore, StoredCommand } from '../server/service/actuation/command-dispatcher.js'
16
+
17
+ const DOMAIN = 'd-1'
18
+ const AT = '2026-09-04T02:00:00.000Z'
19
+
20
+ const cmd = (over: Partial<StoredCommand> = {}): StoredCommand =>
21
+ ({
22
+ id: 'cmd-1',
23
+ instanceId: 'twin-a',
24
+ kind: 'production.release',
25
+ origin: 'rule',
26
+ state: 'approved',
27
+ reversible: false,
28
+ /* 되돌릴 수 없는 조치는 **승인 기록**이 있어야 넘어간다(§`asApproved`) — 그 모양이 이것이다. */
29
+ approval: { by: 'lead', at: AT },
30
+ ...over
31
+ }) as StoredCommand
32
+
33
+ /**
34
+ * 표 하나와 가짜 커넥터 — `save` 가 무엇을 적었는지 본다.
35
+ *
36
+ * 의존을 인자로 받는 서명이라 여기서 다 흉내낼 수 있다. 직접 부르는 서명이었을 때는 이 시험이
37
+ * DB·모듈 부팅을 끌어와 아예 뜨지 않았다 — 그것이 이 파일을 그 모양으로 바꾼 이유다.
38
+ */
39
+ function bench(rows: StoredCommand[], over: Record<string, unknown> = {}) {
40
+ const table = new Map(rows.map(r => [r.id, { ...r }]))
41
+ const saves: StoredCommand[] = []
42
+ const store: CommandStore = {
43
+ load: async (_d, id) => (table.has(id) ? { ...table.get(id)! } : null),
44
+ save: async (_d, c) => {
45
+ saves.push({ ...c })
46
+ table.set(c.id, { ...c })
47
+ },
48
+ pendingDispatch: async () => [...table.values()]
49
+ } as any
50
+
51
+ const calls: string[] = []
52
+ const deps = {
53
+ store,
54
+ /* 어댑터가 늘 받아 준다 — 「집었나」를 보는 시험이라 저쪽 사정은 다른 파일이 지킨다. */
55
+ actuate: async (c: any) => {
56
+ calls.push(c.id)
57
+ return { ok: true, ref: `WO-${c.id}` }
58
+ },
59
+ idempotent: async () => false,
60
+ ...over
61
+ } as any
62
+ return { store, table, saves, deps, calls }
63
+ }
64
+
65
+ /* ── 첫 시도를 줍는다 ─────────────────────────────────────────────────────── */
66
+
67
+ test('★ 승인만 되고 안 나간 것을 줍는다 — 승인 직후 발신이 놓친 그 창이다', async () => {
68
+ const { table, deps, calls } = bench([cmd()])
69
+
70
+ const report = await sweepPendingDispatch(DOMAIN, deps)
71
+ /* 어댑터가 없으므로 `routeActuation` 이 거절한다 — 그래도 **집었다**는 것이 이 시험의 주제다. */
72
+ assert.equal(report.seen, 1)
73
+ assert.equal(table.get('cmd-1')!.state !== 'approved', true, '집지 않아 상태가 그대로다')
74
+ })
75
+
76
+ test('★ 시도 횟수와 다음 시각을 적는다 — 메모리에 두면 재기동에 사라진다', async () => {
77
+ const { table, deps, calls } = bench([cmd()])
78
+
79
+ await sweepPendingDispatch(DOMAIN, deps)
80
+ const after = table.get('cmd-1')!
81
+
82
+ assert.equal(after.attempts, 1, '첫 시도가 1로 적혀야 한다')
83
+ assert.ok(after.nextAttemptAt, '다음 시각이 없으면 물러섬이 재기동에 사라진다')
84
+ assert.ok(Date.parse(after.nextAttemptAt!) > Date.now(), '다음 시각이 과거다')
85
+ })
86
+
87
+ /* ── 집지 않는 것들 ───────────────────────────────────────────────────────── */
88
+
89
+ test('★ 종착은 집지 않는다', async () => {
90
+ const { saves, deps } = bench([cmd({ state: 'acked', attempts: 1 })])
91
+
92
+ const report = await sweepPendingDispatch(DOMAIN, deps)
93
+
94
+ assert.equal(report.dispatched, 0)
95
+ assert.equal(report.skipped['not-dispatchable'], 1)
96
+ assert.deepEqual(saves, [], '집지 않았는데 표를 고쳤다')
97
+ })
98
+
99
+ test('★ 어댑터가 다시 될 일인지 말하지 않았으면 집지 않는다 — 목록에 「모름」으로 남는다', async () => {
100
+ const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, error: '저쪽이 안 받았습니다' })])
101
+
102
+ const report = await sweepPendingDispatch(DOMAIN, deps)
103
+
104
+ assert.equal(report.skipped['outlook-unknown'], 1)
105
+ assert.equal(report.dispatched, 0)
106
+ })
107
+
108
+ test('★ 멱등을 선언하지 않은 커넥터는 재시도하지 않는다', async () => {
109
+ const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, retry: 'again' })])
110
+
111
+ const report = await sweepPendingDispatch(DOMAIN, deps)
112
+
113
+ assert.equal(report.skipped['not-idempotent'], 1)
114
+ })
115
+
116
+ test('★ 영원히 안 될 것은 집지 않는다', async () => {
117
+ const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, retry: 'never' })])
118
+
119
+ assert.equal((await sweepPendingDispatch(DOMAIN, deps)).skipped['never'], 1)
120
+ })
121
+
122
+ test('★ 물러섬이 안 끝났으면 기다린다', async () => {
123
+ const later = new Date(Date.now() + 300_000).toISOString()
124
+ const { saves, deps } = bench([cmd({ state: 'failed', attempts: 1, retry: 'again', nextAttemptAt: later })])
125
+
126
+ const report = await sweepPendingDispatch(DOMAIN, deps)
127
+
128
+ /* 멱등 선언이 없어 그쪽에서 먼저 걸린다 — 어느 이유든 **집지 않았고 표를 안 고쳤다**가 핵심이다. */
129
+ assert.equal(report.dispatched, 0)
130
+ assert.deepEqual(saves, [])
131
+ })
132
+
133
+ /* ── 저장소가 못 내면 아무것도 하지 않는다 ──────────────────────────────── */
134
+
135
+ test('★ 대기 줄을 못 내는 저장소에서는 아무것도 하지 않는다 — 옛 거동이 깨지지 않게', async () => {
136
+ const deps = { store: { load: async () => null, save: async () => {} }, actuate: async () => ({ ok: true }), idempotent: async () => false } as any
137
+
138
+ const report = await sweepPendingDispatch(DOMAIN, deps)
139
+
140
+ assert.deepEqual(report, { seen: 0, dispatched: 0, skipped: {} })
141
+ })
142
+
143
+ test('★ 표 읽기가 터져도 그물이 죽지 않는다', async () => {
144
+ const deps = {
145
+ store: {
146
+ load: async () => null,
147
+ save: async () => {},
148
+ pendingDispatch: async () => {
149
+ throw new Error('DB 가 없다')
150
+ }
151
+ },
152
+ actuate: async () => ({ ok: true }),
153
+ idempotent: async () => false
154
+ } as any
155
+
156
+ const report = await sweepPendingDispatch(DOMAIN, deps)
157
+ assert.equal(report.seen, 0)
158
+ })
159
+
160
+ /* ── 한 조치가 터져도 다음을 본다 ───────────────────────────────────────── */
161
+
162
+ test('★ 한 조치에서 터져도 다음 조치를 본다 — 그물이 한 건에 멈추면 뒤가 다 막힌다', async () => {
163
+ const { deps, store } = bench([cmd({ id: 'a' }), cmd({ id: 'b' })])
164
+ const original = store.save
165
+ let first = true
166
+ ;(store as any).save = async (d: string, c: StoredCommand) => {
167
+ if (first) {
168
+ first = false
169
+ throw new Error('첫 저장이 터졌다')
170
+ }
171
+ return original!(d, c)
172
+ }
173
+
174
+ const report = await sweepPendingDispatch(DOMAIN, deps)
175
+
176
+ assert.equal(report.seen, 2, '둘 다 읽었다')
177
+ /* 하나가 터졌어도 바퀴가 끝났다 — 던지면 이 단정에 오지 못한다. */
178
+ assert.ok(report.dispatched + Object.values(report.skipped).reduce((a, b) => a + b, 0) >= 1)
179
+ })
180
+
181
+ /* ── 한 바퀴 상한 ─────────────────────────────────────────────────────────── */
182
+
183
+ test('★ 대기 줄이 커도 한 주기를 독차지하지 않는다', async () => {
184
+ const many = Array.from({ length: 120 }, (_, i) => cmd({ id: `c-${i}`, state: 'acked', attempts: 1 }))
185
+ const { deps, store } = bench(many)
186
+
187
+ const report = await sweepPendingDispatch(DOMAIN, deps)
188
+
189
+ assert.equal(report.seen, 120, '표는 다 읽는다')
190
+ assert.equal(report.skipped['not-dispatchable'], 50, '한 바퀴에 보는 것은 상한까지다')
191
+ })
@@ -15,7 +15,7 @@ import assert from 'node:assert/strict'
15
15
  import { createRequire } from 'node:module'
16
16
 
17
17
  const req = createRequire(import.meta.url)
18
- const { bizStepOf, epcOf, orderOf, locationOf, equipmentIdOf, twinEventKeys } = req('../dist-server/service/twin-event/twin-event-keys.js')
18
+ const { bizStepOf, epcOf, orderOf, bizTransactionOf, locationOf, equipmentIdOf, twinEventKeys } = req('../dist-server/service/twin-event/twin-event-keys.js')
19
19
 
20
20
  /** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */
21
21
  const EPCIS_OBJECT = {
@@ -31,12 +31,18 @@ const EPCIS_OBJECT = {
31
31
  }
32
32
  }
33
33
 
34
- test('EPCIS 이벤트에서 네 축이 모두 뽑힌다', () => {
34
+ test('EPCIS 이벤트에서 축이 모두 뽑히고, 거래는 거래 칸에 든다', () => {
35
35
  const k = twinEventKeys(EPCIS_OBJECT)
36
36
  assert.equal(k.bizStep, 'receiving')
37
37
  assert.equal(k.epc, 'urn:epc:id:sgtin:0614141.107346.2018')
38
- assert.equal(k.orderId, 'urn:epc:id:gdti:0614141.402.2')
39
38
  assert.equal(k.locationId, 'urn:epc:id:sgln:0614141.00777.DOCK-3')
39
+ /*
40
+ * ── 이 기대가 옮겨졌다 (2026-09-05 사용자 결정) ────────────────────────────
41
+ * 전에는 이 GDTI 가 `orderId` 로 뽑혔다. 그래서 한 오더의 이력이 색인 안에서 두 쪽으로
42
+ * 갈라졌다 — 운영 델타는 `order-1`, EPCIS 는 GDTI. 이제 칸이 둘이다.
43
+ */
44
+ assert.equal(k.bizTransaction, 'urn:epc:id:gdti:0614141.402.2')
45
+ assert.equal(k.orderId, undefined, '거래 식별자가 오더 칸에 앉았다')
40
46
  })
41
47
 
42
48
  test('식별자는 끝마디가 아니라 전체를 저장한다 — 부분일치 검색이 양방향으로 성립해야 한다', () => {
@@ -86,8 +92,16 @@ test('★ 운영 델타의 오더는 `orderId` 다 — 이 시험이 결함을
86
92
  orderOf({ data: { orderId: 'WO-1', bizTransactionList: [{ bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }] } }),
87
93
  'WO-1'
88
94
  )
89
- /* 오더를 말하지 않는 EPCIS 는 거래로 내려간다(전과 같다). */
90
- assert.equal(orderOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), 'PO-9')
95
+ /*
96
+ * ── 이 줄이 뒤집혔다 (2026-09-05 사용자 결정) ──────────────────────────────
97
+ * 전에는 오더를 말하지 않는 EPCIS 가 **거래로 내려갔다.** 그래서 한 칸에 두 낱말이 들었고,
98
+ * 한 오더의 이력이 색인 안에서 두 쪽으로 갈라졌다(실측: 어휘가 섞인 행 10,004개).
99
+ *
100
+ * 바로 위 줄이 그 위험을 이미 알고 있었다 — 「거래와 오더는 같은 것이 아닐 수 있다」. 알면서
101
+ * 같은 칸에 넣고 있었다. 이제 갈라 든다.
102
+ */
103
+ assert.equal(orderOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), undefined)
104
+ assert.equal(bizTransactionOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), 'PO-9')
91
105
  })
92
106
 
93
107
  test('★ 승격 키는 **커널이 실제로 내는 델타 모양**으로 검사한다', () => {
@@ -156,7 +170,7 @@ test('상한을 넘는 값은 오류 없이 사라지지 않는다 — 잘리되
156
170
  })
157
171
 
158
172
  test('빈 봉투에도 터지지 않는다', () => {
159
- const empty = { action: undefined, bizStep: undefined, epc: undefined, orderId: undefined, locationId: undefined, moverId: undefined }
173
+ const empty = { action: undefined, bizStep: undefined, epc: undefined, orderId: undefined, bizTransaction: undefined, locationId: undefined, moverId: undefined }
160
174
  assert.deepEqual(twinEventKeys(undefined), empty)
161
175
  assert.deepEqual(twinEventKeys({}), empty)
162
176
  })
@@ -153,13 +153,13 @@ test('이어진 것은 양방향이고 선언된 관계만 따른다', async ()
153
153
  const loc = await q.twinModelItem(INSTANCE, 'locations', 'weld-station', ctx)
154
154
 
155
155
  /* 들어오는 쪽 — 이 자리를 기준으로 삼는 설비 둘. 선언(`equipment.homeLocation`)이 알려 준다. */
156
- const home = loc.links.incoming.find((l: any) => l.via === 'twin.rel.home' && l.from === 'equipment')
156
+ const home = loc.links.incoming.find((l: any) => l.via === 'ops.rel.home' && l.from === 'equipment')
157
157
  assert.ok(home, '나를 가리키는 관계가 보여야 한 걸음씩 걸을 수 있다')
158
158
  assert.deepEqual(home.ids.sort(), ['welder-1', 'welder-2'])
159
159
  assert.equal(home.total, 2)
160
160
 
161
161
  /* 나가는 쪽 — 자리는 구역을 가리킨다. 대상이 **축 밖**(공간의 구역)이라는 사실을 그대로 낸다. */
162
- const area = loc.links.out.find((l: any) => l.via === 'twin.rel.area')
162
+ const area = loc.links.out.find((l: any) => l.via === 'ops.rel.area')
163
163
  assert.ok(area, '커널이 "밖" 이라 부른 참조도 이어진 것으로 낸다')
164
164
  assert.equal(area.target.kind, 'external', '갈 곳이 다르므로 화면이 링크를 만들지 말지 알 수 있어야 한다')
165
165
  })
@@ -168,13 +168,13 @@ test('문서에만 사는 항목의 관계도 선언대로 이어진다', async
168
168
  const recipe = await q.twinModelItem(INSTANCE, 'recipes', 'frame-recipe', ctx)
169
169
  assert.equal(recipe.provenance.basis, 'document')
170
170
 
171
- const route = recipe.links.out.find((l: any) => l.via === 'twin.rel.route')
171
+ const route = recipe.links.out.find((l: any) => l.via === 'ops.rel.route')
172
172
  assert.deepEqual(route.ids, ['frame-route'])
173
173
 
174
174
  /* `inputs[].material` — 배열을 지나는 경로가 선언의 문법이고, 그것을 따르는 것이 선언을 존중하는 것이다. */
175
- const input = recipe.links.out.find((l: any) => l.via === 'twin.rel.input')
175
+ const input = recipe.links.out.find((l: any) => l.via === 'ops.rel.input')
176
176
  assert.deepEqual(input.ids, ['steel'])
177
- const output = recipe.links.out.find((l: any) => l.via === 'twin.rel.output')
177
+ const output = recipe.links.out.find((l: any) => l.via === 'ops.rel.output')
178
178
  assert.deepEqual(output.ids, ['frame'])
179
179
  })
180
180
 
@@ -216,7 +216,7 @@ test('지도의 선이 진단이 된다 — 이어진 것뿐 아니라 **끊긴
216
216
  await TwinEngine.ingestMaster(domainId, bad, 'reset')
217
217
 
218
218
  const sum = await new TwinModelQuery().twinModelSummary(INSTANCE, ctx)
219
- const home = sum.edges.find((e: any) => e.from === 'equipment' && e.to === 'locations' && e.via === 'twin.rel.home')
219
+ const home = sum.edges.find((e: any) => e.from === 'equipment' && e.to === 'locations' && e.via === 'ops.rel.home')
220
220
  assert.ok(home, '설비→자리 선이 있어야 한다')
221
221
  assert.equal(home.linked, 3, '제대로 이어진 설비 셋')
222
222
  assert.equal(home.dangling, 1, '없는 자리를 가리키는 설비 하나 — 가장 나쁜 부류이므로 따로 센다')