@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
@@ -0,0 +1,333 @@
1
+ /*
2
+ * **밀어 받는 길의 번호** — 빠진 것을 알아채고, 재기동을 넘어 이어받는다.
3
+ *
4
+ * ── 왜 이 시험이 있나 (2026-08-31) ────────────────────────────────────────
5
+ * 훅이 놓치는 것은 예외가 아니라 정상이다. 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가
6
+ * 끊겼을 때. 그 대비가 드문 폴링 하나뿐이었다 — 폴링은 「놓친 것이 있나」를 값으로 알아내는 것이고,
7
+ * 번호는 빠진 것을 곧바로 안다.
8
+ *
9
+ * ── 왜 순수 함수 시험만으로 부족한가 ─────────────────────────────────────
10
+ * 번호 판정 자체는 계약 쪽에서 확인한다(`ops-contract/test/webhook-contract.test.ts`). 여기서 보는
11
+ * 것은 **배선**이다 — 판정이 응답이 되는가, 커서에 적히는가, 다시 세운 뒤에 읽히는가.
12
+ *
13
+ * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 커서가 정확히 그 모양이었다.
14
+ * `TwinReference.liveCursor` 는 재기동을 넘어 사는 자리인데 **훅 경로가 그것을 읽지도 쓰지도 않았다.**
15
+ * 쓰는 곳은 폴링 하나뿐이었다.
16
+ *
17
+ * 그래서 마지막 시험이 저장본을 그대로 두고 **핸들러를 새로 부른다.** 한 프로세스 안에서만 확인하면
18
+ * 「적히나」는 보이지만 「다시 세우면 읽히나」는 보이지 않는다.
19
+ */
20
+ import { test } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract'
24
+
25
+ import { handleHook, type HookStore } from '../server/service/reference/reference-hook.js'
26
+ import { registerAdapterType, getAdapter } from '../server/service/reference/reference-adapter.js'
27
+
28
+ const DOMAIN = 'd-1'
29
+ const SOURCE = 'operato-plant'
30
+ const TWIN = 'twin-a'
31
+ const SECRET = 'shared-secret'
32
+ const SCOPE = 'EVENTS'
33
+ /** 커서 열쇠에는 트윈이 함께 들어간다 — 연결 하나가 트윈 여럿을 만든다. */
34
+ const KEY = `${TWIN}::${SCOPE}`
35
+
36
+ /** 보낸 봉투를 그대로 레코드로 내는 커넥터 — 옮길 것이 없으므로 번호만 꺼낸다. */
37
+ const ADAPTER_TYPE = 'test-numbered-push'
38
+ if (!getAdapter(ADAPTER_TYPE)) {
39
+ registerAdapterType({
40
+ type: ADAPTER_TYPE,
41
+ label: 'numbered push (test)',
42
+ handleInbound: (_cfg: any, _site: any, body: any) => ({
43
+ items: body?.items ?? [{ record: { moverId: 'eq-1', kind: 'press', status: 'idle' }, seq: body?.seq }],
44
+ scope: body?.scope
45
+ })
46
+ } as any)
47
+ }
48
+
49
+ /** 저장본 하나 — 프로세스가 아니라 여기에 산다. 그래서 핸들러를 새로 불러도 남는다. */
50
+ function makeStore(initial?: unknown): HookStore & { row: any } {
51
+ const row = {
52
+ id: 'ref-1',
53
+ source: SOURCE,
54
+ adapterType: ADAPTER_TYPE,
55
+ connectionConfig: { hookSecret: SECRET },
56
+ scopeSpec: { produced: [{ siteId: 'site-a', instanceId: TWIN }] },
57
+ liveCursor: initial
58
+ }
59
+ return {
60
+ row,
61
+ load: async (domainId: string, source: string) => (domainId === DOMAIN && source === SOURCE ? row : null),
62
+ saveCursor: async (refId: string, cursor: unknown) => {
63
+ assert.equal(refId, row.id)
64
+ /* 저장소를 흉내 내는 것이라 참조를 그대로 두지 않고 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
65
+ row.liveCursor = JSON.parse(JSON.stringify(cursor))
66
+ }
67
+ }
68
+ }
69
+
70
+ const applied = (_id: string, records: unknown[]) => ({ applied: records.length, duplicates: 0, rejected: [] })
71
+
72
+ function push(store: HookStore, seq: number | undefined, opts: { scope?: string; ingest?: any } = {}) {
73
+ return handleHook({
74
+ domainId: DOMAIN,
75
+ source: SOURCE,
76
+ instanceId: TWIN,
77
+ headers: { 'x-twin-hook-secret': SECRET },
78
+ body: { seq, scope: 'scope' in opts ? opts.scope : SCOPE },
79
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
80
+ ingest: opts.ingest ?? applied,
81
+ store
82
+ })
83
+ }
84
+
85
+ test('★ 첫 봉투를 받고 그 번호를 커서에 적는다', async () => {
86
+ const store = makeStore()
87
+ const out = await push(store, 918342)
88
+
89
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
90
+ assert.equal(out.body.sequenceChecked, true)
91
+ assert.equal(out.body.lastSeq, 918342)
92
+ assert.equal(store.row.liveCursor?.streams?.[KEY]?.seq, 918342, '커서에 적히지 않았다')
93
+ })
94
+
95
+ test('★ 건너뛴 번호는 409 로 답하고 그 앞부터 달라고 한다', async () => {
96
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
97
+ const out = await push(store, 7)
98
+
99
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
100
+ assert.equal(out.body.expectedSeq, 5)
101
+ assert.equal(out.body.lastSeq, 4)
102
+ assert.equal(out.body.scope, SCOPE, '어느 줄의 번호인지 함께 말한다')
103
+ })
104
+
105
+ test('★ 구멍이 났을 때 커서를 올리지 않는다 — 올리면 그 구간이 영영 안 온다', async () => {
106
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
107
+ await push(store, 7)
108
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
109
+ })
110
+
111
+ test('★ 이미 본 번호는 받아들이되 커서를 뒤로 끌지 않는다 — 재전송이 정상 동작이다', async () => {
112
+ const store = makeStore({ streams: { [KEY]: { seq: 9 } } })
113
+ const out = await push(store, 9)
114
+
115
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
116
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 9)
117
+ })
118
+
119
+ test('★ 반영되지 않았으면 커서를 올리지 않는다 — 트윈이 안 도는 사이의 번호를 지나치면 그 구간이 빈다', async () => {
120
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
121
+ const out = await push(store, 5, { ingest: () => ({ applied: 0, duplicates: 0, rejected: [] }) })
122
+
123
+ assert.equal(out.status, WEBHOOK_STATUS.notLive)
124
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4, '한 건도 안 들어갔는데 번호가 올라갔다')
125
+ })
126
+
127
+ test('★ 번호를 매기지 않는 원본은 확인하지 않고, 확인하지 않았다고 말한다', async () => {
128
+ /* chef · ppms 가 그렇다. 「확인했고 이상 없다」와 같아 보이면 어느 연결이 지켜지는지 알 수 없다. */
129
+ const store = makeStore()
130
+ const out = await push(store, undefined, { scope: undefined })
131
+
132
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
133
+ assert.equal(out.body.sequenceChecked, false)
134
+ assert.equal(store.row.liveCursor, undefined, '번호가 없으면 커서를 만들지 않는다')
135
+ })
136
+
137
+ test('★ 번호만 있고 어느 줄인지 없으면 확인하지 않는다 — 짐작한 열쇠는 줄이 늘면 어긋난다', async () => {
138
+ const store = makeStore()
139
+ const out = await push(store, 918342, { scope: undefined })
140
+
141
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
142
+ assert.equal(out.body.sequenceChecked, false)
143
+ })
144
+
145
+ test('★ 줄마다 번호가 따로 간다 — 두 줄이 한 커서에 섞이면 끊임없이 「빠졌다」가 된다', async () => {
146
+ const store = makeStore()
147
+ await push(store, 5, { scope: 'EVENTS' })
148
+ await push(store, 1, { scope: 'MASTER' })
149
+
150
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::EVENTS`].seq, 5)
151
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::MASTER`].seq, 1)
152
+
153
+ /* MASTER 의 1 이 EVENTS 의 5 를 되돌리지 않았으므로 EVENTS 6 이 그대로 이어진다. */
154
+ assert.equal((await push(store, 6, { scope: 'EVENTS' })).status, WEBHOOK_STATUS.ok)
155
+ })
156
+
157
+ test('★ 재기동을 넘어 이어받는다 — 저장본만 남기고 핸들러를 새로 부른다', async () => {
158
+ const first = makeStore()
159
+ await push(first, 5)
160
+ const saved = JSON.parse(JSON.stringify(first.row.liveCursor))
161
+
162
+ /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
163
+ const second = makeStore(saved)
164
+
165
+ assert.equal((await push(second, 7)).status, WEBHOOK_STATUS.sequenceGap, '이어받지 못하면 7 이 그냥 통과한다')
166
+ assert.equal((await push(second, 6)).status, WEBHOOK_STATUS.ok)
167
+ assert.equal(second.row.liveCursor.streams[KEY].seq, 6)
168
+ })
169
+
170
+ test('★ 커서를 적지 못해도 이미 들어간 사실을 무르지 않는다 — 다음 번호에서 구멍으로 드러난다', async () => {
171
+ const store = makeStore()
172
+ const broken: HookStore = { load: store.load, saveCursor: async () => { throw new Error('disk full') } }
173
+
174
+ const out = await push(broken, 5)
175
+ assert.equal(out.status, WEBHOOK_STATUS.ok, '유입은 이미 끝났다 — 커서를 못 적은 것이 그것을 무를 이유가 아니다')
176
+ })
177
+
178
+ /* ── 한 요청에 봉투 여럿 ──────────────────────────────────────────────────── */
179
+
180
+ /** 번호가 붙은 봉투 여럿을 한 요청으로 보낸다 — 밀어 주는 쪽은 한 건씩 보내지 않는다. */
181
+ function pushBatch(store: HookStore, seqs: number[], opts: { scope?: string; ingest?: any } = {}) {
182
+ return handleHook({
183
+ domainId: DOMAIN,
184
+ source: SOURCE,
185
+ instanceId: TWIN,
186
+ headers: { 'x-twin-hook-secret': SECRET },
187
+ body: {
188
+ scope: 'scope' in opts ? opts.scope : SCOPE,
189
+ items: seqs.map(seq => ({ seq, record: { moverId: `eq-${seq}`, kind: 'press', status: 'idle' } }))
190
+ },
191
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
192
+ ingest: opts.ingest ?? applied,
193
+ store
194
+ })
195
+ }
196
+
197
+ test('★ 배치 가운데가 빠지면 그 앞까지만 받는다 — 구멍 뒤를 받으면 그 구멍이 영영 안 메워진다', async () => {
198
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
199
+ const taken: unknown[][] = []
200
+ const out = await pushBatch(store, [5, 6, 9, 10], {
201
+ ingest: (_id: string, records: unknown[]) => {
202
+ taken.push(records)
203
+ return { applied: records.length, duplicates: 0, rejected: [] }
204
+ }
205
+ })
206
+
207
+ assert.equal(taken[0].length, 2, '5·6 만 넣어야 한다')
208
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
209
+ assert.equal(out.body.expectedSeq, 7)
210
+ assert.equal(out.body.lastSeq, 6, '앞의 것은 받았다는 것을 함께 말한다 — 안 그러면 5·6 을 또 보낸다')
211
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
212
+ })
213
+
214
+ test('★ 배치 전체가 이어지면 끝 번호가 커서가 된다', async () => {
215
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
216
+ const out = await pushBatch(store, [5, 6, 7])
217
+
218
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
219
+ assert.equal(out.body.lastSeq, 7)
220
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 7)
221
+ })
222
+
223
+ test('★ 첫 봉투부터 구멍이면 아무것도 넣지 않고 커서도 그대로다', async () => {
224
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
225
+ let called = false
226
+ const out = await pushBatch(store, [9, 10], {
227
+ ingest: () => {
228
+ called = true
229
+ return { applied: 2, duplicates: 0, rejected: [] }
230
+ }
231
+ })
232
+
233
+ assert.equal(called, false, '받을 것이 없는데 유입을 불렀다')
234
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
235
+ assert.equal(out.body.expectedSeq, 5)
236
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
237
+ })
238
+
239
+ test('★ 번호가 있는 봉투와 없는 봉투를 섞으면 받지 않는다 — 반쯤 확인한 것을 확인했다고 말하지 않는다', async () => {
240
+ const store = makeStore()
241
+ const out = await handleHook({
242
+ domainId: DOMAIN,
243
+ source: SOURCE,
244
+ instanceId: TWIN,
245
+ headers: { 'x-twin-hook-secret': SECRET },
246
+ body: { scope: SCOPE, items: [{ seq: 1, record: {} }, { record: {} }] },
247
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
248
+ ingest: applied,
249
+ store
250
+ })
251
+
252
+ assert.equal(out.status, WEBHOOK_STATUS.badPayload)
253
+ assert.match(String(out.body.error), /mixed batch/)
254
+ })
255
+
256
+ /* ── 일부만 떨어졌을 때 ───────────────────────────────────────────────────── */
257
+
258
+ const REJECT_ONE = (_id: string, records: unknown[]) => ({
259
+ applied: records.length - 1,
260
+ duplicates: 0,
261
+ rejected: [{ record: records[records.length - 1], errors: ['equipment: 계약에 없는 필드 — foo'] }]
262
+ })
263
+
264
+ test('★ 일부가 떨어지면 422 로 답하고 무엇이 떨어졌는지 함께 준다', async () => {
265
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
266
+ const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
267
+
268
+ assert.equal(out.status, WEBHOOK_STATUS.partial)
269
+ assert.equal((out.body.rejected as any[]).length, 1)
270
+ assert.match(String((out.body.rejected as any[])[0].errors[0]), /계약에 없는 필드/)
271
+ })
272
+
273
+ test('★ 떨어진 것이 있어도 다시 보내라고 하지 않는다 — 나머지는 이미 들어갔다', async () => {
274
+ assert.equal(webhookSenderAction(WEBHOOK_STATUS.partial), 'partial')
275
+ assert.notEqual(webhookSenderAction(WEBHOOK_STATUS.partial), 'resend-from')
276
+ })
277
+
278
+ test('★ 일부가 떨어져도 받은 데까지 커서가 간다 — 안 그러면 그 구간이 매번 다시 온다', async () => {
279
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
280
+ const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
281
+
282
+ assert.equal(out.body.lastSeq, 6)
283
+ assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
284
+ })
285
+
286
+ test('★ 구멍과 떨어진 것이 함께 나면 한 응답에 둘 다 싣는다', async () => {
287
+ /* 두 소식이 갈라져 오면 보내는 쪽은 둘 중 하나만 한다. */
288
+ const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
289
+ const out = await pushBatch(store, [5, 6, 9], { ingest: REJECT_ONE })
290
+
291
+ assert.equal(out.status, WEBHOOK_STATUS.sequenceGap, '연속성이 먼저다 — 그것이 다음에 무엇을 보낼지 정한다')
292
+ assert.equal(out.body.expectedSeq, 7)
293
+ assert.equal(out.body.lastSeq, 6)
294
+ assert.equal((out.body.rejected as any[]).length, 1, '떨어진 것도 함께 알린다')
295
+ })
296
+
297
+ test('전부 통과하면 떨어진 목록이 빈다 — 없는 것과 안 본 것이 같아 보이지 않게', async () => {
298
+ const store = makeStore()
299
+ const out = await pushBatch(store, [1, 2])
300
+ assert.deepEqual(out.body.rejected, [])
301
+ })
302
+
303
+ test('★ 한 연결이 만든 두 트윈이 계수기를 함께 쓰지 않는다', async () => {
304
+ /*
305
+ * 커서는 연결마다 한 행에 산다(`TwinReference.liveCursor`). 그런데 연결 하나가 트윈 여럿을 만든다.
306
+ * 보내는 쪽의 단위 이름만으로 열쇠를 잡으면 두 공장이 한 계수기를 쓰고, B 의 1 이 A 의 5 뒤에서
307
+ * 「이미 본 번호」로 버려진다.
308
+ */
309
+ const store = makeStore()
310
+ const other = 'twin-b'
311
+ store.row.scopeSpec.produced.push({ siteId: 'site-b', instanceId: other })
312
+
313
+ const send = (instanceId: string, seq: number) =>
314
+ handleHook({
315
+ domainId: DOMAIN,
316
+ source: SOURCE,
317
+ instanceId,
318
+ headers: { 'x-twin-hook-secret': SECRET },
319
+ body: { scope: SCOPE, items: [{ seq, record: { moverId: 'eq-1', kind: 'press', status: 'idle' } }] },
320
+ nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
321
+ ingest: applied,
322
+ store
323
+ })
324
+
325
+ await send(TWIN, 5)
326
+ const first = await send(other, 1)
327
+ assert.equal(first.status, WEBHOOK_STATUS.ok, 'B 공장의 첫 봉투가 A 공장의 번호에 걸려 버려졌다')
328
+
329
+ assert.equal(store.row.liveCursor.streams[`${TWIN}::${SCOPE}`].seq, 5)
330
+ assert.equal(store.row.liveCursor.streams[`${other}::${SCOPE}`].seq, 1)
331
+
332
+ assert.equal((await send(TWIN, 6)).status, WEBHOOK_STATUS.ok, 'A 공장이 B 의 번호에 끌려갔다')
333
+ })
@@ -0,0 +1,56 @@
1
+ /*
2
+ * 같은 사실을 두 번 받았을 때 한 번으로 세는 근거 — **정체**.
3
+ *
4
+ * 정체를 못 만들면 그 사실은 dedupe 밖이고, 다시 받을 때마다 저널에 새로 앉는다. backfill 이 그것을
5
+ * 드러냈다(2026-09-06).
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { factIdentity, takeUnidentified } from '../server/engine/ingest-dedupe.js'
11
+
12
+ /* ── 주체가 subjectId 인 사실 ───────────────────────────────────────────────── */
13
+
14
+ test('★ 처분과 시험은 subjectId 가 주체다 — 그 이름이 없으면 정체가 안 만들어진다', () => {
15
+ /*
16
+ * [측정함 2026-09-06] `fillTwinScope` 를 돌릴 때마다 처분 4건 · 시험 3건이 다시 앉았다. 나머지
17
+ * 34건은 걸러지는데 이 둘만 계속 늘었다.
18
+ *
19
+ * 원인은 `discriminatorsOf` 에 `subjectId` 가 없어서 `parts` 가 비고, `factIdentity` 가
20
+ * `undefined` 를 낸 것이다. 정체가 없으면 dedupe 밖이다.
21
+ */
22
+ const disposition = {
23
+ eventType: 'nonconformance.disposition',
24
+ eventTime: '2026-09-01T02:50:51.832Z',
25
+ data: { subjectId: 'LOT-A', decision: 'scrap', quantity: 50 }
26
+ }
27
+ const id = factIdentity(disposition)
28
+ assert.ok(id, '정체를 못 만들면 다시 받을 때마다 새로 앉는다')
29
+ assert.ok(id!.includes('subject=LOT-A'), `주체가 정체에 안 들어갔다: ${id}`)
30
+ })
31
+
32
+ test('★ 다른 로트의 같은 처분은 다른 사실이다', () => {
33
+ const at = '2026-09-01T02:50:51.832Z'
34
+ const a = factIdentity({ eventType: 'nonconformance.disposition', eventTime: at, data: { subjectId: 'LOT-A', decision: 'scrap' } })
35
+ const b = factIdentity({ eventType: 'nonconformance.disposition', eventTime: at, data: { subjectId: 'LOT-F', decision: 'scrap' } })
36
+ assert.notEqual(a, b, '두 로트의 처분이 한 사실로 뭉쳤다')
37
+ })
38
+
39
+ test('★ 같은 사실을 다시 받으면 같은 정체다 — backfill 이 저널을 안 부풀린다', () => {
40
+ const rec = { eventType: 'nonconformance.disposition', eventTime: '2026-09-01T02:50:51.832Z', data: { subjectId: 'LOT-A', decision: 'scrap', quantity: 50 } }
41
+ assert.equal(factIdentity(rec), factIdentity({ ...rec, data: { ...rec.data } }))
42
+ })
43
+
44
+ test('★ 시험 결과는 명세도 가른다 — 같은 대상에 시험이 여럿이다', () => {
45
+ const at = '2026-09-01T00:00:00.000Z'
46
+ const a = factIdentity({ eventType: 'test.result', eventTime: at, data: { subjectId: 'EQ-1', specId: 'CP-PBA-INCOMING' } })
47
+ const b = factIdentity({ eventType: 'test.result', eventTime: at, data: { subjectId: 'EQ-1', specId: 'CP-PBA-TEST' } })
48
+ assert.notEqual(a, b, '같은 대상의 두 시험이 한 사실로 뭉쳤다')
49
+ })
50
+
51
+ test('★ 정체를 못 만든 것을 센다 — 조용히 dedupe 밖으로 나가지 않는다', () => {
52
+ takeUnidentified()
53
+ assert.equal(factIdentity({ eventType: 'made.up.kind', eventTime: '2026-09-01T00:00:00.000Z', data: {} }), undefined)
54
+ assert.deepEqual(takeUnidentified(), { 'made.up.kind': 1 })
55
+ assert.deepEqual(takeUnidentified(), {}, '읽고 비운다')
56
+ })
@@ -0,0 +1,111 @@
1
+ /*
2
+ * ★ **지금은 조용한 것이 정상이다** — 밤마다 나던 거짓 빨강.
3
+ *
4
+ * ── 무엇이 틀렸나 (2026-09-04) ──────────────────────────────────────────────
5
+ * 유입 건강이 **한 자로 전부를 쟀다.** 10분 안에 아무것도 오지 않으면 「유입 없음」이다. 그런데
6
+ * 태양광은 밤에 발전하지 않고, 업무시간만 도는 원천은 저녁에 아무것도 내지 않는다. 정상인데
7
+ * **매일 밤 절반이 빨갛게 났다.**
8
+ *
9
+ * 거짓 빨강이 쌓이면 진짜 빨강도 같이 안 읽힌다. 오늘 UX 레인이 「늘 참인 경고는 벽지가 된다」고
10
+ * 한 것과 같은 부류다 — 사흘이면 그 자리에 무엇이 적혀도 안 읽는다.
11
+ *
12
+ * 그리고 15분마다 긷는 원천은 긷는 사이마다 빨갛다. 이 파일이 고치는 코드가 그 한계를 **스스로
13
+ * 주석에 적어 두고 있었다**: 「주기를 알게 되면 그 값으로 견주는 것이 맞다」.
14
+ *
15
+ * ── 초록도 빨강도 아닌 셋째 값 ──────────────────────────────────────────────
16
+ * 조용한 시간을 초록으로 칠하면 **밤에 정말 끊긴 원천**을 아침까지 아무도 모른다. 그래서 답은
17
+ * 「지금은 안 오는 것이 맞다」이고, 그것은 다른 값이어야 한다.
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+
22
+ import { syncVerdictOf, STALE_AFTER_MS } from '../server/engine/ingest-health.js'
23
+
24
+ const NOW = 1_800_000_000_000
25
+ const long = NOW - STALE_AFTER_MS - 60_000 /* 한 창을 넘긴 침묵 */
26
+
27
+ const at = (over: Record<string, unknown> = {}) =>
28
+ syncVerdictOf({
29
+ feedState: 'attached',
30
+ recent: { offered: 100, rejected: 0 },
31
+ lastAtMs: NOW - 1_000,
32
+ nowMs: NOW,
33
+ lastUndeliveredAtMs: -1,
34
+ offeredTotal: 100,
35
+ ...over
36
+ } as any)
37
+
38
+ /* ── 조용한 시간 ──────────────────────────────────────────────────────────── */
39
+
40
+ test('★ 조용한 시간의 긴 침묵은 「유입 없음」이 아니다', () => {
41
+ assert.equal(at({ lastAtMs: long, quietNow: true }), 'expected-quiet')
42
+ })
43
+
44
+ test('★ 같은 침묵이 활동 시간이면 「유입 없음」이다', () => {
45
+ assert.equal(at({ lastAtMs: long, quietNow: false }), 'no-traffic')
46
+ })
47
+
48
+ test('★ 달력을 선언하지 않았으면 지금까지처럼 답한다 — 모르는 것을 「조용할 때」로 읽지 않는다', () => {
49
+ /*
50
+ * `quietNow` 를 주지 않은 원천이 전부 「지금은 조용한 시간」이 되면, 정말 끊긴 원천이 통째로
51
+ * 조용해진다. 없으면 옛 거동 그대로다.
52
+ */
53
+ assert.equal(at({ lastAtMs: long }), 'no-traffic')
54
+ })
55
+
56
+ test('★ 조용한 시간이어도 사실이 사라지고 있으면 그것이 먼저다', () => {
57
+ /*
58
+ * 버려지는 중은 **몇 시든 문제**다. 밤이라고 덮으면 밤새 새는 것을 아침에 안다.
59
+ */
60
+ const v = at({ lastAtMs: long, quietNow: true, lastUndeliveredAtMs: NOW - 1_000 })
61
+ assert.equal(v, 'twin-not-running')
62
+ })
63
+
64
+ test('★ 조용한 시간이어도 결선이 없으면 그것이 먼저다', () => {
65
+ assert.equal(at({ feedState: 'detached', lastAtMs: long, quietNow: true }), 'not-connected')
66
+ })
67
+
68
+ test('★ 한 번도 받지 않았는데 지금이 조용한 시간이면 「모른다」가 아니다', () => {
69
+ /*
70
+ * 밤에 처음 붙인 태양광 연결이 「기다린다」로 보이면, 사람이 아침까지 무엇을 기다리는지 모른다.
71
+ */
72
+ assert.equal(at({ lastAtMs: -1, recent: null, offeredTotal: 0, quietNow: true }), 'expected-quiet')
73
+ assert.equal(at({ lastAtMs: -1, recent: null, offeredTotal: 0 }), 'unknown')
74
+ })
75
+
76
+ test('★ 창이 비었어도 조용한 시간이면 정상이다', () => {
77
+ assert.equal(at({ recent: { offered: 0, rejected: 0 }, quietNow: true }), 'expected-quiet')
78
+ assert.equal(at({ recent: { offered: 0, rejected: 0 } }), 'no-traffic')
79
+ })
80
+
81
+ test('조용한 시간에도 받고 있으면 받는 중이다 — 침묵이 아니면 이 축이 개입하지 않는다', () => {
82
+ assert.equal(at({ quietNow: true }), 'syncing')
83
+ })
84
+
85
+ /* ── 원천이 선언한 주기 ──────────────────────────────────────────────────── */
86
+
87
+ test('★ 15분마다 긷는 원천은 그 주기 안에서 정상이다', () => {
88
+ /*
89
+ * 창 하나(10분)로 재면 긷는 사이마다 빨갛다. 원천이 주기를 선언하면 그것으로 견준다.
90
+ */
91
+ const twelveMin = NOW - 12 * 60_000
92
+
93
+ assert.equal(at({ lastAtMs: twelveMin }), 'no-traffic', '선언이 없으면 옛 거동')
94
+ assert.equal(at({ lastAtMs: twelveMin, expectedSilenceMs: 20 * 60_000 }), 'syncing')
95
+ })
96
+
97
+ test('★ 선언한 주기를 넘기면 그때는 「유입 없음」이다 — 선언이 면죄부가 아니다', () => {
98
+ const thirtyMin = NOW - 30 * 60_000
99
+ assert.equal(at({ lastAtMs: thirtyMin, expectedSilenceMs: 20 * 60_000 }), 'no-traffic')
100
+ })
101
+
102
+ test('0이나 음수 주기는 선언이 아니다 — 창 하나로 돌아간다', () => {
103
+ for (const bad of [0, -1, NaN]) {
104
+ assert.equal(at({ lastAtMs: long, expectedSilenceMs: bad }), 'no-traffic', `${bad} 를 주기로 받았다`)
105
+ }
106
+ })
107
+
108
+ test('★ 주기와 달력은 다른 축이다 — 주기를 넘겼는데 조용한 시간이면 정상이다', () => {
109
+ const thirtyMin = NOW - 30 * 60_000
110
+ assert.equal(at({ lastAtMs: thirtyMin, expectedSilenceMs: 20 * 60_000, quietNow: true }), 'expected-quiet')
111
+ })
@@ -130,3 +130,82 @@ test('트윈마다 따로 센다 — 한 트윈의 되풀이가 다른 트윈을
130
130
  const { duplicates } = b.filter([energy('inv-1', 1)])
131
131
  assert.equal(duplicates, 0)
132
132
  })
133
+
134
+ /* ════════════════════════════════════════════════════════════════════════════
135
+ * ★ **구별할 값이 봉투 안에 있으면 다 본다** — 골라 쓰고 나머지를 버리던 자리.
136
+ *
137
+ * ── 실측 (2026-09-05, 인티그레이션·plant 레인) ─────────────────────────────
138
+ * plant 이 실적 둘을 보냈는데 한 건이 저널에 안 남았다.
139
+ *
140
+ * seq 36 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-01 · goodCount 12
141
+ * seq 41 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-02 · goodCount 9
142
+ *
143
+ * 시각이 같았고(원본이 끝시각을 복사) 대상을 **먼저 걸리는 하나**로 골라서 `moverId` 가 이겼다.
144
+ * 거부가 아니라 **되풀이로 세어졌다** — 유입 장부에 `offered 5 · rejected 0 · journaled 3`.
145
+ *
146
+ * 원인이 시험 데이터였다고 해서 거동이 옳아지지 않는다. 두 봉투는 `taskId` 도 수량도 달랐고,
147
+ * **구별할 값이 이미 봉투 안에 있었는데 판정이 그것을 안 봤다.**
148
+ *
149
+ * ── 이 파일이 스스로 적어 둔 값 ───────────────────────────────────────────
150
+ * 「모르는 것을 같다고 판단하면 서로 다른 사실이 하나로 합쳐진다.
151
+ * 알리지 않고 잃는 것보다 두 번 세는 것이 낫다.」
152
+ *
153
+ * 지금 거동은 **알리지 않고 잃는 쪽**이었다.
154
+ * ════════════════════════════════════════════════════════════════════════════ */
155
+
156
+ const AT_36 = '2026-09-05T06:50:00.000Z'
157
+ const quality = (taskId: string, goodCount: number) => ({
158
+ eventType: 'quality.output',
159
+ data: { moverId: 'EQ-CT-MWELD-01', taskId, goodCount, at: AT_36 }
160
+ })
161
+
162
+ test('★ 한 설비가 두 작업을 같은 시각에 보고해도 두 사실이다 — 실제로 한 건을 잃던 그 줄', () => {
163
+ const a = factIdentity(quality('PR-CT-WIRE-01', 12))
164
+ const b = factIdentity(quality('PR-CT-WIRE-02', 9))
165
+
166
+ assert.ok(a && b, '정체를 못 만들면 거르지 않는다 — 이 검사가 뜻이 없다')
167
+ assert.notEqual(a, b, '작업이 다른 두 실적이 한 사실이 됐다')
168
+ })
169
+
170
+ test('★ 같은 봉투를 다시 보내면 여전히 같은 사실이다 — 넓혀도 되풀이는 잡힌다', () => {
171
+ /*
172
+ * 넓히는 것이 「되풀이를 놓친다」로 이어지지 않는다는 것이 이 변경의 근거다. 같은 봉투는 이
173
+ * 값들이 전부 같으므로 정체도 같다.
174
+ */
175
+ assert.equal(factIdentity(quality('PR-CT-WIRE-01', 12)), factIdentity(quality('PR-CT-WIRE-01', 12)))
176
+ })
177
+
178
+ test('★ 한 개체의 서로 다른 관측을 가른다 — EPCIS 는 그것이 정상이다', () => {
179
+ /*
180
+ * 저널 246만 건에 옛 규칙을 다시 걸어 여섯 묶음이 나왔고 전부 이 모양이었다. 한 물건이 한
181
+ * 순간에 입고이면서 개시일 수 있다 — 표준에서 이상한 일이 아닌데 규칙이 그 셋을 안 봤다.
182
+ */
183
+ const epc = 'urn:epc:id:sgtin:9521321.011111.1'
184
+ const receiving = {
185
+ eventType: 'epcis.ObjectEvent',
186
+ data: { epcList: [epc], bizStep: 'receiving', action: 'ADD', disposition: 'sellable_accessible', eventTime: AT_36 }
187
+ }
188
+ const commissioning = {
189
+ eventType: 'epcis.ObjectEvent',
190
+ data: { epcList: [epc], bizStep: 'commissioning', action: 'OBSERVE', disposition: 'reserved', eventTime: AT_36 }
191
+ }
192
+
193
+ assert.notEqual(factIdentity(receiving), factIdentity(commissioning), '입고와 개시가 한 사실이 됐다')
194
+ })
195
+
196
+ test('★ 참조가 달라도 갈린다 — 주체만 보고 나머지를 버리지 않는다', () => {
197
+ /*
198
+ * 「주체를 먼저 본다」는 판단 자체는 옳았다. 틀린 것은 **고르고 나머지를 버린 것**이다.
199
+ */
200
+ const one = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-1', at: AT_36 } }
201
+ const two = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-2', at: AT_36 } }
202
+
203
+ assert.notEqual(factIdentity(one), factIdentity(two))
204
+ })
205
+
206
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
207
+ /* 가르는 값이 하나도 없으면 정체를 안 만든다(거르지 않고 통과) — 그 경계가 살아 있는지. */
208
+ assert.equal(factIdentity({ eventType: 'x', data: { at: AT_36 } }), undefined, '대상 없는 봉투에 정체를 만들었다')
209
+ assert.equal(factIdentity({ eventType: 'x', data: { taskId: 'T-1' } }), undefined, '시각 없는 봉투에 정체를 만들었다')
210
+ assert.ok(factIdentity(quality('T-1', 1))!.includes('task=T-1'), '작업이 정체에 안 들어갔다')
211
+ })